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 <[email protected]>
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

@@ -995,7 +995,6 @@ fn a_fillet_or_chamfer_on_one_edge_ending_at_a_cylinder() {
}
#[test]
#[ignore = "fails until #557 is fixed"]
fn a_sketch_on_a_patterned_boss_top() {
let mut c: Vec<Value> = rectangle(json!("XY"), [0., 0.], [60., 20.]).into();
c.extend([
+1 -25
View File
@@ -137,18 +137,12 @@ fn body_names_saved_before_exact_history_keep_their_pieces() {
/// ignored test below that checks its faces; a listed plane whose face attaches fails the main
/// check until it is removed from here.
type Planes = &'static [([f64; 3], f64)];
const NO_ATTACHMENT_YET: [(&str, &str, Option<Planes>, u32); 9] = [
("issue-489-stacked-join", "Body1", Some(&[([1., 0., 0.], 35.), ([-1., 0., 0.], 35.)]), 491),
("issue-489-stacked-join", "Body1", Some(&[([0., 0., 1.], 30.)]), 492),
("m0-revolve-primitives", "Body2", Some(&[([-FRAC_1_SQRT_2, 0., FRAC_1_SQRT_2], -21. * FRAC_1_SQRT_2)]), 493),
("split-body-pieces", "Body1", Some(&[([0., FRAC_1_SQRT_2, FRAC_1_SQRT_2], 38.5 * FRAC_1_SQRT_2), ([0., -1., 0.], -18.)]), 493),
("split-body-pieces", "Body1 (1)", Some(&[([0., 1., 0.], 12.)]), 493),
const NO_ATTACHMENT_YET: [(&str, &str, Option<Planes>, u32); 4] = [
("m0-revolve-primitives", "Body1", None, 494),
("naming-split-pieces", "Body1", None, 494),
("modern-plastic", "Body1", None, 494),
("modern-sheet-metal", "Body1", None, 495),
];
use std::f64::consts::FRAC_1_SQRT_2;
/// The issue a face is listed under, and whether it is listed by its plane (not as every face).
fn known_without_attachment(design: &str, body: &str, normal: [f64; 3], distance: f64) -> Option<(u32, bool)> {
@@ -267,24 +261,6 @@ fn known_faces_attach(issue: u32) {
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
#[test]
#[ignore = "fails until #491 is fixed"]
fn a_side_face_join_merged_from_two_extrudes_takes_an_attached_sketch() {
known_faces_attach(491);
}
#[test]
#[ignore = "fails until #492 is fixed"]
fn a_face_split_into_pieces_by_a_later_feature_takes_an_attached_sketch() {
known_faces_attach(492);
}
#[test]
#[ignore = "fails until #493 is fixed"]
fn cut_walls_and_chamfer_faces_take_an_attached_sketch() {
known_faces_attach(493);
}
#[test]
#[ignore = "fails until #494 is fixed"]
fn faces_after_revolve_offset_face_and_boss_take_an_attached_sketch() {
+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:[email protected]`, 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:[email protected]`), 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:[email protected]`, 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,
};
+7 -7
View File
@@ -263,10 +263,10 @@ make a legacy face sketch there).
| 2 | | A helix edge projected along its axis is a point (expected) | `edge.project` 2 | 1 | oracle/50-sweep-tight-bend `Bend/F3:side:c1#1\|F3:side:c1#2~2` |
Where the face history stands (`crates/kernel/src/history.rs`, [face attachments](face-attachments.md)):
a face takes an attached sketch only when the body carries an exact source certificate for it.
Extrude caps and sides, boxes, shells' inner walls, STEP faces and sheet metal panels have one,
and booleans, fillets and shells pass untouched faces' sources through. Everything that makes or
reshapes faces without recording one (cut walls, holes, chamfers, revolves, lofts, sweeps, drafts,
tapers, splits, intersects, shell rims, pattern and mirror copies, sheet metal rims) leaves faces
that refuse a sketch, and press-pull and extrude-from-face then fall back to a legacy face sketch
that is found again by a point. Together they are the largest group of failures.
a flat face takes an attached sketch when the body carries an exact source certificate for it
(extrude caps and sides, boxes, shells' inner walls, STEP faces, sheet metal panels), or else
when its persistent name follows exact history (a `lineage` certificate): cut walls and floors,
chamfers, pattern and mirror copies, sides a Join merged, and pieces of a split face. What still
refuses a sketch is what makes faces without recording their history (revolves in some cases,
Offset Face, the plastic features, lofts, sweeps, drafts, tapers, shell rims, sheet metal rims):
press-pull and extrude-from-face then fall back to a legacy face sketch found again by a point.
+25 -7
View File
@@ -13,12 +13,28 @@ profile's cap. A profile drawn over a face sketch's projected boundary (a rectan
traced over the whole face, or a strip whose sides lie on the face's edges) is identified by
the drawn curves: where a drawn curve and a projected one cover the same edge, the drawn curve
is the source. Two drawn curves, or two projections, over one edge remain ambiguous. Curved surfaces cannot host a planar sketch through this workflow.
Edited surfaces without retained construction provenance, ambiguous sources, and unsupported
generators require an explicit fixed plane instead. A face that a Join merged from two features' coplanar
faces belongs to neither, so it also needs a fixed plane. The error says why, and the Create
Sketch dialog then offers **Use fixed plane**, which places the sketch on that face's current
plane without following later changes. A nearby or parallel face is never used
to repair a new attachment automatically.
Every other flat face is attached by its exact history name (a `lineage` certificate) when it
has one: the persistent name the kernel's face history gives it, which says what made the face
and follows it through every later feature. That covers a Cut's walls and floor
(`F5:side:l1`, `F5:top`), a chamfer face (`F7:blend:…`), each copy a pattern or mirror makes
(`F4:[email protected]`: the seed's face and the copy's instance), a side wall a Join merged from two
features (named after the first, answering to the other), and the pieces a later feature split
a face into (all pieces of one name on one oriented plane are that face). A lineage face's
plane is its current exact plane, measured in a frame that depends on the plane alone: its
origin where the definition origin projects onto it, its x axis the first of the definition's
X, Y and Z axes not steep to it (`|cos| < 0.6`), projected. The construction certificates
above still come first; a lineage certificate is used where none names the face alone. Copies
carry their seed's construction certificate, so a sketch attached to the seed before it was
patterned keeps resolving to the seed: of the faces carrying one certificate, the producing
feature's own face is the one whose name is not a copy's.
Faces whose history is not exact (Offset Face, the plastic features, sheet metal rims, faces a
feature rebuilt without reporting its history), ambiguous sources, and the pieces of a face
split on purpose with Split Face (each piece is its own face) require an explicit fixed plane
instead. The error says why, and the Create Sketch dialog then offers **Use fixed plane**,
which places the sketch on that face's current plane without following later changes. A
nearby or parallel face is never used to repair a new attachment automatically.
The flat faces of a sheet metal body are the exception: they have no construction
certificate, so a sketch on one attaches to its panel by name (`sheet:<body>:<panel>:<side>`)
@@ -58,7 +74,9 @@ A missing author path also requires explicit repair. Reattachment and creation c
pending positions only on the involved source and author paths. Failed commands restore
those pending positions; undo restores the previous attachment and placement state.
File format 11 introduced these optional attachments, which format 12 also preserves. Existing designs with face references
File format 11 introduced these optional attachments, which format 12 also preserves. Format 16
adds the `lineage` certificate (older readers ask for an update rather than meeting an unknown
kind); anchors saved with construction certificates resolve as before. Existing designs with face references
without an attachment retain their legacy geometry and lookup behavior when opened; they
are not silently upgraded. Redefining such a sketch onto a supported selected face creates
the stronger attachment. Unsupported legacy geometry can instead be retained on an explicit
File diff suppressed because it is too large. Load diff