fix: faces of an extrude drawn over a face's projected edges keep an exact source (#505)

A profile traced over a face sketch's projected boundary left the extrude's caps and sides without an exact planar source, so a sketch or offset plane on the new top face was refused. The drawn curve is now the edge's source where it covers a projection. Clearer fixed-plane message; regression tests in the engine, UI harness and design corpus, plus a corpus-wide flat-face attachment check (known gaps under #491-#495).

Fixes #489

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-12 23:01:35 -04:00
1 parent 4aaa5457b3
commit 45bc22fc30
11 files changed
+1113 -15

No files matched your search

+44 -8
View File
@@ -348,7 +348,9 @@ pub(crate) fn flat_source_matches(certificate: &FaceCertificate, source: &solidc
/// Match a generated segment to one exact analytic sketch curve. Coincident
/// candidates are ambiguous and never resolved by distance or candidate order.
pub(crate) struct ExactSketchCurves<'a> {
segments: Vec<(&'a str, Vec<Seg2>)>,
/// Each analytic curve's id, whether it is projected (linked) reference geometry, and its
/// segments.
segments: Vec<(&'a str, bool, Vec<Seg2>)>,
}
impl<'a> ExactSketchCurves<'a> {
@@ -379,22 +381,33 @@ impl<'a> ExactSketchCurves<'a> {
} else {
sketch.sketch.segs(index)
};
segments.push((curve.id.as_str(), originals));
segments.push((curve.id.as_str(), curve.link.is_some(), originals));
}
Self { segments }
}
/// The one sketch curve a generated segment lies on. A curve drawn over projected
/// reference geometry (a profile traced over a face's live boundary) is that segment's
/// source: the projection only marks where it was drawn. Two drawn curves, or two
/// projections, over the same segment stay ambiguous.
pub(crate) fn find(&self, generated: &Seg2) -> Option<String> {
let mut found = None;
for (id, originals) in &self.segments {
let mut drawn = None;
let mut projected = None;
for (id, linked, originals) in &self.segments {
if originals.iter().any(|original| exact_segment_contains(original, generated)) {
if found.is_some() {
return None;
let slot = if *linked { &mut projected } else { &mut drawn };
if slot.is_some() {
*slot = Some(None);
} else {
*slot = Some(Some(*id));
}
found = Some(*id);
}
}
found.map(str::to_owned)
match (drawn, projected) {
(Some(id), _) => id.map(str::to_owned),
(None, Some(id)) => id.map(str::to_owned),
(None, None) => None,
}
}
}
@@ -607,6 +620,29 @@ mod tests {
assert_eq!(ExactSketchCurves::new(&moved).find(&partial), None);
}
#[test]
fn a_curve_drawn_over_a_projected_boundary_is_the_source() {
use solidcraft_geom::Vec2;
// A face sketch's live boundary, with a profile edge traced over it (issue #489).
let mut sketch = solidcraft_sketch::Sketch::new();
let projected = sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("edge")).unwrap();
sketch.curves[projected].link = Some("boundary".into());
let sketch = solved(sketch);
let edge = Seg2::Line { a: Vec2::new(10., 0.), b: Vec2::ZERO };
assert_eq!(ExactSketchCurves::new(&sketch).find(&edge).as_deref(), Some("edge"), "a lone projection is its own source");
let mut traced = sketch.clone();
traced.sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("drawn")).unwrap();
assert_eq!(ExactSketchCurves::new(&traced).find(&edge).as_deref(), Some("drawn"));
// Two drawn curves over it, or two projections, stay ambiguous.
let mut twice = traced.clone();
twice.sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("again")).unwrap();
assert_eq!(ExactSketchCurves::new(&twice).find(&edge), None);
let mut projections = sketch.clone();
let other = projections.sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("other")).unwrap();
projections.sketch.curves[other].link = Some("edge link".into());
assert_eq!(ExactSketchCurves::new(&projections).find(&edge), None);
}
#[test]
#[ignore = "manual optimized certification microbenchmark; no timing assertion"]
fn benchmark_prepared_extrude_sources() {
+4
View File
@@ -1532,3 +1532,7 @@ mod offset_edit_guard_tests;
#[cfg(test)]
#[path = "offset_plane_face_tests.rs"]
mod offset_plane_face_tests;
#[cfg(test)]
#[path = "stacked_face_tests.rs"]
mod stacked_face_tests;
+13
View File
@@ -183,6 +183,19 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
}
None
}
Err(solidcraft_doc::DocError::Invalid(message))
if matches!(
message.as_str(),
"this face has no exact planar source; choose a fixed plane instead"
| "this face has no supported construction certificate; choose a fixed plane instead"
) =>
{
return Err(bad(
cmd,
"this face can't keep an attachment: it was merged from more than one feature's faces or reshaped by an edit. \
Pick another face, or use a fixed plane here (fixed:true), which won't follow later changes to the face",
));
}
Err(error) => return Err(bad(cmd, format!("{error}; choose a supported exact face or explicitly use fixed:true"))),
};
Ok(PlaneRef::Face { plane, at, name, anchor })
+133
View File
@@ -0,0 +1,133 @@
//! Issue #489: a sketch or an offset plane on the top face of an extrude that was itself
//! sketched on a face. Sketching a profile over the whole face draws curves on top of the face's
//! projected boundary; the extrude's faces must still carry an exact, certified planar source.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use crate::Session;
use serde_json::{Value, json};
use solidcraft_doc::FaceAttachmentStatus;
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|e| panic!("{command} {params}: {e}"))
}
fn profile(s: &mut Session, circle: bool, full: bool) {
if circle {
run(s, "sketch.circle.center", json!({"center": [20, 20], "diameter": if full { 40 } else { 16 }}));
} else if full {
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]}));
} else {
run(s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [30, 30]}));
}
}
/// A 40 mm base (square or round) 10 mm tall, then a second extrude 10 mm up from a sketch on
/// its top face (or on a plane at that height), joined or as a new body.
fn two_extrudes(circle: bool, full: bool, on_face: bool, operation: &str) -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
profile(&mut s, circle, true);
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "First"}));
let plane = if on_face { json!({"face": [20, 20, 10]}) } else { json!("XY") };
let mut params = json!({"plane": plane, "name": "Upper"});
if !on_face {
params["offset"] = json!(10);
}
run(&mut s, "sketch.create", params);
profile(&mut s, circle, full);
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "operation": operation, "name": "Second"}));
s
}
fn healthy(s: &Session, sketch: &str) -> solidcraft_geom::Plane {
let id = s.doc.find_feature(sketch).map(|f| f.id).unwrap();
match s.placed_document().sketch_face_attachment(id, &s.model) {
FaceAttachmentStatus::Healthy { plane, .. } => plane,
other => panic!("{sketch}: {other:?}"),
}
}
fn no_errors(s: &Session) {
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
}
#[test]
fn sketch_and_offset_plane_on_the_top_face_of_a_face_sketched_extrude() {
for operation in ["join", "new"] {
for circle in [false, true] {
for full in [true, false] {
for on_face in [true, false] {
let case = format!("{operation} circle={circle} full={full} on_face={on_face}");
let mut s = two_extrudes(circle, full, on_face, operation);
let top = [20., 20., 20.];
s.execute("sketch.create", &json!({"plane": {"face": top}, "name": "Top"})).unwrap_or_else(|e| panic!("{case}: {e}"));
run(&mut s, "sketch.finish", json!({}));
let plane = healthy(&s, "Top");
assert!((plane.origin.z - 20.).abs() < 1e-9 && (plane.normal().z - 1.).abs() < 1e-9, "{case}: {plane:?}");
s.execute("construct.plane.offset", &json!({"base": {"face": top}, "offset": 5, "name": "Above"}))
.unwrap_or_else(|e| panic!("{case}: {e}"));
// The second extrude's flat side is an exact source too, unless a full-face
// Join merged it with the first extrude's side: that face belongs to neither,
// so it offers the explicit fixed plane with a readable reason.
if !circle {
let side = if full { [40., 20., 15.] } else { [30., 20., 15.] };
let params = json!({"plane": {"face": side}, "name": "Side"});
if full && operation == "join" {
let error = s.execute("sketch.create", &params).unwrap_err().to_string();
assert!(error.contains("merged") && error.contains("fixed:true"), "{case}: {error}");
run(&mut s, "sketch.create", json!({"plane": {"face": side, "fixed": true}, "name": "Side"}));
} else {
s.execute("sketch.create", &params).unwrap_or_else(|e| panic!("{case} side: {e}"));
assert!((healthy(&s, "Side").normal().x - 1.).abs() < 1e-9, "{case}");
}
run(&mut s, "sketch.finish", json!({}));
}
s.refresh();
no_errors(&s);
}
}
}
}
}
/// Three extrudes, each sketched over the whole top face of the one before; editing the first
/// one's height moves every later sketch with its face.
#[test]
fn three_stacked_extrudes_follow_an_edit_of_the_first() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S1"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "E1"}));
for (k, top) in [(2, 10.), (3, 20.)] {
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 20, top]}, "name": format!("S{k}")}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "operation": "join", "name": format!("E{k}")}));
}
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 20, 30]}, "name": "S4"}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 20], "diameter": 10}));
run(&mut s, "sketch.finish", json!({}));
s.refresh();
no_errors(&s);
assert_eq!(s.model.state().bodies.len(), 1);
for (name, z) in [("S2", 10.), ("S3", 20.), ("S4", 30.)] {
assert!((healthy(&s, name).origin.z - z).abs() < 1e-9, "{name}");
}
run(&mut s, "timeline.edit", json!({"feature": "E1", "set": {"extent": {"distance": "25"}}}));
s.refresh();
no_errors(&s);
for (name, z) in [("S2", 25.), ("S3", 35.), ("S4", 45.)] {
assert!((healthy(&s, name).origin.z - z).abs() < 1e-9, "{name} after edit");
}
let volume: f64 = s.model.state().bodies.iter().map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).sum();
assert!((volume - 40. * 40. * 45.).abs() < 1e-6, "{volume}");
// Saved and reopened, every attachment still resolves.
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
reopened.refresh();
no_errors(&reopened);
assert!((healthy(&reopened, "S4").origin.z - 45.).abs() < 1e-9);
}
+1 -1
View File
@@ -2321,7 +2321,7 @@ pub fn start_sketch(app: &mut SolveApp, sel: &Sel) -> Result<(), String> {
_ => return Err("Select an origin plane, construction plane or planar body face".into()),
};
let before = app.cam;
app.run("sketch.create", json!({ "plane": plane }))?;
app.run("sketch.create", json!({ "plane": plane })).map_err(|error| crate::friendly_error("sketch.create", &error))?;
app.pre_sketch_cam = Some(before);
look_at_sketch(app);
// On a face: look at the picked point, not the plane's origin (a corner of the face),
@@ -330,3 +330,43 @@ fn nested_anchor_saved_fixed_conversion_preserves_rotated_second_author_plane()
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), before);
}
/// Issue #489: extrude, sketch a rectangle over the whole top face (its corners snap to the
/// face's projected boundary), extrude it up as a Join, then pick the new top face for a sketch.
#[test]
fn picked_top_face_of_a_stacked_join_extrude_takes_a_sketch() {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.run("sketch.create", json!({"plane":"XY","name":"Base"})).unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,40]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.run("solid.extrude", json!({"distance":10,"name":"First"})).unwrap();
h.call("ui.view", json!({"view":"top"}));
h.call("ui.view", json!({"view":"fit"}));
h.settle();
let pick_face = |h: &mut Harness, z: f64| {
h.step(&json!({"start":"sketch.create"})).unwrap();
h.step(&json!({"click":{"world":[20,20,z]}})).unwrap();
h.settle();
let error = h.app.dialog.as_ref().and_then(|d| d.error.clone());
let sketch = h.app.session.active_sketch.unwrap_or_else(|| panic!("top face at z={z} must take a sketch: {error:?}"));
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(_), .. }, .. } = &h.app.session.doc.feature(sketch).unwrap().kind else {
panic!("the top face at z={z} must give a certified attachment");
};
sketch
};
pick_face(&mut h, 10.);
h.step(&json!({"start":"sketch.rectangle.two_point"})).unwrap();
h.step(&json!({"click":{"world":[0,0,10]}})).unwrap();
h.step(&json!({"click":{"world":[40,40,10]}})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
h.app.finish_sketch();
h.app.run("solid.extrude", json!({"distance":10,"operation":"join","name":"Second"})).unwrap();
assert_eq!(h.app.session.model.state().bodies.len(), 1);
h.settle();
let top = pick_face(&mut h, 20.);
let FaceAttachmentStatus::Healthy { plane, .. } = h.app.session.placed_document().sketch_face_attachment(top, &h.app.session.model) else {
panic!("attachment must be healthy");
};
assert!((plane.origin.z - 20.).abs() < 1e-9);
}