Merge remote-tracking branch 'origin/main' into feat/profiles-by-curve-ids

# Conflicts:
#	crates/doc/src/lib.rs
#	crates/sketch/src/lib.rs
This commit is contained in:
CypherNaugh_0x committed 2026-10-11 13:07:46 +02:00
commit 184e301c46
180 files changed
+29315 -1568

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
//! Presentation/projection state for an exact selected planar source placement.
use crate::{DocError, Document, FaceAnchor, ModelBody, ModelState, Result};
use std::borrow::Cow;
pub fn state_in_face_anchor_frame<'a>(doc: &Document, state: &'a ModelState, anchor: &FaceAnchor, author: u64) -> Result<Cow<'a, ModelState>> {
let frame = anchor.author_frame(doc, author)?;
let raw = state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("anchored body `{}`", anchor.target.body)))?;
let relative = anchor.source_to_author(doc, author, raw)?;
let mut output = crate::state_in_frame_at(doc, state, author, frame).into_owned();
let shape = if crate::is_identity(&relative) { raw.body.clone() } else { solvecraft_kernel::transform_matrix(&raw.body, relative)? };
let mut body = ModelBody::new(raw.name.clone(), shape, raw.feature);
body.component = raw.component;
body.transferred = raw.transferred;
body.source_name = raw.source_name.clone();
body.source_transform = crate::mat_mul(&relative, &raw.source_transform);
body.imported = raw.imported.clone();
body.names = crate::naming::NameCell::known(crate::naming::face_names(raw));
let slot = output.bodies.iter_mut().find(|body| body.name == raw.name).ok_or_else(|| DocError::Unknown("anchored body frame".into()))?;
*slot = body;
Ok(Cow::Owned(output))
}
+51 -3
View File
@@ -45,6 +45,15 @@ pub struct FaceLook {
/// The face's persistent name (found first).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// 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 {
!*value
}
/// The look a body was imported with, carried through later features (which build new kernel
@@ -130,6 +139,24 @@ 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> {
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 {
self.model_body_look(body)
.map(|look| look.opacity)
.or_else(|| body.body.paint().map(|paint| paint.opacity as f64))
.or_else(|| body.imported.as_ref().and_then(|paint| paint.opacity.map(f64::from)))
.unwrap_or(1.0)
}
/// A body's look (without face appearances): its own, else its component's, else its
/// material's.
pub fn body_look(&self, name: &str, feature: u64) -> Option<Look> {
@@ -167,8 +194,27 @@ impl Document {
None => Vec::new(),
},
};
for f in self.appearances.faces.iter().filter(|f| f.body == b.name) {
let (p, look) = (f.point, f.look.clone());
for f in self.appearances.faces.iter().filter(|f| f.body == b.definition_name()) {
let (p, look) = (crate::apply_point(&b.source_transform, f.point), f.look.clone());
if f.strict_name {
let Some(name) = f.name.as_ref() else { continue };
let Some(origin_feature) = name.strip_prefix('F').and_then(|name| name.split_once(':')).and_then(|(id, _)| id.parse().ok()) else {
continue;
};
let target = crate::config::ConfigFaceTarget {
body: f.body.clone(),
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 {
out.retain(|(old, _)| *old != index);
out.push((index, look.clone()));
}
continue;
}
// By name (every piece of a split face), else by the point.
if let Some(n) = &f.name {
let stem = crate::naming::strip_piece(n);
@@ -181,7 +227,9 @@ impl Document {
continue;
}
}
if let Some(i) = face_index_at(b, p) {
if !f.strict_name
&& let Some(i) = face_index_at(b, p)
{
out.retain(|(j, _)| *j != i);
out.push((i, look));
}
+47 -9
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);
@@ -385,24 +386,61 @@ impl Document {
/// further instance), its component, the occurrence path, and the transform from the frame
/// its geometry is in to the world.
pub fn body_frames(&self, st: &crate::ModelState) -> Vec<(String, u64, Vec<u64>, Mat)> {
self.body_frames_with_source(st).into_iter().map(|(world, _, component, path, matrix)| (world, component, path, matrix)).collect()
}
/// Body frames with explicit definition names; callers must not infer them from labels.
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() {
let primary = path == self.first_path(comp);
let label = || -> String {
path.iter().filter_map(|id| self.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect::<Vec<_>>().join("/")
};
for b in st.bodies.iter().filter(|b| self.body_component(&b.name, b.feature) == comp) {
let name = if primary { b.name.clone() } else { format!("{} ({})", b.name, label()) };
let w = match self.body_offsets.get(&b.name) {
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.model_body_offset(b) {
Some(o) => mat_mul(&m, o),
None => m,
};
out.push((name, comp, path.clone(), w));
out.push((name, b.definition_name().to_string(), comp, path.clone(), w));
}
}
out
}
/// Computed placement name. Source names retain priority; ambiguous labels use full IDs.
/// Resolves label ambiguity with bounded dynamic counts instead of expanding all placements.
pub fn world_body_name(&self, st: &crate::ModelState, source: &str, component: u64, path: &[u64]) -> String {
if path == self.first_path(component) {
return source.to_string();
}
let names: Vec<&str> = path.iter().filter_map(|id| self.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect();
let label = names.join("/");
let candidate = format!("{source} ({label})");
let mut counts = std::collections::BTreeMap::from([(0, 1usize)]);
for name in &names {
let mut next = std::collections::BTreeMap::<u64, usize>::new();
for o in self.occurrences.iter().filter(|o| o.name == *name) {
if let Some(count) = counts.get(&o.parent) {
let n = next.entry(o.component).or_default();
*n = (*n + count).min(2);
}
}
counts = next;
}
let ambiguous = counts.get(&component).copied().unwrap_or(0) > 1;
// Slash and reserved-marker names could alias concatenated labels or fallback keys.
let reserved =
self.occurrences.iter().any(|o| o.name.contains('/') || o.name.contains("@occ[")) || st.bodies.iter().any(|b| b.name.contains("@occ["));
if !ambiguous && !reserved && st.body(&candidate).is_none() {
return candidate;
}
let ids = path.iter().map(u64::to_string).collect::<Vec<_>>().join("/");
let encoded = serde_json::to_string(source).unwrap_or_else(|_| source.to_string());
let mut key = format!("{encoded}@occ[{ids}]");
while st.body(&key).is_some() {
key.push('#');
}
key
}
/// The occurrence path of a component's first placement.
pub fn first_path(&self, component: u64) -> Vec<u64> {
let mut path = Vec::new();
+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(())
}
+387
View File
@@ -49,7 +49,10 @@ impl FeatureKind {
FeatureKind::Extrude { sketch, .. }
| FeatureKind::Revolve { sketch, .. }
| FeatureKind::Emboss { sketch, .. }
| FeatureKind::SheetBase { sketch, .. }
| FeatureKind::SheetContour { sketch, .. }
| FeatureKind::Rib { sketch, .. } => sk(sketch),
FeatureKind::Patch { sketch: Some(s), .. } => sk(s),
FeatureKind::Sweep { sketch, path_sketch, .. } => {
sk(sketch);
sk(path_sketch);
@@ -74,3 +77,387 @@ impl FeatureKind {
}
}
}
/// Remap persistent topology tokens without rewriting arbitrary user expressions or labels.
pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<String, String>) -> String {
// Imported names belong to the complete canonical body prefix, even when that prefix
// happens to contain text resembling a feature token.
if let Some((body, suffix)) = name.rsplit_once(":face:")
&& let Some(new) = bodies.get(body)
{
return format!("{new}:face:{suffix}");
}
let mut out = String::new();
let mut at = 0;
while at < name.len() {
let rest = &name[at..];
if rest.starts_with('F') && (at == 0 || matches!(name.as_bytes().get(at - 1), Some(b'@') | Some(b':') | Some(b'|'))) {
let end = rest.as_bytes().iter().skip(1).take_while(|b| b.is_ascii_digit()).count() + 1;
if end > 1
&& matches!(rest.as_bytes().get(end), Some(b':') | Some(b'.'))
&& let Ok(id) = rest[1..end].parse::<u64>()
&& let Some(new) = ids.get(&id)
{
out.push_str(&format!("F{new}"));
at += end;
continue;
}
}
let Some(ch) = rest.chars().next() else { break };
out.push(ch);
at += ch.len_utf8();
}
// Import faces have a canonical body prefix rather than a feature token.
if let Some((body, suffix)) = out.rsplit_once(":face:")
&& let Some(new) = bodies.get(body)
{
return format!("{new}:face:{suffix}");
}
out
}
fn mapped_name(n: &mut String, names: &BTreeMap<String, String>) {
if let Some(new) = names.get(n) {
*n = new.clone();
}
}
/// Simultaneous generated-parameter substitution: map values may themselves be old keys.
pub fn clone_expression(source: &str, parameters: &BTreeMap<String, String>) -> String {
let mut result = source.to_string();
let mut temporary = Vec::new();
for (index, (old, new)) in parameters.iter().enumerate() {
let mut token = format!("__clone_reference_{index}");
while source.contains(&token) || parameters.values().any(|n| n == &token) {
token.push('_');
}
result = crate::expr::rename_reference(&result, old, &token);
temporary.push((token, new));
}
for (token, new) in temporary {
result = crate::expr::rename_reference(&result, &token, new);
}
result
}
fn remap_geo(
g: &mut crate::construct::GeoRef,
ids: &BTreeMap<u64, u64>,
names: &BTreeMap<String, String>,
bodies: &BTreeMap<String, String>,
depth: usize,
) -> crate::Result<()> {
use crate::construct::GeoRef;
if depth > 64 {
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
}
match g {
GeoRef::SketchPoint { sketch, .. } | GeoRef::SketchCurve { sketch, .. } => {
if let Some(new) = ids.get(sketch) {
*sketch = *new;
}
}
GeoRef::Face { name: Some(n), .. } | GeoRef::Edge { name: Some(n), .. } => *n = remap_topology(n, ids, bodies),
GeoRef::ConstructPoint { name } | GeoRef::Axis { name } => mapped_name(name, names),
GeoRef::Plane { plane } => remap_plane(plane, ids, names, bodies, depth + 1)?,
_ => {}
}
Ok(())
}
fn remap_plane(
p: &mut PlaneRef,
ids: &BTreeMap<u64, u64>,
names: &BTreeMap<String, String>,
bodies: &BTreeMap<String, String>,
depth: usize,
) -> crate::Result<()> {
if depth > 64 {
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
}
match p {
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => remap_plane(base, ids, names, bodies, depth + 1)?,
PlaneRef::Construction { name } => mapped_name(name, names),
PlaneRef::Face { name, anchor, .. } => {
if let Some(name) = name {
*name = remap_topology(name, ids, bodies);
}
if let Some(anchor) = anchor {
anchor.remap_target(ids, bodies)?;
}
}
_ => {
for r in p.refs_mut() {
remap_geo(r, ids, names, bodies, depth + 1)?;
}
}
}
Ok(())
}
fn remap_link(
source: &mut solvecraft_sketch::LinkSource,
ids: &BTreeMap<u64, u64>,
names: &BTreeMap<String, String>,
bodies: &BTreeMap<String, String>,
depth: usize,
) {
use solvecraft_sketch::LinkSource;
if depth > 64 {
return;
}
match source {
LinkSource::Edge { body, .. }
| LinkSource::Face { body, .. }
| LinkSource::Body { body }
| LinkSource::Vertex { body, .. }
| LinkSource::Iso { body, .. }
| LinkSource::Spun { body, .. } => mapped_name(body, bodies),
LinkSource::SketchCurve { sketch, .. } | LinkSource::SketchPoint { sketch, .. } => {
if let Some(new) = ids.get(sketch) {
*sketch = *new;
}
}
LinkSource::OnSurface { sketch, body, .. } => {
if let Some(new) = ids.get(sketch) {
*sketch = *new;
}
mapped_name(body, bodies);
}
LinkSource::Intersection { a, b } => {
remap_link(a, ids, names, bodies, depth + 1);
remap_link(b, ids, names, bodies, depth + 1);
}
LinkSource::Plane { name } => mapped_name(name, names),
_ => {}
}
}
impl crate::Feature {
/// Clone identities through typed reference fields. Sketch-local entity IDs stay local.
pub fn remap_clone(
&mut self,
ids: &BTreeMap<u64, u64>,
names: &BTreeMap<String, String>,
bodies: &BTreeMap<String, String>,
parameters: &BTreeMap<String, String>,
) -> crate::Result<()> {
use FeatureKind as K;
match &self.kind {
K::Sketch { plane, sketch } => {
checked_plane(plane, 0)?;
for link in &sketch.links {
checked_link(&link.source, 0)?;
}
}
K::ConstructionPlane { plane }
| K::Split { plane, .. }
| K::SplitFace { plane, .. }
| K::Mirror { plane, .. }
| K::SurfaceTrim { plane, .. } => checked_plane(plane, 0)?,
K::Draft { neutral, .. } => checked_plane(neutral, 0)?,
K::ReplaceFace { target, .. } => checked_plane(target, 0)?,
K::ConstructionAxis { def } => {
for r in def.refs() {
checked_geo(r, 0)?;
}
}
K::ConstructionPoint { def } => {
for r in def.refs() {
checked_geo(r, 0)?;
}
}
_ => {}
}
self.kind.remap_refs(ids, names);
for n in &mut self.body_names {
mapped_name(n, bodies);
}
for n in &mut self.participants {
mapped_name(n, bodies);
}
for n in &mut self.param_names {
mapped_name(n, parameters);
}
for n in self.face_names.iter_mut().chain(&mut self.edge_names) {
*n = remap_topology(n, ids, bodies);
}
for (_, e, _) in self.kind.inputs_mut() {
*e = clone_expression(e, parameters);
}
for e in self.kind.point_expr_mut().into_iter().flatten() {
*e = clone_expression(e, parameters);
}
match &mut self.kind {
K::Emboss { face: Some(face), .. } => mapped_name(&mut face.body, bodies),
K::Split { tool: Some(tool), .. } | K::SplitFace { tool: Some(tool), .. } | K::SurfaceTrim { tool: Some(tool), .. } => {
mapped_name(&mut tool.body, bodies)
}
_ => {}
}
match &mut self.kind {
K::Extrude { targets, .. }
| K::Revolve { targets, .. }
| K::Loft { targets, .. }
| K::Sweep { targets, .. }
| K::Pipe { targets, .. }
| K::Emboss { targets, .. } => {
for n in targets {
mapped_name(n, bodies);
}
}
K::Combine { target, tools, .. } => {
mapped_name(target, bodies);
for n in tools {
mapped_name(n, bodies);
}
}
K::Pattern { bodies: list, .. }
| K::Mirror { bodies: list, .. }
| K::Scale { bodies: list, .. }
| K::BoundingSolid { bodies: list, .. }
| K::Align { bodies: list, .. }
| K::Remove { bodies: list }
| K::Stitch { bodies: list, .. }
| K::Move { bodies: list, .. }
| K::BodyTransfer { bodies: list, .. } => {
for n in list {
mapped_name(n, bodies);
}
}
K::Thicken { bodies: list, targets, .. } => {
for n in list.iter_mut().chain(targets) {
mapped_name(n, bodies);
}
}
K::BoundaryFill { tools, .. } => {
for n in tools {
mapped_name(n, bodies);
}
}
K::Fillet { body, .. }
| K::Chamfer { body, .. }
| K::Shell { body, .. }
| K::Draft { body, .. }
| K::SplitFace { body, .. }
| K::OffsetFace { body, .. }
| K::DeleteFace { body, .. }
| K::ReplaceFace { body, .. }
| K::SheetFlange { body, .. }
| K::SheetHem { body, .. }
| K::SheetFold { body, .. }
| K::SheetUnfold { body, .. }
| K::Boss { body, .. }
| K::Lip { body, .. }
| K::SnapFit { body, .. }
| K::Rest { body, .. }
| K::Patch { body, .. } => {
if let Some(n) = body {
mapped_name(n, bodies);
}
}
K::Split { body, .. } | K::SheetConvert { body, .. } | K::SurfaceExtend { body, .. } | K::SurfaceTrim { body, .. } => {
mapped_name(body, bodies)
}
_ => {}
}
match &mut self.kind {
K::Sketch { plane, sketch } => {
remap_plane(plane, ids, names, bodies, 0)?;
for c in &mut sketch.constraints {
if let Some(n) = &mut c.param {
mapped_name(n, parameters);
}
}
for link in &mut sketch.links {
remap_link(&mut link.source, ids, names, bodies, 0);
if let Some(n) = &mut link.face_name {
*n = remap_topology(n, ids, bodies);
}
}
}
K::ConstructionPlane { plane }
| K::Split { plane, .. }
| K::SplitFace { plane, .. }
| K::Mirror { plane, .. }
| K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, names, bodies, 0)?,
K::Draft { neutral, .. } => remap_plane(neutral, ids, names, bodies, 0)?,
K::ReplaceFace { target, .. } => remap_plane(target, ids, names, bodies, 0)?,
K::ConstructionAxis { def } => {
for r in def.refs_mut() {
remap_geo(r, ids, names, bodies, 0)?;
}
}
K::ConstructionPoint { def } => {
for r in def.refs_mut() {
remap_geo(r, ids, names, bodies, 0)?;
}
}
_ => {}
}
Ok(())
}
}
fn checked_geo(g: &crate::construct::GeoRef, depth: usize) -> crate::Result<()> {
if depth > 64 {
return Err(crate::DocError::Invalid("copy reference nesting exceeds 64".into()));
}
if let crate::construct::GeoRef::Plane { plane } = g {
checked_plane(plane, depth + 1)?;
}
Ok(())
}
fn checked_plane(p: &PlaneRef, depth: usize) -> crate::Result<()> {
if depth > 64 {
return Err(crate::DocError::Invalid("copy reference nesting exceeds 64".into()));
}
match p {
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => checked_plane(base, depth + 1)?,
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } => {
checked_geo(a, depth + 1)?;
checked_geo(b, depth + 1)?;
}
PlaneRef::Perpendicular { plane, line } => {
checked_geo(plane, depth + 1)?;
checked_geo(line, depth + 1)?;
}
PlaneRef::ThreePoints { a, b, c } => {
checked_geo(a, depth + 1)?;
checked_geo(b, depth + 1)?;
checked_geo(c, depth + 1)?;
}
PlaneRef::Tangent { face, .. } => checked_geo(face, depth + 1)?,
PlaneRef::AlongPath { path, .. } => checked_geo(path, depth + 1)?,
_ => {}
}
Ok(())
}
fn checked_link(source: &solvecraft_sketch::LinkSource, depth: usize) -> crate::Result<()> {
if depth > 64 {
return Err(crate::DocError::Invalid("copy link nesting exceeds 64".into()));
}
if let solvecraft_sketch::LinkSource::Intersection { a, b } = source {
checked_link(a, depth + 1)?;
checked_link(b, depth + 1)?;
}
Ok(())
}
#[cfg(test)]
mod clone_tests {
use super::*;
#[test]
fn imported_names_and_topology_tokens_have_separate_identity() {
let ids = BTreeMap::from([(1, 2), (10, 20)]);
let bodies = BTreeMap::from([("F1:Housing".into(), "F1:Housing (copy)".into())]);
assert_eq!(remap_topology("F1:Housing:face:0", &ids, &bodies), "F1:Housing (copy):face:0");
assert_eq!(remap_topology("F1:+x|F10:[email protected]", &ids, &bodies), "F2:+x|F20:[email protected]");
assert_eq!(remap_topology("UserF1:label", &ids, &bodies), "UserF1:label");
}
#[test]
fn parameter_remap_is_simultaneous_and_token_scoped() {
let map = BTreeMap::from([("d1".into(), "d2".into()), ("d2".into(), "d3".into())]);
assert_eq!(clone_expression("d1 + d2 + d10 + 2 mm", &map), "d2 + d3 + d10 + 2 mm");
}
}
+278 -25
View File
@@ -1,11 +1,24 @@
//! Configurations: a table whose rows are variants of the design. Each column is a parameter
//! (its expression per row) or a feature (suppressed or not per row); activating a row sets
//! those values in the design, which then rebuilds as usual.
//! expression, feature suppression, body material or body appearance. Activating a row sets
//! those values in the design atomically, which then rebuilds as usual.
use serde::{Deserialize, Serialize};
use crate::{DocError, Document, Result};
#[path = "config_rules.rs"]
mod rules;
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::*;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Column {
@@ -13,12 +26,20 @@ pub enum Column {
Param(String),
/// A feature's suppression (by feature id).
Suppress(u64),
/// A body's physical material (library name, or Default to clear).
Material(String),
/// A body's appearance (library name, serialized Look, or empty to clear).
Appearance(String),
/// Shared component definition's direct appearance.
ComponentAppearance(u64),
/// A certified semantic face lineage in a shared definition.
FaceAppearance(ConfigFaceTarget),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ConfigRow {
pub name: String,
/// One cell per column: an expression, or "true"/"false" for suppression.
/// One cell per column: expression, suppression, material name or appearance look.
pub cells: Vec<String>,
}
@@ -28,67 +49,235 @@ pub struct ConfigTable {
pub rows: Vec<ConfigRow>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rules: Vec<ConfigRule>,
}
pub const MAX_COLUMNS: usize = 500;
pub const MAX_ROWS: usize = 2000;
impl ConfigTable {
pub fn is_empty(&self) -> bool {
self.columns.is_empty() && self.rows.is_empty()
pub fn suppression_cell(value: &str) -> Result<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" => Ok(true),
"false" | "0" | "no" => Ok(false),
_ => Err(DocError::Invalid("suppression must be true or false".into())),
}
}
impl ConfigTable {
pub fn is_empty(&self) -> bool {
self.columns.is_empty() && self.rows.is_empty() && self.rules.is_empty()
}
}
/// Parse an appearance cell. Empty/Default follows the body's material; named library looks
/// and JSON Look objects support both convenient editing and lossless custom colours.
pub fn appearance_cell(value: &str) -> Result<Option<crate::appearance::Look>> {
let value = value.trim();
if value.is_empty() || value.eq_ignore_ascii_case("default") {
return Ok(None);
}
if value.len() > 4096 {
return Err(DocError::Invalid("appearance value too long".into()));
}
if let Some(look) = crate::appearance::library(value) {
return Ok(Some(look));
}
let look: crate::appearance::Look =
serde_json::from_str(value).map_err(|_| DocError::Invalid(format!("unknown configuration appearance `{value}`")))?;
if look.name.chars().count() > 128 || !look.opacity.is_finite() || !(0.0..=1.0).contains(&look.opacity) {
return Err(DocError::Invalid("appearance requires a name up to 128 characters and opacity in 0..1".into()));
}
Ok(Some(look))
}
/// Parse a physical material cell, returning None for the default material.
pub fn material_cell(value: &str) -> Result<Option<String>> {
let name = crate::MATERIALS
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(value.trim()))
.map(|(n, _)| *n)
.ok_or_else(|| DocError::Invalid(format!("unknown configuration material `{value}`")))?;
Ok((name != "Default").then(|| name.to_string()))
}
impl Document {
/// A column's current value in the design.
pub fn config_current(&self, c: &Column) -> Option<String> {
match c {
Column::Param(n) => self.all_param_exprs().into_iter().find(|(m, _, _)| m == n).map(|(_, e, _)| e),
Column::Suppress(id) => self.feature(*id).map(|f| f.suppressed.to_string()),
/// Freeze design-unit-sensitive configuration literals when changing display units.
/// Explicit units and nonliteral expressions retain their source text.
pub fn preserve_configuration_units(&mut self, previous_unit: &str, kept_parameters: &[String]) {
let feature_lengths: Vec<String> = self
.features
.iter()
.flat_map(|feature| {
feature
.param_names
.iter()
.zip(feature.kind.inputs())
.filter(|(_, (_, _, kind))| *kind == crate::expr::Kind::Length)
.map(|(name, _)| name.clone())
})
.collect();
let lengths: std::collections::BTreeSet<String> = feature_lengths.into_iter().chain(kept_parameters.iter().cloned()).collect();
for row in &mut self.configs.rows {
for (column, cell) in self.configs.columns.iter().zip(&mut row.cells) {
if matches!(column,Column::Param(name) if lengths.contains(name)) && cell.trim().parse::<f64>().is_ok() {
*cell = format!("{} {previous_unit}", cell.trim());
}
}
}
for rule in &mut self.configs.rules {
rule.preserve_length_units(&lengths, previous_unit);
}
}
/// A column's display key: `param:<name>` or `suppress:<feature name>`.
/// A column's current value in the design.
pub fn config_current(&self, c: &Column) -> Option<String> {
match c {
Column::Param(n) => self.all_param_exprs().into_iter().find(|(m, _, _)| m == n).map(|(_, e, kind)| {
// Feature literals are stored in base units, unlike newly typed bare
// row cells. Capture their unit explicitly before editing in an inch design.
if self.param(n).is_none() && kind == crate::expr::Kind::Length && self.units != "mm" && e.trim().parse::<f64>().is_ok() {
format!("{} mm", e.trim())
} else {
e
}
}),
Column::Suppress(id) => self.feature(*id).map(|f| f.suppressed.to_string()),
Column::Material(body) => Some(self.materials.get(body).cloned().unwrap_or_else(|| "Default".into())),
Column::Appearance(body) => Some(self.appearances.bodies.get(body).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default()),
Column::ComponentAppearance(id) => {
Some(self.appearances.components.get(id).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default())
}
Column::FaceAppearance(target) => {
Some(self.config_face_look(target).and_then(|look| serde_json::to_string(&look).ok()).unwrap_or_default())
}
}
}
/// A column's display key, prefixed by its kind to distinguish targets with equal names.
pub fn config_key(&self, c: &Column) -> String {
match c {
Column::Param(n) => format!("param:{n}"),
Column::Suppress(id) => format!("suppress:{}", self.feature(*id).map(|f| f.name.clone()).unwrap_or_else(|| id.to_string())),
Column::Material(body) => format!("material:{body}"),
Column::Appearance(body) => format!("appearance:{body}"),
Column::ComponentAppearance(id) => format!("component_appearance:{id}"),
Column::FaceAppearance(target) => format!("face_appearance:{}", target.key()),
}
}
/// Set the design to a row's values (all or nothing).
pub fn apply_configuration(&mut self, row: &str) -> Result<()> {
self.apply_configuration_with_trace(row).map(|_| ())
}
pub fn apply_configuration_with_trace(&mut self, row: &str) -> Result<Vec<RuleTrace>> {
let r = self.configs.rows.iter().find(|r| r.name == row).cloned().ok_or_else(|| DocError::Unknown(format!("configuration `{row}`")))?;
if self.configs.columns.len() > MAX_COLUMNS || self.configs.rows.len() > MAX_ROWS || r.cells.len() != self.configs.columns.len() {
return Err(DocError::Invalid("configuration table exceeds limits or has incomplete rows".into()));
}
let mut next = self.clone();
let changes: Vec<(String, String)> = self
.configs
.columns
.iter()
.zip(&r.cells)
.filter_map(|(c, v)| match c {
Column::Param(name) => Some((name.clone(), v.clone())),
_ => None,
})
.collect();
next.change_params_atomic(&changes).map_err(|e| DocError::Invalid(format!("configuration `{row}`: {e}")))?;
for (c, v) in self.configs.columns.iter().zip(&r.cells) {
if v.len() > 4096 {
return Err(DocError::Invalid("configuration value too long".into()));
}
match c {
Column::Param(n) => {
if next.config_current(c).as_deref() != Some(v.as_str()) {
next.change_param(n, v, None, None).map_err(|e| DocError::Invalid(format!("configuration `{row}`, parameter `{n}`: {e}")))?;
}
}
Column::Param(_) => {}
Column::Suppress(id) => {
let on = match v.trim() {
"true" | "1" | "yes" => true,
"false" | "0" | "no" => false,
o => return Err(DocError::Invalid(format!("configuration `{row}`: suppression must be true or false, not `{o}`"))),
};
let on = suppression_cell(v)?;
let f = next.feature_mut(*id).ok_or_else(|| DocError::Unknown(format!("configuration `{row}`: feature {id} is gone")))?;
f.suppressed = on;
}
Column::Material(body) => match material_cell(v)? {
Some(material) => {
next.materials.insert(body.clone(), material);
}
None => {
next.materials.remove(body);
}
},
Column::Appearance(body) => match appearance_cell(v)? {
Some(look) => {
next.appearances.bodies.insert(body.clone(), look);
}
None => {
next.appearances.bodies.remove(body);
}
},
Column::ComponentAppearance(id) => {
if *id == 0 || !next.components.iter().any(|component| component.id == *id) {
return Err(DocError::Unknown(format!("component {id} is gone")));
}
match appearance_cell(v)? {
Some(look) => {
next.appearances.components.insert(*id, look);
}
None => {
next.appearances.components.remove(id);
}
}
}
Column::FaceAppearance(target) => {
next.certify_config_face(target)?;
next.appearances.faces.retain(|face| !target.matches(face));
if let Some(look) = appearance_cell(v)? {
next.appearances.faces.push(crate::appearance::FaceLook {
body: target.body.clone(),
point: target.point,
look,
name: Some(target.name.clone()),
strict_name: true,
certificate: target.certificate.clone(),
});
}
}
}
}
next.configs.active = Some(row.to_string());
let trace = next.evaluate_configuration_rules()?;
*self = next;
Ok(())
Ok(trace)
}
/// The active row's values all hold in the design.
pub fn configuration_matches(&self) -> bool {
let Some(r) = self.configs.active.as_ref().and_then(|a| self.configs.rows.iter().find(|r| r.name == *a)) else { return false };
if r.cells.len() != self.configs.columns.len() {
return false;
}
if !self.configs.rules.is_empty() {
let mut expected = self.clone();
if expected.apply_configuration(&r.name).is_err() {
return false;
}
return self.configs.columns.iter().all(|column| self.config_current(column) == expected.config_current(column));
}
self.configs.columns.iter().zip(&r.cells).all(|(c, v)| match c {
Column::Suppress(_) => self.config_current(c).as_deref() == Some(v.trim()),
Column::Param(_) => self.config_current(c).is_some_and(|x| x.replace(' ', "") == v.replace(' ', "")),
Column::Suppress(id) => suppression_cell(v).is_ok_and(|on| self.feature(*id).is_some_and(|f| f.suppressed == on)),
Column::Param(name) => {
let kind = self
.features
.iter()
.find_map(|f| f.param_names.iter().position(|n| n == name).and_then(|k| f.kind.inputs().get(k).map(|input| input.2)));
let expected = kind.map_or_else(|| v.trim().to_string(), |kind| self.with_design_unit(v, kind));
self.config_current(c)
.is_some_and(|current| current.chars().filter(|c| !c.is_whitespace()).eq(expected.chars().filter(|c| !c.is_whitespace())))
}
Column::Material(body) => material_cell(v).is_ok_and(|m| self.materials.get(body) == m.as_ref()),
Column::Appearance(body) => appearance_cell(v).is_ok_and(|look| self.appearances.bodies.get(body) == look.as_ref()),
Column::ComponentAppearance(id) => appearance_cell(v).is_ok_and(|look| self.appearances.components.get(id) == look.as_ref()),
Column::FaceAppearance(target) => appearance_cell(v).is_ok_and(|look| self.config_face_look(target) == look),
})
}
}
@@ -97,6 +286,53 @@ impl Document {
mod tests {
use super::*;
#[test]
fn rows_use_final_graph_and_missing_targets_never_reappear() {
let mut d = Document::new("A");
d.set_param("a", "1 mm", None, None).unwrap();
d.set_param("b", "a", None, None).unwrap();
d.configs.columns = vec![Column::Param("a".into()), Column::Param("b".into())];
d.configs.rows.push(ConfigRow { name: "Reordered".into(), cells: vec!["b".into(), "2 mm".into()] });
d.apply_configuration("Reordered").unwrap();
assert!(d.configuration_matches());
assert_eq!(d.param_values().0["a"].v, 2.0);
d.configs.rows.push(ConfigRow { name: "Cycle".into(), cells: vec!["b".into(), "a".into()] });
let before = d.clone();
assert!(d.apply_configuration("Cycle").is_err());
assert_eq!(d, before);
d.params.retain(|p| p.name != "a");
let before = d.clone();
assert!(d.apply_configuration("Reordered").is_err());
assert_eq!(d, before);
}
#[test]
fn bulk_changes_reject_new_errors_in_unchanged_dependents() {
let mut d = Document::new("A");
d.set_param("length", "2 mm", None, None).unwrap();
d.set_param("twice", "length * 2", None, None).unwrap();
let before = d.clone();
assert!(d.change_params_atomic(&[("length".into(), "1 / 0".into())]).is_err());
assert_eq!(d, before);
assert!(d.change_params_atomic(&[("length".into(), "3 mm".into()), ("length".into(), "4 mm".into())]).is_err());
assert_eq!(d, before);
}
#[test]
fn unchanged_erroneous_configured_parameter_is_a_noop_but_changed_error_rejects() {
let mut d = Document::new("Existing broken design");
d.set_param("width", "10 mm", None, None).unwrap();
d.params[0].expr = "1/".into();
d.configs.columns = vec![Column::Param("width".into())];
d.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["1/".into()] });
d.apply_configuration("Default").unwrap();
assert!(d.configuration_matches());
d.configs.rows.push(ConfigRow { name: "Different error".into(), cells: vec!["missing + 1".into()] });
let before = d.clone();
assert!(d.apply_configuration("Different error").is_err());
assert_eq!(d, before);
}
#[test]
fn rows_switch_parameters() {
let mut d = Document::new("A");
@@ -114,4 +350,21 @@ mod tests {
assert!(d.apply_configuration("Bad").is_err());
assert_eq!(d, before, "all or nothing");
}
#[test]
fn invalid_saved_appearance_rolls_back_material_and_parameters() {
let mut d = Document::new("A");
d.set_param("width", "10 mm", None, None).unwrap();
d.configs.columns = vec![Column::Param("width".into()), Column::Material("Body".into()), Column::Appearance("Body".into())];
d.configs.rows.push(ConfigRow {
name: "Broken".into(),
cells: vec!["20 mm".into(), "Steel".into(), r#"{"name":"Invalid","color":[1,2,3],"opacity":2}"#.into()],
});
let before = d.clone();
assert!(d.apply_configuration("Broken").is_err());
assert_eq!(d, before);
assert!(appearance_cell(r#"{"name":"Invalid","color":[1,2,3],"opacity":1e999}"#).is_err());
assert!(appearance_cell(&"a".repeat(4097)).is_err());
assert!(appearance_cell("default").unwrap().is_none());
}
}
+414
View File
@@ -0,0 +1,414 @@
//! 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;
fn valid_signature(outer: &[String], holes: &[Vec<String>]) -> bool {
let valid = |ids: &[String]| {
!ids.is_empty() && ids.len() <= 2000 && ids.iter().all(|id| !id.is_empty() && id.len() <= 128) && ids.windows(2).all(|pair| pair[0] < pair[1])
};
valid(outer)
&& holes.len() <= 128
&& holes.iter().all(|ids| valid(ids))
&& outer.len() + holes.iter().map(Vec::len).sum::<usize>() <= 2000
&& holes.windows(2).all(|pair| pair[0] < pair[1])
&& holes.iter().flatten().all(|id| outer.binary_search(id).is_err())
&& holes.iter().flatten().collect::<std::collections::BTreeSet<_>>().len() == holes.iter().map(Vec::len).sum::<usize>()
}
fn valid_signature_json(outer: Option<&serde_json::Value>, holes: Option<&serde_json::Value>) -> bool {
let valid = |value: &serde_json::Value| {
value.as_array().is_some_and(|ids| {
!ids.is_empty()
&& ids.len() <= 2000
&& ids.iter().all(|id| id.as_str().is_some_and(|id| !id.is_empty() && id.len() <= 128))
&& ids.windows(2).all(|pair| pair[0].as_str() < pair[1].as_str())
})
};
let Some(outer) = outer.filter(|value| valid(value)).and_then(serde_json::Value::as_array) else {
return false;
};
let Some(holes) = holes.and_then(serde_json::Value::as_array).filter(|holes| holes.len() <= 128) else {
return false;
};
if !holes.iter().all(valid) {
return false;
}
let total = outer.len() + holes.iter().filter_map(serde_json::Value::as_array).map(Vec::len).sum::<usize>();
let ids: Vec<_> = outer.iter().filter_map(serde_json::Value::as_str).collect();
total <= 2000
&& holes
.iter()
.filter_map(serde_json::Value::as_array)
.flatten()
.filter_map(serde_json::Value::as_str)
.collect::<std::collections::BTreeSet<_>>()
.len()
== total - outer.len()
&& holes
.iter()
.filter_map(serde_json::Value::as_array)
.flatten()
.filter_map(serde_json::Value::as_str)
.all(|id| ids.binary_search(&id).is_err())
&& holes.windows(2).all(|pair| {
let a: Vec<_> = pair[0].as_array().into_iter().flatten().filter_map(serde_json::Value::as_str).collect();
let b: Vec<_> = pair[1].as_array().into_iter().flatten().filter_map(serde_json::Value::as_str).collect();
a < b
})
}
pub(crate) fn cap_key(outer: &[String], holes: &[Vec<String>]) -> String {
use sha2::{Digest, Sha256};
let mut hash = Sha256::new();
for ids in std::iter::once(outer).chain(holes.iter().map(Vec::as_slice)) {
hash.update((ids.len() as u64).to_le_bytes());
for id in ids {
hash.update((id.len() as u64).to_le_bytes());
hash.update(id.as_bytes());
}
}
format!("{:x}", hash.finalize())
}
pub(crate) fn exact_region_signature(sketch: &SolvedSketch, region: &solvecraft_geom::Region2) -> Option<solvecraft_kernel::CapSignature> {
if region.outer.segs.len() + region.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() > 4096 || region.holes.len() > 128 {
return None;
}
let signature = |outline: &solvecraft_geom::Loop2| {
let mut ids = outline.segs.iter().map(|segment| exact_sketch_curve(sketch, segment)).collect::<Option<Vec<_>>>()?;
ids.sort();
ids.dedup();
Some(ids)
};
let outer = signature(&region.outer)?;
let mut holes = region.holes.iter().map(signature).collect::<Option<Vec<_>>>()?;
holes.sort();
valid_signature(&outer, &holes).then_some((outer, holes))
}
/// Charge all source-curve comparisons before matching; over-budget tools stay usable but uncertified.
type CapBindings = Vec<Vec<Option<solvecraft_kernel::CapSignature>>>;
pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solvecraft_kernel::Body], budget: usize) -> Option<CapBindings> {
use solvecraft_kernel::FaceSource;
if sketch.sketch.curves.len() > 2000 {
return None;
}
let mut faces = 0usize;
let mut comparisons = 0usize;
for body in bodies {
for source in body.face_sources().into_iter().flatten() {
faces = faces.checked_add(1)?;
if faces > 50_000 {
return None;
}
let segments = match source {
Some(FaceSource::ExtrudeSide { .. }) => 1,
Some(FaceSource::ExtrudeCap { region, .. }) => {
region.outer.segs.len().checked_add(region.holes.iter().map(|hole| hole.segs.len()).sum::<usize>())?
}
_ => 0,
};
comparisons = comparisons.checked_add(segments.checked_mul(sketch.sketch.curves.len())?)?;
if comparisons > budget {
return None;
}
}
}
let mut counts = std::collections::BTreeMap::new();
let mut bindings = Vec::with_capacity(bodies.len());
for body in bodies {
let mut cached: Option<(&solvecraft_geom::Region2, Option<solvecraft_kernel::CapSignature>)> = None;
let mut values = Vec::new();
for source in body.face_sources().into_iter().flatten() {
let signature = if let Some(FaceSource::ExtrudeCap { region, high, .. }) = source {
let signature = match &cached {
Some((previous, signature)) if *previous == region => signature.clone(),
_ => exact_region_signature(sketch, region),
};
cached = Some((region, signature.clone()));
if let Some((outer, holes)) = &signature {
*counts.entry((*high, outer.clone(), holes.clone())).or_insert(0usize) += 1;
}
signature
} else {
None
};
values.push(signature);
}
bindings.push(values);
}
for (body, values) in bodies.iter().zip(&mut bindings) {
for (source, value) in body.face_sources().into_iter().flatten().zip(values) {
if let (Some(FaceSource::ExtrudeCap { high, .. }), Some((outer, holes))) = (source, value.as_ref())
&& counts.get(&(*high, outer.clone(), holes.clone())) != Some(&1)
{
*value = None;
}
}
}
Some(bindings)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum FaceCertificate {
Box { axis: u8, high: bool },
ExtrudeCap { sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>> },
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::ExtrudeCap { sketch, .. } = self
&& let Some(mapped) = features.get(sketch)
{
*sketch = *mapped;
}
}
pub fn validate(&self) -> Result<()> {
let valid = match self {
Self::Box { axis, .. } => *axis <= 2,
Self::ExtrudeCap { sketch, outer, holes, .. } => *sketch != 0 && valid_signature(outer, holes),
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("box") => {
fields.len() == 3
&& fields.keys().all(|key| ["kind", "axis", "high"].contains(&key.as_str()))
&& fields.get("axis").and_then(serde_json::Value::as_u64).is_some_and(|axis| axis <= 2)
&& fields.get("high").and_then(serde_json::Value::as_bool).is_some()
}
Some("extrude_cap") => {
fields.len() == 5
&& fields.keys().all(|key| ["kind", "sketch", "high", "outer", "holes"].contains(&key.as_str()))
&& nonzero("sketch")
&& fields.get("high").and_then(serde_json::Value::as_bool).is_some()
&& valid_signature_json(fields.get("outer"), fields.get("holes"))
}
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, .. })
| Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { producer, .. })
| Some(solvecraft_kernel::FaceSource::Box { producer: Some(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 Ok(None),
};
certificate.validate()?;
Ok(Some(certificate))
}
pub fn strong_face_target(&self, body: &ModelBody, index: usize, point: solvecraft_geom::Vec3) -> Result<crate::config::ConfigFaceTarget> {
let mut target = self.configuration_face_target(body, index, point)?;
match body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) {
Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), axis, high, .. }) => {
target.origin_feature = *producer;
let axis_name = ["x", "y", "z"].get(*axis as usize).ok_or_else(|| DocError::Invalid("invalid box face axis".into()))?;
target.name = format!("F{producer}:{}{axis_name}", if *high { "+" } else { "-" });
target.certificate = Some(FaceCertificate::Box { axis: *axis, high: *high });
}
Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, .. }) => {
target.origin_feature = *producer;
target.name = format!("F{producer}:cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes));
target.certificate = Some(FaceCertificate::ExtrudeCap { sketch: *sketch, high: *high, outer: outer.clone(), holes: holes.clone() });
}
_ => {}
}
self.certify_config_face(&target)?;
Ok(target)
}
}
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.)));
}
}
+218
View File
@@ -0,0 +1,218 @@
//! Configuration graph remapping for independent component copies.
use super::{Column, ConfigTable};
use crate::{DocError, Result};
use std::collections::BTreeMap;
#[derive(Clone, Debug, Default)]
pub struct ConfigCloneMaps {
pub features: BTreeMap<u64, u64>,
pub components: BTreeMap<u64, u64>,
pub bodies: BTreeMap<String, String>,
pub parameters: BTreeMap<String, String>,
}
/// Simultaneous token-reference substitution, including maps whose values are other keys.
pub(super) fn remap_expression(source: &str, parameters: &BTreeMap<String, String>) -> String {
let refs = crate::expr::references(source);
let mut result = source.to_string();
let mut replacements = Vec::new();
for (index, reference) in refs.iter().enumerate() {
if let Some(mapped) = parameters.get(reference) {
let mut temporary = format!("_config_clone_{index}");
while refs.contains(&temporary) || parameters.contains_key(&temporary) || parameters.values().any(|value| value == &temporary) {
temporary.push('_');
}
result = crate::expr::rename_reference(&result, reference, &temporary);
replacements.push((temporary, mapped));
}
}
for (temporary, mapped) in replacements {
result = crate::expr::rename_reference(&result, &temporary, mapped);
}
result
}
impl ConfigTable {
/// Append mapped columns/cells/rules atomically; source targets and active row remain intact.
pub fn clone_targets(&mut self, maps: &ConfigCloneMaps) -> Result<()> {
if maps.features.values().chain(maps.components.values()).any(|id| *id == 0)
|| maps.parameters.values().any(|name| crate::Document::param_name_problem(name).is_some())
|| maps.bodies.values().any(|name| name.is_empty() || name.len() > 128)
{
return Err(DocError::Invalid("invalid configuration clone identity mapping".into()));
}
if self.columns.len() > super::MAX_COLUMNS
|| self.rows.len() > super::MAX_ROWS
|| self.rules.len() > super::MAX_RULES
|| self.rows.iter().any(|row| row.cells.len() != self.columns.len())
{
return Err(DocError::Invalid("cannot clone an incomplete configuration table".into()));
}
let mut next = self.clone();
for (index, column) in self.columns.iter().enumerate() {
if let Column::FaceAppearance(target) = column
&& maps.bodies.contains_key(&target.body)
&& !maps.features.contains_key(&target.origin_feature)
{
return Err(DocError::Invalid(format!(
"cannot copy configured face `{}`: imported foreign body has no certified cloned lineage",
target.name
)));
}
let mapped = match column {
Column::Param(name) => maps.parameters.get(name).cloned().map(Column::Param),
Column::Suppress(id) => maps.features.get(id).copied().map(Column::Suppress),
Column::Material(name) => maps.bodies.get(name).cloned().map(Column::Material),
Column::Appearance(name) => maps.bodies.get(name).cloned().map(Column::Appearance),
Column::ComponentAppearance(id) => maps.components.get(id).copied().map(Column::ComponentAppearance),
Column::FaceAppearance(target) => {
maps.bodies.get(&target.body).zip(maps.features.get(&target.origin_feature)).map(|(body, origin)| {
let mut target = target.clone();
target.body = body.clone();
target.name = target
.name
.strip_prefix(&format!("F{}:", target.origin_feature))
.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)
})
}
};
if let Some(mapped) = mapped {
if mapped == *column {
continue;
}
if next.columns.iter().any(|column| {
*column == mapped
|| matches!((column,&mapped),(Column::FaceAppearance(a),Column::FaceAppearance(b)) if a.body == b.body && a.name == b.name)
}) {
return Err(DocError::Invalid("configuration copy target collides with an existing column".into()));
}
if next.columns.len() >= super::MAX_COLUMNS {
return Err(DocError::Invalid("configuration copy exceeds column limit".into()));
}
next.columns.push(mapped);
for (source, destination) in self.rows.iter().zip(&mut next.rows) {
let value = source.cells.get(index).cloned().unwrap_or_default();
let value = if matches!(column, Column::Param(_)) { remap_expression(&value, &maps.parameters) } else { value };
if value.len() > 4096 {
return Err(DocError::Invalid("configuration copied value exceeds length limit".into()));
}
destination.cells.push(value);
}
}
}
for rule in &self.rules {
if let Some(mut copied) = rule.clone_for_targets(maps) {
if next.rules.len() >= super::MAX_RULES {
return Err(DocError::Invalid("configuration copy exceeds rule limit".into()));
}
let stem: String = rule.id.chars().take(40).collect();
copied.id = (1..=super::MAX_RULES + 1)
.map(|i| format!("{stem}_copy{i}"))
.find(|id| !next.rules.iter().any(|rule| rule.id == *id))
.ok_or_else(|| DocError::Invalid("no configuration rule ID available".into()))?;
copied.name = format!("{} (copy)", rule.name.chars().take(120).collect::<String>());
next.rules.push(copied);
}
}
super::validate_rules_json(&serde_json::json!({"configs":{"rules":next.rules}}))?;
*self = next;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ConfigFaceTarget, ConfigRow, ConfigRule, RuleCondition, RuleEffect};
#[test]
fn clone_preserves_source_variations_and_remaps_only_typed_targets_and_tokens() {
let mut table = ConfigTable {
columns: vec![
Column::Param("d1".into()),
Column::Param("d2".into()),
Column::Appearance("Old".into()),
Column::ComponentAppearance(2),
Column::FaceAppearance(ConfigFaceTarget {
body: "Old".into(),
origin_feature: 3,
name: "F3:+z".into(),
point: solvecraft_geom::Vec3::new(1., 1., 2.),
certificate: None,
}),
],
rows: vec![ConfigRow {
name: "Default".into(),
cells: vec!["d1 * 2 + d2".into(), "4 mm".into(), "d1".into(), String::new(), String::new()],
}],
active: Some("Default".into()),
rules: vec![ConfigRule {
id: "Rule1".into(),
name: "Rule1".into(),
enabled: true,
length_unit: Some("mm".into()),
condition: RuleCondition::Suppression { feature: 3, value: false },
effect: RuleEffect::DriveParameter { parameter: "d1".into(), expression: "d2 * 2 + d1".into() },
}],
};
let original = table.clone();
let maps = ConfigCloneMaps {
features: BTreeMap::from([(3, 7)]),
components: BTreeMap::from([(2, 8)]),
bodies: BTreeMap::from([("Old".into(), "New".into())]),
parameters: BTreeMap::from([("d1".into(), "copy_d1".into()), ("d2".into(), "copy_d2".into())]),
};
table.clone_targets(&maps).unwrap();
assert_eq!(&table.columns[..5], &original.columns);
assert_eq!(&table.rows[0].cells[..5], &original.rows[0].cells);
assert_eq!(table.columns[5], Column::Param("copy_d1".into()));
assert_eq!(table.rows[0].cells[5], "copy_d1 * 2 + copy_d2");
assert_eq!(table.rows[0].cells[7], "d1"); // appearance strings are never parameter expressions
assert_eq!(table.columns[8], Column::ComponentAppearance(8));
let Column::FaceAppearance(face) = &table.columns[9] else { panic!() };
assert_eq!(face.name, "F7:+z");
assert_eq!(face.body, "New");
assert_eq!(table.rules[0], original.rules[0]);
assert!(
matches!(&table.rules[1].effect,RuleEffect::DriveParameter{parameter,expression} if parameter=="copy_d1" && expression=="copy_d2 * 2 + copy_d1")
);
assert!(matches!(table.rules[1].condition, RuleCondition::Suppression { feature: 7, .. }));
assert_eq!(table.active, original.active);
let chained = BTreeMap::from([("d1".into(), "d2".into()), ("d2".into(), "d3".into())]);
assert_eq!(remap_expression("d1 * 2 + d2", &chained), "d2 * 2 + d3");
let collision = ConfigCloneMaps { parameters: chained, ..Default::default() };
let mut original_copy = original.clone();
assert!(original_copy.clone_targets(&collision).is_err());
assert_eq!(original_copy, original);
}
#[test]
fn foreign_strict_lineage_failure_is_atomic() {
let mut table = ConfigTable {
columns: vec![
Column::Param("width".into()),
Column::FaceAppearance(ConfigFaceTarget {
body: "Foreign".into(),
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()] }],
..Default::default()
};
let original = table.clone();
let maps = ConfigCloneMaps {
bodies: BTreeMap::from([("Foreign".into(), "Copied".into())]),
parameters: BTreeMap::from([("width".into(), "copied_width".into())]),
..Default::default()
};
assert!(table.clone_targets(&maps).unwrap_err().to_string().contains("certified"));
assert_eq!(table, original);
}
}
+684
View File
@@ -0,0 +1,684 @@
//! Bounded typed configuration rules. The saved row remains the editable source;
//! ordered rules derive an effective design without overwriting its cells.
use super::Column;
use crate::{DocError, Document, Result, expr};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
pub const MAX_RULES: usize = 128;
const MAX_NODES: usize = 64;
const MAX_DEPTH: usize = 8;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigRule {
pub id: String,
pub name: String,
#[serde(default = "enabled")]
pub enabled: bool,
/// Unit context for feature-length expressions, fixed when authoring a rule.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub length_unit: Option<String>,
pub condition: RuleCondition,
pub effect: RuleEffect,
}
fn enabled() -> bool {
true
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum RuleCondition {
Always,
All { conditions: Vec<RuleCondition> },
Any { conditions: Vec<RuleCondition> },
Not { condition: Box<RuleCondition> },
Parameter { parameter: String, operator: RuleOperator, expression: String },
Suppression { feature: u64, value: bool },
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuleOperator {
Eq,
Ne,
Lt,
Le,
Gt,
Ge,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum RuleEffect {
DriveParameter { parameter: String, expression: String },
DriveSuppression { feature: u64, value: bool },
ParameterRange { parameter: String, minimum: String, maximum: String },
AllowParameterValues { parameter: String, expressions: Vec<String> },
}
#[derive(Clone, Debug, Serialize)]
pub struct RuleTrace {
pub id: String,
pub name: String,
pub matched: bool,
pub effect: RuleEffect,
}
/// Check saved rule structure before recursive serde decoding. Absence is backward
/// compatible; malformed present data is refused. Runtime evaluation checks it again.
pub fn validate_rules_json(document: &Value) -> Result<()> {
let Some(rules) = document.get("configs").and_then(|configs| configs.get("rules")) else { return Ok(()) };
let rules = rules.as_array().ok_or_else(|| invalid("rules must be an array"))?;
if rules.len() > MAX_RULES {
return Err(invalid("too many rules"));
}
for rule in rules {
let rule = rule.as_object().ok_or_else(|| invalid("rule must be an object"))?;
for (key, limit) in [("id", 64), ("name", 128)] {
let value = rule.get(key).and_then(Value::as_str).ok_or_else(|| invalid("rule id and name are required"))?;
if value.trim().is_empty() || value.chars().count() > limit {
return Err(invalid("rule id or name is empty or too long"));
}
}
if let Some(unit) = rule.get("length_unit") {
let unit =
unit.as_str().filter(|unit| matches!(*unit, "mm" | "cm" | "m" | "in" | "ft")).ok_or_else(|| invalid("invalid rule length unit"))?;
let _ = unit;
}
let mut pending = vec![(rule.get("condition").ok_or_else(|| invalid("condition required"))?, 1usize)];
let mut count = 0;
while let Some((condition, depth)) = pending.pop() {
count += 1;
if depth > MAX_DEPTH || count > MAX_NODES {
return Err(invalid("condition nesting or node limit exceeded"));
}
let kind = condition.get("type").and_then(Value::as_str).ok_or_else(|| invalid("condition type required"))?;
match kind {
"all" | "any" => {
let children = condition.get("conditions").and_then(Value::as_array).ok_or_else(|| invalid("conditions must be an array"))?;
if children.is_empty() || children.len() > MAX_NODES {
return Err(invalid("condition group is empty or too large"));
}
pending.extend(children.iter().map(|child| (child, depth + 1)));
}
"not" => pending.push((condition.get("condition").ok_or_else(|| invalid("nested condition required"))?, depth + 1)),
"parameter" => {
check_parameter(condition.get("parameter"))?;
check_expression(condition.get("expression"))?;
}
"always" | "suppression" => {}
_ => return Err(invalid("unknown condition type")),
}
}
let effect = rule.get("effect").ok_or_else(|| invalid("effect required"))?;
if effect.get("type").and_then(Value::as_str) != Some("drive_suppression") {
check_parameter(effect.get("parameter"))?;
}
match effect.get("type").and_then(Value::as_str) {
Some("drive_parameter") => check_expression(effect.get("expression"))?,
Some("parameter_range") => {
check_expression(effect.get("minimum"))?;
check_expression(effect.get("maximum"))?;
}
Some("allow_parameter_values") => {
let expressions = effect.get("expressions").and_then(Value::as_array).ok_or_else(|| invalid("allowed values must be an array"))?;
if expressions.is_empty() || expressions.len() > 128 {
return Err(invalid("allowed values are empty or too many"));
}
for expression in expressions {
check_expression(Some(expression))?;
}
}
Some("drive_suppression") => {}
_ => return Err(invalid("unknown effect type")),
}
}
Ok(())
}
fn check_expression(value: Option<&Value>) -> Result<()> {
let text = value.and_then(Value::as_str).ok_or_else(|| invalid("expression must be text"))?;
if text.trim().is_empty() || text.len() > 4096 {
return Err(invalid("expression is empty or too long"));
}
Ok(())
}
fn check_parameter(value: Option<&Value>) -> Result<()> {
let name = value.and_then(Value::as_str).ok_or_else(|| invalid("parameter name required"))?;
if name.is_empty() || name.len() > 64 || Document::param_name_problem(name).is_some() {
return Err(invalid("invalid parameter name"));
}
Ok(())
}
fn invalid(message: &str) -> DocError {
DocError::Invalid(format!("configuration rules: {message}"))
}
fn operands(
doc: &Document,
values: &BTreeMap<String, expr::Value>,
parameter: &str,
expression: &str,
length_unit: Option<&str>,
) -> Result<(f64, f64)> {
let lhs = values.get(parameter).copied().ok_or_else(|| invalid(&format!("parameter `{parameter}` is missing or invalid")))?;
let unit = doc
.param(parameter)
.map(|p| p.unit.as_str())
.or_else(|| lhs.kind().map(|kind| if kind == expr::Kind::Length { length_unit.unwrap_or(doc.units.as_str()) } else { kind.default_unit() }))
.unwrap_or("");
let defaults = expr::Defaults::for_unit(unit, expr::unit_info(&doc.units).map(|(_, scale)| scale).unwrap_or(1.0));
let rhs = expr::eval_with_defaults(
expression,
&|name| values.get(name).copied().ok_or_else(|| invalid(&format!("unknown parameter `{name}`"))),
defaults,
)?;
let kind = lhs.kind().ok_or_else(|| invalid("comparison requires length, angle or unitless values"))?;
let right = rhs.to_kind_in(kind, defaults)?;
if !lhs.v.is_finite() || !right.is_finite() {
return Err(invalid("comparison requires finite values"));
}
Ok((lhs.v, right))
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
}
fn validate_drive_graph(graph: &BTreeMap<String, Vec<String>>, parameter: &str, expression: &str) -> Result<()> {
let mut pending = expr::references(expression);
let mut visited = BTreeSet::new();
while let Some(name) = pending.pop() {
if name == parameter {
return Err(invalid(&format!("drive for `{parameter}` creates a parameter cycle")));
}
if visited.insert(name.clone())
&& let Some(references) = graph.get(&name)
{
pending.extend(references.iter().cloned());
}
}
Ok(())
}
fn condition(doc: &Document, values: &BTreeMap<String, expr::Value>, value: &RuleCondition, length_unit: Option<&str>) -> Result<bool> {
match value {
RuleCondition::Always => Ok(true),
RuleCondition::All { conditions } => {
for c in conditions {
if !condition(doc, values, c, length_unit)? {
return Ok(false);
}
}
Ok(true)
}
RuleCondition::Any { conditions } => {
for c in conditions {
if condition(doc, values, c, length_unit)? {
return Ok(true);
}
}
Ok(false)
}
RuleCondition::Not { condition: c } => Ok(!condition(doc, values, c, length_unit)?),
RuleCondition::Suppression { feature, value } => {
Ok(doc.feature(*feature).ok_or_else(|| invalid("condition feature is gone"))?.suppressed == *value)
}
RuleCondition::Parameter { parameter, operator, expression } => {
let (a, b) = operands(doc, values, parameter, expression, length_unit)?;
Ok(match operator {
RuleOperator::Eq => close(a, b),
RuleOperator::Ne => !close(a, b),
RuleOperator::Lt => a < b,
RuleOperator::Le => a <= b || close(a, b),
RuleOperator::Gt => a > b,
RuleOperator::Ge => a >= b || close(a, b),
})
}
}
}
impl Document {
/// Apply ordered drives, then enforce all matched restrictions on the final state.
/// Running on a scratch document makes a failing rule fully atomic for callers.
pub(super) fn evaluate_configuration_rules(&mut self) -> Result<Vec<RuleTrace>> {
validate_rules_json(&serde_json::json!({"configs":{"rules":self.configs.rules}}))?;
let mut ids = BTreeSet::new();
let mut trace = Vec::new();
let mut restrictions = Vec::new();
let (mut values, _) = self.param_values();
let parameter_names: BTreeSet<String> = self.all_param_exprs().into_iter().map(|(name, _, _)| name).collect();
let mut dependency_graph: BTreeMap<String, Vec<String>> =
self.all_param_exprs().into_iter().map(|(name, expression, _)| (name, expr::references(&expression))).collect();
for rule in self.configs.rules.clone() {
if !ids.insert(rule.id.clone()) {
return Err(invalid("duplicate rule id"));
}
if let Some(name) = rule.parameter_references().iter().find(|name| !parameter_names.contains(*name)) {
return Err(invalid(&format!("rule `{}` references missing parameter `{name}`", rule.name)));
}
// Targets must remain configured, including disabled rules, so dormant rules
// cannot silently become invalid and surprise a subsequent activation.
let target = match &rule.effect {
RuleEffect::DriveParameter { parameter, .. }
| RuleEffect::ParameterRange { parameter, .. }
| RuleEffect::AllowParameterValues { parameter, .. } => Column::Param(parameter.clone()),
RuleEffect::DriveSuppression { feature, .. } => Column::Suppress(*feature),
};
if !self.configs.columns.contains(&target) {
return Err(invalid(&format!("rule `{}` target is not configured", rule.name)));
}
let matched = rule.enabled
&& condition(self, &values, &rule.condition, rule.length_unit.as_deref())
.map_err(|e| invalid(&format!("rule `{}`: {e}", rule.name)))?;
trace.push(RuleTrace { id: rule.id, name: rule.name.clone(), matched, effect: rule.effect.clone() });
if !matched {
continue;
}
match rule.effect {
RuleEffect::DriveParameter { parameter, mut expression } => {
validate_drive_graph(&dependency_graph, &parameter, &expression)?;
dependency_graph.insert(parameter.clone(), expr::references(&expression));
if self.param(&parameter).is_none()
&& rule.length_unit.as_deref().is_some_and(|unit| unit != self.units)
&& values.get(&parameter).is_some_and(|value| value.kind() == Some(expr::Kind::Length))
{
let (_, value) = operands(self, &values, &parameter, &expression, rule.length_unit.as_deref())?;
expression = format!("{value} mm");
}
self.change_params_atomic(&[(parameter, expression)]).map_err(|e| invalid(&format!("rule `{}`: {e}", rule.name)))?;
values = self.param_values().0;
}
RuleEffect::DriveSuppression { feature, value } => {
self.feature_mut(feature).ok_or_else(|| invalid("rule feature is gone"))?.suppressed = value
}
effect => restrictions.push((rule.name, effect, rule.length_unit)),
}
}
for (name, effect, length_unit) in restrictions {
let valid = match effect {
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
let (value, min) = operands(self, &values, &parameter, &minimum, length_unit.as_deref())?;
let (_, max) = operands(self, &values, &parameter, &maximum, length_unit.as_deref())?;
min <= max && (value >= min || close(value, min)) && (value <= max || close(value, max))
}
RuleEffect::AllowParameterValues { parameter, expressions } => {
let mut valid = false;
for expression in expressions {
let (a, b) = operands(self, &values, &parameter, &expression, length_unit.as_deref())?;
valid |= close(a, b);
}
valid
}
_ => true,
};
if !valid {
return Err(invalid(&format!("rule `{name}` restricts the final value")));
}
}
Ok(trace)
}
}
impl ConfigRule {
pub(super) fn preserve_length_units(&mut self, parameters: &BTreeSet<String>, unit: &str) {
if self.length_unit.is_none() {
self.length_unit = Some(unit.to_string());
}
fn annotate(parameter: &str, expression: &mut String, parameters: &BTreeSet<String>, unit: &str) {
if parameters.contains(parameter) && expression.trim().parse::<f64>().is_ok() {
*expression = format!("{} {unit}", expression.trim());
}
}
fn walk(condition: &mut RuleCondition, parameters: &BTreeSet<String>, unit: &str) {
match condition {
RuleCondition::Parameter { parameter, expression, .. } => annotate(parameter, expression, parameters, unit),
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
for condition in conditions {
walk(condition, parameters, unit);
}
}
RuleCondition::Not { condition } => walk(condition, parameters, unit),
_ => {}
}
}
walk(&mut self.condition, parameters, unit);
match &mut self.effect {
RuleEffect::DriveParameter { parameter, expression } => annotate(parameter, expression, parameters, unit),
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
annotate(parameter, minimum, parameters, unit);
annotate(parameter, maximum, parameters, unit);
}
RuleEffect::AllowParameterValues { parameter, expressions } => {
for expression in expressions {
annotate(parameter, expression, parameters, unit);
}
}
_ => {}
}
}
/// Parameter references include targets and condition/effect expressions.
pub fn parameter_references(&self) -> Vec<String> {
let mut copy = self.clone();
let mut references = Vec::new();
copy.visit_parameters(&mut |text, expression| {
if expression {
references.extend(expr::references(text));
} else {
references.push(text.clone());
}
});
references
}
pub fn rename_parameter(&mut self, old: &str, new: &str) {
self.visit_parameters(&mut |text, expression| {
if expression {
*text = expr::rename_reference(text, old, new);
} else if text == old {
*text = new.to_string();
}
});
}
pub(super) fn clone_for_targets(&self, maps: &super::ConfigCloneMaps) -> Option<Self> {
let mapped = match &self.effect {
RuleEffect::DriveParameter { parameter, .. }
| RuleEffect::ParameterRange { parameter, .. }
| RuleEffect::AllowParameterValues { parameter, .. } => maps.parameters.contains_key(parameter),
RuleEffect::DriveSuppression { feature, .. } => maps.features.contains_key(feature),
};
if !mapped {
return None;
}
let mut copy = self.clone();
copy.visit_parameters(&mut |text, expression| {
*text = if expression {
super::clone::remap_expression(text, &maps.parameters)
} else {
maps.parameters.get(text).cloned().unwrap_or_else(|| text.clone())
};
});
fn remap(condition: &mut RuleCondition, maps: &super::ConfigCloneMaps) {
match condition {
RuleCondition::Suppression { feature, .. } => {
*feature = maps.features.get(feature).copied().unwrap_or(*feature);
}
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
for condition in conditions {
remap(condition, maps);
}
}
RuleCondition::Not { condition } => remap(condition, maps),
_ => {}
}
}
remap(&mut copy.condition, maps);
if let RuleEffect::DriveSuppression { feature, .. } = &mut copy.effect {
*feature = maps.features.get(feature).copied().unwrap_or(*feature);
}
Some(copy)
}
fn visit_parameters(&mut self, visit: &mut impl FnMut(&mut String, bool)) {
fn walk(c: &mut RuleCondition, visit: &mut impl FnMut(&mut String, bool)) {
match c {
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
for c in conditions {
walk(c, visit);
}
}
RuleCondition::Not { condition } => walk(condition, visit),
RuleCondition::Parameter { parameter, expression, .. } => {
visit(parameter, false);
visit(expression, true);
}
_ => {}
}
}
walk(&mut self.condition, visit);
match &mut self.effect {
RuleEffect::DriveParameter { parameter, expression } => {
visit(parameter, false);
visit(expression, true);
}
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
visit(parameter, false);
visit(minimum, true);
visit(maximum, true);
}
RuleEffect::AllowParameterValues { parameter, expressions } => {
visit(parameter, false);
for e in expressions {
visit(e, true);
}
}
_ => {}
}
}
pub fn references_features(&self, ids: &[u64]) -> bool {
fn condition(c: &RuleCondition, ids: &[u64]) -> bool {
match c {
RuleCondition::Suppression { feature, .. } => ids.contains(feature),
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => conditions.iter().any(|c| condition(c, ids)),
RuleCondition::Not { condition: c } => condition(c, ids),
_ => false,
}
}
condition(&self.condition, ids) || matches!(&self.effect,RuleEffect::DriveSuppression {feature,..} if ids.contains(feature))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ConfigRow;
fn design() -> Document {
let mut doc = Document::new("Rules");
doc.set_param("width", "10 mm", None, None).unwrap();
doc.set_param("depth", "5 mm", None, None).unwrap();
doc.configs.columns = vec![Column::Param("width".into()), Column::Param("depth".into())];
doc.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
doc
}
fn rule(id: &str, condition: RuleCondition, effect: RuleEffect) -> ConfigRule {
ConfigRule { id: id.into(), name: id.into(), enabled: true, length_unit: None, condition, effect }
}
#[test]
fn ordered_rules_derive_values_without_overwriting_rows() {
let mut doc = design();
doc.configs.rules = vec![
rule("first", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "width".into(), expression: "1 in".into() }),
rule(
"second",
RuleCondition::Parameter { parameter: "width".into(), operator: RuleOperator::Gt, expression: "20 mm".into() },
RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width / 2".into() },
),
];
let source = doc.configs.rows.clone();
let trace = doc.apply_configuration_with_trace("Default").unwrap();
assert!(trace.iter().all(|entry| entry.matched));
assert!((doc.param_values().0["depth"].v - 12.7).abs() < 1e-9);
assert_eq!(doc.configs.rows, source);
assert!(doc.configuration_matches());
doc.configs.rules.swap(0, 1);
let trace = doc.apply_configuration_with_trace("Default").unwrap();
assert!(!trace[0].matched);
assert_eq!(doc.param_values().0["depth"].v, 5.0);
}
#[test]
fn restrictions_intersect_and_fail_atomically_with_named_rule() {
let mut doc = design();
doc.configs.rules = vec![
rule(
"range",
RuleCondition::Always,
RuleEffect::ParameterRange { parameter: "width".into(), minimum: "5 mm".into(), maximum: "1 in".into() },
),
rule(
"choices",
RuleCondition::Always,
RuleEffect::AllowParameterValues { parameter: "width".into(), expressions: vec!["1 cm".into(), "20 mm".into()] },
),
];
doc.apply_configuration("Default").unwrap();
doc.configs.rows[0].cells[0] = "15 mm".into();
let before = doc.clone();
assert!(doc.apply_configuration("Default").unwrap_err().to_string().contains("choices"));
assert_eq!(doc, before);
doc.configs.rules[0].effect = RuleEffect::ParameterRange { parameter: "width".into(), minimum: "1 deg".into(), maximum: "90 deg".into() };
assert!(doc.apply_configuration("Default").is_err());
}
#[test]
fn rule_references_follow_rename_and_prevent_delete() {
let mut doc = design();
doc.configs.rules.push(rule(
"derive",
RuleCondition::Parameter { parameter: "width".into(), operator: RuleOperator::Ge, expression: "depth".into() },
RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width / 2".into() },
));
assert!(doc.remove_param("width").is_err());
doc.rename_param("width", "span").unwrap();
let refs = doc.configs.rules[0].parameter_references();
assert!(refs.contains(&"span".into()));
assert!(!refs.contains(&"width".into()));
doc.apply_configuration("Default").unwrap();
assert_eq!(doc.param_values().0["depth"].v, 5.0);
}
#[test]
fn angle_and_unitless_comparisons_keep_their_dimensions() {
let mut doc = design();
doc.set_param("angle", "45 deg", Some("deg"), None).unwrap();
doc.set_param("count", "1", Some(""), None).unwrap();
doc.configs.columns.push(Column::Param("count".into()));
doc.configs.rows[0].cells.push("1".into());
doc.configs.rules.push(rule(
"angle-drive",
RuleCondition::Parameter { parameter: "angle".into(), operator: RuleOperator::Gt, expression: "1".into() },
RuleEffect::DriveParameter { parameter: "count".into(), expression: "2".into() },
));
let trace = doc.apply_configuration_with_trace("Default").unwrap();
assert!(trace[0].matched);
assert_eq!(doc.param_values().0["count"].v, 2.0);
doc.configs.rules[0].condition =
RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Eq, expression: "1 deg".into() };
let before = doc.clone();
assert!(doc.apply_configuration("Default").is_err());
assert_eq!(doc, before);
}
#[test]
fn saved_rule_budgets_are_checked_before_deserialization() {
let valid = serde_json::to_value(rule(
"r",
RuleCondition::Always,
RuleEffect::DriveParameter { parameter: "width".into(), expression: "2 mm".into() },
))
.unwrap();
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[valid.clone()]}})).is_ok());
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":null}})).is_err());
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":vec![valid.clone();129]}})).is_err());
let mut condition = serde_json::json!({"type":"always"});
for _ in 0..8 {
condition = serde_json::json!({"type":"not","condition":condition});
}
let mut deep = valid.clone();
deep["condition"] = condition;
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[deep]}})).is_err());
let mut wide = valid.clone();
wide["condition"] = serde_json::json!({"type":"all","conditions":vec![serde_json::json!({"type":"always"});64]});
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[wide]}})).is_err());
let mut long = valid.clone();
long["effect"]["expression"] = serde_json::json!("a".repeat(4097));
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[long]}})).is_err());
let mut malformed = valid;
malformed["condition"] = serde_json::json!({"type":"any","conditions":{}});
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[malformed]}})).is_err());
}
#[test]
fn compound_conditions_short_circuit_guarded_numeric_expressions() {
let mut doc = design();
doc.set_param("count", "0", Some(""), None).unwrap();
let guard = RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Gt, expression: "0".into() };
let invalid_when_zero = RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Gt, expression: "1 / count".into() };
doc.configs.rules = vec![rule(
"guarded",
RuleCondition::All { conditions: vec![guard, invalid_when_zero.clone()] },
RuleEffect::DriveParameter { parameter: "width".into(), expression: "20 mm".into() },
)];
assert!(!doc.apply_configuration_with_trace("Default").unwrap()[0].matched);
assert_eq!(doc.param_values().0["width"].v, 10.0);
doc.configs.rules[0].condition = RuleCondition::Any {
conditions: vec![
RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Eq, expression: "0".into() },
invalid_when_zero,
],
};
assert!(doc.apply_configuration_with_trace("Default").unwrap()[0].matched);
assert_eq!(doc.param_values().0["width"].v, 20.0);
}
#[test]
fn saved_documents_reject_malformed_deep_and_oversized_rules_before_typed_decode() {
let valid = serde_json::to_value(rule(
"r",
RuleCondition::Always,
RuleEffect::DriveParameter { parameter: "width".into(), expression: "2 mm".into() },
))
.unwrap();
let mut deep = valid.clone();
let mut condition = serde_json::json!({"type":"always"});
for _ in 0..8 {
condition = serde_json::json!({"type":"not","condition":condition});
}
deep["condition"] = condition;
let mut wide = valid.clone();
wide["condition"] = serde_json::json!({"type":"all","conditions":vec![serde_json::json!({"type":"always"});64]});
let mut invalid_context = valid.clone();
invalid_context["length_unit"] = serde_json::json!("mile".repeat(1000));
for invalid in [
Value::Null,
serde_json::json!([deep]),
serde_json::json!([wide]),
serde_json::json!(vec![valid; 129]),
serde_json::json!([invalid_context]),
] {
let mut saved = serde_json::to_value(design()).unwrap();
saved["configs"]["rules"] = invalid;
let error = crate::format::read(&saved.to_string()).unwrap_err().to_string();
assert!(error.contains("configuration rules"), "saved boundary must report bounded rule validation: {error}");
}
}
#[test]
fn cross_unit_effective_literals_keep_declared_drive_cycle_checks() {
let mut doc = Document::new("Cross-unit drive graph");
doc.add_feature(
crate::FeatureKind::Box {
corner: ["0".into(), "0".into(), "0".into()],
length: "10 mm".into(),
width: "5 mm".into(),
height: "10 mm".into(),
operation: crate::Operation::NewBody,
},
None,
)
.unwrap();
let width = doc.features[0].param_names[0].clone();
let depth = doc.features[0].param_names[1].clone();
doc.rename_param(&width, "width").unwrap();
doc.rename_param(&depth, "depth").unwrap();
doc.configs.columns = vec![Column::Param("width".into()), Column::Param("depth".into())];
doc.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
doc.units = "in".into();
let mut first =
rule("first", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "width".into(), expression: "depth + 1".into() });
first.length_unit = Some("mm".into());
let mut second =
rule("second", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width + 1".into() });
second.length_unit = Some("mm".into());
doc.configs.rules = vec![first];
doc.apply_configuration("Default").unwrap();
assert_eq!(doc.config_current(&Column::Param("width".into())).as_deref(), Some("6 mm"));
doc.configs.rules.push(second);
let before = doc.clone();
assert!(doc.apply_configuration("Default").is_err());
assert_eq!(doc, before);
}
}
+305
View File
@@ -0,0 +1,305 @@
//! Stable configuration style targets. Display indices and placement names are never saved.
use super::Column;
use crate::{DocError, Document, FeatureKind, ModelState, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use solvecraft_geom::Vec3;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigFaceTarget {
pub body: String,
pub origin_feature: u64,
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 {
// Length-prefix encoding keeps punctuation in body/name unambiguous.
format!("{}:{}{}", self.body.len(), self.body, self.name)
}
pub fn matches(&self, face: &crate::appearance::FaceLook) -> bool {
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()
|| self.name.len() > 512
|| self.origin_feature == 0
|| [self.point.x, self.point.y, self.point.z].iter().any(|v| !v.is_finite() || v.abs() > 1e9)
{
return Err(DocError::Invalid("invalid configuration face target".into()));
}
Ok(())
}
}
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) {
if columns.len() > super::MAX_COLUMNS {
return Err(DocError::Invalid("too many configuration columns".into()));
}
for column in columns {
if let Some(target) = column.get("face_appearance") {
validate_face_target_json(target)?;
}
if let Some(id) = column.get("component_appearance")
&& id.as_u64().is_none_or(|id| id == 0)
{
return Err(DocError::Invalid("configuration component ID must be nonzero".into()));
}
}
}
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(())
}
impl Document {
/// Certify named generator lineage; generic index-derived naming is unsupported.
pub fn certify_config_face(&self, target: &ConfigFaceTarget) -> Result<()> {
target.validate()?;
let prefix = format!("F{}:", target.origin_feature);
let role = target.name.strip_prefix(&prefix).ok_or_else(|| DocError::Invalid("face lineage does not match its feature".into()))?;
if crate::naming::strip_piece(&target.name) != target.name {
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::Box { axis, high }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => {
["x", "y", "z"].get(*axis as usize).is_some_and(|axis| role == format!("{}{axis}", if *high { "+" } else { "-" }))
}
(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => {
sketch == producer_sketch && extent.taper.is_none()
&& role == format!("cap:{}:{}", if *high { "high" } else { "low" }, super::cap_key(outer, holes))
}
(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),
// Curved side lineage currently uses centroid/nearest-curve heuristics;
// certify caps only until the naming layer supplies proven curve provenance.
FeatureKind::Extrude { extent, .. } => {
extent.from.is_none()
&& extent.start_offset.as_deref().is_none_or(|value| {
crate::expr::is_literal(value)
&& crate::expr::eval_with(value, &|name| Err(DocError::Unknown(name.into())))
.ok()
.and_then(|value| value.to_kind(crate::expr::Kind::Length).ok())
.is_some_and(|value| value.abs() < 1e-12)
})
&& extent.distance2.is_none()
&& ["start", "top", "bottom"].contains(&role)
}
FeatureKind::Revolve { angle2, symmetric, .. } => angle2.is_none() && !symmetric && ["start", "end"].contains(&role),
_ => false,
};
if !valid {
return Err(DocError::Invalid("this face has no supported stable semantic lineage".into()));
}
Ok(())
}
/// Verify the current named pieces describe one surface, not ambiguous generated roles.
pub fn configuration_face_indices(&self, body: &crate::ModelBody, target: &ConfigFaceTarget) -> Result<Vec<usize>> {
self.certify_config_face(target)?;
if body.body.is_mesh() || body.definition_name() != target.body {
return Err(DocError::Invalid("configured face definition does not match".into()));
}
if matches!(target.certificate, Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::ExtrudeCap { .. })) {
let indices: Vec<_> = body
.body
.face_sources()
.into_iter()
.flatten()
.enumerate()
.filter_map(|(index, source)| {
let matched = match (&target.certificate, source) {
(
Some(super::FaceCertificate::Box { axis, high }),
Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), axis: current_axis, high: current_high, .. }),
) => *producer == target.origin_feature && axis == current_axis && high == current_high,
(
Some(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }),
Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap {
producer,
sketch: current_sketch,
high: current_high,
outer: current_outer,
holes: current_holes,
..
}),
) => {
*producer == target.origin_feature
&& sketch == current_sketch
&& high == current_high
&& outer == current_outer
&& holes == current_holes
}
_ => false,
};
matched.then_some(index)
})
.collect();
if indices.len() != 1 {
return Err(DocError::Unknown("attached planar face certificate is missing or ambiguous".into()));
}
return Ok(indices);
}
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()));
}
return Ok(indices);
}
// Caps and box faces must share an oriented plane. This rejects generic cap-role
// collisions while permitting the coplanar pieces of a split supported face.
let mesh = body.mesh();
let mut plane: Option<(Vec3, Vec3)> = None;
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
if indices.binary_search(&(*face as usize)).is_err() {
continue;
}
let Some([a, b, c]) = mesh.tri(triangle) else { continue };
let Some(normal) = (b - a).cross(c - a).normalized() else { continue };
if let Some((point, base)) = plane {
let magnitude = [point.x, point.y, point.z, a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z]
.iter()
.fold(body.body.size(), |scale, value| scale.max(value.abs()));
let tolerance = (magnitude * f64::EPSILON * 128.0).max(1e-7);
if normal.dot(base) < 1.0 - 1e-6 || [a, b, c].iter().any(|vertex| (*vertex - point).dot(base).abs() > tolerance) {
return Err(DocError::Invalid("face lineage names more than one oriented surface".into()));
}
} else {
plane = Some((a, normal));
}
}
if plane.is_none() {
return Err(DocError::Invalid("configured face has no valid surface".into()));
}
Ok(indices)
}
pub fn config_face_look(&self, target: &ConfigFaceTarget) -> Option<crate::appearance::Look> {
self.appearances.faces.iter().rev().find(|face| target.matches(face)).map(|face| face.look.clone())
}
/// Validate against the final rebuilt definition state. Missing names never pick nearby faces.
pub fn configuration_style_status(&self, state: &ModelState) -> Result<Vec<serde_json::Value>> {
let mut result = Vec::new();
for column in &self.configs.columns {
match column {
Column::ComponentAppearance(id) => {
let component =
self.components.iter().find(|c| c.id == *id).ok_or_else(|| DocError::Unknown(format!("component {id} is gone")))?;
result.push(serde_json::json!({"key":self.config_key(column),"target":component.name,"shared":true,"dormant":false}));
}
Column::FaceAppearance(target) => {
self.certify_config_face(target)?;
let dormant = match state.body(&target.body) {
None if self.feature(target.origin_feature).is_some_and(|feature| feature.suppressed) => true,
None => return Err(DocError::Unknown(format!("configured face body `{}` is gone", target.body))),
Some(body) => {
self.configuration_face_indices(body, target)?;
false
}
};
result.push(serde_json::json!({"key":self.config_key(column),"target":format!("{} / {}",target.body,target.name),"shared":true,"dormant":dormant}));
}
_ => {}
}
}
Ok(result)
}
/// Maintain columns and row cell alignment when component definitions are deleted.
pub fn remove_configuration_components(&mut self, ids: &[u64]) {
let keep: Vec<bool> = self.configs.columns.iter().map(|c| !matches!(c,Column::ComponentAppearance(id) if ids.contains(id))).collect();
let mut mask = keep.iter();
self.configs.columns.retain(|_| mask.next().copied().unwrap_or(false));
for row in &mut self.configs.rows {
let mut mask = keep.iter();
row.cells.retain(|_| mask.next().copied().unwrap_or(false));
}
self.appearances.components.retain(|id, _| !ids.contains(id));
}
}
+189 -16
View File
@@ -56,15 +56,18 @@ pub enum PlaneRef {
AlongPath { path: Box<GeoRef>, t: String },
/// Through a straight edge (or line, axis) and square to a plane (or planar face).
Perpendicular { plane: Box<GeoRef>, line: Box<GeoRef> },
/// A planar body face: found again on each evaluation (the face with this plane's normal
/// nearest `at`), so sketches on it follow when earlier features change; `plane` is the
/// plane as picked (its frame, and the fallback when the face is gone). `name` is the face's
/// persistent name (found first; see `naming`).
/// A planar body face. Certified attachments resolve only their exact source and report
/// an error when it is lost; `plane` then supplies a repair guide. Legacy references without
/// an anchor try the persistent `name`, then the nearest parallel face at `at`, and retain
/// the picked `plane` as their historical fallback.
Face {
plane: Plane,
at: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
/// Exact attachment identity for newly certified faces; absent on legacy designs.
#[serde(default, skip_serializing_if = "Option::is_none")]
anchor: Option<Box<crate::FaceAnchor>>,
},
}
@@ -118,6 +121,16 @@ pub enum Direction {
Symmetric,
}
/// Angular starts in older designs used the raw axis sign. New revolves measure starts
/// along the same sketch-normal sweep direction as the kernel.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RevolveConvention {
#[default]
LegacyAxisStart,
SketchNormal,
}
/// Extent of an extrude.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Extent {
@@ -163,10 +176,97 @@ pub enum AxisRef {
Line { origin: Vec3, dir: Vec3 },
}
/// Persistent cylindrical Emboss selection, authored in the feature component frame.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct EmbossFace {
pub body: String,
pub at: Vec3,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub path: Vec<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub author_path: Vec<u64>,
}
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: &crate::ModelBody) -> Result<crate::Mat> {
let owner = doc.model_body_component(body);
if sketch_owner == owner {
return Self::component_frame(doc, owner, &self.path);
}
if sketch_owner == author {
return Self::component_frame(doc, author, &self.author_path);
}
let path = doc.first_path(sketch_owner);
let mut component = sketch_owner;
for _ in 0..64 {
if component == 0 {
break;
}
let mut occurrences = doc.occurrences.iter().filter(|o| o.component == component);
let occurrence = occurrences.next().ok_or_else(|| DocError::Invalid("sketch placement no longer exists".into()))?;
if occurrences.next().is_some() {
return Err(DocError::Invalid("sketch placement is ambiguous".into()));
}
component = occurrence.parent;
}
if component != 0 {
return Err(DocError::Invalid("sketch placement exceeds depth limit".into()));
}
Self::component_frame(doc, sketch_owner, &path)
}
/// Resolve this exact placement without expanding every assembly instance.
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.model_body_offset(body) {
frame = crate::mat_mul(&frame, offset);
}
Ok(frame)
}
/// Resolve one saved component path without expanding other occurrences.
pub fn component_frame(doc: &Document, owner: u64, path: &[u64]) -> Result<crate::Mat> {
if path.len() > 64 || doc.occurrences.len() > 100_000 {
return Err(DocError::Invalid("selected cylinder path exceeds work limits".into()));
}
let mut frame = crate::IDENTITY;
let mut component = 0;
let mut seen = std::collections::BTreeSet::new();
for id in path {
if *id == 0 || !seen.insert(*id) {
return Err(DocError::Invalid("invalid selected cylinder path".into()));
}
let occurrence = doc
.occurrences
.iter()
.find(|o| o.id == *id && o.parent == component)
.ok_or_else(|| DocError::Invalid("selected cylinder placement no longer exists".into()))?;
frame = crate::mat_mul(&frame, &occurrence.transform);
component = occurrence.component;
}
if path.is_empty() {
frame = doc.component_transform(owner);
} else if component != owner {
return Err(DocError::Invalid("selected cylinder path belongs to another component".into()));
}
Ok(frame)
}
}
/// Feature definitions.
#[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,
@@ -197,6 +297,9 @@ pub enum FeatureKind {
/// Symmetric: `angle` split evenly either side of the profile.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
symmetric: bool,
/// Missing fields retain the angular placement of existing designs.
#[serde(default)]
convention: RevolveConvention,
/// To Object: turn until the face, vertex or point here.
#[serde(default, skip_serializing_if = "Option::is_none")]
to: Option<Vec3>,
@@ -439,13 +542,15 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<String>,
},
/// Raise (emboss) or sink (deboss) sketch profiles on the planar face they lie on.
/// Raise or sink profiles on a planar face, or wrap them on a selected cylindrical wall.
Emboss {
sketch: u64,
profiles: ProfileSel,
depth: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
deboss: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
face: Option<EmbossFace>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<String>,
},
@@ -1042,7 +1147,8 @@ impl FeatureKind {
| K::SurfaceTrim { .. }
| K::Move { .. }
| K::Import { .. }
| K::MeshImport { .. } => Vec::new(),
| K::MeshImport { .. }
| K::BodyTransfer { .. } => Vec::new(),
}
}
@@ -1067,6 +1173,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",
@@ -1131,6 +1238,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",
@@ -1222,6 +1330,7 @@ impl FeatureKind {
FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(),
FeatureKind::Revolve { to: Some(p), .. } => vec![p],
FeatureKind::Thread { face, .. } => vec![face],
FeatureKind::Emboss { face: Some(face), .. } => vec![&mut face.at],
FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position],
_ => Vec::new(),
}
@@ -1257,6 +1366,9 @@ impl FeatureKind {
if let Some(d) = &extent.taper {
v.push(d);
}
if let Some(d) = &extent.to_offset {
v.push(d);
}
}
FeatureKind::Revolve { angle, angle2, .. } => {
v.push(angle);
@@ -1382,7 +1494,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
}
@@ -1588,7 +1700,7 @@ impl Document {
/// else its component's, else its material's look, else the colour it was imported with.
/// (Faces may have their own: `face_looks`.)
pub fn body_color(&self, b: &crate::ModelBody) -> Option<[f32; 3]> {
self.body_look(&b.name, b.feature).map(|l| l.rgb()).or_else(|| b.body.color()).or_else(|| b.imported.as_ref().and_then(|i| i.color))
self.model_body_look(b).map(|l| l.rgb()).or_else(|| b.body.color()).or_else(|| b.imported.as_ref().and_then(|i| i.color))
}
/// The model with each body carrying the colour, opacity and face colours it is shown with
@@ -1597,17 +1709,12 @@ impl Document {
let mut st = state.clone();
for b in &mut st.bodies {
let c = self.body_color(b);
let look = self.body_look(&b.name, b.feature);
let faces: Vec<solvecraft_kernel::FacePaint> = self
.face_colors(b)
.into_iter()
.map(|(face, l)| solvecraft_kernel::FacePaint { face, color: l.rgb(), opacity: l.opacity as f32 })
.collect();
let opacity = look
.map(|l| l.opacity as f32)
.or_else(|| b.body.paint().map(|p| p.opacity))
.or_else(|| b.imported.as_ref().and_then(|i| i.opacity))
.unwrap_or(1.0);
let opacity = self.model_body_opacity(b) as f32;
let paint = solvecraft_kernel::Paint { opacity, faces };
if c != b.body.color() || b.body.paint() != Some(&paint) {
b.body = b.body.clone().with_color(c).with_paint(Some(paint));
@@ -1623,9 +1730,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) {
@@ -1706,6 +1840,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 {
@@ -1718,7 +1858,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(),
@@ -1741,9 +1881,26 @@ impl Document {
let idx = self.feature_index(id).ok_or_else(|| DocError::Unknown(format!("feature {id}")))?;
let mut gone = vec![id];
gone.extend(self.features.iter().filter(|f| f.kind.input_sketches().contains(&id)).map(|f| f.id));
let gone_params: Vec<String> = self.features.iter().filter(|f| gone.contains(&f.id)).flat_map(|f| f.param_names.clone()).collect();
self.configs
.rules
.retain(|rule| !rule.references_features(&gone) && !rule.parameter_references().iter().any(|name| gone_params.contains(name)));
self.features.retain(|f| !gone.contains(&f.id));
// Configuration columns of deleted features go with them.
let keep: Vec<bool> = self.configs.columns.iter().map(|c| !matches!(c, crate::config::Column::Suppress(id) if gone.contains(id))).collect();
let keep: Vec<bool> = self
.configs
.columns
.iter()
.map(|c| match c {
crate::config::Column::Suppress(id) => !gone.contains(id),
crate::config::Column::Param(name) => !gone_params.contains(name),
crate::config::Column::FaceAppearance(target) => !gone.contains(&target.origin_feature),
_ => true,
})
.collect();
self.appearances
.faces
.retain(|face| !face.strict_name || !face.name.as_ref().is_some_and(|name| gone.iter().any(|id| name.starts_with(&format!("F{id}:")))));
if keep.contains(&false) {
let mut k = keep.iter();
self.configs.columns.retain(|_| k.next().copied().unwrap_or(true));
@@ -1770,6 +1927,19 @@ impl Document {
fn referenced_names(&self) -> Vec<String> {
let mut used: Vec<String> = Vec::new();
for rule in &self.configs.rules {
used.extend(rule.parameter_references());
}
for (index, column) in self.configs.columns.iter().enumerate() {
if let crate::config::Column::Param(name) = column {
used.push(name.clone());
for row in &self.configs.rows {
if let Some(value) = row.cells.get(index) {
used.extend(expr::references(value));
}
}
}
}
for f in &self.features {
for e in f.kind.expressions() {
used.extend(expr::references(e));
@@ -1962,6 +2132,9 @@ impl Document {
c.kind.set_value(v);
}
}
for c in &sk.constraints {
sk.validate(&c.kind)?;
}
Ok(())
}
}
+508 -90
View File
@@ -16,6 +16,10 @@ use crate::{AxisRef, DocError, Document, Feature, FeatureKind, HoleKind, Operati
#[derive(Clone, Debug)]
pub struct ModelBody {
pub name: String,
/// Definition identity for a computed placement; never parsed from its display name.
pub source_name: Option<String>,
/// Definition geometry coordinates to this computed body's frame.
pub source_transform: crate::Mat,
pub body: Body,
/// Feature that created it.
pub feature: u64,
@@ -29,13 +33,30 @@ 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>>,
}
impl ModelBody {
pub fn new(name: String, body: Body, feature: u64) -> Self {
ModelBody { name, body, feature, mesh: Arc::new(OnceLock::new()), key: 0, names: Default::default(), component: 0, imported: None }
ModelBody {
name,
source_name: None,
source_transform: crate::IDENTITY,
body,
feature,
mesh: Arc::new(OnceLock::new()),
key: 0,
names: Default::default(),
component: 0,
transferred: false,
imported: None,
}
}
pub fn definition_name(&self) -> &str {
self.source_name.as_deref().unwrap_or(&self.name)
}
/// Display mesh (chord tolerance 1/1000 of the body size), computed once.
pub fn mesh(&self) -> Arc<Mesh> {
@@ -256,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);
@@ -274,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) => {
@@ -284,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())
@@ -476,8 +508,31 @@ 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()) {
let anchor = crate::face_anchor::plane_face_anchor(plane);
if (doc.occurrences.is_empty() && (anchor.is_none() || doc.body_offsets.is_empty()))
|| (!matches!(plane, PlaneRef::Face { .. }) && anchor.is_none() && sketch.links.is_empty())
{
return 0;
}
let mut h = std::collections::hash_map::DefaultHasher::new();
@@ -614,7 +669,10 @@ impl Model {
let mut warning = None;
// Face references by name: move each face point onto its named face.
let renamed;
let f = if f.face_names.iter().any(|n| !n.is_empty()) {
// Cylindrical emboss resolves its named face in the selected body's frame
// and certifies an analytic anchor; mesh projection here would undo that.
let explicit_emboss = matches!(&f.kind, FeatureKind::Emboss { face: Some(_), .. });
let f = if !explicit_emboss && f.face_names.iter().any(|n| !n.is_empty()) {
let mut g = f.clone();
let mut notes = Vec::new();
for (p, n) in g.kind.face_points_mut().into_iter().zip(&f.face_names) {
@@ -652,7 +710,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()
}
@@ -660,7 +718,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
@@ -702,7 +762,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)));
@@ -773,8 +833,61 @@ fn new_name(state: &mut ModelState, f: &Feature, k: usize) -> String {
}
}
fn copy_body_name(state: &ModelState, f: &Feature, k: usize, source: &str) -> String {
let base = f.body_names.get(k).filter(|name| !name.trim().is_empty()).map(String::as_str).unwrap_or(source);
unique_body_name(state, base)
}
/// 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 };
@@ -800,6 +913,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(()) };
@@ -813,6 +933,8 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation
}
Operation::Cut | Operation::Intersect => {
let bop = if op == Operation::Cut { BoolOp::Cut } else { BoolOp::Intersect };
let affected: std::collections::BTreeSet<String> =
target_idx.iter().filter_map(|i| state.bodies.get(*i).map(|b| b.name.clone())).collect();
let mut remove = Vec::new();
for &ti in &target_idx {
let Some(mut acc) = state.bodies.get(ti).map(|b| Some(b.body.clone())) else { continue };
@@ -837,9 +959,15 @@ 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) {
if !affected.contains(&mb.name) || state.sheets.iter().any(|s| s.body == mb.name) {
split.push(mb);
continue;
}
let pieces = mb.body.lumps().unwrap_or_else(|_| vec![mb.body.clone()]);
if pieces.len() < 2 {
split.push(mb);
@@ -858,7 +986,7 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation
}
/// Distance that reaches through every target body from the sketch plane (either side).
fn through_all_distance(st: &ModelState, plane: &Plane, targets: &[String]) -> Result<f64> {
pub fn through_all_distance(st: &ModelState, plane: &Plane, targets: &[String]) -> Result<f64> {
let mut far: f64 = 0.0;
let mut any = false;
for b in st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)) {
@@ -1356,8 +1484,107 @@ 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> {
revolve_extent_with_convention(st, plane, regions, (origin, axis), (angle, angle2), symmetric, to, crate::RevolveConvention::LegacyAxisStart)
}
#[allow(clippy::too_many_arguments)]
pub fn revolve_extent_with_convention(
st: &ModelState,
plane: &Plane,
regions: &[Region2],
(origin, axis): (Vec3, Vec3),
(angle, angle2): (f64, Option<f64>),
symmetric: bool,
to: Option<Vec3>,
convention: crate::RevolveConvention,
) -> 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 = if convention == crate::RevolveConvention::SketchNormal {
regions.first().map(|r| plane.to_world(r.centroid())).ok_or_else(|| DocError::Invalid("no profile selected".into()))?
} else {
plane.to_world(points.iter().fold(Vec2::default(), |sum, point| sum + *point) * (1.0 / points.len().max(1) as f64))
};
let projection_axis = if convention == crate::RevolveConvention::SketchNormal {
axis.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))?
} else {
axis
};
let flat = |point: Vec3| {
let v = point - origin;
v - projection_axis * v.dot(projection_axis)
};
let (u, v) = (flat(centre), flat(point));
let angle_axis = if convention == crate::RevolveConvention::SketchNormal {
if v.len() < 1e-9 {
return Err(DocError::Invalid("the revolve target lies on the axis; pick a point away from the axis".into()));
}
kernel::canonical_revolve_axis(
plane,
regions.first().ok_or_else(|| DocError::Invalid("no profile selected".into()))?,
plane.to_local(origin),
Vec2::new(projection_axis.dot(plane.x), projection_axis.dot(plane.y)),
)?
} else {
-axis
};
let sweep = angle_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 = match (convention, regions.first()) {
(crate::RevolveConvention::SketchNormal, Some(region)) => {
kernel::canonical_revolve_axis(plane, region, plane.to_local(origin), Vec2::new(unit_axis.dot(plane.x), unit_axis.dot(plane.y)))?
}
_ => {
if unit_axis.cross(radial).dot(plane.normal()) < 0.0 {
-unit_axis
} else {
unit_axis
}
}
};
let display_start = match convention {
crate::RevolveConvention::LegacyAxisStart => start * -unit_axis.dot(rotation_axis),
crate::RevolveConvention::SketchNormal => start,
};
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();
@@ -1397,11 +1624,12 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
if extent.distance2.is_some() || extent.start_offset.is_some() || extent.direction == crate::Direction::Symmetric {
return Err(DocError::Invalid("not supported yet: taper with two-sided, symmetric or offset extents".into()));
}
let sign = if extent.direction == crate::Direction::Negative { -1.0 } else { 1.0 };
let sign = if extent.to.is_none() && extent.direction == crate::Direction::Negative { -1.0 } else { 1.0 };
let (len, sign) = if d < 0.0 { (-d, -sign) } else { (d, sign) };
let start_plane = ss.plane.offset(from);
return Ok(regions
.iter()
.map(|r| kernel::extrude_tapered(&ss.plane, r, len, sign, taper))
.map(|r| kernel::extrude_tapered(&start_plane, r, len, sign, taper))
.collect::<std::result::Result<Vec<_>, _>>()?);
}
let off = from
@@ -1459,59 +1687,102 @@ 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() {
let cap_bindings = crate::config::extrude_cap_bindings(&ss, &tools, 10_000_000);
tools = tools
.into_iter()
.enumerate()
.map(|(body_index, body)| {
let curves: Vec<_> = body
.face_sources()
.into_iter()
.flatten()
.map(|source| match source {
Some(kernel::FaceSource::ExtrudeSide { segment, .. }) if cap_bindings.is_some() => {
crate::config::exact_sketch_curve(&ss, segment)
}
_ => None,
})
.collect();
let caps =
cap_bindings.as_ref().and_then(|bindings| bindings.get(body_index)).cloned().unwrap_or_else(|| vec![None; curves.len()]);
body.with_extrude_certificates(f.id, *sketch, &curves)?
.with_extrude_cap_certificates(f.id, *sketch, &caps)
.map_err(DocError::from)
})
.collect::<Result<Vec<_>>>()?;
}
Ok(tools)
}
FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, to, .. } => {
FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, convention, 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_with_convention(
st,
&ss.plane,
&regions,
(ss.plane.to_world(o), ss.plane.dir_to_world(d)),
angles,
*symmetric,
*to,
*convention,
)?;
let (start, ang) = (extent.start, extent.sweep);
let region_extents = regions
.iter()
.map(|r| {
if *convention == crate::RevolveConvention::SketchNormal && to.is_some() {
revolve_extent_with_convention(
st,
&ss.plane,
std::slice::from_ref(r),
(ss.plane.to_world(o), ss.plane.dir_to_world(d)),
angles,
*symmetric,
*to,
*convention,
)
} else {
Ok(extent)
}
})
.collect::<Result<Vec<_>>>()?;
let (operation, targets) = feature_op(f);
let axis_w = ss.plane.dir_to_world(d);
let origin_w = ss.plane.to_world(o);
let probe = |p: Vec2| {
let q = ss.plane.to_world(p);
(1..4)
.map(|k| {
let a = ang * k as f64 / 4.0;
let v = q - origin_w;
origin_w + v * a.cos() + axis_w.cross(v) * a.sin() + axis_w * (axis_w.dot(v) * (1.0 - a.cos()))
})
.collect::<Vec<_>>()
};
let regions: Vec<Region2> = regions.iter().map(|r| grow_profile_for_coplanar(st, r, operation, &targets, &probe)).collect();
let regions: Vec<Region2> = regions
.iter()
.zip(&region_extents)
.map(|(r, region_extent)| {
let rotation_axis = match convention {
crate::RevolveConvention::LegacyAxisStart => axis_w,
crate::RevolveConvention::SketchNormal => kernel::canonical_revolve_axis(&ss.plane, r, o, d)?,
};
let probe = |p: Vec2| {
let q = ss.plane.to_world(p);
(1..4)
.map(|k| {
let a = region_extent.sweep * k as f64 / 4.0
+ if *convention == crate::RevolveConvention::SketchNormal { region_extent.start } else { 0.0 };
let v = q - origin_w;
origin_w + v * a.cos() + rotation_axis.cross(v) * a.sin() + rotation_axis * (rotation_axis.dot(v) * (1.0 - a.cos()))
})
.collect::<Vec<_>>()
};
Ok(grow_profile_for_coplanar(st, r, operation, &targets, &probe))
})
.collect::<Result<Vec<_>>>()?;
// To a curved face: turn past the whole target body, then cut back to its surface
// (the piece that starts at the profile).
let curved = to.and_then(|p| curved_target_any(st, p));
if let Some(tb) = curved.and_then(|bi| st.bodies.get(bi)) {
let curved_body = curved.and_then(|bi| st.bodies.get(bi));
let build_curved = |regions: &[Region2], angle_axis: Vec3, tb: &ModelBody| -> Result<Vec<Body>> {
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| {
@@ -1521,15 +1792,36 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
let u0 = flat(c);
let angle_of = |q: Vec3| {
let v = flat(q);
let a = (axis_w * -1.0).dot(u0.cross(v)).atan2(u0.dot(v));
let a = angle_axis.dot(u0.cross(v)).atan2(u0.dot(v));
if a < 0.0 { a + std::f64::consts::TAU } else { a }
};
let far = tb.mesh().positions.iter().map(|q| angle_of(*q)).fold(0.0, f64::max);
let sweep = (far + 0.05).min(std::f64::consts::TAU - 1e-3);
let bodies = kernel::revolve(&ss.plane, &regions, o, d, sweep)?;
let bodies = kernel::revolve(&ss.plane, regions, o, d, sweep)?;
let n = ss.plane.normal();
let tol_side = |q: Vec3| if flat(q).dot(u0) > 0.0 { ((q - ss.plane.origin).dot(n)).abs() } else { f64::MAX };
return trim_to_body(bodies, &tb.body, tol_side);
trim_to_body(bodies, &tb.body, tol_side)
};
if *convention == crate::RevolveConvention::LegacyAxisStart
&& let Some(tb) = curved_body
{
return build_curved(&regions, -axis_w, tb);
}
if *convention == crate::RevolveConvention::SketchNormal {
let mut out = Vec::new();
for (region, region_extent) in regions.iter().zip(&region_extents) {
let rotation_axis = kernel::canonical_revolve_axis(&ss.plane, region, o, d)?;
if let Some(tb) = curved_body {
out.extend(build_curved(std::slice::from_ref(region), rotation_axis, tb)?);
continue;
}
let bodies = kernel::revolve(&ss.plane, std::slice::from_ref(region), o, d, region_extent.sweep)?;
let m = crate::rigid(Vec3::ZERO, origin_w, rotation_axis, region_extent.start);
for body in bodies {
out.push(if region_extent.start.abs() < 1e-12 { body } else { kernel::transform_matrix(&body, m)? });
}
}
return Ok(out);
}
let bodies = kernel::revolve(&ss.plane, &regions, o, d, ang)?;
if start.abs() < 1e-12 {
@@ -1606,6 +1898,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.
@@ -1618,6 +1919,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()));
@@ -1682,6 +1985,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.
@@ -1863,10 +2211,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) {
@@ -1901,6 +2249,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();
@@ -2066,7 +2416,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)?);
@@ -2074,7 +2424,14 @@ fn replay(
}
}
// New bodies from a pattern join their source when they touch it (as one feature would).
apply_op(st, f, copies, op, &targets)?;
if f.body_names.is_empty() {
apply_op(st, f, copies, op, &targets)?;
} else {
let start = st.bodies.iter().filter(|b| b.feature == f.id).count();
let mut named = f.clone();
named.body_names = f.body_names.iter().skip(start).cloned().collect();
apply_op(st, &named, copies, op, &targets)?;
}
}
Ok(())
}
@@ -2087,7 +2444,7 @@ mod surface;
/// Height above `plane` (along its normal) of the object at `p` as seen from `centre`: the face
/// there if it is planar and parallel (its plane), else where a ray from `centre` along the
/// normal meets the body there, else the point itself.
fn object_height(st: &ModelState, plane: &Plane, centre: Vec3, p: Vec3) -> f64 {
pub fn object_height(st: &ModelState, plane: &Plane, centre: Vec3, p: Vec3) -> f64 {
let n = plane.normal();
let h = |q: Vec3| (q - plane.origin).dot(n);
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
@@ -2314,6 +2671,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 { .. }
@@ -2335,13 +2693,21 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::Align { .. }
| FeatureKind::Remove { .. } => more::eval(doc, vals, f, st),
FeatureKind::Sketch { plane, sketch } => {
let fst = state_in_frame(doc, st, f.component);
let (plane, face_warning) = match plane {
PlaneRef::Face { plane: picked, at, name } => face_plane(&fst, picked, *at, name.as_deref()),
other => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
let anchor = crate::face_anchor::plane_face_anchor(plane);
let fst = match anchor {
Some(anchor) => crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?,
None => state_in_frame(doc, st, f.component),
};
let (plane, face_warning) = match (anchor, plane) {
(Some(_), plane) => (doc.resolve_attached_plane(vals, plane, f.component, st, &fst)?, None),
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref()),
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
};
let mut sk = sketch.clone();
let mut warns = crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk);
let mut warns = match anchor {
Some(anchor) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor),
None => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk),
};
drop(fst);
sk.refresh_offsets()?;
if let Some(w) = face_warning {
@@ -2349,7 +2715,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
doc.apply_dimension_values(vals, &mut sk)?;
let mut report = solve(&mut sk);
if !sk.offsets.is_empty() {
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
// Source dimensions may have moved curves during the solve.
sk.refresh_offsets()?;
report = solve(&mut sk);
@@ -2360,6 +2726,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
if !warns.is_empty() {
*warning = Some(warns.join("; "));
}
sk.validate_periodic_splines()?;
let profiles = find_profiles(&sk);
st.sketches.retain(|s| s.feature != f.id);
st.sketches.push(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles });
@@ -2371,16 +2738,26 @@ 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 };
let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*");
let rest: Vec<String> = st
.bodies
.iter()
.filter(|b| {
!handled.contains(&b.name)
&& (all
|| if effective.is_empty() {
b.component == f.component || b.feature == f.id
} else {
effective.contains(&b.name)
})
})
.map(|b| b.name.clone())
.filter(|n| !handled.contains(n) && (targets.is_empty() || targets.contains(n)))
.collect();
if rest.is_empty() {
return Ok(());
@@ -2392,7 +2769,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.
@@ -2593,17 +2970,19 @@ 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);
}
// Bodies: each copy is a new body named after its source.
let mut output = 0;
for n in bodies {
let mb = st.body(n).cloned().ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
for m in &mats {
let copy = kernel::transform_matrix(&mb.body, *m)?;
let name = unique_body_name(st, &mb.name);
let name = copy_body_name(st, f, output, &mb.name);
st.bodies.push(ModelBody::new(name, copy, f.id));
output += 1;
}
}
Ok(())
@@ -2614,6 +2993,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
return replay(doc, vals, f, st, features, &[mirror_matrix(&pl)], warning);
}
let m = mirror_matrix(&pl);
let mut output = 0;
for n in bodies {
let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { continue };
@@ -2627,13 +3007,15 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
*slot = ModelBody::new(mb.name.clone(), p, mb.feature);
}
} else {
let name = unique_body_name(st, &mb.name);
let name = copy_body_name(st, f, output, &mb.name);
st.bodies.push(ModelBody::new(name, p, f.id));
output += 1;
}
}
} else {
let name = unique_body_name(st, &mb.name);
let name = copy_body_name(st, f, output, &mb.name);
st.bodies.push(ModelBody::new(name, mirrored, f.id));
output += 1;
}
}
Ok(())
@@ -2708,6 +3090,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
let corner = point(vals, corner)?;
let b = kernel::box_solid(corner, corner + s)?;
let b = if *operation == Operation::NewBody { b.with_box_certificate(f.id)? } else { b };
apply_op(st, f, vec![b], *operation, &[])
}
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
@@ -2829,18 +3212,19 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
Ok(())
}
FeatureKind::MeshImport { meshes, .. } => {
for (name, b) in mesh_bodies(meshes)? {
let name = unique_body_name(st, &name);
for (k, (name, b)) in mesh_bodies(meshes)?.into_iter().enumerate() {
let name = copy_body_name(st, f, k, &name);
st.bodies.push(ModelBody::new(name, b, f.id));
}
Ok(())
}
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();
@@ -2879,22 +3263,36 @@ fn unique_body_name(st: &ModelState, base: &str) -> String {
/// face finds the face where it is shown and projects its edges there. The model itself when
/// every placement agrees (the usual case).
pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> {
state_in_frame_at(doc, st, comp, doc.component_transform(comp))
}
/// As `state_in_frame`, using the exact active placement for its shared source geometry.
pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, frame: Mat) -> std::borrow::Cow<'a, ModelState> {
use std::borrow::Cow;
if doc.occurrences.is_empty() && doc.body_offsets.is_empty() {
return Cow::Borrowed(st);
}
let Some(inv) = crate::mat_inverse(&doc.component_transform(comp)) else { return Cow::Borrowed(st) };
let Some(inv) = crate::mat_inverse(&frame) else { return Cow::Borrowed(st) };
let comp_of_feature = |id: u64| doc.feature(id).map(|f| f.component).unwrap_or(0);
let rel = |c: u64, body: Option<&str>| {
let m = crate::mat_mul(&inv, &doc.component_transform(c));
let m = crate::mat_mul(&inv, &if c == comp { frame } else { doc.component_transform(c) });
match body.and_then(|b| doc.body_offsets.get(b)) {
Some(o) => crate::mat_mul(&m, o),
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();
@@ -2906,6 +3304,10 @@ pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std:
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();
nb.names = crate::naming::NameCell::known(names);
*b = nb;
}
@@ -2916,6 +3318,10 @@ pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std:
{
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)
}
@@ -2944,17 +3350,19 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
let mut out = ModelState { body_counter: st.body_counter, ..Default::default() };
for (comp, path, m) in doc.placements() {
let primary = path == first_path(comp);
let label = || -> String {
path.iter().filter_map(|id| doc.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect::<Vec<_>>().join("/")
};
let ident = crate::is_identity(&m);
for b in st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == comp) {
let name = if primary { b.name.clone() } else { format!("{} ({})", b.name, label()) };
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.
placed.names = crate::naming::NameCell::known(crate::naming::face_names(b));
@@ -2969,12 +3377,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);
}
}
+175 -53
View File
@@ -2,18 +2,66 @@
//! kernel's extrude, boolean, offset and transform operations).
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<()> {
match &f.kind {
FeatureKind::Emboss { sketch, profiles, depth, deboss, targets } => {
FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face } => {
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
let regions = select_profiles(ss, profiles)?;
let d = val(vals, depth, Kind::Length)?;
if !(d > 0.0) {
return Err(DocError::Invalid("the emboss depth must be positive".into()));
}
if let Some(face) = face {
if !targets.is_empty() && (targets.len() != 1 || targets.first() != Some(&face.body)) {
return Err(DocError::Invalid("cylinder emboss must keep its selected target body".into()));
}
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)?;
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)?;
let sketch_transform = crate::mat_mul(&inverse, &sketch_frame);
let plane = Plane::new(
crate::apply_point(&sketch_transform, ss.plane.origin),
crate::apply_vector(&sketch_transform, ss.plane.x),
crate::apply_vector(&sketch_transform, ss.plane.y),
)
.ok_or_else(|| DocError::Invalid("invalid cylinder sketch frame".into()))?;
let mut at = crate::apply_point(&crate::mat_mul(&inverse, &author_frame), face.at);
let name = f
.face_names
.first()
.filter(|name| !name.is_empty())
.ok_or_else(|| DocError::Invalid("selected cylinder face has no persistent identity".into()))?;
{
let mut scoped = ModelState::default();
scoped.bodies.push(mb.clone());
let (picked, split) = crate::naming::point_on_face(&scoped, name, at)
.ok_or_else(|| DocError::Invalid("selected cylinder face no longer exists".into()))?;
if split {
return Err(DocError::Invalid("selected cylinder face was split".into()));
}
let (index, _, _) = crate::project::nearest_face(&mb.mesh(), picked)
.ok_or_else(|| DocError::Invalid("selected cylinder face cannot be resolved".into()))?;
if crate::naming::face_names(&mb).get(index as usize) != Some(name) {
return Err(DocError::Invalid("selected cylinder face is ambiguous".into()));
}
at = kernel::cylinder_emboss_anchor(&mb.body, index as usize, picked)?;
}
let result = kernel::emboss_cylinder_at(&mb.body, at, &plane, &regions, if *deboss { -d } else { d })?;
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, result, mb.feature);
}
return Ok(());
}
let (a, b) = if *deboss { (-d, 0.0) } else { (0.0, d) };
let tools = kernel::extrude(&ss.plane, &regions, a, b)?;
if st.bodies.is_empty() {
@@ -242,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(())
}
@@ -466,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);
@@ -481,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 {
@@ -496,64 +555,127 @@ 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();
for i in 0..st.sheets.len() {
let Some(sh) = st.sheets.get(i).cloned() else { continue };
if !targets.is_empty() && !targets.contains(&sh.body) {
let targets = if targets.is_empty() { &f.participants } else { targets };
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
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 == original.body && (b.component == f.component || b.feature == f.id))
} else {
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 {
vec![(None, Region2 { outer: Loop2::polygon(&sh.outline()?.into_iter().next().unwrap_or_default()), holes: Vec::new() })]
}
} 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;
}
let outline = if sh.flat { sh.outline()?.into_iter().flatten().collect::<Vec<_>>() } else { panel.outer.polyline(1e-3) };
for r in &regions {
let Some(flat) = sh.region_to_flat(pi, &ss.plane, r) else { continue };
let fp = flat.outer.polyline(1e-3);
let overlaps = fp.iter().any(|p| point_in_poly(&outline, *p)) || outline.iter().any(|p| point_in_poly(&fp, *p));
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)
}
fn point_in_poly(poly: &[Vec2], p: Vec2) -> bool {
let mut inside = false;
let m = poly.len();
for i in 0..m {
let (Some(&a), Some(&b)) = (poly.get(i), poly.get((i + m - 1) % m)) else { continue };
if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x {
inside = !inside;
}
}
inside
}
/// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance
/// `level` from `at` along `n`: the edges used by one of them only, chained.
fn face_loops(mesh: &solvecraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Vec<Vec2>> {
+561
View File
@@ -0,0 +1,561 @@
//! Exact ordinary face attachments. Missing identities never invoke a proximity fallback.
use crate::config::ConfigFaceTarget;
use crate::{DocError, Document, EmbossFace, FeatureKind, Model, ModelBody, ModelState, PlaneRef, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use solvecraft_geom::Plane;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FaceAnchor {
pub target: ConfigFaceTarget,
pub component: u64,
pub path: Vec<u64>,
pub author_path: Vec<u64>,
/// Origin and x direction in the exact canonical face plane's coordinates.
pub coordinates: [f64; 2],
pub x_axis: [f64; 2],
}
impl FaceAnchor {
pub fn remap_target(
&mut self,
features: &std::collections::BTreeMap<u64, u64>,
bodies: &std::collections::BTreeMap<String, String>,
) -> Result<()> {
if bodies.contains_key(&self.target.body) && !features.contains_key(&self.target.origin_feature) {
return Err(DocError::Invalid("cannot clone an attached face without its certified producer".into()));
}
self.target.name = crate::remap_topology(&self.target.name, features, bodies);
if let Some(body) = bodies.get(&self.target.body) {
self.target.body = body.clone();
}
if let Some(feature) = features.get(&self.target.origin_feature) {
self.target.origin_feature = *feature;
}
if let Some(certificate) = &mut self.target.certificate {
certificate.remap_features(features);
}
self.validate()
}
pub fn validate(&self) -> Result<()> {
self.target.validate()?;
if self.target.certificate.is_none()
|| self.path.len() > 64
|| self.author_path.len() > 64
|| self.path.iter().chain(&self.author_path).any(|id| *id == 0)
|| (self.component == 0 && !self.path.is_empty())
|| (self.component != 0 && self.path.is_empty())
|| self.coordinates.iter().any(|value| !value.is_finite() || value.abs() > 1e9)
|| self.x_axis.iter().any(|value| !value.is_finite())
|| (self.x_axis[0].hypot(self.x_axis[1]) - 1.).abs() > 1e-6
{
return Err(DocError::Invalid("invalid exact face attachment".into()));
}
Ok(())
}
pub fn author_frame(&self, doc: &Document, author: u64) -> Result<crate::Mat> {
if author != 0 && self.author_path.is_empty() {
return Err(DocError::Invalid(
"attached sketch requires an explicit author occurrence path; activate a placement or choose a fixed plane".into(),
));
}
EmbossFace::component_frame(doc, author, &self.author_path)
}
pub fn source_to_author(&self, doc: &Document, author: u64, body: &ModelBody) -> Result<crate::Mat> {
self.validate()?;
if doc.model_body_component(body) != self.component {
return Err(DocError::Invalid("attached face's component ownership changed; reattach explicitly".into()));
}
let inverse = crate::mat_inverse(&self.author_frame(doc, author)?)
.ok_or_else(|| DocError::Invalid("attached sketch author frame is invalid".into()))?;
let source =
EmbossFace { body: self.target.body.clone(), at: self.target.point, path: self.path.clone(), author_path: self.author_path.clone() };
Ok(crate::mat_mul(&inverse, &source.body_frame(doc, body)?))
}
}
pub fn plane_face_anchor(plane: &PlaneRef) -> Option<&FaceAnchor> {
match plane {
PlaneRef::Face { anchor, .. } => anchor.as_deref(),
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane_face_anchor(base),
_ => None,
}
}
fn has_face_reference(plane: &PlaneRef) -> bool {
match plane {
PlaneRef::Face { .. } => true,
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => has_face_reference(base),
_ => false,
}
}
impl Document {
pub fn resolve_attached_plane(
&self,
vals: &std::collections::BTreeMap<String, crate::expr::Value>,
plane: &PlaneRef,
author: u64,
state: &ModelState,
framed: &ModelState,
) -> Result<Plane> {
fn replace(doc: &Document, plane: &mut PlaneRef, author: u64, state: &ModelState, depth: usize) -> Result<()> {
if depth > 64 {
return Err(DocError::Invalid("attached plane nesting exceeds budget".into()));
}
match plane {
PlaneRef::Face { anchor: Some(anchor), .. } => *plane = PlaneRef::Custom { plane: doc.resolve_face_anchor(anchor, author, state)? },
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => replace(doc, base, author, state, depth + 1)?,
_ => {}
}
Ok(())
}
let mut resolved = plane.clone();
replace(self, &mut resolved, author, state, 0)?;
self.resolve_plane_in(vals, &resolved, 0, Some(framed))
}
}
/// All status planes are world-space. A lost plane is a repair guide only.
#[derive(Clone, Debug)]
pub enum FaceAttachmentStatus {
Fixed { plane: Option<Plane> },
Legacy { plane: Option<Plane> },
Healthy { plane: Plane, target: ConfigFaceTarget, path: Vec<u64> },
Lost { message: String, repair_plane: Option<Plane> },
}
fn map_plane(matrix: &crate::Mat, plane: &Plane) -> Result<Plane> {
Plane::new(crate::apply_point(matrix, plane.origin), crate::apply_vector(matrix, plane.x), crate::apply_vector(matrix, plane.y))
.ok_or_else(|| DocError::Invalid("attached plane transform is degenerate".into()))
}
impl Document {
/// Capture a known selected face in the definition model, without another nearest pick.
pub fn capture_face_anchor(
&self,
body: &ModelBody,
index: usize,
picked: &Plane,
target_path: &[u64],
author: u64,
author_path: &[u64],
) -> Result<FaceAnchor> {
let source = body
.body
.face_plane(index)
.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),
path: target_path.to_vec(),
author_path: author_path.to_vec(),
coordinates: [0., 0.],
x_axis: [1., 0.],
};
let canonical = map_plane(&anchor.source_to_author(self, author, body)?, &source)?;
if picked.normal().dot(canonical.normal()) < 1. - 1e-7 || (picked.origin - canonical.origin).dot(canonical.normal()).abs() > 1e-7 {
return Err(DocError::Invalid("picked plane does not match its exact source face".into()));
}
let delta = picked.origin - canonical.origin;
anchor.coordinates = [delta.dot(canonical.x), delta.dot(canonical.y)];
anchor.x_axis = [picked.x.dot(canonical.x), picked.x.dot(canonical.y)];
anchor.validate()?;
Ok(anchor)
}
pub fn resolve_face_anchor(&self, anchor: &FaceAnchor, author: u64, state: &ModelState) -> Result<Plane> {
anchor.validate()?;
let body =
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()))?;
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()))?;
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()));
}
}
let canonical = map_plane(&anchor.source_to_author(self, author, body)?, &plane)?;
let origin = canonical.origin + canonical.x * anchor.coordinates[0] + canonical.y * anchor.coordinates[1];
let x = canonical.x * anchor.x_axis[0] + canonical.y * anchor.x_axis[1];
Plane::new(origin, x, canonical.normal().cross(x)).ok_or_else(|| DocError::Invalid("attached sketch basis is degenerate".into()))
}
pub fn sketch_face_attachment(&self, feature: u64, model: &Model) -> FaceAttachmentStatus {
let Some(feature) = self.feature(feature) else {
return FaceAttachmentStatus::Lost { message: "sketch no longer exists".into(), repair_plane: None };
};
let FeatureKind::Sketch { plane, .. } = &feature.kind else {
return FaceAttachmentStatus::Fixed { plane: None };
};
let Some(anchor) = plane_face_anchor(plane) else {
let path = self.first_path(feature.component);
let unique = path.iter().all(|id| {
self.occurrences
.iter()
.find(|o| o.id == *id)
.is_some_and(|selected| self.occurrences.iter().filter(|o| o.component == selected.component).count() == 1)
});
let world = unique.then(|| EmbossFace::component_frame(self, feature.component, &path).ok()).flatten();
let current = model.state().sketch(feature.id).map(|sketch| sketch.plane).or_else(|| {
let (vals, _) = self.param_values();
self.resolve_plane(&vals, plane, 0).ok()
});
let current = world.and_then(|world| current.and_then(|plane| map_plane(&world, &plane).ok()));
return if has_face_reference(plane) {
FaceAttachmentStatus::Legacy { plane: current }
} else {
FaceAttachmentStatus::Fixed { plane: current }
};
};
let (vals, _) = self.param_values();
let picked = self.resolve_plane(&vals, plane, 0).ok();
let world = anchor.author_frame(self, feature.component);
let repair_plane = world.as_ref().ok().and_then(|matrix| picked.as_ref().and_then(|picked| map_plane(matrix, picked).ok()));
let state = model.state_before(feature.id);
match crate::state_in_face_anchor_frame(self, &state, anchor, feature.component)
.and_then(|framed| self.resolve_attached_plane(&vals, plane, feature.component, &state, &framed))
.and_then(|plane| map_plane(&world?, &plane))
{
Ok(plane) => FaceAttachmentStatus::Healthy { plane, target: anchor.target.clone(), path: anchor.path.clone() },
Err(error) => FaceAttachmentStatus::Lost { message: format!("lost face attachment on `{}`: {error}", anchor.target.body), repair_plane },
}
}
}
pub fn validate_face_anchor_json(value: &Value) -> Result<()> {
let fields = value.as_object().ok_or_else(|| DocError::Invalid("face anchor must be an object".into()))?;
if fields.len() != 6 || fields.keys().any(|key| !["target", "component", "path", "author_path", "coordinates", "x_axis"].contains(&key.as_str()))
{
return Err(DocError::Invalid("unknown or missing face anchor fields".into()));
}
let target = fields.get("target").ok_or_else(|| DocError::Invalid("face anchor target missing".into()))?;
crate::config::validate_face_target_json(target)?;
if target.get("certificate").is_none_or(Value::is_null) {
return Err(DocError::Invalid("face anchor needs a source certificate".into()));
}
let component =
fields.get("component").and_then(Value::as_u64).ok_or_else(|| DocError::Invalid("face anchor component must be an integer".into()))?;
for key in ["path", "author_path"] {
if fields.get(key).and_then(Value::as_array).is_none_or(|path| {
path.len() > 64
|| path.iter().any(|id| id.as_u64().is_none_or(|id| id == 0))
|| (key == "path" && component == 0 && !path.is_empty())
|| (key == "path" && component != 0 && path.is_empty())
}) {
return Err(DocError::Invalid("face anchor path is invalid or oversized".into()));
}
}
for key in ["coordinates", "x_axis"] {
let values = fields
.get(key)
.and_then(Value::as_array)
.filter(|values| values.len() == 2)
.ok_or_else(|| DocError::Invalid("face anchor vectors require exactly two values".into()))?;
if values.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9)) {
return Err(DocError::Invalid("face anchor vector is invalid".into()));
}
if key == "x_axis" && (values[0].as_f64().unwrap_or(0.).hypot(values[1].as_f64().unwrap_or(0.)) - 1.).abs() > 1e-6 {
return Err(DocError::Invalid("face anchor basis must be normalized".into()));
}
}
Ok(())
}
/// Walk reference-bearing JSON before typed decoding, excluding bulk geometry payloads.
pub fn validate_face_anchors_json(value: &Value) -> Result<()> {
let mut stack = vec![(value, 0usize)];
let mut visited = 0usize;
while let Some((value, depth)) = stack.pop() {
visited += 1;
if visited > 2_000_000 || depth > 128 {
return Err(DocError::Invalid("face anchor structure exceeds work limits".into()));
}
match value {
Value::Object(fields) => {
if fields.get("type").and_then(Value::as_str) == Some("face")
&& let Some(anchor) = fields.get("anchor")
&& !anchor.is_null()
{
validate_face_anchor_json(anchor)?;
}
for (key, child) in fields {
if !["sketch", "meshes", "step", "configs", "appearances", "materials"].contains(&key.as_str())
&& matches!(child, Value::Array(_) | Value::Object(_))
{
stack.push((child, depth + 1));
}
}
}
Value::Array(values) => {
for child in values {
if matches!(child, Value::Array(_) | Value::Object(_)) {
stack.push((child, depth + 1));
}
}
}
_ => {}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use solvecraft_geom::Vec3;
fn fixture() -> (Document, ModelBody, usize, Plane) {
let mut doc = Document::default();
let kind: FeatureKind =
serde_json::from_value(serde_json::json!({"type":"box","corner":[0,0,0],"length":"10","width":"10","height":"10"})).unwrap();
let id = doc.add_feature(kind, Some("Base")).unwrap();
let body = ModelBody::new(
"Base".into(),
solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(id).unwrap(),
id,
);
let index = body
.body
.face_sources()
.unwrap()
.iter()
.position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::Box { axis: 2, high: true, .. })))
.unwrap();
let canonical = body.body.face_plane(index).unwrap();
let picked = Plane::new(canonical.origin + canonical.x * 2. + canonical.y * 3., canonical.y, -canonical.x).unwrap();
(doc, body, index, picked)
}
#[test]
fn exact_anchor_preserves_picked_basis_and_follows_height_without_parallel_rescue() {
let (doc, body, index, picked) = fixture();
let anchor = doc.capture_face_anchor(&body, index, &picked, &[], 0, &[]).unwrap();
let anchor: FaceAnchor = serde_json::from_str(&serde_json::to_string(&anchor).unwrap()).unwrap();
assert!(anchor.author_frame(&doc, 1).is_err(), "strong author attachment cannot infer a first placement");
let mut missing_source = anchor.clone();
missing_source.component = 1;
assert!(missing_source.validate().is_err(), "strong source attachment needs an explicit path");
assert!(validate_face_anchor_json(&serde_json::to_value(missing_source).unwrap()).is_err());
let mut state = ModelState::default();
state.bodies.push(body);
let initial = doc.resolve_face_anchor(&anchor, 0, &state).unwrap();
assert!((initial.origin - picked.origin).len() < 1e-9);
assert!((initial.x - picked.x).len() < 1e-9);
state.bodies[0] = ModelBody::new(
"Base".into(),
solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 20.))
.unwrap()
.with_box_certificate(anchor.target.origin_feature)
.unwrap(),
anchor.target.origin_feature,
);
let changed = doc.resolve_face_anchor(&anchor, 0, &state).unwrap();
assert!((changed.origin.z - 20.).abs() < 1e-9);
assert!((changed.origin.x - initial.origin.x).abs() < 1e-9);
state.bodies[0] = ModelBody::new(
"Control".into(),
solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 30.))
.unwrap()
.with_box_certificate(anchor.target.origin_feature)
.unwrap(),
anchor.target.origin_feature,
);
assert!(doc.resolve_face_anchor(&anchor, 0, &state).is_err());
state.bodies[0].name = "Base".into();
state.bodies[0].body = solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 30.))
.unwrap()
.with_box_certificate(anchor.target.origin_feature + 1)
.unwrap();
assert!(doc.resolve_face_anchor(&anchor, 0, &state).is_err(), "reused name cannot replace the producer");
}
#[test]
fn nested_anchor_offset_tracks_source_and_raw_budgets_precede_decode() {
let (doc, body, index, picked) = fixture();
let anchor = doc.capture_face_anchor(&body, index, &picked, &[], 0, &[]).unwrap();
let plane = PlaneRef::Offset {
base: Box::new(PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: Some(Box::new(anchor.clone())) }),
distance: "2".into(),
};
let mut state = ModelState::default();
state.bodies.push(body);
let (vals, _) = doc.param_values();
let result = doc.resolve_attached_plane(&vals, &plane, 0, &state, &state).unwrap();
assert!((result.origin.z - 12.).abs() < 1e-9);
state.bodies.clear();
assert!(doc.resolve_attached_plane(&vals, &plane, 0, &state, &state).is_err());
let mut raw = serde_json::to_value(anchor).unwrap();
assert!(validate_face_anchor_json(&raw).is_ok());
raw["path"] = serde_json::json!([1]);
assert!(validate_face_anchor_json(&raw).is_err());
raw["path"] = serde_json::json!([]);
raw["target"]["name"] = Value::String("x".repeat(1_000_000));
assert!(validate_face_anchor_json(&raw).is_err());
}
#[test]
fn extrude_cap_profile_reordering_never_retargets_an_ordinal_body() {
use solvecraft_geom::Vec2;
let mut sketch = solvecraft_sketch::Sketch::new();
for offset in [0., 30.] {
let points: Vec<_> = [(0., 0.), (10., 0.), (10., 10.), (0., 10.)]
.iter()
.map(|(x, y)| sketch.add_point(Vec2::new(x + offset, *y), None).unwrap())
.collect();
for edge in 0..4 {
sketch.add_line_pts(points[edge], points[(edge + 1) % 4], None).unwrap();
}
}
let mut doc = Document::default();
let source = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
let extent: crate::Extent = serde_json::from_value(serde_json::json!({"distance":"10"})).unwrap();
let producer = doc
.add_feature(
FeatureKind::Extrude {
sketch: source,
profiles: crate::ProfileSel::Indices { indices: vec![0, 1] },
extent,
operation: crate::Operation::NewBody,
targets: vec![],
},
None,
)
.unwrap();
let mut model = Model::new();
model.evaluate(&doc);
let state = model.state();
assert_eq!(state.bodies.len(), 2);
let solved = state.sketch(source).unwrap();
let regions = crate::select_profiles(solved, &crate::ProfileSel::All).unwrap();
let raw = solvecraft_kernel::extrude(&solved.plane, &regions, 0., 10.).unwrap();
assert!(
crate::config::extrude_cap_bindings(solved, &raw, 0).is_none(),
"exhausted certification work must leave the complete producer uncertified"
);
let bindings = crate::config::extrude_cap_bindings(solved, &raw, 10_000_000).unwrap();
assert_eq!(bindings.iter().flatten().flatten().count(), 4, "two profiles each retain both exact caps within budget");
let mut duplicate = raw.clone();
duplicate.extend(raw);
let ambiguous = crate::config::extrude_cap_bindings(solved, &duplicate, 10_000_000).unwrap();
assert!(ambiguous.iter().flatten().all(Option::is_none), "duplicated profile identities cannot stamp ambiguous caps");
let body = &state.bodies[0];
let index = body
.body
.face_sources()
.unwrap()
.iter()
.position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { high: true, .. })))
.unwrap();
let picked = body.body.face_plane(index).unwrap();
let anchor = doc.capture_face_anchor(body, index, &picked, &[], 0, &[]).unwrap();
let bounds = body.mesh().bounds();
let FeatureKind::Extrude { profiles, .. } = &mut doc.feature_mut(producer).unwrap().kind else {
panic!("fixture must retain its extrude feature")
};
*profiles = crate::ProfileSel::Indices { indices: vec![1, 0] };
model.evaluate(&doc);
let changed = model.state();
assert_eq!(changed.bodies.len(), 2);
if doc.resolve_face_anchor(&anchor, 0, &changed).is_ok() {
let current = changed.body(&anchor.target.body).unwrap().mesh().bounds();
assert!((current.min.x - bounds.min.x).abs() < 1e-9, "a same-role cap of the other profile must never replace the source");
}
}
#[test]
fn wrapped_legacy_face_is_reported_as_legacy_without_inferred_upgrade() {
let (mut doc, _, _, picked) = fixture();
let id = doc
.add_feature(
FeatureKind::Sketch {
plane: PlaneRef::Offset {
base: Box::new(PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: None }),
distance: "2".into(),
},
sketch: solvecraft_sketch::Sketch::new(),
},
None,
)
.unwrap();
let mut model = Model::new();
model.evaluate(&doc);
let FaceAttachmentStatus::Legacy { plane: Some(plane) } = doc.sketch_face_attachment(id, &model) else {
panic!("wrapped legacy attachment must retain its classification and fixed-plane escape");
};
assert!((plane.origin.z - 12.).abs() < 1e-9);
let FeatureKind::Sketch { plane, .. } = &doc.feature(id).unwrap().kind else { panic!("fixture must retain its sketch feature") };
assert!(plane_face_anchor(plane).is_none());
}
#[test]
fn warm_root_body_offset_edit_matches_cold_attached_sketch_and_downstream_bounds() {
use solvecraft_geom::Vec2;
let (mut doc, _, _, _) = fixture();
let mut warm = Model::new();
warm.evaluate(&doc);
let source = warm.state();
let body = &source.bodies[0];
let index = body
.body
.face_sources()
.unwrap()
.iter()
.position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::Box { axis: 2, high: true, .. })))
.unwrap();
let picked = body.body.face_plane(index).unwrap();
let anchor = doc.capture_face_anchor(body, index, &picked, &[], 0, &[]).unwrap();
let source_name = body.name.clone();
let mut sketch = solvecraft_sketch::Sketch::new();
let points: Vec<_> =
[(2., 2.), (4., 2.), (4., 4.), (2., 4.)].iter().map(|(x, y)| sketch.add_point(Vec2::new(*x, *y), None).unwrap()).collect();
for edge in 0..4 {
sketch.add_line_pts(points[edge], points[(edge + 1) % 4], None).unwrap();
}
let sk = doc
.add_feature(
FeatureKind::Sketch {
plane: PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: Some(Box::new(anchor)) },
sketch,
},
None,
)
.unwrap();
let extent = serde_json::from_value(serde_json::json!({"distance":"2"})).unwrap();
let boss = doc
.add_feature(
FeatureKind::Extrude { sketch: sk, profiles: crate::ProfileSel::All, extent, operation: crate::Operation::NewBody, targets: vec![] },
Some("Boss"),
)
.unwrap();
warm.evaluate(&doc);
let initial = warm.state();
let before = initial.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
let mut offset = crate::IDENTITY;
offset[3][0] = 5.;
offset[3][2] = 3.;
doc.body_offsets.insert(source_name, offset);
warm.evaluate(&doc);
let mut cold = Model::new();
cold.evaluate(&doc);
let warm_state = warm.state();
let cold_state = cold.state();
let after = warm_state.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
let rebuilt = cold_state.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
assert!(
(after.min - before.min - Vec3::new(5., 0., 3.)).len() < 1e-7,
"before={before:?}, warm={after:?}, cold={rebuilt:?}, warm_plane={:?}, cold_plane={:?}",
warm_state.sketch(sk).map(|sk| sk.plane),
cold_state.sketch(sk).map(|sk| sk.plane)
);
assert!((after.min - rebuilt.min).len() < 1e-7);
assert!((after.max - rebuilt.max).len() < 1e-7);
let FaceAttachmentStatus::Healthy { plane, .. } = doc.sketch_face_attachment(sk, &warm) else {
panic!("offset source must remain attached");
};
assert!((plane.origin - warm_state.sketch(sk).unwrap().plane.origin).len() < 1e-9);
assert!((warm_state.sketch(sk).unwrap().plane.origin - cold_state.sketch(sk).unwrap().plane.origin).len() < 1e-9);
}
}
+852 -2
View File
@@ -20,13 +20,26 @@
//! "don't mate". Older joints keep their placement: their `flip` is inverted.
//! - 6: XZ origin planes use sketch y = -Z and normal +Y. Older XZ references become
//! explicit planes retaining their previous axes, including offset-plane expressions.
//! - 7: configuration tables can include body material and appearance columns. Version 6
//! rows need no transformation; the version stamp lets older readers request an update
//! instead of encountering an unknown column variant.
//! - 8: ordered configuration rules and persistent signed line-angle, arc-sweep and slot
//! dimensions. Version 7 geometry and table values retain their meaning without changes.
//! - 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.
//! - 11: durable sketch face anchors, explicitly periodic fit splines and versioned revolve
//! conventions. Absent markers retain legacy attachment and interpolation behavior.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 6;
pub const FORMAT: u32 = 11;
pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}")
@@ -39,7 +52,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; 5] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6];
const JSON_STEPS: [JsonStep; 10] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| 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,
@@ -130,10 +143,35 @@ pub fn read(s: &str) -> Result<Document> {
)));
}
check_numbers(&v, 0)?;
crate::config::validate_rules_json(&v)?;
crate::config::validate_styles_json(&v)?;
crate::face_anchor::validate_face_anchors_json(&v)?;
validate_versioned_geometry(&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
@@ -145,12 +183,327 @@ 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}")))?;
sketch.validate_periodic_splines().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.
normalise(&mut d)?;
d.format = format_string(FORMAT);
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(()) };
if features.len() > crate::document::MAX_FEATURES {
return Err(DocError::Invalid("document too large".into()));
}
for feature in features {
if feature.get("type").and_then(Value::as_str) != Some("emboss") {
continue;
}
let Some(target) = feature.get("face").filter(|target| !target.is_null()) else { continue };
let invalid = || DocError::Invalid("damaged design: an emboss face needs a bounded body name and three finite coordinates".into());
let object = target.as_object().ok_or_else(invalid)?;
if object.keys().any(|key| !matches!(key.as_str(), "body" | "at" | "path" | "author_path")) {
return Err(invalid());
}
let body = object.get("body").and_then(Value::as_str).ok_or_else(invalid)?;
let point = object.get("at").and_then(Value::as_array).ok_or_else(invalid)?;
if body.trim().is_empty()
|| body.len() > 512
|| body.chars().any(char::is_control)
|| point.len() != 3
|| point.iter().any(|coordinate| coordinate.as_f64().is_none_or(|number| !number.is_finite() || number.abs() > MAX_REAL))
{
return Err(invalid());
}
for path in ["path", "author_path"].into_iter().filter_map(|key| object.get(key)) {
let path = path.as_array().ok_or_else(invalid)?;
if path.len() > 64 {
return Err(invalid());
}
let mut ids = std::collections::BTreeSet::new();
for id in path {
let id = id.as_u64().filter(|id| *id != 0).ok_or_else(invalid)?;
if !ids.insert(id) {
return Err(invalid());
}
}
}
}
Ok(())
}
/// Bound explicitly versioned geometry before allocating typed spline point arrays.
fn validate_versioned_geometry(value: &Value) -> Result<()> {
let invalid = || DocError::Invalid("damaged design: invalid periodic spline or revolve convention".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("revolve")
&& let Some(convention) = feature.get("convention")
&& !matches!(convention.as_str(), Some("legacy_axis_start" | "sketch_normal"))
{
return Err(invalid());
}
if feature.get("type").and_then(Value::as_str) != Some("sketch") {
continue;
}
let Some(sketch) = feature.get("sketch") else { continue };
if let Some(links) = sketch.get("links").and_then(Value::as_array) {
for link in links {
if link.get("auto_face").is_some_and(|marker| !marker.is_boolean()) {
return Err(invalid());
}
}
}
let Some(curves) = sketch.get("curves").and_then(Value::as_array) else { continue };
let points = sketch.get("points").and_then(Value::as_array);
let mut work = 0usize;
for curve in curves {
if curve.get("type").and_then(Value::as_str) != Some("spline") {
continue;
}
let Some(marker) = curve.get("periodic") else { continue };
let periodic = marker.as_bool().ok_or_else(invalid)?;
if !periodic {
continue;
}
let indices = curve.get("pts").and_then(Value::as_array).ok_or_else(invalid)?;
work = work.saturating_add(indices.len());
if !(4..=solvecraft_sketch::MAX_SPLINE_POINTS).contains(&indices.len())
|| work > 1_000_000
|| curve.get("control").is_some_and(|control| control.as_bool() != Some(false))
|| curve.get("degree").is_some_and(|degree| degree.as_u64() != Some(3))
{
return Err(invalid());
}
let count = points.ok_or_else(invalid)?.len();
if indices.iter().any(|index| index.as_u64().is_none_or(|index| index >= count as u64)) {
return Err(invalid());
}
}
}
Ok(())
}
fn validate_freeform_offsets(value: &Value) -> Result<()> {
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("sketch") {
continue;
}
let Some(records) = feature.get("sketch").and_then(|sketch| sketch.get("freeform_offsets")) else { continue };
let invalid = || DocError::Invalid("damaged design: invalid free-form offset metadata".into());
let records = records.as_array().ok_or_else(invalid)?;
if records.len() > 128 {
return Err(invalid());
}
let periodic_outputs: std::collections::BTreeSet<_> = records
.iter()
.filter(|record| record.get("certificate").and_then(Value::as_u64) == Some(2))
.filter_map(|record| record.get("output").and_then(Value::as_str))
.collect();
let mut periodic_curves = std::collections::BTreeMap::new();
if !periodic_outputs.is_empty()
&& let Some(curves) = feature.get("sketch").and_then(|sketch| sketch.get("curves")).and_then(Value::as_array)
{
if curves.len() > 1_000_000 {
return Err(invalid());
}
for curve in curves {
if let Some(id) = curve.get("id").and_then(Value::as_str)
&& periodic_outputs.contains(id)
&& periodic_curves.insert(id, curve).is_some()
{
return Err(invalid());
}
}
}
let mut outputs = std::collections::BTreeSet::new();
for record in records {
let object = record.as_object().ok_or_else(invalid)?;
if object.len() != 9
|| object.keys().any(|key| {
!matches!(
key.as_str(),
"source" | "output" | "forward" | "distance" | "divisions" | "pieces" | "closed" | "tolerance" | "certificate"
)
})
{
return Err(invalid());
}
let source = record.get("source").and_then(Value::as_str).ok_or_else(invalid)?;
let output = record.get("output").and_then(Value::as_str).ok_or_else(invalid)?;
if [source, output].iter().any(|id| id.is_empty() || id.len() > 512 || id.chars().any(char::is_control))
|| source == output
|| !outputs.insert(output)
{
return Err(invalid());
}
let divisions = record.get("divisions").and_then(Value::as_u64).ok_or_else(invalid)?;
let pieces = record.get("pieces").and_then(Value::as_u64).ok_or_else(invalid)?;
let distance = record.get("distance").and_then(Value::as_f64).ok_or_else(invalid)?;
let tolerance = record.get("tolerance").and_then(Value::as_f64).ok_or_else(invalid)?;
if !(1..=256).contains(&divisions)
|| !divisions.is_power_of_two()
|| !(1..=256).contains(&pieces)
|| pieces * divisions >= solvecraft_sketch::MAX_SPLINE_POINTS as u64
|| !distance.is_finite()
|| !(1e-9..=MAX_REAL).contains(&distance.abs())
|| !tolerance.is_finite()
|| !(tolerance > 0.0 && tolerance <= 0.002)
|| !matches!(record.get("certificate").and_then(Value::as_u64), Some(1 | 2))
|| record.get("forward").and_then(Value::as_bool).is_none()
|| record.get("closed").and_then(Value::as_bool).is_none()
{
return Err(invalid());
}
if record.get("certificate").and_then(Value::as_u64) == Some(2) {
let output_curve = periodic_curves.get(output);
if record.get("closed").and_then(Value::as_bool) != Some(true)
|| output_curve.is_none_or(|curve| {
curve.get("type").and_then(Value::as_str) != Some("spline") || curve.get("periodic").and_then(Value::as_bool) != Some(true)
})
{
return Err(invalid());
}
}
}
let mut resolved = std::collections::BTreeSet::new();
for record in records {
let source = record.get("source").and_then(Value::as_str).ok_or_else(invalid)?;
let output = record.get("output").and_then(Value::as_str).ok_or_else(invalid)?;
if outputs.contains(source) && !resolved.contains(source) {
return Err(invalid());
}
resolved.insert(output);
}
}
Ok(())
}
/// Largest coordinate or size a design may hold (mm): a kilometre-scale part is already far
/// outside what the kernel's tolerances serve.
const MAX_REAL: f64 = 1e9;
@@ -210,6 +563,503 @@ 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};
let mut document = Document::new("legacy named extent");
document.set_param("offset", "2", Some("mm"), None).unwrap();
let sketch = document
.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: solvecraft_sketch::Sketch::new() }, None)
.unwrap();
document
.add_feature(
FeatureKind::Extrude {
sketch,
profiles: ProfileSel::All,
extent: Extent {
distance: "10".into(),
direction: Direction::Negative,
distance2: None,
start_offset: None,
through_all: false,
taper: Some("0 deg".into()),
to: Some(solvecraft_geom::Vec3::new(0.0, 0.0, 10.0)),
to_offset: Some("offset + 1 mm".into()),
from: Some(solvecraft_geom::Vec3::new(0.0, 0.0, 5.0)),
},
operation: Operation::NewBody,
targets: vec![],
},
None,
)
.unwrap();
document
.add_feature(
FeatureKind::Box {
corner: ["0".into(), "0".into(), "0".into()],
length: "d1".into(),
width: "4".into(),
height: "6".into(),
operation: Operation::NewBody,
},
None,
)
.unwrap();
let mut legacy: Value = serde_json::from_str(&document.to_json()).unwrap();
legacy["format"] = Value::from("solvecraft/8");
// Version 8 exposed distance and taper, but had no model slot for To Offset.
legacy["features"][1]["param_names"] = serde_json::json!(["d1", "d2"]);
legacy["features"][2]["param_names"] = serde_json::json!(["d3", "d4", "d5"]);
let old_kind = legacy["features"][1].clone();
let upgraded = read(&legacy.to_string()).unwrap();
assert_eq!(&upgraded.features[1].param_names[..2], ["d1", "d2"]);
assert_eq!(upgraded.features[2].param_names, ["d3", "d4", "d5"]);
assert_eq!(upgraded.features[1].param_names.len(), 3);
assert_eq!(upgraded.features[1].param_names[2], "d6");
let saved: Value = serde_json::from_str(&upgraded.to_json()).unwrap();
for field in ["extent", "sketch", "operation", "profiles"] {
assert_eq!(saved["features"][1][field], old_kind[field]);
}
let (values, errors) = upgraded.param_values();
assert!(errors.is_empty(), "{errors:?}");
assert_eq!(values.get("d1").unwrap().v, 10.0);
assert_eq!(values.get("d3").unwrap().v, 10.0);
assert_eq!(values.get("d6").unwrap().v, 3.0);
assert_eq!(read(&upgraded.to_json()).unwrap(), upgraded);
}
#[test]
fn saved_offset_cannot_alias_and_overwrite_its_source_geometry() {
use solvecraft_geom::Vec2;
use solvecraft_sketch::{CurveKind, Sketch};
let mut sketch = Sketch::new();
let center = sketch.add_point(Vec2::ZERO, None).unwrap();
let major = sketch.add_point(Vec2::new(10.0, 0.0), None).unwrap();
let source = sketch.add_curve(CurveKind::Ellipse { c: center, m: major, r: 5.0 }, None).unwrap();
let output = sketch.add_freeform_offset(source, true, -1.0).unwrap();
assert!(sketch.validate_freeform_offsets().is_ok());
if let CurveKind::Spline { pts, .. } = &mut sketch.curves[output].kind {
pts[0] = center;
} else {
panic!("offset must produce a fit spline");
}
let mut document = Document::new("aliased offset geometry");
document.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
let error = read(&document.to_json()).unwrap_err().to_string();
assert!(error.contains("damaged design"), "{error}");
}
#[test]
fn structurally_valid_offset_records_must_reference_real_sketch_geometry() {
let mut document = Document::new("missing offset geometry");
document
.add_feature(
crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch: solvecraft_sketch::Sketch::new() },
None,
)
.unwrap();
let mut value: Value = serde_json::from_str(&document.to_json()).unwrap();
value["features"][0]["sketch"]["freeform_offsets"] = serde_json::json!([{
"source":"ellipse", "output":"offset", "forward":true, "distance":2.0,
"divisions":8, "pieces":4, "closed":true, "tolerance":0.002, "certificate":1
}]);
assert!(validate_freeform_offsets(&value).is_ok());
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("damaged design"), "{error}");
}
#[test]
fn malformed_face_anchors_are_refused_before_typed_decoding() {
let anchor = serde_json::json!({
"target":{"body":"Base", "origin_feature":1, "name":"Base:+z", "point":[0,0,10],
"certificate":{"kind":"box", "axis":2, "high":true}},
"component":0, "path":[], "author_path":[], "coordinates":[0,0], "x_axis":[1,0]
});
let make = |anchor: Value| {
serde_json::json!({"features":[{"type":"sketch", "plane":{
"type":"face", "anchor":anchor
}}]})
};
assert!(crate::face_anchor::validate_face_anchors_json(&make(anchor.clone())).is_ok());
let mut nested = serde_json::json!({"features":[{"type":"sketch", "plane":{
"type":"offset", "base":{"type":"at_angle", "base":{"type":"face", "anchor":anchor.clone()}}
}}]});
assert!(crate::face_anchor::validate_face_anchors_json(&nested).is_ok());
nested["features"][0]["plane"]["base"]["base"]["anchor"]["coordinates"] = serde_json::json!([0, 0, 0]);
assert!(crate::face_anchor::validate_face_anchors_json(&nested).is_err());
let mut malformed = Vec::new();
for (key, damaged) in [
("component", serde_json::json!(-1)),
("path", serde_json::json!([0])),
("author_path", serde_json::json!((1..=65).collect::<Vec<u64>>())),
("coordinates", serde_json::json!([1_000_000_001_u64, 0])),
("coordinates", serde_json::json!([0])),
("x_axis", serde_json::json!([0, 0])),
("unknown", serde_json::json!(true)),
] {
let mut candidate = anchor.clone();
candidate[key] = damaged;
malformed.push(candidate);
}
let mut missing_certificate = anchor.clone();
missing_certificate["target"].as_object_mut().unwrap().remove("certificate");
malformed.push(missing_certificate);
let mut invalid_role = anchor;
invalid_role["target"]["certificate"]["axis"] = Value::from(3);
malformed.push(invalid_role);
for candidate in malformed {
let value = make(candidate);
assert!(crate::face_anchor::validate_face_anchors_json(&value).is_err());
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(!error.contains("design file:"), "must refuse before typed decode: {error}");
}
}
#[test]
fn periodic_markers_are_bounded_before_typed_decoding() {
let curve = serde_json::json!({"id":"closed", "type":"spline", "pts":[0,1,2,3], "periodic":true});
let make = |curve: Value| {
serde_json::json!({"features":[{"type":"sketch", "sketch":{
"points":[{}, {}, {}, {}], "curves":[curve]
}}]})
};
assert!(validate_versioned_geometry(&make(curve.clone())).is_ok());
for (key, malformed) in [
("periodic", serde_json::json!("true")),
("control", serde_json::json!(true)),
("degree", serde_json::json!(2)),
("pts", serde_json::json!([0, 1, 2])),
("pts", serde_json::json!([0, 1, 2, u64::MAX])),
("pts", serde_json::json!(vec![0; solvecraft_sketch::MAX_SPLINE_POINTS + 1])),
] {
let mut damaged = curve.clone();
damaged[key] = malformed;
assert!(validate_versioned_geometry(&make(damaged)).is_err(), "{key}");
}
let mut legacy = curve;
legacy.as_object_mut().unwrap().remove("periodic");
legacy["control"] = Value::Bool(true);
assert!(validate_versioned_geometry(&make(legacy)).is_ok());
for convention in [serde_json::json!(null), serde_json::json!(false), serde_json::json!("unknown")] {
assert!(validate_versioned_geometry(&serde_json::json!({"features":[{"type":"revolve", "convention":convention}]})).is_err());
}
}
#[test]
fn periodic_offset_certificate_requires_closed_periodic_output() {
let record = serde_json::json!({"source":"ellipse", "output":"offset", "forward":true,
"distance":2.0, "divisions":8, "pieces":4, "closed":true,
"tolerance":0.002, "certificate":2});
let mut value = serde_json::json!({"features":[{"type":"sketch", "sketch":{
"freeform_offsets":[record], "curves":[{"id":"offset", "type":"spline", "periodic":true}]
}}]});
assert!(validate_freeform_offsets(&value).is_ok());
let duplicate = value["features"][0]["sketch"]["curves"][0].clone();
value["features"][0]["sketch"]["curves"].as_array_mut().unwrap().push(duplicate);
assert!(validate_freeform_offsets(&value).is_err(), "periodic output ID must be unique");
value["features"][0]["sketch"]["curves"].as_array_mut().unwrap().pop();
value["features"][0]["sketch"]["curves"][0]["periodic"] = Value::Bool(false);
assert!(validate_freeform_offsets(&value).is_err());
value["features"][0]["sketch"]["curves"][0]["periodic"] = Value::Bool(true);
value["features"][0]["sketch"]["freeform_offsets"][0]["closed"] = Value::Bool(false);
assert!(validate_freeform_offsets(&value).is_err());
value["features"][0]["sketch"]["freeform_offsets"][0]["certificate"] = Value::from(1);
assert!(validate_freeform_offsets(&value).is_ok(), "legacy certificate semantics remain valid");
}
#[test]
fn periodic_file_validation_refuses_damaged_closed_geometry() {
use solvecraft_geom::Vec2;
use solvecraft_sketch::{CurveKind, Sketch};
let mut sketch = Sketch::new();
let mut pts = Vec::new();
for point in [Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(0.0, 10.0), Vec2::ZERO] {
pts.push(sketch.add_point(point, None).unwrap());
}
let first = pts[0];
let neighbor = pts[1];
let closing = pts[3];
sketch.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true }, None).unwrap();
let mut document = Document::new("periodic file validation");
document.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
assert_eq!(read(&document.to_json()).unwrap(), document);
let mut value: Value = serde_json::from_str(&document.to_json()).unwrap();
value["features"][0]["sketch"]["points"][closing]["pos"] = serde_json::json!([1, 0]);
assert!(read(&value.to_string()).unwrap_err().to_string().contains("periodic"));
value["features"][0]["sketch"]["points"][closing]["pos"] = value["features"][0]["sketch"]["points"][first]["pos"].clone();
value["features"][0]["sketch"]["points"][neighbor]["pos"] = value["features"][0]["sketch"]["points"][first]["pos"].clone();
assert!(read(&value.to_string()).unwrap_err().to_string().contains("periodic"));
}
#[test]
fn freeform_offset_metadata_is_bounded_before_typed_decoding() {
let valid = serde_json::json!({"source":"ellipse", "output":"offset", "forward":true,
"distance":2.0, "divisions":8, "pieces":4, "closed":true,
"tolerance":0.002, "certificate":1});
let mut malformed = Vec::new();
for (key, value) in [
("divisions", serde_json::json!(u64::MAX)),
("pieces", serde_json::json!(u64::MAX)),
("divisions", serde_json::json!(3)),
("distance", serde_json::json!(0)),
("tolerance", serde_json::json!(0.003)),
("certificate", serde_json::json!(2)),
("output", serde_json::json!("x".repeat(513))),
("source", serde_json::json!("offset")),
("unknown", serde_json::json!(true)),
] {
let mut record = valid.clone();
record[key] = value;
malformed.push(serde_json::json!([record]));
}
malformed.push(serde_json::json!(vec![valid.clone(); 129]));
malformed.push(serde_json::json!([valid.clone(), valid.clone()]));
let mut cycle = valid.clone();
cycle["source"] = serde_json::json!("offset");
cycle["output"] = serde_json::json!("ellipse");
malformed.push(serde_json::json!([valid, cycle]));
for records in malformed {
let mut value: Value = serde_json::from_str(&Document::new("hostile offset").to_json()).unwrap();
value["features"] = serde_json::json!([{"type":"sketch", "sketch":{"freeform_offsets":records}}]);
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("free-form offset metadata"), "{error}");
value["format"] = Value::from(format_string(FORMAT + 1));
assert!(read(&value.to_string()).unwrap_err().to_string().contains("newer SolveCraft"));
}
}
#[test]
fn explicit_emboss_target_bounds_precede_typed_decoding() {
for target in [
serde_json::json!({"body":"", "at":[0,0,0]}),
serde_json::json!({"body":"Body", "at":[0,0]}),
serde_json::json!({"body":"Body", "at":[0,"invalid",0]}),
serde_json::json!({"body":"Body", "at":[0,0,0], "unknown":true}),
serde_json::json!({"body":"x".repeat(513), "at":[0,0,0]}),
serde_json::json!({"body":"Body", "at":[0,0,0], "path":"bad"}),
serde_json::json!({"body":"Body", "at":[0,0,0], "path":[0]}),
serde_json::json!({"body":"Body", "at":[0,0,0], "path":[1,1]}),
serde_json::json!({"body":"Body", "at":[0,0,0], "path":(1..=65).collect::<Vec<u64>>()}),
serde_json::json!({"body":"Body", "at":[0,0,0], "author_path":[1,1]}),
serde_json::json!({"body":"Body", "at":[0,0,0], "author_path":(1..=65).collect::<Vec<u64>>()}),
] {
let mut value: Value = serde_json::from_str(&Document::new("hostile target").to_json()).unwrap();
value["features"] = serde_json::json!([{"type":"emboss", "face":target}]);
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("emboss face"), "{error}");
value["format"] = Value::from(format_string(FORMAT + 1));
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("newer SolveCraft"), "{error}");
}
}
#[test]
fn version_six_configuration_rows_upgrade_without_changing_values_or_features() {
use crate::config::{Column, ConfigRow};
let mut d = Document::new("legacy configurations");
d.set_param("width", "10 mm", None, None).unwrap();
let feature = d
.add_feature(
crate::FeatureKind::Box {
corner: ["0".into(), "0".into(), "0".into()],
length: "width".into(),
width: "20".into(),
height: "5".into(),
operation: crate::Operation::NewBody,
},
None,
)
.unwrap();
d.configs.columns = vec![Column::Param("width".into()), Column::Suppress(feature)];
d.configs.rows = vec![
ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "false".into()] },
ConfigRow { name: "Alternate".into(), cells: vec!["width / 2 + 5 mm".into(), "yes".into()] },
];
d.configs.active = Some("Default".into());
let mut legacy: Value = serde_json::from_str(&d.to_json()).unwrap();
legacy["format"] = Value::from("solvecraft/6");
let original_table = legacy["configs"].clone();
let upgraded = read(&legacy.to_string()).unwrap();
assert_eq!(upgraded, d, "the identity migration preserves the complete design");
assert_eq!(serde_json::to_value(&upgraded.configs).unwrap(), original_table);
assert_eq!(upgraded.format, format_string(FORMAT));
assert!(upgraded.configuration_matches());
legacy.as_object_mut().unwrap().remove("configs");
assert!(read(&legacy.to_string()).unwrap().configs.is_empty());
legacy["configs"] = serde_json::json!({"columns":[],"rows":[]});
assert!(read(&legacy.to_string()).unwrap().configs.is_empty());
}
#[test]
fn version_seven_styles_upgrade_and_newer_versions_are_refused_before_columns() {
use crate::config::{Column, ConfigRow};
let mut d = Document::new("configured styles");
d.configs.columns = vec![Column::Material("Body".into()), Column::Appearance("Body".into())];
d.configs.rows = vec![ConfigRow {
name: "Blue steel".into(),
cells: vec!["Steel".into(), r#"{"name":"Custom blue","color":[40,90,186],"opacity":0.4}"#.into()],
}];
d.apply_configuration("Blue steel").unwrap();
let mut value: Value = serde_json::from_str(&d.to_json()).unwrap();
value["format"] = Value::from("solvecraft/7");
let original_table = value["configs"].clone();
let roundtrip = read(&value.to_string()).unwrap();
assert_eq!(roundtrip, d);
assert_eq!(serde_json::to_value(&roundtrip.configs).unwrap(), original_table);
assert_eq!(roundtrip.format, format_string(FORMAT));
assert!(roundtrip.configuration_matches());
value["format"] = Value::from(format_string(FORMAT + 1));
value["configs"]["columns"] = serde_json::json!([{"unknown_future_column":"Body"}]);
value["configs"]["rules"] = serde_json::json!({"unknown_future_rule": true});
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("newer SolveCraft") && error.contains("update SolveCraft"), "{error}");
assert!(!error.contains("unknown variant"), "format refusal precedes decoding new variants");
}
#[test]
fn version_seven_origin_sketch_keeps_geometry_dimension_ids_and_expressions() {
use solvecraft_geom::Vec2;
use solvecraft_sketch::{ConstraintKind, Sketch};
let mut d = Document::new("legacy origin dimensions");
d.set_param("reach", "25 mm", Some("mm"), None).unwrap();
let dim = d.new_model_param("reach", "mm");
let mut sketch = Sketch::new();
let end = sketch.add_point(Vec2::new(25.0, 0.0), Some("tip")).unwrap();
let line = sketch.add_line_pts(0, end, Some("edge")).unwrap();
sketch.add_constraint(ConstraintKind::Horizontal { l: line }, None).unwrap();
sketch.add_constraint(ConstraintKind::Length { l: line, value: 25.0 }, Some(dim)).unwrap();
d.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, Some("Dimensioned")).unwrap();
let mut legacy: Value = serde_json::from_str(&d.to_json()).unwrap();
legacy["format"] = Value::from("solvecraft/7");
let features = legacy["features"].clone();
let params = legacy["params"].clone();
let upgraded = read(&legacy.to_string()).unwrap();
assert_eq!(upgraded, d);
let upgraded_json: Value = serde_json::from_str(&upgraded.to_json()).unwrap();
assert_eq!(upgraded_json["features"], features);
assert_eq!(upgraded_json["params"], params);
assert_eq!(read(&upgraded.to_json()).unwrap(), upgraded);
}
#[test]
fn version_eight_persistent_angle_sweep_and_slot_dimensions_roundtrip() {
use solvecraft_geom::Vec2;
use solvecraft_sketch::{ConstraintKind, Sketch};
let mut d = Document::new("persistent typed dimensions");
let heading = d.new_model_param("45 deg", "deg");
let sweep = d.new_model_param("180 deg", "deg");
let overall = d.new_model_param("10 mm", "mm");
let mut sketch = Sketch::new();
let end = sketch.add_point(Vec2::new(2.0, 2.0), None).unwrap();
let line = sketch.add_line_pts(0, end, None).unwrap();
let a = sketch.add_arc(Vec2::ZERO, Vec2::new(0.0, 2.0), Vec2::new(0.0, -2.0), [None; 3], None).unwrap();
let b = sketch.add_arc(Vec2::new(6.0, 0.0), Vec2::new(6.0, -2.0), Vec2::new(6.0, 2.0), [None; 3], None).unwrap();
sketch.add_constraint(ConstraintKind::LineAngle { l: line, value: std::f64::consts::FRAC_PI_4 }, Some(heading)).unwrap();
sketch.add_constraint(ConstraintKind::ArcSweep { c: a, value: std::f64::consts::PI }, Some(sweep)).unwrap();
sketch.add_constraint(ConstraintKind::SlotOverallLength { a, b, value: 10.0 }, Some(overall)).unwrap();
d.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
let mut saved: Value = serde_json::from_str(&d.to_json()).unwrap();
assert_eq!(saved["format"], format_string(FORMAT));
assert_eq!(read(&saved.to_string()).unwrap(), d);
saved["format"] = Value::from(format_string(FORMAT + 1));
saved["features"][0]["sketch"]["constraints"][0]["type"] = Value::from("future_dimension");
let error = read(&saved.to_string()).unwrap_err().to_string();
assert!(error.contains("newer SolveCraft") && !error.contains("unknown variant"), "{error}");
}
#[test]
fn bulk_input_repair_preserves_existing_names_and_reuses_surplus_names() {
let mut d = Document::new("input repair");
+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).
+14 -2
View File
@@ -7,13 +7,19 @@
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};
pub mod config;
pub mod construct;
mod document;
mod eval;
pub mod expr;
pub mod face_anchor;
pub use face_anchor::{FaceAnchor, FaceAttachmentStatus};
pub mod format;
pub mod joints;
pub mod motion;
@@ -28,8 +34,9 @@ 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, curve_pick_error, edge_names_for, edge_refs, parse_metric_thread,
pattern_matrices, state_in_frame, world_state,
EdgeRef, FeatureResult, Model, ModelBody, ModelState, RevolveExtent, SolvedSketch, ThreadInfo, curve_pick_error, edge_names_for, edge_refs,
object_height, parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention, 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;
@@ -51,5 +58,10 @@ pub enum DocError {
pub type Result<T> = std::result::Result<T, DocError>;
#[cfg(test)]
mod named_projection_tests;
#[cfg(test)]
mod tests;
mod anchor_frame;
pub use anchor_frame::state_in_face_anchor_frame;
+50
View File
@@ -0,0 +1,50 @@
use crate::{Document, ModelBody, ModelState};
use solvecraft_geom::{Plane, Vec3};
use solvecraft_sketch::{LinkKind, LinkSource, Sketch};
use std::collections::BTreeMap;
use std::sync::Arc;
fn named_box(name: &str, width: f64, height: f64) -> ModelBody {
let body = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(width, width, height)).unwrap();
let mut names = vec!["unrelated".to_string(); body.faces(0.01).unwrap().len()];
let top = body.faces(0.01).unwrap().into_iter().find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.99)).unwrap().index;
names[top] = "shared:top".into();
let mut result = ModelBody::new(name.into(), body, 1);
result.names = crate::naming::NameCell::known(Arc::new(names));
result
}
#[test]
fn named_manual_projection_keeps_its_body_and_face_at_shared_boundaries() {
let doc = Document::default();
let mut initial = ModelState::default();
initial.bodies.extend([named_box("Source", 20., 10.), named_box("Control", 5., 10.)]);
let plane = Plane::XY.offset(20.);
let source = LinkSource::Face { body: "Source".into(), at: Vec3::new(1., 1., 10.) };
let resolved = crate::project::resolve(&doc, &BTreeMap::new(), &initial, &plane, LinkKind::Project, &source).unwrap();
let mut sketch = Sketch::default();
let link = sketch.add_link(LinkKind::Project, source, &resolved.geom).unwrap();
sketch.links[0].face_name = Some("shared:top".into());
sketch.links[0].source = LinkSource::Face { body: "Source".into(), at: Vec3::new(0., 0., 10.) };
let mut current = ModelState::default();
current.bodies.extend([named_box("Source", 20., 20.), named_box("Control", 5., 10.)]);
assert!(crate::project::refresh_links(&doc, &BTreeMap::new(), &current, &plane, &mut sketch).is_empty());
assert!(!sketch.links[0].lost);
assert!(sketch.points.iter().any(|point| point.pos.x > 19.));
assert!(sketch.points.iter().any(|point| point.pos.y > 19.));
assert!(matches!(&sketch.links[0].source,LinkSource::Face{body,at} if body=="Source" && (at.z-20.).abs()<1e-6));
// The closest named point can sit on the wall/top seam: retain its actual
// face index rather than re-picking the adjacent vertical wall.
let index = crate::naming::face_on_body(current.body("Source").unwrap(), "shared:top", Vec3::new(0., 0., 10.)).unwrap().0;
assert!(current.body("Source").unwrap().body.faces(0.01).unwrap()[index].plane_normal.unwrap().z > 0.99);
let saved = sketch.clone();
let mut missing = ModelState::default();
missing.bodies.push(named_box("Control", 5., 10.));
let warnings = crate::project::refresh_links(&doc, &BTreeMap::new(), &missing, &plane, &mut sketch);
assert!(sketch.links[0].lost);
assert!(!warnings.is_empty());
assert_eq!(sketch.curves, saved.curves);
assert_eq!(sketch.points, saved.points);
assert_eq!(sketch.links[0].id, link);
assert!(matches!(&sketch.links[0].source,LinkSource::Face{body,..} if body=="Source"));
}
+55 -1
View File
@@ -195,11 +195,22 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
|| xb.max.z < bb.min.z - tol
|| xb.min.z > bb.max.z + tol)
};
// Independently created solids own their faces even when another body
// occupies the same coordinates. Boolean operations still inherit inputs.
let independent_solid = matches!(
o.feature.kind,
FeatureKind::Box { operation: crate::Operation::NewBody, .. }
| FeatureKind::Cylinder { operation: crate::Operation::NewBody, .. }
| FeatureKind::Sphere { operation: crate::Operation::NewBody, .. }
| FeatureKind::Torus { operation: crate::Operation::NewBody, .. }
| FeatureKind::Extrude { operation: crate::Operation::NewBody, .. }
| FeatureKind::Revolve { operation: crate::Operation::NewBody, .. }
);
let inputs: Vec<(&ModelBody, FaceIndex, Arc<Vec<String>>)> = o
.before
.bodies
.iter()
.filter(|x| !x.body.is_mesh() && overlaps(x))
.filter(|x| !independent_solid && !x.body.is_mesh() && overlaps(x))
.take(64)
.map(|x| {
let n = face_count(x);
@@ -644,6 +655,21 @@ pub fn face_names_at(st: &ModelState, pts: &[Vec3]) -> Vec<String> {
.collect()
}
/// Capture a face name from the recorded projection body only.
pub fn face_name_at_body(body: &ModelBody, point: Vec3) -> Option<String> {
if body.body.is_mesh() || !point.is_finite() {
return None;
}
let mesh = body.mesh();
let count = face_count(body);
if mesh.triangles.len() > 2_000_000 || count > 50_000 {
return None;
}
let tolerance = (body.body.size() * 1e-3).max(1e-3);
let index = FaceIndex::new(&mesh, count).face_at(point, tolerance)?;
face_names(body).get(index).filter(|name| !name.is_empty()).cloned()
}
/// The point on the face with this name nearest `p` (a split face: its nearest piece, with
/// `true` for "it was split"); `None` when no face has the name.
pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool)> {
@@ -668,3 +694,31 @@ pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool
}
best.map(|(_, q, split)| (q, split))
}
/// Resolve a legacy named face only on its recorded source body. Return the
/// selected face index as well as the closest point, which may lie on a shared
/// edge and therefore must not be used to pick the face a second time.
pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec3, bool)> {
if body.body.is_mesh() {
return None;
}
let names = face_names(body);
let mesh = body.mesh();
let stem = strip_piece(name);
let mut best: Option<(f64, usize, Vec3, bool)> = None;
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
let index = usize::try_from(*face).ok()?;
let Some(candidate) = names.get(index) else { continue };
let exact = candidate == name;
if !exact && strip_piece(candidate) != stem {
continue;
}
let Some([a, b, c]) = mesh.tri(triangle) else { continue };
let q = closest(p, a, b, c);
let distance = q.dist(p) - if exact { 1e-9 } else { 0. };
if best.is_none_or(|(old, _, _, _)| distance < old) {
best = Some((distance, index, q, !exact));
}
}
best.map(|(_, index, q, split)| (index, q, split))
}
+83 -3
View File
@@ -65,6 +65,9 @@ impl FeatureKind {
if let Some(d) = &mut extent.taper {
v.push(("Taper angle", d, A));
}
if let Some(d) = &mut extent.to_offset {
v.push(("To offset", d, L));
}
}
FeatureKind::Revolve { angle, angle2, .. } => {
v.push(("Angle", angle, A));
@@ -271,7 +274,8 @@ impl FeatureKind {
| FeatureKind::Loft { .. }
| FeatureKind::Sweep { .. }
| FeatureKind::Import { .. }
| FeatureKind::MeshImport { .. } => {}
| FeatureKind::MeshImport { .. }
| FeatureKind::BodyTransfer { .. } => {}
}
v
}
@@ -502,6 +506,53 @@ impl Document {
Ok(())
}
/// Change existing expressions against their final dependency graph, independent of
/// column order. Unlike `change_param`, this never creates a missing parameter.
pub fn change_params_atomic(&mut self, changes: &[(String, String)]) -> Result<()> {
if changes.len() > crate::config::MAX_COLUMNS {
return Err(DocError::Invalid("too many parameter changes".into()));
}
let mut names = BTreeSet::new();
let mut changed = BTreeSet::new();
let (_, before_errors) = self.param_values();
let mut next = self.clone();
for (name, expression) in changes {
if !names.insert(name.clone()) {
return Err(DocError::Invalid(format!("duplicate parameter `{name}`")));
}
if expression.len() > 4096 || expression.trim().is_empty() {
return Err(DocError::Expr("expression is empty or too long".into()));
}
if let Some(p) = next.params.iter_mut().find(|p| p.name == *name) {
if p.expr != expression.trim() {
changed.insert(name.clone());
}
p.expr = expression.trim().to_string();
continue;
}
let hit = next.features.iter().enumerate().find_map(|(i, f)| f.param_names.iter().position(|n| n == name).map(|k| (i, k)));
let Some((i, k)) = hit else { return Err(DocError::Unknown(format!("parameter `{name}`"))) };
let kind = next.features.get(i).and_then(|f| f.kind.inputs().get(k).map(|x| x.2)).unwrap_or(Kind::Unitless);
let normalized = next.with_design_unit(expression, kind);
let Some(feature) = next.features.get_mut(i) else { return Err(DocError::Unknown(format!("parameter `{name}`"))) };
let Some((_, target, _)) = feature.kind.inputs_mut().into_iter().nth(k) else {
return Err(DocError::Unknown(format!("parameter `{name}`")));
};
if *target != normalized {
changed.insert(name.clone());
}
*target = normalized;
}
let (_, errors) = next.param_values();
for (name, error) in errors {
if changed.contains(&name) || !before_errors.contains_key(&name) {
return Err(DocError::Expr(format!("parameter `{name}`: {error}")));
}
}
*self = next;
Ok(())
}
/// The parameters table (user, sketch dimensions, feature inputs).
pub fn param_rows(&self) -> Vec<ParamRow> {
let (vals, errs) = self.param_values();
@@ -639,6 +690,8 @@ pub enum ParamUser {
Feature { feature: u64, name: String, input: String },
/// A sketch dimension (`constraint` is its id).
Dimension { feature: u64, name: String, constraint: String },
/// A configured column or an expression saved in a configuration row.
Configuration { row: Option<String>, column: String },
}
impl ParamUser {
@@ -647,6 +700,10 @@ impl ParamUser {
ParamUser::Parameter { name } => format!("parameter {name}"),
ParamUser::Feature { name, input, .. } => format!("{name} ({input})"),
ParamUser::Dimension { name, constraint, .. } => format!("{name} dimension {constraint}"),
ParamUser::Configuration { row, column } => match row {
Some(row) => format!("configuration `{row}` ({column})"),
None => format!("configuration column {column}"),
},
}
}
}
@@ -716,6 +773,24 @@ impl Document {
/// expressions refer to it, and the sketch dimensions it drives.
pub fn param_users(&self, name: &str) -> Vec<ParamUser> {
let mut out = Vec::new();
for rule in &self.configs.rules {
if rule.parameter_references().iter().any(|reference| reference == name) {
out.push(ParamUser::Configuration { row: None, column: format!("rule `{}`", rule.name) });
}
}
for (index, column) in self.configs.columns.iter().enumerate() {
if let crate::config::Column::Param(target) = column {
let key = self.config_key(column);
if target == name {
out.push(ParamUser::Configuration { row: None, column: key.clone() });
}
for row in &self.configs.rows {
if row.cells.get(index).is_some_and(|value| expr::references(value).iter().any(|reference| reference == name)) {
out.push(ParamUser::Configuration { row: Some(row.name.clone()), column: key.clone() });
}
}
}
}
for p in &self.params {
if p.name != name && expr::references(&p.expr).iter().any(|r| r == name) {
out.push(ParamUser::Parameter { name: p.name.clone() });
@@ -828,9 +903,14 @@ impl Document {
}
}
// Configuration cells refer to parameters too.
for rule in &mut self.configs.rules {
rule.rename_parameter(old, new);
}
for r in &mut self.configs.rows {
for cell in &mut r.cells {
*cell = expr::rename_reference(cell, old, new);
for (column, cell) in self.configs.columns.iter().zip(&mut r.cells) {
if matches!(column, crate::config::Column::Param(_)) {
*cell = expr::rename_reference(cell, old, new);
}
}
}
if let Some(c) = self.param_comments.remove(old) {
+139 -8
View File
@@ -20,6 +20,9 @@ const AXIS_HALF: f64 = 100.0;
/// Points in one fitted chain at most (longer chains are thinned).
const MAX_CHAIN: usize = 4000;
#[path = "project_order.rs"]
mod order;
/// A resolved link: its geometry in sketch coordinates and the (possibly moved) reference.
#[derive(Clone, Debug)]
pub struct Resolved {
@@ -29,6 +32,9 @@ pub struct Resolved {
pub source: LinkSource,
/// The reference was not exactly where it was and was re-found on the nearest geometry.
pub moved: bool,
/// Exact face selected during this resolution, before its closest point is
/// projected onto an edge shared with another face. Computed metadata only.
pub face_index: Option<usize>,
}
/// Resolutions of body-based references, keyed by the reference, the sketch plane and the
@@ -91,6 +97,7 @@ fn resolve_uncached(
) -> Result<Resolved> {
let lost = |what: &str| DocError::Invalid(format!("{what} no longer exists"));
let mut moved = false;
let mut face_index = None;
let mut source = src.clone();
let mut wires: Vec<Vec<Vec3>> = Vec::new();
// Included curves that leave the sketch plane stay 3D.
@@ -122,6 +129,7 @@ fn resolve_uncached(
LinkSource::Face { body, at } => {
let (bi, mesh) = body_mesh(st, body, *at)?;
let (f, q, d) = nearest_face(&mesh, *at).ok_or_else(|| lost("the face"))?;
face_index = Some(usize::try_from(f).map_err(|_| DocError::Invalid("face index exceeds platform limit".into()))?);
let size = mesh.bounds().diagonal().max(1e-9);
if d > size * 0.25 {
return Err(lost("the face"));
@@ -133,7 +141,7 @@ fn resolve_uncached(
LinkKind::Intersect => section(plane, &mesh, Some(f)),
_ => {
let mut g = Vec::new();
for e in mesh.face_edges(f) {
for e in order::face_edges(&mesh, f, plane)? {
if mesh.seams.get(e).copied().unwrap_or(false) {
continue;
}
@@ -264,19 +272,130 @@ fn resolve_uncached(
if geom.is_empty() && wires.is_empty() {
return Err(DocError::Invalid(format!("the {} projects to nothing on this sketch plane", src.describe())));
}
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved })
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved, face_index })
}
/// Re-resolve every link of a sketch (in place). Returns warnings for lost, moved or rebuilt
/// links; lost links keep their last geometry.
fn lost(reference: &str) -> DocError {
DocError::Unknown(format!("{reference} no longer exists"))
}
fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str, index: usize, at: Vec3) -> Result<Resolved> {
let target = st.body(body).ok_or_else(|| lost("named projection body"))?;
let mesh = target.mesh();
if mesh.triangles.len() > 2_000_000 || mesh.edges.len() > 2_000_000 {
return Err(DocError::Invalid("named face projection work limit exceeded".into()));
}
let face_index = Some(index);
let index = u32::try_from(index).map_err(|_| DocError::Invalid("named projection face index exceeds limit".into()))?;
let geom = if kind == LinkKind::Intersect {
section(plane, &mesh, Some(index))
} else {
let mut geom = Vec::new();
for edge in order::face_edges(&mesh, index, plane)? {
if mesh.seams.get(edge).copied().unwrap_or(false) {
continue;
}
if let Some(points) = mesh.edges.get(edge) {
geom.extend(fit_world(plane, points));
}
}
merge_split_circles(geom)
};
if geom.is_empty() {
return Err(DocError::Invalid("named face projects to nothing on this sketch plane".into()));
}
Ok(Resolved { geom, wires: Vec::new(), source: LinkSource::Face { body: body.into(), at }, moved: false, face_index })
}
pub fn resolve_anchored_face(doc: &Document, st: &ModelState, plane: &Plane, kind: LinkKind, anchor: &crate::FaceAnchor) -> Result<Resolved> {
let body = st.body(&anchor.target.body).ok_or_else(|| lost("anchored projection body"))?;
let indices = doc.configuration_face_indices(body, &anchor.target)?;
resolve_face_indices(st, plane, kind, &body.name, &indices)
}
/// Project explicitly selected current face indices, without claiming persistent provenance.
/// Callers retaining a link must supply their own replay identity policy.
pub fn resolve_face_indices(st: &ModelState, plane: &Plane, kind: LinkKind, body_name: &str, indices: &[usize]) -> Result<Resolved> {
let body = st.body(body_name).ok_or_else(|| lost("projection body"))?;
let mesh = body.mesh();
if mesh.triangles.len() > 2_000_000 || indices.len() > 10_000 {
return Err(DocError::Invalid("anchored projection work limit exceeded".into()));
}
let work = mesh.triangles.len().saturating_add(mesh.edges.len()).saturating_mul(indices.len());
if work > 10_000_000 {
return Err(DocError::Invalid("anchored projection aggregate work limit exceeded".into()));
}
let selected: std::collections::BTreeSet<_> = indices.iter().copied().collect();
let mut geometry = Vec::new();
for index in indices {
let index = u32::try_from(*index).map_err(|_| DocError::Invalid("anchored face index exceeds format bounds".into()))?;
if kind == LinkKind::Intersect {
geometry.extend(section(plane, &mesh, Some(index)));
} else {
for edge in order::face_edges(&mesh, index, plane)? {
if mesh.seams.get(edge).copied().unwrap_or(false) {
continue;
}
if let Some(points) = mesh.edges.get(edge) {
geometry.extend(fit_world(plane, points));
}
}
}
}
let point = mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.find_map(|(triangle, face)| selected.contains(&(*face as usize)).then(|| mesh.tri(triangle)).flatten().map(|[a, b, c]| (a + b + c) / 3.0))
.ok_or_else(|| lost("anchored projection face"))?;
if geometry.is_empty() {
return Err(lost("anchored projection boundaries"));
}
Ok(Resolved {
geom: merge_split_circles(geometry),
wires: Vec::new(),
source: LinkSource::Face { body: body.name.clone(), at: point },
moved: false,
face_index: if indices.len() == 1 { indices.first().copied() } else { None },
})
}
pub fn refresh_face_anchor_links(
doc: &Document,
vals: &BTreeMap<String, Value>,
st: &ModelState,
plane: &Plane,
sk: &mut Sketch,
anchor: &crate::FaceAnchor,
) -> Vec<String> {
refresh_links_in(doc, vals, st, plane, sk, Some(anchor))
}
pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, plane: &Plane, sk: &mut Sketch) -> Vec<String> {
refresh_links_in(doc, vals, st, plane, sk, None)
}
fn refresh_links_in(
doc: &Document,
vals: &BTreeMap<String, Value>,
st: &ModelState,
plane: &Plane,
sk: &mut Sketch,
anchor: Option<&crate::FaceAnchor>,
) -> Vec<String> {
let mut warn = Vec::new();
let links = sk.links.clone();
for l in &links {
let mut source = l.source.clone();
if let (Some(name), LinkSource::Face { at, .. }) = (&l.face_name, &mut source) {
if let Some((p, split)) = crate::naming::point_on_face(st, name, *at) {
let mut named_face = None;
if !l.auto_face
&& let (Some(name), LinkSource::Face { body, at }) = (&l.face_name, &mut source)
{
if let Some((index, p, split)) = st.body(body).and_then(|target| crate::naming::face_on_body(target, name, *at)) {
*at = p;
named_face = Some((body.clone(), index, p));
if split {
warn.push(format!("projected face `{name}` was split; using the nearest piece"));
}
@@ -286,7 +405,19 @@ pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelS
continue;
}
}
match resolve(doc, vals, st, plane, l.kind, &source) {
let resolved = if l.auto_face {
anchor
.filter(|anchor| {
l.face_name.as_ref() == Some(&anchor.target.name) && matches!(&l.source,LinkSource::Face {body,..} if *body==anchor.target.body)
})
.ok_or_else(|| lost("exact auto-projected attachment"))
.and_then(|anchor| resolve_anchored_face(doc, st, plane, l.kind, anchor))
} else if let Some((body, index, at)) = named_face {
resolve_named_face(st, plane, l.kind, &body, index, at)
} else {
resolve(doc, vals, st, plane, l.kind, &source)
};
match resolved {
Ok(r) => {
sk.set_link_source(&l.id, r.source);
let _ = sk.set_link_wires(&l.id, &r.wires);
@@ -439,9 +570,9 @@ fn project_sketch_curve(sk: &Sketch, from: &Plane, ci: usize, to: &Plane) -> Vec
(Some(c), Some(m)) => vec![LinkGeom::Ellipse { c: map(c), major: map(m), minor: *r }],
_ => Vec::new(),
},
Some(CurveKind::Spline { pts, control, degree }) if parallel => {
Some(CurveKind::Spline { pts, control, degree, periodic }) if parallel => {
let p: Option<Vec<Vec2>> = pts.iter().map(|q| sk.point(*q).map(map)).collect();
p.map(|pts| vec![LinkGeom::Spline { pts, control: *control, degree: *degree }]).unwrap_or_default()
p.map(|pts| vec![LinkGeom::Spline { pts, control: *control, degree: *degree, periodic: *periodic }]).unwrap_or_default()
}
Some(CurveKind::Conic { a, b, apex, rho }) if parallel => match (sk.point(*a), sk.point(*apex), sk.point(*b)) {
(Some(a), Some(x), Some(b)) => vec![LinkGeom::Conic { a: map(a), apex: map(x), b: map(b), rho: *rho }],
@@ -557,7 +688,7 @@ fn as_spline(pts: &[Vec2], tol: f64) -> Option<LinkGeom> {
<= tol
});
if ok {
return Some(LinkGeom::Spline { pts: fit, control: false, degree: 3 });
return Some(LinkGeom::Spline { pts: fit, control: false, degree: 3, periodic: false });
}
}
None
+1 -1
View File
@@ -456,7 +456,7 @@ fn link_polyline(g: &solvecraft_sketch::LinkGeom) -> Vec<Vec2> {
LinkGeom::Ellipse { c, major, minor } => {
(0..=96).map(|i| solvecraft_sketch::ellipse_point(c, major, minor, i as f64 / 96.0 * std::f64::consts::TAU)).collect()
}
LinkGeom::Spline { ref pts, control, degree } => solvecraft_sketch::spline_polyline(pts, control, degree),
LinkGeom::Spline { ref pts, control, degree, periodic } => solvecraft_sketch::spline_polyline_mode(pts, control, degree, periodic),
LinkGeom::Point(_) => Vec::new(),
}
}
+290
View File
@@ -0,0 +1,290 @@
//! Stable face projection order: outer boundary first, then holes by geometry.
//! B-rep edge indices depend on face enumeration and must not choose sketch curve IDs.
use std::collections::BTreeMap;
use solvecraft_geom::{Mesh, Plane, Vec3};
use crate::{DocError, Result};
type Key = (i64, i64, i64);
pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<usize>> {
let bounds = mesh.bounds();
let origin = bounds.min;
let local_origin = plane.to_local(origin);
let tol = (bounds.diagonal() * 1e-8).max(1e-9);
if !tol.is_finite() {
return Err(DocError::Invalid("face projection has invalid bounds".into()));
}
let key = |p: Vec3| -> Key {
let p = p - origin;
((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64)
};
let mut edges: Vec<usize> = mesh.face_edges(face).into_iter().filter(|e| !mesh.seams.get(*e).copied().unwrap_or(false)).collect();
if edges.iter().any(|e| !mesh.edges.get(*e).is_some_and(|p| p.len() >= 2 && p.iter().all(|p| p.is_finite()))) {
return Err(DocError::Invalid("face projection has an invalid boundary edge".into()));
}
if edges.len() > solvecraft_sketch::MAX_LINK_ENTITIES {
return Err(DocError::Invalid("face projection exceeds the boundary edge limit".into()));
}
let geometry_key = |e: usize| {
let pts = mesh.edges.get(e).map(Vec::as_slice).unwrap_or_default();
let lo = pts.iter().copied().fold(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::min);
let hi = pts.iter().copied().fold(Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), Vec3::max);
(key(lo), key(hi))
};
edges.sort_by_key(|e| geometry_key(*e));
let mut at: BTreeMap<Key, Vec<usize>> = BTreeMap::new();
for e in &edges {
if let Some(pts) = mesh.edges.get(*e) {
for point in [pts.first(), pts.last()].into_iter().flatten() {
at.entry(key(*point)).or_default().push(*e);
}
}
}
let mut used = vec![false; mesh.edges.len()];
let mut boundaries = Vec::new();
for start_edge in &edges {
if used.get(*start_edge).copied().unwrap_or(true) {
continue;
}
let Some(pts) = mesh.edges.get(*start_edge) else { continue };
let (Some(a), Some(b)) = (pts.first(), pts.last()) else { continue };
let start = if key(*a) <= key(*b) { *a } else { *b };
let mut current = start;
let mut edge = *start_edge;
let mut boundary = Vec::new();
let mut twice_area = 0.0;
for _ in 0..edges.len() {
let Some(pts) = mesh.edges.get(edge) else { break };
let (Some(a), Some(b)) = (pts.first(), pts.last()) else { break };
let forward = current.dist(*a) <= current.dist(*b);
for pair in pts.windows(2) {
let (Some(a), Some(b)) = (pair.first(), pair.get(1)) else { continue };
let (a, b) = (plane.to_local(*a) - local_origin, plane.to_local(*b) - local_origin);
twice_area += if forward { a.cross(b) } else { b.cross(a) };
}
current = if forward { *b } else { *a };
if let Some(slot) = used.get_mut(edge) {
*slot = true;
}
boundary.push(edge);
if current.dist(start) <= tol {
break;
}
// Neighbouring quantization cells cover points on either side of a rounding
// boundary. Connectivity is checked in world space, not in the projection.
let (x, y, z) = key(current);
let mut candidates = Vec::new();
for dx in -1..=1 {
for dy in -1..=1 {
for dz in -1..=1 {
let cell = (x.saturating_add(dx), y.saturating_add(dy), z.saturating_add(dz));
if let Some(list) = at.get(&cell) {
candidates.extend(list.iter().copied().filter(|e| {
!used.get(*e).copied().unwrap_or(true)
&& mesh.edges.get(*e).is_some_and(|p| {
p.first().is_some_and(|a| a.dist(current) <= tol) || p.last().is_some_and(|b| b.dist(current) <= tol)
})
}));
}
}
}
}
candidates.sort_by_key(|e| geometry_key(*e));
candidates.dedup();
if candidates.len() > 1 {
return Err(DocError::Invalid("face projection has a branched boundary".into()));
}
let Some(next) = candidates.first() else { break };
edge = *next;
}
boundary.sort_by_key(|e| geometry_key(*e));
let closed = current.dist(start) <= tol;
if !closed || !twice_area.is_finite() {
return Err(DocError::Invalid("face projection has an open or invalid boundary".into()));
}
// Equal-sized holes use their geometric key, not round-off from summing the
// same area at different positions or walking their edges in reverse.
let area = (twice_area.abs() / (tol * bounds.diagonal().max(tol))).round() as i64;
let first = boundary.first().copied().map(geometry_key);
boundaries.push((area, first, boundary));
}
boundaries.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
Ok(boundaries.into_iter().flat_map(|(_, _, edges)| edges).collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn rectangles() -> Mesh {
let mut mesh = Mesh::default();
for (x, y) in [(40.0, 30.0), (38.0, 28.0), (4.0, 2.0)] {
let pts = [Vec3::new(-x, -y, 0.0), Vec3::new(x, -y, 0.0), Vec3::new(x, y, 0.0), Vec3::new(-x, y, 0.0)];
mesh.positions.extend(pts);
for i in 0..4 {
mesh.edges.push(vec![pts[i], pts[(i + 1) % 4]]);
mesh.edge_faces.push(vec![7]);
}
}
mesh
}
#[test]
fn boundaries_and_edge_identity_are_independent_of_mesh_enumeration() {
let baseline = rectangles();
let expected: Vec<_> =
face_edges(&baseline, 7, &Plane::XY).unwrap().into_iter().map(|e| super::super::fit_world(&Plane::XY, &baseline.edges[e])).collect();
for shift in 0..baseline.edges.len() {
for reverse in [false, true] {
let mut mesh = baseline.clone();
mesh.edges = (0..mesh.edges.len()).map(|i| baseline.edges[(i * 5) % baseline.edges.len()].clone()).collect();
mesh.edges.rotate_left(shift);
if reverse {
mesh.edges.reverse();
for pts in &mut mesh.edges {
pts.reverse();
}
}
let actual: Vec<_> = face_edges(&mesh, 7, &Plane::XY)
.unwrap()
.into_iter()
.map(|e| {
let mut pts = mesh.edges[e].clone();
// Lines' direction does not change their geometry.
if pts.first().unwrap().x > pts.last().unwrap().x || pts.first().unwrap().y > pts.last().unwrap().y {
pts.reverse();
}
super::super::fit_world(&Plane::XY, &pts)
})
.collect();
let normalized: Vec<_> = expected
.iter()
.map(|g| {
let solvecraft_sketch::LinkGeom::Line(a, b) = g[0] else { panic!("line") };
if a.x > b.x || a.y > b.y { vec![solvecraft_sketch::LinkGeom::Line(b, a)] } else { g.clone() }
})
.collect();
assert_eq!(actual, normalized);
let ordered = face_edges(&mesh, 7, &Plane::XY).unwrap();
for (loop_index, chunk) in ordered.chunks(4).enumerate() {
let want = [40.0, 38.0, 4.0][loop_index];
assert!(chunk.iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x.abs() <= want));
assert!(chunk.iter().flat_map(|e| &mesh.edges[*e]).any(|p| p.x.abs() == want));
}
}
}
}
#[test]
fn rounded_boundary_arc_sweeps_survive_individual_edge_reversal() {
use solvecraft_geom::{Seg2, Vec2};
use solvecraft_sketch::LinkGeom;
let mut mesh = Mesh::default();
for (extent, radius) in [(10.0, 3.0), (8.0, 1.0)] {
for k in 0..4 {
let angle = k as f64 * std::f64::consts::FRAC_PI_2;
let rotate = |p: Vec2| (0..k).fold(p, |p, _| p.perp());
let centre = rotate(Vec2::new(extent - radius, extent - radius));
let arc = Seg2::Arc { center: centre, radius, start: angle, sweep: std::f64::consts::FRAC_PI_2 };
let line = Seg2::Line { a: arc.point_at(1.0), b: rotate(Vec2::new(-(extent - radius), extent)) };
for seg in [arc, line] {
let pts: Vec<_> = (0..=16).map(|i| Plane::XY.to_world(seg.point_at(i as f64 / 16.0))).collect();
mesh.positions.extend(pts.iter().copied());
mesh.edges.push(pts);
mesh.edge_faces.push(vec![2]);
}
}
}
let normalized = |mesh: &Mesh| -> Vec<Vec<LinkGeom>> {
face_edges(mesh, 2, &Plane::XY)
.unwrap()
.into_iter()
.map(|e| {
super::super::fit_world(&Plane::XY, &mesh.edges[e])
.into_iter()
.map(|g| match g {
LinkGeom::Line(a, b) if (a.x, a.y) > (b.x, b.y) => LinkGeom::Line(b, a),
g => g,
})
.collect()
})
.collect()
};
let expected = normalized(&mesh);
for shift in 0..mesh.edges.len() {
let mut permuted = mesh.clone();
permuted.edges = (0..mesh.edges.len()).map(|i| mesh.edges[(i * 5 + shift) % mesh.edges.len()].clone()).collect();
for (i, points) in permuted.edges.iter_mut().enumerate() {
if i % 2 == 0 {
points.reverse();
}
}
let actual = normalized(&permuted);
for (a, b) in actual.iter().zip(&expected) {
assert_eq!(a.len(), b.len());
for (a, b) in a.iter().zip(b) {
match (a, b) {
(LinkGeom::Line(a, b), LinkGeom::Line(c, d)) => assert!(a.dist(*c) < 1e-9 && b.dist(*d) < 1e-9),
(LinkGeom::Arc { c, a, b }, LinkGeom::Arc { c: cc, a: aa, b: bb }) => {
assert!(c.dist(*cc) < 1e-9 && a.dist(*aa) < 1e-9 && b.dist(*bb) < 1e-9);
}
_ => panic!("boundary changed kind"),
}
}
}
}
}
#[test]
fn incomplete_and_branched_boundaries_are_reported_without_partial_geometry() {
let mut open = rectangles();
open.edges.pop();
open.edge_faces.pop();
assert!(face_edges(&open, 7, &Plane::XY).unwrap_err().to_string().contains("open"));
let mut branched = rectangles();
branched.edges.push(vec![Vec3::new(-40.0, -30.0, 0.0), Vec3::ZERO]);
branched.edge_faces.push(vec![7]);
assert!(face_edges(&branched, 7, &Plane::XY).is_err());
let mut invalid = rectangles();
invalid.edges[0].push(Vec3::new(f64::NAN, 0.0, 0.0));
assert!(face_edges(&invalid, 7, &Plane::XY).unwrap_err().to_string().contains("invalid"));
}
#[test]
fn equal_area_holes_use_geometric_order_after_translation() {
let mut baseline = rectangles();
baseline.edges.truncate(4);
baseline.edge_faces.truncate(4);
for x in [-12.0, 12.0] {
let pts = [Vec3::new(x - 2.0, -2.0, 0.0), Vec3::new(x + 2.0, -2.0, 0.0), Vec3::new(x + 2.0, 2.0, 0.0), Vec3::new(x - 2.0, 2.0, 0.0)];
for i in 0..4 {
baseline.edges.push(vec![pts[i], pts[(i + 1) % 4]]);
baseline.edge_faces.push(vec![7]);
}
}
let shift = Vec3::new(12345.6789, -23456.7891, 987.0);
for p in &mut baseline.positions {
*p += shift;
}
for points in &mut baseline.edges {
for p in points {
*p += shift;
}
}
for offset in 0..baseline.edges.len() {
let mut mesh = baseline.clone();
mesh.edges = (0..mesh.edges.len()).map(|i| baseline.edges[(i * 5 + offset) % baseline.edges.len()].clone()).collect();
for (i, points) in mesh.edges.iter_mut().enumerate() {
if i % 2 == 0 {
points.reverse();
}
}
let order = face_edges(&mesh, 7, &Plane::XY).unwrap();
assert!(order[4..8].iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x < shift.x));
assert!(order[8..12].iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x > shift.x));
}
}
}
+578 -125
View File
@@ -16,6 +16,126 @@ use solvecraft_kernel::{self as kernel, Body, EdgeSpec, FaceSpec, SurfSpec};
use crate::expr::{self, Kind, Value};
use crate::{DocError, Document, IDENTITY, Mat, Result, apply_point, apply_vector, mat_inverse, mat_mul};
// Bound the aggregate geometry, including sampled curves and intermediate clipping pieces.
// These are work limits, never geometry truncation: a larger input returns a useful error.
const MAX_FLAT_POINTS: usize = 16_384;
const MAX_FLAT_PIECES: usize = 4096;
const MAX_FLAT_WORK: usize = 500_000_000;
fn flat_budget_error() -> DocError {
DocError::Invalid("sheet metal flat geometry exceeds the bounded processing budget".into())
}
pub(crate) struct FlatWork(usize);
impl FlatWork {
pub(crate) fn new() -> Self {
Self(MAX_FLAT_WORK)
}
fn charge(&mut self, work: usize) -> Result<()> {
self.0 = self.0.checked_sub(work).ok_or_else(flat_budget_error)?;
Ok(())
}
}
fn flat_polyline(lp: &Loop2) -> Result<Vec<Vec2>> {
if lp.segs.len() > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
let mut points = Vec::new();
for seg in &lp.segs {
let p = seg.polyline(1e-3);
let add = p.len().saturating_sub(1);
if points.len().saturating_add(add) > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
points.extend(p.iter().take(add));
}
Ok(points)
}
fn flat_points(polys: &[Vec<Vec2>]) -> Result<usize> {
let n = polys.iter().try_fold(0usize, |n, p| n.checked_add(p.len()).ok_or_else(flat_budget_error))?;
if n > MAX_FLAT_POINTS || polys.len() > MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
Ok(n)
}
fn planar_region(region: &Region2) -> Result<Region2> {
if region.holes.len() > MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
region_complexity(std::slice::from_ref(region))?;
let convert = |lp: &Loop2| -> Result<Loop2> {
if lp.segs.iter().all(|s| matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) {
flat_polyline(lp)?;
Ok(lp.clone())
} else {
// Keep free-form profiles through controlled 0.001 mm chord sampling.
Ok(Loop2::polygon(&flat_polyline(lp)?))
}
};
Ok(Region2 {
outer: convert(&region.outer)?.ccw(),
holes: region.holes.iter().map(|h| convert(h).map(|l| l.ccw().reversed())).collect::<Result<_>>()?,
})
}
fn region_complexity(regions: &[Region2]) -> Result<(usize, usize)> {
regions_complexity(regions.iter())
}
fn regions_complexity<'a>(regions: impl Iterator<Item = &'a Region2>) -> Result<(usize, usize)> {
let (mut edges, mut points) = (0usize, 0usize);
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());
}
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))
}
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);
work.charge(edges.saturating_mul(edges).saturating_add(edges.saturating_mul(ap.saturating_add(bp))).saturating_mul(8))?;
let regions = kernel::planar_regions_boolean(a, b, op, 1e-7)?;
region_complexity(&regions)?;
Ok(regions)
}
// 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.
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 {
let mut next = Vec::new();
let mut iter = groups.into_iter();
while let Some(a) = iter.next() {
match iter.next() {
Some(b) => next.push(material_boolean(&a, &b, kernel::BoolOp::Union, work)?),
None => next.push(a),
}
}
groups = next;
}
Ok(groups.pop().unwrap_or_default())
}
/// A sheet metal rule. Values are expressions; `Thickness` inside them is the rule's thickness.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SheetRule {
@@ -178,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.
@@ -187,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 {
@@ -424,65 +555,71 @@ impl SheetBody {
}
}
/// The pieces of the sheet as cells: base and flange panels and bend zones, each less the
/// cut-outs over it (cut-outs inside a panel stay exact holes; ones crossing a piece's edge
/// must be convex polygons).
fn cells(&self) -> Result<Vec<Cell>> {
let mut pieces: Vec<(Piece, Vec<Vec2>)> =
self.panels().into_iter().map(|(pi, r)| (Piece::Panel(pi), dedup(&r.outer.ccw().polyline(1e-3)))).collect();
for (i, f) in self.flanges.iter().enumerate() {
pieces.push((Piece::Zone(i), dedup(&self.zone(f).outer.ccw().polyline(1e-3))));
/// The pieces of the sheet as cells: base and flange panels and bend zones, each less
/// the combined cut-out material. Planar clipping retains holes and disconnected islands.
fn cells_with_work(&self, work: &mut FlatWork) -> Result<Vec<Cell>> {
if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES || self.holes.len() > MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
let raw = self
.flanges
.iter()
.fold(self.base.outer.segs.len(), |n, f| n.saturating_add(f.shape.as_ref().map_or(4, Vec::len)))
.saturating_add(self.holes.iter().fold(0usize, |n, h| n.saturating_add(h.outer.segs.len())));
if raw > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
let mut pieces: Vec<(Piece, Region2)> = self.panels().into_iter().map(|(pi, r)| (Piece::Panel(pi), r)).collect();
for (i, f) in self.flanges.iter().enumerate() {
pieces.push((Piece::Zone(i), self.zone(f)));
}
let mut cuts = Vec::new();
let mut cut_points = 0usize;
for hole in &self.holes {
let region = planar_region(hole)?;
cut_points = cut_points.saturating_add(region_complexity(std::slice::from_ref(&region))?.1);
if cut_points > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
cuts.push(region);
}
cuts.extend(self.through_pocket_regions(work)?);
let cuts = material_union(cuts, work)?;
let mut out = Vec::new();
for (piece, poly) in pieces {
let mut inner: Vec<Vec<Seg2>> = Vec::new();
let mut crossing: Vec<Vec<Vec2>> = Vec::new();
for h in &self.holes {
let hp = dedup(&h.outer.ccw().polyline(1e-3));
if matches!(piece, Piece::Panel(_)) && hp.iter().all(|p| point_in(&poly, *p)) {
inner.push(h.outer.ccw().reversed().segs.clone());
} else if hp.iter().any(|p| point_in(&poly, *p)) || poly.iter().any(|p| point_in(&hp, *p)) || polys_cross(&poly, &hp) {
if !is_convex(&hp) {
return Err(DocError::Invalid(
"not supported yet: cut-outs over a panel's edge or a bend that are not convex polygons".into(),
));
}
crossing.push(hp);
let mut points = 0usize;
for (piece, region) in pieces {
let panel = planar_region(&region)?;
let material = if cuts.is_empty() { vec![panel] } else { material_boolean(&[panel], &cuts, kernel::BoolOp::Cut, work)? };
for region in material {
points = points.saturating_add(region_complexity(std::slice::from_ref(&region))?.1);
if points > MAX_FLAT_POINTS || out.len() >= MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
}
if crossing.is_empty() {
let outer = Loop2::polygon(&poly).segs;
out.push(Cell {
piece,
outer: if matches!(piece, Piece::Panel(None)) { self.base.outer.ccw().segs.clone() } else { outer },
holes: inner,
});
continue;
}
let mut parts = vec![poly];
for h in &crossing {
parts = parts.iter().flat_map(|p| convex_diff(p, h)).collect();
}
let loops = merge_loops(&parts);
let (outers, holes): (Vec<Vec<Vec2>>, Vec<Vec<Vec2>>) = loops.into_iter().partition(|l| signed_area(l) > 0.0);
for o in outers {
let mut hs: Vec<Vec<Seg2>> =
holes.iter().filter(|h| h.first().is_some_and(|p| point_in(&o, *p))).map(|h| Loop2::polygon(h).segs).collect();
hs.extend(inner.iter().filter(|h| h.first().is_some_and(|s| point_in(&o, s.start()))).cloned());
out.push(Cell { piece, outer: Loop2::polygon(&o).segs, holes: hs });
out.push(Cell { piece, outer: region.outer.segs, holes: region.holes.into_iter().map(|l| l.segs).collect() });
}
}
Ok(out)
}
/// Faces of the solid made by `cells` between heights `z0` and `z1`.
fn faces(&self, cells: &[Cell], z0: f64, z1: f64) -> Result<Vec<FaceSpec>> {
fn faces(&self, cells: &[Cell], z0: f64, z1: f64, work: &mut FlatWork) -> Result<Vec<FaceSpec>> {
// Split straight edges where other cells' corners touch them, so neighbours share edges.
let edges = cells
.iter()
.fold(0usize, |n, c| n.saturating_add(c.outer.len()).saturating_add(c.holes.iter().fold(0usize, |m, h| m.saturating_add(h.len()))));
if edges > MAX_FLAT_POINTS || cells.len() > MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
work.charge(edges.saturating_mul(edges).saturating_mul(4))?;
let corners: Vec<Vec2> = cells.iter().flat_map(|c| c.outer.iter().chain(c.holes.iter().flatten()).map(Seg2::start)).collect();
let split_loop = |segs: &[Seg2]| -> Vec<Seg2> {
let mut split_edges = 0usize;
let mut split_loop = |segs: &[Seg2]| -> Result<Vec<Seg2>> {
let mut v = Vec::new();
for e in segs {
let Seg2::Line { a, b } = *e else {
split_edges += 1;
if split_edges > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
v.push(*e);
continue;
};
@@ -499,18 +636,31 @@ impl SheetBody {
ts.dedup_by(|x, y| (*x - *y).abs() < 1e-12);
let mut prev = a;
for t in ts {
split_edges += 1;
if split_edges > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
let q = a + d * t;
v.push(Seg2::Line { a: prev, b: q });
prev = q;
}
split_edges += 1;
if split_edges > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
v.push(Seg2::Line { a: prev, b });
}
v
Ok(v)
};
let cells: Vec<Cell> = cells
.iter()
.map(|c| Cell { piece: c.piece, outer: split_loop(&c.outer), holes: c.holes.iter().map(|h| split_loop(h)).collect() })
.collect();
let mut split_cells = Vec::new();
for c in cells {
let outer = split_loop(&c.outer)?;
let holes = c.holes.iter().map(|h| split_loop(h)).collect::<Result<_>>()?;
split_cells.push(Cell { piece: c.piece, outer, holes });
}
// Reserve the complete adjacency scan before visiting any wall.
work.charge(split_edges.saturating_mul(split_edges).saturating_mul(2))?;
let cells = split_cells;
let near = |p: Vec2, q: Vec2| p.dist(q) < 1e-6;
// Shared with a neighbour: the same flat edge the other way, landing in the same place
// (flat neighbours across a corner of two bends do not).
@@ -602,40 +752,204 @@ impl SheetBody {
if self.flat {
return self.flat_solid();
}
Ok(kernel::sew(&self.faces(&self.cells()?, 0.0, self.t)?)?)
let mut work = FlatWork::new();
let cells = self.cells_with_work(&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 loops = self.outline()?;
let mut loops: Vec<Loop2> = loops.into_iter().map(|l| Loop2::polygon(&l)).collect();
loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs()));
let mut it = loops.into_iter();
let outer = it.next().ok_or_else(|| DocError::Invalid("empty flat pattern".into()))?.ccw();
let mut holes: Vec<Loop2> = it.map(|l| l.ccw().reversed()).collect();
let op = outer.polyline(1e-3);
for h in &self.holes {
let hp = h.outer.polyline(1e-3);
if hp.iter().all(|p| point_in(&op, *p)) {
holes.push(h.outer.ccw().reversed());
} else {
return Err(DocError::Invalid("not supported yet: cut-outs over the flat pattern's edge".into()));
}
}
let regions = self.flat_regions()?;
let pl = plane_of(&self.frame_mat(), Vec3::ZERO, Vec3::X, Vec3::Y)?;
kernel::extrude(&pl, &[Region2 { outer, holes }], 0.0, self.t)?.into_iter().next().ok_or_else(|| DocError::Invalid("flat pattern".into()))
let body = kernel::extrude_planar_regions(&pl, &regions, 0.0, self.t)?;
self.machine_pockets(body, &mut FlatWork::new())
}
/// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out.
pub fn outline(&self) -> Result<Vec<Vec<Vec2>>> {
let mut polys: Vec<Vec<Vec2>> = vec![dedup(&self.base.outer.ccw().polyline(1e-3))];
for f in &self.flanges {
polys.push(dedup(&self.zone(f).outer.ccw().polyline(1e-3)));
if f.leg > 1e-9 {
polys.push(dedup(&self.panel(f).outer.ccw().polyline(1e-3)));
/// 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()))?;
}
}
let loops = merge_loops(&polys);
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.
if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES || self.holes.len() > MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
let raw = self.flanges.iter().fold(self.base.outer.segs.len(), |n, f| n.saturating_add(f.shape.as_ref().map_or(4, Vec::len)));
if raw > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
let mut regions = Vec::new();
let mut panel_points = 0usize;
for (_, r) in self.panels() {
let r = planar_region(&r)?;
panel_points = panel_points.saturating_add(region_complexity(std::slice::from_ref(&r))?.1);
if panel_points > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
regions.push(r);
}
for f in &self.flanges {
regions.push(planar_region(&self.zone(f))?);
region_complexity(&regions)?;
}
let regions = material_union(regions, &mut work)?;
let mut cuts = Vec::new();
let mut cut_points = 0usize;
for hole in &self.holes {
let region = planar_region(hole)?;
cut_points = cut_points.saturating_add(region_complexity(std::slice::from_ref(&region))?.1);
if cut_points > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
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)? };
Ok(regions)
}
/// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out.
pub fn outline(&self) -> Result<Vec<Vec<Vec2>>> {
if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
let mut polys: Vec<Vec<Vec2>> = vec![dedup(&flat_polyline(&self.base.outer.ccw())?)];
for f in &self.flanges {
polys.push(dedup(&flat_polyline(&self.zone(f).outer.ccw())?));
if f.leg > 1e-9 {
polys.push(dedup(&flat_polyline(&self.panel(f).outer.ccw())?));
}
flat_points(&polys)?;
}
let loops = merge_loops(&polys, &mut FlatWork::new())?;
if loops.is_empty() {
return Err(DocError::Invalid("empty flat pattern".into()));
}
@@ -670,13 +984,13 @@ impl SheetBody {
/// the sewn (faces, edges, vertices).
pub fn round_hole_correction(&self) -> (i64, i64, i64) {
let (mut df, mut de, mut dv) = (0i64, 0i64, 0i64);
for h in &self.holes {
let Ok(regions) = self.flat_regions() else { return (0, 0, 0) };
for h in regions.iter().flat_map(|r| &r.holes) {
let arcs: Vec<(Vec2, f64)> =
h.outer.segs.iter().filter_map(|s| if let Seg2::Arc { center, radius, .. } = s { Some((*center, *radius)) } else { None }).collect();
let n = h.outer.segs.len() as i64;
let full = !arcs.is_empty()
&& arcs.len() == h.outer.segs.len()
&& arcs.iter().all(|(c, r)| c.dist(arcs[0].0) < 1e-9 && (r - arcs[0].1).abs() < 1e-9);
h.segs.iter().filter_map(|s| if let Seg2::Arc { center, radius, .. } = s { Some((*center, *radius)) } else { None }).collect();
let n = h.segs.len() as i64;
let full =
!arcs.is_empty() && arcs.len() == h.segs.len() && arcs.iter().all(|(c, r)| c.dist(arcs[0].0) < 1e-9 && (r - arcs[0].1).abs() < 1e-9);
if full && n > 1 {
df += 1 - n;
de += 2 - 3 * n;
@@ -688,7 +1002,7 @@ impl SheetBody {
/// Bounding size of the flat pattern (x, y in the flat frame).
pub fn flat_size(&self) -> Result<(f64, f64)> {
let loops = self.outline()?;
let loops: Vec<Vec<Vec2>> = self.flat_regions()?.iter().map(|r| r.outer.polyline(1e-3)).collect();
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
for p in loops.iter().flatten() {
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
@@ -1114,8 +1428,9 @@ impl SheetBody {
}
/// Map a region in a world plane to flat coordinates through a panel's placement.
pub fn region_to_flat(&self, panel: Option<usize>, plane: &Plane, r: &Region2) -> Option<Region2> {
let inv = mat_inverse(&self.world(panel))?;
pub fn region_to_flat(&self, panel: Option<usize>, plane: &Plane, r: &Region2) -> Result<Region2> {
region_complexity(std::slice::from_ref(r))?;
let inv = mat_inverse(&self.world(panel)).ok_or_else(|| DocError::Invalid("invalid sheet panel frame".into()))?;
let map = |p: Vec2| {
let q = apply_point(&inv, plane.to_world(p));
Vec2::new(q.x, q.y)
@@ -1138,16 +1453,18 @@ impl SheetBody {
other => Seg2::Line { a: map(other.start()), b: map(other.end()) },
}
};
let curved_other = r.outer.segs.iter().any(|x| !matches!(x, Seg2::Line { .. } | Seg2::Arc { .. }));
let mut lp = if curved_other {
Loop2::polygon(&r.outer.polyline(1e-3).into_iter().map(map).collect::<Vec<_>>())
} else {
Loop2 { segs: r.outer.segs.iter().map(seg).collect() }
let map_loop = |lp: &Loop2| -> Result<Loop2> {
let curved_other = lp.segs.iter().any(|x| !matches!(x, Seg2::Line { .. } | Seg2::Arc { .. }));
if curved_other {
Ok(Loop2::polygon(&flat_polyline(lp)?.into_iter().map(map).collect::<Vec<_>>()))
} else {
Ok(Loop2 { segs: lp.segs.iter().map(seg).collect() })
}
};
if lp.signed_area() < 0.0 {
lp = lp.reversed();
}
Some(Region2 { outer: lp, holes: Vec::new() })
Ok(Region2 {
outer: map_loop(&r.outer)?.ccw(),
holes: r.holes.iter().map(|h| map_loop(h).map(|l| l.ccw().reversed())).collect::<Result<_>>()?,
})
}
/// Panels whose plane is parallel to `plane` (world): (panel, region).
@@ -1161,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 {
@@ -1178,7 +1511,9 @@ fn point_in(poly: &[Vec2], p: Vec2) -> bool {
/// The boundary of a union of counter-clockwise polygons that meet edge to edge: edges are
/// split where corners touch them, edges running both ways cancel, the rest chain into loops
/// (outer loops counter-clockwise, holes clockwise; straight runs merged).
fn merge_loops(polys: &[Vec<Vec2>]) -> Vec<Vec<Vec2>> {
fn merge_loops(polys: &[Vec<Vec2>], work: &mut FlatWork) -> Result<Vec<Vec<Vec2>>> {
let n = flat_points(polys)?;
work.charge(n.saturating_mul(n).saturating_mul(2))?;
let mut segs: Vec<(Vec2, Vec2)> = Vec::new();
for p in polys {
let m = p.len();
@@ -1208,9 +1543,13 @@ fn merge_loops(polys: &[Vec<Vec2>]) -> Vec<Vec<Vec2>> {
ts.sort_by(|x, y| x.total_cmp(y));
ts.dedup_by(|x, y| (*x - *y).abs() < 1e-12);
for w in ts.windows(2) {
if split.len() >= MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
split.push((*a + d * w[0], *a + d * w[1]));
}
}
work.charge(split.len().saturating_mul(split.len()).saturating_mul(2))?;
let same = |p: Vec2, q: Vec2| p.dist(q) < 1e-6;
let keep: Vec<(Vec2, Vec2)> = split
.iter()
@@ -1248,7 +1587,7 @@ fn merge_loops(polys: &[Vec<Vec2>]) -> Vec<Vec<Vec2>> {
loops.push(clean);
}
}
loops
Ok(loops)
}
fn signed_area(p: &[Vec2]) -> f64 {
@@ -1256,27 +1595,6 @@ fn signed_area(p: &[Vec2]) -> f64 {
(0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::<f64>() / 2.0
}
/// A convex polygon minus a convex polygon (both counter-clockwise), as convex pieces.
fn convex_diff(a: &[Vec2], h: &[Vec2]) -> Vec<Vec<Vec2>> {
let mut out = Vec::new();
let mut rest = a.to_vec();
let m = h.len();
for i in 0..m {
let (Some(&p), Some(&q)) = (h.get(i), h.get((i + 1) % m)) else { continue };
let Some(d) = (q - p).normalized() else { continue };
let left = Vec2::new(-d.y, d.x);
let outside = dedup(&clip(&rest, p, left * -1.0));
if outside.len() >= 3 && signed_area(&outside).abs() > 1e-12 {
out.push(outside);
}
rest = dedup(&clip(&rest, p, left));
if rest.len() < 3 {
break;
}
}
out
}
/// Do two polygons' edges cross?
fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool {
let seg = |p: &[Vec2], i: usize| -> Option<(Vec2, Vec2)> { Some((*p.get(i)?, *p.get((i + 1) % p.len())?)) };
@@ -1291,25 +1609,25 @@ fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool {
})
}
fn is_convex(p: &[Vec2]) -> bool {
let m = p.len();
(0..m).all(|i| match (p.get(i), p.get((i + 1) % m), p.get((i + 2) % m)) {
(Some(a), Some(b), Some(c)) => (*b - *a).cross(*c - *b) >= -1e-9,
_ => true,
})
/// Bounded overlap predicate, including crossing-only cuts whose vertices lie outside a panel.
pub(crate) fn flat_regions_overlap(a: &Region2, b: &Region2, work: &mut FlatWork) -> Result<bool> {
let (a, b) = (planar_region(a)?, planar_region(b)?);
Ok(!material_boolean(&[a], &[b], kernel::BoolOp::Intersect, work)?.is_empty())
}
/// A sheet from a closed profile (base flange): the region in `plane`, thickness along its normal.
pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region: Region2) -> SheetBody {
let holes = region.holes.into_iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect();
SheetBody {
body: body.into(),
rule: rule.into(),
t: rv.thickness,
k: rv.k_factor,
frame: plane,
base: region,
base: Region2 { outer: region.outer, holes: Vec::new() },
flanges: Vec::new(),
holes: Vec::new(),
holes,
pockets: Vec::new(),
flat: false,
gap: rv.gap,
base_curves: Vec::new(),
@@ -1361,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(),
@@ -1510,4 +1829,138 @@ mod tests {
s.flat = true;
assert!(s.solid().is_ok());
}
#[test]
fn disconnected_material_keeps_each_hole_with_its_own_region() {
let mut s = plate(100.0, 60.0);
s.holes.push(rect(Vec2::new(45.0, -5.0), Vec2::new(55.0, -5.0), Vec2::new(55.0, 65.0), Vec2::new(45.0, 65.0)));
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 30.0), 5.0), holes: vec![] });
let regions = s.flat_regions().unwrap();
assert_eq!(regions.len(), 2);
let left = regions.iter().find(|r| r.outer.contains(Vec2::new(10.0, 10.0))).unwrap();
let right = regions.iter().find(|r| r.outer.contains(Vec2::new(90.0, 10.0))).unwrap();
assert_eq!(left.holes.len(), 1);
assert!(left.holes[0].segs.iter().all(|s| matches!(s, Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9)));
assert!(right.holes.is_empty());
let area: f64 = regions.iter().map(Region2::area).sum();
assert!((area - (5400.0 - std::f64::consts::PI * 25.0)).abs() < 1e-6);
assert!((vol(&s.flat_solid().unwrap()) - area * 2.5).abs() < 0.1);
}
#[test]
fn ring_cut_retains_exact_central_material_island() {
let mut s = plate(100.0, 60.0);
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(50.0, 30.0), 10.0), holes: vec![Loop2::circle(Vec2::new(50.0, 30.0), 5.0)] });
let regions = s.flat_regions().unwrap();
assert_eq!(regions.len(), 2);
let island = regions.iter().find(|r| r.outer.contains(Vec2::new(50.0, 30.0))).unwrap();
assert!(island.holes.is_empty());
assert!(island.outer.segs.iter().all(|s| matches!(s, Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9)));
let area: f64 = regions.iter().map(Region2::area).sum();
assert!((area - (6000.0 - std::f64::consts::PI * 75.0)).abs() < 1e-6);
assert!((vol(&s.flat_solid().unwrap()) - area * 2.5).abs() < 0.1);
}
#[test]
fn freeform_profile_uses_bounded_sampling_with_exact_round_holes() {
let mut s = plate(100.0, 60.0);
s.base.outer.segs = vec![
Seg2::Line { a: Vec2::ZERO, b: Vec2::new(100.0, 0.0) },
Seg2::Line { a: Vec2::new(100.0, 0.0), b: Vec2::new(100.0, 60.0) },
Seg2::Cubic { p0: Vec2::new(100.0, 60.0), p1: Vec2::new(200.0 / 3.0, 80.0), p2: Vec2::new(100.0 / 3.0, 80.0), p3: Vec2::new(0.0, 60.0) },
Seg2::Line { a: Vec2::new(0.0, 60.0), b: Vec2::ZERO },
];
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(50.0, 30.0), 3.0), holes: vec![] });
let regions = s.flat_regions().unwrap();
assert!(regions[0].outer.segs.iter().all(|s| matches!(s, Seg2::Line { .. })));
assert!(regions[0].holes[0].segs.iter().all(|s| matches!(s, Seg2::Arc { .. })));
// The cubic has x=100(1-t), y=60+60t(1-t), hence an exact blank area of 7000.
let expected = (7000.0 - std::f64::consts::PI * 9.0) * 2.5;
assert!((vol(&s.solid().unwrap()) - expected).abs() < 0.5);
assert!((vol(&s.flat_solid().unwrap()) - expected).abs() < 0.5);
}
#[test]
fn flat_geometry_budgets_reject_aggregate_and_intermediate_work() {
let mut s = plate(100.0, 60.0);
// Each arc is individually legal, but their combined sampled points exceed the cap.
s.base.outer.segs = vec![Seg2::Arc { center: Vec2::ZERO, radius: 1000.0, start: 0.0, sweep: std::f64::consts::TAU }; 8];
assert!(s.flat_regions().unwrap_err().to_string().contains("budget"));
let mut s = plate(100.0, 60.0);
s.holes = vec![Region2 { outer: Loop2::circle(Vec2::new(20.0, 20.0), 1.0), holes: vec![] }; MAX_FLAT_PIECES + 1];
assert!(s.flat_regions().unwrap_err().to_string().contains("budget"));
let p = vec![vec![Vec2::ZERO; MAX_FLAT_POINTS]];
assert!(merge_loops(&p, &mut FlatWork::new()).unwrap_err().to_string().contains("budget"));
// Exhausting a clipping/merge allowance must report an error before work begins.
assert!(merge_loops(&[vec![Vec2::ZERO, Vec2::X, Vec2::Y]], &mut FlatWork(0)).is_err());
}
#[test]
fn overlapping_round_cutouts_union_with_exact_arc_boundaries() {
let mut s = plate(100.0, 60.0);
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 30.0), 5.0), holes: vec![] });
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(22.0, 30.0), 5.0), holes: vec![] });
let regions = s.flat_regions().unwrap();
assert_eq!(regions[0].holes.len(), 1);
assert!(regions[0].holes[0].segs.iter().all(|s| matches!(s, Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9)));
let intersection = 50.0 * (0.2f64).acos() - (96.0f64).sqrt();
let area = 6000.0 - (50.0 * std::f64::consts::PI - intersection);
assert!((regions[0].area() - area).abs() < 1e-6);
assert!((vol(&s.solid().unwrap()) - area * 2.5).abs() < 0.1);
}
#[test]
fn nested_and_repeated_round_cutouts_keep_only_the_largest_exact_boundary() {
for reverse in [false, true] {
let mut s = plate(100.0, 60.0);
let mut radii = vec![3.0, 5.0, 5.0];
if reverse {
radii.reverse();
}
for radius in radii {
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 30.0), radius), holes: vec![] });
}
let regions = s.flat_regions().unwrap();
assert_eq!(regions[0].holes.len(), 1);
assert!((regions[0].area() - (6000.0 - std::f64::consts::PI * 25.0)).abs() < 1e-6);
let volume = vol(&s.solid().unwrap());
// Body measure tessellates curved faces; the exact region area above is the
// stronger analytic check, while this follows the existing sheet measure budget.
assert!((volume - (6000.0 - std::f64::consts::PI * 25.0) * 2.5).abs() < 0.1, "{volume}");
assert_eq!(s.round_hole_correction(), (-1, -4, -2));
}
}
#[test]
fn sewing_budget_rejects_edge_splitting_growth_before_allocating_faces() {
let s = plate(100.0, 60.0);
let cells: Vec<Cell> = (0..250)
.map(|i| Cell {
piece: Piece::Panel(None),
outer: Loop2::polygon(&[Vec2::new(i as f64, 0.0), Vec2::new(250.0, 0.0), Vec2::new(i as f64, 1.0)]).segs,
holes: vec![],
})
.collect();
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"));
}
}
}
+1
View File
@@ -157,6 +157,7 @@ fn errors_are_reported_not_fatal() {
targets: vec![],
angle2: None,
symmetric: false,
convention: Default::default(),
to: None,
},
None,