Compare commits

..

58 Commits

Author SHA1 Message Date
bherbruck 777f960999 Release 0.40.0-rc.2 2026-11-03 15:36:03 -05:00
bherbruck d4f923a3e6 Release 0.40.0-rc.1 2026-11-03 02:20:23 -05:00
bherbruck bbb98928fb Release 0.40.0-rc.1 2026-11-03 01:00:12 -05:00
bherbruck 8e2b0cd60d Release 0.30.1 2026-11-02 11:11:46 -05:00
bherbruck afd81380ab Release 0.30.0 2026-11-01 21:55:18 -05:00
bherbruck 3a5195b0a0 Release 0.29.0 2026-10-31 19:20:30 -04:00
bherbruck ee6b85605a Release 0.28.0 2026-10-30 19:50:14 -04:00
bherbruck aa1fe28ddf Release 0.27.1 2026-10-29 23:34:31 -04:00
bherbruck 5b0eb08473 Release 0.27.0 2026-10-29 19:53:24 -04: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
15 changed files with 46 additions and 566 deletions
+4 -44
View File
@@ -349,20 +349,16 @@ impl Ahead {
/// [`Ahead::evaluate`], setting `step` to the number of the command running (from 1), so a
/// front end can say how far a batch has got.
pub fn evaluate_counting(self, commands: &[(String, Value)], step: &std::sync::atomic::AtomicUsize) -> Result<Evaluated> {
let stop = self.0.stop.clone();
stop.within(|| {
let (after, _) = self.0.preview_in_place(commands, Some(step))?;
after.world_state()?.warm_display();
Ok(Evaluated(after.model))
})
let (after, _) = self.0.preview_in_place(commands, Some(step))?;
after.world_state()?.warm_display();
Ok(Evaluated(after.model))
}
/// Run `commands` one after another, each reply or error in turn (what they evaluate stays
/// in the shared evaluation cache): the checks a front end makes before a command, such as
/// what a delete takes with it (#2678), computed off its window thread.
pub fn replies(mut self, commands: &[(String, Value)]) -> Vec<Result<Value>> {
let stop = self.0.stop.clone();
stop.within(|| commands.iter().map(|(id, params)| self.0.execute(id, params)).collect())
commands.iter().map(|(id, params)| self.0.execute(id, params)).collect()
}
}
@@ -538,10 +534,6 @@ pub struct Session {
/// not a move of the design's, so the placements the design reports and Capture Position
/// keeps leave it out ([`Session::uncaptured_moves`]).
pub exploded: std::collections::BTreeMap<u64, solidcraft_doc::Mat>,
/// Stops the work computing off the window thread for this design, shared with its copies.
stop: solidcraft_kernel::Stop,
/// A copy of a design ([`Session::scratch`]), sharing its token.
is_copy: bool,
world_cache: std::sync::Mutex<Option<WorldCache>>,
/// The design the joints were last solved for, as the solve left it, and the model it read
/// (see [`Session::solve_joints`]).
@@ -802,8 +794,6 @@ impl Session {
/// A session on `doc` evaluated as `model`, with what it hides.
pub(crate) fn with_model(doc: Arc<Document>, visibility: Visibility, model: Model) -> Self {
Session {
stop: Default::default(),
is_copy: false,
saved: doc.clone(),
doc,
model,
@@ -884,11 +874,6 @@ impl Session {
/// design before the batch, which is what the batch promised (#3242). The design is what a
/// checkpoint keeps, not the replaced session's file path (docs/mcp.md).
pub fn replace_design(&mut self, fresh: Session) {
// Nothing still computing for the design that leaves has anywhere to go. A copy leaves
// the design it came from going, and its token with it.
if !self.is_copy {
self.stop_computing();
}
let auto_project = self.auto_project;
let validation = self.validation();
let single_surface_analysis = self.single_surface_analysis;
@@ -920,19 +905,6 @@ impl Session {
self.replacements = replacements;
}
/// The token over the work running off the window thread for this design, on any of its
/// copies: a job started from a copy runs [`solidcraft_kernel::Stop::within`] it.
pub fn stop_token(&self) -> solidcraft_kernel::Stop {
self.stop.clone()
}
/// This design is going away: the work still computing for it stops at the kernel's next
/// check, and its results go nowhere (they are only ever for this design's evaluation cache).
/// A kernel call that doesn't check runs to its end, on a kernel of its own, holding up nothing.
pub fn stop_computing(&self) {
self.stop.stop();
}
/// Install (or with `None` remove) the check [`Session::execute`] runs before every command,
/// for an app that embeds the engine and decides which commands may run. A refusal comes back
/// as [`EngineError::Refused`] and leaves the session as it was.
@@ -1899,8 +1871,6 @@ impl Session {
/// commands on without touching this one.
pub fn scratch(&self) -> Session {
Session {
stop: self.stop.clone(),
is_copy: true,
doc: self.doc.clone(),
model: self.model.clone(),
undo: Vec::new(),
@@ -2002,16 +1972,6 @@ impl Session {
commands: &[(String, Value)],
marker: Option<Option<usize>>,
original: Option<Session>,
) -> Result<ComputedCommands> {
let stop = self.stop.clone();
stop.within(|| self.compute_commands_under(commands, marker, original))
}
fn compute_commands_under(
self,
commands: &[(String, Value)],
marker: Option<Option<usize>>,
original: Option<Session>,
) -> Result<ComputedCommands> {
let editor_original = original.as_ref().map(|session| session.doc.clone());
let mut computed = self.scratch();
-1
View File
@@ -410,7 +410,6 @@ mod curved_seam_round;
mod flush_join_loft;
mod gltf_import;
mod import_component;
mod kernel_pool;
mod large_step_import;
mod loft_ring;
mod loft_spacing;
-181
View File
@@ -1,181 +0,0 @@
//! A design's bodies are made and used on whichever kernel of the pool is free
//! (`solidcraft_smlib`'s `core::Pool`). What it builds must not depend on which: to the last
//! number, on a new kernel or one that has done much, while another is busy, with bodies made on
//! one kernel going into operations on another, through the evaluation cache, undo and redo.
use super::*;
/// A hole, a round and a chamfer, each building on the last.
fn features() -> Vec<(&'static str, Value)> {
vec![
("solid.box", json!({"length": 40, "width": 30, "height": 20})),
("solid.cylinder", json!({"base": [12, 15, -1], "axis": [0, 0, 1], "radius": 3.4, "height": 30, "operation": "cut"})),
("solid.fillet", json!({"edges": [[0, 0, 10]], "radius": 2})),
("solid.chamfer", json!({"edges": [[20, 0, 20]], "distance": 1})),
]
}
fn join() -> (&'static str, Value) {
("solid.box", json!({"corner": [25, 10, 20], "length": 10, "width": 10, "height": 5, "operation": "join"}))
}
/// Everything a design built, exactly: each body's name, whole B-rep record and triangle count.
fn built(s: &Session) -> Vec<String> {
let st = s.model.state();
st.bodies
.iter()
.map(|b| {
let record = serde_json::to_string(b.body.record()).unwrap();
format!("{} {} {record}", b.name, b.mesh().triangles.len())
})
.collect()
}
fn design(features: &[(&'static str, Value)]) -> Session {
let mut s = Session::default();
for (id, p) in features {
run(&mut s, id, p.clone());
}
s
}
fn assert_same(what: &str, a: &[String], b: &[String]) {
assert_eq!(a.len(), b.len(), "{what}: how many bodies");
assert!(a == b, "{what}: the design built differently");
}
#[test]
fn a_design_rebuilds_the_same_on_any_kernel() {
let f = features();
let first = built(&design(&f));
assert!(!first.is_empty());
// On a new kernel, the one that built it above busy elsewhere.
let held = solidcraft_kernel::hold_a_kernel();
assert_same("a new kernel", &first, &built(&design(&f)));
// And another new one, with both of those out of use.
let held_too = solidcraft_kernel::hold_a_kernel();
assert_same("another new kernel", &first, &built(&design(&f)));
drop((held, held_too));
// On one that has built plenty since.
for _ in 0..3 {
design(&f);
}
assert_same("a kernel that did much before", &first, &built(&design(&f)));
}
/// A curvature round, whose procedural blends take their serials from the kernel: one body made
/// on each of two kernels, the serials of neither mistaken for the other's in the kernel that
/// solves stations for both.
#[test]
fn a_procedural_blend_builds_the_same_on_any_kernel() {
let f = vec![
("solid.box", json!({"length": 40, "width": 30, "height": 20})),
("solid.fillet", json!({"edges": [[20, 0, 20]], "radius": 3, "type": "curvature"})),
];
let first = design(&f);
let held = solidcraft_kernel::hold_a_kernel();
let second = design(&f);
let b = built(&second);
assert_same("a new kernel", &built(&first), &b);
let v = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
let (mut first, mut second) = (first, second);
let beside = v(&mut second);
drop(held);
assert_eq!(v(&mut first).to_bits(), beside.to_bits(), "one measured right after the other on one kernel");
}
/// A modeled thread, a screw sweep and its boolean, on a short hole.
#[test]
fn a_thread_builds_the_same_on_any_kernel() {
let f = vec![
("solid.box", json!({"length": 20, "width": 20, "height": 4})),
("solid.cylinder", json!({"base": [10, 10, -1], "axis": [0, 0, 1], "radius": 3.4, "height": 10, "operation": "cut"})),
("solid.thread", json!({"face": [13.4, 10, 2], "modeled": true})),
];
let first = built(&design(&f));
let held = solidcraft_kernel::hold_a_kernel();
assert_same("a new kernel", &first, &built(&design(&f)));
drop(held);
}
/// Bodies from the evaluation cache and the undo stack go on into operations on another kernel:
/// the join's boolean takes a body the held kernel made, undo restores it, redo brings back the
/// join's, and each is what the whole design built in one go on a single kernel gives.
#[test]
fn bodies_from_another_kernel_go_on_through_the_cache_undo_and_redo() {
let f = features();
let mut all = f.clone();
all.push(join());
let (before, after) = (built(&design(&f)), built(&design(&all)));
let mut s = design(&f);
let held = solidcraft_kernel::hold_a_kernel();
let (id, p) = join();
run(&mut s, id, p);
assert_same("joined on another kernel", &after, &built(&s));
run(&mut s, "edit.undo", json!({}));
assert_same("undone", &before, &built(&s));
drop(held);
let held = solidcraft_kernel::hold_a_kernel();
run(&mut s, "edit.redo", json!({}));
assert_same("redone", &after, &built(&s));
// Rebuilt without the cache, every feature again, while that kernel stays out of use.
s.model.no_cache = true;
*s.model.cache.lock().unwrap() = Default::default();
run(&mut s, "timeline.roll_to", json!({"position": 0}));
run(&mut s, "timeline.roll_to", json!({}));
assert_same("rebuilt from the start", &after, &built(&s));
drop(held);
// And the body goes on from there, back on the first kernel.
run(&mut s, "solid.box", json!({"corner": [-5, 0, 0], "length": 5, "width": 5, "height": 5, "operation": "join"}));
let mut more = all.clone();
more.push(("solid.box", json!({"corner": [-5, 0, 0], "length": 5, "width": 5, "height": 5, "operation": "join"})));
assert_same("a further join", &built(&design(&more)), &built(&s));
}
/// A design rebuilt while other work keeps kernels of the pool busy on other threads builds what
/// it builds alone, whichever kernels its calls happen to get.
#[test]
fn a_design_rebuilt_beside_other_work_builds_the_same() {
let f = features();
let alone = built(&design(&f));
let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let busy: Vec<_> = (0..3)
.map(|_| {
let (done, f) = (done.clone(), f.clone());
std::thread::spawn(move || {
while !done.load(std::sync::atomic::Ordering::Relaxed) {
design(&f);
}
})
})
.collect();
for _ in 0..3 {
assert_same("beside other work", &alone, &built(&design(&f)));
}
done.store(true, std::sync::atomic::Ordering::Relaxed);
for b in busy {
b.join().unwrap();
}
}
/// A copy that replaces its own design leaves the design it was copied from going: the token over
/// that design's work is stopped only when that design itself closes or is replaced.
#[test]
fn a_copy_replacing_its_design_does_not_stop_the_design_it_came_from() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
let token = s.stop_token();
let new = [("file.new".to_string(), json!({}))];
assert!(s.ahead().replies(&new).iter().all(Result::is_ok));
assert!(!token.is_stopped(), "a copy evaluating ahead");
let _ = s.scratch().compute_commands(&new, None);
assert!(!token.is_stopped(), "a copy computing a dialog's commands");
assert!(s.preview(&new).is_ok());
assert!(!token.is_stopped(), "a preview");
// It still works, and the design's own replacement does stop it.
run(&mut s, "solid.box", json!({"corner": [20, 0, 0], "length": 10, "width": 10, "height": 10}));
run(&mut s, "file.new", json!({}));
assert!(token.is_stopped(), "the design itself replaced");
assert!(!s.stop_token().is_stopped());
}
+6 -12
View File
@@ -454,18 +454,15 @@ const MAX_REFUSAL_PROBES: usize = 20_000;
impl Refusal {
/// The refusal of `record` for `error`, with the face SMLib refuses on its own.
pub(crate) fn of(record: &Brep, error: &solidcraft_smlib::Error) -> Refusal {
fn of(record: &Brep, error: &solidcraft_smlib::Error) -> Refusal {
if let solidcraft_smlib::Error::Internal(why) = error {
return Refusal { face: None, surface: None, why: why.clone(), internal: true };
}
let _span = solidcraft_profile::span("kernel", "smlib_refusal_face");
// It is kept with the body for every later reader: a stopped job's probes must not decide it.
let face = solidcraft_smlib::stop::unstoppable(|| {
let alone = |f: usize| crate::surfaces::Parts::of(record, &[(f, false)]).and_then(|p| p.canonical()).map(|c| c.brep);
(record.faces.len() <= MAX_REFUSAL_PROBES)
.then(|| (0..record.faces.len()).find(|&f| alone(f).is_ok_and(|r| solidcraft_smlib::Brep::from_record(&r, true).is_err())))
.flatten()
});
let alone = |f: usize| crate::surfaces::Parts::of(record, &[(f, false)]).and_then(|p| p.canonical()).map(|c| c.brep);
let face = (record.faces.len() <= MAX_REFUSAL_PROBES)
.then(|| (0..record.faces.len()).find(|&f| alone(f).is_ok_and(|r| solidcraft_smlib::Brep::from_record(&r, true).is_err())))
.flatten();
let surface = face.and_then(|f| record.faces.get(f)).map(|f| SurfaceKind::of(&f.surface));
Refusal { face, surface, why: error.to_string(), internal: false }
}
@@ -548,10 +545,7 @@ impl Body {
pub fn smlib(&self) -> Result<&solidcraft_smlib::Brep> {
self.require_brep("an SMLib operation")?;
self.smlib
.get_or_init(|| {
solidcraft_smlib::stop::unstoppable(|| solidcraft_smlib::Brep::from_record(&self.record, true))
.map_err(|e| Refusal::of(&self.record, &e))
})
.get_or_init(|| solidcraft_smlib::Brep::from_record(&self.record, true).map_err(|e| Refusal::of(&self.record, &e)))
.as_ref()
.map_err(
|r| if r.internal { KernelError::Internal(format!("SMLib: {}", r.why)) } else { KernelError::Failed(format!("SMLib: {}", r.why)) },
-2
View File
@@ -215,8 +215,6 @@ pub use prism_curve::{PlanarPiece, PlanarSpline};
pub use prism_measure::{PrismSection, certified_prism_equivalent, certified_prism_section};
pub use repair::{Hole, RebuiltFace, RefusedFace, RepairOptions, RepairReport, SewnGap, repair};
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew, sew_touching};
pub use solidcraft_smlib::hold_a_kernel;
pub use solidcraft_smlib::stop::Stop;
pub use solidcraft_smlib::trace as smlib_trace;
pub use splitface::{SplitTool, split_body, split_faces, split_faces_with_map};
pub use step::step_export;
+5 -10
View File
@@ -28,10 +28,9 @@ fn pool() -> Option<&'static rayon::ThreadPool> {
/// thread. Waking the pool's workers costs tens of microseconds, so callers ask for it only
/// when the items carry more work than that (decided from the work, not the item count).
pub(crate) fn map<T: Sync, R: Send>(what: &'static str, items: &[T], parallel: bool, f: impl Fn(&T) -> Result<R> + Sync) -> Vec<Result<R>> {
// The workers run under the caller's validation mode (`crate::validation`) and stop token.
// The workers run under the caller's validation mode (`crate::validation`).
let scope = crate::validation::current();
let stop = solidcraft_smlib::stop::current();
let one = |item: &T| solidcraft_smlib::stop::scoped(stop.clone(), || crate::validation::within(scope.clone(), || guard(what, || f(item))));
let one = |item: &T| crate::validation::within(scope.clone(), || guard(what, || f(item)));
// The browser build has no worker pool: everything runs on the calling thread.
#[cfg(target_arch = "wasm32")]
let _ = parallel;
@@ -88,8 +87,7 @@ pub fn map_with_local_pools<T: Sync, R: Send>(items: &[T], max_threads: usize, f
}
let pool = cell.get().ok_or_else(|| crate::KernelError::Failed("missing mesh worker".into()))?;
let scope = crate::validation::current();
let stop = solidcraft_smlib::stop::current();
pool.install(|| solidcraft_smlib::stop::scoped(stop, || crate::validation::within(scope, || guard("mesh worker", || f(item)))))
pool.install(|| crate::validation::within(scope, || guard("mesh worker", || f(item))))
})
};
#[cfg(target_arch = "wasm32")]
@@ -103,12 +101,9 @@ pub fn map_with_local_pools<T: Sync, R: Send>(items: &[T], max_threads: usize, f
/// `f` over `items` as [`for_each_on_threads`] runs it, results in item order (`None` where an
/// item panicked).
pub fn map_on_threads<T: Sync, R: Send>(items: &[T], max_threads: usize, f: impl Fn(&T) -> R + Sync) -> Vec<Option<R>> {
// The threads run under the caller's validation mode (`crate::validation`) and stop token.
// The threads run under the caller's validation mode (`crate::validation`).
let scope = crate::validation::current();
let stop = solidcraft_smlib::stop::current();
let one = |item: &T| {
solidcraft_smlib::stop::scoped(stop.clone(), || crate::validation::within(scope.clone(), || guard("parallel item", || Ok(f(item))).ok()))
};
let one = |item: &T| crate::validation::within(scope.clone(), || guard("parallel item", || Ok(f(item))).ok());
#[cfg(not(target_arch = "wasm32"))]
{
use std::sync::atomic::{AtomicUsize, Ordering};
-14
View File
@@ -2439,17 +2439,3 @@ fn a_box_cut_round_a_small_hole_of_a_long_plate_keeps_the_hole() {
}
}
}
/// A refusal is kept with its body for every later reader: which face it names doesn't depend on
/// whether the job that found it had been stopped, whose probes would all be refused.
#[test]
fn a_stopped_job_does_not_decide_which_face_a_refusal_names() {
let body = crate::build::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let why = solidcraft_smlib::Error::Argument;
let plain = crate::body::Refusal::of(body.record(), &why);
assert_eq!(plain.face, None, "every face of a box is taken on its own");
let stop = crate::Stop::new();
stop.stop();
let stopped = stop.within(|| crate::body::Refusal::of(body.record(), &why));
assert_eq!(stopped.face, plain.face);
}
+23 -105
View File
@@ -11,7 +11,7 @@ use std::sync::{Mutex, PoisonError};
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
use solidcore::geom::vector3d::Vec3;
use solidcore::kernel::{CancelToken, Kernel, KernelOptions, Rng, UpstreamFixes};
use solidcore::kernel::{Kernel, KernelOptions, UpstreamFixes};
use solidcore::mass::integrator::properties::{PropertyAttributes, brep_volume};
use solidcore::primitive::api_primitives as primitives;
@@ -412,8 +412,7 @@ const HELD_BACK: &[&str] = &["fillet_refuse_rounds_breaking_through"];
/// How far apart two kernels' procedural blend serials start. A body keeps the serials of the
/// kernel that made it and may be used on any other, whose memo of solved stations is keyed by
/// serial alone: the ranges keep one definition's stations from answering for another's. A serial
/// only names a definition, so which range one comes from changes no answer.
/// serial alone: the ranges keep one definition's stations from answering for another's.
pub(crate) const BLEND_SERIALS_PER_KERNEL: u64 = 1 << 40;
/// A new kernel. Each takes the next range of blend serials, the first starting at 0.
@@ -425,85 +424,30 @@ fn fresh() -> Kernel {
k
}
/// The kernels calls run on. The lock is only held to take or put back a kernel, never during a
/// call: a call that runs long (or never returns) holds up nothing but its own body, so one
/// design's long operation never delays another's. Calls one after another all get the same
/// kernel; one that starts while another runs gets one of its own (solidcore keeps no global
/// state, and a body's identities are valid only in the kernel that issued them, so any kernel
/// takes any body).
///
/// A call stuck for good keeps its kernel and the thread it runs on, and costs nothing more: at
/// most `most_idle` kernels wait between calls, however many calls once ran side by side. A job
/// that starts again and sticks again holds one more each time, so at `most_running` calls a new
/// call fails, saying so, rather than grow without end or wait for calls that may never return.
pub(crate) struct Pool<K> {
pub(crate) idle: Vec<K>,
pub(crate) running: usize,
pub(crate) most_idle: usize,
pub(crate) most_running: usize,
/// Most kernels kept for later calls; those over it are dropped when their call ends.
const MOST_IDLE: usize = 8;
/// The kernels no call is using, the last used on top. The lock is only held to take or put back
/// a kernel, never during a call: a call that runs long (or never returns) holds up nothing but
/// its own body, so one design's long operation never delays another's. Calls one after another
/// all get the same kernel, as a single kernel would serve them; one that starts while another
/// runs gets one of its own (solidcore keeps no global state, and a body's identities are valid
/// only in the kernel that issued them, so any kernel takes any body).
static IDLE: Mutex<Vec<Kernel>> = Mutex::new(Vec::new());
fn take_kernel() -> Kernel {
IDLE.lock().unwrap_or_else(PoisonError::into_inner).pop().unwrap_or_else(fresh)
}
impl<K> Pool<K> {
/// A kernel for a call, the last used first; `None` when too many are running.
pub(crate) fn take(&mut self, make: impl FnOnce() -> K) -> Option<K> {
if self.running >= self.most_running {
return None;
}
self.running += 1;
Some(self.idle.pop().unwrap_or_else(make))
fn put_kernel(k: Kernel) {
let mut idle = IDLE.lock().unwrap_or_else(PoisonError::into_inner);
if idle.len() < MOST_IDLE {
idle.push(k);
}
/// A call ended, handing back its kernel if it is fit to use again.
pub(crate) fn put(&mut self, kernel: Option<K>) {
self.running = self.running.saturating_sub(1);
if let Some(kernel) = kernel
&& self.idle.len() < self.most_idle
{
self.idle.push(kernel);
}
}
}
/// Most calls at once. Far above what any work needs: an operation runs on at most 16 threads.
const MOST_RUNNING: usize = 256;
static POOL: Mutex<Pool<Kernel>> = Mutex::new(Pool { idle: Vec::new(), running: 0, most_idle: 8, most_running: MOST_RUNNING });
fn take_kernel() -> Option<Kernel> {
POOL.lock().unwrap_or_else(PoisonError::into_inner).take(fresh)
}
fn put_kernel(k: Option<Kernel>) {
POOL.lock().unwrap_or_else(PoisonError::into_inner).put(k);
}
/// What a call must not find from the calls before it: the same operation on the same bodies
/// answers the same on any kernel, however much that kernel did first. (Its draws of random rays,
/// the stations it remembers; marks, identities and what is only kept to reuse memory change no
/// answer. Tolerances and fixes are the same on every kernel.)
fn start_clean(k: &mut Kernel) {
k.rng = Rng::new(KernelOptions::default().seed);
*k.blend_station_memo.borrow_mut() = Default::default();
k.fillet_point_step.set(None);
k.upstream_ub_passed.clear();
}
/// A kernel kept out of use until dropped ([`crate::hold_a_kernel`]).
pub struct HeldKernel(Option<Kernel>);
impl Drop for HeldKernel {
fn drop(&mut self) {
put_kernel(self.0.take());
}
}
pub(crate) fn hold_a_kernel() -> HeldKernel {
HeldKernel(take_kernel())
}
/// Runs `f` on a kernel of its own. A panic in solidcore is caught: the kernel, which it may have
/// left half-updated, is dropped and the call fails with [`Error::Internal`]. A token over this
/// thread's work ([`crate::stop`]) stops it.
/// left half-updated, is replaced by a fresh one and the call fails with [`Error::Internal`].
pub(crate) fn run<T: dump::Answer>(call: &'static str, f: impl FnOnce(&mut Kernel) -> std::result::Result<T, solidcore::SolidError>) -> Result<T> {
run_on(call, &[], f)
}
@@ -526,26 +470,7 @@ pub(crate) fn run_on<T: dump::Answer>(
inputs: dump::Inputs<'_>,
f: impl FnOnce(&mut Kernel) -> std::result::Result<T, solidcore::SolidError>,
) -> Result<T> {
let stop = crate::stop::token();
if stop.as_ref().is_some_and(CancelToken::is_cancelled) {
return Err(Error::Solidcore(format!("{call}: cancelled")));
}
call_on(call, inputs, stop, f)
}
/// [`run_on`], stopped by `stop`.
fn call_on<T: dump::Answer>(
call: &'static str,
inputs: dump::Inputs<'_>,
stop: Option<CancelToken>,
f: impl FnOnce(&mut Kernel) -> std::result::Result<T, solidcore::SolidError>,
) -> Result<T> {
let Some(mut k) = take_kernel() else {
log::warn!(target: "smlib", "{call}: refused, {MOST_RUNNING} kernel calls are still running");
return Err(Error::Solidcore(format!("{call}: {MOST_RUNNING} kernel calls are still running")));
};
start_clean(&mut k);
k.cancel = stop.clone();
let mut k = take_kernel();
// A profiler span per call: where the kernel's time goes.
let _span = solidcraft_profile::span("solidcore", call);
let in_flight = dump::in_flight(call);
@@ -570,11 +495,6 @@ fn call_on<T: dump::Answer>(
}
};
drop(in_flight);
// Stopped, it is no failure to look into, and solidcore may have left the kernel part way.
if stop.is_some_and(|s| s.is_cancelled()) {
put_kernel(None);
return r;
}
let why = match &r {
Ok(v) => v.refusal(),
Err(e) => Some(e.to_string()),
@@ -583,8 +503,7 @@ fn call_on<T: dump::Answer>(
// The kernel the call ran on, or the fresh one that replaced it after a panic.
dump::failed(&k, call, &why, inputs);
}
k.cancel = None;
put_kernel(Some(k));
put_kernel(k);
r
}
@@ -611,8 +530,7 @@ pub(crate) fn with_body<T: dump::Answer>(
if body.0.is_poisoned() {
return Err(Error::Internal(format!("{call}: the body was left unfinished by an earlier panic")));
}
// No token stops it: stopped part way, solidcore may leave the body it borrows half-changed.
call_on(call, &[("brep", body)], None, |k| f(k, &mut body.0.lock().unwrap_or_else(PoisonError::into_inner)))
run_on(call, &[("brep", body)], |k| f(k, &mut body.0.lock().unwrap_or_else(PoisonError::into_inner)))
}
/// An axis-aligned box from its lower corner (`SmApiCreateBox`, as `smlib_make_box` checks it:
-8
View File
@@ -19,7 +19,6 @@ pub mod pcurves;
mod sheets;
mod split;
pub mod ssi;
pub mod stop;
pub mod sweeps;
pub mod trace;
pub mod trim;
@@ -233,13 +232,6 @@ pub fn current_call() -> Option<String> {
core::dump::current()
}
/// Keeps one of the kernels calls run on out of use until dropped: the next calls run on another.
/// For tests of what happens when a design's bodies come from a kernel that is busy elsewhere.
#[doc(hidden)]
pub fn hold_a_kernel() -> core::HeldKernel {
core::hold_a_kernel()
}
/// Is this thread inside a solidcore call? A panic there is caught and becomes
/// [`Error::Internal`]; crash reports mark it recovered.
pub fn in_call() -> bool {
+1 -1
View File
@@ -129,7 +129,7 @@ impl Spec {
impl Surface {
/// solidcore's surface, made the first time it is needed.
fn core(&self) -> Result<&crate::core::split::Tool> {
self.core.get_or_init(|| crate::stop::unstoppable(|| crate::core::ssi::surface(&self.spec))).as_ref().map_err(Clone::clone)
self.core.get_or_init(|| crate::core::ssi::surface(&self.spec)).as_ref().map_err(Clone::clone)
}
pub(crate) fn spec(&self) -> &Spec {
-71
View File
@@ -1,71 +0,0 @@
//! Stopping the kernel calls of work nobody waits for any more: the jobs of a design that closed.
//! Work runs [`Stop::within`] its design's token; once the token is stopped, each operation under
//! it refuses to start, and one running stops when solidcore next checks ([`solidcore::kernel::Kernel::cancel`]).
//!
//! A stopped operation is an error like any other, and the caller's bodies are as they were: only
//! operations that build on copies stop. Calls that answer from a body itself ([`crate::core::with_body`],
//! measuring, say) always run to their end, since solidcore may leave what a stopped call borrows
//! half-changed.
use std::cell::RefCell;
use solidcore::kernel::CancelToken;
thread_local! {
static CURRENT: RefCell<Option<Stop>> = const { RefCell::new(None) };
}
/// Stops the kernel work running under it, on every thread it reached.
#[derive(Clone, Debug, Default)]
pub struct Stop(CancelToken);
impl Stop {
pub fn new() -> Stop {
Stop::default()
}
/// Stop the work under this token, now and from now on.
pub fn stop(&self) {
self.0.cancel();
}
pub fn is_stopped(&self) -> bool {
self.0.is_cancelled()
}
/// Run `f` with this token over the kernel work it does on this thread.
pub fn within<R>(&self, f: impl FnOnce() -> R) -> R {
scoped(Some(self.clone()), f)
}
}
/// The token over this thread's work, to hand to the worker threads it starts.
pub fn current() -> Option<Stop> {
CURRENT.with(|c| c.borrow().clone())
}
/// Run `f` under `stop` (`None`: under none), as [`current`] was on the thread that started it.
pub fn scoped<R>(stop: Option<Stop>, f: impl FnOnce() -> R) -> R {
let previous = CURRENT.with(|c| c.replace(stop));
struct Restore(Option<Option<Stop>>);
impl Drop for Restore {
fn drop(&mut self) {
if let Some(previous) = self.0.take() {
CURRENT.with(|c| *c.borrow_mut() = previous);
}
}
}
let _restore = Restore(Some(previous));
f()
}
/// Run `f` so that no token stops it: for what fills a cell every later user of a body reads,
/// which must not keep a stopped job's error.
pub fn unstoppable<R>(f: impl FnOnce() -> R) -> R {
scoped(None, f)
}
/// solidcore's token of the work on this thread.
pub(crate) fn token() -> Option<CancelToken> {
CURRENT.with(|c| c.borrow().as_ref().map(|s| s.0.clone()))
}
+1 -81
View File
@@ -78,7 +78,7 @@ fn calls_from_many_threads_are_serialised() {
static DUMP_DIR_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Entry points that ask no kernel, so have nothing to say here.
const NO_KERNEL: [&str; 14] = [
const NO_KERNEL: [&str; 13] = [
"inertia_tensor",
"is_valid",
"failed_tests",
@@ -92,7 +92,6 @@ const NO_KERNEL: [&str; 14] = [
"word",
"words",
"in_call",
"hold_a_kernel",
];
const ANSWERING: [&str; 63] = [
@@ -511,82 +510,3 @@ fn a_panic_beside_a_running_call_costs_only_its_own_kernel() {
release.send(()).unwrap();
held.join().unwrap().unwrap();
}
/// A stuck call keeps its kernel and nothing more: the pool never holds more than its limit of
/// idle kernels however many calls ran side by side, and once as many calls as it allows are
/// stuck, a new call is refused rather than growing it or waiting.
#[test]
fn the_kernel_pool_stays_bounded_when_calls_stick() {
let mut pool = core::Pool { idle: Vec::new(), running: 0, most_idle: 1, most_running: 3 };
let mut made = 0;
let mut make = || {
made += 1;
made
};
let (a, b, c) = (pool.take(&mut make), pool.take(&mut make), pool.take(&mut make));
assert_eq!((a, b, c), (Some(1), Some(2), Some(3)), "side by side, each on its own");
assert_eq!(pool.take(&mut make), None, "refused, not waiting, not one more");
pool.put(a);
assert_eq!(pool.take(&mut make), Some(1), "room again, its kernel used again");
// The other two never come back here: they still count, and hold no idle slot.
pool.put(Some(1));
pool.put(b);
assert_eq!(pool.idle, [1], "no more idle than allowed");
assert_eq!(pool.running, 1, "one still stuck");
// A stopped call's kernel is not used again.
pool.put(None);
assert_eq!(pool.running, 0);
assert_eq!(pool.idle, [1]);
}
/// A stop reaches a running call at solidcore's next check and later calls before they start;
/// the kernel it ran on is not used again; calls of other work and answers from a body go on.
#[test]
fn a_stopped_call_ends_at_its_next_check_and_holds_up_nothing() {
let stop = stop::Stop::new();
let (started, is_started) = std::sync::mpsc::channel();
let running = {
let stop = stop.clone();
std::thread::spawn(move || {
stop.within(|| {
let first: Result<()> = core::run("stopped_call", |k| {
let _ = started.send(());
// A long solidcore loop, until it is stopped.
loop {
k.check_cancelled("stopped_call")?;
std::thread::yield_now();
}
});
let next = Brep::make_box([0.0; 3], [1.0; 3]);
(first, next.map(|_| ()))
})
})
};
is_started.recv().unwrap();
let body = answered(|| Brep::make_box([0.0; 3], [10.0; 3]).unwrap());
stop.stop();
let (first, next) = answered(move || running.join().unwrap());
assert!(first.is_err_and(|e| e.to_string().contains("cancel")));
assert!(next.is_err_and(|e| e.to_string().contains("cancelled")), "refused before it starts");
// Under a stopped token, a body still answers, and is as it was.
let v = stop.within(|| body.volume(1e-7)).unwrap();
assert!((v - 1000.0).abs() < 1e-6, "{v}");
assert!(stop.within(|| body.boolean(&body, BooleanOp::Union)).is_err(), "an operation is refused");
// What fills a shared cell is not stopped.
assert!(stop.within(|| stop::unstoppable(|| Brep::make_box([0.0; 3], [1.0; 3]))).is_ok());
// And nothing else is affected.
let v = body.boolean(&body, BooleanOp::Union).unwrap().unwrap().volume(1e-7).unwrap();
assert!((v - 1000.0).abs() < 1e-6, "{v}");
}
/// Every call starts with the same draws of random rays, whatever its kernel did before: what
/// depends on them answers the same on any kernel of the pool.
#[test]
fn every_call_starts_clean_on_any_kernel() {
let draw = |k: &mut solidcore::kernel::Kernel| Ok(k.rng.next_f64());
let first = core::run("draw", draw).unwrap();
assert_eq!(core::run("draw", draw).unwrap(), first, "the same kernel, later");
let held = hold_a_kernel();
assert_eq!(core::run("draw", draw).unwrap(), first, "another kernel");
drop(held);
}
+4 -5
View File
@@ -572,11 +572,10 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) {
let next = if i + 1 < n { i + 1 } else { i - 1 };
switch(app, next);
}
if let Some(slot) = app.docs.slots.remove(i) {
slot.session.stop_computing();
if let Some(a) = slot.autosave {
a.discard();
}
if let Some(slot) = app.docs.slots.remove(i)
&& let Some(a) = slot.autosave
{
a.discard();
}
if app.docs.active > i {
app.docs.active -= 1;
@@ -725,33 +725,6 @@ fn a_design_closed_while_its_history_computes_holds_up_nothing() {
assert_eq!(h.app.dialog.as_ref().and_then(|d| d.command()), Some("solid.extrude"));
}
/// Closing a design or replacing it stops the kernel work still computing for it; showing another
/// tab doesn't, since that design is still there to come back to.
#[test]
fn closing_a_design_stops_what_it_has_computing_and_a_tab_switch_does_not() {
let mut h = block_boss_hole();
let first = h.app.session.stop_token();
let copy = h.app.session.ahead();
crate::documents::new_tab(&mut h.app);
assert!(!first.is_stopped(), "only another tab shown");
let second = h.app.session.stop_token();
assert!(!second.is_stopped());
crate::documents::close(&mut h.app, 0, true);
assert!(first.is_stopped(), "the first design closed");
assert!(!second.is_stopped(), "this design goes on");
assert!(
copy.replies(&[("solid.box".to_string(), json!({"length": 1, "width": 1, "height": 1}))]).iter().all(Result::is_err),
"its job gets nowhere"
);
h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
h.app.run("file.new", json!({})).unwrap();
assert!(second.is_stopped(), "replaced in its tab");
assert!(!h.app.session.stop_token().is_stopped());
h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
crate::documents::close(&mut h.app, 0, true);
assert!(!h.app.session.stop_token().is_stopped(), "the blank design that took the last tab's place");
}
/// A design's history command stays with its tab: another tab doesn't wait for it and doesn't get
/// its result, even once it is in. Shown again, the design picks it up.
#[test]
+2 -4
View File
@@ -438,10 +438,8 @@ fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, V
// Watched by `progress`, so a job left behind for other inputs skips its remaining features.
let watched = progress.clone();
let spawned = std::thread::Builder::new().name("preview".into()).spawn(move || {
let job = scratch.stop_token().within(|| {
solidcraft_engine::doc::progress::watch(watched, || {
if tool { compute_tool(&scratch, &cmds, colors) } else { compute(&scratch, &cmds, colors) }
})
let job = solidcraft_engine::doc::progress::watch(watched, || {
if tool { compute_tool(&scratch, &cmds, colors) } else { compute(&scratch, &cmds, colors) }
});
let _ = tx.send(job);
});