Compare commits

...

60 Commits

Author SHA1 Message Date
bherbruck aa1fe28ddf Release 0.27.1 2026-10-29 23:34:31 -04:00
bherbruck 8f1b456275 Release 0.27.1 (#9181)
* Release 0.27.1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Render job test: wait for the cancelled job's reservation to be released

The worker drops a job's reservation just after the job turns terminal, so
checking admitted bytes right after wait_timeout raced (10 of 40 local runs
failed). Poll with a deadline, as dielectric_tests does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:34:23 -04:00
bherbruck b596bfc730 Bottom bar: keep active toggles readable when the selected fill is as dark as the accent (#9168) (#9171)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:42:27 -04:00
Colin Faulkingham d11d268597 Render: reject excessive pixel work during preflight (#5088) (#5728)
* claim: #5088

* fix: reject excessive render pixel work during preflight (#5088)
2026-10-30 01:35:12 +00:00
github-actions[bot] 4b0ba62c69 Regenerate generated files and fold pending files [skip ci] 2026-10-30 01:13:55 +00:00
taylorhayis 313d2bdb41 Sketch: snap where two recent centre and midpoint guides meet (#8694) (#8744)
* Sketch: snap where two recent centre and midpoint guides meet (#8694)

The alignment guide remembered one reference and did not move the cursor. Keep the previous centre or midpoint as well, and snap a drawing point to where their guides cross.

* Sketch: keep the recent-guide crossing snap off until asked (#8694)

The crossing snap was on whenever alignment guides were. Give it its own Sketch preference, off by default, so the guides stay visual until someone turns the snap on.

* Sketch recent snap: ar/he/vi strings and clippy borrow fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:40:05 +00:00
Colin Faulkingham 079f6e61e6 Sketch: show no geometry for an empty sketch (#9077) (#9104)
* claim: #9077

* Sketch: show no geometry for empty viewport sketches (#9077)
2026-10-30 00:33:47 +00:00
Rick Morgans 4a781b8cd8 MCP: --param-check checks calls to the running app (#9072) (#9073)
* claim: #9072

* MCP: --param-check checks calls to the running app (#9072)

`mcp --connect` (and `mcp` once it bridges to an app) took
`--param-check` and forwarded every call unchecked, while the app's own
session is always off. `Remote` now checks each `engine.execute` and
`engine.batch` against this server's command contracts before sending:
`strict` refuses a call, or a batch with such a step, without sending
anything, and `warn` adds `param_warnings` to the app's reply (each
step's result in a batch). A command with no contract here goes to the
app unchecked. `Auto` carries the headless session's level over when it
bridges to a late app. `batch_preflight::contracts` is the contract half
of the existing preflight, for a runner in another process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:33:42 +00:00
bherbruck 5b0eb08473 Release 0.27.0 2026-10-29 19:53:24 -04:00
semmelrinde 87206f20c8 Theme: the System theme leaves the window to follow the system appearance (#8983) (#9103)
theme::apply always sets egui's theme to light or dark, and egui's own window
sync then told the native window to be light or dark. A macOS window told so
keeps that appearance and no longer reports the system appearance changing,
so with Theme set to System the app stayed light after macOS turned dark. The
app now syncs the window itself: the system's appearance while the theme
follows it, the theme's own otherwise (for the title bar).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:51:07 +00:00
Logan Night 5a24d4095a Sketch: an old edge-on projection keeps its line when it is re-resolved (#9025) (#9097)
* claim: #9025

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Sketch: an old edge-on projection keeps its line when it is re-resolved (#9025)

A face seen edge-on was saved as two lines on the same two points, one each way, and is one line now. Re-resolving the link dropped both lines and built a new one under a new id, so an offset that names the first line as its source failed with "an offset source vanished or changed shape". Keep the first line and drop only the second.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-29 23:50:29 +00:00
bherbruck 9df8837d43 Release 0.26.1 2026-10-29 06:33:09 -04:00
bherbruck 409ab1ed45 Release 0.26.0 (zip 9.1.3) 2026-10-28 19:41:38 -04:00
bherbruck 5e03fe2f4b Release 0.26.0 2026-10-28 19:32:17 -04:00
bherbruck 37a586dd61 Release 0.25.1 2026-10-27 20:58:30 -04:00
bherbruck a79f40a711 Release 0.25.0 2026-10-27 18:05:39 -04:00
bherbruck b5728f284b Release 0.24.1 2026-10-27 08:34:27 -04:00
bherbruck 5965439a15 Release 0.24.0 2026-10-26 19:23:21 -04:00
bherbruck 586a358ed4 Release 0.23.1 2026-10-26 07:07:21 -04:00
bherbruck 7770f8982d Release 0.23.0 2026-10-25 18:18:39 -04:00
bherbruck 72ad6383ef Release 0.22.1 2026-10-25 10:38:39 -04:00
bherbruck 0537623a75 Release 0.22.0 2026-10-24 18:53:43 -04:00
bherbruck 65d288227e Release 0.21.0 (rc.2's code, version only)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:51:08 -04:00
bherbruck fedb3bcdb1 Release 0.21.0-rc.2 2026-10-23 17:14:38 -04:00
bherbruck 097b8b7550 Release 0.21.0-rc.1
The tree is main at f120285c9 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-23 13:48:05 -04:00
bherbruck af7ca3a8a2 Release 0.21.0-rc.1
The tree is main at b31fddb98 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-23 13:37:43 -04:00
bherbruck 27ddb725c7 Release 0.21.0-rc.1
The tree is main at 2bf3d4e11 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-23 12:10:02 -04:00
bherbruck a06bdf5cfc Release 0.20.0
The tree is main at 030187452 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-22 18:34:40 -04:00
bherbruck 33fdcdb3a0 Release 0.19.0
The tree is main at 5899e5b6c exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-21 21:13:33 -04:00
bherbruck c07ab5f8f9 Release 0.18.4
The tree is main at 728f753b8 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-21 12:26:34 -04:00
bherbruck fea32f8aac Release 0.18.3
The tree is main at 034aa60d4 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-21 08:46:37 -04:00
bherbruck 4927214976 Release 0.18.2
The tree is main at 57bcc4083 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 22:09:18 -04:00
bherbruck b621819c0c Release 0.18.1
The tree is main at 522ae7755 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 17:39:11 -04:00
bherbruck ec552a5300 Release 0.18.0
The tree is main at c3151fb7f exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 16:28:59 -04:00
bherbruck e0cc740cc3 Release 0.17.0
The tree is main at 511faedcf exactly; the 0.16.x release commits are kept as a parent so release only moves forward.
2026-10-20 11:41:48 -04:00
bherbruck 5de48e916d Release 0.16.2 2026-10-19 00:39:30 +00:00
bherbruck 80af7bbe3e Release 0.16.2
Version 0.16.2: 0.16.1 with the rigid orbit fix (#2753). Same design format (27).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:38:53 +00:00
bherbruck 191e983423 Fix orbit about the picked point (regression in 0.16.0) (#2753)
Orbiting about the point under the cursor (or the origin) turned the camera
about its target and then panned to put the pivot back. The pan works at the
target's depth with the parallel-view scale, so in a perspective view the
pivot slid off its pixel and the model swung about the camera.

The camera now turns rigidly about the pivot: Camera::orbit_about keeps the
pivot's coordinates in the camera's frame and rebuilds the target from the
new orientation, so the pivot stays exactly on its pixel in both projections.
The Shift snap spring and view.navigate's pivot orbit use the same.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit e88779218)
2026-10-19 00:38:52 +00:00
bherbruck 2b9445b049 Release 0.16.1 2026-10-18 20:16:21 +00:00
bherbruck 7ddf48ca16 Release 0.16.1
Version 0.16.1: 0.16.0 with the orbit fix (reverts #2142). Same design format (27).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:12:04 +00:00
bherbruck d2f6848377 Revert #2142: orbit about the picked point like 0.15.0
#2142 made orbiting about the point under the cursor turn the camera in place instead of turning the part about that point. Restore the 0.15.0 behaviour.

This reverts commit cac4bccea8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:12:00 +00:00
bherbruck 0b6d6fecd8 Release 0.16.0 2026-10-18 18:16:11 +00:00
bherbruck fa691707c2 Release 0.15.0 2026-10-17 13:54:24 +00:00
bherbruck b9a55c74e2 Release 0.15.0
Version 0.15.0; the pending design-format changes are folded into format 26.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:53:44 +00:00
bherbruck 2bb9c81069 Release 0.14.0 2026-10-16 09:19:14 +00:00
bherbruck cedb51b8ea Release 0.14.0
Version 0.14.0; the pending design-format changes are folded into format 25.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:19:10 +00:00
bherbruck 66d13059dd Release 0.13.1 2026-10-15 21:56:43 +00:00
bherbruck 800ea40c0d Release 0.13.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:56:35 +00:00
bherbruck 20442eecd3 Agent chat on Windows: start npm agents through their .cmd shim
npm puts a Unix shell script with the bare name (codex-acp,
claude-code-acp, gemini) beside the .cmd shim. The lookup tried the bare
name first, so every npm-installed agent failed with "not a valid Win32
application (os error 193)". Windows now only considers .exe/.cmd/.bat,
and a command saved with the bare path runs the shim beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 60ea8c69d2)
2026-10-15 21:51:01 +00:00
bherbruck 22d1cd7032 Release 0.13.0 2026-10-15 20:34:13 +00:00
bherbruck 6a0dc18655 Fix the Windows 32-bit and macOS release builds
On 32-bit Windows SetWindowLongPtrW is SetWindowLongW, which takes and
returns i32, so the window-procedure hook didn't compile for i686.
The macOS app bundle now signs the bundled solidcraft-cli before the
bundle itself; codesign refuses a bundle with an unsigned helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 94e76cd97e)
2026-10-15 20:34:12 +00:00
bherbruck e006dbf1af Release 0.13.0
The tree is the 0.13.0 version commit (8241a750fa) on main exactly; the previous release is kept as a parent so release only moves forward.
2026-10-15 19:59:50 +00:00
bherbruck 8241a750fa Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:59:42 +00:00
bherbruck 41779cf7bb Release 0.12.0: release branch follows main again after the 0.11.1 patch
The tree is main at c36a6f9f exactly; the 0.11.1 patch commit is kept as a parent so release only moves forward.
2026-10-15 13:32:01 +00:00
bherbruck b01b9689c8 Release 0.11.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:41:41 +00:00
bherbruck 4426da6f5d Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU (#1190)
* Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU

wgpu started Vulkan, D3D12 and OpenGL together and preferred Vulkan, so
every Windows start loaded all installed Vulkan drivers. An old Intel
Vulkan driver (igvk64.dll) crashed 0.10.1 at startup inside the driver.

The app now probes D3D12, then Vulkan, then OpenGL, one backend at a
time, and starts only the first with a hardware adapter (WARP when none
has one). WGPU_BACKEND still wins; graphics.json's "backend" sets the
order by hand; the software renderer (safe mode, the retry) uses D3D12
alone. A new Windows CI job starts the real app under several settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: a test filter YAML parses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: the startup smoke test writes graphics.json where the app reads it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: run the WARP shader tests one at a time

WARP's shader JIT (d3d10warp.dll) crashed twice in 124 runs of the gpu
tests with four test threads, with a null read inside its own optimizer
while several devices compiled compute pipelines at once. Serial runs
did not crash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 95ea05e7c8)
2026-10-14 20:41:37 +00:00
bherbruck 5ead82e627 Release 0.11.0: release branch follows main again after the 0.10.1 patch
The tree is main at cccbe125 exactly; c29b0d2f (0.10.1) is kept as a parent so release only moves forward.
2026-10-14 20:07:41 +00:00
bherbruck c29b0d2f13 Release 0.10.1 2026-10-14 14:41:19 +00:00
bherbruck 480fd8d517 Fix the startup crash on D3D12 with FXC: CPU fallback for the tessellation pipeline, FXC-friendly shader (#1098) (#1099)
* GPU tessellation: a pipeline the device cannot build falls back to CPU meshing instead of crashing (#1098)

Build the tessellation compute pipeline inside error scopes; TessGpu::new returns an error,
and the viewport logs it and meshes bodies on the CPU. A Windows CI job builds the pipeline
with D3D12's FXC on the WARP adapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tessellation shader: basis functions in one-dimensional local arrays, so D3D12's FXC compiles it (#1098)

FXC has to unroll loops that write an array inside a struct (Basis.n0/n1) or a 2-D array
(ndu) by a counter, and cannot with run-time trip counts (X3511). A2.2 basis functions in a
local 1-D array returned whole; derivatives from the degree p-1 functions; the pole step
offsets computed rather than indexed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:41:16 +00:00
bherbruck 4c09fa5355 Hovered and selected faces light solidly again over GPU-drawn bodies (#1064)
Since the model's mesh went back to the reference mesher (#940), the GPU viewport drew
opaque bodies from their display plans while hover and selection lit faces with the
reference mesh's triangles. Two triangulations of the same face at equal depth fought, so
a hovered face shimmered in wedges instead of lighting whole.

Faces lit over a body drawn from its plan now use the plan's own triangles
(ModelBody::plan_mesh, evaluated once on the CPU), and the highlight pass gets a small
depth bias toward the eye so f32 rounding between the GPU and CPU evaluations of the same
triangles cannot speckle it either.

Test: every face of a box with a pocket and a round boss, hovered and rendered offscreen
through the viewport's real pipelines on the GPU path, lights the same pixels as on the
CPU path (skipped without a GPU adapter).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:56:47 +00:00
35 changed files with 979 additions and 189 deletions
+1
View File
@@ -610,6 +610,7 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
| assets/icons/ui/design/svg/flat/status_pin.svg | SolidCraft contributors | Original analytic pin icon for persistent viewport results | MIT OR Apache-2.0 |
| assets/locales/pending/render-wear.json | SolidCraft contributors | Original UI labels and translations | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/equation_curve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/locales/pending/sketch-recent-snap.json | SolidCraft contributors | Original preference messages for the recent centre and midpoint snap | MIT OR Apache-2.0 |
## Code adapted from sibling projects
Generated
+17 -17
View File
@@ -4210,7 +4210,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.27.0"
version = "0.27.1"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4246,11 +4246,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.27.0"
version = "0.27.1"
[[package]]
name = "solidcraft-cli"
version = "0.27.0"
version = "0.27.1"
dependencies = [
"image",
"log",
@@ -4262,7 +4262,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.27.0"
version = "0.27.1"
dependencies = [
"proc-macro2",
"quote",
@@ -4271,7 +4271,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.27.0"
version = "0.27.1"
dependencies = [
"log",
"serde",
@@ -4288,7 +4288,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.27.0"
version = "0.27.1"
dependencies = [
"image",
"log",
@@ -4309,7 +4309,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.27.0"
version = "0.27.1"
dependencies = [
"serde",
"serde_json",
@@ -4318,7 +4318,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.27.0"
version = "0.27.1"
dependencies = [
"image",
"log",
@@ -4338,7 +4338,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.27.0"
version = "0.27.1"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4362,7 +4362,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.27.0"
version = "0.27.1"
dependencies = [
"log",
"proptest",
@@ -4382,7 +4382,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.27.0"
version = "0.27.1"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4391,7 +4391,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.27.0"
version = "0.27.1"
dependencies = [
"image",
"moxcms",
@@ -4405,7 +4405,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.27.0"
version = "0.27.1"
dependencies = [
"harfrust",
"serde",
@@ -4418,14 +4418,14 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.27.0"
version = "0.27.1"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-ui-egui"
version = "0.27.0"
version = "0.27.1"
dependencies = [
"arboard",
"egui",
@@ -4455,7 +4455,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.27.0"
version = "0.27.1"
dependencies = [
"eframe",
"egui",
@@ -5998,7 +5998,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.27.0"
version = "0.27.1"
dependencies = [
"proc-macro2",
"serde",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.27.0"
version = "0.27.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+4 -6
View File
@@ -720,11 +720,8 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
eprintln!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
}
}
// How a headless session treats calls that break a command's parameter contract.
// How calls that break a command's parameter contract are treated, headless or bridged.
let param_check = parse_param_check("mcp", args)?;
if param_check != solidcraft_engine::schema::ParamCheck::Off && flag(args, "--connect").is_some() {
eprintln!("solidcraft mcp: --param-check applies to headless sessions; calls to the app are not checked yet");
}
let backend: Box<dyn solidcraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
(Some(addr), None) => {
@@ -732,7 +729,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
return Err(format!("mcp: --connect wants HOST:PORT, such as 127.0.0.1:7878, not `{addr}`"));
};
// Serve without the app too: the tools connect once it runs, and say it isn't until then.
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token"));
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token")).with_param_check(param_check);
if let Err(e) = remote.try_connect() {
eprintln!(
"solidcraft mcp: no SolidCraft app at {addr} yet ({e}); start it with `solidcraft --control {port}` \
@@ -752,7 +749,8 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
let auto = !args.iter().any(|a| a == "--headless");
if auto && let Some(port) = discover_app() {
eprintln!("solidcraft mcp: bridged to the running app at 127.0.0.1:{port} (--headless starts a session of its own)");
return serve_mcp(Box::new(solidcraft_mcp::Remote::new(&format!("127.0.0.1:{port}"), flag(args, "--token"))), scope);
let remote = solidcraft_mcp::Remote::new(&format!("127.0.0.1:{port}"), flag(args, "--token")).with_param_check(param_check);
return serve_mcp(Box::new(remote), scope);
}
let mut headless = solidcraft_mcp::Headless::default();
headless.session.param_check = param_check;
+2
View File
@@ -1979,6 +1979,8 @@
"prefs.sketch_exit_view.previous": "العودة إلى العرض السابق",
"prefs.sketch_exit_view.unless_rotated": "العودة ما لم يُدوَّر العرض",
"prefs.sketch_exit_view_note": "أين ينتقل العرض بعد إنهاء الرسم أو الإلغاء أو الضغط على Esc مرتين.",
"prefs.sketch_recent_snap": "الالتقاط التلقائي إلى المراكز ونقاط المنتصف الأخيرة",
"prefs.sketch_recent_snap_note": "أثناء الرسم، يلتقط المؤشر الموضع الذي يتقاطع فيه دليلا آخر مركزي دائرتين أو نقطتي منتصف حافتين مستقيمتين. إيقاف: تبقى الأدلة للعرض فقط ولا يتحرك المؤشر.",
"prefs.sketch_selection": "الرسم والتحديد",
"prefs.sketch_selection_heading": "الرسم والتحديد",
"prefs.sketch_selection_note": "كيف تلتقط الرسومات وكيف تحدد النقرات.",
+2
View File
@@ -1881,6 +1881,8 @@
"prefs.sketch_align_points_note": "Beim Zeichnen richtet sich ein Punkt zugleich waagerecht an einem Skizzenpunkt und senkrecht an einem anderen aus, mit einer gepunkteten Hilfslinie; der Klick behält beide Ausrichtungen. Aus: nur am vorherigen Punkt, jeweils eine Achse.",
"prefs.sketch_conflict_colour": "Widersprüchliche Geometrie in der Konfliktfarbe",
"prefs.sketch_conflict_colour_note": "Solange die Bedingungen einer Skizze im Konflikt stehen, werden auch die Kurven und Punkte, die eine nicht erfüllte Bedingung hält, in der Konfliktfarbe gezeichnet, nicht nur ihr Symbol oder ihre Bemaßung. Aus: Sie behalten ihre üblichen Farben.",
"prefs.sketch_recent_snap": "Automatisch an zuletzt verwendete Mittelpunkte fangen",
"prefs.sketch_recent_snap_note": "Fängt den Zeiger beim Zeichnen dort ein, wo sich die Hilfslinien der beiden zuletzt verwendeten Kreismittelpunkte oder Kantenmitten kreuzen. Aus: die Hilfslinien bleiben sichtbar und der Zeiger bewegt sich nicht.",
"prefs.sketch_state_icons": "Skizzensymbole in der Farbe ihres Zustands",
"prefs.sketch_state_icons_note": "Im Verlauf und im Browser nimmt das Symbol einer Skizze die Farbe ihres Zustands an (unterbestimmt, vollständig bestimmt oder im Konflikt), und ihr Tooltip nennt den Zustand. Aus: Alle Skizzen haben dasselbe Symbol.",
"prefs.sketch_tab_always": "Registerkarte Skizze immer zeigen",
+2
View File
@@ -39,6 +39,8 @@
"prefs.megabytes": "MB",
"prefs.sketch_conflict_colour": "Conflicting geometry in the conflict colour",
"prefs.sketch_conflict_colour_note": "While a sketch's constraints conflict, the curves and points a failing constraint holds are drawn in the conflict colour too, not only its glyph or dimension. Off: they keep their usual colours.",
"prefs.sketch_recent_snap": "Auto snap to recent centres and midpoints",
"prefs.sketch_recent_snap_note": "While drawing, snap the cursor where the guides of the two most recent circle centres or straight-edge midpoints cross. Off: those guides stay visual and the cursor does not move.",
"prefs.sketch_state_icons": "Sketch icons in the colour of their state",
"prefs.sketch_state_icons_note": "In History and the browser, a sketch's icon takes the colour of its state (under constrained, fully constrained or conflicting) and its tooltip names the state. Off: every sketch has the same icon.",
"prefs.sketch_tracking_guides": "Centre and midpoint alignment guides",
+2
View File
@@ -1979,6 +1979,8 @@
"prefs.sketch_exit_view.previous": "Return to the previous view",
"prefs.sketch_exit_view.unless_rotated": "Return unless rotated",
"prefs.sketch_exit_view_note": "Where the view goes after Finish Sketch, Cancel or a double Esc.",
"prefs.sketch_recent_snap": "Auto snap to recent centers and midpoints",
"prefs.sketch_recent_snap_note": "While drawing, snap the cursor where the guides of the two most recent circle centers or straight-edge midpoints cross. Off: those guides stay visual and the cursor does not move.",
"prefs.sketch_selection": "Sketch & selection",
"prefs.sketch_selection_heading": "Sketch and selection",
"prefs.sketch_selection_note": "How sketches snap and how clicks select.",
+2
View File
@@ -1979,6 +1979,8 @@
"prefs.sketch_exit_view.previous": "חזרה לתצוגה הקודמת",
"prefs.sketch_exit_view.unless_rotated": "חזרה אלא אם התצוגה סובבה",
"prefs.sketch_exit_view_note": "לאן התצוגה עוברת אחרי סיום סקיצה, ביטול או Esc כפול.",
"prefs.sketch_recent_snap": "הצמדה אוטומטית למרכזים ולנקודות אמצע אחרונים",
"prefs.sketch_recent_snap_note": "בזמן שרטוט, הסמן נצמד למקום שבו נחתכים קווי העזר של שני מרכזי המעגלים או נקודות האמצע של קצוות ישרים האחרונים. כבוי: קווי העזר נשארים לתצוגה בלבד והסמן אינו זז.",
"prefs.sketch_selection": "סקיצה ובחירה",
"prefs.sketch_selection_heading": "סקיצה ובחירה",
"prefs.sketch_selection_note": "איך סקיצות נצמדות ואיך לחיצות בוחרות.",
+2
View File
@@ -1979,6 +1979,8 @@
"prefs.sketch_exit_view.previous": "前のビューに戻る",
"prefs.sketch_exit_view.unless_rotated": "回転していなければ戻る",
"prefs.sketch_exit_view_note": "「スケッチを終了」、キャンセル、Esc の 2 回押しの後にビューが移る先です。",
"prefs.sketch_recent_snap": "最近使った中心と中点へ自動スナップ",
"prefs.sketch_recent_snap_note": "作図中、直前の2つの円の中心または直線辺の中点のガイドが交わる位置にカーソルをスナップします。オフ: ガイドは表示のみで、カーソルは移動しません。",
"prefs.sketch_selection": "スケッチと選択",
"prefs.sketch_selection_heading": "スケッチと選択",
"prefs.sketch_selection_note": "スケッチのスナップとクリックによる選択。",
+2
View File
@@ -1620,6 +1620,8 @@
"prefs.sketch_align_points_note": "그리는 동안 점이 한 스케치 점과 수평으로, 다른 점과 수직으로 동시에 맞춰지고 점선 안내선이 표시됩니다. 클릭하면 두 정렬이 모두 유지됩니다. 끄기: 이전 점에만, 한 번에 한 축씩 맞춥니다.",
"prefs.sketch_conflict_colour": "충돌하는 기하를 충돌 색상으로 표시",
"prefs.sketch_conflict_colour_note": "스케치의 구속 조건이 충돌하는 동안 실패한 구속 조건의 글리프나 치수뿐 아니라 그 구속 조건이 잡고 있는 곡선과 점도 충돌 색상으로 그립니다. 끄기: 평소 색상을 유지합니다.",
"prefs.sketch_recent_snap": "최근 중심과 중점에 자동 스냅",
"prefs.sketch_recent_snap_note": "그리는 동안 최근에 사용한 두 원의 중심 또는 직선 모서리 중점의 안내선이 만나는 위치로 커서를 스냅합니다. 끄기: 안내선은 보기만 하고 커서는 움직이지 않습니다.",
"prefs.sketch_selection": "스케치 및 선택",
"prefs.sketch_selection_heading": "스케치 및 선택",
"prefs.sketch_selection_note": "스케치가 스냅되는 방식과 클릭으로 선택하는 방식입니다.",
+2
View File
@@ -1979,6 +1979,8 @@
"prefs.sketch_exit_view.previous": "Trở về góc nhìn trước đó",
"prefs.sketch_exit_view.unless_rotated": "Trở về góc nhìn trước trừ khi đã xoay",
"prefs.sketch_exit_view_note": "Góc nhìn sẽ chuyển đến đâu sau khi bấm Hoàn tất phác thảo, Hủy bỏ hoặc bấm phím Esc hai lần.",
"prefs.sketch_recent_snap": "Tự động bắt điểm vào tâm và trung điểm gần đây",
"prefs.sketch_recent_snap_note": "Khi vẽ, con trỏ được bắt vào nơi các đường dẫn của hai tâm đường tròn hoặc trung điểm cạnh thẳng gần nhất cắt nhau. Tắt: các đường dẫn chỉ để xem và con trỏ không di chuyển.",
"prefs.sketch_selection": "Phác thảo & chọn đối tượng",
"prefs.sketch_selection_heading": "Phác thảo và lựa chọn",
"prefs.sketch_selection_note": "Cách phác thảo bắt dính và cách nhấp chuột chọn đối tượng.",
+2
View File
@@ -1979,6 +1979,8 @@
"prefs.sketch_exit_view.previous": "返回之前的视图",
"prefs.sketch_exit_view.unless_rotated": "未旋转时返回",
"prefs.sketch_exit_view_note": "“完成草图”、取消或连按两次 Esc 后视图的去向。",
"prefs.sketch_recent_snap": "自动吸附到最近的圆心和中点",
"prefs.sketch_recent_snap_note": "绘图时,将光标吸附到最近两个圆心或直线边中点的辅助线相交处。关闭:辅助线仅作显示,光标不会移动。",
"prefs.sketch_selection": "草图与选择",
"prefs.sketch_selection_heading": "草图和选择",
"prefs.sketch_selection_note": "草图如何捕捉,以及单击如何选择。",
+24 -4
View File
@@ -19,7 +19,7 @@
use serde_json::{Value, json};
use crate::schema::ParamCheck;
use crate::{EngineError, find_command};
use crate::{CommandSpec, EngineError, find_command};
/// The first step a batch would refuse at its turn.
#[derive(Clone, Debug, PartialEq)]
@@ -61,15 +61,31 @@ pub fn check(steps: &[Value], level: ParamCheck, host: &[&str]) -> Option<Refusa
return Some(Refusal { index, command: Some(id.to_string()), error: EngineError::UnknownCommand(id.to_string()).to_string() });
};
if level == ParamCheck::Strict
&& let Some(contract) = spec.contract
&& let Some(first) = contract.check(step.get("params").unwrap_or(&Value::Null)).into_iter().next()
&& let Some(refusal) = contract_refusal(index, id, spec, step)
{
return Some(Refusal { index, command: Some(id.to_string()), error: crate::params::bad(id, first.message).to_string() });
return Some(refusal);
}
}
None
}
/// The first of `steps` whose parameters break its command's typed contract, for a runner in
/// another process that checks the rest itself (the MCP server bridged to an app at
/// [`ParamCheck::Strict`]): a step naming no command, or one this registry lacks (the app may be
/// another version), is left to it.
pub fn contracts(steps: &[Value]) -> Option<Refusal> {
steps.iter().enumerate().find_map(|(index, step)| {
let id = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str)?;
contract_refusal(index, id, find_command(id)?, step)
})
}
/// Step `index`'s refusal when its parameters break `spec`'s typed contract.
fn contract_refusal(index: usize, id: &str, spec: &CommandSpec, step: &Value) -> Option<Refusal> {
let first = spec.contract?.check(step.get("params").unwrap_or(&Value::Null)).into_iter().next()?;
Some(Refusal { index, command: Some(id.to_string()), error: crate::params::bad(id, first.message).to_string() })
}
#[cfg(test)]
mod tests {
use super::*;
@@ -130,5 +146,9 @@ mod tests {
let mut s = crate::Session { param_check: ParamCheck::Strict, ..crate::Session::default() };
let at_turn = s.execute("solid.box", &steps[1]["params"]).unwrap_err().to_string();
assert_eq!(r.error, at_turn);
// Another process's runner gets the same refusal, and keeps what it checks itself.
assert_eq!(contracts(&steps), Some(r));
let theirs = vec![json!({"params": {}}), step("app.only", json!({"x": 1})), step("view.pick", json!({}))];
assert_eq!(contracts(&theirs), None);
}
}
+54
View File
@@ -480,6 +480,60 @@ fn compiled_out_raytrace_is_rejected_before_scene_capture() {
}
}
#[test]
fn preflight_and_start_reject_excessive_pixel_work_without_reserving_a_job() {
let (mut s, host) = session();
for _ in 0..8 {
s.execute("solid.box", &json!({"length":20,"width":20,"height":20,"corner":[-10,-10,-10]})).unwrap();
}
let mut camera = solidcraft_render::Camera { distance: 1.0, fov: 0.0, ..Default::default() };
camera.set_view(solidcraft_render::StandardView::Front);
let params = json!({"settings":{"width":1024,"height":1024,"camera":camera}});
let doc = s.doc.clone();
let before = (s.revision, s.evaluations, s.is_dirty(), s.undo.len(), s.redo.len());
for command in ["render.preflight", "render.start"] {
let error = s.execute(command, &params).unwrap_err().to_string();
assert!(error.contains("128000000 pixel tests"), "{error}");
assert_eq!(host.admitted_bytes(), 0);
}
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(before, (s.revision, s.evaluations, s.is_dirty(), s.undo.len(), s.redo.len()));
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
{
let raytrace_host = CpuHost::for_platform().unwrap();
s.set_render_job_host(Some(raytrace_host.clone())).unwrap();
let params = json!({"settings":{"width":1024,"height":1024,"camera":camera},
"integrator":"raytrace","transparent_background":false});
let preflight = s.execute("render.preflight", &params).unwrap();
assert_eq!(preflight["resolved"]["executor"], "cpu-raytrace");
assert!(preflight["admission"].get("projected_pixel_tests").is_none());
assert_eq!(raytrace_host.admitted_bytes(), 0);
let started = s.execute("render.start", &params).unwrap();
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
s.execute("render.cancel", &json!({"job":id})).unwrap();
let status = s.render_job_handle(id).unwrap().wait_timeout(std::time::Duration::from_secs(10)).unwrap();
assert_eq!(status.progress.phase, solidcraft_render::job::Phase::Cancelled);
// The worker drops its reservation just after the job turns terminal.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while raytrace_host.admitted_bytes() != 0 {
assert!(std::time::Instant::now() < deadline, "worker did not release the cancelled job's reservation");
std::thread::yield_now();
}
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(before, (s.revision, s.evaluations, s.is_dirty(), s.undo.len(), s.redo.len()));
s.set_render_job_host(Some(host.clone())).unwrap();
}
let params = json!({"settings":{"width":16,"height":16,"camera":camera}});
let preflight = s.execute("render.preflight", &params).unwrap();
assert!(preflight["admission"]["projected_pixel_tests"].as_u64().unwrap() > 0);
assert_eq!(host.admitted_bytes(), 0);
let id = s.execute("render.start", &params).unwrap()["job"].clone();
finish(&host);
assert_eq!(s.execute("render.status", &json!({"job":id})).unwrap()["progress"]["phase"], "completed");
}
#[test]
fn identities_are_isolated_across_documents_scratch_and_new() {
let (mut a, host) = session();
+33
View File
@@ -591,6 +591,39 @@ fn admission_rejects_hostile_parameters_and_transparency() {
assert!(scene::flatten(&scene, Vec3::ZERO, &CancelToken::default()).is_err());
}
#[test]
fn admission_ignores_the_cpu_raster_projected_pixel_limit() {
let mesh = Mesh {
positions: vec![Vec3::new(-10.0, -10.0, 0.0), Vec3::new(10.0, -10.0, 0.0), Vec3::new(10.0, 10.0, 0.0)],
normals: vec![Vec3::Z; 3],
triangles: vec![[0, 1, 2]; 3201],
tri_face: vec![0; 3201],
..Default::default()
};
let scene = ProductScene {
bounds: mesh.bounds(),
meshes: vec![ProductMesh {
name: "overlapping triangles".into(),
mesh: Arc::new(mesh),
color: [255; 3],
opacity: 1.0,
surface: Some(white()),
transmission: None,
faces: Vec::new(),
}],
};
let mut r = request(100);
r.camera.distance = 1.0;
r.camera.fov = 0.0;
let raster_error = solidcraft_render::job::admit(&scene, &r).unwrap_err();
assert_eq!(raster_error.code, ErrorCode::LimitExceeded);
assert!(raster_error.message.contains("128000000 pixel tests"), "{raster_error:?}");
// Exercise the actual GPU admission path without requiring a Vulkan device.
let gpu = admission(&scene, &r, JobOptions::default()).unwrap();
assert_eq!(gpu.projected_pixel_tests, None);
assert!(gpu.estimated_total_bytes > gpu.geometry_bytes);
}
#[test]
fn admission_applies_the_gpu_estimate_instead_of_the_cpu_raster_budget() {
// A library-bounded source (not an ordinary UI scene): about 100 MiB of face appearances
+100 -12
View File
@@ -8,6 +8,7 @@ use serde_json::{Value, json};
use solidcraft_engine::Session;
use solidcraft_engine::control_pending;
use solidcraft_engine::render::StandardView;
use solidcraft_engine::schema::ParamCheck;
use solidcraft_engine::view::Guides;
/// Something that answers control-channel methods: `engine.execute`, `engine.commands`,
@@ -33,10 +34,10 @@ pub trait Backend {
fn notice(&self) -> Option<String> {
None
}
/// How this side's session checks commands' typed contracts (`--param-check`), when this
/// process holds it. A running app checks its own (`engine.batch`), so `Remote` says off.
fn param_check(&self) -> solidcraft_engine::schema::ParamCheck {
solidcraft_engine::schema::ParamCheck::Off
/// How calls are checked against their commands' typed contracts (`--param-check`): by the
/// headless session, or by `Remote` before it sends them to the app.
fn param_check(&self) -> ParamCheck {
ParamCheck::Off
}
}
@@ -135,6 +136,8 @@ pub struct Remote {
/// An outstanding request's end, once a later call waited for it (for `pending`).
settled: Option<Settled>,
waits: Waits,
/// How calls are checked before they are sent (`--param-check`).
param_check: ParamCheck,
}
/// How a tool error starts when no app listens at the bridged address. The agent reads it, and
@@ -160,7 +163,17 @@ impl Remote {
pub fn new(addr: &str, token: Option<&str>) -> Self {
let port = addr.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()).unwrap_or(0);
let explicit_token = token.map(str::to_string);
Self { addr: addr.to_string(), port, conn: None, next_id: 1, explicit_token, outstanding: None, settled: None, waits: WAITS }
Self {
addr: addr.to_string(),
port,
conn: None,
next_id: 1,
explicit_token,
outstanding: None,
settled: None,
waits: WAITS,
param_check: ParamCheck::Off,
}
}
/// Shorter waits for requests still running (tests).
@@ -444,12 +457,85 @@ impl Remote {
}
}
/// The issues `params` raise against the typed contract of `id` in this server's registry; none
/// when it has no contract for `id` (the app may have commands this server lacks).
fn contract_issues(id: &str, params: &Value) -> Vec<String> {
let contract = solidcraft_engine::find_command(id).and_then(|c| c.contract);
contract.map(|c| c.check(params).into_iter().map(|i| i.message).collect()).unwrap_or_default()
}
/// `reply` with `issues` as its `param_warnings`, as a headless session at the warn level adds
/// them; a reply that has its own (an app that checked the call itself) keeps those.
fn with_param_warnings(mut reply: Value, issues: Vec<String>) -> Value {
if !issues.is_empty()
&& let Some(o) = reply.as_object_mut()
{
o.entry("param_warnings").or_insert_with(|| json!(issues));
}
reply
}
impl Remote {
/// Check calls against their commands' contracts at `level` before sending them
/// (`--param-check`): the app's own session checks nothing.
pub fn with_param_check(mut self, level: ParamCheck) -> Self {
self.param_check = level;
self
}
/// `engine.execute` checked here: `strict` refuses a call that breaks its command's contract
/// without sending it, and `warn` sends it and adds the issues to the app's reply.
fn execute_checked(&mut self, params: Value) -> Result<Value, String> {
let id = params.get("command").and_then(Value::as_str).unwrap_or_default();
let issues = contract_issues(id, params.get("params").unwrap_or(&Value::Null));
if self.param_check == ParamCheck::Strict
&& let Some(first) = issues.first()
{
return Err(solidcraft_engine::params::bad(id, first.as_str()).to_string());
}
let reply = self.call_once("engine.execute", params, true)?;
Ok(with_param_warnings(reply, issues))
}
/// `engine.batch` checked here: `strict` refuses a batch with a step that breaks its command's
/// contract before anything is sent, as the app would refuse it unrun, and `warn` adds each
/// step's issues to its result.
fn batch_checked(&mut self, params: Value) -> Result<Value, String> {
let steps = params.get("commands").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
if self.param_check == ParamCheck::Strict {
if let Some(refusal) = solidcraft_engine::batch_preflight::contracts(steps) {
return Ok(refusal.reply(steps.len()));
}
return self.call_once("engine.batch", params, true);
}
let issues: Vec<Vec<String>> = steps
.iter()
.map(|step| {
let id = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).unwrap_or_default();
contract_issues(id, step.get("params").unwrap_or(&Value::Null))
})
.collect();
let mut reply = self.call_once("engine.batch", params, true)?;
if let Some(results) = reply.get_mut("results").and_then(Value::as_array_mut) {
for (result, issues) in results.iter_mut().zip(issues) {
*result = with_param_warnings(std::mem::take(result), issues);
}
}
Ok(reply)
}
}
impl Backend for Remote {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
if method == "control.pending" {
return self.pending_status();
match method {
"control.pending" => self.pending_status(),
"engine.execute" if self.param_check != ParamCheck::Off => self.execute_checked(params),
"engine.batch" if self.param_check != ParamCheck::Off => self.batch_checked(params),
_ => self.call_once(method, params, true),
}
self.call_once(method, params, true)
}
fn param_check(&self) -> ParamCheck {
self.param_check
}
fn has_ui(&self) -> bool {
true
@@ -576,7 +662,7 @@ impl Backend for Headless {
fn in_process(&self) -> bool {
true
}
fn param_check(&self) -> solidcraft_engine::schema::ParamCheck {
fn param_check(&self) -> ParamCheck {
self.session.param_check
}
}
@@ -605,7 +691,8 @@ impl Auto {
}
if let Some(port) = (self.discover)() {
eprintln!("solidcraft mcp: the SolidCraft app is running now; bridged to 127.0.0.1:{port}");
self.remote = Some(Remote::new(&format!("127.0.0.1:{port}"), self.token.as_deref()));
let level = self.param_check();
self.remote = Some(Remote::new(&format!("127.0.0.1:{port}"), self.token.as_deref()).with_param_check(level));
self.headless = None;
}
}
@@ -642,10 +729,11 @@ impl Backend for Auto {
fn in_process(&self) -> bool {
self.remote.is_none()
}
fn param_check(&self) -> solidcraft_engine::schema::ParamCheck {
fn param_check(&self) -> ParamCheck {
match (&self.remote, &self.headless) {
(Some(r), _) => r.param_check(),
(None, Some(h)) => h.param_check(),
_ => solidcraft_engine::schema::ParamCheck::Off,
(None, None) => ParamCheck::Off,
}
}
fn notice(&self) -> Option<String> {
+59
View File
@@ -2322,6 +2322,58 @@ fn describe_command_and_strict_parameter_checking() {
assert_eq!(m["body_count"], json!(0), "{m}");
}
/// Bridged to an app (#9072), the server checks each call against its command's contract before
/// sending it, as a headless session does: the app's own session checks nothing. `strict` sends
/// nothing that breaks a contract, `warn` adds `param_warnings` to the app's reply, and a command
/// this server has no contract for goes to the app unchecked.
#[test]
fn a_bridged_server_checks_calls_at_its_param_check_level() {
use solidcraft_engine::schema::ParamCheck;
let (addr, seen) = scripted_app(|req| match req["method"].as_str() {
Some("engine.batch") => {
let n = req["params"]["commands"].as_array().map_or(0, Vec::len);
json!({"ok": true, "result": {"completed": n, "results": vec![json!({}); n], "undone": 0}})
}
_ => json!({"ok": true, "result": {"created_bodies": ["Box1"]}}),
});
let bridged = |level| {
let remote = crate::Remote::connect_with_token(&addr, Some("0123456789abcdef0123456789abcdef")).unwrap().with_param_check(level);
Server::new(Box::new(remote))
};
let sent = |what: &str| seen.lock().unwrap().1.iter().filter(|w| w.as_str() == what).count();
let text = |r: &Value| r["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string();
let misspelt = json!({"command": "solid.box", "params": {"length": 5, "width": 5, "height": 5, "operaton": "cut"}});
let good = json!({"command": "solid.box", "params": {"length": 5, "width": 5, "height": 5}});
let warning = "unknown key `operaton`; did you mean `operation`?";
let mut s = bridged(ParamCheck::Strict);
let r = raw_tool(&mut s, "execute", misspelt.clone());
assert_eq!(r["result"]["isError"], json!(true), "{r}");
assert!(text(&r).contains(&format!("invalid parameters for `solid.box`: {warning}")), "{r}");
assert_eq!(sent("engine.execute solid.box"), 0, "refused unsent");
let r = raw_tool(&mut s, "execute", json!({"command": "solid.fillet", "params": {"nonsense": 1}}));
assert_ne!(r["result"]["isError"], json!(true), "{r}");
assert_eq!(sent("engine.execute solid.fillet"), 1, "no contract here: the app decides");
let r = raw_tool(&mut s, "batch", json!({"commands": [good, misspelt]}));
assert_eq!(r["result"]["isError"], json!(true), "{r}");
let reply: Value = serde_json::from_str(&text(&r)).unwrap();
assert_eq!((&reply["failed"]["index"], &reply["completed"], &reply["not_run"]), (&json!(1), &json!(0), &json!([0, 1])), "{reply}");
assert!(reply["failed"]["error"].as_str().unwrap_or_default().contains(warning), "{reply}");
assert_eq!(sent("engine.batch"), 0, "refused unsent");
let mut s = bridged(ParamCheck::Warn);
let r = tool(&mut s, "execute", misspelt.clone());
assert_eq!(r["param_warnings"], json!([warning]), "{r}");
assert_eq!(sent("engine.execute solid.box"), 1);
let r = tool(&mut s, "batch", json!({"commands": [good, misspelt]}));
assert_eq!((r["results"][0].get("param_warnings"), &r["results"][1]["param_warnings"]), (None, &json!([warning])), "{r}");
assert_eq!(sent("engine.batch"), 1);
let mut s = bridged(ParamCheck::Off);
let r = tool(&mut s, "execute", misspelt);
assert_eq!(r.get("param_warnings"), None, "{r}");
}
/// Plain `mcp` with no app running: the session is headless, says so on every result, and bridges
/// to the app once one runs, but only while it holds no work of its own.
#[test]
@@ -2345,6 +2397,13 @@ fn auto_session_bridges_to_a_late_app_only_while_empty() {
assert!(!text(&r).contains("headless session because"), "{r}");
assert!(s.backend().has_ui());
assert!(s.backend().describe().contains("connected to the SolidCraft app"));
// The headless session's `--param-check` level goes on checking the bridged calls.
let mut headless = Headless::default();
headless.session.param_check = solidcraft_engine::schema::ParamCheck::Strict;
let mut strict = Server::new(Box::new(crate::Auto::new(headless, Box::new(|| Some(1)), None)));
raw_tool(&mut strict, "inspect_design", json!({}));
assert!(strict.backend().has_ui());
assert_eq!(strict.backend().param_check(), solidcraft_engine::schema::ParamCheck::Strict);
// With work in the headless session, the app showing up changes nothing.
port.set(None);
+6 -8
View File
@@ -285,7 +285,7 @@ fn cancellation_while_encoding_cannot_publish_a_late_frame() {
}
#[test]
fn malformed_preparation_fails_the_job_without_stopping_the_host() {
fn malformed_geometry_is_rejected_without_stopping_the_host() {
let host = CpuHost::cooperative();
let scene = ProductScene {
bounds: Aabb3::EMPTY,
@@ -299,13 +299,9 @@ fn malformed_preparation_fails_the_job_without_stopping_the_host() {
faces: Vec::new(),
}],
};
let broken = host.submit(id(1, 1), 1, scene, request()).unwrap();
drain(&host);
let status = broken.status().unwrap();
assert_eq!(status.progress.phase, Phase::Failed);
assert_eq!(status.error.unwrap().code, ErrorCode::InvalidScene);
assert!(status.frame.is_none(), "a failed preparation path has no cached PNG identity");
assert!(broken.frame().unwrap().is_none());
let error = host.submit(id(1, 1), 1, scene, request()).err().unwrap();
assert_eq!(error.code, ErrorCode::InvalidScene);
assert_eq!(host.admitted_bytes(), 0);
assert!(host.is_available());
let healthy = host.submit(id(1, 2), 1, empty(), request()).unwrap();
drain(&host);
@@ -538,6 +534,7 @@ impl ImageExecutor for DropProbe {
Ok(Admission {
triangles: 0,
face_appearances: 0,
projected_pixel_tests: None,
geometry_bytes: 0,
pixel_scratch_bytes: self.estimated,
preparation_bytes: 0,
@@ -780,6 +777,7 @@ fn registered_large_pool_is_explicit_finite_and_accounted() {
Ok(Admission {
triangles: 0,
face_appearances: 0,
projected_pixel_tests: None,
geometry_bytes: 0,
pixel_scratch_bytes: 0,
preparation_bytes: 0,
+153 -101
View File
@@ -23,6 +23,9 @@ const SS: usize = 2;
pub struct Admission {
pub triangles: u64,
pub face_appearances: u64,
/// Supersampled raster work only; absent for ray tracing and registered executors.
#[serde(skip_serializing_if = "Option::is_none")]
pub projected_pixel_tests: Option<u64>,
pub geometry_bytes: u64,
pub pixel_scratch_bytes: u64,
pub preparation_bytes: u64,
@@ -79,7 +82,9 @@ pub fn admit(scene: &ProductScene, request: &ImageRequest) -> Result<Admission>
}
pub(crate) fn admit_base(scene: &ProductScene, request: &ImageRequest) -> Result<Admission> {
admit_raster(admit_source(scene, request, MAX_TRIANGLES)?)
let mut admission = admit_raster(admit_source(scene, request, MAX_TRIANGLES)?)?;
admission.projected_pixel_tests = Some(projected_pixel_tests(scene, request)?);
Ok(admission)
}
/// Common camera, dimension and source admission for registered executors, without any CPU
@@ -231,6 +236,7 @@ fn admit_source_with_limits(
admission: Admission {
triangles,
face_appearances: faces,
projected_pixel_tests: None,
geometry_bytes: geometry,
pixel_scratch_bytes: 0,
preparation_bytes: 0,
@@ -266,6 +272,137 @@ fn admit_raster(source: Source) -> Result<Admission> {
Ok(a)
}
/// The same clipping, supersampling and bounds govern admission and raster work.
struct Projection {
vp: Mat4,
back: Vec3,
target: Vec3,
sw: usize,
sh: usize,
}
impl Projection {
fn new(scene: &ProductScene, request: &ImageRequest) -> Result<Self> {
let (sw, sh) = (request.width as usize * SS, request.height as usize * SS);
let radius = scene.bounds.diagonal().max(10.0) + scene.bounds.center().dist(request.camera.target);
if !radius.is_finite() {
return Err(invalid("invalid product bounds"));
}
Ok(Self { vp: request.camera.view_proj(sw as f64 / sh as f64, radius), back: request.camera.back(), target: request.camera.target, sw, sh })
}
fn project(&self, body: &crate::ProductMesh, triangle: usize, look: Option<&FaceAppearance>) -> Result<Option<Triangle>> {
let mesh = &body.mesh;
let indices = mesh.triangles.get(triangle).ok_or_else(|| invalid("missing product triangle"))?;
let vertices = indices.map(|i| mesh.positions.get(i as usize).copied());
let normals = indices.map(|i| mesh.normals.get(i as usize).copied());
let ([Some(a), Some(b), Some(c)], [Some(na), Some(nb), Some(nc)]) = (vertices, normals) else {
return Err(invalid("invalid triangle index or missing normal"));
};
let world = [a, b, c];
let normals = [na, nb, nc];
if world.iter().chain(normals.iter()).any(|v| !v.is_finite()) {
return Err(invalid("non-finite product triangle"));
}
// Transmission is physical optical data, never a request to change CPU preview.
// Keep the existing scalar sorted-alpha shading for glass previews.
let (colour, opacity, surface) = look.map_or((body.color, body.opacity, body.surface.filter(|_| body.transmission.is_none())), |look| {
(look.color, look.opacity, look.surface.filter(|_| look.transmission.is_none()))
});
if surface.is_some() && opacity != 1.0 {
return Err(invalid("opaque PBR requires opacity 1"));
}
let normals = if surface.is_some() {
[crate::surface::unit_direction(na), crate::surface::unit_direction(nb), crate::surface::unit_direction(nc)]
.map(|n| n.map_err(|e| invalid(&e.to_string())))
} else {
[Ok(normals[0]), Ok(normals[1]), Ok(normals[2])]
};
let [na, nb, nc] = normals;
let normals = [na?, nb?, nc?];
if opacity <= 0.0 {
return Ok(None);
}
let clip = world.map(|p| self.vp.apply(p));
if clip.iter().flatten().any(|v| !v.is_finite()) {
return Err(invalid("non-finite product projection"));
}
if clip.iter().any(|p| p[3] <= 1e-9) {
return Ok(None);
}
let inv_w = clip.map(|p| 1.0 / p[3]);
let points = clip.map(|p| [(p[0] / p[3] + 1.0) * 0.5 * self.sw as f64, (1.0 - p[1] / p[3]) * 0.5 * self.sh as f64, p[2] / p[3]]);
if points.iter().flatten().any(|v| !v.is_finite()) || points.iter().any(|p| !(p[2] as f32).is_finite()) {
return Err(invalid("product projection exceeds pixel precision"));
}
let [a, b, c] = points;
let (x0, x1) = (a[0].min(b[0]).min(c[0]).floor().max(0.0), a[0].max(b[0]).max(c[0]).ceil().min(self.sw as f64 - 1.0));
let (y0, y1) = (a[1].min(b[1]).min(c[1]).floor().max(0.0), a[1].max(b[1]).max(c[1]).ceil().min(self.sh as f64 - 1.0));
let area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
if !(x0 <= x1 && y0 <= y1) || area.abs() < 1e-12 {
return Ok(None);
}
if !area.is_finite() {
return Err(invalid("invalid projected area"));
}
let centre_depth = (world[0] / 3.0 + world[1] / 3.0 + world[2] / 3.0 - self.target).dot(self.back);
if !centre_depth.is_finite() {
return Err(invalid("invalid triangle depth"));
}
Ok(Some(Triangle {
points,
world,
normals,
inv_w,
colour: colour.map(decode),
opacity,
surface,
centre_depth,
area,
bounds: [x0 as usize, x1 as usize, y0 as usize, y1 as usize],
x: x0 as usize,
y: y0 as usize,
}))
}
}
fn add_pixel_tests(sum: &mut u64, triangle: &Triangle) -> Result<()> {
let [x0, x1, y0, y1] = triangle.bounds;
let tests = ((x1 - x0 + 1) as u64).checked_mul((y1 - y0 + 1) as u64).ok_or_else(|| limit("pixel work overflow"))?;
*sum = sum.checked_add(tests).ok_or_else(|| limit("pixel work overflow"))?;
if *sum > MAX_PIXEL_TESTS {
return Err(limit("projected product exceeds 128000000 pixel tests; reduce image dimensions or visible geometry"));
}
Ok(())
}
fn validate_face(face: &FaceAppearance) -> Result<()> {
if !face.opacity.is_finite() || !(0.0..=1.0).contains(&face.opacity) {
return Err(invalid("invalid face opacity"));
}
Ok(())
}
fn projected_pixel_tests(scene: &ProductScene, request: &ImageRequest) -> Result<u64> {
let projection = Projection::new(scene, request)?;
let mut tests = 0;
for body in &scene.meshes {
let mut looks = BTreeMap::new();
for face in &body.faces {
validate_face(face)?;
// Match the document and task policy: the last assignment wins.
looks.insert(face.face, face);
}
for index in 0..body.mesh.triangles.len() {
let look = body.mesh.tri_face.get(index).and_then(|face| looks.get(face)).copied();
if let Some(triangle) = projection.project(body, index, look)? {
add_pixel_tests(&mut tests, &triangle)?;
}
}
}
Ok(tests)
}
fn reserve<T>(count: usize) -> Result<Vec<T>> {
let mut v = Vec::new();
v.try_reserve_exact(count).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "could not reserve render work memory"))?;
@@ -326,10 +463,7 @@ pub struct ImageTask {
admission: Admission,
cancel: CancelToken,
stage: Stage,
vp: Mat4,
back: Vec3,
sw: usize,
sh: usize,
projection: Projection,
looks: Vec<BTreeMap<u32, FaceAppearance>>,
body: usize,
item: usize,
@@ -361,13 +495,8 @@ impl ImageTask {
return Err(JobError::new(ErrorCode::InvalidRequest, "raster task cannot execute a raytrace request"));
}
let admission = admit(&scene, &request)?;
let (sw, sh) = (request.width as usize * SS, request.height as usize * SS);
let radius = scene.bounds.diagonal().max(10.0) + scene.bounds.center().dist(request.camera.target);
if !radius.is_finite() {
return Err(invalid("invalid product bounds"));
}
let vp = request.camera.view_proj(sw as f64 / sh as f64, radius);
let back = request.camera.back();
let projection = Projection::new(&scene, &request)?;
let (sw, sh) = (projection.sw, projection.sh);
let looks = reserve(scene.meshes.len())?;
let (opaque, glass, scratch) =
(reserve(admission.triangles as usize)?, reserve(admission.triangles as usize)?, reserve(admission.triangles as usize)?);
@@ -378,10 +507,7 @@ impl ImageTask {
admission,
cancel,
stage: Stage::Looks,
vp,
back,
sw,
sh,
projection,
looks,
body: 0,
item: 0,
@@ -417,7 +543,9 @@ impl ImageTask {
Stage::Clear | Stage::Opaque | Stage::Glass | Stage::Downsample => Progress {
phase: Phase::Running,
done: self.pixel_done,
total: self.pixel_tests + (self.sw * self.sh) as u64 + u64::from(self.request.width) * u64::from(self.request.height),
total: self.pixel_tests
+ (self.projection.sw * self.projection.sh) as u64
+ u64::from(self.request.width) * u64::from(self.request.height),
},
Stage::Encode => Progress {
phase: Phase::Encoding,
@@ -466,9 +594,7 @@ impl ImageTask {
self.looks.push(BTreeMap::new());
}
if let Some(face) = body.faces.get(self.item) {
if !face.opacity.is_finite() || !(0.0..=1.0).contains(&face.opacity) {
return Err(invalid("invalid face opacity"));
}
validate_face(face)?;
let looks = self.looks.get_mut(self.body).ok_or_else(|| invalid("missing body appearances"))?;
// Match the document policy: the last assignment of a face wins.
looks.insert(face.face, face.clone());
@@ -483,78 +609,9 @@ impl ImageTask {
fn project(&self, draw: Draw) -> Result<Option<Triangle>> {
let body = self.scene.meshes.get(draw.body as usize).ok_or_else(|| invalid("missing product body"))?;
let mesh = &body.mesh;
let indices = mesh.triangles.get(draw.triangle as usize).ok_or_else(|| invalid("missing product triangle"))?;
let vertices = indices.map(|i| mesh.positions.get(i as usize).copied());
let normals = indices.map(|i| mesh.normals.get(i as usize).copied());
let ([Some(a), Some(b), Some(c)], [Some(na), Some(nb), Some(nc)]) = (vertices, normals) else {
return Err(invalid("invalid triangle index or missing normal"));
};
let world = [a, b, c];
let normals = [na, nb, nc];
if world.iter().chain(normals.iter()).any(|v| !v.is_finite()) {
return Err(invalid("non-finite product triangle"));
}
let look = self.looks.get(draw.body as usize).and_then(|looks| mesh.tri_face.get(draw.triangle as usize).and_then(|face| looks.get(face)));
// Transmission is physical optical data, never a request to change CPU preview.
// Keep the existing scalar sorted-alpha shading for glass previews.
let (colour, opacity, surface) = look.map_or((body.color, body.opacity, body.surface.filter(|_| body.transmission.is_none())), |look| {
(look.color, look.opacity, look.surface.filter(|_| look.transmission.is_none()))
});
if surface.is_some() && opacity != 1.0 {
return Err(invalid("opaque PBR requires opacity 1"));
}
let normals = if surface.is_some() {
[crate::surface::unit_direction(na), crate::surface::unit_direction(nb), crate::surface::unit_direction(nc)]
.map(|n| n.map_err(|e| invalid(&e.to_string())))
} else {
[Ok(normals[0]), Ok(normals[1]), Ok(normals[2])]
};
let [na, nb, nc] = normals;
let normals = [na?, nb?, nc?];
if opacity <= 0.0 {
return Ok(None);
}
let clip = world.map(|p| self.vp.apply(p));
if clip.iter().flatten().any(|v| !v.is_finite()) {
return Err(invalid("non-finite product projection"));
}
if clip.iter().any(|p| p[3] <= 1e-9) {
return Ok(None);
}
let inv_w = clip.map(|p| 1.0 / p[3]);
let points = clip.map(|p| [(p[0] / p[3] + 1.0) * 0.5 * self.sw as f64, (1.0 - p[1] / p[3]) * 0.5 * self.sh as f64, p[2] / p[3]]);
if points.iter().flatten().any(|v| !v.is_finite()) || points.iter().any(|p| !(p[2] as f32).is_finite()) {
return Err(invalid("product projection exceeds pixel precision"));
}
let [a, b, c] = points;
let (x0, x1) = (a[0].min(b[0]).min(c[0]).floor().max(0.0), a[0].max(b[0]).max(c[0]).ceil().min(self.sw as f64 - 1.0));
let (y0, y1) = (a[1].min(b[1]).min(c[1]).floor().max(0.0), a[1].max(b[1]).max(c[1]).ceil().min(self.sh as f64 - 1.0));
let area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
if !(x0 <= x1 && y0 <= y1) || area.abs() < 1e-12 {
return Ok(None);
}
if !area.is_finite() {
return Err(invalid("invalid projected area"));
}
let centre_depth = (world[0] / 3.0 + world[1] / 3.0 + world[2] / 3.0 - self.request.camera.target).dot(self.back);
if !centre_depth.is_finite() {
return Err(invalid("invalid triangle depth"));
}
Ok(Some(Triangle {
points,
world,
normals,
inv_w,
colour: colour.map(decode),
opacity,
surface,
centre_depth,
area,
bounds: [x0 as usize, x1 as usize, y0 as usize, y1 as usize],
x: x0 as usize,
y: y0 as usize,
}))
let look =
self.looks.get(draw.body as usize).and_then(|looks| body.mesh.tri_face.get(draw.triangle as usize).and_then(|face| looks.get(face)));
self.projection.project(body, draw.triangle as usize, look)
}
fn prepare_step(&mut self) -> Result<()> {
@@ -571,12 +628,7 @@ impl ImageTask {
}
let mut draw = Draw { body: self.body as u32, triangle: self.item as u32, depth: 0.0 };
if let Some(triangle) = self.project(draw)? {
let [x0, x1, y0, y1] = triangle.bounds;
let tests = ((x1 - x0 + 1) as u64).checked_mul((y1 - y0 + 1) as u64).ok_or_else(|| limit("pixel work overflow"))?;
self.pixel_tests = self.pixel_tests.checked_add(tests).ok_or_else(|| limit("pixel work overflow"))?;
if self.pixel_tests > MAX_PIXEL_TESTS {
return Err(limit("projected product exceeds 128000000 pixel tests"));
}
add_pixel_tests(&mut self.pixel_tests, &triangle)?;
draw.depth = triangle.centre_depth;
if triangle.opacity >= 1.0 {
self.opaque.push(draw);
@@ -637,7 +689,7 @@ impl ImageTask {
}
fn clear_step(&mut self) {
if self.pixels.len() >= self.sw * self.sh {
if self.pixels.len() >= self.projection.sw * self.projection.sh {
self.stage = Stage::Opaque;
self.draw = 0;
return;
@@ -664,7 +716,7 @@ impl ImageTask {
}
let triangle = self.triangle.as_mut().ok_or_else(|| invalid("missing raster triangle"))?;
let eye = (self.request.camera.fov > 0.0).then(|| self.request.camera.eye());
raster_pixel(triangle, self.sw, self.back, eye, self.request.studio_weights, &mut self.pixels, &mut self.depth)?;
raster_pixel(triangle, self.projection.sw, self.projection.back, eye, self.request.studio_weights, &mut self.pixels, &mut self.depth)?;
triangle.x += 1;
if triangle.x > triangle.bounds[1] {
triangle.x = triangle.bounds[0];
@@ -699,7 +751,7 @@ impl ImageTask {
let (mut colour, mut alpha) = ([0.0; 3], 0.0);
for dy in 0..SS {
for dx in 0..SS {
let p = self.pixels.get((y * SS + dy) * self.sw + x * SS + dx).ok_or_else(|| invalid("missing downsample pixel"))?;
let p = self.pixels.get((y * SS + dy) * self.projection.sw + x * SS + dx).ok_or_else(|| invalid("missing downsample pixel"))?;
let a = f64::from(p[3]) / 255.0;
for (sum, channel) in colour.iter_mut().zip(p.iter().take(3)) {
*sum += decode(*channel) * a;
+62 -6
View File
@@ -167,12 +167,7 @@ fn invalid_sizes_indices_opacity_and_large_work_lists_fail_without_a_frame() {
} else {
body.faces.push(FaceAppearance { face: 0, color: [0; 3], opacity: f64::NAN, surface: None, transmission: None });
}
let mut task = ImageTask::new(scene(vec![body]), request(), CancelToken::default()).unwrap();
let error = loop {
if let Err(error) = task.step(64) {
break error;
}
};
let error = ImageTask::new(scene(vec![body]), request(), CancelToken::default()).err().unwrap();
assert_eq!(error.code, ErrorCode::InvalidScene);
}
let mut body = body;
@@ -202,3 +197,64 @@ fn registered_executor_geometry_admission_excludes_the_cpu_raster_estimate() {
// The CPU host itself keeps rejecting the source.
assert_eq!(admit(&source, &r).unwrap_err().code, ErrorCode::LimitExceeded);
}
#[test]
fn admission_rejects_projected_pixel_work_before_starting_a_job() {
let mut body = square("Overlapping glass", [100, 120, 140], 0.55, Vec3::ZERO);
let mesh = Arc::make_mut(&mut body.mesh);
mesh.triangles = vec![[0, 1, 2]; 1024];
mesh.tri_face = vec![0; 1024];
let scene = scene(vec![body]);
let request = ImageRequest { width: 512, height: 512, ..request() };
let error = admit(&scene, &request).expect_err("preflight must reject the same pixel-work limit as the rasterizer");
assert_eq!(error.code, ErrorCode::LimitExceeded);
assert!(error.message.contains("pixel tests"));
let request = ImageRequest { width: 64, height: 64, ..request };
let admission = admit(&scene, &request).unwrap();
assert!(admission.projected_pixel_tests.unwrap() > 0);
assert!(admission.projected_pixel_tests.unwrap() <= MAX_PIXEL_TESTS);
let png = finish(ImageTask::new(scene, request, CancelToken::default()).unwrap());
assert_eq!(image::load_from_memory(&png).unwrap().width(), 64);
}
#[test]
fn pixel_work_admission_includes_the_limit_and_uses_clipped_sample_bounds() {
let mut body = square("Full viewport", [100; 3], 1.0, Vec3::ZERO);
let mesh = Arc::make_mut(&mut body.mesh);
// Each triangle's bounds cover all 200 * 200 supersampled pixels.
mesh.triangles = vec![[0, 1, 2]; 3200];
mesh.tri_face = vec![0; 3200];
let mut request = ImageRequest { width: 100, height: 100, ..request() };
request.camera.distance = 1.0;
request.camera.fov = 0.0;
let mut scene = scene(vec![body]);
assert_eq!(admit(&scene, &request).unwrap().projected_pixel_tests, Some(128_000_000));
let mut task = ImageTask::new(scene.clone(), request.clone(), CancelToken::default()).unwrap();
while task.stage != Stage::Clear {
task.step(1).unwrap();
}
assert_eq!(task.pixel_tests, 128_000_000);
Arc::make_mut(&mut scene.meshes[0].mesh).triangles.push([0, 1, 2]);
assert_eq!(admit(&scene, &request).unwrap_err().code, ErrorCode::LimitExceeded);
// Registered executors budget their own work, independently of the raster projection.
let geometry = admit_geometry(&scene, &request, MAX_TRIANGLES).unwrap();
assert_eq!(geometry.projected_pixel_tests, None);
assert!(serde_json::to_value(geometry).unwrap().get("projected_pixel_tests").is_none());
}
#[test]
fn pixel_work_ignores_invisible_triangles_and_respects_the_last_face_override() {
let mut body = square("Hidden", [100; 3], 0.0, Vec3::ZERO);
let request = request();
assert_eq!(admit(&scene(vec![body.clone()]), &request).unwrap().projected_pixel_tests, Some(0));
body.faces.push(FaceAppearance { face: 0, color: [100; 3], opacity: 0.55, surface: None, transmission: None });
assert!(admit(&scene(vec![body.clone()]), &request).unwrap().projected_pixel_tests.unwrap() > 0);
body.faces.push(FaceAppearance { face: 0, color: [100; 3], opacity: 0.0, surface: None, transmission: None });
assert_eq!(admit(&scene(vec![body]), &request).unwrap().projected_pixel_tests, Some(0));
let offscreen = square("Offscreen", [100; 3], 1.0, Vec3::new(10_000.0, 0.0, 0.0));
assert_eq!(admit(&scene(vec![offscreen]), &request).unwrap().projected_pixel_tests, Some(0));
let mut degenerate = square("Degenerate", [100; 3], 1.0, Vec3::ZERO);
Arc::make_mut(&mut degenerate.mesh).triangles = vec![[0, 0, 0]];
assert_eq!(admit(&scene(vec![degenerate]), &request).unwrap().projected_pixel_tests, Some(0));
}
+20
View File
@@ -1063,6 +1063,26 @@ impl Sketch {
}
return Ok(false);
}
// A face seen edge-on was once saved as two lines on the same two points, one each way;
// it is one line now. Keep the first line (an offset or a constraint may name it) and
// drop only the second, instead of rebuilding both under new ids.
if let Some([LCurve::Line(0, 1), LCurve::Line(1, 0)]) = cur.as_deref()
&& let [LCurve::Line(0, 1)] = new.as_slice()
&& let [_, second] = curves[..]
&& let [p0, p1] = points[..]
&& lay.lone.is_empty()
&& let Some(a) = order.first().and_then(|li| lay.pts.get(*li)).copied()
&& let Some(b) = order.get(1).and_then(|li| lay.pts.get(*li)).copied()
{
if let Some(p) = self.points.get_mut(p0) {
p.pos = a;
}
if let Some(p) = self.points.get_mut(p1) {
p.pos = b;
}
self.remove_curves(&[second]);
return Ok(true);
}
// Rebuild: drop the old entities (constraints on them go too) and make new ones.
self.drop_link_entities(id);
self.build_link(id, &lay)?;
+49
View File
@@ -374,3 +374,52 @@ fn linked_conic_curves_take_the_n_prefix_not_the_constraints_k() {
let ids: Vec<String> = sk.link_curves(&link).iter().map(|c| sk.curves[*c].id.clone()).collect();
assert_eq!(ids, vec!["l1".to_string(), "n1".to_string()], "linked curve ids");
}
/// A face seen edge-on was saved as two lines on the same two points, one each way, and is one
/// line now. Re-resolving it must not give the first line a new id: an offset names it as its
/// source, and lost it ("an offset source vanished or changed shape").
#[test]
fn edge_on_face_saved_as_a_line_pair_keeps_the_line_an_offset_follows() {
let (a, b) = (Vec2::new(5.0, 5.0), Vec2::new(15.0, 5.0));
let mut sk = Sketch::new();
// `add_link` makes one line of a reversed pair, so lay the pair out the way a saved file has it.
let link = "j1".to_string();
let (pa, pb) = (sk.add_point(a, None).unwrap(), sk.add_point(b, None).unwrap());
for (from, to, id) in [(pa, pb, "l1"), (pb, pa, "l2")] {
let c = sk.add_line_pts(from, to, Some(id)).unwrap();
sk.curves[c].link = Some(link.clone());
}
for point in [pa, pb] {
sk.points[point].link = Some(link.clone());
}
sk.links.push(Link {
id: link.clone(),
kind: LinkKind::Project,
source: face(),
face_name: None,
edge_name: None,
auto_face: false,
lost: false,
});
let ids: Vec<String> = sk.link_curves(&link).iter().map(|c| sk.curves[*c].id.clone()).collect();
assert_eq!(ids.len(), 2, "the saved link holds the pair");
let (p, q) = (sk.add_point(Vec2::new(5.0, 3.0), None).unwrap(), sk.add_point(Vec2::new(15.0, 3.0), None).unwrap());
let out = sk.add_line_pts(p, q, None).unwrap();
let out_id = sk.curves[out].id.clone();
sk.offsets.push(crate::offset::OffsetChain {
sources: vec![(ids[0].clone(), true)],
outputs: vec![out_id],
distance: -2.0,
closed: false,
topology: None,
});
sk.refresh_offsets().unwrap();
sk.update_link(&link, &[LinkGeom::Line(a, Vec2::new(17.0, 5.0))]).unwrap();
let kept: Vec<String> = sk.link_curves(&link).iter().map(|c| sk.curves[*c].id.clone()).collect();
assert_eq!(kept, vec![ids[0].clone()], "the first line keeps its id");
sk.refresh_offsets().unwrap();
let end = sk.points[sk.link_points(&link)[1]].pos;
assert_eq!(end, Vec2::new(17.0, 5.0), "the line moved to the new geometry");
}
+148 -18
View File
@@ -250,13 +250,16 @@ pub fn show(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
}
}
/// A hovered centre or straight-edge midpoint. Transient, per viewport, never a constraint.
/// The two most recent hovered centres or straight-edge midpoints. Transient, per viewport,
/// never a constraint. A third hover drops the oldest. Endpoints are ordinary point snaps.
#[derive(Default)]
pub(crate) struct Tracking {
sketch: Option<u64>,
document: std::sync::Weak<solidcraft_engine::doc::Document>,
anchor: Option<String>,
previous: Option<String>,
pub(crate) guide: Option<(Vec2, Vec2)>,
pub(crate) remembered_guide: Option<(Vec2, Vec2)>,
}
impl Tracking {
@@ -271,26 +274,91 @@ impl Tracking {
}
}
fn point_of(sk: &Sketch, id: &str) -> Option<Vec2> {
Self::reference(sk, sk.curve_index(id)?)
}
/// Keep `id` as one of the two remembered references. Hovering the older one swaps them,
/// so going back does not drop it. A third, different reference drops the oldest.
fn remember(&mut self, id: String) {
if self.anchor.as_deref() == Some(id.as_str()) {
return;
}
if self.previous.as_deref() == Some(id.as_str()) {
std::mem::swap(&mut self.anchor, &mut self.previous);
return;
}
self.previous = self.anchor.replace(id);
}
/// Where the horizontal through one reference meets the vertical through the other, when
/// that point is within a few pixels of the cursor and is not either reference itself.
pub(crate) fn meeting(&self, sk: &Sketch, plane: &solidcraft_engine::geom::Plane, proj: &Proj, cursor: egui::Pos2) -> Option<Vec2> {
let a = self.anchor.as_deref().and_then(|id| Self::point_of(sk, id))?;
let b = self.previous.as_deref().and_then(|id| Self::point_of(sk, id))?;
if a.dist(b) < 1e-6 {
return None;
}
let to = |p: Vec2| proj.to_screen(plane.to_world(p));
let mut best: Option<(f32, Vec2)> = None;
for at in [Vec2::new(a.x, b.y), Vec2::new(b.x, a.y)] {
if at.dist(a) < 1e-6 || at.dist(b) < 1e-6 {
continue;
}
let Some(screen) = to(at).filter(|p| proj.rect.contains(*p)) else { continue };
let distance = cursor.distance(screen);
if distance < 8.0 && best.is_none_or(|(d, _)| distance < d) {
best = Some((distance, at));
}
}
best.map(|(_, at)| at)
}
fn axis_guide(anchor: Vec2, at: Vec2, cursor: egui::Pos2, to: &impl Fn(Vec2) -> Option<egui::Pos2>) -> Option<(Vec2, Vec2)> {
let origin = to(anchor)?;
let mut distance = 6.0;
let mut end = None;
for candidate in [Vec2::new(at.x, anchor.y), Vec2::new(anchor.x, at.y)] {
let Some(screen) = to(candidate) else { continue };
let off_axis = cursor.distance(screen);
if origin.distance(screen) > 8.0 && off_axis < distance {
distance = off_axis;
end = Some(candidate);
}
}
end.map(|end| (anchor, end))
}
fn update(&mut self, sk: &Sketch, plane: &solidcraft_engine::geom::Plane, proj: &Proj, cursor: egui::Pos2) {
self.guide = None;
self.remembered_guide = None;
let to = |p: Vec2| proj.to_screen(plane.to_world(p));
// Screen-space tolerances stay the same at any zoom; only visible references wake.
let mut nearest = 8.0;
let mut nearest_id = None;
for (index, curve) in sk.curves.iter().enumerate() {
let Some(p) = Self::reference(sk, index).and_then(to).filter(|p| proj.rect.contains(*p)) else { continue };
let distance = cursor.distance(p);
if distance < nearest {
nearest = distance;
self.anchor = Some(curve.id.clone());
nearest_id = Some(curve.id.clone());
}
}
let Some((anchor, origin)) = self.anchor.as_deref().and_then(|id| {
let anchor = Self::reference(sk, sk.curve_index(id)?)?;
Some((anchor, to(anchor)?))
if let Some(id) = nearest_id {
self.remember(id);
}
let Some(anchor) = self.anchor.as_deref().and_then(|id| Self::point_of(sk, id)).or_else(|| {
self.anchor = self.previous.take();
self.anchor.as_deref().and_then(|id| Self::point_of(sk, id))
}) else {
self.anchor = None;
self.previous = None;
return;
};
if self.previous.as_deref().and_then(|id| Self::point_of(sk, id)).is_none() {
self.previous = None;
}
let Some(origin) = to(anchor) else { return };
if !proj.rect.contains(origin) {
return;
}
@@ -300,21 +368,27 @@ impl Tracking {
if !at.x.is_finite() || !at.y.is_finite() {
return;
}
// Near the crossing, both guides end on that point. Otherwise each reference keeps
// its own axis, and a single guide stays visual (it does not move the cursor).
if let Some(meet) = self.meeting(sk, plane, proj, cursor) {
let span = |from: Vec2| to(from).zip(to(meet)).filter(|(a, b)| a.distance(*b) > 8.0).map(|_| (from, meet));
self.guide = span(anchor);
if let Some(previous) = self.previous.as_deref().and_then(|id| Self::point_of(sk, id)) {
self.remembered_guide = span(previous);
}
return;
}
// Finite guides connect the reference to the cursor's projection onto its axis.
// No model-space length cap: the viewport clips them, as it does other sketch guides.
let mut distance = 6.0;
for end in [Vec2::new(at.x, anchor.y), Vec2::new(anchor.x, at.y)] {
let Some(screen) = to(end) else { continue };
let off_axis = cursor.distance(screen);
if origin.distance(screen) > 8.0 && off_axis < distance {
distance = off_axis;
self.guide = Some((anchor, end));
}
self.guide = Self::axis_guide(anchor, at, cursor, &to);
if let Some(previous) = self.previous.as_deref().and_then(|id| Self::point_of(sk, id)) {
self.remembered_guide = Self::axis_guide(previous, at, cursor, &to);
}
}
}
/// Visual H/V tracking works before a tool's first click and while simply editing a sketch.
/// The crossing of the two remembered references is also a snap. A single guide stays visual.
/// Existing snaps, picks and constraints retain their behaviour.
pub(crate) fn track_and_show(app: &mut SolveApp, painter: &egui::Painter, proj: &Proj, cursor: Option<egui::Pos2>) {
crate::scenario::publish_count("sketch_tracking_guides", 0.0);
@@ -334,13 +408,20 @@ pub(crate) fn track_and_show(app: &mut SolveApp, painter: &egui::Painter, proj:
return;
};
tracking.update(&ss.sketch, &ss.plane, proj, cursor);
crate::scenario::publish_count("sketch_tracking_guides", usize::from(tracking.guide.is_some()) as f64);
if let Some((a, b)) = tracking.guide
&& let (Some(a), Some(b)) = (proj.to_screen(ss.plane.to_world(a)), proj.to_screen(ss.plane.to_world(b)))
{
let tk = Tokens::get();
let shown = tracking.guide.is_some() || tracking.remembered_guide.is_some();
crate::scenario::publish_count("sketch_tracking_guides", usize::from(shown) as f64);
let tk = Tokens::get();
let draw = |guide: (Vec2, Vec2)| {
let (Some(a), Some(b)) = (proj.to_screen(ss.plane.to_world(guide.0)), proj.to_screen(ss.plane.to_world(guide.1))) else { return None };
painter.extend(crate::viewport::dashed_line(&[a, b], painter.clip_rect(), Stroke::new(1.0, tk.rubber_band), 5.0, 4.0));
painter.circle_stroke(a, 4.0, Stroke::new(1.0, tk.rubber_band));
Some(b)
};
let ends = [tracking.guide.and_then(draw), tracking.remembered_guide.and_then(draw)];
if let (Some(a), Some(b)) = (ends[0], ends[1])
&& a.distance(b) < 1.0
{
painter.circle_stroke(a, 4.0, Stroke::new(1.0, tk.rubber_band));
}
}
@@ -393,6 +474,55 @@ mod tests {
}
}
#[test]
fn two_recent_references_meet_and_a_third_drops_the_oldest() {
use solidcraft_engine::geom::Plane;
let mut app = SolveApp::new(Default::default(), Default::default());
app.cam.yaw = 0.0;
app.cam.pitch = std::f64::consts::FRAC_PI_2;
app.cam.distance = 180.0;
let mut sk = Sketch::new();
sk.add_line(Vec2::new(-30.0, 20.0), Vec2::new(30.0, 20.0), None, None, Some("top")).unwrap();
sk.add_line(Vec2::new(30.0, -20.0), Vec2::new(30.0, 20.0), None, None, Some("right")).unwrap();
sk.add_circle(Vec2::new(-10.0, 3.0), 5.0, None, Some("c")).unwrap();
sk.add_line(Vec2::new(50.0, -10.0), Vec2::new(50.0, 10.0), None, None, Some("far")).unwrap();
let proj = crate::viewport::projection(&app, egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)));
let to = |p: Vec2| proj.to_screen(Plane::XY.to_world(p)).unwrap();
let mut tracking = Tracking::default();
let hover = |tracking: &mut Tracking, p: Vec2| tracking.update(&sk, &Plane::XY, &proj, to(p));
hover(&mut tracking, Vec2::new(0.0, 20.0));
hover(&mut tracking, Vec2::new(30.0, 0.0));
let centre = Vec2::ZERO;
let near = to(centre) + egui::vec2(4.0, 3.0);
assert!(tracking.meeting(&sk, &Plane::XY, &proj, near).unwrap().dist(centre) < 1e-6);
tracking.update(&sk, &Plane::XY, &proj, near);
let (a, b) = tracking.guide.unwrap();
let (c, d) = tracking.remembered_guide.unwrap();
assert!(b.dist(centre) < 1e-6 && d.dist(centre) < 1e-6, "both guides end on the crossing");
assert!(a.dist(Vec2::new(30.0, 0.0)) < 1e-6 && c.dist(Vec2::new(0.0, 20.0)) < 1e-6);
assert!(tracking.meeting(&sk, &Plane::XY, &proj, to(centre) + egui::vec2(20.0, 0.0)).is_none());
// Back to the first reference: both stay, in swapped order.
hover(&mut tracking, Vec2::new(0.0, 20.0));
assert_eq!(tracking.anchor.as_deref(), Some("top"));
assert_eq!(tracking.previous.as_deref(), Some("right"));
// A circle centre and a midpoint meet the same way.
hover(&mut tracking, Vec2::new(-10.0, 3.0));
let cross = Vec2::new(0.0, 3.0);
assert!(tracking.meeting(&sk, &Plane::XY, &proj, to(cross)).unwrap().dist(cross) < 1e-6);
// A third reference drops the oldest. The far edge shares a horizontal with the right edge,
// so their axes do not invent the rectangle centre.
hover(&mut tracking, Vec2::new(30.0, 0.0));
hover(&mut tracking, Vec2::new(50.0, 0.0));
assert_eq!(tracking.anchor.as_deref(), Some("far"));
assert_eq!(tracking.previous.as_deref(), Some("right"));
assert!(tracking.meeting(&sk, &Plane::XY, &proj, to(centre)).is_none());
sk.curves.iter_mut().find(|c| c.id == "far").unwrap().id = "gone".into();
tracking.update(&sk, &Plane::XY, &proj, to(Vec2::new(40.0, 40.0)));
assert_eq!(tracking.anchor.as_deref(), Some("right"));
assert!(tracking.previous.is_none());
assert!(tracking.guide.is_none(), "a removed reference leaves no guide");
}
#[test]
fn parallel_and_perpendicular_from_the_previous_point() {
let s = sk();
+70
View File
@@ -1279,6 +1279,9 @@ preference_settings! {
suppress_sketch_snap: ModifierCombo = ModifierCombo::CTRL_ALT, PAGE_SKETCH;
/// Temporary horizontal/vertical guides from hovered centres and edge midpoints.
sketch_tracking_guides: bool = true, PAGE_SKETCH;
/// Snap a drawing point to where the two most recent centre or midpoint guides cross.
/// Off: the guides stay visual and the cursor does not move (#8694).
sketch_recent_snap: bool = false, PAGE_SKETCH;
/// Drawing lines a point up with the sketch's points on both axes at once, keeping the
/// horizontal or vertical snap to the previous point, and the click adds the alignments
/// (#7828). Off by default: only with the previous point, one axis at a time, as before.
@@ -1371,6 +1374,8 @@ pub struct PrefsWindow {
snapshot: Option<Snapshot>,
/// The up axis preference the view follows now (applied when it changes, and at startup).
applied_up: Option<String>,
/// The appearance last asked of the native window ([`sync_window_theme`]).
window_theme: Option<egui::SystemTheme>,
/// The perspective angle the view was last drawn with ([`follow_view_fov`]); 0 before the first.
drawn_fov: f64,
/// The search text of the interface font list.
@@ -1859,6 +1864,26 @@ fn defaults(app: &mut SolveApp, page: usize) {
/// Settings that follow the preferences every frame: the theme when it follows the system, and
/// the interface scale.
/// Tell the native window which appearance to take (#8983): the operating system's while the
/// theme follows it, otherwise the theme's own, so the title bar matches. egui's own window sync
/// (`Options::sync_window_theme`) went by the egui theme, which [`crate::theme::apply`] always
/// sets to light or dark. A macOS window told to be light stays light and no longer reports the
/// system appearance changing, so a theme following the system never turned with it.
fn sync_window_theme(app: &mut SolveApp, ctx: &egui::Context, follows_system: bool) {
let window = if follows_system {
egui::SystemTheme::SystemDefault
} else if app.ui.dark {
egui::SystemTheme::Dark
} else {
egui::SystemTheme::Light
};
if app.prefs_window.window_theme != Some(window) {
ctx.options_mut(|o| o.sync_window_theme = false);
ctx.send_viewport_cmd(egui::ViewportCommand::SetTheme(window));
app.prefs_window.window_theme = Some(window);
}
}
pub fn apply(app: &mut SolveApp, ctx: &egui::Context) {
sync_surface_analysis(app);
apply_network(&app.preferences);
@@ -1884,10 +1909,12 @@ pub fn apply(app: &mut SolveApp, ctx: &egui::Context) {
if let Some(f) = file {
app.ui.dark = f.base.is_dark();
}
let follows_system = app.preferences.theme == "system" && file.is_none();
let custom = file.map(|f| crate::theme::Custom { dark: f.base.is_dark(), tokens: crate::theme_files::resolve(f) });
if crate::theme::set_custom(custom) {
app.styled = false;
}
sync_window_theme(app, ctx, follows_system);
if crate::theme::set_logo_accent(app.preferences.logo_accent) {
app.styled = false;
}
@@ -4947,6 +4974,7 @@ mod tests {
("sketch_state_icons", json!(false), PAGE_SKETCH),
("sketch_tracking_guides", json!(false), PAGE_SKETCH),
("sketch_align_points", json!(true), PAGE_SKETCH),
("sketch_recent_snap", json!(true), PAGE_SKETCH),
("msaa", json!(8), PAGE_GRAPHICS),
("single_surface_analysis", json!(false), PAGE_GRAPHICS),
];
@@ -5141,6 +5169,48 @@ mod tests {
assert!(!app.ui.dark);
}
/// The System theme leaves the native window to follow the operating system (#8983). A macOS
/// window told to be light or dark keeps that appearance and no longer reports the system
/// appearance changing, so the app stayed light after the system turned dark. Dark and Light
/// still give the window their own appearance, for its title bar.
#[test]
fn the_system_theme_leaves_the_window_to_follow_the_system() {
let mut h = crate::scenario::Harness::new();
// A frame with the system appearance `system`: the window themes the frame asked for.
let frame = |h: &mut crate::scenario::Harness, system: egui::Theme| {
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))),
system_theme: Some(system),
..Default::default()
};
let app = &mut h.app;
let out = h.ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
let commands = out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default();
commands.into_iter().filter_map(|c| if let egui::ViewportCommand::SetTheme(t) = c { Some(t) } else { None }).collect::<Vec<_>>()
};
let frames = |h: &mut crate::scenario::Harness, system: egui::Theme| (0..3).flat_map(|_| frame(h, system)).collect::<Vec<_>>();
let out = h.call("ui.prefs", json!({"theme": "system"}));
assert_eq!(out["result"]["theme"], "system", "{out}");
frames(&mut h, egui::Theme::Light);
assert_eq!(h.app.prefs_window.window_theme, Some(egui::SystemTheme::SystemDefault));
assert!(!h.ctx.options(|o| o.sync_window_theme), "egui would tell the window light or dark");
assert!(!h.app.ui.dark);
// The system turns dark: the app follows, and nothing is asked of the window.
assert_eq!(frames(&mut h, egui::Theme::Dark), vec![]);
assert!(h.app.ui.dark);
assert_eq!(frames(&mut h, egui::Theme::Light), vec![]);
assert!(!h.app.ui.dark);
// An explicit theme gives the window its appearance, whatever the system's.
let out = h.call("ui.prefs", json!({"theme": "light"}));
assert_eq!(out["result"]["theme"], "light", "{out}");
assert_eq!(frames(&mut h, egui::Theme::Dark), vec![]);
assert_eq!(h.app.prefs_window.window_theme, Some(egui::SystemTheme::Light));
assert!(!h.app.ui.dark);
}
/// The 3D view in the other theme's colours (#4146): `ui.prefs` sets it, the window keeps its
/// theme and the next frame draws the view from the other side, and it is saved with the
/// preferences. With a theme file on, the view takes the built-in Light or Dark colours.
+2
View File
@@ -7,6 +7,8 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
ui_kit::settings_section(ui, "sketch", app.tr("prefs.sketching"), |ui| {
ui_kit::checkbox(ui, app.tr("prefs.sketch_tracking_guides"), &mut app.preferences.sketch_tracking_guides)
.on_hover_text(app.tr("prefs.sketch_tracking_guides_note"));
ui_kit::checkbox(ui, app.tr("prefs.sketch_recent_snap"), &mut app.preferences.sketch_recent_snap)
.on_hover_text(app.tr("prefs.sketch_recent_snap_note"));
ui_kit::checkbox(ui, app.tr("prefs.sketch_align_points"), &mut app.preferences.sketch_align_points)
.on_hover_text(app.tr("prefs.sketch_align_points_note"));
let mut auto = app.session.auto_project;
+28 -1
View File
@@ -1013,6 +1013,33 @@ fn status_bar_spans_the_window_below_history_in_both_layouts_and_is_covered() {
}
}
#[test]
fn viewport_sketch_status_distinguishes_empty_and_constrained_geometry() {
for mode in [LayoutMode::Docked, LayoutMode::Floating] {
let mut h = Harness::new();
h.call("ui.set", json!({"layout":mode}));
let corner = |h: &Harness| painted_texts(h).into_iter().filter(|t| t.starts_with("Sketch1 — ")).collect::<Vec<_>>();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.settle();
assert_eq!(corner(&h), ["Sketch1 — No geometry yet"], "{mode:?}: an empty sketch is not fully constrained");
assert!(h.widget("No geometry yet").is_some(), "the palette agrees with the viewport");
let rectangle = h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]})).unwrap();
h.settle();
assert_eq!(corner(&h), ["Sketch1 — 2 degrees of freedom"], "the rectangle's width and height are free");
h.app.run("sketch.constraint.fix", json!({"entities":rectangle["curves"]})).unwrap();
h.settle();
assert_eq!(corner(&h), ["Sketch1 — fully constrained"], "drawn geometry can be fully constrained");
h.app.run("edit.undo", json!({})).unwrap();
h.settle();
assert_eq!(corner(&h), ["Sketch1 — 2 degrees of freedom"]);
h.app.run("edit.undo", json!({})).unwrap();
h.settle();
assert_eq!(corner(&h), ["Sketch1 — No geometry yet"], "undoing the geometry restores the empty status");
}
}
#[test]
fn status_bar_names_what_is_edited_and_the_sketch_state_and_replaces_the_views_corner_text() {
use crate::status_bar::SketchState;
@@ -1020,7 +1047,7 @@ fn status_bar_names_what_is_edited_and_the_sketch_state_and_replaces_the_views_c
let corner = |h: &Harness| painted_texts(h).into_iter().filter(|t| t.starts_with("Sketch1 — ")).collect::<Vec<_>>();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.settle();
assert_eq!(corner(&h), ["Sketch1 — fully constrained"], "without the bar the state stays in the view's corner");
assert_eq!(corner(&h), ["Sketch1 — No geometry yet"], "without the bar the state stays in the view's corner");
h.call("ui.prefs", json!({"status_bar": true}));
h.settle();
let contents = crate::status_bar::contents(&h.app);
@@ -307,6 +307,55 @@ fn centre_and_midpoint_tracking_is_visual_in_idle_and_drawing_modes() {
assert_eq!(h.app.session.undo.len(), undo);
}
#[test]
fn two_recent_midpoints_snap_where_their_guides_meet() {
use solidcraft_engine::geom::Vec2;
let mut h = tracking_fixture();
// Grid snap at this zoom already rounds a nearby cursor onto the origin. Snap off leaves
// the cursor where it is, so only the remembered crossing can pull it to (0, 0).
h.app.run("sketch.options", json!({"snap": false})).unwrap();
h.app.start("sketch.line");
// Top midpoint (0, 20) and the circle centre (-10, 3) meet at (0, 3), which is not the
// model origin and not a sketch point. A cursor a fraction of a millimetre away stays put
// until both references have been hovered.
let cross = Vec2::new(0.0, 3.0);
let near = json!({"sketch":[0.4,3.3]});
step(&mut h, json!({"move":{"sketch":[0,20]}}));
let [x, y] = h.at(&near).unwrap();
let pos = egui::pos2(x as f32, y as f32);
let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.expect("viewport"));
let (at, _) = crate::viewport::sketch_point_at(&h.app, &proj, pos).unwrap();
assert!(at.dist(cross) > 0.2, "one reference does not pin both coordinates: {at:?}");
step(&mut h, json!({"move":{"sketch":[-10,3]}}));
step(&mut h, json!({"move":{"sketch":[0.4,3.3]}}));
let (a, b) = h.app.viewport.tracking.guide.expect("current guide");
let (c, d) = h.app.viewport.tracking.remembered_guide.expect("remembered guide");
assert!(b.dist(cross) < 1e-6 && d.dist(cross) < 1e-6, "{a:?} {b:?} {c:?} {d:?}");
let [x, y] = h.at(&near).unwrap();
let pos = egui::pos2(x as f32, y as f32);
let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.expect("viewport"));
let (at, _) = crate::viewport::sketch_point_at(&h.app, &proj, pos).unwrap();
assert!(at.dist(cross) > 0.2, "the crossing snap is off until asked for: {at:?}");
h.call("ui.prefs", json!({"sketch_recent_snap": true}));
let (at, id) = crate::viewport::sketch_point_at(&h.app, &proj, pos).unwrap();
assert!(id.is_none(), "{id:?}");
assert!(at.dist(cross) < 1e-6, "{at:?}");
crate::tool_modifiers::set_target_midpoint(true);
let shifted = crate::viewport::sketch_point_at(&h.app, &proj, pos);
crate::tool_modifiers::set_target_midpoint(false);
let (at, _) = shifted.unwrap();
assert!(at.dist(cross) > 0.2, "shift midpoint mode skips the crossing: {at:?}");
step(&mut h, json!({"click":{"sketch":[0.4,3.3]}}));
let placed = h.app.tool.as_ref().unwrap().pts[0].0;
assert!(placed.dist(cross) < 1e-6, "{placed:?}");
let curves = h.app.session.world_state().sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch.curves.len();
assert_eq!(curves, 5, "the snap adds no construction geometry");
h.call("ui.prefs", json!({"sketch_tracking_guides":false}));
h.frames(2);
let (at, _) = crate::viewport::sketch_point_at(&h.app, &proj, pos).unwrap();
assert!(at.dist(cross) > 0.2, "the preference keeps the old cursor: {at:?}");
}
#[test]
fn tracking_clears_on_escape_sketch_exit_and_preference_and_respects_suppression() {
let mut h = tracking_fixture();
+10
View File
@@ -511,6 +511,16 @@ pub fn refine_snap(
}
}
}
// The two most recent midpoints or centres: snap where their axes meet. A free coordinate,
// so placing it adds no constraint. Off unless Sketch preferences ask for it. One reference
// stays a visual guide. Shift skips this.
if !mid_only
&& app.preferences.sketch_recent_snap
&& best.is_none()
&& let Some(at) = app.viewport.tracking.meeting(sk, plane, proj, a)
{
best = Some((at.dist(lp), "+", at));
}
// The tool's points and kind (a click takes the tool out of the app while it runs, so the
// last hover's are kept: a rectangle's click takes no Line tool inference its hover didn't).
let (own, line_tool) = match app.tool.as_ref() {
+35 -1
View File
@@ -101,6 +101,19 @@ pub(crate) fn navigation_fill(t: &Tokens, fill: NavigationFill) -> Option<egui::
}
}
/// The label and icon colour of a slot. An active slot keeps the accent ink on its selected fill
/// where that reads; a theme whose selected fill is as dark as its accent (SolidCraft 95's navy)
/// gets the theme's selected-text ink instead, so an active toggle never turns into a blank block.
pub(crate) fn navigation_ink(t: &Tokens, fill: NavigationFill) -> egui::Color32 {
if fill != NavigationFill::Active {
return t.form_ink;
}
if crate::theme::contrast_ratio(t.form_primary, t.selected_bg) >= NAV_ACTIVE_MIN_CONTRAST { t.form_primary } else { t.selected_ink }
}
/// WCAG small-text contrast for an active slot's label on its selected fill.
pub(crate) const NAV_ACTIVE_MIN_CONTRAST: f64 = 4.5;
pub(crate) fn navigation_opacity(idle: Idle, active: bool) -> f32 {
match idle {
_ if active => 1.0,
@@ -135,7 +148,7 @@ pub(crate) fn navigation_button_editable(ui: &mut Ui, rect: Rect, button: Naviga
egui::StrokeKind::Inside,
);
}
let ink = if fill == NavigationFill::Active { t.form_primary } else { t.form_ink };
let ink = navigation_ink(&t, fill);
let text = button.labels.then(|| ui.painter().layout_no_wrap(button.label.to_owned(), crate::fonts::medium(NAV_TEXT), ink));
let mut x = if button.menu {
rect.left() + NAV_MENU_LEFT + if editing { NAV_BORDER } else { 0.0 }
@@ -213,3 +226,24 @@ pub(crate) fn navigation_surface(ui: &Ui, rect: Rect, size: Size, editing: bool)
.corner_radius(NavigationMetrics::for_size(size).radius);
super::Container::paint_frame(ui.painter(), frame, rect.shrink(NAV_BORDER));
}
#[cfg(test)]
mod active_ink_tests {
use super::{NavigationFill, navigation_fill, navigation_ink};
#[test]
fn active_navigation_slots_stay_readable_in_every_built_in_theme() {
let mut themes = vec![("Dark", crate::theme::Tokens::dark()), ("Light", crate::theme::Tokens::light())];
themes.extend(crate::theme_files::builtin().iter().map(|theme| (theme.name.as_str(), crate::theme_files::resolve(theme))));
let mut failures = Vec::new();
for (name, tokens) in themes {
let Some(fill) = navigation_fill(&tokens, NavigationFill::Active) else { continue };
let ink = navigation_ink(&tokens, NavigationFill::Active);
let ratio = crate::theme::contrast_ratio(ink, fill);
if ratio < 3.0 {
failures.push(format!("{name}: {ratio:.2}:1 ({ink:?} on {fill:?})"));
}
}
assert!(failures.is_empty(), "active navigation slot unreadable:\n{}", failures.join("\n"));
}
}
+2
View File
@@ -5896,6 +5896,8 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
if let Some(r) = app.viewport.layout.as_ref().map(|l| l.content).filter(|_| !crate::status_bar::shown(app)) {
let status = if !ss.report.ok() {
crate::i18n::tr("sketch.status_conflict").to_string()
} else if !crate::sketch_palette::has_geometry(sk) {
crate::i18n::tr("ui.no_geometry_yet").to_string()
} else if ss.report.dof == 0 {
crate::i18n::tr("sketch.status_fully_constrained").to_string()
} else if ss.report.dof == 1 {
+12 -4
View File
@@ -32,10 +32,18 @@ constraint status and **Finish Sketch** button remain visible.
Hover over a circle or arc centre, or the midpoint of a straight sketch edge, then move
horizontally or vertically to see a dotted alignment guide. The guide connects that reference
to the cursor's projection on the sketch axis, within the viewport; it disappears when you
move away from the axis. This works while editing and before a drawing tool's first click.
It adds no geometry or constraints. Esc, leaving the viewport, or ending the sketch clears
the reference. Preferences › Sketch › **Center and midpoint alignment guides** turns it off;
the configured snap-suppression modifier also hides it.
move away from the axis. SolidCraft keeps the two most recent of these references and can
draw both guides. One guide on its own does not move the cursor, and endpoints are ordinary
point snaps rather than remembered references. Preferences › Sketch › **Auto snap to recent
centers and midpoints** is off until you turn it on. With it on, a drawing tool snaps the
cursor where the horizontal through one reference meets the vertical through the other. On a
rectangle, the top midpoint and a side midpoint then meet at the rectangle's centre, so that
point can be placed without a construction line. Holding Shift for midpoint-only snapping
skips the crossing. This works while editing and before a drawing tool's first click.
Placing the point adds no geometry or constraint of its own. Esc, leaving the viewport, or
ending the sketch clears the references. **Center and midpoint alignment guides** turns the
guides off, which also leaves the cursor unmoved; the configured snap-suppression modifier
does the same for that gesture.
## Geometry
+5 -2
View File
@@ -421,6 +421,7 @@ One setting per line, sorted by name. A change that adds a setting adds its own
- `sketch_auto_locate` (default false; with `sketch_auto_dimensions`, new shapes are also placed from the sketch origin until fully constrained, as `sketch.draw.dimensioned` with `locate`)
- `sketch_conflict_colour` (default true: while the open sketch's constraints conflict, the curves and points a failing constraint holds are drawn in the theme's `sketch_conflict` colour too, not only its glyph or dimension; false: only the glyphs and dimensions are red, in `error`, as before, #7244)
- `sketch_exit_view` (`keep` (default: the view stays where it is when a sketch ends), `previous` (back to the view from before the sketch), `unless_rotated` (back unless the sketch view was turned))
- `sketch_recent_snap` (default false: while drawing, the cursor snaps where the horizontal guide of one recent circle or arc centre or straight-edge midpoint meets the vertical guide of another; a free coordinate, with no constraint and no construction geometry. Off: those guides stay visual and the cursor does not move. The references come from `sketch_tracking_guides`, #8694)
- `sketch_state_icons` (default true: a sketch's icon in History and the browser takes the colour of its solve state, `sketch` under constrained, `sketch_fixed` fully constrained, `error` while its constraints conflict, and its tooltip names the state; false: every sketch row's icon and tooltip as before, #7245)
- `sketch_tab_always` (default false: the SKETCH tab stays in the ribbon right after MODEL while no sketch is open; a tool in its CREATE or REFERENCE section then opens Create Sketch, on the selected plane or planar face or else on the plane picked next, and starts in the new sketch, #7904)
- `smooth_wheel_zoom` (default true)
@@ -464,8 +465,10 @@ the Design camera. See [the integrated Render workspace](render-workspace.md).
`ui.prefs {sketch_tracking_guides: true}` enables temporary horizontal and vertical dotted
alignment guides from hovered circle/arc centres and straight-edge midpoints (default true).
The guide spans the reference and cursor projection, including positions inside a circle.
It is visual only: coordinates, snapping and constraints are unchanged. Set the preference
to `false` to hide the guides.
The two most recent references are kept, and both guides can be drawn. A single guide does
not move the cursor. Set the preference to `false` to hide the guides. Snap suppression
(Alt, unless rebound) does the same for that gesture. Snapping the cursor to where those
guides cross is separate, and off unless `sketch_recent_snap` is true.
### Continuous camera motion
+9 -5
View File
@@ -68,10 +68,14 @@ temporary file by the app and read back by the server, so both must run on the s
Commands with a typed parameter contract (every new command, and more of the older ones as they
are converted; `describe_command` shows a command's schemas) can be checked as they are called.
`--param-check off|warn|strict` sets the level for a headless session, including a script or
recipe given with `--in`; the default is `off`, which changes nothing. `warn` runs the call and
adds `param_warnings` to its result, such as ``unknown key `operaton`; did you mean
`operation`?`` (a starting script's warnings go to stderr). `strict` refuses the call, and the
design is left as it was. Calls bridged to the app are not checked yet.
recipe given with `--in`, and for calls bridged to the app; the default is `off`, which changes
nothing. `warn` runs the call and adds `param_warnings` to its result, such as ``unknown key
`operaton`; did you mean `operation`?`` (a starting script's warnings go to stderr). `strict`
refuses the call, and the design is left as it was. Bridged, the server checks each call (and
each step of a `batch`) against its own commands' contracts before sending it: `strict` sends
nothing that breaks one, and a command the server has no contract for goes to the app unchecked.
Use the server from the same release as the app: an app newer than the server may take keys the
server's contracts don't know yet, and `strict` would refuse them.
### Which files the agent can reach
@@ -277,7 +281,7 @@ The `skill_reference` tool returns these documents from the copies built into So
| `list_commands` | `filter?` | The command catalog: id, label, tab/panel, shortcut, parameter docs, enabled now. `filter` matches id, label, tab and panel. |
| `describe_command` | `id` | One command's parameter docs (`params`) 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. The same as `execute` `engine.describe {command}`. |
| `execute` | `command`, `params?`, `verbose?` (default true) | Run any command with JSON parameters (`list_commands` shows them). Failures leave the design unchanged. A feature command replies with the feature (`feature`, `name`), every body (`bodies`) and the bodies it created, changed and removed (`created_bodies`, `changed_bodies`, `removed_bodies`). `verbose: false` uses the same compact reply as `batch`. |
| `batch` | `commands: [{command, params}]`, `rollback?` (default true), `verbose?` (default true), `timings?`, `profile?`, `coalesce_parameters?` (default false) | Run steps in order, stop at the first error and undo the completed steps. The reply names the failing step (`failed.index`, `failed.command`, `failed.error`), every result before it, `rolled_back` (true only when the design really went back to where the batch started), `undone`, `applied` (the indices of the steps whose changes are in the design) and `not_run` (the steps never sent). A step may name a tool instead of a command, with that tool's arguments as `params`: `set_parameter` (runs `parameters.change`; `value` or `expression`), `list_commands` (`engine.commands`, filtered by `filter` as the tool filters it), `describe_command` (`engine.describe`), `execute` (`{command, params}`), `measure`, `inspect_design` (`document.inspect`: each sketch as its counts, not its full contents, and no joints), `undo`, `redo`, `export`, `save`, `open`, `new_design` and `crash_reports`. `screenshot`, `body_topology` (use `model.edges` and `model.faces`), `batch`, `pending`, `running` and `skill_reference` can't be steps, nor can `component.edit_in_place` and `component.finish_edit`, however a step names them, as they go to another design's tab (send them with the `execute` tool on their own): such a batch fails before anything is sent. So does a step the batch would certainly refuse at its turn (#5853): a step without a command, an unknown command id (the error names the closest ids) or, with `--param-check strict`, parameters that break a command's contract. A headless session checks this itself; an app checks it against its own commands when it gets the batch. `verbose: false` keeps the reply small in large designs: feature steps leave out the full body list (`created_bodies`, `changed_bodies`, `removed_bodies` stay) and sketch steps the ids they repeat outside `created`. Bridged to an app that has `engine.batch`, the whole batch is one control request (one frame of the app). Older apps, and apps showing the agent cursor, get the steps one by one. `timings: true` adds `timings: [{index, command, ms}]` for every step run, the failing one included, and `total_ms`. `profile: true` also adds each step's `profile: {boolean_ms, tessellate_ms, other_ms}`, its time in kernel booleans, in tessellation and the rest. Steps are timed where they run: in the app when bridged. An app without `engine.batch` is timed step by step, without `profile`. `coalesce_parameters: true` sets each run of consecutive plain parameter edits (`parameters.change` or `set_parameter` with a name and a value, or `changes`) together, so the design rebuilds once per run instead of once per edit. Steps between runs see the design up to date. Each step keeps its own result (`coalesced`: the run's length), and the run is one undo step. The run skips the states between its edits, so an edit refused on its own can go through with the run's later ones. A refused run goes step by step, naming the refused step. |
| `batch` | `commands: [{command, params}]`, `rollback?` (default true), `verbose?` (default true), `timings?`, `profile?`, `coalesce_parameters?` (default false) | Run steps in order, stop at the first error and undo the completed steps. The reply names the failing step (`failed.index`, `failed.command`, `failed.error`), every result before it, `rolled_back` (true only when the design really went back to where the batch started), `undone`, `applied` (the indices of the steps whose changes are in the design) and `not_run` (the steps never sent). A step may name a tool instead of a command, with that tool's arguments as `params`: `set_parameter` (runs `parameters.change`; `value` or `expression`), `list_commands` (`engine.commands`, filtered by `filter` as the tool filters it), `describe_command` (`engine.describe`), `execute` (`{command, params}`), `measure`, `inspect_design` (`document.inspect`: each sketch as its counts, not its full contents, and no joints), `undo`, `redo`, `export`, `save`, `open`, `new_design` and `crash_reports`. `screenshot`, `body_topology` (use `model.edges` and `model.faces`), `batch`, `pending`, `running` and `skill_reference` can't be steps, nor can `component.edit_in_place` and `component.finish_edit`, however a step names them, as they go to another design's tab (send them with the `execute` tool on their own): such a batch fails before anything is sent. So does a step the batch would certainly refuse at its turn (#5853): a step without a command, an unknown command id (the error names the closest ids) or, with `--param-check strict`, parameters that break a command's contract. A headless session checks this itself; an app checks it against its own commands when it gets the batch, and a bridged server at `--param-check strict` checks the contracts before it sends the batch. `verbose: false` keeps the reply small in large designs: feature steps leave out the full body list (`created_bodies`, `changed_bodies`, `removed_bodies` stay) and sketch steps the ids they repeat outside `created`. Bridged to an app that has `engine.batch`, the whole batch is one control request (one frame of the app). Older apps, and apps showing the agent cursor, get the steps one by one. `timings: true` adds `timings: [{index, command, ms}]` for every step run, the failing one included, and `total_ms`. `profile: true` also adds each step's `profile: {boolean_ms, tessellate_ms, other_ms}`, its time in kernel booleans, in tessellation and the rest. Steps are timed where they run: in the app when bridged. An app without `engine.batch` is timed step by step, without `profile`. `coalesce_parameters: true` sets each run of consecutive plain parameter edits (`parameters.change` or `set_parameter` with a name and a value, or `changes`) together, so the design rebuilds once per run instead of once per edit. Steps between runs see the design up to date. Each step keeps its own result (`coalesced`: the run's length), and the run is one undo step. The run skips the states between its edits, so an edit refused on its own can go through with the run's later ones. A refused run goes step by step, naming the refused step. |
| `inspect_design` | `measure?` (default true), `sketches?` (default true), `joints?` (default true) | Design properties (`metadata`: project, author… and other tools' keys; set them with `execute` `document.metadata.set`), parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible; `removed_later: true` while a sketch is being edited on a rolled-back timeline and a later feature deletes the body, which is then not shown), sketches (curves, points, constraints, profiles, DOF, solve status, visible), joints when the design has any (as `joint.inspect` gives them: each origin's occurrence, component and path, the snap as saved, the feature, face, edge or sketch curve (`sketch: {id, name, curve}`) its persistent names refer to, `resolves` and the `error` when it is lost; kind, offset, angle, flip, values, limits, expressions, motion links; `execute` `joint.inspect {joint}` gives one joint), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`), and joint glyphs with `{joints: [id or name], visible}` or `{folder: "joints", visible}` (a hidden joint still constrains and drives; `joint.list` reports each joint's `visible`). |
| `measure` | `body?` or `items?` | Area (mm²), bbox, topology counts and `closed` per body. Volume (mm³), volume centre of mass, density and mass are reported only for closed, consistently oriented bodies (null or omitted for open surfaces). Volume/mass totals skip open bodies; `total.open_bodies_skipped` gives their count. Area and topology totals include all bodies. With `items` (one or two selections as `execute` takes them: `face`, `edge`, `vertex`, `sketch_curve`, `profile`), the picked geometry instead: faces and edges need only `type`, `body` and `point`; `index` is optional. See below. |
| `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, name, area, centroid, normal, `point`, and for a cylindrical face its `cylinder`: `origin`, `axis`, `radius`). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. A face's `point` lies on it and picks it (its centroid may not: that of a face with a hole is in the hole); a face's `name` picks it for the commands that take faces (`face.delete`, `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face`, `solid.replace_face`, and `face` of `sheet.convert`). |
+8 -3
View File
@@ -71,12 +71,17 @@ additionally yields after four rows. The UI pumps 1024 units per frame. These ar
bounds, not hard real-time guarantees: allocation, codec compression and OS scheduling
still have platform-dependent latency.
Admission caps output at 4096 per dimension and 4 million pixels; pixel scratch is
CPU raster admission caps output at 4096 per dimension and 4 million pixels; pixel scratch is
estimated at 48 bytes per output pixel (2x supersampling). Other limits: 262144
triangles/face overrides/edge polylines, 128 MiB retained source geometry, 384 MiB total
estimated memory per job, 512 MiB shared host admission including retained PNGs, and
128 million projected pixel tests. The projected-work limit is checked during bounded
preparation and may fail an admitted job. Encoded PNG bytes have an additional
128 million projected pixel tests. Preflight and Start check projected work using the
same clipped, supersampled triangle bounds as the rasterizer, including face opacity
overrides. Raster admission reports `projected_pixel_tests`; CPU raytracing and registered
GPU executors omit this field and use their own work and memory budgets. Over-budget
raster requests are rejected before a job is queued, with advice to reduce image
dimensions or visible geometry.
The worker retains its bounded preparation check. Encoded PNG bytes have an additional
`8 * output_pixels + 65536` cap. Allocator and codec internals are estimates, not an
operating-system memory quota. Limits fail with errors rather than allocating unbounded
buffers. Completed frames keep their memory reservation while bytes are retained.