From 3e2b109cc76eaf2646dc024ebabacb83e98fb83a Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:02:24 -0400 Subject: [PATCH] engine: feature commands take touching; a join's result notice Every feature command reads touching (separate | one_body) as it reads participants; the box and extrude contracts declare it. A feature result carries notice {message, contact, action} when its join kept solids apart ("These bodies only touch along an edge, so they were kept as separate bodies."), the action a timeline.edit that sets touching to one_body; programmatic calls open nothing. Tests adjusted: join_meeting_nothing_keeps_the_first_body now expects the separate body and its notice by default and the old single body of two lumps with touching one_body; the schema key lists and the FeatureResult literal gain the new fields. Engine tests whose subject is a body joined from pieces that share no volume make it with touching: one_body (posts joined while apart, a cube joined clear of another, a cylinder joined clear of a ring, a tab joined in a component); a legacy design reopens with touching: one_body on its emboss; the 0.1 mm gap of a_join_adds_only_its_sketch leaves the block a body of its own; and the #3378 spoke starts 1 mm inside the hub, so the join merges its targets instead of touching the hub along a line. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt Claude-Session: https://claude.ai/code/session_01BnQawySUFx57iaJZq7z1Fm (cherry picked from commit 7a2915fe32e6549cbf3170d69e0f5b6518e19da3) --- crates/engine/src/boolean_regression_tests.rs | 17 ++++---- .../src/cmd/config_certificate_tests.rs | 5 ++- crates/engine/src/cmd/features.rs | 41 +++++++++++++------ crates/engine/src/cmd/interference_tests.rs | 6 ++- crates/engine/src/cmd/patterns_tests.rs | 6 ++- crates/engine/src/exact_tool_tests.rs | 12 ++++-- crates/engine/src/schema/features.rs | 35 +++++++++++++++- crates/engine/src/schema/features_tests.rs | 4 +- crates/engine/src/schema/types.rs | 30 +++++++++++++- crates/engine/src/tests.rs | 14 ++++++- crates/engine/src/tests/robustness.rs | 5 ++- 11 files changed, 139 insertions(+), 36 deletions(-) diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs index 961e074cb9..3f4e1fe490 100644 --- a/crates/engine/src/boolean_regression_tests.rs +++ b/crates/engine/src/boolean_regression_tests.rs @@ -284,7 +284,8 @@ fn buried_join_end_ignores_bodies_in_other_components() { run(&mut s, "sketch.create", json!({"plane": "XY"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [2, 2], "p1": [4, 4]})); run(&mut s, "sketch.finish", json!({})); - run(&mut s, "solid.extrude", json!({"distance": 16, "operation": "join", "body_name": "Tab"})); + // Child's box is apart from the tab: `touching: one_body` keeps them one body to measure. + run(&mut s, "solid.extrude", json!({"distance": 16, "operation": "join", "body_name": "Tab", "touching": "one_body"})); let got = volume(&s, if own_body { "Child" } else { "Tab" }); let expected = 64.0 + if own_body { 800.0 } else { 0.0 }; assert!((got - expected).abs() < 1e-6, "{got}"); @@ -1010,11 +1011,11 @@ fn multi_target_join_keeps_nearby_bodies_and_original_absorption_order() { } } -/// Two posts joined into one body while apart, trimmed by a cut that splits neither, then joined -/// to a case they stand on. The cut split the joined posts into "Posts" and "Posts (1)" without -/// saying so (`created_bodies` was empty), and the join then took only "Posts", leaving the other -/// post behind as a body of its own. A cut that does cut a body in two still makes two bodies, -/// and its reply names the new one. +/// Two posts joined into one body while apart (`touching: one_body`), trimmed by a cut that +/// splits neither, then joined to a case they stand on. The cut split the joined posts into +/// "Posts" and "Posts (1)" without saying so (`created_bodies` was empty), and the join then took +/// only "Posts", leaving the other post behind as a body of its own. A cut that does cut a body in +/// two still makes two bodies, and its reply names the new one. #[test] fn cut_keeps_posts_joined_while_apart_in_one_body() { let mut s = Session::default(); @@ -1022,7 +1023,7 @@ fn cut_keeps_posts_joined_while_apart_in_one_body() { s.execute("solid.box", &json!({"length": 60, "width": 40, "height": 10, "center": [0, 0, 5], "operation": "new", "body_name": "Case"})).unwrap(); s.execute("solid.box", &post("Posts", -20.0)).unwrap(); s.execute("solid.box", &post("Post2", 20.0)).unwrap(); - s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Post2"], "operation": "join"})).unwrap(); + s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Post2"], "operation": "join", "touching": "one_body"})).unwrap(); s.execute("solid.box", &json!({"length": 80, "width": 20, "height": 10, "center": [0, 0, 40], "operation": "new", "body_name": "Trim"})).unwrap(); let cut = s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Trim"], "operation": "cut"})).unwrap(); // Each post loses its top 5 mm: 10 x 10 x 25 left of each. @@ -1063,7 +1064,7 @@ fn combine_of_a_piece_a_cut_used_to_split_off_takes_the_body_it_is_part_of() { .unwrap(); s.execute("solid.box", &post("Posts", -20.0)).unwrap(); s.execute("solid.box", &post("Post2", 20.0)).unwrap(); - s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Post2"], "operation": "join"})).unwrap(); + s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Post2"], "operation": "join", "touching": "one_body"})).unwrap(); s.execute("solid.box", &json!({"length": 80, "width": 20, "height": 10, "center": [0, 0, 40], "operation": "new", "body_name": "Trim"})) .unwrap(); s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Trim"], "operation": "cut"})).unwrap(); diff --git a/crates/engine/src/cmd/config_certificate_tests.rs b/crates/engine/src/cmd/config_certificate_tests.rs index ffd0fd20e9..64fa932d5f 100644 --- a/crates/engine/src/cmd/config_certificate_tests.rs +++ b/crates/engine/src/cmd/config_certificate_tests.rs @@ -112,8 +112,9 @@ fn legacy_v9_imported_emboss_anchor_and_ordinary_face_paint_replay_without_renam legacy["format"] = json!("solidcraft/9"); let reopened = Session::new(solidcraft_doc::Document::from_json(&legacy.to_string()).unwrap()); // Saved before format 14, it keeps naming the pieces it makes in the kernel's shell order, - // and numbering the pieces of its faces as that build did (#3902). - let anchored = solidcraft_doc::Feature { shell_piece_order: true, kernel_piece_order: true, ..anchored }; + // and numbering the pieces of its faces as that build did (#3902); saved before joins kept + // solids that only touch apart, it keeps them one body. + let anchored = solidcraft_doc::Feature { shell_piece_order: true, kernel_piece_order: true, touching: solidcraft_doc::Touching::OneBody, ..anchored }; assert_eq!(reopened.doc.feature(anchored.id).unwrap(), &anchored); // Names saved before format 14 keep naming the face they named, marked so they are found as // the build that saved them found them. diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 15616d4675..b50fe881ce 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use solidcraft_doc::{ AxisRef, Direction, Expr, Extent, FeatureKind, Operation, PointExpr, ProfileSel, RailFit, SweepOrientation, SweepRail, ThinSide, ThinWall, - expr::Kind, point_expr, + Touching, expr::Kind, point_expr, }; use solidcraft_doc::{BodyName, FeatureName, SketchId}; use solidcraft_doc::{Dir, FacePick, Position}; @@ -11,7 +11,7 @@ use solidcraft_geom::Vec3; use super::{CommandSpec, not_in_sketch}; use crate::params::{bad, bool_, expr, expr_problem, req_expr, str_, string_list, vec2, vec3}; -use crate::schema::features::{BoxParams, ExtrudeParams, FeatureResult}; +use crate::schema::features::{BoxParams, ExtrudeParams, FeatureNotice, FeatureResult, NoticeAction}; use crate::schema::{TypedContract, output_schema_for}; use crate::{EngineError, Result, Session}; @@ -25,33 +25,33 @@ pub static COMMANDS: &[CommandSpec] = &[ .key("E") .contract(&EXTRUDE_CONTRACT) .output(output_schema_for::) - .params("distance: expr (or through_all: true); taper?: angle expr (positive: outward, so the far end is larger along the extrusion direction; negative: inward, so the far end is smaller); corner?: preserve_curvature|tangent|round (how a taper closes a sharp corner its offset opens: each wall continues as itself, a line's plane or an arc's cone, until it meets its neighbour; each continues straight along its end tangent; or an arc about the corner. Without it, each profile preserves curvature where every curve is a line or an arc and takes tangent where another curve has a corner, and the feature's warning says so; a corner rule given is kept, and refused where the kernel cannot build it); sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile; a pick on a drawn profile that reference geometry, such as the projected outline of the face the sketch is on, cuts into regions takes the whole drawn profile); exact_regions?: bool (each index or point pick is exactly the region it names, as the dialogs send); default: every drawn profile, except that a loop inside another is its hole and a loop inside that hole is material again; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); faces?: [[x,y,z],…] (coplanar faces of one body); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?: expr (Two Sides: a second distance the other way from the first side, which may be through_all or to; with direction: negative the first side goes against the normal and distance2 along it); start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"), + .params("distance: expr (or through_all: true); taper?: angle expr (positive: outward, so the far end is larger along the extrusion direction; negative: inward, so the far end is smaller); corner?: preserve_curvature|tangent|round (how a taper closes a sharp corner its offset opens: each wall continues as itself, a line's plane or an arc's cone, until it meets its neighbour; each continues straight along its end tangent; or an arc about the corner. Without it, each profile preserves curvature where every curve is a line or an arc and takes tangent where another curve has a corner, and the feature's warning says so; a corner rule given is kept, and refused where the kernel cannot build it); sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile; a pick on a drawn profile that reference geometry, such as the projected outline of the face the sketch is on, cuts into regions takes the whole drawn profile); exact_regions?: bool (each index or point pick is exactly the region it names, as the dialogs send); default: every drawn profile, except that a loop inside another is its hole and a loop inside that hole is material again; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); faces?: [[x,y,z],…] (coplanar faces of one body); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?: expr (Two Sides: a second distance the other way from the first side, which may be through_all or to; with direction: negative the first side goes against the normal and distance2 along it); start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); touching?: separate|one_body (a join's solids that only touch, along an edge or at a point, or don't touch: separate bodies, the default, or one body); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"), CommandSpec::new("solid.revolve", "Revolve", revolve) .at("SOLID", "CREATE") .icon("revolve") - .params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world) | construction axis name (followed when edited) | {origin, dir} | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, targets?, name?, body_name?"), + .params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world) | construction axis name (followed when edited) | {origin, dir} | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, touching?, targets?, name?, body_name?"), CommandSpec::new("solid.sweep", "Sweep", sweep) .at("SOLID", "CREATE") .icon("sweep") - .params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); rails?: [{sketch?: sketch (default path_sketch), curves: [curve ids in order]}] (guide rails, one or two, each meeting every plane square to the path once along it, from the end nearest the path's start: one rail turns the profile so its point where the rail starts stays on the rail and scales it with the rail's distance from the path; two rails carry it between them, its points where they start staying on both; rails take the default orientation, no twist and no scale, and a path without sharp corners); rail_fit?: scale (default: the profile grows and shrinks evenly) | stretch (only across to the rail, between the rails); operation?, targets?, name?, body_name?"), - CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2…50, in order; matching hole counts, no holes at an apex); rails?: [{sketch, curves: [ids in order]}] (one or two guide rails from the first section to the last, through every section's outline: the loft follows them between the sections; not with sections that have holes); rail_fit?: scale|stretch (default scale: with one rail the sections grow evenly with the rail's distance from their middle, with two with the distance between the rails; stretch: only that way); operation?, targets? (Join excludes hidden bodies from automatic targets; explicit targets or participants may include them), name?, body_name?"), + .params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); rails?: [{sketch?: sketch (default path_sketch), curves: [curve ids in order]}] (guide rails, one or two, each meeting every plane square to the path once along it, from the end nearest the path's start: one rail turns the profile so its point where the rail starts stays on the rail and scales it with the rail's distance from the path; two rails carry it between them, its points where they start staying on both; rails take the default orientation, no twist and no scale, and a path without sharp corners); rail_fit?: scale (default: the profile grows and shrinks evenly) | stretch (only across to the rail, between the rails); operation?, touching?, targets?, name?, body_name?"), + CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2…50, in order; matching hole counts, no holes at an apex); rails?: [{sketch, curves: [ids in order]}] (one or two guide rails from the first section to the last, through every section's outline: the loft follows them between the sections; not with sections that have holes); rail_fit?: scale|stretch (default scale: with one rail the sections grow evenly with the rail's distance from their middle, with two with the distance between the rails; stretch: only that way); operation?, touching?, targets? (Join excludes hidden bodies from automatic targets; explicit targets or participants may include them), name?, body_name?"), CommandSpec::new("solid.box", "Box", crate::checked!("solid.box", prim_box)) .at("SOLID", "CREATE") .icon("box") .contract(&BOX_CONTRACT) .output(output_schema_for::) - .params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"), - CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, targets?: [body], name?, body_name?"), - CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"), - CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"), - CommandSpec::new("solid.cone", "Cone", prim_cone).at("SOLID", "CREATE").icon("cone").params("radius | diameter, height: expr (the base circle, and the top's distance along the axis; a negative height goes the other way); top_radius? | top_diameter?: expr (the top circle; default 0: a full cone ending in an apex); base?: [x,y,z] (centre of the base circle: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, targets?: [body], name?, body_name?"), + .params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, touching?, targets?: [body], name?, body_name?"), + CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, touching?, targets?: [body], name?, body_name?"), + CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, touching?, targets?: [body], name?, body_name?"), + CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, touching?, targets?: [body], name?, body_name?"), + CommandSpec::new("solid.cone", "Cone", prim_cone).at("SOLID", "CREATE").icon("cone").params("radius | diameter, height: expr (the base circle, and the top's distance along the axis; a negative height goes the other way); top_radius? | top_diameter?: expr (the top circle; default 0: a full cone ending in an apex); base?: [x,y,z] (centre of the base circle: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, touching?, targets?: [body], name?, body_name?"), CommandSpec::new("solid.fillet", "Fillet", fillet) .at("SOLID", "MODIFY") .icon("fillet") .key("F") .params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; g2: a patch repeating each blend's surface up to the second order, constant-radius exact fillets only, refused where it cannot be built); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"), CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"), - CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"), + CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; touching?: separate|one_body (a join's solids that only touch, along an edge or at a point, or don't touch: separate bodies, the default, or one body); keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"), CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect) .at("SOLID", "CREATE") .icon("pattern_rect") @@ -611,6 +611,11 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu /// `add_feature`; `world` is false when `kind` is already in the active component's frame. fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) -> Result { let name = str_(p, "name"); + // What a join makes of solids that only touch or don't touch: separate bodies unless asked. + let touching = match p.get("touching") { + None | Some(Value::Null) => Touching::Separate, + Some(v) => v.as_str().and_then(Touching::parse).ok_or_else(|| EngineError::Other("`touching` must be separate or one_body".into()))?, + }; // The bodies before the feature, to say which ones it changed or removed. let before = s.model.state(); // Commands speak world coordinates; the feature is authored in the active component. @@ -721,6 +726,7 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) -> let id = s.doc_mut().add_feature(kind, name)?; if let Some(f) = s.doc_mut().feature_mut(id) { f.participants = participants; + f.touching = touching; f.edge_refs = refs; f.edge_names = names; f.face_names = face_names; @@ -774,6 +780,16 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) -> // A warning (a reference found again elsewhere, a reduced radius) is part of // the reply: a script or an agent sees it without asking the timeline (#616). let warning = r.as_ref().and_then(|r| r.warning.clone()); + // A join that kept solids apart says so, with the edit that makes them one body instead. + let notice = r.as_ref().and_then(|r| r.state.kept_separate(id)).map(|kept| FeatureNotice { + message: kept.notice().to_string(), + contact: kept.id().to_string(), + action: NoticeAction { + label: "Make one body".into(), + command: "timeline.edit".into(), + params: json!({"feature": id, "set": {"touching": "one_body"}}), + }, + }); let owned = |names: Vec<&str>| names.into_iter().map(str::to_string).collect(); let reply = FeatureResult { feature: id, @@ -783,6 +799,7 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) -> changed_bodies: owned(changed), removed_bodies: owned(removed), warning, + notice, ms: r.map(|r| r.ms), param_warnings: Vec::new(), }; diff --git a/crates/engine/src/cmd/interference_tests.rs b/crates/engine/src/cmd/interference_tests.rs index 09feb1c933..3a29a0fc5a 100644 --- a/crates/engine/src/cmd/interference_tests.rs +++ b/crates/engine/src/cmd/interference_tests.rs @@ -69,7 +69,11 @@ fn a_piece_of_a_body_inside_another_is_still_found() { let mut s = Session::default(); run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "corner": [0, 0, 0], "body_name": "Block"})); run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [5, 5, 5], "operation": "new", "body_name": "Pieces"})); - run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [30, 5, 5], "operation": "join", "targets": ["Pieces"]})); + run( + &mut s, + "solid.box", + json!({"length": 2, "width": 2, "height": 2, "corner": [30, 5, 5], "operation": "join", "targets": ["Pieces"], "touching": "one_body"}), + ); let st = s.world_state().unwrap(); let pieces = st.bodies.iter().find(|b| b.name == "Pieces").map(|b| b.body.shell_count()); assert_eq!(pieces, Some(2), "the two cubes are one body in two pieces"); diff --git a/crates/engine/src/cmd/patterns_tests.rs b/crates/engine/src/cmd/patterns_tests.rs index 1740a09f71..08e096e515 100644 --- a/crates/engine/src/cmd/patterns_tests.rs +++ b/crates/engine/src/cmd/patterns_tests.rs @@ -705,7 +705,9 @@ fn copies_of_a_primitive_cut_only_its_targets() { } /// #3378: a pattern or mirror of a join whose targets the join merged into one body copies onto -/// that body, as before #2755, instead of looking for the target the join used up. +/// that body, as before #2755, instead of looking for the target the join used up. The spoke +/// starts 1 mm inside the hub, so it shares volume with both (one tangent to the hub would only +/// touch it along a line, and the join would keep the hub a body of its own). #[test] fn copies_of_a_join_that_merged_its_targets_join_the_merged_body() { for (copy, n) in [ @@ -717,7 +719,7 @@ fn copies_of_a_join_that_merged_its_targets_join_the_merged_body() { run(&mut s, "solid.cylinder", json!({"radius": 63, "height": 3, "base": [0, 0, 0], "axis": "Y", "operation": "cut", "targets": ["rim"]})); run(&mut s, "solid.cylinder", json!({"radius": 12, "height": 3, "base": [0, 0, 0], "axis": "Y", "body_name": "hub"})); let v0 = volume(&mut s); - let spoke = json!({"length": 52, "width": 3, "height": 2.5, "corner": [12, 0, -1.25], "operation": "join", "targets": ["rim", "hub"], "name": "spoke"}); + let spoke = json!({"length": 53, "width": 3, "height": 2.5, "corner": [11, 0, -1.25], "operation": "join", "targets": ["rim", "hub"], "name": "spoke"}); run(&mut s, "solid.box", spoke); let v1 = volume(&mut s); let (id, p) = (copy[0].as_str().unwrap_or_default(), copy[1].clone()); diff --git a/crates/engine/src/exact_tool_tests.rs b/crates/engine/src/exact_tool_tests.rs index dc29b3c4c1..ee34231ccc 100644 --- a/crates/engine/src/exact_tool_tests.rs +++ b/crates/engine/src/exact_tool_tests.rs @@ -103,8 +103,9 @@ fn a_cut_leaves_what_stands_beside_it() { } /// A join adds only its sketch: a block against a wall narrower than the block adds nothing -/// beside the wall, a block 0.1 mm from a wall leaves the gap, and a pad flush with a block's -/// side does not reach a neighbour 0.8 mm from that side. +/// beside the wall, a block 0.1 mm from a wall leaves the gap (and, meeting nothing, stays a body +/// of its own), and a pad flush with a block's side does not reach a neighbour 0.8 mm from that +/// side. #[test] fn a_join_adds_only_its_sketch() { let wall = || { @@ -119,8 +120,11 @@ fn a_join_adds_only_its_sketch() { let mut s = wall(); rectangle(&mut s, at_height(0.0), [2.1, 10.0], [20.0, 50.0], 30.0, "join", Value::Null); - assert!(same(volume(&s, "Wall"), 2.0 * 40.0 * 30.0 + 17.9 * 40.0 * 30.0), "{}", volume(&s, "Wall")); - assert!(!has(&s, "Wall", [2.05, 30.0, 15.0]), "the 0.1 mm gap is filled"); + let names = bodies(&s); + assert_eq!(names.len(), 2, "{names:?}"); + let total: f64 = names.iter().map(|b| volume(&s, b)).sum(); + assert!(same(total, 2.0 * 40.0 * 30.0 + 17.9 * 40.0 * 30.0), "{total}"); + assert!(names.iter().all(|b| !has(&s, b, [2.05, 30.0, 15.0])), "the 0.1 mm gap is filled"); let mut s = Session::default(); run(&mut s, "solid.box", json!({"length": 300, "width": 200, "height": 100, "body_name": "A"})); diff --git a/crates/engine/src/schema/features.rs b/crates/engine/src/schema/features.rs index 81286aa334..25fe04a38e 100644 --- a/crates/engine/src/schema/features.rs +++ b/crates/engine/src/schema/features.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use super::types::{ AngleExpr, ConventionParam, DirectionParam, ExtentKeyword, ExtrudeOperation, FacesParam, FromPoint, IdOrName, LengthExpr, OperationParam, Point3, - PointExpr3, ProfilesParam, StringList, ThinSideParam, ToPoint, present, + PointExpr3, ProfilesParam, StringList, ThinSideParam, ToPoint, TouchingParam, present, }; /// Add a box primitive. @@ -38,6 +38,9 @@ pub struct BoxParams { /// The bodies a join, cut or intersect works on. Default: the bodies the box meets. #[serde(default)] pub targets: Option, + /// What a join makes of solids that only touch or don't touch. Default: separate. + #[serde(default)] + pub touching: Option, /// The bodies taking part (the same as `targets` for a primitive). #[serde(default)] pub participants: Option, @@ -135,6 +138,9 @@ pub struct ExtrudeParams { /// that body. #[serde(default)] pub targets: Option, + /// What a join makes of solids that only touch or don't touch. Default: separate. + #[serde(default)] + pub touching: Option, /// The bodies taking part; overrides the default scope. #[serde(default)] pub participants: Option, @@ -166,6 +172,28 @@ pub struct ExtrudeParams { pub thickness2: Option, } +/// Something a feature's result tells the user without stopping them, with an action to take. +#[derive(Debug, Serialize, JsonSchema)] +pub struct FeatureNotice { + /// The text to show. + pub message: String, + /// How the solids kept apart met: `along_edge`, `at_point` or `apart`. + pub contact: String, + /// The command that changes the feature as the notice offers. + pub action: NoticeAction, +} + +/// A command a notice offers to run. +#[derive(Debug, Serialize, JsonSchema)] +pub struct NoticeAction { + /// The action's label. + pub label: String, + /// The command's id. + pub command: String, + /// Its parameters. + pub params: Value, +} + /// What a feature command returns. #[derive(Debug, Serialize, JsonSchema)] pub struct FeatureResult { @@ -184,6 +212,11 @@ pub struct FeatureResult { /// A warning from the evaluation (a Boolean that needed a retry, a reference found again /// elsewhere), or `null`. pub warning: Option, + /// A join that kept solids as separate bodies because they only touch along an edge or at a + /// point, or don't touch (its `touching: separate`): what to tell the user, and the edit that + /// makes them one body instead. Left out otherwise. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notice: Option, /// How long the feature took to evaluate, in milliseconds. pub ms: Option, /// In the warn parameter-check mode: what the call's parameters got wrong. diff --git a/crates/engine/src/schema/features_tests.rs b/crates/engine/src/schema/features_tests.rs index 4ed208cded..394e7ca445 100644 --- a/crates/engine/src/schema/features_tests.rs +++ b/crates/engine/src/schema/features_tests.rs @@ -9,7 +9,7 @@ use crate::schema::{ParamCheck, input_schema_for, output_schema_for}; fn schemas_and_the_key_check_agree() { assert_keys( input_schema_for::(), - &["body_name", "body_names", "center", "corner", "height", "length", "name", "operation", "participants", "targets", "width"], + &["body_name", "body_names", "center", "corner", "height", "length", "name", "operation", "participants", "targets", "touching", "width"], ); assert_keys( input_schema_for::(), @@ -43,6 +43,7 @@ fn schemas_and_the_key_check_agree() { "through_all", "to", "to_offset", + "touching", ], ); } @@ -90,6 +91,7 @@ fn feature_result_serializes_like_the_old_reply() { warning: warning.clone(), ms, param_warnings: vec![], + notice: None, }; assert_eq!(serde_json::to_value(&new).ok(), Some(old)); } diff --git a/crates/engine/src/schema/types.rs b/crates/engine/src/schema/types.rs index 1aa122ac00..5b1a47a9c8 100644 --- a/crates/engine/src/schema/types.rs +++ b/crates/engine/src/schema/types.rs @@ -8,7 +8,7 @@ use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; use serde::de::Error as _; use serde::{Deserialize, Deserializer}; use serde_json::{Value, json}; -use solidcraft_doc::{Expr, Operation, ThinSide}; +use solidcraft_doc::{Expr, Operation, ThinSide, Touching}; use solidcraft_geom::{Vec2, Vec3}; use super::check::whole; @@ -379,6 +379,34 @@ impl JsonSchema for OperationParam { } } +/// What a join makes of solids that only touch or don't touch, as `Touching::parse` reads it. +#[derive(Clone, Debug, PartialEq)] +pub struct TouchingParam(pub Touching); + +/// The feature commands' message for a `touching` they don't know. +const TOUCHING_MESSAGE: &str = "`touching` must be separate or one_body"; + +impl<'de> Deserialize<'de> for TouchingParam { + fn deserialize>(d: D) -> Result { + match Value::deserialize(d)? { + Value::String(s) => Touching::parse(&s).map(TouchingParam).ok_or_else(|| D::Error::custom(whole(TOUCHING_MESSAGE))), + _ => Err(D::Error::custom(whole(TOUCHING_MESSAGE))), + } + } +} + +impl JsonSchema for TouchingParam { + fn schema_name() -> Cow<'static, str> { + "Touching".into() + } + fn json_schema(_: &mut SchemaGenerator) -> Schema { + enum_schema( + &["separate", "one_body"], + "What a join makes of solids that only touch, along an edge or at a point, or don't touch: separate bodies (separate, the default) or one body of several pieces (one_body).", + ) + } +} + /// Extrude's operation: the above or `auto` (matched with `eq_ignore_ascii_case`, as the /// handler does). #[derive(Clone, Debug, PartialEq)] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e3eb5a41e3..5ebb8decf9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -203,12 +203,22 @@ fn join_merges_into_the_body_it_meets() { assert!(rel(volume(&mut s), 2064.0) < 1e-6); } -/// A join that meets no body still joins the first body, as a separate lump. +/// A join that meets no body keeps what it made as a body of its own beside the first body +/// (`touching: separate`, the default), and says so with the edit that makes them one; with +/// `touching: one_body` it 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 r = run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [40, 0, 0], "operation": "join"})); + assert_eq!(r["notice"]["contact"], "apart", "{r}"); + assert_eq!(r["notice"]["action"]["params"]["set"], json!({"touching": "one_body"}), "{r}"); + assert_eq!(s.model.state().bodies.len(), 2); + assert!(rel(volume(&mut s), 1064.0) < 1e-6); + run(&mut s, "edit.undo", json!({})); + let r = + run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [40, 0, 0], "operation": "join", "touching": "one_body"})); + assert!(r.get("notice").is_none(), "{r}"); let st = s.model.state(); assert_eq!(st.bodies.len(), 1); assert_eq!(st.bodies[0].body.lumps().unwrap().len(), 2); diff --git a/crates/engine/src/tests/robustness.rs b/crates/engine/src/tests/robustness.rs index df49cb5a21..355edd6b73 100644 --- a/crates/engine/src/tests/robustness.rs +++ b/crates/engine/src/tests/robustness.rs @@ -141,7 +141,8 @@ fn fillet_that_would_open_the_surface_is_refused() { } } -/// Shell of a body in two pieces (a cylinder joined clear of a ring): each piece is shelled. +/// Shell of a body in two pieces (a cylinder joined clear of a ring, kept one body with +/// `touching: one_body`): each piece is shelled. #[test] fn shell_of_a_body_in_two_pieces() { script(json!([ @@ -149,7 +150,7 @@ fn shell_of_a_body_in_two_pieces() { {"command": "sketch.rectangle.two_point", "params": {"p0": [2.5, 0], "p1": [22.5, 17.5]}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.revolve", "params": {"angle": "360 deg", "axis": "Z"}}, - {"command": "solid.cylinder", "params": {"axis": [0.0, 1.0, 0.0], "base": [-22.5, 22.5, 0.0], "height": 30.0, "operation": "join", "radius": 10.0}}, + {"command": "solid.cylinder", "params": {"axis": [0.0, 1.0, 0.0], "base": [-22.5, 22.5, 0.0], "height": 30.0, "operation": "join", "radius": 10.0, "touching": "one_body"}}, {"command": "solid.shell", "params": {"faces": [[-22.5, 22.5, 0.0]], "thickness": 2.0}} ])); }