Compare commits

...
Author SHA1 Message Date
bherbruck ef8f6ca6f9 fix: restore shell and cut wall topology in 0.2.1 2026-10-11 15:17:05 -04:00
bherbruck 1aedbf6631 Merge pull request #176 from 0xlillith/fix/coincident-line-picks
fix: connect closest endpoints when Coincident picks two lines
2026-10-11 15:07:56 -04:00
bherbruck 6f872c7789 docs: restore the original enclosure demo in the updated UI 2026-10-11 15:05:16 -04:00
bherbruck 9bcd8ffd56 Merge pull request #172 from 0xlillith/fix/cli-script-errors
fix: honor --save and --out when CLI run uses --results
2026-10-11 15:01:06 -04:00
lillith 1adb2cd69f fix: accept two line picks for sketch coincidence 2026-10-11 20:34:04 +02:00
bherbruck 9d7be3c292 Merge pull request #161 from 0xlillith/test/import-budget-determinism
test: avoid wall-clock assertions in import budget regressions
2026-10-11 14:24:25 -04:00
lillith ac7d5d14cf fix: honor CLI save and exports with results output 2026-10-11 20:08:51 +02:00
bherbruck a7512e5e08 Merge pull request #160 from 0xlillith/docs/browser-file-storage
docs: explain browser saves and local downloads
2026-10-11 13:49:54 -04:00
lillith 4c6afc6ba4 test: avoid wall-clock assertions in import budget regressions 2026-10-11 19:23:13 +02:00
lillith f2f8a0df57 docs: explain browser saves and local downloads 2026-10-11 19:17:55 +02:00
bherbruck d9d4dd262d Merge pull request #155 from EmDecay/fix/147-input-channel-caps
fix: cap control requests and refuse file reads that never end
2026-10-11 12:39:09 -04:00
bherbruck 2eee967d40 Merge pull request #154 from EmDecay/fix/146-step-iges-expansions
fix: bound four unbounded expansions in STEP and IGES import
2026-10-11 12:38:30 -04:00
bherbruck 6140f89a42 Merge pull request #153 from EmDecay/fix/145-sketch-size-caps
fix: hold the sketch caps at design load and bound the solver per component
2026-10-11 12:38:00 -04:00
bherbruck 66f351e93b Merge pull request #152 from EmDecay/fix/144-import-work-budgets
fix: budget the work 3MF and DXF import does, not only its output
2026-10-11 12:37:23 -04:00
bherbruck 5b54275626 Merge pull request #151 from EmDecay/fix/143-construction-plane-recursion
fix: continue the plane depth count through model geometry
2026-10-11 12:36:25 -04:00
bherbruck 7abfe69603 Merge pull request #157 from bherbruck/docs/enclosure-demo-refresh
docs: restore the enclosure demo with the 0.2.0 UI
2026-10-11 12:27:22 -04:00
bherbruck 1a62c4513f docs: restore the enclosure demo with the 0.2.0 UI 2026-10-11 12:20:48 -04:00
bherbruck 2f78d3ef81 Merge pull request #149 from jyviko/fix/join-merges-met-bodies
Join merges every body the tool meets
2026-10-11 11:36:27 -04:00
Matt 78f80143d5 fix: hold the sketch caps at design load and bound the solver per component
Nothing on the load path checked a sketch's size, and every sketch in the
timeline is solved when a design opens. The solver sizes its dense matrices by
the connected component, so a 30 MB file with 200,000 chained points asked the
allocator for terabytes and the process aborted, which no panic guard can
intercept.

Reject an oversized sketch while the design is still raw JSON, so the typed
arrays are never built, and refuse a connected component with more variables
than fits in a bounded matrix: that component now reports a failed solve and
leaves its geometry alone. 10,000 chained points went from 9.6 GB and 25
seconds to a failed solve in well under a second.

Fixes #145

Co-Authored-By: Claude Opus 5 <[email protected]>
(cherry picked from commit 04abbf95c26f68c7d0a126ed9e2827049f593361)
2026-10-11 08:22:11 -07:00
Matt b2cf19c9fb fix: cap control requests and refuse file reads that never end
The control channel read each request with no length cap, so a client sending
gigabytes without a newline made the server thread allocate until the process
was killed. Read lines against the 16 MiB cap the MCP server already uses, now
one shared constant, answer a longer one with an error and close; close a
connection after eight lines that are not JSON, serve at most sixteen
connections at once, and run at most sixty-four queued requests per frame so a
flood cannot starve drawing.

Every file command checked the size with the metadata and then read to the end.
A character device such as /dev/zero reports a length of 0, so the cap passed
and the read was unbounded; a FIFO blocked the calling thread, which over the
control channel is the thread drawing the window. vfs::read now takes the
caller's cap, refuses anything that is not a regular file before opening it,
re-checks on the handle to close the window between the two, and reads no more
than the cap plus the byte that proves it was exceeded. Callers pass the limits
they already had, and the recovery read and the CLI's own reads go through it
too. `solvecraft-cli eval /dev/zero` and a FIFO now fail at once instead of
never returning.

Fixes #147

Co-Authored-By: Claude Opus 5 <[email protected]>
(cherry picked from commit 4156e8acd11c90c435a54a3b0ccc59991db71b12)
2026-10-11 08:22:07 -07:00
Matt b5fb86f245 fix: bound four unbounded expansions in STEP and IGES import
Each of these asks the allocator for tens of gigabytes, or runs for hours, from
a file of a few kilobytes to a few megabytes. An allocation failure is an abort,
so no panic guard can report it and the user's unsaved work is gone.

- IGES composite curves (102) name their children by pointer, and a tree whose
  every node names the same child twice fans out to 2^30 leaf visits under the
  depth cap. Count the entities visited and the segments produced against one
  budget, since a node that yields no segment trips no bound on the output, and
  refuse a composite curve that contains itself.
- IGES directory entries could all name the same parameter range, each
  materialising it as its own string and token list. Keep the total text
  proportional to the parameter section the file carries.
- STEP knot multiplicities were expanded into one value each before the knot
  count was compared with the control points: 20 million knots of multiplicity
  64 built a 10 GB vector only to be rejected. Check the sum first.
- One STEP face could list any number of boundary loops, each cloning its edge
  handles into a new wire that the face extent then samples. Cap the loops and
  the edges they hold; 100,000 bounds went from 2.1 GB to an immediate error.

Also guard the IGES translation, which was the one importer stage outside every
panic guard, keep the vertex-sewing bucket key clear of the overflow its
neighbour lookup caused for coordinates near f64's range, and count how often a
loop passes each vertex once instead of once per edge.

Fixes #146

Co-Authored-By: Claude Opus 5 <[email protected]>
(cherry picked from commit c32792b71c76d74348ad5002d8d2da870937e58e)
2026-10-11 08:22:07 -07:00
Matt a03e4b9413 fix: budget the work 3MF and DXF import does, not only its output
3MF: flatten budgeted triangles but copied the whole vertex array for every
placement, so an object with 100,000 vertices and one triangle placed by 500
build items reached 6.8 GB resident, and 10,000 items (still a ~200 KB file)
asked for about 140 GB and aborted on the allocation. Copy only the vertices
the triangles reach, keeping their relative order so an ordinary mesh is
numbered as it was in the file.

DXF: every bound in convert was on the geometry that comes out, and a block
that emits none tripped none of them. Six self-inserts in one empty block fan
out to 6^16 visits, and a 176-byte file never returned. Count entities visited
and array cells placed against a work budget, skip the array loop for a block
with no geometry, and bound the array size on its own.

Fixes #144

Co-Authored-By: Claude Opus 5 <[email protected]>
(cherry picked from commit 3bf3b68a1340d9bdef8dcefd6c4fb5a7cae6eec4)
2026-10-11 08:22:07 -07:00
Matt 0b5e15e2b9 fix: continue the plane depth count through model geometry
A construction plane built from model geometry resolves each of its sides
through the document again. That hop restarted the recursion depth at 0, so a
plane that is its own side never exceeded depth 1 and the 32-level cap never
fired: a ~600-byte design file recursed until the stack overflowed and the
process aborted, which no panic guard can intercept.

Carry the depth on the construction context so the geometry arms continue the
caller's count instead of restarting it.

Fixes #143

Co-Authored-By: Claude Opus 5 <[email protected]>
(cherry picked from commit 4bca1d49f27dfe9ef0140132746e1c7415daba8e)
2026-10-11 08:22:07 -07:00
Iraklis KourtisandClaude Opus 5.5 8a38a7354c fix: join merges every body the tool meets
A Join unioned its tools into the first target only, leaving other target
bodies the tool overlapped or touched as separate, interpenetrating bodies.
Each tool now merges with every target it meets (shared material or face
contact, tested on the operands' boundaries) into one body under the first
one's name; absorbed bodies are removed and their threads follow. A tool that
meets no target still joins the first target as a separate lump. Sheet metal
and mesh bodies are never absorbed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-11 10:41:48 -04:00
72 changed files with 4313 additions and 824 deletions

No files matched your search

+2 -1
View File
@@ -18,7 +18,8 @@ Generated-in-code assets are original and have no file to list:
| File | Author | Source | Licence |
|---|---|---|---|
| docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | MIT OR Apache-2.0 |
| docs/screenshots/demo.gif | SolveCraft contributors | original screen recording of the actual SolveCraft 0.2.0 English desktop UI (2× GIF preview; full video at real-time speed) | MIT OR Apache-2.0 |
| docs/screenshots/enclosure-demo-0.2.1.gif | SolveCraft contributors | original electronics enclosure screen recording of the actual SolveCraft 0.2.1 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 |
| docs/screenshots/enclosure-demo-0.2.1.mp4 | SolveCraft contributors | original electronics enclosure screen recording of the actual SolveCraft 0.2.1 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 |
| docs/book/src/generated/sample-plate.png | SolveCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/bracket.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/enclosure.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
Generated
+13 -13
View File
@@ -3256,7 +3256,7 @@ dependencies = [
[[package]]
name = "solvecraft"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"eframe",
"egui",
@@ -3270,7 +3270,7 @@ dependencies = [
[[package]]
name = "solvecraft-cli"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3279,7 +3279,7 @@ dependencies = [
[[package]]
name = "solvecraft-doc"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"log",
"serde",
@@ -3294,7 +3294,7 @@ dependencies = [
[[package]]
name = "solvecraft-engine"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"log",
"serde",
@@ -3310,7 +3310,7 @@ dependencies = [
[[package]]
name = "solvecraft-geom"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"serde",
"serde_json",
@@ -3318,7 +3318,7 @@ dependencies = [
[[package]]
name = "solvecraft-io"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"quick-xml 0.38.4",
"solvecraft-doc",
@@ -3330,7 +3330,7 @@ dependencies = [
[[package]]
name = "solvecraft-kernel"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"log",
"serde",
@@ -3345,7 +3345,7 @@ dependencies = [
[[package]]
name = "solvecraft-mcp"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3353,7 +3353,7 @@ dependencies = [
[[package]]
name = "solvecraft-render"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"image",
"serde",
@@ -3362,7 +3362,7 @@ dependencies = [
[[package]]
name = "solvecraft-sketch"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"serde",
"serde_json",
@@ -3373,7 +3373,7 @@ dependencies = [
[[package]]
name = "solvecraft-ui-egui"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"egui",
"egui-wgpu",
@@ -3388,7 +3388,7 @@ dependencies = [
[[package]]
name = "solvecraft-web"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"eframe",
"egui",
@@ -4760,7 +4760,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.2.0"
version = "0.2.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+2 -2
View File
@@ -7,9 +7,9 @@ Sketch, constrain, and build solids with a parametric feature timeline. Change a
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
model through the built-in MCP server. Part of the storytold "craft" family.
![SolveCraft 0.2.0: dimension a line, extrude a plate with an elliptical hole, and change its width while preserving the hole](docs/screenshots/demo.gif)
![SolveCraft: draw a rounded electronics enclosure, sketch its USB port directly on a face, add four mounting bosses and resize it through Parameters](docs/screenshots/enclosure-demo-0.2.1.gif)
*The actual 0.2.0 desktop UI: dimension a line from its endpoints, build a plate with an elliptical hole, then change its width from 25 → 35 mm and watch it rebuild. Includes the new axis navigation. 2× GIF preview; [full video at real-time speed](https://github.com/bherbruck/solvecraft/releases/download/v0.2.0/solvecraft-0.2.0-demo.mp4).*
*Start with an empty design and draw the original parametric electronics enclosure in the English desktop UI: rounded corners, a shell, a USB port sketched directly on the wall and a four-boss pattern. Change width from 80 → 100 mm and wall thickness from 2 → 3 mm in Parameters and watch it rebuild. Original animated pointer and click cues; 4× playback. [Full video](https://github.com/bherbruck/solvecraft/raw/refs/heads/main/docs/screenshots/enclosure-demo-0.2.1.mp4), generated with the [demo script](examples/demo/record_ui.sh).*
**Documentation:** the [SolveCraft book](https://bherbruck.github.io/solvecraft/) (user guide,
developer guide and command reference; sources in [docs/book](docs/book)), and the
+27 -3
View File
@@ -85,9 +85,31 @@ headline counts in-scope tabs only and lists the deferred ones separately.
preventing the reproduced thin-web loss. Unknown additional trims refuse; inherited
common-cylinder trims retain their existing policy, so this is not a general Boolean proof.
- Exact full-path assembly joint editing remains separate work and is not part of this batch.
- The replacement README demo records the actual English 0.2.0 UI, with a 2× preview and
a full video at real-time speed. Package verification and publication follow
[the release workflow](docs/releasing.md); development pushes are not application releases.
- Stable 0.2.0 is published: all release payload checksums, native cold reopening, deployed
web app and both Flatpak architectures were verified. The replacement README demo uses
the original electronics enclosure story and animated pointer in the actual English
0.2.0 UI, at 4× playback. Publication follows [the release workflow](docs/releasing.md).
## 0.2.1 critical fixes
- Axis view and navigation controls stay anchored to the viewport when feature dialogs or
the command palette open, at normal and enlarged display scales. Fit still accounts for
dialog coverage; covered controls do not receive clicks through dialogs.
- Eligible planar faces survive fillet, chamfer and shell operations with exact source
certificates. Sketches placed directly on the enclosure wall and inner floor follow
width and wall changes, including after the USB cut and cold reopening.
- Remove artificial straight-wall and rounded-corner subdivisions left by shell and
extrusion cuts. Identical generated wall segments span adjacent slabs as one physical
face, with exact full-height supports and boundary stitching. Preserve authored face
splits and face appearances. Permanent regressions cover material, STEP, reopening and
unaffected bodies across the extrusion end plane.
- Feature dialogs use the available screen height for their fields, with OK and Cancel
outside the scrolling region, including enlarged display scales.
- Design format 13 records shell-face attachments. Older designs remain readable;
newly saved designs require 0.2.1 or later. Ambiguous or unsupported faces still refuse
attachment rather than guessing a plane.
- Saved projections from a removed strip retain their geometry and report the lost
reference. Explicitly reselect the wall to repair that projection.
## View navigation
@@ -98,6 +120,8 @@ headline counts in-scope tabs only and lists the deferred ones separately.
## Known limitations
- Associative shell-face sketches require a unique certified planar source. Nested shell
attachments and ambiguous coincident sources remain unsupported.
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular
+20 -19
View File
@@ -97,7 +97,9 @@ fn pretty(v: &Value) -> String {
}
pub fn read_json(path: &str) -> Result<Value, String> {
let s = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
// Through the engine's file access, which refuses anything that is not a regular file and
// reads no more than the cap: a character device or a FIFO would otherwise never end.
let s = solvecraft_engine::io::vfs::read_to_string(path, solvecraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
serde_json::from_str(&s).map_err(|e| format!("{path}: {e}"))
}
@@ -152,28 +154,31 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
Some(b) => load(b)?,
None => Session::default(),
};
if args.iter().any(|a| a == "--results") {
// Print every command's result (scripts only).
let results = if args.iter().any(|a| a == "--results") {
// Collect every command's result (scripts only), then process requested files too.
let v = read_json(path.ok_or("--results needs a script")?)?;
let r = s.run_script(&v).map_err(|e| e.to_string())?;
println!("{}", pretty(&Value::Array(r)));
return Ok(());
}
match (base, path) {
(Some(_), Some(p)) => {
let v = read_json(p)?;
s.run_script(&v).map_err(|e| e.to_string())?;
Some(Value::Array(r))
} else {
match (base, path) {
(Some(_), Some(p)) => {
let v = read_json(p)?;
s.run_script(&v).map_err(|e| e.to_string())?;
}
(None, Some(p)) => s = load(p)?,
_ => {}
}
(None, Some(p)) => s = load(p)?,
_ => {}
}
None
};
for out in flags(args, "--out") {
s.execute("file.export", &json!({"path": out})).map_err(|e| e.to_string())?;
}
if let Some(p) = flag(args, "--save") {
s.execute("file.save_as", &json!({"path": p})).map_err(|e| e.to_string())?;
}
if !args.iter().any(|a| a == "--quiet") {
if let Some(results) = results {
println!("{}", pretty(&results));
} else if !args.iter().any(|a| a == "--quiet") {
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
println!("{}", pretty(&m));
}
@@ -359,13 +364,9 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
fn cmd_bench_open(args: &[String]) -> Result<(), String> {
let path = args.first().ok_or("usage: solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only]")?;
let t = std::time::Instant::now();
let len = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len();
let step = solvecraft_engine::io::is_step_path(path);
let limit = if step { solvecraft_engine::io::MAX_STEP_BYTES } else { solvecraft_engine::io::MAX_DESIGN_BYTES };
if len > limit as u64 {
return Err(format!("{path}: file too large"));
}
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let bytes = solvecraft_engine::io::vfs::read(path, limit).map_err(|e| format!("{path}: {e}"))?;
let read_ms = t.elapsed().as_secs_f64() * 1000.0;
println!("{}", json!({"step": "read", "ms": read_ms, "bytes": bytes.len()}));
let t = std::time::Instant::now();
@@ -150,6 +150,30 @@ fn try_bytes(bytes: &[u8]) {
}
}
/// Hostile shapes that no mutation of a saved file produces: plane references that close a
/// cycle, which resolve through the model and back into the document.
fn generated() -> Vec<(String, Vec<u8>)> {
let to = |name: &str| json!({"type": "plane", "plane": {"type": "construction", "name": name}});
let plane = |id: u64, name: &str, side: Value| {
json!({"id": id, "name": name, "suppressed": false, "type": "construction_plane",
"plane": {"type": "midplane", "a": side, "b": {"type": "plane", "plane": {"type": "origin", "name": "XZ"}}}})
};
let design = |name: &str, features: Value| {
json!({"format": "solvecraft/11", "name": name, "units": "mm", "params": [], "next_id": 9, "features": features}).to_string().into_bytes()
};
// A sketch past the caps interactive editing enforces: solving it would size dense matrices
// by the point count and abort on the allocation.
let point = json!({"id": "p1", "pos": [0.0, 0.0], "fixed": false});
let oversized = json!([{"id": 1, "name": "Sketch1", "suppressed": false, "type": "sketch",
"plane": {"type": "origin", "name": "XY"},
"sketch": {"points": vec![point; 200_000], "curves": [], "constraints": []}}]);
vec![
("construction plane is its own side".into(), design("selfplane", json!([plane(1, "P1", to("P1"))]))),
("two construction planes refer to each other".into(), design("twoplanes", json!([plane(1, "P1", to("P2")), plane(2, "P2", to("P1"))]))),
("sketch with 200,000 points".into(), design("bigsketch", oversized)),
]
}
#[test]
fn damaged_and_hostile_design_files_never_panic() {
let mut rng = Rng(0x5eed_cafe_f00d_d00d);
@@ -201,6 +225,7 @@ fn damaged_and_hostile_design_files_never_panic() {
cases.push(("binary".into(), (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect()));
all_cases.extend(cases.into_iter().map(|(what, b)| (name.clone(), what, b)));
}
all_cases.extend(generated().into_iter().map(|(what, b)| ("generated".to_string(), what, b)));
let next = std::sync::atomic::AtomicUsize::new(0);
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
let failures: Vec<String> = std::thread::scope(|scope| {
+97
View File
@@ -0,0 +1,97 @@
//! Result reporting must not skip requested files or execute a script twice.
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};
use serde_json::{Value, json};
static NEXT: AtomicUsize = AtomicUsize::new(0);
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let dir = std::env::temp_dir().join(format!("solvecraft-run-results-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&dir).unwrap();
Self(dir)
}
fn script(&self) -> PathBuf {
let path = self.0.join("box.json");
std::fs::write(&path, json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}}]}).to_string()).unwrap();
path
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn cli() -> Command {
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
}
fn json_output(output: Output) -> Value {
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
serde_json::from_slice(&output.stdout).unwrap()
}
fn body_count(path: &Path) -> Value {
json_output(cli().arg("eval").arg(path).output().unwrap())["body_count"].clone()
}
#[test]
fn results_still_save_and_export_the_script_once() {
let dir = Scratch::new();
let saved = dir.0.join("box.solvecraft");
let stl = dir.0.join("box.stl");
let obj = dir.0.join("box.obj");
let result = json_output(
cli().arg("run").arg(dir.script()).arg("--results").arg("--save").arg(&saved).arg("--out").arg(&stl).arg("--out").arg(&obj).output().unwrap(),
);
assert_eq!(result.as_array().unwrap().len(), 1);
assert_eq!(body_count(&saved), 1);
for exported in [&stl, &obj] {
assert!(std::fs::metadata(exported).unwrap().len() > 0);
assert_eq!(body_count(exported), 1);
}
}
#[test]
fn results_with_base_design_and_quiet_still_report_and_save() {
let dir = Scratch::new();
let script = dir.script();
let base = dir.0.join("base.solvecraft");
let saved = dir.0.join("combined.solvecraft");
json_output(cli().arg("run").arg(&script).arg("--save").arg(&base).output().unwrap());
let result = json_output(cli().args(["run", "--results", "--quiet", "--in"]).arg(&base).arg(&script).arg("--save").arg(&saved).output().unwrap());
assert_eq!(result.as_array().unwrap().len(), 1);
assert_eq!(body_count(&saved), 2);
assert_eq!(body_count(&base), 1);
}
#[test]
fn results_propagate_output_errors() {
let dir = Scratch::new();
for flag in ["--save", "--out"] {
let path = dir.0.join("missing").join(if flag == "--save" { "box.solvecraft" } else { "box.stl" });
let output = cli().arg("run").arg(dir.script()).arg("--results").arg(flag).arg(&path).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(!output.stderr.is_empty());
assert!(output.stdout.is_empty());
assert!(!path.exists());
}
}
#[test]
fn results_without_script_still_fail() {
let dir = Scratch::new();
let base = dir.0.join("base.solvecraft");
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
let output = cli().args(["run", "--results", "--in"]).arg(&base).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stderr).contains("--results needs a script"));
}
+1 -1
View File
@@ -349,7 +349,7 @@ pub fn rename(files: &Rc<Files>, from: &str, to: &str) -> Result<(), String> {
if files.entries.borrow().iter().any(|e| e.name == to) {
return Err(format!("there is already a file named {to}"));
}
let bytes = vfs::read(&format!("{OPFS}{from}")).map_err(|e| e.to_string())?;
let bytes = vfs::read(&format!("{OPFS}{from}"), solvecraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| e.to_string())?;
vfs::write(&format!("{OPFS}{to}"), &bytes).map_err(|e| e.to_string())?;
delete(files, from);
flush(files);
+1 -1
View File
@@ -234,7 +234,7 @@ mod web {
self.app.insert_path(&path);
self.files.panel.set(false);
}
"download" => match vfs::read(&path) {
"download" => match vfs::read(&path, solvecraft_engine::io::MAX_DESIGN_BYTES) {
Ok(b) => {
if let Err(e) = files::download(&name, &b) {
self.app.set_status(e, true);
+112 -4
View File
@@ -3,12 +3,22 @@
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use serde_json::{Value, json};
use solvecraft_engine::MAX_LINE;
use solvecraft_ui_egui::ControlRequest;
/// Connections served at once. The channel has no authentication beyond being bound to
/// loopback, so a flood must not be able to spawn threads without end.
const MAX_CONNECTIONS: usize = 16;
/// Lines that are not JSON before the connection is closed, so garbage cannot loop forever.
const MAX_BAD_LINES: usize = 8;
static OPEN: AtomicUsize = AtomicUsize::new(0);
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let listener = match TcpListener::bind(("127.0.0.1", port)) {
@@ -21,19 +31,69 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
eprintln!("solvecraft: control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
if OPEN.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS {
OPEN.fetch_sub(1, Ordering::Relaxed);
let mut refused = stream;
let _ = writeln!(refused, "{}", json!({"ok": false, "error": "too many connections"}));
continue;
}
let tx = tx.clone();
let ctx = ctx.clone();
std::thread::spawn(move || serve(stream, tx, ctx));
std::thread::spawn(move || {
serve(stream, tx, ctx);
OPEN.fetch_sub(1, Ordering::Relaxed);
});
}
});
rx
}
/// One line without its newline, or `None` at the end of the stream. A client that sends
/// gigabytes with no newline would otherwise make this thread allocate until the process is
/// killed, so the line is refused as soon as it passes `max`.
fn read_line(r: &mut impl BufRead, max: usize) -> std::io::Result<Option<Vec<u8>>> {
let mut line = Vec::new();
loop {
let (done, used) = {
let available = r.fill_buf()?;
if available.is_empty() {
return Ok((!line.is_empty()).then_some(line));
}
match available.iter().position(|b| *b == b'\n') {
Some(i) => {
line.extend_from_slice(available.get(..i).unwrap_or(&[]));
(true, i + 1)
}
None => {
line.extend_from_slice(available);
(false, available.len())
}
}
};
r.consume(used);
if done {
return Ok(Some(line));
}
if line.len() > max {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "request too large"));
}
}
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
for line in BufReader::new(read).lines() {
let Ok(line) = line else { break };
let mut reader = BufReader::new(read);
let mut bad = 0usize;
loop {
let line = match read_line(&mut reader, MAX_LINE) {
Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(),
Ok(None) => break,
Err(e) => {
let _ = writeln!(out, "{}", json!({"ok": false, "error": e.to_string()}));
break;
}
};
if line.trim().is_empty() {
continue;
}
@@ -53,10 +113,58 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
}
r
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
Err(e) => {
bad += 1;
json!({"ok": false, "error": format!("bad JSON: {e}")})
}
};
if writeln!(out, "{reply}").is_err() {
break;
}
if bad >= MAX_BAD_LINES {
break;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A request longer than the cap is refused instead of allocated.
#[test]
fn lines_past_the_cap_are_refused() {
let flood = vec![b'A'; MAX_LINE + 2];
let mut r = BufReader::new(flood.as_slice());
let e = read_line(&mut r, MAX_LINE).unwrap_err();
assert_eq!(e.kind(), std::io::ErrorKind::InvalidData);
assert!(e.to_string().contains("request too large"), "{e}");
}
/// Ordinary framing: lines split on newlines, blank lines kept, no trailing newline needed.
#[test]
fn lines_split_on_newlines() {
let text = b"{\"a\":1}\n\n{\"b\":2}\nlast".to_vec();
let mut r = BufReader::new(text.as_slice());
let mut got = Vec::new();
while let Some(line) = read_line(&mut r, MAX_LINE).unwrap() {
got.push(String::from_utf8(line).unwrap());
}
assert_eq!(got, vec!["{\"a\":1}", "", "{\"b\":2}", "last"]);
assert!(read_line(&mut r, MAX_LINE).unwrap().is_none());
}
/// A line that spans several reads of the underlying stream still arrives whole.
#[test]
fn lines_longer_than_one_buffer_arrive_whole() {
let long = "x".repeat(200_000);
let text = format!("{long}\n{long}\n").into_bytes();
let mut r = BufReader::with_capacity(64, text.as_slice());
assert_eq!(read_line(&mut r, MAX_LINE).unwrap().unwrap().len(), long.len());
assert_eq!(read_line(&mut r, MAX_LINE).unwrap().unwrap().len(), long.len());
assert!(read_line(&mut r, MAX_LINE).unwrap().is_none());
// The same line against a smaller cap is refused.
let mut r = BufReader::with_capacity(64, text.as_slice());
assert!(read_line(&mut r, 1000).is_err());
}
}
+104
View File
@@ -154,10 +154,19 @@ pub enum FaceCertificate {
ExtrudeCap { sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>> },
ExtrudeSide { sketch: u64, curve: String },
Imported { payload_sha256: String, item: u64, face: u64 },
ShellPlanar { source_producer: u64, source: Box<FaceCertificate>, inner: bool },
}
impl FaceCertificate {
pub fn remap_features(&mut self, features: &std::collections::BTreeMap<u64, u64>) {
if let Self::ShellPlanar { source_producer, source, .. } = self {
if let Some(mapped) = features.get(source_producer) {
*source_producer = *mapped;
}
if !matches!(source.as_ref(), Self::ShellPlanar { .. }) {
source.remap_features(features);
}
}
if let Self::ExtrudeSide { sketch, .. } | Self::ExtrudeCap { sketch, .. } = self
&& let Some(mapped) = features.get(sketch)
{
@@ -175,6 +184,9 @@ impl FaceCertificate {
&& payload_sha256.len() == 64
&& payload_sha256.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
Self::ShellPlanar { source_producer, source, .. } => {
*source_producer != 0 && !matches!(source.as_ref(), Self::ShellPlanar { .. }) && source.validate().is_ok()
}
};
if valid { Ok(()) } else { Err(DocError::Invalid("invalid face source certificate".into())) }
}
@@ -216,11 +228,83 @@ pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()>
.and_then(serde_json::Value::as_str)
.is_some_and(|digest| digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
}
Some("shell_planar") => {
fields.len() == 4
&& fields.keys().all(|key| ["kind", "source_producer", "source", "inner"].contains(&key.as_str()))
&& nonzero("source_producer")
&& fields.get("inner").and_then(serde_json::Value::as_bool).is_some()
&& fields.get("source").is_some_and(|source| {
source.as_object().is_some_and(|source| source.get("kind").and_then(serde_json::Value::as_str) != Some("shell_planar"))
&& validate_certificate_json(source).is_ok()
})
}
_ => false,
};
if valid { Ok(()) } else { Err(DocError::Invalid("malformed or oversized face source certificate".into())) }
}
/// Canonical diagnostic role; producer identity remains in the typed certificate.
pub(crate) fn shell_certificate_role(source: &FaceCertificate, inner: bool) -> Result<String> {
source.validate()?;
let role = match source {
FaceCertificate::Box { axis, high } => {
let axis = ["x", "y", "z"].get(*axis as usize).ok_or_else(|| DocError::Invalid("invalid shell source axis".into()))?;
format!("box:{}{axis}", if *high { "+" } else { "-" })
}
FaceCertificate::ExtrudeSide { curve, .. } => format!("side:{curve}"),
FaceCertificate::ExtrudeCap { high, outer, holes, .. } => format!("cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes)),
FaceCertificate::Imported { payload_sha256, item, face } => format!("import:{payload_sha256}:{item}:{face}"),
FaceCertificate::ShellPlanar { .. } => return Err(DocError::Invalid("nested shell source certificates are unsupported".into())),
};
Ok(format!("shell:{}:{role}", if inner { "inner" } else { "outer" }))
}
pub(crate) fn flat_source_certificate(source: &solvecraft_kernel::FaceSource) -> Option<(u64, FaceCertificate)> {
use solvecraft_kernel::FaceSource;
match source {
FaceSource::Box { producer: Some(producer), axis, high, .. } => Some((*producer, FaceCertificate::Box { axis: *axis, high: *high })),
FaceSource::CertifiedExtrudeSide { producer, sketch, curve, .. } => {
Some((*producer, FaceCertificate::ExtrudeSide { sketch: *sketch, curve: curve.clone() }))
}
FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, .. } => {
Some((*producer, FaceCertificate::ExtrudeCap { sketch: *sketch, high: *high, outer: outer.clone(), holes: holes.clone() }))
}
FaceSource::Imported { producer: Some(producer), payload_sha256: Some(digest), item, face } => {
Some((*producer, FaceCertificate::Imported { payload_sha256: digest.clone(), item: *item, face: *face }))
}
_ => None,
}
}
pub(crate) fn flat_source_matches(certificate: &FaceCertificate, source: &solvecraft_kernel::FaceSource, producer: u64) -> bool {
use solvecraft_kernel::FaceSource;
match (certificate, source) {
(FaceCertificate::Box { axis, high }, FaceSource::Box { producer: Some(current), axis: current_axis, high: current_high, .. }) => {
producer == *current && axis == current_axis && high == current_high
}
(
FaceCertificate::ExtrudeSide { sketch, curve },
FaceSource::CertifiedExtrudeSide { producer: current, sketch: current_sketch, curve: current_curve, .. },
) => producer == *current && sketch == current_sketch && curve == current_curve,
(
FaceCertificate::ExtrudeCap { sketch, high, outer, holes },
FaceSource::CertifiedExtrudeCap {
producer: current,
sketch: current_sketch,
high: current_high,
outer: current_outer,
holes: current_holes,
..
},
) => producer == *current && sketch == current_sketch && high == current_high && outer == current_outer && holes == current_holes,
(
FaceCertificate::Imported { payload_sha256, item, face },
FaceSource::Imported { producer: Some(current), payload_sha256: Some(digest), item: current_item, face: current_face },
) => producer == *current && payload_sha256 == digest && item == current_item && face == current_face,
_ => false,
}
}
/// Match a generated segment to one exact analytic sketch curve. Coincident
/// candidates are ambiguous and never resolved by distance or candidate order.
pub(crate) fn exact_sketch_curve(sketch: &SolvedSketch, generated: &Seg2) -> Option<String> {
@@ -306,6 +390,7 @@ impl Document {
Some(solvecraft_kernel::FaceSource::Imported { producer: Some(producer), .. })
| Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. })
| Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { producer, .. })
| Some(solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), .. })
| Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), .. }) => *producer,
_ => name
.strip_prefix('F')
@@ -316,6 +401,8 @@ impl Document {
let certificate = self.configuration_face_certificate(body, index)?;
if let Some(FaceCertificate::ExtrudeSide { curve, .. }) = &certificate {
name = format!("F{origin}:side:{curve}");
} else if let Some(FaceCertificate::ShellPlanar { source, inner, .. }) = &certificate {
name = format!("F{origin}:{}", shell_certificate_role(source, *inner)?);
} else if !name.starts_with(&format!("F{origin}:")) {
name = format!("F{origin}:face:{index}");
}
@@ -332,6 +419,19 @@ impl Document {
let Some(source) = body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) else {
return Ok(None);
};
if let solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), source, inner, .. } = source {
if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Shell { .. })) {
return Err(DocError::Invalid("shell source producer missing".into()));
}
let (source_producer, source) =
flat_source_certificate(source).ok_or_else(|| DocError::Invalid("shell has no flat certified source".into()))?;
if !self.flat_certificate_producer_matches(source_producer, &source) {
return Err(DocError::Invalid("shell original source producer missing".into()));
}
let certificate = FaceCertificate::ShellPlanar { source_producer, source: Box::new(source), inner: *inner };
certificate.validate()?;
return Ok(Some(certificate));
}
if let solvecraft_kernel::FaceSource::Imported { item, face, payload_sha256: Some(digest), producer: Some(producer) } = source {
if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Import { .. })) {
return Err(DocError::Invalid("import source producer missing".into()));
@@ -378,6 +478,10 @@ pub(crate) fn payload_digest(payload: &str) -> String {
format!("{:x}", Sha256::digest(payload.as_bytes()))
}
#[cfg(test)]
#[path = "config_shell_tests.rs"]
mod shell_tests;
#[cfg(test)]
mod tests {
use super::*;
+459
View File
@@ -0,0 +1,459 @@
use super::*;
use crate::{FeatureKind, Model, PlaneRef};
use serde_json::json;
use solvecraft_geom::{Vec2, Vec3};
fn shell_document() -> (Document, u64, u64) {
let mut doc = Document::new("certified shell");
doc.set_param("width", "20", Some("mm"), None).unwrap();
doc.set_param("wall", "2", Some("mm"), None).unwrap();
let base = doc
.add_feature(
serde_json::from_value(json!({"type":"box","corner":[0,0,0],"length":"width","width":"12","height":"10"})).unwrap(),
Some("Case"),
)
.unwrap();
doc.feature_mut(base).unwrap().body_names = vec!["Case".into()];
let shell = doc
.add_feature(
FeatureKind::Shell {
faces: vec![Vec3::new(5., 6., 10.)],
thickness: "wall".into(),
body: Some("Case".into()),
direction: "inside".into(),
tangent_chain: false,
},
Some("Shell"),
)
.unwrap();
(doc, base, shell)
}
fn face_index(body: &ModelBody, inner: bool) -> usize {
body.body
.face_sources()
.unwrap()
.iter()
.position(|source| {
matches!(source, Some(solvecraft_kernel::FaceSource::ShellPlanar { inner: current, source, .. })
if *current == inner && matches!(source.as_ref(), solvecraft_kernel::FaceSource::Box { axis:0,high:true,.. }))
})
.unwrap()
}
#[test]
fn shell_planar_raw_bounds_flat_identity_and_clone_remap() {
let plane = solvecraft_geom::Plane::XY;
let same = |planes: &[Option<solvecraft_geom::Plane>]| super::super::styles::shell_plane_family(planes.iter().copied());
assert!(same(&[Some(plane), Some(plane)]));
assert!(!same(&[]));
assert!(!same(&[Some(plane), None]));
for different in [
solvecraft_geom::Plane { origin: Vec3::X, ..plane },
solvecraft_geom::Plane { x: -plane.x, y: -plane.y, ..plane },
solvecraft_geom::Plane { y: -plane.y, ..plane },
] {
assert!(!same(&[Some(plane), Some(different)]));
}
let valid = json!({"kind":"shell_planar","source_producer":3,"source":{"kind":"extrude_side","sketch":2,"curve":"c4"},"inner":true});
assert!(validate_certificate_json(&valid).is_ok());
let mut certificate: FaceCertificate = serde_json::from_value(valid.clone()).unwrap();
certificate.remap_features(&[(2, 20), (3, 30)].into_iter().collect());
assert_eq!(serde_json::to_value(&certificate).unwrap()["source_producer"], 30);
assert_eq!(serde_json::to_value(&certificate).unwrap()["source"]["sketch"], 20);
for bad in [
json!({"kind":"shell_planar","source_producer":0,"source":{"kind":"box","axis":0,"high":true},"inner":false}),
json!({"kind":"shell_planar","source_producer":3,"source":null,"inner":false}),
json!({"kind":"shell_planar","source_producer":3,"source":valid,"inner":false}),
json!({"kind":"shell_planar","source_producer":3,"source":{"kind":"extrude_side","sketch":2,"curve":"x".repeat(1_000_000)},"inner":false}),
json!({"kind":"shell_planar","source_producer":3,"source":{"kind":"box","axis":0,"high":true,"extra":[[[]]]},"inner":false}),
] {
assert!(validate_certificate_json(&bad).is_err());
}
}
#[test]
fn shell_planar_anchor_rebuild_cold_geometry_and_missing_source_errors() {
let (mut doc, base, shell) = shell_document();
let mut model = Model::new();
model.evaluate(&doc);
assert!(model.results.iter().all(|result| result.error.is_none()), "{:?}", model.results);
let state = model.state();
let body = state.body("Case").unwrap();
let outer = doc.strong_face_target(body, face_index(body, false), Vec3::default()).unwrap();
let outer_matches: Vec<_> = body.body.face_sources().unwrap().iter().enumerate().filter_map(|(index, source)| {
matches!(source, Some(solvecraft_kernel::FaceSource::ShellPlanar { inner: false, source, .. }) if matches!(source.as_ref(), solvecraft_kernel::FaceSource::Box { axis: 0, high: true, .. })).then_some((index, body.body.face_plane(index)))
}).collect();
assert_eq!(outer_matches.len(), 1, "outer support must be one physical face: {outer_matches:?}");
assert!(super::super::styles::shell_plane_family(outer_matches.iter().map(|(_, plane)| *plane)));
let outer_plane = body.body.face_plane(face_index(body, false)).unwrap();
let outer_anchor = doc.capture_face_anchor(body, face_index(body, false), &outer_plane, &[], 0, &[]).unwrap();
let projected = crate::project::resolve_anchored_face(&doc, &state, &outer_plane, solvecraft_sketch::LinkKind::Project, &outer_anchor).unwrap();
let mut linked = solvecraft_sketch::Sketch::new();
let link = linked.add_link(solvecraft_sketch::LinkKind::Project, projected.source, &projected.geom).unwrap();
assert!(!linked.update_link(&link, &projected.geom).unwrap());
let inner_index = face_index(body, true);
let inner = doc.strong_face_target(body, inner_index, Vec3::default()).unwrap();
assert_ne!(outer.key(), inner.key());
assert!(matches!(&inner.certificate, Some(FaceCertificate::ShellPlanar { source_producer,inner:true,.. }) if *source_producer==base));
assert_eq!(inner.origin_feature, shell);
let picked = body.body.face_plane(inner_index).unwrap();
assert!((picked.origin.x - 18.).abs() < 1e-8);
let anchor = doc.capture_face_anchor(body, inner_index, &picked, &[], 0, &[]).unwrap();
let mut sketch = solvecraft_sketch::Sketch::new();
let points: Vec<_> =
[(2., 2.), (4., 2.), (4., 4.), (2., 4.)].into_iter().map(|(x, y)| sketch.add_point(Vec2::new(x, y), None).unwrap()).collect();
for edge in 0..4 {
sketch.add_line_pts(points[edge], points[(edge + 1) % 4], None).unwrap();
}
let sk = doc
.add_feature(
FeatureKind::Sketch {
plane: PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: Some(Box::new(anchor.clone())) },
sketch,
},
Some("Inner face sketch"),
)
.unwrap();
let boss = doc
.add_feature(
FeatureKind::Extrude {
sketch: sk,
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"2"})).unwrap(),
operation: crate::Operation::NewBody,
targets: vec![],
},
Some("Face boss"),
)
.unwrap();
model.evaluate(&doc);
let before = model.state().bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
doc.set_param("width", "30", Some("mm"), None).unwrap();
doc.set_param("wall", "3", Some("mm"), None).unwrap();
model.evaluate(&doc);
let changed = model.state();
let plane = doc.resolve_face_anchor(&anchor, 0, &changed).unwrap();
assert!((plane.origin.x - 27.).abs() < 1e-8);
let after = changed.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
assert!((solvecraft_kernel::measure(&changed.bodies.iter().find(|body| body.feature == boss).unwrap().body).unwrap().volume - 8.).abs() < 1e-8);
assert!((doc.resolve_face_anchor(&outer_anchor, 0, &changed).unwrap().origin.x - 30.).abs() < 1e-8);
let changed_outer_plane = doc.resolve_face_anchor(&outer_anchor, 0, &changed).unwrap();
let refreshed =
crate::project::resolve_anchored_face(&doc, &changed, &changed_outer_plane, solvecraft_sketch::LinkKind::Project, &outer_anchor).unwrap();
assert!(!linked.update_link(&link, &refreshed.geom).unwrap(), "same exterior boundary must retain projected IDs");
assert!((after.min.x - before.min.x - 9.).abs() < 1e-8, "{before:?} -> {after:?}");
assert!((after.max.x - before.max.x - 9.).abs() < 1e-8, "{before:?} -> {after:?}");
assert!((after.min.y - before.min.y).abs() < 1e-8 && (after.min.z - before.min.z).abs() < 1e-8);
let saved = doc.to_json();
assert!(saved.contains("solvecraft/13"));
let loaded = crate::format::read(&saved).unwrap();
let mut cold = Model::new();
cold.evaluate(&loaded);
let cold_state = cold.state();
let cold_bounds = cold_state.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
assert!(after.min.dist(cold_bounds.min) < 1e-9 && after.max.dist(cold_bounds.max) < 1e-9);
assert!(loaded.resolve_face_anchor(&anchor, 0, &cold_state).unwrap().origin.dist(plane.origin) < 1e-9);
let mut wrong = anchor.clone();
wrong.target.origin_feature = base;
assert!(doc.resolve_face_anchor(&wrong, 0, &changed).is_err());
let mut wrong_source = anchor.clone();
if let Some(FaceCertificate::ShellPlanar { source_producer, .. }) = &mut wrong_source.target.certificate {
*source_producer = shell;
}
assert!(doc.resolve_face_anchor(&wrong_source, 0, &changed).is_err());
doc.feature_mut(shell).unwrap().suppressed = true;
model.evaluate(&doc);
let lost = model.state();
assert!(doc.resolve_face_anchor(&anchor, 0, &lost).is_err());
assert!(!lost.bodies.iter().any(|body| body.feature == boss), "lost attachment must prevent downstream geometry");
}
#[test]
fn shell_planar_format13_keeps_v12_documents_and_refuses_future() {
let (doc, _, _) = shell_document();
let mut legacy: serde_json::Value = serde_json::from_str(&doc.to_json()).unwrap();
legacy["format"] = json!("solvecraft/12");
let loaded = crate::format::read(&legacy.to_string()).unwrap();
let mut current: serde_json::Value = serde_json::from_str(&loaded.to_json()).unwrap();
assert_eq!(current["format"], "solvecraft/13");
current["format"] = json!("solvecraft/12");
assert_eq!(legacy, current, "identity migration must preserve every prior field");
legacy["format"] = json!("solvecraft/14");
assert!(crate::format::read(&legacy.to_string()).is_err());
}
#[test]
fn regularized_shell_keeps_legacy_and_explicit_single_face_projections_closed() {
let (mut doc, _, _) = shell_document();
let mut model = Model::new();
for (width, wall) in [(20., 2.), (30., 3.)] {
doc.set_param("width", &width.to_string(), Some("mm"), None).unwrap();
doc.set_param("wall", &wall.to_string(), Some("mm"), None).unwrap();
model.evaluate(&doc);
let loaded = crate::format::read(&doc.to_json()).unwrap();
let mut cold = Model::new();
cold.evaluate(&loaded);
for state in [model.state(), cold.state()] {
let body = state.body("Case").unwrap();
let index = face_index(body, false);
let mesh = body.mesh();
assert_eq!(body.body.face_sources().unwrap().iter().filter(|source|matches!(source,Some(solvecraft_kernel::FaceSource::ShellPlanar {inner:false,source,..}) if matches!(source.as_ref(),solvecraft_kernel::FaceSource::Box {axis:0,high:true,..}))).count(),1,"no physical outer-wall strip face may remain");
let plane = body.body.face_plane(index).unwrap();
let point = mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.find_map(|(tri, face)| (*face as usize == index).then(|| mesh.tri(tri)).flatten())
.map(|[a, b, c]| (a + b + c) / 3.)
.unwrap();
let source = solvecraft_sketch::LinkSource::Face { body: "Case".into(), at: point };
let (values, _) = doc.param_values();
let legacy = crate::project::resolve(&doc, &values, &state, &plane, solvecraft_sketch::LinkKind::Project, &source).unwrap();
let explicit = crate::project::resolve_face_indices(&state, &plane, solvecraft_sketch::LinkKind::Project, "Case", &[index]).unwrap();
assert_eq!(legacy.geom, explicit.geom);
assert_eq!(explicit.geom.len(), 4, "raw planar piece keeps all four edges");
let mut sketch = solvecraft_sketch::Sketch::new();
let link = sketch.add_link(solvecraft_sketch::LinkKind::Project, source, &legacy.geom).unwrap();
sketch.links[0].face_name = Some(crate::naming::face_names(body)[index].clone());
let warnings = crate::project::refresh_links(&doc, &values, &state, &plane, &mut sketch);
assert!(warnings.is_empty(), "{warnings:?}");
assert!(!sketch.links[0].lost);
assert!(!sketch.update_link(&link, &explicit.geom).unwrap());
}
}
}
#[test]
fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material() {
let (mut doc, _, shell) = shell_document();
let mut model = Model::new();
model.evaluate(&doc);
let state = model.state();
let body = state.body("Case").unwrap();
let index = face_index(body, false);
let plane = body.body.face_plane(index).unwrap();
let stem = crate::naming::face_names(body)[index].clone();
assert_eq!(crate::naming::strip_piece(&stem), stem);
// Reconstruct the original raw shell before certificate stamping/regularization,
// and run the unchanged historical naming/capture path on its actual strip.
let before = model.state_before(shell);
// Legacy Shell did not propagate construction certificates. Omit transient
// metadata here; operand paint also disables new wall-run coalescence so
// this control retains the actual historical raw pieces.
let raw_source = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(20., 12., 10.)).unwrap().with_paint(Some(solvecraft_kernel::Paint {
opacity: 1.,
faces: vec![solvecraft_kernel::FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }],
}));
let raw = solvecraft_kernel::shell(&raw_source, &[Vec3::new(5., 6., 10.)], 2.).unwrap();
let mut old_body = ModelBody::new("Case".into(), raw, shell);
old_body.names = crate::naming::NameCell::new(Some(std::sync::Arc::new(crate::naming::NamingOrigin {
feature: doc.feature(shell).unwrap().clone(),
before,
depth: 1,
mats: vec![],
})));
let former = crate::naming::face_name_at_body(&old_body, Vec3::new(20., 6., 1.)).unwrap();
assert_eq!(former, format!("{stem}#1"), "historical actual strip capture records full piece name");
let mut sketch = solvecraft_sketch::Sketch::new();
let corners = [Vec2::new(0., 0.), Vec2::new(12., 0.), Vec2::new(12., 2.), Vec2::new(0., 2.)];
let geometry: Vec<_> = (0..4).map(|i| solvecraft_sketch::LinkGeom::Line(corners[i], corners[(i + 1) % 4])).collect();
sketch
.add_link(
solvecraft_sketch::LinkKind::Project,
solvecraft_sketch::LinkSource::Face { body: "Case".into(), at: Vec3::new(20., 6., 1.) },
&geometry,
)
.unwrap();
sketch.links[0].face_name = Some(former.clone());
let original = sketch.clone();
let sk = doc
.add_feature(
FeatureKind::Sketch { plane: PlaneRef::Face { plane, at: Vec3::new(20., 6., 1.), name: Some(former), anchor: None }, sketch },
Some("Saved strip sketch"),
)
.unwrap();
let boss = doc
.add_feature(
FeatureKind::Extrude {
sketch: sk,
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
operation: crate::Operation::NewBody,
targets: vec![],
},
Some("Saved strip profile material"),
)
.unwrap();
let mut legacy: serde_json::Value = serde_json::from_str(&doc.to_json()).unwrap();
legacy["format"] = json!("solvecraft/12");
let mut doc = crate::format::read(&legacy.to_string()).unwrap();
for width in [20., 30.] {
doc.set_param("width", &width.to_string(), Some("mm"), None).unwrap();
doc.set_param("wall", "3", Some("mm"), None).unwrap();
model.evaluate(&doc);
let loaded = crate::format::read(&doc.to_json()).unwrap();
let mut cold = Model::new();
cold.evaluate(&loaded);
for current in [&model, &cold] {
let warning = current.results.iter().find(|r| r.id == sk).unwrap().warning.as_deref().unwrap();
assert!(warning.contains("no longer exists; last geometry retained"), "{warning}");
let state = current.state();
let solved = state.sketch(sk).unwrap();
assert!(solved.sketch.links[0].lost);
assert_eq!(solved.sketch.curves, original.curves);
assert_eq!(solved.sketch.points, original.points);
let material = state.bodies.iter().find(|body| body.feature == boss).unwrap();
assert!(
(solvecraft_kernel::measure(&material.body).unwrap().volume - 24.).abs() < 1e-8,
"old24mm3 profile must not silently become120mm3 wall profile"
);
let bounds = material.mesh().bounds();
assert!((bounds.min.x - width).abs() < 1e-8 && (bounds.max.x - width - 1.).abs() < 1e-8);
assert!((bounds.max.y - 12.).abs() < 1e-8 && (bounds.max.z - 2.).abs() < 1e-8);
}
}
}
#[test]
fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
use solvecraft_kernel::{FacePaint, Paint};
use solvecraft_sketch::{LinkKind, LinkSource, Sketch};
let mut doc = Document::new("saved curved strip");
doc.set_param("width", "80", Some("mm"), None).unwrap();
doc.set_param("wall", "2", Some("mm"), None).unwrap();
let base = doc
.add_feature(
serde_json::from_value(json!({"type":"box","corner":["-width/2",-30,0],"length":"width","width":"60","height":"30"})).unwrap(),
Some("Case"),
)
.unwrap();
doc.feature_mut(base).unwrap().body_names = vec!["Case".into()];
doc.add_feature(
serde_json::from_value(json!({"type":"fillet","body":"Case","edges":[[40,30,15],[-40,30,15],[-40,-30,15],[40,-30,15]],"radius":"6"}))
.unwrap(),
Some("Corners"),
)
.unwrap();
let shell = doc
.add_feature(
FeatureKind::Shell {
faces: vec![Vec3::new(0., 0., 30.)],
thickness: "wall".into(),
body: Some("Case".into()),
direction: "inside".into(),
tangent_chain: false,
},
Some("Shell"),
)
.unwrap();
let mut model = Model::new();
model.evaluate(&doc);
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results);
let before = model.state_before(shell);
// Per-face paint disables the construction optimization: this control keeps
// the real pre-repair slab faces without forging a name or source witness.
let old_source = before
.body("Case")
.unwrap()
.body
.clone()
.with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
let cavity_box = solvecraft_kernel::box_solid(Vec3::new(-38., -28., 2.), Vec3::new(38., 28., 35.)).unwrap();
let cavity = solvecraft_kernel::fillet(
&cavity_box,
&[Vec3::new(38., 28., 15.), Vec3::new(-38., 28., 15.), Vec3::new(-38., -28., 15.), Vec3::new(38., -28., 15.)],
4.,
)
.unwrap();
let raw = solvecraft_kernel::boolean(&old_source, &cavity, solvecraft_kernel::BoolOp::Cut).unwrap().unwrap();
let mut old_body = ModelBody::new("Case".into(), raw, shell);
old_body.names = crate::naming::NameCell::new(Some(std::sync::Arc::new(crate::naming::NamingOrigin {
feature: doc.feature(shell).unwrap().clone(),
before,
depth: 1,
mats: vec![],
})));
let t = std::f64::consts::FRAC_1_SQRT_2;
let at = Vec3::new(34. + 6. * t, 24. + 6. * t, 1.);
let former = crate::naming::face_name_at_body(&old_body, at).unwrap();
let stem = crate::naming::strip_piece(&former).to_string();
assert_ne!(former, stem, "actual old cylindrical strip must have a piece suffix");
let state = model.state();
let current = state.body("Case").unwrap();
let names = crate::naming::face_names(current);
let index = names.iter().position(|name| name == &stem).unwrap();
assert!(current.body.face_was_coalesced(index));
assert!(current.body.face_plane(index).is_none(), "witness grants no planar attachment authority");
assert!(old_body.body.face_sources().is_none_or(|sources| sources.iter().enumerate().all(|(index, _)| !old_body.body.face_was_coalesced(index))));
let mut old_state = (*model.state()).clone();
old_state.bodies = vec![old_body];
let source = LinkSource::Face { body: "Case".into(), at };
// Recover the saved projection from the old face's complete raw topology.
// Display seam suppression must not discard a historical strip boundary.
let old_body = old_state.body("Case").unwrap();
let old_index = crate::naming::face_on_body(old_body, &former, at).unwrap().0;
let mesh = old_body.mesh();
let mut geometry = Vec::new();
for (index, faces) in mesh.edge_faces.iter().enumerate() {
if !faces.contains(&(old_index as u32)) {
continue;
}
let points = &mesh.edges[index];
let a = solvecraft_geom::Plane::YZ.to_local(*points.first().unwrap());
let b = solvecraft_geom::Plane::YZ.to_local(*points.last().unwrap());
let (a, b) = (Vec2::new(a.x, a.y), Vec2::new(b.x, b.y));
assert!(a.dist(b) > 1e-6);
for point in points {
let q = solvecraft_geom::Plane::YZ.to_local(*point);
let q = Vec2::new(q.x, q.y);
let direction = b - a;
let t = ((q - a).dot(direction) / direction.dot(direction)).clamp(0., 1.);
assert!(q.dist(a + direction * t) < 1e-7, "exact cylindrical quadrant projects to a straight support");
}
geometry.push(solvecraft_sketch::LinkGeom::Line(a, b));
}
assert_eq!(geometry.len(), 4, "historical curved strip has a complete four-edge rectangular projection");
let mut sketch = Sketch::new();
sketch.add_link(LinkKind::Project, source, &geometry).unwrap();
sketch.links[0].face_name = Some(former);
let original = sketch.clone();
let sk = doc
.add_feature(FeatureKind::Sketch { plane: PlaneRef::Custom { plane: solvecraft_geom::Plane::YZ }, sketch }, Some("Saved corner strip"))
.unwrap();
let boss = doc
.add_feature(
FeatureKind::Extrude {
sketch: sk,
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
operation: crate::Operation::NewBody,
targets: vec![],
},
Some("Retained strip material"),
)
.unwrap();
for (width, wall) in [(80., 2.), (100., 3.)] {
doc.set_param("width", &width.to_string(), Some("mm"), None).unwrap();
doc.set_param("wall", &wall.to_string(), Some("mm"), None).unwrap();
model.evaluate(&doc);
let loaded = crate::format::read(&doc.to_json()).unwrap();
let mut cold = Model::new();
cold.evaluate(&loaded);
for current in [&model, &cold] {
let result = current.results.iter().find(|r| r.id == sk).unwrap();
assert!(result.error.is_none());
assert!(result.warning.as_deref().unwrap().contains("no longer exists; last geometry retained"));
let state = current.state();
let solved = state.sketch(sk).unwrap();
assert!(solved.sketch.links[0].lost);
assert_eq!(solved.sketch.points, original.points);
assert_eq!(solved.sketch.curves, original.curves);
let material = state.bodies.iter().find(|body| body.feature == boss).unwrap();
assert!(
(solvecraft_kernel::measure(&material.body).unwrap().volume - 12.).abs() < 1e-7,
"two-mm strip cannot expand to a full thirty-mm wall"
);
}
}
}
+36
View File
@@ -5,6 +5,12 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use solvecraft_geom::Vec3;
pub(crate) fn shell_plane_family(planes: impl IntoIterator<Item = Option<solvecraft_geom::Plane>>) -> bool {
let mut planes = planes.into_iter();
let Some(Some(first)) = planes.next() else { return false };
first.origin.is_finite() && first.x.is_finite() && first.y.is_finite() && planes.all(|plane| plane == Some(first))
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigFaceTarget {
@@ -98,6 +104,20 @@ pub fn validate_styles_json(value: &Value) -> Result<()> {
}
impl Document {
pub(crate) fn flat_certificate_producer_matches(&self, producer: u64, certificate: &super::FaceCertificate) -> bool {
use super::FaceCertificate;
let Some(feature) = self.feature(producer) else { return false };
match (certificate, &feature.kind) {
(FaceCertificate::Box { .. }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => true,
(FaceCertificate::ExtrudeCap { sketch, .. }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody, .. }) => sketch == current && extent.taper.is_none(),
(FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody, .. }) => {
sketch == current && extent.taper.is_none()
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Circle { .. } | solvecraft_sketch::CurveKind::Arc { .. }))))
}
(FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => true,
_ => false,
}
}
/// Certify named generator lineage; generic index-derived naming is unsupported.
pub fn certify_config_face(&self, target: &ConfigFaceTarget) -> Result<()> {
target.validate()?;
@@ -121,6 +141,11 @@ impl Document {
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Circle { .. } | solvecraft_sketch::CurveKind::Arc { .. }))))
}
(super::FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => role.strip_prefix("face:").is_some_and(|index| index.parse::<usize>().is_ok()),
(super::FaceCertificate::ShellPlanar { source_producer, source, inner }, FeatureKind::Shell { .. }) => {
*source_producer != target.origin_feature
&& self.flat_certificate_producer_matches(*source_producer, source)
&& super::shell_certificate_role(source, *inner).is_ok_and(|expected| role == expected)
}
_ => false,
};
return if valid { Ok(()) } else { Err(DocError::Invalid("face certificate no longer matches its producer".into())) };
@@ -156,6 +181,17 @@ impl Document {
if body.body.is_mesh() || body.definition_name() != target.body {
return Err(DocError::Invalid("configured face definition does not match".into()));
}
if let Some(super::FaceCertificate::ShellPlanar { source_producer, source, inner }) = &target.certificate {
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, current)| {
matches!(current, Some(solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), source: current_source, inner: current_inner, .. }) if *producer == target.origin_feature && inner == current_inner && super::flat_source_matches(source, current_source, *source_producer)).then_some(index)
}).collect();
// A shell can split one original support into several pieces. All pieces
// must retain the same canonical frame, not merely lie on a common plane.
if !shell_plane_family(indices.iter().map(|index| body.body.face_plane(*index))) {
return Err(DocError::Unknown("shell planar face certificate is missing or ambiguous".into()));
}
return Ok(indices);
}
if matches!(target.certificate, Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::ExtrudeCap { .. })) {
let indices: Vec<_> = body
.body
+6 -2
View File
@@ -105,6 +105,10 @@ pub(crate) struct Ctx<'a> {
pub doc: &'a Document,
pub vals: &'a BTreeMap<String, Value>,
pub st: &'a ModelState,
/// How deep the plane resolution that led here already is. A plane built from model
/// geometry resolves its sides through the document again, so the count has to continue
/// across that hop; restarting it lets a plane that refers to itself recurse forever.
pub depth: usize,
}
fn bad(m: impl Into<String>) -> DocError {
@@ -281,7 +285,7 @@ pub(crate) fn line(ctx: &Ctx, g: &GeoRef) -> Result<(Vec3, Vec3)> {
/// A reference as a plane.
pub(crate) fn plane(ctx: &Ctx, g: &GeoRef) -> Result<Plane> {
match g {
GeoRef::Plane { plane } => ctx.doc.resolve_plane_in(ctx.vals, plane, 0, Some(ctx.st)),
GeoRef::Plane { plane } => ctx.doc.resolve_plane_in(ctx.vals, plane, ctx.depth + 1, Some(ctx.st)),
GeoRef::Face { at, name } => {
let (b, fi, q) = face_of(ctx.st, *at, name)?;
match kernel::face_surface(&b.body, fi) {
@@ -527,7 +531,7 @@ pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
let (p, tan) = along(&path_points(ctx, path)?, t).ok_or_else(|| bad("the path has no length"))?;
Plane::from_normal(p, tan).ok_or_else(degenerate)
}
_ => ctx.doc.resolve_plane_in(ctx.vals, p, 0, Some(ctx.st)),
_ => ctx.doc.resolve_plane_in(ctx.vals, p, ctx.depth + 1, Some(ctx.st)),
}
}
+1 -1
View File
@@ -2132,7 +2132,7 @@ impl Document {
| PlaneRef::AlongPath { .. }
| PlaneRef::Perpendicular { .. } => {
let st = st.ok_or_else(|| DocError::Invalid("this plane is built from the model: evaluate first".into()))?;
crate::construct::resolve_geo_plane(&crate::construct::Ctx { doc: self, vals, st }, p)
crate::construct::resolve_geo_plane(&crate::construct::Ctx { doc: self, vals, st, depth }, p)
}
}
}
+38 -9
View File
@@ -924,13 +924,40 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation
}
match op {
Operation::Join => {
let Some(&first) = target_idx.first() else { return Ok(()) };
let Some(mut acc) = state.bodies.get(first).map(|b| b.body.clone()) else { return Ok(()) };
for t in &tools {
acc = kernel::boolean(&acc, t, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("join produced nothing".into()))?;
}
if let Some(mb) = state.bodies.get_mut(first) {
*mb = ModelBody::new(mb.name.clone(), acc, mb.feature);
// Each tool joins every target it meets into one body, kept under the first one's
// name; a tool that meets none joins the first target as a separate lump. A sheet
// metal body keeps its own identity: only the first body met may be one.
let mut live: Vec<String> = target_idx.iter().filter_map(|i| state.bodies.get(*i).map(|b| b.name.clone())).collect();
let union = |a: &Body, b: &Body| kernel::boolean(a, b, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("join produced nothing".into()));
for tool in &tools {
let mut met: Vec<String> = Vec::new();
for name in &live {
let Some(mb) = state.body(name) else { continue };
let sheet = state.sheets.iter().any(|s| &s.body == name);
if mb.body.is_mesh() || (!met.is_empty() && sheet) {
continue;
}
if mb.body.meets(tool)? {
met.push(name.clone());
}
}
let Some(keep) = met.first().or(live.first()).cloned() else { return Ok(()) };
let Some(base) = state.body(&keep).map(|b| b.body.clone()) else { continue };
let mut acc = union(&base, tool)?;
for name in met.iter().skip(1) {
if let Some(b) = state.body(name) {
acc = union(&acc, &b.body)?;
}
}
let absorbed: std::collections::BTreeSet<String> = met.into_iter().skip(1).collect();
if let Some(mb) = state.bodies.iter_mut().find(|b| b.name == keep) {
*mb = ModelBody::new(mb.name.clone(), acc, mb.feature);
}
state.bodies.retain(|b| !absorbed.contains(&b.name));
for t in state.threads.iter_mut().filter(|t| absorbed.contains(&t.body)) {
t.body = keep.clone();
}
live.retain(|n| !absorbed.contains(n));
}
}
Operation::Cut | Operation::Intersect => {
@@ -2883,6 +2910,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
o => return Err(DocError::Invalid(format!("unknown shell direction `{o}` (inside, outside or both)"))),
};
let nb = nb.with_shell_certificate(f.id)?;
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, nb, mb.feature);
}
@@ -2905,7 +2933,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
Ok(())
}
FeatureKind::ConstructionAxis { def } => {
let (origin, dir) = crate::construct::resolve_axis(&crate::construct::Ctx { doc, vals, st }, def)?;
let (origin, dir) = crate::construct::resolve_axis(&crate::construct::Ctx { doc, vals, st, depth: 0 }, def)?;
st.construct.push(crate::construct::Construct {
feature: f.id,
name: f.name.clone(),
@@ -2914,7 +2942,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
Ok(())
}
FeatureKind::ConstructionPoint { def } => {
let p = crate::construct::resolve_point(&crate::construct::Ctx { doc, vals, st }, def)?;
let p = crate::construct::resolve_point(&crate::construct::Ctx { doc, vals, st, depth: 0 }, def)?;
st.construct.push(crate::construct::Construct { feature: f.id, name: f.name.clone(), geom: crate::construct::ConstructGeom::Point(p) });
Ok(())
}
@@ -3132,6 +3160,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
FeatureKind::Chamfer { distance2, angle, flip, .. } => chamfer_with(vals, &mb.body, &edges, r, distance2, angle, *flip)?,
_ => return Err(DocError::Invalid("blend".into())),
};
let nb = nb.with_preserved_planar_sources(&mb.body)?;
let (name, feat) = (mb.name.clone(), mb.feature);
if let Some(slot) = st.bodies.get_mut(ti) {
*slot = ModelBody::new(name, nb, feat);
+61 -3
View File
@@ -36,13 +36,16 @@
//! - 12: persistent analytic offset boundary roles and association profile references.
//! Older analytic chains retain their source/output correspondence and saved geometry;
//! migration never invents retired entities or renumbers existing curves.
//! - 13: exact planar shell face anchors retain the shell producer, original certified
//! source and distinct inner/outer role. Version 12 anchors retain their identities;
//! migration never guesses attachments for older designs.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 12;
pub const FORMAT: u32 = 13;
pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}")
@@ -55,8 +58,8 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 11] =
[|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(())];
const JSON_STEPS: [JsonStep; 12] =
[|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(())];
/// Old XZ references retain their exact frame, including nested offset and angled planes.
/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
@@ -153,6 +156,7 @@ pub fn read(s: &str) -> Result<Document> {
validate_versioned_geometry(&v)?;
validate_emboss_targets(&v)?;
validate_freeform_offsets(&v)?;
validate_sketch_sizes(&v)?;
crate::format_offsets::validate_raw(&v)?;
let legacy_wires = validate_wires(&v, version)?;
validate_body_transfers(&v)?;
@@ -213,6 +217,29 @@ pub fn read(s: &str) -> Result<Document> {
Ok(d)
}
/// The caps interactive editing enforces, applied to the raw JSON so an oversized sketch is
/// never deserialised. Every sketch in the timeline is solved when the design opens, and the
/// solver sizes its dense matrices by the sketch, so a file with hundreds of thousands of
/// points would ask the allocator for terabytes and abort before anything could report it.
fn validate_sketch_sizes(value: &Value) -> Result<()> {
let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) };
let invalid = || DocError::Invalid("damaged design: sketch too large".into());
for feature in features {
if feature.get("type").and_then(Value::as_str) != Some("sketch") {
continue;
}
let Some(sketch) = feature.get("sketch") else { continue };
let len = |key: &str| sketch.get(key).and_then(Value::as_array).map_or(0, Vec::len);
if len("points") > solvecraft_sketch::MAX_POINTS
|| len("curves") > solvecraft_sketch::MAX_CURVES
|| len("constraints") > solvecraft_sketch::MAX_CONSTRAINTS
{
return Err(invalid());
}
}
Ok(())
}
/// Bound arrays before typed allocation; only old files with absent markers need migration.
fn validate_wires(value: &Value, version: u32) -> Result<Vec<(usize, usize)>> {
let invalid = || DocError::Invalid("damaged design: 3D curve arrays, ids or coordinate spaces are invalid".into());
@@ -1199,4 +1226,35 @@ mod tests {
assert!(read(&v.to_string()).is_err(), "out-of-range numbers anywhere");
assert!(read("{\"format\": 2}").is_err());
}
/// Every sketch in the timeline is solved when the design opens, and the solver sizes its
/// dense matrices by the sketch. The caps interactive editing enforces have to hold for a
/// file too, before the arrays are deserialised.
#[test]
fn oversized_sketches_are_refused_before_typed_decoding() {
use solvecraft_sketch::{MAX_CONSTRAINTS, MAX_CURVES, MAX_POINTS, Sketch};
// A sketch right at the point cap, chained by coincident constraints.
let mut sketch = Sketch::new();
let mut previous = 0;
while sketch.points.len() < MAX_POINTS {
let i = sketch.add_point(solvecraft_geom::Vec2::new(0.001 * sketch.points.len() as f64, 0.0), None).unwrap();
sketch.add_constraint(solvecraft_sketch::ConstraintKind::Coincident { p: previous, q: i }, None).unwrap();
previous = i;
}
let mut doc = Document::new("big sketch");
doc.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
let at_cap: Value = serde_json::from_str(&doc.to_json()).unwrap();
assert!(read(&at_cap.to_string()).is_ok(), "a sketch at the caps must still load");
// One past each cap, grown at the JSON level: no typed array is ever built.
for (key, cap) in [("points", MAX_POINTS), ("constraints", MAX_CONSTRAINTS), ("curves", MAX_CURVES)] {
let mut value = at_cap.clone();
let slot = &mut value["features"][0]["sketch"][key];
let first = slot.as_array().and_then(|a| a.first()).cloned().unwrap_or(Value::Null);
*slot = Value::Array(vec![first; cap + 1]);
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("sketch too large"), "{key}: {error}");
}
}
}
+130 -20
View File
@@ -141,15 +141,13 @@ fn resolve_uncached(
LinkKind::Intersect => section(plane, &mesh, Some(f)),
_ => {
let mut g = Vec::new();
for e in order::face_edges(&mesh, f, plane)? {
if mesh.seams.get(e).copied().unwrap_or(false) {
continue;
}
let target = st.bodies.get(bi).ok_or_else(|| lost("the projection body"))?;
for e in projection_face_edges(target, &mesh, f, plane)? {
if let Some(pts) = mesh.edges.get(e) {
g.extend(fit_world(plane, pts));
}
}
g
if certified_planar_construction(target, f as usize) { merge_family_lines(g)? } else { g }
}
}
}
@@ -293,15 +291,13 @@ fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str
section(plane, &mesh, Some(index))
} else {
let mut geom = Vec::new();
for edge in order::face_edges(&mesh, index, plane)? {
if mesh.seams.get(edge).copied().unwrap_or(false) {
continue;
}
for edge in projection_face_edges(target, &mesh, index, plane)? {
if let Some(points) = mesh.edges.get(edge) {
geom.extend(fit_world(plane, points));
}
}
merge_split_circles(geom)
let geom = merge_split_circles(geom);
if certified_planar_construction(target, index as usize) { merge_family_lines(geom)? } else { geom }
};
if geom.is_empty() {
return Err(DocError::Invalid("named face projects to nothing on this sketch plane".into()));
@@ -309,6 +305,29 @@ fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str
Ok(Resolved { geom, wires: Vec::new(), source: LinkSource::Face { body: body.into(), at }, moved: false, face_index })
}
fn projection_face_edges(body: &crate::ModelBody, mesh: &Mesh, index: u32, plane: &Plane) -> Result<Vec<usize>> {
let shell_plane = body
.body
.face_sources()
.and_then(|sources| sources.get(index as usize))
.is_some_and(|source| matches!(source, Some(solvecraft_kernel::FaceSource::ShellPlanar { .. })));
if shell_plane { order::raw_face_edges(mesh, index, plane) } else { order::face_edges(mesh, index, plane) }
}
fn certified_planar_construction(body: &crate::ModelBody, index: usize) -> bool {
body.body.face_sources().and_then(|sources| sources.get(index)).is_some_and(|source| {
matches!(
source,
Some(
solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(_), .. }
| solvecraft_kernel::FaceSource::Box { producer: Some(_), .. }
| solvecraft_kernel::FaceSource::CertifiedExtrudeSide { plane: Some(_), .. }
| solvecraft_kernel::FaceSource::CertifiedExtrudeCap { .. }
)
)
})
}
pub fn resolve_anchored_face(doc: &Document, st: &ModelState, plane: &Plane, kind: LinkKind, anchor: &crate::FaceAnchor) -> Result<Resolved> {
let body = st.body(&anchor.target.body).ok_or_else(|| lost("anchored projection body"))?;
let indices = doc.configuration_face_indices(body, &anchor.target)?;
@@ -329,21 +348,31 @@ pub fn resolve_face_indices(st: &ModelState, plane: &Plane, kind: LinkKind, body
}
let selected: std::collections::BTreeSet<_> = indices.iter().copied().collect();
let mut geometry = Vec::new();
for index in indices {
let index = u32::try_from(*index).map_err(|_| DocError::Invalid("anchored face index exceeds format bounds".into()))?;
if kind == LinkKind::Intersect {
if kind == LinkKind::Intersect {
for index in indices {
let index = u32::try_from(*index).map_err(|_| DocError::Invalid("anchored face index exceeds format bounds".into()))?;
geometry.extend(section(plane, &mesh, Some(index)));
}
} else {
let edges = if let [index] = indices {
projection_face_edges(
body,
&mesh,
u32::try_from(*index).map_err(|_| DocError::Invalid("face index exceeds format bounds".into()))?,
plane,
)?
} else {
for edge in order::face_edges(&mesh, index, plane)? {
if mesh.seams.get(edge).copied().unwrap_or(false) {
continue;
}
if let Some(points) = mesh.edges.get(edge) {
geometry.extend(fit_world(plane, points));
}
order::family_edges(&mesh, &selected, plane)?
};
for edge in edges {
if let Some(points) = mesh.edges.get(edge) {
geometry.extend(fit_world(plane, points));
}
}
}
if kind != LinkKind::Intersect && (indices.len() > 1 || indices.first().is_some_and(|index| certified_planar_construction(body, *index))) {
geometry = merge_family_lines(geometry)?;
}
let point = mesh
.triangles
.iter()
@@ -373,6 +402,75 @@ pub fn refresh_face_anchor_links(
refresh_links_in(doc, vals, st, plane, sk, Some(anchor))
}
/// Remove degree-two straight stations introduced by coplanar face subdivision.
/// The walk uses endpoint incidence; geometric sorting does not imply adjacency.
fn merge_family_lines(geom: Vec<LinkGeom>) -> Result<Vec<LinkGeom>> {
if geom.len().saturating_mul(geom.len()).saturating_mul(3) > 10_000_000 {
return Err(DocError::Invalid("face family straight-boundary work limit exceeded".into()));
}
let ends = |curve: &LinkGeom| match curve {
LinkGeom::Line(a, b) | LinkGeom::Arc { a, b, .. } | LinkGeom::Conic { a, b, .. } => Some((*a, *b)),
LinkGeom::Spline { pts, periodic: false, .. } => pts.first().zip(pts.last()).map(|(a, b)| (*a, *b)),
_ => None,
};
let scale = geom.iter().filter_map(&ends).flat_map(|(a, b)| [a.len(), b.len()]).fold(1., f64::max);
let tol = scale * f64::EPSILON * 512.;
let mut used = vec![false; geom.len()];
let mut result = Vec::new();
for (index, curve) in geom.iter().enumerate() {
if used.get(index).copied().unwrap_or(true) {
continue;
}
if let Some(slot) = used.get_mut(index) {
*slot = true;
}
let LinkGeom::Line(a, b) = curve else {
result.push(curve.clone());
continue;
};
let mut endpoints = [*a, *b];
for side in 0..2 {
let mut current = index;
for _ in 0..geom.len() {
let Some(point) = endpoints.get(side).copied() else { break };
let mut incidence = geom
.iter()
.enumerate()
.filter_map(|(other, curve)| ends(curve).filter(|(a, b)| a.dist(point) <= tol || b.dist(point) <= tol).map(|_| other));
let (Some(one), Some(two), None) = (incidence.next(), incidence.next(), incidence.next()) else { break };
let other = if one == current {
two
} else if two == current {
one
} else {
break;
};
if used.get(other).copied().unwrap_or(true) {
break;
}
let Some(LinkGeom::Line(a, b)) = geom.get(other) else { break };
let next = if a.dist(point) <= tol { *b } else { *a };
let Some(opposite) = endpoints.get(1 - side).copied() else { break };
let (Some(incoming), Some(outgoing)) = ((point - opposite).normalized(), (next - point).normalized()) else { break };
if incoming.dot(outgoing) <= 0. || incoming.cross(outgoing).abs() > f64::EPSILON * 512. {
break;
}
if let Some(slot) = used.get_mut(other) {
*slot = true;
}
if let Some(slot) = endpoints.get_mut(side) {
*slot = next;
}
current = other;
}
}
if let (Some(a), Some(b)) = (endpoints.first(), endpoints.get(1)) {
result.push(LinkGeom::Line(*a, *b));
}
}
Ok(result)
}
pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, plane: &Plane, sk: &mut Sketch) -> Vec<String> {
refresh_links_in(doc, vals, st, plane, sk, None)
}
@@ -394,6 +492,18 @@ fn refresh_links_in(
&& let (Some(name), LinkSource::Face { body, at }) = (&l.face_name, &mut source)
{
if let Some((index, p, split)) = st.body(body).and_then(|target| crate::naming::face_on_body(target, name, *at)) {
let stem = crate::naming::strip_piece(name);
let removed_piece = split
&& name != stem
&& st.body(body).is_some_and(|target| {
(certified_planar_construction(target, index) || target.body.face_was_coalesced(index))
&& crate::naming::face_names(target).get(index).is_some_and(|current| current == stem)
});
if removed_piece {
sk.set_link_lost(&l.id, true);
warn.push(format!("projected face piece `{name}` no longer exists; last geometry retained"));
continue;
}
*at = p;
named_face = Some((body.clone(), index, p));
if split {
+103 -1
View File
@@ -10,6 +10,36 @@ use crate::{DocError, Result};
type Key = (i64, i64, i64);
pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<usize>> {
let edges = mesh.face_edges(face).into_iter().filter(|e| !mesh.seams.get(*e).copied().unwrap_or(false)).collect();
boundary_edges(mesh, edges, plane)
}
/// Full boundary of an explicitly selected analytic planar piece. Display seams
/// hide subdivisions for picking, but cannot remove a raw piece's closing edge.
pub(super) fn raw_face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<usize>> {
boundary_edges(mesh, mesh.face_edges(face), plane)
}
/// Exterior of an exact current face family. Shared topological edges are internal;
/// geometric proximity never merges distinct edges or chooses a different face.
pub(super) fn family_edges(mesh: &Mesh, selected: &std::collections::BTreeSet<usize>, plane: &Plane) -> Result<Vec<usize>> {
let mut edges = Vec::new();
for (index, faces) in mesh.edge_faces.iter().enumerate() {
let count = faces.iter().filter(|face| selected.contains(&(**face as usize))).count();
if count == 0 {
continue;
}
if faces.len() > 2 || (faces.len() == 2 && faces.first() == faces.get(1)) {
return Err(DocError::Invalid("face family has ambiguous edge adjacency".into()));
}
if count == 1 && !mesh.seams.get(index).copied().unwrap_or(false) {
edges.push(index);
}
}
boundary_edges(mesh, edges, plane)
}
fn boundary_edges(mesh: &Mesh, mut edges: Vec<usize>, plane: &Plane) -> Result<Vec<usize>> {
let bounds = mesh.bounds();
let origin = bounds.min;
let local_origin = plane.to_local(origin);
@@ -21,7 +51,6 @@ pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<us
let p = p - origin;
((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64)
};
let mut edges: Vec<usize> = mesh.face_edges(face).into_iter().filter(|e| !mesh.seams.get(*e).copied().unwrap_or(false)).collect();
if edges.iter().any(|e| !mesh.edges.get(*e).is_some_and(|p| p.len() >= 2 && p.iter().all(|p| p.is_finite()))) {
return Err(DocError::Invalid("face projection has an invalid boundary edge".into()));
}
@@ -119,6 +148,79 @@ pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<us
mod tests {
use super::*;
#[test]
fn face_family_omits_only_exact_shared_topological_edges() {
let mut mesh = Mesh::default();
for (a, b, faces) in [
((0., 0.), (2., 0.), vec![0]),
((2., 0.), (2., 1.), vec![0]),
((2., 1.), (0., 1.), vec![0, 1]),
((0., 1.), (0., 0.), vec![0]),
((2., 1.), (2., 2.), vec![1]),
((2., 2.), (0., 2.), vec![1]),
((0., 2.), (0., 1.), vec![1]),
] {
mesh.edges.push(vec![Vec3::new(a.0, a.1, 0.), Vec3::new(b.0, b.1, 0.)]);
mesh.positions.extend([Vec3::new(a.0, a.1, 0.), Vec3::new(b.0, b.1, 0.)]);
mesh.edge_faces.push(faces);
}
let selected = [0, 1].into_iter().collect();
let edges = family_edges(&mesh, &selected, &Plane::XY).unwrap();
assert_eq!(edges.len(), 6);
assert!(!edges.contains(&2));
let geometry: Vec<_> = edges.iter().flat_map(|edge| super::super::fit_world(&Plane::XY, &mesh.edges[*edge])).collect();
assert_eq!(super::super::merge_family_lines(geometry).unwrap().len(), 4);
assert_eq!(family_edges(&mesh, &[0].into_iter().collect(), &Plane::XY).unwrap().len(), 4);
mesh.edge_faces[2] = vec![0, 1, 2];
assert!(family_edges(&mesh, &selected, &Plane::XY).is_err());
mesh.edge_faces[2] = vec![0, 0];
assert!(family_edges(&mesh, &selected, &Plane::XY).is_err());
}
#[test]
fn distinct_crossing_boundaries_are_not_geometrically_cancelled() {
let mut mesh = Mesh::default();
for (face, x, y) in [(0, 0., 0.), (1, 1., 1.)] {
let corners = [Vec3::new(x, y, 0.), Vec3::new(x + 2., y, 0.), Vec3::new(x + 2., y + 2., 0.), Vec3::new(x, y + 2., 0.)];
mesh.positions.extend(corners);
for i in 0..4 {
mesh.edges.push(vec![corners[i], corners[(i + 1) % 4]]);
mesh.edge_faces.push(vec![face]);
}
}
let edges = family_edges(&mesh, &[0, 1].into_iter().collect(), &Plane::XY).unwrap();
assert_eq!(edges.len(), 8);
let geom: Vec<_> = edges.iter().flat_map(|edge| super::super::fit_world(&Plane::XY, &mesh.edges[*edge])).collect();
let mut sk = solvecraft_sketch::Sketch::new();
let link = sk
.add_link(solvecraft_sketch::LinkKind::Project, solvecraft_sketch::LinkSource::Face { body: "ambiguous".into(), at: Vec3::ZERO }, &geom)
.unwrap();
assert!(sk.update_link(&link, &geom).is_err());
}
#[test]
fn family_line_normalization_preserves_corners_arcs_branches_and_retracing() {
use solvecraft_geom::Vec2;
use solvecraft_sketch::LinkGeom;
let line = |a, b| LinkGeom::Line(Vec2::new(a, 0.), Vec2::new(b, 0.));
assert_eq!(super::super::merge_family_lines(vec![line(0., 1.), line(2., 1.)]).unwrap(), vec![line(0., 2.)]);
for geometry in [
vec![line(0., 1.), line(1., 0.5)],
vec![line(0., 1.), LinkGeom::Line(Vec2::new(1., 0.), Vec2::new(2., 1e-8))],
vec![line(0., 1.), line(1., 2.), LinkGeom::Line(Vec2::new(1., 0.), Vec2::new(1., 1.))],
vec![line(0., 1.), line(1., 2.), LinkGeom::Conic { a: Vec2::new(1., 0.), apex: Vec2::new(2., 1.), b: Vec2::new(3., 1.), rho: 0.5 }],
vec![
line(0., 1.),
line(1., 2.),
LinkGeom::Spline { pts: vec![Vec2::new(1., 0.), Vec2::new(2., 1.), Vec2::new(3., 1.)], control: false, degree: 2, periodic: false },
],
vec![line(0., 1.), LinkGeom::Arc { c: Vec2::new(1., 1.), a: Vec2::new(1., 0.), b: Vec2::new(2., 1.) }],
vec![line(0., 1.), line(1.00001, 2.)],
] {
assert_eq!(super::super::merge_family_lines(geometry.clone()).unwrap(), geometry);
}
}
fn rectangles() -> Mesh {
let mut mesh = Mesh::default();
for (x, y) in [(40.0, 30.0), (38.0, 28.0), (4.0, 2.0)] {
+33
View File
@@ -367,3 +367,36 @@ fn timeline_clones_share_meshes_even_when_cloned_before_tessellation() {
assert!(std::sync::Arc::ptr_eq(&a.mesh(), &b.mesh()));
assert!(!a.mesh().triangles.is_empty());
}
/// A construction plane that is its own side resolves through the model and back into the
/// document. The depth count has to survive that hop, or the recursion overflows the stack and
/// aborts the process, which no panic guard can intercept.
#[test]
fn self_referencing_construction_planes_are_errors() {
let selfref = |name: &str| Box::new(construct::GeoRef::Plane { plane: PlaneRef::Construction { name: name.into() } });
let xz = || Box::new(construct::GeoRef::Plane { plane: PlaneRef::Origin { name: "XZ".into() } });
// Directly its own first side.
let mut doc = Document::new("selfplane");
doc.add_feature(FeatureKind::ConstructionPlane { plane: PlaneRef::Midplane { a: selfref("P1"), b: xz() } }, Some("P1")).unwrap();
let mut m = Model::new();
m.evaluate(&doc);
assert!(m.results.iter().any(|r| r.error.is_some()), "a plane that is its own side must report an error: {:?}", m.results);
// Two planes that are each other's side.
let mut doc = Document::new("twoplanes");
doc.add_feature(FeatureKind::ConstructionPlane { plane: PlaneRef::Midplane { a: selfref("P2"), b: xz() } }, Some("P1")).unwrap();
doc.add_feature(FeatureKind::ConstructionPlane { plane: PlaneRef::Midplane { a: selfref("P1"), b: xz() } }, Some("P2")).unwrap();
let mut m = Model::new();
m.evaluate(&doc);
assert!(m.results.iter().any(|r| r.error.is_some()), "a two-plane cycle must report an error: {:?}", m.results);
// A perpendicular plane whose base is itself, and a sketch placed on it.
let mut doc = Document::new("perpplane");
let line = Box::new(construct::GeoRef::World { axis: "X".into() });
doc.add_feature(FeatureKind::ConstructionPlane { plane: PlaneRef::Perpendicular { plane: selfref("P1"), line } }, Some("P1")).unwrap();
doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Construction { name: "P1".into() }, sketch: Sketch::new() }, Some("Sketch1")).unwrap();
let mut m = Model::new();
m.evaluate(&doc);
assert!(m.results.iter().any(|r| r.error.is_some()), "a cyclic perpendicular plane must report an error: {:?}", m.results);
}
+8 -6
View File
@@ -10,7 +10,7 @@ fn run(s: &mut Session, command: &str, params: Value) -> Value {
}
fn check(s: &mut Session, want: f64, hole: bool, height: f64) {
s.refresh();
assert!(s.model.results.iter().all(|result| result.error.is_none()));
assert!(s.model.results.iter().all(|result| result.error.is_none() && result.warning.is_none()), "{:?}", s.model.results);
let state = s.model.state();
let got: f64 = state.bodies.iter().map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).sum();
// Legacy linked outlines fit tessellated curves; enforce the existing 1e-4 material envelope.
@@ -22,10 +22,10 @@ fn check(s: &mut Session, want: f64, hole: bool, height: f64) {
}
}
#[test]
fn generated_face_profiles_keep_certified_and_internal_legacy_links_live_after_edit_and_reopen() {
fn generated_face_profiles_keep_exact_links_live_after_edit_and_reopen() {
for hole in [false, true] {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"name":"Base"}));
let base = run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"name":"Base"}))["feature"].as_u64().unwrap();
if hole {
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.circle.center", json!({"center":[20,15],"radius":5}));
@@ -37,9 +37,11 @@ fn generated_face_profiles_keep_certified_and_internal_legacy_links_live_after_e
let FeatureKind::Extrude { sketch, .. } = pad.kind else { panic!("extrude") };
let generated = s.doc.feature(sketch).unwrap();
let FeatureKind::Sketch { plane, sketch: profile } = &generated.kind else { panic!("sketch") };
assert!(matches!(plane,PlaneRef::Face{anchor,..} if anchor.is_some()!=hole));
let PlaneRef::Face { anchor: Some(anchor), .. } = plane else { panic!("the original Box cap remains exact after a hole cut") };
assert_eq!(anchor.target.origin_feature, base);
assert!(matches!(anchor.target.certificate, Some(solvecraft_doc::config::FaceCertificate::Box { axis: 2, high: true })));
assert!(!profile.links.is_empty());
assert!(profile.links.iter().all(|link| link.auto_face != hole && !link.lost));
assert!(profile.links.iter().all(|link| link.auto_face && !link.lost));
let area = 1200.0 - if hole { 25.0 * PI } else { 0.0 };
check(&mut s, area * 30.0, hole, 30.0);
run(&mut s, "timeline.edit", json!({"feature":"Base","set":{"height":"25","length":"50"}}));
@@ -48,7 +50,7 @@ fn generated_face_profiles_keep_certified_and_internal_legacy_links_live_after_e
let edited = s.model.state();
assert!(edited.bodies.iter().any(|body| body.body.contains(Vec3::new(49.0, 5.0, 34.0))));
assert!(!edited.bodies.iter().any(|body| body.body.contains(Vec3::new(51.0, 5.0, 34.0))));
assert!(edited.sketch(sketch).unwrap().sketch.links.iter().all(|link| !link.lost));
assert!(edited.sketch(sketch).unwrap().sketch.links.iter().all(|link| link.auto_face && !link.lost));
let solved = edited.sketch(sketch).unwrap().plane;
assert!((solved.origin.z - 25.0).abs() < 1e-6);
let before = s.model.state();
+3 -15
View File
@@ -51,11 +51,7 @@ fn path_arg<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
/// Read a STEP file as an Import feature.
fn read_step(path: &str) -> Result<solvecraft_io::StepFeature> {
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta > solvecraft_io::MAX_STEP_BYTES as u64 {
return Err(EngineError::Other(format!("{path}: file too large")));
}
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let bytes = solvecraft_io::vfs::read(path, solvecraft_io::MAX_STEP_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
solvecraft_io::step_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
@@ -80,11 +76,7 @@ fn add_step(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
/// Add a MeshImport feature for a 3MF or STL file.
fn add_mesh(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta > solvecraft_io::MAX_3MF_BYTES as u64 {
return Err(EngineError::Other(format!("{path}: file too large")));
}
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let bytes = solvecraft_io::vfs::read(path, solvecraft_io::MAX_3MF_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mf = solvecraft_io::mesh_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&mf.name).to_string();
let id = s.doc_mut().add_feature(mf.kind, Some(&fname))?;
@@ -132,11 +124,7 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
return Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]}));
}
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta as usize > solvecraft_io::MAX_DESIGN_BYTES {
return Err(EngineError::Other(format!("{path}: file too large")));
}
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let bytes = solvecraft_io::vfs::read(path, solvecraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let doc = solvecraft_io::read_design(&bytes)?;
*s = Session::new(doc);
s.path = Some(path.to_string());
+4 -5
View File
@@ -226,16 +226,15 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"format": format, "path": path, "text": text}))
}
/// Largest parameter table we read from a file.
const MAX_PARAMETER_BYTES: usize = 16 << 20;
fn import(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parameters.import";
let text = match (str_(p, "text"), str_(p, "path")) {
(Some(t), _) => t.to_string(),
(None, Some(path)) => {
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta > 16 << 20 {
return Err(bad(cmd, "file too large"));
}
solvecraft_io::vfs::read_to_string(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
solvecraft_io::vfs::read_to_string(path, MAX_PARAMETER_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
}
_ => return Err(bad(cmd, "give `text` or `path`")),
};
+37 -2
View File
@@ -114,7 +114,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "CONSTRAINTS")
.icon("c_coincident")
.enabled(in_sketch)
.params("a: point, b: point or curve"),
.params("a, b: points, a point and a curve, or two distinct lines (joins their closest endpoints)"),
CommandSpec::new("sketch.constraint.tangent", "Tangent", c_tangent)
.at("SKETCH", "CONSTRAINTS")
.icon("c_tangent")
@@ -1235,13 +1235,17 @@ fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
// Already tied (e.g. connected when it was drawn there): nothing to add.
let sk = s.doc.sketch(target_sketch(s, p, cmd)?)?;
let pt = |k: &str| p.get(k).and_then(Value::as_str).and_then(|r| sk.resolve_point(r));
if let (Some(a), Some(b)) = (pt("a"), pt("b"))
let line_points = coincident_line_points(sk, p, cmd)?;
if let Some((a, b)) = line_points.or_else(|| pt("a").zip(pt("b")))
&& let Some(c) = sk.constraints.iter().find(|c| matches!(c.kind, ConstraintKind::Coincident { p, q } if (p, q) == (a, b) || (p, q) == (b, a)))
{
let rep = solve(&mut sk.clone());
return Ok(json!({"constraint": c.id, "existing": true, "sketch": {"solved": rep.ok(), "dof": rep.dof}}));
}
constrain(s, p, cmd, |sk| {
if let Some((a, b)) = line_points {
return Ok(ConstraintKind::Coincident { p: a, q: b });
}
let aref = p.get("a").and_then(Value::as_str).unwrap_or_default();
let bref = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?;
// A curve picked first and a point second: the point goes onto the curve.
@@ -1257,6 +1261,37 @@ fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// Four endpoint pairs, ordered independently of pick order when distances tie.
fn coincident_line_points(sk: &Sketch, p: &Value, cmd: &str) -> Result<Option<(usize, usize)>> {
let curve = |key: &str| p.get(key).and_then(Value::as_str).and_then(|id| sk.curve_index(id));
let (Some(a), Some(b)) = (curve("a"), curve("b")) else { return Ok(None) };
if a == b {
return Err(bad(cmd, "choose two distinct lines"));
}
let ends = |i: usize| match sk.curves.get(i).map(|curve| &curve.kind) {
Some(CurveKind::Line { a, b }) => Some([*a, *b]),
_ => None,
};
let (Some(first), Some(second)) = (ends(a), ends(b)) else {
return Err(bad(cmd, "two curves must both be straight lines; otherwise choose a point and a curve"));
};
let mut best: Option<(f64, usize, usize)> = None;
for a in first {
for b in second {
let (Some(pa), Some(pb)) = (sk.point(a), sk.point(b)) else { return Err(bad(cmd, "line endpoint is missing")) };
let candidate = (pa.dist(pb), a.min(b), a.max(b));
if best.is_none_or(|old| candidate < old) {
best = Some(candidate);
}
}
}
let (_, a, b) = best.ok_or_else(|| bad(cmd, "line endpoints are missing"))?;
if a == b {
return Err(bad(cmd, "the lines already share an endpoint"));
}
Ok(Some((a, b)))
}
fn two_curves(sk: &Sketch, p: &Value, cmd: &str) -> Result<(usize, usize)> {
Ok((curve_ref(sk, p.get("a"), cmd, "a")?, curve_ref(sk, p.get("b"), cmd, "b")?))
}
+1 -5
View File
@@ -31,11 +31,7 @@ fn image_bytes(p: &Value, cmd: &str) -> Result<Vec<u8>> {
base64_decode(d).ok_or_else(|| bad(cmd, "`data` is not base64"))?
} else {
let path = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "give `path` or `data`"))?;
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta as usize > MAX_CANVAS_BYTES {
return Err(bad(cmd, "the image is too large"));
}
solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
solvecraft_io::vfs::read(path, MAX_CANVAS_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
};
if b.len() > MAX_CANVAS_BYTES {
return Err(bad(cmd, "the image is too large"));
+181
View File
@@ -399,3 +399,184 @@ fn angled_attachment_tracks_source_plane_and_reopens() {
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert_eq!(reopened.model.state().sketch(id).unwrap().plane, placed);
}
#[test]
fn rounded_enclosure_shell_side_and_floor_attach_through_width_wall_and_reopen() {
use solvecraft_doc::{FeatureKind, config::FaceCertificate, face_anchor::plane_face_anchor};
let mut s = Session::default();
for (name, expression) in [("width", "80 mm"), ("depth", "60 mm"), ("height", "30 mm"), ("wall", "2 mm")] {
run(&mut s, "parameters.add", json!({"name":name,"expression":expression}));
}
let base = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Outline"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.rectangle.center", json!({"center":[0,0],"corner":[40,30]}));
run(&mut s, "sketch.dimension", json!({"entities":["l1"],"value":"width"}));
run(&mut s, "sketch.dimension", json!({"entities":["l2"],"value":"depth"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch":base,"distance":"height","operation":"new","body_name":"Case"}));
run(&mut s, "solid.fillet", json!({"body":"Case","edges":[[40,30,15],[-40,30,15],[-40,-30,15],[40,-30,15]],"radius":6}));
run(&mut s, "solid.shell", json!({"body":"Case","faces":[[0,0,30]],"thickness":"wall","name":"Hollow"}));
assert_eq!(s.model.state().body("Case").unwrap().body.face_count(), 19, "rounded Shell has no separate base strips");
let pick = |s: &Session, normal: Vec3, point: Vec3| {
let state = s.model.state();
let body = state.body("Case").unwrap();
let face = body
.body
.faces(0.01)
.unwrap()
.into_iter()
.find(|f| f.plane_normal.is_some_and(|n| n.dot(normal) > 0.999) && (f.centroid - point).dot(normal).abs() < 1e-6)
.unwrap();
json!({"body":"Case","index":face.index,"face":[point.x,point.y,point.z]})
};
let side_pick = pick(&s, Vec3::X, Vec3::new(40., 10., 20.));
let side = run(&mut s, "sketch.create", json!({"name":"USB","plane":side_pick,"project_edges":true}))["sketch"].as_u64().unwrap();
let port_plane = s.model.state().sketch(side).unwrap().plane;
let p0 = port_plane.to_local(Vec3::new(40., -6., 8.5));
let p1 = port_plane.to_local(Vec3::new(40., 6., 15.5));
let port_curves = run(&mut s, "sketch.rectangle.two_point", json!({"p0":[p0.x,p0.y],"p1":[p1.x,p1.y]}))["curves"].clone();
run(&mut s, "sketch.finish", json!({}));
run(
&mut s,
"solid.extrude",
json!({"sketch":side,"profiles":[{"curves":port_curves}],"distance":"wall","direction":"negative","operation":"cut","targets":["Case"],"name":"USBcut"}),
);
let floor_pick = pick(&s, Vec3::Z, Vec3::new(6., -9., 2.));
let floor = run(&mut s, "sketch.create", json!({"name":"Bosses","plane":floor_pick,"project_edges":true}))["sketch"].as_u64().unwrap();
let boss_curves = run(&mut s, "sketch.circle.center", json!({"center":[30,20],"radius":3.5}))["curves"].clone();
let centre = format!("{}.center", boss_curves[0].as_str().unwrap());
run(&mut s, "sketch.dimension", json!({"entities":["origin",centre],"type":"horizontal","value":"width / 2 - 10 mm"}));
run(&mut s, "sketch.dimension", json!({"entities":["origin",centre],"type":"vertical","value":"depth / 2 - 10 mm"}));
run(&mut s, "sketch.finish", json!({}));
run(
&mut s,
"solid.extrude",
json!({"sketch":floor,"profiles":[{"curves":boss_curves}],"distance":12,"operation":"join","targets":["Case"],"name":"Boss"}),
);
run(
&mut s,
"solid.pattern.rectangular",
json!({"features":["Boss"],"dir1":[-1,0,0],"count1":2,"spacing1":"width - 20 mm","dir2":[0,-1,0],"count2":2,"spacing2":"depth - 20 mm","name":"BossPattern"}),
);
for id in [side, floor] {
let FeatureKind::Sketch { plane, .. } = &s.doc.feature(id).unwrap().kind else { panic!() };
assert!(matches!(plane_face_anchor(plane).unwrap().target.certificate, Some(FaceCertificate::ShellPlanar { .. })));
}
run(&mut s, "parameters.change", json!({"name":"width","expression":"100 mm"}));
run(&mut s, "parameters.change", json!({"name":"wall","expression":"3 mm"}));
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
for session in [&s, &reopened] {
for id in [side, floor] {
let result = session.model.result(id).unwrap();
assert!(result.error.is_none(), "{}: {:?}", result.name, result.error);
assert!(result.warning.is_none(), "{}: {:?}", result.name, result.warning);
assert!(
session.model.state().sketch(id).unwrap().sketch.links.iter().all(|link| !link.lost),
"projected boundary must resolve current geometry"
);
}
let state = session.model.state();
assert!(state.body("Case").is_some());
assert!((state.sketch(side).unwrap().plane.origin.x - 50.).abs() < 1e-6);
assert!((state.sketch(floor).unwrap().plane.origin.z - 3.).abs() < 1e-6);
assert!(state.sketch(floor).unwrap().plane.normal().dot(Vec3::Z) > 0.999);
let mesh = state.body("Case").unwrap().body.tessellate_with(1e-4, true).unwrap();
let case = &state.body("Case").unwrap().body;
let corners: Vec<_> = (0..case.face_count()).filter(|i| matches!(solvecraft_kernel::face_surface(case, *i), Some(solvecraft_kernel::FaceSurface::Cylinder { radius, .. }) if (radius - 6.).abs() < 1e-6)).collect();
assert_eq!(corners.len(), 4, "later port/boss features must not subdivide untouched outer corners");
for index in corners {
let points: Vec<_> = mesh
.edge_faces
.iter()
.enumerate()
.filter(|(_, faces)| faces.contains(&(index as u32)))
.flat_map(|(edge, _)| &mesh.edges[edge])
.collect();
assert!(points.iter().any(|p| p.z.abs() < 1e-8));
assert!(points.iter().any(|p| (p.z - 30.).abs() < 1e-8));
}
assert!(!mesh.contains(Vec3::new(49., 0., 12.)), "USB must remain open after resize/reopen");
assert!(mesh.contains(Vec3::new(49., 15., 12.)), "adjacent right wall remains solid");
assert!(mesh.contains(Vec3::new(-49., 0., 12.)), "opposite wall must stay solid");
let seed = state.sketch(floor).unwrap().plane.to_world(solvecraft_geom::Vec2::new(40., 20.)) + Vec3::Z * 6.;
assert!(mesh.contains(seed), "seed boss starts on current floor");
for shift in [Vec3::ZERO, Vec3::new(-80., 0., 0.), Vec3::new(0., -40., 0.), Vec3::new(-80., -40., 0.)] {
assert!(mesh.contains(seed + shift), "each patterned boss must survive resize/reopen at {:?}", seed + shift);
}
assert!(!mesh.contains(Vec3::new(0., 0., 6.)), "the cavity remains hollow");
let area = |w: f64, d: f64, r: f64| w * d - (4. - std::f64::consts::PI) * r * r;
let expected = area(100., 60., 6.) * 30. - area(94., 54., 3.) * 27. - 12. * 7. * 3. + 4. * std::f64::consts::PI * 3.5 * 3.5 * 12.;
assert!((mesh.measure().volume - expected).abs() < 0.1, "actual {}, analytic {expected}", mesh.measure().volume);
}
run(&mut s, "timeline.suppress", json!({"feature":"Hollow","suppressed":true}));
assert!(s.model.result(side).unwrap().error.is_some());
assert!(s.model.result(floor).unwrap().error.is_some());
}
#[test]
fn offset_rectangle_pocket_has_one_logical_outer_wall_and_closed_face_projection() {
let mut s = Session::default();
let base = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.rectangle.center", json!({"center":[0,0],"corner":[30,27]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch":base,"distance":10,"operation":"new","body_name":"Stock"}));
let control_sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[100,100],"p1":[104,104]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch":control_sketch,"distance":10,"operation":"new","body_name":"Control"}));
let untouched = s.model.state().body("Control").unwrap().body.clone();
let before_control = solvecraft_kernel::step_export(&[&untouched], "control snapshot").unwrap();
let pick = top_pick(&s, "Stock");
let pocket = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap();
let side = {
let state = s.model.state();
let sk = &state.sketch(pocket).unwrap().sketch;
let curve = sk.curves.iter().find(|c| c.id == "l1").unwrap();
let solvecraft_sketch::CurveKind::Line { a, b } = curve.kind else { panic!() };
sk.point(a).unwrap() + sk.point(b).unwrap()
};
let outer = run(&mut s, "sketch.offset", json!({"curves":["l1"],"distance":0.3425231845240546,"side":[side.x,side.y]}))["curves"].clone();
let inner = run(&mut s, "sketch.offset", json!({"curves":[outer[0]],"distance":5.609348914858092,"side":[0,0]}))["curves"].clone();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch":pocket,"profiles":[{"curves":inner}],"distance":-9,"operation":"cut"}));
let control = s.model.state().body("Control").unwrap().body.clone();
assert!(control.same(&untouched), "a separate body crossing the cutter end plane remains the exact same solid");
assert_eq!(solvecraft_kernel::step_export(&[&control], "control snapshot").unwrap(), before_control);
let state = s.model.state();
let body = state.body("Stock").unwrap();
let display = body.body.display_mesh(0.01).unwrap();
let face = body
.body
.faces(0.01)
.unwrap()
.into_iter()
.find(|face| face.plane_normal.is_some_and(|n| n.dot(Vec3::X) > 0.999) && (face.centroid.x - 30.).abs() < 1e-6)
.unwrap();
assert_eq!(body.body.faces(0.01).unwrap().len(), 11, "actual pocket topology has no outer floor-height strips");
assert_eq!(display.surface_patch(face.index).len(), 1);
let attached = run(&mut s, "sketch.create", json!({"plane":{"body":"Stock","index":face.index,"face":[30,0,0.5]}}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.finish", json!({}));
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
for session in [&s, &reopened] {
let result = session.model.result(attached).unwrap();
assert!(result.error.is_none(), "{:?}", result.error);
assert!(result.warning.is_none(), "{:?}", result.warning);
let state = session.model.state();
let sketch = &state.sketch(attached).unwrap().sketch;
assert!(sketch.links.iter().all(|link| !link.lost));
assert_eq!(sketch.curves.len(), 4, "whole outer-wall rectangle has no floor-height stations");
let mesh = state.body("Stock").unwrap().body.display_mesh(0.01).unwrap();
assert!(mesh.contains(Vec3::new(0., 0., 0.5)));
assert!(!mesh.contains(Vec3::new(0., 0., 5.)));
assert!(mesh.contains(Vec3::new(28., 0., 5.)));
let control = &state.body("Control").unwrap().body;
assert_eq!(control.faces(0.01).unwrap().len(), 6);
assert_eq!(control.edges(0.01).unwrap().len(), 12);
let control_mesh = control.tessellate(0.01).unwrap();
assert!((control_mesh.measure().volume - 160.).abs() < 1e-6);
assert!(control_mesh.contains(Vec3::new(102., 102., 0.5)));
assert!(control_mesh.contains(Vec3::new(102., 102., 5.)));
let expected = 60. * 54. * 10. - 4. * 24.73317426966596 * 21.733174269665962 * 9.;
assert!((mesh.measure().volume - expected).abs() < 1e-6, "actual {}, expected {expected}", mesh.measure().volume);
}
}
+1 -5
View File
@@ -34,11 +34,7 @@ fn source_text(p: &Value, cmd: &str) -> Result<String> {
return Ok(t.to_string());
}
let path = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "give `path` or `text`"))?;
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta as usize > MAX_DRAWING_BYTES {
return Err(EngineError::Other(format!("{path}: file too large")));
}
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let bytes = solvecraft_io::vfs::read(path, MAX_DRAWING_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(String::from_utf8_lossy(&bytes).to_string())
}
+33
View File
@@ -401,6 +401,39 @@ fn point_constraints_keep_the_first_pick() {
assert!(pt(&s, "p2").dist(solvecraft_geom::Vec2::new(3.0, -7.0)) < 1e-6, "{:?}", pt(&s, "p2"));
}
#[test]
fn coincident_line_pairs_resolve_ties_independently_of_pick_order() {
for (a, b) in [("l1", "l2"), ("l2", "l1")] {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points":[[10,10],[30,10]],"infer":false,"connect":false}));
run(&mut s, "sketch.line", json!({"points":[[10,20],[30,20]],"infer":false,"connect":false}));
run(&mut s, "sketch.constraint.coincident", json!({"a":a,"b":b}));
let sk = sketch(&s);
let p = sk.resolve_point("l1.start").unwrap();
let q = sk.resolve_point("l2.start").unwrap();
assert!(sk.point(p).unwrap().dist(sk.point(q).unwrap()) < 1e-6);
let count = sk.constraints.len();
let result = run(&mut s, "sketch.constraint.coincident", json!({"a":b,"b":a}));
assert_eq!(result["existing"], true);
assert_eq!(sketch(&s).constraints.len(), count);
}
}
#[test]
fn coincident_refuses_invalid_curve_pairs_atomically() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points":[[10,10],[30,10]],"infer":false,"connect":false}));
run(&mut s, "sketch.circle.center", json!({"center":[40,40],"radius":5}));
let before = serde_json::to_value(&s.doc).unwrap();
let undo = s.undo.len();
for (a, b, message) in [("l1", "l1", "distinct lines"), ("l1", "c1", "straight lines"), ("c1", "l1", "straight lines")] {
let error = s.execute("sketch.constraint.coincident", &json!({"a":a,"b":b})).unwrap_err();
assert!(error.to_string().contains(message), "{error}");
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
assert_eq!(s.undo.len(), undo);
}
}
// ---------------------------------------------------------------------------------------------
// Sketch Palette options
+4
View File
@@ -33,6 +33,10 @@ pub use solvecraft_kernel as kernel;
pub use solvecraft_render as render;
pub use solvecraft_sketch as sketch;
/// Longest accepted request line on the JSON-lines channels (MCP over stdio and the control
/// server over loopback), so both refuse runaway input at the same size.
pub const MAX_LINE: usize = 16 << 20;
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
#[error("unknown command `{0}`")]
+3 -1
View File
@@ -100,7 +100,9 @@ pub fn load(dir: &Path, id: &str) -> Result<(Document, Entry)> {
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.ok_or_else(|| EngineError::Other(format!("no recovered design `{id}`")))?;
let bytes = fs::read(file(dir, id, "solvecraft")).map_err(|e| EngineError::Other(format!("recovered design `{id}`: {e}")))?;
let path = file(dir, id, "solvecraft");
let bytes = solvecraft_io::vfs::read(&path.to_string_lossy(), solvecraft_io::MAX_DESIGN_BYTES)
.map_err(|e| EngineError::Other(format!("recovered design `{id}`: {e}")))?;
Ok((solvecraft_io::read_design(&bytes)?, entry))
}
+109 -14
View File
@@ -23,28 +23,97 @@ fn rel(a: f64, b: f64) -> f64 {
#[test]
fn join_extrude_from_a_chamfered_top_face() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name":"height","expression":"20 mm"}));
run(&mut s, "parameters.add", json!({"name":"bevel","expression":"2 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
run(&mut s, "solid.extrude", json!({"distance": 20}));
run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 20]], "distance": 2}));
let source = run(&mut s, "solid.extrude", json!({"distance": "height","name":"Stem"}))["feature"].as_u64().unwrap();
run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 20]], "distance": "bevel"}));
let base = volume(&mut s);
let before = s.doc.to_json();
let active = s.active_sketch;
assert!(s.execute("sketch.create", &json!({"plane":{"face":[0,0,20]}})).is_err(), "modified faces require an explicit fixed plane");
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.active_sketch, active);
assert!(rel(volume(&mut s), base) < 1e-12);
let id = run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20],"fixed":true}}))["sketch"].as_u64().unwrap();
let solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Custom { plane }, sketch } = &s.doc.feature(id).unwrap().kind else {
panic!("explicit fixed face must become a custom plane");
let id = run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20]}}))["sketch"].as_u64().unwrap();
let solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Face { anchor: Some(anchor), .. }, sketch } =
&s.doc.feature(id).unwrap().kind
else {
panic!("the chamfered cap retains its exact original construction support");
};
assert!((plane.origin.z - 20.).abs() < 1e-9);
assert!(sketch.links.is_empty(), "fresh fixed plane does not create live auto-projections");
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":8}));
assert_eq!(anchor.target.origin_feature, source);
assert!(matches!(anchor.target.certificate, Some(solvecraft_doc::config::FaceCertificate::ExtrudeCap { high: true, .. })));
assert!(!sketch.links.is_empty());
assert!(sketch.links.iter().all(|link| link.auto_face && !link.lost));
// The default linked cap outline is radius8; no explicit fixed plane or
// duplicate manually drawn circle is required to join a pad to that face.
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
assert_eq!(s.model.state().bodies.len(), 1);
assert!(rel(volume(&mut s), base + PI * 64.0 * 5.0) < 5e-4);
for (height, bevel) in [(20., 2.), (25., 3.)] {
run(&mut s, "parameters.change", json!({"name":"height","expression":format!("{height} mm")}));
run(&mut s, "parameters.change", json!({"name":"bevel","expression":format!("{bevel} mm")}));
let expected = PI * (100. * height - (10. * bevel * bevel - bevel * bevel * bevel / 3.) + (10. - bevel) * (10. - bevel) * 5.);
let loaded = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let mut cold = Session::new(loaded);
let warm_volume = volume(&mut s);
let cold_volume = volume(&mut cold);
assert!(rel(warm_volume, expected) < 5e-4, "{warm_volume} vs {expected}");
assert!((warm_volume - cold_volume).abs() < 1e-8);
for current in [&s, &cold] {
assert!(current.model.results.iter().all(|result| result.error.is_none() && result.warning.is_none()), "{:?}", current.model.results);
let state = current.model.state();
assert_eq!(state.bodies.len(), 1);
let solved = state.sketch(id).unwrap();
assert!((solved.plane.origin.z - height).abs() < 1e-8);
assert!(solved.sketch.links.iter().all(|link| link.auto_face && !link.lost));
let body = &state.bodies[0];
assert!(body.body.contains(solvecraft_geom::Vec3::new(0., 0., height + 4.)));
assert!(!body.body.contains(solvecraft_geom::Vec3::new(0., 0., height + 6.)));
assert!(body.body.contains(solvecraft_geom::Vec3::new(10. - bevel - 0.5, 0., height + 4.)));
assert!(!body.body.contains(solvecraft_geom::Vec3::new(10. - bevel + 0.5, 0., height + 4.)));
}
}
}
/// A join that bridges two bodies makes them one body, not an overlap of the first and the second.
#[test]
fn join_bridging_two_bodies_merges_them() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new_body"}));
run(&mut s, "solid.box", json!({"length": 20, "width": 4, "height": 4, "corner": [5, 3, 3], "operation": "join"}));
let st = s.model.state();
assert_eq!(st.bodies.len(), 1, "both boxes and the bar are one body");
assert_eq!(st.bodies[0].name, "Body1");
assert_eq!(st.bodies[0].body.lumps().unwrap().len(), 1);
// 2 boxes + the bar's 10 mm outside them.
assert!(rel(volume(&mut s), 2000.0 + 10.0 * 16.0) < 1e-6);
}
/// A join touching only the second body merges into that body and leaves the first alone.
#[test]
fn join_merges_into_the_body_it_meets() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new_body"}));
// Sits on Body2's top face: a face contact, no shared volume.
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [23, 3, 10], "operation": "join"}));
let st = s.model.state();
assert_eq!(st.bodies.len(), 2);
let b1 = st.bodies.iter().find(|b| b.name == "Body1").unwrap();
let b2 = st.bodies.iter().find(|b| b.name == "Body2").unwrap();
assert_eq!(b1.body.face_count(), 6, "Body1 is untouched");
assert_eq!(b2.body.lumps().unwrap().len(), 1);
assert!(rel(volume(&mut s), 2064.0) < 1e-6);
}
/// A join that meets no body still joins the first body, as a separate lump.
#[test]
fn join_meeting_nothing_keeps_the_first_body() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [40, 0, 0], "operation": "join"}));
let st = s.model.state();
assert_eq!(st.bodies.len(), 1);
assert_eq!(st.bodies[0].body.lumps().unwrap().len(), 2);
assert!(rel(volume(&mut s), 1064.0) < 1e-6);
}
mod component_frames;
@@ -2255,3 +2324,29 @@ fn adopting_a_computed_component_keeps_its_warm_world_geometry() {
s.invalidate_view(100);
assert!(std::sync::Arc::ptr_eq(&prepared, &s.world_state()));
}
/// Every file command checked the size with the metadata and then read to the end. A character
/// device reports a length of 0, so the cap passed and the read never finished; a FIFO blocked
/// the calling thread, which over the control channel is the thread drawing the window.
#[cfg(unix)]
#[test]
fn special_files_are_refused_by_every_file_command() {
let mut s = Session::default();
for cmd in ["doc.open", "parameters.import"] {
let r = s.execute(cmd, &json!({"path": "/dev/zero"}));
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("not a regular file")), "{cmd}: {r:?}");
}
// The commands that check the extension first need a special file with the right name.
let dir = std::env::temp_dir().join(format!("solvecraft-special-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
for (cmd, name) in [("file.insert_step", "x.step"), ("file.insert_mesh", "x.3mf"), ("sketch.insert_dxf", "x.dxf")] {
let p = dir.join(name);
let _ = std::fs::remove_file(&p);
if !std::process::Command::new("mkfifo").arg(&p).status().map(|st| st.success()).unwrap_or(false) {
continue;
}
let r = s.execute(cmd, &json!({"path": p.to_string_lossy()}));
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("not a regular file")), "{cmd}: {r:?}");
}
let _ = std::fs::remove_dir_all(&dir);
}
+41 -7
View File
@@ -90,7 +90,7 @@ pub fn read_dxf(text: &str) -> Result<Vec<Geom2>> {
}
}
let mut out = Vec::new();
convert(&ents, k, &blocks, 0, &mut out)?;
convert(&ents, k, &blocks, 0, &mut out, &mut 0)?;
Ok(out)
}
@@ -120,16 +120,22 @@ fn section(p: &[(i32, String)], start: usize) -> Result<Vec<Ent>> {
/// Deepest block nesting expanded.
const MAX_BLOCK_DEPTH: usize = 16;
/// Entities visited and array cells placed while expanding blocks. Every bound below this one
/// is on the geometry that comes out, and a block that emits none trips none of them: N blocks
/// that each insert the same empty block N times fan out to N^MAX_BLOCK_DEPTH visits for no
/// output at all. This counts the work instead.
const MAX_WORK: usize = 4 * MAX_ENTITIES;
/// Entities to geometry (millimetres), expanding block references.
fn convert(ents: &[Ent], k: f64, blocks: &HashMap<String, (Vec2, Vec<Ent>)>, depth: usize, out: &mut Vec<Geom2>) -> Result<()> {
fn convert(ents: &[Ent], k: f64, blocks: &HashMap<String, (Vec2, Vec<Ent>)>, depth: usize, out: &mut Vec<Geom2>, work: &mut usize) -> Result<()> {
let pt = |x: f64, y: f64| Vec2::new(x * k, y * k);
let mut i = 0;
while i < ents.len() {
let Some(e) = ents.get(i) else { break };
i += 1;
if out.len() > MAX_ENTITIES {
return Err(IoError::Invalid("too many entities".into()));
*work = work.saturating_add(1);
if out.len() > MAX_ENTITIES || *work > MAX_WORK {
return Err(IoError::Invalid("the drawing is too complex".into()));
}
// Paper space (layouts) is not the drawing.
if e.get(67).is_some_and(|v| v != 0.0) {
@@ -142,13 +148,18 @@ fn convert(ents: &[Ent], k: f64, blocks: &HashMap<String, (Vec2, Vec<Ent>)>, dep
continue;
}
let mut inner = Vec::new();
convert(list, k, blocks, depth + 1, &mut inner)?;
convert(list, k, blocks, depth + 1, &mut inner, work)?;
if inner.is_empty() {
continue;
}
let (sx, sy) = (e.get(41).unwrap_or(1.0), e.get(42).unwrap_or(1.0));
let rot = e.get(50).unwrap_or(0.0).to_radians();
let ins = pt(e.get(10).unwrap_or(0.0), e.get(20).unwrap_or(0.0));
let (cols, rows) = (e.get(70).unwrap_or(1.0).clamp(1.0, 10_000.0) as usize, e.get(71).unwrap_or(1.0).clamp(1.0, 10_000.0) as usize);
if cols.saturating_mul(rows).saturating_mul(inner.len()) > MAX_ENTITIES {
return Err(IoError::Invalid("too many entities".into()));
let cells = cols.saturating_mul(rows);
*work = work.saturating_add(cells);
if cells > MAX_ENTITIES || cells.saturating_mul(inner.len()) > MAX_ENTITIES || *work > MAX_WORK {
return Err(IoError::Invalid("the drawing is too complex".into()));
}
let (dc, dr) = (e.get(44).unwrap_or(0.0) * k, e.get(45).unwrap_or(0.0) * k);
for r in 0..rows {
@@ -492,4 +503,27 @@ mod tests {
assert_eq!(read_dxf(selfref).unwrap().len(), 16);
assert!(read_dxf("AutoCAD Binary DXF\r\n").is_err());
}
/// Bounds on the geometry that comes out do not see a block that emits none: six
/// self-inserts fan out to 6^16 visits for no output. Both shapes must be refused quickly.
#[test]
fn empty_blocks_do_not_fan_out() {
let t = std::time::Instant::now();
let fanout = format!(
"0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n2\nA\n{}0\nENDBLK\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nINSERT\n2\nA\n0\nENDSEC\n0\nEOF\n",
"0\nINSERT\n2\nA\n".repeat(6)
);
assert!(read_dxf(&fanout).is_err(), "a block of six self-inserts must be refused");
// An empty block placed as a 10,000 by 10,000 array, a thousand times over.
let array = format!(
"0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n2\nE\n0\nENDBLK\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n{}0\nENDSEC\n0\nEOF\n",
"0\nINSERT\n2\nE\n70\n10000\n71\n10000\n".repeat(1000)
);
let g = read_dxf(&array).unwrap_or_default();
assert!(g.is_empty(), "an empty block has no geometry to place");
assert!(t.elapsed() < std::time::Duration::from_secs(5), "took {:?}", t.elapsed());
}
}
+68 -7
View File
@@ -528,20 +528,44 @@ fn flatten(m: &Model, id: u32, xf: &Xf, out: &mut MeshObject, stack: &mut Vec<u3
return bad("components nested too deeply or recursive");
}
let o = m.objects.get(&id).ok_or_else(|| IoError::Invalid(format!("3MF: missing object {id}")))?;
*total += o.triangles.len();
*total = total.saturating_add(o.triangles.len());
if *total > MAX_ELEMENTS {
return bad("too many triangles after placing components");
}
let base = u32::try_from(out.positions.len()).map_err(|_| IoError::Invalid("3MF: too many vertices".into()))?;
let n = o.positions.len();
out.positions.extend(o.positions.iter().map(|p| apply(xf, *p) * m.unit));
// One object can be placed by thousands of build items and components, and each placement
// needs its own transformed copy of the vertices. Copying only the vertices the triangles
// reach keeps that cost tied to the triangle budget above; copying the whole vertex array
// let an object with many vertices and one triangle multiply into tens of gigabytes.
let mut used: Vec<u32> = o.triangles.iter().flatten().copied().collect();
used.sort_unstable();
used.dedup();
if used.last().is_some_and(|v| *v as usize >= n) {
return bad(format!("object {id}: triangle refers to a missing vertex"));
}
let base = u32::try_from(out.positions.len()).map_err(|_| IoError::Invalid("3MF: too many vertices".into()))?;
if base as usize + used.len() > u32::MAX as usize {
return bad("too many vertices");
}
for v in &used {
let Some(p) = o.positions.get(*v as usize) else {
return bad(format!("object {id}: triangle refers to a missing vertex"));
};
out.positions.push(apply(xf, *p) * m.unit);
}
// Vertices keep their relative order, so a mesh whose triangles use all of them is
// numbered exactly as it was in the file.
let at = |v: u32| used.binary_search(&v).ok().map(|i| base + i as u32);
let flip = det(xf) < 0.0;
for t in &o.triangles {
if t.iter().any(|v| *v as usize >= n) {
return bad(format!("object {id}: triangle refers to a missing vertex"));
let mut q = [0u32; 3];
for (slot, v) in q.iter_mut().zip(t) {
let Some(i) = at(*v) else {
return bad(format!("object {id}: triangle refers to a missing vertex"));
};
*slot = i;
}
let t = t.map(|v| v + base);
out.triangles.push(if flip { [t[0], t[2], t[1]] } else { t });
out.triangles.push(if flip { [q[0], q[2], q[1]] } else { q });
}
if out.color.is_none() {
out.color = o.material.and_then(|(g, i)| m.materials.get(&g).and_then(|g| g.get(i).copied().flatten()));
@@ -734,4 +758,41 @@ mod tests {
.is_err()
);
}
/// Every placement needs its own transformed copy of the vertices, so an object with many
/// vertices and one triangle used to cost its whole vertex array per build item: 100k
/// vertices times 10,000 items asked for about 140 GB and the process aborted on the
/// allocation, which no panic guard can intercept.
#[test]
fn many_placements_of_a_big_object_stay_bounded() {
let t = std::time::Instant::now();
let verts: String = (0..100_000).map(|i| format!("<vertex x=\"{}\" y=\"{}\" z=\"0\"/>", i % 100, i / 100)).collect();
let model = format!(
"{}<object id=\"1\" type=\"model\"><mesh><vertices>{verts}</vertices>\
<triangles><triangle v1=\"0\" v2=\"1\" v3=\"2\"/></triangles></mesh></object></resources><build>{}</build></model>",
r#"<model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources>"#,
"<item objectid=\"1\"/>".repeat(10_000)
);
let (objs, _) = read_3mf(&package(&model, None)).unwrap();
assert_eq!(objs.len(), 10_000);
// Only the three vertices the one triangle reaches are copied per placement.
assert!(objs.iter().all(|o| o.positions.len() == 3 && o.triangles.len() == 1), "{:?}", objs.first().map(|o| o.positions.len()));
assert!(t.elapsed() < std::time::Duration::from_secs(30), "took {:?}", t.elapsed());
}
/// Triangles are budgeted across every placement, so fan-out cannot outrun the cap.
#[test]
fn placements_share_the_triangle_budget() {
let verts: String = (0..30_000).map(|i| format!("<vertex x=\"{}\" y=\"0\" z=\"0\"/>", i % 100)).collect();
let tris: String = (0..10_000).map(|i| format!("<triangle v1=\"{}\" v2=\"{}\" v3=\"{}\"/>", 3 * i, 3 * i + 1, 3 * i + 2)).collect();
let model = format!(
"{}<object id=\"1\" type=\"model\"><mesh><vertices>{verts}</vertices><triangles>{tris}</triangles></mesh></object>\
</resources><build>{}</build></model>",
r#"<model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources>"#,
"<item objectid=\"1\"/>".repeat(4_000)
);
let (objs, warnings) = read_3mf(&package(&model, None)).unwrap();
assert!(objs.len() < 4_000, "the shared budget must stop the later items: {} built", objs.len());
assert!(warnings.iter().any(|w| w.contains("too many triangles")), "{warnings:?}");
}
}
+73 -9
View File
@@ -9,8 +9,30 @@ use std::io::{Error, ErrorKind, Result};
mod imp {
use super::*;
pub fn read(path: &str) -> Result<Vec<u8>> {
std::fs::read(path)
/// At most `max` bytes of a regular file. A character device such as `/dev/zero` reports a
/// length of 0, so checking the metadata and then reading to the end is unbounded; a FIFO
/// blocks the caller instead. Refuse anything that is not a regular file, and never read
/// more than the caller's cap plus the byte that proves it was exceeded.
pub fn read(path: &str, max: usize) -> Result<Vec<u8>> {
// Cheap and non-blocking: refuse before opening, in case the open itself would block.
let meta = std::fs::metadata(path)?;
if !meta.is_file() {
return Err(Error::new(ErrorKind::InvalidInput, "not a regular file"));
}
if meta.len() > max as u64 {
return Err(Error::new(ErrorKind::InvalidData, "file too large"));
}
let f = std::fs::File::open(path)?;
// The path may have been swapped between the two calls; the handle decides.
if !f.metadata()?.is_file() {
return Err(Error::new(ErrorKind::InvalidInput, "not a regular file"));
}
let mut out = Vec::with_capacity(meta.len().min(16 << 20) as usize);
std::io::Read::read_to_end(&mut std::io::Read::take(f, max as u64 + 1), &mut out)?;
if out.len() > max {
return Err(Error::new(ErrorKind::InvalidData, "file too large"));
}
Ok(out)
}
pub fn write(path: &str, bytes: &[u8]) -> Result<()> {
std::fs::write(path, bytes)
@@ -42,8 +64,13 @@ mod imp {
Error::new(ErrorKind::NotFound, format!("{path}: no such file in this browser"))
}
pub fn read(path: &str) -> Result<Vec<u8>> {
FILES.with(|f| f.borrow().get(path).cloned()).ok_or_else(|| missing(path))
/// At most `max` bytes, to match the native signature.
pub fn read(path: &str, max: usize) -> Result<Vec<u8>> {
let bytes = FILES.with(|f| f.borrow().get(path).cloned()).ok_or_else(|| missing(path))?;
if bytes.len() > max {
return Err(Error::new(ErrorKind::InvalidData, "file too large"));
}
Ok(bytes)
}
pub fn write(path: &str, bytes: &[u8]) -> Result<()> {
FILES.with(|f| f.borrow_mut().insert(path.to_string(), bytes.to_vec()));
@@ -77,9 +104,9 @@ mod imp {
pub use imp::*;
/// A file's text.
pub fn read_to_string(path: &str) -> Result<String> {
String::from_utf8(read(path)?).map_err(|e| Error::new(ErrorKind::InvalidData, e))
/// A file's text, at most `max` bytes.
pub fn read_to_string(path: &str, max: usize) -> Result<String> {
String::from_utf8(read(path, max)?).map_err(|e| Error::new(ErrorKind::InvalidData, e))
}
#[cfg(test)]
@@ -90,13 +117,50 @@ mod tests {
let _ = std::fs::create_dir_all(&dir);
let p = dir.join("a.txt").to_string_lossy().to_string();
assert!(!super::exists(&p));
assert!(super::read(&p).is_err());
assert!(super::read(&p, 16).is_err());
super::write(&p, b"hello").unwrap();
assert!(super::exists(&p));
assert_eq!(super::len(&p).unwrap(), 5);
assert_eq!(super::read_to_string(&p).unwrap(), "hello");
assert_eq!(super::read_to_string(&p, 16).unwrap(), "hello");
// The cap is the caller's, not the file's.
assert!(super::read(&p, 4).is_err());
assert_eq!(super::read(&p, 5).unwrap(), b"hello");
super::remove(&p).unwrap();
assert!(!super::exists(&p));
let _ = std::fs::remove_dir_all(&dir);
}
/// `metadata().len()` is 0 for a character device and for a FIFO, so a cap checked against
/// it passes and the read then runs to an end that never comes. Refuse what is not a
/// regular file, and do it before opening: opening a FIFO blocks until a writer appears.
#[cfg(all(unix, not(target_arch = "wasm32")))]
#[test]
fn special_files_are_refused_without_blocking() {
let e = super::read("/dev/zero", 1024).unwrap_err();
assert!(e.to_string().contains("not a regular file"), "{e}");
assert!(super::read_to_string("/dev/zero", 1024).is_err());
let dir = std::env::temp_dir().join(format!("solvecraft-vfs-fifo-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let fifo = dir.join("p");
let made = std::process::Command::new("mkfifo").arg(&fifo).status().map(|s| s.success()).unwrap_or(false);
if made {
let path = fifo.to_string_lossy().to_string();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(super::read(&path, 1024).is_err());
});
let refused = rx.recv_timeout(std::time::Duration::from_secs(5));
assert_eq!(refused, Ok(true), "reading a FIFO must be refused, not block");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A directory is not a file either.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn directories_are_not_files() {
let dir = std::env::temp_dir().to_string_lossy().to_string();
assert!(super::read(&dir, 1024).is_err());
}
}
+28 -3
View File
@@ -5,6 +5,10 @@ use truck_modeling as mt;
use crate::{KernelError, Result, guard};
#[path = "planar_regularize.rs"]
mod planar_regularize;
#[path = "planar_sources.rs"]
mod planar_sources;
#[path = "shell_groups.rs"]
mod shell_groups;
@@ -33,11 +37,12 @@ pub struct Body {
#[derive(Clone, Debug, PartialEq)]
pub enum FaceSource {
ExtrudeSide { segment: solvecraft_geom::Seg2, plane: Option<solvecraft_geom::Plane> },
ExtrudeSide { segment: solvecraft_geom::Seg2, plane: Option<solvecraft_geom::Plane>, coalesced: bool },
CertifiedExtrudeSide { producer: u64, sketch: u64, curve: String, plane: Option<solvecraft_geom::Plane> },
ExtrudeCap { high: bool, region: solvecraft_geom::Region2, plane: solvecraft_geom::Plane },
CertifiedExtrudeCap { producer: u64, sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>>, plane: solvecraft_geom::Plane },
Box { producer: Option<u64>, axis: u8, high: bool, plane: solvecraft_geom::Plane },
ShellPlanar { producer: Option<u64>, source: Box<FaceSource>, inner: bool, plane: solvecraft_geom::Plane },
Imported { item: u64, face: u64, payload_sha256: Option<String>, producer: Option<u64> },
}
@@ -48,7 +53,9 @@ impl FaceSource {
fn plane_mut(&mut self) -> Option<&mut solvecraft_geom::Plane> {
match self {
Self::ExtrudeSide { plane, .. } | Self::CertifiedExtrudeSide { plane, .. } => plane.as_mut(),
Self::ExtrudeCap { plane, .. } | Self::CertifiedExtrudeCap { plane, .. } | Self::Box { plane, .. } => Some(plane),
Self::ExtrudeCap { plane, .. } | Self::CertifiedExtrudeCap { plane, .. } | Self::Box { plane, .. } | Self::ShellPlanar { plane, .. } => {
Some(plane)
}
Self::Imported { .. } => None,
}
}
@@ -358,6 +365,12 @@ impl Body {
self.face_sources.as_deref().map(Vec::as_slice)
}
/// A current wall reconstructed from an exact contiguous section run.
/// This transient witness grants no durable source/producer identity.
pub fn face_was_coalesced(&self, index: usize) -> bool {
matches!(self.face_sources().and_then(|s| s.get(index)), Some(Some(FaceSource::ExtrudeSide { coalesced: true, .. })))
}
/// An exact construction/import plane; never inferred from tessellation.
pub fn face_plane(&self, index: usize) -> Option<solvecraft_geom::Plane> {
if index >= 50_000 {
@@ -366,7 +379,10 @@ impl Body {
let source = self.face_sources()?.get(index)?.as_ref()?;
match source {
FaceSource::ExtrudeSide { plane, .. } | FaceSource::CertifiedExtrudeSide { plane, .. } => *plane,
FaceSource::ExtrudeCap { plane, .. } | FaceSource::CertifiedExtrudeCap { plane, .. } | FaceSource::Box { plane, .. } => Some(*plane),
FaceSource::ExtrudeCap { plane, .. }
| FaceSource::CertifiedExtrudeCap { plane, .. }
| FaceSource::Box { plane, .. }
| FaceSource::ShellPlanar { plane, .. } => Some(*plane),
FaceSource::Imported { .. } => self.planar_face(index),
}
}
@@ -884,6 +900,15 @@ impl Body {
self.lumps_with_clearance(0.0)
}
/// Whether this body and `other` share material or touch (within a fine mesh tolerance).
pub fn meets(&self, other: &Body) -> Result<bool> {
self.require_brep("contact test")?;
other.require_brep("contact test")?;
let tol = (self.size().max(other.size()) * 1e-4).max(1e-6);
let (a, b) = (self.tessellate(tol)?, other.tessellate(tol)?);
shell_groups::meshes_meet(&a, &b, 2.0 * tol)
}
/// A boolean shortcut needs clearance larger than both shell mesh deflections.
pub(crate) fn separated_lumps(&self) -> Result<Vec<Body>> {
self.lumps_with_clearance(4.0 * (self.size() * 1e-3).max(1e-3))
+1 -1
View File
@@ -179,7 +179,7 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
}
_ => None,
};
Some(crate::FaceSource::ExtrudeSide { segment: **segment, plane: basis })
Some(crate::FaceSource::ExtrudeSide { segment: **segment, plane: basis, coalesced: false })
} else {
None
};
+22 -8
View File
@@ -116,18 +116,32 @@ fn construction_planes_follow_reflected_and_sheared_affine_images() {
}
#[test]
fn material_changes_discard_producer_certificates() {
fn material_changes_preserve_only_unchanged_planar_support_certificates() {
let base = crate::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(42).unwrap();
let tool = crate::box_solid(Vec3::new(5., -1., -1.), Vec3::new(11., 11., 11.)).unwrap();
let cut = crate::boolean(&base, &tool, crate::BoolOp::Cut).unwrap().unwrap();
assert!((cut.tessellate(1e-4).unwrap().measure().volume - 500.).abs() < 1e-8);
assert!(cut.face_sources().into_iter().flatten().flatten().all(|source| matches!(
source,
FaceSource::ExtrudeSide { .. }
| FaceSource::ExtrudeCap { .. }
| FaceSource::Box { producer: None, .. }
| FaceSource::Imported { producer: None, .. }
)));
// Unchanged uniquely certified supports retain their role; a newly cut
// support must never inherit the removed opposite wall's source identity.
let mut retained = 0;
let mut new_wall = 0;
for index in 0..cut.face_count() {
let actual = cut.planar_face(index).unwrap();
let source = cut.face_sources().and_then(|s| s.get(index)).and_then(Option::as_ref);
if let Some(FaceSource::Box { producer: Some(42), axis, high, .. }) = source {
assert!(!(*axis == 0 && *high));
let canonical = cut.face_plane(index).unwrap();
assert!(actual.normal().dot(canonical.normal()) > 1. - 1e-10);
assert!(actual.height(canonical.origin).abs() < 1e-10);
retained += 1;
}
if actual.normal().x > 0.9 {
assert!((actual.origin.x - 5.).abs() < 1e-10);
assert!(!matches!(source, Some(FaceSource::Box { producer: Some(_), .. })));
new_wall += 1;
}
}
assert_eq!((retained, new_wall), (5, 1));
}
#[test]
+61 -7
View File
@@ -14,6 +14,12 @@ pub const MAX_IGES_BYTES: usize = 512 << 20;
const MAX_ENTITIES: usize = 2_000_000;
const MAX_DEPTH: usize = 32;
const MAX_LIST: usize = 1_000_000;
/// Entities visited and segments produced while expanding model curves. A composite curve
/// (102) lists its children by pointer, and nothing stops a tree whose every node names the
/// same child twice: 30 levels under the depth cap is 2^30 leaf visits and about 160 GB of
/// segments. The depth cap alone does not see fan-out, and a node that produces no segment
/// trips no output bound, so the visits are counted too.
const MAX_CURVE_WORK: usize = 4 * MAX_ENTITIES;
type R<T> = Result<T, String>;
type V3 = [f64; 3];
@@ -249,7 +255,16 @@ fn parse(text: &str) -> R<Iges> {
des.insert(2 * k + 1, de);
}
let mut params = HashMap::new();
// Nothing stops thousands of directory entries from naming the same parameter range, and
// each one materialises that range as its own string and token list. Keep the total text
// proportional to the parameter section the file actually carries.
let max_lines = p_lines.len().saturating_mul(4).saturating_add(1024);
let mut lines_used = 0usize;
for (seq, de) in &des {
lines_used = lines_used.saturating_add(de.lines.max(1));
if lines_used > max_lines {
return Err("the file's parameter data is too large".into());
}
let mut s = String::new();
for l in de.pd..de.pd.saturating_add(de.lines.max(1)) {
// Columns 1–64 run on (a long string may continue on the next line).
@@ -368,6 +383,10 @@ struct Tr<'a> {
/// Sewing tolerance (mm).
tol: f64,
surf_cache: HashMap<(usize, [u64; 12]), u64>,
/// Entities visited and segments produced while expanding curves (see `MAX_CURVE_WORK`).
curve_work: usize,
/// Composite curves (102) on the path being expanded.
composites: Vec<i64>,
}
fn xf_key(x: &Xf) -> [u64; 12] {
@@ -442,6 +461,19 @@ impl<'a> Tr<'a> {
if depth > MAX_DEPTH {
return Err("curves nested too deeply".into());
}
self.curve_work = self.curve_work.saturating_add(1);
if self.curve_work > MAX_CURVE_WORK {
return Err("the file's curves are too complex".into());
}
let out = self.curve_parts(seq, xf, depth)?;
self.curve_work = self.curve_work.saturating_add(out.len());
if self.curve_work > MAX_CURVE_WORK {
return Err("a curve has too many segments".into());
}
Ok(out)
}
fn curve_parts(&mut self, seq: i64, xf: &Xf, depth: usize) -> R<Vec<Seg>> {
let de = self.de(seq)?;
let x = xf.then(&self.own_xf(seq, 0)?);
match de.typ {
@@ -479,13 +511,23 @@ impl<'a> Tr<'a> {
Ok(vec![Seg::along(cid, |t| at(a0 + (a1 - a0) * t))])
}
102 => {
// A composite curve that contains itself, directly or through another, would
// expand until the depth cap; refusing it on the way down is clearer.
if self.composites.contains(&seq) {
return Err(format!("entity {seq}: composite curve contains itself"));
}
let n = self.i(seq, 1)?.clamp(0, MAX_LIST as i64) as usize;
let mut out = Vec::new();
for k in 0..n {
let s = self.i(seq, 2 + k)?;
out.extend(self.curve(s, &x, depth + 1)?);
}
Ok(out)
self.composites.push(seq);
let r = (|| {
for k in 0..n {
let s = self.i(seq, 2 + k)?;
out.extend(self.curve(s, &x, depth + 1)?);
}
Ok(out)
})();
self.composites.pop();
r
}
126 => {
let nb = self.nurbs_curve(seq, &x)?;
@@ -833,7 +875,9 @@ struct Sewer {
impl Sewer {
fn vertex(&mut self, p: V3) -> usize {
let c = |x: f64| (x / self.tol).floor() as i64;
// Neighbouring buckets are this key plus or minus one, so leave room for that: a
// coordinate near f64's range saturates the cast and the addition would overflow.
let c = |x: f64| (x / self.tol).floor().clamp(-9e15, 9e15) as i64;
let key = [c(p[0]), c(p[1]), c(p[2])];
for dx in -1..=1 {
for dy in -1..=1 {
@@ -1008,13 +1052,23 @@ fn write_shell(o: &mut Out, sw: &Sewer, faces: &[FaceB], styles: &mut Vec<(u64,
}
/// IGES text restated as a STEP exchange (millimetres), with what could not be read.
/// This was the one importer stage outside every panic guard, so a panic here reached the CLI
/// and the wasm build, where nothing catches it.
pub fn iges_to_step(text: &str) -> std::result::Result<(String, Vec<String>), String> {
crate::guard("IGES import", || translate(text).map_err(crate::KernelError::Invalid)).map_err(|e| match e {
crate::KernelError::Invalid(m) => m,
other => other.to_string(),
})
}
fn translate(text: &str) -> std::result::Result<(String, Vec<String>), String> {
if text.len() > MAX_IGES_BYTES {
return Err("IGES file too large".into());
}
let f = parse(text)?;
let tol = (f.resolution * f.unit).clamp(1e-6, 0.05).max(1e-5);
let mut t = Tr { f: &f, o: Out { lines: Vec::new() }, warnings: Vec::new(), tol, surf_cache: HashMap::new() };
let mut t =
Tr { f: &f, o: Out { lines: Vec::new() }, warnings: Vec::new(), tol, surf_cache: HashMap::new(), curve_work: 0, composites: Vec::new() };
// Header: units and context.
let mm = t.o.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))");
let rad = t.o.add("(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.))");
+52
View File
@@ -157,3 +157,55 @@ fn a_drill_point_round_trips_through_iges() {
assert!(rel(measure(&drilled).unwrap().volume, v) < 1e-3);
round_trip(&drilled);
}
/// A composite curve (102) names its children by pointer, and nothing stops a tree whose every
/// node names the same child twice. Twenty levels under the depth cap is a million leaf visits
/// and thirty is 2^30; only counting the work sees that, because the depth never grows past the
/// cap and a node that yields no segment trips no bound on the output.
#[test]
fn composite_curve_fan_out_is_bounded() {
let quad = |z: f64| {
let c = [[0.0, 0.0, z], [10.0, 0.0, z], [10.0, 20.0, z], [0.0, 20.0, z]];
let fmt = |p: [f64; 3]| format!("{:?},{:?},{:?}", p[0], p[1], p[2]);
format!("1,1,1,1,0,0,1,0,0,0.,0.,1.,1.,0.,0.,1.,1.,1.,1.,1.,1.,{},{},{},{},0.,1.,0.,1.", fmt(c[0]), fmt(c[1]), fmt(c[3]), fmt(c[2]))
};
let mut ents: Vec<(i64, i64, String)> = vec![(128, 0, quad(0.0)), (110, 0, "0.,0.,0.,10.,0.,0.".into())];
let (surface, mut child) = (1, 3);
// Twenty composites, each naming the one below it twice.
for _ in 0..20 {
ents.push((102, 0, format!("2,{child},{child}")));
child = 2 * ents.len() - 1;
}
ents.push((142, 0, format!("0,{surface},0,{child},2")));
let cos = 2 * ents.len() - 1;
ents.push((144, 0, format!("{surface},1,0,{cos}")));
let reported = match iges_to_step(&iges_file(&ents)) {
Ok((_, warnings)) => warnings.join(" "),
Err(error) => error,
};
// Check the work-budget rejection, not elapsed time: CI runs test binaries concurrently.
assert!(reported.contains("too complex") || reported.contains("too many segments"), "{reported}");
}
/// Nothing stops directory entries from all naming the same parameter range, and each one
/// materialises it as its own string and token list. Ten thousand entries over a million
/// shared lines retained about a gigabyte each.
#[test]
fn directory_entries_cannot_share_their_way_past_the_file_size() {
let mut out = vec![format!("{:<72}S{:>7}", "test", 1), format!("{:<72}G{:>7}", "1H,,1H;,,,,,,,,,,,1.,2,2HMM,,,,1.E-6;", 1)];
// 1,000 entries, each claiming the same 2,000 parameter lines.
for k in 0..1_000 {
out.push(format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", 110, 1, 0, 0, 0, 0, 0, 0, "00000000", 2 * k + 1));
out.push(format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", 110, 0, 0, 2_000, 0, 0, 0, "", 0, 2 * k + 2));
}
for i in 0..2_000 {
out.push(format!("{:<64}{:>8}P{:>7}", "0.,0.,0.,1.,0.,0.;", 1, i + 1));
}
out.push(format!("{:<72}T{:>7}", "", 1));
let t = std::time::Instant::now();
let error = iges_to_step(&out.join("\n")).unwrap_err();
assert!(error.contains("parameter data is too large"), "{error}");
assert!(t.elapsed() < std::time::Duration::from_secs(10), "took {:?}", t.elapsed());
}
+3
View File
@@ -221,6 +221,9 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
)),
};
}
if matches!(op, BoolOp::Cut | BoolOp::Union) {
r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, op == BoolOp::Cut)).transpose());
}
r
}
+284
View File
@@ -0,0 +1,284 @@
//! Exact topological removal of construction-induced planar subdivisions.
//! Shared edge identity and complete construction lineage select the groups.
use super::{Body, from_p3};
use crate::{Result, guard};
use solvecraft_geom::Plane;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::Arc;
use truck_modeling as mt;
fn root(parent: &mut [usize], mut i: usize) -> usize {
while parent[i] != i {
parent[i] = parent[parent[i]];
i = parent[i];
}
i
}
fn merged_face(faces: &[&mt::Face], members: &[usize], plane: Plane, eligible: &HashSet<mt::EdgeID>) -> Option<mt::Face> {
let mut counts = HashMap::<mt::EdgeID, (usize, bool)>::new();
for i in members {
for edge in faces.get(*i)?.boundary_iters().into_iter().flatten() {
// Whole linear supports, not a sampled plane fit.
let mt::Curve::Line(line) = edge.curve() else { return None };
let (a, b) = (from_p3(line.0), from_p3(line.1));
if !a.is_finite() || !b.is_finite() || a.len().max(b.len()) > 1e9 {
return None;
}
let front = from_p3(edge.absolute_front().point());
let back = from_p3(edge.absolute_back().point());
if !front.is_finite() || !back.is_finite() {
return None;
}
let error = 4096. * f64::EPSILON * (1. + a.len() + b.len() + plane.origin.len());
if plane.height(a).abs() > error || plane.height(b).abs() > error || a.dist(front) > error || b.dist(back) > error {
return None;
}
let count = counts.entry(edge.id()).or_insert((0, edge.orientation()));
if count.0 == 1 && (!eligible.contains(&edge.id()) || count.1 == edge.orientation()) {
return None;
}
count.0 += 1;
if count.0 > 2 {
return None;
}
}
}
let mut boundary = Vec::new();
for i in members {
for edge in faces.get(*i)?.boundary_iters().into_iter().flatten() {
if counts.get(&edge.id()).is_some_and(|(n, _)| *n == 1) {
boundary.push(edge);
}
}
}
let mut next = HashMap::new();
let mut incoming = HashMap::new();
for (i, edge) in boundary.iter().enumerate() {
if next.insert(edge.front().id(), i).is_some() || incoming.insert(edge.back().id(), i).is_some() {
return None;
}
}
if next.len() != incoming.len() || next.keys().any(|id| !incoming.contains_key(id)) {
return None;
}
let mut used = vec![false; boundary.len()];
let mut outer = None;
let mut holes = Vec::new();
for first in 0..boundary.len() {
if used[first] {
continue;
}
let start = boundary.get(first)?.front().id();
let origin = from_p3(boundary.get(first)?.front().point());
let mut index = first;
let mut edges = Vec::new();
let (mut area, mut magnitude, mut arithmetic_error) = (0., 0., 0.);
loop {
if *used.get(index)? {
return None;
}
used[index] = true;
let edge = boundary.get(index)?.clone();
let da = from_p3(edge.front().point()) - origin;
let db = from_p3(edge.back().point()) - origin;
let a = solvecraft_geom::Vec2::new(da.dot(plane.x), da.dot(plane.y));
let b = solvecraft_geom::Vec2::new(db.dot(plane.x), db.dot(plane.y));
let term = a.cross(b);
area += term;
magnitude += term.abs();
let coordinate_error =
128. * f64::EPSILON * (1. + origin.len() + from_p3(edge.front().point()).len() + from_p3(edge.back().point()).len());
arithmetic_error += coordinate_error * (da.len() + db.len() + coordinate_error) * 16.;
let back = edge.back().id();
edges.push(edge);
if back == start {
break;
}
index = *next.get(&back)?;
}
if edges.len() < 3 || !area.is_finite() || area.abs() <= arithmetic_error + 64. * f64::EPSILON * (edges.len() as f64 + 1.) * magnitude.max(1.)
{
return None;
}
let wire = mt::Wire::from(edges);
if area > 0. {
if outer.replace(wire).is_some() {
return None;
}
} else {
holes.push(wire);
}
}
let mut wires = vec![outer?];
wires.extend(holes);
mt::Face::try_new(wires, faces.get(*members.first()?)?.oriented_surface()).ok()
}
impl Body {
pub(crate) fn regularize_certified_planar_faces(self) -> Result<Self> {
let Some(sources) = self.face_sources() else { return Ok(self) };
if sources.len() > 16_384 || self.paint().is_some_and(|p| !p.faces.is_empty()) {
return Ok(self);
}
let split_points = self.split_keep().iter().try_fold(0usize, |sum, line| sum.checked_add(line.len()));
let Some(split_points) = split_points.filter(|n| *n <= 65_536) else { return Ok(self) };
let result = guard("certified planar topology regularization", || {
let mut faces = Vec::new();
let mut shell_owner = Vec::new();
let mut use_budget = 65_536usize;
for (shell_index, shell) in self.solid.boundaries().iter().enumerate() {
for face in shell.face_iter() {
if faces.len() >= sources.len() {
return Ok(None);
}
for _ in face.edge_iter() {
let Some(left) = use_budget.checked_sub(1) else { return Ok(None) };
use_budget = left;
}
faces.push(face);
shell_owner.push(shell_index);
}
}
if faces.len() != sources.len() {
return Ok(None);
}
let valid: Vec<_> = faces
.iter()
.zip(sources)
.map(|(f, s)| {
s.as_ref()
.and_then(super::planar_sources::certified_plane)
.filter(|p| super::planar_sources::actual_plane(f).is_some_and(|actual| super::planar_sources::same_support(p, &actual)))
})
.collect();
let mut adjacency = HashMap::<mt::EdgeID, Vec<(usize, mt::Edge)>>::new();
for (i, face) in faces.iter().enumerate() {
for edge in face.boundary_iters().into_iter().flatten() {
adjacency.entry(edge.id()).or_default().push((i, edge));
}
}
let mut eligible = HashSet::new();
let mut parent: Vec<_> = (0..faces.len()).collect();
let mut identity = 16_000_000usize;
let mut work = 2_000_000usize;
for uses in adjacency.values() {
let [(a, ea), (b, eb)] = uses.as_slice() else { continue };
if a == b || shell_owner[*a] != shell_owner[*b] || ea.orientation() == eb.orientation() {
continue;
}
let (Some(pa), Some(pb)) = (valid[*a], valid[*b]) else { continue };
if pa != pb {
continue;
}
let (Some(sa), Some(sb)) = (sources[*a].as_ref(), sources[*b].as_ref()) else { continue };
let Some(cost) = super::planar_sources::descriptor_cost(sa)
.and_then(|a| super::planar_sources::descriptor_cost(sb).and_then(|b| a.checked_add(b)))
else {
continue;
};
let Some(left) = identity.checked_sub(cost) else { return Ok(None) };
identity = left;
if sa != sb {
continue;
}
let Some(cost) = split_points.checked_mul(6).and_then(|n| n.checked_add(1)) else { return Ok(None) };
let Some(left) = work.checked_sub(cost) else { return Ok(None) };
work = left;
if crate::heal::on_split_line(&crate::heal::edge_points(ea), self.split_keep()) {
continue;
}
eligible.insert(ea.id());
let (ra, rb) = (root(&mut parent, *a), root(&mut parent, *b));
parent[ra.max(rb)] = ra.min(rb);
}
let mut groups = BTreeMap::<usize, Vec<usize>>::new();
for i in 0..faces.len() {
groups.entry(root(&mut parent, i)).or_default().push(i);
}
let mut replacements = HashMap::new();
let mut changed = false;
let mut metadata = 2_000_000usize;
for (leader, members) in &groups {
if members.len() < 2 {
continue;
}
let Some(face) = valid[*leader].and_then(|p| merged_face(&faces, members, p, &eligible)) else { continue };
replacements.insert(*leader, face);
changed = true;
}
if !changed {
return Ok(None);
}
let mut shells = Vec::new();
let mut output_sources = Vec::new();
let mut output_ids = Vec::new();
let mut offset = 0;
for shell in self.solid.boundaries() {
let mut output = Vec::new();
for face in shell.face_iter() {
let leader = root(&mut parent, offset);
if let Some(merged) = replacements.get(&leader) {
if offset == leader {
output.push(merged.clone());
} else {
offset += 1;
continue;
}
} else {
output.push(face.clone());
}
output_ids.push(output.last().ok_or_else(|| crate::KernelError::Failed("missing regularized face".into()))?.id());
let source = sources[offset].as_ref();
if let Some(source) = source {
let Some(cost) = super::planar_sources::descriptor_cost(source) else { return Ok(None) };
let Some(left) = metadata.checked_sub(cost) else { return Ok(None) };
metadata = left;
}
output_sources.push(source.cloned());
offset += 1;
}
shells.push(mt::Shell::from(output));
}
let solid = mt::Solid::try_new(shells).map_err(|e| crate::KernelError::Failed(format!("planar regularization does not close: {e}")))?;
let body = Body::new(solid)?.with_color(self.color()).with_paint(self.paint().cloned()).with_split_lines(self.split_lines().cloned());
if !body.solid.face_iter().map(|f| f.id()).eq(output_ids) {
return Ok(None);
}
Ok(Some(body.with_face_sources(Some(Arc::new(output_sources)))))
})?;
Ok(result.unwrap_or(self))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alternate_adjacency_path_cannot_remove_protected_internal_edge() {
let vertices: Vec<_> =
(0..9).map(|i| mt::builder::vertex(super::super::p3(solvecraft_geom::Vec3::new((i % 3) as f64, (i / 3) as f64, 0.)))).collect();
let mut edges = HashMap::<(usize, usize), mt::Edge>::new();
let mut faces = Vec::new();
for (x, y) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
let ids = [y * 3 + x, y * 3 + x + 1, (y + 1) * 3 + x + 1, (y + 1) * 3 + x];
let wire: mt::Wire = (0..4)
.map(|i| {
let (a, b) = (ids[i], ids[(i + 1) % 4]);
let key = (a.min(b), a.max(b));
let edge = edges.entry(key).or_insert_with(|| mt::builder::line(&vertices[key.0], &vertices[key.1]));
if a == key.0 { edge.clone() } else { edge.inverse() }
})
.collect();
faces.push(mt::builder::try_attach_plane(&[wire]).unwrap());
}
let refs: Vec<_> = faces.iter().collect();
let mut eligible: HashSet<_> = edges.values().map(|e| e.id()).collect();
assert!(merged_face(&refs, &[0, 1, 2, 3], Plane::XY, &eligible).is_some());
eligible.remove(&edges[&(1, 4)].id());
assert!(
merged_face(&refs, &[0, 1, 2, 3], Plane::XY, &eligible).is_none(),
"remaining three internal joins cannot bypass authored split protection"
);
}
}
+516
View File
@@ -0,0 +1,516 @@
//! Exact oriented planar support lineage through a trim and an inside shell.
//! No tessellated face names, centroids, or nearest-plane choices are used.
use super::{Body, FaceSource, from_p3};
use crate::{KernelError, Result, guard};
use solvecraft_geom::{Plane, Vec3};
use std::sync::Arc;
use truck_modeling as mt;
pub(super) fn actual_plane(face: &mt::Face) -> Option<Plane> {
let mt::Surface::Plane(p) = face.oriented_surface() else { return None };
let u = p.u_axis();
let v = p.v_axis();
let u = Vec3::new(u.x, u.y, u.z);
let v = Vec3::new(v.x, v.y, v.z);
if !u.is_finite() || !v.is_finite() || u.cross(v).len() <= u.len() * v.len() * 1e-8 {
return None;
}
Plane::new(from_p3(p.origin()), u, v)
}
pub(super) fn same_support(a: &Plane, b: &Plane) -> bool {
let error = 4096. * f64::EPSILON * (1. + a.origin.len() + b.origin.len());
a.normal().cross(b.normal()).len() <= 4096. * f64::EPSILON
&& a.normal().dot(b.normal()) > 0.
&& (a.origin - b.origin).dot(a.normal()).abs() <= error
}
fn base_plane(source: &FaceSource, actual: &Plane) -> Option<Plane> {
match source {
FaceSource::CertifiedExtrudeSide { plane: Some(p), .. }
| FaceSource::CertifiedExtrudeCap { plane: p, .. }
| FaceSource::Box { producer: Some(_), plane: p, .. }
| FaceSource::ShellPlanar { producer: Some(_), plane: p, .. } => Some(*p),
FaceSource::Imported { producer: Some(_), payload_sha256: Some(_), .. } => Some(*actual),
_ => None,
}
}
pub(super) fn certified_plane(source: &FaceSource) -> Option<Plane> {
match source {
FaceSource::CertifiedExtrudeSide { plane: Some(p), .. }
| FaceSource::CertifiedExtrudeCap { plane: p, .. }
| FaceSource::Box { producer: Some(_), plane: p, .. }
| FaceSource::ShellPlanar { producer: Some(_), plane: p, .. } => Some(*p),
_ => None,
}
}
fn boolean_source(source: &FaceSource, constructed: bool) -> bool {
matches!(source, FaceSource::ShellPlanar { producer: Some(_), .. }) || (constructed && certified_plane(source).is_some())
}
pub(super) fn descriptor_cost(source: &FaceSource) -> Option<usize> {
let mut cost = 256usize;
match source {
FaceSource::CertifiedExtrudeCap { outer, holes, .. } => {
if holes.len() > 4096 {
return None;
}
cost = cost.checked_add(holes.len().checked_mul(64)?)?;
let mut entries = 4096usize;
for id in outer.iter().chain(holes.iter().flatten()) {
entries = entries.checked_sub(1)?;
if id.len() > 128 {
return None;
}
cost = cost.checked_add(id.len().checked_add(64)?)?;
}
}
FaceSource::CertifiedExtrudeSide { curve, .. } => {
if curve.len() > 128 {
return None;
}
cost = cost.checked_add(curve.len())?;
}
FaceSource::Imported { payload_sha256: Some(digest), .. } => {
if digest.len() > 64 {
return None;
}
cost = cost.checked_add(digest.len())?;
}
FaceSource::ShellPlanar { source, .. } if !matches!(source.as_ref(), FaceSource::ShellPlanar { .. }) => {
cost = cost.checked_add(descriptor_cost(source)?)?;
}
FaceSource::ExtrudeCap { region, .. } => {
if region.holes.len() > 8192 {
return None;
}
cost = cost.checked_add(region.holes.len().checked_mul(64)?)?;
let count = region.holes.iter().try_fold(region.outer.segs.len(), |sum, h| sum.checked_add(h.segs.len()))?;
if count > 8192 {
return None;
}
cost = cost.checked_add(count.checked_mul(128)?)?;
}
FaceSource::ExtrudeSide { .. } | FaceSource::Imported { .. } | FaceSource::Box { .. } => {}
_ => return None,
}
Some(cost)
}
impl Body {
/// Preserve uniquely certified unchanged planar construction supports.
pub fn with_preserved_planar_sources(self, input: &Body) -> Result<Self> {
self.map_planar_sources(input, None, None, &[], false)
}
/// Generate distinct outer/inner roles from the exact original supports of
/// an inside shell. Generated rims and curved walls are intentionally absent.
pub(crate) fn with_shell_planar_sources(self, input: &Body, thickness: f64, open: &[Vec3]) -> Result<Self> {
self.map_planar_sources(input, Some(thickness), None, open, false)
}
/// Retain exact shell roles, and construction roles for Cut, only on unchanged
/// supports without a competing coplanar tool. No imported or fitted role is inferred.
pub(crate) fn with_preserved_boolean_planar_sources(self, input: &Body, tool: &Body, constructed: bool) -> Result<Self> {
if !input.face_sources().is_some_and(|sources| sources.iter().flatten().any(|s| boolean_source(s, constructed))) {
return Ok(self);
}
self.map_planar_sources(input, None, Some(tool), &[], constructed)
}
fn map_planar_sources(mut self, input: &Body, shell: Option<f64>, blocker: Option<&Body>, open: &[Vec3], constructed: bool) -> Result<Self> {
let Some(sources) = input.face_sources() else { return Ok(self) };
if input.solid.face_iter().count() > 16_384
|| self.solid.face_iter().count() > 16_384
|| blocker.is_some_and(|b| b.solid.face_iter().count() > 16_384)
{
return Err(KernelError::Invalid("planar source face budget exceeded".into()));
}
if sources.len() != input.solid.face_iter().count() {
return Err(KernelError::Invalid("planar source alignment mismatch".into()));
}
if shell.is_some_and(|t| !t.is_finite() || t <= 0.) {
return Err(KernelError::Invalid("invalid shell source thickness".into()));
}
if open.len() > 256 {
return Ok(self);
}
let opening_tolerance = if shell.is_some() { input.size() * 1e-4 + 1e-6 } else { 0. };
let mapped = guard("exact planar support lineage", || {
let competing: Vec<_> = blocker.into_iter().flat_map(|b| b.solid.face_iter()).filter_map(actual_plane).collect();
let mut candidates = Vec::new();
let mut unknown_supports = Vec::new();
for (face, source) in input.solid.face_iter().zip(sources) {
let Some(actual) = actual_plane(face) else { continue };
let Some(source) = source.as_ref() else {
unknown_supports.push(actual);
continue;
};
if (blocker.is_some() && !boolean_source(source, constructed))
|| (shell.is_some() && matches!(source, FaceSource::ShellPlanar { .. }))
{
unknown_supports.push(actual);
continue;
}
let Some(plane) = base_plane(source, &actual) else {
unknown_supports.push(actual);
continue;
};
if !same_support(&plane, &actual) || (shell.is_some() && open.iter().any(|p| actual.height(*p).abs() <= opening_tolerance)) {
unknown_supports.push(actual);
continue;
}
let Some(cost) = descriptor_cost(source) else {
unknown_supports.push(actual);
continue;
};
candidates.push((plane, source, false, cost));
if let Some(t) = shell {
let inner = Plane::new(plane.origin - plane.normal() * t, plane.x, -plane.y)
.ok_or_else(|| KernelError::Invalid("invalid shell inner source plane".into()))?;
candidates.push((inner, source, true, cost));
}
}
let mut work = 2_000_000usize;
let mut metadata = 2_000_000usize;
let mut identity_bytes = 16_000_000usize;
let mut result = Vec::new();
for (index, face) in self.solid.face_iter().enumerate() {
let mut found = None;
if let Some(actual) = actual_plane(face) {
let mut blocked = false;
for plane in &unknown_supports {
work = work.checked_sub(1).ok_or_else(|| KernelError::Invalid("unknown planar source comparison budget exceeded".into()))?;
if same_support(plane, &actual) {
blocked = true;
break;
}
}
for plane in &competing {
work = work.checked_sub(1).ok_or_else(|| KernelError::Invalid("planar tool comparison budget exceeded".into()))?;
if same_support(plane, &actual)
|| same_support(
&Plane::new(plane.origin, plane.x, -plane.y).ok_or_else(|| KernelError::Invalid("invalid opposing plane".into()))?,
&actual,
)
{
blocked = true;
break;
}
}
if blocked {
let existing = self
.face_sources()
.and_then(|sources| sources.get(index))
.and_then(|s| s.as_ref())
.filter(|s| (blocker.is_some() || self.face_was_coalesced(index)) && !boolean_source(s, constructed));
let keep = existing.and_then(|s| {
descriptor_cost(s).and_then(|cost| {
metadata.checked_sub(cost).map(|remaining| {
metadata = remaining;
s.clone()
})
})
});
result.push(keep);
continue;
}
let mut chosen: Option<(Plane, &FaceSource, bool, usize)> = None;
for (plane, source, inner, cost) in &candidates {
work = work.checked_sub(1).ok_or_else(|| KernelError::Invalid("planar source comparison budget exceeded".into()))?;
if same_support(plane, &actual) {
if let Some((previous_plane, previous_source, previous_inner, _)) = chosen {
let Some(remaining) = identity_bytes.checked_sub(*cost) else {
chosen = None;
break;
};
identity_bytes = remaining;
// Several current pieces of one exact source are one
// logical face; distinct source identities remain ambiguous.
if previous_plane != *plane || previous_source != *source || previous_inner != *inner {
chosen = None;
break;
}
} else {
chosen = Some((*plane, *source, *inner, *cost));
}
}
}
if let Some((plane, source, inner, cost)) = chosen
&& let Some(remaining) = metadata.checked_sub(cost)
{
metadata = remaining;
found = Some(if shell.is_some() {
FaceSource::ShellPlanar { producer: None, source: Box::new(source.clone()), inner, plane }
} else {
source.clone()
});
}
}
if found.is_none() && (blocker.is_some() || self.face_was_coalesced(index)) {
found = self
.face_sources()
.and_then(|sources| sources.get(index))
.and_then(|s| s.as_ref())
.filter(|s| !boolean_source(s, constructed))
.and_then(|s| {
descriptor_cost(s).and_then(|cost| {
metadata.checked_sub(cost).map(|remaining| {
metadata = remaining;
s.clone()
})
})
});
}
result.push(found);
}
Ok(result)
})?;
self.face_sources = Some(Arc::new(mapped));
self.regularize_certified_planar_faces()
}
pub fn with_shell_certificate(mut self, producer: u64) -> Result<Self> {
if producer == 0 {
return Err(KernelError::Invalid("invalid shell source producer".into()));
}
if let Some(sources) = &self.face_sources {
let mut sources = sources.as_ref().clone();
for source in sources.iter_mut().flatten() {
if let FaceSource::ShellPlanar { producer: id, .. } = source {
*id = Some(producer);
}
}
self.face_sources = Some(Arc::new(sources));
}
self.regularize_certified_planar_faces()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{box_solid, fillet, shell, transform_matrix};
#[test]
fn rounded_shell_planar_roles_are_exact_distinct_and_transformable() {
for (width, thickness) in [(80., 2.), (100., 3.)] {
let box_body = box_solid(Vec3::new(-width / 2., -30., 0.), Vec3::new(width / 2., 30., 30.)).unwrap().with_box_certificate(7).unwrap();
let rounded = fillet(
&box_body,
&[
Vec3::new(width / 2., 30., 15.),
Vec3::new(-width / 2., 30., 15.),
Vec3::new(-width / 2., -30., 15.),
Vec3::new(width / 2., -30., 15.),
],
6.,
)
.unwrap()
.with_preserved_planar_sources(&box_body)
.unwrap();
assert_eq!(rounded.face_sources().unwrap().iter().flatten().count(), 6);
let cup = shell(&rounded, &[Vec3::new(0., 0., 30.)], thickness).unwrap().with_shell_certificate(9).unwrap();
let sources = cup.face_sources().unwrap();
assert!(
!sources
.iter()
.flatten()
.any(|s| matches!(s,FaceSource::ShellPlanar {source, ..} if matches!(source.as_ref(),FaceSource::Box {axis:2,high:true,..})))
);
let right = sources.iter().position(|s| matches!(s, Some(FaceSource::ShellPlanar { inner:false, source, .. }) if matches!(source.as_ref(),FaceSource::Box {axis:0,high:true,..}))).unwrap();
let floor = sources.iter().position(|s| matches!(s, Some(FaceSource::ShellPlanar { inner:true, source, .. }) if matches!(source.as_ref(),FaceSource::Box {axis:2,high:false,..}))).unwrap();
assert!((cup.face_plane(right).unwrap().origin.x - width / 2.).abs() < 1e-9);
let floor_plane = cup.face_plane(floor).unwrap();
assert!((floor_plane.origin.z - thickness).abs() < 1e-9);
assert!(floor_plane.normal().dot(Vec3::Z) > 0.999999);
for (i, s) in sources.iter().enumerate() {
if matches!(s, Some(FaceSource::ExtrudeSide { coalesced: true, plane: None, .. })) {
assert!(cup.face_plane(i).is_none(), "loss witness is not source authority");
} else if s.is_some() {
assert!(same_support(&cup.face_plane(i).unwrap(), &cup.planar_face(i).unwrap()));
}
}
let moved = transform_matrix(&cup, [[0., 1., 0., 0.], [-1., 0., 0., 0.], [0., 0., 1., 0.], [100., 200., 300., 1.]]).unwrap();
assert!((moved.face_plane(floor).unwrap().origin.z - (300. + thickness)).abs() < 1e-9);
assert!(matches!(moved.face_sources().unwrap().get(right), Some(Some(FaceSource::ShellPlanar { producer: Some(9), inner: false, .. }))));
assert!(moved.face_plane(right).unwrap().normal().dot(Vec3::Y) > 0.999999);
let raw = cup.tessellate(0.01).unwrap();
let display = cup.display_mesh(0.01).unwrap();
let cylindrical: Vec<_> = cup
.solid
.face_iter()
.enumerate()
.filter_map(|(i, f)| {
let crate::FaceSurface::Cylinder { radius, .. } = crate::face_surface(&cup, i)? else { return None };
let lo = f.vertex_iter().map(|v| v.point().z).fold(f64::INFINITY, f64::min);
let hi = f.vertex_iter().map(|v| v.point().z).fold(f64::NEG_INFINITY, f64::max);
Some((i, radius, lo, hi))
})
.collect();
eprintln!("roundedShell width{width} thickness{thickness} raw{} cylindrical roles{cylindrical:?}", cup.solid.face_iter().count());
let mut corner_joins = 0;
for (edge, adjacent) in raw.edge_faces.iter().enumerate() {
let [a, b] = adjacent.as_slice() else { continue };
if raw.edges[edge].iter().all(|p| (p.z - thickness).abs() < 1e-8)
&& cylindrical.iter().any(|(i, r, _, _)| *i == *a as usize && (*r - 6.).abs() < 1e-6)
&& cylindrical.iter().any(|(i, r, _, _)| *i == *b as usize && (*r - 6.).abs() < 1e-6)
{
corner_joins += 1;
assert!(display.seams[edge], "existing cylindrical corner joins must not draw a floor-height arc");
}
}
assert_eq!(corner_joins, 0, "no physical lower corner strip remains");
assert_eq!(cup.face_count(), 19);
let outer: Vec<_> = cylindrical.iter().filter(|(_, r, _, _)| (*r - 6.).abs() < 1e-6).collect();
assert_eq!(outer.len(), 4);
assert!(outer.iter().all(|(i, _, _, _)| cup.face_was_coalesced(*i) && moved.face_was_coalesced(*i)));
assert!(outer.iter().all(|(_, _, lo, hi)| lo.abs() < 1e-9 && (*hi - 30.).abs() < 1e-9));
for axis in 0..2 {
for high in [false, true] {
let matching: Vec<_> = sources.iter().enumerate().filter_map(|(i,s)| matches!(s,Some(FaceSource::ShellPlanar {inner:false,source,..}) if matches!(source.as_ref(),FaceSource::Box {axis:a,high:h,..} if *a==axis && *h==high)).then_some(i)).collect();
assert_eq!(matching.len(), 1, "each straight exterior wall is one actual face");
let face = cup.solid.face_iter().nth(matching[0]).unwrap();
let z: Vec<_> = face.vertex_iter().map(|v| v.point().z).collect();
assert!(z.iter().any(|z| z.abs() < 1e-9));
assert!(z.iter().any(|z| (*z - 30.).abs() < 1e-9));
}
}
assert!(display.contains(Vec3::new(width / 2. - thickness / 2., 0., thickness / 2.)));
assert!(display.contains(Vec3::new(width / 2. - thickness / 2., 0., 15.)));
assert!(!display.contains(Vec3::new(0., 0., 15.)));
assert_eq!(raw.measure().volume, display.measure().volume);
let expected = (width * 60. - (4. - std::f64::consts::PI) * 36.) * 30.
- ((width - 2. * thickness) * (60. - 2. * thickness) - (4. - std::f64::consts::PI) * (6. - thickness).powi(2)) * (30. - thickness);
assert!((cup.tessellate(1e-4).unwrap().measure().volume - expected).abs() < 0.2);
let imported = crate::step_import(&crate::step_export(&[&cup], "regularized rounded shell").unwrap()).unwrap();
assert!(imported.warnings.is_empty());
let copy = &imported.bodies[0].body;
assert_eq!(copy.face_count(), 19);
// STEP may additionally coalesce cylindrical corner patches. The
// certified planar wall topology itself must survive unchanged.
for (axis, coordinate) in [(Vec3::X, width / 2.), (-Vec3::X, width / 2.), (Vec3::Y, 30.), (-Vec3::Y, 30.)] {
let walls: Vec<_> = copy
.solid
.face_iter()
.filter(|f| actual_plane(f).is_some_and(|p| p.normal().dot(axis) > 0.999 && (p.origin.dot(axis) - coordinate).abs() < 1e-8))
.collect();
assert_eq!(walls.len(), 1);
let z: Vec<_> = walls[0].vertex_iter().map(|v| v.point().z).collect();
assert!(z.iter().any(|z| z.abs() < 1e-8));
assert!(z.iter().any(|z| (*z - 30.).abs() < 1e-8));
}
let copy_mesh = copy.tessellate(1e-4).unwrap();
assert!((copy_mesh.measure().volume - expected).abs() < 0.2);
assert!(copy_mesh.contains(Vec3::new(width / 2. - thickness / 2., 0., 15.)));
assert!(!copy_mesh.contains(Vec3::new(0., 0., 15.)));
}
}
#[test]
fn simple_shell_has_one_full_height_face_per_outer_wall_and_exports_that_topology() {
let source = box_solid(Vec3::ZERO, Vec3::new(40., 30., 20.)).unwrap().with_box_certificate(1).unwrap();
let pending = shell(&source, &[Vec3::new(20., 15., 20.)], 2.).unwrap();
let cup = pending.clone().with_shell_certificate(2).unwrap();
assert_eq!(cup.solid.face_iter().count(), 11);
let mesh = cup.tessellate(0.01).unwrap();
assert!((mesh.measure().volume - (40. * 30. * 20. - 36. * 26. * 18.)).abs() < 1e-6);
assert!(mesh.contains(Vec3::new(20., 1., 1.)));
assert!(mesh.contains(Vec3::new(20., 1., 10.)));
assert!(!mesh.contains(Vec3::new(20., 15., 10.)));
let imported = crate::step_import(&crate::step_export(&[&cup], "regularized shell").unwrap()).unwrap();
assert!(imported.warnings.is_empty());
let copy = &imported.bodies[0].body;
assert_eq!(copy.solid.face_iter().count(), 11);
assert!((copy.tessellate(0.01).unwrap().measure().volume - mesh.measure().volume).abs() < 1e-6);
}
#[test]
fn coincident_competing_source_planes_and_tool_planes_stay_unknown() {
let a = box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(1).unwrap();
let b = box_solid(Vec3::new(20., 0., 0.), Vec3::new(30., 10., 10.)).unwrap().with_box_certificate(2).unwrap();
let mut shells = a.solid.boundaries().to_vec();
shells.extend(b.solid.boundaries().iter().cloned());
let mut combined = Body::new(mt::Solid::try_new(shells).unwrap()).unwrap();
combined.face_sources = Some(Arc::new(a.face_sources().unwrap().iter().chain(b.face_sources().unwrap()).cloned().collect()));
let result = a.clone().with_preserved_planar_sources(&combined).unwrap();
for (i, face) in result.solid.face_iter().enumerate() {
if actual_plane(face).unwrap().normal().dot(Vec3::Z).abs() > 0.999 {
assert!(result.face_sources().unwrap()[i].is_none());
}
}
let mut mixed = combined.clone();
mixed.face_sources =
Some(Arc::new(a.face_sources().unwrap().iter().cloned().chain(std::iter::repeat_n(None, b.solid.face_iter().count())).collect()));
let guarded = a.clone().with_preserved_planar_sources(&mixed).unwrap();
for (i, face) in guarded.solid.face_iter().enumerate() {
if actual_plane(face).unwrap().normal().dot(Vec3::Z).abs() > 0.999 {
assert!(guarded.face_sources().unwrap()[i].is_none(), "uncertified competing support must not gain identity");
}
}
let cup = shell(&a, &[Vec3::new(5., 5., 10.)], 1.).unwrap().with_shell_certificate(3).unwrap();
let tool = box_solid(Vec3::new(2., 2., 1.), Vec3::new(4., 4., 3.)).unwrap();
let preserved = cup.clone().with_preserved_boolean_planar_sources(&cup, &tool, false).unwrap();
for (i, face) in preserved.solid.face_iter().enumerate() {
let Some(p) = actual_plane(face) else { continue };
if p.normal().dot(Vec3::Z) > 0.999 && (p.origin.z - 1.).abs() < 1e-9 {
assert!(preserved.face_sources().unwrap()[i].is_none());
}
}
}
#[test]
fn pocket_cut_removes_internal_wall_faces_in_actual_topology() {
use solvecraft_geom::{Loop2, Region2, Vec2};
let region = Region2 {
outer: Loop2::polygon(&[Vec2::new(-30., -27.), Vec2::new(30., -27.), Vec2::new(30., 27.), Vec2::new(-30., 27.)]),
holes: vec![],
};
let original = crate::extrude(&Plane::XY, std::slice::from_ref(&region), 0., 10.).unwrap().pop().unwrap();
let curves: Vec<_> = original
.face_sources()
.unwrap()
.iter()
.enumerate()
.map(|(i, source)| matches!(source, Some(FaceSource::ExtrudeSide { .. })).then(|| format!("l{i}")))
.collect();
let original = original.with_extrude_certificates(2, 1, &curves).unwrap();
let (x, y) = (24.73317426966596, 21.733174269665962);
let cutter = box_solid(Vec3::new(-x, -y, 1.), Vec3::new(x, y, 10.)).unwrap();
let pocket = crate::boolean(&original, &cutter, crate::BoolOp::Cut).unwrap().unwrap();
let raw = pocket.tessellate(0.01).unwrap();
let display = pocket.display_mesh(0.01).unwrap();
let sources = pocket.face_sources().unwrap();
assert_eq!(pocket.solid.face_iter().count(), 11, "four outer walls, four inner walls, rim, floor, bottom");
let sides: Vec<_> =
sources.iter().enumerate().filter_map(|(i, s)| matches!(s, Some(FaceSource::CertifiedExtrudeSide { .. })).then_some(i)).collect();
assert_eq!(sides.len(), 4);
for i in sides {
let face = pocket.solid.face_iter().nth(i).unwrap();
let z: Vec<_> = face.vertex_iter().map(|v| v.point().z).collect();
assert!(z.iter().any(|z| z.abs() < 1e-9));
assert!(z.iter().any(|z| (*z - 10.).abs() < 1e-9));
assert!(!face.edge_iter().any(|e| (e.front().point().z - 1.).abs() < 1e-9 && (e.back().point().z - 1.).abs() < 1e-9));
}
assert!(display.contains(Vec3::new(0., 0., 0.5)));
assert!(!display.contains(Vec3::new(0., 0., 5.)));
assert!(display.contains(Vec3::new(28., 0., 5.)));
assert!((display.measure().volume - (60. * 54. * 10. - 4. * x * y * 9.)).abs() < 1e-7);
assert_eq!(raw.measure().volume, display.measure().volume);
let imported = crate::step_import(&crate::step_export(&[&pocket], "regularized pocket").unwrap()).unwrap();
assert!(imported.warnings.is_empty());
let copy = &imported.bodies[0].body;
assert_eq!(copy.solid.face_iter().count(), 11);
let copy_mesh = copy.tessellate(0.01).unwrap();
assert!((copy_mesh.measure().volume - raw.measure().volume).abs() < 1e-6);
assert!(copy_mesh.contains(Vec3::new(0., 0., 0.5)));
assert!(!copy_mesh.contains(Vec3::new(0., 0., 5.)));
let competing = box_solid(Vec3::new(30., -1., 0.), Vec3::new(31., 1., 2.)).unwrap();
let guarded = pocket.clone().with_preserved_boolean_planar_sources(&pocket, &competing, true).unwrap();
for (index, source) in guarded.face_sources().unwrap().iter().enumerate() {
if pocket.face_plane(index).is_some_and(|p| p.normal().dot(Vec3::X) > 0.999 && (p.origin.x - 30.).abs() < 1e-9) {
assert!(source.is_none(), "competing tool plane cannot retain source identity");
}
}
}
}
+4
View File
@@ -130,6 +130,10 @@ pub fn planar_convex(b: &Body) -> Result<Vec<(HalfSpace, Vec3)>> {
/// Hollow a convex planar body: walls of `thickness` inside, faces near `open` removed.
pub fn shell(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
shell_impl(b, open, thickness)?.with_shell_planar_sources(b, thickness, open)
}
fn shell_impl(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
b.require_brep("shell")?;
if !(thickness.is_finite() && thickness > 1e-6) {
return Err(KernelError::Invalid("shell thickness must be positive".into()));
+156 -26
View File
@@ -683,12 +683,17 @@ fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option
};
slabs.push(oriented(&s));
}
sew_sections(plane, d, &levels, &slabs, tol)
sew_sections(plane, d, &levels, &slabs, tol, false)
}
/// Sew the exact sections between successive levels, including empty gaps.
fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64) -> Option<Result<Body>> {
if levels.len() != slabs.len() + 1 || slabs.len() > 128 || plane.normal().dot(d) < 1. - 1e-12 {
fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64, coalesce: bool) -> Option<Result<Body>> {
if levels.len() != slabs.len() + 1
|| slabs.len() > 128
|| plane.normal().dot(d) < 1. - 1e-12
|| levels.iter().any(|z| !z.is_finite())
|| levels.windows(2).any(|z| z[0] >= z[1])
{
return None;
}
let mut blocks = Vec::new();
@@ -732,7 +737,7 @@ fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>],
pieces.push(piece);
}
} else {
let piece = sew_connected_sections(plane, d, &levels[first..=end], &slabs[first..end], tol)
let piece = sew_connected_sections(plane, d, &levels[first..=end], &slabs[first..end], tol, coalesce)
.ok_or_else(|| KernelError::Failed("stepped prism construction failed".into()))??;
remaining_faces =
remaining_faces.checked_sub(piece.face_count()).ok_or_else(|| KernelError::Failed("prism output face budget".into()))?;
@@ -765,8 +770,18 @@ fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>],
})())
}
/// Stable exact identity for the bounded analytic segment subset.
fn wall_key(s: &Seg2) -> Option<[u64; 6]> {
let bits = |v: f64| v.is_finite().then(|| if v == 0. { 0 } else { v.to_bits() });
match s {
Seg2::Line { a, b } => Some([0, bits(a.x)?, bits(a.y)?, bits(b.x)?, bits(b.y)?, 0]),
Seg2::Arc { center, radius, start, sweep } => Some([1, bits(center.x)?, bits(center.y)?, bits(*radius)?, bits(*start)?, bits(*sweep)?]),
_ => None,
}
}
/// A genuinely stepped connected axial block has no single extrusion footprint.
fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64) -> Option<Result<Body>> {
fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64, coalesce: bool) -> Option<Result<Body>> {
use crate::sew::{EdgeSpec, FaceSpec, SurfSpec};
// Faces at each level: up where the slab below sticks out, down where the one above does.
let diff = |x: &[Region2], y: &[Region2]| -> Option<Vec<Region2>> {
@@ -800,30 +815,61 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
_ => EdgeSpec::Line { a: at(sg.start(), z), b: at(sg.end(), z) },
}
};
let mut faces: Vec<FaceSpec> = Vec::new();
for (i, s) in slabs.iter().enumerate() {
let (z0, z1) = (*levels.get(i)?, *levels.get(i + 1)?);
for lp in loops_of(s) {
// Exact oriented generated segments alone define a run. No support fitting,
// proximity matching, or old face lineage is used here.
let mut runs: Vec<(usize, usize, Seg2)> = Vec::new();
let mut last = std::collections::BTreeMap::new();
let mut remaining = MAX_PLANAR_SEGMENTS * 2;
for (i, slab) in slabs.iter().enumerate() {
for lp in loops_of(slab) {
for sg0 in &lp.segs {
for sg in split_seg(sg0, &pts, tol)? {
let (a, b) = (sg.start(), sg.end());
let lp3 = vec![
edge(&sg, z0),
EdgeSpec::Line { a: at(b, z0), b: at(b, z1) },
edge(&sg.reversed(), z1),
EdgeSpec::Line { a: at(a, z1), b: at(a, z0) },
];
let tangent = plane.dir_to_world(sg.derivative(0.5));
let outward = tangent.cross(d).normalized()?;
let surface = match sg {
Seg2::Arc { .. } => SurfSpec::Extruded { a: at(a, z0), b: at(b, z0), mid: at(sg.point_at(0.5), z0), dir: d * (z1 - z0) },
_ => SurfSpec::Plane,
};
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) });
remaining = remaining.checked_sub(1)?;
let key = wall_key(&sg)?;
if coalesce {
if let Some(&index) = last.get(&key) {
let run: &mut (usize, usize, Seg2) = runs.get_mut(index)?;
if run.1 == i {
run.1 = i + 1;
continue;
}
// Duplicate oriented boundaries in one slab are not
// a valid reason to reconstruct a larger wall.
if run.1 == i + 1 {
return None;
}
}
last.insert(key, runs.len());
}
runs.push((i, i + 1, sg));
}
}
}
}
let mut faces: Vec<FaceSpec> = Vec::new();
let mut sources = Vec::new();
for (first, end, sg) in runs {
let (z0, z1) = (levels[first], levels[end]);
let (a, b) = (sg.start(), sg.end());
let mut lp3 = Vec::with_capacity(2 + 2 * (end - first));
lp3.push(edge(&sg, z0));
// Keep station vertices at both ends: neighboring stepped walls/caps
// must share complete edges rather than create T-junctions.
for k in first..end {
lp3.push(EdgeSpec::Line { a: at(b, levels[k]), b: at(b, levels[k + 1]) });
}
lp3.push(edge(&sg.reversed(), z1));
for k in (first..end).rev() {
lp3.push(EdgeSpec::Line { a: at(a, levels[k + 1]), b: at(a, levels[k]) });
}
let outward = plane.dir_to_world(sg.derivative(0.5)).cross(d).normalized()?;
let surface = match sg {
Seg2::Arc { .. } => SurfSpec::Extruded { a: at(a, z0), b: at(b, z0), mid: at(sg.point_at(0.5), z0), dir: d * (z1 - z0) },
_ => SurfSpec::Plane,
};
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) });
sources.push((end > first + 1).then_some(crate::FaceSource::ExtrudeSide { segment: sg, plane: None, coalesced: true }));
}
for (z, up, rs) in &caps {
for r in rs {
let mut loops = Vec::new();
@@ -836,9 +882,10 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::<Vec<_>>());
}
faces.push(FaceSpec { loops, surface: SurfSpec::Plane, probe: None });
sources.push(None);
}
}
Some(crate::sew::sew_touching(&faces))
Some(crate::sew::sew_touching_with_sources(&faces, &sources))
}
fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
@@ -1055,7 +1102,19 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>>
if slabs.iter().all(Vec::is_empty) {
return Some(Ok(None));
}
sew_sections(&plane, d, &levels, &slabs, tol).map(|r| r.map(Some))
sew_sections(
&plane,
d,
&levels,
&slabs,
tol,
op == BoolOp::Cut
&& a.split_keep().is_empty()
&& b.split_keep().is_empty()
&& a.paint().is_none_or(|p| p.faces.is_empty())
&& b.paint().is_none_or(|p| p.faces.is_empty()),
)
.map(|r| r.map(Some))
}
pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
@@ -1144,7 +1203,12 @@ mod planar_tests {
assert!(mesh.contains(Vec3::new(21., 21., 3.)));
assert!(!mesh.contains(Vec3::new(25., 25., 3.)));
assert!(mesh.contains(Vec3::new(25., 25., 22.)));
assert!(lump.face_sources().is_none_or(|s| s.iter().all(Option::is_none)), "genuinely stepped geometry remains uncertified");
assert!(
lump.face_sources().is_none_or(|s| s
.iter()
.all(|s| s.is_none() || matches!(s, Some(crate::FaceSource::ExtrudeSide { coalesced: true, plane: None, .. })))),
"genuinely stepped geometry has no durable certificate"
);
assert!((mesh.measure().volume - (18. * (100. - 4. * std::f64::consts::PI) + 500.)).abs() < 0.02);
}
}
@@ -1152,6 +1216,72 @@ mod planar_tests {
assert_eq!(crate::step_export(&[&tool], "tool").unwrap(), before_tool);
assert_eq!(crate::step_export(&[&roof], "roof").unwrap(), before_roof);
}
#[test]
fn contiguous_analytic_walls_remove_faces_but_retain_vertical_stations() {
for curved in [false, true] {
let outer = if curved {
Region2 { outer: Loop2::circle(Vec2::ZERO, 10.), holes: vec![] }
} else {
rectangle(Vec2::new(-10., -10.), Vec2::new(10., 10.))
};
let mut upper = outer.clone();
upper.holes.push(if curved {
Loop2::circle(Vec2::ZERO, 5.).reversed()
} else {
rectangle(Vec2::new(-5., -5.), Vec2::new(5., 5.)).outer.reversed()
});
let slabs = vec![vec![outer], vec![upper]];
let old = sew_sections(&Plane::XY, Vec3::Z, &[0., 2., 20.], &slabs, 1e-7, false).unwrap().unwrap();
let merged = sew_sections(&Plane::XY, Vec3::Z, &[0., 2., 20.], &slabs, 1e-7, true).unwrap().unwrap();
let count = if curved { 2 } else { 4 };
assert_eq!(old.face_count() - merged.face_count(), count);
let marked: Vec<_> = merged.solid.face_iter().enumerate().filter(|(i, _)| merged.face_was_coalesced(*i)).collect();
assert_eq!(marked.len(), count);
for (_, face) in marked {
assert_eq!(face.boundaries()[0].len(), 6, "all axial station vertices are retained");
let heights: Vec<_> = face.vertex_iter().map(|v| v.point().z).collect();
for z in [0., 2., 20.] {
assert!(heights.contains(&z));
}
}
assert!(old.face_sources().is_none_or(|s| s.iter().all(Option::is_none)));
let expected = if curved { std::f64::consts::PI * (100. * 20. - 25. * 18.) } else { 400. * 20. - 100. * 18. };
let mesh = merged.tessellate(1e-5).unwrap();
assert!((mesh.measure().volume - expected).abs() < 0.03);
assert!(mesh.contains(Vec3::new(0., 0., 1.)));
assert!(!mesh.contains(Vec3::new(0., 0., 10.)));
assert!(merged.validity().is_empty());
assert_ne!(wall_key(&slabs[0][0].outer.segs[0]), wall_key(&slabs[0][0].outer.segs[0].reversed()));
}
}
#[test]
fn cut_run_coalescing_respects_authored_splits_and_face_paint() {
let region = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.), holes: vec![] };
let tool_region = Region2 { outer: Loop2::circle(Vec2::ZERO, 5.), holes: vec![] };
let stock = crate::extrude(&Plane::XY, &[region], 0., 20.).unwrap().pop().unwrap();
let cutter = crate::extrude(&Plane::XY, &[tool_region], 2., 22.).unwrap().pop().unwrap();
let merged = stack_boolean(&stock, &cutter, BoolOp::Cut).unwrap().unwrap().unwrap();
let protected = [
stock.clone().with_split_lines(Some(std::sync::Arc::new(vec![vec![Vec3::new(10., 0., 0.), Vec3::new(10., 0., 20.)]]))),
stock.with_paint(Some(crate::Paint { opacity: 1., faces: vec![crate::FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] })),
];
for input in protected {
let output = stack_boolean(&input, &cutter, BoolOp::Cut).unwrap().unwrap().unwrap();
assert_eq!(output.face_count(), merged.face_count() + 2);
assert!((0..output.face_count()).all(|i| !output.face_was_coalesced(i)));
// Independent circular chord-volume bound: 2*pi*(10*20+5*18)*1e-5 < .019.
let expected = std::f64::consts::PI * (100. * 20. - 25. * 18.);
for body in [&output, &merged] {
let mesh = body.tessellate(1e-5).unwrap();
assert!((mesh.measure().volume - expected).abs() < 0.03);
assert!(mesh.contains(Vec3::new(0., 0., 1.)));
assert!(!mesh.contains(Vec3::new(0., 0., 10.)));
assert!(mesh.contains(Vec3::new(7., 0., 10.)));
}
}
}
#[test]
fn annular_inward_tool_recovers_exact_stacks_before_cap_policy() {
let plane = Plane::XY;
+17 -6
View File
@@ -97,13 +97,16 @@ impl Cache {
/// Sew the faces into a closed solid.
pub fn sew(faces: &[FaceSpec]) -> Result<Body> {
sew_impl(faces, false)
sew_impl(faces, false, None)
}
/// Separate boundary sheets at a shared geometric edge without moving it.
/// The face adjacency must still describe one connected solid boundary.
pub(crate) fn sew_touching(faces: &[FaceSpec]) -> Result<Body> {
sew_impl(faces, true)
/// Optional transient sources follow only unchanged produced face identities.
pub(crate) fn sew_touching_with_sources(faces: &[FaceSpec], sources: &[Option<crate::FaceSource>]) -> Result<Body> {
if sources.len() != faces.len() {
return Err(KernelError::Invalid("sew source alignment".into()));
}
sew_impl(faces, true, Some(sources))
}
type EdgeUse = (usize, usize, usize);
@@ -347,7 +350,7 @@ fn split_vertex_fans(faces: Vec<mt::Face>) -> Result<Vec<mt::Face>> {
.collect()
}
fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result<Body> {
fn sew_impl(faces: &[FaceSpec], touching: bool, sources: Option<&[Option<crate::FaceSource>]>) -> Result<Body> {
if faces.len() < 4 || faces.len() > 200_000 {
return Err(KernelError::Invalid("a solid needs 4…200000 faces".into()));
}
@@ -396,6 +399,11 @@ fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result<Body> {
};
out.push(face);
}
let source_ids: HashMap<_, _> = out
.iter()
.enumerate()
.filter_map(|(i, f)| sources.and_then(|s| s.get(i)).and_then(Option::as_ref).map(|s| (f.id(), (f.orientation(), s))))
.collect();
if !sheets.is_empty() && !faces_connected(&out) {
return Err(KernelError::Invalid("sew: bodies touch only along an edge".into()));
}
@@ -404,7 +412,10 @@ fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result<Body> {
}
let shells = closed_components(out)?;
let solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("sew: the faces do not close: {e}")))?;
Body::new(solid)
let body = Body::new(solid)?;
let mapped =
body.solid.face_iter().map(|f| source_ids.get(&f.id()).filter(|(o, _)| *o == f.orientation()).map(|(_, s)| (*s).clone())).collect();
Ok(body.with_face_sources(sources.map(|_| std::sync::Arc::new(mapped))))
})
}
+38
View File
@@ -79,6 +79,44 @@ fn triangles_meet(a: &[Vec3; 3], b: &[Vec3; 3], gap: f64) -> bool {
true
}
/// Whether two closed meshes share material or touch: a boundary triangle of one comes within
/// `gap` of one of the other's, or one encloses the other.
pub(super) fn meshes_meet(a: &Mesh, b: &Mesh, gap: f64) -> Result<bool> {
if !gap.is_finite() || gap <= 0.0 {
return Err(failed("invalid contact clearance"));
}
let (ba, bb) = (a.bounds(), b.bounds());
if ba.is_empty() || bb.is_empty() || boxes_separate(ba, bb, gap) {
return Ok(false);
}
// Only triangles near the other body can touch it.
let near = |m: &Mesh, other: Aabb3| -> Result<Vec<([Vec3; 3], Aabb3)>> {
let mut out = Vec::new();
for t in &m.triangles {
let tri = m.tri(t).ok_or_else(|| failed("invalid contact triangle"))?;
let bounds = box_of(&tri);
if !boxes_separate(bounds, other, gap) {
out.push((tri, bounds));
}
}
Ok(out)
};
let (ta, tb) = (near(a, bb)?, near(b, ba)?);
let work = ta.len().checked_mul(tb.len()).ok_or_else(|| failed("contact work exceeds the limit"))?;
if work > MAX_PAIR_WORK {
return Err(failed("contact work exceeds the limit"));
}
for (x, xb) in &ta {
for (y, yb) in &tb {
if !boxes_separate(*xb, *yb, gap) && triangles_meet(x, y, gap) {
return Ok(true);
}
}
}
// No boundary contact: they meet only if one lies wholly inside the other.
Ok(b.positions.first().is_some_and(|p| a.contains(*p)) || a.positions.first().is_some_and(|p| b.contains(*p)))
}
pub(super) fn groups(meshes: &[Mesh], volumes: &[f64], gap: f64) -> Result<Vec<Vec<usize>>> {
if meshes.is_empty() || meshes.len() != volumes.len() || meshes.len() > MAX_SHELLS {
return Err(failed("shell count exceeds the grouping limit or is invalid"));
+21 -2
View File
@@ -16,6 +16,10 @@ use crate::guard;
const MAX_FACES: usize = 500_000;
/// Most edges in one loop.
const MAX_LOOP: usize = 100_000;
/// Most boundary loops on one face. Each bound clones its edge handles into a new wire and the
/// face extent then samples nine points per edge, so one face listing the same 100,000-edge
/// loop a million times would hold 1e11 handles.
const MAX_BOUNDS: usize = 10_000;
/// A body read from a brep entity.
pub(crate) struct BrepOut {
@@ -196,9 +200,18 @@ impl Builder {
if !matches!(e.name(), "ADVANCED_FACE" | "FACE_SURFACE" | "FACE") {
return Err(format!("unsupported face {}", e.name()));
}
let bounds = refs(p.get(1));
if bounds.len() > MAX_BOUNDS {
return Err(format!("a face with more than {MAX_BOUNDS} boundary loops"));
}
let mut wires = Vec::new();
for b in refs(p.get(1)) {
let mut edges = 0usize;
for b in bounds {
if let Some(w) = self.bound(cx, b)? {
edges = edges.saturating_add(w.len());
if edges > MAX_LOOP {
return Err(format!("a face with more than {MAX_LOOP} boundary edges"));
}
wires.push(w);
}
}
@@ -263,7 +276,13 @@ impl Builder {
.into_iter()
.map(|w| {
let edges: Vec<mt::Edge> = w.edge_iter().cloned().collect();
let passes = |e: &mt::Edge| edges.iter().filter(|x| x.front().id() == e.front().id()).count();
// Counted once: a loop may hold 100,000 edges, and asking per edge how
// often its front vertex appears would scan them all again each time.
let mut fronts: HashMap<_, usize> = HashMap::new();
for e in &edges {
*fronts.entry(e.front().id()).or_default() += 1;
}
let passes = |e: &mt::Edge| fronts.get(&e.front().id()).copied().unwrap_or(0);
match edges.first().map(passes) {
Some(n) if n > 1 => {
let start = edges.iter().position(|e| passes(e) == 1).unwrap_or(0);
+10 -3
View File
@@ -146,7 +146,11 @@ pub(crate) fn axis1(cx: &Ctx, id: u64) -> R<(mt::Point3, mt::Vector3)> {
Ok((o, d))
}
fn knot_vec(mults: Option<&Param>, knots: Option<&Param>) -> R<mt::KnotVec> {
/// `expected` is the knot count the control points and degree imply. Multiplicities are
/// expanded into one value each, so the sum has to be checked before the expansion: 20 million
/// knots of multiplicity 64 would build a 10 GB vector that the length check then rejects, and
/// an allocation failure aborts the process where an error does not.
fn knot_vec(mults: Option<&Param>, knots: Option<&Param>, expected: usize) -> R<mt::KnotVec> {
let m: Vec<usize> = mults
.and_then(Param::as_list)
.ok_or("B-spline without knot multiplicities")?
@@ -157,6 +161,9 @@ fn knot_vec(mults: Option<&Param>, knots: Option<&Param>) -> R<mt::KnotVec> {
if m.len() != k.len() || k.is_empty() || k.windows(2).any(|w| w.first() > w.get(1)) {
return Err("inconsistent knot vector".into());
}
if m.len() > expected || m.iter().try_fold(0usize, |a, v| a.checked_add(*v)) != Some(expected) {
return Err("knot multiplicities do not match the control points".into());
}
mt::KnotVec::from_single_multi(k, m).map_err(|e| format!("knot vector: {e}"))
}
@@ -238,7 +245,7 @@ fn bspline_curve(cx: &Ctx, e: &Entity) -> R<CurveGeo> {
}
let ctrl: Vec<mt::Point3> = ctrl_ids.iter().map(|c| point(cx, c.as_ref_id().ok_or("bad control point")?)).collect::<R<_>>()?;
let kv = match knots {
Some((m, k)) => knot_vec(m, k)?,
Some((m, k)) => knot_vec(m, k, ctrl.len() + degree + 1)?,
None => generated_knots(kind, ctrl.len(), degree)?,
};
if kv.len() != ctrl.len() + degree + 1 {
@@ -737,7 +744,7 @@ pub(crate) fn bspline_surface(cx: &Ctx, e: &Entity) -> R<mt::Surface> {
return Err("ragged B-spline control net".into());
}
let (ku, kv) = match knots {
Some([mu, mv, ku, kv]) => (knot_vec(mu, ku)?, knot_vec(mv, kv)?),
Some([mu, mv, ku, kv]) => (knot_vec(mu, ku, nu + du + 1)?, knot_vec(mv, kv, nv + dv + 1)?),
None => (generated_knots(kind, nu, du)?, generated_knots(kind, nv, dv)?),
};
if ku.len() != nu + du + 1 || kv.len() != nv + dv + 1 {
+53
View File
@@ -622,3 +622,56 @@ fn glued_halves_export_as_whole_faces() {
// Box sides and caps (6) and two holes.
assert_eq!(imp.bodies[0].file_faces, 8);
}
/// Knot multiplicities are expanded into one value each before the knot count is compared with
/// the control points, so the sum has to be checked first: 1,000,000 knots of multiplicity 64
/// against two control points asked for a 500 MB vector (20,000,000 for 10 GB) only to reject
/// it afterwards, and an allocation failure aborts the process where an error does not.
#[test]
fn knot_multiplicities_are_checked_before_they_are_expanded() {
let curve = |n: usize, mult: usize| {
let knots: Vec<String> = (0..n).map(|i| format!("{i}.")).collect();
let mults: Vec<String> = std::iter::repeat_n(mult.to_string(), n).collect();
format!(
"{HEAD}#1=CARTESIAN_POINT('',(0.,0.,0.));\n#2=CARTESIAN_POINT('',(1.,0.,0.));\n\
#3=B_SPLINE_CURVE_WITH_KNOTS('',1,(#1,#2),.UNSPECIFIED.,.F.,.F.,({}),({}),.UNSPECIFIED.);\n{TAIL}",
mults.join(","),
knots.join(",")
)
};
let read = |text: &str| {
let ex = p21::parse(text).unwrap();
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 };
geom::curve(&cx, 3, 0)
};
let error = read(&curve(1_000_000, 64)).unwrap_err();
// This diagnostic comes from the check before expansion; wall-clock time depends on CI load.
assert!(error.contains("knot multiplicities do not match"), "{error}");
// A degree-1 curve with two control points needs exactly four knot values.
assert!(read(&curve(2, 2)).is_ok());
assert!(read(&curve(4, 1)).is_ok());
assert!(read(&curve(2, 3)).is_err());
}
/// One `ADVANCED_FACE` may list any number of bounds, and each one clones its edge handles into
/// a new wire that the face extent then samples. A face naming the same loop a million times
/// held 1e11 handles.
#[test]
fn faces_with_too_many_bounds_are_refused() {
let good = step_export(&[&box_solid(Vec3::ZERO, Vec3::new(5.0, 5.0, 5.0)).unwrap()], "t").unwrap();
// One extra face on the cube's shell, listing its outer bound 100,000 times.
let bounds = std::iter::repeat_n("#900001", 100_000).collect::<Vec<_>>().join(",");
let mut extra = format!("#900000=ADVANCED_FACE('',({bounds}),#900010,.T.);\n");
extra += "#900001=FACE_OUTER_BOUND('',#900002,.T.);\n#900002=EDGE_LOOP('',(#900003));\n";
extra += "#900003=ORIENTED_EDGE('',*,*,#900004,.T.);\n#900004=EDGE_CURVE('',#900005,#900005,#900007,.T.);\n";
extra += "#900005=VERTEX_POINT('',#900006);\n#900006=CARTESIAN_POINT('',(0.,0.,0.));\n";
extra += "#900007=LINE('',#900006,#900008);\n#900008=VECTOR('',#900009,1.);\n#900009=DIRECTION('',(1.,0.,0.));\n";
extra += "#900010=PLANE('',#900011);\n#900011=AXIS2_PLACEMENT_3D('',#900006,#900012,#900009);\n#900012=DIRECTION('',(0.,0.,1.));\n";
let text =
good.replacen("CLOSED_SHELL('',(", "CLOSED_SHELL('',(#900000,", 1).replacen("ENDSEC;\nEND-ISO", &format!("{extra}ENDSEC;\nEND-ISO"), 1);
let start = std::time::Instant::now();
let imp = step_import(&text).unwrap();
assert!(imp.warnings.iter().any(|w| w.contains("boundary loops")), "{:?}", imp.warnings);
assert!(start.elapsed() < std::time::Duration::from_secs(10), "took {:?}", start.elapsed());
}
+2 -2
View File
@@ -3,6 +3,8 @@
use std::io::{BufRead, Write};
use serde_json::{Value, json};
/// Longest accepted request line, shared with the control server.
use solvecraft_engine::MAX_LINE;
use crate::backend::Backend;
use crate::tools::{call_tool, check_arguments, tool_definitions};
@@ -18,8 +20,6 @@ const INVALID_PARAMS: i64 = -32602;
const INTERNAL_ERROR: i64 = -32603;
const RESOURCE_NOT_FOUND: i64 = -32002;
/// Longest accepted request line (a guard against runaway input).
const MAX_LINE: usize = 16 << 20;
/// Most messages in one JSON-RPC batch.
const MAX_BATCH: usize = 1000;
+1 -1
View File
@@ -30,7 +30,7 @@ pub use curves::{
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text};
pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE};
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError, SketchView};
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, MAX_CONSTRAINTS, MAX_CURVES, MAX_POINTS, SPoint, Sketch, SketchError, SketchView};
pub use offset::OffsetChain;
pub use offset_topology::{OffsetCreated, OffsetRole, OffsetRoleFlags, OffsetRoleKind, OffsetTopology};
pub use periodic::periodic_beziers;
+2
View File
@@ -4,6 +4,8 @@ use solvecraft_geom::{Seg2, Vec2};
/// Hard caps that keep hostile input from exhausting memory or time.
pub const MAX_POINTS: usize = 20_000;
pub const MAX_CONSTRAINTS: usize = 40_000;
/// Curves need points, so this follows the point cap.
pub const MAX_CURVES: usize = MAX_POINTS;
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum SketchError {
+50 -1
View File
@@ -15,6 +15,12 @@ use crate::linalg::{Mat, determined_vars, solve as lin_solve};
use crate::model::{ConstraintKind, CurveKind, Sketch};
const MAX_ITERS: usize = 500;
/// Variables in one connected component. Each component is solved as a dense system: an n by n
/// matrix for the Levenberg-Marquardt step, rows by n for the Jacobian. 4,000 variables is a
/// 128 MB matrix; past that the request grows to gigabytes and an allocation failure aborts the
/// process, which no panic guard can intercept. A component this size is reported as failed and
/// its geometry left alone.
const MAX_COMPONENT_VARS: usize = 4_000;
/// Converged when every residual is below this (mm).
const TOL: f64 = 1e-9;
/// A solution is accepted when every residual is below this (mm).
@@ -1050,8 +1056,9 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport {
let mut iterations = 0;
// Solve each component; `blocks` is consumed by index so rebuild per component.
let all_blocks = blocks;
let oversized = |vars: &[usize]| vars.len() > MAX_COMPONENT_VARS;
for (_, vars, bis) in &comps {
if bis.is_empty() {
if bis.is_empty() || oversized(vars) {
continue;
}
let sub: Vec<(Block, Vec<usize>)> = bis.iter().filter_map(|&bi| all_blocks.get(bi).map(|(b, v)| (copy_block(b), v.clone()))).collect();
@@ -1064,6 +1071,10 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport {
let mut failing: Vec<String> = Vec::new();
let mut det = vec![false; lay.n];
let mut rank = 0;
// A component too large to solve is a failure, not an allocation.
if comps.iter().any(|(_, vars, bis)| !bis.is_empty() && oversized(vars)) {
max_residual = f64::MAX;
}
let mut check = |r: &[f64], owner: &[usize], blocks: &[(Block, Vec<usize>)], failing: &mut Vec<String>| {
max_residual = max_residual.max(max_abs(r));
for (row, v) in r.iter().enumerate() {
@@ -1077,6 +1088,9 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport {
}
};
for (_, vars, bis) in &comps {
if oversized(vars) {
continue;
}
let sub: Vec<(Block, Vec<usize>)> = bis.iter().filter_map(|&bi| all_blocks.get(bi).map(|(b, v)| (copy_block(b), v.clone()))).collect();
if sub.is_empty() {
continue;
@@ -1250,4 +1264,39 @@ mod typed_dimension_tests {
sk.remove_curves(&[l]);
assert!(sk.constraints.is_empty());
}
/// One connected component is solved as a dense system, so its variable count sets the
/// allocation: 10,000 chained points are 20,000 variables and a 3.2 GB matrix. The solve
/// has to report failure instead of asking for the memory, because an allocation failure
/// aborts the process where a failed solve does not.
#[test]
fn components_too_large_to_solve_fail_instead_of_allocating() {
let mut sk = Sketch::new();
let mut previous = 0;
for i in 1..10_000 {
let p = sk.add_point(Vec2::new(0.001 * i as f64, 0.0), None).unwrap();
sk.add_constraint(ConstraintKind::Coincident { p: previous, q: p }, None).unwrap();
previous = p;
}
let before = sk.points.clone();
let t = std::time::Instant::now();
let report = solve(&mut sk);
assert_eq!(report.status, SolveStatus::Failed);
// Geometry is left as it was.
assert_eq!(sk.points, before);
assert!(t.elapsed() < std::time::Duration::from_secs(10), "took {:?}", t.elapsed());
}
/// A sketch whose components each stay under the cap still solves.
#[test]
fn many_small_components_still_solve() {
let mut sk = Sketch::new();
for i in 0..200 {
let a = sk.add_point(Vec2::new(i as f64, 0.0), None).unwrap();
let b = sk.add_point(Vec2::new(i as f64, 5.0), None).unwrap();
let l = sk.add_line_pts(a, b, None).unwrap();
sk.add_constraint(ConstraintKind::Vertical { l }, None).unwrap();
}
assert!(solve(&mut sk).ok());
}
}
File diff suppressed because it is too large. Load diff
+33 -4
View File
@@ -1023,6 +1023,29 @@ mod tests {
assert_eq!(cell_value("param:width", "90 mm"), json!("90 mm"));
}
fn click_rule_button(h: &mut crate::scenario::Harness, name: &str) {
// The table header also contains this name. Its TextRun may be emitted
// after the popup, so select the actual choice rather than matching text.
let bounds = h
.output
.accesskit_update
.as_ref()
.unwrap()
.nodes
.iter()
.rev()
.find_map(|(_, n)| {
(n.role() == egui::accesskit::Role::Button && !n.is_disabled() && n.label() == Some(name)).then(|| n.bounds()).flatten()
})
.expect("rule button");
h.step(&json!({"click": [(bounds.x0 + bounds.x1) / 2.0, (bounds.y0 + bounds.y1) / 2.0]})).unwrap();
}
fn select_rule_parameter(h: &mut crate::scenario::Harness, name: &str) {
click_rule_button(h, name);
assert!(h.widget("Choose…").is_none(), "parameter choice should close and update the dropdown");
}
#[test]
fn configuration_compound_condition_and_rule_order_are_editable_in_dialog() {
let mut h = crate::scenario::Harness::new();
@@ -1032,9 +1055,11 @@ mod tests {
{"call":"engine.execute","params":{"command":"config.configure","params":{"params":["length"]}}},
{"start":"config.table"},{"widget":"Add or edit rule"},
{"widget":"Always"},{"widget":"All"},{"widget":"Always"},{"widget":"Parameter"},
{"widget":"Choose…"},{"widget":"length"},{"widget":"Save Rule"}
{"widget":"Choose…"}
]))
.unwrap();
select_rule_parameter(&mut h, "length");
h.step(&json!({"widget":"Save Rule"})).unwrap();
assert!(
matches!(&h.app.session.doc.configs.rules[0].condition,solvecraft_engine::doc::config::RuleCondition::All{conditions} if matches!(&conditions[0],solvecraft_engine::doc::config::RuleCondition::Parameter{..}))
);
@@ -1044,7 +1069,8 @@ mod tests {
])).unwrap();
assert!(h.widget("length = 20 mm").is_some());
assert_eq!(h.app.session.doc.param("length").unwrap().expr, "40 mm");
h.run(&json!([{"widget":"↑"},{"widget":"Preview Row"}])).unwrap();
click_rule_button(&mut h, "↑");
h.step(&json!({"widget":"Preview Row"})).unwrap();
assert_eq!(h.app.session.doc.configs.rules[0].id, "last");
assert!(h.widget("length = 10 mm").is_some());
assert_eq!(h.app.session.doc.param("length").unwrap().expr, "40 mm");
@@ -1060,8 +1086,11 @@ mod tests {
{"call":"engine.execute","params":{"command":"config.configure","params":{"params":["length"]}}},
{"start":"config.table"},
{"widget":"Add or edit rule"},
{"widget":"Choose…"},
{"widget":"length"},
{"widget":"Choose…"}
]))
.unwrap();
select_rule_parameter(&mut h, "length");
h.run(&json!([
{"widget":"Save Rule"}
]))
.unwrap();
+45
View File
@@ -89,6 +89,51 @@ pub fn right_aligned<R>(ui: &mut Ui, total: f32, width: f32, add: impl FnOnce(&m
#[cfg(test)]
mod tests {
#[test]
fn feature_forms_use_available_height_and_keep_actions_visible() {
use crate::scenario::Harness;
use serde_json::json;
for (width, height, scale) in [(1400.0, 900.0, 1.0), (1400.0, 900.0, 2.0)] {
let mut h = Harness::new();
h.app.preferences.ui_scale = scale;
h.call("ui.resize", json!({"width":width / scale,"height":height / scale}));
h.app.run("sketch.create", json!({"plane":"XY","name":"Base"})).unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.step(&json!({"start":"solid.extrude"})).unwrap();
h.frames(10);
let dialog = h.app.viewport.dialog_rect.unwrap();
let viewport = h.app.viewport.rect.unwrap();
assert!(dialog.height() > 230.0, "scale={scale}: {dialog:?}");
assert!(viewport.expand(1.0).contains_rect(dialog), "scale={scale}: {dialog:?} outside {viewport:?}");
assert!(h.widget("Distance").is_some(), "distance must be visible without scrolling");
if scale == 1.0 {
assert!(h.widget("Operation").is_some(), "operation must be visible without scrolling");
}
assert!(dialog.contains(crate::scenario::handle_at("dialog:OK").unwrap()));
assert!(h.widget("Cancel").is_some(), "cancel must be visible without scrolling");
h.step(&json!({"widget":"Cancel"})).unwrap();
assert!(h.app.dialog.is_none());
h.step(&json!({"start":"solid.shell"})).unwrap();
h.frames(8);
let short = h.app.viewport.dialog_rect.unwrap();
assert!(short.height() < dialog.height(), "short forms should shrink: {short:?}, {dialog:?}");
assert!(h.widget("Cancel").is_some());
h.step(&json!({"widget":"Cancel"})).unwrap();
h.step(&json!({"start":"solid.hole"})).unwrap();
if let Some(crate::dialogs::Dialog { kind: crate::dialogs::Kind::Hole { kind, opts, .. }, .. }) = h.app.dialog.as_mut() {
*kind = 1;
opts.tapped = true;
}
h.frames(8);
let long = h.app.viewport.dialog_rect.unwrap();
assert!(viewport.expand(1.0).contains_rect(long), "long form clipped: {long:?}");
assert!(h.widget("Cancel").is_some(), "long-form actions must stay visible");
assert!(long.contains(crate::scenario::handle_at("dialog:OK").unwrap()));
h.step(&json!({"widget":"Cancel"})).unwrap();
assert!(h.app.dialog.is_none());
}
}
/// Windows are made in this file only, so none can skip the width limits.
#[test]
fn windows_come_from_the_frame_helper() {
+10 -1
View File
@@ -78,6 +78,8 @@ mod revolve_edit_tests;
mod revolve_manipulator_tests;
pub mod scenario;
pub mod selection;
#[cfg(test)]
mod shell_face_sketch_ui_tests;
pub mod shortcut_box;
pub mod sketch3d;
#[cfg(test)]
@@ -989,9 +991,16 @@ impl SolveApp {
}
fn drain_control(&mut self, ctx: &egui::Context) {
/// Requests run on this thread, so a flood would starve rendering. The rest stay
/// queued for the next frame.
const PER_FRAME: usize = 64;
let Some(rx) = self.control_rx.take() else { return };
// While the agent cursor acts out a request, later ones wait their turn.
while !self.agent.busy() {
for _ in 0..PER_FRAME {
if self.agent.busy() {
break;
}
let Ok(req) = rx.try_recv() else { break };
let reply = req.reply.clone();
if agent_cursor::intercept(self, &req.method, &req.params, &reply) {
@@ -0,0 +1,103 @@
use crate::scenario::Harness;
use serde_json::json;
use solvecraft_engine::{
Sel,
doc::{FaceAttachmentStatus, FeatureKind, PlaneRef},
};
fn shell() -> Harness {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.run("solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Case"})).unwrap();
h.app.run("solid.shell", json!({"faces":[[20,15,20]],"thickness":2})).unwrap();
h.call("ui.view", json!({"view":"front"}));
h.call("ui.view", json!({"view":"fit"}));
h.settle();
h
}
fn assert_attached(h: &Harness) {
let id = h.app.session.active_sketch.expect("a combined certified wall remains a sketch target");
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &h.app.session.doc.feature(id).unwrap().kind else {
panic!("wall sketch must have a durable attachment");
};
assert_eq!(anchor.target.body, "Case");
assert_eq!(serde_json::to_value(&anchor.target.certificate).unwrap()["kind"], "shell_planar");
assert!(matches!(h.app.session.doc.sketch_face_attachment(id, &h.app.session.model), FaceAttachmentStatus::Healthy { .. }));
let world = h.app.session.world_state();
let solved = world.sketch(id).unwrap();
assert!(solved.plane.origin.y.abs() < 1e-8);
assert!(solved.plane.normal().y.abs() > 0.999);
assert!(h.app.session.model.result(id).unwrap().error.is_none());
}
fn selected_wall(h: &Harness) -> usize {
assert_eq!(h.app.session.selection.len(), 1, "the exterior wall is one physical face, not a grouped lower strip");
let Sel::Face { body, index, .. } = &h.app.session.selection[0] else {
panic!("actual wall click must select a face");
};
assert_eq!(body, "Case");
*index
}
fn assert_one_physical_wall(h: &mut Harness) -> usize {
h.step(&json!({"click":{"world":[20,0,1]}})).unwrap();
let lower = selected_wall(h);
h.call("ui.click", json!({"x":300,"y":200}));
assert!(h.app.session.selection.is_empty());
h.step(&json!({"click":{"world":[20,0,10]}})).unwrap();
assert_eq!(selected_wall(h), lower, "below and above the floor height must hit the same BRep face index");
lower
}
#[test]
fn selected_shell_wall_patch_still_creates_a_certified_sketch() {
let mut h = shell();
assert_one_physical_wall(&mut h);
let undo = h.app.session.undo.len();
h.step(&json!({"start":"sketch.create"})).unwrap();
h.settle();
assert_attached(&h);
assert_eq!(h.app.session.undo.len(), undo + 1);
}
#[test]
fn pending_create_sketch_accepts_an_actual_shell_wall_patch_click() {
let mut h = shell();
assert_one_physical_wall(&mut h);
h.call("ui.click", json!({"x":300,"y":200}));
assert!(h.app.session.selection.is_empty());
let undo = h.app.session.undo.len();
h.step(&json!({"start":"sketch.create"})).unwrap();
assert!(h.app.session.active_sketch.is_none());
h.step(&json!({"click":{"world":[20,0,1]}})).unwrap();
h.settle();
assert_attached(&h);
assert_eq!(h.app.session.undo.len(), undo + 1);
}
#[test]
fn rounded_shell_corner_is_one_physical_face_above_and_below_the_floor() {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.run("solid.box", json!({"length":80,"width":60,"height":30,"corner":[-40,-30,0],"body_name":"Case"})).unwrap();
h.app.run("solid.fillet", json!({"edges":[[40,30,15],[-40,30,15],[-40,-30,15],[40,-30,15]],"radius":6})).unwrap();
h.app.run("solid.shell", json!({"faces":[[0,0,30]],"thickness":2})).unwrap();
h.call("ui.view", json!({"view":"iso"}));
h.call("ui.view", json!({"view":"fit"}));
h.settle();
let state = h.app.session.world_state();
assert_eq!(state.body("Case").unwrap().body.face_count(), 19, "no artificial lower corner faces remain in the BRep");
let document = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let redo = h.app.session.redo.len();
h.step(&json!({"click":{"world":[38.242640687,-28.242640687,1]}})).unwrap();
let lower = selected_wall(&h);
h.call("ui.click", json!({"x":300,"y":200}));
assert!(h.app.session.selection.is_empty());
h.step(&json!({"click":{"world":[38.242640687,-28.242640687,15]}})).unwrap();
assert_eq!(selected_wall(&h), lower, "lower and upper corner clicks hit the same actual face, not two grouped pieces");
assert_eq!(h.app.session.doc.to_json(), document);
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(h.app.session.redo.len(), redo);
}
@@ -142,14 +142,16 @@ fn unsupported_modified_face_offers_explicit_fixed_plane_without_live_links() {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Modified"})).unwrap();
h.app.run("solid.fillet", json!({"edges":[[0,0,5]],"radius":1})).unwrap();
// An intentional split gives the +z source two distinct planar pieces. A
// strict attachment cannot claim the original whole-face certificate.
h.app.run("solid.split_face", json!({"body":"Modified","faces":[[5,5,10]],"plane":{"origin":[5,0,0],"normal":[1,0,0]}})).unwrap();
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
h.call("ui.view", json!({"view":"top"}));
h.call("ui.view", json!({"view":"fit"}));
h.settle();
h.step(&json!({"start":"sketch.create"})).unwrap();
h.step(&json!({"click":{"world":[5,5,10]}})).unwrap();
h.step(&json!({"click":{"world":[2.5,5,10]}})).unwrap();
assert!(h.app.session.active_sketch.is_none());
assert!(h.app.dialog.as_ref().unwrap().error.is_some());
assert_eq!(h.app.session.doc.to_json(), before);
+12 -6
View File
@@ -42,16 +42,14 @@ impl Layout {
pub fn new(viewport: Rect, docks: &[Rect], scale: f32) -> Self {
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
let mut obstacles: Vec<_> = docks.iter().take(6).copied().filter(|r| r.is_finite()).map(|r| r.expand(4.0)).collect();
let available = free_rect(viewport, &obstacles);
let axis_view = if available.width() >= 116.0 && available.height() >= 116.0 {
// Navigation is anchored to the viewport, never to transient dialog space.
let available = viewport;
let mut axis_view = if available.width() >= 116.0 && available.height() >= 116.0 {
Rect::from_min_size(pos2(available.right() - 116.0, available.top()), vec2(116.0, 116.0))
} else {
Rect::NOTHING
};
if axis_view.is_positive() {
obstacles.push(axis_view);
}
let nav_space = free_rect(viewport, &obstacles);
let nav_space = viewport;
let columns = ((nav_space.width() - 10.0) / 30.0).floor().clamp(1.0, 10.0);
let nav_height = (10.0 / columns).ceil() * 30.0;
let navigation = if nav_space.width() >= columns * 30.0 + 10.0 && nav_space.height() >= nav_height + 7.0 {
@@ -59,6 +57,14 @@ impl Layout {
} else {
Rect::NOTHING
};
// On tiny viewports keep the bottom controls usable rather than overlapping them.
// This decision depends only on viewport size, never on an overlay.
if axis_view.intersect(navigation).is_positive() {
axis_view = Rect::NOTHING;
}
if axis_view.is_positive() {
obstacles.push(axis_view);
}
if navigation.is_positive() {
obstacles.push(navigation);
}
+89 -1
View File
@@ -142,7 +142,19 @@ fn measured_axis_view_targets_home_and_drag_work_beside_the_palette_at_hidpi() {
assert_eq!(outer, layout.axis_view);
assert!(outer.contains_rect(home));
assert!(layout.viewport.contains_rect(outer));
assert!(!outer.intersect(palette).is_positive());
assert_eq!(outer.right_top(), layout.viewport.right_top());
if outer.intersect(palette).is_positive() {
// Short palettes may cover the fixed anchor; covered controls never steal input.
let covered = outer.intersect(palette).center();
let camera = h.app.cam;
h.call("ui.click", json!({"x":covered.x,"y":covered.y}));
assert_eq!(h.app.cam, camera);
assert!(h.app.viewport.axis_drag.is_none());
assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), outer);
assert_eq!(h.app.session.doc.to_json(), baseline);
assert_eq!(h.app.session.undo.len(), undo);
continue;
}
assert!(!layout.navigation.intersect(outer).is_positive());
for name in ["x", "-x", "y", "-y", "z", "-z"] {
let p = crate::scenario::handle_at(&format!("axis_view:{name}")).unwrap();
@@ -194,3 +206,79 @@ fn immediate_standard_view_cancels_fit_before_the_measured_frame() {
assert_eq!(h.app.session.doc.to_json(), baseline);
assert_eq!(h.app.session.undo.len(), undo);
}
#[test]
fn navigation_anchors_ignore_positioned_resized_and_covering_dialogs() {
for (width, height, scale) in [(1400.0, 900.0, 1.0), (700.0, 450.0, 2.0), (700.0, 360.0, 2.0)] {
let vp = Rect::from_min_max(pos2(250.0, 126.0), pos2(width, height - 30.0));
let baseline = Layout::new(vp, &[], scale);
assert!(baseline.axis_view.is_positive() && baseline.navigation.is_positive());
assert_eq!(baseline.axis_view.right_top(), vp.right_top());
assert_eq!(baseline.navigation.center().x, vp.center().x);
assert_eq!(baseline.navigation.bottom(), vp.bottom() - 7.0);
let cases = [
vec![Rect::from_min_max(pos2(width - 220.0, vp.top() + 120.0), vp.max)],
vec![Rect::from_min_size(vp.min, vec2(210.0, 170.0))],
vec![Rect::from_center_size(vp.center(), vec2(250.0, 180.0)), Rect::from_min_size(vp.min, vec2(70.0, 120.0))],
vec![vp],
];
for docks in cases {
let measured = Layout::new(vp, &docks, scale);
assert_eq!(measured.axis_view, baseline.axis_view, "{docks:?}");
assert_eq!(measured.navigation, baseline.navigation, "{docks:?}");
assert!(vp.contains_rect(measured.axis_view));
assert!(vp.contains_rect(measured.navigation));
}
}
}
#[test]
fn opening_different_command_dialogs_keeps_published_navigation_positions() {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap();
h.settle();
let axis = crate::scenario::rect_of("axis_view:outer").unwrap();
let nav = crate::scenario::rect_of("nav:outer").unwrap();
let camera = h.app.cam;
let baseline = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
for command in ["solid.extrude", "solid.fillet", "inspect.measure"] {
h.app.start(command);
h.settle();
assert!(h.app.dialog.is_some(), "{command}");
assert!(h.app.viewport.dialog_rect.is_some(), "dialog must be measured");
assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), axis, "{command}");
assert_eq!(crate::scenario::rect_of("nav:outer").unwrap(), nav, "{command}");
assert_eq!(h.app.cam, camera);
let pan = pos2(nav.left() + 5.0 + 2.0 * 30.0 + 13.0, nav.top() + 15.0);
assert!(!h.app.viewport.dialog_rect.unwrap().contains(pan));
h.app.viewport.nav = None;
h.call("ui.click", json!({"x":pan.x,"y":pan.y}));
assert_eq!(h.app.viewport.nav, Some(crate::viewport::NavMode::Pan), "fixed navigation stays usable beside {command}");
h.app.dialog = None;
h.settle();
assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), axis);
assert_eq!(crate::scenario::rect_of("nav:outer").unwrap(), nav);
}
assert_eq!(h.app.session.doc.to_json(), baseline);
assert_eq!(h.app.session.undo.len(), undo);
}
#[test]
fn tiny_viewports_never_overlap_navigation_and_axis_view() {
for width in [39.0, 40.0, 90.0, 120.0, 160.0, 310.0] {
for height in [30.0, 80.0, 130.0, 160.0, 240.0] {
let vp = Rect::from_min_size(pos2(20.0, 30.0), vec2(width, height));
let clear = Layout::new(vp, &[], 1.0);
let covered = Layout::new(vp, &[vp], 1.0);
assert_eq!(clear.axis_view, covered.axis_view);
assert_eq!(clear.navigation, covered.navigation);
assert!(!clear.axis_view.intersect(clear.navigation).is_positive());
for control in [clear.axis_view, clear.navigation] {
if control.is_positive() {
assert!(vp.contains_rect(control), "{clear:?}");
}
}
}
}
}
+63
View File
@@ -0,0 +1,63 @@
//! Coincident accepts clicked line bodies as well as explicit sketch points.
use serde_json::json;
use solvecraft_engine::sketch::ConstraintKind;
use solvecraft_ui_egui::scenario::Harness;
fn setup() -> Harness {
let mut h = Harness::new();
h.run(&json!([
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[10,10],[30,10]],"infer":false,"connect":false}}},
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[35,15],[35,40]],"infer":false,"connect":false}}},
{"call":"ui.view","params":{"view":"top"}}
]))
.unwrap();
h
}
#[test]
fn clicked_lines_join_closest_endpoints_in_both_orders() {
for reverse in [false, true] {
let mut h = setup();
let before = serde_json::to_value(&h.app.session.doc).unwrap();
let undo = h.app.session.undo.len();
let picks = if reverse { [[35.0, 27.5], [20.0, 10.0]] } else { [[20.0, 10.0], [35.0, 27.5]] };
h.run(
&json!([{"start":"sketch.constraint.coincident"}, {"click":{"sketch":picks[0]}}, {"click":{"sketch":picks[1]}}, {"expect":{"errors":0}}]),
)
.unwrap();
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
let sk = h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap();
let a = sk.resolve_point("l1.end").unwrap();
let b = sk.resolve_point("l2.start").unwrap();
assert!(sk.point(a).unwrap().dist(sk.point(b).unwrap()) < 1e-6);
assert!(sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Coincident {p,q} if (p,q)==(a,b) || (p,q)==(b,a))));
let fixed_ref = if reverse { "l2.end" } else { "l1.start" };
let expected = if reverse { [35.0, 40.0] } else { [10.0, 10.0] };
let fixed = sk.point(sk.resolve_point(fixed_ref).unwrap()).unwrap();
assert!((fixed.x - expected[0]).abs() < 1e-6 && (fixed.y - expected[1]).abs() < 1e-6);
assert_eq!(h.app.session.undo.len(), undo + 1);
let after = serde_json::to_value(&h.app.session.doc).unwrap();
h.run(&json!([{"call":"engine.execute","params":{"command":"edit.undo","params":{}}}])).unwrap();
assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), before);
h.run(&json!([{"call":"engine.execute","params":{"command":"edit.redo","params":{}}}])).unwrap();
assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), after);
}
}
#[test]
fn fixed_line_pair_rejects_without_changing_document_or_history() {
let mut h = setup();
h.run(&json!([
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entity":"l1"}}},
{"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entity":"l2"}}}
]))
.unwrap();
let before = serde_json::to_value(&h.app.session.doc).unwrap();
let undo = h.app.session.undo.len();
h.run(&json!([{"start":"sketch.constraint.coincident"},{"click":{"sketch":[20,10]}},{"click":{"sketch":[35,27.5]}}])).unwrap();
assert!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), before);
assert_eq!(h.app.session.undo.len(), undo);
}
@@ -61,20 +61,13 @@
}
},
{
"note": "The shelled floor is picked rather than the origin plane. Its uncertified mesh face requires an explicit fixed-plane choice."
"note": "The certified Shell floor is picked rather than the origin plane and starts a healthy attached sketch directly."
},
{
"expect": {
"sketching": false,
"dialog": "this face has no exact planar source"
}
},
{
"widget": "Use fixed plane"
},
{
"expect": {
"sketching": true
"sketching": true,
"dialog": null,
"errors": 0
}
},
{
+21 -1
View File
@@ -18,7 +18,9 @@ replaced version is kept as `<file>.bak`. Unsaved changes are autosaved to a rec
and the next launch offers to recover them. Older design files are upgraded on open, and files
from a newer version are refused with a message.
Current designs use format 10 for recorded body transfers, explicit 3D curve coordinate
Current designs use format 12, including persistent analytic offset boundary roles and
association profile references, durable sketch face anchors, periodic fit splines and versioned
revolve conventions. It also retains recorded body transfers, explicit 3D curve coordinate
spaces and certified face bindings, alongside semantic configuration styles, associative
free-form offsets and cylindrical emboss selections. Older ownership maps, geometry and
expressions retain their saved meaning. Legacy 3D curves with retained associations receive
@@ -26,3 +28,21 @@ their producer's coordinate-space marker without changing any saved points; deta
retain world coordinates. A lost cross-component association refreshed by an older build may
need an explicit relink or refresh when its source returns. Loading never guesses a numerical
correction. Association records, coordinate arrays and placements are bounded and checked.
## Saving in the web app
In the web app, **Save** and **Save As** keep designs in this browser's private storage;
they do not open a dialog for choosing a folder on your computer. Clearing the site's data
can remove those designs. Download a copy to keep it outside the browser or move it to
another device.
1. Open the File menu and choose **Open** to show **Files in this browser**.
2. Choose **Download open design** for the current design, including its unsaved changes,
or **Download** beside a stored file for its last saved contents.
3. Find the `.solvecraft` file in your browser's downloads. Whether a folder chooser appears
depends on your browser's download settings.
Use **Open from computer…** in the same panel to reopen a downloaded design. The web app
works on a browser-stored copy; later saves do not update the original downloaded file.
Use **Rename** beside a stored file to change its name. Exports such as STEP and STL are
also downloaded.
+5
View File
@@ -91,6 +91,11 @@ midpoint, symmetry, fix, curvature (G2) and polygon. **Sketch Dimension** (D) ad
aligned, horizontal, vertical, radius, diameter and angle dimensions. Each is a parameter
(`d1`, `d2`, …), so it can hold an expression.
For **Coincident**, pick two points, a point and a curve (in either order), or two
distinct straight lines. Two lines join their closest pair of endpoints; the first
picked line stays in place when the existing constraints allow it. Pick endpoints
explicitly when you want to connect a different pair.
For Parallel, Perpendicular, Equal, Tangent, Concentric and Collinear, select exactly two
sketch curves (Shift-click to add the second) and choose the constraint to apply it immediately.
You can also start the constraint first and then pick its curves. An incomplete or mixed
+8
View File
@@ -6,6 +6,14 @@ line `{"id": …, "ok": true, "result": …}` or `{"id": …, "ok": false, "erro
handled on the UI thread between frames; pointer and keyboard input is injected as real egui
events, one pointer event per frame.
The channel is untrusted input. A request line may be at most 16 MiB; a longer one is answered
with `{"ok": false, "error": "request too large"}` and the connection is closed, as are
connections that send eight lines that are not JSON. At most 16 connections are served at once
(further ones get `{"ok": false, "error": "too many connections"}`), at most 64 queued requests
run per frame so a flood cannot starve drawing, and a request that the UI thread has not
answered within 60 seconds is replied to with `{"ok": false, "error": "timeout"}`. The same
16 MiB line limit applies to the MCP server.
## Engine
| Method | Params | Result |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.
+138 -23
View File
@@ -11,8 +11,9 @@ Rectangular Pattern dialog, then width and wall changed in the Parameters dialog
Modelling steps go through the app's UI over its control channel: the pointer glides to a
toolbar button and clicks it (the real dialog opens), glides to the face or point it picks and
clicks, and values are typed into the dialog, as by hand. Sketches go on the model's faces and
the camera follows like a user's would (Look At on a new sketch, back out to a 3/4 view on
clicks, and values are typed into the dialog, as by hand. The port and boss sketches attach
directly to the enclosure's side face and inner floor, as in the original demo.
The camera follows like a user's would (Look At on a new sketch, back out to a 3/4 view on
Finish Sketch, a little orbit between features). The parameters and the dimensions that use them
are MCP `execute` calls through `solvecraft-cli mcp --connect` (enclosure_mcp.py's client).
@@ -37,8 +38,9 @@ MIN_CAPTION = 1.4
class Demo:
def __init__(self, port, mcp, log, speed):
def __init__(self, port, mcp, log, speed, minimum_caption=MIN_CAPTION):
self.port, self.mcp, self.log, self.speed = port, mcp, log, speed
self.minimum_caption = minimum_caption
self.pos = None
self.stop = 10**6
@@ -54,8 +56,8 @@ class Demo:
raise StopIteration
# Every caption stays up long enough to read.
shown = time.time() - getattr(self, "shown_at", 0.0)
if shown < MIN_CAPTION:
time.sleep(MIN_CAPTION - shown)
if shown < self.minimum_caption:
time.sleep(self.minimum_caption - shown)
self.shown_at = time.time()
print(f" {text} [{via}]", file=sys.stderr)
self.mark(caption=text, via=via)
@@ -71,8 +73,16 @@ class Demo:
return r.get("result")
def at(self, **where):
p = self.ui("ui.at", where)
return (p[0], p[1])
# A clicked combo publishes its popup handles on the next rendered frame.
# Observe that bounded transition; never substitute a command for the click.
for attempt in range(50 if "handle" in where else 1):
r = control(self.port, "ui.at", where)
if r.get("ok", False):
p = r["result"]
return (p[0], p[1])
if attempt + 1 < 50 and "handle" in where:
time.sleep(0.1)
raise RuntimeError(f"ui.at {where}: {r.get('error')}")
def glide(self, to, dur=0.3):
"""Move the pointer to `to` along an eased path (the app sees every step: hover
@@ -93,10 +103,16 @@ class Demo:
self.ui("ui.click", {"x": to[0], "y": to[1]})
self.mark(cursor=list(to), click=True)
self.wait(settle)
self.settle_camera()
def tool(self, command, panel=None):
"""Click a toolbar button (opening its panel's drop-down first when it's in one)."""
if panel:
self.settle_camera()
dialog = self.ui("ui.inspect").get("dialog") or ""
if "error: Some(" in dialog:
raise RuntimeError(f"the previous UI operation was refused: {dialog}")
handles = self.ui("ui.handles")
if panel and not any(h["name"] == f"toolbar:{command}" for h in handles):
self.click(self.at(handle=panel), settle=0.3)
self.click(self.at(handle=f"toolbar:{command}"), settle=0.35)
@@ -106,16 +122,31 @@ class Demo:
if enter:
self.ui("ui.key", {"key": "Enter"})
self.wait(0.5)
self.settle_camera()
def key(self, k):
self.ui("ui.key", {"key": k})
self.wait(0.15)
self.settle_camera()
def view(self, v, settle=0.7):
self.ui("ui.view", {"view": v, "animate": True})
if v == "home":
# The shipping Axis View's Home button, with the same camera/style as v3.
self.click(self.at(handle="axis_view:home"), settle=0.3)
else:
self.ui("ui.view", {"view": v, "animate": True})
self.wait(settle)
self.settle_camera()
if v == "home":
# Original Home also fitted the model. The current Axis View only
# rotates: retaining the close sketch camera crops the first extrusion.
self.ui("ui.view", {"view": "fit"})
self.settle_camera()
vp = self.ui("ui.inspect")["viewport"]
self.ui("ui.scroll", {"x": vp[0] + vp[2] / 2, "y": vp[1] + vp[3] / 2, "delta": -250})
self.settle_camera()
def orbit(self, dx, dy=0, steps=40):
"""A right-button drag in the viewport, as a user orbits."""
vp = self.ui("ui.inspect").get("viewport") or [250, 126, 1350, 800]
@@ -139,18 +170,38 @@ class Demo:
self.pos = tuple(to)
def settle_camera(self):
"""Wait until the app has played queued pointer events (the camera stops moving)."""
"""Wait for queued pointer events, moving dialogs and geometry previews."""
last, still = None, 0
for _ in range(80):
for _ in range(1200):
time.sleep(0.1)
cam = json.dumps(self.ui("ui.inspect").get("camera"))
still = still + 1 if cam == last else 0
last = cam
if still >= 2:
state = self.ui("ui.inspect")
current = json.dumps([state.get("camera"), state.get("tool"), self.ui("ui.handles")], sort_keys=True)
busy = state.get("preview", {}).get("busy", False)
still = still + 1 if current == last and not busy else 0
last = current
if still >= 3:
return
raise RuntimeError("the camera/dialog/preview did not settle within 120 seconds")
def execute(self, command, params):
self.settle_camera()
self.mcp.tool("execute", {"command": command, "params": params})
self.settle_camera()
def datum_sketch(d, base, offset):
"""Create a parametric datum in the real dialog, then select its browser row."""
d.ui("ui.set", {"showOrigin": True})
d.tool("construct.plane.offset", panel="panel:SOLID:CONSTRUCT")
d.click(d.at(plane=base), settle=0.3)
d.type(offset)
names = [h["name"] for h in d.ui("ui.handles") if h["name"].startswith("timeline:")]
plane = names[-1].split(":", 1)[1]
d.datums = getattr(d, "datums", []) + [plane]
d.tool("sketch.create")
d.click(d.at(handle=f"row:{plane}"), settle=0.8)
d.settle_camera()
d.ui("ui.set", {"showOrigin": False})
def build_box(d, params):
@@ -189,16 +240,22 @@ def build_box(d, params):
d.view("home", 0.6)
d.caption("Extrude: height", "UI click")
# The first Fit framed a flat sketch at z=0. Leave room for the upcoming
# 30 mm height instead of letting its far corner grow above the viewport.
vp = d.ui("ui.inspect")["viewport"]
d.ui("ui.scroll", {"x": vp[0] + vp[2] / 2, "y": vp[1] + vp[3] / 2, "delta": -250})
d.settle_camera()
d.tool("solid.extrude")
d.wait(0.3)
d.type("height")
d.caption("Fillet the four vertical edges: 6 mm", "UI click")
d.tool("solid.fillet")
for p in ([40, -30, 15], [-40, -30, 15], [40, 30, 15]):
for p in ([40, -30, 15], [-40, -30, 15]):
d.click(d.at(world=p), settle=0.15)
d.orbit(-260, 20)
d.click(d.at(world=[-40, 30, 15]), settle=0.2)
for p in ([40, 30, 15], [-40, 30, 15]):
d.click(d.at(world=p), settle=0.2)
d.type("6")
d.view("home", 0.5)
@@ -310,13 +367,17 @@ def run_v3(d):
select_all_and_type(d, d.at(handle="dialog:Spacing 2"), "-(depth - 2*inset)", enter=False)
d.wait(0.4)
d.click(d.at(handle="dialog:OK"), settle=0.4)
# Up a little, so the corners behind the walls show.
# The original small orbit reveals the corners behind the walls.
d.orbit(-60, 45)
# Raise the view enough to see all four 12 mm bosses behind the 30 mm walls.
d.orbit(0, 35)
d.key("Escape")
d.caption("Parameters dialog: width 80 → 100 mm", "UI click")
d.tool("parameters.change", panel="panel:SOLID:MODIFY")
d.wait(0.3)
# Move the dialog aside and the model over, so both show.
# Original framing: move the dialog aside and pan the model beside it.
title = d.at(handle="params:title")
d.drag(title, (title[0] - 330, title[1] + 20), steps=25)
vp = d.ui("ui.inspect").get("viewport") or [250, 126, 1350, 800]
@@ -329,10 +390,8 @@ def run_v3(d):
d.click(d.at(handle="params:close"), settle=0.4)
d.caption("", "")
d.orbit(300, 0, steps=45)
# End on a high 3/4 view: all four standoffs in sight.
# Keep the original demo's lower three-quarter silhouette.
d.view("home", 0.5)
d.orbit(0, 45, steps=25)
d.wait(1.5)
@@ -344,17 +403,73 @@ def main():
ap.add_argument("--story", choices=["v2", "v3"], default="v3", help="v2: standoffs from one sketch, width over MCP; v3: one standoff patterned, Parameters dialog")
ap.add_argument("--until", type=int, default=10**6, help="stop before step N (trying the script out)")
ap.add_argument("--speed", type=float, default=0.55, help="scale the pauses (2 = twice as long)")
ap.add_argument("--save", help="save the completed healthy enclosure and measured proof")
ap.add_argument("--minimum-caption", type=float, default=MIN_CAPTION, help="minimum real-time caption duration before playback acceleration")
a = ap.parse_args()
if not math.isfinite(a.speed) or a.speed <= 0 or not math.isfinite(a.minimum_caption) or a.minimum_caption < 0:
ap.error("pause scale must be positive and caption duration nonnegative")
mcp = Mcp([a.cli, "mcp", "--connect", f"127.0.0.1:{a.port}"])
mcp.send("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "enclosure-demo-v2", "version": "2"}})
mcp.send("notifications/initialized", notify=True)
d = Demo(a.port, mcp, open(a.log, "w") if a.log else None, a.speed)
d = Demo(a.port, mcp, open(a.log, "w") if a.log else None, a.speed, a.minimum_caption)
d.ui("ui.prefs", {"language": "en", "theme": "dark", "ui_scale": 1, "nav": "fusion", "button": "apply"})
d.ui("ui.resize", {"width": 1920, "height": 1080})
d.ui("ui.home", {"open": False})
d.ui("ui.documents", {"action": "new"})
d.ui("ui.home", {"open": False})
d.ui("ui.set", {"showOrigin": True})
d.settle_camera()
d.stop = a.until - 1
t0 = time.time()
try:
run_v3(d) if a.story == "v3" else run_v2(d)
except StopIteration:
pass
if a.save and a.until == 10**6:
# Keep this health/save/reopen proof in the raw take, outside public playback.
d.mark(private_start=True)
d.settle_camera()
design = d.ui("document.inspect", {"measure": True})
if len(design.get("bodies", [])) != 1 or any(f.get("error") or f.get("warning") for f in design.get("timeline", [])):
raise RuntimeError(f"the completed enclosure is unhealthy: {design}")
expected_width = 100
bbox = design["bodies"][0]["bbox"]
if any(abs(v - want) > 1e-5 for side, expected in [("min", [-expected_width / 2, -30, 0]), ("max", [expected_width / 2, 30, 30])] for v, want in zip(bbox[side], expected)):
raise RuntimeError(f"the enclosure did not rebuild to 100 x 60 x 30 mm: {design}")
values = {p["name"]: p.get("value") for p in design.get("params", []) if not p.get("error")}
for name, expected in [("width", 100), ("wall", 3 if a.story == "v3" else 2)]:
if values.get(name) != expected:
raise RuntimeError(f"the final {name} parameter is not {expected}: {design}")
d.execute("file.save_as", {"path": a.save})
saved = json.load(open(a.save))
fillets = [f for f in saved["features"] if f.get("type") == "fillet"]
if len(fillets) != 1 or len(fillets[0].get("edges", [])) != 4:
raise RuntimeError("the enclosure did not retain all four selected corner fillets")
with open(a.save + ".measure.json", "w") as proof:
json.dump(design, proof, indent=2)
# Reopen the saved design for the ending: the live pattern preview can leave
# stale display edges behind. This is the actual app, not a rendered substitute.
d.ui("ui.documents", {"action": "close", "force": True})
d.ui("ui.file", {"path": a.save})
for _ in range(1200):
if not d.ui("ui.inspect").get("file_busy", False):
break
time.sleep(0.1)
else:
raise RuntimeError("the saved enclosure did not reopen")
d.ui("ui.home", {"open": False})
d.settle_camera()
d.ui("ui.set", {"showOrigin": False, "showSketches": False})
d.mark(private_end=True)
d.ui("ui.view", {"view": "home"})
d.settle_camera()
d.orbit(0, 80)
d.ui("ui.view", {"view": "fit"})
d.settle_camera()
vp = d.ui("ui.inspect")["viewport"]
d.ui("ui.scroll", {"x": vp[0] + vp[2] / 2, "y": vp[1] + vp[3] / 2, "delta": -120})
d.settle_camera()
d.glide((1500, 730), 0.4)
d.mark(caption="", via="")
mcp.close()
print(f"done in {time.time() - t0:.1f}s", file=sys.stderr)
+62 -9
View File
@@ -1,29 +1,82 @@
#!/bin/bash
# record_ui.sh DISPLAY PORT OUTDIR [STORY]: record enclosure_ui_mcp.py --story STORY (v2 or v3,
# record_ui.sh DISPLAY PORT OUTDIR [STORY] [PLAYBACK_RATE]: record enclosure_ui_mcp.py --story STORY (v2 or v3,
# default v3) driving the app on X display DISPLAY (1920x1080, started with --control PORT) to
# OUTDIR/enclosure-demo-STORY.mp4 (H.264) and .gif, with captions and a drawn pointer (the app
# gets synthetic pointer events, so the screen's own pointer never moves). Needs ffmpeg with
# x11grab and libass.
# PLAYBACK_RATE defaults to 1; label accelerated previews with their rate when publishing.
# SOLVECRAFT_DEMO_NAME overrides the filename stem; PAUSE scales UI pauses, CAPTION is the
# minimum real-time caption duration, and HOLD keeps the live completed model on screen.
# For a readable 4x preview use SOLVECRAFT_DEMO_CAPTION=4 and SOLVECRAFT_DEMO_HOLD=8.
set -e
DISP=${1:-:0}; PORT=${2:-7878}; OUT=$(cd "${3:-.}" && pwd); STORY=${4:-v3}
DISP=${1:-:0}; PORT=${2:-7878}; OUT=$(cd "${3:-.}" && pwd); STORY=${4:-v3}; RATE=${5:-1}
HERE=$(cd "$(dirname "$0")" && pwd)
CLI=${SOLVECRAFT_CLI:-solvecraft-cli}
NAME=${SOLVECRAFT_DEMO_NAME:-enclosure-demo-$STORY}
PAUSE=${SOLVECRAFT_DEMO_PAUSE:-0.55}
CAPTION=${SOLVECRAFT_DEMO_CAPTION:-1.4}
HOLD=${SOLVECRAFT_DEMO_HOLD:-1}
python3 -c 'import math,sys; r=float(sys.argv[1]); assert math.isfinite(r) and 0<r<=16' "$RATE"
# Frame the actual app at capture resolution before the first recorded blank viewport.
python3 - "$HERE" "$PORT" <<'PY'
import sys,time
sys.path.insert(0,sys.argv[1])
from enclosure_mcp import control
r=control(int(sys.argv[2]),"ui.resize",{"width":1920,"height":1080})
if not r.get("ok"): raise RuntimeError(r)
for _ in range(100):
vp=control(int(sys.argv[2]),"ui.inspect").get("result",{}).get("viewport",[])
if len(vp)==4 and vp[0]+vp[2]>=1910 and vp[1]+vp[3]>=1000: break
time.sleep(.1)
else: raise RuntimeError("the capture window did not resize")
PY
# x11grab stamps frames with the wall clock: its "start:" is when the first frame was taken.
ffmpeg -loglevel info -nostats -y -f x11grab -draw_mouse 0 -video_size 1920x1080 -framerate 30 -i "$DISP" \
-c:v libx264 -preset veryfast -crf 16 -pix_fmt yuv420p "$OUT/raw.mp4" 2> "$OUT/ffmpeg.log" &
FF=$!
trap 'kill -INT $FF 2>/dev/null || true' EXIT
sleep 1
python3 "$HERE/enclosure_ui_mcp.py" --port "$PORT" --cli "$CLI" --story "$STORY" --log "$OUT/steps.log"
# Finalize the recording even when the modelling script refuses an unhealthy take.
# Waiting lets ffmpeg write the MP4 index so the failed take remains inspectable.
trap 'kill -INT $FF 2>/dev/null || true; wait $FF 2>/dev/null || true' EXIT
sleep 1
python3 "$HERE/enclosure_ui_mcp.py" --port "$PORT" --cli "$CLI" --story "$STORY" --speed "$PAUSE" --minimum-caption "$CAPTION" --log "$OUT/steps.log" --save "$OUT/enclosure-$STORY.solvecraft"
sleep "$HOLD"
kill -INT $FF; wait $FF || true
START=$(grep -o "start: [0-9.]*" "$OUT/ffmpeg.log" | head -1 | cut -d" " -f2)
python3 "$HERE/overlay.py" "$OUT/steps.log" "$START" "$OUT"
read CX CY < "$OUT/cursor.start"
ffmpeg -loglevel error -y -i "$OUT/raw.mp4" -loop 1 -i "$OUT/cursor.png" -loop 1 -i "$OUT/ripple.png" -filter_complex \
"[0:v]ass=$OUT/captions.ass[v0];[v0][2:v]overlay@rip=x=-100:y=-100:enable='$(cat "$OUT/ripple.enable")':shortest=1[v1];[v1][1:v]overlay@cur=x=$CX:y=$CY:shortest=1,sendcmd=f=$OUT/cursor.cmd[v]" \
-map "[v]" -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p -movflags +faststart "$OUT/enclosure-demo-$STORY.mp4"
ffmpeg -loglevel error -y -i "$OUT/enclosure-demo-$STORY.mp4" \
-map "[v]" -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p -movflags +faststart "$OUT/real-time.mp4"
# Composite first, then omit the marked private proof interval. The burned-in cursor,
# ripples and captions remain attached to their actual frames when playback time closes.
python3 - "$OUT/steps.log" "$START" "$RATE" "$OUT/public.filter" <<'PY'
import json,math,pathlib,sys
log,start,rate,destination=sys.argv[1:]
start,rate=float(start),float(rate)
p=pathlib.Path(log)
if p.stat().st_size>128*1024*1024: raise RuntimeError("pointer log exceeds capture budget")
markers=[]
with p.open() as stream:
for i,line in enumerate(stream):
if i>=500000: raise RuntimeError("pointer log exceeds event budget")
event=json.loads(line)
for kind in ("private_start","private_end"):
if event.get(kind): markers.append((kind,float(event["t"])-start))
if markers:
if len(markers)!=2 or [k for k,_ in markers]!=["private_start","private_end"]:
raise RuntimeError("expected one ordered private capture interval")
a,b=(t for _,t in markers)
if not all(math.isfinite(t) for t in (a,b)) or not 0<=a<b<=86400:
raise RuntimeError("invalid private capture interval")
select=f"select='not(between(t,{a:.6f},{b:.6f}))',"
else:
select="" # Partial rehearsals have no health/save/reopen interval.
pathlib.Path(destination).write_text(select+f"setpts=N/(FRAME_RATE*TB*{rate})")
PY
# Accelerate the public composite: cursor, ripples and captions stay aligned with the UI.
ffmpeg -loglevel error -y -i "$OUT/real-time.mp4" -vf "$(cat "$OUT/public.filter")" -an \
-c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p -movflags +faststart "$OUT/$NAME.mp4"
ffmpeg -loglevel error -y -i "$OUT/$NAME.mp4" \
-vf "fps=8,scale=720:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=80:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4" \
"$OUT/enclosure-demo-$STORY.gif"
ls -la "$OUT"/enclosure-demo-$STORY.*
"$OUT/$NAME.gif"
ls -la "$OUT/$NAME.mp4" "$OUT/$NAME.gif"