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:
commit
184e301c46
180 files changed
+29315
-1568
No files matched your search
@@ -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))
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(®ion.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.)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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, ¶meter, &expression)?;
|
||||
dependency_graph.insert(parameter.clone(), expr::references(&expression));
|
||||
if self.param(¶meter).is_none()
|
||||
&& rule.length_unit.as_deref().is_some_and(|unit| unit != self.units)
|
||||
&& values.get(¶meter).is_some_and(|value| value.kind() == Some(expr::Kind::Length))
|
||||
{
|
||||
let (_, value) = operands(self, &values, ¶meter, &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, ¶meter, &minimum, length_unit.as_deref())?;
|
||||
let (_, max) = operands(self, &values, ¶meter, &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, ¶meter, &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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
®ions,
|
||||
(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(®ion_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, ®ions, 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(®ions, -axis_w, tb);
|
||||
}
|
||||
if *convention == crate::RevolveConvention::SketchNormal {
|
||||
let mut out = Vec::new();
|
||||
for (region, region_extent) in regions.iter().zip(®ion_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, ®ions, 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
@@ -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, ®ions, 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, ®ions, 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 ®ions {
|
||||
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 ®ions {
|
||||
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>> {
|
||||
|
||||
@@ -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, ®ions, 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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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(), ¤t, &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"));
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(®ion.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(®ion.outer).chain(®ion.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(®ions)?;
|
||||
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(®ions)?;
|
||||
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(®ion))?.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(®ion)?;
|
||||
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(®ion))?.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, ®ions, 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(®ions)?;
|
||||
}
|
||||
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(®ion))?.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(®ions, &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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,7 @@ fn errors_are_reported_not_fatal() {
|
||||
targets: vec![],
|
||||
angle2: None,
|
||||
symmetric: false,
|
||||
convention: Default::default(),
|
||||
to: None,
|
||||
},
|
||||
None,
|
||||
|
||||
Reference in new issue
Block a user