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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
Claude-Session: https://claude.ai/code/session_01BnQawySUFx57iaJZq7z1Fm
(cherry picked from commit 7a2915fe32e6549cbf3170d69e0f5b6518e19da3)
This commit is contained in:
jyviko
2026-10-22 09:02:24 -04:00
parent 94ee28d5ce
commit 3e2b109cc7
11 changed files with 139 additions and 36 deletions
@@ -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();
@@ -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.
+29 -12
View File
@@ -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::<FeatureResult>)
.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::<FeatureResult>)
.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<Value> {
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(),
};
+5 -1
View File
@@ -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");
+4 -2
View File
@@ -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());
+8 -4
View File
@@ -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"}));
+34 -1
View File
@@ -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<StringList>,
/// What a join makes of solids that only touch or don't touch. Default: separate.
#[serde(default)]
pub touching: Option<TouchingParam>,
/// The bodies taking part (the same as `targets` for a primitive).
#[serde(default)]
pub participants: Option<StringList>,
@@ -135,6 +138,9 @@ pub struct ExtrudeParams {
/// that body.
#[serde(default)]
pub targets: Option<StringList>,
/// What a join makes of solids that only touch or don't touch. Default: separate.
#[serde(default)]
pub touching: Option<TouchingParam>,
/// The bodies taking part; overrides the default scope.
#[serde(default)]
pub participants: Option<StringList>,
@@ -166,6 +172,28 @@ pub struct ExtrudeParams {
pub thickness2: Option<LengthExpr>,
}
/// 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<String>,
/// 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<FeatureNotice>,
/// How long the feature took to evaluate, in milliseconds.
pub ms: Option<f64>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
+3 -1
View File
@@ -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::<BoxParams>(),
&["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::<ExtrudeParams>(),
@@ -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));
}
+29 -1
View File
@@ -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: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
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)]
+12 -2
View File
@@ -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);
+3 -2
View File
@@ -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}}
]));
}