Sketch on cut walls, chamfers, pattern copies, join-merged sides and split faces (#625)

A flat face that no construction certificate names on its own now attaches by its exact history name (a new lineage face certificate): cut walls and floors, chamfer faces, each pattern or mirror copy, side walls a Join merged, and the pieces a later feature split a face into. Construction certificates still come first; a seed keeps its certificate when it is patterned. Two flush extrudes of one outline joined end to end keep their exact history in the prism boolean. Face sketch 52% -> 71%, offset plane 52% -> 71%, press-pull/extrude from face 41% -> 60% in the action matrix; baseline blessed.

Fixes #491, fixes #492, fixes #493, fixes #557.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-13 05:26:29 -04:00
1 parent 27f20f97cb
commit 6d5df4b9b3
18 files changed
+786 -381

No files matched your search

+161 -2
View File
@@ -179,6 +179,13 @@ pub enum FaceCertificate {
source: Box<FaceCertificate>,
inner: bool,
},
/// A flat face known by its exact history name (the target's `name`, without piece
/// numbers): what the kernel's face history says it came from, followed through every
/// feature since (a cut wall `F5:side:l1`, a chamfer `F7:blend:…`, a pattern copy
/// `F4:top@F6.2`, a face a join merged from two features). Used where no construction
/// certificate above names the face uniquely. Its plane is the face's exact current plane in
/// the frame [`lineage_frame`] gives it. See docs/face-attachments.md.
Lineage,
}
impl FaceCertificate {
@@ -210,8 +217,9 @@ impl FaceCertificate {
&& payload_sha256.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
Self::ShellPlanar { source_producer, source, .. } => {
*source_producer != 0 && !matches!(source.as_ref(), Self::ShellPlanar { .. }) && source.validate().is_ok()
*source_producer != 0 && !matches!(source.as_ref(), Self::ShellPlanar { .. } | Self::Lineage) && source.validate().is_ok()
}
Self::Lineage => true,
};
if valid { Ok(()) } else { Err(DocError::Invalid("invalid face source certificate".into())) }
}
@@ -258,13 +266,16 @@ pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()>
.and_then(serde_json::Value::as_str)
.is_some_and(|digest| digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
}
Some("lineage") => fields.len() == 1,
Some("shell_planar") => {
fields.len() == 4
&& fields.keys().all(|key| ["kind", "source_producer", "source", "inner"].contains(&key.as_str()))
&& nonzero("source_producer")
&& fields.get("inner").and_then(serde_json::Value::as_bool).is_some()
&& fields.get("source").is_some_and(|source| {
source.as_object().is_some_and(|source| source.get("kind").and_then(serde_json::Value::as_str) != Some("shell_planar"))
source
.as_object()
.is_some_and(|source| !matches!(source.get("kind").and_then(serde_json::Value::as_str), Some("shell_planar" | "lineage")))
&& validate_certificate_json(source).is_ok()
})
}
@@ -286,6 +297,7 @@ pub(crate) fn shell_certificate_role(source: &FaceCertificate, inner: bool) -> R
FaceCertificate::ExtrudeCap { high, outer, holes, .. } => format!("cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes)),
FaceCertificate::Imported { payload_sha256, item, face } => format!("import:{payload_sha256}:{item}:{face}"),
FaceCertificate::ShellPlanar { .. } => return Err(DocError::Invalid("nested shell source certificates are unsupported".into())),
FaceCertificate::Lineage => return Err(DocError::Invalid("a shell's source needs a construction certificate".into())),
};
Ok(format!("shell:{}:{role}", if inner { "inner" } else { "outer" }))
}
@@ -551,6 +563,153 @@ impl Document {
}
}
/// The frame a [`FaceCertificate::Lineage`] face's plane is given, from the plane alone (never
/// from how a kernel parametrizes the surface, so it survives rebuilds and kernel changes): its
/// origin where the body's definition origin projects onto the plane, its x axis the first of
/// the definition's X, Y and Z axes that is not steep to the plane (`|cos| < 0.6`; one always
/// is), projected onto it. A face that moves along its normal keeps the frame where it was;
/// one that moves within its plane (a Move feature) keeps it too, as a sketch on a face does
/// in the definition's coordinates.
pub fn lineage_frame(body: &ModelBody, plane: &solidcraft_geom::Plane) -> Option<solidcraft_geom::Plane> {
use solidcraft_geom::Vec3;
let n = plane.normal();
let m = &body.source_transform;
let origin = crate::apply_point(m, Vec3::ZERO);
let axis = [Vec3::X, Vec3::Y, Vec3::Z].into_iter().filter_map(|a| crate::apply_vector(m, a).normalized()).find(|a| a.dot(n).abs() < 0.6)?;
let x = axis - n * axis.dot(n);
let origin = origin - n * (origin - plane.origin).dot(n);
solidcraft_geom::Plane::new(origin, x, n.cross(x))
}
/// Whether two exact planes are one oriented plane (pieces of one face).
fn same_oriented_plane(a: &solidcraft_geom::Plane, b: &solidcraft_geom::Plane, scale: f64) -> bool {
a.normal().dot(b.normal()) >= 1. - 1e-8 && (b.origin - a.origin).dot(a.normal()).abs() <= 1e-7 * (1. + scale)
}
impl Document {
/// The exact plane an anchor on face `index` of `body` is measured in: the construction
/// certificate's plane, or for a [`FaceCertificate::Lineage`] face its current exact plane in
/// the [`lineage_frame`].
pub fn anchor_face_plane(&self, body: &ModelBody, index: usize, certificate: Option<&FaceCertificate>) -> Option<solidcraft_geom::Plane> {
match certificate {
Some(FaceCertificate::Lineage) => body.body.planar_face(index).and_then(|plane| lineage_frame(body, &plane)),
_ => body.body.face_plane(index),
}
}
/// A target for flat face `index` of `body` by its exact history name
/// ([`FaceCertificate::Lineage`]), when that name follows exact history, names a feature of
/// the design and finds this face (with its other pieces, all on one oriented plane).
pub(crate) fn lineage_face_target(
&self,
body: &ModelBody,
index: usize,
point: solidcraft_geom::Vec3,
) -> Option<crate::config::ConfigFaceTarget> {
let names = crate::naming::names(body);
if !names.verified(index) {
return None;
}
let name = crate::naming::strip_piece(names.faces.get(index)?).to_string();
let origin = name.strip_prefix('F')?.split_once(':')?.0.parse::<u64>().ok()?;
let inverse = crate::mat_inverse(&body.source_transform)?;
let target = crate::config::ConfigFaceTarget {
body: body.definition_name().into(),
origin_feature: origin,
name,
point: crate::apply_point(&inverse, point),
certificate: Some(FaceCertificate::Lineage),
};
self.configuration_face_indices(body, &target).ok()?.contains(&index).then_some(target)
}
/// The faces of `body` an exact history name finds: those named `name` or a piece of it, else
/// (a later join merged it into another face) the face that answers to it. Every one must
/// follow exact history and lie on one oriented plane.
pub(crate) fn lineage_face_indices(&self, body: &ModelBody, name: &str) -> Result<Vec<usize>> {
let names = crate::naming::names(body);
let mut indices: Vec<usize> =
names.faces.iter().enumerate().filter(|(_, face)| crate::naming::strip_piece(face) == name).map(|(index, _)| index).collect();
if indices.is_empty() {
indices =
names.info.iter().enumerate().filter(|(_, info)| info.aliases.iter().any(|alias| alias == name)).map(|(index, _)| index).collect();
}
if indices.is_empty() {
return Err(DocError::Unknown(format!("face `{name}` on `{}` no longer exists", body.name)));
}
self.coplanar_pieces(body, &indices, |index| names.verified(index))?;
Ok(indices)
}
/// Check that `indices` are pieces of one flat face: each `exact` and planar, all on one
/// oriented plane, and not split on purpose (Split Face: its pieces are separate faces, so
/// a pick on one never stands for the whole; docs/face-attachments.md).
pub(crate) fn coplanar_pieces(&self, body: &ModelBody, indices: &[usize], exact: impl Fn(usize) -> bool) -> Result<()> {
let scale = body.body.size();
let mut first = None;
for index in indices {
if !exact(*index) {
return Err(DocError::Invalid("this face's history is not exact".into()));
}
let plane = body.body.planar_face(*index).ok_or_else(|| DocError::Invalid("attached face is not flat".into()))?;
match &first {
None => first = Some(plane),
Some(first) if same_oriented_plane(first, &plane, scale) => {}
Some(_) => return Err(DocError::Invalid("attached face pieces no longer share one oriented plane".into())),
}
}
if let (Some(plane), true) = (first, indices.len() > 1) {
let tolerance = 1e-6 * (1. + scale);
let on_plane = |line: &Vec<solidcraft_geom::Vec3>| {
!line.is_empty() && line.iter().all(|p| (*p - plane.origin).dot(plane.normal()).abs() <= tolerance)
};
if body.body.split_lines().is_some_and(|lines| lines.iter().any(on_plane)) {
return Err(DocError::Invalid(
"this face was split on purpose (Split Face): pick an exact face piece's own plane with fixed:true".into(),
));
}
}
Ok(())
}
/// Of several faces carrying one construction certificate of feature `origin`, the pieces of
/// that feature's own face: copies a pattern or mirror made carry their seed's certificate
/// but are named as copies (`…@F<pattern>.<instance>`, docs/face-attachments.md), and pieces
/// a later feature split the face into share its exact name and plane.
pub(crate) fn certified_pieces(&self, body: &ModelBody, indices: Vec<usize>, origin: u64) -> Result<Vec<usize>> {
if indices.len() < 2 {
return Ok(indices);
}
let names = crate::naming::names(body);
let own = format!("F{origin}:");
let stem = |index: usize| names.faces.get(index).map(|name| crate::naming::strip_piece(name));
let seed: Vec<usize> =
indices.into_iter().filter(|index| stem(*index).is_some_and(|name| name.starts_with(&own) && !name.contains('@'))).collect();
let ambiguous = || DocError::Unknown("attached planar face certificate is missing or ambiguous".into());
let first = seed.first().and_then(|index| stem(*index)).ok_or_else(ambiguous)?;
if seed.iter().any(|index| stem(*index) != Some(first)) {
return Err(ambiguous());
}
// The certificate is exact; the names only tell the seed from its copies here.
self.coplanar_pieces(body, &seed, |_| true).map_err(|_| ambiguous())?;
Ok(seed)
}
/// Of several faces carrying one certificate of feature `origin`, those that are not a
/// pattern's or mirror's copies (named `…@F<pattern>.<instance>`), when any is not.
pub(crate) fn without_copies(&self, body: &ModelBody, indices: Vec<usize>) -> Vec<usize> {
if indices.len() < 2 {
return indices;
}
let names = crate::naming::names(body);
let copy = |index: &usize| names.faces.get(*index).is_some_and(|name| crate::naming::strip_piece(name).contains('@'));
if indices.iter().all(copy) {
return indices;
}
indices.into_iter().filter(|index| !copy(index)).collect()
}
}
pub(crate) fn payload_digest(payload: &str) -> String {
use sha2::{Digest, Sha256};
format!("{:x}", Sha256::digest(payload.as_bytes()))
+8 -4
View File
@@ -145,6 +145,7 @@ impl Document {
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solidcraft_sketch::CurveKind::Line { .. } | solidcraft_sketch::CurveKind::Circle { .. } | solidcraft_sketch::CurveKind::Arc { .. }))))
}
(super::FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => role.strip_prefix("face:").is_some_and(|index| index.parse::<usize>().is_ok()),
(super::FaceCertificate::Lineage, _) => !role.is_empty(),
(super::FaceCertificate::ShellPlanar { source_producer, source, inner }, FeatureKind::Shell { .. }) => {
*source_producer != target.origin_feature
&& self.flat_certificate_producer_matches(*source_producer, source)
@@ -185,6 +186,9 @@ impl Document {
if body.body.is_mesh() || body.definition_name() != target.body {
return Err(DocError::Invalid("configured face definition does not match".into()));
}
if let Some(super::FaceCertificate::Lineage) = &target.certificate {
return self.lineage_face_indices(body, &target.name);
}
if let Some(super::FaceCertificate::ShellPlanar { source_producer, source, inner }) = &target.certificate {
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, current)| {
matches!(current, Some(solidcraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), source: current_source, inner: current_inner, .. }) if *producer == target.origin_feature && inner == current_inner && super::flat_source_matches(source, current_source, *source_producer)).then_some(index)
@@ -238,10 +242,10 @@ impl Document {
matched.then_some(index)
})
.collect();
if indices.len() != 1 {
if indices.is_empty() {
return Err(DocError::Unknown("attached planar face certificate is missing or ambiguous".into()));
}
return Ok(indices);
return self.certified_pieces(body, indices, target.origin_feature);
}
if let Some(super::FaceCertificate::ExtrudeSide { sketch, curve }) = &target.certificate {
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
@@ -250,7 +254,7 @@ impl Document {
if indices.is_empty() {
return Err(DocError::Unknown("constructed side certificate no longer exists".into()));
}
return Ok(indices);
return Ok(self.without_copies(body, indices));
}
if let Some(super::FaceCertificate::Imported { payload_sha256, item, face }) = &target.certificate {
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
@@ -259,7 +263,7 @@ impl Document {
if indices.is_empty() {
return Err(DocError::Unknown("immutable import face certificate no longer exists".into()));
}
return Ok(indices);
return Ok(self.without_copies(body, indices));
}
let names = crate::naming::face_names(body);
let indices: Vec<usize> =
+28 -8
View File
@@ -144,15 +144,30 @@ impl Document {
author: u64,
author_path: &[u64],
) -> Result<FaceAnchor> {
let source = body
// A construction certificate that names this face alone, else its exact history name.
let certified = body
.body
.face_plane(index)
.ok_or_else(|| DocError::Invalid("this face has no exact planar source; choose a fixed plane instead".into()))
.and_then(|source| {
let target = self.strong_face_target(body, index, source.origin)?;
if target.certificate.is_none() {
return Err(DocError::Invalid("this face has no supported construction certificate; choose a fixed plane instead".into()));
}
let indices = self.configuration_face_indices(body, &target)?;
self.coplanar_pieces(body, &indices, |_| true)?;
Ok(target)
});
let target = match certified {
Ok(target) => target,
Err(error) => match body.body.planar_face(index).and_then(|plane| self.lineage_face_target(body, index, plane.origin)) {
Some(target) => target,
None => return Err(error),
},
};
let source = self
.anchor_face_plane(body, index, target.certificate.as_ref())
.ok_or_else(|| DocError::Invalid("this face has no exact planar source; choose a fixed plane instead".into()))?;
let target = self.strong_face_target(body, index, source.origin)?;
if target.certificate.is_none() {
return Err(DocError::Invalid("this face has no supported construction certificate; choose a fixed plane instead".into()));
}
self.configuration_face_indices(body, &target)?;
let mut anchor = FaceAnchor {
target,
component: self.model_body_component(body),
@@ -178,9 +193,14 @@ impl Document {
state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("attached body `{}` no longer exists", anchor.target.body)))?;
let indices = self.configuration_face_indices(body, &anchor.target)?;
let first = indices.first().copied().ok_or_else(|| DocError::Unknown("attached face no longer exists".into()))?;
let plane = body.body.face_plane(first).ok_or_else(|| DocError::Invalid("attached face no longer has an exact planar source".into()))?;
let certificate = anchor.target.certificate.as_ref();
let plane = self
.anchor_face_plane(body, first, certificate)
.ok_or_else(|| DocError::Invalid("attached face no longer has an exact planar source".into()))?;
for index in indices.iter().skip(1) {
let other = body.body.face_plane(*index).ok_or_else(|| DocError::Invalid("attached face pieces are not exactly planar".into()))?;
let other = self
.anchor_face_plane(body, *index, certificate)
.ok_or_else(|| DocError::Invalid("attached face pieces are not exactly planar".into()))?;
if plane.normal().dot(other.normal()) < 1. - 1e-8 || (other.origin - plane.origin).dot(plane.normal()).abs() > 1e-7 {
return Err(DocError::Invalid("attached face pieces no longer share one oriented plane".into()));
}
+4
View File
@@ -66,6 +66,10 @@
//! A design can also keep what was hidden when it was saved (`visibility`: hidden bodies,
//! occurrence placements, sketches and origin items; `browser.visibility`). A design without
//! it shows everything, as it always did; a reader that ignores it loses only the hiding.
//! Sketch face anchors can name a flat face by its exact history name (a `lineage`
//! certificate: the target's `name`, e.g. a cut wall `F5:side:l1` or a pattern copy
//! `F4:top@F6.2`), where no construction certificate names it alone. Anchors with
//! construction certificates resolve as before.
use serde_json::Value;
+27 -16
View File
@@ -136,31 +136,42 @@ fn generated_profiles_and_press_pull_convert_rotated_occurrence_picks_once() {
}
}
/// A 40 x 30 x 20 box whose top is split at x = 20 and its left half raised 5 by Offset Face:
/// the raised top has no construction certificate and no exact history name yet (#494).
fn raised_half(s: &mut Session, body: &str) {
run(s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":body}));
run(s, "solid.split_face", json!({"faces":[[10,15,20]],"plane":{"origin":[20,0,0],"normal":[1,0,0]}}));
run(s, "solid.offset_face", json!({"faces":[[10,15,20]],"distance":5}));
}
#[test]
fn legacy_internal_capture_refuses_an_uncaptured_source_in_another_component() {
let mut s = Session::default();
let component = run(&mut s, "component.create", json!({"name":"Source"}));
let occurrence = component["occurrence"].as_u64().unwrap();
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Stock"}));
// A top merged from two joined boxes has no single certified source: the legacy path.
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"corner":[40,0,0],"operation":"join"}));
// A face Offset Face raised has no construction certificate or exact history name yet:
// the legacy path.
raised_half(&mut s, "Stock");
run(&mut s, "component.activate", json!({"component":0}));
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":false}));
let state = s.model.state();
let index = state
// The raised half's top.
let end = state
.body("Stock")
.unwrap()
.body
.faces(1e-4)
.unwrap()
.iter()
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 20.0).abs() < 1e-6)
.unwrap()
.index;
.into_iter()
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 25.0).abs() < 1e-6)
.unwrap();
let before = s.doc.to_json();
let error =
super::super::sketch::face::capture_internal(&s, &json!({"face":[35,5,20],"body":"Stock","index":index,"path":[occurrence]}), "internal")
.unwrap_err();
let error = super::super::sketch::face::capture_internal(
&s,
&json!({"face":[40,15,25],"body":"Stock","index":end.index,"path":[occurrence]}),
"internal",
)
.unwrap_err();
assert!(error.to_string().contains("capture the source occurrence position"), "{error}");
assert_eq!(s.doc.to_json(), before);
}
@@ -278,14 +289,14 @@ fn join_keeps_exact_face_attachments_from_both_operands() {
}
#[test]
fn join_leaves_a_face_merged_from_both_operands_without_an_attachment() {
fn join_gives_a_face_merged_from_both_operands_its_history_name() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10}));
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"corner":[20,0,0],"operation":"join"}));
// Both boxes' tops share one support: no single construction identity owns it.
let error = s.execute("sketch.create", &json!({"plane":{"face":[10,10,10]}})).unwrap_err().to_string();
assert!(error.contains("fixed:true"), "{error}");
run(&mut s, "sketch.create", json!({"plane":{"face":[10,10,10],"fixed":true}}));
// Both boxes' tops share one support: no single construction certificate owns it, but its
// exact history names it (#491).
let top = run(&mut s, "sketch.create", json!({"plane":{"face":[10,10,10]}}))["sketch"].as_u64().unwrap();
assert_eq!(face_anchor(&s, top).target.certificate, Some(solidcraft_doc::config::FaceCertificate::Lineage));
// An unshared support keeps its box role.
run(&mut s, "sketch.finish", json!({}));
let side = run(&mut s, "sketch.create", json!({"plane":{"face":[0,10,5]}}))["sketch"].as_u64().unwrap();
+3
View File
@@ -1574,6 +1574,9 @@ mod offset_edit_guard_tests;
#[path = "offset_plane_face_tests.rs"]
mod offset_plane_face_tests;
#[cfg(test)]
#[path = "lineage_face_tests.rs"]
mod lineage_face_tests;
#[cfg(test)]
#[path = "stacked_face_tests.rs"]
mod stacked_face_tests;
+166
View File
@@ -0,0 +1,166 @@
//! Sketches on flat faces that no construction certificate names: a Cut's walls and floor, a
//! chamfer, a pattern's copies, a side a Join merged from two features, the pieces of a split
//! face (#491, #492, #493, #557). They attach by the face's exact history name
//! (`FaceCertificate::Lineage`), follow the face through parameter edits and survive a save.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use crate::Session;
use serde_json::{Value, json};
use solidcraft_doc::FaceAttachmentStatus;
use solidcraft_doc::config::FaceCertificate;
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|e| panic!("{command} {params}: {e}"))
}
fn sketch_on(s: &mut Session, at: [f64; 3], name: &str) {
run(s, "sketch.create", json!({"plane": {"face": at}, "name": name}));
run(s, "sketch.finish", json!({}));
}
fn status(s: &Session, sketch: &str) -> FaceAttachmentStatus {
let id = s.doc.find_feature(sketch).map(|f| f.id).unwrap();
s.placed_document().sketch_face_attachment(id, &s.model)
}
fn healthy(s: &Session, sketch: &str) -> (solidcraft_geom::Plane, Option<FaceCertificate>) {
match status(s, sketch) {
FaceAttachmentStatus::Healthy { plane, target, .. } => (plane, target.certificate),
other => panic!("{sketch}: {other:?}"),
}
}
fn no_errors(s: &Session) {
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
}
fn reopened(s: &Session) -> Session {
let mut t = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
t.refresh();
no_errors(&t);
t
}
/// A 40 x 30 x 10 block with a slot cut 5 deep across it from x = `slot_x` (a parameter), 10 wide.
fn slotted_block() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "Block"}));
run(&mut s, "parameters.add", json!({"name": "slot_x", "expression": "15 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "SlotSketch"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [15, -5], "p1": [25, 35]}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "p1"], "type": "horizontal", "value": "slot_x"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "10 mm"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "SlotSketch", "distance": 12, "start_offset": 5, "operation": "cut", "name": "Slot"}));
no_errors(&s);
s
}
#[test]
fn a_cut_wall_and_floor_take_a_sketch_that_follows_the_cut() {
let mut s = slotted_block();
sketch_on(&mut s, [15., 15., 7.5], "Wall");
sketch_on(&mut s, [20., 15., 5.], "Floor");
let (wall, certificate) = healthy(&s, "Wall");
assert_eq!(certificate, Some(FaceCertificate::Lineage));
// The wall faces into the slot (+x), its frame's origin over the design origin.
assert!((wall.normal().x - 1.).abs() < 1e-9 && wall.origin.x.abs() > 14.9 && (wall.origin.x - 15.).abs() < 1e-9, "{wall:?}");
assert!((wall.origin.y.abs() + wall.origin.z.abs()) < 1e-9, "{wall:?}");
let (floor, _) = healthy(&s, "Floor");
assert!((floor.normal().z - 1.).abs() < 1e-9 && (floor.origin.z - 5.).abs() < 1e-9, "{floor:?}");
// The slot moves: the sketch on its wall moves with it.
run(&mut s, "parameters.change", json!({"name": "slot_x", "expression": "20 mm"}));
no_errors(&s);
let (wall, _) = healthy(&s, "Wall");
assert!((wall.origin.x - 20.).abs() < 1e-9, "{wall:?}");
let t = reopened(&s);
assert!((healthy(&t, "Wall").0.origin.x - 20.).abs() < 1e-9);
assert!((healthy(&t, "Floor").0.origin.z - 5.).abs() < 1e-9);
}
#[test]
fn the_pieces_of_a_split_top_take_a_sketch() {
let mut s = slotted_block();
// The slot splits the block's top in two; each piece takes a sketch (on that face's plane).
for (name, x) in [("Left", 5.), ("Right", 35.)] {
sketch_on(&mut s, [x, 15., 10.], name);
assert!((healthy(&s, name).0.origin.z - 10.).abs() < 1e-9, "{name}");
}
run(&mut s, "parameters.change", json!({"name": "slot_x", "expression": "20 mm"}));
no_errors(&s);
assert!((healthy(&s, "Left").0.origin.z - 10.).abs() < 1e-9);
}
#[test]
fn a_chamfer_face_takes_a_sketch_that_follows_the_chamfer() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "bevel", "expression": "3 mm"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "name": "Base"}));
run(&mut s, "solid.chamfer", json!({"edges": [[40, 15, 10]], "distance": "bevel", "name": "Bevel"}));
no_errors(&s);
sketch_on(&mut s, [38.5, 15., 8.5], "OnBevel");
let (plane, certificate) = healthy(&s, "OnBevel");
assert_eq!(certificate, Some(FaceCertificate::Lineage));
let n = std::f64::consts::FRAC_1_SQRT_2;
assert!((plane.normal().x - n).abs() < 1e-9 && (plane.normal().z - n).abs() < 1e-9, "{plane:?}");
// A bigger chamfer moves the face inward: the sketch goes with it.
let height = |p: &solidcraft_geom::Plane| (p.origin - solidcraft_geom::Vec3::new(40., 0., 10.)).dot(p.normal());
assert!((height(&plane) + 3. * n).abs() < 1e-9, "{plane:?}");
run(&mut s, "parameters.change", json!({"name": "bevel", "expression": "5 mm"}));
no_errors(&s);
assert!((height(&healthy(&s, "OnBevel").0) + 5. * n).abs() < 1e-9);
healthy(&reopened(&s), "OnBevel");
}
/// A plate with a boss patterned 3 times along x, `pitch` apart.
fn patterned_bosses(sketch_on_seed_first: bool) -> Session {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "pitch", "expression": "20 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [80, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
run(&mut s, "sketch.create", json!({"plane": {"face": [40, 10, 5]}}));
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 4}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 6, "operation": "join", "name": "Boss"}));
if sketch_on_seed_first {
sketch_on(&mut s, [10., 10., 11.], "Seed");
}
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Boss"], "dir1": [1, 0, 0], "count1": 3, "spacing1": "pitch", "name": "Row"}));
no_errors(&s);
s
}
#[test]
fn each_patterned_copy_takes_its_own_sketch() {
let mut s = patterned_bosses(true);
// A sketch made on the seed before the pattern keeps its construction certificate: the
// copies carry it too, but are named as copies.
assert!(matches!(healthy(&s, "Seed").1, Some(FaceCertificate::ExtrudeCap { .. })));
for (name, x) in [("First", 10.), ("Middle", 30.), ("Last", 50.)] {
sketch_on(&mut s, [x, 10., 11.], name);
assert!((healthy(&s, name).0.origin.z - 11.).abs() < 1e-9, "{name}");
}
run(&mut s, "parameters.change", json!({"name": "pitch", "expression": "25 mm"}));
no_errors(&s);
for name in ["Seed", "First", "Middle", "Last"] {
healthy(&s, name);
}
let t = reopened(&s);
for name in ["Seed", "Middle", "Last"] {
healthy(&t, name);
}
}
#[test]
fn a_piece_of_a_face_split_on_purpose_still_needs_a_fixed_plane() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Modified"}));
run(&mut s, "solid.split_face", json!({"body": "Modified", "faces": [[5, 5, 10]], "plane": {"origin": [5, 0, 0], "normal": [1, 0, 0]}}));
let error = s.execute("sketch.create", &json!({"plane": {"face": [2.5, 5, 10]}})).unwrap_err().to_string();
assert!(error.contains("fixed:true"), "{error}");
}
+4 -3
View File
@@ -75,9 +75,10 @@ fn explicit_rebind_preserves_geometry_dimensions_context_and_one_undo() {
#[test]
fn unsupported_face_has_explicit_fixed_alternative_and_no_live_projection() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"}));
// The tops of two joined boxes merge into one face that no single construction owns.
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[10,0,0],"operation":"join"}));
// A top half Offset Face raised: no construction certificate and no exact history name yet.
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Base"}));
run(&mut s, "solid.split_face", json!({"faces":[[10,15,20]],"plane":{"origin":[20,0,0],"normal":[1,0,0]}}));
run(&mut s, "solid.offset_face", json!({"faces":[[10,15,20]],"distance":5}));
let mut pick = top_pick(&s, "Base");
let before = s.doc.to_json();
let error = s.execute("sketch.create", &json!({"plane":pick})).unwrap_err();
+5 -12
View File
@@ -67,20 +67,13 @@ fn sketch_and_offset_plane_on_the_top_face_of_a_face_sketched_extrude() {
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.
// The second extrude's flat side attaches too, also where a full-face Join
// merged it with the first extrude's side (#491): that face is known by its
// exact history name.
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}");
}
s.execute("sketch.create", &json!({"plane": {"face": side}, "name": "Side"})).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();
+10 -6
View File
@@ -830,12 +830,16 @@ fn step_open_then_model_on_it() {
run(&mut s, "solid.hole", json!({"position": at(28.0, 15.0, 20.0), "diameter": 6}));
want -= PI * 9.0 * 20.0;
assert!(rel(volume(&mut s), want) < 5e-4, "{} vs {want}", volume(&mut s));
// Modified imported faces no longer have an immutable construction certificate.
// The public attachment refuses them; the user can explicitly choose a fixed plane.
let before_fixed = s.doc.to_json();
assert!(s.execute("sketch.create", &json!({"plane":{"face":at(10.0,15.0,20.0)}})).is_err());
assert_eq!(s.doc.to_json(), before_fixed);
run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0), "fixed": true}}));
// Modified imported faces no longer have an immutable construction certificate; the top
// attaches by its exact history name instead.
let top = run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0)}}))["sketch"].as_u64().unwrap();
assert!(matches!(
s.placed_document().sketch_face_attachment(top, &s.model),
doc::FaceAttachmentStatus::Healthy {
target: doc::config::ConfigFaceTarget { certificate: Some(doc::config::FaceCertificate::Lineage), .. },
..
}
));
let c = [x0 + 10.0, y0 + 15.0];
// Sketch coordinates on the face are relative to the model origin projected onto it.
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [c[0] - 4.0, c[1] - 4.0], "p1": [c[0] + 4.0, c[1] + 4.0]}));
+8
View File
@@ -6,6 +6,14 @@
//! the operation's own operand list, which each operation documents. A body made by an
//! operation carries its history (see [`Body::history`]); [`History::substitute`] composes an
//! operation with one that made one of its operands.
//!
//! This is what persistent names and face attachments stand on. The document composes each
//! feature's histories into face names (`F5:side:l1`, `F7:blend:…`, `F4:top@F6.2`, see
//! `solidcraft_doc::history_names`); a name is exact only when every operation on the way
//! reported its history. A flat face with an exact name takes a durable sketch attachment by that
//! name (`FaceCertificate::Lineage`, docs/face-attachments.md), so an operation that records its
//! history here gives its faces attachments with no attachment code of its own. An operation
//! that can't should leave its result without one (`Body::without_history`), never guess.
use std::collections::BTreeSet;
+14 -2
View File
@@ -1225,7 +1225,12 @@ fn sew_sections(
for (r, piece) in crate::extrude(plane, section, levels[first], levels[end])?.into_iter().enumerate() {
remaining_faces =
remaining_faces.checked_sub(piece.face_count()).ok_or_else(|| KernelError::Failed("prism output face budget".into()))?;
pieces.push((piece, lineage::Built::Extruded { section: tagged.region(r), lo: levels[first], hi: levels[end] }));
// Each slab's section, as read there: the same outline, but its walls
// read beside whichever operand is there.
let layers =
(first..end).map(|k| Some((slabs.get(k)?.region(r), *levels.get(k)?, *levels.get(k + 1)?))).collect::<Option<Vec<_>>>();
let layers = layers.ok_or_else(|| KernelError::Failed("missing prism section".into()))?;
pieces.push((piece, lineage::Built::Extruded { layers }));
}
} else {
let (piece, built) = sew_connected_sections(plane, d, &levels[first..=end], &slabs[first..end], tol, coalesce, history)
@@ -1507,6 +1512,13 @@ fn simple_prism_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2
if (u - x).abs() > tol * (1.0 + u) || (u - y).abs() > tol * (1.0 + u) {
return sew_stack(&plane, d, &pa, &pb, tol, &history).map(|r| r.map(Some));
}
// One outline end to end: also slab by slab, so the history follows each operand's
// walls (one longer prism, whose walls are made from both).
if let Some(Ok(body)) = sew_stack(&plane, d, &pa, &pb, tol, &history)
&& body.history().is_some()
{
return Some(Ok(Some(body)));
}
(pa.lo.min(pb.lo), pa.hi.max(pb.hi))
} else {
(pa.lo, pa.hi)
@@ -1518,7 +1530,7 @@ fn simple_prism_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2
};
// Each body over its region.
let mut pieces: Vec<(Body, lineage::Built)> =
bodies.into_iter().enumerate().map(|(r, b)| (b, lineage::Built::Extruded { section: section.region(r), lo, hi })).collect();
bodies.into_iter().enumerate().map(|(r, b)| (b, lineage::Built::Extruded { layers: vec![(section.region(r), lo, hi)] })).collect();
let last = pieces.pop()?;
if !pieces.is_empty() {
// Several pieces: one body of several lumps (the last first).
+52 -25
View File
@@ -59,8 +59,11 @@ pub(super) struct Context<'a> {
/// How one piece of the result was built.
pub(super) enum Built {
/// One body of `crate::extrude` of the section's one region between levels `lo` and `hi`.
Extruded { section: Section, lo: f64, hi: f64 },
/// One body of `crate::extrude` of a section's one region through `layers`: successive
/// slabs (the section as read in each, between its two levels), bottom first, all of one
/// outline. Several where operands stacked end to end share an outline (one wall then lies
/// over each operand's wall in turn, and is made from all of them).
Extruded { layers: Vec<(Section, f64, f64)> },
/// Sewn (`sew_connected_sections`) from these faces, the sewing saying which each face of
/// the piece was built from (`None`: it could not).
Sewn { specs: Vec<Spec>, faces: Option<Vec<usize>> },
@@ -111,10 +114,11 @@ impl Spec {
}
}
/// A face of a piece: the wall over a segment with this tag between two levels, or a cap.
/// A face of an extruded piece: a wall or a cap.
#[derive(Clone, Copy)]
enum Kind {
Wall(Tag, f64, f64),
/// The wall over this segment of the section.
Wall(usize),
Cap(usize),
}
@@ -139,7 +143,7 @@ impl<'a> Context<'a> {
let mut edges = HashMap::new();
for (body, built) in pieces {
let (f, e) = match built {
Built::Extruded { section, lo, hi } => self.extruded(caps, body, section, *lo, *hi)?,
Built::Extruded { layers } => self.extruded(caps, body, layers)?,
Built::Sewn { specs, faces } => self.sewn(caps, body, specs, faces.as_deref()?)?,
Built::Traced => {
let h = body.history()?;
@@ -331,28 +335,35 @@ impl<'a> Context<'a> {
}
}
/// Faces and edges of a piece `crate::extrude` built from a section of one region. Its
/// history says which faces are walls over which segment (numbered over the region's
/// loops in order) and which are its caps; the start cap's wires run over the segments in
/// loop order (as the extrude builds them), so the vertex after each wire edge is where one
/// segment runs into the next, or splits.
/// Faces and edges of a piece `crate::extrude` built from a section of one region through
/// `layers` (see [`Built::Extruded`]). Its history says which faces are walls over which
/// segment (numbered over the region's loops in order) and which are its caps; the start
/// cap's wires run over the segments in loop order (as the extrude builds them), so the
/// vertex after each wire edge is where one segment runs into the next, or splits. A wall
/// through several layers is made from the wall each layer's segment was read beside, and
/// an edge between two walls from the operand edges there in each layer.
#[allow(clippy::type_complexity)]
fn extruded(
&self,
caps: &[Cap],
body: &Body,
section: &Section,
lo: f64,
hi: f64,
layers: &[(Section, f64, f64)],
) -> Option<(Vec<(BTreeSet<FaceOrigin>, bool)>, HashMap<mt::EdgeID, Vec<EdgeOrigin>>)> {
let h = body.history()?;
let tags = section.segments().1;
let tops = [self.cap_origins(caps, section, lo, false), self.cap_origins(caps, section, hi, true)];
let ((first, lo, _), (last, _, hi)) = (layers.first()?, layers.last()?);
let (lo, hi) = (*lo, *hi);
let tags: Vec<Vec<Tag>> = layers.iter().map(|(section, ..)| section.segments().1).collect();
let count = tags.first()?.len();
if tags.iter().any(|t| t.len() != count) {
return None;
}
let tag = |layer: usize, segment: usize| tags.get(layer).and_then(|t| t.get(segment)).copied();
let tops = [self.cap_origins(caps, first, lo, false), self.cap_origins(caps, last, hi, true)];
let kinds: Vec<Kind> = h
.faces
.iter()
.map(|f| match f.origins.as_slice() {
[FaceOrigin::Profile { segment }] => Some(Kind::Wall(*tags.get(*segment)?, lo, hi)),
[FaceOrigin::Profile { segment }] => (*segment < count).then_some(Kind::Wall(*segment)),
[FaceOrigin::Cap { end }] => Some(Kind::Cap(usize::from(*end))),
_ => None,
})
@@ -360,7 +371,14 @@ impl<'a> Context<'a> {
let faces = kinds
.iter()
.map(|k| match *k {
Kind::Wall(t, lo, hi) => Some((std::iter::once(Self::wall(t)?).collect(), self.whole(caps, t, lo, hi))),
Kind::Wall(segment) => {
let walls = (0..layers.len()).map(|layer| Self::wall(tag(layer, segment)?)).collect::<Option<BTreeSet<_>>>()?;
let whole = match layers {
[(_, lo, hi)] => self.whole(caps, tag(0, segment)?, *lo, *hi),
_ => false,
};
Some((walls, whole))
}
Kind::Cap(end) => Some(tops.get(end).filter(|o| !o.is_empty())?.clone()).map(|o| (o, false)),
})
.collect::<Option<Vec<_>>>()?;
@@ -374,7 +392,9 @@ impl<'a> Context<'a> {
})
};
let start = kinds.iter().position(|k| matches!(k, Kind::Cap(0)))?;
let mut junctions: HashMap<mt::VertexID, (Tag, Tag)> = HashMap::new();
// At each vertex of the start cap: the segment running into it, the one running out,
// and whether that is one segment the extrude split there.
let mut junctions: HashMap<mt::VertexID, (usize, usize, bool)> = HashMap::new();
for w in body.solid.face_iter().nth(start)?.absolute_boundaries() {
let es: Vec<mt::Edge> = w.edge_iter().cloned().collect();
let segs: Vec<usize> = es.iter().map(segment_of).collect::<Option<_>>()?;
@@ -383,20 +403,27 @@ impl<'a> Context<'a> {
}
for (k, e) in es.iter().enumerate() {
let (s, next) = (*segs.get(k)?, *segs.get((k + 1) % segs.len())?);
let (t, u) = (*tags.get(s)?, *tags.get(next)?);
// A segment the extrude split in two runs on through the vertex between.
let pair = if s != next || k + 1 == segs.len() { (t, u) } else { (Tag { end: None, ..t }, Tag { start: None, ..t }) };
junctions.insert(e.back().id(), pair);
junctions.insert(e.back().id(), (s, next, s == next && k + 1 != segs.len()));
}
}
let mut origins = HashMap::new();
for (e, fs) in edges.iter().zip(&bounds) {
let [f, g] = fs.as_slice() else { return None };
let o = match (kinds.get(*f)?, kinds.get(*g)?) {
(Kind::Wall(t, ..), Kind::Cap(end)) | (Kind::Cap(end), Kind::Wall(t, ..)) => self.cap_edge(*t, tops.get(*end)?)?,
(Kind::Wall(segment), Kind::Cap(end)) | (Kind::Cap(end), Kind::Wall(segment)) => {
let layer = if *end == 0 { 0 } else { layers.len() - 1 };
self.cap_edge(tag(layer, *segment)?, tops.get(*end)?)?
}
(Kind::Wall(..), Kind::Wall(..)) => {
let (t1, t2) = [e.absolute_front().id(), e.absolute_back().id()].iter().find_map(|v| junctions.get(v))?;
self.junction(*t1, *t2, lo, hi)?
let (s, next, split) = *[e.absolute_front().id(), e.absolute_back().id()].iter().find_map(|v| junctions.get(v))?;
let mut o = BTreeSet::new();
for (layer, (_, lo, hi)) in layers.iter().enumerate() {
let (t, u) = (tag(layer, s)?, tag(layer, next)?);
// A segment the extrude split in two runs on through the vertex between.
let (t, u) = if split { (Tag { end: None, ..t }, Tag { start: None, ..t }) } else { (t, u) };
o.extend(self.junction(t, u, *lo, *hi)?);
}
o.into_iter().collect()
}
_ => return None,
};