mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"}));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}}
|
||||
]));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user