feat: preserve body history and placed modelling workflows

Record ownership transfers in the timeline so earlier Join/Cut membership,
world placement, machining data and styles survive component moves, edits,
independent copying, undo and reopening. Preserve pending positions in history
snapshots and keep constructor transfers ordered during feature redefinition.

Retain shallow sheet pockets and exact planar intersection islands through
unfold/refold, with depth-aware flat previews and DXF export. Store explicit
3D wire coordinate spaces and migrate known legacy provenance without moving
saved points or analytic fits.

Infer unique eligible cylindrical Emboss targets, align Revolve manipulators
with actual extent geometry, and discard obsolete preview errors after input
correction. Certify constructed and immutable imported face styles using exact
source metadata while preserving legacy face names. Upgrade designs to format10
with bounded validation and geometry-preserving legacy migration.
This commit is contained in:
bherbruck committed 2026-10-11 03:59:12 -04:00
1 parent 0398a813c8
commit c1d819f235
86 files changed
+6011 -488

No files matched your search

+10 -1
View File
@@ -48,6 +48,8 @@ pub struct FaceLook {
/// Strict semantic assignments never transfer to a nearby replacement face.
#[serde(default, skip_serializing_if = "is_false")]
pub strict_name: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub certificate: Option<crate::config::FaceCertificate>,
}
fn is_false(value: &bool) -> bool {
@@ -139,7 +141,13 @@ pub fn material_look(material: &str) -> Option<Look> {
impl Document {
/// Shared definition appearance for a computed occurrence body.
pub fn model_body_look(&self, body: &crate::ModelBody) -> Option<Look> {
self.body_look(body.definition_name(), body.feature)
let name = body.definition_name();
self.appearances
.bodies
.get(name)
.cloned()
.or_else(|| self.appearances.components.get(&self.model_body_component(body)).cloned())
.or_else(|| self.materials.get(name).and_then(|material| material_look(material)))
}
/// Effective body opacity, including paint carried from an imported body.
pub fn model_body_opacity(&self, body: &crate::ModelBody) -> f64 {
@@ -198,6 +206,7 @@ impl Document {
origin_feature,
name: crate::naming::strip_piece(name).into(),
point: f.point,
certificate: f.certificate.clone(),
};
let Ok(indices) = self.configuration_face_indices(b, &target) else { continue };
for index in indices {
+4 -3
View File
@@ -328,7 +328,8 @@ impl FeatureKind {
| FeatureKind::Stitch { .. }
| FeatureKind::Thicken { .. }
| FeatureKind::Import { .. }
| FeatureKind::MeshImport { .. } => {}
| FeatureKind::MeshImport { .. }
| FeatureKind::BodyTransfer { .. } => {}
FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::SurfaceTrim { plane, tool, keep, .. } => {
map_plane_ref(m, plane);
@@ -392,9 +393,9 @@ impl Document {
pub fn body_frames_with_source(&self, st: &crate::ModelState) -> Vec<(String, String, u64, Vec<u64>, Mat)> {
let mut out = Vec::new();
for (comp, path, m) in self.placements() {
for b in st.bodies.iter().filter(|b| self.body_component(&b.name, b.feature) == comp) {
for b in st.bodies.iter().filter(|b| self.model_body_component(b) == comp) {
let name = self.world_body_name(st, &b.name, comp, &path);
let w = match self.body_offsets.get(&b.name) {
let w = match self.model_body_offset(b) {
Some(o) => mat_mul(&m, o),
None => m,
};
+80
View File
@@ -0,0 +1,80 @@
//! Recorded ownership changes are evaluated at their timeline position.
use crate::{DocError, Document, Mat, ModelBody, ModelState, Result};
use solvecraft_geom::Vec3;
use std::collections::BTreeSet;
pub fn valid_transfer_matrix(m: &Mat) -> bool {
let axis = |i: usize| Vec3::new(m[i][0], m[i][1], m[i][2]);
let (x, y, z) = (axis(0), axis(1), axis(2));
m.iter().flatten().all(|v| v.is_finite())
&& m[3][..3].iter().all(|v| v.abs() <= 1e9)
&& [x, y, z].iter().all(|v| (v.dot(*v) - 1.0).abs() < 1e-6)
&& x.dot(y).abs() < 1e-6
&& x.dot(z).abs() < 1e-6
&& y.dot(z).abs() < 1e-6
&& x.cross(y).dot(z) > 0.0
&& [m[0][3], m[1][3], m[2][3]].iter().all(|v| v.abs() < 1e-9)
&& (m[3][3] - 1.0).abs() < 1e-9
}
/// Validate the stored boundary before allocation, loading, or evaluation.
pub fn validate_body_transfer(doc: &Document, bodies: &[String], from: u64, to: u64, transform: &Mat) -> Result<()> {
if bodies.is_empty()
|| bodies.len() > 1000
|| bodies.iter().any(|name| name.trim().is_empty() || name.len() > 512 || name.chars().any(char::is_control))
|| bodies.iter().collect::<BTreeSet<_>>().len() != bodies.len()
|| !valid_transfer_matrix(transform)
|| [from, to].iter().any(|id| *id != 0 && !doc.components.iter().any(|c| c.id == *id))
{
return Err(DocError::Invalid("invalid body transfer boundary".into()));
}
Ok(())
}
pub(crate) fn evaluate(doc: &Document, st: &mut ModelState, bodies: &[String], from: u64, to: u64, transform: Mat) -> Result<()> {
validate_body_transfer(doc, bodies, from, to, &transform)?;
let mut next = st.clone();
for name in bodies {
let old = st.body(name).ok_or_else(|| DocError::Unknown(format!("transferred body `{name}`")))?;
if doc.model_body_component(old) != from {
return Err(DocError::Invalid(format!("body `{name}` no longer belongs to transfer source component {from}")));
}
let shape = if crate::is_identity(&transform) { old.body.clone() } else { solvecraft_kernel::transform_matrix(&old.body, transform)? };
let mut body = ModelBody::new(old.name.clone(), shape, old.feature);
body.component = to;
body.transferred = true;
body.source_name = old.source_name.clone();
body.source_transform = crate::mat_mul(&transform, &old.source_transform);
body.imported = old.imported.clone();
body.names = crate::naming::NameCell::known(crate::naming::face_names(old));
let slot = next.bodies.iter_mut().find(|b| b.name == *name).ok_or_else(|| DocError::Unknown(format!("transferred body `{name}`")))?;
*slot = body;
}
crate::body_transfer_sheet::remap_sheet_metadata(&mut next, bodies, &transform)?;
*st = next;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_transfer_constructor_preserves_document_and_allocator() {
let mut doc = Document::default();
let before = doc.to_json();
let mut scaled = crate::IDENTITY;
scaled[0][0] = 2.0;
for (bodies, from, transform) in [
(vec!["Block".into()], 42, crate::IDENTITY),
(vec!["Block".into(), "Block".into()], 0, crate::IDENTITY),
(vec![String::new()], 0, crate::IDENTITY),
(vec![" ".into()], 0, crate::IDENTITY),
(vec!["bad\nname".into()], 0, crate::IDENTITY),
(vec!["Block".into()], 0, scaled),
] {
assert!(doc.add_feature(crate::FeatureKind::BodyTransfer { bodies, from, to: 0, transform }, None).is_err());
assert_eq!(doc.to_json(), before);
}
}
}
+54
View File
@@ -0,0 +1,54 @@
//! World-frame metadata at a timeline body ownership boundary.
use solvecraft_geom::Plane;
use crate::{DocError, Mat, ModelState, Result, apply_point, apply_vector};
pub(crate) fn remap_sheet_metadata(st: &mut ModelState, names: &[String], transform: &Mat) -> Result<()> {
if names.len() > 1000 || st.sheets.len().saturating_add(st.threads.len()) > 100_000 || !transform.iter().flatten().all(|v| v.is_finite()) {
return Err(DocError::Invalid("body transfer metadata exceeds its finite processing budget".into()));
}
let names: std::collections::BTreeSet<&str> = names.iter().map(String::as_str).collect();
if !st.sheets.iter().any(|s| names.contains(s.body.as_str())) && !st.threads.iter().any(|t| names.contains(t.body.as_str())) {
return Ok(());
}
if !crate::valid_transfer_matrix(transform) {
return Err(DocError::Invalid("sheet and thread transfer requires a rigid placement".into()));
}
let frames: Vec<_> = st
.sheets
.iter()
.enumerate()
.filter(|(_, s)| names.contains(s.body.as_str()))
.map(|(i, s)| {
let frame = Plane::new(apply_point(transform, s.frame.origin), apply_vector(transform, s.frame.x), apply_vector(transform, s.frame.y))
.ok_or_else(|| DocError::Invalid("invalid transferred sheet frame".into()))?;
Ok((i, frame))
})
.collect::<Result<_>>()?;
let threads: Vec<_> = st
.threads
.iter()
.enumerate()
.filter(|(_, t)| names.contains(t.body.as_str()))
.map(|(i, t)| {
let point = apply_point(transform, t.axis_point);
let axis = apply_vector(transform, t.axis).normalized().ok_or_else(|| DocError::Invalid("invalid transferred thread axis".into()))?;
if !point.is_finite() {
return Err(DocError::Invalid("invalid transferred thread placement".into()));
}
Ok((i, point, axis))
})
.collect::<Result<_>>()?;
for (i, frame) in frames {
if let Some(s) = st.sheets.get_mut(i) {
s.frame = frame;
}
}
for (i, point, axis) in threads {
if let Some(t) = st.threads.get_mut(i) {
t.axis_point = point;
t.axis = axis;
}
}
Ok(())
}
+2 -1
View File
@@ -304,7 +304,8 @@ impl crate::Feature {
| K::Align { bodies: list, .. }
| K::Remove { bodies: list }
| K::Stitch { bodies: list, .. }
| K::Move { bodies: list, .. } => {
| K::Move { bodies: list, .. }
| K::BodyTransfer { bodies: list, .. } => {
for n in list {
mapped_name(n, bodies);
}
+4
View File
@@ -12,6 +12,9 @@ pub use rules::*;
#[path = "config_styles.rs"]
mod styles;
pub use styles::*;
#[path = "config_certificates.rs"]
mod certificates;
pub use certificates::*;
#[path = "config_clone.rs"]
mod clone;
pub use clone::*;
@@ -235,6 +238,7 @@ impl Document {
look,
name: Some(target.name.clone()),
strict_name: true,
certificate: target.certificate.clone(),
});
}
}
+232
View File
@@ -0,0 +1,232 @@
//! Certificates are producer identities, never a nearest-surface selection.
use crate::{DocError, Document, FeatureKind, ModelBody, Result, SolvedSketch};
use serde::{Deserialize, Serialize};
use solvecraft_geom::Seg2;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum FaceCertificate {
ExtrudeSide { sketch: u64, curve: String },
Imported { payload_sha256: String, item: u64, face: u64 },
}
impl FaceCertificate {
pub fn remap_features(&mut self, features: &std::collections::BTreeMap<u64, u64>) {
if let Self::ExtrudeSide { sketch, .. } = self
&& let Some(mapped) = features.get(sketch)
{
*sketch = *mapped;
}
}
pub fn validate(&self) -> Result<()> {
let valid = match self {
Self::ExtrudeSide { sketch, curve } => *sketch != 0 && !curve.is_empty() && curve.len() <= 128,
Self::Imported { payload_sha256, item, face } => {
*item != 0
&& *face != 0
&& payload_sha256.len() == 64
&& payload_sha256.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
};
if valid { Ok(()) } else { Err(DocError::Invalid("invalid face source certificate".into())) }
}
}
pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()> {
if value.is_null() {
return Ok(());
}
let fields = value.as_object().ok_or_else(|| DocError::Invalid("face certificate must be an object".into()))?;
let nonzero = |field: &str| fields.get(field).and_then(serde_json::Value::as_u64).is_some_and(|id| id != 0);
let valid = match fields.get("kind").and_then(serde_json::Value::as_str) {
Some("extrude_side") => {
fields.len() == 3
&& fields.keys().all(|key| ["kind", "sketch", "curve"].contains(&key.as_str()))
&& nonzero("sketch")
&& fields.get("curve").and_then(serde_json::Value::as_str).is_some_and(|curve| !curve.is_empty() && curve.len() <= 128)
}
Some("imported") => {
fields.len() == 4
&& fields.keys().all(|key| ["kind", "payload_sha256", "item", "face"].contains(&key.as_str()))
&& nonzero("item")
&& nonzero("face")
&& fields
.get("payload_sha256")
.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)))
}
_ => false,
};
if valid { Ok(()) } else { Err(DocError::Invalid("malformed or oversized face source certificate".into())) }
}
/// Match a generated segment to one exact analytic sketch curve. Coincident
/// candidates are ambiguous and never resolved by distance or candidate order.
pub(crate) fn exact_sketch_curve(sketch: &SolvedSketch, generated: &Seg2) -> Option<String> {
if sketch.sketch.curves.len() > 2000 {
return None;
}
let mut found = None;
for (index, curve) in sketch.sketch.curves.iter().enumerate() {
if curve.construction
|| !matches!(
curve.kind,
solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Arc { .. } | solvecraft_sketch::CurveKind::Circle { .. }
)
{
continue;
}
let matches = if let solvecraft_sketch::CurveKind::Circle { c, r } = curve.kind {
sketch
.sketch
.point(c)
.is_some_and(|center| exact_segment_contains(&Seg2::Arc { center, radius: r, start: 0., sweep: std::f64::consts::TAU }, generated))
} else {
sketch.sketch.segs(index).iter().any(|original| exact_segment_contains(original, generated))
};
if matches {
if found.is_some() {
return None;
}
found = Some(curve.id.clone());
}
}
found
}
fn exact_segment_contains(original: &Seg2, generated: &Seg2) -> bool {
match (original, generated) {
(Seg2::Line { a, b }, Seg2::Line { a: c, b: d }) => {
let direction = *b - *a;
let scale = direction.len().max((*d - *c).len()).max(1.);
let interval = |point: solvecraft_geom::Vec2| {
let t = (point - *a).dot(direction) / direction.len2();
(-1e-10..=1. + 1e-10).contains(&t)
};
direction.len() > 1e-12
&& direction.cross(*c - *a).abs() <= 1e-10 * scale * scale
&& direction.cross(*d - *a).abs() <= 1e-10 * scale * scale
&& interval(*c)
&& interval(*d)
}
(Seg2::Arc { center: a, radius: ar, start: os, sweep: ow }, Seg2::Arc { center: b, radius: br, start: gs, sweep: gw }) => {
let tau = std::f64::consts::TAU;
let tolerance = 1e-10;
let length = ow.abs();
let generated_length = gw.abs();
if a.dist(*b) > 1e-10 * ar.max(*br).max(1.)
|| (ar - br).abs() > 1e-10 * ar.max(*br).max(1.)
|| length > tau + tolerance
|| generated_length > length + tolerance
{
return false;
}
if length >= tau - tolerance {
return true;
}
let original_low = if *ow >= 0. { *os } else { os + ow };
let generated_low = if *gw >= 0. { *gs } else { gs + gw };
let offset = (generated_low - original_low).rem_euclid(tau);
let offset = if offset > tau - tolerance { 0. } else { offset };
offset + generated_length <= length + tolerance
}
_ => false,
}
}
impl Document {
pub fn configuration_face_target(&self, body: &ModelBody, index: usize, point: solvecraft_geom::Vec3) -> Result<crate::config::ConfigFaceTarget> {
let names = crate::naming::face_names(body);
let mut name = names
.get(index)
.map(|name| crate::naming::strip_piece(name).to_string())
.ok_or_else(|| DocError::Unknown("face source name missing".into()))?;
let origin = match body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) {
Some(solvecraft_kernel::FaceSource::Imported { producer: Some(producer), .. })
| Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. }) => *producer,
_ => name
.strip_prefix('F')
.and_then(|name| name.split_once(':'))
.and_then(|(id, _)| id.parse().ok())
.ok_or_else(|| DocError::Invalid("face source producer missing".into()))?,
};
let certificate = self.configuration_face_certificate(body, index)?;
if let Some(FaceCertificate::ExtrudeSide { curve, .. }) = &certificate {
name = format!("F{origin}:side:{curve}");
} else if !name.starts_with(&format!("F{origin}:")) {
name = format!("F{origin}:face:{index}");
}
let inverse = crate::mat_inverse(&body.source_transform).ok_or_else(|| DocError::Invalid("invalid definition placement".into()))?;
Ok(crate::config::ConfigFaceTarget {
body: body.definition_name().into(),
origin_feature: origin,
name,
point: crate::apply_point(&inverse, point),
certificate,
})
}
pub fn configuration_face_certificate(&self, body: &ModelBody, index: usize) -> Result<Option<FaceCertificate>> {
let Some(source) = body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) else {
return Ok(None);
};
if let solvecraft_kernel::FaceSource::Imported { item, face, payload_sha256: Some(digest), producer: Some(producer) } = source {
if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Import { .. })) {
return Err(DocError::Invalid("import source producer missing".into()));
}
let certificate = FaceCertificate::Imported { payload_sha256: digest.clone(), item: *item, face: *face };
certificate.validate()?;
return Ok(Some(certificate));
}
let certificate = match source {
solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch, curve } => {
if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Extrude { .. })) {
return Err(DocError::Invalid("side source producer missing".into()));
}
FaceCertificate::ExtrudeSide { sketch: *sketch, curve: curve.clone() }
}
_ => return Err(DocError::Invalid("face source does not match producer".into())),
};
certificate.validate()?;
Ok(Some(certificate))
}
}
pub(crate) fn payload_digest(payload: &str) -> String {
use sha2::{Digest, Sha256};
format!("{:x}", Sha256::digest(payload.as_bytes()))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn certificate_budgets_apply_before_saved_target_and_paint_decode() {
for certificate in [
json!({"kind":"extrude_side","sketch":1,"curve":"x".repeat(1_000_000)}),
json!({"kind":"imported","payload_sha256":"0".repeat(64),"item":0,"face":1}),
json!({"kind":"imported","payload_sha256":"g".repeat(64),"item":1,"face":1}),
json!({"kind":"extrude_side","sketch":1,"curve":"c1","extra":[[[[[]]]]]}),
] {
assert!(validate_certificate_json(&certificate).is_err());
let target = json!({"configs":{"columns":[{"face_appearance":{"body":"B","origin_feature":1,"name":"F1:side:c1","point":[0,0,0],"certificate":certificate}}]}});
assert!(crate::config::validate_styles_json(&target).is_err());
let paint = json!({"appearances":{"faces":[{"certificate":certificate}]}});
assert!(crate::config::validate_styles_json(&paint).is_err());
}
assert!(validate_certificate_json(&json!({"kind":"imported","payload_sha256":"a".repeat(64),"item":1,"face":2})).is_ok());
assert!(validate_certificate_json(&json!({"kind":"extrude_side","sketch":1,"curve":"c1"})).is_ok());
}
#[test]
fn analytic_arc_interval_refuses_unsampled_gaps_and_accepts_wrap_reversal() {
let radians = |degrees: f64| degrees.to_radians();
let arc = |start, sweep| Seg2::Arc { center: solvecraft_geom::Vec2::default(), radius: 10., start: radians(start), sweep: radians(sweep) };
assert!(!exact_segment_contains(&arc(10., 350.), &arc(20., 360.)), "five point probes missed the missing ten-degree gap");
assert!(!exact_segment_contains(&arc(10., 350.), &arc(20., -360.)));
assert!(exact_segment_contains(&arc(350., 40.), &arc(355., 20.)));
assert!(exact_segment_contains(&arc(350., 40.), &arc(15., -20.)));
assert!(exact_segment_contains(&arc(30., -40.), &arc(15., -20.)));
assert!(!exact_segment_contains(&arc(350., 40.), &arc(345., 20.)));
assert!(exact_segment_contains(&arc(0., 360.), &arc(20., -360.)));
}
}
+5
View File
@@ -75,6 +75,9 @@ impl ConfigTable {
.map(|role| format!("F{origin}:{role}"))
.unwrap_or_else(|| target.name.clone());
target.origin_feature = *origin;
if let Some(certificate) = &mut target.certificate {
certificate.remap_features(&maps.features);
}
Column::FaceAppearance(target)
})
}
@@ -140,6 +143,7 @@ mod tests {
origin_feature: 3,
name: "F3:+z".into(),
point: solvecraft_geom::Vec3::new(1., 1., 2.),
certificate: None,
}),
],
rows: vec![ConfigRow {
@@ -196,6 +200,7 @@ mod tests {
origin_feature: 3,
name: "F3:+z".into(),
point: solvecraft_geom::Vec3::ZERO,
certificate: None,
}),
],
rows: vec![ConfigRow { name: "Default".into(), cells: vec!["2 mm".into(), String::new()] }],
+79 -12
View File
@@ -13,6 +13,8 @@ pub struct ConfigFaceTarget {
pub name: String,
/// Definition-frame point for diagnostics and rendering; never a resolver fallback.
pub point: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub certificate: Option<super::FaceCertificate>,
}
impl ConfigFaceTarget {
pub fn key(&self) -> String {
@@ -23,6 +25,9 @@ impl ConfigFaceTarget {
face.body == self.body && face.name.as_ref().is_some_and(|name| crate::naming::strip_piece(name) == self.name)
}
pub fn validate(&self) -> Result<()> {
if let Some(certificate) = &self.certificate {
certificate.validate()?;
}
if self.body.is_empty()
|| self.body.len() > 128
|| self.name.is_empty()
@@ -36,6 +41,25 @@ impl ConfigFaceTarget {
}
}
pub fn validate_face_target_json(target: &Value) -> Result<()> {
let fields = target.as_object().ok_or_else(|| DocError::Invalid("face target must be an object".into()))?;
if !(4..=5).contains(&fields.len())
|| fields.keys().any(|key| !["body", "origin_feature", "name", "point", "certificate"].contains(&key.as_str()))
|| fields.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128)
|| fields.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512)
|| fields.get("origin_feature").and_then(Value::as_u64).is_none_or(|id| id == 0)
|| fields.get("point").and_then(Value::as_array).is_none_or(|point| {
point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9))
})
{
return Err(DocError::Invalid("invalid or oversized configuration face target".into()));
}
if let Some(certificate) = fields.get("certificate") {
super::validate_certificate_json(certificate)?;
}
Ok(())
}
/// Budget malformed style nodes before decoding their typed representation.
pub fn validate_styles_json(value: &Value) -> Result<()> {
if let Some(columns) = value.get("configs").and_then(|v| v.get("columns")).and_then(Value::as_array) {
@@ -44,18 +68,7 @@ pub fn validate_styles_json(value: &Value) -> Result<()> {
}
for column in columns {
if let Some(target) = column.get("face_appearance") {
let fields = target.as_object().ok_or_else(|| DocError::Invalid("face target must be an object".into()))?;
if fields.len() != 4
|| fields.keys().any(|key| !["body", "origin_feature", "name", "point"].contains(&key.as_str()))
|| fields.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128)
|| fields.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512)
|| fields.get("origin_feature").and_then(Value::as_u64).is_none_or(|id| id == 0)
|| fields.get("point").and_then(Value::as_array).is_none_or(|point| {
point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9))
})
{
return Err(DocError::Invalid("invalid or oversized configuration face target".into()));
}
validate_face_target_json(target)?;
}
if let Some(id) = column.get("component_appearance")
&& id.as_u64().is_none_or(|id| id == 0)
@@ -64,6 +77,23 @@ pub fn validate_styles_json(value: &Value) -> Result<()> {
}
}
}
if let Some(faces) = value.get("appearances").and_then(|value| value.get("faces")).and_then(Value::as_array) {
for face in faces {
if let Some(certificate) = face.get("certificate") {
super::validate_certificate_json(certificate)?;
if !certificate.is_null()
&& (face.get("strict_name").and_then(Value::as_bool) != Some(true)
|| face.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128)
|| face.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512)
|| face.get("point").and_then(Value::as_array).is_none_or(|point| {
point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9))
}))
{
return Err(DocError::Invalid("invalid certified face appearance".into()));
}
}
}
}
Ok(())
}
@@ -77,6 +107,17 @@ impl Document {
return Err(DocError::Invalid("configure a face lineage, not a numbered split piece".into()));
}
let feature = self.feature(target.origin_feature).ok_or_else(|| DocError::Unknown("face origin feature is gone".into()))?;
if let Some(certificate) = &target.certificate {
let valid = match (certificate, &feature.kind) {
(super::FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => {
sketch == producer_sketch && extent.taper.is_none() && role == format!("side:{curve}")
&& 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, solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Circle { .. } | solvecraft_sketch::CurveKind::Arc { .. }))))
}
(super::FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => role.strip_prefix("face:").is_some_and(|index| index.parse::<usize>().is_ok()),
_ => false,
};
return if valid { Ok(()) } else { Err(DocError::Invalid("face certificate no longer matches its producer".into())) };
}
let valid = match &feature.kind {
FeatureKind::Box { .. } => ["+x", "-x", "+y", "-y", "+z", "-z"].contains(&role),
FeatureKind::Cylinder { .. } => ["top", "bottom", "side"].contains(&role),
@@ -108,12 +149,38 @@ 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::ExtrudeSide { sketch, curve }) = &target.certificate {
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
matches!(source, Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch: current_sketch, curve: current_curve }) if *producer == target.origin_feature && current_sketch == sketch && current_curve == curve).then_some(index)
}).collect();
if indices.is_empty() {
return Err(DocError::Unknown("constructed side certificate no longer exists".into()));
}
return Ok(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)| {
matches!(source, Some(solvecraft_kernel::FaceSource::Imported { item: current_item, face: current_face, payload_sha256: Some(digest), producer: Some(producer) }) if current_item == item && current_face == face && digest == payload_sha256 && *producer == target.origin_feature).then_some(index)
}).collect();
if indices.is_empty() {
return Err(DocError::Unknown("immutable import face certificate no longer exists".into()));
}
return Ok(indices);
}
let names = crate::naming::face_names(body);
let indices: Vec<usize> =
names.iter().enumerate().filter(|(_, name)| crate::naming::strip_piece(name) == target.name).map(|(index, _)| index).collect();
if indices.is_empty() {
return Err(DocError::Unknown(format!("configured face `{}` on `{}` no longer exists", target.name, target.body)));
}
if let Some(certificate) = &target.certificate {
for index in &indices {
if self.configuration_face_certificate(body, *index)?.as_ref() != Some(certificate) {
return Err(DocError::Invalid("face pieces do not share the exact certified source".into()));
}
}
return Ok(indices);
}
if target.name.ends_with(":side") {
if indices.len() != 1 {
return Err(DocError::Invalid("split curved face lineage is not yet certified".into()));
+52 -8
View File
@@ -174,8 +174,8 @@ pub struct EmbossFace {
impl EmbossFace {
/// The selected sketch placement, shared by evaluation and interactive guides.
pub fn sketch_frame(&self, doc: &Document, author: u64, sketch_owner: u64, body_feature: u64) -> Result<crate::Mat> {
let owner = doc.body_component(&self.body, body_feature);
pub fn sketch_frame(&self, doc: &Document, author: u64, sketch_owner: u64, body: &crate::ModelBody) -> Result<crate::Mat> {
let owner = doc.model_body_component(body);
if sketch_owner == owner {
return Self::component_frame(doc, owner, &self.path);
}
@@ -202,10 +202,10 @@ impl EmbossFace {
}
/// Resolve this exact placement without expanding every assembly instance.
pub fn body_frame(&self, doc: &Document, feature: u64) -> Result<crate::Mat> {
let owner = doc.body_component(&self.body, feature);
pub fn body_frame(&self, doc: &Document, body: &crate::ModelBody) -> Result<crate::Mat> {
let owner = doc.model_body_component(body);
let mut frame = Self::component_frame(doc, owner, &self.path)?;
if let Some(offset) = doc.body_offsets.get(&self.body) {
if let Some(offset) = doc.model_body_offset(body) {
frame = crate::mat_mul(&frame, offset);
}
Ok(frame)
@@ -244,6 +244,14 @@ impl EmbossFace {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FeatureKind {
/// A recorded timeline ownership boundary. Geometry is converted from its
/// current local frame into the destination component frame exactly once.
BodyTransfer {
bodies: Vec<String>,
from: u64,
to: u64,
transform: crate::Mat,
},
Sketch {
plane: PlaneRef,
sketch: Sketch,
@@ -1121,7 +1129,8 @@ impl FeatureKind {
| K::SurfaceTrim { .. }
| K::Move { .. }
| K::Import { .. }
| K::MeshImport { .. } => Vec::new(),
| K::MeshImport { .. }
| K::BodyTransfer { .. } => Vec::new(),
}
}
@@ -1146,6 +1155,7 @@ impl FeatureKind {
/// Fusion-like type name (timeline tooltip, `inspect`).
pub fn type_name(&self) -> &'static str {
match self {
FeatureKind::BodyTransfer { .. } => "BodyTransferFeature",
FeatureKind::Sketch { .. } => "Sketch",
FeatureKind::Extrude { .. } => "ExtrudeFeature",
FeatureKind::Revolve { .. } => "RevolveFeature",
@@ -1210,6 +1220,7 @@ impl FeatureKind {
/// Default name prefix (`Extrude` → `Extrude1`).
pub fn base_name(&self) -> &'static str {
match self {
FeatureKind::BodyTransfer { .. } => "BodyTransfer",
FeatureKind::Sketch { .. } => "Sketch",
FeatureKind::Extrude { .. } => "Extrude",
FeatureKind::Revolve { .. } => "Revolve",
@@ -1465,7 +1476,7 @@ impl FeatureKind {
FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v),
FeatureKind::SurfaceExtend { distance, .. } => v.push(distance),
FeatureKind::Patch { .. } => {}
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => {}
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } | FeatureKind::BodyTransfer { .. } => {}
}
v
}
@@ -1701,9 +1712,36 @@ impl Document {
/// The component a body belongs to: where it was moved, else its feature's.
pub fn body_component(&self, body: &str, feature: u64) -> u64 {
self.features
.iter()
.take(self.marker.unwrap_or(usize::MAX))
.rev()
.find_map(|f| {
if f.suppressed {
return None;
}
match &f.kind {
FeatureKind::BodyTransfer { bodies, to, .. } if bodies.iter().any(|b| b == body) => Some(*to),
_ => None,
}
})
.unwrap_or_else(|| self.legacy_body_component(body, feature))
}
pub fn legacy_body_component(&self, body: &str, feature: u64) -> u64 {
self.body_components.get(body).copied().unwrap_or_else(|| self.feature(feature).map(|f| f.component).unwrap_or(0))
}
/// Ownership in this evaluated timeline state, rather than a later transfer.
pub fn model_body_component(&self, body: &crate::ModelBody) -> u64 {
if body.transferred || body.source_name.is_some() { body.component } else { self.legacy_body_component(&body.name, body.feature) }
}
/// Recorded transfers already converted geometry into the new local frame.
pub fn model_body_offset(&self, body: &crate::ModelBody) -> Option<&crate::Mat> {
if body.transferred { None } else { self.body_offsets.get(&body.name) }
}
/// Add a component under `parent` and return its id.
pub fn add_component(&mut self, name: Option<&str>, parent: u64) -> Result<u64> {
if parent != 0 && !self.components.iter().any(|c| c.id == parent) {
@@ -1784,6 +1822,12 @@ impl Document {
if self.features.len() >= MAX_FEATURES {
return Err(DocError::Invalid("too many features".into()));
}
let component = if let FeatureKind::BodyTransfer { bodies, from, to, transform } = &kind {
crate::validate_body_transfer(self, bodies, *from, *to, transform)?;
*to
} else {
0
};
let id = self.next_id.max(1);
self.next_id = id + 1;
let name = match name {
@@ -1796,7 +1840,7 @@ impl Document {
suppressed: false,
body_names: Vec::new(),
param_names: Vec::new(),
component: 0,
component,
edge_refs: Vec::new(),
edge_names: Vec::new(),
face_names: Vec::new(),
+270 -48
View File
@@ -33,6 +33,8 @@ pub struct ModelBody {
pub(crate) names: crate::naming::NameCell,
/// The component the body belongs to (set after each feature).
pub component: u64,
/// A timeline transfer has converted this body into its evaluated owner's frame.
pub transferred: bool,
/// The look it was imported with, carried through later features.
pub imported: Option<Arc<crate::appearance::ImportedLook>>,
}
@@ -49,6 +51,7 @@ impl ModelBody {
key: 0,
names: Default::default(),
component: 0,
transferred: false,
imported: None,
}
}
@@ -274,6 +277,8 @@ impl ModelState {
b.name.hash(&mut h);
b.key.hash(&mut h);
b.feature.hash(&mut h);
b.component.hash(&mut h);
b.transferred.hash(&mut h);
}
for s in &self.sketches {
s.feature.hash(&mut h);
@@ -292,6 +297,13 @@ impl ModelState {
let depth = before.bodies.iter().filter_map(|b| b.names.origin.as_ref().map(|o| o.depth)).max().unwrap_or(0) + 1;
let origin = Arc::new(crate::naming::NamingOrigin { feature: f.clone(), before: before.clone(), depth, mats });
for b in &mut self.bodies {
if !matches!(f.kind, FeatureKind::BodyTransfer { .. })
&& let Some(old) = before.bodies.iter().find(|old| old.name == b.name && old.transferred)
{
b.transferred = true;
b.component = old.component;
b.source_transform = old.source_transform;
}
let kept = before.bodies.iter().find(|x| x.name == b.name && x.body.same(&b.body));
match kept {
Some(x) => {
@@ -302,7 +314,9 @@ impl ModelState {
let mut h = std::collections::hash_map::DefaultHasher::new();
(key, &b.name).hash(&mut h);
b.key = h.finish();
b.names = crate::naming::NameCell::new(Some(origin.clone()));
if !matches!(f.kind, FeatureKind::BodyTransfer { .. }) {
b.names = crate::naming::NameCell::new(Some(origin.clone()));
}
// Faces split on purpose stay split through later features (for export).
if b.body.split_lines().is_none()
&& let Some(lines) = before.bodies.iter().find(|x| x.name == b.name).and_then(|x| x.body.split_lines())
@@ -494,6 +508,26 @@ fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelStat
/// Where components are placed, for a sketch that sees other components' geometry (on a face,
/// or with projections): it is resolved in its own component's view of them.
fn placement_digest(doc: &Document, f: &Feature) -> u64 {
let path = match &f.kind {
FeatureKind::Sweep { path_sketch, .. }
| FeatureKind::Pipe { path_sketch, .. }
| FeatureKind::Pattern { pattern: crate::PatternKind::Path { path_sketch, .. }, .. } => Some(*path_sketch),
_ => None,
};
if let Some(path) = path
&& doc.sketch(path).is_ok_and(|sketch| !sketch.wires.is_empty())
{
let mut hash = std::collections::hash_map::DefaultHasher::new();
let owner = doc.feature(path).map(|feature| feature.component).unwrap_or(0);
for frame in [doc.component_transform(f.component), doc.component_transform(owner)] {
frame.iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
}
if let FeatureKind::Sweep { sketch, .. } = &f.kind {
let owner = doc.feature(*sketch).map(|feature| feature.component).unwrap_or(0);
doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
}
return hash.finish();
}
let FeatureKind::Sketch { plane, sketch } = &f.kind else { return 0 };
if doc.occurrences.is_empty() || (!matches!(plane, PlaneRef::Face { .. }) && sketch.links.is_empty()) {
return 0;
@@ -673,7 +707,7 @@ impl Model {
Ok(()) => {
// Copies are named after their sources through the instance transforms.
let mats = match &f.kind {
FeatureKind::Pattern { pattern, .. } => pattern_transforms(&vals, &state, pattern).unwrap_or_default(),
FeatureKind::Pattern { pattern, .. } => pattern_transforms(doc, &vals, &state, f.component, pattern).unwrap_or_default(),
FeatureKind::Mirror { plane, .. } => {
doc.resolve_plane_in(&vals, plane, 0, Some(&state)).map(|pl| vec![mirror_matrix(&pl)]).unwrap_or_default()
}
@@ -681,7 +715,9 @@ impl Model {
};
next.stamp(&state, f, key, mats);
for b in &mut next.bodies {
b.component = doc.body_component(&b.name, b.feature);
if !b.transferred {
b.component = doc.legacy_body_component(&b.name, b.feature);
}
}
state = Arc::new(next);
None
@@ -723,7 +759,7 @@ fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
Document::eval_in(vals, e, k)
}
fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region2>> {
pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region2>> {
let ps = &ss.profiles;
if ps.is_empty() {
return Err(DocError::Invalid(format!("{} has no closed profiles", ss.name)));
@@ -794,7 +830,55 @@ fn copy_body_name(state: &ModelState, f: &Feature, k: usize, source: &str) -> St
}
/// Add tool bodies to the model according to the operation.
fn sheet_boolean_unchanged(body: &Body, tools: &[Body], op: Operation) -> Result<()> {
if sheet_boolean_changes(body, tools, op)? {
return Err(DocError::Invalid("this sheet machining cannot be preserved in its flat material: use a parallel, untapered extrude pocket/cut, or unfold before intersecting; unsupported 3D sheet machining is refused to prevent material restoration".into()));
}
Ok(())
}
fn sheet_boolean_changes(body: &Body, tools: &[Body], op: Operation) -> Result<bool> {
let united;
let tools: &[Body] = if op == Operation::Intersect {
united = intersection_tool_union(tools)?;
&united
} else {
tools
};
for tool in tools {
let test = if op == Operation::Cut { BoolOp::Intersect } else { BoolOp::Cut };
if kernel::boolean(body, tool, test)?.is_some() {
return Ok(true);
}
}
Ok(false)
}
/// Selected profiles form one machining tool, including disconnected tool material.
/// Balanced unions avoid repeatedly rebuilding a growing aggregate boundary.
fn intersection_tool_union(tools: &[Body]) -> Result<Vec<Body>> {
if tools.len() > 1000 || tools.iter().fold(0usize, |n, b| n.saturating_add(b.face_count())) > 16_384 {
return Err(DocError::Invalid("intersection tools exceed the bounded geometry budget".into()));
}
let mut groups = tools.to_vec();
while groups.len() > 1 {
let mut next = Vec::new();
let mut iter = groups.into_iter();
while let Some(a) = iter.next() {
match iter.next() {
Some(b) => {
next.push(kernel::boolean(&a, &b, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("intersection tool union is empty".into()))?)
}
None => next.push(a),
}
}
groups = next;
}
Ok(groups)
}
fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation, targets: &[String]) -> Result<()> {
let tools = if op == Operation::Intersect { intersection_tool_union(&tools)? } else { tools };
// Participants: the feature's own targets, else its explicit participants, else the bodies
// of its component (and what it made itself): other components are left alone.
let targets: &[String] = if targets.is_empty() { &f.participants } else { targets };
@@ -820,6 +904,13 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation
}
return Ok(());
}
if matches!(op, Operation::Cut | Operation::Intersect) {
for &i in &target_idx {
if let Some(mb) = state.bodies.get(i).filter(|b| state.sheets.iter().any(|s| s.body == b.name)) {
sheet_boolean_unchanged(&mb.body, &tools, op)?;
}
}
}
match op {
Operation::Join => {
let Some(&first) = target_idx.first() else { return Ok(()) };
@@ -859,6 +950,8 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation
i += 1;
keep
});
let live: std::collections::BTreeSet<&str> = state.bodies.iter().map(|b| b.name.as_str()).collect();
state.threads.retain(|t| live.contains(t.body.as_str()));
// A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)").
let mut split: Vec<ModelBody> = Vec::new();
for mb in std::mem::take(&mut state.bodies) {
@@ -1382,8 +1475,57 @@ fn hole_tools(
Ok(tools)
}
/// The angular interval used by both the solid and its interactive extent handles.
#[derive(Clone, Copy, Debug)]
pub struct RevolveExtent {
pub start: f64,
pub sweep: f64,
pub rotation_axis: Vec3,
pub display_start: f64,
/// A curved target trims the swept solid and has no single angular boundary.
pub curved_to: bool,
}
pub fn revolve_extent(
st: &ModelState,
plane: &Plane,
regions: &[Region2],
(origin, axis): (Vec3, Vec3),
(angle, angle2): (f64, Option<f64>),
symmetric: bool,
to: Option<Vec3>,
) -> Result<RevolveExtent> {
let (start, sweep) = match (to, angle2, symmetric) {
(Some(point), _, _) => {
let points: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
let centre = plane.to_world(points.iter().fold(Vec2::default(), |sum, point| sum + *point) * (1.0 / points.len().max(1) as f64));
let flat = |point: Vec3| {
let v = point - origin;
v - axis * v.dot(axis)
};
let (u, v) = (flat(centre), flat(point));
let sweep = (-axis).dot(u.cross(v)).atan2(u.dot(v));
(0.0, if sweep <= 1e-9 { sweep + std::f64::consts::TAU } else { sweep })
}
(None, Some(other), _) => (-other, angle + other),
(None, None, true) => (-angle / 2.0, angle),
(None, None, false) => (0.0, angle),
};
if !(sweep.is_finite() && start.is_finite() && sweep.abs() > 1e-9 && sweep.abs() <= std::f64::consts::TAU + 1e-9) {
return Err(DocError::Invalid("the revolve angle must be more than 0 and at most 360°".into()));
}
let points: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
let centre = plane.to_world(points.iter().fold(Vec2::default(), |sum, point| sum + *point) * (1.0 / points.len().max(1) as f64));
let unit_axis = axis.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))?;
let radial_centre = regions.first().map(|region| plane.to_world(region.centroid())).unwrap_or(centre);
let radial = radial_centre - origin - unit_axis * (radial_centre - origin).dot(unit_axis);
let rotation_axis = if unit_axis.cross(radial).dot(plane.normal()) < 0.0 { -unit_axis } else { unit_axis };
let display_start = start * -unit_axis.dot(rotation_axis);
Ok(RevolveExtent { start, sweep, rotation_axis, display_start, curved_to: to.is_some_and(|point| curved_target_any(st, point).is_some()) })
}
/// The tool bodies a feature adds or removes (extrude, revolve, primitives), in the current state.
fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -> Result<Vec<Body>> {
fn feature_tools(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -> Result<Vec<Body>> {
match &f.kind {
FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => {
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone();
@@ -1486,41 +1628,36 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
let start = if hi > 0.0 { lo + off } else { hi + off };
tools = trim_to_body(tools, &tb.body, |q: Vec3| ((q - ss.plane.origin).dot(n) - start).abs())?;
}
if *operation == Operation::NewBody && extent.taper.is_none() {
tools = tools
.into_iter()
.map(|body| {
let curves: Vec<_> = body
.face_sources()
.into_iter()
.flatten()
.map(|source| match source {
Some(kernel::FaceSource::ExtrudeSide { segment }) => crate::config::exact_sketch_curve(&ss, segment),
_ => None,
})
.collect();
body.with_extrude_certificates(f.id, *sketch, &curves).map_err(DocError::from)
})
.collect::<Result<Vec<_>>>()?;
}
Ok(tools)
}
FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, to, .. } => {
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone();
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
let (o, d) = revolve_axis(&ss, axis)?;
// Where the turn starts (from the profile) and how far it goes.
let (start, ang) = match (to, angle2, symmetric) {
(Some(p), _, _) => {
let (ow, aw) = (ss.plane.to_world(o), ss.plane.dir_to_world(d));
let pts: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
let c = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, q| a + *q) * (1.0 / pts.len().max(1) as f64));
let flat = |q: Vec3| {
let v = q - ow;
v - aw * v.dot(aw)
};
let (u, v) = (flat(c), flat(*p));
// The revolve turns clockwise about its axis direction (the kernel's sense).
let a = (aw * -1.0).dot(u.cross(v)).atan2(u.dot(v));
let a = if a <= 1e-9 { a + std::f64::consts::TAU } else { a };
(0.0, a)
}
(None, Some(a2), _) => {
let (a, b) = (val(vals, angle, Kind::Angle)?, val(vals, a2, Kind::Angle)?);
(-b, a + b)
}
(None, None, true) => {
let a = val(vals, angle, Kind::Angle)?;
(-a / 2.0, a)
}
(None, None, false) => (0.0, val(vals, angle, Kind::Angle)?),
let angles = if to.is_some() {
(0.0, None)
} else {
(val(vals, angle, Kind::Angle)?, angle2.as_ref().map(|a| val(vals, a, Kind::Angle)).transpose()?)
};
if !(ang.abs() > 1e-9 && ang.abs() <= std::f64::consts::TAU + 1e-9) {
return Err(DocError::Invalid("the revolve angle must be more than 0 and at most 360°".into()));
}
let extent = revolve_extent(st, &ss.plane, &regions, (ss.plane.to_world(o), ss.plane.dir_to_world(d)), angles, *symmetric, *to)?;
let (start, ang) = (extent.start, extent.sweep);
let (operation, targets) = feature_op(f);
let axis_w = ss.plane.dir_to_world(d);
let origin_w = ss.plane.to_world(o);
@@ -1633,6 +1770,15 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
let ps =
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
let profile;
let ss = if !ps.sketch.wires.is_empty() {
profile = profile_in_frame(doc, ss, f.component)?;
&profile
} else {
ss
};
let mapped = path_in_frame(doc, ps, f.component)?;
let ps = mapped.as_ref();
let regions = solvecraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
let start = regions.first().map(|r| ss.plane.to_world(r.centroid())).unwrap_or_default();
// A path through 3D sketch curves follows them as points.
@@ -1645,6 +1791,8 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => {
let ps =
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
let mapped = path_in_frame(doc, ps, f.component)?;
let ps = mapped.as_ref();
let r = val(vals, diameter, Kind::Length)? / 2.0;
if !(r > 1e-6) {
return Err(DocError::Invalid("pipe diameter must be positive".into()));
@@ -1709,6 +1857,51 @@ fn pipe_region(vals: &BTreeMap<String, Value>, r: f64, wall: &Option<String>) ->
Ok(Region2 { outer: solvecraft_geom::Loop2::circle(Vec2::ZERO, r), holes })
}
fn map_wire(wire: &mut solvecraft_sketch::Wire, matrix: &Mat) {
for point in wire.pts.iter_mut().chain(&mut wire.fit) {
*point = crate::apply_point(matrix, *point);
}
}
fn profile_in_frame(doc: &Document, sketch: &SolvedSketch, consumer: u64) -> Result<SolvedSketch> {
let owner = doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0);
let inverse =
crate::mat_inverse(&doc.component_transform(consumer)).ok_or_else(|| DocError::Invalid("profile consumer frame is invalid".into()))?;
let relative = crate::mat_mul(&inverse, &doc.component_transform(owner));
let mut mapped = sketch.clone();
mapped.plane = Plane::new(
crate::apply_point(&relative, sketch.plane.origin),
crate::apply_vector(&relative, sketch.plane.x),
crate::apply_vector(&relative, sketch.plane.y),
)
.ok_or_else(|| DocError::Invalid("profile sketch frame is invalid".into()))?;
Ok(mapped)
}
/// Stored wire coordinates are converted once into the feature consumer frame.
fn path_in_frame<'a>(doc: &Document, sketch: &'a SolvedSketch, consumer: u64) -> Result<std::borrow::Cow<'a, SolvedSketch>> {
use std::borrow::Cow;
if sketch.sketch.wires.is_empty() {
return Ok(Cow::Borrowed(sketch));
}
let owner = doc.feature(sketch.feature).map(|f| f.component).unwrap_or(0);
let inverse =
crate::mat_inverse(&doc.component_transform(consumer)).ok_or_else(|| DocError::Invalid("path consumer frame is not invertible".into()))?;
let relative = crate::mat_mul(&inverse, &doc.component_transform(owner));
let mut mapped = sketch.clone();
mapped.plane = Plane::new(
crate::apply_point(&relative, sketch.plane.origin),
crate::apply_vector(&relative, sketch.plane.x),
crate::apply_vector(&relative, sketch.plane.y),
)
.ok_or_else(|| DocError::Invalid("path sketch frame is invalid".into()))?;
for wire in &mut mapped.sketch.wires {
let matrix = if wire.space == solvecraft_sketch::WireSpace::Component { &relative } else { &inverse };
map_wire(wire, matrix);
}
Ok(Cow::Owned(mapped))
}
/// A path that uses 3D sketch curves (wires), as one chain of world points from the end nearest
/// `start` (no start: the first curve runs toward the second). `None` when the path is all
/// planar curves.
@@ -1890,10 +2083,10 @@ const MAX_INSTANCES: usize = 1000;
/// The placements (column-major matrices) of a pattern's copies, the original excluded.
pub fn pattern_matrices(doc: &Document, st: &ModelState, p: &crate::PatternKind) -> Result<Vec<[[f64; 4]; 4]>> {
let (vals, _) = doc.param_values();
pattern_transforms(&vals, st, p)
pattern_transforms(doc, &vals, st, 0, p)
}
fn pattern_transforms(vals: &BTreeMap<String, Value>, st: &ModelState, p: &crate::PatternKind) -> Result<Vec<Mat>> {
fn pattern_transforms(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, component: u64, p: &crate::PatternKind) -> Result<Vec<Mat>> {
let count = |e: &str| -> Result<usize> {
let n = val(vals, e, Kind::Unitless)?.round();
if !(1.0..=MAX_INSTANCES as f64).contains(&n) {
@@ -1928,6 +2121,8 @@ fn pattern_transforms(vals: &BTreeMap<String, Value>, st: &ModelState, p: &crate
let n = count(c)?;
let d = val(vals, spacing, Kind::Length)?;
let ps = st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("path sketch {path_sketch}")))?;
let mapped = path_in_frame(doc, ps, component)?;
let ps = mapped.as_ref();
let mut segs = path_segments(ps, path, Vec3::new(f64::NAN, f64::NAN, f64::NAN))?;
if *flip {
segs = segs.iter().rev().map(reverse_seg).collect();
@@ -2093,7 +2288,7 @@ fn replay(
}
}
} else {
let tools = feature_tools(vals, src, st)?;
let tools = feature_tools(doc, vals, src, st)?;
for m in mats {
for t in &tools {
copies.push(kernel::transform_matrix(t, *m)?);
@@ -2348,6 +2543,7 @@ fn chamfer_with(
fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
match &f.kind {
FeatureKind::BodyTransfer { bodies, from, to, transform } => crate::body_transfer::evaluate(doc, st, bodies, *from, *to, *transform),
FeatureKind::SheetBase { .. }
| FeatureKind::SheetContour { .. }
| FeatureKind::SheetFlange { .. }
@@ -2405,9 +2601,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::Loft { operation, targets, .. }
| FeatureKind::Coil { operation, targets, .. }
| FeatureKind::Sweep { operation, targets, .. } => {
let tools = feature_tools(vals, f, st)?;
let tools = feature_tools(doc, vals, f, st)?;
// Cuts through sheet metal become cut-outs of its flat pattern.
if *operation == Operation::Cut && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() {
if matches!(operation, Operation::Cut | Operation::Intersect) && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() {
let handled = more::sheet_cut(f, st, &tools, targets)?;
if !handled.is_empty() {
let effective = if targets.is_empty() { &f.participants } else { targets };
@@ -2436,7 +2632,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
FeatureKind::Hole { thread, diameter, .. } => {
let (spots, dir) = hole_spots(st, f)?;
let tools = feature_tools(vals, f, st)?;
let tools = feature_tools(doc, vals, f, st)?;
apply_op(st, f, tools, Operation::Cut, &[])?;
if let Some(d) = thread {
// A point on each hole's wall, a little below the entry.
@@ -2637,7 +2833,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
Ok(())
}
FeatureKind::Pattern { features, pattern, bodies } => {
let mats = pattern_transforms(vals, st, pattern)?;
let mats = pattern_transforms(doc, vals, st, f.component, pattern)?;
if bodies.is_empty() {
return replay(doc, vals, f, st, features, &mats, warning);
}
@@ -2886,10 +3082,11 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
FeatureKind::Import { step, .. } => {
let imp = kernel::step_import_shared(step)?;
let digest = crate::config::payload_digest(step);
for (k, b) in imp.bodies.iter().enumerate() {
let base = f.body_names.get(k).filter(|n| !n.trim().is_empty()).unwrap_or(&b.name);
let name = unique_body_name(st, base);
st.bodies.push(ModelBody::new(name, b.body.clone(), f.id));
st.bodies.push(ModelBody::new(name, b.body.clone().with_import_certificate(&digest, f.id)?, f.id));
}
if !imp.warnings.is_empty() {
let n = imp.warnings.len();
@@ -2946,9 +3143,18 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram
None => m,
}
};
let body_m: Vec<Mat> = st.bodies.iter().map(|b| rel(doc.body_component(&b.name, b.feature), Some(&b.name))).collect();
let body_m: Vec<Mat> = st
.bodies
.iter()
.map(|b| {
let m = rel(doc.model_body_component(b), None);
doc.model_body_offset(b).map(|offset| crate::mat_mul(&m, offset)).unwrap_or(m)
})
.collect();
let sketch_m: Vec<Mat> = st.sketches.iter().map(|s| rel(comp_of_feature(s.feature), None)).collect();
if body_m.iter().chain(&sketch_m).all(crate::is_identity) {
let world_wires_need_mapping = !crate::is_identity(&inv)
&& st.sketches.iter().any(|sketch| sketch.sketch.wires.iter().any(|wire| wire.space == solvecraft_sketch::WireSpace::World));
if body_m.iter().chain(&sketch_m).all(crate::is_identity) && !world_wires_need_mapping {
return Cow::Borrowed(st);
}
let mut out = st.clone();
@@ -2960,6 +3166,7 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram
let names = crate::naming::face_names(b);
let mut nb = ModelBody::new(b.name.clone(), t, b.feature);
nb.component = b.component;
nb.transferred = b.transferred;
nb.source_name = b.source_name.clone();
nb.source_transform = crate::mat_mul(m, &b.source_transform);
nb.imported = b.imported.clone();
@@ -2973,6 +3180,10 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram
{
s.plane = np;
}
for wire in &mut s.sketch.wires {
let matrix = if wire.space == solvecraft_sketch::WireSpace::Component { m } else { &inv };
map_wire(wire, matrix);
}
}
Cow::Owned(out)
}
@@ -3002,16 +3213,17 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
for (comp, path, m) in doc.placements() {
let primary = path == first_path(comp);
let ident = crate::is_identity(&m);
for b in st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == comp) {
for b in st.bodies.iter().filter(|b| doc.model_body_component(b) == comp) {
let name = doc.world_body_name(st, &b.name, comp, &path);
// A body moved in from another component keeps its place (its offset).
let bm = doc.body_offsets.get(&b.name).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
let bm = doc.model_body_offset(b).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
let body =
if crate::is_identity(&bm) { b.body.clone() } else { kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone()) };
let mut placed = ModelBody::new(name, body, b.feature);
placed.source_name = Some(b.definition_name().to_string());
placed.source_transform = crate::mat_mul(&bm, &b.source_transform);
placed.component = comp;
placed.transferred = b.transferred;
placed.imported = b.imported.clone();
// Placement preserves topology: face ownership remains that of the
// source model, including modifiers on component instances.
@@ -3027,12 +3239,22 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
s2.plane = np;
}
}
for wire in &mut s2.sketch.wires {
if wire.space == solvecraft_sketch::WireSpace::Component {
map_wire(wire, &m);
}
}
out.sketches.push(s2);
}
for t in st.threads.iter().filter(|t| comp_of_feature(t.feature) == comp) {
for t in st
.threads
.iter()
.filter(|t| st.body(&t.body).map(|body| doc.model_body_component(body)).unwrap_or_else(|| comp_of_feature(t.feature)) == comp)
{
let mut t2 = t.clone();
t2.axis_point = crate::apply_point(&m, t.axis_point);
t2.axis = crate::apply_vector(&m, t.axis);
let frame = st.body(&t.body).and_then(|body| doc.model_body_offset(body)).map(|offset| crate::mat_mul(&m, offset)).unwrap_or(m);
t2.axis_point = crate::apply_point(&frame, t.axis_point);
t2.axis = crate::apply_vector(&frame, t.axis);
out.threads.push(t2);
}
}
+122 -38
View File
@@ -4,6 +4,7 @@
use super::*;
use crate::EmbossFace;
use crate::apply_point;
use crate::sheet;
use solvecraft_geom::{Loop2, Seg2};
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
@@ -22,11 +23,11 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
let i =
st.bodies.iter().position(|b| b.name == face.body).ok_or_else(|| DocError::Unknown(format!("emboss body `{}`", face.body)))?;
let mb = st.bodies.get(i).cloned().ok_or_else(|| DocError::Invalid("emboss body".into()))?;
let body_frame = face.body_frame(doc, mb.feature)?;
let body_frame = face.body_frame(doc, &mb)?;
let inverse = crate::mat_inverse(&body_frame).ok_or_else(|| DocError::Invalid("invalid cylinder body frame".into()))?;
let sketch_owner = doc.feature(ss.feature).map(|f| f.component).unwrap_or(0);
let author_frame = EmbossFace::component_frame(doc, f.component, &face.author_path)?;
let sketch_frame = face.sketch_frame(doc, f.component, sketch_owner, mb.feature)?;
let sketch_frame = face.sketch_frame(doc, f.component, sketch_owner, &mb)?;
let sketch_transform = crate::mat_mul(&inverse, &sketch_frame);
let plane = Plane::new(
crate::apply_point(&sketch_transform, ss.plane.origin),
@@ -289,12 +290,22 @@ fn point_on_polyline(pts: &[Vec2], d: f64) -> Vec2 {
}
/// Rebuild a sheet body's solid from its model.
fn replace_sheet_body(mb: &mut ModelBody, body: Body) {
let mut next = ModelBody::new(mb.name.clone(), body, mb.feature);
next.component = mb.component;
next.transferred = mb.transferred;
next.source_name = mb.source_name.clone();
next.source_transform = mb.source_transform;
next.imported = mb.imported.clone();
*mb = next;
}
fn rebuild_sheet(st: &mut ModelState, idx: usize) -> Result<()> {
let sheet = st.sheets.get(idx).cloned().ok_or_else(|| DocError::Invalid("sheet".into()))?;
let solid = sheet.solid()?;
let i = st.bodies.iter().position(|b| b.name == sheet.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", sheet.body)))?;
if let Some(mb) = st.bodies.get_mut(i) {
*mb = ModelBody::new(mb.name.clone(), solid, mb.feature);
replace_sheet_body(mb, solid);
}
Ok(())
}
@@ -513,7 +524,7 @@ pub(super) fn sheet_eval(
sh.holes = region.holes.iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect();
let solid = sh.solid()?;
if let Some(b) = st.bodies.iter_mut().find(|b| &b.name == body) {
*b = ModelBody::new(b.name.clone(), solid, b.feature);
replace_sheet_body(b, solid);
}
st.sheets.retain(|s| &s.body != body);
st.sheets.push(sh);
@@ -528,14 +539,15 @@ fn poly_area(p: &[Vec2]) -> f64 {
(0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::<f64>() / 2.0
}
/// An extrude cut through sheet metal: the profiles become cut-outs of the flat pattern
/// (parallel to a panel, through its thickness). Returns the sheet bodies handled.
/// Exact prismatic machining, retained in sheet material/depth metadata.
pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], targets: &[String]) -> Result<Vec<String>> {
let FeatureKind::Extrude { sketch, profiles, .. } = &f.kind else { return Ok(Vec::new()) };
let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) };
if extent.taper.is_some() {
return Ok(Vec::new());
}
let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) };
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
let n = ss.plane.normal();
// How far the tools reach along the sketch normal.
let mut span = (f64::MAX, f64::MIN);
for t in tools {
for p in t.tessellate((t.size() * 1e-3).max(1e-3))?.positions {
@@ -543,51 +555,123 @@ pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], target
span = (span.0.min(d), span.1.max(d));
}
}
let mut handled = Vec::new();
let mut overlap_work = crate::sheet::FlatWork::new();
let targets = if targets.is_empty() { &f.participants } else { targets };
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
for i in 0..st.sheets.len() {
let Some(sh) = st.sheets.get(i).cloned() else { continue };
let mut plans = Vec::new();
let mut work = sheet::FlatWork::new();
for original in &st.sheets {
let eligible = all
|| if targets.is_empty() {
st.bodies.iter().any(|b| b.name == sh.body && (b.component == f.component || b.feature == f.id))
st.bodies.iter().any(|b| b.name == original.body && (b.component == f.component || b.feature == f.id))
} else {
targets.contains(&sh.body)
targets.contains(&original.body)
};
if !eligible {
continue;
}
let candidates: Vec<(Option<usize>, Region2)> = if sh.flat {
if sh.frame.normal().cross(n).len() > 1e-6 { Vec::new() } else { sh.flat_regions()?.into_iter().map(|region| (None, region)).collect() }
} else {
sh.parallel_panels(&ss.plane)
let Some(mb) = st.body(&original.body) else {
continue;
};
let mut added = Vec::new();
for (pi, panel) in candidates {
// The panel's faces along the sketch normal must both be inside the cut.
let w = sh.world(pi);
let z0 = (apply_point(&w, Vec3::ZERO) - ss.plane.origin).dot(n);
let z1 = (apply_point(&w, Vec3::Z * sh.t) - ss.plane.origin).dot(n);
if z0.min(z1) < span.0 - 1e-6 || z0.max(z1) > span.1 + 1e-6 {
continue;
}
for r in &regions {
let flat = sh.region_to_flat(pi, &ss.plane, r)?;
let overlaps = crate::sheet::flat_regions_overlap(&panel, &flat, &mut overlap_work)?;
if overlaps && !added.iter().any(|x: &Region2| x == &flat) {
added.push(flat);
let mut sh = original.clone();
if *operation == Operation::Intersect && (!sh.flat && !sh.flanges.is_empty()) {
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
continue;
}
if !sh.flat && !sh.flanges.is_empty() && !super::sheet_boolean_changes(&mb.body, tools, *operation)? {
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
continue;
}
if let Some(bends) = sh.unmodeled_solid(&ss.plane)? {
for tool in tools {
if kernel::boolean(&bends, tool, kernel::BoolOp::Intersect)?.is_some() {
return Err(DocError::Invalid(
"this machining intersects a folded bend or a nonparallel panel; unfold the sheet before machining that material".into(),
));
}
}
}
if added.is_empty() {
continue;
let candidates = if sh.flat {
if sh.frame.normal().cross(n).len() > 1e-6 { Vec::new() } else { sh.flat_regions()?.into_iter().map(|r| (None, r)).collect() }
} else {
sh.material_panels(Some(&ss.plane), &mut work)?
};
let mut modeled = false;
for (pi, panel) in candidates {
let w = if sh.flat { sh.world(None) } else { sh.world(pi) };
let zorigin = (apply_point(&w, Vec3::ZERO) - ss.plane.origin).dot(n);
let sign = crate::apply_vector(&w, Vec3::Z).dot(n);
if sign.abs() < 1e-6 {
continue;
}
let (a, b) = ((span.0 - zorigin) / sign, (span.1 - zorigin) / sign);
let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t));
if *operation == Operation::Intersect {
if z1 <= z0 {
sh.holes.push(panel);
modeled = true;
continue;
}
if z0 > 1e-7 || z1 < sh.t - 1e-7 {
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
continue;
}
let mapped = regions.iter().map(|r| sh.region_to_flat(pi, &ss.plane, r)).collect::<Result<Vec<_>>>()?;
let tool = sheet::material_union(mapped, &mut work)?;
let retained = sheet::material_boolean(std::slice::from_ref(&panel), &tool, kernel::BoolOp::Intersect, &mut work)?;
let removed = sheet::material_boolean(&[panel], &retained, kernel::BoolOp::Cut, &mut work)?;
sh.holes.extend(removed);
modeled = true;
} else if z1 > z0 {
for r in &regions {
let flat = sh.region_to_flat(pi, &ss.plane, r)?;
let clipped =
sheet::material_boolean(std::slice::from_ref(&panel), std::slice::from_ref(&flat), kernel::BoolOp::Intersect, &mut work)?;
if !clipped.is_empty() && z0 <= 1e-7 && z1 >= sh.t - 1e-7 {
// Keep a single original tool boundary across structural seams.
// Re-clipping notch polygons creates coincident seam endpoints.
if !sh.holes.contains(&flat) {
sh.holes.push(flat);
}
modeled = true;
} else {
for region in clipped {
sh.pockets.push(sheet::SheetPocket { region, z0, z1 });
modeled = true;
}
}
}
}
}
if let Some(s) = st.sheets.get_mut(i) {
s.holes.extend(added);
if !modeled {
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
} else if sh.flat_material()?.is_empty() {
// Empty exact material removes geometry and metadata together. Budget or
// topology errors propagate; they are never mistaken for an empty blank.
plans.push((sh.body.clone(), None));
} else {
let body = sh.solid()?;
plans.push((sh.body.clone(), Some((sh, body))));
}
}
let handled = plans.iter().map(|(name, _)| name.clone()).collect();
for (name, result) in plans {
match result {
Some((sh, body)) => {
if let Some(mb) = st.bodies.iter_mut().find(|b| b.name == name) {
replace_sheet_body(mb, body);
}
if let Some(target) = st.sheets.iter_mut().find(|s| s.body == name) {
*target = sh;
}
}
None => {
st.bodies.retain(|b| b.name != name);
st.sheets.retain(|s| s.body != name);
st.threads.retain(|t| t.body != name);
}
}
rebuild_sheet(st, i)?;
handled.push(sh.body.clone());
}
Ok(handled)
}
+235 -2
View File
@@ -28,13 +28,16 @@
//! - 9: configured component/semantic-face styles, associative free-form offsets and
//! explicit cylindrical emboss targets. Existing version 8 geometry and rules are
//! preserved; new fields use defaults rather than guessing a target or an association.
//! - 10: timeline body transfers, explicit 3D wire coordinate spaces and certified face
//! bindings. Legacy ownership maps remain unchanged. Retained wire associations determine
//! their space; saved coordinates are never rewritten during migration.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 9;
pub const FORMAT: u32 = 10;
pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}")
@@ -47,7 +50,7 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 8] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(())];
const JSON_STEPS: [JsonStep; 9] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(())];
/// Old XZ references retain their exact frame, including nested offset and angled planes.
/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
@@ -142,10 +145,29 @@ pub fn read(s: &str) -> Result<Document> {
crate::config::validate_styles_json(&v)?;
validate_emboss_targets(&v)?;
validate_freeform_offsets(&v)?;
let legacy_wires = validate_wires(&v, version)?;
validate_body_transfers(&v)?;
for step in JSON_STEPS.iter().skip(version as usize - 1).take((FORMAT - version) as usize) {
step(&mut v)?;
}
let mut d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
let mut legacy_by_feature = std::collections::BTreeMap::<usize, Vec<usize>>::new();
for (feature, wire) in legacy_wires {
legacy_by_feature.entry(feature).or_default().push(wire);
}
for (feature_index, wire_indices) in legacy_by_feature {
if let Some(crate::Feature { kind: crate::FeatureKind::Sketch { sketch, .. }, .. }) = d.features.get_mut(feature_index) {
let mut spaces = std::collections::BTreeMap::new();
for link in &sketch.links {
spaces.entry(link.id.as_str()).or_insert_with(|| solvecraft_sketch::WireSpace::from_legacy_link(link));
}
for wire_index in wire_indices {
if let Some(wire) = sketch.wires.get_mut(wire_index) {
wire.space = wire.link.as_deref().and_then(|id| spaces.get(id)).copied().unwrap_or_default();
}
}
}
}
if d.features.len() > crate::document::MAX_FEATURES
|| d.params.len() > crate::document::MAX_PARAMS
|| d.components.len() > crate::document::MAX_FEATURES
@@ -157,9 +179,21 @@ pub fn read(s: &str) -> Result<Document> {
return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name)));
}
}
let component_ids: std::collections::BTreeSet<_> = d.components.iter().map(|component| component.id).chain(std::iter::once(0)).collect();
for feature in &d.features {
if let crate::FeatureKind::Sketch { sketch, .. } = &feature.kind {
sketch.validate_freeform_offsets().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
sketch.validate_wires().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
}
if let crate::FeatureKind::BodyTransfer { from, to, .. } = &feature.kind
&& [from, to].into_iter().any(|id| !component_ids.contains(id))
{
return Err(DocError::Invalid("damaged design: body transfer references an unknown component".into()));
}
if let crate::FeatureKind::BodyTransfer { to, .. } = &feature.kind
&& feature.component != *to
{
return Err(DocError::Invalid("damaged design: body transfer owner must match its destination".into()));
}
}
// Fill in what older designs (and hand-edited files) may lack; a no-op otherwise.
@@ -168,6 +202,112 @@ pub fn read(s: &str) -> Result<Document> {
Ok(d)
}
/// Bound arrays before typed allocation; only old files with absent markers need migration.
fn validate_wires(value: &Value, version: u32) -> Result<Vec<(usize, usize)>> {
let invalid = || DocError::Invalid("damaged design: 3D curve arrays, ids or coordinate spaces are invalid".into());
let mut legacy = Vec::new();
let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(legacy) };
if features.len() > crate::document::MAX_FEATURES {
return Err(invalid());
}
for (feature_index, feature) in features.iter().enumerate() {
if feature.get("type").and_then(Value::as_str) != Some("sketch") {
continue;
}
let Some(wires) = feature.get("sketch").and_then(|sketch| sketch.get("wires")) else { continue };
let wires = wires.as_array().ok_or_else(invalid)?;
if wires.len() > 20_000 {
return Err(invalid());
}
let mut ids = std::collections::BTreeSet::new();
let mut work = 0usize;
for (wire_index, wire) in wires.iter().enumerate() {
let id = wire.get("id").and_then(Value::as_str).ok_or_else(invalid)?;
if id.is_empty() || id.len() > 512 || !ids.insert(id) {
return Err(invalid());
}
if let Some(link) = wire.get("link").filter(|link| !link.is_null())
&& link.as_str().is_none_or(|link| link.is_empty() || link.len() > 512)
{
return Err(invalid());
}
match wire.get("space") {
None if version < 10 => legacy.push((feature_index, wire_index)),
None => {}
Some(Value::String(space)) if matches!(space.as_str(), "world" | "component") => {}
_ => return Err(invalid()),
}
let pts = wire.get("pts").and_then(Value::as_array).ok_or_else(invalid)?;
let fit = match wire.get("fit") {
None => &[][..],
Some(fit) => fit.as_array().ok_or_else(invalid)?.as_slice(),
};
work = work.saturating_add(pts.len()).saturating_add(fit.len());
if work > 1_000_000 || pts.is_empty() || pts.len() > 100_000 || fit.len() > 500 || (pts.len() == 1 && fit.len() != 1) {
return Err(invalid());
}
for point in pts.iter().chain(fit) {
let point = point.as_array().ok_or_else(invalid)?;
if point.len() != 3 || point.iter().any(|coordinate| coordinate.as_f64().is_none_or(|n| !n.is_finite() || n.abs() >= MAX_REAL)) {
return Err(invalid());
}
}
}
}
Ok(legacy)
}
fn validate_body_transfers(value: &Value) -> Result<()> {
let invalid = || DocError::Invalid("damaged design: body transfer needs bounded unique bodies, components and a rigid placement".into());
let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) };
for feature in features {
if feature.get("type").and_then(Value::as_str) != Some("body_transfer") {
continue;
}
let bodies = feature.get("bodies").and_then(Value::as_array).ok_or_else(invalid)?;
if bodies.is_empty() || bodies.len() > 1000 {
return Err(invalid());
}
let mut names = std::collections::BTreeSet::new();
for body in bodies {
let body = body.as_str().ok_or_else(invalid)?;
if body.trim().is_empty() || body.len() > 512 || body.chars().any(char::is_control) || !names.insert(body) {
return Err(invalid());
}
}
for key in ["from", "to"] {
if feature.get(key).and_then(Value::as_u64).is_none() {
return Err(invalid());
}
}
let owner = match feature.get("component") {
None => 0,
Some(value) => value.as_u64().ok_or_else(invalid)?,
};
if feature.get("to").and_then(Value::as_u64) != Some(owner) {
return Err(DocError::Invalid("damaged design: body transfer owner must match its destination".into()));
}
let matrix = feature.get("transform").and_then(Value::as_array).ok_or_else(invalid)?;
if matrix.len() != 4 {
return Err(invalid());
}
let mut placement = [[0.0; 4]; 4];
for (out, column) in placement.iter_mut().zip(matrix) {
let column = column.as_array().ok_or_else(invalid)?;
if column.len() != 4 {
return Err(invalid());
}
for (out, number) in out.iter_mut().zip(column) {
*out = number.as_f64().ok_or_else(invalid)?;
}
}
if !crate::body_transfer::valid_transfer_matrix(&placement) {
return Err(invalid());
}
}
Ok(())
}
/// Validate new explicit targets before typed decoding. Older planar features have none.
fn validate_emboss_targets(value: &Value) -> Result<()> {
let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) };
@@ -335,6 +475,99 @@ fn normalise(d: &mut Document) -> Result<()> {
mod tests {
use super::*;
#[test]
fn version_nine_wire_spaces_preserve_saved_points_and_explicit_markers() {
use solvecraft_geom::Vec3;
use solvecraft_sketch::{LinkKind, LinkSource, Sketch, WireSpace};
let mut sketch = Sketch::new();
sketch.add_drawn_wire(&[Vec3::new(12.0, 3.0, 4.0)]).unwrap();
let link = sketch
.add_link_with_wires(
LinkKind::Include,
LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO },
&[],
&[vec![Vec3::new(2.0, 7.0, 9.0), Vec3::new(2.0, 8.0, 9.0)]],
)
.unwrap();
sketch.set_link_lost(&link, true);
let mut doc = Document::new("legacy wire spaces");
doc.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap();
json["format"] = Value::from("solvecraft/9");
for wire in json["features"][0]["sketch"]["wires"].as_array_mut().unwrap() {
wire.as_object_mut().unwrap().remove("space");
}
let old = json["features"][0]["sketch"]["wires"].clone();
let migrated = read(&json.to_string()).unwrap();
let crate::FeatureKind::Sketch { sketch, .. } = &migrated.features[0].kind else { panic!("sketch") };
assert_eq!(sketch.wires[0].space, WireSpace::World);
assert_eq!(sketch.wires[1].space, WireSpace::Component);
let saved: Value = serde_json::from_str(&migrated.to_json()).unwrap();
for (a, b) in old.as_array().unwrap().iter().zip(saved["features"][0]["sketch"]["wires"].as_array().unwrap()) {
assert_eq!(a["pts"], b["pts"]);
assert_eq!(a["fit"], b["fit"]);
assert_eq!(a["link"], b["link"]);
}
assert_eq!(read(&migrated.to_json()).unwrap(), migrated);
json["features"][0]["sketch"]["wires"][1]["space"] = Value::from("world");
let explicit = read(&json.to_string()).unwrap();
let crate::FeatureKind::Sketch { sketch, .. } = &explicit.features[0].kind else { panic!("sketch") };
assert_eq!(sketch.wires[1].space, WireSpace::World);
}
#[test]
fn hostile_wire_and_transfer_shapes_are_refused_before_typed_decode() {
let base: Value = serde_json::from_str(&Document::new("bounds").to_json()).unwrap();
let wire = serde_json::json!({"id":"w1", "pts":[[1,2,3],[4,5,6]],"space":"world"});
for damaged in [
serde_json::json!({"space":"guess"}),
serde_json::json!({"pts":[[1,2]]}),
serde_json::json!({"pts":[[1e9,2,3],[4,5,6]]}),
serde_json::json!({"fit": vec![[1,2,3];501]}),
] {
let mut changed = wire.clone();
for (key, value) in damaged.as_object().unwrap() {
changed[key] = value.clone();
}
let mut file = base.clone();
file["features"] = serde_json::json!([{"type":"sketch","sketch":{"wires":[changed]}}]);
assert!(validate_wires(&file, FORMAT).is_err());
}
let transfer = serde_json::json!({"type":"body_transfer","component":1,"bodies":["Body1"],"from":0,"to":1,"transform":crate::IDENTITY});
for damaged in [
serde_json::json!({"bodies":["Body1","Body1"]}),
serde_json::json!({"from":-1}),
serde_json::json!({"component":0}),
serde_json::json!({"transform":[[2,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]}),
serde_json::json!({"bodies":["\n"]}),
] {
let mut changed = transfer.clone();
for (key, value) in damaged.as_object().unwrap() {
changed[key] = value.clone();
}
let mut file = base.clone();
file["features"] = serde_json::json!([changed]);
assert!(validate_body_transfers(&file).is_err());
}
}
#[test]
fn recorded_transfer_roundtrips_and_unknown_component_is_refused() {
let mut doc = Document::new("recorded ownership boundary");
doc.add_feature(crate::FeatureKind::BodyTransfer { bodies: vec!["Body1".into()], from: 0, to: 0, transform: crate::IDENTITY }, None).unwrap();
assert_eq!(read(&doc.to_json()).unwrap(), doc);
let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap();
assert_eq!(json["features"][0]["type"], "body_transfer");
json["features"][0]["to"] = Value::from(12345);
json["features"][0]["component"] = Value::from(12345);
assert!(read(&json.to_string()).unwrap_err().to_string().contains("unknown component"));
json["features"][0]["to"] = Value::from(0);
assert!(read(&json.to_string()).unwrap_err().to_string().contains("owner must match"));
json["features"][0]["component"] = Value::from(0);
json["features"][0]["transform"][0][0] = Value::from(2);
assert!(read(&json.to_string()).unwrap_err().to_string().contains("rigid placement"));
}
#[test]
fn version_eight_to_object_offset_keeps_existing_model_parameter_names() {
use crate::{Direction, Extent, FeatureKind, Operation, PlaneRef, ProfileSel};
+1 -1
View File
@@ -223,7 +223,7 @@ pub fn motion(kind: JointKind, q: &[f64]) -> Mat {
/// Bodies of a component in the model (its own frame).
fn component_bodies<'a>(doc: &Document, st: &'a ModelState, component: u64) -> Vec<&'a crate::ModelBody> {
st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == component).collect()
st.bodies.iter().filter(|b| doc.model_body_component(b) == component).collect()
}
/// The component an occurrence places (0 for the root).
+5 -2
View File
@@ -7,6 +7,9 @@
pub mod appearance;
mod assembly;
mod body_transfer;
mod body_transfer_sheet;
pub use body_transfer::{valid_transfer_matrix, validate_body_transfer};
pub mod canvas;
mod clipboard;
pub use clipboard::{clone_expression, remap_topology};
@@ -29,8 +32,8 @@ pub mod sheet;
pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse, mat_mul, rigid};
pub use document::*;
pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, object_height, parse_metric_thread,
pattern_matrices, state_in_frame, state_in_frame_at, through_all_distance, world_state,
EdgeRef, FeatureResult, Model, ModelBody, ModelState, RevolveExtent, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, object_height,
parse_metric_thread, pattern_matrices, revolve_extent, select_profiles, state_in_frame, state_in_frame_at, through_all_distance, world_state,
};
pub use params::{ParamRow, ParamUser, cycles};
pub use solvecraft_kernel as kernel;
+2 -1
View File
@@ -274,7 +274,8 @@ impl FeatureKind {
| FeatureKind::Loft { .. }
| FeatureKind::Sweep { .. }
| FeatureKind::Import { .. }
| FeatureKind::MeshImport { .. } => {}
| FeatureKind::MeshImport { .. }
| FeatureKind::BodyTransfer { .. } => {}
}
v
}
+199 -16
View File
@@ -83,24 +83,32 @@ fn planar_region(region: &Region2) -> Result<Region2> {
}
fn region_complexity(regions: &[Region2]) -> Result<(usize, usize)> {
if regions.len() > MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
regions_complexity(regions.iter())
}
fn regions_complexity<'a>(regions: impl Iterator<Item = &'a Region2>) -> Result<(usize, usize)> {
let (mut edges, mut points) = (0usize, 0usize);
for lp in regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)) {
edges = edges.saturating_add(lp.segs.len());
if edges > MAX_FLAT_POINTS {
let mut pieces = 0usize;
for region in regions {
pieces += 1;
if pieces > MAX_FLAT_PIECES || region.holes.len() > MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
points = points.saturating_add(flat_polyline(lp)?.len());
if points > MAX_FLAT_POINTS {
return Err(flat_budget_error());
for lp in std::iter::once(&region.outer).chain(&region.holes) {
edges = edges.saturating_add(lp.segs.len());
if edges > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
points = points.saturating_add(flat_polyline(lp)?.len());
if points > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
}
}
Ok((edges, points))
}
fn material_boolean(a: &[Region2], b: &[Region2], op: kernel::BoolOp, work: &mut FlatWork) -> Result<Vec<Region2>> {
pub(crate) fn material_boolean(a: &[Region2], b: &[Region2], op: kernel::BoolOp, work: &mut FlatWork) -> Result<Vec<Region2>> {
let (ae, ap) = region_complexity(a)?;
let (be, bp) = region_complexity(b)?;
let edges = ae.saturating_add(be);
@@ -112,7 +120,7 @@ fn material_boolean(a: &[Region2], b: &[Region2], op: kernel::BoolOp, work: &mut
// Merge in balanced rounds so hundreds of disjoint holes or adjoining panels do not
// repeatedly arrange a growing accumulated boundary against just one small region.
fn material_union(regions: Vec<Region2>, work: &mut FlatWork) -> Result<Vec<Region2>> {
pub(crate) fn material_union(regions: Vec<Region2>, work: &mut FlatWork) -> Result<Vec<Region2>> {
region_complexity(&regions)?;
let mut groups: Vec<Vec<Region2>> = regions.into_iter().map(|r| vec![r]).collect();
while groups.len() > 1 {
@@ -290,6 +298,10 @@ pub struct SheetBody {
pub flanges: Vec<SheetFlange>,
/// Cut-outs (flat coordinates) through the sheet.
pub holes: Vec<Region2>,
/// Planar machining in flat coordinates, with depths measured from the flat bottom.
/// Derived from feature history; these records are not a document schema change.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub pockets: Vec<SheetPocket>,
/// Unfolded.
pub flat: bool,
/// Gap between flanges that meet at a corner.
@@ -299,6 +311,13 @@ pub struct SheetBody {
pub base_curves: Vec<(String, Vec2, Vec2)>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct SheetPocket {
pub region: Region2,
pub z0: f64,
pub z1: f64,
}
/// A piece of the sheet: a panel (None = the base) or a flange's bend zone.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Piece {
@@ -564,6 +583,7 @@ impl SheetBody {
}
cuts.push(region);
}
cuts.extend(self.through_pocket_regions(work)?);
let cuts = material_union(cuts, work)?;
let mut out = Vec::new();
let mut points = 0usize;
@@ -734,20 +754,148 @@ impl SheetBody {
}
let mut work = FlatWork::new();
let cells = self.cells_with_work(&mut work)?;
Ok(kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?)
let body = kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?;
self.machine_pockets(body, &mut work)
}
/// The flat pattern as one extrusion (outline with its cut-outs).
pub fn flat_solid(&self) -> Result<Body> {
let regions = self.flat_regions()?;
let pl = plane_of(&self.frame_mat(), Vec3::ZERO, Vec3::X, Vec3::Y)?;
Ok(kernel::extrude_planar_regions(&pl, &regions, 0.0, self.t)?)
let body = kernel::extrude_planar_regions(&pl, &regions, 0.0, self.t)?;
self.machine_pockets(body, &mut FlatWork::new())
}
/// Replay exact planar machining after forming the full-thickness material.
fn machine_pockets(&self, mut body: Body, work: &mut FlatWork) -> Result<Body> {
if self.pockets.len() > 1000 {
return Err(flat_budget_error());
}
regions_complexity(self.pockets.iter().map(|p| &p.region))?;
let panels = if self.flat {
vec![(None, self.flat_regions()?)]
} else {
self.material_panels(None, work)?.into_iter().map(|(pi, r)| (pi, vec![r])).collect()
};
for pocket in &self.pockets {
if !pocket.z0.is_finite() || !pocket.z1.is_finite() || pocket.z0 < 0.0 || pocket.z1 > self.t || pocket.z0 >= pocket.z1 {
return Err(DocError::Invalid("invalid sheet pocket depth".into()));
}
if !self.flat {
for flange in &self.flanges {
if flat_regions_overlap(&pocket.region, &self.zone(flange), work)? {
return Err(DocError::Invalid(
"a sheet pocket crosses a bend zone; planar pocket machining cannot yet preserve curved bend material".into(),
));
}
}
}
for (pi, panel) in &panels {
let clipped = material_boolean(panel, std::slice::from_ref(&pocket.region), kernel::BoolOp::Intersect, work)?;
if clipped.is_empty() {
continue;
}
let w = if self.flat { self.frame_mat() } else { self.world(*pi) };
let plane = plane_of(&w, Vec3::ZERO, Vec3::X, Vec3::Y)?;
// BRep replay is costlier than planar clipping; share the command budget.
work.charge(100_000)?;
let tool = kernel::extrude_planar_regions(&plane, &clipped, pocket.z0, pocket.z1)?;
body = kernel::boolean(&body, &tool, kernel::BoolOp::Cut)?
.ok_or_else(|| DocError::Invalid("sheet pocket removes all sheet material".into()))?;
}
}
Ok(body)
}
/// Actual curved material, used to certify unsupported folded machining before mutation.
pub(crate) fn unmodeled_solid(&self, plane: &Plane) -> Result<Option<Body>> {
if self.flat || self.flanges.is_empty() {
return Ok(None);
}
let mut work = FlatWork::new();
let cells: Vec<_> = self
.cells_with_work(&mut work)?
.into_iter()
.filter(|c| match c.piece {
Piece::Zone(_) => true,
Piece::Panel(pi) => apply_vector(&self.world(pi), Vec3::Z).cross(plane.normal()).len() > 1e-6,
})
.collect();
if cells.is_empty() {
return Ok(None);
}
Ok(Some(kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?))
}
fn through_pocket_regions(&self, work: &mut FlatWork) -> Result<Vec<Region2>> {
if self.pockets.is_empty() {
return Ok(Vec::new());
}
if self.pockets.len() > 1000 {
return Err(flat_budget_error());
}
regions_complexity(self.pockets.iter().map(|p| &p.region))?;
let mut levels = vec![0.0, self.t];
for p in &self.pockets {
if !p.z0.is_finite() || !p.z1.is_finite() || p.z0 < 0.0 || p.z1 > self.t || p.z0 >= p.z1 {
return Err(DocError::Invalid("invalid sheet pocket depth".into()));
}
levels.extend([p.z0, p.z1]);
}
levels.sort_by(f64::total_cmp);
levels.dedup();
work.charge(levels.len().saturating_mul(self.pockets.len()))?;
// Most pockets leave an untouched thickness layer. Certify that cheap case
// before arranging hundreds of unrelated profiles at every depth level.
if levels.windows(2).any(|zs| !self.pockets.iter().any(|p| p.z0 <= zs[0] && p.z1 >= zs[1])) {
return Ok(Vec::new());
}
let mut through: Option<Vec<Region2>> = None;
for zs in levels.windows(2) {
let active: Vec<_> = self.pockets.iter().filter(|p| p.z0 <= zs[0] && p.z1 >= zs[1]).map(|p| p.region.clone()).collect();
if active.is_empty() {
return Ok(Vec::new());
}
let removed = material_union(active, work)?;
through = Some(match through {
None => removed,
Some(previous) => material_boolean(&previous, &removed, kernel::BoolOp::Intersect, work)?,
});
if through.as_ref().is_some_and(Vec::is_empty) {
return Ok(Vec::new());
}
}
Ok(through.unwrap_or_default())
}
/// Pocket operations clipped to the surviving flat material, with exact depth intervals.
pub fn machining(&self) -> Result<Vec<SheetPocket>> {
let material = self.flat_regions()?;
let mut work = FlatWork::new();
let mut out = Vec::new();
for pocket in &self.pockets {
for region in material_boolean(&material, std::slice::from_ref(&pocket.region), kernel::BoolOp::Intersect, &mut work)? {
if out.len() >= 1000 {
return Err(flat_budget_error());
}
out.push(SheetPocket { region, z0: pocket.z0, z1: pocket.z1 });
}
}
Ok(out)
}
/// Actual flat material boundaries after cut-outs. The same panel and cut regions build
/// the folded solid; planar unions remove panel/bend seams and turn edge-crossing cuts
/// into notches. Lines and circular arcs remain exact through clipping and merging.
pub fn flat_regions(&self) -> Result<Vec<Region2>> {
let regions = self.flat_material()?;
if regions.is_empty() {
return Err(DocError::Invalid("empty flat pattern".into()));
}
Ok(regions)
}
pub(crate) fn flat_material(&self) -> Result<Vec<Region2>> {
let mut work = FlatWork::new();
// Merge structural pieces before cutting: clipping each panel first introduces
// coincident notch endpoints at seams, while a single material cut has no seams.
@@ -783,11 +931,9 @@ impl SheetBody {
}
cuts.push(region);
}
cuts.extend(self.through_pocket_regions(&mut work)?);
let cuts = material_union(cuts, &mut work)?;
let regions = if cuts.is_empty() { regions } else { material_boolean(&regions, &cuts, kernel::BoolOp::Cut, &mut work)? };
if regions.is_empty() {
return Err(DocError::Invalid("empty flat pattern".into()));
}
Ok(regions)
}
/// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out.
@@ -1332,6 +1478,22 @@ impl SheetBody {
})
.collect()
}
pub(crate) fn material_panels(&self, plane: Option<&Plane>, work: &mut FlatWork) -> Result<Vec<(Option<usize>, Region2)>> {
Ok(self
.cells_with_work(work)?
.into_iter()
.filter_map(|c| {
let Piece::Panel(pi) = c.piece else {
return None;
};
if plane.is_some_and(|p| apply_vector(&self.world(pi), Vec3::Z).cross(p.normal()).len() > 1e-6) {
return None;
}
Some((pi, Region2 { outer: Loop2 { segs: c.outer }, holes: c.holes.into_iter().map(|segs| Loop2 { segs }).collect() }))
})
.collect())
}
}
fn point_in(poly: &[Vec2], p: Vec2) -> bool {
@@ -1465,6 +1627,7 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region
base: Region2 { outer: region.outer, holes: Vec::new() },
flanges: Vec::new(),
holes,
pockets: Vec::new(),
flat: false,
gap: rv.gap,
base_curves: Vec::new(),
@@ -1516,6 +1679,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
base: Region2 { outer: Loop2 { segs: Vec::new() }, holes: Vec::new() },
flanges: Vec::new(),
holes: Vec::new(),
pockets: Vec::new(),
flat: false,
gap: rv.gap,
base_curves: Vec::new(),
@@ -1780,4 +1944,23 @@ mod tests {
assert!(s.faces(&cells, 0.0, 2.5, &mut FlatWork::new()).unwrap_err().to_string().contains("budget"));
assert!(s.faces(&cells[..1], 0.0, 2.5, &mut FlatWork(0)).unwrap_err().to_string().contains("budget"));
}
#[test]
fn pocket_geometry_and_depth_budgets_are_checked_before_replay() {
let pocket = SheetPocket {
region: rect(Vec2::new(20.0, 20.0), Vec2::new(30.0, 20.0), Vec2::new(30.0, 30.0), Vec2::new(20.0, 30.0)),
z0: 0.0,
z1: 1.0,
};
let mut s = plate(100.0, 60.0);
s.pockets = vec![pocket.clone(); 1001];
assert!(s.flat_solid().unwrap_err().to_string().contains("budget"));
s.pockets = vec![pocket.clone()];
s.pockets[0].region.outer.segs = vec![Seg2::Line { a: Vec2::ZERO, b: Vec2::X }; MAX_FLAT_POINTS + 1];
assert!(s.flat_solid().unwrap_err().to_string().contains("budget"));
for (z0, z1) in [(f64::NAN, 1.0), (-1.0, 1.0), (0.0, 3.0), (1.0, 1.0)] {
s.pockets = vec![SheetPocket { z0, z1, ..pocket.clone() }];
assert!(s.flat_solid().unwrap_err().to_string().contains("depth"));
}
}
}