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:
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([
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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> =
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, ¶ms).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}");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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", ¶ms).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", ¶ms).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();
|
||||
|
||||
@@ -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]}));
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
+263
-263
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user