diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 688a0a57..81e79c06 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -751,6 +751,12 @@ pub enum FeatureKind { plane: PlaneRef, #[serde(default, skip_serializing_if = "Option::is_none")] tool: Option, + /// A straight line of this sketch as the tool (#1552): the plane through it along the + /// sketch's normal, which follows the sketch. + #[serde(default, skip_serializing_if = "Option::is_none")] + sketch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + line: Option, }, /// A construction axis (Fusion's CONSTRUCT axes). ConstructionAxis { diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 8f54b1fa..8b42a922 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -4400,10 +4400,17 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } Ok(()) } - FeatureKind::Split { body, plane, tool } => { + FeatureKind::Split { body, plane, tool, sketch, line } => { let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?; let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Unknown(format!("body `{body}`"))) }; let parts = match tool { + // A sketch line: the plane through it along the sketch's normal. + _ if sketch.is_some() || line.is_some() => { + let (Some(sketch), Some(line)) = (sketch, line) else { + return Err(DocError::Invalid("a sketch line tool needs both its sketch and its line".into())); + }; + kernel::split_by_plane(&mb.body, &sketch_line_plane(st, sketch.0, line)?)? + } // A face of a body (its surface extended). Some(t) => { let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?; @@ -5267,3 +5274,17 @@ mod body_name_tests { assert!(began.elapsed() < std::time::Duration::from_secs(5), "{:?}", began.elapsed()); } } + +/// The plane a straight sketch line sweeps along its sketch's normal: through the line, its x +/// along the line and its y along the normal (a Split Body tool, #1552). +pub fn sketch_line_plane(st: &ModelState, sketch: u64, line: &str) -> Result { + let ss = st.sketch(sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?; + let ci = ss.sketch.curve_index(line).ok_or_else(|| DocError::Unknown(format!("curve `{line}`")))?; + let (a, b) = match ss.sketch.segs(ci).as_slice() { + [solidcraft_geom::Seg2::Line { a, b }] => (*a, *b), + _ => return Err(DocError::Invalid(format!("`{line}` is not a straight line: split by a sketch line"))), + }; + let (wa, wb) = (ss.plane.to_world(a), ss.plane.to_world(b)); + let x = (wb - wa).normalized().ok_or_else(|| DocError::Invalid(format!("`{line}` has no length")))?; + Ok(Plane { origin: wa, x, y: ss.plane.normal() }) +} diff --git a/crates/doc/src/feature_walk_tests/fixtures.rs b/crates/doc/src/feature_walk_tests/fixtures.rs index eaf315dc..6a23ee71 100644 --- a/crates/doc/src/feature_walk_tests/fixtures.rs +++ b/crates/doc/src/feature_walk_tests/fixtures.rs @@ -84,6 +84,7 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> { ("draft", json!({"type": "draft", "faces": [o], "angle": "draft.angle", "neutral": plane("draft_neutral"), "pull": [0, 0, 1]})), ("construction_plane", json!({"type": "construction_plane", "plane": plane("cplane")})), ("split", json!({"type": "split", "body": "B", "plane": plane("split")})), + ("split_line", json!({"type": "split", "body": "B", "plane": plane("split_line")})), ( "construction_axis", json!({"type": "construction_axis", "def": {"type": "two_planes", "a": geo_plane("axis_a"), "b": geo_plane("axis_b")}}), @@ -203,6 +204,7 @@ pub(super) fn with_refs(name: &str, mut v: Value) -> Value { "hole_simple" | "hole_drilled" | "hole_counterbore" | "hole_countersink" => json!({"points": {"sketch": 3, "ids": ["p"]}}), "loft" => json!({"sections": [{"sketch": 4, "profiles": {"type": "all"}}, {"sketch": 5, "profiles": {"type": "all"}}], "targets": ["B"]}), "split" | "surface_trim" => json!({"tool": tool}), + "split_line" => json!({"sketch": 6, "line": "l1"}), "surface_loft" => json!({"sections": [{"sketch": 4}, {"sketch": 5}]}), "split_face" => json!({"body": "B", "tool": tool, "sketch": 6}), "emboss" => json!({"face": {"body": "B", "at": o}, "targets": ["B"]}), diff --git a/crates/doc/src/feature_walk_tests/golden_exprs.txt b/crates/doc/src/feature_walk_tests/golden_exprs.txt index c21df323..7a060857 100644 --- a/crates/doc/src/feature_walk_tests/golden_exprs.txt +++ b/crates/doc/src/feature_walk_tests/golden_exprs.txt @@ -161,6 +161,11 @@ split Angle = split.angle (Angle) Offset = split.offset (Length) expressions: split.angle, split.offset +split_line + inputs: + Angle = split_line.angle (Angle) + Offset = split_line.offset (Length) + expressions: split_line.angle, split_line.offset construction_axis inputs: Angle = axis_a.angle (Angle) diff --git a/crates/doc/src/feature_walk_tests/golden_geometry.txt b/crates/doc/src/feature_walk_tests/golden_geometry.txt index 0f50600f..32f8592e 100644 --- a/crates/doc/src/feature_walk_tests/golden_geometry.txt +++ b/crates/doc/src/feature_walk_tests/golden_geometry.txt @@ -76,6 +76,8 @@ construction_plane moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2] split moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2], tool.point[0], tool.point[1], tool.point[2] +split_line + moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2] construction_axis moves: def.a.plane.axis_dir[0], def.a.plane.axis_dir[1], def.a.plane.axis_origin[0], def.a.plane.axis_origin[1], def.a.plane.axis_origin[2], def.b.plane.axis_dir[0], def.b.plane.axis_dir[1], def.b.plane.axis_origin[0], def.b.plane.axis_origin[1], def.b.plane.axis_origin[2] construction_point_planes diff --git a/crates/doc/src/feature_walk_tests/golden_refs.txt b/crates/doc/src/feature_walk_tests/golden_refs.txt index edc123b4..9c56ab0b 100644 --- a/crates/doc/src/feature_walk_tests/golden_refs.txt +++ b/crates/doc/src/feature_walk_tests/golden_refs.txt @@ -81,6 +81,10 @@ construction_plane split remap_refs: plane.base.base.name remap_bodies: body, tool.body +split_line + sketches: [6] + remap_refs: plane.base.base.name, sketch + remap_bodies: body construction_axis construction_point_planes construction_point_along diff --git a/crates/doc/src/format/pending/split-by-sketch-line.rs b/crates/doc/src/format/pending/split-by-sketch-line.rs new file mode 100644 index 00000000..dcd55165 --- /dev/null +++ b/crates/doc/src/format/pending/split-by-sketch-line.rs @@ -0,0 +1,4 @@ +//! A Split Body feature can take a straight sketch line as its tool (`sketch`, the sketch's +//! feature id, and `line`, the line's id), splitting by the plane through the line along the +//! sketch's normal (#1552). Older designs need no transformation: a split without them uses its +//! plane or face as before; 0.13.x asks for an update instead of splitting by the stored plane. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 29f1d93a..ae00b365 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -59,8 +59,8 @@ pub use document::*; pub use eval::{ EdgeRef, FeatureResult, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SolvedSketch, ThreadInfo, curve_pick_error, edge_names_for, edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, object_height, parse_metric_thread, pattern_matrices, - revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction, state_in_frame, state_in_frame_at, through_all_distance, - world_state, world_state_reusing, + revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction, sketch_line_plane, state_in_frame, state_in_frame_at, + through_all_distance, world_state, world_state_reusing, }; pub use expr::Expr; pub use names::{BodyName, FeatureName, SketchId}; diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index eaf371ae..a86ad7bd 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -105,7 +105,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("solid.split_body", "Split Body", split_body) .at("SOLID", "MODIFY") .icon("split") - .params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended)"), + .params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id, line: curve id (a straight sketch line, along the sketch's normal)"), CommandSpec::new("solid.press_pull", "Press Pull", press_pull) .at("SOLID", "MODIFY") .icon("presspull") @@ -1472,6 +1472,27 @@ fn split_body(s: &mut Session, p: &Value) -> Result { if s.model.state().body(&body).is_none() { return Err(bad(cmd, format!("no body `{body}`"))); } + // A straight sketch line as the tool: `sketch` and `line` (#1552). + if p.get("sketch").is_some_and(|v| !v.is_null()) || p.get("line").is_some_and(|v| !v.is_null()) { + let sketch = p.get("sketch").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`sketch` must be a sketch feature id"))?; + let line = str_(p, "line").ok_or_else(|| bad(cmd, "`line` must name a line of the sketch"))?.to_string(); + let st = s.model.state(); + if st.sketch(sketch).is_none() { + return Err(bad(cmd, format!("no sketch {sketch}"))); + } + solidcraft_doc::sketch_line_plane(&st, sketch, &line).map_err(|e| bad(cmd, e.to_string()))?; + return add_feature( + s, + p, + FeatureKind::Split { + body: BodyName(body), + plane: solidcraft_doc::PlaneRef::Origin { name: "XY".into() }, + tool: None, + sketch: Some(solidcraft_doc::SketchId(sketch)), + line: Some(line), + }, + ); + } // A face as the tool: `tool: {body, point}` (its surface extended); else a plane. if let Some(t) = p.get("tool").filter(|t| !t.is_null()) { let tb = t.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`tool.body` must name a body"))?.to_string(); @@ -1488,11 +1509,13 @@ fn split_body(s: &mut Session, p: &Value) -> Result { body: BodyName(body), plane: solidcraft_doc::PlaneRef::Origin { name: "XY".into() }, tool: Some(solidcraft_doc::FaceAt { body: BodyName(tb), point: Position(point) }), + sketch: None, + line: None, }, ); } let plane = plane_param(s, p.get("plane"), cmd)?; - add_feature(s, p, FeatureKind::Split { body: BodyName(body), plane, tool: None }) + add_feature(s, p, FeatureKind::Split { body: BodyName(body), plane, tool: None, sketch: None, line: None }) } /// ISO 273 clearance hole diameters (close, normal, loose fit) for metric bolts. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 02ff3555..d0be87f3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1804,6 +1804,47 @@ fn split_body_by_a_face() { assert!(s.execute("solid.split_body", &json!({"body": "Block", "tool": {"body": "Tool", "point": [100, 0, 0]}})).is_err()); } +/// Split Body by a sketch line (#1552): the plane through the line along the sketch's normal +/// splits a box; the split follows the line when the sketch changes, the design saves the line, +/// and anything but a straight line of a sketch is refused. +#[test] +fn split_body_by_a_sketch_line() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "Block"})); + let sk = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.line", json!({"points": [[4, -5], [4, 25]], "ids": ["cut"]})); + run(&mut s, "sketch.circle.center", json!({"center": [50, 50], "radius": 2, "id": "c"})); + run(&mut s, "sketch.finish", json!({})); + let volumes = |s: &Session| { + let st = s.world_state(); + let mut v: Vec = + st.bodies.iter().filter(|b| b.name.starts_with("Block")).map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).collect(); + v.sort_by(f64::total_cmp); + v + }; + for bad in [ + json!({"body": "Block", "sketch": sk, "line": "c"}), + json!({"body": "Block", "sketch": sk}), + json!({"body": "Block", "sketch": 999, "line": "cut"}), + ] { + assert!(s.execute("solid.split_body", &bad).is_err(), "{bad}"); + } + run(&mut s, "solid.split_body", json!({"body": "Block", "sketch": sk, "line": "cut"})); + let v = volumes(&s); + assert!(v.len() == 2 && rel(v[0], 2400.0) < 1e-6 && rel(v[1], 3600.0) < 1e-6, "{v:?}"); + // The split follows the line. + run(&mut s, "sketch.edit", json!({"sketch": sk})); + run(&mut s, "sketch.move_point", json!({"point": "cut.start", "to": [2, -5]})); + run(&mut s, "sketch.move_point", json!({"point": "cut.end", "to": [2, 25]})); + run(&mut s, "sketch.finish", json!({})); + let v = volumes(&s); + assert!(v.len() == 2 && rel(v[0], 1200.0) < 1e-6 && rel(v[1], 4800.0) < 1e-6, "{v:?}"); + // The design keeps the sketch and the line. + let saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap(); + let split = saved["features"].as_array().and_then(|f| f.iter().find(|f| f["type"] == "split")).cloned().unwrap(); + assert_eq!((split["sketch"].as_u64(), split["line"].as_str()), (Some(sk), Some("cut")), "{split}"); +} + /// Fillet types: chord length (radius from the face angle) and a radius varying along the /// edge; volumes from the removed material. #[test] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 33bba37f..2f6911c6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -522,9 +522,10 @@ impl Dialog { Kind::AnglePlane { angle: "45 deg".into() }, vec![SelInput::new("Plane", PLANES, false), SelInput::new("Axis", AXES, false)], ), - "solid.split_body" => { - Dialog::new(Kind::Split, vec![SelInput::new("Body to split", BODIES, false), SelInput::new("Splitting tool", PLANES | FACES, false)]) - } + "solid.split_body" => Dialog::new( + Kind::Split, + vec![SelInput::new("Body to split", BODIES, false), SelInput::new("Splitting tool", PLANES | FACES | AXES, false)], + ), "solid.scale" => Dialog::new(Kind::Scale { factor: "2".into() }, vec![SelInput::new("Bodies", BODIES, true)]), "solid.offset_face" => Dialog::new(Kind::OffsetFaces { distance: "2 mm".into() }, vec![SelInput::new("Faces", PLANAR_FACES, true)]), "solid.thread" => { @@ -3889,7 +3890,19 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S Some((_, n)) => json!({"body": body, "plane": {"origin": pt(*point), "normal": pt(n)}}), None => json!({"body": body, "tool": {"body": b, "point": pt(*point)}}), }, - _ => return Err("pick a plane or a face".into()), + // A straight sketch line, in the sketch it was picked in (#1552). + Some(Sel::SketchCurve { id }) => { + let picked = d.extra.get("tool_sketch").and_then(Value::as_u64); + let st = s.world_state(); + let holding: Vec = st.sketches.iter().filter(|ss| ss.sketch.curve_index(id).is_some()).map(|ss| ss.feature).collect(); + let sketch = match picked { + Some(k) if holding.contains(&k) => k, + None if holding.len() == 1 => holding.first().copied().ok_or("pick a sketch line")?, + _ => return Err("pick the sketch line in the viewport".into()), + }; + json!({"body": body, "sketch": sketch, "line": id}) + } + _ => return Err("pick a plane, a face or a sketch line".into()), }; ("solid.split_body", p) } @@ -4866,12 +4879,17 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + FeatureKind::Split { body, plane, tool, sketch, line } => { let mut d = start("solid.split_body")?; if let Some(inp) = d.inputs.first_mut() { inp.items = vec![Sel::Body { name: body.to_string() }]; } - if let Some(inp) = d.inputs.get_mut(1) { + if let (Some(k), Some(id)) = (sketch, line) { + d.extra.insert("tool_sketch".into(), json!(k.0)); + if let Some(inp) = d.inputs.get_mut(1) { + inp.items = vec![Sel::SketchCurve { id: id.clone() }]; + } + } else if let Some(inp) = d.inputs.get_mut(1) { inp.items = match tool { Some(t) => face_sel(s, t.point.0).into_iter().collect(), None => plane_sel(s, plane).into_iter().collect(), diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 267a3bc0..95eb6fda 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1677,6 +1677,14 @@ pub(crate) fn dialog_pick(app: &mut SolveApp, hit: &Hit, sel: Sel) { }; d.extra.remove("path"); } + // Split Body's sketch line keeps the sketch it was picked in (curve ids repeat across sketches). + if matches!(d.kind, crate::dialogs::Kind::Split) { + if let Hit::SketchCurve { sketch, .. } = hit { + d.extra.insert("tool_sketch".into(), serde_json::json!(sketch)); + } else { + d.extra.remove("tool_sketch"); + } + } app.dialog = Some(d); }