Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef8f6ca6f9 | ||
|
|
1aedbf6631 | ||
|
|
6f872c7789 | ||
|
|
9bcd8ffd56 | ||
|
|
1adb2cd69f | ||
|
|
9d7be3c292 | ||
|
|
ac7d5d14cf | ||
|
|
a7512e5e08 | ||
|
|
4c6afc6ba4 | ||
|
|
f2f8a0df57 | ||
|
|
d9d4dd262d | ||
|
|
2eee967d40 | ||
|
|
6140f89a42 | ||
|
|
66f351e93b | ||
|
|
5b54275626 | ||
|
|
7abfe69603 | ||
|
|
1a62c4513f | ||
|
|
2f78d3ef81 | ||
|
|
78f80143d5 | ||
|
|
b2cf19c9fb | ||
|
|
b5fb86f245 | ||
|
|
a03e4b9413 | ||
|
|
0b5e15e2b9 | ||
|
|
8a38a7354c |
No files matched your search
+2
-1
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
*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
@@ -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
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)] {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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`")),
|
||||
};
|
||||
|
||||
@@ -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")?))
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}`")]
|
||||
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.))");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(®ion), 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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))))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+557
-549
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user