mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-06 20:02:16 +00:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 586a358ed4 | |||
| 4d61100534 | |||
| 4ced1d7bcf | |||
| a82f839255 | |||
| 08484af293 | |||
| d29e70bad9 | |||
| 1d482aff74 | |||
| 2445c0f13d | |||
| 3d0e2a8b3c | |||
| 2a01cca00e | |||
| d7cc5621fa | |||
| be6c7fc17c | |||
| f006eefd6e | |||
| 15269f85a1 | |||
| e53390ea1b | |||
| c8314687fc | |||
| 878339cac8 | |||
| 348bbc1dfa | |||
| 262f5c61c0 | |||
| ece2a8693a | |||
| 0f77199449 | |||
| e02ef3246f | |||
| ffbeb42b5c | |||
| 816bcf10de | |||
| 28689e784e | |||
| d396be78a2 | |||
| cbac207852 | |||
| ff2ca2d462 | |||
| ab80a410a1 | |||
| b94692497e | |||
| 7bcc1ee0eb | |||
| 36a60a70d9 | |||
| 5d17fa970b | |||
| b5bab3f663 | |||
| ccea34e73c | |||
| c7f86c7309 | |||
| 4355d117ef | |||
| 39951a22ff | |||
| a3890b3bd5 | |||
| 59cad5f0bb | |||
| 5fbe058051 | |||
| 0752d8d79c | |||
| 0344a41dc4 | |||
| dba8108369 | |||
| 57d787f5ba |
@@ -6,6 +6,7 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report this! Inside the app, **Report an Issue** fills most of this in for you, with the command, its settings and your environment.
|
||||
Before reporting a boolean or modelling failure, please try to reproduce it on the latest nightly or a build from current `main`, and say which build you used.
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
|
||||
@@ -65,6 +65,7 @@ jobs:
|
||||
run: |
|
||||
mkdir -p site
|
||||
cp -r target/book/. site/
|
||||
bash docs/book/seo.sh site # canonical links, robots.txt, sitemap.xml
|
||||
cp -r dist/web site/app
|
||||
- uses: actions/configure-pages@v5
|
||||
id: pages
|
||||
|
||||
@@ -15,14 +15,18 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
|
||||
`/solidcraft/`; do not add permanent old-name Pages aliases.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
|
||||
and the relevant `plan/architecture.md` section.
|
||||
2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what
|
||||
is rejected for licence reasons).
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask
|
||||
unless it lists the decision as the owner's.
|
||||
1. What to work on: [ROADMAP.md](ROADMAP.md) (status and milestones), "Where help is wanted most"
|
||||
in CONTRIBUTING.md, and the open issues labelled `help wanted`, `good first issue` and
|
||||
`needs-triage`. Check what is already open first (below).
|
||||
2. How the code fits together: [docs/architecture.md](docs/architecture.md) (the layer table) and
|
||||
"How the code is laid out" in CONTRIBUTING.md. Kernel work is paused ("Kernel work is paused"
|
||||
in CONTRIBUTING.md).
|
||||
3. What we may depend on: the licence rules under Non-negotiables below, `deny.toml`, and
|
||||
`cargo xtask licences --check`. Only `crates/kernel` may use `truck`.
|
||||
4. Work on your own: when the code, the docs and these rules settle a question, act on it. Ask on
|
||||
the issue or the PR only when the answer changes what users see or what the change covers.
|
||||
|
||||
`plan/` is gitignored (local only).
|
||||
Maintainers may keep local notes in `plan/` (gitignored); nothing in this file depends on them.
|
||||
|
||||
## Never crash
|
||||
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
@@ -82,7 +86,10 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
|
||||
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
|
||||
`solid.extrude` or `sketch.rectangle.two_point`, never another product's command id; label;
|
||||
toolbar tab and panel; icon; shortcut; params doc; `enabled`; JSON `run`) + tests. The toolbar,
|
||||
toolbar tab and panel; icon; shortcut; params doc; `enabled`; JSON `run`) + input and output
|
||||
schemas (a contract and a reply type in `crates/engine/src/schema/`, registered with
|
||||
`checked!`, `.contract` and `.output`; a command should have them, and a test warns
|
||||
without them (it will become required); `schema/unconverted.txt` only shrinks; how: docs/book/src/dev/schemas.md) + tests. The toolbar,
|
||||
dialogs, sketch tools, palette, scripts, CLI and control channel all reach the same commands.
|
||||
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form; only toolbar
|
||||
clicks (`ui.start`) start interactive tools and dialogs, which then run commands.
|
||||
@@ -110,7 +117,9 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
|
||||
Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
|
||||
assets, layers). Commit after every feature arc that builds.
|
||||
assets, layers). Commit after every feature arc that builds. The gate reads the working tree,
|
||||
so do not switch branches while it runs: it compiles the other branch's code and fails on a
|
||||
change that is not in the PR. Run it in the background and wait for its log.
|
||||
- **Publishing**: the repository is github.com/tangency-labs/solidcraft. Changes land through
|
||||
**pull requests** and the **merge queue**: push a branch and open a PR. The heavy CI jobs start
|
||||
when a maintainer adds `approved-for-ci` after reviewing the code. Approved PRs go into the queue
|
||||
@@ -209,7 +218,7 @@ before the draft is verified.
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
|
||||
`docs/roadmap-detail.md` (committed) tracks status, milestones and estimates; `ROADMAP.md` is the short user-facing roadmap. Update it whenever a
|
||||
milestone task lands. `docs/features.md` (SolidCraft's commands by area, from the registry) and
|
||||
`THIRD-PARTY-LICENSES.txt` are regenerated on `main` after each merge by the Generated files
|
||||
workflow; never regenerate them in a PR.
|
||||
|
||||
@@ -284,6 +284,8 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/icons/ui/design/svg/model/circle_tt.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/circle_ttt.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/coil.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/colored_components.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/colored_components_off.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/combine.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/component.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/compute.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
|
||||
+18
-3
@@ -11,7 +11,7 @@ build it, how the code is laid out, the few rules that aren't negotiable, and wh
|
||||
needed most.
|
||||
|
||||
Before choosing a task, check the [command inventory](docs/features.md) and
|
||||
[roadmap](ROADMAP.md) for implemented capabilities and current priorities.
|
||||
[roadmap](docs/roadmap-detail.md) for implemented capabilities and current priorities.
|
||||
|
||||
Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH).
|
||||
|
||||
@@ -130,7 +130,9 @@ The [architecture page](docs/architecture.md) has the layer table. The short ver
|
||||
- Every call into truck goes through `solidcraft_kernel::guard`.
|
||||
- Every crash fix comes with a regression test.
|
||||
5. **Everything is a command, with a test.** A new feature means a `CommandSpec` in
|
||||
`crates/engine/src/cmd/`, engine tests that measure the result (volume, area,
|
||||
`crates/engine/src/cmd/` with input and output schemas (see
|
||||
[Adding a command](docs/book/src/dev/commands.md#adding-a-command); a test warns about a
|
||||
command without them, and will fail it once required), engine tests that measure the result (volume, area,
|
||||
face/edge/vertex counts against a value you can derive), and for UI work a scenario (below).
|
||||
6. **One set of camera controls.** Every page or canvas that shows a view (the modelling
|
||||
viewport, the Render preview, and any new page such as a flat pattern or drawing) calls
|
||||
@@ -274,6 +276,9 @@ and a fresh CI run:
|
||||
and the Lists workflow) fails on an unsorted catalog or an id that appears twice.
|
||||
- **`mod` lines**: one per line, in the sorted group they belong to (`cargo fmt` keeps each group
|
||||
sorted). Add a new module into the existing group, not as a new group at the end.
|
||||
- **Commands without schemas** (`crates/engine/src/schema/unconverted.txt`): sorted, one id per
|
||||
line, and it only shrinks. Converting a command removes its line; a new command is never added
|
||||
to it.
|
||||
- **Languages** (`crates/ui-egui/src/i18n.rs`): one per line, English first, then by id (a test
|
||||
checks it).
|
||||
- **Generated files**: `THIRD-PARTY-LICENSES.txt` and `docs/features.md` are rewritten on `main`
|
||||
@@ -334,6 +339,16 @@ Changes with no useful observable behavior do not need screenshot or video evide
|
||||
small example without private designs or sensitive details; attach the media or link to an
|
||||
accessible recording rather than committing generated review artifacts.
|
||||
|
||||
To show a picture without committing it, attach the media to the PR (it goes to the PR's own
|
||||
attachment storage and renders inline in the comment). A gist is the better home for a log or
|
||||
another text file, and you link it; a gist does not render a picture inline. The review artifacts
|
||||
stay out of the diff that way, and nothing has to be pushed to a branch of its own.
|
||||
|
||||
Attaching works in the browser for anyone who can comment: drag the file into a comment on the
|
||||
pull request. A command-line agent without a browser session usually cannot attach a file (the
|
||||
upload needs write access to this repository). Say so in the pull request, and the person running
|
||||
the agent or a maintainer can attach it.
|
||||
|
||||
Keep an eye on your PR's status, but don't push to it only to catch up with `main` (no merge of
|
||||
`main`, no Update branch button, no rebase for that alone): every push restarts the whole CI
|
||||
run for everyone waiting behind it, and `main`'s own run catches what lands in between. When you
|
||||
@@ -389,7 +404,7 @@ In order of how much they'd help users:
|
||||
2. **Boolean robustness.** A random-design fuzzer currently gets about half of its designs
|
||||
through without a kernel error. Each failing seed is a reproducible bug.
|
||||
3. **Drawings, CAM and mesh tools.** These are out of scope for now (see
|
||||
[ROADMAP.md](ROADMAP.md)), but anyone who wants to own one is welcome.
|
||||
[ROADMAP.md](docs/roadmap-detail.md)), but anyone who wants to own one is welcome.
|
||||
|
||||
## Updating the generated feature list
|
||||
|
||||
|
||||
Generated
+17
-17
@@ -4208,7 +4208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4240,11 +4240,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4256,7 +4256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4265,7 +4265,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4282,7 +4282,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"schemars",
|
||||
@@ -4302,7 +4302,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4311,7 +4311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4331,7 +4331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4355,7 +4355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4375,7 +4375,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4384,7 +4384,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4398,7 +4398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4409,14 +4409,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4445,7 +4445,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5983,7 +5983,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
## Download
|
||||
|
||||
- **Windows:** the [installer][windows-msi], or `winget install bherbruck.SolidCraft`.
|
||||
- **Windows:** the [installer][windows-msi].
|
||||
- **macOS:** the [disk image][macos-dmg] (Apple silicon and Intel).
|
||||
- **Linux:** the [AppImage][linux-appimage], or `sudo snap install solidcraft` ([Snap Store][snap-store]).
|
||||
- **Web:** [try it in your browser][web-app], no install.
|
||||
|
||||
+80
-694
@@ -1,696 +1,82 @@
|
||||
# SolidCraft roadmap
|
||||
|
||||
Status as of **2026-10-14**. SolidCraft supports parametric modelling (sketch →
|
||||
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| Where are we? | **M0 and M13 done; M1, 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; FreeCAD/Rhino import; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
|
||||
| Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth |
|
||||
| Reference tests | [Current case count and results](docs/oracle.md): public cases in `tests/oracle/` with analytic or FreeCAD-computed expected values, checked by the reference-test job in [CI](.github/workflows/ci.yml). |
|
||||
| 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) |
|
||||
|
||||
Optional GPU viewport ambient occlusion is available in View settings and Graphics preferences,
|
||||
with persistent settings, Performance/Quality graphics presets (Quality combines AO, Fine tessellation and adaptive 2× supersampling; manual combinations show Custom), and the
|
||||
`view.ambient_occlusion` and `view.graphics_preset` commands. See [display settings](docs/ambient-occlusion.md).
|
||||
|
||||
## 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. See [WIP reconciliation](docs/wip-reconciliation.md).
|
||||
Face-based Extrude Join (including outward Press Pull) now defaults to the picked body,
|
||||
so edge-touching assembly neighbours stay separate regardless of body order (#226).
|
||||
|
||||
In scope, in priority order:
|
||||
1. SOLID and SKETCH (now).
|
||||
2. Parameters / functional constraints (done: see `parameters.*`, `expr.evaluate`; unit-aware,
|
||||
cycles reported with their path, rename follows references, deletes list their users):
|
||||
user parameters with units and expressions (+-*/, ^,
|
||||
parentheses, sin/cos/sqrt/min/max/floor/ceil/round, unit conversion), model parameters
|
||||
d1, d2… for every dimension and feature input, references between parameters with a
|
||||
dependency graph and cycle detection, re-evaluation on change, comments, favourites,
|
||||
CSV/JSON import and export.
|
||||
3. Components and assemblies with joints (M9).
|
||||
4. SHEET METAL: flange, contour flange, bend, unfold/refold, flat pattern with DXF export, sheet
|
||||
metal rules (thickness, K-factor, bend radius) (M10).
|
||||
5. A small PLASTIC subset for enclosures, after sheet metal: Boss, Rib, Web, Lip/Groove, Snap Fit.
|
||||
|
||||
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need (scope proposed in #772), PCB / 3D
|
||||
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | Milestone | Status | Hours left |
|
||||
|---|---|---|---|
|
||||
| M0 | Vertical slice | done | — |
|
||||
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, Shift-picked line midpoints in Horizontal/Vertical using persisted points and ordinary constraints (#2087), driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
|
||||
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces, at distances from reference edges, and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
|
||||
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, Shift-picked line midpoints in Horizontal/Vertical using persisted points and ordinary constraints (#2087), drawing snaps to where two lines, circles or arcs cross (#4967), driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
|
||||
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
|
||||
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks; tiny rim edges remain selected instead of nearby wall seams when filleting (#2498) | in progress | 30 |
|
||||
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), FreeCAD/Rhino solids through cadmpeg (done, desktop, browser and CLI), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
|
||||
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; responsive feature history with width-driven icons/names/title, snapping width resize, bottom/left/browser-stack docking, shared-column collapse, rollback bar, multi-selection and persistent folders), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator, extrude taper), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; Browser Expand All / Collapse All (`browser.expand_all`, `browser.collapse_all`) on the design and component menus; preferences | in progress | 6 |
|
||||
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane, a face or sketch curves swept onto the surface, keeping a side or the picked regions; mutual trim of two surfaces, extended to meet and stitched, #767), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764: edge chains along one or several faces, closed rims paired seam to seam or from picked seams, a dialog), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two sketch curves exact with G0–G3, three or four as the flat patch of their loop, and curve networks: Coons and Gordon surfaces through sketch, 3D-sketch or edge curves, a point for a three-sided side, G1/G2 against the faces along one direction's edges, joined into their body on request; G1/G2 on two sides that meet at a corner waits for the Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown, and guide rails on solid and surface lofts (one or two, scaling or stretching the sections between; #201). Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), and every remaining #757–#772 item | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements, and exported as an animation coming apart and back together); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. macOS Developer ID signing and notarization are in the release workflow (#5749; they switch on when the Apple secrets exist). Windows installer signing and broader performance work remain | in progress | 16 |
|
||||
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
|
||||
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
|
||||
| M15 | Frames and weldments (#4003): structural members along 2D and 3D sketch lines, butt and mitre corners, trim to faces and bodies, end caps, gussets, cosmetic welds and a cut list. Done: the profile library (`frame.profile.list`, `frame.profile.properties`, `frame.profile.insert`): bar, round bar, rectangular and round tube, angle, channel and I-beam, each SolidCraft's own parametric definition with exact fillet arcs and exact section properties (area, centroid, second moments), tested against hand-computed values; members (`frame.member`): one body per straight line of a 2D or 3D frame sketch, following parameter edits, with a built-in profile (its dimensions parameter expressions) or a profile sketch (its drawn material by alternating nested loops), anchor, angle and mirror; each member's name kept with its line. Left: corners, trims, end caps, gussets, welds, cut list, FRAME tab and dialogs | in progress | 60 |
|
||||
|
||||
M6 navigation preferences include **Touchpad**: Shift + pointer movement pans, Alt/Option +
|
||||
pointer movement orbits without a mouse button, and vertical scrolling zooms. Other presets
|
||||
retain their saved two-finger scroll preference; camera motion is available through
|
||||
`view.navigate` and modifier-aware `ui.move` automation.
|
||||
With the **Fusion** preset and two-finger pan enabled, two fingers pan and Shift + two fingers
|
||||
orbit freely, with pinch zoom and mouse bindings unchanged. The shared navigation handler
|
||||
applies the same gestures to model and Render views and blocks orbit on flat pages.
|
||||
|
||||
UI redesign (#2016): the File/start view (#2027) uses shared light/dark navigation,
|
||||
action tiles, recent rows and sample cards, with recovery review and Save As/Export
|
||||
entry points. Document loading, sample caches and recovery safety retain their
|
||||
existing command paths.
|
||||
|
||||
M11 precursor: File → Export Current View as DXF exports shown placed bodies and sketches from the current
|
||||
camera as a 2D millimetre drawing, with hidden edges omitted or dashed. Vector occlusion splits
|
||||
partly hidden edges and includes curved silhouettes. Repeated part meshes and triangle
|
||||
hierarchies are shared; bounded desktop preparation and occlusion use up to eight workers.
|
||||
Curves are approximated; drawing sheets,
|
||||
annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md).
|
||||
|
||||
Drawing foundations: [RFC #860](https://github.com/tangency-labs/solidcraft/issues/860)
|
||||
and the [foundation ledger](docs/drawing-foundations.md) track the bounded implementation
|
||||
packages. The first geometry increment defines validated section records in a plane-local
|
||||
frame; the ledger separates that contract from section producers and the Drawing workspace.
|
||||
|
||||
Desktop view DXF export runs in the file-operation worker with five-stage progress and
|
||||
cooperative cancellation. Curve tolerance (0.0001–1 mm, default 0.01 mm) is remembered alongside
|
||||
the hidden-edge setting. Cancelled exports preserve the model and the previous destination;
|
||||
completed drawings publish atomically. The browser still exports synchronously.
|
||||
|
||||
## Interactive modelling
|
||||
|
||||
- Modelled threads preview their cut before OK using the background worker on desktop.
|
||||
Browser users apply the cut with OK to avoid blocking on every edit.
|
||||
|
||||
- Thread resolves a suggested metric size when a cylindrical face is picked. Diameter and
|
||||
pitch selectors expose the existing metric designation parameter; custom designations stay
|
||||
editable, and Automatic size returns to the face's suggestion. Cosmetic/modelled and
|
||||
full/partial-length options remain available. This does not add tolerance classes or
|
||||
left-hand threads.
|
||||
- New Body extrusion and face-offset previews show completed intermediate geometry while a
|
||||
drag continues, then catch up to the exact final value. Changes to the selected geometry,
|
||||
operation or feature still discard obsolete results. Distance arrows and their value boxes
|
||||
follow the current frame's input.
|
||||
- Large fine modelled threads use pitch-based helix resolution and tighter faceted
|
||||
reconstruction fallbacks, preserving thin crests and closed shells on large shafts.
|
||||
|
||||
## Physical material data
|
||||
|
||||
- Physical material card validation (#923/#922) provides typed SI mechanical and
|
||||
thermal data, per-property sources/conditions and isotropic/orthotropic stability
|
||||
checks through read-only `material.properties.validate`. It does not assign or
|
||||
persist properties, provide a solver or enable rendering; see
|
||||
[the contract and staged follow-ups](docs/physical-material-cards.md).
|
||||
|
||||
- Directional elasticity (#994) queries the validated card in material-local axes
|
||||
through read-only `material.properties.directional`, with source/condition review.
|
||||
It does not assign body frames, predict structural behavior or enable a solver.
|
||||
|
||||
## Library foundations (#785, #786, #787)
|
||||
|
||||
- Local scalar libraries (#846) inspect/export bounded portable catalogs and copy exact
|
||||
selected appearance/material revisions into existing design definitions in one undo step.
|
||||
Appearance offers local loading/search/export; physical materials use existing commands.
|
||||
This slice is independent of the render stack and does not change the document format.
|
||||
See [the contract and limits](docs/book/src/dev/local-libraries.md).
|
||||
|
||||
- Proposed phases cover offline bundled/project/user packs, physical materials and
|
||||
appearances, editable wood mapping, and parameterized component/assembly families.
|
||||
Stable identity/revision, copied design definitions, provenance and resource limits
|
||||
apply from the first implementation. Existing material, appearance and standard-part
|
||||
commands remain the application paths. See [the proposal](docs/book/src/dev/libraries.md)
|
||||
for source/license review and acceptance gates. This is planning only: pack loading,
|
||||
texture rendering and expanded component families are not implemented by this entry.
|
||||
|
||||
## Render foundation
|
||||
|
||||
- Original starter appearances (#1722): 34 opaque recipes through existing commands, with six explicitly unavailable glass recipes and preserved legacy looks; see [the contract](docs/render-starter-appearances.md).
|
||||
|
||||
- Bounded linear film accumulation (#1696) supplies caller-admitted mean/variance storage and complete-pass views; actual progressive consumers remain separate. See [the film contract](docs/render-film.md).
|
||||
|
||||
- Definition-local visual mapping (#1214) supplies a bounded numerical frame, physical repeat coordinates and a deterministic one-time grain-axis suggestion. It adds no saved record or texture consumer; see [the mapping contract](docs/render-visual-mapping.md).
|
||||
|
||||
- Explicit texture normalization (#1218) produces completed float RGBA with
|
||||
role-aware linear color/data semantics, bounded matrix/TRC ICC conversion and
|
||||
opt-in EXIF orientation. It retains high-precision raw samples and records lossy
|
||||
gamut clipping only when explicitly requested. It is not a renderer/resource
|
||||
consumer; see [the supported policy and limits](docs/render-texture-normalization.md).
|
||||
|
||||
- Opt-in opaque PBR (#1046) connects persisted surface parameters to the integrated
|
||||
product viewport and CPU PNG, using a fixed direct-light studio. Geometry and mass
|
||||
stay unchanged. HDRI, transmission and textures remain separate gates.
|
||||
See [the consumer and limitations](docs/render-opaque-pbr.md).
|
||||
|
||||
- Combined integration is tracked in #842, with independent scalar libraries in
|
||||
#863 and richer PBR/material-card transport in #1086. See [phase boundaries and validation gates](docs/render-integration.md).
|
||||
Render/library consumers use the merged asynchronous picker (#1127). Material-card
|
||||
UI (#1094), texture mapping and HDRI lighting remain explicit follow-ups.
|
||||
|
||||
- R4a adds bounded CPU product-image jobs, cancellation and atomic PNG export through
|
||||
engine commands, the Render panel, CLI and persistent MCP. Immutable captures and
|
||||
results stay outside persistence/undo; native workers and cooperative browser tasks
|
||||
share the same work protocol. See [jobs, limits and export](docs/render-jobs.md).
|
||||
|
||||
- R3 adds an overlay-free product scene and the integrated Render workspace, with
|
||||
runtime GPU host capabilities, per-document drafts and named camera/background
|
||||
controls. Design keeps its camera and drawing policy. Raster transparency remains
|
||||
approximate; PBR and environments are later slices. See
|
||||
[workspace and scene contracts](docs/render-workspace.md).
|
||||
|
||||
- R2 adds bounded named render setups in document format 21 and the read/save
|
||||
`render.settings.get/set` commands. Proven render-only changes preserve geometry
|
||||
and prepared world data through undo/redo and checkpoint restore. This slice does
|
||||
not add a Render workspace, PBR, environments or image jobs; see
|
||||
[settings and refresh policy](docs/render-settings.md).
|
||||
- Bounded PNG/JPEG texture admission (#900) validates in-memory sources and returns
|
||||
completed RGBA8 pixels with explicit color/data roles. It is an input utility only:
|
||||
ICC/16-bit normalization, local wood mapping, resource persistence and the renderer
|
||||
consumer remain separate work. See [limits and licensed-file probes](docs/render-texture-input.md).
|
||||
|
||||
- The initial opaque surface numerical contract for #1004 provides bounded linear
|
||||
color/metallic/roughness/IOR admission and an original tested direct-light BRDF.
|
||||
The numerical module alone advertises no capabilities; the installed consumer above
|
||||
provides rendering. See
|
||||
[the model, evidence and remaining consumer gates](docs/render-surface.md).
|
||||
|
||||
- A bounded cooperative Radiance RGBE input utility is available in L1 render;
|
||||
it does not yet enable HDRI/PBR rendering or a document import command. See
|
||||
[the input contract](docs/render-hdr-input.md) and #813 for integration work.
|
||||
- R1 adds renderer-independent backend/profile metadata, strict request resolution and
|
||||
bounded image/scratch admission checks, exposed by the read-only `render.backends`
|
||||
command. It describes the current CPU raster snapshot path without evaluating the model.
|
||||
The legacy Design snapshot route remains unchanged. Later slices above provide the
|
||||
workspace/jobs and opaque PBR; HDRI and optional engines remain future work. See [the contract](docs/render-backends.md)
|
||||
and [the phased proposal (#620)](https://github.com/bherbruck/solidcraft/issues/620).
|
||||
|
||||
## Dimensioning and error reporting
|
||||
|
||||
- Operation length fields share design-unit defaults and input evaluation, including secondary
|
||||
distances, thin walls, blend controls, sheet/plastic rules, joints and motion keys. Canvas
|
||||
handles and sketch previews use the same lengths as committed geometry. Existing stored
|
||||
millimetre values retain their size when edited in a design using another unit (#3271).
|
||||
|
||||
- MCP `execute` accepts `verbose: false`, sharing the compact reply helper used by
|
||||
`batch`: body changes and created sketch IDs stay, while repeated lists are omitted.
|
||||
Both tools retain full replies by default.
|
||||
|
||||
- Sketch Create groups drawing variants under Line, Rectangle, Circle, Arc, Slot,
|
||||
Polygon and Spline. Each open part remembers its last interactive choice per family
|
||||
across sketch sessions and document-tab switches; closing the part resets these UI choices.
|
||||
- Inscribed, circumscribed and edge polygon tools preview their actual outline before
|
||||
placement, including orientation and the selected side count.
|
||||
- Construction (X) preserves the active sketch drawing tool, picked anchors and inline
|
||||
dimension values, so line type can change while a shape is in progress.
|
||||
- Planar sketch construction previews and hover feedback keep the same dashed pattern as
|
||||
committed geometry; centerlines retain their dash-dot pattern.
|
||||
|
||||
- Point has a default Sketch CREATE toolbar button alongside the existing drawing tools.
|
||||
Saved custom layouts are preserved; Reset Toolbar restores the default Point button.
|
||||
- Construction planes cannot take an origin-plane name (XY, XZ, YZ, Top, Front, Right). Plane
|
||||
arguments reject such a name when an older design's construction plane already has it. Use
|
||||
the other alias, the construction feature's numeric ID or an explicit plane; existing
|
||||
stored references keep their saved meaning. Section Analysis uses the same check.
|
||||
- Sketch text can be edited from a letter interior or outline's context menu or the Edit Text command.
|
||||
Interior selection requires every profile boundary to belong to the same text source;
|
||||
the editor highlights the whole source without replacing profile selections.
|
||||
Content, baseline X/Y, height and rotation update the original text source in one undo step;
|
||||
cancelling or rejecting an invalid value preserves the sketch. Position and height fields
|
||||
use the document units captured when editing starts; generated outlines remain associated
|
||||
with their text source, and untouched inputs preserve their saved internal values.
|
||||
Dragging a text outline moves the entire source in one undo step; Escape restores the
|
||||
gesture. Move/Copy on a selected text outline provides unit-aware X/Y distances and rotation
|
||||
about its baseline; copies keep an independent editable text source.
|
||||
- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool
|
||||
waits for a distinct point without a degenerate-shape error or history entry. Typed input
|
||||
and programmatic commands retain their validation.
|
||||
- Circular-pattern and angled-plane axes reject explicitly supplied malformed or expression
|
||||
origins instead of silently rotating about zero. Numeric origins remain in millimetres;
|
||||
associative axis expressions and construction-axis references remain unsupported.
|
||||
- Single-line and rectangle-side dimensioning supports selecting a line or its two endpoints.
|
||||
Type a value and press Enter to commit one undo step; rejected values remain editable and
|
||||
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
|
||||
and navigation controls, including enlarged displays and nested pending placements.
|
||||
- Sketch Dimension takes a line and a circle or arc (#1803): it measures from the line to the
|
||||
circle's edge (the gap, 0 when tangent, or how far the edge reaches past a line that cuts
|
||||
it), drives it like any other dimension and keeps the circle on its side of the line.
|
||||
- Sketch Dimension takes a point and a circle or arc, or two circles or arcs (#4064): it
|
||||
measures to the circle's edge (from the point, or between the two edges: the gap, 0 when
|
||||
they touch, how far they overlap, or the narrowest gap between nested circles).
|
||||
- Sketch annotations, live drawing lengths and Measure use document units. Inches show
|
||||
three decimals and retain a meaningful fourth digit. Bare length dimension entry captures
|
||||
the current design unit; changing units leaves existing geometry and expressions intact.
|
||||
Component/canvas distance controls, sketch cursor coordinates, flat-pattern dimensions
|
||||
and pocket depths, interference volumes and parts-library lengths also use design units.
|
||||
- Curvature and Polygon constraints accept complete preselected operands. Fix/Unfix
|
||||
consumes complete point/curve selections consistently; mixed or ambiguous selections
|
||||
retain the picking workflow. Rejected constraints preserve selection and undo history.
|
||||
- Contextual error reporting: a **Create Issue** action beside feature errors opens a
|
||||
reviewable GitHub issue draft with the error, operation settings, build diagnostics and
|
||||
useful reproduction context. The draft is editable, with optional capture of the actual
|
||||
viewport and copy/save controls. Opening GitHub does not submit the issue. Help also exposes
|
||||
reporting for unexpected results that did not produce an error.
|
||||
- The header exposes **Report a bug**, including an accessible compact control on
|
||||
narrow windows. It opens the same editable report and screenshot review without
|
||||
changing the active model or a pending dimension.
|
||||
- Crash reports: every panic (in a command, in the kernel, anywhere else) and every unclean
|
||||
exit (abort, stack overflow, kill: found at the next start through a locked per-run command
|
||||
journal) leaves a local report with the backtrace, version and commit, the command and the
|
||||
design that reproduce it (`solidcraft-cli crashes replay <id>`). Repeats are counted, not
|
||||
rewritten; at most 20 reports, 50 MB and 90 days. The app shows them after a crash (Help >
|
||||
Crash Reports…) with a reviewed issue draft; agents read them through
|
||||
`diagnostics.crash_reports`, the MCP `crash_reports` tool or the CLI. Nothing is sent
|
||||
anywhere. See [crash reports](docs/crash-reports.md).
|
||||
- Agent chat (off by default, desktop): a side panel that drives a coding agent the user
|
||||
already has (Claude Code through its ACP adapter, Codex, Gemini CLI, OpenCode, Grok Build, …) over the
|
||||
Agent Client Protocol, under the user's own login; SolidCraft stores no keys. The agent works
|
||||
through SolidCraft's MCP tools on a gated endpoint of its own: file actions wait for Allow or
|
||||
Deny, crash reports are off limits, 24 changes per message, one undo step per message, and
|
||||
the agent's whole process group ends with the panel or the app. Docks left or right,
|
||||
minimises to a strip. Grok Build discovery includes `~/.grok/bin` and reuses the user's
|
||||
Grok login. See [agent chat](docs/agent-chat.md).
|
||||
- Command scripts from designs (#886): `timeline.export_script` (CLI `solidcraft-cli script`)
|
||||
writes a design's history as a script that `eval` and `run` replay into the same design —
|
||||
user parameters, units, sheet metal and plastic rules and materials as their own commands,
|
||||
then each feature through `timeline.add` with its ids, names and dimension parameters kept,
|
||||
so every feature kind replays exactly and a failing feature fails the same way. Trimming the
|
||||
script after a step gives the design at that step. Appearances, folders, views and
|
||||
configurations are listed as left out; components and joints are not supported yet.
|
||||
|
||||
## 0.2.0 release batch
|
||||
|
||||
- Includes the new axis navigation, adaptive sketch drawing/picking, origin-corner repairs,
|
||||
durable supported profile/face references, endpoint dimensioning and contextual reporting.
|
||||
- Smooth ruled-wall display joins are hidden only within the supported geometric certificate;
|
||||
authored splits and actual creases remain protected. Wireframe retains physical edges.
|
||||
- Paste New uses a bounded current-geometry comparison for supported convex line/circle
|
||||
prisms with separated circular holes; unsupported shapes retain the existing strict check.
|
||||
- Common-bore Union retries check additional boundary curves before recutting the bore,
|
||||
preventing the reproduced thin-web loss. Unknown additional trims refuse; inherited
|
||||
common-cylinder trims retain their existing policy, so this is not a general Boolean proof.
|
||||
- Exact full-path assembly joint editing remains separate work and is not part of this batch.
|
||||
- Stable 0.2.0 is published: all release payload checksums, native cold reopening, deployed
|
||||
web app and both Flatpak architectures were verified. The replacement README demo uses
|
||||
the original electronics enclosure story and animated pointer in the actual English
|
||||
0.2.0 UI, at 4× playback. Publication follows [the release workflow](docs/releasing.md).
|
||||
|
||||
## 0.2.1 critical fixes
|
||||
|
||||
- Separated multi-profile Join/Cut extrusions into one solid target batch their tools using
|
||||
conservative construction bounds, avoiding a Boolean rebuild for every profile. Live
|
||||
extrusion previews build only their tools; confirmation validates the final Boolean.
|
||||
- Coplanar selected body faces share one linked sketch and one extrusion. Automatic Join/Cut
|
||||
keeps the evaluator's participant scope, including parts of an empty imported component. Desktop OK/Enter
|
||||
computes on a scratch session in the background, with atomic acceptance and cancellation.
|
||||
Planar section stitching projects lines/arcs analytically and removes duplicate endpoints.
|
||||
- Imported rotated face groups can check all full start-face seams in one guarded
|
||||
construction. Separated tools may batch a Join when each meets the same single body;
|
||||
nearby uninvolved bodies retain the original contact decisions. Regression comparisons
|
||||
cover all four extrusion operations.
|
||||
- Inward multi-face cuts retain every eligible target and subtract separated tools together.
|
||||
Safe interior classification handles roundoff at shared imported line/arc endpoints.
|
||||
Straight extrusion feedback caches existing face/profile triangles and updates the display
|
||||
directly, including a newly opened STEP's single identity component placement; other
|
||||
extents, moved or repeated components and curved sketch profiles keep the Engine preview.
|
||||
- The extrusion fixes retain tracked Boolean history and the current `solidcraft` packages.
|
||||
Confirmation and tool previews use main's shared background calculation. Main's collapsed
|
||||
blend-wall refusal and its tests are retained, including smaller valid radii and mirrored parts.
|
||||
- Axis view and navigation controls stay anchored to the viewport when feature dialogs or
|
||||
the command palette open, at normal and enlarged display scales. Fit still accounts for
|
||||
dialog coverage; covered controls do not receive clicks through dialogs.
|
||||
- Eligible planar faces survive fillet, chamfer and shell operations with exact source
|
||||
certificates. Sketches placed directly on the enclosure wall and inner floor follow
|
||||
width and wall changes, including after the USB cut and cold reopening.
|
||||
- Remove artificial straight-wall and rounded-corner subdivisions left by shell and
|
||||
extrusion cuts. Identical generated wall segments span adjacent slabs as one physical
|
||||
face, with exact full-height supports and boundary stitching. Preserve authored face
|
||||
splits and face appearances. Permanent regressions cover material, STEP, reopening and
|
||||
unaffected bodies across the extrusion end plane.
|
||||
- Feature dialogs use the available screen height for their fields, with OK and Cancel
|
||||
outside the scrolling region, including enlarged display scales.
|
||||
- Design format 13 records shell-face attachments. Older designs remain readable;
|
||||
newly saved designs require 0.2.1 or later. Ambiguous or unsupported faces still refuse
|
||||
attachment rather than guessing a plane.
|
||||
- Saved projections from a removed strip retain their geometry and report the lost
|
||||
reference. Explicitly reselect the wall to repair that projection.
|
||||
|
||||
## Workspace layouts (#2029)
|
||||
|
||||
- General preferences save Floating or Compact using the universal UI containers.
|
||||
Compact joins the workspace selector and ribbon, docks the content-hugging Browser
|
||||
flush beneath them with a square join, and opens tool sheets below the compass.
|
||||
Sheets remain draggable, rounding all corners away from their right dock; footer
|
||||
backgrounds follow the same corners. A separate preference hides ribbon icon labels
|
||||
for a shorter ribbon in either layout.
|
||||
Assistant keeps the outermost edge and History keeps its chosen dock. Switching
|
||||
retains the active tool, camera, selection and design; system windows and menus
|
||||
keep their existing behavior.
|
||||
|
||||
## View navigation
|
||||
|
||||
- Navigation has an opt-in Free orbit preference for mouse and 3D-mouse rotation through
|
||||
both poles with roll. It defaults off, preserving existing preset snapping and pole limits.
|
||||
The SolidWorks face menu offers Normal to Selection (`view.normal_to`), shared with Look At
|
||||
and Ctrl+8, including repeat-to-flip, preserving scale and projection.
|
||||
|
||||
- Desktop Ctrl/Cmd +/- and 0 change or reset the saved UI scale. The scale slider keeps
|
||||
its layout stable during a drag and applies the selected scale on release.
|
||||
|
||||
- OpenSCAD navigation preset: left drag orbits, right/middle drag pans, Shift+right/middle
|
||||
drag zooms, and the wheel zooms. Available in the shared desktop/web Navigation preferences.
|
||||
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
|
||||
to 26 standard and diagonal views. Pointer motion accumulates independently of a lightly
|
||||
damped spring, with a boundary margin to prevent flicker. Z-up/Y-up and the nearest square
|
||||
top/bottom heading are preserved. Diagonals include 45-degree edge and isometric corner views.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Scheduled and manual CI scan locked dependency advisories, bans and sources with
|
||||
cargo-deny. Vulnerabilities fail the scan; unmaintained crates warn. Local vendored
|
||||
patches require separate upstream review because advisory databases cannot version-match
|
||||
their path dependencies. Pull requests retain the existing licence checks.
|
||||
|
||||
- Associative shell-face sketches require a unique certified planar source. Nested shell
|
||||
attachments and ambiguous coincident sources remain unsupported.
|
||||
- G2 fillets: exact quintic blends of straight edges between planar faces, with one planar
|
||||
end face at each end, including sloped ends (#2708). The UI and `solid.fillet` expose
|
||||
`curvature` (alias `g2`); the size is an equivalent circular setback. Curved faces/edges,
|
||||
multi-face ends and corner junctions remain unsupported.
|
||||
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
|
||||
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
|
||||
meet (a sphere for equal radii); perpendicular corner fillets after a closed ring of earlier
|
||||
rounds preserve the ring's radii and the enclosed planar face (#1557);
|
||||
mitres between separate rounds of the same radius keep their endpoint tangent, so a later
|
||||
fillet can select an upright and its adjoining bottom mitre as one chain (#2422);
|
||||
straight edges (convex or concave) between planar faces with perpendicular
|
||||
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
|
||||
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
|
||||
corners around pocket rims, with unequal wall slopes and extensions confined to the face;
|
||||
connected equal-distance chamfers also trim each other across unequal sloped rim walls;
|
||||
curved chamfer chains with straight planar ends can terminate across two existing planar
|
||||
bevel faces, splitting their shared seam; picks on the same tangent chain are applied once (#1303);
|
||||
whole smooth loops of a planar face, picked whole or by one edge (tangent
|
||||
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
|
||||
plate outlines), loops with sharp corners between straight or mixed arc/line edges (straight
|
||||
blends meet in mitres; perpendicular curved walls use offset intersections, with D-rim removal
|
||||
checked within 0.1% of analytic offset slices), runs of
|
||||
neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square;
|
||||
with the edge between them too, the corner becomes a sphere octant),
|
||||
rounded corners rounded again (sphere octants, tori at concave corners); every edge of a
|
||||
convex planar body or of an extruded part; fillets inside patterns; closed chains of curved
|
||||
edges between any two smooth sides (a branch pipe on a main pipe), by a rolling ball (the
|
||||
blend a rational B-spline through exact arcs), closed or ending at planar faces square to the
|
||||
edges, where straight or curved cap edges are trimmed to the contacts (the straight edge
|
||||
between two overlapping extruded arcs); knife edges, where the two faces have opposite
|
||||
normals (a wall that runs out tangent into a face: the ball that fits there); an edge that
|
||||
runs out along an end face tangent to one side (the arc top edge of that wall), cut where the
|
||||
blend meets it; rounds that end in an earlier round on curved faces, in any order (the rounds
|
||||
of two edges at a vertex meet in a crease, as on a box), picked together or one after the
|
||||
other, except a round after a round of the edge between the wall and a flat face that it
|
||||
would run over; the three edges at a vertex of any smooth faces, of one radius, end in a
|
||||
piece of sphere. A limiting blend that collapses a side wall into coincident boundary
|
||||
curves is refused as too large rather than returning an unmeshable face (#1113).
|
||||
- Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal
|
||||
sections stacked end to end) are combined exactly in 2D. Qualified axial cuts preserve
|
||||
annular holes with overrun cutters, including inward Press Pull after a joined extrusion;
|
||||
curved wall qualification rejects false transverse axes instead of filling holes.
|
||||
Bodies touching along one whole face
|
||||
(a part and its mirror image) are stitched; coincident planar faces are pushed apart when
|
||||
their neighbours are planes or cylinders. Other coincident curved configurations can still
|
||||
fail. Bounded containment classification preserves fully internal cavities and nested
|
||||
material islands independently of shell order, with STEP round-trip and material-membership
|
||||
regressions. Near-contact curved shells use the general boolean path rather than a
|
||||
containment shortcut. Spheres are built from six pole-free patches.
|
||||
- Mass properties come from fine tessellation (curved faces within ~1e-4 relative); triangles
|
||||
that cut through curved faces (trimmed faces after booleans) are split onto the surface first.
|
||||
- STEP import (our own reader, crates/kernel/src/step_in) reads solids (B-rep with analytic and B-spline geometry), units,
|
||||
product names, colours and assemblies; analytic offsets on planes, cylinders, cones, spheres
|
||||
and regular tori are read exactly (signed/nested distances, units and face sense). Freeform
|
||||
offset surfaces and pcurve-only edges are not read yet,
|
||||
and assembly components are flattened into bodies. Closed periodic faces (torus, B-spline
|
||||
bands) are split in two for meshing; measure() uses a finer tolerance for small radii.
|
||||
- Persistent naming follows the kernel's exact face history (docs/book/src/dev/naming.md):
|
||||
references resolve by name first, follow splits and merges, and warn when the entity split,
|
||||
merged or vanished, or was found by geometry only (operations that report no history yet).
|
||||
New planar sketch attachments use exact box, extrusion cap/side and immutable imported-face
|
||||
source certificates with checked target and author occurrence paths. A missing or changed
|
||||
source fails explicitly; Redefine Sketch Plane previews reattachment or a fixed plane while
|
||||
retaining current geometry and dimension identities. Unsupported modified faces need an
|
||||
explicit fixed plane. Legacy attachments retain their original resolver behavior.
|
||||
- Modelled threads are cut as a faceted body (curved faces within 1/200 of the pitch); Extrude
|
||||
and Revolve To Object on a curved face cut the tool back to the target's surface.
|
||||
- Explicit sketch breaks preserve exact ellipses, fit/control splines (including high degrees)
|
||||
and conics; analytic trim/break also sees free-form cutters. Free-form curves cut at a tangent
|
||||
touch (no clean crossing) enter that profile as polylines.
|
||||
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
|
||||
A sweep with a twist, an end scale or a parallel or direction orientation (#771) follows its
|
||||
path that way too, even along sketch lines and arcs: B-spline walls through stations, ruled
|
||||
walls through denser stations where the path has corners or the profile has holes.
|
||||
Guide rails (one or two, #771) do the same: each station meets every rail once, and the rail
|
||||
turns the profile and scales it (or stretches it across to the rail) against the start; two
|
||||
rails carry it between them. Rails along a path with sharp corners are refused for now.
|
||||
- Extend intersects exact ellipses and rational/high-degree splines without moving the line or
|
||||
circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance
|
||||
certificate and refine within bounded node limits while retaining existing IDs.
|
||||
At the node limit, geometric certificate subdivisions accommodate unequal spline parameter
|
||||
speeds without changing stored nodes or relaxing the distance tolerance (#971).
|
||||
Associated offset fit nodes stay hidden from display, picking and snapping; dragging the
|
||||
editable source updates the derived boundary live. Break Association restores point editing.
|
||||
Singular, self-intersecting and ambiguous curves refuse atomically; mixed junctions and nonsmooth
|
||||
closed natural-spline seams remain limitations. New explicitly closed fit splines and closed
|
||||
offset outputs use periodic C2 interpolation, supporting repeated certified offsets; older
|
||||
natural splines are preserved. Clicking the starting fit point deliberately closes a new loop.
|
||||
Newly captured adaptive profile references with complete source coverage retain their outer
|
||||
and hole identities through span-count changes, including parameter edits after extrusion.
|
||||
Partial, intersected or ambiguous boundaries do not qualify for these whole-source references.
|
||||
Legacy curve-span multisets remain exact and unchanged; already-lost selections may need
|
||||
reselection. Format 12 stores the new bounded whole-boundary references.
|
||||
Projected analytic boundaries follow nested loop identities through resizing, including
|
||||
stationary holes and rounded corners. Supported convex, tangent line-and-arc boundaries
|
||||
can lose or regain corner arcs during an inset while preserving reserved curve identities
|
||||
and linked profile selections. Format 12 saves those associations; compatible older closed
|
||||
offsets upgrade structurally without changing their curve IDs. Breaking an association
|
||||
freezes its current boundary and materializes downstream profile references. Child offsets
|
||||
of a boundary with retired arcs still require breaking its association first; mixed or
|
||||
nonconvex junctions and retained references to disappearing individual arcs remain limited.
|
||||
- Extrude and Revolve editors retain original direction, extent expressions, explicit targets,
|
||||
axes, From/To inputs and All-profile intent. No-op edits preserve downstream dimension names
|
||||
and selected occurrence frames; cancel and successful edits preserve one-step undo history.
|
||||
Placed feature edits preview through the edited timeline entry, and cylindrical Emboss guides
|
||||
use the sketch's actual target, author or uniquely placed third-component frame.
|
||||
Revolve rotators follow the actual signed axis and support symmetric and two-sided extents;
|
||||
dragging a To Object extent converts its derived value to an explicit angle. Legacy reversed-axis
|
||||
symmetric geometry is preserved through format 11's explicit legacy convention. New revolves
|
||||
use the sketch-normal direction for each selected profile, including To Object and curved
|
||||
target trimming, independently of the selected axis sign.
|
||||
- Cylindrical emboss/deboss wraps bounded orthogonal line profiles, including holes, concave
|
||||
outlines and nested islands, onto complete exterior cylinders or coaxial sleeves with exact
|
||||
radial walls. New features infer a unique eligible cylinder when the face field is empty;
|
||||
ambiguous targets require an explicit face. Saved target
|
||||
and author occurrence paths retain the chosen placements; missing/split faces refuse rather
|
||||
than retarget. General free-form profiles, seams, stepped cylinders and other curved surfaces
|
||||
remain unsupported. STEP round-trip and independent radial/volume checks cover this subset.
|
||||
- Shell and offset faces work on bodies of planes, cylinders, cones, spheres and tori (convex or not:
|
||||
filleted boxes, bosses, domes, countersinks; corners where four or more planes meet, such as a
|
||||
floor falling to a drain, as long as the corner's edges are all convex or all concave); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
|
||||
sections ruled. Circular-meridian barrels retain curved inner walls through either axial
|
||||
opening (#2421). Solid lofts preserve holes through sections with matching hole counts
|
||||
(#2435), including inner walls and caps with openings; a hole cannot appear or disappear
|
||||
between sections, and sections with holes cannot collapse to a point.
|
||||
|
||||
- Sketch corner tools: fillet and all three chamfer modes pick a connected corner even when
|
||||
its endpoint overlaps the fixed origin or a loose point. Ordinary drawing still snaps to
|
||||
the origin; clicked UI regressions verify geometry, closed profiles and undo.
|
||||
- Sketch display: drawing, picking, highlighting, tool previews and profile shading share
|
||||
adaptive projected curve boundaries. Fit, zoom, display scale and placed sketch frames
|
||||
invalidate the display cache without changing CAD geometry or timeline history. Work
|
||||
budgets and clipped paths prevent unbounded sampling or invented edges across the eye plane.
|
||||
The Sketch Palette scrolls its options on short windows and enlarged displays while
|
||||
keeping constraint status and Finish Sketch visible.
|
||||
- Face projection: closed boundaries have a deterministic outer-first order. Refresh matches
|
||||
existing geometry before assigning positions, retaining saved curve IDs and constraints
|
||||
across enumeration changes and resize, including concentric circles. Ambiguous identities
|
||||
retain the last geometry and report a lost reference; permutation matching is bounded.
|
||||
- Project accepts complete supported preselection in one undo step. Visible external sketch
|
||||
curves and points retain their source sketch identity; model geometry, axes and planes use
|
||||
the existing linked projection commands. Rejection preserves the document and selection;
|
||||
mixed selections retain ordinary tool-first picking.
|
||||
- Inline sketch dimensions: Tab and Shift+Tab cycle within the active shape's value boxes,
|
||||
selecting visited values for replacement. Expression completion retains forward-Tab
|
||||
priority; real keyboard regressions cover rectangles, lines and single-value circles.
|
||||
Typed lengths, radii, line angles, signed arc sweeps and slot lengths persist as constraints
|
||||
and expressions through save/reopen. Shape creation and its dimensions form one undo step;
|
||||
rejected values keep the editable field focused.
|
||||
- Repeated components: Browser selection, visibility, properties, copy and Move/Copy use
|
||||
checked occurrence paths. Active nested occurrences author planar sketches and solids in
|
||||
their selected frame. Pending moves compose through parents; dialog cancellation restores
|
||||
its selected move. Source materials and body/face appearances follow transformed instances.
|
||||
Shared nested definitions remain shared. Deep Paste New copies reachable definition graphs,
|
||||
independent feature/input histories, styles and internal motion records, with bounded geometry
|
||||
checks and explicit external-link notices. Paste New selects the copy while preserving the active
|
||||
authoring context. Imported periodic STEP seams survive placement and reexport with shared
|
||||
topology and appearances intact. Recorded body transfers preserve historical Join/Cut membership,
|
||||
material, placements and machining data through edits, copying, undo and reopen. New 3D wires
|
||||
record their coordinate frame; legacy wires migrate from known provenance without changing
|
||||
coordinates. Broken legacy links with missing provenance can require explicit refresh or relink.
|
||||
See [occurrence workflows](docs/occurrence-workflows.md).
|
||||
- Toolbar drops on panel labels, gaps and margins append commands with insertion feedback;
|
||||
button drops keep insert-before ordering.
|
||||
- Parameters and Configurations have distinct actions in the SOLID toolbar's CONFIGURE panel.
|
||||
Parameters opens the user/model variable editor; Configurations opens the configuration table.
|
||||
Configure remains available in the panel dropdown.
|
||||
- Parameter creation supports Enter with validation and continued keyboard entry. Shared
|
||||
expression autocomplete supports Up/Down selection, Enter/Tab acceptance and Escape dismissal;
|
||||
accepting a suggestion does not submit a parameter or trigger viewport shortcuts.
|
||||
- Movable window title bars follow the app's dark/light header palette. Radial right-click
|
||||
menus are opt-in in Preferences > Navigation; ordinary context menus remain the default.
|
||||
- Desktop distribution: running Windows windows and executable resources use the app icon;
|
||||
packaging validates icon resources. Nix installs launchers, MIME data and generated icons,
|
||||
with explicit launch apps and Vulkan-loader/driver discovery. Flatpak feed instructions
|
||||
register the required runtime source; release checks exercise desktop modelling on X11/Wayland.
|
||||
- OpenGL rendering clamps effective anti-aliasing to 1x across UI pipelines, textures and CAD
|
||||
callbacks to prevent black frames. Other backends retain the requested sample count, and
|
||||
stored preferences remain unchanged.
|
||||
- Configuration rules: ordered typed conditions can drive parameters and suppressions or
|
||||
restrict values. Preview explains effective values without modifying the document; applying
|
||||
rules validates rows atomically. Length expressions retain their authoring units across
|
||||
document-unit changes. The UI supports structured All/Any/Not conditions and rule ordering.
|
||||
Component styles and certified semantic face styles are available; unsupported lineage refuses
|
||||
atomically, and stale certified face paint cannot jump to another surface. See [configurations](docs/configurations.md).
|
||||
- Sheet-metal material: inherited holes, crossing edge notches, overlapping cuts and retained
|
||||
islands share the same boundaries in flat preview, DXF and unfolded solids. Detached material
|
||||
pieces survive unfold/refold as one logical sheet group, including split flanged sheets.
|
||||
Cut-outs made in the flat pattern cross bends at any angle or curved: their edges fold to
|
||||
helices and curves on the bend, their walls square to the sheet. Free-form bases use bounded
|
||||
sampling.
|
||||
Parallel prismatic partial-depth cuts retain pocket side and depth through transfer and
|
||||
unfold/refold, with separate pocket contours in preview and DXF. Planar Intersect preserves
|
||||
exact holes and retained islands, including the union of multiple selected profiles, and
|
||||
records complementary depth layers as pockets while retaining the stock gauge. Unsupported
|
||||
tapered or bend-crossing machining refuses atomically instead of restoring material later.
|
||||
Sketches attach to a sheet's flat faces (base and flanges, either side) by panel, in the
|
||||
flat pattern's coordinates, and follow it through rebuilds and unfold/refold.
|
||||
A flange on another flange's far edge (a return) follows that flange's closed end and keeps
|
||||
clear of the flange beside it, and the returns of one feature are mitred toward each other
|
||||
with the rule's gap where their flanges meet at a corner; returns of separate features, and
|
||||
flanges on returns, are not closed toward each other.
|
||||
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
|
||||
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
|
||||
scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
|
||||
check. Under-resolved fits return an error.
|
||||
- Contact driving checks the trajectory between clear endpoints, including linked joints,
|
||||
playback and sparse motion exports. Checks use bounded tessellation samples, not exact
|
||||
continuous collision detection; local thin walls and small details remain limitations.
|
||||
See [contact-set limits and recovery](docs/contact-sets.md).
|
||||
|
||||
## Assembly dragging
|
||||
|
||||
The design's first part is grounded when it is made, and a left-button drag repositions a free part (`occurrence.drag`: one undo step, exact occurrence paths including repeated jointed definitions, any floating-chain member carries the chain, grounded parts and joints hold where present, Escape cancels).
|
||||
|
||||
## 3D sketch bridges
|
||||
|
||||
3D sketch drawing snaps to points on other visible sketch planes without flattening them onto
|
||||
the active plane. Bridge endpoints capture their source positions; associative endpoint links,
|
||||
construction-plane selection of 3D wires and loft guide rails remain separate work.
|
||||
Feature dialog header chevrons collapse and restore the form without discarding selections
|
||||
or edited values, including the Appearance panel.
|
||||
## Construction on 3D paths
|
||||
|
||||
Plane Along Path has a viewport path selector, fractional position, live preview and timeline
|
||||
editing. Construction planes and points resolve drawn 3D wires without applying their sketch
|
||||
plane a second time. Curved paths use sampled segments; multi-curve chains, absolute distance
|
||||
and reference-object placement remain separate work, as do loft rails and centerlines.
|
||||
|
||||
## Design properties (#1015)
|
||||
|
||||
- Designs carry project, part number, revision, author, organization, description and licence
|
||||
in an optional `metadata` object (File → Design Properties…, `document.metadata.*`,
|
||||
`inspect_design`). STEP and IGES headers and 3MF model metadata take the fields their format
|
||||
has a place for. Other tools keep their own data under a reverse-DNS key, kept as it is
|
||||
through open, edit and save and never exported. See [design properties](docs/book/src/guide/files.md#design-properties).
|
||||
Per-component properties (a part number per component) can follow with the same type.
|
||||
|
||||
## Document-opening performance
|
||||
|
||||
- Desktop STEP import prepares consecutive independent solids in bounded batches with up
|
||||
to eight workers, retaining serial traversal, result order and persistent source identities.
|
||||
The web path remains serial. Original multi-solid fixtures show 64–81% lower complete
|
||||
import times; single-solid assembly parts are not scheduled across nodes. See
|
||||
[measurements and limits](docs/perf.md#parallel-step-body-preparation-2026-10-12).
|
||||
- Design containers (#303, phase one) separate and deduplicate STEP, raw images and
|
||||
lossless binary mesh arrays while keeping plain JSON as a supported Save As/export
|
||||
format. Generated-mesh storage measurements reduce serialization and decoding work;
|
||||
a seekable reader defers original blobs until after provisional display.
|
||||
See [container format](docs/design-container.md) and [performance measurements](docs/perf.md).
|
||||
- Warm saves with explicit `cache: true` can include a versioned native B-rep cache through SolidCraft-owned topology
|
||||
and geometry records, with a separate truck adapter and bounded binary reader. Exact
|
||||
face-source certificates and operation history survive codec round trips. A provisional
|
||||
picture can be generated before original blobs are read; editing and measurement wait
|
||||
for authoritative reconstruction. Cached metadata binds preview and reconstruction to
|
||||
the same document despite concurrent file writes. Default saves omit this cache and never force cold
|
||||
evaluation or meshing; explicit `preview: true` remains a separate warm-mesh option.
|
||||
Keys, resource budgets and document/revision guards reject incompatible, hostile or
|
||||
stale results. A second modelling kernel still requires its own tested adapter.
|
||||
- Warm saves with explicit `cache: true` can also carry a bounded PNG thumbnail from already cached meshes.
|
||||
Home reads it without rebuilding the design when its local picture is missing;
|
||||
mismatched or malformed thumbnails fall back to normal generation. The image is
|
||||
kernel-independent and saving never forces cold meshing to produce it.
|
||||
|
||||
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
|
||||
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
|
||||
reuse reduce repeated work without relaxing geometry checks. Three alternating optimized
|
||||
benchmark runs reduce median rebuild time by 26% on the public sensor enclosure and 22%
|
||||
on a local case assembly; shape measurements and topology match the original build.
|
||||
See [performance measurements](docs/perf.md). Further cold Boolean and import costs remain.
|
||||
- Prepared assembly geometry survives selection and view changes while real geometry and
|
||||
placement changes invalidate it. A Pi assembly edge-selection probe drops from 9.24 s to
|
||||
0.57 s; initial opening is unchanged. Actual Windows desktop profiling measures 15.87 s
|
||||
median opening, with 82% spent rebuilding geometry; halving full opening remains open.
|
||||
|
||||
## Kernel
|
||||
|
||||
- Successful Boolean retries report their strategy and numerical adjustments as
|
||||
feature warnings and structured inspection data. Diagnostics survive cached
|
||||
evaluation, undo/redo and cold reopening; the robustness runner counts retry
|
||||
events by strategy and reports any bounded-log truncation. Existing geometry
|
||||
acceptance checks remain in force.
|
||||
|
||||
- Cylinders of one radius whose axes cross (a second bore across a bore of the same size, a pipe
|
||||
tee of equal pipes) touch at two points, where the mesh intersection cannot trace the curves
|
||||
and a boolean cannot tell the four arms of the crossing apart. When the ordinary boolean fails
|
||||
on such a pair, both faces are split along the line of their cylinder through the touching
|
||||
points (the solids are unchanged), the boolean is taken of those, and its history is composed
|
||||
back over the operands as given (#788). The result keeps the split lines as edges of the bore
|
||||
walls. A crossing the ordinary route fails on and the split faces do not help (a radius of 17.5
|
||||
in a 45 mm block with a tool of 80 mm) is still refused.
|
||||
- Boolean intersection-curve construction refuses point-only, collapsed and non-finite
|
||||
polylines without panicking. Direct constructor regressions retain valid open endpoints
|
||||
and closed spherical intersections; the original interactive trigger remains unidentified.
|
||||
- Shell of a stepped, joined planar body no longer creates horizontal subdivisions across
|
||||
continuous walls. Fresh axis-aligned Cut walls are stitched using their exact construction
|
||||
support and shared topology, preserving real steps and intentional boundaries. The local
|
||||
reported case returns from 86 to 49 faces with unchanged material and bounds; regressions
|
||||
cover stepped stock, STEP round trips and saved projections from removed pieces.
|
||||
|
||||
truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean
|
||||
retry/validation; see `plan/adr/0001-geometry-kernel.md` (local) — summary in README.
|
||||
The short version, for people who use SolidCraft. The detailed developer status (milestone
|
||||
tables, limitations, measurements) is in [docs/roadmap-detail.md](docs/roadmap-detail.md).
|
||||
|
||||
## What SolidCraft is
|
||||
|
||||
SolidCraft is open-source parametric 3D CAD written in Rust. You sketch, constrain, extrude, and
|
||||
change a parameter to watch the timeline rebuild. It runs as a desktop app and in the browser
|
||||
([try it](https://solidcraft.io/app)), and it has a command-line tool and an MCP server so that
|
||||
scripts and AI agents can drive the same commands you use.
|
||||
|
||||
## Works today
|
||||
|
||||
- Sketching with a constraint solver, and the usual create and modify tools.
|
||||
- Extrude, revolve, loft, sweep, coil, fillet, chamfer, shell, draft, holes, threads,
|
||||
patterns, mirror and split.
|
||||
- Parameters with units and expressions, and configurations.
|
||||
- Faces and edges keep their names through edits, so references survive changes.
|
||||
- Components, assemblies, joints, motion studies and exploded views.
|
||||
- Sheet metal with flat patterns and DXF export, and a plastic-enclosure subset.
|
||||
- A Render workspace with GPU rendering, studio lighting, materials and image export
|
||||
(first release in 0.22.0).
|
||||
- Import and export of STEP, IGES, 3MF, STL, OBJ and DXF, and FreeCAD and Rhino files.
|
||||
- Autosave and crash recovery, and the browser build of the same app.
|
||||
|
||||
The full list of commands is in [docs/features.md](docs/features.md). Release notes for each
|
||||
version are on the [releases page](https://github.com/tangency-labs/solidcraft/releases).
|
||||
|
||||
## In progress now
|
||||
|
||||
Each feature below is a [milestone](https://github.com/tangency-labs/solidcraft/milestones) holding
|
||||
the issues for it, so the count shows how far along it is. Counts are as of 2026-10-25, one per
|
||||
feature or request. Work merged on a feature branch (`drawings`, `render`) counts as built but
|
||||
reaches the app only when that branch merges. They are rough, not a promise.
|
||||
|
||||
- **[Rendering & materials](https://github.com/tangency-labs/solidcraft/milestone/17)**: in progress, 9 of 34 pieces done, 3 of them built only on the `render` branch and in the app when it merges. The basic Render workspace is already in releases (0.22.0); the interface rework (#6281) and the material and light libraries are what is left.
|
||||
- **[Drawings](https://github.com/tangency-labs/solidcraft/milestone/8)**: in progress, 3 of 21 pieces built (2 in releases, 1 on the `drawings` branch), and 3 more partly built there (sheets and borders, bill-of-materials types, the paper scene). Not in the app yet: it lands when the `drawings` branch merges. Today you can export the current view as a DXF.
|
||||
- **[Sheet metal](https://github.com/tangency-labs/solidcraft/milestone/15)**: in progress, 1 of 30 requests done. Flanges, hems, unfold and flat patterns already work; the 30 are extensions, mostly the per-edge flange options (segments, miters, partial flanges, reliefs) and bend allowance.
|
||||
- **[Sketching](https://github.com/tangency-labs/solidcraft/milestone/14)**: in progress, 12 of 50 pieces done. Dimension types, selection and snapping options.
|
||||
- **[Solid modeling](https://github.com/tangency-labs/solidcraft/milestone/2)**: in progress, 9 of 50 pieces done. Blends, holes, threads and patterns.
|
||||
- **[Assemblies & joints](https://github.com/tangency-labs/solidcraft/milestone/3)**: in progress, 4 of 19 pieces done. Joints, motion studies and exploded views already work; placement rules, linked sub-assemblies, fastener stacks and weldment corners are the pieces left.
|
||||
- **[Import & export](https://github.com/tangency-labs/solidcraft/milestone/5)**: in progress, 7 of 39 pieces done. FreeCAD, SolidWorks and point-cloud import, and mesh export options.
|
||||
- **[AI agents & MCP](https://github.com/tangency-labs/solidcraft/milestone/18)**: in progress, 2 of 8 pieces done. Agent chat and the MCP server.
|
||||
- **[Interface, web app & plugins](https://github.com/tangency-labs/solidcraft/milestone/4)**: in progress, 31 of 96 pieces done. Ribbon redesign, navigation, themes, the web app and plugins.
|
||||
- **[SMLib kernel migration](https://github.com/tangency-labs/solidcraft/milestone/1)**: in progress, 1 tracking issue open. The kernel is moving from truck to NVIDIA's SMLib (usd-brep, Apache-2.0) on the `kernel-smlib` branch; it is not in releases yet. Tracked in [#4012](https://github.com/tangency-labs/solidcraft/issues/4012).
|
||||
|
||||
## Next
|
||||
|
||||
- **[Surfacing](https://github.com/tangency-labs/solidcraft/milestone/16)**: next, 6 of 14 pieces done. Patch, trim, blend and match exist; the rest of the surface toolset is planned.
|
||||
- **[Manufacturing / CAM](https://github.com/tangency-labs/solidcraft/milestone/9)**: next, 1 pieces tracked, none done. There is no CAM in SolidCraft yet. Send to Slicer and a first toolpath slice are in review; proposals are [#1153](https://github.com/tangency-labs/solidcraft/issues/1153) and [#1621](https://github.com/tangency-labs/solidcraft/issues/1621).
|
||||
|
||||
Features are not tied to a release or a date. Work lands in the daily releases as it is finished.
|
||||
Anything without a milestone, such as bug fixes, is not a feature and is not listed here.
|
||||
|
||||
## How releases work
|
||||
|
||||
- Releases ship daily, as numbered versions (v0.22.0, v0.22.1) with nightly pre-releases in
|
||||
between. They are delivered through the in-app updater.
|
||||
- SolidCraft tells you when a newer stable release exists and never updates without asking.
|
||||
Where it can replace itself safely (AppImage, Windows installer and portable builds) there is
|
||||
an **Update now** button; downloads are checked against the release's SHA-256 list. See
|
||||
[updates and privacy](docs/book/src/guide/updates.md).
|
||||
- Design files carry a format number. A new version opens designs from older versions, but an
|
||||
older version refuses a design saved by a newer one (0.22.0 writes format 35, and 0.21.0 and
|
||||
earlier will not open it). Each release's notes say when the format changed. Keep a copy of
|
||||
an important design before you move to a new version.
|
||||
|
||||
## Licence
|
||||
|
||||
SolidCraft is licensed MIT OR Apache-2.0, at your option; see [LICENSE-MIT](LICENSE-MIT) and
|
||||
[LICENSE-APACHE](LICENSE-APACHE). Versions already released stay available under those licences
|
||||
permanently.
|
||||
|
||||
## How to follow along
|
||||
|
||||
- Feature milestones: <https://github.com/tangency-labs/solidcraft/milestones>
|
||||
- Issues: <https://github.com/tangency-labs/solidcraft/issues> (area labels such as
|
||||
`area:kernel`, `area:sketch` and `area:ui` group them)
|
||||
- Discord: <https://discord.gg/FtU4NQNVDH>
|
||||
- Releases: <https://github.com/tangency-labs/solidcraft/releases>
|
||||
- Contributing: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
@@ -114,6 +114,46 @@ pub fn opens_design(extensions: &[&str]) -> bool {
|
||||
extensions.is_empty() || extensions.iter().any(|e| solidcraft_engine::io::DESIGN_EXTS.contains(e))
|
||||
}
|
||||
|
||||
/// Where an Open for `extensions` (what the command reads; `&[]`: anything) is answered.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum OpenRoute {
|
||||
/// The browser's open dialog for a design: the file keeps its handle, so Save writes back.
|
||||
Disk,
|
||||
/// The "Files in this browser" window, where the browser can't open a design from the disk.
|
||||
Panel,
|
||||
/// A plain file input that offers these types: the file is read into the engine's files and
|
||||
/// the command runs on it (Insert DXF or SVG, images, meshes, imported settings).
|
||||
Upload,
|
||||
}
|
||||
|
||||
/// Choose how an Open for `extensions` asks for its file; `api`: the browser can open a file
|
||||
/// from the disk and keep its handle.
|
||||
pub fn open_route(api: bool, extensions: &[&str]) -> OpenRoute {
|
||||
match (opens_design(extensions), api) {
|
||||
(true, true) => OpenRoute::Disk,
|
||||
(true, false) => OpenRoute::Panel,
|
||||
(false, _) => OpenRoute::Upload,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `accept` list of a file input for `extensions` (with or without their dot): ".dxf,.svg".
|
||||
pub fn accept_list(extensions: &[&str]) -> String {
|
||||
let list: Vec<String> = extensions
|
||||
.iter()
|
||||
.map(|e| e.trim().trim_start_matches('.'))
|
||||
.filter(|e| !e.is_empty())
|
||||
.map(|e| format!(".{}", e.to_ascii_lowercase()))
|
||||
.collect();
|
||||
list.join(",")
|
||||
}
|
||||
|
||||
/// The largest file an upload for `extensions` reads: images go into a canvas, which has a size
|
||||
/// limit, so a bigger one is refused before it is read. `None`: the importer checks the file.
|
||||
pub fn upload_limit(extensions: &[&str]) -> Option<usize> {
|
||||
let image = |e: &&str| ["png", "jpg", "jpeg"].contains(&e.trim_start_matches('.').to_ascii_lowercase().as_str());
|
||||
(!extensions.is_empty() && extensions.iter().all(image)).then_some(solidcraft_engine::doc::canvas::MAX_CANVAS_BYTES)
|
||||
}
|
||||
|
||||
/// The file type a Save As dialog offers for `name`: (description, MIME type, extension).
|
||||
pub fn save_type(name: &str) -> (&'static str, &'static str, &'static str) {
|
||||
let json = std::path::Path::new(name).extension().is_some_and(|e| e.eq_ignore_ascii_case("json"));
|
||||
@@ -140,7 +180,7 @@ mod web {
|
||||
use wasm_bindgen_futures::{JsFuture, spawn_local};
|
||||
use web_sys::{FileSystemFileHandle, IdbDatabase, IdbObjectStore, IdbOpenDbRequest, IdbRequest, IdbTransactionMode};
|
||||
|
||||
use super::{HANDLE_MAX_AGE_MS, is_abort, make_id, opens_design, parse, path, plan_prune, save_type};
|
||||
use super::{HANDLE_MAX_AGE_MS, is_abort, make_id, parse, path, plan_prune, save_type};
|
||||
use crate::files::{Files, err, read_handle};
|
||||
|
||||
const DB: &str = "solidcraft-disk-handles";
|
||||
@@ -294,7 +334,7 @@ mod web {
|
||||
let options = Object::new();
|
||||
let _ = Reflect::set(&options, &"multiple".into(), &JsValue::FALSE);
|
||||
let extensions = Array::new();
|
||||
for e in [".solidcraft", ".solvecraft", ".json", ".step", ".stp", ".3mf", ".stl", ".obj", ".glb", ".gltf", ".fcstd", ".3dm"] {
|
||||
for e in [".solidcraft", ".solvecraft", ".json", ".step", ".stp", ".3mf", ".stl", ".obj", ".glb", ".gltf", ".fcstd", ".3dm", ".dxf", ".svg"] {
|
||||
extensions.push(&e.into());
|
||||
}
|
||||
let accept = Object::new();
|
||||
@@ -315,11 +355,6 @@ mod web {
|
||||
p
|
||||
}
|
||||
|
||||
/// Does an Open for `extensions` use the browser's open dialog?
|
||||
pub fn handles_open(extensions: &[&str]) -> bool {
|
||||
supported() && opens_design(extensions)
|
||||
}
|
||||
|
||||
// ---- writing ------------------------------------------------------------------------
|
||||
|
||||
/// Queue what a command wrote to a disk path. Writes go one at a time; a newer write of the
|
||||
@@ -621,6 +656,21 @@ mod tests {
|
||||
assert!(!opens_design(&["json"]));
|
||||
}
|
||||
|
||||
/// Insert DXF or SVG, images and other non-design files get a file input that offers their
|
||||
/// types in every browser (#2052); designs keep the disk dialog or the browser's files.
|
||||
#[test]
|
||||
fn opens_of_other_files_use_a_file_input_for_their_types() {
|
||||
assert_eq!(open_route(true, &["dxf"]), OpenRoute::Upload);
|
||||
assert_eq!(open_route(false, &["png", "jpg", "jpeg"]), OpenRoute::Upload);
|
||||
assert_eq!(open_route(true, &[]), OpenRoute::Disk);
|
||||
assert_eq!(open_route(false, &["solidcraft"]), OpenRoute::Panel);
|
||||
assert_eq!(accept_list(&["dxf"]), ".dxf");
|
||||
assert_eq!(accept_list(&["png", ".JPG", "", "jpeg"]), ".png,.jpg,.jpeg");
|
||||
assert_eq!(accept_list(&[]), "");
|
||||
assert!(upload_limit(&["png", "jpg", "jpeg"]).is_some());
|
||||
assert_eq!(upload_limit(&["dxf"]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_save_dialog_offers_the_type_of_the_name() {
|
||||
assert_eq!(save_type("Plate.solidcraft").2, ".solidcraft");
|
||||
|
||||
@@ -500,8 +500,9 @@ fn input_picker(files: &Rc<Files>, intent: Intent) {
|
||||
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
|
||||
input.set_type("file");
|
||||
// Everything the browser build imports, from the format registry: cadmpeg is pure Rust, so
|
||||
// FreeCAD and Rhino files are part of it.
|
||||
let accept = solidcraft_engine::io::formats::open_extensions(true).iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
|
||||
// FreeCAD and Rhino files are part of it; plus the DXF and SVG drawings Open and Insert take.
|
||||
let accept =
|
||||
solidcraft_engine::io::formats::open_extensions(true).iter().chain(&["dxf", "svg"]).map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
|
||||
input.set_accept(&accept);
|
||||
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
|
||||
let _ = input.set_attribute("style", "display:none");
|
||||
@@ -527,6 +528,67 @@ fn input_picker(files: &Rc<Files>, intent: Intent) {
|
||||
input.click();
|
||||
}
|
||||
|
||||
/// Ask for one file of these types (`extensions`, e.g. `["dxf"]`) with a plain file input, the
|
||||
/// dialog every browser has. The answer is the file's path in the engine's files (under
|
||||
/// [`UPLOAD`], as for a file picked in the disk dialog), so the command that asked runs on it;
|
||||
/// `None` when the dialog was closed or the file could not be read.
|
||||
pub fn pick_upload(files: &Rc<Files>, extensions: &[&str]) -> impl std::future::Future<Output = Option<String>> + use<> {
|
||||
// Shown now, in the click: a file dialog needs the user's gesture.
|
||||
let chosen = choose_file(&disk::accept_list(extensions));
|
||||
let limit = disk::upload_limit(extensions);
|
||||
let files = files.clone();
|
||||
async move {
|
||||
let read = async {
|
||||
let value = JsFuture::from(chosen?).await.map_err(err)?;
|
||||
// Closing the dialog answers no file: nothing to say.
|
||||
let Ok(file) = value.dyn_into::<web_sys::File>() else { return Ok(None) };
|
||||
let name = file.name();
|
||||
if limit.is_some_and(|max| !file.size().is_finite() || file.size() > max as f64) {
|
||||
return Err(format!("{name}: the file is too large"));
|
||||
}
|
||||
let bytes = blob_bytes(&file).await.map_err(|e| format!("{name}: {e}"))?;
|
||||
let path = format!("{UPLOAD}{name}");
|
||||
vfs::insert(&path, bytes);
|
||||
Ok(Some(path))
|
||||
};
|
||||
match read.await {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
files.say(format!("open: {e}"), true);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a hidden `<input type=file>` offering `accept` (call in the click). The promise answers
|
||||
/// the picked `File`, or `null` when the dialog is closed (the input's `cancel` event).
|
||||
fn choose_file(accept: &str) -> Result<js_sys::Promise, String> {
|
||||
let doc = web_sys::window().and_then(|w| w.document()).ok_or("this page can't show a file dialog")?;
|
||||
let input = doc.create_element("input").map_err(err)?.dyn_into::<web_sys::HtmlInputElement>().map_err(|_| "no file input".to_string())?;
|
||||
input.set_type("file");
|
||||
input.set_accept(accept);
|
||||
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
|
||||
input.set_attribute("style", "display:none").map_err(err)?;
|
||||
doc.body().ok_or("this page has no body")?.append_child(&input).map_err(err)?;
|
||||
let promise = js_sys::Promise::new(&mut |resolve: Function, _reject: Function| {
|
||||
// One answer for a pick or a cancel; read from the event, so it does not keep the input.
|
||||
let done = Closure::once_into_js(move |event: web_sys::Event| {
|
||||
let file = event.target().and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok()).and_then(|input| {
|
||||
input.set_onchange(None);
|
||||
input.set_oncancel(None);
|
||||
input.remove();
|
||||
input.files().and_then(|list| list.get(0))
|
||||
});
|
||||
let _ = resolve.call1(&JsValue::UNDEFINED, &file.map_or(JsValue::NULL, JsValue::from));
|
||||
});
|
||||
input.set_onchange(Some(done.unchecked_ref()));
|
||||
input.set_oncancel(Some(done.unchecked_ref()));
|
||||
});
|
||||
input.click();
|
||||
Ok(promise)
|
||||
}
|
||||
|
||||
/// Read files dropped on the page.
|
||||
pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput) {
|
||||
for f in std::mem::take(&mut raw.dropped_files) {
|
||||
|
||||
@@ -330,7 +330,7 @@ mod web {
|
||||
files::pick_from_disk(&self.files, Intent::Open);
|
||||
}
|
||||
if ui_kit::button(ui, "Insert from computer…", ui_kit::Button::Normal)
|
||||
.on_hover_text("STEP, 3MF or STL into the open design")
|
||||
.on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_drawings_hint"))
|
||||
.clicked()
|
||||
{
|
||||
files::pick_from_disk(&self.files, Intent::Insert);
|
||||
@@ -383,7 +383,7 @@ mod web {
|
||||
let design = solidcraft_engine::io::is_design_path(&e.name);
|
||||
if !design
|
||||
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
|
||||
.on_hover_text("Into the open design (STEP, 3MF, STL)")
|
||||
.on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
|
||||
.clicked()
|
||||
{
|
||||
act = Some(("insert", e.name.clone()));
|
||||
@@ -707,14 +707,17 @@ mod web {
|
||||
let (restorable, restorer) = (files.clone(), files.clone());
|
||||
let services = if launch.embed { Services::default() } else { Services {
|
||||
// Open shows the browser's open dialog for a design where it has one
|
||||
// (the file keeps its handle, so Save writes back to it); otherwise,
|
||||
// and for inserting STEP or meshes, the browser's files window.
|
||||
pick_open: Some(Box::new(move |exts: &[&str]| {
|
||||
if disk::handles_open(exts) {
|
||||
return Box::pin(disk::pick_open(&opener));
|
||||
// (the file keeps its handle, so Save writes back to it), else the
|
||||
// browser's files window. A command that reads another kind of file
|
||||
// (Insert DXF or SVG, an image, a mesh, settings) gets a file dialog
|
||||
// offering that kind, and runs on the picked file.
|
||||
pick_open: Some(Box::new(move |exts: &[&str]| match disk::open_route(disk::supported(), exts) {
|
||||
disk::OpenRoute::Disk => Box::pin(disk::pick_open(&opener)),
|
||||
disk::OpenRoute::Upload => Box::pin(files::pick_upload(&opener, exts)),
|
||||
disk::OpenRoute::Panel => {
|
||||
panel.panel.set(true);
|
||||
Box::pin(std::future::ready(None))
|
||||
}
|
||||
panel.panel.set(true);
|
||||
Box::pin(std::future::ready(None))
|
||||
})),
|
||||
// A design is saved to a file the user picks, or kept in the browser
|
||||
// where the browser can't pick one; everything else is downloaded.
|
||||
|
||||
@@ -292,7 +292,9 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
dialog = dialog.add_filter(label, &case_variants(first));
|
||||
}
|
||||
// The groups come from the format registry, so a new format joins this dialog by itself.
|
||||
dialog = dialog.add_filter("Designs, CAD files and meshes", &case_variants(&io::formats::open_extensions(true)));
|
||||
let mut everything = io::formats::open_extensions(true);
|
||||
everything.extend(["dxf", "svg"]);
|
||||
dialog = dialog.add_filter(solidcraft_ui_egui::i18n::tr("file.open.drawing_filter"), &case_variants(&everything));
|
||||
for (label, exts) in io::formats::import_filters(true) {
|
||||
dialog = dialog.add_filter(label, &case_variants(&exts));
|
||||
}
|
||||
@@ -576,13 +578,22 @@ fn scene_samples(backend: eframe::wgpu::Backend, samples: u32, supported: &[u32]
|
||||
}
|
||||
}
|
||||
|
||||
/// Anti-aliasing samples from Preferences (4 when unset).
|
||||
fn msaa() -> u16 {
|
||||
/// Keep the default until the installed renderer's startup check proves a fallback is needed.
|
||||
fn scene_msaa_request(saved: Option<u16>) -> u16 {
|
||||
saved.unwrap_or(MSAA)
|
||||
}
|
||||
|
||||
/// Query the scene's colour/depth intersection, rather than the depthless window's counts.
|
||||
fn scene_sample_choices(depth_bits: u8, stencil_bits: u8, query: impl FnOnce(Option<eframe::wgpu::TextureFormat>) -> Vec<u32>) -> Vec<u32> {
|
||||
query(eframe::egui_wgpu::depth_format_from_bits(depth_bits, stencil_bits))
|
||||
}
|
||||
|
||||
/// An explicitly saved anti-aliasing choice, absent until Graphics changes it.
|
||||
fn msaa() -> Option<u16> {
|
||||
graphics()
|
||||
.and_then(|v| v.get("msaa").and_then(serde_json::Value::as_u64))
|
||||
.and_then(|m| u16::try_from(m).ok())
|
||||
.filter(|m| [1, 2, 4, 8].contains(m))
|
||||
.unwrap_or(MSAA)
|
||||
}
|
||||
|
||||
/// Windows and Linux: no OS title bar; the application bar is the title bar
|
||||
@@ -686,7 +697,7 @@ fn main() -> eframe::Result {
|
||||
if safe_mode {
|
||||
notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into());
|
||||
}
|
||||
let samples = if safe_mode { MSAA } else { msaa() };
|
||||
let saved_samples = if safe_mode { None } else { msaa() };
|
||||
let wgpu_options = wgpu_options(cpu_renderer, if safe_mode { None } else { backend_preference() }.as_deref());
|
||||
let depth_bits = if !safe_mode && startup::float_depth_everywhere(&wgpu_options) { FLOAT_DEPTH_BITS } else { DEPTH_BITS };
|
||||
// Crashes leave a report in the crash folder (solidcraft_engine::crash, docs/crash-reports.md).
|
||||
@@ -816,18 +827,24 @@ fn main() -> eframe::Result {
|
||||
(info.name.clone(), format!("{} ({:?}, {:?})", info.name, info.device_type, info.backend))
|
||||
})
|
||||
.collect();
|
||||
let scene_samples = scene_samples(rs.adapter.get_info().backend, u32::from(samples), &rs.msaa_supported);
|
||||
let info = rs.adapter.get_info();
|
||||
let samples = scene_msaa_request(saved_samples);
|
||||
let supported = scene_sample_choices(depth_bits, STENCIL_BITS, |depth| {
|
||||
eframe::egui_wgpu::supported_msaa_samples(&rs.adapter, &rs.device, rs.target_format, depth)
|
||||
});
|
||||
let scene_samples = scene_samples(info.backend, u32::from(samples), &supported);
|
||||
eprintln!("SolidCraft graphics: {:?}, MSAA {scene_samples}x (requested {}x)", rs.adapter.get_info().backend, samples);
|
||||
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
|
||||
app.set_wgpu_split(rs, depth_bits, STENCIL_BITS, scene_samples);
|
||||
let scene_samples = app.set_wgpu_split(rs, depth_bits, STENCIL_BITS, scene_samples);
|
||||
app.msaa_supported = supported;
|
||||
app.preferences.msaa = u32::from(samples);
|
||||
solidcraft_ui_egui::gpu::install_picture_renderer(rs);
|
||||
// The graphics driver is loaded now; it may have put its own exception filter
|
||||
// in front of ours.
|
||||
#[cfg(target_os = "windows")]
|
||||
native_crash::reinstall();
|
||||
// A saved anti-aliasing the device can't do: back to the default, with a notice.
|
||||
// Reflect a confirmed probe fallback or an unsupported saved count in Graphics.
|
||||
solidcraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples));
|
||||
let info = rs.adapter.get_info();
|
||||
solidcraft_engine::crash::set_environment(&format!(
|
||||
"Renderer: {:?} {} ({}), MSAA {scene_samples}x",
|
||||
info.backend, info.name, info.driver
|
||||
@@ -1200,6 +1217,34 @@ mod msaa_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_sample_choices_include_the_scene_depth_format() {
|
||||
use eframe::wgpu::TextureFormat;
|
||||
for (bits, expected) in [(24, TextureFormat::Depth24PlusStencil8), (32, TextureFormat::Depth32FloatStencil8)] {
|
||||
// A synthetic colour target supports 8x, but the scene depth supports only 1x/4x.
|
||||
let supported = super::scene_sample_choices(bits, 8, |depth| match depth {
|
||||
Some(format) if format == expected => vec![1, 4],
|
||||
None => vec![1, 4, 8],
|
||||
other => panic!("unexpected scene depth: {other:?}"),
|
||||
});
|
||||
assert_eq!(supported, [1, 4]);
|
||||
assert_eq!(super::scene_samples(eframe::wgpu::Backend::Dx12, 8, &supported), 4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_msaa_keeps_four_samples_by_default_and_explicit_choices() {
|
||||
use eframe::wgpu::Backend;
|
||||
let default = super::scene_msaa_request(None);
|
||||
assert_eq!(default, 4);
|
||||
for backend in [Backend::Dx12, Backend::Vulkan, Backend::Metal] {
|
||||
assert_eq!(super::scene_samples(backend, u32::from(default), &[1, 4]), 4);
|
||||
}
|
||||
for saved in [1, 2, 4, 8] {
|
||||
assert_eq!(super::scene_msaa_request(Some(saved)), saved);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scene_follows_the_backend_policy_too() {
|
||||
use eframe::wgpu::Backend;
|
||||
|
||||
@@ -48,6 +48,38 @@ source = "#236ead"
|
||||
light = "#236ead"
|
||||
dark = "#2976b8"
|
||||
|
||||
# Simultaneous component colours for inspection icons; existing workspace ramps stay unchanged.
|
||||
|
||||
[roles.component-orange-1]
|
||||
source = "#f1d2af"
|
||||
light = "#f1d2af"
|
||||
dark = "#ba8f6a"
|
||||
|
||||
[roles.component-orange-2]
|
||||
source = "#c06f00"
|
||||
light = "#c06f00"
|
||||
dark = "#b86f04"
|
||||
|
||||
[roles.component-orange-3]
|
||||
source = "#a45000"
|
||||
light = "#a45000"
|
||||
dark = "#95682d"
|
||||
|
||||
[roles.component-yellow-1]
|
||||
source = "#e5d99b"
|
||||
light = "#e5d99b"
|
||||
dark = "#a9985f"
|
||||
|
||||
[roles.component-yellow-2]
|
||||
source = "#a28200"
|
||||
light = "#a28200"
|
||||
dark = "#9a8100"
|
||||
|
||||
[roles.component-yellow-3]
|
||||
source = "#826700"
|
||||
light = "#826700"
|
||||
dark = "#857100"
|
||||
|
||||
[strokes] # on the 64 grid
|
||||
geometry = 2.55
|
||||
action = 4.25
|
||||
|
||||
@@ -57,6 +57,8 @@ circle_tt false
|
||||
circle_ttt false
|
||||
close false
|
||||
coil false
|
||||
colored_components false
|
||||
colored_components_off false
|
||||
combine false
|
||||
compare false
|
||||
component false
|
||||
@@ -245,6 +247,7 @@ status_error false
|
||||
status_recovery false
|
||||
style false
|
||||
suppress false
|
||||
surface false
|
||||
surface_blend false
|
||||
surface_boundary false
|
||||
surface_extend false
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Colored components</title>
|
||||
<path d="M9.235 17.706L15.059 20.882L15.059 48.941L9.235 44.926Z" fill="#c06f00"/>
|
||||
<path d="M35.176 9.235L15.059 20.882L15.059 48.941L36.765 35.139Z" fill="#a45000"/>
|
||||
<path d="M29.046 5L35.176 8.176L15.196 19.718L9.066 16.541Z" fill="#f1d2af"/>
|
||||
<path d="M29.046 5.529L35.176 8.706L35.176 36.235L15.059 49.471L9.235 45.765L9.066 17.071Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.066 17.6L15.059 20.882L34.647 9.235" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.059 20.882L15.059 47.067" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M32.222 18.595L44.949 25.944L44.949 51.122L32.222 43.774Z" fill="#328bd3"/>
|
||||
<path d="M54.934 20.173L44.949 25.944L44.949 51.122L54.934 45.352Z" fill="#236ead"/>
|
||||
<path d="M42.059 12.412L54.934 20.173L44.949 25.944L32 17.706Z" fill="#b9ddf8"/>
|
||||
<path d="M41.159 11.766L54.934 20.173L54.934 45.352L44.949 51.122L31.164 42.715L31.164 17.536Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M32.222 18.595L44.949 25.944L54.934 20.173" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M44.949 25.944L44.949 51.122" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.299 39.062L31.015 46.411L31.015 59L18.299 51.652Z" fill="#a28200"/>
|
||||
<path d="M41.921 40.121L31.015 46.411L31.015 59L41.921 52.711Z" fill="#826700"/>
|
||||
<path d="M29.194 32.773L41.921 40.121L31.015 46.411L18.299 39.062Z" fill="#e5d99b"/>
|
||||
<path d="M29.194 32.773L41.921 40.121L41.921 52.711L31.015 59L18.299 51.652L18.299 39.062Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.299 39.062L31.015 46.411L41.921 40.121" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M31.015 46.411L31.015 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Disable component colors</title>
|
||||
<path d="M9.235 17.706L15.059 20.882L15.059 48.941L9.235 44.926Z" fill="#328bd3"/>
|
||||
<path d="M35.176 9.235L15.059 20.882L15.059 48.941L36.765 35.139Z" fill="#236ead"/>
|
||||
<path d="M29.046 5L35.176 8.176L15.196 19.718L9.066 16.541Z" fill="#b9ddf8"/>
|
||||
<path d="M29.046 5.529L35.176 8.706L35.176 36.235L15.059 49.471L9.235 45.765L9.066 17.071Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.066 17.6L15.059 20.882L34.647 9.235" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.059 20.882L15.059 47.067" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M32.222 18.595L44.949 25.944L44.949 51.122L32.222 43.774Z" fill="#328bd3"/>
|
||||
<path d="M54.934 20.173L44.949 25.944L44.949 51.122L54.934 45.352Z" fill="#236ead"/>
|
||||
<path d="M42.059 12.412L54.934 20.173L44.949 25.944L32 17.706Z" fill="#b9ddf8"/>
|
||||
<path d="M41.159 11.766L54.934 20.173L54.934 45.352L44.949 51.122L31.164 42.715L31.164 17.536Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M32.222 18.595L44.949 25.944L54.934 20.173" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M44.949 25.944L44.949 51.122" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.299 39.062L31.015 46.411L31.015 59L18.299 51.652Z" fill="#328bd3"/>
|
||||
<path d="M41.921 40.121L31.015 46.411L31.015 59L41.921 52.711Z" fill="#236ead"/>
|
||||
<path d="M29.194 32.773L41.921 40.121L31.015 46.411L18.299 39.062Z" fill="#b9ddf8"/>
|
||||
<path d="M29.194 32.773L41.921 40.121L41.921 52.711L31.015 59L18.299 51.652L18.299 39.062Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.299 39.062L31.015 46.411L41.921 40.121" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M31.015 46.411L31.015 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -2031,6 +2031,7 @@
|
||||
"ui.one_side": "Einseitig",
|
||||
"ui.open": "Öffnen",
|
||||
"ui.open_ends_tip": "Berührt nichts",
|
||||
"ui.open_linked_design": "Verknüpftes Design öffnen",
|
||||
"ui.open_preferences": "Einstellungen öffnen",
|
||||
"ui.open_recent": "Zuletzt verwendet",
|
||||
"ui.operation": "Operation",
|
||||
@@ -2233,6 +2234,7 @@
|
||||
"ui.up_axis_note": "Welche Achse in den Ansichten nach oben zeigt (Achsenansicht, Home, Ansehen). Designs bleiben unverändert.",
|
||||
"ui.updateToCurrentView": "Auf aktuelle Ansicht aktualisieren",
|
||||
"ui.view_centre": "Ansichtszentrum",
|
||||
"ui.viewport_theme_follow": "Dem Design folgen",
|
||||
"ui.visual_shaded_hidden_edges": "Schattiert mit verdeckten Kanten",
|
||||
"ui.visual_shaded_with_edges": "Schattiert mit Kanten",
|
||||
"ui.visual_style": "Ansichtsstil",
|
||||
|
||||
@@ -692,6 +692,7 @@
|
||||
"cmd.component_capture_position": "Capture Position",
|
||||
"cmd.component_copies": "Find Identical Copies",
|
||||
"cmd.component_create": "New Component",
|
||||
"cmd.component_cycle_color": "Cycle Component Color",
|
||||
"cmd.component_delete": "Delete Component",
|
||||
"cmd.component_extract": "Extract to File",
|
||||
"cmd.component_from_bodies": "Create Components from Bodies",
|
||||
@@ -776,6 +777,8 @@
|
||||
"cmd.inspect_accessibility": "Accessibility Analysis",
|
||||
"cmd.inspect_center_of_mass": "Center of Mass",
|
||||
"cmd.inspect_clearance": "Clearance",
|
||||
"cmd.inspect_colored_components": "Enable Component Colors",
|
||||
"cmd.inspect_colored_components_disable": "Disable Component Colors",
|
||||
"cmd.inspect_compare": "Compare Bodies",
|
||||
"cmd.inspect_continuity": "Continuity Analysis",
|
||||
"cmd.inspect_curvature_comb": "Curvature Comb Analysis",
|
||||
@@ -1133,6 +1136,7 @@
|
||||
"dxf.progress.write": "Writing DXF (5/5)",
|
||||
"dxf.tolerance": "Curve tolerance",
|
||||
"dxf.tolerance_help": "Visible bodies and shown sketches only. Curves use straight segments. Smaller tolerance gives finer curves and uses more time and memory.",
|
||||
"file.open.drawing_filter": "Designs, CAD files, meshes and drawings",
|
||||
"file_load.built": "Built so far: {built} of {total} features.",
|
||||
"file_load.converting": "Converting solid {done} of {total}",
|
||||
"file_load.inserting": "Inserting geometry",
|
||||
@@ -1371,6 +1375,7 @@
|
||||
"pick the path": "pick the path",
|
||||
"prefs.add_item": "Add the item",
|
||||
"prefs.alt_frees_manipulators": "Alt / Option also frees manipulator arrows",
|
||||
"prefs.anti_aliasing_amd_dx12": "Anti-aliasing uses 1× because the startup check drew nothing with multisampling and passed at 1×. You can choose another setting above; restart to apply it if the check passes.",
|
||||
"prefs.anti_aliasing_note": "Smooths jagged edges.",
|
||||
"prefs.applies_on_restart": "Applies on restart",
|
||||
"prefs.assistant": "Assistant",
|
||||
@@ -2386,6 +2391,7 @@
|
||||
"ui.agent_chat_mode_all": "All",
|
||||
"ui.agent_chat_mode_ask": "Ask",
|
||||
"ui.agent_chat_mode_edits": "Edits",
|
||||
"ui.agent_chat_model_no_tools": "{model} can't use SolidCraft's tools, so it can only chat. Pick a model with tool support.",
|
||||
"ui.agent_chat_model_tip": "The model the agent uses. Your choice is remembered for this agent.",
|
||||
"ui.agent_chat_new": "New chat",
|
||||
"ui.agent_chat_new_confirm": "Start a new chat? The current one is cleared.",
|
||||
@@ -2394,6 +2400,7 @@
|
||||
"ui.agent_chat_no_control": "The agent chat's endpoint did not start, so the agent can't reach the design.",
|
||||
"ui.agent_chat_no_images": "This agent doesn't take pictures",
|
||||
"ui.agent_chat_no_picture": "There is no picture on the clipboard",
|
||||
"ui.agent_chat_no_tool_call": "The model didn't use SolidCraft's tools, so the design wasn't changed. Choose a model that supports tool calling; see Local models in the agent chat guide.",
|
||||
"ui.agent_chat_none_found": "No coding agent found. Install one of these, then press Refresh:",
|
||||
"ui.agent_chat_none_found_short": "No agent found",
|
||||
"ui.agent_chat_not_installed": "Not installed",
|
||||
@@ -2894,6 +2901,7 @@
|
||||
"ui.one_side": "One Side",
|
||||
"ui.open": "Open",
|
||||
"ui.open_ends_tip": "Touches nothing",
|
||||
"ui.open_linked_design": "Open Linked Design",
|
||||
"ui.open_preferences": "Open Preferences",
|
||||
"ui.open_recent": "Open Recent",
|
||||
"ui.operation": "Operation",
|
||||
@@ -3094,6 +3102,7 @@
|
||||
"ui.total_angle": "Total angle",
|
||||
"ui.trackpad_pan_tip": "Two-finger scroll pans; in Fusion, Shift + two fingers orbits. Off: scroll zooms. Mouse wheel and pinch zoom; thumb wheel pans sideways.",
|
||||
"ui.trim": "Trim",
|
||||
"ui.turn_up_axis": "Turn Up Axis",
|
||||
"ui.twist": "Twist",
|
||||
"ui.two_finger_scroll_pans": "Two-finger scroll pans",
|
||||
"ui.two_sides": "Two Sides",
|
||||
@@ -3111,6 +3120,7 @@
|
||||
"ui.updateToCurrentView": "Update to Current View",
|
||||
"ui.vertical_constraint": "Vertical Constraint",
|
||||
"ui.view_centre": "View centre",
|
||||
"ui.viewport_theme_follow": "Follow theme",
|
||||
"ui.visual_shaded_hidden_edges": "Shaded with hidden edges",
|
||||
"ui.visual_shaded_with_edges": "Shaded with edges",
|
||||
"ui.visual_style": "Visual style",
|
||||
@@ -3175,6 +3185,8 @@
|
||||
"update.up_to_date": "SolidCraft is up to date.",
|
||||
"update.update_now": "Update now",
|
||||
"update.you_have": "You have {version}.",
|
||||
"web.files.insert_drawings_hint": "CAD files, meshes, DXF or SVG into the open design",
|
||||
"web.files.insert_stored_drawings_hint": "Into the open design (CAD files, meshes, DXF, SVG)",
|
||||
"web.open_full": "Open in SolidCraft",
|
||||
"web.open_link": "Open linked design?",
|
||||
"web.open_link_confirm": "There are unsaved changes. Open the linked design in a new tab?",
|
||||
|
||||
@@ -692,6 +692,7 @@
|
||||
"cmd.component_capture_position": "位置をキャプチャ",
|
||||
"cmd.component_copies": "同一のコピーを検索",
|
||||
"cmd.component_create": "新規コンポーネント",
|
||||
"cmd.component_cycle_color": "コンポーネントの色を順に変更",
|
||||
"cmd.component_delete": "コンポーネントを削除",
|
||||
"cmd.component_extract": "ファイルに抽出",
|
||||
"cmd.component_from_bodies": "ボディからコンポーネントを作成",
|
||||
@@ -776,6 +777,8 @@
|
||||
"cmd.inspect_accessibility": "アクセシビリティ解析",
|
||||
"cmd.inspect_center_of_mass": "重心",
|
||||
"cmd.inspect_clearance": "クリアランス",
|
||||
"cmd.inspect_colored_components": "コンポーネントの色分けを有効化",
|
||||
"cmd.inspect_colored_components_disable": "コンポーネントの色分けを無効化",
|
||||
"cmd.inspect_compare": "ボディを比較",
|
||||
"cmd.inspect_continuity": "連続性解析",
|
||||
"cmd.inspect_curvature_comb": "曲率コーム解析",
|
||||
@@ -1133,6 +1136,7 @@
|
||||
"dxf.progress.write": "DXFを書き込み中 (5/5)",
|
||||
"dxf.tolerance": "曲線の許容誤差",
|
||||
"dxf.tolerance_help": "表示中のボディとスケッチを出力し、曲線は線分で近似します。許容誤差を小さくすると曲線が滑らかになりますが、処理時間とメモリ使用量が増えます。",
|
||||
"file.open.drawing_filter": "デザイン、CADファイル、メッシュ、図面",
|
||||
"file_load.built": "構築済み: 全{total}フィーチャー中{built}件。",
|
||||
"file_load.converting": "ソリッドを変換中: {done}/{total}",
|
||||
"file_load.inserting": "ジオメトリを挿入中",
|
||||
@@ -1371,6 +1375,7 @@
|
||||
"pick the path": "パスを選択",
|
||||
"prefs.add_item": "項目を追加",
|
||||
"prefs.alt_frees_manipulators": "Alt / Option でマニピュレーターの矢印も自由に動かす",
|
||||
"prefs.anti_aliasing_amd_dx12": "起動時のチェックでマルチサンプリングでは何も描画されず、1×では正常に描画されたため、アンチエイリアシングを1×に設定しました。上で別の設定を選択できます。再起動後のチェックに合格すると、その設定が適用されます。",
|
||||
"prefs.anti_aliasing_note": "輪郭のぎざぎざを滑らかにします。",
|
||||
"prefs.applies_on_restart": "再起動後に適用",
|
||||
"prefs.assistant": "アシスタント",
|
||||
@@ -2386,6 +2391,7 @@
|
||||
"ui.agent_chat_mode_all": "すべて",
|
||||
"ui.agent_chat_mode_ask": "確認",
|
||||
"ui.agent_chat_mode_edits": "編集",
|
||||
"ui.agent_chat_model_no_tools": "{model} は SolidCraft のツールを使えず、チャットのみ可能です。ツール対応のモデルを選んでください。",
|
||||
"ui.agent_chat_model_tip": "エージェントが使うモデル。選択はこのエージェントに対して記憶されます。",
|
||||
"ui.agent_chat_new": "新しいチャット",
|
||||
"ui.agent_chat_new_confirm": "新しいチャットを始めますか? 現在のチャットは消去されます。",
|
||||
@@ -2394,6 +2400,7 @@
|
||||
"ui.agent_chat_no_control": "エージェントチャットのエンドポイントが起動しなかったため、エージェントはデザインにアクセスできません。",
|
||||
"ui.agent_chat_no_images": "このエージェントは画像を受け付けません",
|
||||
"ui.agent_chat_no_picture": "クリップボードに画像がありません",
|
||||
"ui.agent_chat_no_tool_call": "モデルが SolidCraft のツールを使わなかったため、デザインは変更されませんでした。ツール呼び出しに対応したモデルを選んでください。エージェントチャットのガイドの「ローカルモデル」を参照してください。",
|
||||
"ui.agent_chat_none_found": "コーディングエージェントが見つかりません。次のいずれかをインストールしてから「再検索」を押してください:",
|
||||
"ui.agent_chat_none_found_short": "エージェントが見つかりません",
|
||||
"ui.agent_chat_not_installed": "未インストール",
|
||||
@@ -2894,6 +2901,7 @@
|
||||
"ui.one_side": "片側",
|
||||
"ui.open": "開く",
|
||||
"ui.open_ends_tip": "どこにも接していない",
|
||||
"ui.open_linked_design": "リンクされたデザインを開く",
|
||||
"ui.open_preferences": "環境設定を開く",
|
||||
"ui.open_recent": "最近使った項目を開く",
|
||||
"ui.operation": "操作",
|
||||
@@ -3094,6 +3102,7 @@
|
||||
"ui.total_angle": "合計角度",
|
||||
"ui.trackpad_pan_tip": "2本指スクロールでパンします。FusionではShift + 2本指スクロールで回転します。オフの場合はスクロールでズームします。マウスホイールとピンチ操作はズーム、サイドホイールは横方向のパンになります。",
|
||||
"ui.trim": "トリム",
|
||||
"ui.turn_up_axis": "上方向の軸を回転",
|
||||
"ui.twist": "ねじれ",
|
||||
"ui.two_finger_scroll_pans": "2本指スクロールでパン",
|
||||
"ui.two_sides": "両側",
|
||||
@@ -3111,6 +3120,7 @@
|
||||
"ui.updateToCurrentView": "現在のビューに更新",
|
||||
"ui.vertical_constraint": "垂直拘束",
|
||||
"ui.view_centre": "ビュー中心",
|
||||
"ui.viewport_theme_follow": "テーマに従う",
|
||||
"ui.visual_shaded_hidden_edges": "隠線付きシェーディング",
|
||||
"ui.visual_shaded_with_edges": "エッジ付きシェーディング",
|
||||
"ui.visual_style": "表示スタイル",
|
||||
@@ -3175,6 +3185,8 @@
|
||||
"update.up_to_date": "SolidCraft は最新です。",
|
||||
"update.update_now": "今すぐ更新",
|
||||
"update.you_have": "現在のバージョンは {version} です。",
|
||||
"web.files.insert_drawings_hint": "CADファイル、メッシュ、DXF、SVGを開いているデザインに挿入",
|
||||
"web.files.insert_stored_drawings_hint": "開いているデザインに挿入(CADファイル、メッシュ、DXF、SVG)",
|
||||
"web.open_full": "SolidCraft で開く",
|
||||
"web.open_link": "リンク先のデザインを開きますか?",
|
||||
"web.open_link_confirm": "保存していない変更があります。リンク先のデザインを新しいタブで開きますか?",
|
||||
|
||||
@@ -2200,6 +2200,7 @@
|
||||
"ui.one_side": "한 방향",
|
||||
"ui.open": "열기",
|
||||
"ui.open_ends_tip": "아무것과도 닿지 않음",
|
||||
"ui.open_linked_design": "연결된 디자인 열기",
|
||||
"ui.open_preferences": "환경 설정 열기",
|
||||
"ui.open_recent": "최근 항목 열기",
|
||||
"ui.operation": "작업",
|
||||
@@ -2401,6 +2402,7 @@
|
||||
"ui.up_axis_note": "뷰(축 뷰, 홈, 보기)에서 위쪽을 가리키는 축입니다. 디자인은 변경되지 않습니다.",
|
||||
"ui.updateToCurrentView": "현재 보기로 업데이트",
|
||||
"ui.view_centre": "뷰 중심",
|
||||
"ui.viewport_theme_follow": "테마 따르기",
|
||||
"ui.visual_shaded_hidden_edges": "음영 처리 및 숨겨진 모서리",
|
||||
"ui.visual_shaded_with_edges": "음영 처리 및 모서리",
|
||||
"ui.visual_style": "시각 스타일",
|
||||
|
||||
@@ -692,6 +692,7 @@
|
||||
"cmd.component_capture_position": "捕获位置",
|
||||
"cmd.component_copies": "查找相同副本",
|
||||
"cmd.component_create": "新建组件",
|
||||
"cmd.component_cycle_color": "循环切换组件颜色",
|
||||
"cmd.component_delete": "删除组件",
|
||||
"cmd.component_extract": "提取到文件",
|
||||
"cmd.component_from_bodies": "由实体创建组件",
|
||||
@@ -776,6 +777,8 @@
|
||||
"cmd.inspect_accessibility": "可达性分析",
|
||||
"cmd.inspect_center_of_mass": "质心",
|
||||
"cmd.inspect_clearance": "间隙",
|
||||
"cmd.inspect_colored_components": "启用组件颜色",
|
||||
"cmd.inspect_colored_components_disable": "禁用组件颜色",
|
||||
"cmd.inspect_compare": "比较实体",
|
||||
"cmd.inspect_continuity": "连续性分析",
|
||||
"cmd.inspect_curvature_comb": "曲率梳分析",
|
||||
@@ -1133,6 +1136,7 @@
|
||||
"dxf.progress.write": "正在写入 DXF (5/5)",
|
||||
"dxf.tolerance": "曲线公差",
|
||||
"dxf.tolerance_help": "仅导出可见实体和显示的草图。曲线使用直线段近似。公差越小,曲线越精细,但需要更多时间和内存。",
|
||||
"file.open.drawing_filter": "设计、CAD 文件、网格和图纸",
|
||||
"file_load.built": "已构建:{built}/{total} 个特征。",
|
||||
"file_load.converting": "正在转换实体 {done}/{total}",
|
||||
"file_load.inserting": "正在插入几何体",
|
||||
@@ -1371,6 +1375,7 @@
|
||||
"pick the path": "选择路径",
|
||||
"prefs.add_item": "添加项目",
|
||||
"prefs.alt_frees_manipulators": "Alt / Option 也可自由拖动操纵器箭头",
|
||||
"prefs.anti_aliasing_amd_dx12": "由于启动检查在启用多重采样时未绘制任何内容,而在1×时通过了检查,抗锯齿已设为1×。您可以在上方选择其他设置;重启后若检查通过,该设置将生效。",
|
||||
"prefs.anti_aliasing_note": "平滑锯齿边缘。",
|
||||
"prefs.applies_on_restart": "重启后生效",
|
||||
"prefs.assistant": "助手",
|
||||
@@ -2386,6 +2391,7 @@
|
||||
"ui.agent_chat_mode_all": "全部",
|
||||
"ui.agent_chat_mode_ask": "询问",
|
||||
"ui.agent_chat_mode_edits": "编辑",
|
||||
"ui.agent_chat_model_no_tools": "{model} 无法使用 SolidCraft 的工具,只能聊天。请选择支持工具的模型。",
|
||||
"ui.agent_chat_model_tip": "智能体使用的模型。你的选择会为此智能体记住。",
|
||||
"ui.agent_chat_new": "新对话",
|
||||
"ui.agent_chat_new_confirm": "开始新对话?当前对话将被清除。",
|
||||
@@ -2394,6 +2400,7 @@
|
||||
"ui.agent_chat_no_control": "智能体聊天的端点未能启动,因此智能体无法访问设计。",
|
||||
"ui.agent_chat_no_images": "该智能体不接收图片",
|
||||
"ui.agent_chat_no_picture": "剪贴板上没有图片",
|
||||
"ui.agent_chat_no_tool_call": "模型没有使用 SolidCraft 的工具,因此设计没有更改。请选择支持工具调用的模型;参见智能体聊天指南中的“本地模型”。",
|
||||
"ui.agent_chat_none_found": "未找到编程智能体。请安装以下之一,然后点击刷新:",
|
||||
"ui.agent_chat_none_found_short": "未找到智能体",
|
||||
"ui.agent_chat_not_installed": "未安装",
|
||||
@@ -2894,6 +2901,7 @@
|
||||
"ui.one_side": "单侧",
|
||||
"ui.open": "打开",
|
||||
"ui.open_ends_tip": "未接触任何曲线",
|
||||
"ui.open_linked_design": "打开链接设计",
|
||||
"ui.open_preferences": "打开首选项",
|
||||
"ui.open_recent": "打开最近",
|
||||
"ui.operation": "操作",
|
||||
@@ -3094,6 +3102,7 @@
|
||||
"ui.total_angle": "总角度",
|
||||
"ui.trackpad_pan_tip": "双指滚动平移;Fusion 预设下,Shift + 双指滚动旋转视图。关闭时滚动缩放。鼠标滚轮和双指捏合缩放,拇指滚轮横向平移。",
|
||||
"ui.trim": "修剪",
|
||||
"ui.turn_up_axis": "旋转向上轴",
|
||||
"ui.twist": "扭转",
|
||||
"ui.two_finger_scroll_pans": "双指滚动平移",
|
||||
"ui.two_sides": "双侧",
|
||||
@@ -3111,6 +3120,7 @@
|
||||
"ui.updateToCurrentView": "更新为当前视图",
|
||||
"ui.vertical_constraint": "垂直约束",
|
||||
"ui.view_centre": "视图中心",
|
||||
"ui.viewport_theme_follow": "跟随主题",
|
||||
"ui.visual_shaded_hidden_edges": "带隐藏边的着色",
|
||||
"ui.visual_shaded_with_edges": "带边着色",
|
||||
"ui.visual_style": "视觉样式",
|
||||
@@ -3175,6 +3185,8 @@
|
||||
"update.up_to_date": "SolidCraft 已是最新版本。",
|
||||
"update.update_now": "立即更新",
|
||||
"update.you_have": "当前版本为 {version}。",
|
||||
"web.files.insert_drawings_hint": "将 CAD 文件、网格、DXF 或 SVG 插入当前设计",
|
||||
"web.files.insert_stored_drawings_hint": "插入当前设计(CAD 文件、网格、DXF、SVG)",
|
||||
"web.open_full": "在 SolidCraft 中打开",
|
||||
"web.open_link": "打开链接的设计?",
|
||||
"web.open_link_confirm": "有未保存的更改。要在新标签页中打开链接的设计吗?",
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Identification colours, independent of materials and appearances.
|
||||
|
||||
use crate::Document;
|
||||
|
||||
/// Automatic colours select this palette in creation order; cycling uses the same order.
|
||||
/// Colors are generated by picking colors on a color wheel, with 0.9 brightness and 0.8 saturation.
|
||||
/// First pass is 3 colors, 120 huedegrees from each other. Second pass starts with 60 degrees from
|
||||
/// the first one. Then third and fourth pass fill 60 and 30 degrees gaps between already generated
|
||||
/// colors of the palette.
|
||||
pub const PALETTE: [[u8; 3]; 24] = [
|
||||
[230, 161, 92],
|
||||
[92, 230, 161],
|
||||
[161, 92, 230],
|
||||
[161, 230, 92],
|
||||
[92, 161, 230],
|
||||
[230, 92, 161],
|
||||
[230, 230, 92],
|
||||
[92, 230, 92],
|
||||
[92, 230, 230],
|
||||
[92, 92, 230],
|
||||
[230, 92, 230],
|
||||
[230, 92, 92],
|
||||
[230, 195, 92],
|
||||
[195, 230, 92],
|
||||
[126, 230, 92],
|
||||
[92, 230, 126],
|
||||
[92, 230, 195],
|
||||
[92, 195, 230],
|
||||
[92, 126, 230],
|
||||
[126, 92, 230],
|
||||
[195, 92, 230],
|
||||
[230, 92, 195],
|
||||
[230, 92, 126],
|
||||
[230, 126, 92],
|
||||
];
|
||||
|
||||
/// Select a colour in creation order, wrapping at the end of the palette.
|
||||
pub fn automatic_color(index: u64) -> [u8; 3] {
|
||||
PALETTE.get((index % PALETTE.len() as u64) as usize).copied().unwrap_or([230, 161, 92])
|
||||
}
|
||||
|
||||
pub fn next_color(color: [u8; 3]) -> [u8; 3] {
|
||||
let index = PALETTE.iter().position(|c| *c == color).map_or(0, |i| (i + 1) % PALETTE.len());
|
||||
PALETTE.get(index).copied().unwrap_or([230, 161, 92])
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn component_identification_color(&self, id: u64) -> [u8; 3] {
|
||||
self.components.iter().enumerate().find(|(_, c)| c.id == id).map_or_else(
|
||||
|| automatic_color(0),
|
||||
|(position, c)| c.identification_color.unwrap_or_else(|| automatic_color(c.identification_color_index.unwrap_or(position as u64))),
|
||||
)
|
||||
}
|
||||
|
||||
/// Fill older records in stored order and repair stale counters without changing saved indices.
|
||||
pub(crate) fn normalise_component_color_indices(&mut self) -> crate::Result<()> {
|
||||
let mut next = self.next_component_color_index;
|
||||
for component in &self.components {
|
||||
if let Some(index) = component.identification_color_index {
|
||||
next = next.max(index.checked_add(1).ok_or_else(|| crate::DocError::Invalid("component colour sequence exhausted".into()))?);
|
||||
}
|
||||
}
|
||||
for component in &mut self.components {
|
||||
if component.identification_color_index.is_none() {
|
||||
let following = next.checked_add(1).ok_or_else(|| crate::DocError::Invalid("component colour sequence exhausted".into()))?;
|
||||
component.identification_color_index = Some(next);
|
||||
next = following;
|
||||
}
|
||||
}
|
||||
self.next_component_color_index = next;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn old_components_default_to_stable_colors_and_overrides_round_trip() {
|
||||
let mut doc = Document::default();
|
||||
let id = doc.add_component(Some("Part"), 0).unwrap();
|
||||
let automatic = doc.component_identification_color(id);
|
||||
let mut legacy: serde_json::Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
legacy["components"][0].as_object_mut().unwrap().remove("identification_color");
|
||||
if let Some(pending) = legacy.get_mut("format_pending").and_then(|v| v.as_array_mut()) {
|
||||
pending.retain(|name| name != "component-identification-colors");
|
||||
}
|
||||
let restored = Document::from_json(&legacy.to_string()).unwrap();
|
||||
assert_eq!(restored.component_identification_color(id), automatic);
|
||||
assert_eq!(restored.components[0].identification_color, None);
|
||||
let override_color = next_color(automatic);
|
||||
assert_ne!(override_color, automatic);
|
||||
doc.components[0].identification_color = Some(override_color);
|
||||
doc.components[0].name = "Renamed".into();
|
||||
let saved = Document::from_json(&doc.to_json()).unwrap();
|
||||
assert_eq!(saved.component_identification_color(id), override_color);
|
||||
assert_eq!(saved.components[0].identification_color, Some(override_color));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_order_wraps_and_survives_deletion_reordering_and_reopening() {
|
||||
let mut doc = Document::default();
|
||||
let mut ids = Vec::new();
|
||||
for index in 0..25 {
|
||||
// IDs also belong to occurrences, so their numeric values are not colour indices.
|
||||
let id = doc.add_component(None, 0).unwrap();
|
||||
assert_eq!(doc.component_identification_color(id), PALETTE[index % PALETTE.len()]);
|
||||
ids.push(id);
|
||||
}
|
||||
let retained = ids[1];
|
||||
doc.components.retain(|c| c.id != ids[0]);
|
||||
doc.occurrences.retain(|o| o.component != ids[0]);
|
||||
doc.components.reverse();
|
||||
let mut restored = Document::from_json(&doc.to_json()).unwrap();
|
||||
assert_eq!(restored.component_identification_color(retained), PALETTE[1]);
|
||||
let next = restored.add_component(None, 0).unwrap();
|
||||
assert_eq!(restored.component_identification_color(next), PALETTE[1]);
|
||||
assert_eq!(restored.next_component_color_index, 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_order_and_overrides_are_preserved_and_stale_counters_repaired() {
|
||||
let mut doc = Document::default();
|
||||
let first = doc.add_component(None, 0).unwrap();
|
||||
let second = doc.add_component(None, first).unwrap();
|
||||
doc.components[1].identification_color = Some([1, 2, 3]);
|
||||
let mut json: serde_json::Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
json.as_object_mut().unwrap().remove("next_component_color_index");
|
||||
for component in json["components"].as_array_mut().unwrap() {
|
||||
component.as_object_mut().unwrap().remove("identification_color_index");
|
||||
}
|
||||
if let Some(pending) = json.get_mut("format_pending").and_then(|v| v.as_array_mut()) {
|
||||
pending.retain(|name| name != "sequential-component-colors");
|
||||
}
|
||||
let mut restored = Document::from_json(&json.to_string()).unwrap();
|
||||
assert_eq!(restored.components[0].identification_color_index, Some(0));
|
||||
assert_eq!(restored.components[1].identification_color_index, Some(1));
|
||||
assert_eq!(restored.component_identification_color(first), PALETTE[0]);
|
||||
assert_eq!(restored.component_identification_color(second), [1, 2, 3]);
|
||||
let third = restored.add_component(None, 0).unwrap();
|
||||
assert_eq!(restored.component_identification_color(third), PALETTE[2]);
|
||||
restored.next_component_color_index = 0;
|
||||
let mut repaired = Document::from_json(&restored.to_json()).unwrap();
|
||||
let fourth = repaired.add_component(None, 0).unwrap();
|
||||
assert_eq!(repaired.component_identification_color(fourth), PALETTE[3]);
|
||||
let mut hostile: serde_json::Value = serde_json::from_str(&repaired.to_json()).unwrap();
|
||||
hostile["components"][0]["identification_color_index"] = u64::MAX.into();
|
||||
assert!(Document::from_json(&hostile.to_string()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycling_visits_every_palette_color_and_wraps() {
|
||||
let original = automatic_color(123);
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut color = original;
|
||||
for _ in 0..PALETTE.len() {
|
||||
assert!(seen.insert(color));
|
||||
color = next_color(color);
|
||||
}
|
||||
assert_eq!(color, original);
|
||||
assert_eq!(automatic_color(0), PALETTE[0]);
|
||||
assert_eq!(automatic_color(1), PALETTE[1]);
|
||||
assert_eq!(automatic_color(24), PALETTE[0]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@
|
||||
//! These functions format values, not validate them: non-finite inputs retain Rust's `NaN`,
|
||||
//! `inf` and `-inf` text. Callers that require finite measurements must validate them first.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{DocError, Result, expr};
|
||||
|
||||
/// Units per millimetre and the unit's name. Unknown names use millimetres.
|
||||
pub fn unit_scale(units: &str) -> (f64, &'static str) {
|
||||
match units {
|
||||
@@ -39,6 +43,142 @@ pub fn format_length(mm: f64, units: &str, decimals: u8) -> String {
|
||||
format!("{} {u}", length_number(mm, units, decimals))
|
||||
}
|
||||
|
||||
/// The decimal separator of a number, independent of UI language.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DecimalMarker {
|
||||
Point,
|
||||
Comma,
|
||||
}
|
||||
|
||||
/// How an exact tie in the prepared decimal digits is rounded.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Ties {
|
||||
HalfEven,
|
||||
HalfAwayFromZero,
|
||||
}
|
||||
|
||||
/// Number presentation for non-UI consumers. This does not change the legacy length helpers.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct NumberFormat {
|
||||
pub marker: DecimalMarker,
|
||||
pub groups: bool,
|
||||
pub leading_zero: bool,
|
||||
pub trailing_zeros: bool,
|
||||
pub ties: Ties,
|
||||
}
|
||||
|
||||
impl Default for NumberFormat {
|
||||
fn default() -> Self {
|
||||
Self { marker: DecimalMarker::Point, groups: false, leading_zero: true, trailing_zeros: true, ties: Ties::HalfEven }
|
||||
}
|
||||
}
|
||||
|
||||
/// Formatted text without a unit suffix, and the rounded number in the display unit.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Shown {
|
||||
pub text: String,
|
||||
pub value: f64,
|
||||
}
|
||||
|
||||
/// Format an internal value (millimetres or radians) in a strictly validated display unit.
|
||||
pub fn format_value(value: f64, kind: expr::Kind, unit: &str, decimals: u8, f: &NumberFormat) -> Result<Shown> {
|
||||
let (unit_kind, scale) = expr::unit_info(unit).ok_or_else(|| DocError::Invalid("unknown number display unit".into()))?;
|
||||
if unit_kind != kind {
|
||||
return Err(DocError::Invalid("number display unit has a different kind".into()));
|
||||
}
|
||||
format_display(value / scale, scale, decimals, f)
|
||||
}
|
||||
|
||||
/// Format a unit-less number with the same validation and decimal rounding policy.
|
||||
pub fn format_number(x: f64, decimals: u8, f: &NumberFormat) -> Result<Shown> {
|
||||
format_display(x, 1.0, decimals, f)
|
||||
}
|
||||
|
||||
fn format_display(x: f64, scale: f64, decimals: u8, f: &NumberFormat) -> Result<Shown> {
|
||||
// The unit table supplies a positive finite scale. Its decimal step must be at most
|
||||
// 1e-9 internal units; the precision floor can make the effective tie window narrower.
|
||||
let unit_resolution = (scale.log10() + 9.0).ceil().max(0.0) as usize;
|
||||
let resolution = unit_resolution.max(usize::from(decimals) + 3);
|
||||
let (rounded, value) = round_decimal(x, decimals, resolution, f.ties)?;
|
||||
let (integer, fraction) = rounded.split_once('.').unwrap_or((&rounded, ""));
|
||||
let fraction = if f.trailing_zeros { fraction } else { fraction.trim_end_matches('0') };
|
||||
let integer = if !f.leading_zero && integer == "0" && !fraction.is_empty() { "" } else { integer };
|
||||
let mut text = String::new();
|
||||
if value.is_sign_negative() {
|
||||
text.push('−');
|
||||
}
|
||||
grouped_digits(&mut text, integer, true, f.groups);
|
||||
if !fraction.is_empty() {
|
||||
text.push(match f.marker {
|
||||
DecimalMarker::Point => '.',
|
||||
DecimalMarker::Comma => ',',
|
||||
});
|
||||
grouped_digits(&mut text, fraction, false, f.groups);
|
||||
}
|
||||
Ok(Shown { text, value })
|
||||
}
|
||||
|
||||
/// Prepare a bounded magnitude, then round decimal digits, never a binary tie comparison.
|
||||
fn round_decimal(x: f64, decimals: u8, resolution: usize, ties: Ties) -> Result<(String, f64)> {
|
||||
if decimals > 8 {
|
||||
return Err(DocError::Invalid("number precision must be at most 8 decimal places".into()));
|
||||
}
|
||||
if !x.is_finite() || x.abs() > 1e12 {
|
||||
return Err(DocError::Invalid("display value must be finite with magnitude at most 1e12".into()));
|
||||
}
|
||||
let prepared = format!("{:.*}", resolution, x.abs());
|
||||
let (integer, fraction) = prepared.split_once('.').unwrap_or((&prepared, ""));
|
||||
let places = usize::from(decimals);
|
||||
let mut digits: Vec<u8> = integer.bytes().chain(fraction.bytes().take(places)).collect();
|
||||
let discarded = fraction.as_bytes().get(places..).unwrap_or_default();
|
||||
let up = match discarded.first().copied() {
|
||||
Some(b'6'..=b'9') => true,
|
||||
Some(b'5') => {
|
||||
discarded.get(1..).unwrap_or_default().iter().any(|d| *d != b'0')
|
||||
|| match ties {
|
||||
Ties::HalfEven => digits.last().is_some_and(|d| !(d - b'0').is_multiple_of(2)),
|
||||
Ties::HalfAwayFromZero => true,
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if up {
|
||||
let mut carry = true;
|
||||
for d in digits.iter_mut().rev() {
|
||||
if *d == b'9' {
|
||||
*d = b'0';
|
||||
} else {
|
||||
*d += 1;
|
||||
carry = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if carry {
|
||||
digits.insert(0, b'1');
|
||||
}
|
||||
}
|
||||
if places > 0 {
|
||||
let point = digits.len().checked_sub(places).ok_or_else(|| DocError::Invalid("invalid prepared number".into()))?;
|
||||
digits.insert(point, b'.');
|
||||
}
|
||||
let rounded = String::from_utf8(digits).map_err(|_| DocError::Invalid("invalid prepared digits".into()))?;
|
||||
let magnitude = rounded.parse::<f64>().map_err(|_| DocError::Invalid("invalid rounded number".into()))?;
|
||||
let negative = x.is_sign_negative() && magnitude != 0.0;
|
||||
Ok((rounded, if negative { -magnitude } else { magnitude }))
|
||||
}
|
||||
|
||||
fn grouped_digits(text: &mut String, digits: &str, integer: bool, groups: bool) {
|
||||
for (i, digit) in digits.chars().enumerate() {
|
||||
if groups && digits.len() >= 5 && i > 0 && if integer { (digits.len() - i).is_multiple_of(3) } else { i.is_multiple_of(3) } {
|
||||
text.push('\u{202f}');
|
||||
}
|
||||
text.push(digit);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{format_length, length_number, length_precision, unit_scale};
|
||||
@@ -121,3 +261,359 @@ mod tests {
|
||||
assert_eq!(format_length(f64::NEG_INFINITY, "cm", 2), "-inf cm");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod number_tests {
|
||||
use super::*;
|
||||
use expr::Kind::{Angle, Length, Unitless};
|
||||
|
||||
fn pair(x: f64, dp: u8, even: &str, away: &str) {
|
||||
for (ties, expected) in [(Ties::HalfEven, even), (Ties::HalfAwayFromZero, away)] {
|
||||
let shown = format_number(x, dp, &NumberFormat { ties, ..NumberFormat::default() }).unwrap();
|
||||
assert_eq!(shown.text, expected, "x={x:?}, dp={dp}, ties={ties:?}");
|
||||
let parsed: f64 = expected.replace('−', "-").parse().unwrap();
|
||||
assert_eq!(shown.value, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_exact_binary_ties() {
|
||||
for (x, dp, even, away) in [
|
||||
(0.125, 2, "0.12", "0.13"),
|
||||
(0.375, 2, "0.38", "0.38"),
|
||||
(12.5, 0, "12", "13"),
|
||||
(-12.5, 0, "−12", "−13"),
|
||||
(13.5, 0, "14", "14"),
|
||||
(-13.5, 0, "−14", "−14"),
|
||||
] {
|
||||
pair(x, dp, even, away);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_decimal_ties() {
|
||||
for (x, even, away) in [(2.675, "2.68", "2.68"), (2.665, "2.66", "2.67"), (1.005, "1.00", "1.01"), (0.285, "0.28", "0.29")] {
|
||||
pair(x, 2, even, away);
|
||||
}
|
||||
// The existing UI policy deliberately remains different.
|
||||
assert_eq!(length_number(2.665, "mm", 2), "2.67");
|
||||
assert_eq!(length_number(2.675, "mm", 2), "2.67");
|
||||
assert_eq!(length_number(1.005, "mm", 2), "1.00");
|
||||
assert_eq!(length_number(0.285, "mm", 2), "0.28");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_tie_zone_edges_and_precision_floor() {
|
||||
for (x, expected) in [(61.49999999999999, "62"), (61.4999999996, "62"), (61.4999999994, "61"), (61.4999997, "61"), (61.4996, "61")] {
|
||||
pair(x, 0, expected, expected);
|
||||
}
|
||||
pair(1.0000000049, 8, "1.00000000", "1.00000000");
|
||||
pair(2.4501, 1, "2.5", "2.5");
|
||||
pair(2.5499, 1, "2.5", "2.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_carries() {
|
||||
pair(9.995, 2, "10.00", "10.00");
|
||||
pair(-9.995, 2, "−10.00", "−10.00");
|
||||
pair(99.5, 0, "100", "100");
|
||||
pair(0.999999995, 8, "1.00000000", "1.00000000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_units_and_aliases() {
|
||||
let f = NumberFormat::default();
|
||||
for (x, kind, unit, dp, expected) in [
|
||||
(25.4, Length, "in", 3, "1.000"),
|
||||
(25.4 * (1.0 + 1e-12), Length, "in", 3, "1.000"),
|
||||
(25.4, Length, "in", 0, "1"),
|
||||
(12.5, Length, "cm", 2, "1.25"),
|
||||
(3810.0, Length, "ft", 1, "12.5"),
|
||||
(std::f64::consts::PI / 6.0, Angle, "deg", 1, "30.0"),
|
||||
(1.0, Angle, "deg", 3, "57.296"),
|
||||
(1.0, Angle, "rad", 3, "1.000"),
|
||||
(0.001, Length, "um", 3, "1.000"),
|
||||
(0.001, Length, "µm", 3, "1.000"),
|
||||
(1e-6, Length, "nm", 3, "1.000"),
|
||||
(0.0254, Length, "mil", 3, "1.000"),
|
||||
(914.4, Length, "yd", 3, "1.000"),
|
||||
(1e6, Length, "km", 3, "1.000"),
|
||||
(25.4, Length, " inch ", 3, "1.000"),
|
||||
(std::f64::consts::PI, Angle, "°", 1, "180.0"),
|
||||
(std::f64::consts::PI, Angle, "degrees", 1, "180.0"),
|
||||
(1.25, Unitless, "", 2, "1.25"),
|
||||
(1.25, Unitless, "none", 2, "1.25"),
|
||||
(1.25, Unitless, "unitless", 2, "1.25"),
|
||||
] {
|
||||
let shown = format_value(x, kind, unit, dp, &f).unwrap();
|
||||
assert_eq!(shown.text, expected, "{x:?} {kind:?} in {unit:?}");
|
||||
assert_eq!(shown.value, expected.parse::<f64>().unwrap());
|
||||
}
|
||||
for (kind, unit) in [(Length, "deg"), (Angle, "mm"), (Length, ""), (Length, "parsec"), (Unitless, "mm"), (Length, "IN")] {
|
||||
assert!(matches!(format_value(1.0, kind, unit, 2, &f), Err(DocError::Invalid(_))), "{kind:?} {unit:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_unit_resolution_edges() {
|
||||
let f = NumberFormat { ties: Ties::HalfAwayFromZero, ..NumberFormat::default() };
|
||||
for (kind, unit, resolution) in [
|
||||
(Length, "mm", 9),
|
||||
(Length, "cm", 10),
|
||||
(Length, "in", 11),
|
||||
(Length, "m", 12),
|
||||
(Length, "ft", 12),
|
||||
(Angle, "deg", 8),
|
||||
(Unitless, "", 9),
|
||||
(Length, "nm", 3),
|
||||
(Length, "km", 15),
|
||||
] {
|
||||
let scale = expr::unit_info(unit).unwrap().1;
|
||||
let quantum = 10.0_f64.powi(-resolution);
|
||||
for (distance, expected) in [(0.4, "1"), (0.6, "0")] {
|
||||
let internal = (0.5 - distance * quantum) * scale;
|
||||
assert_eq!(
|
||||
format_value(internal, kind, unit, 0, &f).unwrap().text,
|
||||
expected,
|
||||
"{unit:?}, resolution={resolution}, distance={distance}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_zero_sign_caps_and_shown_value() {
|
||||
for x in [-0.0, -0.004, f64::from_bits(1)] {
|
||||
let shown = format_number(x, 2, &NumberFormat::default()).unwrap();
|
||||
assert_eq!(shown.text, "0.00");
|
||||
assert_eq!(shown.value.to_bits(), 0.0_f64.to_bits());
|
||||
}
|
||||
pair(-1.5, 1, "−1.5", "−1.5");
|
||||
pair(12.5, 8, "12.50000000", "12.50000000");
|
||||
let f = NumberFormat::default();
|
||||
for dp in [9, 255] {
|
||||
assert!(matches!(format_number(12.5, dp, &f), Err(DocError::Invalid(_))));
|
||||
assert!(matches!(format_value(25.4, Length, "in", dp, &f), Err(DocError::Invalid(_))));
|
||||
}
|
||||
for x in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e13, -1e13, 1e12_f64.next_up(), -1e12_f64.next_up()] {
|
||||
assert!(matches!(format_number(x, 2, &f), Err(DocError::Invalid(_))), "{x:?}");
|
||||
assert!(matches!(format_value(x, Length, "mm", 2, &f), Err(DocError::Invalid(_))), "{x:?}");
|
||||
}
|
||||
for x in [-1e12, 1e12] {
|
||||
assert_eq!(format_number(x, 8, &f).unwrap().value, x);
|
||||
}
|
||||
assert_eq!(format_value(1e15, Length, "m", 8, &f).unwrap().value, 1e12, "cap is in display units");
|
||||
assert_eq!(format_number(2.665, 2, &f).unwrap().value, 2.66);
|
||||
assert_eq!(format_value(25.4, Length, "in", 3, &f).unwrap().value, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_format_options_and_group_boundaries() {
|
||||
let base = NumberFormat::default();
|
||||
let comma = NumberFormat { marker: DecimalMarker::Comma, ..base };
|
||||
assert_eq!(format_number(2.665, 2, &comma).unwrap().text, "2,66");
|
||||
let no_leading = NumberFormat { leading_zero: false, ..base };
|
||||
assert_eq!(format_number(0.5, 3, &no_leading).unwrap().text, ".500");
|
||||
let trim = NumberFormat { trailing_zeros: false, ..base };
|
||||
assert_eq!(format_number(25.0, 2, &trim).unwrap().text, "25");
|
||||
assert_eq!(format_number(25.5, 2, &trim).unwrap().text, "25.5");
|
||||
assert_eq!(format_number(0.5, 3, &NumberFormat { leading_zero: false, ..trim }).unwrap().text, ".5");
|
||||
let groups = NumberFormat { groups: true, ..base };
|
||||
for (x, dp, expected) in [
|
||||
(12345.6789, 4, "12\u{202f}345.6789"),
|
||||
(1.23456, 5, "1.234\u{202f}56"),
|
||||
(1234.5678, 4, "1234.5678"),
|
||||
(123456.0, 0, "123\u{202f}456"),
|
||||
(1234.0, 0, "1234"),
|
||||
(12345.0, 0, "12\u{202f}345"),
|
||||
(1.23456789, 8, "1.234\u{202f}567\u{202f}89"),
|
||||
(-12345.6789, 4, "−12\u{202f}345.6789"),
|
||||
(9999.9, 0, "10\u{202f}000"),
|
||||
] {
|
||||
assert_eq!(format_number(x, dp, &groups).unwrap().text, expected, "{x:?}, dp={dp}");
|
||||
}
|
||||
assert_eq!(format_number(12345.6789, 4, &NumberFormat { groups: true, ..comma }).unwrap().text, "12\u{202f}345,6789");
|
||||
assert_eq!(format_number(1.23450, 5, &NumberFormat { groups: true, ..trim }).unwrap().text, "1.2345", "group printed digits after trimming");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_provisional_leading_zero_for_rounded_zero() {
|
||||
// Provisional #5290 assumption: a fractional zero also omits the integer zero.
|
||||
let f = NumberFormat { leading_zero: false, ..NumberFormat::default() };
|
||||
for x in [0.0, -0.0, -0.004] {
|
||||
assert_eq!(format_number(x, 2, &f).unwrap().text, ".00");
|
||||
assert_eq!(format_number(x, 0, &f).unwrap().text, "0");
|
||||
assert_eq!(format_number(x, 2, &NumberFormat { trailing_zeros: false, ..f }).unwrap().text, "0");
|
||||
}
|
||||
assert_eq!(format_number(-0.5, 2, &f).unwrap().text, "−.50");
|
||||
assert_eq!(format_number(0.0, 2, &NumberFormat { marker: DecimalMarker::Comma, ..f }).unwrap().text, ",00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_provisional_serde_convention_and_strict_fields() {
|
||||
// Provisional #5290 assumption, matching expr::Kind and other doc enums.
|
||||
let f: NumberFormat = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(f, NumberFormat::default());
|
||||
assert_eq!(
|
||||
serde_json::to_value(f).unwrap(),
|
||||
serde_json::json!({
|
||||
"marker": "point", "groups": false, "leading_zero": true,
|
||||
"trailing_zeros": true, "ties": "half_even"
|
||||
})
|
||||
);
|
||||
let f: NumberFormat = serde_json::from_value(serde_json::json!({"marker":"comma", "ties":"half_away_from_zero"})).unwrap();
|
||||
assert_eq!(f.marker, DecimalMarker::Comma);
|
||||
assert_eq!(f.ties, Ties::HalfAwayFromZero);
|
||||
assert!(f.leading_zero && f.trailing_zeros && !f.groups);
|
||||
assert_eq!(serde_json::from_value::<NumberFormat>(serde_json::to_value(f).unwrap()).unwrap(), f);
|
||||
for bad in [serde_json::json!({"locale":"en"}), serde_json::json!({"marker":"Point"}), serde_json::json!({"ties":"unknown"})] {
|
||||
assert!(serde_json::from_value::<NumberFormat>(bad).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn random(state: &mut u64) -> u64 {
|
||||
*state ^= *state << 13;
|
||||
*state ^= *state >> 7;
|
||||
*state ^= *state << 17;
|
||||
*state
|
||||
}
|
||||
|
||||
/// Compare the printed decimal exactly with the input's binary fraction. A second f64
|
||||
/// subtraction can exceed the bound solely because parsing the correct text rounds again.
|
||||
fn decimal_within_bound(x: f64, text: &str, dp: u8, resolution: usize) -> bool {
|
||||
let unsigned = text.trim_start_matches('-');
|
||||
let (whole, fraction) = unsigned.split_once('.').unwrap_or((unsigned, ""));
|
||||
let coefficient: u128 = format!("{whole}{fraction}").parse().unwrap();
|
||||
let coefficient = coefficient * 10_u128.pow(u32::from(dp) - fraction.len() as u32);
|
||||
let extra = 10_u128.pow((resolution - usize::from(dp)) as u32);
|
||||
// Multiply |text - input| <= (10^-dp + 10^-resolution)/2 by 2*10^resolution.
|
||||
let center = 2 * coefficient * extra;
|
||||
let radius = extra + 1;
|
||||
let bits = x.abs().to_bits();
|
||||
let exponent = ((bits >> 52) & 0x7ff) as u32;
|
||||
let mantissa = u128::from(bits & ((1_u64 << 52) - 1));
|
||||
let (mantissa, shift) = if exponent == 0 { (mantissa, 1074) } else { (mantissa | (1_u128 << 52), 1075 - exponent) };
|
||||
let input = 2 * mantissa * 10_u128.pow(resolution as u32);
|
||||
// Input is below 2^104 at the supported resolutions. A shifted bound above u128's
|
||||
// range is therefore certainly above input; saturation preserves the comparison.
|
||||
let shifted = |n: u128| {
|
||||
if n == 0 {
|
||||
0
|
||||
} else if shift >= 128 || n > (u128::MAX >> shift) {
|
||||
u128::MAX
|
||||
} else {
|
||||
n << shift
|
||||
}
|
||||
};
|
||||
shifted(center.saturating_sub(radius)) <= input && input <= shifted(center + radius)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_large_magnitude_decimal_error_bound() {
|
||||
let x = 34947628153.461784;
|
||||
let shown = format_number(x, 5, &NumberFormat::default()).unwrap();
|
||||
assert_eq!(shown.text, "34947628153.46178");
|
||||
assert!(decimal_within_bound(x, &shown.text, 5, 9));
|
||||
assert!(!decimal_within_bound(x, "34947628153.46177", 5, 9));
|
||||
// Another exact binary tie whose rounded decimal has an inexact f64 representation.
|
||||
let x = 100000000000.25;
|
||||
let shown = format_number(x, 1, &NumberFormat::default()).unwrap();
|
||||
assert_eq!(shown.text, "100000000000.2");
|
||||
assert!(decimal_within_bound(x, &shown.text, 1, 9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_seeded_100k_properties() {
|
||||
// Reference resolutions derived by hand from the unit table, not from the formatter.
|
||||
let units = [
|
||||
("mm", 9),
|
||||
("cm", 10),
|
||||
("m", 12),
|
||||
("in", 11),
|
||||
("ft", 12),
|
||||
("yd", 12),
|
||||
("km", 15),
|
||||
("um", 6),
|
||||
("µm", 6),
|
||||
("nm", 3),
|
||||
("mil", 8),
|
||||
("inch", 11),
|
||||
("deg", 8),
|
||||
("rad", 9),
|
||||
("°", 8),
|
||||
("degree", 8),
|
||||
("degrees", 8),
|
||||
("", 9),
|
||||
("none", 9),
|
||||
("unitless", 9),
|
||||
(" in ", 11),
|
||||
("parsec", 9),
|
||||
("IN", 9),
|
||||
("\0", 9),
|
||||
("../../mm", 9),
|
||||
("🦀", 9),
|
||||
];
|
||||
let mut seed = 0x5290_a735_19d2_8bc1;
|
||||
let (mut accepted, mut rejected, mut ordered) = (0, 0, 0);
|
||||
for case in 0..100_000 {
|
||||
let x = f64::from_bits(random(&mut seed));
|
||||
let dp = (random(&mut seed) % 13) as u8;
|
||||
let bits = random(&mut seed);
|
||||
let f = NumberFormat {
|
||||
marker: if bits & 1 == 0 { DecimalMarker::Point } else { DecimalMarker::Comma },
|
||||
groups: bits & 2 != 0,
|
||||
leading_zero: bits & 4 != 0,
|
||||
trailing_zeros: bits & 8 != 0,
|
||||
ties: if bits & 16 == 0 { Ties::HalfEven } else { Ties::HalfAwayFromZero },
|
||||
};
|
||||
let kind = match random(&mut seed) % 3 {
|
||||
0 => Length,
|
||||
1 => Angle,
|
||||
_ => Unitless,
|
||||
};
|
||||
let (unit, base_resolution) = units[(random(&mut seed) % units.len() as u64) as usize];
|
||||
let bare = case % 2 == 0;
|
||||
let info = if bare { Some((Unitless, 1.0)) } else { expr::unit_info(unit).filter(|(k, _)| *k == kind) };
|
||||
let call = |v| if bare { format_number(v, dp, &f) } else { format_value(v, kind, unit, dp, &f) };
|
||||
let result = call(x);
|
||||
let display = info.map(|(_, scale)| x / scale);
|
||||
let valid = dp <= 8 && display.is_some_and(|v| v.is_finite() && v.abs() <= 1e12);
|
||||
if !valid {
|
||||
rejected += 1;
|
||||
assert!(matches!(result, Err(DocError::Invalid(_))), "case {case}: {x:?}, dp={dp}, {kind:?}, {unit:?}: {result:?}");
|
||||
continue;
|
||||
}
|
||||
accepted += 1;
|
||||
let shown = result.unwrap();
|
||||
let canonical = shown.text.replace(',', ".").replace('\u{202f}', "").replace('−', "-");
|
||||
let parsed: f64 = canonical.parse().unwrap();
|
||||
assert_eq!(shown.value.to_bits(), parsed.to_bits(), "case {case}: {shown:?}");
|
||||
assert!(!shown.text.contains('-'));
|
||||
if parsed == 0.0 {
|
||||
assert!(!shown.value.is_sign_negative() && !shown.text.starts_with('−'));
|
||||
}
|
||||
let step = 10.0_f64.powi(-i32::from(dp));
|
||||
let resolution = if bare { 9 } else { base_resolution }.max(usize::from(dp) + 3);
|
||||
assert!(
|
||||
decimal_within_bound(display.unwrap(), &canonical, dp, resolution),
|
||||
"case {case}: input={x:?}, dp={dp}, unit={unit:?}, {shown:?}"
|
||||
);
|
||||
if parsed != 0.0 {
|
||||
assert_eq!(parsed.is_sign_negative(), x.is_sign_negative());
|
||||
}
|
||||
// Shown.value already has display units; converting it again would be wrong.
|
||||
assert_eq!(format_number(shown.value, dp, &f).unwrap().text, shown.text, "idempotence case {case}");
|
||||
// Fixed settings/units; move by >=2 display steps and several input ULPs.
|
||||
// Unlike next_up alone, this requires actual output increases, not just equal zeros.
|
||||
let scale = info.unwrap().1;
|
||||
let delta = (2.0 * step * scale).max(x.abs() * f64::EPSILON * 4.0);
|
||||
let y = x + delta;
|
||||
if let Ok(next) = call(y) {
|
||||
ordered += 1;
|
||||
assert!(y > x && next.value > shown.value, "monotonicity case {case}: {x:?}->{y:?}, dp={dp}, unit={unit:?}: {shown:?}->{next:?}");
|
||||
}
|
||||
}
|
||||
assert!(accepted > 10_000 && rejected > 10_000 && ordered > 10_000, "coverage: accepted={accepted}, rejected={rejected}, ordered={ordered}");
|
||||
eprintln!("seed=0x5290a73519d28bc1 cases=100000 accepted={accepted} rejected={rejected} strictly_ordered={ordered}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1957,6 +1957,12 @@ pub struct SketchPoints {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Component {
|
||||
pub id: u64,
|
||||
/// Optional identification colour, separate from normal appearances.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub identification_color: Option<[u8; 3]>,
|
||||
/// Creation sequence used to select an automatic identification colour.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub identification_color_index: Option<u64>,
|
||||
pub name: String,
|
||||
/// Parent component (0 = the root).
|
||||
#[serde(default)]
|
||||
@@ -2536,6 +2542,9 @@ pub struct Document {
|
||||
/// Components below the root, as a tree (by parent).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub components: Vec<Component>,
|
||||
/// Next automatic colour sequence number; deletions never rewind it.
|
||||
#[serde(default)]
|
||||
pub next_component_color_index: u64,
|
||||
/// Placements of components in their parents (each non-root component has at least one).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub occurrences: Vec<crate::Occurrence>,
|
||||
@@ -2800,6 +2809,7 @@ impl Document {
|
||||
next_id: 1,
|
||||
reserved_body_names: Default::default(),
|
||||
components: Vec::new(),
|
||||
next_component_color_index: 0,
|
||||
occurrences: Vec::new(),
|
||||
assembly: Default::default(),
|
||||
sheet: Default::default(),
|
||||
@@ -3114,6 +3124,9 @@ impl Document {
|
||||
if self.components.len() >= 10_000 {
|
||||
return Err(DocError::Invalid("too many components".into()));
|
||||
}
|
||||
self.normalise_component_color_indices()?;
|
||||
let color_index = self.next_component_color_index;
|
||||
let next_color_index = color_index.checked_add(1).ok_or_else(|| DocError::Invalid("component colour sequence exhausted".into()))?;
|
||||
let id = self.next_id.max(1);
|
||||
self.next_id = id.checked_add(1).ok_or_else(|| DocError::Invalid("too many items in the design".into()))?;
|
||||
let name = match name.map(str::trim) {
|
||||
@@ -3126,7 +3139,8 @@ impl Document {
|
||||
format!("Component{k}")
|
||||
}
|
||||
};
|
||||
self.components.push(Component { id, name, parent, ..Default::default() });
|
||||
self.components.push(Component { id, name, parent, identification_color_index: Some(color_index), ..Default::default() });
|
||||
self.next_component_color_index = next_color_index;
|
||||
let occurrence = self.add_occurrence(id, parent, crate::IDENTITY)?;
|
||||
// The design's first part is grounded, so joints and drags have something fixed to work
|
||||
// from (#2804); later parts and copies start free. Only creation decides this: opening a
|
||||
@@ -3652,6 +3666,7 @@ impl Document {
|
||||
/// parameters (in place).
|
||||
pub fn apply_dimension_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
|
||||
self.apply_point_values(vals, sk)?;
|
||||
self.apply_pattern_values(vals, sk)?;
|
||||
for c in &mut sk.constraints {
|
||||
if let Some(p) = &c.param {
|
||||
let kind = if c.kind.is_angle() { Kind::Angle } else { Kind::Length };
|
||||
@@ -3683,6 +3698,43 @@ impl Document {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Evaluate the retained pattern inputs before solving its associative relations.
|
||||
pub fn apply_pattern_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
|
||||
use solidcraft_sketch::SketchPatternKind;
|
||||
self.apply_pattern_quantities(vals, sk)?;
|
||||
for index in 0..sk.patterns.len() {
|
||||
let Some(pattern) = sk.patterns.get(index) else { continue };
|
||||
let mut kind = pattern.kind.clone();
|
||||
for (key, expression) in &pattern.expressions {
|
||||
let unit = match key.as_str() {
|
||||
"count" | "count2" => Kind::Unitless,
|
||||
"spacing" | "spacing2" => Kind::Length,
|
||||
"angle" => Kind::Angle,
|
||||
_ => return Err(DocError::Invalid(format!("unknown sketch pattern input `{key}`"))),
|
||||
};
|
||||
let value = crate::eval::val(vals, expression, unit).map_err(|e| DocError::Expr(format!("pattern {} `{key}`: {e}", pattern.id)))?;
|
||||
let count = || -> Result<usize> {
|
||||
if !(1.0..=500.0).contains(&value) || value.fract() != 0.0 {
|
||||
return Err(DocError::Invalid(format!("pattern `{key}` must be a positive integer from 1 to 500")));
|
||||
}
|
||||
Ok(value as usize)
|
||||
};
|
||||
match (&mut kind, key.as_str()) {
|
||||
(SketchPatternKind::Circular { count: n, .. } | SketchPatternKind::Rectangular { count: n, .. }, "count") => *n = count()?,
|
||||
(SketchPatternKind::Rectangular { count2, .. }, "count2") => *count2 = count()?,
|
||||
(SketchPatternKind::Rectangular { spacing, .. }, "spacing") => *spacing = value,
|
||||
(SketchPatternKind::Rectangular { spacing2, .. }, "spacing2") => *spacing2 = value,
|
||||
(SketchPatternKind::Circular { angle, .. }, "angle") => *angle = value.to_degrees(),
|
||||
_ => return Err(DocError::Invalid(format!("invalid sketch pattern input `{key}`"))),
|
||||
}
|
||||
}
|
||||
if kind != pattern.kind {
|
||||
sk.update_pattern(index, kind)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move the points placed by parameters to where their expressions come to (in place). Such
|
||||
/// a point is fixed, so the solve keeps it there.
|
||||
pub fn apply_point_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
|
||||
@@ -3778,7 +3830,6 @@ impl Document {
|
||||
moved_from: Option<&Sketch>,
|
||||
analysis: solidcraft_sketch::Analysis,
|
||||
) -> Result<solidcraft_sketch::SolveReport> {
|
||||
self.apply_pattern_quantities(vals, sk)?;
|
||||
self.apply_dimension_values(vals, sk)?;
|
||||
let was = |sk: &Sketch| moved_from.and_then(|from| sk.positions_in(from));
|
||||
let offsets = !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty();
|
||||
|
||||
+33
-2
@@ -68,6 +68,9 @@ pub struct ModelBody {
|
||||
/// [`kernel::measure`] of `body` (with the body it was measured on), worked out once and
|
||||
/// shared by clones: inspecting a design again measures only the bodies that changed.
|
||||
measured: Arc<OnceLock<(Body, kernel::Result<kernel::BodyMeasure>)>>,
|
||||
/// [`Body::is_surface`] of `body` (with the body it was measured on), worked out once and
|
||||
/// shared by clones.
|
||||
surface: Arc<OnceLock<(Body, bool)>>,
|
||||
}
|
||||
|
||||
impl ModelBody {
|
||||
@@ -93,6 +96,16 @@ impl ModelBody {
|
||||
source_key: 0,
|
||||
outline: Arc::new(OnceLock::new()),
|
||||
measured: Arc::new(OnceLock::new()),
|
||||
surface: Arc::new(OnceLock::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A surface body: some shell does not close ([`Body::is_surface`]), worked out once.
|
||||
pub fn is_surface(&self) -> bool {
|
||||
match self.surface.get_or_init(|| (self.body.clone(), self.body.is_surface())) {
|
||||
(measured, s) if measured.same(&self.body) => *s,
|
||||
// The body was replaced in this cell's holder since: look again.
|
||||
_ => self.body.is_surface(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1183,6 +1196,7 @@ fn param_names(f: &Feature) -> Vec<String> {
|
||||
let mut names: Vec<String> = f.kind.expressions().iter().flat_map(|e| expr::references(e)).collect();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
|
||||
names.extend(sketch.patterns.iter().flat_map(|p| p.expressions.values()).flat_map(|e| expr::references(e)));
|
||||
names.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
|
||||
names.extend(sketch.text_word_exprs().into_iter().flat_map(|e| crate::text_params::references(e)));
|
||||
}
|
||||
@@ -1330,13 +1344,28 @@ fn feature_json(f: &Feature, steps: &StepDigests) -> String {
|
||||
/// constraints' values. Nothing else: what evaluation derives for its own copy (an offset's
|
||||
/// upgraded topology, refreshed links) stays out of the stored sketch. A `solved` that does not
|
||||
/// match `stored` entity for entity is not adopted.
|
||||
fn adopt_solution(stored: &mut solidcraft_sketch::Sketch, solved: &solidcraft_sketch::Sketch) {
|
||||
fn adopt_solution(stored: &mut solidcraft_sketch::Sketch, solved: &solidcraft_sketch::Sketch) -> bool {
|
||||
if stored.patterns != solved.patterns {
|
||||
// Rebuild authored copies without storing topology derived from links or offsets.
|
||||
let mut updated = stored.clone();
|
||||
for pattern in &solved.patterns {
|
||||
let Some(index) = updated.patterns.iter().position(|p| p.id == pattern.id) else { return false };
|
||||
if updated.update_pattern(index, pattern.kind.clone()).is_err() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if updated.patterns != solved.patterns || !adopt_solution(&mut updated, solved) {
|
||||
return false;
|
||||
}
|
||||
*stored = updated;
|
||||
return true;
|
||||
}
|
||||
if stored.points.len() != solved.points.len()
|
||||
|| stored.curves.len() != solved.curves.len()
|
||||
|| stored.constraints.len() != solved.constraints.len()
|
||||
|| stored.constraints.iter().zip(&solved.constraints).any(|(a, b)| a.id != b.id)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
for (p, q) in stored.points.iter_mut().zip(&solved.points) {
|
||||
p.pos = q.pos;
|
||||
@@ -1358,6 +1387,7 @@ fn adopt_solution(stored: &mut solidcraft_sketch::Sketch, solved: &solidcraft_sk
|
||||
c.kind.set_value(v);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The values features are evaluated with: the parameters, and the design's length unit.
|
||||
@@ -1864,6 +1894,7 @@ impl Model {
|
||||
s.report.keeps_orientation()
|
||||
&& doc.sketch(s.feature).is_ok_and(|stored| {
|
||||
stored.driving_values() != s.sketch.driving_values()
|
||||
|| stored.patterns != s.sketch.patterns
|
||||
|| s.sketch.points.iter().zip(&stored.points).any(|(p, q)| p.link.is_some() && p.id == q.id && p.pos != q.pos)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1144,6 +1144,7 @@ fn rigid(m: &crate::Mat) -> bool {
|
||||
|
||||
/// Feature input names and component occurrences, which format 1 files may predate.
|
||||
fn normalise(d: &mut Document) -> Result<()> {
|
||||
d.normalise_component_color_indices()?;
|
||||
d.name_all_feature_inputs();
|
||||
let missing: Vec<(u64, u64)> =
|
||||
d.components.iter().filter(|c| !d.occurrences.iter().any(|o| o.component == c.id)).map(|c| (c.id, c.parent)).collect();
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Components save an optional `identification_color_index` and the document saves
|
||||
//! `next_component_color_index`. Automatic colours follow creation order and wrap through the
|
||||
//! palette; deletions do not rewind the sequence. Older records receive indices in stored order
|
||||
//! during normalization (not the order they were originally created), preserving explicit RGB overrides.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! An edge joint origin (`Snap::Edge`) saves an optional `axis`: `{"along": [x, y, z]}` (z runs
|
||||
//! along the edge, the way the vector points) or `{"fixed": [x, y, z]}` (z is that direction), for
|
||||
//! an edge or a corner picked in the Joint dialog, which used to be saved as a point. Older
|
||||
//! designs have no `axis` and resolve as before (z out of the face). The last release refuses a
|
||||
//! design with this change by its newer format number.
|
||||
@@ -0,0 +1,2 @@
|
||||
//! Sketch patterns retain count, spacing and angle expressions alongside evaluated settings.
|
||||
//! Missing expressions preserve the numeric settings of older designs without rewriting them.
|
||||
@@ -75,6 +75,10 @@ pub enum Snap {
|
||||
face: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
corner: Option<String>,
|
||||
/// The frame's z when it isn't the face's normal (an edge or a corner picked as it is,
|
||||
/// not on a face): the point still follows the edge, found by name.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
axis: Option<EdgeAxis>,
|
||||
},
|
||||
/// A point, z along `z` (default +Z) and x toward `x` laid square to z (default: one chosen
|
||||
/// from z alone).
|
||||
@@ -87,6 +91,25 @@ pub enum Snap {
|
||||
},
|
||||
}
|
||||
|
||||
/// The z of an edge origin that isn't its face's normal (component coordinates); its x follows
|
||||
/// from z alone, as a point origin's does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EdgeAxis {
|
||||
/// Along a straight edge, the way this points: a hinge on the edge turns about it as it moves.
|
||||
Along(Vec3),
|
||||
/// This direction.
|
||||
Fixed(Vec3),
|
||||
}
|
||||
|
||||
impl EdgeAxis {
|
||||
pub fn dir(self) -> Vec3 {
|
||||
match self {
|
||||
EdgeAxis::Along(d) | EdgeAxis::Fixed(d) => d,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What sets a joint origin's x axis (its turn about z): a direction, or a face found by its
|
||||
/// persistent name, so a round origin keeps its turn when the design changes.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -179,7 +202,7 @@ impl Snap {
|
||||
Snap::Frame { origin, z, x } => origin.is_finite() && z.is_finite() && x.is_none_or(|x| x.is_finite()),
|
||||
Snap::FaceCenter { pick, x, .. } | Snap::CircleCenter { pick, x, .. } => pick.is_finite() && x_ok(x),
|
||||
Snap::Point { point, z, x } => point.is_finite() && z.is_none_or(|z| z.is_finite()) && x.is_none_or(|x| x.is_finite()),
|
||||
Snap::Edge { pick, .. } => pick.is_finite(),
|
||||
Snap::Edge { pick, axis, .. } => pick.is_finite() && axis.is_none_or(|a| a.dir().is_finite()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2079,7 +2102,7 @@ pub mod cam;
|
||||
|
||||
#[path = "joints_edge.rs"]
|
||||
mod edge;
|
||||
pub use edge::edge_snap;
|
||||
pub use edge::{edge_axis, edge_snap};
|
||||
|
||||
#[path = "joints_reach.rs"]
|
||||
mod reach;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::{Snap, component_bodies, frame};
|
||||
use super::{EdgeAxis, Snap, component_bodies, frame};
|
||||
use crate::{DocError, Document, IDENTITY, Mat, ModelBody, ModelState, Result, apply_point, mat_inverse, mat_mul};
|
||||
|
||||
/// A body's placement in its component and its inverse: a body moved in from another component
|
||||
@@ -108,7 +108,7 @@ fn meeting(e: &[Vec3], f: &[Vec3], tol: f64) -> Option<Vec3> {
|
||||
|
||||
/// The frame of an edge snap in its component's coordinates.
|
||||
pub(super) fn edge_frame(doc: &Document, st: &ModelState, comp: u64, snap: &Snap) -> Result<Mat> {
|
||||
let Snap::Edge { pick, edge, face, corner } = snap else { return Err(DocError::Invalid("not an edge origin".into())) };
|
||||
let Snap::Edge { pick, edge, face, corner, axis } = snap else { return Err(DocError::Invalid("not an edge origin".into())) };
|
||||
let bad = || DocError::Invalid("joint origin: degenerate frame".into());
|
||||
let gone = |what: &str| DocError::Invalid(format!("joint origin: {what} no longer exists (edit the joint to pick again)"));
|
||||
let found = component_bodies(doc, st, comp).into_iter().find_map(|b| {
|
||||
@@ -143,6 +143,27 @@ pub(super) fn edge_frame(doc: &Document, st: &ModelState, comp: u64, snap: &Snap
|
||||
frame(at, z, Some(x)).ok_or_else(bad)
|
||||
}
|
||||
}?;
|
||||
// A z of its own: x from z alone, at the point the edge gives.
|
||||
let local = match axis {
|
||||
None => local,
|
||||
Some(a) => {
|
||||
let at = Vec3::new(local[3][0], local[3][1], local[3][2]);
|
||||
let (_, inv) = placement(doc, b);
|
||||
let want = crate::apply_vector(&inv, a.dir());
|
||||
let z = match a {
|
||||
EdgeAxis::Fixed(_) => want,
|
||||
// The edge's run, the way `want` points.
|
||||
EdgeAxis::Along(_) => {
|
||||
let run = match (e.first(), e.last()) {
|
||||
(Some(p), Some(q)) => (*q - *p).normalized().ok_or_else(bad)?,
|
||||
_ => return Err(bad()),
|
||||
};
|
||||
if run.dot(want) < 0.0 { -run } else { run }
|
||||
}
|
||||
};
|
||||
frame(at, z, None).ok_or_else(bad)?
|
||||
}
|
||||
};
|
||||
Ok(mat_mul(&placed, &local))
|
||||
}
|
||||
|
||||
@@ -207,5 +228,16 @@ pub fn edge_snap(doc: &Document, st: &ModelState, comp: u64, pick: Vec3, on: Opt
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(Snap::Edge { pick, edge: names.get(i).cloned().filter(|n| !n.is_empty()), face, corner })
|
||||
Ok(Snap::Edge { pick, edge: names.get(i).cloned().filter(|n| !n.is_empty()), face, corner, axis: None })
|
||||
}
|
||||
|
||||
/// Edge snap `snap` of component `comp` given z `z` (component coordinates) of its own: along the
|
||||
/// edge when the edge runs that way (so a hinge on it keeps turning about it), else fixed.
|
||||
pub fn edge_axis(doc: &Document, st: &ModelState, comp: u64, snap: Snap, z: Vec3) -> Snap {
|
||||
let Snap::Edge { pick, edge, face, corner, .. } = snap else { return snap };
|
||||
let with = |axis| Snap::Edge { pick, edge: edge.clone(), face: face.clone(), corner: corner.clone(), axis: Some(axis) };
|
||||
let along = with(EdgeAxis::Along(z));
|
||||
let runs =
|
||||
edge_frame(doc, st, comp, &along).is_ok_and(|m| z.normalized().is_some_and(|z| Vec3::new(m[2][0], m[2][1], m[2][2]).dot(z) > 1.0 - 1e-9));
|
||||
if runs { along } else { with(EdgeAxis::Fixed(z)) }
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ pub use body_transfer::{valid_transfer_matrix, validate_body_transfer};
|
||||
pub mod canvas;
|
||||
mod clipboard;
|
||||
pub use clipboard::{clone_expression, remap_topology};
|
||||
pub mod component_color;
|
||||
mod component_removal;
|
||||
pub use component_removal::RemovedTree;
|
||||
pub mod config;
|
||||
|
||||
@@ -78,6 +78,7 @@ impl FeatureKind {
|
||||
inputs.extend(placement);
|
||||
if let FeatureKind::Sketch { sketch, .. } = self {
|
||||
inputs.extend(sketch.patterns.iter().filter_map(|p| p.count_expression.as_deref()));
|
||||
inputs.extend(sketch.patterns.iter().flat_map(|p| p.expressions.values().map(String::as_str)));
|
||||
}
|
||||
inputs
|
||||
}
|
||||
@@ -738,7 +739,9 @@ impl Document {
|
||||
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: format!("{} Quantity", pattern.id) });
|
||||
}
|
||||
}
|
||||
for (what, e) in sketch.text_exprs() {
|
||||
for (what, e) in
|
||||
sketch.text_exprs().into_iter().chain(sketch.patterns.iter().flat_map(|p| p.expressions.iter().map(|(k, e)| (k.as_str(), e))))
|
||||
{
|
||||
if expr::references(e).iter().any(|r| r == name) {
|
||||
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: what.to_string() });
|
||||
}
|
||||
@@ -857,7 +860,7 @@ impl Document {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for e in sketch.patterns.iter_mut().filter_map(|p| p.count_expression.as_mut()) {
|
||||
for e in sketch.patterns.iter_mut().flat_map(|p| p.count_expression.iter_mut().chain(p.expressions.values_mut())) {
|
||||
let r = expr::rename_reference(e, old, new);
|
||||
if r != *e {
|
||||
*e = r;
|
||||
@@ -964,6 +967,7 @@ impl Document {
|
||||
let mut names: BTreeSet<String> = f.param_names.iter().filter(|n| !n.is_empty()).cloned().collect();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
|
||||
names.extend(sketch.patterns.iter().flat_map(|p| p.expressions.values()).flat_map(|e| expr::references(e)));
|
||||
names.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
|
||||
names.extend(sketch.patterns.iter().filter_map(|p| p.count_expression.as_deref()).flat_map(expr::references));
|
||||
names.extend(sketch.text_word_exprs().into_iter().flat_map(|e| crate::text_params::references(e)));
|
||||
@@ -1007,6 +1011,7 @@ impl Document {
|
||||
hit |= f.kind.expressions().iter().any(|e| expr::references(e).iter().any(|r| affected.contains(r)));
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
hit |= sketch.constraints.iter().any(|c| c.param.as_ref().is_some_and(|p| affected.contains(p)));
|
||||
hit |= sketch.patterns.iter().flat_map(|p| p.expressions.values()).any(|e| expr::references(e).iter().any(|r| affected.contains(r)));
|
||||
hit |= sketch.text_exprs().iter().any(|(_, e)| expr::references(e).iter().any(|r| affected.contains(r)));
|
||||
hit |= sketch.text_word_exprs().iter().any(|e| crate::text_params::references(e).iter().any(|r| affected.contains(r)));
|
||||
}
|
||||
|
||||
@@ -117,6 +117,47 @@ fn revolve_cut_round_a_box_corner_does_not_panic() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #5543: a grid of 121 holes cut through a plate, four of its circles drawn a second time on
|
||||
/// top of themselves. Each of those was two profiles of one region and so two coincident tools:
|
||||
/// the cut retried with the tool nudged and took fifty times as long. The profiles drawn twice
|
||||
/// are cut once: no retry, and the plate is the one the 121 circles drawn once make.
|
||||
#[test]
|
||||
fn circles_drawn_twice_are_cut_once() {
|
||||
let cut = |repeats: usize| {
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0": [-35, -35], "p1": [35, 35]})).unwrap();
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
s.execute("solid.extrude", &json!({"distance": 2, "operation": "new"})).unwrap();
|
||||
s.execute("sketch.create", &json!({"plane": "XY", "offset": 2})).unwrap();
|
||||
let mut centres = Vec::new();
|
||||
for i in -7i32..8 {
|
||||
for j in -8i32..9 {
|
||||
let (x, y) = (f64::from(j) * 3.45 + if i % 2 != 0 { 1.725 } else { 0.0 }, f64::from(i) * 3.0);
|
||||
if x.hypot(y) <= 20.7 {
|
||||
centres.push([x, y]);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(centres.len(), 121);
|
||||
for c in centres.iter().chain(centres.iter().take(repeats)) {
|
||||
s.execute("sketch.circle.center", &json!({"center": c, "radius": 1.15})).unwrap();
|
||||
}
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
let r = s.execute("solid.extrude", &json!({"operation": "cut", "direction": "negative", "distance": 2, "targets": ["Body1"]})).unwrap();
|
||||
assert!(r["warning"].is_null(), "{}", r["warning"]);
|
||||
let state = s.model.state();
|
||||
let body = &state.body("Body1").unwrap().body;
|
||||
(body.volume_at(1e-4).unwrap(), body.face_count())
|
||||
};
|
||||
let (once, faces_once) = cut(0);
|
||||
let (twice, faces_twice) = cut(4);
|
||||
let analytic = 70.0 * 70.0 * 2.0 - 121.0 * std::f64::consts::PI * 1.15 * 1.15 * 2.0;
|
||||
assert!((once - analytic).abs() < 0.2, "{once} vs {analytic}");
|
||||
assert!((twice - once).abs() < 1e-9 * once, "{twice} vs {once}");
|
||||
assert_eq!(faces_twice, faces_once);
|
||||
}
|
||||
|
||||
/// #1985: the STEP export of a small enclosure never finished. The export reads its own file
|
||||
/// back, and meshing the screw hole's seam cylinder never ended: the boundary walk took
|
||||
/// projected angles millions of turns apart. The file is written, reads back cleanly, and holds
|
||||
|
||||
@@ -28,6 +28,9 @@ mod transfer;
|
||||
mod occurrence_tests;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.cycle_color", "Cycle Component Color", cycle_color)
|
||||
.icon("colored_components").doc_only()
|
||||
.params("component: id or name; saves the next identification colour and enables Component Colors; shared by every instance, separate from appearances"),
|
||||
CommandSpec::new("component.create", "New Component", new_component)
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("component")
|
||||
@@ -565,3 +568,13 @@ pub fn to_active_frame(s: &Session, kind: &mut FeatureKind) {
|
||||
kind.transform_geometry(&inv);
|
||||
}
|
||||
}
|
||||
|
||||
fn cycle_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.cycle_color";
|
||||
let id = component(s, p, "component", cmd)?;
|
||||
let next = solidcraft_doc::component_color::next_color(s.doc.component_identification_color(id));
|
||||
let c = s.doc_mut().components.iter_mut().find(|c| c.id == id).ok_or_else(|| bad(cmd, "choose a component below the design root"))?;
|
||||
c.identification_color = Some(next);
|
||||
s.colored_components = Some(());
|
||||
Ok(json!({"component": id, "color": next}))
|
||||
}
|
||||
|
||||
@@ -257,6 +257,7 @@ pub(super) fn paste(
|
||||
if let Some(added) = scratch.doc_mut().components.iter_mut().find(|x| x.id == new) {
|
||||
added.metadata = c.metadata.clone();
|
||||
added.properties = c.properties.clone();
|
||||
added.identification_color = c.identification_color;
|
||||
}
|
||||
components.insert(*old, new);
|
||||
if *old != component {
|
||||
|
||||
@@ -250,7 +250,13 @@ fn an_edge_origin_is_copied_onto_the_copys_edges() {
|
||||
name: "Corner".into(),
|
||||
origin: JointOrigin {
|
||||
occurrence: 1,
|
||||
snap: Snap::Edge { pick: Vec3::new(1.0, 2.0, 3.0), edge: edge("F1:top|F1:side"), face: edge("F1:top"), corner: edge("F1:top|F10:end") },
|
||||
snap: Snap::Edge {
|
||||
pick: Vec3::new(1.0, 2.0, 3.0),
|
||||
edge: edge("F1:top|F1:side"),
|
||||
face: edge("F1:top"),
|
||||
corner: edge("F1:top|F10:end"),
|
||||
axis: None,
|
||||
},
|
||||
},
|
||||
}];
|
||||
destination.assembly.origins.clear();
|
||||
|
||||
@@ -113,7 +113,8 @@ fn stored(design: Option<&str>, file: &Path) -> String {
|
||||
fn resolve(design: Option<&str>, link: &str) -> PathBuf {
|
||||
let p = Path::new(link);
|
||||
match base_dir(design) {
|
||||
Some(d) if p.is_relative() => d.join(p),
|
||||
// Browser storage keys do not let the OS resolve `..` for us.
|
||||
Some(d) if p.is_relative() => absolute(&d.join(p)),
|
||||
_ => p.to_path_buf(),
|
||||
}
|
||||
}
|
||||
@@ -385,7 +386,8 @@ fn list_links(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
.map(|(c, l)| {
|
||||
let file = resolve(s.path.as_deref(), &l.path);
|
||||
let name = s.doc.components.iter().find(|x| x.id == *c).map(|x| x.name.clone());
|
||||
json!({"component": c, "name": name, "path": l.path, "file": file.display().to_string(), "found": file.is_file()})
|
||||
let file = file.to_string_lossy();
|
||||
json!({"component": c, "name": name, "path": l.path, "file": file, "found": solidcraft_io::vfs::is_file(&file)})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"links": links}))
|
||||
@@ -403,7 +405,7 @@ mod tests {
|
||||
let design = root.join("assembly").join("case.solidcraft");
|
||||
let design = design.to_string_lossy().into_owned();
|
||||
assert_eq!(stored(Some(&design), &part), "../parts/plate.solidcraft");
|
||||
assert_eq!(absolute(&resolve(Some(&design), "../parts/plate.solidcraft")), part);
|
||||
assert_eq!(resolve(Some(&design), "../parts/plate.solidcraft"), part);
|
||||
// A design path given relative to the working folder works the same.
|
||||
assert_eq!(stored(Some("case.solidcraft"), &absolute(Path::new("parts/plate.solidcraft"))), "parts/plate.solidcraft");
|
||||
// Before the design has a file the path is kept whole; saving it makes it relative.
|
||||
@@ -594,6 +596,39 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Two arms jointed top face to bottom face, both occurrences of one linked arm design: when
|
||||
/// the arm is made thicker, its holes moved or it is made longer, Update Links brings the
|
||||
/// change in and every joint type re-solves onto the moved faces, edges and corners, as it
|
||||
/// does inside one design (`jointed_arms_stay_together_as_the_arm_changes`).
|
||||
#[test]
|
||||
fn jointed_linked_arms_stay_together_through_update_links() {
|
||||
use crate::cmd::joints::face_tests::{ARM_EDITS, ARM_FORMS, arm_pairs, arm_part, arm_size, arms_apart, joint_arms};
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-link-arms-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("arm.solidcraft");
|
||||
let mut apart = Vec::new();
|
||||
for form in ARM_FORMS {
|
||||
let mut part = Session::default();
|
||||
arm_part(&mut part);
|
||||
run(&mut part, "file.save", json!({"path": path}));
|
||||
let mut s = Session::default();
|
||||
let first = run(&mut s, "component.insert_design", json!({"path": path, "name": "Arm"}))["occurrence"].as_u64().unwrap();
|
||||
let pairs = arm_pairs(&mut s, first);
|
||||
joint_arms(&mut s, form, &pairs);
|
||||
apart.extend(arms_apart(&mut s, "as made", form, &pairs, arm_size(&[])));
|
||||
for (i, (name, value)) in ARM_EDITS.into_iter().enumerate() {
|
||||
run(&mut part, "parameters.change", json!({"name": name, "expression": format!("{value} mm")}));
|
||||
run(&mut part, "file.save", json!({}));
|
||||
let update = run(&mut s, "component.update_links", json!({}));
|
||||
assert_eq!(update["updated"], json!(["Arm"]), "{form} {name}: {update}");
|
||||
assert_eq!(update["warnings"], json!([]), "{form} {name}: {update}");
|
||||
apart.extend(arms_apart(&mut s, &format!("{name} = {value}"), form, &pairs, arm_size(&ARM_EDITS[..=i])));
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(apart.is_empty(), "{} joints apart:\n{}", apart.len(), apart.join("\n"));
|
||||
}
|
||||
|
||||
/// A joint made on a link that kept no face names (an older design) names the faces by their
|
||||
/// place in the file: Update Links says it can't vouch for it, and keeps the names from then on.
|
||||
#[test]
|
||||
|
||||
@@ -104,6 +104,10 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => skip("the origin axes can't be deleted".into()),
|
||||
},
|
||||
Sel::SketchMidpoint { .. } => skip("a midpoint belongs to its sketch edge".into()),
|
||||
Sel::ConstructionPoint { name } => match s.doc.find_feature(name) {
|
||||
Some(f) if matches!(f.kind, FeatureKind::ConstructionPoint { .. }) => features.push(f.id),
|
||||
_ => skip(format!("no construction point named \"{name}\"")),
|
||||
},
|
||||
Sel::Body { name } => bodies.push(name.clone()),
|
||||
Sel::Face { body, point, .. } => match faces.iter_mut().find(|(b, _)| b == body) {
|
||||
Some((_, ps)) => ps.push(*point),
|
||||
|
||||
@@ -518,9 +518,14 @@ fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sort_group_features(&mut next);
|
||||
let slot = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?;
|
||||
let active = active_param(p, cmd)?;
|
||||
let (mut next, ordered, _) = reorder_features(s, next, &moving, slot, false, cmd)?;
|
||||
let (mut next, ordered, _, checked) = reorder_features(s, next, &moving, slot, false, cmd)?;
|
||||
let marker = next.marker;
|
||||
place_marker(s, &mut next, &moving, active, cmd)?;
|
||||
let checked = checked.filter(|_| next.marker == marker);
|
||||
if next != *s.doc {
|
||||
if let Some(model) = checked {
|
||||
s.model = model;
|
||||
}
|
||||
*s.doc_mut() = next;
|
||||
}
|
||||
Ok(json!({"features": ordered, "groups": groups, "group": target, "position": slot}))
|
||||
@@ -1175,8 +1180,15 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let list: Vec<String> = broken.iter().map(|(n, e)| format!("{n} ({e})")).collect();
|
||||
return Err(bad(cmd, format!("moving {name} there breaks {}", list.join("; "))));
|
||||
}
|
||||
let marker = next.marker;
|
||||
place_marker(s, &mut next, &[id], active, cmd)?;
|
||||
if next != *s.doc {
|
||||
// The check evaluated the new order: keep its results, so setting the design doesn't
|
||||
// evaluate the moved part of the timeline a second time (#6186). Placing the marker
|
||||
// elsewhere makes them stale.
|
||||
if next.marker == marker {
|
||||
s.model = after;
|
||||
}
|
||||
*s.doc_mut() = next;
|
||||
}
|
||||
let mut out = json!({"feature": id, "position": to});
|
||||
@@ -1232,9 +1244,15 @@ fn reorder_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let slot = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?;
|
||||
let active = active_param(p, cmd)?;
|
||||
let allow = allow_errors(p, cmd)?;
|
||||
let (mut next, ordered, broken) = reorder_features(s, (*s.doc).clone(), &ids, slot, allow, cmd)?;
|
||||
let (mut next, ordered, broken, checked) = reorder_features(s, (*s.doc).clone(), &ids, slot, allow, cmd)?;
|
||||
let marker = next.marker;
|
||||
place_marker(s, &mut next, &ordered, active, cmd)?;
|
||||
// The checked results stand only when placing the marker left the design as they were made for.
|
||||
let checked = checked.filter(|_| next.marker == marker);
|
||||
if next != *s.doc {
|
||||
if let Some(model) = checked {
|
||||
s.model = model;
|
||||
}
|
||||
*s.doc_mut() = next;
|
||||
}
|
||||
let mut out = json!({"features": ordered, "position": slot.min(s.doc.features.len())});
|
||||
@@ -1244,6 +1262,8 @@ fn reorder_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The reordered design, the moved features in order, and the model the move was checked on
|
||||
/// (none when the order didn't change), for the caller to keep (#6186).
|
||||
fn reorder_features(
|
||||
s: &Session,
|
||||
mut next: solidcraft_doc::Document,
|
||||
@@ -1251,7 +1271,7 @@ fn reorder_features(
|
||||
slot: usize,
|
||||
allow_errors: bool,
|
||||
cmd: &str,
|
||||
) -> Result<(solidcraft_doc::Document, Vec<u64>, Vec<(String, String)>)> {
|
||||
) -> Result<(solidcraft_doc::Document, Vec<u64>, Vec<(String, String)>, Option<solidcraft_doc::Model>)> {
|
||||
let n = next.features.len();
|
||||
let slot = slot.min(n);
|
||||
let selected: std::collections::BTreeSet<u64> = ids.iter().copied().collect();
|
||||
@@ -1276,17 +1296,19 @@ fn reorder_features(
|
||||
next.features.splice(insert_at..insert_at, moving);
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
let mut broken = Vec::new();
|
||||
if !unchanged {
|
||||
let mut after = solidcraft_doc::Model::new();
|
||||
after.evaluate(&next);
|
||||
broken = newly_broken(&s.model, &after);
|
||||
if !broken.is_empty() && !allow_errors {
|
||||
let list: Vec<String> = broken.iter().map(|(name, error)| format!("{name} ({error})")).collect();
|
||||
return Err(bad(cmd, format!("moving the selected features there breaks {}", list.join("; "))));
|
||||
}
|
||||
if unchanged {
|
||||
return Ok((next, ordered, Vec::new(), None));
|
||||
}
|
||||
Ok((next, ordered, broken))
|
||||
// From the session's own results: the features before the first one moved are kept, not
|
||||
// evaluated again from an empty model (#6186).
|
||||
let mut after = s.model.clone();
|
||||
after.evaluate(&next);
|
||||
let broken = newly_broken(&s.model, &after);
|
||||
if !broken.is_empty() && !allow_errors {
|
||||
let list: Vec<String> = broken.iter().map(|(name, error)| format!("{name} ({error})")).collect();
|
||||
return Err(bad(cmd, format!("moving the selected features there breaks {}", list.join("; "))));
|
||||
}
|
||||
Ok((next, ordered, broken, Some(after)))
|
||||
}
|
||||
|
||||
fn input_keys(kind: &FeatureKind) -> Vec<(String, solidcraft_doc::expr::Kind)> {
|
||||
|
||||
@@ -1009,3 +1009,27 @@ fn renaming_a_plane_keeps_its_projections_canvases_and_hidden_state() {
|
||||
assert_eq!(s.visibility.hidden_origin, ["Rail"]);
|
||||
assert_eq!(plane_link(&s), "Ledge");
|
||||
}
|
||||
|
||||
/// #6186: a reorder evaluates the design once. The model the move was checked on is the one the
|
||||
/// session keeps, so setting the reordered design finds every result ready: nothing is
|
||||
/// recomputed or taken from the cache again. The same for `timeline.reorder_many`, whose check
|
||||
/// starts from the session's results rather than an empty model.
|
||||
#[test]
|
||||
fn a_reorder_keeps_the_model_it_was_checked_on() {
|
||||
let mut s = Session::default();
|
||||
for i in 0..4 {
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5 + i, "corner": [i * 10, 0, 0], "operation": "new"}));
|
||||
}
|
||||
let bodies = |s: &Session| s.model.state().bodies.len();
|
||||
run(&mut s, "timeline.reorder", json!({"feature": "Box2", "position": 0}));
|
||||
assert_eq!((s.model.last_recomputed, s.model.last_restored), (0, 0), "the checked model was kept");
|
||||
assert_eq!(s.doc.features.first().map(|f| f.name.as_str()), Some("Box2"));
|
||||
assert_eq!(bodies(&s), 4);
|
||||
run(&mut s, "timeline.reorder_many", json!({"features": ["Box4"], "position": 2}));
|
||||
assert_eq!((s.model.last_recomputed, s.model.last_restored), (0, 0), "the checked model was kept");
|
||||
assert_eq!(s.doc.features.get(2).map(|f| f.name.as_str()), Some("Box4"));
|
||||
// The kept model is the design's: evaluating it from scratch gives the same bodies.
|
||||
let mut fresh = solidcraft_doc::Model::new();
|
||||
fresh.evaluate(&s.doc);
|
||||
assert_eq!(fresh.state().bodies.len(), bodies(&s));
|
||||
}
|
||||
|
||||
@@ -106,6 +106,22 @@ fn inside_hull(sorted: &[(f64, f64)], q: (f64, f64)) -> bool {
|
||||
hull.len() >= 3 && (0..hull.len()).all(|i| cross(hull[i], hull[(i + 1) % hull.len()], q) >= -eps)
|
||||
}
|
||||
|
||||
/// The outward normal of the planar face at the world point `p`: on the model as the view
|
||||
/// places it (moved components where they are shown).
|
||||
pub(super) fn face_normal_world(s: &Session, p: Vec3) -> Option<Vec3> {
|
||||
let (m, f, d, _) = face_at_state(&s.world_state(), p, None).ok().flatten()?;
|
||||
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
|
||||
if d > tol * 10.0 {
|
||||
return None;
|
||||
}
|
||||
m.triangles
|
||||
.iter()
|
||||
.zip(&m.tri_face)
|
||||
.filter(|(_, tf)| **tf == f)
|
||||
.filter_map(|(t, _)| m.tri(t))
|
||||
.find_map(|[a, b, c]| (b - a).cross(c - a).normalized())
|
||||
}
|
||||
|
||||
/// Is the body face at `p` planar (`None`: no face there)?
|
||||
pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option<bool> {
|
||||
let (m, f, d, _) = face_at(s, p)?;
|
||||
|
||||
@@ -19,7 +19,7 @@ const BOX_CONTRACT: TypedContract<BoxParams> = TypedContract::new();
|
||||
const EXTRUDE_CONTRACT: TypedContract<ExtrudeParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.extrude", "Extrude", crate::checked!("solid.extrude", EXTRUDE_CONTRACT, extrude))
|
||||
CommandSpec::new("solid.extrude", "Extrude", crate::checked!("solid.extrude", extrude))
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("extrude")
|
||||
.key("E")
|
||||
@@ -35,7 +35,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("sweep")
|
||||
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); rails?: [{sketch?: sketch (default path_sketch), curves: [curve ids in order]}] (guide rails, one or two, each meeting every plane square to the path once along it, from the end nearest the path's start: one rail turns the profile so its point where the rail starts stays on the rail and scales it with the rail's distance from the path; two rails carry it between them, its points where they start staying on both; rails take the default orientation, no twist and no scale, and a path without sharp corners); rail_fit?: scale (default: the profile grows and shrinks evenly) | stretch (only across to the rail, between the rails); operation?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2…50, in order; matching hole counts, no holes at an apex); rails?: [{sketch, curves: [ids in order]}] (one or two guide rails from the first section to the last, through every section's outline: the loft follows them between the sections; not with sections that have holes); rail_fit?: scale|stretch (default scale: with one rail the sections grow evenly with the rail's distance from their middle, with two with the distance between the rails; stretch: only that way); operation?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.box", "Box", crate::checked!("solid.box", BOX_CONTRACT, prim_box))
|
||||
CommandSpec::new("solid.box", "Box", crate::checked!("solid.box", prim_box))
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("box")
|
||||
.contract(&BOX_CONTRACT)
|
||||
|
||||
@@ -410,6 +410,17 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("no body `{b}`")));
|
||||
}
|
||||
}
|
||||
// Picks and snaps are where the view shows them (the world); the feature moves the bodies
|
||||
// in their component's frame, so both ends go into the first moved body's frame. A body
|
||||
// picked in another placed component (aligning two imported parts) is found where it
|
||||
// is shown, not where its own frame has it.
|
||||
let frame = s
|
||||
.placed_document()
|
||||
.body_frames_with_source(&s.model.state())
|
||||
.into_iter()
|
||||
.find(|(world, own, ..)| *world == bodies[0] || *own == bodies[0])
|
||||
.map(|(.., m)| m);
|
||||
let inverse = frame.and_then(|m| solidcraft_doc::mat_inverse(&m)).unwrap_or(solidcraft_doc::IDENTITY);
|
||||
// A side is a plane (`from_plane`/`to_plane`: XY|XZ|YZ, a construction or offset plane, or
|
||||
// {origin, normal}), kept as a plane so the bodies follow it when it moves; or a point and, for
|
||||
// a face, its normal.
|
||||
@@ -417,7 +428,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
p.get(key).map(|_| super::features::plane_param(s, p.get(key), cmd)).transpose()
|
||||
};
|
||||
let (from_plane, to_plane) = (plane(s, "from_plane")?, plane(s, "to_plane")?);
|
||||
let pick = |s: &Session, pk: &str, fk: &str, plane: &Option<solidcraft_doc::PlaneRef>| -> Result<(Vec3, Option<Vec3>)> {
|
||||
let world_pick = |s: &Session, pk: &str, fk: &str, plane: &Option<solidcraft_doc::PlaneRef>| -> Result<(Vec3, Option<Vec3>)> {
|
||||
if plane.is_some() {
|
||||
if p.get(pk).is_some() || p.get(fk).is_some() {
|
||||
return Err(bad(cmd, format!("give `{pk}_plane` or `{pk}`/`{fk}`, not both")));
|
||||
@@ -426,7 +437,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok((Vec3::ZERO, None));
|
||||
}
|
||||
if let Some(fp) = p.get(fk).and_then(vec3) {
|
||||
let n = super::face::face_normal(s, fp).ok_or_else(|| bad(cmd, format!("`{fk}` must be on a planar face")))?;
|
||||
let n = super::face::face_normal_world(s, fp).ok_or_else(|| bad(cmd, format!("`{fk}` must be on a planar face")))?;
|
||||
return Ok((fp, Some(n)));
|
||||
}
|
||||
// The origin point.
|
||||
@@ -444,6 +455,13 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|v| (v, None))
|
||||
.ok_or_else(|| bad(cmd, format!("give `{pk}`, `{fk}` or `{pk}_plane` ([x, y, z], \"origin\", or a plane)")))
|
||||
};
|
||||
let pick = |s: &Session, pk: &str, fk: &str, plane: &Option<solidcraft_doc::PlaneRef>| -> Result<(Vec3, Option<Vec3>)> {
|
||||
let (at, n) = world_pick(s, pk, fk, plane)?;
|
||||
if plane.is_some() {
|
||||
return Ok((at, n));
|
||||
}
|
||||
Ok((solidcraft_doc::apply_point(&inverse, at), n.and_then(|n| solidcraft_doc::apply_vector(&inverse, n).normalized())))
|
||||
};
|
||||
let (from, from_normal) = pick(s, "from", "from_face", &from_plane)?;
|
||||
let (to, to_normal) = pick(s, "to", "to_face", &to_plane)?;
|
||||
// Faces turn only to meet another face or a plane; a plane's normal is read when evaluated.
|
||||
|
||||
@@ -2046,3 +2046,64 @@ fn an_imported_assembly_with_a_repeated_part_moves_as_one() {
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A 20 × 30 × 8 plate made in its own component and stood up by its placement (turned
|
||||
/// `angle` degrees about X), the way an imported part set upright is: in the world it lies
|
||||
/// flat at `x0`..`x0 + 20`, y 0..30, z 0..8. The design is active again afterwards.
|
||||
fn placed_plate(s: &mut Session, name: &str, x0: f64, angle: f64) {
|
||||
let made = run(s, "component.create", json!({"name": name, "grounded": false}));
|
||||
// Turned +90° about X a point goes (x, y, z) → (x, −z, y); turned −90°, (x, z, −y).
|
||||
let corner = if angle > 0.0 { json!([x0, 0, -30]) } else { json!([x0, -8, 0]) };
|
||||
let (width, height) = (8, 30);
|
||||
run(s, "solid.box", json!({"length": 20, "width": width, "height": height, "corner": corner, "body_name": name}));
|
||||
run(s, "occurrence.move", json!({"occurrence": made["occurrence"], "axis": [1, 0, 0], "angle": format!("{angle} deg")}));
|
||||
run(s, "component.activate", json!({"component": 0}));
|
||||
let b = world_bounds(s, name);
|
||||
for (got, want) in [(b.min.x, x0), (b.max.x, x0 + 20.0), (b.min.y, 0.0), (b.max.y, 30.0), (b.min.z, 0.0), (b.max.z, 8.0)] {
|
||||
assert!((got - want).abs() < 1e-6, "{name} placed at {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn world_bounds(s: &Session, name: &str) -> solidcraft_geom::Aabb3 {
|
||||
s.world_state().body(name).unwrap_or_else(|| panic!("no {name}")).mesh().bounds()
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
/// Move's vector, axis and pivot are where the view shows them, whatever frame the moved
|
||||
/// body's component has: a plate stood up by its placement and moved up goes up.
|
||||
#[test]
|
||||
fn a_move_goes_the_way_it_is_shown_on_a_placed_component() {
|
||||
let mut s = Session::default();
|
||||
placed_plate(&mut s, "Plate", 0.0, 90.0);
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Plate"], "translate": [0, 0, 10]}));
|
||||
let b = world_bounds(&s, "Plate");
|
||||
assert!(near(b.min.z, 10.0) && near(b.max.z, 18.0) && near(b.min.y, 0.0) && near(b.max.y, 30.0), "moved up: {b:?}");
|
||||
// A quarter turn about the world's Z through the origin: (x, y) → (−y, x).
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Plate"], "axis": [0, 0, 1], "angle": 90}));
|
||||
let b = world_bounds(&s, "Plate");
|
||||
assert!(near(b.min.x, -30.0) && near(b.max.x, 0.0) && near(b.min.y, 0.0) && near(b.max.y, 20.0) && near(b.min.z, 10.0), "turned: {b:?}");
|
||||
}
|
||||
|
||||
/// Align between two parts placed in different frames: both ends are taken where they are
|
||||
/// shown, so the plate lands on the other one rather than at right angles to it.
|
||||
#[test]
|
||||
fn align_takes_both_ends_where_they_are_shown_across_placed_components() {
|
||||
let mut s = Session::default();
|
||||
placed_plate(&mut s, "A", 0.0, 90.0);
|
||||
placed_plate(&mut s, "B", 50.0, -90.0);
|
||||
// Corner to corner: a plain move.
|
||||
run(&mut s, "solid.align", json!({"bodies": ["A"], "from": [0, 0, 8], "to": [50, 0, 8]}));
|
||||
let a = world_bounds(&s, "A");
|
||||
assert!(near(a.min.x, 50.0) && near(a.max.x, 70.0) && near(a.min.z, 0.0) && near(a.max.z, 8.0), "{a:?}");
|
||||
// Snapped corners, then the top face onto B's top face: A turns over and sits on B.
|
||||
run(&mut s, "solid.align", json!({"bodies": ["A"], "from": {"snap": "vertex", "at": [50, 0, 8]}, "to": {"snap": "vertex", "at": [70, 30, 8]}}));
|
||||
let a = world_bounds(&s, "A");
|
||||
assert!(near(a.min.x, 70.0) && near(a.min.y, 30.0) && near(a.max.z, 8.0), "snapped: {a:?}");
|
||||
run(&mut s, "solid.align", json!({"bodies": ["A"], "from_face": [80, 45, 8], "to_face": [60, 15, 8]}));
|
||||
let a = world_bounds(&s, "A");
|
||||
assert!(near(a.min.z, 8.0) && near(a.max.z, 16.0), "on B's top, not across it: {a:?}");
|
||||
assert!(near(0.5 * (a.min.x + a.max.x), 60.0) && near(0.5 * (a.min.y + a.max.y), 15.0), "centred on the picked point: {a:?}");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Documents and files: new, open (designs and STEP), insert STEP, save, export.
|
||||
//! Documents and files: new, open designs, CAD files and drawings, insert STEP, save, export.
|
||||
|
||||
#[path = "file_thumbnail.rs"]
|
||||
mod thumbnail;
|
||||
@@ -20,7 +20,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("open")
|
||||
.key("Ctrl+O")
|
||||
.noundo()
|
||||
.params("path: .solidcraft design, or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh)"),
|
||||
.params("path: .solidcraft design, .dxf/.svg drawing (opens as a new, unsaved design with an XY sketch, using sketch.insert_dxf/sketch.insert_svg units and defaults), or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh)"),
|
||||
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
@@ -260,6 +260,18 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !solidcraft_io::is_step_path(path) && !solidcraft_io::is_gltf_path(path) && p.get("up").is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad("doc.open", "`up` applies to STEP, IGES and glTF files only"));
|
||||
}
|
||||
if let Some(command) = super::drawing_import_command(path) {
|
||||
let stem = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled".into());
|
||||
let mut fresh = Session::new(Document::new(&stem));
|
||||
let mut out = fresh.execute(command, &json!({"path": path}))?;
|
||||
fresh.undo.clear();
|
||||
s.replace_design(fresh);
|
||||
out["path"] = json!(path);
|
||||
out["name"] = json!(s.doc.name);
|
||||
out["features"] = json!(s.doc.features.len());
|
||||
out["errors"] = json!(s.model.results.iter().filter(|r| r.error.is_some()).count());
|
||||
return Ok(out);
|
||||
}
|
||||
if solidcraft_io::is_step_path(path) || solidcraft_io::is_mesh_path(path) {
|
||||
// A new, unsaved design holding the file's bodies.
|
||||
let stem = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled".into());
|
||||
@@ -763,6 +775,35 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_drawing_files_creates_an_unsaved_xy_sketch() {
|
||||
let dir = name_dir("drawing-open");
|
||||
let dxf = "0\nSECTION\n2\nENTITIES\n0\nLINE\n10\n0\n20\n0\n11\n10\n21\n0\n0\nLINE\n10\n10\n20\n0\n11\n10\n21\n10\n0\nARC\n10\n0\n20\n0\n40\n5\n50\n0\n51\n90\n0\nENDSEC\n0\nEOF\n";
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0 L10 0 L10 10"/><circle cx="5" cy="5" r="2"/></svg>"#;
|
||||
for (file, text) in [("outline.DXF", dxf), ("outline.SVG", svg)] {
|
||||
let path = dir.join(file);
|
||||
std::fs::write(&path, text).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane":"YZ"})).unwrap();
|
||||
let result = s.execute("doc.open", &json!({"path":path})).unwrap();
|
||||
assert_eq!(result["errors"], 0);
|
||||
assert_eq!(s.doc.features.len(), 1);
|
||||
let id = s.active_sketch.unwrap();
|
||||
let sketch = s.doc.sketch(id).unwrap();
|
||||
assert_eq!(s.model.state().sketch(id).unwrap().plane, solidcraft_geom::Plane::XY);
|
||||
assert_eq!(sketch.curves.len(), 3);
|
||||
assert_eq!(s.doc.name, "outline");
|
||||
assert!(s.path.is_none(), "Save must ask for a design path");
|
||||
assert!(s.is_dirty());
|
||||
assert!(s.undo.is_empty());
|
||||
let before = s.doc.clone();
|
||||
std::fs::write(&path, "invalid drawing").unwrap();
|
||||
assert!(s.execute("doc.open", &json!({"path":path})).is_err());
|
||||
assert_eq!(s.doc, before, "a failed open preserves the current design");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// #3044: a save run on a scratch copy of the session (on a worker, so the window keeps
|
||||
/// drawing) and taken up with `adopt_save` leaves the session as saving it directly does:
|
||||
/// the design named after its first file, its file, and nothing unsaved; undo keeps the name.
|
||||
|
||||
@@ -8,6 +8,9 @@ use crate::params::{bad, bool_, str_, string_list};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("inspect.colored_components", "Enable Component Colors", colored_components)
|
||||
.at("SOLID", "INSPECT").icon("colored_components").noundo()
|
||||
.params("enabled?: bool (omitted: toggle); temporary component colours, without changing appearances"),
|
||||
CommandSpec::new("inspect.measure", "Measure", measure)
|
||||
.at("SOLID", "INSPECT")
|
||||
.icon("measure")
|
||||
@@ -44,7 +47,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.doc_only()
|
||||
.params("→ built-in and design-owned materials (user: true, look, optional physical_card/physical_validation/density_comparison). Read-only; candidate engineering records do not certify sources or supply a solver"),
|
||||
CommandSpec::new("engine.commands", "List Commands", commands).icon("list").noundo(),
|
||||
CommandSpec::new("engine.describe", "Describe Command", crate::checked!("engine.describe", DESCRIBE_CONTRACT, describe))
|
||||
CommandSpec::new("engine.describe", "Describe Command", crate::checked!("engine.describe", describe))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
@@ -724,3 +727,16 @@ fn material_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(json!({"materials": built_in.chain(user).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn colored_components(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("enabled").is_some_and(|v| !v.is_boolean()) {
|
||||
return Err(bad("inspect.colored_components", "enabled must be a boolean"));
|
||||
}
|
||||
let enabled = bool_(p, "enabled").unwrap_or(s.colored_components.is_none());
|
||||
if enabled && s.colored_components.is_none() {
|
||||
s.colored_components = Some(());
|
||||
} else if !enabled {
|
||||
s.colored_components = None;
|
||||
}
|
||||
Ok(json!({"enabled": enabled}))
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ mod inspect;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("joint.origin", "Joint Origin", joint_origin).at("SOLID", "CREATE").icon("joint_origin").params(
|
||||
"name?, occurrence?: id|name (default: from the pick, or the named face's component), face: [x,y,z] | \"<name>\" (centre of a planar face) | circle: [x,y,z] | \"<name>\" (centre of a round end: a point on its wall or rim, the end's centre, or the round face's name as model.faces lists it) | edge: [x,y,z] (a point on an edge of a planar face: the edge's middle, z out of the face, y into it; on?: [x,y,z] a point on the face (default: the larger planar face beside the edge); corner?: [x,y,z] near one end: the corner there, x and y along its two edges into the face; found again by name, so it follows the face as it changes size) | point: [x,y,z], z?: [x,y,z]; body?: the named face's body; end?: \"<name>\" (a circle's end: the face across its rim there; default the end nearer the pick); x?: [x,y,z] (a direction, world) | \"<face name>\" | {face: [x,y,z]} (the frame's x: a direction, a plane's normal, or the way to another hole's axis; keeps a round origin's turn; a point's x is a direction, laid square to its z, default: chosen from z alone). Faces are kept by name: the origin follows its face, and a circle its end, when the design changes",
|
||||
"name?, occurrence?: id|name (default: from the pick, or the named face's component), face: [x,y,z] | \"<name>\" (centre of a planar face) | circle: [x,y,z] | \"<name>\" (centre of a round end: a point on its wall or rim, the end's centre, or the round face's name as model.faces lists it) | edge: [x,y,z] (a point on an edge of a planar face: the edge's middle, z out of the face, y into it; on?: [x,y,z] a point on the face (default: the larger planar face beside the edge); corner?: [x,y,z] near one end: the corner there, x and y along its two edges into the face; found again by name, so it follows the face as it changes size; z?: [x,y,z] the frame's z instead of the face's normal, along the edge when it runs that way, x chosen from z: an edge or corner picked as it is, kept as a point where it can't be found by name) | point: [x,y,z], z?: [x,y,z]; a circle's z?: [x,y,z] (a round edge's centre with the way out of it: its end by name when that is the frame it shows, else a point there); body?: the named face's body; end?: \"<name>\" (a circle's end: the face across its rim there; default the end nearer the pick); x?: [x,y,z] (a direction, world) | \"<face name>\" | {face: [x,y,z]} (the frame's x: a direction, a plane's normal, or the way to another hole's axis; keeps a round origin's turn; a point's x is a direction, laid square to its z, default: chosen from z alone). Faces are kept by name: the origin follows its face, and a circle its end, when the design changes",
|
||||
),
|
||||
CommandSpec::new("joint.create", "Joint", joint).at("SOLID", "ASSEMBLE").icon("joint").key("J").params(
|
||||
"type: rigid|revolute|slider|cylindrical|pin_slot|planar|ball, or a relation: parallel|perpendicular|angle|point_on_plane|point_on_line|line_on_plane \
|
||||
@@ -384,7 +384,20 @@ pub(crate) fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<
|
||||
}
|
||||
"edge" => {
|
||||
let (on, corner) = (v.get("on").and_then(vec3).map(local), v.get("corner").and_then(vec3).map(local));
|
||||
joints::edge_snap(&s.doc, &st, component, local(world), on, corner).map_err(|e| bad(cmd, e.to_string()))?
|
||||
let snap = joints::edge_snap(&s.doc, &st, component, local(world), on, corner);
|
||||
match given_z(v, &local_dir, cmd)? {
|
||||
None => snap.map_err(|e| bad(cmd, e.to_string()))?,
|
||||
// An edge or a corner picked as it is (z given): it follows the edge by name with
|
||||
// that z, or, where it can't be (no planar face beside it, no names), stays a
|
||||
// point there as before.
|
||||
Some(z) => {
|
||||
let at = corner.unwrap_or(local(world));
|
||||
let point = Snap::Point { point: at, z: Some(z), x: None };
|
||||
let followed = snap.ok().map(|snap| joints::edge_axis(&s.doc, &st, component, snap, z));
|
||||
let o = followed.map(|snap| JointOrigin { occurrence, snap }).filter(|o| same_frame(s, o, at, z));
|
||||
return Ok(o.unwrap_or(JointOrigin { occurrence, snap: point }));
|
||||
}
|
||||
}
|
||||
}
|
||||
"point" => {
|
||||
let z = v.get("z").and_then(vec3).map(local_dir);
|
||||
@@ -407,6 +420,28 @@ pub(crate) fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<
|
||||
}
|
||||
};
|
||||
let o = JointOrigin { occurrence, snap };
|
||||
// A round edge's centre with the z it was picked with: its end, by name, where that is the
|
||||
// frame it shows; else a point there as before.
|
||||
if what == "circle"
|
||||
&& let Some(z) = given_z(v, &local_dir, cmd)?
|
||||
{
|
||||
let at = local(world);
|
||||
// The turn the point gave, kept.
|
||||
let point = Snap::Point { point: at, z: Some(z), x: None };
|
||||
let px =
|
||||
joints::resolve_origin(&s.doc, &st, &JointOrigin { occurrence, snap: point.clone() }).ok().map(|m| Vec3::new(m[0][0], m[0][1], m[0][2]));
|
||||
let turn = |snap: Snap| match (snap, px) {
|
||||
(Snap::CircleCenter { pick, face, end, x: None }, Some(x)) => Snap::CircleCenter { pick, face, end, x: Some(XRef::Dir(x)) },
|
||||
(snap, _) => snap,
|
||||
};
|
||||
let by_wall = settle(s, JointOrigin { occurrence, snap: turn(o.snap.clone()) }, v, cmd).ok();
|
||||
let by_centre = || {
|
||||
let snap = joints::round_end_at(&s.doc, &st, component, at)?;
|
||||
settle(s, JointOrigin { occurrence, snap: turn(snap) }, v, cmd).ok()
|
||||
};
|
||||
let found = by_wall.filter(|o| same_frame(s, o, at, z)).or_else(|| by_centre().filter(|o| same_frame(s, o, at, z)));
|
||||
return Ok(found.unwrap_or(JointOrigin { occurrence, snap: point }));
|
||||
}
|
||||
match settle(s, o, v, cmd) {
|
||||
Ok(o) => Ok(o),
|
||||
// A circle given by its centre, where no face lies (#393): that end of the cylinder, by
|
||||
@@ -422,6 +457,24 @@ pub(crate) fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<
|
||||
}
|
||||
}
|
||||
|
||||
/// An origin's `z` (world), in its component's coordinates.
|
||||
fn given_z(v: &Value, local_dir: &dyn Fn(Vec3) -> Vec3, cmd: &str) -> Result<Option<Vec3>> {
|
||||
match v.get("z") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(z) => {
|
||||
vec3(z).filter(|z| z.is_finite() && z.len() > 1e-9).map(|z| Some(local_dir(z))).ok_or_else(|| bad(cmd, "`z` is a direction [x, y, z]"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether origin `o` resolves, now, to the frame at `at` with z along `z` (component coordinates)
|
||||
/// that its pick showed.
|
||||
fn same_frame(s: &Session, o: &JointOrigin, at: Vec3, z: Vec3) -> bool {
|
||||
let Ok(m) = joints::resolve_origin(&s.doc, &s.model.state(), o) else { return false };
|
||||
let (o, oz) = (Vec3::new(m[3][0], m[3][1], m[3][2]), Vec3::new(m[2][0], m[2][1], m[2][2]));
|
||||
o.dist(at) <= 1e-6 * at.len().max(100.0) && z.normalized().is_some_and(|z| oz.dot(z) > 1.0 - 1e-9)
|
||||
}
|
||||
|
||||
/// The longest face name a joint origin takes. A face that cuts split and merged keeps the names
|
||||
/// of the faces around it (`<name>#[<name>,…]`): several hundred characters on a part a text or
|
||||
/// a pattern of holes was cut through.
|
||||
@@ -1462,7 +1515,7 @@ mod edge_tests;
|
||||
mod exprs_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "joints_face_tests.rs"]
|
||||
mod face_tests;
|
||||
pub(crate) mod face_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "joint_frames_tests.rs"]
|
||||
mod frames_tests;
|
||||
|
||||
@@ -400,3 +400,177 @@ fn a_face_centre_stays_put_when_a_hole_in_its_face_moves_or_goes() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Two arms jointed together, the arm part edited: every joint type keeps its origins
|
||||
// together, for each way the joint dialog picks an origin, inside one design and through a link.
|
||||
|
||||
/// The arm: a plate `L` × 20 × `T` (100 × 20 × 5 to start) along +X from the origin, with a Ø 6
|
||||
/// hole through it at x = `HX` (10) and another 10 mm in from its far end. Made at the active
|
||||
/// component.
|
||||
pub(crate) fn arm_part(s: &mut Session) {
|
||||
for (name, e) in [("T", "5 mm"), ("L", "100 mm"), ("HX", "10 mm")] {
|
||||
run(s, "parameters.add", json!({"name": name, "expression": e}));
|
||||
}
|
||||
run(s, "solid.box", json!({"length": "L", "width": 20, "height": "T", "corner": [0, -10, 0], "body_name": "Arm"}));
|
||||
run(s, "solid.cylinder", json!({"diameter": 6, "height": 100, "base": ["HX", 0, -50], "operation": "cut"}));
|
||||
run(s, "solid.cylinder", json!({"diameter": 6, "height": 100, "base": ["L - 10 mm", 0, -50], "operation": "cut"}));
|
||||
}
|
||||
|
||||
/// The arm's edits, (parameter, value), applied in turn: thicker (A's top face moves along the
|
||||
/// joint's axis, as does B's bottom hole rim relative to A), A's hole moves sideways, and the arm
|
||||
/// grows so its far end (B's hole, edge and corner) moves sideways.
|
||||
pub(crate) const ARM_EDITS: [(&str, f64); 4] = [("T", 12.0), ("HX", 15.0), ("L", 140.0), ("T", 3.0)];
|
||||
|
||||
/// The joint types tried (each between its own pair of arms) and as-built.
|
||||
pub(crate) const ARM_KINDS: [&str; 9] = ["rigid", "revolute", "slider", "cylindrical", "pin_slot", "planar", "ball", "point_on_plane", "as_built"];
|
||||
|
||||
/// How the joint dialog picks an origin on arm A's top (a) and arm B's bottom (b), as world
|
||||
/// params, for the arms at A (0, y, 0) and B (0, y + 60, 0) in the arm's starting size.
|
||||
pub(crate) fn arm_origins(form: &str, y: f64) -> (serde_json::Value, serde_json::Value) {
|
||||
let b = y + 60.0;
|
||||
match form {
|
||||
// A hole's rim (a round edge): its centre and the way out of the material.
|
||||
"rim" => (json!({"circle": [10, y, 5], "z": [0, 0, 1]}), json!({"circle": [90, b, 0], "z": [0, 0, -1]})),
|
||||
// A hole's wall.
|
||||
"wall" => (json!({"circle": [13, y, 4]}), json!({"circle": [93, b, 1]})),
|
||||
// The plate's top and bottom faces.
|
||||
"face" => (json!({"face": [50, y + 5.0, 5]}), json!({"face": [50, b + 5.0, 0]})),
|
||||
// A straight edge: its middle, z along it.
|
||||
"edge" => (json!({"edge": [50, y + 10.0, 5], "z": [1, 0, 0]}), json!({"edge": [50, b + 10.0, 0], "z": [1, 0, 0]})),
|
||||
// A corner (a vertex), z along the part's +Z.
|
||||
_ => (
|
||||
json!({"edge": [100, y + 10.0, 5], "corner": [100, y + 10.0, 5], "z": [0, 0, 1]}),
|
||||
json!({"edge": [100, b + 10.0, 0], "corner": [100, b + 10.0, 0], "z": [0, 0, 1]}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const ARM_FORMS: [&str; 5] = ["rim", "wall", "face", "edge", "corner"];
|
||||
|
||||
/// One joint of each of [`ARM_KINDS`] between arm pairs `pairs` (A, B occurrences), its origins
|
||||
/// picked as `form` picks them.
|
||||
pub(crate) fn joint_arms(s: &mut Session, form: &str, pairs: &[(u64, u64)]) {
|
||||
for (k, (kind, (a, b))) in ARM_KINDS.iter().zip(pairs).enumerate() {
|
||||
let (mut pa, mut pb) = arm_origins(form, 120.0 * k as f64);
|
||||
pa["occurrence"] = json!(a);
|
||||
pb["occurrence"] = json!(b);
|
||||
let name = format!("{form}-{kind}");
|
||||
let r = if *kind == "as_built" {
|
||||
run(s, "joint.as_built", json!({"type": "rigid", "name": name, "a": a, "b": b, "at": pa}))
|
||||
} else {
|
||||
run(s, "joint.create", json!({"type": kind, "name": name, "a": pa, "b": pb}))
|
||||
};
|
||||
assert_eq!(r["errors"], json!([]), "{name}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The arm's size as edited so far: T, HX, L (mm).
|
||||
pub(crate) fn arm_size(edits: &[(&str, f64)]) -> (f64, f64, f64) {
|
||||
let at = |n: &str, d: f64| edits.iter().rev().find(|(m, _)| *m == n).map_or(d, |(_, v)| *v);
|
||||
(at("T", 5.0), at("HX", 10.0), at("L", 100.0))
|
||||
}
|
||||
|
||||
/// Where `form` picked A's and B's origins, on the arm as it is now (T, HX, L): component
|
||||
/// coordinates of the geometry itself (the hole's rim, the plate's top face's centre, …).
|
||||
fn arm_spots(form: &str, (t, hx, l): (f64, f64, f64)) -> (Vec3, Vec3) {
|
||||
match form {
|
||||
"rim" | "wall" => (Vec3::new(hx, 0.0, t), Vec3::new(l - 10.0, 0.0, 0.0)),
|
||||
"face" => (Vec3::new(l / 2.0, 0.0, t), Vec3::new(l / 2.0, 0.0, 0.0)),
|
||||
"edge" => (Vec3::new(l / 2.0, 10.0, t), Vec3::new(l / 2.0, 10.0, 0.0)),
|
||||
_ => (Vec3::new(l, 10.0, t), Vec3::new(l, 10.0, 0.0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Each joint between `pairs` checked against the arm's geometry as it is now (`size`): A's
|
||||
/// origin is on the rim, face, edge or corner it was picked on; B's is on its own and sits on
|
||||
/// A's, z against z (point-on-plane: on A's plane; as-built: B keeps its place from A's
|
||||
/// origin). The joints that aren't, with why.
|
||||
pub(crate) fn arms_apart(s: &mut Session, edit: &str, form: &str, pairs: &[(u64, u64)], size: (f64, f64, f64)) -> Vec<String> {
|
||||
let list = run(s, "joint.list", json!({}));
|
||||
let mut out: Vec<String> = list["errors"].as_array().into_iter().flatten().map(|e| format!("{edit}: {} {}", e["name"], e["why"])).collect();
|
||||
let v = |f: &serde_json::Value, k: &str| {
|
||||
Vec3::new(f[k][0].as_f64().unwrap_or(f64::NAN), f[k][1].as_f64().unwrap_or(f64::NAN), f[k][2].as_f64().unwrap_or(f64::NAN))
|
||||
};
|
||||
let world = |o: u64, p: Vec3| solidcraft_doc::apply_point(&solidcraft_doc::joints::occurrence_world(&s.doc, o), p);
|
||||
let (sa, sb) = arm_spots(form, size);
|
||||
let (sa0, _) = arm_spots(form, arm_size(&[]));
|
||||
for (kind, (a, b)) in ARM_KINDS.iter().zip(pairs) {
|
||||
let name = format!("{form}-{kind}");
|
||||
let Some(j) = list["joints"].as_array().into_iter().flatten().find(|j| j["name"] == name.as_str()) else {
|
||||
out.push(format!("{edit}: {name} is gone"));
|
||||
continue;
|
||||
};
|
||||
let (oa, ob, za, zb) = (v(&j["frame_a"], "origin"), v(&j["frame_b"], "origin"), v(&j["frame_a"], "z"), v(&j["frame_b"], "z"));
|
||||
let (at_a, at_b) = (world(*a, sa), world(*b, sb));
|
||||
let mut why = Vec::new();
|
||||
if oa.dist(at_a) > 1e-6 {
|
||||
why.push(format!("A's origin at {oa:?}, its geometry at {at_a:?}"));
|
||||
}
|
||||
match *kind {
|
||||
"as_built" => {
|
||||
// B was made 60 mm along Y from A, the origin wherever it was on A.
|
||||
let want = world(*a, Vec3::new(0.0, 60.0, 0.0) + sa - sa0);
|
||||
if world(*b, Vec3::ZERO).dist(want) > 1e-6 || oa.dist(ob) > 1e-6 {
|
||||
why.push(format!("B at {:?}, not {want:?}", world(*b, Vec3::ZERO)));
|
||||
}
|
||||
}
|
||||
"point_on_plane" => {
|
||||
if (at_b - at_a).dot(za).abs() > 1e-6 {
|
||||
why.push(format!("B's spot {at_b:?} is off A's plane at {at_a:?} z {za:?}"));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if ob.dist(at_b) > 1e-6 || at_a.dist(at_b) > 1e-6 || za.dot(zb) > -1.0 + 1e-9 {
|
||||
why.push(format!("B's origin at {ob:?} z {zb:?}, its geometry at {at_b:?}, A's at {at_a:?} z {za:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !why.is_empty() {
|
||||
out.push(format!("{edit}: {name}: {}", why.join("; ")));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The A, B arm occurrence pairs (one per joint type), A grounded, from the arm's first
|
||||
/// occurrence `first` (at the origin).
|
||||
pub(crate) fn arm_pairs(s: &mut Session, first: u64) -> Vec<(u64, u64)> {
|
||||
run(s, "occurrence.ground", json!({"occurrence": first, "grounded": true}));
|
||||
(0..ARM_KINDS.len())
|
||||
.map(|k| {
|
||||
let y = 120.0 * k as f64;
|
||||
let a = if k == 0 {
|
||||
first
|
||||
} else {
|
||||
run(s, "occurrence.copy", json!({"occurrence": first, "translate": [0, y, 0]}))["occurrence"].as_u64().unwrap_or(0)
|
||||
};
|
||||
run(s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
|
||||
let b = run(s, "occurrence.copy", json!({"occurrence": first, "translate": [0, y + 60.0, 0]}))["occurrence"].as_u64().unwrap_or(0);
|
||||
(a, b)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Two arms jointed top face to bottom face, the arm's thickness, holes and length changed in the
|
||||
/// design: every joint re-solves onto the moved faces, edges and corners (the joint dialog kept
|
||||
/// a round edge, a straight one and a corner as points where they were picked, so a thicker
|
||||
/// arm overlapped the one jointed to it).
|
||||
#[test]
|
||||
fn jointed_arms_stay_together_as_the_arm_changes() {
|
||||
let mut apart = Vec::new();
|
||||
for form in ARM_FORMS {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "Arm"}));
|
||||
arm_part(&mut s);
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let first = occ(&s, "Arm");
|
||||
let pairs = arm_pairs(&mut s, first);
|
||||
joint_arms(&mut s, form, &pairs);
|
||||
apart.extend(arms_apart(&mut s, "as made", form, &pairs, arm_size(&[])));
|
||||
for (i, (name, value)) in ARM_EDITS.into_iter().enumerate() {
|
||||
run(&mut s, "parameters.change", json!({"name": name, "expression": format!("{value} mm")}));
|
||||
apart.extend(arms_apart(&mut s, &format!("{name} = {value}"), form, &pairs, arm_size(&ARM_EDITS[..=i])));
|
||||
}
|
||||
}
|
||||
assert!(apart.is_empty(), "{} joints apart:\n{}", apart.len(), apart.join("\n"));
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ pub(crate) use face::{FaceRefForm, face_ref_form};
|
||||
pub(crate) use features::profiles as feature_profiles;
|
||||
pub(crate) use features::{EdgeRefForm, edge_ref_form, sketch_key};
|
||||
pub use features::{auto_operation, extrude_reach};
|
||||
pub use sketch_import::drawing_import_command;
|
||||
pub use sketch_modify::{extend_arc_preview, extend_preview, trim_piece};
|
||||
pub use view_dxf::sketch::exportable as view_dxf_exportable;
|
||||
pub use view_navigation::standard_view;
|
||||
@@ -214,12 +215,16 @@ impl CommandSpec {
|
||||
}
|
||||
pub fn info(&self, s: &Session) -> CommandInfo {
|
||||
let e = (self.enabled)(s);
|
||||
let (label, icon) = match self.id {
|
||||
"inspect.colored_components" if s.colored_components.is_some() => ("Disable Component Colors", "colored_components_off"),
|
||||
_ => (self.label, self.icon),
|
||||
};
|
||||
CommandInfo {
|
||||
id: self.id,
|
||||
label: self.label,
|
||||
label,
|
||||
tab: self.tab,
|
||||
panel: self.panel,
|
||||
icon: self.icon,
|
||||
icon,
|
||||
shortcut: self.shortcut,
|
||||
params: self.params,
|
||||
enabled: e.is_ok(),
|
||||
|
||||
@@ -58,12 +58,16 @@ pub(super) fn source(s: &Session, p: &Value, cmd: &str) -> Result<MotionTrace> {
|
||||
let placed = placed_world(s, &occurrence)?;
|
||||
let inverse = solidcraft_doc::mat_inverse(&placed).ok_or_else(|| bad(cmd, "invalid occurrence placement"))?;
|
||||
let canonical = joints::occurrence_world(&s.doc, id);
|
||||
for key in ["face", "circle", "edge", "point"] {
|
||||
for key in ["face", "circle", "edge", "point", "corner"] {
|
||||
if let Some(at) = reference.get(key).and_then(crate::params::vec3) {
|
||||
let at = apply_point(&canonical, apply_point(&inverse, at));
|
||||
reference[key] = json!([at.x, at.y, at.z]);
|
||||
}
|
||||
}
|
||||
if let Some(z) = reference.get("z").and_then(crate::params::vec3) {
|
||||
let z = solidcraft_doc::apply_vector(&canonical, solidcraft_doc::apply_vector(&inverse, z));
|
||||
reference["z"] = json!([z.x, z.y, z.z]);
|
||||
}
|
||||
let o = super::super::joints::origin_param(s, Some(&reference), cmd)?;
|
||||
if o.occurrence != occurrence.last().copied().unwrap_or(0) {
|
||||
return Err(bad(cmd, "point_ref must belong to the traced occurrence"));
|
||||
|
||||
@@ -120,7 +120,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y] (each a number or an expression over the design's parameters: the point is then fixed where it comes to and follows them) or a snap: \"mid:<line>\" (held at the line's midpoint), \"on:<curve>:x,y\" (on the curve near x,y), {vertex: [x,y,z]} or {on_edge: [x,y,z]} of the model; id?", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
|
||||
"entities: [refs] (a line midpoint is \"mid:line_id\"), type?: auto|distance|horizontal|distance_x|vertical|distance_y|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override), or `name = expression` (e.g. \"width = 40\"; also `#name = expression`), which adds the user parameter `name` with that expression and dimensions with `name` (refused when the name is taken or reserved; the reply's `defined` names it); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. Two midpoints: placing text above or below their bounding box measures horizontally, beside it vertically. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A line and a circle or arc: the distance from the line to its edge (the gap, or how far the edge reaches past a line that cuts it; 0 is tangent); dimension the centre point for the distance to the centre. A point and a circle or arc: the distance from the point to its edge (or how far within it a point inside lies). Two circles or arcs: the gap between their edges (or how far they overlap when they cross, or the narrowest gap between them when one lies inside the other); dimension their centre points for the distance between centres. A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends true): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled",
|
||||
"entities: [refs] (a line midpoint is \"mid:line_id\"), type?: auto|distance|horizontal|distance_x|vertical|distance_y|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override), or `name = expression` (e.g. \"width = 40\"; also `#name = expression`), which adds the user parameter `name` with that expression and dimensions with `name` (refused when the name is taken or reserved; the reply's `defined` names it); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. Two midpoints: placing text above or below their bounding box measures horizontally, beside it vertically. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A line and a circle or arc: the distance from the line to its edge (the gap, or how far the edge reaches past a line that cuts it; 0 is tangent); dimension the centre point for the distance to the centre. A point and a circle or arc: the distance from the point to its edge (or how far within it a point inside lies). Two circles or arcs: the gap between their edges (or how far they overlap when they cross, or the narrowest gap between them when one lies inside the other); dimension their centre points for the distance between centres. An arc and one of its end points: the arc's length (type arc_length does it from the arc alone). A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends true): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled",
|
||||
),
|
||||
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
@@ -283,8 +283,8 @@ pub(super) fn edit_solved<T>(
|
||||
) -> Result<(T, Option<SolveReport>)> {
|
||||
let mut doc = (*s.doc).clone();
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
if sk.patterns.iter().any(|p| p.count_expression.is_some()) {
|
||||
doc.apply_pattern_quantities(&doc.param_values().0, &mut sk)?;
|
||||
if sk.patterns.iter().any(|p| p.count_expression.is_some() || !p.expressions.is_empty()) {
|
||||
doc.apply_pattern_values(&doc.param_values().0, &mut sk)?;
|
||||
}
|
||||
// The next frame of a live gesture on this sketch, nothing else run since the last one: its
|
||||
// links are as that frame projected them, from a model that hasn't changed (#3222).
|
||||
@@ -2125,6 +2125,13 @@ fn symmetric_about_point(sk: &mut Sketch, a: usize, b: usize, c: usize, cmd: &st
|
||||
add_c(sk, ConstraintKind::Midpoint { p: c, l })
|
||||
}
|
||||
|
||||
/// Is point `q` an end of arc `c`: one of its end points, or a point where one of them is?
|
||||
fn arc_end(sk: &Sketch, c: usize, q: usize) -> bool {
|
||||
let Some(CurveKind::Arc { a, b, .. }) = sk.curves.get(c).map(|x| &x.kind) else { return false };
|
||||
let at = |i: usize| sk.point(i);
|
||||
*a == q || *b == q || at(q).is_some_and(|pq| [*a, *b].iter().any(|e| at(*e).is_some_and(|pe| pe.dist(pq) < 1e-9)))
|
||||
}
|
||||
|
||||
fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.constraint.symmetry";
|
||||
let (ids, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
@@ -2439,6 +2446,11 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(ConstraintKind::PointLineDistance { p: q, l, value: d }, d)
|
||||
}
|
||||
}
|
||||
// An arc and one of its own end points (or a point on that end): its length (#754).
|
||||
((Some(q), None), Some((None, Some(c)))) | ((None, Some(c)), Some((Some(q), None))) if arc_end(sk, c, q) => {
|
||||
let len = arc_len(sk, c).ok_or_else(|| bad(cmd, "arc length needs an arc"))?;
|
||||
(ConstraintKind::ArcLength { c, value: len }, len)
|
||||
}
|
||||
((None, Some(a)), Some((None, Some(b)))) if ty == "overall_length" => {
|
||||
let current = sk
|
||||
.center(a)
|
||||
|
||||
@@ -1339,3 +1339,29 @@ fn conflicting_component_dimension_is_still_refused() {
|
||||
assert!(error.contains("would over-constrain the sketch (conflicts with"), "{error}");
|
||||
assert_eq!(serde_json::to_value(sketch(&s)).unwrap(), before);
|
||||
}
|
||||
|
||||
/// #754: an arc and one of its end points, in either order, dimension the arc's length (the
|
||||
/// pair was refused). A line's end that meets the arc's end counts too; another point does not.
|
||||
#[test]
|
||||
fn an_arc_and_its_end_point_dimension_the_arc_length() {
|
||||
let arc_length = |s: &Session, a: &str| {
|
||||
let sk = sketch(s);
|
||||
match sk.segs(sk.curve_index(a).unwrap())[0] {
|
||||
solidcraft_geom::Seg2::Arc { radius, sweep, .. } => radius * sweep.abs(),
|
||||
_ => f64::NAN,
|
||||
}
|
||||
};
|
||||
for (entities, value) in [(json!(["a1", "a1.end"]), 20.0), (json!(["a1.start", "a1"]), 18.0), (json!(["a1", "l1.start"]), 25.0)] {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [10, 0], "end": [0, 10], "id": "a1"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 10], [-15, 10]], "ids": ["l1"]}));
|
||||
let r = run(&mut s, "sketch.dimension", json!({"entities": entities, "value": value}));
|
||||
assert_eq!(r["type"], "ArcLengthDimension", "{entities}: {r}");
|
||||
assert!((arc_length(&s, "a1") - value).abs() < 1e-6, "{entities}: {}", arc_length(&s, "a1"));
|
||||
}
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [10, 0], "end": [0, 10], "id": "a1"}));
|
||||
run(&mut s, "sketch.point", json!({"point": [20, 20], "id": "p9"}));
|
||||
let e = s.execute("sketch.dimension", &json!({"entities": ["a1", "p9"]})).map(|v| v["type"].clone());
|
||||
assert!(!matches!(e, Ok(ref t) if t == "ArcLengthDimension"), "a point off the arc's ends is not an arc length: {e:?}");
|
||||
}
|
||||
|
||||
@@ -26,6 +26,18 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned)"),
|
||||
];
|
||||
|
||||
/// The sketch import command for a drawing file name, regardless of extension case.
|
||||
pub fn drawing_import_command(path: &str) -> Option<&'static str> {
|
||||
let ext = std::path::Path::new(path).extension()?.to_str()?;
|
||||
if ext.eq_ignore_ascii_case("dxf") {
|
||||
Some("sketch.insert_dxf")
|
||||
} else if ext.eq_ignore_ascii_case("svg") {
|
||||
Some("sketch.insert_svg")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn source_text(p: &Value, cmd: &str) -> Result<String> {
|
||||
if let Some(t) = str_(p, "text") {
|
||||
if t.len() > MAX_DRAWING_BYTES {
|
||||
|
||||
@@ -95,12 +95,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "PATTERN")
|
||||
.icon("sketch_circ_pattern")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("entities: [ids], center: [x,y] or point ref, count: 2…500 (number or unitless expression, retained through edits), angle?: total deg (default 360); alignment?: left|center|right (default right); reversed?: bool (default false). Quantity includes the original. Center balances copies around it; reversed flips the direction and extra side for even totals. Partial arcs span angle with angle/(count-1) spacing; full turns use angle/count. Adds associative pattern constraints; a coordinate center creates a constrainable point. Returns pattern and center ids. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
|
||||
.params(concat!("entities: [ids], center: [x,y] or point ref, count: 2…500 (number or unitless expression, retained through edits), angle?: total deg, number or expression (default 360); alignment?: left|center|right (default right); reversed?: bool (default false). Quantity includes the original. Center balances copies around it; reversed flips the direction and extra side for even totals. Partial arcs span angle with angle/(count-1) spacing; full turns use angle/count. Adds associative pattern constraints; a coordinate center creates a constrainable point. Returns pattern and center ids. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
|
||||
CommandSpec::new("sketch.pattern.rectangular", "Rectangular Pattern", pattern_rect)
|
||||
.at("SKETCH", "PATTERN")
|
||||
.icon("sketch_rect_pattern")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("entities: [ids], count, spacing; count2?, spacing2?; direction?, direction2?: sketch line ids (live references); dir?, dir2?: [x,y] vectors (defaults: sketch x, perpendicular to direction 1); alignment?, alignment2?: left|center|right (default right). Signed spacings reverse each direction. reversed?, reversed2?: bool (default false); reverses placement and the extra side of even centered totals. Quantities include the original. Adds associative pattern constraints. Returns pattern id. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
|
||||
.params(concat!("entities: [ids], count, spacing; count2?, spacing2?; counts: positive integer numbers or expressions, at most 500 instances total; spacings: length numbers or expressions; direction?, direction2?: sketch line ids (live references); dir?, dir2?: [x,y] vectors (defaults: sketch x, perpendicular to direction 1); alignment?, alignment2?: left|center|right (default right). Signed spacings reverse each direction. reversed?, reversed2?: bool (default false); reverses placement and the extra side of even centered totals. Quantities include the original. Adds associative pattern constraints. Returns pattern id. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
|
||||
];
|
||||
|
||||
const TOL: f64 = 1e-7;
|
||||
@@ -2520,14 +2520,6 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info}))
|
||||
}
|
||||
|
||||
fn count(p: &Value, k: &str, cmd: &str) -> Result<usize> {
|
||||
let n = num(p, k).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
|
||||
if !(1.0..=MAX_COPIES as f64).contains(&n) || n.fract() != 0.0 {
|
||||
return Err(bad(cmd, format!("`{k}` must be 1…{MAX_COPIES}")));
|
||||
}
|
||||
Ok(n as usize)
|
||||
}
|
||||
|
||||
fn point_or_ref(sk: &Sketch, p: &Value, k: &str, cmd: &str) -> Result<Vec2> {
|
||||
match p.get(k) {
|
||||
Some(Value::String(r)) => sk.resolve_point(r).and_then(|i| sk.point(i)).ok_or_else(|| bad(cmd, format!("unknown point `{r}`"))),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Associative sketch patterns. Editing retains ids at surviving instance indices.
|
||||
|
||||
use solidcraft_sketch::{PatternInstance, SketchPattern, SketchPatternKind};
|
||||
use solidcraft_sketch::{SketchPattern, SketchPatternKind};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::{alignment_param, reversed_param};
|
||||
@@ -50,6 +50,30 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut expressions = old.as_ref().map(|old| old.expressions.clone()).unwrap_or_default();
|
||||
for key in ["count", "count2", "spacing", "spacing2", "angle"] {
|
||||
if p.get(key).is_some() {
|
||||
expressions.remove(key);
|
||||
}
|
||||
if let Some(Value::String(e)) = p.get(key) {
|
||||
expressions.insert(key.into(), e.clone());
|
||||
}
|
||||
}
|
||||
for (key, expression) in &expressions {
|
||||
let kind = match key.as_str() {
|
||||
"count" | "count2" => solidcraft_doc::expr::Kind::Unitless,
|
||||
"angle" => solidcraft_doc::expr::Kind::Angle,
|
||||
_ => solidcraft_doc::expr::Kind::Length,
|
||||
};
|
||||
let value = doc.eval(expression, kind).map_err(|e| bad(cmd, format!("`{key}`: {e}")))?;
|
||||
params[key] = json!(if key == "angle" { value.to_degrees() } else { value });
|
||||
}
|
||||
if !circular
|
||||
&& params.get("spacing2").is_none()
|
||||
&& let Some(e) = expressions.get("spacing").cloned()
|
||||
{
|
||||
expressions.insert("spacing2".into(), e);
|
||||
}
|
||||
let (mut cs, mut ps) = entities(sk, ¶ms, cmd)?;
|
||||
cs.sort_unstable();
|
||||
cs.dedup();
|
||||
@@ -76,23 +100,18 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let count_expression = if circular {
|
||||
match params.get("count") {
|
||||
Some(Value::String(expression)) => {
|
||||
let expression = expression.trim().to_string();
|
||||
let value = doc.eval(&expression, solidcraft_doc::expr::Kind::Unitless)?;
|
||||
params["count"] = json!(SketchPattern::circular_quantity(value)?);
|
||||
Some(expression)
|
||||
}
|
||||
_ => None,
|
||||
let expression =
|
||||
expressions.remove("count").or_else(|| old.as_ref().and_then(|p| p.count_expression.clone()).filter(|_| p.get("count").is_none()));
|
||||
if let Some(expression) = &expression {
|
||||
let value = doc.eval(expression, solidcraft_doc::expr::Kind::Unitless)?;
|
||||
params["count"] = json!(SketchPattern::circular_quantity(value).map_err(|e| bad(cmd, format!("`count`: {e}")))?);
|
||||
}
|
||||
expression
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let n = count(¶ms, "count", cmd)?;
|
||||
if count_expression.is_some() && n < 2 {
|
||||
return Err(bad(cmd, "circular pattern Quantity must be 2…500"));
|
||||
}
|
||||
let (kind, center, slots) = if circular {
|
||||
let (kind, center) = if circular {
|
||||
let total = if params.get("angle").is_some() { num(¶ms, "angle").ok_or_else(|| bad(cmd, "`angle` must be finite"))? } else { 360.0 };
|
||||
if !(total.abs() > 1e-9 && total.abs() <= 360.0) {
|
||||
return Err(bad(cmd, "`angle` must be nonzero and at most 360 degrees"));
|
||||
@@ -108,15 +127,9 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
None => return Err(bad(cmd, "missing `center`")),
|
||||
};
|
||||
let id = sk.points.get(center).ok_or_else(|| bad(cmd, "missing center point"))?.id.clone();
|
||||
let full = (total.abs() - 360.0).abs() < 1e-9;
|
||||
let step = total / if full { n as f64 } else { (n.max(2) - 1) as f64 };
|
||||
let alignment = alignment_param(¶ms, "alignment", cmd)?;
|
||||
let reversed = reversed_param(¶ms, "reversed", cmd)?;
|
||||
(
|
||||
SketchPatternKind::Circular { center: id, count: n, angle: total, alignment, reversed },
|
||||
Some(center),
|
||||
(1..n).map(|i| ([i, 0], (step * alignment.multiplier(i, reversed)).to_radians(), Vec2::ZERO)).collect::<Vec<_>>(),
|
||||
)
|
||||
(SketchPatternKind::Circular { center: id, count: n, angle: total, alignment, reversed }, Some(center))
|
||||
} else {
|
||||
let spacing = num(¶ms, "spacing").ok_or_else(|| bad(cmd, "`spacing` must be finite"))?;
|
||||
let count2 = if params.get("count2").is_some() { count(¶ms, "count2", cmd)? } else { 1 };
|
||||
@@ -144,19 +157,6 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
let alignment2 = alignment_param(¶ms, "alignment2", cmd)?;
|
||||
let reversed = reversed_param(¶ms, "reversed", cmd)?;
|
||||
let reversed2 = reversed_param(¶ms, "reversed2", cmd)?;
|
||||
let first = line_direction(sk, direction.as_deref()).unwrap_or(dir);
|
||||
let second = line_direction(sk, direction2.as_deref()).or(dir2).unwrap_or_else(|| first.perp());
|
||||
let slots = (0..n)
|
||||
.flat_map(|i| {
|
||||
(0..count2).filter_map(move |j| {
|
||||
(i != 0 || j != 0).then_some((
|
||||
[i, j],
|
||||
0.0,
|
||||
first * (spacing * alignment.multiplier(i, reversed)) + second * (spacing2 * alignment2.multiplier(j, reversed2)),
|
||||
))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
(
|
||||
SketchPatternKind::Rectangular {
|
||||
dir,
|
||||
@@ -173,7 +173,6 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
reversed2,
|
||||
},
|
||||
None,
|
||||
slots,
|
||||
)
|
||||
};
|
||||
let id = match &old {
|
||||
@@ -181,82 +180,20 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
None => sk.fresh("pat")?,
|
||||
};
|
||||
let center_id = center.and_then(|i| sk.points.get(i)).map(|p| p.id.clone());
|
||||
let mut existing = old.map(|old| old.instances).unwrap_or_default();
|
||||
// Remove retired instances first: all following indices are resolved by stable id.
|
||||
for instance in existing.iter().filter(|instance| !slots.iter().any(|(index, _, _)| *index == instance.index)) {
|
||||
let curves: Vec<_> = instance.curves.iter().filter_map(|id| sk.curve_index(id)).collect();
|
||||
sk.constraints.retain(|c| !instance.constraints.contains(&c.id));
|
||||
sk.remove_curves(&curves);
|
||||
let points: Vec<_> = instance.points.iter().filter_map(|(_, id)| sk.resolve_point(id)).collect();
|
||||
sk.remove_points(&points);
|
||||
}
|
||||
let mut instances = Vec::new();
|
||||
let center = center_id.as_ref().and_then(|id| sk.resolve_point(id));
|
||||
if center_id.is_some() && center.is_none() {
|
||||
return Err(bad(cmd, "the center belongs to an instance removed by this edit"));
|
||||
}
|
||||
if !circular {
|
||||
// A direction can have belonged to an instance retired above. Resolve it again
|
||||
// before creating relations so an edit never silently substitutes a vector.
|
||||
direction_ref(sk, ¶ms, "direction", cmd)?;
|
||||
direction_ref(sk, ¶ms, "direction2", cmd)?;
|
||||
}
|
||||
let mut curves = Vec::new();
|
||||
let mut constraints = Vec::new();
|
||||
for (index, angle, offset) in slots {
|
||||
let (base_offset, directions) = linear_displacement(sk, &kind, index, offset);
|
||||
let c = center.and_then(|i| sk.point(i)).unwrap_or(Vec2::ZERO);
|
||||
let transform = |p| rotate(c, angle)(p) + offset;
|
||||
let instance = if let Some(i) = existing.iter().position(|instance| instance.index == index) {
|
||||
let instance = existing.remove(i);
|
||||
for cid in &instance.constraints {
|
||||
if let Some(constraint) = sk.constraints.iter_mut().find(|constraint| constraint.id == *cid)
|
||||
&& let ConstraintKind::PatternPoint { center: slot, angle: a, offset: off, directions: dirs, .. } = &mut constraint.kind
|
||||
{
|
||||
*slot = center;
|
||||
*a = angle;
|
||||
*off = base_offset;
|
||||
*dirs = directions;
|
||||
}
|
||||
}
|
||||
for (seed, copy) in &instance.points {
|
||||
let pos = sk.resolve_point(seed).and_then(|i| sk.point(i)).ok_or_else(|| bad(cmd, "missing seed point"))?;
|
||||
if let Some(point) = sk.resolve_point(copy).and_then(|i| sk.points.get_mut(i)) {
|
||||
point.pos = transform(pos);
|
||||
}
|
||||
}
|
||||
instance
|
||||
} else {
|
||||
let cs: Vec<_> = entity_ids.iter().filter_map(|id| sk.curve_index(id)).collect();
|
||||
let ps: Vec<_> = entity_ids.iter().filter(|id| sk.curve_index(id).is_none()).filter_map(|id| sk.resolve_point(id)).collect();
|
||||
let (made, map) = copy_through(sk, &cs, &ps, &transform, false, &|_| false, 1.0)?;
|
||||
let mut cons = Vec::new();
|
||||
for (p, q) in &map {
|
||||
cons.push(add_c(sk, ConstraintKind::PatternPoint { p: *p, q: *q, center, angle, offset: base_offset, directions })?);
|
||||
}
|
||||
for (a, b) in cs.iter().zip(&made) {
|
||||
if matches!(sk.curves.get(*a).map(|c| &c.kind), Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. })) {
|
||||
cons.push(add_c(sk, ConstraintKind::PatternRadius { a: *a, b: *b })?);
|
||||
}
|
||||
}
|
||||
PatternInstance {
|
||||
index,
|
||||
curves: ids_of(sk, &made),
|
||||
points: map.iter().filter_map(|(p, q)| Some((sk.points.get(*p)?.id.clone(), sk.points.get(*q)?.id.clone()))).collect(),
|
||||
constraints: cons,
|
||||
}
|
||||
};
|
||||
curves.extend(instance.curves.iter().cloned());
|
||||
constraints.extend(instance.constraints.iter().cloned());
|
||||
instances.push(instance);
|
||||
}
|
||||
let center_id = center.and_then(|i| sk.points.get(i)).map(|p| p.id.clone());
|
||||
let pattern = SketchPattern { id: id.clone(), entities: entity_ids, kind, count_expression, instances };
|
||||
if let Some(i) = old_index {
|
||||
sk.patterns.insert(i.min(sk.patterns.len()), pattern);
|
||||
} else {
|
||||
sk.patterns.push(pattern);
|
||||
}
|
||||
let index = old_index.unwrap_or(sk.patterns.len());
|
||||
let pattern = SketchPattern {
|
||||
id: id.clone(),
|
||||
entities: entity_ids,
|
||||
kind: kind.clone(),
|
||||
instances: old.map(|old| old.instances).unwrap_or_default(),
|
||||
expressions,
|
||||
count_expression,
|
||||
};
|
||||
sk.patterns.insert(index.min(sk.patterns.len()), pattern);
|
||||
sk.update_pattern(index, kind)?;
|
||||
let pattern = sk.patterns.get(index).ok_or_else(|| bad(cmd, "missing sketch pattern"))?;
|
||||
let curves: Vec<_> = pattern.instances.iter().flat_map(|i| i.curves.iter().cloned()).collect();
|
||||
let constraints: Vec<_> = pattern.instances.iter().flat_map(|i| i.constraints.iter().cloned()).collect();
|
||||
Ok(json!({"pattern":id,"center":center_id,"curves":curves,"constraints":constraints}))
|
||||
})?;
|
||||
let mut out = out;
|
||||
@@ -266,6 +203,14 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn count(p: &Value, k: &str, cmd: &str) -> Result<usize> {
|
||||
let n = num(p, k).ok_or_else(|| bad(cmd, format!("`{k}` must be a number or expression")))?;
|
||||
if !(1.0..=MAX_COPIES as f64).contains(&n) || n.fract() != 0.0 {
|
||||
return Err(bad(cmd, format!("`{k}` must be a positive integer from 1 to {MAX_COPIES}")));
|
||||
}
|
||||
Ok(n as usize)
|
||||
}
|
||||
|
||||
fn direction_ref(sk: &Sketch, p: &Value, key: &str, cmd: &str) -> Result<Option<String>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
@@ -278,30 +223,3 @@ fn line_direction(sk: &Sketch, id: Option<&str>) -> Option<Vec2> {
|
||||
let Shape::Line { a, b } = sk.shape(sk.curve_index(id?)?)? else { return None };
|
||||
(b - a).normalized()
|
||||
}
|
||||
|
||||
/// Keep sampled vector directions compatible with older designs, while picked lines are
|
||||
/// solver references. The default second direction follows the first line's perpendicular.
|
||||
fn linear_displacement(sk: &Sketch, kind: &SketchPatternKind, [i, j]: [usize; 2], fallback: Vec2) -> (Vec2, [Option<(usize, Vec2)>; 2]) {
|
||||
let SketchPatternKind::Rectangular { dir, dir2, direction, direction2, spacing, spacing2, alignment, alignment2, reversed, reversed2, .. } = kind
|
||||
else {
|
||||
return (fallback, [None; 2]);
|
||||
};
|
||||
let mut offset = Vec2::ZERO;
|
||||
let mut directions = [None; 2];
|
||||
let d1 = *spacing * alignment.multiplier(i, *reversed);
|
||||
let d2 = *spacing2 * alignment2.multiplier(j, *reversed2);
|
||||
if let Some(l) = direction.as_ref().and_then(|id| sk.curve_index(id)) {
|
||||
directions[0] = Some((l, Vec2::new(d1, if dir2.is_none() && direction2.is_none() { d2 } else { 0.0 })));
|
||||
} else {
|
||||
offset += *dir * d1;
|
||||
if dir2.is_none() && direction2.is_none() {
|
||||
offset += dir.perp() * d2;
|
||||
}
|
||||
}
|
||||
if let Some(l) = direction2.as_ref().and_then(|id| sk.curve_index(id)) {
|
||||
directions[1] = Some((l, Vec2::new(d2, 0.0)));
|
||||
} else if let Some(dir2) = dir2 {
|
||||
offset += *dir2 * d2;
|
||||
}
|
||||
(offset, directions)
|
||||
}
|
||||
|
||||
@@ -678,3 +678,80 @@ fn a_pattern_too_large_to_solve_says_why() {
|
||||
.to_string();
|
||||
assert!(e.contains("unknowns") && e.contains(&solidcraft_sketch::MAX_COMPONENT_VARS.to_string()) && !e.contains("conflicts with"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangular_pattern_expressions_follow_parameters_and_survive_reopening() {
|
||||
let mut s = new_sketch();
|
||||
for (name, expression, unit) in [("pitch", "30", "mm"), ("copies", "3", "")] {
|
||||
run(&mut s, "parameters.add", json!({"name":name,"expression":expression,"unit":unit}));
|
||||
}
|
||||
run(&mut s, "sketch.circle.center", json!({"id":"seed","center":[5,5],"radius":1}));
|
||||
let out = run(
|
||||
&mut s,
|
||||
"sketch.pattern.rectangular",
|
||||
json!({"entities":["seed"],"count":"copies","spacing":"pitch","count2":"2","spacing2":"pitch / 2"}),
|
||||
);
|
||||
let first = ids(&out["curves"])[0].clone();
|
||||
assert!(!s.doc.param_users("pitch").is_empty());
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let mut s = Session { doc: solidcraft_doc::format::read(&s.doc.to_json()).unwrap().into(), ..Default::default() };
|
||||
s.refresh();
|
||||
run(&mut s, "parameters.change", json!({"name":"pitch","expression":"40"}));
|
||||
run(&mut s, "parameters.change", json!({"name":"copies","expression":"4"}));
|
||||
let state = s.world_state();
|
||||
let sk = &state.sketch(1).unwrap().sketch;
|
||||
assert_eq!(sk.patterns[0].instances.len(), 7);
|
||||
assert_eq!(sk.patterns[0].instances[0].curves[0], first);
|
||||
assert!(point(sk, &format!("{first}.center")).dist(Vec2::new(5., 25.)) < 1e-6);
|
||||
let last = &sk.patterns[0].instances.last().unwrap().curves[0];
|
||||
assert!(point(sk, &format!("{last}.center")).dist(Vec2::new(125., 25.)) < 1e-6);
|
||||
run(&mut s, "parameters.change", json!({"name":"copies","expression":"2"}));
|
||||
assert_eq!(s.world_state().sketch(1).unwrap().sketch.patterns[0].instances.len(), 3);
|
||||
run(&mut s, "parameters.change", json!({"name":"pitch","new_name":"step"}));
|
||||
assert_eq!(serde_json::to_value(&s.doc.sketch(1).unwrap().patterns[0]).unwrap()["expressions"]["spacing"], "step");
|
||||
assert!(s.execute("parameters.change", &json!({"delete":"step"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_pattern_expressions_validate_inputs_and_keep_numeric_compatibility() {
|
||||
let mut s = new_sketch();
|
||||
for (name, expression, unit) in [("turn", "90", "deg"), ("copies", "3", "")] {
|
||||
run(&mut s, "parameters.add", json!({"name":name,"expression":expression,"unit":unit}));
|
||||
}
|
||||
run(&mut s, "sketch.circle.center", json!({"id":"seed","center":[20,0],"radius":1}));
|
||||
let out = run(&mut s, "sketch.pattern.circular", json!({"entities":["seed"],"center":"origin","count":"copies","angle":"turn"}));
|
||||
run(&mut s, "parameters.change", json!({"name":"turn","expression":"180"}));
|
||||
run(&mut s, "parameters.change", json!({"name":"copies","expression":"4"}));
|
||||
let sk = sketch(&s);
|
||||
let last = &sk.patterns[0].instances.last().unwrap().curves[0];
|
||||
assert!(point(&sk, &format!("{last}.center")).dist(Vec2::new(-20., 0.)) < 1e-6);
|
||||
for expression in ["2.5", "501", "0", "-1"] {
|
||||
let error = s.execute("parameters.change", &json!({"name":"copies","expression":expression})).unwrap_err().to_string();
|
||||
assert!(error.contains("whole number"), "{error}");
|
||||
}
|
||||
for (key, value, message) in [("count", json!("2.5"), "count"), ("count", json!("501"), "count"), ("angle", json!("missing + 1"), "missing")] {
|
||||
let mut p = json!({"pattern":out["pattern"]});
|
||||
p[key] = value;
|
||||
let error = s.execute("sketch.pattern.circular", &p).unwrap_err().to_string();
|
||||
assert!(error.contains(message), "{error}");
|
||||
}
|
||||
let error = s.execute("sketch.pattern.rectangular", &json!({"entities":["seed"],"count":2,"spacing":"unknown_pitch"})).unwrap_err().to_string();
|
||||
assert!(error.contains("spacing") && error.contains("unknown_pitch"), "{error}");
|
||||
run(&mut s, "sketch.pattern.circular", json!({"pattern":out["pattern"],"count":3,"angle":90}));
|
||||
assert!(serde_json::to_value(&sketch(&s).patterns[0]).unwrap().get("expressions").is_none());
|
||||
let legacy = s.doc.to_json();
|
||||
// Numeric patterns omit the optional expression field, just as older files do.
|
||||
assert!(!s.doc.to_json().contains("\"expressions\""));
|
||||
let reopened = solidcraft_doc::format::read(&legacy).unwrap();
|
||||
assert_eq!(reopened.sketch(1).unwrap().patterns[0].instances.len(), 2);
|
||||
// A design with plain numbers saves back unchanged and evaluates to the same copies.
|
||||
assert_eq!(reopened.to_json(), legacy);
|
||||
let before = sketch(&s);
|
||||
let mut s = Session { doc: reopened.into(), ..Default::default() };
|
||||
s.refresh();
|
||||
let state = s.world_state();
|
||||
let after = &state.sketch(1).unwrap().sketch;
|
||||
for (a, b) in before.points.iter().zip(&after.points) {
|
||||
assert!(a.id == b.id && a.pos.dist(b.pos) < 1e-9, "{} moved", a.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ enum K {
|
||||
Make,
|
||||
/// A direction: turned with the command's frame.
|
||||
Dir,
|
||||
/// A point the command's bodies move about: into the frame of those bodies (the command's
|
||||
/// frame, as its directions are).
|
||||
Moved,
|
||||
/// An arbitrary axis object in the authoring frame: {origin, dir}.
|
||||
Axis,
|
||||
/// A plane object (`{face}`, `{origin, normal}`, `{origin, x_dir, y_dir}`) or a plane name.
|
||||
@@ -61,7 +64,10 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
|
||||
"solid.draft" => &[("faces", Picks), ("neutral", Plane), ("pull", Dir), ("body", Body)],
|
||||
"solid.press_pull" => &[("face", Pick), ("edges", Picks)],
|
||||
"solid.replace_face" => &[("faces", Picks), ("target_face", Pick), ("target", Plane), ("body", Body)],
|
||||
"solid.align" => &[("from", Pick), ("from_face", Pick), ("to", Pick), ("to_face", Pick), ("bodies", Body)],
|
||||
// Align finds its picks where they are shown and takes both ends into the moved bodies'
|
||||
// frame itself: a pick mapped into the frame of the body under it would put the two ends
|
||||
// in different frames when they lie on separately placed components.
|
||||
"solid.align" => &[("bodies", Body)],
|
||||
// A mirror keeps the placement its bodies were picked at (`cmd::features::placed_bodies`);
|
||||
// its plane is in the active component, where the copies go.
|
||||
"solid.mirror" => &[("plane", HerePlane)],
|
||||
@@ -84,7 +90,7 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
|
||||
"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)],
|
||||
"solid.move" => &[("bodies", Body), ("translate", Dir), ("axis", Dir), ("origin", Make)],
|
||||
"solid.move" => &[("bodies", Body), ("translate", Dir), ("axis", Dir), ("origin", Moved)],
|
||||
"solid.combine" => &[("target", Body), ("tools", Body)],
|
||||
"solid.remove" | "solid.bounding_solid" | "material.assign" => &[("bodies", Body)],
|
||||
_ => &[],
|
||||
@@ -444,7 +450,19 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
|
||||
command_frames.active = frame;
|
||||
command_frames.sketch = frame;
|
||||
}
|
||||
let mut m = Mapper { s, f: command_frames, first: None, changed: false, here: false };
|
||||
// A move has no picks: its vector, axis and pivot are in the world and turn with the frame
|
||||
// of the bodies it moves (an imported part stood upright moved up goes up, not along its
|
||||
// own frame's z). A turn with no pivot is about the world origin.
|
||||
let first = (id == "solid.move")
|
||||
.then(|| obj.get("bodies").and_then(Value::as_array)?.first()?.as_str())
|
||||
.flatten()
|
||||
.and_then(|b| command_frames.bodies(s).iter().find(|(world, own, _)| world == b || own == b).map(|(.., m)| *m));
|
||||
let mut obj = obj.clone();
|
||||
if first.is_some() && obj.contains_key("axis") && !obj.contains_key("origin") {
|
||||
obj.insert("origin".into(), serde_json::json!([0.0, 0.0, 0.0]));
|
||||
}
|
||||
let obj = &obj;
|
||||
let mut m = Mapper { s, f: command_frames, first, changed: false, here: false };
|
||||
let body = obj.get("body").or_else(|| obj.get("face_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.
|
||||
@@ -460,6 +478,10 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
|
||||
Picks => m.picks(v, body.as_deref()),
|
||||
Make => m.make(v),
|
||||
Dir => m.dir(v),
|
||||
Moved => {
|
||||
let frame = m.first.unwrap_or(m.f.active);
|
||||
vec3(v).and_then(|p| m.local_point(&frame, p))
|
||||
}
|
||||
Axis => {
|
||||
if let Some(axis) = v.as_object() {
|
||||
let mut axis = axis.clone();
|
||||
|
||||
@@ -150,6 +150,10 @@ pub enum Sel {
|
||||
Plane {
|
||||
name: String,
|
||||
},
|
||||
/// A construction point, by feature name.
|
||||
ConstructionPoint {
|
||||
name: String,
|
||||
},
|
||||
/// An origin axis (X, Y, Z) or a construction axis (by feature name).
|
||||
Axis {
|
||||
name: String,
|
||||
@@ -327,6 +331,8 @@ pub struct Session {
|
||||
pub section: Option<(Vec3, Vec3)>,
|
||||
/// Whether the section's cut faces are hatched (`inspect.section`'s `hatch`; on by default).
|
||||
pub section_hatch: bool,
|
||||
/// Component identification colours are enabled when present; view state only.
|
||||
pub colored_components: Option<()>,
|
||||
/// Surface analysis shading the model (zebra, draft, curvature map).
|
||||
pub analysis: Option<SurfaceAnalysis>,
|
||||
/// The seams `inspect.continuity` last measured, for the viewport to shade. View state: not
|
||||
@@ -491,6 +497,7 @@ impl Session {
|
||||
sketch_clipboard: None,
|
||||
section: None,
|
||||
section_hatch: true,
|
||||
colored_components: None,
|
||||
analysis: None,
|
||||
continuity: Vec::new(),
|
||||
checkpoints: Vec::new(),
|
||||
@@ -888,7 +895,9 @@ impl Session {
|
||||
.features
|
||||
.iter()
|
||||
.filter_map(|feature| match &feature.kind {
|
||||
solidcraft_doc::FeatureKind::Sketch { sketch, .. } if sketch.patterns.iter().any(|p| p.count_expression.is_some()) => {
|
||||
solidcraft_doc::FeatureKind::Sketch { sketch, .. }
|
||||
if sketch.patterns.iter().any(|p| p.count_expression.is_some() || !p.expressions.is_empty()) =>
|
||||
{
|
||||
Some((feature.id, sketch.clone()))
|
||||
}
|
||||
_ => None,
|
||||
@@ -897,7 +906,7 @@ impl Session {
|
||||
if !updated.is_empty() {
|
||||
let (vals, _) = self.doc.param_values();
|
||||
for (_, sketch) in &mut updated {
|
||||
self.doc.apply_pattern_quantities(&vals, sketch)?;
|
||||
self.doc.apply_pattern_values(&vals, sketch)?;
|
||||
}
|
||||
}
|
||||
for (id, sketch) in updated {
|
||||
@@ -1152,6 +1161,7 @@ impl Session {
|
||||
sketch_clipboard: self.sketch_clipboard.clone(),
|
||||
section: None,
|
||||
section_hatch: true,
|
||||
colored_components: None,
|
||||
analysis: None,
|
||||
continuity: Vec::new(),
|
||||
checkpoints: Vec::new(),
|
||||
@@ -1401,11 +1411,18 @@ pub(crate) type SketchValues = std::collections::BTreeMap<u64, std::collections:
|
||||
fn own_values(sk: &solidcraft_sketch::Sketch) -> std::collections::BTreeMap<String, f64> {
|
||||
let mut values: std::collections::BTreeMap<_, _> =
|
||||
sk.constraints.iter().filter(|c| c.param.is_some() && !c.driven).filter_map(|c| Some((c.id.clone(), c.kind.value()?))).collect();
|
||||
for pattern in &sk.patterns {
|
||||
if pattern.count_expression.is_some()
|
||||
&& let solidcraft_sketch::SketchPatternKind::Circular { count, .. } = pattern.kind
|
||||
{
|
||||
values.insert(format!("pattern:{}:count", pattern.id), count as f64);
|
||||
for p in &sk.patterns {
|
||||
use solidcraft_sketch::SketchPatternKind;
|
||||
let inputs = match p.kind {
|
||||
SketchPatternKind::Circular { count, angle, .. } => vec![("count", count as f64), ("angle", angle)],
|
||||
SketchPatternKind::Rectangular { count, spacing, count2, spacing2, .. } => {
|
||||
vec![("count", count as f64), ("spacing", spacing), ("count2", count2 as f64), ("spacing2", spacing2)]
|
||||
}
|
||||
};
|
||||
for (key, value) in inputs {
|
||||
if p.expressions.contains_key(key) || (key == "count" && p.count_expression.is_some()) {
|
||||
values.insert(format!("{}.{}", p.id, key), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
values
|
||||
@@ -1458,6 +1475,7 @@ fn same_but_features(a: &Document, b: &Document) -> bool {
|
||||
browser_order,
|
||||
named_views,
|
||||
visibility,
|
||||
next_component_color_index,
|
||||
} = a;
|
||||
*render == b.render
|
||||
&& *format == b.format
|
||||
@@ -1491,4 +1509,5 @@ fn same_but_features(a: &Document, b: &Document) -> bool {
|
||||
&& *browser_order == b.browser_order
|
||||
&& *named_views == b.named_views
|
||||
&& *visibility == b.visibility
|
||||
&& *next_component_color_index == b.next_component_color_index
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ impl<P: DeserializeOwned + JsonSchema + 'static> Contract for TypedContract<P> {
|
||||
}
|
||||
|
||||
/// The most keys of one call that are looked at, and the longest key given a suggestion.
|
||||
const MAX_KEYS: usize = 64;
|
||||
pub(crate) const MAX_KEYS: usize = 64;
|
||||
const MAX_KEY_LEN: usize = 64;
|
||||
const MAX_MESSAGE_LEN: usize = 300;
|
||||
const MAX_WHOLE_LEN: usize = 4096;
|
||||
@@ -260,6 +260,20 @@ pub(crate) fn parse_issue(e: &serde_path_to_error::Error<serde_json::Error>) ->
|
||||
Issue { path, message: truncate(&message, MAX_MESSAGE_LEN), suggestion: None }
|
||||
}
|
||||
|
||||
/// Run `handler` as the command `id`, checking the call against the contract `id` publishes
|
||||
/// (its [`CommandSpec::contract`](crate::cmd::CommandSpec::contract)), so the contract enforced is
|
||||
/// always the one described. With the level off and no audit recorder, this is exactly
|
||||
/// `handler(s, p)`, without looking anything up; a command without a contract just runs.
|
||||
pub fn run_checked_as(s: &mut Session, p: &Value, id: &str, handler: Run) -> Result<Value> {
|
||||
if s.param_check == ParamCheck::Off && !record::enabled() {
|
||||
return handler(s, p);
|
||||
}
|
||||
match crate::cmd::find_command(id).and_then(|c| c.contract) {
|
||||
Some(contract) => run_checked(s, p, id, contract, handler),
|
||||
None => handler(s, p),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `handler` as the command `id`, checking the call against `contract` as the session's
|
||||
/// [`ParamCheck`] level asks (see the module docs). With the level off and no audit recorder,
|
||||
/// this is exactly `handler(s, p)`.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! New commands come with schemas: every command has a contract (its input schema) and an
|
||||
//! output schema, apart from `planned!` placeholders and the commands listed in
|
||||
//! `unconverted.txt`. That list holds the commands that predate the rule; it only shrinks.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::command_specs;
|
||||
use crate::schema::ParamCheck;
|
||||
use crate::schema::check::MAX_KEYS;
|
||||
|
||||
/// Whether a command without schemas fails the test. Off for now: the test warns instead. The
|
||||
/// owner will flip this to `true` when the PR backlog is down. `SOLIDCRAFT_SCHEMA_RULE_STRICT=1`
|
||||
/// turns it on for one run.
|
||||
const SCHEMA_RULE_STRICT: bool = false;
|
||||
|
||||
const UNCONVERTED: &str = include_str!("unconverted.txt");
|
||||
|
||||
/// The ids in `unconverted.txt`, in file order (`#` lines are comments).
|
||||
fn unconverted() -> Vec<&'static str> {
|
||||
UNCONVERTED.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_has_input_and_output_schemas() {
|
||||
let listed: BTreeSet<&str> = unconverted().into_iter().collect();
|
||||
let mut problems = Vec::new();
|
||||
let mut missing = Vec::new();
|
||||
for c in command_specs() {
|
||||
let has = (c.contract.is_some(), c.output.is_some());
|
||||
let listed = listed.contains(c.id);
|
||||
if c.planned.is_some() {
|
||||
if listed {
|
||||
problems.push(format!("`{}` is a planned! placeholder, which needs no line in unconverted.txt", c.id));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match (has, listed) {
|
||||
((true, true), true) => problems.push(format!("`{}` has its schemas now: remove its line from unconverted.txt", c.id)),
|
||||
((true, true), false) | (_, true) => {}
|
||||
((input, output), false) => missing.push(format!(
|
||||
"`{}` has no {} schema. Every new command needs a contract (`.contract(…)`, run through `checked!`) \
|
||||
and an output type (`.output(…)`): see docs/book/src/dev/schemas.md",
|
||||
c.id,
|
||||
match (input, output) {
|
||||
(true, _) => "output",
|
||||
(false, true) => "input",
|
||||
(false, false) => "input or output",
|
||||
}
|
||||
)),
|
||||
}
|
||||
}
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
if !missing.is_empty() {
|
||||
let strict = SCHEMA_RULE_STRICT || std::env::var_os("SOLIDCRAFT_SCHEMA_RULE_STRICT").is_some_and(|v| v != "0");
|
||||
let report = format!(
|
||||
"{} command(s) lack an input or output schema. This is a warning for now; it will become required:\n{}",
|
||||
missing.len(),
|
||||
missing.join("\n")
|
||||
);
|
||||
assert!(!strict, "{report}");
|
||||
eprintln!("warning: {report}");
|
||||
// A GitHub Actions annotation, so the warning shows on the CI run page too.
|
||||
println!("::warning title=Commands without schemas::{}", missing.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// A contract that is published but not checked would describe calls nobody refuses: every
|
||||
/// command with a contract runs through `checked!` with its own id, which checks the contract
|
||||
/// that id publishes. Calls `run` itself (not `Session::execute`, which first asks `enabled`)
|
||||
/// in strict with an unknown key, which must be refused under the command's own id before the
|
||||
/// handler sees it.
|
||||
#[test]
|
||||
fn every_contract_is_checked_in_front_of_its_handler() {
|
||||
const PROBE: &str = "not_a_key_of_any_command";
|
||||
for c in command_specs() {
|
||||
let Some(contract) = c.contract else { continue };
|
||||
// The check reads at most `MAX_KEYS` keys of a call, so a contract can't take more.
|
||||
let keys = contract.allowed_keys().len();
|
||||
assert!(keys <= MAX_KEYS, "`{}` takes {keys} keys; a call may have at most {MAX_KEYS}, so no call could use them all", c.id);
|
||||
let mut s = Session { param_check: ParamCheck::Strict, ..Session::default() };
|
||||
let run = c.run;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&mut s, &json!({PROBE: 1}))));
|
||||
let fix = format!("give its CommandSpec `crate::checked!(\"{}\", <its handler>)`", c.id);
|
||||
let error = match result {
|
||||
Ok(Err(e)) => e.to_string(),
|
||||
Ok(Ok(v)) => panic!("`{}` ran with an unknown key in strict ({v}): {fix}", c.id),
|
||||
Err(_) => panic!("`{}` panicked on an unknown key in strict: {fix}", c.id),
|
||||
};
|
||||
let expected = format!("invalid parameters for `{}`: unknown key `{PROBE}`", c.id);
|
||||
assert!(error.starts_with(&expected), "`{}` should refuse with \"{expected}\", not \"{error}\": {fix}", c.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_unconverted_list_is_sorted_and_names_real_commands() {
|
||||
let ids = unconverted();
|
||||
for pair in ids.windows(2) {
|
||||
assert!(pair[0] < pair[1], "unconverted.txt must be sorted, each id once: `{}` comes before `{}`", pair[0], pair[1]);
|
||||
}
|
||||
let registered: BTreeSet<&str> = command_specs().iter().map(|c| c.id).collect();
|
||||
let stale: Vec<&str> = ids.into_iter().filter(|id| !registered.contains(id)).collect();
|
||||
assert!(stale.is_empty(), "unconverted.txt names commands that don't exist (remove their lines): {stale:?}");
|
||||
}
|
||||
@@ -36,12 +36,20 @@
|
||||
//! `strict_keeps_todays_errors`. Then run the whole test suite with the audit recorder (see
|
||||
//! `record.rs`): a call that succeeds today but breaks the contract is either a form the
|
||||
//! contract is missing or a caller's mistake.
|
||||
//!
|
||||
//! New commands come with both schemas: `coverage_tests.rs` warns about a command without a
|
||||
//! contract or an output type (it will fail once `SCHEMA_RULE_STRICT` flips), unless it is a `planned!` placeholder or listed in
|
||||
//! `unconverted.txt`, which holds the commands that predate the rule. That list only shrinks:
|
||||
//! converting a command removes its line, and nothing is added to it. How to write a contract,
|
||||
//! with the shared types and a checklist: docs/book/src/dev/schemas.md.
|
||||
|
||||
mod check;
|
||||
#[cfg_attr(target_arch = "wasm32", path = "record_wasm.rs")]
|
||||
mod record;
|
||||
mod types;
|
||||
|
||||
#[cfg(test)]
|
||||
mod coverage_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -49,16 +57,17 @@ mod tests;
|
||||
pub mod features;
|
||||
pub mod inspect;
|
||||
|
||||
pub use check::{Contract, Issue, ParamCheck, TypedContract, input_schema_for, output_schema_for, run_checked};
|
||||
pub use check::{Contract, Issue, ParamCheck, TypedContract, input_schema_for, output_schema_for, run_checked, run_checked_as};
|
||||
pub use types::*;
|
||||
|
||||
/// A command's `run` that checks the call against `$contract` before running `$handler`
|
||||
/// (see [`run_checked`]). Expands to a non-capturing closure, which coerces to the registry's
|
||||
/// `Run` function pointer.
|
||||
/// A command's `run` that checks the call against the contract command `$id` publishes
|
||||
/// (`.contract(…)` on its `CommandSpec`) before running `$handler` (see [`run_checked_as`]), so
|
||||
/// the contract enforced is always the one described. Expands to a non-capturing closure, which
|
||||
/// coerces to the registry's `Run` function pointer.
|
||||
#[macro_export]
|
||||
#[doc(hidden)]
|
||||
macro_rules! checked {
|
||||
($id:literal, $contract:path, $handler:path) => {
|
||||
|s: &mut $crate::Session, p: &serde_json::Value| $crate::schema::run_checked(s, p, $id, &$contract, $handler)
|
||||
($id:literal, $handler:path) => {
|
||||
|s: &mut $crate::Session, p: &serde_json::Value| $crate::schema::run_checked_as(s, p, $id, $handler)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
# Commands that predate the rule that every command has input and output schemas
|
||||
# (coverage_tests.rs). This list only shrinks: when you give a command its contract and output
|
||||
# type, remove its line. Never add one: a new command comes with both schemas. Sorted, one id
|
||||
# per line.
|
||||
appearance.assign
|
||||
appearance.library
|
||||
appearance.list
|
||||
assembly.bom
|
||||
body.rename
|
||||
browser.collapse_all
|
||||
browser.expand_all
|
||||
browser.group
|
||||
browser.move
|
||||
browser.order
|
||||
browser.rename_group
|
||||
browser.ungroup
|
||||
browser.visibility
|
||||
canvas.calibrate
|
||||
canvas.decal
|
||||
canvas.delete
|
||||
canvas.edit
|
||||
canvas.insert
|
||||
component.activate
|
||||
component.break_link
|
||||
component.capture_position
|
||||
component.copies
|
||||
component.create
|
||||
component.delete
|
||||
component.extract
|
||||
component.from_bodies
|
||||
component.insert_design
|
||||
component.links
|
||||
component.list
|
||||
component.metadata
|
||||
component.metadata.set
|
||||
component.move_bodies
|
||||
component.move_sketches
|
||||
component.paste_new
|
||||
component.properties
|
||||
component.relink
|
||||
component.rename
|
||||
component.replace_with_link
|
||||
component.revert_position
|
||||
component.unlock
|
||||
component.update_links
|
||||
config.activate
|
||||
config.column
|
||||
config.configure
|
||||
config.preview
|
||||
config.row
|
||||
config.rule
|
||||
config.rules
|
||||
config.table
|
||||
construct.axis.edge
|
||||
construct.axis.normal_to_face
|
||||
construct.axis.through_cylinder
|
||||
construct.axis.two_planes
|
||||
construct.axis.two_points
|
||||
construct.plane.along_path
|
||||
construct.plane.at_angle
|
||||
construct.plane.midplane
|
||||
construct.plane.offset
|
||||
construct.plane.perpendicular
|
||||
construct.plane.tangent
|
||||
construct.plane.three_points
|
||||
construct.plane.two_edges
|
||||
construct.point.along_path
|
||||
construct.point.center
|
||||
construct.point.edge_plane
|
||||
construct.point.three_planes
|
||||
construct.point.two_edges
|
||||
construct.point.vertex
|
||||
contact.check
|
||||
contact.create
|
||||
contact.disable_all
|
||||
contact.edit
|
||||
contact.enable_all
|
||||
contact.enable_sets
|
||||
contact.list
|
||||
diagnostics.crash_report
|
||||
diagnostics.crash_reports
|
||||
diagnostics.crash_reports.clear
|
||||
diagnostics.crash_test
|
||||
doc.open
|
||||
doc.recover
|
||||
doc.recovery_discard
|
||||
doc.recovery_list
|
||||
document.inspect
|
||||
document.metadata
|
||||
document.metadata.remove
|
||||
document.metadata.set
|
||||
document.units
|
||||
edit.checkpoint
|
||||
edit.redo
|
||||
edit.restore_checkpoint
|
||||
edit.undo
|
||||
engine.commands
|
||||
explode.create
|
||||
explode.delete
|
||||
explode.list
|
||||
explode.show
|
||||
expr.evaluate
|
||||
face.delete
|
||||
file.export
|
||||
file.export_view_dxf
|
||||
file.insert_mesh
|
||||
file.insert_step
|
||||
file.new
|
||||
file.save
|
||||
file.save_as
|
||||
file.save_mesh
|
||||
file.slicers
|
||||
frame.member
|
||||
frame.profile.insert
|
||||
frame.profile.list
|
||||
frame.profile.properties
|
||||
inspect.accessibility
|
||||
inspect.center_of_mass
|
||||
inspect.clearance
|
||||
inspect.compare
|
||||
inspect.continuity
|
||||
inspect.curvature_comb
|
||||
inspect.curvature_map
|
||||
inspect.draft
|
||||
inspect.environment_map
|
||||
inspect.interference
|
||||
inspect.isocurve
|
||||
inspect.measure
|
||||
inspect.minimum_radius
|
||||
inspect.parameter_sweep
|
||||
inspect.section
|
||||
inspect.silhouette
|
||||
inspect.zebra
|
||||
joint.as_built
|
||||
joint.cam
|
||||
joint.create
|
||||
joint.delete
|
||||
joint.drive
|
||||
joint.edit
|
||||
joint.gear
|
||||
joint.inspect
|
||||
joint.limits
|
||||
joint.list
|
||||
joint.motion_link
|
||||
joint.origin
|
||||
joint.rack_pinion
|
||||
joint.reach
|
||||
joint.rigid_group
|
||||
joint.screw
|
||||
joint.solve
|
||||
joint.sweep
|
||||
library.apply
|
||||
library.export
|
||||
library.inspect
|
||||
material.assign
|
||||
material.create
|
||||
material.delete
|
||||
material.edit
|
||||
material.inspect
|
||||
material.list
|
||||
material.properties.directional
|
||||
material.properties.validate
|
||||
mesh.convert
|
||||
model.edges
|
||||
model.faces
|
||||
model.threads
|
||||
motion.delete
|
||||
motion.export
|
||||
motion.export_animation
|
||||
motion.interference
|
||||
motion.list
|
||||
motion.play
|
||||
motion.plot
|
||||
motion.study
|
||||
motion.trace
|
||||
motion.trace.edit
|
||||
motion.trace.keep
|
||||
occurrence.copy
|
||||
occurrence.drag
|
||||
occurrence.ground
|
||||
occurrence.move
|
||||
occurrence.properties
|
||||
parameters.add
|
||||
parameters.change
|
||||
parameters.comment
|
||||
parameters.complete
|
||||
parameters.delete
|
||||
parameters.export
|
||||
parameters.favorite
|
||||
parameters.graph
|
||||
parameters.import
|
||||
parameters.list
|
||||
parameters.move
|
||||
parameters.rename
|
||||
parameters.users
|
||||
parts.fastener
|
||||
parts.insert
|
||||
parts.library
|
||||
parts.resize
|
||||
plastic.assign_rule
|
||||
plastic.boss
|
||||
plastic.lip
|
||||
plastic.manage_rules
|
||||
plastic.rest
|
||||
plastic.rules
|
||||
plastic.snap_fit
|
||||
render.aging.describe
|
||||
render.backends
|
||||
render.camera.fit
|
||||
render.camera.get
|
||||
render.camera.set
|
||||
render.cancel
|
||||
render.effects.describe
|
||||
render.export
|
||||
render.hdr.inspect
|
||||
render.library.appearance.apply
|
||||
render.library.describe
|
||||
render.library.environment.apply
|
||||
render.library.list
|
||||
render.library.physical.prepare
|
||||
render.library.preview
|
||||
render.library.studio.apply
|
||||
render.library.studio.prepare
|
||||
render.library.surface.bind
|
||||
render.library.surface.prepare
|
||||
render.library.surface.unbind
|
||||
render.library.targets
|
||||
render.optics.bind
|
||||
render.optics.unbind
|
||||
render.preflight
|
||||
render.preset.apply
|
||||
render.preset.resolve
|
||||
render.quality.prepare
|
||||
render.result.pin
|
||||
render.result.regrade
|
||||
render.scene
|
||||
render.settings.apply
|
||||
render.settings.capture_hdr
|
||||
render.settings.get
|
||||
render.settings.set
|
||||
render.shot.duplicate
|
||||
render.shot.remove
|
||||
render.shot.rename
|
||||
render.stage.backdrop
|
||||
render.stage.clear
|
||||
render.stage.ground
|
||||
render.start
|
||||
render.status
|
||||
select.clear
|
||||
select.set
|
||||
selection.delete
|
||||
sheet.convert
|
||||
sheet.export_bend_table
|
||||
sheet.export_dxf
|
||||
sheet.flange
|
||||
sheet.flat_pattern
|
||||
sheet.fold
|
||||
sheet.hem
|
||||
sheet.manage_rules
|
||||
sheet.path_flange
|
||||
sheet.perforate
|
||||
sheet.refold
|
||||
sheet.rules
|
||||
sheet.unfold
|
||||
sketch.arc.center_point
|
||||
sketch.arc.tangent
|
||||
sketch.arc.three_point
|
||||
sketch.auto_constrain
|
||||
sketch.auto_constrain_from_datum
|
||||
sketch.auto_project
|
||||
sketch.blend_curve
|
||||
sketch.break
|
||||
sketch.break_link
|
||||
sketch.cancel
|
||||
sketch.centerline
|
||||
sketch.chain
|
||||
sketch.chamfer.distance_angle
|
||||
sketch.chamfer.equal_distance
|
||||
sketch.chamfer.two_distance
|
||||
sketch.circle.center
|
||||
sketch.circle.three_point
|
||||
sketch.circle.three_tangent
|
||||
sketch.circle.two_point
|
||||
sketch.circle.two_tangent
|
||||
sketch.conic
|
||||
sketch.constrainer
|
||||
sketch.constraint.coincident
|
||||
sketch.constraint.collinear
|
||||
sketch.constraint.concentric
|
||||
sketch.constraint.curvature
|
||||
sketch.constraint.equal
|
||||
sketch.constraint.fix
|
||||
sketch.constraint.horizontal_vertical
|
||||
sketch.constraint.midpoint
|
||||
sketch.constraint.parallel
|
||||
sketch.constraint.perpendicular
|
||||
sketch.constraint.polygon
|
||||
sketch.constraint.symmetry
|
||||
sketch.constraint.tangent
|
||||
sketch.construction
|
||||
sketch.copy
|
||||
sketch.create
|
||||
sketch.cut
|
||||
sketch.delete
|
||||
sketch.dimension
|
||||
sketch.dimension_text
|
||||
sketch.drag
|
||||
sketch.drag_end
|
||||
sketch.draw.dimensioned
|
||||
sketch.edit
|
||||
sketch.edit_text
|
||||
sketch.ellipse
|
||||
sketch.export_dxf
|
||||
sketch.extend
|
||||
sketch.fillet
|
||||
sketch.finish
|
||||
sketch.finish_auto_constrain
|
||||
sketch.fit_curves_to_section
|
||||
sketch.glyphs
|
||||
sketch.include_3d
|
||||
sketch.infinite
|
||||
sketch.insert_dxf
|
||||
sketch.insert_svg
|
||||
sketch.inspect
|
||||
sketch.intersect
|
||||
sketch.intersection_curve
|
||||
sketch.isoparametric_curve
|
||||
sketch.line
|
||||
sketch.line.midpoint
|
||||
sketch.line3d
|
||||
sketch.loose_ends
|
||||
sketch.loose_ends.tie
|
||||
sketch.mirror
|
||||
sketch.move
|
||||
sketch.move3d
|
||||
sketch.move_point
|
||||
sketch.offset
|
||||
sketch.offset.break
|
||||
sketch.options
|
||||
sketch.paste
|
||||
sketch.pattern.circular
|
||||
sketch.pattern.rectangular
|
||||
sketch.point
|
||||
sketch.point3d
|
||||
sketch.polygon.circumscribed
|
||||
sketch.polygon.edge
|
||||
sketch.polygon.inscribed
|
||||
sketch.project
|
||||
sketch.project_to_surface
|
||||
sketch.rectangle.center
|
||||
sketch.rectangle.rounded
|
||||
sketch.rectangle.three_point
|
||||
sketch.rectangle.two_point
|
||||
sketch.redefine
|
||||
sketch.scale
|
||||
sketch.slot.arc_center
|
||||
sketch.slot.arc_three_point
|
||||
sketch.slot.center_point
|
||||
sketch.slot.center_to_center
|
||||
sketch.slot.overall
|
||||
sketch.snap
|
||||
sketch.solve
|
||||
sketch.spline.control_point
|
||||
sketch.spline.control_point_5
|
||||
sketch.spline.fit_point
|
||||
sketch.spline3d
|
||||
sketch.spun_profile
|
||||
sketch.text
|
||||
sketch.toggle_driven
|
||||
sketch.trim
|
||||
sketch.trim.path
|
||||
solid.align
|
||||
solid.boundary_fill
|
||||
solid.bounding_solid
|
||||
solid.chamfer
|
||||
solid.coil
|
||||
solid.combine
|
||||
solid.cylinder
|
||||
solid.draft
|
||||
solid.emboss
|
||||
solid.fillet
|
||||
solid.hole
|
||||
solid.loft
|
||||
solid.mirror
|
||||
solid.move
|
||||
solid.offset_face
|
||||
solid.pattern.circular
|
||||
solid.pattern.path
|
||||
solid.pattern.rectangular
|
||||
solid.pipe
|
||||
solid.press_pull
|
||||
solid.remove
|
||||
solid.replace_face
|
||||
solid.revolve
|
||||
solid.rib
|
||||
solid.scale
|
||||
solid.shell
|
||||
solid.sphere
|
||||
solid.split_body
|
||||
solid.split_face
|
||||
solid.sweep
|
||||
solid.thread
|
||||
solid.torus
|
||||
solid.web
|
||||
surface.blend
|
||||
surface.boundary
|
||||
surface.extend
|
||||
surface.extrude
|
||||
surface.fair
|
||||
surface.loft
|
||||
surface.match
|
||||
surface.offset
|
||||
surface.patch
|
||||
surface.reverse_normal
|
||||
surface.revolve
|
||||
surface.ruled
|
||||
surface.stitch
|
||||
surface.sweep
|
||||
surface.thicken
|
||||
surface.trim
|
||||
surface.unstitch
|
||||
surface.untrim
|
||||
timeline.add
|
||||
timeline.compute_all
|
||||
timeline.copy
|
||||
timeline.delete
|
||||
timeline.dependents
|
||||
timeline.edit
|
||||
timeline.export_script
|
||||
timeline.group
|
||||
timeline.inspect
|
||||
timeline.move_many
|
||||
timeline.move_to_group
|
||||
timeline.paste
|
||||
timeline.redefine
|
||||
timeline.rename
|
||||
timeline.rename_group
|
||||
timeline.reorder
|
||||
timeline.reorder_many
|
||||
timeline.roll_to
|
||||
timeline.rollback
|
||||
timeline.suppress
|
||||
timeline.ungroup
|
||||
view.ambient_occlusion
|
||||
view.apply
|
||||
view.back
|
||||
view.bottom
|
||||
view.delete
|
||||
view.front
|
||||
view.graphics_preset
|
||||
view.history_layout
|
||||
view.isometric
|
||||
view.layout
|
||||
view.left
|
||||
view.list
|
||||
view.navigate
|
||||
view.normal_to
|
||||
view.rename
|
||||
view.right
|
||||
view.save
|
||||
view.top
|
||||
+161
-15
@@ -209,6 +209,116 @@ pub fn grid_step(half_height: f64) -> (f64, f64) {
|
||||
(minor, minor * 5.0)
|
||||
}
|
||||
|
||||
/// How the grid fades out with the distance from the middle of the view (#4214).
|
||||
///
|
||||
/// The grid used to stop at a fixed distance, so its edge showed as a cut across the picture. It
|
||||
/// now fades: at full strength near the middle of the view, and gone before the grid's own edge
|
||||
/// and before the picture's far plane cuts the ground, so that neither of them shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct GridFade {
|
||||
/// The distance from the middle of the view where the grid starts to dim.
|
||||
pub start: f64,
|
||||
/// The distance from the middle of the view where the grid is gone.
|
||||
pub end: f64,
|
||||
}
|
||||
|
||||
impl GridFade {
|
||||
/// The fade for a view of the given half height, seen at the given slant (the cosine of the
|
||||
/// angle between the view and the grid's plane), over a grid of the given half size, that
|
||||
/// reaches no further than `far_reach` before the picture's far plane cuts the ground.
|
||||
pub fn new(half_height: f64, slant: f64, ext: f64, far_reach: f64) -> Self {
|
||||
// The distance from the middle of the view to its edge, measured on the grid's plane: the
|
||||
// ground stretches as it is seen at a slant, as the grid's own size does (#3087).
|
||||
let reach = half_height / slant.abs().max(0.25);
|
||||
// The grid is gone before its own edge, and before the far plane cuts the ground, so that
|
||||
// neither of them shows as a cut.
|
||||
let end = (2.2 * reach).min(ext).min(far_reach);
|
||||
GridFade { start: 0.6 * reach.min(end), end }
|
||||
}
|
||||
|
||||
/// How much of the grid is left at a distance from the middle of the view.
|
||||
pub fn alpha(&self, d: f64) -> f32 {
|
||||
if !d.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
if d <= self.start {
|
||||
return 1.0;
|
||||
}
|
||||
if d >= self.end || self.end <= self.start {
|
||||
return 0.0;
|
||||
}
|
||||
((self.end - d) / (self.end - self.start)) as f32
|
||||
}
|
||||
|
||||
/// The pieces the grid's line at the given offset from the middle is drawn in: each with the
|
||||
/// alpha the fade gives it at the distance where it lies. The line runs along the grid's other
|
||||
/// axis; it is drawn whole where the grid is at full strength, and in pieces that fade
|
||||
/// towards its ends.
|
||||
pub fn pieces(&self, offset: f64) -> Vec<(f64, f64, f32)> {
|
||||
let mut out = Vec::new();
|
||||
let off = offset.abs();
|
||||
if !(self.end > 0.0) || off >= self.end {
|
||||
return out;
|
||||
}
|
||||
// Where the grid is still at full strength.
|
||||
let solid = if off < self.start { (self.start * self.start - off * off).max(0.0).sqrt() } else { 0.0 };
|
||||
if solid > 0.0 {
|
||||
out.push((-solid, solid, 1.0));
|
||||
}
|
||||
// The fading ends, in pieces whose distance from the middle grows evenly, so that the
|
||||
// grid dims evenly too.
|
||||
let from = off.max(self.start);
|
||||
for k in 0..GRID_FADE_STEPS {
|
||||
let (d0, d1) =
|
||||
(from + (self.end - from) * k as f64 / GRID_FADE_STEPS as f64, from + (self.end - from) * (k + 1) as f64 / GRID_FADE_STEPS as f64);
|
||||
let (s0, s1) = ((d0 * d0 - off * off).max(0.0).sqrt(), (d1 * d1 - off * off).max(0.0).sqrt());
|
||||
let alpha = self.alpha((d0 + d1) * 0.5);
|
||||
if alpha > 0.0 {
|
||||
out.push((s0, s1, alpha));
|
||||
out.push((-s1, -s0, alpha));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// How many pieces a grid line's fading end is drawn in: enough that the grid dims evenly, few
|
||||
/// enough that the grid stays cheap to draw.
|
||||
const GRID_FADE_STEPS: u32 = 12;
|
||||
|
||||
/// How far the grid reaches before the picture's far plane cuts the ground: the distance on the
|
||||
/// grid's plane from the middle of the view to where the far plane ends it (#4214). Looking
|
||||
/// straight down, the ground's depth does not grow and the far plane never cuts it.
|
||||
///
|
||||
/// `c` is the middle of the view and `n` the grid's normal, both in the world.
|
||||
pub fn grid_far_reach(cam: &Camera, far: f64, c: Vec3, n: Vec3) -> f64 {
|
||||
let eye = cam.eye();
|
||||
// The direction the camera looks, and where it looks on the grid's plane: the ground's
|
||||
// forward direction, which the depth grows along.
|
||||
let view = -cam.back();
|
||||
let forward = view - n * view.dot(n);
|
||||
let len = forward.len();
|
||||
if len < 1e-9 {
|
||||
return f64::INFINITY;
|
||||
}
|
||||
let depth = (c - eye).dot(view);
|
||||
if far <= depth {
|
||||
return 0.0;
|
||||
}
|
||||
(far - depth) / len
|
||||
}
|
||||
|
||||
/// The grid's half size for a view of the given half height, seen at the given slant (the cosine
|
||||
/// of the angle between the view and the grid's plane): the grid reaches as far on its plane as
|
||||
/// the view is wide, which stretches as the plane is seen at a slant (#3087).
|
||||
///
|
||||
/// The picture and the window share it, so that they draw the same grid (#4214).
|
||||
pub fn grid_ext(half_height: f64, major: f64, slant: f64) -> f64 {
|
||||
let major = major.max(1e-3);
|
||||
let ext = (half_height * 3.0 / major).ceil() * major;
|
||||
(ext / slant.abs().max(0.25) / major).ceil() * major
|
||||
}
|
||||
|
||||
/// Sketch curves as world polylines with their colour.
|
||||
pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool]) -> Vec<(Vec<Vec3>, Rgb, bool)> {
|
||||
sketch_lines_with_sampler(sk, plane, active, determined, world_curve_parts)
|
||||
@@ -348,6 +458,12 @@ pub fn sketch_point_visible(sk: &Sketch, i: usize) -> bool {
|
||||
sk.points.get(i).is_some_and(|p| SketchPointVisibility::new(sk).takes(p))
|
||||
}
|
||||
|
||||
/// Stable identification colour shared by every body of a component.
|
||||
pub fn component_color(s: &Session, component: u64) -> Option<[u8; 3]> {
|
||||
s.colored_components?;
|
||||
Some(s.doc.component_identification_color(component))
|
||||
}
|
||||
|
||||
/// The scene for a headless render, cut by the session's section.
|
||||
pub fn scene(s: &Session, cam: &Camera) -> Scene {
|
||||
scene_with_section(s, cam, s.section)
|
||||
@@ -474,34 +590,52 @@ fn build_scene(
|
||||
// Grid on the ground plane (XY with Z up, XZ with Y up), drawn under the model as in the viewport.
|
||||
if guides.grid {
|
||||
let (minor, major) = grid_step(cam.half_height());
|
||||
let ext = (cam.half_height() * 3.0 / major).ceil() * major;
|
||||
// Headless pictures keep the grid on the ground, the viewport's default; an open sketch
|
||||
// has its own plane's grid (world coordinates, no camera frame).
|
||||
let (c, u, v, w): (Vec3, Vec3, Vec3, Box<dyn Fn(Vec3) -> Vec3>) = if let Some(pl) = open_sketch_plane(s) {
|
||||
let (c, u, v, slant, w): (Vec3, Vec3, Vec3, f64, Box<dyn Fn(Vec3) -> Vec3>) = if let Some(pl) = open_sketch_plane(s) {
|
||||
let (c, u, v) = sketch_grid_frame(&pl, cam.target, major);
|
||||
(c, u, v, Box::new(|p| p))
|
||||
let n = u.cross(v);
|
||||
(c, u, v, cam.back().dot(n), Box::new(|p| p))
|
||||
} else {
|
||||
let tf = cam.to_frame(cam.target);
|
||||
let (u, v) = (Vec3::X, Vec3::Y);
|
||||
let back = cam.to_frame(cam.back());
|
||||
let n = u.cross(v);
|
||||
let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major);
|
||||
(snap(u) + snap(v), u, v, Box::new(|p| cam.from_frame(p)))
|
||||
(snap(u) + snap(v), u, v, back.dot(n), Box::new(|p| cam.from_frame(p)))
|
||||
};
|
||||
let n = ((ext / minor) as i64).clamp(1, 400);
|
||||
// The grid reaches as far on the ground as the view is wide (the ground stretches as it
|
||||
// is seen at a slant) and fades out with the distance, so that it dissolves into the
|
||||
// distance instead of ending in a cut (#4214).
|
||||
let ext = grid_ext(cam.half_height(), major, slant);
|
||||
let far_reach = grid_far_reach(cam, cam.clip(sc.radius).1, w(c), w(u.cross(v)));
|
||||
let fade = GridFade::new(cam.half_height(), slant, ext, far_reach);
|
||||
let n = ((fade.end / minor) as i64).clamp(1, 400);
|
||||
for i in -n..=n {
|
||||
let t = i as f64 * minor;
|
||||
let major_line = (t / major).fract().abs() < 1e-9;
|
||||
let col = if major_line { p.grid_major } else { p.grid };
|
||||
// The viewport's widths (pixels).
|
||||
let width = if major_line { 1.0 } else { 0.7 };
|
||||
sc.grid.push(SceneLine { points: vec![w(c + u * t - v * ext), w(c + u * t + v * ext)], color: col, width, on_top: false });
|
||||
sc.grid.push(SceneLine { points: vec![w(c - u * ext + v * t), w(c + u * ext + v * t)], color: col, width, on_top: false });
|
||||
// Two lines through the point `c + u * t`: one along v, one along u.
|
||||
for (along, across) in [(v, u), (u, v)] {
|
||||
for (s0, s1, alpha) in fade.pieces(t) {
|
||||
sc.grid.push(SceneLine {
|
||||
points: vec![w(c + across * t + along * s0), w(c + across * t + along * s1)],
|
||||
color: col,
|
||||
alpha,
|
||||
width,
|
||||
on_top: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The origin's axes, each unless hidden one by one (`browser.visibility`), as in the viewport.
|
||||
let axis = cam.half_height() * 0.25;
|
||||
for ((name, d), col) in [("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)].into_iter().zip(p.axes) {
|
||||
if guides.axes && !s.visibility.hidden_origin.iter().any(|h| h == name) {
|
||||
sc.lines.push(SceneLine { points: vec![Vec3::ZERO, d * axis], color: col, width: 1.6, on_top: false });
|
||||
sc.lines.push(SceneLine { points: vec![Vec3::ZERO, d * axis], color: col, alpha: 1.0, width: 1.6, on_top: false });
|
||||
}
|
||||
}
|
||||
let selected_occurrences: Vec<String> = s
|
||||
@@ -520,8 +654,14 @@ fn build_scene(
|
||||
// The body's appearance (or imported colour) and faces with looks of their own, as in
|
||||
// the viewport.
|
||||
let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let color = if selected { p.body_selected } else { s.doc.body_color(body).map_or(p.body, |[r, g, b]| Rgb(to8(r), to8(g), to8(b))) };
|
||||
let face_colors = if selected {
|
||||
let color = if selected {
|
||||
p.body_selected
|
||||
} else if let Some([r, g, b]) = component_color(s, body.component) {
|
||||
Rgb(r, g, b)
|
||||
} else {
|
||||
s.doc.body_color(body).map_or(p.body, |[r, g, b]| Rgb(to8(r), to8(g), to8(b)))
|
||||
};
|
||||
let face_colors = if selected || s.colored_components.is_some() {
|
||||
Vec::new()
|
||||
} else {
|
||||
s.doc.face_colors(body).into_iter().map(|(f, l)| (f as u32, Rgb(l.color[0], l.color[1], l.color[2]))).collect()
|
||||
@@ -536,19 +676,19 @@ fn build_scene(
|
||||
&& s.doc.face_colors(body).iter().all(|(_, l)| l.opacity >= 0.999)
|
||||
{
|
||||
for (a, b) in crate::cosmetic_threads::segments_with_budget(body, &mesh, &st.threads, &mut thread_budget) {
|
||||
sc.lines.push(SceneLine { points: vec![a, b], color: p.edge, width: 0.85, on_top: false });
|
||||
sc.lines.push(SceneLine { points: vec![a, b], color: p.edge, alpha: 1.0, width: 0.85, on_top: false });
|
||||
}
|
||||
}
|
||||
for (i, e) in mesh.edges.iter().enumerate() {
|
||||
if !mesh.shaded_edge_visible(i) {
|
||||
continue;
|
||||
}
|
||||
sc.lines.push(SceneLine { points: e.clone(), color: p.edge, width: 1.2, on_top: false });
|
||||
sc.lines.push(SceneLine { points: e.clone(), color: p.edge, alpha: 1.0, width: 1.2, on_top: false });
|
||||
}
|
||||
// The outline of the body where the section cuts it.
|
||||
if let Some((o, n)) = section {
|
||||
for (a, b) in section_segments(&mesh, o, n) {
|
||||
sc.lines.push(SceneLine { points: vec![a, b], color: p.edge, width: 1.6, on_top: false });
|
||||
sc.lines.push(SceneLine { points: vec![a, b], color: p.edge, alpha: 1.0, width: 1.6, on_top: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -558,7 +698,7 @@ fn build_scene(
|
||||
continue;
|
||||
}
|
||||
for (pts, col, cons) in sketch_lines_in(&ss.sketch, &ss.plane, active, &ss.report.curve_determined, &mut sampler, p) {
|
||||
sc.lines.push(SceneLine { points: pts, color: col, width: if cons { 1.0 } else { 1.6 }, on_top: active });
|
||||
sc.lines.push(SceneLine { points: pts, color: col, alpha: 1.0, width: if cons { 1.0 } else { 1.6 }, on_top: active });
|
||||
}
|
||||
}
|
||||
sc
|
||||
@@ -921,7 +1061,13 @@ mod tests {
|
||||
assert!(!section_segments(&s.world_state().bodies[0].mesh(), Vec3::new(0.0, 15.0, 0.0), -Vec3::Y).is_empty());
|
||||
// A marker inside the body, behind the cut face.
|
||||
let red = Rgb(255, 0, 0);
|
||||
sc.lines.push(SceneLine { points: vec![Vec3::new(5.0, 25.0, 10.0), Vec3::new(35.0, 25.0, 10.0)], color: red, width: 3.0, on_top: false });
|
||||
sc.lines.push(SceneLine {
|
||||
points: vec![Vec3::new(5.0, 25.0, 10.0), Vec3::new(35.0, 25.0, 10.0)],
|
||||
color: red,
|
||||
alpha: 1.0,
|
||||
width: 3.0,
|
||||
on_top: false,
|
||||
});
|
||||
let img = solidcraft_render::render(&sc, &cam, 300, 220);
|
||||
let px = || img.rgba.chunks(4);
|
||||
let marker = px().filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count();
|
||||
|
||||
@@ -189,3 +189,84 @@ fn scene_sampler_uses_exact_active_occurrence_plane_and_preserves_other_scene_co
|
||||
let hidden = scene_with_sketch_sampler(&s, &cam, |_, _, _| panic!("hidden sketch sampled"));
|
||||
assert!(hidden.lines.iter().all(|line| !line.on_top));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colored_components_toggle_is_stable_and_does_not_change_the_design() {
|
||||
let mut s = Session::default();
|
||||
let first_id = s.doc_mut().add_component(Some("First"), 0).unwrap();
|
||||
let second_id = s.doc_mut().add_component(Some("Second"), 0).unwrap();
|
||||
let before = serde_json::to_value(&s.doc).unwrap();
|
||||
assert_eq!(component_color(&s, first_id), None);
|
||||
assert_eq!(s.execute("inspect.colored_components", &json!({})).unwrap()["enabled"], true);
|
||||
s.colored_components = Some(());
|
||||
let first = component_color(&s, first_id);
|
||||
assert!(first.is_some());
|
||||
assert_ne!(first, component_color(&s, second_id));
|
||||
s.execute("inspect.colored_components", &json!({"enabled": true})).unwrap();
|
||||
assert_eq!(component_color(&s, first_id), first);
|
||||
assert!(s.execute("inspect.colored_components", &json!({"enabled": "yes"})).is_err());
|
||||
assert_eq!(component_color(&s, first_id), first);
|
||||
assert_eq!(s.execute("inspect.colored_components", &json!({})).unwrap()["enabled"], false);
|
||||
assert_eq!(component_color(&s, first_id), None);
|
||||
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colored_components_override_appearances_and_restore_them() {
|
||||
let mut s = Session::default();
|
||||
for (name, x) in [("Left", 0), ("Right", 20)] {
|
||||
s.execute("component.activate", &json!({"component": 0})).unwrap();
|
||||
s.execute("component.create", &json!({"name": name})).unwrap();
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]})).unwrap();
|
||||
}
|
||||
s.selection.clear();
|
||||
let cam = Camera::default();
|
||||
let original = scene(&s, &cam);
|
||||
s.execute("inspect.colored_components", &json!({"enabled": true})).unwrap();
|
||||
s.colored_components = Some(());
|
||||
let colored = scene(&s, &cam);
|
||||
assert_eq!(colored.meshes.len(), 2);
|
||||
assert_ne!(colored.meshes[0].color, colored.meshes[1].color);
|
||||
for (body, mesh) in s.world_state().bodies.iter().zip(&colored.meshes) {
|
||||
let [r, g, b] = component_color(&s, body.component).unwrap();
|
||||
assert_eq!(mesh.color, Rgb(r, g, b));
|
||||
assert!(mesh.face_colors.is_empty());
|
||||
}
|
||||
s.execute("inspect.colored_components", &json!({"enabled": false})).unwrap();
|
||||
let restored = scene(&s, &cam);
|
||||
for (a, b) in original.meshes.iter().zip(restored.meshes) {
|
||||
assert_eq!(a.color, b.color);
|
||||
assert_eq!(a.face_colors, b.face_colors);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycling_component_color_is_saved_undoable_and_shared_by_instances() {
|
||||
let mut s = Session::default();
|
||||
let component = s.execute("component.create", &json!({"name":"Part"})).unwrap()["component"].as_u64().unwrap();
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
s.execute("occurrence.copy", &json!({"component":component,"translate":[20,0,0]})).unwrap();
|
||||
let appearances = s.doc.appearances.clone();
|
||||
s.execute("inspect.colored_components", &json!({"enabled":true})).unwrap();
|
||||
let automatic = component_color(&s, component).unwrap();
|
||||
s.execute("inspect.colored_components", &json!({"enabled":false})).unwrap();
|
||||
s.execute("inspect.colored_components", &json!({"enabled":true})).unwrap();
|
||||
assert_eq!(component_color(&s, component), Some(automatic));
|
||||
let result = s.execute("component.cycle_color", &json!({"component":component})).unwrap();
|
||||
let custom = component_color(&s, component).unwrap();
|
||||
assert_ne!(custom, automatic);
|
||||
assert_eq!(result["color"], json!(custom));
|
||||
assert_eq!(s.doc.appearances, appearances);
|
||||
let loaded = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert_eq!(loaded.doc.component_identification_color(component), custom);
|
||||
let copies: Vec<_> = s.world_state().bodies.iter().map(|b| b.component).collect();
|
||||
assert_eq!(copies, [component, component]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(component_color(&s, component), Some(automatic));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(component_color(&s, component), Some(custom));
|
||||
s.execute("component.paste_new", &json!({"component":component,"translate":[40,0,0]})).unwrap();
|
||||
assert!(s.doc.components.iter().filter(|c| c.identification_color == Some(custom)).count() >= 2);
|
||||
assert!(s.execute("component.cycle_color", &json!({"component":"missing"})).is_err());
|
||||
assert!(s.execute("component.cycle_color", &json!({"component":0})).is_err());
|
||||
}
|
||||
|
||||
@@ -6,6 +6,75 @@ use serde_json::json;
|
||||
use solidcraft_geom::Vec2;
|
||||
use solidcraft_sketch::{LinkGeom, LinkKind, LinkSource};
|
||||
|
||||
/// #4214: the grid's fade is at full strength in the middle of the view, and gone before the
|
||||
/// grid's own edge and before the picture's far plane cuts the ground, so that neither of them
|
||||
/// shows as a cut.
|
||||
#[test]
|
||||
fn the_grid_fades_out_before_its_edge_and_before_the_far_plane() {
|
||||
let fade = GridFade::new(40.0, 1.0, 120.0, 90.0);
|
||||
assert!(fade.start < fade.end, "the fade has a width");
|
||||
assert!(fade.end <= 90.0, "the grid is gone before the far plane cuts the ground at 90");
|
||||
assert!(fade.end <= 120.0, "the grid is gone before its own edge at 120");
|
||||
assert_eq!(fade.alpha(0.0), 1.0, "the middle of the grid is at full strength");
|
||||
assert_eq!(fade.alpha(fade.start), 1.0, "the grid is at full strength up to {}", fade.start);
|
||||
assert_eq!(fade.alpha(fade.end), 0.0, "the grid is gone at {}", fade.end);
|
||||
assert_eq!(fade.alpha(fade.end * 2.0), 0.0, "the grid does not come back");
|
||||
assert_eq!(fade.alpha(f64::NAN), 0.0, "a distance that is not a number fades the grid away");
|
||||
// The fade falls evenly.
|
||||
let mid = (fade.start + fade.end) * 0.5;
|
||||
assert!((fade.alpha(mid) - 0.5).abs() < 0.01, "the fade falls evenly: {} at {mid}", fade.alpha(mid));
|
||||
}
|
||||
|
||||
/// #4214: the headless grid fades out with the distance from the middle of the view, as the
|
||||
/// viewport's does: the lines near the middle are at full strength, the ones further out are
|
||||
/// dimmer, and the grid is gone before the distance where it used to stop.
|
||||
#[test]
|
||||
fn the_headless_grid_fades_out_with_the_distance() {
|
||||
let s = Session::default();
|
||||
let mut cam = home_camera(&s);
|
||||
// A top view: the ground is the XY plane and the grid is centred on the target.
|
||||
cam.pitch = std::f64::consts::FRAC_PI_2;
|
||||
cam.target = Vec3::ZERO;
|
||||
let scene = scene(&s, &cam);
|
||||
assert!(!scene.grid.is_empty(), "the grid is drawn");
|
||||
// How far each piece of the grid is from the middle of the view, and the alpha it is drawn
|
||||
// with.
|
||||
let mut pieces: Vec<(f64, f32)> = scene
|
||||
.grid
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let mid = l.points[0] + (l.points[1] - l.points[0]) * 0.5;
|
||||
((mid.x * mid.x + mid.y * mid.y).sqrt(), l.alpha)
|
||||
})
|
||||
.collect();
|
||||
pieces.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
// The grid in the middle of the view is at full strength.
|
||||
let strongest = pieces.iter().filter(|(d, _)| *d < 1.0).map(|(_, a)| *a).fold(0.0f32, f32::max);
|
||||
assert_eq!(strongest, 1.0, "the middle of the grid is at full strength");
|
||||
// The further from the middle, the dimmer: the outer half of the grid is fainter than the
|
||||
// inner half.
|
||||
let (near, far) = pieces.split_at(pieces.len() / 2);
|
||||
let mean = |v: &[(f64, f32)]| v.iter().map(|(_, a)| f64::from(*a)).sum::<f64>() / v.len().max(1) as f64;
|
||||
assert!(mean(near) > mean(far) + 0.1, "the grid fades: {} near the middle, {} far from it", mean(near), mean(far));
|
||||
// The grid is gone before the distance where it used to stop, so its edge never shows.
|
||||
let (_, major) = grid_step(cam.half_height());
|
||||
let cut = (cam.half_height() * 3.0 / major).ceil() * major;
|
||||
let farthest = pieces.last().map(|(d, _)| *d).unwrap_or(0.0);
|
||||
assert!(farthest < cut, "the grid fades out at {farthest}, short of the cut at {cut}");
|
||||
}
|
||||
|
||||
/// #4214: the grid's half size, the one the picture and the window share: the grid reaches as far
|
||||
/// on its plane as the view is wide, and further as the plane is seen at a slant.
|
||||
#[test]
|
||||
fn the_grid_reaches_further_as_its_plane_is_seen_at_a_slant() {
|
||||
let major = grid_step(40.0).1;
|
||||
let flat = grid_ext(40.0, major, 1.0);
|
||||
assert_eq!(flat, (40.0 * 3.0 / major).ceil() * major, "seen face on, the grid reaches as far as the view is wide");
|
||||
assert!(grid_ext(40.0, major, 0.5) > flat, "seen at a slant, the grid reaches further on its plane");
|
||||
// A plane seen edge-on keeps a usable size.
|
||||
assert!(grid_ext(40.0, major, 0.0).is_finite());
|
||||
}
|
||||
|
||||
/// No colour here is one of [`colors`] or an appearance below, and each field has its own.
|
||||
const MARK: Palette = Palette {
|
||||
background: (Rgb(1, 2, 3), Rgb(4, 5, 6)),
|
||||
|
||||
@@ -430,15 +430,15 @@ pub fn scene_geometry(scene: &Scene) -> GpuScene {
|
||||
}
|
||||
}
|
||||
}
|
||||
let c4 = |c: Rgb| [c.0, c.1, c.2, 255];
|
||||
let c4 = |c: Rgb, a: f32| [c.0, c.1, c.2, (a.clamp(0.0, 1.0) * 255.0).round() as u8];
|
||||
for l in &scene.grid {
|
||||
for w in l.points.windows(2) {
|
||||
sc.under_line(w[0].to_f32(), w[1].to_f32(), c4(l.color), l.width as f32);
|
||||
sc.under_line(w[0].to_f32(), w[1].to_f32(), c4(l.color, l.alpha), l.width as f32);
|
||||
}
|
||||
}
|
||||
for l in &scene.lines {
|
||||
for w in l.points.windows(2) {
|
||||
sc.line(w[0].to_f32(), w[1].to_f32(), c4(l.color), l.width as f32, l.on_top);
|
||||
sc.line(w[0].to_f32(), w[1].to_f32(), c4(l.color, l.alpha), l.width as f32, l.on_top);
|
||||
}
|
||||
}
|
||||
sc
|
||||
|
||||
@@ -17,7 +17,11 @@ use wgpu::util::DeviceExt;
|
||||
use crate::pbr;
|
||||
|
||||
mod ao;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod msaa_check;
|
||||
mod oit;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use msaa_check::MsaaCheck;
|
||||
|
||||
/// Triangle vertex: position (3 × f32), normal (3 × f32), sRGB colour (4 × u8).
|
||||
pub const TRI_SIZE: usize = 28;
|
||||
@@ -3481,6 +3485,38 @@ mod glass_tests {
|
||||
assert!(preview[2] > preview[0] + 60, "the preview shows its own blue square: {preview:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_msaa_check_passes_and_detects_a_blank_resolve() {
|
||||
let (adapter, device, queue) = crate::open_headless().expect("software adapter required for the startup check");
|
||||
let depth = crate::depth_format(&device, true);
|
||||
for format in [wgpu::TextureFormat::Rgba8Unorm, wgpu::TextureFormat::Bgra8Unorm, wgpu::TextureFormat::Bgra8UnormSrgb] {
|
||||
let window = GpuTarget { format, depth: None, samples: 1, tess: false };
|
||||
for samples in [1, 4] {
|
||||
if ![format, depth].iter().all(|f| adapter.get_texture_format_features(*f).flags.sample_count_supported(samples)) {
|
||||
continue;
|
||||
}
|
||||
let target = GpuTarget { depth: Some(depth), samples, ..window };
|
||||
let mut renderer = Renderer::with_window_target(&device, target, window, None);
|
||||
let start = std::time::Instant::now();
|
||||
assert_eq!(renderer.check_msaa(&device, &queue).unwrap(), super::MsaaCheck::Passed);
|
||||
eprintln!("startup check: {} {format:?} {samples}x: {:?}", adapter.get_info().name, start.elapsed());
|
||||
assert!(
|
||||
renderer.model.key.is_none() && renderer.highlight.key.is_none() && renderer.ss.is_none(),
|
||||
"probe uploads must not replace the first real frame"
|
||||
);
|
||||
assert_eq!(renderer.target().samples, samples);
|
||||
assert_eq!(renderer.check_msaa_with(&device, &queue, 1).unwrap(), super::MsaaCheck::FailedAtOne);
|
||||
assert_eq!(renderer.target().samples, samples, "a blank 1x result must preserve the selected count");
|
||||
assert_eq!(
|
||||
renderer.check_msaa_with(&device, &queue, 4).unwrap(),
|
||||
if samples > 1 { super::MsaaCheck::Fallback } else { super::MsaaCheck::Passed }
|
||||
);
|
||||
assert_eq!(renderer.target().samples, 1);
|
||||
assert_eq!(renderer.check_msaa(&device, &queue).unwrap(), super::MsaaCheck::Passed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two see-through squares, one at z = 5 (near) and one at z = -5, in `near` and `far`
|
||||
/// colours, given near first or far first.
|
||||
fn two_layers(near: [u8; 4], far: [u8; 4], near_first: bool) -> GpuScene {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! A startup probe of the installed viewport, including highlights, depth, resolve, and blit.
|
||||
use super::*;
|
||||
use crate::{GpuError, Result};
|
||||
|
||||
/// The startup check never changes a setting unless a working 1x replacement is proven.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum MsaaCheck {
|
||||
Passed,
|
||||
Fallback,
|
||||
FailedAtOne,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Check a tiny scene through the installed pipelines and window format. On a blank
|
||||
/// multisampled result, replace this renderer only if the same scene works at 1x.
|
||||
/// Native only: browsers cannot synchronously poll a device. No result is cached on disk.
|
||||
pub fn check_msaa(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) -> Result<MsaaCheck> {
|
||||
self.check_msaa_with(device, queue, 0)
|
||||
}
|
||||
|
||||
pub(super) fn check_msaa_with(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, blank_samples: u32) -> Result<MsaaCheck> {
|
||||
if self.probe_msaa(device, queue, blank_samples)? {
|
||||
return Ok(MsaaCheck::Passed);
|
||||
}
|
||||
if self.target.samples == 1 {
|
||||
return Ok(MsaaCheck::FailedAtOne);
|
||||
}
|
||||
let scopes = [wgpu::ErrorFilter::OutOfMemory, wgpu::ErrorFilter::Internal, wgpu::ErrorFilter::Validation].map(|f| device.push_error_scope(f));
|
||||
let mut replacement = Self::with_window_target(device, GpuTarget { samples: 1, ..self.target }, self.window, None);
|
||||
let mut error = None;
|
||||
for scope in scopes.into_iter().rev() {
|
||||
if let Some(e) = crate::popped(scope.pop()) {
|
||||
error.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
if let Some(error) = error {
|
||||
return Err(GpuError::Render(error.to_string()));
|
||||
}
|
||||
if !replacement.probe_msaa(device, queue, blank_samples)? {
|
||||
return Ok(MsaaCheck::FailedAtOne);
|
||||
}
|
||||
replacement.tess = self.tess.take();
|
||||
*self = replacement;
|
||||
Ok(MsaaCheck::Fallback)
|
||||
}
|
||||
|
||||
fn probe_msaa(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, blank_samples: u32) -> Result<bool> {
|
||||
if !matches!(
|
||||
self.window.format,
|
||||
wgpu::TextureFormat::Rgba8Unorm
|
||||
| wgpu::TextureFormat::Rgba8UnormSrgb
|
||||
| wgpu::TextureFormat::Bgra8Unorm
|
||||
| wgpu::TextureFormat::Bgra8UnormSrgb
|
||||
) {
|
||||
return Err(GpuError::Unsupported("anti-aliasing check colour format".into()));
|
||||
}
|
||||
let scopes = [wgpu::ErrorFilter::OutOfMemory, wgpu::ErrorFilter::Internal, wgpu::ErrorFilter::Validation].map(|f| device.push_error_scope(f));
|
||||
let result = self.probe_pixels(device, queue, blank_samples);
|
||||
// Discard the probe's uploads and picture even on errors, before the real scene arrives.
|
||||
self.model = Batches::default();
|
||||
self.sketch = Batches::default();
|
||||
self.highlight = Batches::default();
|
||||
self.preview = Batches::default();
|
||||
self.ss = None;
|
||||
self.ss_key = None;
|
||||
self.ss_ready = false;
|
||||
self.cached = None;
|
||||
let mut error = None;
|
||||
for scope in scopes.into_iter().rev() {
|
||||
if let Some(e) = crate::popped(scope.pop()) {
|
||||
error.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
if let Some(error) = error {
|
||||
return Err(GpuError::Render(error.to_string()));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn probe_pixels(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, blank_samples: u32) -> Result<bool> {
|
||||
const SIZE: u32 = 16;
|
||||
let mut model = GpuScene::default();
|
||||
for p in [[-0.9, -0.9, 0.0], [0.9, -0.9, 0.0], [0.0, 0.9, 0.0]] {
|
||||
model.tri(p, [0.0, 0.0, 1.0], [255, 0, 0, 255]);
|
||||
}
|
||||
let mut highlight = GpuScene::default();
|
||||
for p in [[-0.8, -0.8, 0.0], [0.8, -0.8, 0.0], [0.8, 0.8, 0.0], [-0.8, -0.8, 0.0], [0.8, 0.8, 0.0], [-0.8, 0.8, 0.0]] {
|
||||
highlight.xray_tri(p, [0.0, 0.0, 1.0], [0, 0, 255, 64]);
|
||||
}
|
||||
let slot = |scene| Arc::new(Mutex::new(scene));
|
||||
let frame = Frame {
|
||||
product: false,
|
||||
key: 1,
|
||||
slot: slot(Some(model)),
|
||||
sketch_key: 2,
|
||||
sketch_slot: slot(None),
|
||||
hl_key: 3,
|
||||
hl_slot: slot(Some(highlight)),
|
||||
pv_key: 4,
|
||||
pv_slot: slot(None),
|
||||
pv_hide: false,
|
||||
view_proj: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.5, 1.0]],
|
||||
back: [0.0, 0.0, 1.0],
|
||||
size_px: [SIZE as f32; 2],
|
||||
clip: [0.0; 4],
|
||||
cap: [0.0; 4],
|
||||
images: Vec::new(),
|
||||
analysis: [0.0; 8],
|
||||
analysis_colors: [0.0; 36],
|
||||
access: None,
|
||||
outline: false,
|
||||
eye: [0.0, 0.0, 100.0, 0.0],
|
||||
ao_radius: 0.0,
|
||||
ssaa: 1.0,
|
||||
hover_tint: [0.0; 3],
|
||||
selection_tint: [0.0; 3],
|
||||
};
|
||||
let size = wgpu::Extent3d { width: SIZE, height: SIZE, depth_or_array_layers: 1 };
|
||||
let color = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("sc_msaa_check"),
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: self.window.format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = color.create_view(&Default::default());
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
self.prepare(device, queue, &mut encoder, &frame);
|
||||
// Tests reproduce a driver returning a blank resolve, without changing production draws.
|
||||
#[cfg(test)]
|
||||
if (blank_samples == 1 || blank_samples == self.target.samples)
|
||||
&& let Some(resolve) = self.ss.as_ref().map(|ss| ss.resolve.as_ref().unwrap_or(&ss.color))
|
||||
{
|
||||
let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: resolve,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store },
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
let _ = blank_samples;
|
||||
{
|
||||
let mut pass = encoder
|
||||
.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store },
|
||||
})],
|
||||
..Default::default()
|
||||
})
|
||||
.forget_lifetime();
|
||||
self.paint(&frame, &mut pass, [0.0, 0.0, SIZE as f32, SIZE as f32]);
|
||||
}
|
||||
// Three interior pixels in one row, padded to the copy alignment.
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("sc_msaa_check_read"),
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo { origin: wgpu::Origin3d { x: 7, y: 8, z: 0 }, ..color.as_image_copy() },
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(256), rows_per_image: Some(1) },
|
||||
},
|
||||
wgpu::Extent3d { width: 3, height: 1, depth_or_array_layers: 1 },
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
let slice = read.slice(..);
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
||||
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
// No submission index: a lost device must not turn a stale submission into a panic.
|
||||
// Software adapters can take seconds on their first draw. Keep the wait bounded;
|
||||
// a timeout remains an inconclusive check and preserves the selected sample count.
|
||||
device
|
||||
.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(std::time::Duration::from_secs(10)) })
|
||||
.map_err(|e| GpuError::Render(format!("anti-aliasing check wait: {e}")))?;
|
||||
rx.try_recv()
|
||||
.map_err(|e| GpuError::Render(format!("anti-aliasing check callback: {e}")))?
|
||||
.map_err(|e| GpuError::Render(format!("anti-aliasing check map: {e}")))?;
|
||||
let bytes = slice.get_mapped_range().map_err(|e| GpuError::Render(e.to_string()))?;
|
||||
let bgra = matches!(self.window.format, wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb);
|
||||
Ok(bytes.get(..12).is_some_and(|pixels| {
|
||||
pixels.as_chunks::<4>().0.iter().all(|p| {
|
||||
let (red, green, blue) = if bgra { (p[2], p[1], p[0]) } else { (p[0], p[1], p[2]) };
|
||||
red > 80 && red > green.saturating_add(40) && blue > 20
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
use solidcraft_engine::render::{Camera, Rgb, SceneLine};
|
||||
use solidcraft_engine::render::{Camera, Rgb, Scene, SceneLine};
|
||||
use solidcraft_engine::view::{self, Guides, colors};
|
||||
use solidcraft_gpu::GpuError;
|
||||
use solidcraft_gpu::capture::{self, Capturer};
|
||||
@@ -162,7 +162,13 @@ fn pictures_show_the_section_and_its_cap() {
|
||||
let mut sc = view::scene(&s, &cam);
|
||||
// A marker inside the body, behind the cut face.
|
||||
let red = Rgb(255, 0, 0);
|
||||
sc.lines.push(SceneLine { points: vec![Vec3::new(5.0, 25.0, 10.0), Vec3::new(35.0, 25.0, 10.0)], color: red, width: 3.0, on_top: false });
|
||||
sc.lines.push(SceneLine {
|
||||
points: vec![Vec3::new(5.0, 25.0, 10.0), Vec3::new(35.0, 25.0, 10.0)],
|
||||
color: red,
|
||||
alpha: 1.0,
|
||||
width: 3.0,
|
||||
on_top: false,
|
||||
});
|
||||
let img = c.rgba(&sc, &cam, 300, 220).unwrap();
|
||||
let marker = pixels(&img).filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count();
|
||||
assert_eq!(marker, 0, "fov {fov}: the inside shows through the cut face");
|
||||
@@ -226,6 +232,30 @@ fn a_body_behind_the_cut_does_not_colour_the_cap() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #4214: a line that is not opaque is blended with what is under it, so the grid's fade reaches
|
||||
/// the picture.
|
||||
#[test]
|
||||
fn pictures_blend_a_line_that_is_not_opaque() {
|
||||
let Some(c) = capturer() else { return };
|
||||
let cam = Camera { distance: 50.0, fov: 0.0, ..Default::default() };
|
||||
let darkest = |alpha: f32| {
|
||||
let mut sc = Scene { radius: 30.0, ..Default::default() };
|
||||
sc.lines.push(SceneLine {
|
||||
points: vec![Vec3::new(-8.0, 2.0, 0.0), Vec3::new(8.0, 2.0, 0.0)],
|
||||
color: Rgb(0, 0, 0),
|
||||
alpha,
|
||||
width: 1.4,
|
||||
on_top: false,
|
||||
});
|
||||
let img = c.rgba(&sc, &cam, 64, 64).unwrap();
|
||||
pixels(&img).map(|p| i32::from(p[0])).min().unwrap_or(255)
|
||||
};
|
||||
let solid = darkest(1.0);
|
||||
let half = darkest(0.5);
|
||||
assert!(solid < 40, "the opaque line is black: {solid}");
|
||||
assert!(half > 60 && half < 170, "the half-opaque line is between the background and the line: {half} against {solid}");
|
||||
}
|
||||
|
||||
/// Surface analysis shades headless pictures as it shades the viewport: a box's flat faces in
|
||||
/// the curvature map's flat colour, zebra stripes in black and white, the accessibility analysis
|
||||
/// in reachable and blocked colours.
|
||||
|
||||
@@ -153,7 +153,7 @@ fn front(mesh: Mesh, lines: Vec<SceneLine>, radius: f64, fov: f64) -> (Scene, Ca
|
||||
}
|
||||
|
||||
fn red_line(a: Vec3, b: Vec3, on_top: bool) -> SceneLine {
|
||||
SceneLine { points: vec![a, b], color: Rgb(255, 0, 0), width: 1.2, on_top }
|
||||
SceneLine { points: vec![a, b], color: Rgb(255, 0, 0), alpha: 1.0, width: 1.2, on_top }
|
||||
}
|
||||
|
||||
fn red_pixels(c: &Capturer, scene: &Scene, cam: &Camera) -> usize {
|
||||
@@ -193,7 +193,7 @@ fn a_selected_edge_on_a_grazing_face_draws_whole() {
|
||||
sc.lifted_line(a.to_f32(), b.to_f32(), [255, 0, 0, 255], 6.0, [n.to_f32(), n.to_f32()]);
|
||||
sc
|
||||
};
|
||||
let reference = |a: Vec3, b: Vec3| vec![SceneLine { points: vec![a, b], color: Rgb(255, 0, 0), width: 6.0, on_top: true }];
|
||||
let reference = |a: Vec3, b: Vec3| vec![SceneLine { points: vec![a, b], color: Rgb(255, 0, 0), alpha: 1.0, width: 6.0, on_top: true }];
|
||||
for (mode, c) in capturers() {
|
||||
for tilt in [60.0_f64, 80.0, 85.0, 88.0] {
|
||||
let tr = tilt.to_radians();
|
||||
|
||||
@@ -35,7 +35,8 @@ fn missing(c: &Capturer, width: f64) -> Vec<usize> {
|
||||
(0..N)
|
||||
.filter(|&i| {
|
||||
let z = -4.0 + i as f64 * 0.23;
|
||||
let line = SceneLine { points: vec![Vec3::new(-8.0, 0.0, z), Vec3::new(8.0, 0.0, z)], color: Rgb(255, 0, 0), width, on_top: true };
|
||||
let line =
|
||||
SceneLine { points: vec![Vec3::new(-8.0, 0.0, z), Vec3::new(8.0, 0.0, z)], color: Rgb(255, 0, 0), alpha: 1.0, width, on_top: true };
|
||||
let scene = Scene { lines: vec![line], radius: 30.0, ..Default::default() };
|
||||
let img = c.rgba(&scene, &cam, 200, 200).unwrap();
|
||||
!img.as_chunks::<4>().0.iter().any(|p| p[0] > 60 && p[0] > p[1].saturating_add(40) && p[0] > p[2].saturating_add(40))
|
||||
|
||||
@@ -46,6 +46,10 @@ mod imp {
|
||||
pub fn exists(path: &str) -> bool {
|
||||
check(path).is_ok() && std::path::Path::new(path).exists()
|
||||
}
|
||||
/// Whether the path names a regular file the caller may access.
|
||||
pub fn is_file(path: &str) -> bool {
|
||||
check(path).is_ok() && std::path::Path::new(path).is_file()
|
||||
}
|
||||
pub fn remove(path: &str) -> Result<()> {
|
||||
check(path)?;
|
||||
std::fs::remove_file(path)
|
||||
@@ -96,6 +100,10 @@ mod imp {
|
||||
pub fn exists(path: &str) -> bool {
|
||||
FILES.with(|f| f.borrow().contains_key(path))
|
||||
}
|
||||
/// Every entry in browser storage is a regular file.
|
||||
pub fn is_file(path: &str) -> bool {
|
||||
exists(path)
|
||||
}
|
||||
pub fn remove(path: &str) -> Result<()> {
|
||||
FILES.with(|f| f.borrow_mut().remove(path)).map(|_| ()).ok_or_else(|| missing(path))
|
||||
}
|
||||
@@ -451,9 +459,12 @@ mod tests {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let p = dir.join("a.txt").to_string_lossy().to_string();
|
||||
assert!(!super::exists(&p));
|
||||
assert!(!super::is_file(&p));
|
||||
assert!(!super::is_file(&dir.to_string_lossy()));
|
||||
assert!(super::read(&p, 16).is_err());
|
||||
super::write(&p, b"hello").unwrap();
|
||||
assert!(super::exists(&p));
|
||||
assert!(super::is_file(&p));
|
||||
assert_eq!(super::len(&p).unwrap(), 5);
|
||||
assert_eq!(super::read_to_string(&p, 16).unwrap(), "hello");
|
||||
// The cap is the caller's, not the file's.
|
||||
@@ -461,6 +472,7 @@ mod tests {
|
||||
assert_eq!(super::read(&p, 5).unwrap(), b"hello");
|
||||
super::remove(&p).unwrap();
|
||||
assert!(!super::exists(&p));
|
||||
assert!(!super::is_file(&p));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"describe_command",
|
||||
"Describe a command",
|
||||
"One command's parameter docs and, for commands with a typed contract (solid.box, solid.extrude and engine.describe so far), the \
|
||||
"One command's parameter docs and, for commands with a typed contract (every new command, and more of the older ones as they are converted), the \
|
||||
JSON Schemas (2020-12) of its parameters (`input`) and of a successful result (`output`); null for the others.",
|
||||
obj(json!({"id": string("Command id, e.g. \"solid.extrude\"")}), &["id"]),
|
||||
Effect::ReadOnly,
|
||||
|
||||
@@ -230,6 +230,8 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
|
||||
}
|
||||
for l in scene.grid.iter().chain(&scene.lines) {
|
||||
let col = l.color.f();
|
||||
// A line that is not opaque is blended with what is under it (the grid fading out).
|
||||
let alpha = f64::from(l.alpha).clamp(0.0, 1.0);
|
||||
let half = (l.width * SS as f64 * 0.5).max(0.5);
|
||||
for seg in l.points.windows(2) {
|
||||
let (Some(&p), Some(&q)) = (seg.first(), seg.get(1)) else { continue };
|
||||
@@ -264,7 +266,7 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
|
||||
let visible = l.on_top
|
||||
|| c.depth.get(i).zip(slope.get(i)).is_some_and(|(dd, s)| z <= f64::from(*dd) + f64::from(*s) * d + z.abs() * DEPTH_EPS);
|
||||
if visible {
|
||||
c.put(ix as usize, iy as usize, col, cov);
|
||||
c.put(ix as usize, iy as usize, col, cov * alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -428,6 +430,7 @@ mod tests {
|
||||
lines: vec![SceneLine {
|
||||
points: vec![Vec3::new(-8.0, 2.0, 0.0), Vec3::new(8.0, 2.0, 0.0)],
|
||||
color: Rgb(0, 0, 255),
|
||||
alpha: 1.0,
|
||||
width: 1.4,
|
||||
on_top: false,
|
||||
}],
|
||||
@@ -499,6 +502,30 @@ mod tests {
|
||||
render(scene, cam, 64, 64).rgba.chunks(4).filter(|p| p[0] > 150 && p[1] < 100 && p[2] < 100).count()
|
||||
}
|
||||
|
||||
/// #4214: a line that is not opaque is blended with what is under it: the grid fades out
|
||||
/// with the distance, and the fade reaches the picture.
|
||||
#[test]
|
||||
fn a_line_that_is_not_opaque_is_blended_with_what_is_under_it() {
|
||||
let line = |alpha: f32| SceneLine {
|
||||
points: vec![Vec3::new(-8.0, 2.0, 0.0), Vec3::new(8.0, 2.0, 0.0)],
|
||||
color: Rgb(0, 0, 255),
|
||||
alpha,
|
||||
width: 1.4,
|
||||
on_top: false,
|
||||
};
|
||||
let cam = Camera { distance: 50.0, fov: 0.0, ..Default::default() };
|
||||
// The blue of the line over the white background, at full strength and at half.
|
||||
let blue = |alpha: f32| {
|
||||
let scene = Scene { lines: vec![line(alpha)], radius: 30.0, ..Default::default() };
|
||||
let img = render(&scene, &cam, 64, 64);
|
||||
img.rgba.chunks(4).map(|p| p[2] as i32).max().unwrap_or(0)
|
||||
};
|
||||
let solid = blue(1.0);
|
||||
let half = blue(0.5);
|
||||
assert!(solid > 200, "the opaque line is blue: {solid}");
|
||||
assert!(half < solid && half > 100, "the half-opaque line is between the background and the line: {half} against {solid}");
|
||||
}
|
||||
|
||||
/// An edge 1 mm behind a sheet is hidden in a close view of a large design. The old test
|
||||
/// allowed 2e-4 of the depth range, 2e-4 · (8 · 1200 + 2) = 1.92 mm here (orthographic; far
|
||||
/// more in perspective), so it was drawn on the sheet. Now the allowance is what the sheet's
|
||||
@@ -515,6 +542,7 @@ mod tests {
|
||||
let edge = SceneLine {
|
||||
points: vec![Vec3::new(-10.0, 0.0, 0.0) + back, Vec3::new(10.0, 0.0, 0.0) + back],
|
||||
color: Rgb(255, 0, 0),
|
||||
alpha: 1.0,
|
||||
width: 1.2,
|
||||
on_top: false,
|
||||
};
|
||||
@@ -534,7 +562,7 @@ mod tests {
|
||||
fn edges_on_a_steep_face_draw_whole() {
|
||||
let tilt = 70f64.to_radians();
|
||||
let on = |x: f64, z: f64| Vec3::new(x, z * tilt.tan(), z);
|
||||
let line = |points: Vec<Vec3>| SceneLine { points, color: Rgb(255, 0, 0), width: 1.2, on_top: false };
|
||||
let line = |points: Vec<Vec3>| SceneLine { points, color: Rgb(255, 0, 0), alpha: 1.0, width: 1.2, on_top: false };
|
||||
for fov in [0.0, 0.6] {
|
||||
for radius in [30.0, 1200.0] {
|
||||
let lines = vec![
|
||||
|
||||
@@ -58,6 +58,9 @@ pub const MIN_ALPHA: f64 = 0.05;
|
||||
pub struct SceneLine {
|
||||
pub points: Vec<Vec3>,
|
||||
pub color: Rgb,
|
||||
/// How opaque the line is: 1 is solid, below 1 it is blended with what is under it (the
|
||||
/// grid fades out with the distance, #4214).
|
||||
pub alpha: f32,
|
||||
/// Width in output pixels.
|
||||
pub width: f64,
|
||||
/// Draw over everything (sketch geometry being edited).
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
//! A line and an arc that meet at two separate points tied by `Coincident` (not at one shared
|
||||
//! point), plus `Tangent`. The tangency has to be read at that touch point: the touch-free form
|
||||
//! (distance from the centre equals the radius) is a double root there, so it converged to only
|
||||
//! sqrt(residual) of tangent and lost a row of rank when the start already was tangent.
|
||||
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
use crate::tests::{Lcg, v};
|
||||
use crate::*;
|
||||
|
||||
/// Circumcentre of three points.
|
||||
fn circumcentre(a: Vec2, t: Vec2, b: Vec2) -> Vec2 {
|
||||
let d = 2.0 * (a.x * (t.y - b.y) + t.x * (b.y - a.y) + b.x * (a.y - t.y));
|
||||
let (a2, t2, b2) = (a.len().powi(2), t.len().powi(2), b.len().powi(2));
|
||||
Vec2::new((a2 * (t.y - b.y) + t2 * (b.y - a.y) + b2 * (a.y - t.y)) / d, (a2 * (b.x - t.x) + t2 * (a.x - b.x) + b2 * (t.x - a.x)) / d)
|
||||
}
|
||||
|
||||
struct Joint {
|
||||
sk: Sketch,
|
||||
line: usize,
|
||||
arc: usize,
|
||||
line_end: usize,
|
||||
arc_start: usize,
|
||||
}
|
||||
|
||||
/// Line (0,0)-(10,0) and the arc through (10,0), (15,5), (10,10), each coordinate moved by `j`.
|
||||
fn joint(j: [f64; 10]) -> Joint {
|
||||
let mut sk = Sketch::new();
|
||||
let line = sk.add_line(v(j[0], j[1]), v(10.0 + j[2], j[3]), None, None, Some("l")).unwrap();
|
||||
let (a, t, b) = (v(10.0 + j[4], j[5]), v(15.0 + j[6], 5.0 + j[7]), v(10.0 + j[8], 10.0 + j[9]));
|
||||
let arc = sk.add_arc(circumcentre(a, t, b), a, b, [None, None, None], Some("a")).unwrap();
|
||||
let (CurveKind::Line { b: line_end, .. }, CurveKind::Arc { a: arc_start, .. }) = (sk.curves[line].kind.clone(), sk.curves[arc].kind.clone())
|
||||
else {
|
||||
panic!("a line and an arc")
|
||||
};
|
||||
sk.add_constraint(ConstraintKind::Coincident { p: line_end, q: arc_start }, None).unwrap();
|
||||
sk.add_constraint(ConstraintKind::Tangent { a: line, b: arc }, None).unwrap();
|
||||
Joint { sk, line, arc, line_end, arc_start }
|
||||
}
|
||||
|
||||
/// The centre-to-joint radius's component along the line: the tangency error in mm.
|
||||
fn tangency_error(jt: &Joint) -> f64 {
|
||||
let (p0, p1) = match jt.sk.curves[jt.line].kind {
|
||||
CurveKind::Line { a, b } => (jt.sk.points[a].pos, jt.sk.points[b].pos),
|
||||
_ => panic!("line"),
|
||||
};
|
||||
let d = p1 - p0;
|
||||
(jt.sk.points[jt.arc_start].pos - jt.sk.center(jt.arc).unwrap()).dot(d) / d.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_tangent_holds_in_the_stored_points() {
|
||||
let mut rng = Lcg(12345);
|
||||
for amp in [0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0] {
|
||||
let mut j = [0.0; 10];
|
||||
for x in &mut j {
|
||||
*x = rng.range(-amp, amp);
|
||||
}
|
||||
let mut jt = joint(j);
|
||||
let rep = solve(&mut jt.sk);
|
||||
assert!(rep.ok(), "amp {amp}: {rep:?}");
|
||||
let err = tangency_error(&jt);
|
||||
// Was 5e-5 .. 1e-4 mm with a reported residual of 1e-10.
|
||||
assert!(err.abs() < 1e-8, "amp {amp}: tangency error {err}");
|
||||
let gap = jt.sk.points[jt.line_end].pos.dist(jt.sk.points[jt.arc_start].pos);
|
||||
assert!(gap < 1e-8, "amp {amp}: gap {gap}");
|
||||
assert_eq!(rep.dof, 6, "amp {amp}: {rep:?}");
|
||||
assert!(rep.redundant.is_empty(), "amp {amp}: {rep:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_tangent_dof_does_not_depend_on_the_start() {
|
||||
let rough = [0.03, -0.04, 0.02, 0.05, -0.05, 0.04, 0.04, -0.03, -0.02, 0.05];
|
||||
let mut reports = Vec::new();
|
||||
for j in [[0.0; 10], rough] {
|
||||
let mut jt = joint(j);
|
||||
reports.push(solve(&mut jt.sk));
|
||||
}
|
||||
for rep in &reports {
|
||||
// 4 (line) + 5 (arc) - 2 (coincident) - 1 (tangent).
|
||||
assert_eq!(rep.dof, 6, "{rep:?}");
|
||||
assert!(rep.redundant.is_empty(), "{rep:?}");
|
||||
assert!(rep.failing.is_empty(), "{rep:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_coincident_chain_still_finds_the_touch_point() {
|
||||
let mut jt = joint([0.03, -0.04, 0.02, 0.05, -0.05, 0.04, 0.04, -0.03, -0.02, 0.05]);
|
||||
// The line end is tied to the arc start only through a third point.
|
||||
let mid = jt.sk.add_point(v(10.0, 0.0), None).unwrap();
|
||||
jt.sk.constraints.remove(0);
|
||||
jt.sk.add_constraint(ConstraintKind::Coincident { p: jt.line_end, q: mid }, None).unwrap();
|
||||
jt.sk.add_constraint(ConstraintKind::Coincident { p: mid, q: jt.arc_start }, None).unwrap();
|
||||
let rep = solve(&mut jt.sk);
|
||||
assert!(rep.ok(), "{rep:?}");
|
||||
let err = tangency_error(&jt);
|
||||
assert!(err.abs() < 1e-8, "tangency error {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_duplicate_dimension_beside_the_tangent_joint_is_still_redundant() {
|
||||
let mut jt = joint([0.0; 10]);
|
||||
let l = jt.line;
|
||||
jt.sk.add_constraint(ConstraintKind::Length { l, value: 10.0 }, None).unwrap();
|
||||
jt.sk.add_constraint(ConstraintKind::Length { l, value: 10.0 }, None).unwrap();
|
||||
let rep = solve(&mut jt.sk);
|
||||
assert!(rep.ok(), "{rep:?}");
|
||||
assert_eq!(rep.redundant.len(), 1, "{rep:?}");
|
||||
assert_eq!(rep.dof, 5, "{rep:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicting_dimensions_beside_the_tangent_joint_still_fail() {
|
||||
let mut jt = joint([0.0; 10]);
|
||||
let l = jt.line;
|
||||
jt.sk.add_constraint(ConstraintKind::Length { l, value: 10.0 }, None).unwrap();
|
||||
jt.sk.add_constraint(ConstraintKind::Length { l, value: 12.0 }, None).unwrap();
|
||||
let rep = solve(&mut jt.sk);
|
||||
assert_eq!(rep.status, SolveStatus::Failed, "{rep:?}");
|
||||
assert!(!rep.failing.is_empty(), "{rep:?}");
|
||||
}
|
||||
|
||||
/// A line at y = 5 and a circle of radius 5 at the origin; the contact is a free point.
|
||||
struct FreePoint {
|
||||
sk: Sketch,
|
||||
line: usize,
|
||||
circle: usize,
|
||||
p: usize,
|
||||
}
|
||||
|
||||
fn free_point_contact(j: [f64; 8], tied_to_line_end: bool) -> FreePoint {
|
||||
let mut sk = Sketch::new();
|
||||
let line =
|
||||
sk.add_line(v(-8.0 + j[0], 5.0 + j[1]), v(if tied_to_line_end { 0.0 } else { 8.0 } + j[2], 5.0 + j[3]), None, None, Some("l")).unwrap();
|
||||
let circle = sk.add_circle(v(j[4], j[5]), 5.0, None, Some("c")).unwrap();
|
||||
let p = sk.add_point(v(j[6], 5.0 + j[7]), None).unwrap();
|
||||
let CurveKind::Line { b: line_end, .. } = sk.curves[line].kind.clone() else { panic!("line") };
|
||||
if tied_to_line_end {
|
||||
// S10: line end ~ free point p, p on the circle.
|
||||
sk.add_constraint(ConstraintKind::Coincident { p, q: line_end }, None).unwrap();
|
||||
} else {
|
||||
// S2b: p on the line and on the circle.
|
||||
sk.add_constraint(ConstraintKind::PointOnCurve { p, c: line }, None).unwrap();
|
||||
}
|
||||
sk.add_constraint(ConstraintKind::PointOnCurve { p, c: circle }, None).unwrap();
|
||||
sk.add_constraint(ConstraintKind::Tangent { a: line, b: circle }, None).unwrap();
|
||||
sk.add_constraint(ConstraintKind::Radius { c: circle, value: 5.0 }, None).unwrap();
|
||||
FreePoint { sk, line, circle, p }
|
||||
}
|
||||
|
||||
fn free_point_error(f: &FreePoint) -> f64 {
|
||||
let CurveKind::Line { a, b } = f.sk.curves[f.line].kind else { panic!("line") };
|
||||
let d = f.sk.points[b].pos - f.sk.points[a].pos;
|
||||
(f.sk.points[f.p].pos - f.sk.center(f.circle).unwrap()).dot(d) / d.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_free_point_contact_is_tangent_and_counted_the_same_from_every_start() {
|
||||
// 9 variables (line 4, circle 3, point 2); equations: point on circle, tangent, radius, plus either
|
||||
// the coincidence (2, S10: dof 4) or the point on the line (1, S2b: dof 5).
|
||||
let mut rng = Lcg(2024);
|
||||
for tied in [true, false] {
|
||||
for amp in [0.0, 0.01, 0.05, 0.5, 1.0] {
|
||||
let mut j = [0.0; 8];
|
||||
for x in &mut j {
|
||||
*x = if amp == 0.0 { 0.0 } else { rng.range(-amp, amp) };
|
||||
}
|
||||
let mut f = free_point_contact(j, tied);
|
||||
let rep = solve(&mut f.sk);
|
||||
assert!(rep.ok(), "tied {tied} amp {amp}: {rep:?}");
|
||||
assert_eq!(rep.dof, if tied { 4 } else { 5 }, "tied {tied} amp {amp}: {rep:?}");
|
||||
assert!(rep.redundant.is_empty(), "tied {tied} amp {amp}: {rep:?}");
|
||||
let err = free_point_error(&f);
|
||||
assert!(err.abs() < 1e-8, "tied {tied} amp {amp}: contact perpendicular {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tangent_to_a_collapsed_round_curve_fails() {
|
||||
// The line passes through the arc's centre while the joint is tangent: only a radius of 0 holds.
|
||||
let mut jt = joint([0.03, -0.04, 0.02, 0.05, -0.05, 0.04, 0.04, -0.03, -0.02, 0.05]);
|
||||
let CurveKind::Arc { c, .. } = jt.sk.curves[jt.arc].kind else { panic!("arc") };
|
||||
jt.sk.add_constraint(ConstraintKind::PointOnCurve { p: c, c: jt.line }, None).unwrap();
|
||||
let rep = solve(&mut jt.sk);
|
||||
assert_eq!(rep.status, SolveStatus::Failed, "{rep:?}");
|
||||
assert!(rep.failing.contains(&"k2".to_string()), "{rep:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_infeasible_tangent_joint_names_the_tangent_too() {
|
||||
let mut jt = joint([0.03, -0.04, 0.02, 0.05, -0.05, 0.04, 0.04, -0.03, -0.02, 0.05]);
|
||||
let CurveKind::Arc { c, .. } = jt.sk.curves[jt.arc].kind else { panic!("arc") };
|
||||
jt.sk.add_constraint(ConstraintKind::Radius { c: jt.arc, value: 5.0 }, None).unwrap();
|
||||
jt.sk.add_constraint(ConstraintKind::PointLineDistance { p: c, l: jt.line, value: 4.0 }, None).unwrap();
|
||||
let rep = solve(&mut jt.sk);
|
||||
assert_eq!(rep.status, SolveStatus::Failed, "{rep:?}");
|
||||
// k1 is the coincident, k2 the tangent.
|
||||
assert!(rep.failing.contains(&"k2".to_string()), "{rep:?}");
|
||||
}
|
||||
|
||||
/// The unsolved sketch of the critic's c2_t6: the tangent is violated because the Coincident is.
|
||||
#[test]
|
||||
fn constraint_residual_still_shows_a_violated_tying_constraint() {
|
||||
let mut sk = Sketch::new();
|
||||
let l = sk.add_line(v(-5.0, 3.0), v(5.0, 3.0), None, None, Some("l")).unwrap();
|
||||
let c = sk.add_circle(v(0.0, 0.0), 5.0, None, Some("c")).unwrap();
|
||||
let q = sk.add_point(v(0.0, 5.0), None).unwrap();
|
||||
let CurveKind::Line { b: end, .. } = sk.curves[l].kind.clone() else { panic!("line") };
|
||||
sk.add_constraint(ConstraintKind::PointOnCurve { p: q, c }, None).unwrap();
|
||||
sk.add_constraint(ConstraintKind::Coincident { p: end, q }, None).unwrap();
|
||||
sk.add_constraint(ConstraintKind::Tangent { a: l, b: c }, None).unwrap();
|
||||
let r = constraint_residual(&sk, 2).unwrap();
|
||||
// The distance from the centre to the line is 3 against a radius of 5.
|
||||
assert!((r - 2.0).abs() < 1e-12, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_midpoint_contact_is_tangent_and_counted_the_same_from_every_start() {
|
||||
let mut rng = Lcg(5);
|
||||
for amp in [0.0, 0.05, 0.5] {
|
||||
let mut j = [0.0; 6];
|
||||
for x in &mut j {
|
||||
*x = if amp == 0.0 { 0.0 } else { rng.range(-amp, amp) };
|
||||
}
|
||||
let mut sk = Sketch::new();
|
||||
let l = sk.add_line(v(-8.0 + j[0], 5.0 + j[1]), v(8.0 + j[2], 5.0 + j[3]), None, None, Some("l")).unwrap();
|
||||
let c = sk.add_circle(v(j[4], j[5]), 5.0, None, Some("c")).unwrap();
|
||||
let m = sk.add_point(v(0.0, 5.0), None).unwrap();
|
||||
sk.add_constraint(ConstraintKind::Midpoint { p: m, l }, None).unwrap();
|
||||
sk.add_constraint(ConstraintKind::PointOnCurve { p: m, c }, None).unwrap();
|
||||
sk.add_constraint(ConstraintKind::Tangent { a: l, b: c }, None).unwrap();
|
||||
let rep = solve(&mut sk);
|
||||
assert!(rep.ok(), "amp {amp}: {rep:?}");
|
||||
// 4 + 3 + 2 = 9 variables, midpoint 2, on circle 1, tangent 1.
|
||||
assert_eq!(rep.dof, 5, "amp {amp}: {rep:?}");
|
||||
assert!(rep.redundant.is_empty(), "amp {amp}: {rep:?}");
|
||||
let CurveKind::Line { a, b } = sk.curves[l].kind else { panic!("line") };
|
||||
let d = sk.points[b].pos - sk.points[a].pos;
|
||||
let err = (sk.points[m].pos - sk.center(c).unwrap()).dot(d) / d.len();
|
||||
assert!(err.abs() < 1e-8, "amp {amp}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tangent_is_never_failing_while_the_sketch_is_solved() {
|
||||
let mut rng = Lcg(31);
|
||||
for trial in 0..60 {
|
||||
let amp = [0.05, 0.4, 1.0][trial % 3];
|
||||
let mut j = [0.0; 10];
|
||||
for x in &mut j {
|
||||
*x = rng.range(-amp, amp);
|
||||
}
|
||||
let mut jt = joint(j);
|
||||
let rep = solve(&mut jt.sk);
|
||||
if rep.status == SolveStatus::Solved {
|
||||
assert!(rep.failing.is_empty(), "trial {trial}: {rep:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,8 @@ pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireSpace, fit_polyline};
|
||||
#[cfg(test)]
|
||||
mod circle_edge_tests;
|
||||
#[cfg(test)]
|
||||
mod coincident_tangent_tests;
|
||||
#[cfg(test)]
|
||||
mod construct_rounds_tests;
|
||||
#[cfg(test)]
|
||||
mod construct_tests;
|
||||
|
||||
+224
-87
@@ -3,7 +3,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
use crate::{CurveKind, Sketch, SketchError};
|
||||
use crate::{ConstraintKind, Curve, CurveKind, Sketch, SketchError};
|
||||
|
||||
/// Placement relative to the original, along a pattern direction's oriented line.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -81,6 +81,8 @@ pub struct SketchPattern {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub count_expression: Option<String>,
|
||||
pub instances: Vec<PatternInstance>,
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub expressions: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl SketchPattern {
|
||||
@@ -106,120 +108,150 @@ impl SketchPattern {
|
||||
}
|
||||
|
||||
impl Sketch {
|
||||
/// Rebuild a circular pattern after its Quantity expression changes. Surviving slots keep
|
||||
/// their ids; new slots are copied from the authored seed. The caller owns the edit transaction.
|
||||
/// Rebuild a circular Quantity while retaining surviving copy ids.
|
||||
pub fn resize_circular_pattern(&mut self, id: &str, count: usize) -> Result<(), SketchError> {
|
||||
if !(2..=500).contains(&count) {
|
||||
return Err(SketchError::Invalid("circular pattern Quantity must be 2…500".into()));
|
||||
}
|
||||
self.validate_patterns()?;
|
||||
let index = self.patterns.iter().position(|p| p.id == id).ok_or_else(|| SketchError::Invalid("missing sketch pattern".into()))?;
|
||||
let mut pattern = self.patterns.remove(index);
|
||||
let SketchPatternKind::Circular { center, count: previous, angle, alignment, reversed } = &mut pattern.kind else {
|
||||
let mut kind = self.patterns.get(index).ok_or_else(|| SketchError::Invalid("missing sketch pattern".into()))?.kind.clone();
|
||||
let SketchPatternKind::Circular { count: previous, .. } = &mut kind else {
|
||||
return Err(SketchError::Invalid("Quantity expression requires a circular pattern".into()));
|
||||
};
|
||||
if *previous == count {
|
||||
self.patterns.insert(index, pattern);
|
||||
return Ok(());
|
||||
}
|
||||
*previous = count;
|
||||
let (center_id, total, alignment, reversed) = (center.clone(), *angle, *alignment, *reversed);
|
||||
for instance in pattern.instances.iter().filter(|i| i.index[0] >= count) {
|
||||
self.update_pattern(index, kind)
|
||||
}
|
||||
|
||||
/// Rebuild a pattern after its driving expressions change, retaining surviving copy ids.
|
||||
pub fn update_pattern(&mut self, index: usize, kind: SketchPatternKind) -> Result<(), SketchError> {
|
||||
let old = self.patterns.get(index).cloned().ok_or_else(|| SketchError::Invalid("missing sketch pattern".into()))?;
|
||||
let check_count = |n: usize| {
|
||||
if !(1..=500).contains(&n) { Err(SketchError::Invalid("pattern count must be a positive integer from 1 to 500".into())) } else { Ok(()) }
|
||||
};
|
||||
match &kind {
|
||||
SketchPatternKind::Circular { count, angle, .. } => {
|
||||
check_count(*count)?;
|
||||
if !angle.is_finite() || angle.abs() <= 1e-9 || angle.abs() > 360.0 {
|
||||
return Err(SketchError::Invalid("pattern angle must be nonzero and at most 360 degrees".into()));
|
||||
}
|
||||
}
|
||||
SketchPatternKind::Rectangular { count, count2, spacing, spacing2, .. } => {
|
||||
check_count(*count)?;
|
||||
check_count(*count2)?;
|
||||
if count.checked_mul(*count2).is_none_or(|n| n > 500) {
|
||||
return Err(SketchError::Invalid("pattern counts must make at most 500 instances total".into()));
|
||||
}
|
||||
if ![spacing, spacing2].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD) {
|
||||
return Err(SketchError::Invalid("pattern spacing must be finite and in range".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.patterns.remove(index);
|
||||
let entity_ids = old.entities.clone();
|
||||
let (center, slots) = match &kind {
|
||||
SketchPatternKind::Circular { center, count, angle, alignment, reversed } => {
|
||||
let center = self.resolve_point(center).ok_or_else(|| SketchError::Invalid("missing pattern center".into()))?;
|
||||
let full = (angle.abs() - 360.0).abs() < 1e-9;
|
||||
let step = angle / if full { *count as f64 } else { (count.max(&2) - 1) as f64 };
|
||||
(
|
||||
Some(center),
|
||||
(1..*count).map(|i| ([i, 0], (step * alignment.multiplier(i, *reversed)).to_radians(), Vec2::ZERO)).collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
SketchPatternKind::Rectangular { count, count2, .. } => {
|
||||
(None, (0..*count).flat_map(|i| (0..*count2).filter_map(move |j| (i != 0 || j != 0).then_some(([i, j], 0.0, Vec2::ZERO)))).collect())
|
||||
}
|
||||
};
|
||||
let center_id = center.and_then(|i| self.points.get(i)).map(|p| p.id.clone());
|
||||
let mut existing = old.instances.clone();
|
||||
// Remove retired instances first: all following indices are resolved by stable id.
|
||||
for instance in existing.iter().filter(|instance| !slots.iter().any(|(index, _, _)| *index == instance.index)) {
|
||||
let curves: Vec<_> = instance.curves.iter().filter_map(|id| self.curve_index(id)).collect();
|
||||
self.constraints.retain(|c| !instance.constraints.contains(&c.id));
|
||||
self.remove_curves(&curves);
|
||||
let points: Vec<_> = instance.points.iter().filter_map(|(_, id)| self.resolve_point(id)).collect();
|
||||
self.remove_points(&points);
|
||||
}
|
||||
pattern.instances.retain(|i| i.index[0] < count);
|
||||
let center = self.resolve_point(¢er_id).ok_or_else(|| SketchError::Invalid("missing pattern center".into()))?;
|
||||
let origin = self.point(center).ok_or_else(|| SketchError::Invalid("missing pattern center".into()))?;
|
||||
let step = total / if (total.abs() - 360.0).abs() < 1e-9 { count as f64 } else { (count - 1) as f64 };
|
||||
for slot in 1..count {
|
||||
let angle = (step * alignment.multiplier(slot, reversed)).to_radians();
|
||||
let (sin, cos) = angle.sin_cos();
|
||||
let transform = |p: Vec2| {
|
||||
let d = p - origin;
|
||||
origin + Vec2::new(d.x * cos - d.y * sin, d.x * sin + d.y * cos)
|
||||
};
|
||||
if let Some(instance) = pattern.instances.iter().find(|i| i.index == [slot, 0]) {
|
||||
if let SketchPatternKind::Rectangular { direction, direction2, .. } = &kind {
|
||||
for id in direction.iter().chain(direction2) {
|
||||
if self.curve_index(id).is_none() {
|
||||
return Err(SketchError::Invalid("the direction belongs to an instance removed by this edit".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut instances = Vec::new();
|
||||
let center = center_id.as_ref().and_then(|id| self.resolve_point(id));
|
||||
if center_id.is_some() && center.is_none() {
|
||||
return Err(SketchError::Invalid("the center belongs to an instance removed by this edit".into()));
|
||||
}
|
||||
for (index, angle, offset) in slots {
|
||||
let (base_offset, directions) = linear_displacement(self, &kind, index, offset);
|
||||
let c = center.and_then(|i| self.point(i)).unwrap_or(Vec2::ZERO);
|
||||
let mut displacement = base_offset;
|
||||
for (line, distance) in directions.into_iter().flatten() {
|
||||
if let Some(crate::Shape::Line { a, b }) = self.shape(line)
|
||||
&& let Some(u) = (b - a).normalized()
|
||||
{
|
||||
displacement += u * distance.x + u.perp() * distance.y;
|
||||
}
|
||||
}
|
||||
let transform = |p| rotate(c, angle)(p) + displacement;
|
||||
let instance = if let Some(i) = existing.iter().position(|instance| instance.index == index) {
|
||||
let instance = existing.remove(i);
|
||||
for cid in &instance.constraints {
|
||||
if let Some(crate::ConstraintKind::PatternPoint { angle: value, .. }) =
|
||||
self.constraints.iter_mut().find(|c| c.id == *cid).map(|c| &mut c.kind)
|
||||
if let Some(constraint) = self.constraints.iter_mut().find(|constraint| constraint.id == *cid)
|
||||
&& let ConstraintKind::PatternPoint { center: slot, angle: a, offset: off, directions: dirs, .. } = &mut constraint.kind
|
||||
{
|
||||
*value = angle;
|
||||
*slot = center;
|
||||
*a = angle;
|
||||
*off = base_offset;
|
||||
*dirs = directions;
|
||||
}
|
||||
}
|
||||
for (seed, copy) in &instance.points {
|
||||
let p =
|
||||
self.resolve_point(seed).and_then(|p| self.point(p)).ok_or_else(|| SketchError::Invalid("missing pattern seed".into()))?;
|
||||
let q = self
|
||||
.resolve_point(copy)
|
||||
.and_then(|p| self.points.get_mut(p))
|
||||
.ok_or_else(|| SketchError::Invalid("missing pattern copy".into()))?;
|
||||
q.pos = transform(p);
|
||||
let pos =
|
||||
self.resolve_point(seed).and_then(|i| self.point(i)).ok_or_else(|| SketchError::Invalid("missing seed point".into()))?;
|
||||
if let Some(point) = self.resolve_point(copy).and_then(|i| self.points.get_mut(i)) {
|
||||
let at = transform(pos);
|
||||
if !crate::model::in_range(at) {
|
||||
return Err(SketchError::Invalid("pattern point is out of range".into()));
|
||||
}
|
||||
point.pos = at;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let curves: Vec<_> = pattern.entities.iter().filter_map(|id| self.curve_index(id)).collect();
|
||||
let mut points = Vec::new();
|
||||
for id in &pattern.entities {
|
||||
if let Some(c) = self.curve_index(id).and_then(|i| self.curves.get(i)) {
|
||||
points.extend(c.kind.point_ids());
|
||||
} else if let Some(p) = self.resolve_point(id) {
|
||||
points.push(p);
|
||||
} else {
|
||||
return Err(SketchError::Invalid("missing pattern entity".into()));
|
||||
instance
|
||||
} else {
|
||||
let cs: Vec<_> = entity_ids.iter().filter_map(|id| self.curve_index(id)).collect();
|
||||
let ps: Vec<_> = entity_ids.iter().filter(|id| self.curve_index(id).is_none()).filter_map(|id| self.resolve_point(id)).collect();
|
||||
let (made, map) = copy_through(self, &cs, &ps, &transform)?;
|
||||
let mut cons = Vec::new();
|
||||
for (p, q) in &map {
|
||||
cons.push(
|
||||
self.add_constraint(ConstraintKind::PatternPoint { p: *p, q: *q, center, angle, offset: base_offset, directions }, None)?,
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut map = Vec::new();
|
||||
for p in points {
|
||||
if map.iter().any(|(seed, _)| *seed == p) {
|
||||
continue;
|
||||
for (a, b) in cs.iter().zip(&made) {
|
||||
if matches!(self.curves.get(*a).map(|c| &c.kind), Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. })) {
|
||||
cons.push(self.add_constraint(ConstraintKind::PatternRadius { a: *a, b: *b }, None)?);
|
||||
}
|
||||
}
|
||||
let pos = self.point(p).ok_or_else(|| SketchError::Invalid("missing pattern point".into()))?;
|
||||
map.push((p, self.add_point(transform(pos), None)?));
|
||||
}
|
||||
if self.curves.len().saturating_add(curves.len()) > crate::MAX_CURVES {
|
||||
return Err(SketchError::TooLarge);
|
||||
}
|
||||
let mut made = Vec::new();
|
||||
for source in &curves {
|
||||
let mut copy = self.curves.get(*source).cloned().ok_or_else(|| SketchError::Invalid("missing pattern curve".into()))?;
|
||||
copy.kind.map_points(&|p| map.iter().find(|(seed, _)| *seed == p).map_or(p, |(_, q)| *q));
|
||||
let prefix = match copy.kind {
|
||||
CurveKind::Line { .. } => "l",
|
||||
CurveKind::Circle { .. } => "c",
|
||||
CurveKind::Arc { .. } => "a",
|
||||
CurveKind::Ellipse { .. } => "e",
|
||||
CurveKind::Spline { .. } => "s",
|
||||
CurveKind::Conic { .. } => "k",
|
||||
};
|
||||
copy.id = self.fresh(prefix)?;
|
||||
copy.link = None;
|
||||
copy.fixed = false;
|
||||
copy.centerline = false;
|
||||
made.push(copy.id.clone());
|
||||
self.curves.push(copy);
|
||||
}
|
||||
let mut constraints = Vec::new();
|
||||
for (p, q) in &map {
|
||||
constraints.push(self.add_constraint(
|
||||
crate::ConstraintKind::PatternPoint { p: *p, q: *q, center: Some(center), angle, offset: Vec2::ZERO, directions: [None; 2] },
|
||||
None,
|
||||
)?);
|
||||
}
|
||||
for (source, copy) in curves.iter().zip(&made) {
|
||||
if matches!(self.curves.get(*source).map(|c| &c.kind), Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. })) {
|
||||
let b = self.curve_index(copy).ok_or_else(|| SketchError::Invalid("missing pattern curve".into()))?;
|
||||
constraints.push(self.add_constraint(crate::ConstraintKind::PatternRadius { a: *source, b }, None)?);
|
||||
PatternInstance {
|
||||
index,
|
||||
curves: made.iter().filter_map(|i| self.curves.get(*i).map(|c| c.id.clone())).collect(),
|
||||
points: map.iter().filter_map(|(p, q)| Some((self.points.get(*p)?.id.clone(), self.points.get(*q)?.id.clone()))).collect(),
|
||||
constraints: cons,
|
||||
}
|
||||
}
|
||||
let points = map.iter().filter_map(|(p, q)| Some((self.points.get(*p)?.id.clone(), self.points.get(*q)?.id.clone()))).collect();
|
||||
pattern.instances.push(PatternInstance { index: [slot, 0], curves: made, points, constraints });
|
||||
};
|
||||
instances.push(instance);
|
||||
}
|
||||
self.patterns.insert(index.min(self.patterns.len()), pattern);
|
||||
self.validate_patterns()
|
||||
|
||||
self.patterns.insert(index.min(self.patterns.len()), SketchPattern { kind, instances, ..old });
|
||||
self.follow_pattern_seeds();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start an explicit Move/Scale of seed geometry with its copies already transformed.
|
||||
@@ -343,6 +375,15 @@ impl Sketch {
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for pattern in &self.patterns {
|
||||
if pattern.expressions.len() > 5
|
||||
|| pattern.expressions.iter().any(|(key, expression)| {
|
||||
!matches!(key.as_str(), "count" | "count2" | "spacing" | "spacing2" | "angle")
|
||||
|| expression.trim().is_empty()
|
||||
|| expression.len() > 4096
|
||||
})
|
||||
{
|
||||
return Err(SketchError::Invalid("invalid sketch pattern expression".into()));
|
||||
}
|
||||
if pattern.count_expression.as_ref().is_some_and(|e| e.trim().is_empty() || e.len() > 4096)
|
||||
|| (pattern.count_expression.is_some() && !matches!(pattern.kind, SketchPatternKind::Circular { .. }))
|
||||
{
|
||||
@@ -424,3 +465,99 @@ impl Sketch {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep sampled vector directions compatible with older designs, while picked lines are
|
||||
/// solver references. The default second direction follows the first line's perpendicular.
|
||||
fn linear_displacement(sk: &Sketch, kind: &SketchPatternKind, [i, j]: [usize; 2], fallback: Vec2) -> (Vec2, [Option<(usize, Vec2)>; 2]) {
|
||||
let SketchPatternKind::Rectangular { dir, dir2, direction, direction2, spacing, spacing2, alignment, alignment2, reversed, reversed2, .. } = kind
|
||||
else {
|
||||
return (fallback, [None; 2]);
|
||||
};
|
||||
let mut offset = Vec2::ZERO;
|
||||
let mut directions = [None; 2];
|
||||
let d1 = *spacing * alignment.multiplier(i, *reversed);
|
||||
let d2 = *spacing2 * alignment2.multiplier(j, *reversed2);
|
||||
if let Some(l) = direction.as_ref().and_then(|id| sk.curve_index(id)) {
|
||||
directions[0] = Some((l, Vec2::new(d1, if dir2.is_none() && direction2.is_none() { d2 } else { 0.0 })));
|
||||
} else {
|
||||
offset += *dir * d1;
|
||||
if dir2.is_none() && direction2.is_none() {
|
||||
offset += dir.perp() * d2;
|
||||
}
|
||||
}
|
||||
if let Some(l) = direction2.as_ref().and_then(|id| sk.curve_index(id)) {
|
||||
directions[1] = Some((l, Vec2::new(d2, 0.0)));
|
||||
} else if let Some(dir2) = dir2 {
|
||||
offset += *dir2 * d2;
|
||||
}
|
||||
(offset, directions)
|
||||
}
|
||||
|
||||
fn involved_points(sk: &Sketch, cs: &[usize], ps: &[usize]) -> Vec<usize> {
|
||||
let mut v: Vec<usize> = Vec::new();
|
||||
for c in cs {
|
||||
if let Some(k) = sk.curves.get(*c).map(|c| &c.kind) {
|
||||
v.extend(k.point_ids());
|
||||
}
|
||||
}
|
||||
v.extend(ps);
|
||||
let mut out = Vec::new();
|
||||
for q in v {
|
||||
if !out.contains(&q) {
|
||||
out.push(q);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Copy seed curves and their points, returning curves and the original-to-copy point map.
|
||||
fn copy_through(sk: &mut Sketch, cs: &[usize], ps: &[usize], f: &dyn Fn(Vec2) -> Vec2) -> Result<(Vec<usize>, Vec<(usize, usize)>), SketchError> {
|
||||
if sk.curves.len().saturating_add(cs.len()) > crate::MAX_CURVES {
|
||||
return Err(crate::SketchError::TooLarge);
|
||||
}
|
||||
let pts = involved_points(sk, cs, ps);
|
||||
let mut map: Vec<(usize, usize)> = Vec::new();
|
||||
for q in &pts {
|
||||
let pos = sk.point(*q).unwrap_or_default();
|
||||
let n = sk.add_point(f(pos), None)?;
|
||||
map.push((*q, n));
|
||||
}
|
||||
let m = |q: usize| map.iter().find(|x| x.0 == q).map(|x| x.1).unwrap_or(q);
|
||||
let mut made = Vec::new();
|
||||
for c in cs {
|
||||
let Some(cu) = sk.curves.get(*c).cloned() else { continue };
|
||||
let mut kind = cu.kind.clone();
|
||||
kind.map_points(&m);
|
||||
let prefix = match &kind {
|
||||
CurveKind::Ellipse { .. } => "e",
|
||||
CurveKind::Spline { .. } => "s",
|
||||
CurveKind::Conic { .. } => "k",
|
||||
CurveKind::Line { .. } => "l",
|
||||
CurveKind::Circle { .. } => "c",
|
||||
CurveKind::Arc { .. } => "a",
|
||||
};
|
||||
let id = sk.fresh(prefix)?;
|
||||
// Copies retain the seed line's extent.
|
||||
let infinite = cu.infinite && matches!(kind, CurveKind::Line { .. });
|
||||
sk.curves.push(Curve {
|
||||
id,
|
||||
kind,
|
||||
construction: cu.construction,
|
||||
reversed: cu.reversed,
|
||||
link: None,
|
||||
centerline: false,
|
||||
fixed: false,
|
||||
infinite,
|
||||
});
|
||||
made.push(sk.curves.len() - 1);
|
||||
}
|
||||
Ok((made, map))
|
||||
}
|
||||
|
||||
fn rotate(c: Vec2, ang: f64) -> impl Fn(Vec2) -> Vec2 {
|
||||
move |q: Vec2| {
|
||||
let d = q - c;
|
||||
let (s, co) = ang.sin_cos();
|
||||
c + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1172,9 +1172,53 @@ fn rejoined(segs: Vec<Seg2>, joins: &impl Fn(&Seg2, &Seg2) -> bool) -> Vec<Seg2>
|
||||
out
|
||||
}
|
||||
|
||||
/// `regions` with each region that repeats an earlier one (the same boundary within a small
|
||||
/// tolerance) left out.
|
||||
fn without_repeats(regions: &[Region2]) -> Vec<Region2> {
|
||||
// Comparing every pair is quadratic: beyond this many the regions are kept as they are.
|
||||
const MAX_COMPARED: usize = 5000;
|
||||
if regions.len() < 2 || regions.len() > MAX_COMPARED {
|
||||
return regions.to_vec();
|
||||
}
|
||||
let keys: Vec<(f64, Vec2, f64)> =
|
||||
regions.iter().map(|r| (r.outer.signed_area().abs(), r.outer.centroid(), r.outer.segs.iter().map(Seg2::length).sum::<f64>())).collect();
|
||||
let mut kept: Vec<usize> = Vec::new();
|
||||
for (i, r) in regions.iter().enumerate() {
|
||||
let Some(&(area, centroid, size)) = keys.get(i) else { continue };
|
||||
let tol = 1e-7 * (1.0 + size);
|
||||
let repeat = kept.iter().any(|&k| {
|
||||
keys.get(k).is_some_and(|&(a, c, _)| (a - area).abs() <= tol * (1.0 + size) && c.dist(centroid) <= tol)
|
||||
&& regions.get(k).is_some_and(|other| same_region(r, other, tol))
|
||||
});
|
||||
if !repeat {
|
||||
kept.push(i);
|
||||
}
|
||||
}
|
||||
kept.iter().filter_map(|&i| regions.get(i).cloned()).collect()
|
||||
}
|
||||
|
||||
/// Whether two regions have the same boundary within `tol`: the same outer loop and the same
|
||||
/// holes, whichever segment each loop starts with.
|
||||
fn same_region(a: &Region2, b: &Region2, tol: f64) -> bool {
|
||||
let on = |p: Vec2, l: &Loop2| l.segs.iter().any(|s| s.point_at(s.closest_param(p)).dist(p) <= tol);
|
||||
// Each loop's corners and segment middles lie on the other loop.
|
||||
let covers = |x: &Loop2, y: &Loop2| x.segs.iter().all(|s| on(s.start(), y) && on(s.mid(), y));
|
||||
let same_loop = |x: &Loop2, y: &Loop2| {
|
||||
(x.signed_area().abs() - y.signed_area().abs()).abs() <= tol * (1.0 + x.segs.iter().map(Seg2::length).sum::<f64>())
|
||||
&& covers(x, y)
|
||||
&& covers(y, x)
|
||||
};
|
||||
a.holes.len() == b.holes.len() && same_loop(&a.outer, &b.outer) && a.holes.iter().all(|h| b.holes.iter().any(|g| same_loop(h, g)))
|
||||
}
|
||||
|
||||
/// Union of regions that touch along shared boundary segments (one extrude of several adjacent
|
||||
/// profiles makes one body). Disjoint regions stay separate.
|
||||
pub fn merge_regions(regions: &[Region2]) -> Vec<Region2> {
|
||||
// A region drawn again on top of another is one region (#5543): a circle drawn twice at one
|
||||
// place is two profiles, and extruded as two coincident tools a cut through a grid of holes
|
||||
// with four such circles retried with the tool nudged and took fifty times as long.
|
||||
let distinct = without_repeats(regions);
|
||||
let regions = distinct.as_slice();
|
||||
let tol = MERGE_TOL * 10.0;
|
||||
// Every boundary segment oriented with the region on its left.
|
||||
let mut segs: Vec<Seg2> = Vec::new();
|
||||
|
||||
@@ -195,8 +195,15 @@ pub fn constraint_residual(sk: &Sketch, ci: usize) -> Option<f64> {
|
||||
Some(p) => Block::TangentAt(ci, &c.kind, p),
|
||||
None => Block::User(ci, &c.kind),
|
||||
};
|
||||
let state = State { sk, lay: &fixed, x: &[] };
|
||||
let mut r = Vec::new();
|
||||
block_residuals(&State { sk, lay: &fixed, x: &[] }, &b, &mut r);
|
||||
block_residuals(&state, &b, &mut r);
|
||||
// A tangency read at its touch point holds there when the curves only touch at a point that
|
||||
// is not on both: the touch-free form (distance from the centre equals the radius) shows a
|
||||
// violated tying constraint as well, so the larger of the two is the constraint's distance.
|
||||
if matches!(b, Block::TangentAt(..)) {
|
||||
super::residuals(&state, &c.kind, &mut r);
|
||||
}
|
||||
Some(max_abs(&r))
|
||||
}
|
||||
|
||||
|
||||
+101
-1
@@ -781,7 +781,72 @@ pub(crate) fn touch_point(sk: &Sketch, k: &ConstraintKind, on_curve: &[(usize, u
|
||||
return Some(*p);
|
||||
}
|
||||
let on = |p: usize, c: usize| on_curve.binary_search(&(p, c)).is_ok();
|
||||
ea.iter().find(|p| on(**p, b)).or_else(|| eb.iter().find(|p| on(**p, a))).copied()
|
||||
if let Some(p) = ea.iter().find(|p| on(**p, b)).or_else(|| eb.iter().find(|p| on(**p, a))) {
|
||||
return Some(*p);
|
||||
}
|
||||
contact_through_coincident(sk, a, b, &ea, &eb, on_curve)
|
||||
}
|
||||
|
||||
/// Whether curve `c` is a straight line.
|
||||
fn is_straight(sk: &Sketch, c: usize) -> bool {
|
||||
matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. }))
|
||||
}
|
||||
|
||||
/// A touch point the two curves reach through `Coincident` constraints, when they share no point
|
||||
/// and no end of one is a point-on-curve of the other: a point lying on `a` (an end of it, or a
|
||||
/// point-on-curve of it) tied by a chain of `Coincident` constraints to a point lying on `b`
|
||||
/// (the chain may be empty: a free point on both curves). That covers two ends tied together, a
|
||||
/// line end tied to a free point that is on the circle, and a free point on both curves.
|
||||
/// Free-form curves count only their ends. Without this the tangency falls back to the
|
||||
/// touch-free form (distance from the centre equals the radius), whose Jacobian is
|
||||
/// rank-deficient at the solution: the touch point is a double root of it, so it converges to
|
||||
/// only sqrt(residual) of tangent and drops rank (see the `coincident_tangent_*` tests). The
|
||||
/// point returned is the one lying on the round curve (for a free-form curve, its end), so the
|
||||
/// radius to it is read from the curve it belongs to.
|
||||
fn contact_through_coincident(sk: &Sketch, a: usize, b: usize, ea: &[usize], eb: &[usize], on_curve: &[(usize, usize)]) -> Option<usize> {
|
||||
let n = sk.points.len();
|
||||
let free = |c: usize| sk.curves.get(c).is_some_and(|c| c.kind.is_freeform());
|
||||
// Points lying on a curve: its ends, and (unless free-form) the points constrained onto it.
|
||||
let lying = |c: usize, ends: &[usize]| -> Vec<usize> {
|
||||
let mut v: Vec<usize> = ends.to_vec();
|
||||
if !free(c) {
|
||||
v.extend(on_curve.iter().filter(|(_, cc)| *cc == c).map(|(p, _)| *p));
|
||||
// The midpoint of a line lies on it.
|
||||
v.extend(sk.constraints.iter().filter(|k| !k.driven).filter_map(|k| match k.kind {
|
||||
ConstraintKind::Midpoint { p, l } if l == c => Some(p),
|
||||
_ => None,
|
||||
}));
|
||||
}
|
||||
v.retain(|p| *p < n);
|
||||
v
|
||||
};
|
||||
let (on_a, on_b) = (lying(a, ea), lying(b, eb));
|
||||
if on_a.is_empty() || on_b.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut parent: Vec<usize> = (0..n).collect();
|
||||
for c in sk.constraints.iter().filter(|c| !c.driven) {
|
||||
if let ConstraintKind::Coincident { p, q } = c.kind
|
||||
&& p != q
|
||||
&& p < n
|
||||
&& q < n
|
||||
{
|
||||
union(&mut parent, p, q);
|
||||
}
|
||||
}
|
||||
for &p in &on_a {
|
||||
let root = find(&mut parent, p);
|
||||
if let Some(&q) = on_b.iter().find(|q| find(&mut parent, **q) == root) {
|
||||
return Some(if free(a) {
|
||||
p
|
||||
} else if free(b) || is_straight(sk, a) {
|
||||
q
|
||||
} else {
|
||||
p
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Variables a block depends on, and whether it reads a point that is a constant of the solve.
|
||||
@@ -2467,6 +2532,11 @@ fn solve_inner(
|
||||
let mut det = vec![false; lay.n];
|
||||
let mut rank = 0;
|
||||
let mut redundant: Vec<String> = Vec::new();
|
||||
let mut degenerate = false;
|
||||
// Tangents whose touch-free form is violated (or whose round curve collapsed): named in
|
||||
// `failing` only when the sketch failed anyway, so a sketch inside the acceptance tolerance
|
||||
// is never `Solved` with a constraint in `failing`.
|
||||
let mut named: Vec<String> = Vec::new();
|
||||
// A component too large to solve is a failure, not an allocation.
|
||||
let too_large = comps.iter().filter(|(_, vars, bis)| !bis.is_empty() && oversized(vars)).map(|(_, vars, _)| vars.len()).max();
|
||||
if too_large.is_some() {
|
||||
@@ -2494,6 +2564,26 @@ fn solve_inner(
|
||||
}
|
||||
let (r, _, owner) = system(sk, &lay, &sub, &mut x, false);
|
||||
check(&r, &owner, &sub, &mut failing);
|
||||
// A tangency read at its touch point can hold there while the curves are not tangent (the
|
||||
// touch point is not on the line, or not on the round curve): that failure is named by the
|
||||
// constraint that ties the point, and the tangent is named as well when the touch-free
|
||||
// form (distance from the centre equals the radius) is violated. A tangent whose round
|
||||
// curve has collapsed to a point holds trivially and is a degenerate answer: it fails.
|
||||
let now = State { sk, lay: &lay, x: &x };
|
||||
for (b, _) in &sub {
|
||||
let (Block::User(ci, k) | Block::TangentAt(ci, k, _)) = b else { continue };
|
||||
let ConstraintKind::Tangent { a: ta, b: tb } = **k else { continue };
|
||||
let Some(c) = sk.constraints.get(*ci) else { continue };
|
||||
let mut touch_free = Vec::new();
|
||||
if matches!(b, Block::TangentAt(..)) {
|
||||
residuals(&now, k, &mut touch_free);
|
||||
}
|
||||
let collapsed = [ta, tb].iter().any(|&cv| !now.is_line(cv) && !now.is_freeform(cv) && now.radius_of(cv) < ACCEPT);
|
||||
if (collapsed || touch_free.iter().any(|v| !(v.abs() < ACCEPT))) && !named.contains(&c.id) {
|
||||
named.push(c.id.clone());
|
||||
}
|
||||
degenerate |= collapsed;
|
||||
}
|
||||
if analysis == Analysis::StatusOnly {
|
||||
continue;
|
||||
}
|
||||
@@ -2545,6 +2635,9 @@ fn solve_inner(
|
||||
let (r, _, owner) = system(sk, &lay, &consts, &mut x, false);
|
||||
check(&r, &owner, &consts, &mut failing);
|
||||
}
|
||||
if degenerate {
|
||||
max_residual = max_residual.max(1.0);
|
||||
}
|
||||
// Weld the whole connected closure graph, preserving every immutable node.
|
||||
// Validate the actual prospective geometry before accepting a numerical solve.
|
||||
let has_periodic = sk.curves.iter().any(|c| matches!(c.kind, CurveKind::Spline { periodic: true, .. }));
|
||||
@@ -2600,6 +2693,13 @@ fn solve_inner(
|
||||
None
|
||||
};
|
||||
let status = if max_residual < ACCEPT { SolveStatus::Solved } else { SolveStatus::Failed };
|
||||
if status == SolveStatus::Failed {
|
||||
for id in named {
|
||||
if !failing.contains(&id) {
|
||||
failing.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in &folded {
|
||||
if !failing.contains(id) {
|
||||
failing.push(id.clone());
|
||||
|
||||
@@ -106,6 +106,8 @@ pub struct AgentConfig {
|
||||
/// API: the endpoint (`…/v1`) and model.
|
||||
pub base_url: String,
|
||||
pub model: String,
|
||||
/// Added with the Ollama preset (even if its display name changes).
|
||||
pub ollama: bool,
|
||||
/// API: a key is stored for it in the system keychain.
|
||||
pub key: bool,
|
||||
/// API: how long one model request may take, in seconds (clamped to
|
||||
@@ -122,6 +124,7 @@ impl Default for AgentConfig {
|
||||
command: String::new(),
|
||||
base_url: String::new(),
|
||||
model: String::new(),
|
||||
ollama: false,
|
||||
key: false,
|
||||
request_timeout_s: DEFAULT_REQUEST_TIMEOUT_S,
|
||||
}
|
||||
|
||||
@@ -183,6 +183,7 @@ impl Worker {
|
||||
if let Err(e) = self.start_mcp() {
|
||||
self.emit(Event::Error(e));
|
||||
}
|
||||
self.emit(Event::ToolsAvailable(!self.tools.is_empty()));
|
||||
self.emit(Event::Ready { agent: format!("{} · {}", self.cfg.name, self.cfg.model), images: true });
|
||||
// The endpoint's models for the picker; an endpoint that does not list them shows none.
|
||||
if let Ok(list) = self.model_list() {
|
||||
@@ -939,7 +940,8 @@ mod tests {
|
||||
events.extend(b.poll());
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(matches!(events.first(), Some(Event::Ready { .. })), "{events:?}");
|
||||
assert_eq!(events.first(), Some(&Event::ToolsAvailable(false)));
|
||||
assert!(events.iter().any(|e| matches!(e, Event::Ready { .. })), "{events:?}");
|
||||
assert!(events.iter().any(|e| matches!(e, Event::Tool { status: Some(ToolStatus::Failed), .. })), "{events:?}");
|
||||
assert!(events.iter().any(|e| matches!(e, Event::Text { text, .. } if text == "Done.")), "{events:?}");
|
||||
assert!(matches!(events.last(), Some(Event::TurnEnded { reason }) if reason.is_empty()), "{events:?}");
|
||||
@@ -1336,7 +1338,8 @@ mod tests {
|
||||
events.extend(b.poll());
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(matches!(events.first(), Some(Event::Ready { .. })), "{events:?}");
|
||||
assert_eq!(events.first(), Some(&Event::ToolsAvailable(false)));
|
||||
assert!(events.iter().any(|e| matches!(e, Event::Ready { .. })), "{events:?}");
|
||||
assert!(events.iter().any(|e| matches!(e, Event::Tool { status: Some(ToolStatus::Failed), .. })), "{events:?}");
|
||||
assert!(events.iter().any(|e| matches!(e, Event::Text { text, .. } if text == "Done.")), "{events:?}");
|
||||
assert!(matches!(events.last(), Some(Event::TurnEnded { reason }) if reason.is_empty()), "{events:?}");
|
||||
|
||||
@@ -34,6 +34,8 @@ pub enum PromptPart {
|
||||
pub enum Event {
|
||||
/// Whether the agent takes documents (PDFs) in a message; sent before [`Event::Ready`].
|
||||
Documents(bool),
|
||||
/// Whether SolidCraft tools were offered by the backend.
|
||||
ToolsAvailable(bool),
|
||||
/// Ready for a message. `agent`: its name and version; `images`: it takes pictures.
|
||||
Ready { agent: String, images: bool },
|
||||
/// Text it streams (`thought`: its reasoning, shown dimmed).
|
||||
|
||||
@@ -352,10 +352,93 @@ fn detect_in(dirs: &[PathBuf], home: Option<&Path>) -> Detection {
|
||||
d
|
||||
}
|
||||
|
||||
/// Query only the endpoint's origin; OpenAI-compatible paths such as `/v1` are unrelated.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn api_origin(base_url: &str) -> Option<String> {
|
||||
let (scheme, rest) = base_url.trim().split_once("://")?;
|
||||
if !matches!(scheme, "http" | "https") {
|
||||
return None;
|
||||
}
|
||||
let authority = rest.split(['/', '?', '#']).next()?;
|
||||
if authority.is_empty() || authority.contains(['@', ' ']) || authority.len() > 2048 {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{scheme}://{authority}"))
|
||||
}
|
||||
|
||||
/// Blocking, best-effort Ollama probe. Call only on a background thread, without API keys.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn ollama_tools(http: &super::backend::HttpFn, base_url: &str, model: &str, known_ollama: bool) -> Option<bool> {
|
||||
use super::backend::HttpRequest;
|
||||
let origin = api_origin(base_url)?;
|
||||
let request = |method, path: &str, body| {
|
||||
let (status, bytes) = http(&HttpRequest {
|
||||
method,
|
||||
url: format!("{origin}/api/{path}"),
|
||||
headers: if method == "POST" { vec![("Content-Type".into(), "application/json".into())] } else { Vec::new() },
|
||||
body,
|
||||
timeout_s: 5,
|
||||
})
|
||||
.ok()?;
|
||||
if !(200..300).contains(&status) || bytes.len() > 4 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_slice::<serde_json::Value>(&bytes).ok()
|
||||
};
|
||||
if !known_ollama {
|
||||
let version = request("GET", "version", None)?;
|
||||
version.get("version")?.as_str()?;
|
||||
}
|
||||
let body = serde_json::to_vec(&serde_json::json!({"model": model})).ok()?;
|
||||
tools_capability(&request("POST", "show", Some(body))?)
|
||||
}
|
||||
|
||||
/// Tool support is unknown unless Ollama returns a capabilities list of strings.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn tools_capability(value: &serde_json::Value) -> Option<bool> {
|
||||
let capabilities = value.get("capabilities")?.as_array()?;
|
||||
if capabilities.iter().any(|capability| !capability.is_string()) {
|
||||
return None;
|
||||
}
|
||||
Some(capabilities.iter().any(|capability| capability.as_str() == Some("tools")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ollama_capabilities_distinguish_tools_chat_and_unknown() {
|
||||
use serde_json::json;
|
||||
assert_eq!(tools_capability(&json!({"capabilities": ["completion", "tools"]})), Some(true));
|
||||
assert_eq!(tools_capability(&json!({"capabilities": ["completion"]})), Some(false));
|
||||
assert_eq!(tools_capability(&json!({"capabilities": []})), Some(false));
|
||||
for value in [json!({}), json!({"capabilities": null}), json!({"capabilities": [false]})] {
|
||||
assert_eq!(tools_capability(&value), None);
|
||||
}
|
||||
assert_eq!(api_origin("http://[::1]:11434/proxy/v1/").as_deref(), Some("http://[::1]:11434"));
|
||||
assert_eq!(api_origin("file:///tmp/model"), None);
|
||||
let http: super::super::backend::HttpFn = std::sync::Arc::new(|request| {
|
||||
assert_eq!(request.timeout_s, 5);
|
||||
match request.method {
|
||||
"GET" => {
|
||||
assert_eq!(request.url, "http://localhost:11434/api/version");
|
||||
Ok((200, br#"{"version":"0.0"}"#.to_vec()))
|
||||
}
|
||||
"POST" => {
|
||||
assert_eq!(request.url, "http://localhost:11434/api/show");
|
||||
assert_eq!(serde_json::from_slice::<serde_json::Value>(request.body.as_ref().unwrap()).unwrap(), json!({"model":"chat:4b"}));
|
||||
Ok((200, br#"{"capabilities":["completion"]}"#.to_vec()))
|
||||
}
|
||||
_ => Err("unexpected HTTP method".into()),
|
||||
}
|
||||
});
|
||||
assert_eq!(ollama_tools(&http, "http://localhost:11434/custom/v1", "chat:4b", false), Some(false));
|
||||
assert_eq!(ollama_tools(&http, "http://localhost:11434/v1", "chat:4b", true), Some(false));
|
||||
let unavailable: super::super::backend::HttpFn = std::sync::Arc::new(|_| Err("unavailable".into()));
|
||||
assert_eq!(ollama_tools(&unavailable, "http://localhost:11434/v1", "chat:4b", false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_program_is_found_in_the_first_folder_that_has_it() {
|
||||
let root = std::env::temp_dir().join(format!("sc-agent-find-{}", std::process::id()));
|
||||
|
||||
@@ -253,6 +253,10 @@ struct Turn {
|
||||
changes: u32,
|
||||
requests: u32,
|
||||
limit_told: bool,
|
||||
tools_available: bool,
|
||||
tool_called: bool,
|
||||
replied: bool,
|
||||
edit_requested: bool,
|
||||
label: String,
|
||||
}
|
||||
|
||||
@@ -390,6 +394,9 @@ pub struct AgentChat {
|
||||
running_endpoint: String,
|
||||
backend: Option<Box<dyn ChatBackend>>,
|
||||
turn: Option<Turn>,
|
||||
tools_available: Option<bool>,
|
||||
/// The latest completed turn's notice; older notices remain in the transcript.
|
||||
turn_warning: Option<String>,
|
||||
held: Vec<Held>,
|
||||
next_approval: u64,
|
||||
/// The chat's control endpoint: the port, and its requests.
|
||||
@@ -564,6 +571,7 @@ impl AgentChat {
|
||||
fn on_event(&mut self, e: Event) -> Option<Outcome> {
|
||||
match e {
|
||||
Event::Documents(documents) => self.documents = documents,
|
||||
Event::ToolsAvailable(available) => self.tools_available = Some(available),
|
||||
Event::Ready { agent, images } => {
|
||||
self.agent = agent;
|
||||
self.images = images;
|
||||
@@ -579,6 +587,12 @@ impl AgentChat {
|
||||
}
|
||||
}
|
||||
Event::Text { thought, text } => {
|
||||
if !thought
|
||||
&& !text.trim().is_empty()
|
||||
&& let Some(turn) = self.turn.as_mut()
|
||||
{
|
||||
turn.replied = true;
|
||||
}
|
||||
if let Some(Item::Agent { thought: t, text: last }) = self.items.last_mut()
|
||||
&& *t == thought
|
||||
{
|
||||
@@ -588,6 +602,9 @@ impl AgentChat {
|
||||
}
|
||||
}
|
||||
Event::Tool { id, title, kind, status, output } => {
|
||||
if let Some(turn) = self.turn.as_mut() {
|
||||
turn.tool_called = true;
|
||||
}
|
||||
let existing = self.items.iter_mut().rev().find(|i| matches!(i, Item::Tool { id: x, .. } if *x == id));
|
||||
if let Some(Item::Tool { title: t, kind: k, status: s, output: o, .. }) = existing {
|
||||
if let Some(v) = title {
|
||||
@@ -822,6 +839,7 @@ pub fn connect(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
chat.notice(tr("ui.agent_chat_no_cli"), true);
|
||||
}
|
||||
let bridge = cli.zip(chat.tools_port).map(|(cli, port)| acp::McpBridge { cli, port });
|
||||
chat.tools_available = Some(bridge.is_some());
|
||||
let wake = ctx.clone();
|
||||
let start = backend::StartContext { cwd: working_dir(), bridge, tools: chat.tools_tx.clone(), wake: Box::new(move || wake.request_repaint()) };
|
||||
let started: Result<Box<dyn ChatBackend>, String> = match entry.kind {
|
||||
@@ -1106,6 +1124,7 @@ pub fn new_chat(app: &mut SolveApp) {
|
||||
let chat = &mut app.agent_chat;
|
||||
chat.confirm_new = false;
|
||||
chat.items.clear();
|
||||
chat.turn_warning = None;
|
||||
chat.gallery = None;
|
||||
chat.picked.clear();
|
||||
chat.picked_context = Context::default();
|
||||
@@ -1188,6 +1207,7 @@ fn start_turn_with(app: &mut SolveApp, text: String, image: bool, at: Option<usi
|
||||
// The undo step is named after the message, or its first file when it has no words.
|
||||
let first_line = text.lines().find(|l| !l.trim().is_empty()).or_else(|| files.first().map(|f| f.name.as_str())).unwrap_or("");
|
||||
let label: String = first_line.chars().take(48).collect();
|
||||
app.agent_chat.turn_warning = None;
|
||||
app.agent_chat.turn = Some(Turn {
|
||||
active: Arc::new(std::sync::atomic::AtomicBool::new(true)),
|
||||
start_doc: app.session.doc.clone(),
|
||||
@@ -1196,6 +1216,10 @@ fn start_turn_with(app: &mut SolveApp, text: String, image: bool, at: Option<usi
|
||||
requests: 0,
|
||||
limit_told: false,
|
||||
label,
|
||||
tools_available: app.agent_chat.tools_available.unwrap_or(app.agent_chat.tools_port.is_some()),
|
||||
tool_called: false,
|
||||
replied: false,
|
||||
edit_requested: requests_edit(&text),
|
||||
});
|
||||
app.agent_chat.last = Some((text, image, at, files));
|
||||
app.agent_chat.state = Some(State::Busy);
|
||||
@@ -1408,6 +1432,24 @@ fn approve_with(app: &mut SolveApp, id: u64, allow: bool, why: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognise only explicit English edit requests; informational questions stay quiet.
|
||||
/// This checks the user's opening words, never interprets the model's prose as a call.
|
||||
fn requests_edit(text: &str) -> bool {
|
||||
let opening: String = text.trim().chars().take(160).collect::<String>().to_ascii_lowercase();
|
||||
let mut words = opening.as_str();
|
||||
for prefix in ["please ", "can you ", "could you ", "would you ", "please "] {
|
||||
if let Some(rest) = words.strip_prefix(prefix) {
|
||||
words = rest;
|
||||
}
|
||||
}
|
||||
[
|
||||
"add", "create", "make", "change", "rename", "move", "rotate", "delete", "remove", "sketch", "extrude", "round", "fillet", "chamfer",
|
||||
"scale", "cut", "drill",
|
||||
]
|
||||
.iter()
|
||||
.any(|verb| words.strip_prefix(*verb).is_some_and(|rest| rest.starts_with(char::is_whitespace)))
|
||||
}
|
||||
|
||||
/// The message is answered: its changes become one undo step, and what still waits is declined.
|
||||
fn end_turn(app: &mut SolveApp) {
|
||||
decline_all(app, "the message ended");
|
||||
@@ -1418,6 +1460,20 @@ fn finish_turn(chat: &mut AgentChat, session: &mut solidcraft_engine::Session) {
|
||||
let Some(turn) = chat.turn.take() else { return };
|
||||
turn.active.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
let steps = merge_undo(&mut session.undo, &turn.start_doc, turn.start_undo, &trf("ui.agent_chat_undo_label", &[("message", &turn.label)]));
|
||||
if chat.ended.as_deref() == Some("")
|
||||
&& !chat.stopped
|
||||
&& turn.tools_available
|
||||
&& !turn.tool_called
|
||||
&& turn.requests == 0
|
||||
&& turn.changes == 0
|
||||
&& steps == 0
|
||||
&& Arc::ptr_eq(&session.doc, &turn.start_doc)
|
||||
&& (!turn.replied || turn.edit_requested)
|
||||
{
|
||||
let warning = tr("ui.agent_chat_no_tool_call").to_string();
|
||||
chat.turn_warning = Some(warning.clone());
|
||||
chat.notice(warning, false);
|
||||
}
|
||||
if steps > 0 {
|
||||
let doc = session.doc.clone();
|
||||
chat.push(Item::Changes { steps, doc, undone: false });
|
||||
@@ -1614,6 +1670,8 @@ pub fn poll(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
start_turn(app, text, image, at, files);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
settings::poll_capabilities(app, ctx);
|
||||
pump_tools(app);
|
||||
if !app.agent_chat.held.is_empty() || app.agent_chat.state() == State::Busy {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(250));
|
||||
@@ -1964,6 +2022,7 @@ pub fn status(app: &SolveApp) -> Value {
|
||||
"model": c.model,
|
||||
"providers": model_providers(app).iter().map(|(name, count)| json!({"name": name, "count": count})).collect::<Vec<_>>(),
|
||||
"provider": shown_provider(app),
|
||||
"modelWarning": settings::model_warning(app),
|
||||
"modes": c.modes.iter().map(|(id, name)| json!({"id": id, "name": name})).collect::<Vec<_>>(),
|
||||
"mode": c.mode,
|
||||
"open": c.open,
|
||||
@@ -1987,6 +2046,7 @@ pub fn status(app: &SolveApp) -> Value {
|
||||
"loading": c.loading.iter().map(|f| f.name.clone()).collect::<Vec<_>>(),
|
||||
"stopped": c.stopped,
|
||||
"turn": c.turn.as_ref().map(|t| json!({"changes": t.changes, "requests": t.requests})),
|
||||
"turnWarning": c.turn_warning,
|
||||
"detection": detection,
|
||||
"items": items,
|
||||
})
|
||||
|
||||
@@ -1163,6 +1163,9 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(warning) = settings::model_warning(app) {
|
||||
ui.add(egui::Label::new(RichText::new(warning).color(t.warning).size(t.font_small)).wrap());
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let b = if app.agent_chat.state().running() {
|
||||
ui_kit::button(ui, tr("ui.agent_chat_stop"), ui_kit::Button::Normal).on_hover_text(tr("ui.agent_chat_stop_tip"))
|
||||
@@ -1469,7 +1472,7 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
Item::Notice { text, error } => {
|
||||
ui_kit::assistant_banner(ui, if *error { ui_kit::AssistantTone::Error } else { ui_kit::AssistantTone::Neutral }, |ui| {
|
||||
ui.label(RichText::new(text).color(if *error { t.error } else { t.text_dim }));
|
||||
ui.add(egui::Label::new(RichText::new(text).color(if *error { t.error } else { t.text_dim })).wrap());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,92 @@ impl Test {
|
||||
pub struct State {
|
||||
pub add: Option<AddState>,
|
||||
pub tests: std::collections::HashMap<String, Test>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
capabilities: std::collections::HashMap<(String, String), Option<bool>>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
checking: Option<CapabilityCheck>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct CapabilityCheck {
|
||||
key: (String, String),
|
||||
rx: std::sync::mpsc::Receiver<Option<bool>>,
|
||||
}
|
||||
|
||||
/// Poll one bounded background probe at a time. Failures are cached too.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn poll_capabilities(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
use std::sync::mpsc::TryRecvError;
|
||||
let state = &mut app.agent_chat.settings;
|
||||
if let Some(check) = &state.checking {
|
||||
let result = match check.rx.try_recv() {
|
||||
Ok(value) => Some(value),
|
||||
Err(TryRecvError::Disconnected) => Some(None),
|
||||
Err(TryRecvError::Empty) => None,
|
||||
};
|
||||
if let Some(value) = result {
|
||||
if state.capabilities.len() >= 128 {
|
||||
state.capabilities.clear();
|
||||
}
|
||||
state.capabilities.insert(check.key.clone(), value);
|
||||
state.checking = None;
|
||||
}
|
||||
}
|
||||
if state.checking.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some((key, known)) = capability_selection(app) else { return };
|
||||
if app.agent_chat.settings.capabilities.contains_key(&key) {
|
||||
return;
|
||||
}
|
||||
let Some(http) = app.services.agent_http.clone() else { return };
|
||||
if app.agent_chat.settings.capabilities.len() >= 128 {
|
||||
app.agent_chat.settings.capabilities.clear();
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let query = key.clone();
|
||||
let wake = ctx.clone();
|
||||
if std::thread::Builder::new()
|
||||
.name("model-capabilities".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send(discover::ollama_tools(&http, &query.0, &query.1, known));
|
||||
wake.request_repaint();
|
||||
})
|
||||
.is_ok()
|
||||
{
|
||||
app.agent_chat.settings.checking = Some(CapabilityCheck { key, rx });
|
||||
} else {
|
||||
app.agent_chat.settings.capabilities.insert(key, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn capability_selection(app: &SolveApp) -> Option<((String, String), bool)> {
|
||||
let entry = agents::current(app)?;
|
||||
if entry.kind != Kind::Api {
|
||||
return None;
|
||||
}
|
||||
let model = if app.agent_chat.backend.is_none() || app.agent_chat.model.is_empty() { entry.model } else { app.agent_chat.model.clone() };
|
||||
if model.is_empty() || model.len() > 4096 || entry.base_url.len() > 4096 {
|
||||
return None;
|
||||
}
|
||||
let known = app.preferences.agents.iter().any(|a| a.id == entry.id && (a.ollama || a.name.eq_ignore_ascii_case("Ollama")));
|
||||
Some(((entry.base_url, model), known))
|
||||
}
|
||||
|
||||
/// The same advisory text is used by the panel and the control channel.
|
||||
pub(super) fn model_warning(app: &SolveApp) -> Option<String> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some((key, _)) = capability_selection(app) {
|
||||
return capability_warning(&key.1, app.agent_chat.settings.capabilities.get(&key).copied().flatten());
|
||||
}
|
||||
let _ = app;
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn capability_warning(model: &str, tools: Option<bool>) -> Option<String> {
|
||||
(tools == Some(false)).then(|| trf("ui.agent_chat_model_no_tools", &[("model", model)]))
|
||||
}
|
||||
|
||||
/// Open the Add agent dialog.
|
||||
@@ -645,6 +731,7 @@ fn add_agent(app: &mut SolveApp) -> Result<(), String> {
|
||||
}
|
||||
AddKind::Api => {
|
||||
c.kind = s.protocol.kind();
|
||||
c.ollama = s.protocol == Protocol::OpenAi && s.preset == 0;
|
||||
c.base_url = agents::normalize_url(&s.base_url)?;
|
||||
c.model = s.model.trim().to_string();
|
||||
c.request_timeout_s = s.request_timeout_s.clamp(agents::MIN_REQUEST_TIMEOUT_S, agents::MAX_REQUEST_TIMEOUT_S);
|
||||
@@ -676,6 +763,61 @@ mod card_tests {
|
||||
use crate::scenario::{self, Harness};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn cached_model_warning_follows_endpoint_model_and_agent_selection() {
|
||||
let mut h = Harness::new();
|
||||
h.app.agent_chat.detection = Some(Default::default());
|
||||
h.app.preferences.agents.push(AgentConfig {
|
||||
id: "local".into(),
|
||||
kind: Kind::Api,
|
||||
base_url: "http://localhost:11434/v1".into(),
|
||||
model: "chat:4b".into(),
|
||||
..Default::default()
|
||||
});
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let count = calls.clone();
|
||||
let ui_thread = std::thread::current().id();
|
||||
h.app.services.agent_http = Some(Arc::new(move |request| {
|
||||
assert_ne!(std::thread::current().id(), ui_thread);
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok((200, if request.method == "GET" { br#"{"version":"0.0"}"#.to_vec() } else { br#"{"capabilities":["completion"]}"#.to_vec() }))
|
||||
}));
|
||||
poll_capabilities(&mut h.app, &h.ctx);
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while h.app.agent_chat.settings.checking.is_some() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
poll_capabilities(&mut h.app, &h.ctx);
|
||||
}
|
||||
assert!(h.app.agent_chat.settings.checking.is_none());
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
poll_capabilities(&mut h.app, &h.ctx);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2, "cached selections do not probe again");
|
||||
assert_eq!(status(&h.app)["modelWarning"], capability_warning("chat:4b", Some(false)).unwrap());
|
||||
h.app.preferences.agents[0].model = "tools:8b".into();
|
||||
assert!(status(&h.app)["modelWarning"].is_null());
|
||||
h.app.preferences.agents[0].model = "chat:4b".into();
|
||||
h.app.preferences.agents[0].base_url = "http://localhost:1234/v1".into();
|
||||
assert!(status(&h.app)["modelWarning"].is_null());
|
||||
h.app.preferences.agents[0].base_url = "http://localhost:11434/v1".into();
|
||||
h.app.preferences.agents[0].kind = Kind::Acp;
|
||||
assert!(status(&h.app)["modelWarning"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_warning_only_for_explicitly_missing_tools() {
|
||||
assert_eq!(
|
||||
capability_warning("local:4b", Some(false)).as_deref(),
|
||||
Some("local:4b can't use SolidCraft's tools, so it can only chat. Pick a model with tool support.")
|
||||
);
|
||||
assert_eq!(capability_warning("local:4b", Some(true)), None);
|
||||
assert_eq!(capability_warning("local:4b", None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_agent_card_retains_kind_handles_draft_and_validation() {
|
||||
let mut h = Harness::new();
|
||||
|
||||
@@ -2382,3 +2382,68 @@ fn add_agent_takes_a_request_timeout_and_the_state_reports_it() {
|
||||
control(&mut app, &ctx, &json!({"add_agent": {"id": "acp", "name": "A", "command": "x --acp"}})).unwrap();
|
||||
assert_eq!(timeout(&mut app, "acp"), Some(Value::Null), "only endpoints have a request timeout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_tool_turn_reports_empty_reply_and_unperformed_edit() {
|
||||
for (prompt, reply) in [
|
||||
("Hello", ""),
|
||||
("Hello", " "),
|
||||
("Make a box", "Here is how to make a box."),
|
||||
("Can you rename the body?", "I cannot rename bodies."),
|
||||
("Could you please make a change to the current shape", "Here is the command."),
|
||||
("Sketch a rectangle", r#"{"name":"batch","arguments":{"commands":[]}}"#),
|
||||
] {
|
||||
let (mut app, _, _) = app_with_script();
|
||||
app.agent_chat.tools_port = Some(12345);
|
||||
start_turn(&mut app, prompt.into(), false, None, vec![]);
|
||||
if !reply.is_empty() {
|
||||
app.agent_chat.on_event(Event::Text { thought: false, text: reply.into() });
|
||||
}
|
||||
app.agent_chat.on_event(Event::TurnEnded { reason: String::new() });
|
||||
end_turn(&mut app);
|
||||
let expected = tr("ui.agent_chat_no_tool_call");
|
||||
assert!(app.agent_chat.items.iter().any(|i| matches!(i, Item::Notice { text, error: false } if text == expected)), "{prompt}");
|
||||
assert_eq!(status(&app)["turnWarning"], expected);
|
||||
assert!(app.session.undo.is_empty());
|
||||
start_turn(&mut app, "What is a fillet?".into(), false, None, vec![]);
|
||||
assert!(status(&app)["turnWarning"].is_null());
|
||||
assert!(app.agent_chat.items.iter().any(|i| matches!(i, Item::Notice { text, .. } if text == expected)));
|
||||
new_chat(&mut app);
|
||||
assert!(status(&app)["turnWarning"].is_null());
|
||||
assert!(app.agent_chat.items.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_tool_turn_warning_does_not_follow_a_tool_call() {
|
||||
let (mut app, _, _) = app_with_script();
|
||||
app.agent_chat.tools_port = Some(12345);
|
||||
start_turn(&mut app, "Make a box".into(), false, None, vec![]);
|
||||
app.agent_chat.on_event(Event::Tool { id: "t".into(), title: Some("batch".into()), kind: None, status: Some(ToolStatus::Failed), output: None });
|
||||
app.agent_chat.on_event(Event::TurnEnded { reason: String::new() });
|
||||
end_turn(&mut app);
|
||||
assert!(status(&app).get("turnWarning").is_some());
|
||||
assert!(status(&app)["turnWarning"].is_null());
|
||||
assert!(!app.agent_chat.items.iter().any(|i| matches!(i, Item::Notice { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_tool_turn_warning_leaves_answers_and_incomplete_turns_quiet() {
|
||||
for (prompt, reply, reason, tools) in [
|
||||
("What is a fillet?", "A fillet rounds an edge.", "", true),
|
||||
("How do I make a box?", "Use the Box command.", "", true),
|
||||
("Make a box", "", "cancelled", true),
|
||||
("Make a box", "", "error", true),
|
||||
("Make a box", "", "", false),
|
||||
] {
|
||||
let (mut app, _, _) = app_with_script();
|
||||
app.agent_chat.tools_port = tools.then_some(12345);
|
||||
start_turn(&mut app, prompt.into(), false, None, vec![]);
|
||||
app.agent_chat.on_event(Event::Text { thought: false, text: reply.into() });
|
||||
app.agent_chat.on_event(Event::TurnEnded { reason: reason.into() });
|
||||
end_turn(&mut app);
|
||||
assert!(status(&app).get("turnWarning").is_some());
|
||||
assert!(status(&app)["turnWarning"].is_null(), "{prompt}: {reason}");
|
||||
assert!(!app.agent_chat.items.iter().any(|i| matches!(i, Item::Notice { text, .. } if text == tr("ui.agent_chat_no_tool_call"))));
|
||||
}
|
||||
}
|
||||
|
||||
+370
-247
@@ -7,6 +7,9 @@
|
||||
//! ([`crate::context_menu`]).
|
||||
|
||||
use crate::ui_kit::{Container, Surface};
|
||||
|
||||
#[path = "browser_cache.rs"]
|
||||
mod cache;
|
||||
#[cfg(test)]
|
||||
#[path = "occurrence_ui_tests.rs"]
|
||||
mod occurrence_ui_tests;
|
||||
@@ -145,6 +148,22 @@ pub struct TreeState {
|
||||
/// The previous frame's complete row order: a Shift click resolves its range here, since
|
||||
/// this frame's `order` does not yet hold the rows below the clicked one (#4991).
|
||||
order_prev: Vec<String>,
|
||||
order_len: usize,
|
||||
cache: Option<cache::Cache>,
|
||||
}
|
||||
|
||||
impl TreeState {
|
||||
fn remember(&mut self, key: &str) {
|
||||
if let Some(slot) = self.order.get_mut(self.order_len) {
|
||||
if slot != key {
|
||||
slot.clear();
|
||||
slot.push_str(key);
|
||||
}
|
||||
} else {
|
||||
self.order.push(key.to_owned());
|
||||
}
|
||||
self.order_len += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Moving an occurrence: the offsets typed so far (pending until Capture Position).
|
||||
@@ -324,6 +343,10 @@ const INDENT: f32 = 14.0;
|
||||
/// Opacity of a dimmed row's icon (a hidden body, sketch, origin item, …).
|
||||
const DIM_ICON_OPACITY: f32 = 0.4;
|
||||
|
||||
fn row_rect(ui: &egui::Ui) -> Rect {
|
||||
Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_H))
|
||||
}
|
||||
|
||||
/// One row's look.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Row<'a> {
|
||||
@@ -379,7 +402,7 @@ pub(crate) struct 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));
|
||||
let r = row_rect(ui);
|
||||
if !ui.is_rect_visible(r) {
|
||||
ui.allocate_space(r.size());
|
||||
return RowResp { rect: Some(r), ..Default::default() };
|
||||
@@ -512,9 +535,10 @@ pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
fn click_select(app: &mut SolveApp, ui: &egui::Ui, key: String, sel: Option<Sel>) {
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
if mods.shift
|
||||
&& let Some(a) = app.tree.anchor.clone()
|
||||
&& let Some(a) = app.tree.anchor.as_deref()
|
||||
{
|
||||
let items = shift_range(&app.tree.order_prev, &a, &key).or_else(|| shift_range(&app.tree.order, &a, &key));
|
||||
let order = app.tree.order.get(..app.tree.order_len).unwrap_or(&[]);
|
||||
let items = shift_range(&app.tree.order_prev, a, &key).or_else(|| shift_range(order, a, &key));
|
||||
if let Some(items) = items {
|
||||
let _ = app.run("select.set", json!({ "items": items, "add": mods.command || mods.ctrl }));
|
||||
return;
|
||||
@@ -626,6 +650,7 @@ pub fn apply_fold(app: &mut SolveApp, open: bool, open_by_default: &[String], cl
|
||||
struct Actions {
|
||||
menu: Option<(Pos2, Target)>,
|
||||
hover_bodies: Vec<String>,
|
||||
hovered: bool,
|
||||
drop: Option<DropAction>,
|
||||
/// What the selection and the 3D view's pointer light in the tree.
|
||||
lit: Lit,
|
||||
@@ -639,8 +664,10 @@ enum Thing {
|
||||
Sketch(u64),
|
||||
/// An origin or construction plane, by name.
|
||||
Plane(String),
|
||||
/// An origin axis.
|
||||
/// An origin or construction axis.
|
||||
Axis(String),
|
||||
/// A construction point.
|
||||
Point(String),
|
||||
}
|
||||
|
||||
/// What the tree lights for the selection and the 3D view's pointer: each lit item's row (in
|
||||
@@ -679,6 +706,7 @@ impl Lit {
|
||||
}
|
||||
Sel::Plane { name } => Thing::Plane(name.clone()),
|
||||
Sel::Axis { name } => Thing::Axis(name.clone()),
|
||||
Sel::ConstructionPoint { name } => Thing::Point(name.clone()),
|
||||
Sel::Occurrence { .. } => continue,
|
||||
});
|
||||
}
|
||||
@@ -689,6 +717,7 @@ impl Lit {
|
||||
) => Some(Thing::Sketch(*sketch)),
|
||||
Some(Hit::Plane { name, .. }) => Some(Thing::Plane(name.clone())),
|
||||
Some(Hit::Axis { name }) => Some(Thing::Axis(name.clone())),
|
||||
Some(Hit::ConstructionPoint { name }) => Some(Thing::Point(name.clone())),
|
||||
None => None,
|
||||
};
|
||||
let doc = app.session.doc.clone();
|
||||
@@ -703,10 +732,20 @@ impl Lit {
|
||||
Thing::Sketch(id) => {
|
||||
components.extend(doc.features.iter().find(|f| f.id == *id && matches!(f.kind, FeatureKind::Sketch { .. })).map(|f| f.component))
|
||||
}
|
||||
Thing::Plane(name) => components.extend(
|
||||
doc.features.iter().find(|f| f.name == *name && matches!(f.kind, FeatureKind::ConstructionPlane { .. })).map(|f| f.component),
|
||||
Thing::Plane(name) | Thing::Axis(name) | Thing::Point(name) => components.extend(
|
||||
doc.features
|
||||
.iter()
|
||||
.find(|f| {
|
||||
f.name == *name
|
||||
&& matches!(
|
||||
f.kind,
|
||||
FeatureKind::ConstructionPlane { .. }
|
||||
| FeatureKind::ConstructionAxis { .. }
|
||||
| FeatureKind::ConstructionPoint { .. }
|
||||
)
|
||||
})
|
||||
.map(|f| f.component),
|
||||
),
|
||||
Thing::Axis(_) => {}
|
||||
}
|
||||
}
|
||||
Lit { selected, hovered, paths, components }
|
||||
@@ -721,25 +760,16 @@ impl Lit {
|
||||
(self.selected.contains(thing), self.hovered.as_ref() == Some(thing))
|
||||
}
|
||||
|
||||
/// The things a folder entry stands for.
|
||||
fn entry_things(folder: &str, e: &Entry) -> Vec<Thing> {
|
||||
match folder {
|
||||
"bodies" => e.bodies.iter().cloned().map(Thing::Body).collect(),
|
||||
"sketches" => e.key.parse().ok().map(Thing::Sketch).into_iter().collect(),
|
||||
_ => vec![Thing::Plane(e.label.clone())],
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a folder entry is lit: (selected, under the pointer). Its own `selected` (picked
|
||||
/// in the tree, or the sketch being edited) counts as selected.
|
||||
fn entry(&self, folder: &str, e: &Entry) -> (bool, bool) {
|
||||
let things = Lit::entry_things(folder, e);
|
||||
(e.selected || things.iter().any(|t| self.selected.contains(t)), things.iter().any(|t| self.hovered.as_ref() == Some(t)))
|
||||
fn entry(&self, app: &SolveApp, e: &Entry) -> (bool, bool) {
|
||||
let (selected, hovered) = self.thing(&e.thing);
|
||||
(e.is_selected(app) || selected, hovered)
|
||||
}
|
||||
|
||||
/// The mark of a row holding these entries: a faint bar when any of them is lit.
|
||||
fn branch<'e>(&self, folder: &str, entries: impl IntoIterator<Item = &'e Entry>) -> Mark {
|
||||
if entries.into_iter().any(|e| self.entry(folder, e) != (false, false)) { Mark::Branch } else { Mark::None }
|
||||
fn branch<'e>(&self, app: &SolveApp, entries: impl IntoIterator<Item = &'e Entry>) -> Mark {
|
||||
if entries.into_iter().any(|e| self.entry(app, e) != (false, false)) { Mark::Branch } else { Mark::None }
|
||||
}
|
||||
|
||||
/// The mark of a placement row: a faint bar when a lit body is placed in it or below it, or
|
||||
@@ -755,6 +785,24 @@ fn lit_row((selected, hovered): (bool, bool)) -> (bool, Mark) {
|
||||
(selected, if selected || hovered { Mark::Here } else { Mark::None })
|
||||
}
|
||||
|
||||
impl Actions {
|
||||
fn hover<'a>(&mut self, bodies: impl Iterator<Item = &'a String>) {
|
||||
let mut len = 0;
|
||||
for body in bodies {
|
||||
if let Some(slot) = self.hover_bodies.get_mut(len) {
|
||||
if slot != body {
|
||||
slot.clone_from(body);
|
||||
}
|
||||
} else {
|
||||
self.hover_bodies.push(body.clone());
|
||||
}
|
||||
len += 1;
|
||||
}
|
||||
self.hover_bodies.truncate(len);
|
||||
self.hovered = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// How narrow the browser sidebar may be dragged, and how wide (the viewport keeps the rest).
|
||||
const MIN_BROWSER_WIDTH: f32 = 170.0;
|
||||
const MAX_BROWSER_WIDTH: f32 = 620.0;
|
||||
@@ -785,10 +833,11 @@ pub(crate) fn resize_column(app: &mut SolveApp, ui: &egui::Ui, rect: Rect, width
|
||||
pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let available = ui.available_rect_before_wrap();
|
||||
let t = Tokens::get();
|
||||
app.tree.order_prev = std::mem::take(&mut app.tree.order);
|
||||
std::mem::swap(&mut app.tree.order_prev, &mut app.tree.order);
|
||||
app.tree.order_len = 0;
|
||||
DRAWN.with(|d| d.borrow_mut().clear());
|
||||
OCCURRENCES.with(|d| d.borrow_mut().clear());
|
||||
let mut acts = Actions::default();
|
||||
let mut acts = Actions { hover_bodies: std::mem::take(&mut app.viewport.hover_bodies), ..Default::default() };
|
||||
let stacked = crate::timeline_left::stacked(app);
|
||||
let container =
|
||||
Container::new(ui.ctx(), Surface::Panel).joined(crate::ui_kit::DockEdges { left: true, top: true, bottom: stacked, ..Default::default() });
|
||||
@@ -969,6 +1018,7 @@ fn model_placement(app: &mut SolveApp, path: &[u64]) -> Option<std::sync::Arc<so
|
||||
app.tree.model.as_mut()?.placement(&app.session, path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn model_children(app: &SolveApp, component: u64) -> Vec<u64> {
|
||||
match app.tree.model.as_ref().filter(|model| model.matches(&app.session)) {
|
||||
Some(model) => model.children.get(&component).cloned().unwrap_or_default(),
|
||||
@@ -1002,8 +1052,14 @@ fn model_feature_indices(app: &SolveApp, component: u64, sketches: bool) -> Vec<
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, f)| {
|
||||
let kind =
|
||||
if sketches { matches!(f.kind, FeatureKind::Sketch { .. }) } else { matches!(f.kind, FeatureKind::ConstructionPlane { .. }) };
|
||||
let kind = if sketches {
|
||||
matches!(f.kind, FeatureKind::Sketch { .. })
|
||||
} else {
|
||||
matches!(
|
||||
f.kind,
|
||||
FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. }
|
||||
)
|
||||
};
|
||||
(f.component == component && kind).then_some(i)
|
||||
})
|
||||
.collect(),
|
||||
@@ -1023,6 +1079,8 @@ fn browser_rows_hugging(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Action
|
||||
fn rows_scrolling(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions, shrink_height: bool) {
|
||||
prepare_model(app);
|
||||
acts.lit = Lit::of(app);
|
||||
let mut cache = app.tree.cache.take().unwrap_or_default();
|
||||
cache.prepare(app);
|
||||
egui::ScrollArea::vertical()
|
||||
// The card may animate all the way down to its header.
|
||||
.min_scrolled_height(0.0)
|
||||
@@ -1030,14 +1088,19 @@ fn rows_scrolling(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions, shr
|
||||
.scroll_source(egui::scroll_area::ScrollSource { scroll_bar: true, mouse_wheel: true, ..egui::scroll_area::ScrollSource::NONE })
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
component_rows(app, ui, 0, 0, acts);
|
||||
component_rows(app, ui, 0, 0, acts, &mut cache);
|
||||
});
|
||||
// The row was reached and scrolled to, or isn't in the tree (a body a later feature removes):
|
||||
// either way the reveal is done.
|
||||
app.tree.reveal = None;
|
||||
app.tree.cache = Some(cache);
|
||||
}
|
||||
|
||||
fn finish_browser(app: &mut SolveApp, ui: &mut egui::Ui, acts: Actions) {
|
||||
fn finish_browser(app: &mut SolveApp, ui: &mut egui::Ui, mut acts: Actions) {
|
||||
app.tree.order.truncate(app.tree.order_len);
|
||||
if !acts.hovered {
|
||||
acts.hover_bodies.clear();
|
||||
}
|
||||
app.viewport.hover_bodies = acts.hover_bodies;
|
||||
if let Some(d) = acts.drop {
|
||||
apply_drop(app, d);
|
||||
@@ -1062,28 +1125,40 @@ fn component_name(app: &SolveApp, id: u64) -> String {
|
||||
crate::dialogs_part_size::browser_label(app, id, &name)
|
||||
}
|
||||
|
||||
fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize, acts: &mut Actions) {
|
||||
let t = Tokens::get();
|
||||
let key = format!("c{id}");
|
||||
let bodies = crate::context_menu::component_bodies(app, id);
|
||||
if let Some(r) = app.tree.reveal.clone()
|
||||
&& (bodies.contains(&r) || model_children(app, id).into_iter().any(|o| reveals_in_occurrence(app, &[o])))
|
||||
fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize, acts: &mut Actions, cache: &mut cache::Cache) {
|
||||
let mut root = std::mem::take(&mut cache.root);
|
||||
component_rows_inner(app, ui, id, depth, acts, cache, &mut root);
|
||||
cache.root = root;
|
||||
}
|
||||
|
||||
fn component_rows_inner(
|
||||
app: &mut SolveApp,
|
||||
ui: &mut egui::Ui,
|
||||
id: u64,
|
||||
depth: usize,
|
||||
acts: &mut Actions,
|
||||
cache: &mut cache::Cache,
|
||||
root: &mut cache::Node,
|
||||
) {
|
||||
let key = &root.key;
|
||||
if let Some(body) = app.tree.reveal.as_ref()
|
||||
&& (cache.root_bodies.contains(body) || root.children.iter().any(|path| reveals_in_occurrence(app, path)))
|
||||
{
|
||||
app.tree.collapsed.remove(&key);
|
||||
app.tree.collapsed.remove(key);
|
||||
}
|
||||
let open = is_open(app, &key, true);
|
||||
let open = is_open(app, key, true);
|
||||
let active = app.session.active_component == id;
|
||||
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 bodies = &cache.root_bodies;
|
||||
let visible = id == 0 || bodies.is_empty() || bodies.iter().any(|b| !app.session.visibility.hidden_bodies.contains(b));
|
||||
let faded = app.session.active_component != 0 && !app.session.doc.component_within(id, app.session.active_component) && !active;
|
||||
let name = component_name(app, id);
|
||||
let badges: &[&str] = if grounded { &["pin"] } else { &[] };
|
||||
let name = &root.name;
|
||||
let badges: &[&str] = if root.grounded { &["pin"] } else { &[] };
|
||||
let row = Row {
|
||||
depth,
|
||||
fold: Some(open),
|
||||
eye: (id != 0).then_some(visible),
|
||||
icon: "component",
|
||||
label: &name,
|
||||
label: name,
|
||||
name: true,
|
||||
selected: app.tree.picked_components.contains(&id),
|
||||
dim: faded,
|
||||
@@ -1095,7 +1170,7 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize,
|
||||
};
|
||||
let r = draw_row(ui, ui.id().with(("comp", id)), &row);
|
||||
if r.fold {
|
||||
toggle(app, &key, true);
|
||||
toggle(app, key, true);
|
||||
}
|
||||
if r.radio {
|
||||
let _ = app.run("component.activate", json!({ "component": id }));
|
||||
@@ -1130,7 +1205,7 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize,
|
||||
}
|
||||
}
|
||||
if r.hovered {
|
||||
acts.hover_bodies = bodies.clone();
|
||||
acts.hover(bodies.iter());
|
||||
}
|
||||
if let Some(p) = r.secondary {
|
||||
acts.menu = Some((p, Target::Component { id }));
|
||||
@@ -1152,64 +1227,83 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize,
|
||||
canvas_rows(app, ui, d, acts);
|
||||
analysis_rows(app, ui, d, acts);
|
||||
}
|
||||
let _ = t;
|
||||
for folder in ["bodies", "sketches", "construction"] {
|
||||
folder_rows(app, ui, id, folder, d, acts);
|
||||
for (slot, folder) in root.folders.iter_mut().zip(["bodies", "sketches", "construction"]) {
|
||||
let data = slot.get_or_insert_with(|| cache.folder(app, id, None, folder));
|
||||
folder_rows_at(app, ui, id, None, folder, d, acts, data);
|
||||
}
|
||||
features::rows(app, ui, id, &[], d, acts);
|
||||
if id == 0 {
|
||||
crate::dialogs_assembly::browser_joints(app, ui, d);
|
||||
}
|
||||
let children = model_children(app, id);
|
||||
for occurrence in children {
|
||||
occurrence_rows(app, ui, vec![occurrence], d, acts);
|
||||
for path in &root.children {
|
||||
occurrence_rows(app, ui, path, d, acts, cache);
|
||||
}
|
||||
}
|
||||
|
||||
fn occurrence_key(path: &[u64]) -> String {
|
||||
format!("o:{}", path.iter().map(u64::to_string).collect::<Vec<_>>().join("/"))
|
||||
use std::fmt::Write;
|
||||
let mut key = String::from("o:");
|
||||
for (i, id) in path.iter().enumerate() {
|
||||
if i != 0 {
|
||||
key.push('/');
|
||||
}
|
||||
let _ = write!(key, "{id}");
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
/// A placement, rather than a component definition: repeated child IDs remain distinct
|
||||
/// because their complete paths are the selection and row identity.
|
||||
fn occurrence_rows(app: &mut SolveApp, ui: &mut egui::Ui, path: Vec<u64>, depth: usize, acts: &mut Actions) {
|
||||
let Some(placement) = model_placement(app, &path) else { return };
|
||||
let key = occurrence_key(&path);
|
||||
if reveals_in_occurrence(app, &path) {
|
||||
app.tree.collapsed.remove(&key);
|
||||
fn occurrence_rows(app: &mut SolveApp, ui: &mut egui::Ui, path: &[u64], depth: usize, acts: &mut Actions, cache: &mut cache::Cache) {
|
||||
// Own this cached row while drawing: children can populate the cache without copying it.
|
||||
let Some((path, mut placement)) = cache.take_occurrence(app, path) else { return };
|
||||
occurrence_rows_inner(app, ui, &path, depth, acts, cache, &mut placement);
|
||||
cache.occurrences.insert(path, placement);
|
||||
}
|
||||
|
||||
fn occurrence_rows_inner(
|
||||
app: &mut SolveApp,
|
||||
ui: &mut egui::Ui,
|
||||
path: &[u64],
|
||||
depth: usize,
|
||||
acts: &mut Actions,
|
||||
cache: &mut cache::Cache,
|
||||
placement: &mut cache::Node,
|
||||
) {
|
||||
let key = &placement.key;
|
||||
if reveals_in_occurrence(app, path) {
|
||||
app.tree.collapsed.remove(key);
|
||||
}
|
||||
let open = is_open(app, &key, true);
|
||||
let active = app.session.active_occurrence_path.as_deref() == Some(path.as_slice())
|
||||
|| (app.session.active_occurrence_path.is_none()
|
||||
&& app.session.active_component == placement.component
|
||||
&& app.session.doc.first_path(placement.component) == path);
|
||||
let visible = app.session.occurrence_visible(&path);
|
||||
let own_visible = !app.session.occurrence_hidden(&path);
|
||||
let selected = app.session.selection.contains(&Sel::Occurrence { path: path.clone() });
|
||||
let faded = app.session.active_occurrence_path.as_ref().is_some_and(|p| !path.starts_with(p) && !p.starts_with(&path));
|
||||
let open = is_open(app, key, true);
|
||||
let on_screen = ui.is_rect_visible(row_rect(ui));
|
||||
let active = app.session.active_occurrence_path.as_deref() == Some(path)
|
||||
|| (app.session.active_occurrence_path.is_none() && app.session.active_component == placement.component && placement.first);
|
||||
let visible = !on_screen || app.session.occurrence_visible(path);
|
||||
let own_visible = !on_screen || !app.session.occurrence_hidden(path);
|
||||
let selected = on_screen && app.session.selection.iter().any(|s| matches!(s, Sel::Occurrence { path: selected } if selected == path));
|
||||
let faded = app.session.active_occurrence_path.as_ref().is_some_and(|p| !path.starts_with(p) && !p.starts_with(path));
|
||||
let badges: &[&str] = if placement.grounded { &["pin"] } else { &[] };
|
||||
let label = crate::dialogs_part_size::browser_label(app, placement.component, &placement.name);
|
||||
app.tree.order.push(key.clone());
|
||||
app.tree.remember(key);
|
||||
let r = draw_row(
|
||||
ui,
|
||||
ui.id().with(&key),
|
||||
ui.id().with(key),
|
||||
&Row {
|
||||
depth,
|
||||
fold: Some(open),
|
||||
eye: Some(own_visible),
|
||||
icon: "component",
|
||||
label: &label,
|
||||
label: &placement.label,
|
||||
name: true,
|
||||
selected,
|
||||
dim: faded || !visible,
|
||||
radio: Some(active),
|
||||
badges,
|
||||
mark: acts.lit.placement(&app.session.doc, &path, placement.component),
|
||||
mark: acts.lit.placement(&app.session.doc, path, placement.component),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if let Some(rect) = r.rect.filter(|_| r.resp.is_some()) {
|
||||
OCCURRENCES.with(|d| d.borrow_mut().push((path.clone(), placement.name.clone(), rect, open)));
|
||||
OCCURRENCES.with(|d| d.borrow_mut().push((path.to_vec(), placement.name.clone(), rect, open)));
|
||||
crate::scenario::publish_handle(&format!("row:occurrence:{}", key.trim_start_matches("o:")), pos2(rect.right() - 12.0, rect.center().y));
|
||||
let x = rect.left() + 2.0 + depth as f32 * INDENT;
|
||||
for (control, offset) in [("fold", 6.0), ("eye", 22.0), ("activate", 38.0)] {
|
||||
@@ -1217,24 +1311,19 @@ fn occurrence_rows(app: &mut SolveApp, ui: &mut egui::Ui, path: Vec<u64>, depth:
|
||||
}
|
||||
}
|
||||
if let Some(resp) = &r.resp {
|
||||
let definition = app.session.doc.components.iter().find(|c| c.id == placement.component).map(|c| c.name.as_str()).unwrap_or("");
|
||||
let location = path
|
||||
.iter()
|
||||
.filter_map(|id| app.session.doc.occurrences.iter().find(|o| o.id == *id))
|
||||
.map(|o| o.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
let inherited = if own_visible && !visible { "\nHidden by a parent placement. Show the parent to reveal this instance." } else { "" };
|
||||
resp.clone().on_hover_text(format!(
|
||||
"Component: {definition}\nPlacement: {}\nGeometry edits apply to every instance of this component.{inherited}",
|
||||
location
|
||||
));
|
||||
resp.clone().on_hover_ui(|ui| {
|
||||
ui.label(&placement.tooltip);
|
||||
if !inherited.is_empty() {
|
||||
ui.label(inherited.trim_start());
|
||||
}
|
||||
});
|
||||
}
|
||||
if r.fold {
|
||||
toggle(app, &key, true);
|
||||
toggle(app, key, true);
|
||||
}
|
||||
if r.eye {
|
||||
let _ = app.run("browser.visibility", json!({"visible": app.session.occurrence_hidden(&path), "paths": [path]}));
|
||||
let _ = app.run("browser.visibility", json!({"visible": app.session.occurrence_hidden(path), "paths": [path]}));
|
||||
}
|
||||
if r.radio || (r.double && !app.session.doc.parts.contains_key(&placement.component)) {
|
||||
let _ = app.run("component.activate", json!({"component": placement.component, "occurrence": placement.occurrence, "path": path}));
|
||||
@@ -1243,16 +1332,17 @@ fn occurrence_rows(app: &mut SolveApp, ui: &mut egui::Ui, path: Vec<u64>, depth:
|
||||
crate::dialogs_part_size::open(app, placement.component);
|
||||
}
|
||||
if r.clicked {
|
||||
click_select(app, ui, key.clone(), Some(Sel::Occurrence { path: path.clone() }));
|
||||
click_select(app, ui, key.clone(), Some(Sel::Occurrence { path: path.to_vec() }));
|
||||
}
|
||||
if r.hovered {
|
||||
acts.hover_bodies = app.session.occurrence_bodies(&path, true).unwrap_or_default();
|
||||
let bodies = placement.hover_bodies.get_or_insert_with(|| app.session.occurrence_bodies(path, true).unwrap_or_default());
|
||||
acts.hover(bodies.iter());
|
||||
}
|
||||
if let Some(p) = r.secondary {
|
||||
if !selected {
|
||||
let _ = app.run("select.set", json!({"items": [Sel::Occurrence { path: path.clone() }]}));
|
||||
let _ = app.run("select.set", json!({"items": [Sel::Occurrence { path: path.to_vec() }]}));
|
||||
}
|
||||
acts.menu = Some((p, Target::Occurrence { path: path.clone() }));
|
||||
acts.menu = Some((p, Target::Occurrence { path: path.to_vec() }));
|
||||
}
|
||||
if let Some(resp) = &r.resp
|
||||
&& let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp != placement.component && d.folder != "construction", DropMark::Box)
|
||||
@@ -1262,16 +1352,14 @@ fn occurrence_rows(app: &mut SolveApp, ui: &mut egui::Ui, path: Vec<u64>, depth:
|
||||
if !open {
|
||||
return;
|
||||
}
|
||||
for folder in ["bodies", "sketches", "construction"] {
|
||||
occurrence_folder_rows(app, ui, placement.component, &path, folder, depth + 1, acts);
|
||||
for (slot, folder) in placement.folders.iter_mut().zip(["bodies", "sketches", "construction"]) {
|
||||
let data = slot.get_or_insert_with(|| cache.folder(app, placement.component, Some(path), folder));
|
||||
folder_rows_at(app, ui, placement.component, Some(path), folder, depth + 1, acts, data);
|
||||
}
|
||||
features::rows(app, ui, placement.component, &path, depth + 1, acts);
|
||||
let children = model_children(app, placement.component);
|
||||
features::rows(app, ui, placement.component, path, depth + 1, acts);
|
||||
if path.len() < 64 {
|
||||
for occurrence in children {
|
||||
let mut child = path.clone();
|
||||
child.push(occurrence);
|
||||
occurrence_rows(app, ui, child, depth + 1, acts);
|
||||
for child in &placement.children {
|
||||
occurrence_rows(app, ui, child, depth + 1, acts, cache);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1712,6 +1800,7 @@ fn pick_from_browser(app: &mut SolveApp, x: Sel) {
|
||||
let hit = match &x {
|
||||
Sel::Plane { name } => crate::viewport::Hit::Plane { name: name.clone(), point: solidcraft_engine::geom::Vec3::ZERO },
|
||||
Sel::Axis { name } => crate::viewport::Hit::Axis { name: name.clone() },
|
||||
Sel::ConstructionPoint { name } => crate::viewport::Hit::ConstructionPoint { name: name.clone() },
|
||||
// The origin point goes only into a dialog input that takes it.
|
||||
Sel::Vertex { body, point } if body.is_empty() && app.dialog.is_some() => crate::viewport::Hit::Vertex { body: String::new(), point: *point },
|
||||
_ => return,
|
||||
@@ -1728,10 +1817,13 @@ fn pick_from_browser(app: &mut SolveApp, x: Sel) {
|
||||
|
||||
/// One item of a component folder.
|
||||
struct Entry {
|
||||
/// The highlight lookup key, owned once with the cached row.
|
||||
thing: Thing,
|
||||
/// The browser key (body name, or the feature id of a sketch or plane).
|
||||
key: String,
|
||||
/// The key in [`TreeState`] row order (`b:`, `s:`, `p:`).
|
||||
row_key: String,
|
||||
patterns: Option<String>,
|
||||
label: String,
|
||||
icon: &'static str,
|
||||
visible: bool,
|
||||
@@ -1744,6 +1836,94 @@ struct Entry {
|
||||
bodies: Vec<String>,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
fn body(app: &SolveApp, b: &solidcraft_engine::doc::ModelBody, world: String) -> Self {
|
||||
Self {
|
||||
thing: Thing::Body(world.clone()),
|
||||
key: b.name.clone(),
|
||||
row_key: format!("b:{world}"),
|
||||
patterns: None,
|
||||
label: b.name.clone(),
|
||||
// An open shell is a surface body, not a solid.
|
||||
icon: if b.is_surface() { "surface" } else { "body" },
|
||||
visible: !app.session.visibility.hidden_bodies.contains(&world),
|
||||
selected: app.session.selection.iter().any(|s| matches!(s, Sel::Body { name } if name == &world)),
|
||||
color: None,
|
||||
locked: app.session.visibility.locked_bodies.contains(&world),
|
||||
sel: Some(Sel::Body { name: world.clone() }),
|
||||
target: Some(Target::Body { name: world.clone() }),
|
||||
bodies: vec![world],
|
||||
}
|
||||
}
|
||||
|
||||
fn sketch(app: &SolveApp, f: &solidcraft_engine::doc::Feature) -> Self {
|
||||
let t = Tokens::get();
|
||||
let active = app.session.active_sketch == Some(f.id);
|
||||
let lost = app.session.model.result(f.id).is_some_and(|result| result.error.is_some())
|
||||
&& matches!(app.session.doc.sketch_face_attachment(f.id, &app.session.model), solidcraft_engine::doc::FaceAttachmentStatus::Lost { .. });
|
||||
Self {
|
||||
key: f.id.to_string(),
|
||||
row_key: format!("s:{}", f.id),
|
||||
thing: Thing::Sketch(f.id),
|
||||
patterns: crate::sketch_pattern_tree::has_patterns(app, f.id).then(|| format!("s:{}/patterns", f.id)),
|
||||
label: if lost {
|
||||
format!("{} (attachment lost)", f.name)
|
||||
} else if active {
|
||||
format!("{} (editing)", f.name)
|
||||
} else {
|
||||
f.name.clone()
|
||||
},
|
||||
icon: "sketch",
|
||||
visible: sketch_visible(app, f.id),
|
||||
selected: active || app.session.selection.iter().any(|s| matches!(s, Sel::Feature { id } if *id == f.id)),
|
||||
color: if lost { Some(t.error) } else { active.then_some(t.sketch_accent) },
|
||||
locked: false,
|
||||
sel: Some(Sel::Feature { id: f.id }),
|
||||
target: Some(Target::Sketch { id: f.id }),
|
||||
bodies: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn plane(app: &SolveApp, f: &solidcraft_engine::doc::Feature) -> Self {
|
||||
let sel = construction_sel(f);
|
||||
let thing = match &sel {
|
||||
Sel::Axis { name } => Thing::Axis(name.clone()),
|
||||
Sel::ConstructionPoint { name } => Thing::Point(name.clone()),
|
||||
_ => Thing::Plane(f.name.clone()),
|
||||
};
|
||||
Self {
|
||||
key: f.id.to_string(),
|
||||
row_key: format!("p:{}", f.id),
|
||||
thing,
|
||||
patterns: None,
|
||||
label: f.name.clone(),
|
||||
icon: crate::timeline::icon_of(&f.kind),
|
||||
visible: !app.session.visibility.hidden_origin.contains(&f.name),
|
||||
selected: app.session.selection.contains(&sel),
|
||||
color: None,
|
||||
locked: false,
|
||||
sel: Some(sel),
|
||||
target: Some(Target::Plane { id: f.id }),
|
||||
bodies: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_visible(&self, app: &SolveApp) -> bool {
|
||||
match &self.sel {
|
||||
Some(Sel::Body { name }) => !app.session.visibility.hidden_bodies.contains(name),
|
||||
Some(Sel::Feature { id }) => sketch_visible(app, *id),
|
||||
Some(Sel::Plane { name }) => !app.session.visibility.hidden_origin.contains(name),
|
||||
_ => self.visible,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_selected(&self, app: &SolveApp) -> bool {
|
||||
self.sel
|
||||
.as_ref()
|
||||
.is_some_and(|s| app.session.selection.contains(s) || matches!(s, Sel::Feature { id } if app.session.active_sketch == Some(*id)))
|
||||
}
|
||||
}
|
||||
|
||||
/// What is being dragged in the tree: items of one component folder.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Drag {
|
||||
@@ -1754,86 +1934,42 @@ struct Drag {
|
||||
|
||||
/// The items of a component folder, in model order.
|
||||
fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
|
||||
let t = Tokens::get();
|
||||
let doc = &app.session.doc;
|
||||
let sel = &app.session.selection;
|
||||
let state = app.session.model.state();
|
||||
match folder {
|
||||
"bodies" => model_body_indices(app, comp)
|
||||
.into_iter()
|
||||
.filter_map(|i| state.bodies.get(i))
|
||||
.filter(|b| !app.session.body_removed_later(&b.name))
|
||||
.map(|b| Entry {
|
||||
key: b.name.clone(),
|
||||
row_key: format!("b:{}", b.name),
|
||||
label: b.name.clone(),
|
||||
icon: "body",
|
||||
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.session.visibility.locked_bodies.contains(&b.name),
|
||||
sel: Some(Sel::Body { name: b.name.clone() }),
|
||||
target: Some(Target::Body { name: b.name.clone() }),
|
||||
bodies: vec![b.name.clone()],
|
||||
})
|
||||
.collect(),
|
||||
"sketches" => model_feature_indices(app, comp, true)
|
||||
.into_iter()
|
||||
.filter_map(|i| doc.features.get(i))
|
||||
.map(|f| {
|
||||
let active = app.session.active_sketch == Some(f.id);
|
||||
let lost = app.session.model.result(f.id).is_some_and(|result| result.error.is_some())
|
||||
&& matches!(doc.sketch_face_attachment(f.id, &app.session.model), solidcraft_engine::doc::FaceAttachmentStatus::Lost { .. });
|
||||
Entry {
|
||||
key: f.id.to_string(),
|
||||
row_key: format!("s:{}", f.id),
|
||||
label: if lost {
|
||||
format!("{} (attachment lost)", f.name)
|
||||
} else if active {
|
||||
format!("{} (editing)", f.name)
|
||||
} else {
|
||||
f.name.clone()
|
||||
},
|
||||
icon: "sketch",
|
||||
visible: sketch_visible(app, f.id),
|
||||
selected: active || sel.iter().any(|x| matches!(x, Sel::Feature { id } if *id == f.id)),
|
||||
color: if lost { Some(t.error) } else { active.then_some(t.sketch_accent) },
|
||||
locked: false,
|
||||
sel: Some(Sel::Feature { id: f.id }),
|
||||
target: Some(Target::Sketch { id: f.id }),
|
||||
bodies: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
_ => model_feature_indices(app, comp, false)
|
||||
.into_iter()
|
||||
.filter_map(|i| doc.features.get(i))
|
||||
.map(|f| Entry {
|
||||
key: f.id.to_string(),
|
||||
row_key: format!("p:{}", f.id),
|
||||
label: f.name.clone(),
|
||||
icon: crate::timeline::icon_of(&f.kind),
|
||||
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,
|
||||
sel: Some(Sel::Plane { name: f.name.clone() }),
|
||||
target: Some(Target::Plane { id: f.id }),
|
||||
bodies: Vec::new(),
|
||||
})
|
||||
.map(|b| Entry::body(app, b, b.name.clone()))
|
||||
.collect(),
|
||||
"sketches" => model_feature_indices(app, comp, true).into_iter().filter_map(|i| doc.features.get(i)).map(|f| Entry::sketch(app, f)).collect(),
|
||||
_ => model_feature_indices(app, comp, false).into_iter().filter_map(|i| doc.features.get(i)).map(|f| Entry::plane(app, f)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn construction_sel(f: &solidcraft_engine::doc::Feature) -> Sel {
|
||||
let name = f.name.clone();
|
||||
match f.kind {
|
||||
FeatureKind::ConstructionPoint { .. } => Sel::ConstructionPoint { name },
|
||||
FeatureKind::ConstructionAxis { .. } => Sel::Axis { name },
|
||||
_ => Sel::Plane { name },
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder's groups (with their items) and the items in no group, in browser order.
|
||||
fn arrange(app: &SolveApp, comp: u64, folder: &str, all: Vec<Entry>) -> (Vec<(solidcraft_engine::doc::BrowserGroup, Vec<Entry>)>, Vec<Entry>) {
|
||||
let doc = &app.session.doc;
|
||||
let groups: Vec<solidcraft_engine::doc::BrowserGroup> =
|
||||
doc.browser_groups.iter().filter(|g| g.component == comp && g.folder == folder).cloned().collect();
|
||||
fn arrange<'a>(
|
||||
doc: &'a solidcraft_engine::doc::Document,
|
||||
comp: u64,
|
||||
folder: &str,
|
||||
all: Vec<Entry>,
|
||||
) -> (Vec<(&'a solidcraft_engine::doc::BrowserGroup, Vec<Entry>)>, Vec<Entry>) {
|
||||
let mut rest: Vec<Option<Entry>> = all.into_iter().map(Some).collect();
|
||||
let mut take = |key: &str| rest.iter_mut().find(|e| e.as_ref().is_some_and(|e| e.key == key)).and_then(Option::take);
|
||||
let grouped: Vec<_> = groups
|
||||
.into_iter()
|
||||
let grouped: Vec<_> = doc
|
||||
.browser_groups
|
||||
.iter()
|
||||
.filter(|g| g.component == comp && g.folder == folder)
|
||||
.map(|g| {
|
||||
let items = g.items.iter().filter_map(|k| take(k)).collect();
|
||||
(g, items)
|
||||
@@ -1841,7 +1977,11 @@ fn arrange(app: &SolveApp, comp: u64, folder: &str, all: Vec<Entry>) -> (Vec<(so
|
||||
.collect();
|
||||
let mut loose: Vec<Entry> = rest.into_iter().flatten().collect();
|
||||
if let Some(order) = doc.browser_order.get(&format!("{comp}/{folder}")) {
|
||||
let pos = |k: &str| order.iter().position(|x| x == k).unwrap_or(usize::MAX);
|
||||
let mut positions = std::collections::HashMap::with_capacity(order.len());
|
||||
for (i, key) in order.iter().enumerate() {
|
||||
positions.entry(key.as_str()).or_insert(i);
|
||||
}
|
||||
let pos = |k: &str| positions.get(k).copied().unwrap_or(usize::MAX);
|
||||
loose.sort_by_key(|e| pos(&e.key));
|
||||
}
|
||||
(grouped, loose)
|
||||
@@ -1886,64 +2026,42 @@ fn folder_static(folder: &str) -> &'static str {
|
||||
|
||||
/// A component's folder: its row, its groups and its items. Rows are drag sources; items,
|
||||
/// groups and the folder row are drop targets (reorder, into a group, out of groups).
|
||||
fn folder_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, folder: &str, depth: usize, acts: &mut Actions) {
|
||||
folder_rows_at(app, ui, comp, None, folder, depth, acts);
|
||||
}
|
||||
|
||||
fn occurrence_folder_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, path: &[u64], folder: &str, depth: usize, acts: &mut Actions) {
|
||||
folder_rows_at(app, ui, comp, Some(path), folder, depth, acts);
|
||||
}
|
||||
|
||||
fn folder_rows_at(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, path: Option<&[u64]>, folder: &str, depth: usize, acts: &mut Actions) {
|
||||
let t = Tokens::get();
|
||||
let mut all = entries(app, comp, folder);
|
||||
let prefix = path.map(occurrence_key).unwrap_or_else(|| format!("c{comp}"));
|
||||
if let Some(path) = path {
|
||||
let Some(placement) = model_placement(app, path) else { return };
|
||||
// Each row's placed body by its definition's name (a search per row was quadratic in a
|
||||
// component's bodies, every frame).
|
||||
let mut placed: std::collections::HashMap<&str, &_> = std::collections::HashMap::with_capacity(placement.bodies.len());
|
||||
for b in &placement.bodies {
|
||||
placed.entry(b.source.as_str()).or_insert(b);
|
||||
}
|
||||
for e in &mut all {
|
||||
if folder == "bodies" {
|
||||
let Some(body) = placed.get(e.key.as_str()) else { continue };
|
||||
e.sel = Some(Sel::Body { name: body.world.clone() });
|
||||
e.target = Some(Target::Body { name: body.world.clone() });
|
||||
e.bodies = vec![body.world.clone()];
|
||||
e.visible = !app.session.visibility.hidden_bodies.contains(&body.world);
|
||||
e.selected = app.session.selection.contains(&Sel::Body { name: body.world.clone() });
|
||||
e.row_key = format!("b:{}", body.world);
|
||||
} else {
|
||||
e.row_key = format!("{prefix}/{}", e.row_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
if all.is_empty() {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn folder_rows_at(
|
||||
app: &mut SolveApp,
|
||||
ui: &mut egui::Ui,
|
||||
comp: u64,
|
||||
path: Option<&[u64]>,
|
||||
folder: &str,
|
||||
depth: usize,
|
||||
acts: &mut Actions,
|
||||
data: &cache::Folder,
|
||||
) {
|
||||
if data.key.is_empty() {
|
||||
return;
|
||||
}
|
||||
let fstatic = folder_static(folder);
|
||||
let key = format!("{prefix}/{folder}");
|
||||
if let Some(r) = app.tree.reveal.clone()
|
||||
let key = &data.key;
|
||||
let prefix = key.rsplit_once('/').map_or(key.as_str(), |(prefix, _)| prefix);
|
||||
if let Some(r) = app.tree.reveal.as_deref()
|
||||
&& folder == "bodies"
|
||||
&& let Some(e) = all.iter().find(|e| e.row_key == format!("b:{r}"))
|
||||
&& let Some(e) = data.entries().find(|e| e.row_key.strip_prefix("b:") == Some(r))
|
||||
{
|
||||
app.tree.collapsed.remove(&key);
|
||||
app.tree.collapsed.remove(key);
|
||||
app.tree.collapsed.remove(&format!("c{comp}"));
|
||||
for g in app.session.doc.browser_groups.iter().filter(|g| g.items.contains(&e.key)) {
|
||||
app.tree.collapsed.remove(&format!("g{}", g.id));
|
||||
}
|
||||
}
|
||||
let open = is_open(app, &key, true);
|
||||
let open = is_open(app, key, true);
|
||||
// The folder's eye is its own: hiding it hides its items without changing theirs, and its
|
||||
// items never change it. It and everything in it fade while it or a placement above is
|
||||
// hidden, whatever their own eyes say.
|
||||
let shown = !app.session.folder_hidden(&key);
|
||||
let shown = !app.session.folder_hidden(key);
|
||||
let folder_faded = !shown || path.is_some_and(|p| !app.session.occurrence_visible(p));
|
||||
let r = draw_row(
|
||||
ui,
|
||||
ui.id().with(&key),
|
||||
ui.id().with(key),
|
||||
&Row {
|
||||
depth,
|
||||
fold: Some(open),
|
||||
@@ -1951,21 +2069,21 @@ fn folder_rows_at(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, path: Option
|
||||
icon: "folder",
|
||||
label: folder_label(folder),
|
||||
dim: folder_faded,
|
||||
mark: acts.lit.branch(folder, &all),
|
||||
mark: acts.lit.branch(app, data.entries()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if r.fold || r.clicked {
|
||||
toggle(app, &key, true);
|
||||
toggle(app, key, true);
|
||||
}
|
||||
if r.eye {
|
||||
toggle_folder(app, &key);
|
||||
toggle_folder(app, key);
|
||||
}
|
||||
if r.hovered {
|
||||
acts.hover_bodies = all.iter().flat_map(|e| e.bodies.clone()).collect();
|
||||
acts.hover(data.entries().flat_map(|e| &e.bodies));
|
||||
}
|
||||
if let Some(p) = r.secondary {
|
||||
acts.menu = Some((p, Target::Folder { component: comp, folder: fstatic.to_string(), scope: prefix.clone() }));
|
||||
acts.menu = Some((p, Target::Folder { component: comp, folder: fstatic.to_string(), scope: prefix.to_owned() }));
|
||||
}
|
||||
// Dropping on the folder row takes items out of their groups, to the end. Bodies or sketches
|
||||
// from another component's folder move into this component, as a drop on its row does
|
||||
@@ -1982,56 +2100,53 @@ fn folder_rows_at(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, path: Option
|
||||
if !open {
|
||||
return;
|
||||
}
|
||||
let (groups, loose) = arrange(app, comp, folder, all);
|
||||
let loose_keys: Vec<String> = loose.iter().map(|e| e.key.clone()).collect();
|
||||
for (g, items) in &groups {
|
||||
let gkey = format!("{prefix}/g{}", g.id);
|
||||
let gopen = is_open(app, &gkey, true);
|
||||
let gshown = !app.session.folder_hidden(&gkey);
|
||||
let loose_keys = &data.loose_keys;
|
||||
for group in &data.groups {
|
||||
let (id, items, gkey) = (group.id, &group.items, &group.key);
|
||||
let gopen = is_open(app, gkey, true);
|
||||
let gshown = !app.session.folder_hidden(gkey);
|
||||
let group_faded = folder_faded || !gshown;
|
||||
let label = format!("{} ({})", g.name, items.len());
|
||||
let r = draw_row(
|
||||
ui,
|
||||
ui.id().with(&gkey),
|
||||
ui.id().with(gkey),
|
||||
&Row {
|
||||
depth: depth + 1,
|
||||
fold: Some(gopen),
|
||||
eye: Some(gshown),
|
||||
icon: "folder",
|
||||
label: &label,
|
||||
label: &group.label,
|
||||
name: true,
|
||||
dim: group_faded,
|
||||
mark: acts.lit.branch(folder, items),
|
||||
mark: acts.lit.branch(app, items),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if r.fold || r.clicked {
|
||||
toggle(app, &gkey, true);
|
||||
toggle(app, gkey, true);
|
||||
}
|
||||
if r.eye {
|
||||
toggle_folder(app, &gkey);
|
||||
toggle_folder(app, gkey);
|
||||
}
|
||||
if r.hovered {
|
||||
acts.hover_bodies = items.iter().flat_map(|e| e.bodies.clone()).collect();
|
||||
acts.hover(items.iter().flat_map(|e| &e.bodies));
|
||||
}
|
||||
if let Some(p) = r.secondary {
|
||||
acts.menu = Some((p, Target::Group { id: g.id, scope: prefix.clone() }));
|
||||
acts.menu = Some((p, Target::Group { id, scope: prefix.to_owned() }));
|
||||
}
|
||||
if let Some(resp) = &r.resp
|
||||
&& let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp == comp && d.folder == fstatic, DropMark::Box)
|
||||
{
|
||||
acts.drop = Some(DropAction::Move { comp, folder: fstatic, keys: d.keys.clone(), group: Some(g.id), before: None });
|
||||
acts.drop = Some(DropAction::Move { comp, folder: fstatic, keys: d.keys.clone(), group: Some(id), before: None });
|
||||
}
|
||||
if gopen {
|
||||
for e in items {
|
||||
entry_row(app, ui, comp, path, fstatic, e, depth + 2, Some(g.id), &loose_keys, group_faded, acts);
|
||||
entry_row(app, ui, comp, path, fstatic, e, depth + 2, Some(id), loose_keys, group_faded, acts);
|
||||
}
|
||||
}
|
||||
}
|
||||
for e in &loose {
|
||||
entry_row(app, ui, comp, path, fstatic, e, depth + 1, None, &loose_keys, folder_faded, acts);
|
||||
for e in &data.loose {
|
||||
entry_row(app, ui, comp, path, fstatic, e, depth + 1, None, loose_keys, folder_faded, acts);
|
||||
}
|
||||
let _ = t;
|
||||
}
|
||||
|
||||
/// The body's name edited in its row (started by a double-click): Enter or leaving the box keeps
|
||||
@@ -2086,33 +2201,43 @@ fn entry_row(
|
||||
parent_hidden: bool,
|
||||
acts: &mut Actions,
|
||||
) {
|
||||
app.tree.order.push(e.row_key.clone());
|
||||
let pattern_sketch =
|
||||
(folder == "sketches").then(|| e.key.parse::<u64>().ok()).flatten().filter(|id| crate::sketch_pattern_tree::has_patterns(app, *id));
|
||||
let pattern_key = format!("{}/patterns", e.row_key);
|
||||
let patterns_open = is_open(app, &pattern_key, true);
|
||||
let badges: &[&str] = if e.locked { &["lock"] } else { &[] };
|
||||
let (selected, mark) = lit_row(acts.lit.entry(folder, e));
|
||||
app.tree.remember(&e.row_key);
|
||||
let pattern_key = e.patterns.as_deref();
|
||||
let patterns_open = pattern_key.is_some_and(|key| is_open(app, key, true));
|
||||
let rect = row_rect(ui);
|
||||
if !ui.is_rect_visible(rect) && !patterns_open && !(folder == "bodies" && app.tree.reveal.as_deref() == Some(e.key.as_str())) {
|
||||
ui.allocate_space(rect.size());
|
||||
return;
|
||||
}
|
||||
let (selected, mark) = lit_row(acts.lit.entry(app, e));
|
||||
let visible = e.is_visible(app);
|
||||
let locked = match &e.sel {
|
||||
Some(Sel::Body { name }) => app.session.visibility.locked_bodies.contains(name),
|
||||
_ => e.locked,
|
||||
};
|
||||
let badges: &[&str] = if locked { &["lock"] } else { &[] };
|
||||
let r = draw_row(
|
||||
ui,
|
||||
ui.id().with(("item", &e.row_key)),
|
||||
&Row {
|
||||
depth,
|
||||
fold: pattern_sketch.map(|_| patterns_open),
|
||||
eye: Some(e.visible),
|
||||
fold: pattern_key.map(|_| patterns_open),
|
||||
eye: Some(visible),
|
||||
icon: e.icon,
|
||||
label: &e.label,
|
||||
name: true,
|
||||
selected,
|
||||
dim: !e.visible || parent_hidden,
|
||||
dim: !visible || parent_hidden,
|
||||
color: e.color,
|
||||
badges,
|
||||
mark,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if r.fold {
|
||||
toggle(app, &pattern_key, true);
|
||||
if r.fold
|
||||
&& let Some(key) = pattern_key
|
||||
{
|
||||
toggle(app, key, true);
|
||||
}
|
||||
if folder == "bodies"
|
||||
&& app.tree.reveal.as_ref().is_some_and(|b| e.row_key.strip_prefix("b:") == Some(b.as_str()))
|
||||
@@ -2122,7 +2247,7 @@ fn entry_row(
|
||||
app.tree.reveal = None;
|
||||
}
|
||||
if r.eye {
|
||||
set_visible(app, folder, &[e], !e.visible);
|
||||
set_visible(app, folder, &[e], !visible);
|
||||
}
|
||||
if r.double && folder == "sketches" {
|
||||
if let Ok(id) = e.key.parse() {
|
||||
@@ -2157,25 +2282,23 @@ fn entry_row(
|
||||
inline_rename(app, ui, rect, e, depth);
|
||||
}
|
||||
if r.hovered && !e.bodies.is_empty() {
|
||||
acts.hover_bodies = e.bodies.clone();
|
||||
acts.hover(e.bodies.iter());
|
||||
}
|
||||
if let Some(p) = r.secondary
|
||||
&& let Some(t) = e.target.clone()
|
||||
{
|
||||
if !e.selected
|
||||
&& let Some(s) = &e.sel
|
||||
{
|
||||
if !selected && let Some(s) = &e.sel {
|
||||
let _ = app.run("select.set", json!({ "items": [s] }));
|
||||
}
|
||||
acts.menu = Some((p, t));
|
||||
}
|
||||
if let Some(sketch) = pattern_sketch.filter(|_| patterns_open) {
|
||||
crate::sketch_pattern_tree::rows(app, ui, sketch, depth + 1, "browser", path.map(|path| (comp, path)), !e.visible || parent_hidden);
|
||||
if patterns_open && let Some(Sel::Feature { id }) = &e.sel {
|
||||
crate::sketch_pattern_tree::rows(app, ui, *id, depth + 1, "browser", path.map(|path| (comp, path)), !visible || parent_hidden);
|
||||
}
|
||||
let Some(resp) = &r.resp else { return };
|
||||
// Drag: the selected items of this folder when this one is selected, else just this one.
|
||||
if resp.drag_started() && !r.on_control {
|
||||
let keys: Vec<String> = if e.selected { selected_keys(app, comp, folder) } else { vec![e.key.clone()] };
|
||||
let keys: Vec<String> = if selected { selected_keys(app, comp, folder) } else { vec![e.key.clone()] };
|
||||
resp.dnd_set_drag_payload(Drag { comp, folder, keys });
|
||||
}
|
||||
if let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp == comp && d.folder == folder && !d.keys.contains(&e.key), DropMark::Above) {
|
||||
|
||||
@@ -75,7 +75,7 @@ pub(super) fn rows(app: &mut SolveApp, ui: &mut egui::Ui, component: u64, path:
|
||||
let Some(feature) = app.session.doc.features.get(index) else { continue };
|
||||
let (id, label, icon) = (feature.id, feature.name.clone(), crate::timeline::icon_of(&feature.kind));
|
||||
let selected = app.session.selection.contains(&Sel::Feature { id });
|
||||
app.tree.order.push(format!("f:{id}"));
|
||||
app.tree.remember(&format!("f:{id}"));
|
||||
let r = draw_row(ui, ui.id().with(("browser_feature", id)), &Row { depth: depth + 1, icon, label: &label, selected, ..Default::default() });
|
||||
if let Some(rect) = r.rect {
|
||||
crate::scenario::publish_handle(&format!("row:browser-feature:{scope}:{id}"), rect.center());
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Stable browser data, indexed once for an evaluated document and borrowed on redraw.
|
||||
use super::*;
|
||||
use solidcraft_engine::doc::{Document, ModelState};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Component {
|
||||
definition: Option<usize>,
|
||||
first: Option<Vec<u64>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Node {
|
||||
pub component: u64,
|
||||
pub occurrence: u64,
|
||||
pub name: String,
|
||||
pub label: String,
|
||||
pub key: String,
|
||||
pub tooltip: String,
|
||||
pub grounded: bool,
|
||||
pub first: bool,
|
||||
pub children: Vec<Vec<u64>>,
|
||||
pub hover_bodies: Option<Vec<String>>,
|
||||
pub folders: [Option<Folder>; 3],
|
||||
}
|
||||
|
||||
pub(super) struct Group {
|
||||
pub id: u64,
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub items: Vec<Entry>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Folder {
|
||||
pub key: String,
|
||||
pub groups: Vec<Group>,
|
||||
pub loose: Vec<Entry>,
|
||||
pub loose_keys: Vec<String>,
|
||||
}
|
||||
|
||||
impl Folder {
|
||||
pub fn entries(&self) -> impl Iterator<Item = &Entry> {
|
||||
self.groups.iter().flat_map(|g| &g.items).chain(&self.loose)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Cache {
|
||||
doc: Weak<Document>,
|
||||
model: Weak<ModelState>,
|
||||
sketch: Option<u64>,
|
||||
colors: [Color32; 2],
|
||||
components: BTreeMap<u64, Component>,
|
||||
ids: BTreeMap<u64, usize>,
|
||||
scheduled: usize,
|
||||
pub root: Node,
|
||||
pub root_bodies: Vec<String>,
|
||||
pub occurrences: HashMap<Vec<u64>, Node>,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
pub fn prepare(&mut self, app: &SolveApp) {
|
||||
let state = app.session.model.state();
|
||||
let t = Tokens::get();
|
||||
let colors = [t.error, t.sketch_accent];
|
||||
if std::ptr::eq(self.doc.as_ptr(), Arc::as_ptr(&app.session.doc))
|
||||
&& std::ptr::eq(self.model.as_ptr(), Arc::as_ptr(&state))
|
||||
&& self.sketch == app.session.active_sketch
|
||||
&& self.colors == colors
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.components.clear();
|
||||
self.ids.clear();
|
||||
self.occurrences.clear();
|
||||
for (i, component) in app.session.doc.components.iter().enumerate() {
|
||||
self.components.entry(component.id).or_default().definition = Some(i);
|
||||
}
|
||||
for (i, occurrence) in app.session.doc.occurrences.iter().enumerate() {
|
||||
self.ids.insert(occurrence.id, i);
|
||||
}
|
||||
self.root = Node {
|
||||
name: component_name(app, 0),
|
||||
key: "c0".into(),
|
||||
children: self.children(app, 0, &[], solidcraft_engine::doc::MAX_PLACEMENTS),
|
||||
..Default::default()
|
||||
};
|
||||
self.scheduled = self.root.children.len();
|
||||
self.root_bodies = crate::context_menu::component_bodies(app, 0);
|
||||
// Weak snapshot identities catch replacement and reevaluation without retaining a
|
||||
// closed design's geometry while the browser is collapsed.
|
||||
self.doc = Arc::downgrade(&app.session.doc);
|
||||
self.model = Arc::downgrade(&state);
|
||||
self.sketch = app.session.active_sketch;
|
||||
self.colors = colors;
|
||||
}
|
||||
|
||||
fn children(&self, app: &SolveApp, component: u64, path: &[u64], remaining: usize) -> Vec<Vec<u64>> {
|
||||
let Some(children) = app.tree.model.as_ref().and_then(|model| model.children.get(&component)) else { return Vec::new() };
|
||||
if path.len() >= 64 {
|
||||
return Vec::new();
|
||||
}
|
||||
children
|
||||
.iter()
|
||||
.filter(|id| !path.contains(id))
|
||||
.take(remaining)
|
||||
.map(|id| {
|
||||
let mut child = Vec::with_capacity(path.len() + 1);
|
||||
child.extend_from_slice(path);
|
||||
child.push(*id);
|
||||
child
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn take_occurrence(&mut self, app: &mut SolveApp, path: &[u64]) -> Option<(Vec<u64>, Node)> {
|
||||
if let Some(cached) = self.occurrences.remove_entry(path) {
|
||||
return Some(cached);
|
||||
}
|
||||
if path.is_empty() || path.len() > 64 {
|
||||
return None;
|
||||
}
|
||||
let doc = self.doc.upgrade()?;
|
||||
let component = model_placement(app, path)?.component;
|
||||
let occurrence = doc.occurrences.get(*self.ids.get(path.last()?)?)?;
|
||||
let index = self.components.get_mut(&component)?;
|
||||
let definition = doc.components.get(index.definition?)?;
|
||||
let first = index.first.get_or_insert_with(|| doc.first_path(component)) == path;
|
||||
let mut tooltip = format!("Component: {}\nPlacement: ", definition.name);
|
||||
for (i, id) in path.iter().enumerate() {
|
||||
if i != 0 {
|
||||
tooltip.push_str(" / ");
|
||||
}
|
||||
tooltip.push_str(&doc.occurrences.get(*self.ids.get(id)?)?.name);
|
||||
}
|
||||
tooltip.push_str("\nGeometry edits apply to every instance of this component.");
|
||||
let children = self.children(app, component, path, solidcraft_engine::doc::MAX_PLACEMENTS.saturating_sub(self.scheduled));
|
||||
self.scheduled += children.len();
|
||||
Some((
|
||||
path.to_vec(),
|
||||
Node {
|
||||
component,
|
||||
occurrence: occurrence.id,
|
||||
name: occurrence.name.clone(),
|
||||
label: crate::dialogs_part_size::browser_label(app, component, &occurrence.name),
|
||||
key: occurrence_key(path),
|
||||
tooltip,
|
||||
grounded: occurrence.grounded,
|
||||
first,
|
||||
children,
|
||||
hover_bodies: None,
|
||||
folders: Default::default(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub fn folder(&self, app: &SolveApp, component: u64, path: Option<&[u64]>, folder: &str) -> Folder {
|
||||
let Some(model) = &app.tree.model else { return Folder::default() };
|
||||
let Some(state) = self.model.upgrade() else { return Folder::default() };
|
||||
let Some(doc) = self.doc.upgrade() else { return Folder::default() };
|
||||
let prefix = path.map(occurrence_key).unwrap_or_else(|| format!("c{component}"));
|
||||
let indices = match folder {
|
||||
"bodies" => &model.bodies,
|
||||
"sketches" => &model.sketches,
|
||||
_ => &model.planes,
|
||||
};
|
||||
let Some(indices) = indices.get(&component) else { return Folder::default() };
|
||||
let mut all = Vec::with_capacity(indices.len());
|
||||
for i in indices {
|
||||
let entry = match folder {
|
||||
"bodies" => state.bodies.get(*i).filter(|b| !app.session.body_removed_later(&b.name)).map(|b| {
|
||||
let world = path.map_or_else(|| b.name.clone(), |p| doc.world_body_name(&state, &b.name, component, p));
|
||||
Entry::body(app, b, world)
|
||||
}),
|
||||
"sketches" => doc.features.get(*i).map(|f| Entry::sketch(app, f)),
|
||||
_ => doc.features.get(*i).map(|f| Entry::plane(app, f)),
|
||||
};
|
||||
if let Some(mut entry) = entry {
|
||||
if path.is_some() && folder != "bodies" {
|
||||
entry.row_key = format!("{prefix}/{}", entry.row_key);
|
||||
if let Some(key) = &mut entry.patterns {
|
||||
*key = format!("{prefix}/{key}");
|
||||
}
|
||||
}
|
||||
all.push(entry);
|
||||
}
|
||||
}
|
||||
if all.is_empty() {
|
||||
return Folder::default();
|
||||
}
|
||||
let (groups, loose) = arrange(&doc, component, folder, all);
|
||||
let loose_keys = loose.iter().map(|e| e.key.clone()).collect();
|
||||
let groups = groups
|
||||
.into_iter()
|
||||
.map(|(group, items)| {
|
||||
let key = format!("{prefix}/g{}", group.id);
|
||||
let label = format!("{} ({})", group.name, items.len());
|
||||
Group { id: group.id, key, label, items }
|
||||
})
|
||||
.collect();
|
||||
Folder { key: format!("{prefix}/{folder}"), groups, loose, loose_keys }
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ impl Model {
|
||||
}
|
||||
let index = match feature.kind {
|
||||
FeatureKind::Sketch { .. } => &mut sketches,
|
||||
FeatureKind::ConstructionPlane { .. } => &mut planes,
|
||||
FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. } => &mut planes,
|
||||
_ => continue,
|
||||
};
|
||||
index.entry(feature.component).or_default().push(i);
|
||||
|
||||
@@ -141,3 +141,57 @@ fn hovering_a_collapsed_nested_placement_walks_its_subtree_once() {
|
||||
h.call("ui.move", json!({"x":at.x,"y":at.y + 1.0}));
|
||||
assert_eq!(h.app.viewport.hover_bodies.len(), 7);
|
||||
}
|
||||
|
||||
/// An open curve turned round is a surface body and the Bodies folder shows it as one; a closed
|
||||
/// profile turned all the way round, and a box, are solids.
|
||||
#[test]
|
||||
fn bodies_folder_tells_surfaces_from_solids() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let mut run = |id: &str, p: serde_json::Value| {
|
||||
h.app.run(id, p).unwrap();
|
||||
};
|
||||
run("sketch.create", json!({"plane":"XY", "name":"S"}));
|
||||
run("sketch.line", json!({"points":[[3, 0], [3, 4]], "ids":["side"]}));
|
||||
run("sketch.rectangle.two_point", json!({"p0":[5, 0], "p1":[7, 3]}));
|
||||
run("sketch.finish", json!({}));
|
||||
run("surface.revolve", json!({"sketch":"S", "curves":["side"], "axis":"y", "body_name":"Tube"}));
|
||||
run("surface.revolve", json!({"sketch":"S", "profiles":[0], "axis":"y", "body_name":"Ring"}));
|
||||
run("solid.box", json!({"length":2, "width":2, "height":2, "corner":[20, 0, 0]}));
|
||||
super::prepare_model(&mut h.app);
|
||||
let icons: Vec<(String, &str)> = super::entries(&h.app, 0, "bodies").into_iter().map(|e| (e.key, e.icon)).collect();
|
||||
assert_eq!(icons.len(), 3, "{icons:?}");
|
||||
assert_eq!(icons[..2], [("Tube".to_string(), "surface"), ("Ring".to_string(), "body")]);
|
||||
assert_eq!(icons[2].1, "body", "the box is a solid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_rows_include_points_and_axes() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("construct.point.vertex", json!({"point":{"point":[20,15,10]},"name":"Point1"})).unwrap();
|
||||
h.app.run("construct.axis.two_points", json!({"a":{"point":[0,0,0]},"b":{"point":[0,0,30]},"name":"Axis1"})).unwrap();
|
||||
for cached in [false, true] {
|
||||
if cached {
|
||||
super::prepare_model(&mut h.app);
|
||||
}
|
||||
let rows = super::entries(&h.app, 0, "construction");
|
||||
assert_eq!(rows.iter().map(|r| r.label.as_str()).collect::<Vec<_>>(), ["Point1", "Axis1"]);
|
||||
for row in &rows {
|
||||
assert!(row.visible && row.sel.is_some() && row.target.is_some());
|
||||
}
|
||||
}
|
||||
let rows = super::entries(&h.app, 0, "construction");
|
||||
for row in &rows {
|
||||
super::pick_from_browser(&mut h.app, row.sel.clone().unwrap());
|
||||
assert!(super::entries(&h.app, 0, "construction").iter().find(|r| r.label == row.label).unwrap().selected);
|
||||
let items = crate::context_menu::items(&h.app, row.target.as_ref().unwrap());
|
||||
for id in ["ui.rename", "ui.delete", "ui.editFeature", "ui.originPart"] {
|
||||
assert!(items.iter().any(|i| i.id == id), "{} lacks {id}", row.label);
|
||||
}
|
||||
assert!(!items.iter().any(|i| i.id == "sketch.create"));
|
||||
}
|
||||
h.app.session.visibility.hidden_origin.push("Point1".into());
|
||||
assert!(!super::entries(&h.app, 0, "construction")[0].visible);
|
||||
h.app.run("selection.delete", json!({"items": rows.iter().map(|r| r.sel.clone().unwrap()).collect::<Vec<_>>()})).unwrap();
|
||||
super::prepare_model(&mut h.app);
|
||||
assert!(super::entries(&h.app, 0, "construction").is_empty());
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ pub enum Target {
|
||||
Sketch {
|
||||
id: u64,
|
||||
},
|
||||
/// A construction plane (its feature id).
|
||||
/// Construction geometry (its feature id).
|
||||
Plane {
|
||||
id: u64,
|
||||
},
|
||||
@@ -527,6 +527,9 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
|
||||
v.push(cmd(app, "solid.shell", "Shell"));
|
||||
v.push(act("ui.editFeature", "Edit Feature", "edit").with(json!({ "feature": feature })).on(feature.is_some()));
|
||||
v.push(act("ui.editSketch", "Edit Profile Sketch", "sketch").with(json!({ "sketch": sketch })).on(sketch.is_some()));
|
||||
let planar =
|
||||
sel.iter().any(|s| matches!(s, Sel::Face { body, index, .. } if crate::dialogs::planar_face(&app.session, body, *index).is_some()));
|
||||
v.push(act("ui.lookAt", "Look At", "lookat").on(planar));
|
||||
v.push(Item::sep());
|
||||
v.push(act("ui.appearance", "Appearance", "appearance").key("A"));
|
||||
v.push(act("ui.properties", "Properties", "measure").with(json!({ "bodies": [face] })));
|
||||
@@ -561,12 +564,16 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
|
||||
v.push(cmd(app, "solid.revolve", "Revolve"));
|
||||
} else {
|
||||
// Empty space, or a plane: both lead with Create Sketch (#3032). On a plane it sketches
|
||||
// there; on empty space it is the toolbar's, which asks for the plane or face.
|
||||
// there, with Look At beside it; on empty space it is the toolbar's, which asks for the
|
||||
// plane or face.
|
||||
let sketch = cmd(app, "sketch.create", "Create Sketch");
|
||||
v.push(match &app.menu.plane {
|
||||
Some(plane) => sketch.with(json!({ "plane": plane })),
|
||||
None => sketch,
|
||||
});
|
||||
match &app.menu.plane {
|
||||
Some(plane) => {
|
||||
v.push(sketch.with(json!({ "plane": plane })));
|
||||
v.push(act("ui.lookAt", "Look At", "lookat").with(json!({ "plane": plane })));
|
||||
}
|
||||
None => v.push(sketch),
|
||||
}
|
||||
v.push(Item::sep());
|
||||
v.push(act("ui.nav", "Pan", "pan").with(json!({ "mode": "pan" })));
|
||||
v.push(act("ui.nav", "Zoom", "zoom").with(json!({ "mode": "zoom" })));
|
||||
@@ -668,22 +675,26 @@ fn sketch_items(app: &SolveApp, id: u64) -> Vec<Item> {
|
||||
]
|
||||
}
|
||||
|
||||
/// A construction plane's row in the browser: sketch on it, edit, delete, rename, show or hide.
|
||||
/// Construction geometry's row: edit, delete, rename, show or hide; sketch on planes.
|
||||
fn plane_items(app: &SolveApp, id: u64) -> Vec<Item> {
|
||||
let Some(f) = app.session.doc.feature(id) else { return Vec::new() };
|
||||
let visible = !app.session.visibility.hidden_origin.contains(&f.name);
|
||||
vec![
|
||||
cmd(app, "sketch.create", "Create Sketch").with(json!({ "plane": f.name })),
|
||||
Item::sep(),
|
||||
act("ui.editFeature", "Edit Feature", "plane").with(json!({ "feature": id })),
|
||||
Item::sep(),
|
||||
let mut items = Vec::new();
|
||||
if matches!(f.kind, solidcraft_engine::doc::FeatureKind::ConstructionPlane { .. }) {
|
||||
items.push(cmd(app, "sketch.create", "Create Sketch").with(json!({ "plane": f.name })));
|
||||
items.push(Item::sep());
|
||||
}
|
||||
items.push(act("ui.editFeature", "Edit Feature", crate::timeline::icon_of(&f.kind)).with(json!({ "feature": id })));
|
||||
items.push(Item::sep());
|
||||
items.extend([
|
||||
act("ui.delete", "Delete", "delete").key("Del").with(json!({ "items": [{"type": "feature", "id": id}] })),
|
||||
act("ui.rename", "Rename", "rename").with(json!({ "feature": id })),
|
||||
Item::sep(),
|
||||
act("ui.originPart", "Show/Hide", "eye").key("V").with(json!({ "keys": [f.name], "show": !visible })),
|
||||
Item::sep(),
|
||||
act("ui.findTimeline", "Find in Timeline", "rollback").with(json!({ "feature": id })),
|
||||
]
|
||||
]);
|
||||
items
|
||||
}
|
||||
|
||||
/// An origin plane's row in the browser: sketch on it, show or hide.
|
||||
@@ -806,6 +817,9 @@ fn component_items(app: &SolveApp, id: u64) -> Vec<Item> {
|
||||
let grounded = occ.is_some_and(|o| o.grounded);
|
||||
let oid = occ.map(|o| o.id).unwrap_or(0);
|
||||
v.push(act("ui.activate", "Activate", "component").with(json!({ "component": id })).on(!active));
|
||||
if doc.links.contains_key(&id) {
|
||||
v.push(act("ui.openLinkedDesign", "Open Linked Design", "open").with(json!({ "component": id })));
|
||||
}
|
||||
v.push(cmd(app, "occurrence.ground", if grounded { "Unground" } else { "Ground" }).with(json!({ "occurrence": oid, "grounded": !grounded })));
|
||||
v.push(
|
||||
act("ui.moveOccurrence", "Move/Copy", "move")
|
||||
@@ -838,6 +852,9 @@ fn component_items(app: &SolveApp, id: u64) -> Vec<Item> {
|
||||
.with(if id == 0 { json!({ "bodies": names }) } else { json!({"component": id}) })
|
||||
.on(id != 0 || !bodies.is_empty()),
|
||||
);
|
||||
if id != 0 {
|
||||
v.push(cmd(app, "component.cycle_color", "Cycle Component Color").with(json!({"component": id})));
|
||||
}
|
||||
v.push(act("ui.properties", "Properties", "measure").with(json!({ "component": id })));
|
||||
if doc.links.contains_key(&id) {
|
||||
v.push(act("ui.relinkSource", "Relink Source (All Instances)…", "update").with(json!({"component": id})));
|
||||
@@ -1257,6 +1274,29 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
let _ = app.run("component.activate", p);
|
||||
}
|
||||
}
|
||||
"ui.openLinkedDesign" => {
|
||||
let Some(component) = id_of(&p, "component") else { return };
|
||||
let links = match app.session.execute("component.links", &json!({})) {
|
||||
Ok(links) => links,
|
||||
Err(e) => {
|
||||
app.set_status(e.to_string(), true);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(link) = links["links"].as_array().and_then(|links| links.iter().find(|link| link["component"] == component)) else {
|
||||
app.set_status("This component is no longer linked to a design", true);
|
||||
return;
|
||||
};
|
||||
let Some(file) = link["file"].as_str() else {
|
||||
app.set_status("The linked design has no file path", true);
|
||||
return;
|
||||
};
|
||||
if link["found"] != true {
|
||||
app.set_status(format!("Linked design not found: {file}"), true);
|
||||
return;
|
||||
}
|
||||
app.open_path_async(file);
|
||||
}
|
||||
"ui.moveOccurrence" => {
|
||||
if let Some(o) = id_of(&p, "occurrence") {
|
||||
let mut movement = crate::browser::OccMove::new(o);
|
||||
@@ -1385,6 +1425,12 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
if let Some(ss) = id_of(&p, "sketch").and_then(|id| st.sketch(id)) {
|
||||
let to = crate::dialogs::onto_sketch(app.cam.looking_from(ss.plane.normal()), ss);
|
||||
app.animate_to(to);
|
||||
} else if let Some(plane) = p.get("plane").and_then(Value::as_str).and_then(solidcraft_engine::geom::Plane::named) {
|
||||
let mut to = app.cam.looking_from(crate::dialogs::facing(app, plane.normal(), true));
|
||||
to.target = plane.origin;
|
||||
app.animate_to(to);
|
||||
} else if p.get("sketch").is_none() {
|
||||
crate::viewport::look_at_selection(app);
|
||||
}
|
||||
}
|
||||
"ui.nav" => {
|
||||
@@ -1447,7 +1493,7 @@ pub fn toggle_visibility(app: &mut SolveApp) {
|
||||
.selection
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Sel::Plane { name } | Sel::Axis { name } => Some(name.clone()),
|
||||
Sel::Plane { name } | Sel::Axis { name } | Sel::ConstructionPoint { name } => Some(name.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@@ -1485,7 +1531,7 @@ pub fn rename_selection(app: &mut SolveApp, at: Pos2) -> bool {
|
||||
Ok(p) => RenameWhat::Component(p.component),
|
||||
Err(_) => return false,
|
||||
},
|
||||
([Sel::Plane { name }], [], []) => match app.session.doc.find_feature(name) {
|
||||
([Sel::Plane { name } | Sel::Axis { name } | Sel::ConstructionPoint { name }], [], []) => match app.session.doc.find_feature(name) {
|
||||
Some(f) => RenameWhat::Feature(f.id),
|
||||
None => return false,
|
||||
},
|
||||
|
||||
@@ -292,7 +292,7 @@ pub(crate) fn view_scene(app: &SolveApp, guides: solidcraft_engine::view::Guides
|
||||
&app.cam,
|
||||
crate::viewport::section_plane(app),
|
||||
solidcraft_engine::view::world_curve_parts,
|
||||
&crate::viewport::scene_palette(app.ui.dark, app.preferences.color_vision),
|
||||
&crate::viewport::scene_palette(app.viewport_dark(), app.preferences.color_vision),
|
||||
guides,
|
||||
app.preferences.cosmetic_threads,
|
||||
);
|
||||
@@ -1168,7 +1168,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// `ui.render` draws in the window's theme (#974): its background is the viewport's, light
|
||||
/// or dark.
|
||||
/// or dark, and with the 3D view in the other theme's colours, the view's (#4146).
|
||||
#[test]
|
||||
fn model_render_is_in_the_windows_theme() {
|
||||
if !crate::gpu::test_picture_renderer() {
|
||||
@@ -1179,9 +1179,15 @@ mod tests {
|
||||
let mut app = SolveApp::new(s, Services::default());
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-render-theme-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for dark in [false, true, false] {
|
||||
app.ui.dark = dark;
|
||||
let png = render(&mut app, &dir.join(format!("dark-{dark}.png")));
|
||||
use crate::theme::ViewportTheme;
|
||||
for (window, view) in
|
||||
[(false, ViewportTheme::Theme), (true, ViewportTheme::Theme), (true, ViewportTheme::Light), (false, ViewportTheme::Dark)]
|
||||
{
|
||||
app.ui.dark = window;
|
||||
app.preferences.viewport_theme = view;
|
||||
let dark = app.viewport_dark();
|
||||
let theme = format!("{window}-{}", view.id());
|
||||
let png = render(&mut app, &dir.join(format!("{theme}.png")));
|
||||
let img = image::load_from_memory(&png).unwrap().to_rgba8();
|
||||
// The top row is the background's top: its most common colour.
|
||||
let mut counts = std::collections::HashMap::new();
|
||||
@@ -1191,7 +1197,7 @@ mod tests {
|
||||
let top = counts.into_iter().max_by_key(|(_, n)| *n).map(|(c, _)| c).unwrap();
|
||||
let want = if dark { crate::theme::Tokens::dark() } else { crate::theme::Tokens::light() }.viewport_top;
|
||||
let near = top.iter().zip([want.r(), want.g(), want.b()]).all(|(a, b)| a.abs_diff(b) <= 2);
|
||||
assert!(near, "dark {dark}: the render's top row is {top:?}, the window's background {want:?}");
|
||||
assert!(near, "{theme}: the render's top row is {top:?}, the view's background {want:?}");
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
@@ -223,6 +223,14 @@ impl SolveApp {
|
||||
let (id, params) = self.deferred_queue.remove(0);
|
||||
let _ = self.run_heavy(&id, params);
|
||||
}
|
||||
if self.deferred.is_none()
|
||||
&& self.deferred_queue.is_empty()
|
||||
&& let Some((id, sketch)) = self.start_after_roll.take()
|
||||
&& self.dialog.is_none()
|
||||
&& self.tool.is_none()
|
||||
{
|
||||
self.start_interactive(&id, Some(sketch));
|
||||
}
|
||||
}
|
||||
|
||||
/// Start evaluating a command ahead. `None` when it should just run: not asked for
|
||||
|
||||
@@ -127,10 +127,11 @@ crate::dialog_descriptor::dialog_descriptors! {
|
||||
margin: String = "2 mm".into() => margin.to_string(),
|
||||
} => json!({"margin": margin});
|
||||
Pipe("solid.pipe", "PIPE", [("Path", CURVES, true)],
|
||||
FeatureKind::Pipe { diameter, wall, .. }) {
|
||||
FeatureKind::Pipe { diameter, wall, operation, .. }) {
|
||||
diameter: String = "5 mm".into() => diameter.to_string(),
|
||||
wall: String = String::new() => wall.as_deref().map(str::to_string).unwrap_or_default(),
|
||||
} => json!({"diameter": diameter});
|
||||
operation: usize = 0 => op_index(operation),
|
||||
} => json!({"diameter": diameter, "operation": OPS.get(*operation).copied().unwrap_or("new")});
|
||||
Emboss("solid.emboss", "EMBOSS", [("Profiles", PROFILES, true), ("Target Face (optional)", FACES, false)],
|
||||
FeatureKind::Emboss { depth, deboss, .. }) {
|
||||
depth: String = "1 mm".into() => depth.to_string(),
|
||||
@@ -609,6 +610,7 @@ fn fits(a: Accept, s: &Sel) -> bool {
|
||||
Sel::Body { .. } => a & BODIES != 0,
|
||||
Sel::Plane { .. } => a & PLANES != 0,
|
||||
Sel::Axis { .. } => a & AXES != 0,
|
||||
Sel::ConstructionPoint { .. } => a & selection::CONSTRUCTION_POINTS != 0,
|
||||
Sel::SketchCurve { .. } => a & (AXES | CURVES | selection::SKETCH_LINES) != 0,
|
||||
Sel::ExternalSketchCurve { .. } => a & selection::SKETCH_LINES != 0,
|
||||
Sel::Vertex { .. } => a & selection::VERTICES != 0,
|
||||
@@ -801,7 +803,7 @@ impl Dialog {
|
||||
),
|
||||
"solid.loft" => Dialog::new(Kind::Loft { operation: usize::from(has_bodies), rail_fit: 0 }, {
|
||||
let [rail, rail2] = crate::dialogs_sweep::rail_inputs();
|
||||
vec![SelInput::new("Profiles", PROFILES, true), rail, rail2]
|
||||
vec![SelInput::new("Profiles", PROFILES | selection::POINTS, true), rail, rail2]
|
||||
}),
|
||||
"solid.sweep" => Dialog::new(Kind::Sweep { operation: usize::from(has_bodies), opts: crate::dialogs_sweep::SweepOpts::default() }, {
|
||||
let [rail, rail2] = crate::dialogs_sweep::rail_inputs();
|
||||
@@ -998,6 +1000,18 @@ impl Dialog {
|
||||
Dialog::new(Kind::EditParam { name: name.into(), expr: s.doc.param(name).map(|p| p.expr.clone()).unwrap_or_default() }, vec![])
|
||||
}
|
||||
|
||||
/// An Extrude or Revolve started from inside a sketch takes that sketch's profile, as it would
|
||||
/// had the sketch stayed open.
|
||||
pub(crate) fn take_sketch_profile(&mut self, s: &Session, sketch: u64) {
|
||||
if matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. })
|
||||
&& let Some(inp) = self.inputs.first_mut()
|
||||
&& inp.items.is_empty()
|
||||
{
|
||||
inp.items = single_profile(s, sketch);
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
|
||||
/// Make the first single input still empty (after the filled ones) active.
|
||||
fn advance(&mut self) {
|
||||
if let Some(i) = self.inputs.iter().position(|i| i.items.is_empty()) {
|
||||
@@ -1440,12 +1454,15 @@ fn fillet_edges(s: &Session, sel: &Sel, chain: bool) -> Vec<Sel> {
|
||||
/// the last sketch's while no feature builds from it yet. A used profile (a cube's sketch on its
|
||||
/// bottom face) is only taken when it is picked.
|
||||
fn default_profiles(s: &Session) -> Vec<Sel> {
|
||||
let st = s.world_state();
|
||||
let sid = s.active_sketch.or_else(|| {
|
||||
s.doc.features.iter().rev().find(|f| matches!(f.kind, FeatureKind::Sketch { .. })).map(|f| f.id).filter(|&id| !profiles_used(s, id))
|
||||
});
|
||||
match sid.and_then(|id| st.sketch(id).map(|ss| (id, ss.profiles.len()))) {
|
||||
Some((id, 1)) => vec![Sel::Profile { sketch: id, index: 0 }],
|
||||
sid.map(|id| single_profile(s, id)).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn single_profile(s: &Session, sketch: u64) -> Vec<Sel> {
|
||||
match s.world_state().sketch(sketch).map(|ss| ss.profiles.len()) {
|
||||
Some(1) => vec![Sel::Profile { sketch, index: 0 }],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -2054,7 +2071,7 @@ fn selection_label(s: &Session, item: &Sel) -> String {
|
||||
match item {
|
||||
Sel::SketchCurve { id, .. } => id.clone(),
|
||||
Sel::SketchPoint { id } => crate::sketch_modify_ui::point_id(s, item).unwrap_or(id).to_string(),
|
||||
Sel::Plane { name } | Sel::Axis { name } | Sel::Body { name } => name.clone(),
|
||||
Sel::Plane { name } | Sel::Axis { name } | Sel::ConstructionPoint { name } | Sel::Body { name } => name.clone(),
|
||||
Sel::Face { body, index, .. } => format!("{body} · face {index}"),
|
||||
Sel::Edge { body, index, .. } => format!("{body} · edge {index}"),
|
||||
Sel::Feature { id } => s.doc.feature(*id).map(|f| f.name.clone()).unwrap_or_else(|| format!("Feature {id}")),
|
||||
@@ -3693,20 +3710,33 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
}
|
||||
Kind::Loft { operation, rail_fit } => {
|
||||
need(0, "profiles of two or more sketches")?;
|
||||
// One section per sketch, in the order the sketches were first picked.
|
||||
let mut sections: Vec<(u64, Vec<usize>)> = Vec::new();
|
||||
// One section per sketch, in the order the sketches were first picked; a sketch point
|
||||
// is a section of its own, an apex the loft closes to (#4830).
|
||||
let mut sections: Vec<(u64, Vec<usize>, Option<&str>)> = Vec::new();
|
||||
for x in sels(d, 0) {
|
||||
if let Sel::Profile { sketch, index } = x {
|
||||
match sections.iter_mut().find(|(sk, _)| sk == sketch) {
|
||||
Some((_, v)) => v.push(*index),
|
||||
None => sections.push((*sketch, vec![*index])),
|
||||
match x {
|
||||
Sel::Profile { sketch, index } => match sections.iter_mut().find(|(sk, _, apex)| sk == sketch && apex.is_none()) {
|
||||
Some((_, v, _)) => v.push(*index),
|
||||
None => sections.push((*sketch, vec![*index], None)),
|
||||
},
|
||||
Sel::SketchPoint { id } => {
|
||||
if let Some((sk, point)) = id.split_once(':').and_then(|(sk, point)| Some((sk.parse::<u64>().ok()?, point))) {
|
||||
sections.push((sk, Vec::new(), Some(point)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if sections.len() < 2 {
|
||||
return Err("pick profiles in two or more sketches".into());
|
||||
return Err("pick profiles in two or more sketches, or a profile and a sketch point".into());
|
||||
}
|
||||
let sections: Vec<Value> = sections.into_iter().map(|(sk, v)| json!({"sketch": sk, "profiles": v})).collect();
|
||||
let sections: Vec<Value> = sections
|
||||
.into_iter()
|
||||
.map(|(sk, v, apex)| match apex {
|
||||
Some(point) => json!({"sketch": sk, "point": point}),
|
||||
None => json!({"sketch": sk, "profiles": v}),
|
||||
})
|
||||
.collect();
|
||||
let mut p = json!({"sections": sections, "operation": OPS.get(*operation).copied().unwrap_or("new")});
|
||||
crate::dialogs_sweep::rail_params(app, *rail_fit, [sels(d, 1), sels(d, 2)], &mut p)?;
|
||||
("solid.loft", p)
|
||||
@@ -4466,7 +4496,7 @@ fn emboss_face_sel(s: &Session, component: u64, face: &solidcraft_engine::doc::E
|
||||
/// Plane At Angle have dialogs of their own.
|
||||
fn construct_picks(id: &str) -> Option<(&'static str, &'static str, &'static [(&'static str, Accept, &'static str)])> {
|
||||
const PLANE: Accept = PLANES | PLANAR_FACES;
|
||||
const POINT: Accept = selection::VERTICES | selection::POINTS;
|
||||
const POINT: Accept = selection::VERTICES | selection::POINTS | selection::CONSTRUCTION_POINTS;
|
||||
const LINE: Accept = EDGES | AXES;
|
||||
const CURVE: Accept = EDGES | CURVES;
|
||||
Some(match id {
|
||||
@@ -4520,6 +4550,7 @@ fn construct_ref(app: &SolveApp, sel: &Sel) -> Result<Value, String> {
|
||||
Sel::Vertex { point, .. } => json!({"vertex": pt(*point)}),
|
||||
Sel::Plane { name } => json!({"plane": name}),
|
||||
Sel::Axis { name } => json!({"axis": name}),
|
||||
Sel::ConstructionPoint { name } => json!({"construct_point": name}),
|
||||
// Picked across sketches as "<sketch id>:<point id>".
|
||||
Sel::SketchPoint { id } => match id.split_once(':').and_then(|(sk, p)| Some((sk.parse::<u64>().ok()?, p))) {
|
||||
Some((sketch, point)) => json!({"sketch": sketch, "point": point}),
|
||||
@@ -5336,13 +5367,12 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Pipe { path_sketch, path, operation, targets, .. } => {
|
||||
FeatureKind::Pipe { path_sketch, path, targets, .. } => {
|
||||
let mut d = start("solid.pipe")?;
|
||||
d.kind = Kind::simple_restore(&f.kind)?;
|
||||
if let Some(inp) = d.inputs.first_mut() {
|
||||
inp.items = path.iter().map(|id| crate::selection::curve_pick(id, path_sketch.0, s.active_sketch)).collect();
|
||||
}
|
||||
d.extra.insert("operation".into(), json!(OPS.get(op_index(operation)).copied().unwrap_or("new")));
|
||||
if !targets.is_empty() {
|
||||
d.extra.insert("targets".into(), json!(targets));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user