diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs index fabaecf9..f2f15a28 100644 --- a/crates/engine/src/boolean_regression_tests.rs +++ b/crates/engine/src/boolean_regression_tests.rs @@ -107,3 +107,16 @@ fn blind_hole_into_a_long_d_profile_bar_stops_short_of_the_round_front() { let faces = s.model.state().body("Slat").map(|b| solidcraft_kernel::measure(&b.body).unwrap().merged.faces); assert_eq!(faces, Some(8)); } + +/// A ring pocket cut from below a recess leaves a 0.5 mm floor joining the outer wall to the inner +/// block: one connected part, yet the cut left two bodies and lost volume (#545). +#[test] +#[ignore = "fails until #545 is fixed"] +fn ring_cut_below_a_recess_keeps_the_part_in_one_body() { + let s = replay(include_str!("../../../examples/regressions/ring_cut_below_recess_thin_floor.json")); + let bodies: Vec<(String, f64)> = s.model.state().bodies.iter().map(|b| (b.name.clone(), volume(&s, &b.name))).collect(); + let want = 100.0 * 100.0 * 30.0 - 88.0 * 88.0 * 12.0 - (95.0 * 95.0 - 68.0 * 68.0) * 17.5; + assert_eq!(bodies.len(), 1, "{bodies:?}"); + let got = volume(&s, "Frame"); + assert!((got - want).abs() < 1e-9 * want, "{got} vs {want}"); +} diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index 42f2f79e..21fbf425 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -2501,3 +2501,81 @@ fn a_hole_on_a_folded_panel_keeps_the_name_of_its_sketch_circle() { assert_eq!(folded.len(), 2, "{folded:?}"); assert!(folded.iter().all(|n| n.contains(&side)), "after the fold the hole is still circle {curve}: {folded:?} (before: {flat:?})"); } + +#[test] +#[ignore = "fails until #541 is fixed"] +fn a_box_cut_straight_through_a_flat_sheet_is_a_cutout_like_the_extrude_cut() { + // A 10 x 10 box perpendicular to the sheet and through its whole thickness removes exactly + // what a sketch rectangle extruded through all removes, which is accepted today. + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + run(&mut s, "solid.box", json!({"corner": [20, 20, -1], "length": 10, "width": 10, "height": 5, "operation": "cut"})); + let mut extruded = Session::default(); + base(&mut extruded, 100.0, 60.0); + rectangle_tool(&mut extruded, "Hole", [20.0, 20.0], [30.0, 30.0]); + run(&mut extruded, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "direction": "symmetric", "operation": "cut"})); + for session in [&mut s, &mut extruded] { + assert!((volume(session) - 14750.0).abs() < 1e-6); + assert_eq!(flat(session)["cutouts"].as_array().map(Vec::len), Some(1)); + } + run(&mut s, "sheet.unfold", json!({})); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - 14750.0).abs() < 1e-6); +} + +#[test] +#[ignore = "fails until #542 is fixed"] +fn flat_pattern_reports_a_round_hole_as_a_circle() { + // The DXF export already writes this hole as a CIRCLE; the JSON flat pattern should say + // "circle, centre (50, 30), radius 1.6" too instead of a sampled polyline. The exact key + // names are open: any object in the cutout that has a `center` and a `radius` counts. + fn circles(v: &Value, out: &mut Vec<([f64; 2], f64)>) { + match v { + Value::Object(m) => { + let center = m.get("center").and_then(Value::as_array).and_then(|c| Some([c.first()?.as_f64()?, c.get(1)?.as_f64()?])); + if let (Some(center), Some(radius)) = (center, m.get("radius").and_then(Value::as_f64)) { + out.push((center, radius)); + } + m.values().for_each(|v| circles(v, out)); + } + Value::Array(a) => a.iter().for_each(|v| circles(v, out)), + _ => {} + } + } + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"})); + run(&mut s, "sketch.circle.center", json!({"center": [50, 30], "radius": 1.6})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "direction": "symmetric", "operation": "cut"})); + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); + assert!(dxf.contains("CIRCLE"), "the DXF keeps the circle"); + let f = flat(&mut s); + let cutouts = f["cutouts"].as_array().cloned().unwrap_or_default(); + assert_eq!(cutouts.len(), 1); + let mut found = Vec::new(); + circles(&cutouts[0], &mut found); + let shown: String = cutouts[0].to_string().chars().take(120).collect(); + assert!( + found.iter().any(|(c, r)| (c[0] - 50.0).abs() < 1e-9 && (c[1] - 30.0).abs() < 1e-9 && (r - 1.6).abs() < 1e-9), + "the round hole is not reported as a circle: {shown} ..." + ); +} + +#[test] +#[ignore = "fails until #570 is fixed"] +fn a_flange_asked_for_0_8_mm_is_0_8_mm_or_refused_never_silently_2_5_mm() { + // `thickness` on sheet.flange may become an inline override of the rule, or be refused as + // an unknown parameter; either is fine. Building the default 2.5 mm sheet silently is not. + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]})); + run(&mut s, "sketch.finish", json!({})); + match s.execute("sheet.flange", &json!({"sketch": "Base", "thickness": 0.8})) { + Ok(_) => { + assert!((flat(&mut s)["thickness"].as_f64().unwrap_or(f64::NAN) - 0.8).abs() < 1e-9); + assert!((volume(&mut s) - 100.0 * 60.0 * 0.8).abs() < 1e-6, "built at {} mm3", volume(&mut s)); + } + Err(e) => assert!(e.to_string().contains("thickness"), "{e}"), + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 960a2b0d..0c32675d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2952,3 +2952,17 @@ fn feature_replies_list_only_the_bodies_they_created() { assert_eq!(r["created_bodies"], json!([]), "{r}"); assert_eq!(r["bodies"], json!(["Base"]), "{r}"); } + +/// Drafting a cylinder's side face 5° from the base plane makes a cone frustum (#547): radius 10 +/// at the base, 10 − 20 · tan 5° at the top. +#[test] +#[ignore = "fails until #547 is fixed"] +fn draft_on_a_cylinder_side_makes_a_frustum() { + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 20})); + run(&mut s, "solid.draft", json!({"faces": [[10, 0, 10]], "angle": "5 deg", "neutral": "XY"})); + let (big, h) = (10.0, 20.0); + let small = big - h * 5f64.to_radians().tan(); + let expect = PI * h / 3.0 * (big * big + big * small + small * small); + assert!(rel(volume(&mut s), expect) < 1e-3, "{} vs {expect}", volume(&mut s)); +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 39d28935..6083e06b 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -454,3 +454,59 @@ fn screenshot_renders_a_saved_named_view() { assert!(photo.starts_with("iVBORw0KGgo"), "not a PNG"); assert_ne!(photo, iso, "the named view rendered like the standard iso view"); } + +/// #530: in a design with many bodies every feature result lists all of them, so a batch reply +/// is mostly the same body list once per step. A compact batch reply names only what each step +/// added or changed. +#[test] +#[ignore = "fails until #530 is fixed"] +fn compact_batch_reply_does_not_repeat_the_body_list() { + let mut s = server(); + let cube = + |x: i64, y: i64| json!({"command": "solid.box", "params": {"length": 1, "width": 1, "height": 1, "corner": [x, y, 0], "operation": "new"}}); + let existing: Vec = (0..50).map(|i| cube(2 * i, 0)).collect(); + tool(&mut s, "batch", json!({"commands": existing})); + let more: Vec = (0..3).map(|i| cube(2 * i, 5)).collect(); + let r = raw_tool(&mut s, "batch", json!({"commands": more, "verbose": false})); + assert!(r.get("error").is_none() && r["result"]["isError"] != json!(true), "{r}"); + let reply = r["result"]["content"][0]["text"].as_str().unwrap_or_default(); + assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 53); + // The new bodies are named; the 50 untouched ones are not repeated. + for new in ["Body51", "Body52", "Body53"] { + assert!(reply.contains(&format!("\"{new}\"")), "{new} missing from the compact reply: {reply}"); + } + for old in ["Body1", "Body25", "Body50"] { + assert!(!reply.contains(&format!("\"{old}\"")), "untouched {old} repeated in the compact reply ({} bytes): {reply}", reply.len()); + } +} + +/// #531: a product model keeps an optional variant hidden in its default presentation. Hiding +/// it must survive saving and reopening the design (suppressing it would change the design). +#[test] +#[ignore = "fails until #531 is fixed"] +fn hidden_body_stays_hidden_after_save_and_reopen() { + let mut s = server(); + tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}})); + tool( + &mut s, + "execute", + json!({"command": "solid.box", "params": {"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0], "operation": "new", "body_name": "Variant"}}), + ); + tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"items": ["Variant"], "visible": false}})); + let visible = |s: &mut Server, body: &str| { + let d = tool(s, "inspect_design", json!({"measure": false})); + d["bodies"].as_array().into_iter().flatten().find(|b| b["name"] == body).map(|b| b["visible"].clone()).unwrap_or(Value::Null) + }; + assert_eq!(visible(&mut s, "Variant"), false); + + let dir = std::env::temp_dir().join(format!("solidcraft-mcp-visibility-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let design = dir.join("variants.solidcraft"); + tool(&mut s, "save", json!({"path": design.to_string_lossy()})); + tool(&mut s, "new_design", json!({"name": "Empty"})); + tool(&mut s, "open", json!({"path": design.to_string_lossy()})); + let (base, variant) = (visible(&mut s, "Base"), visible(&mut s, "Variant")); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(base, true); + assert_eq!(variant, false, "the hidden body is visible again after reopening"); +} diff --git a/examples/regressions/ring_cut_below_recess_thin_floor.json b/examples/regressions/ring_cut_below_recess_thin_floor.json new file mode 100644 index 00000000..31b34df2 --- /dev/null +++ b/examples/regressions/ring_cut_below_recess_thin_floor.json @@ -0,0 +1,18 @@ +{ + "description": "A 100 x 100 x 30 block, a recess 88 x 88 x 12 deep from the top (z 18..30), then one cut from the bottom whose profile is the ring between a 95 x 95 and a 68 x 68 rectangle, 17.5 deep. A 0.5 mm floor (z 17.5..18) is left joining the outer wall to the 68 x 68 x 18 inner block, but the cut gave two bodies, the inner block and a rest 3411.9 mm3 short (#545). Cutting the ring before the recess gives the right part. Result: one body, 100*100*30 - 88*88*12 - (95^2 - 68^2)*17.5 = 130054.5 mm3.", + "commands": [ + {"command": "sketch.create", "params": {"plane": "XY", "offset": 0, "name": "Block"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [100.0, 100.0]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"sketch": "Block", "distance": 30, "body_name": "Frame"}}, + {"command": "sketch.create", "params": {"plane": "XY", "offset": 30, "name": "Recess"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [6.0, 6.0], "p1": [94.0, 94.0]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"sketch": "Recess", "distance": -12, "operation": "cut", "targets": ["Frame"]}}, + {"command": "sketch.create", "params": {"plane": "XY", "offset": 0, "name": "Ring"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [2.5, 2.5], "p1": [97.5, 97.5]}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [16.0, 16.0], "p1": [84.0, 84.0]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"sketch": "Ring", "distance": 17.5, "operation": "cut", "targets": ["Frame"], "profiles": [{"point": [9.25, 50.0]}]}} + ] +}