feat: preserve body history and placed modelling workflows
Record ownership transfers in the timeline so earlier Join/Cut membership, world placement, machining data and styles survive component moves, edits, independent copying, undo and reopening. Preserve pending positions in history snapshots and keep constructor transfers ordered during feature redefinition. Retain shallow sheet pockets and exact planar intersection islands through unfold/refold, with depth-aware flat previews and DXF export. Store explicit 3D wire coordinate spaces and migrate known legacy provenance without moving saved points or analytic fits. Infer unique eligible cylindrical Emboss targets, align Revolve manipulators with actual extent geometry, and discard obsolete preview errors after input correction. Certify constructed and immutable imported face styles using exact source metadata while preserving legacy face names. Upgrade designs to format10 with bounded validation and geometry-preserving legacy migration.
This commit is contained in:
1 parent
0398a813c8
commit
c1d819f235
86 files changed
+6011
-488
No files matched your search
@@ -16,4 +16,5 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
@@ -48,6 +48,8 @@ pub struct FaceLook {
|
||||
/// Strict semantic assignments never transfer to a nearby replacement face.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub strict_name: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub certificate: Option<crate::config::FaceCertificate>,
|
||||
}
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
@@ -139,7 +141,13 @@ pub fn material_look(material: &str) -> Option<Look> {
|
||||
impl Document {
|
||||
/// Shared definition appearance for a computed occurrence body.
|
||||
pub fn model_body_look(&self, body: &crate::ModelBody) -> Option<Look> {
|
||||
self.body_look(body.definition_name(), body.feature)
|
||||
let name = body.definition_name();
|
||||
self.appearances
|
||||
.bodies
|
||||
.get(name)
|
||||
.cloned()
|
||||
.or_else(|| self.appearances.components.get(&self.model_body_component(body)).cloned())
|
||||
.or_else(|| self.materials.get(name).and_then(|material| material_look(material)))
|
||||
}
|
||||
/// Effective body opacity, including paint carried from an imported body.
|
||||
pub fn model_body_opacity(&self, body: &crate::ModelBody) -> f64 {
|
||||
@@ -198,6 +206,7 @@ impl Document {
|
||||
origin_feature,
|
||||
name: crate::naming::strip_piece(name).into(),
|
||||
point: f.point,
|
||||
certificate: f.certificate.clone(),
|
||||
};
|
||||
let Ok(indices) = self.configuration_face_indices(b, &target) else { continue };
|
||||
for index in indices {
|
||||
|
||||
@@ -328,7 +328,8 @@ impl FeatureKind {
|
||||
| FeatureKind::Stitch { .. }
|
||||
| FeatureKind::Thicken { .. }
|
||||
| FeatureKind::Import { .. }
|
||||
| FeatureKind::MeshImport { .. } => {}
|
||||
| FeatureKind::MeshImport { .. }
|
||||
| FeatureKind::BodyTransfer { .. } => {}
|
||||
FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
FeatureKind::SurfaceTrim { plane, tool, keep, .. } => {
|
||||
map_plane_ref(m, plane);
|
||||
@@ -392,9 +393,9 @@ impl Document {
|
||||
pub fn body_frames_with_source(&self, st: &crate::ModelState) -> Vec<(String, String, u64, Vec<u64>, Mat)> {
|
||||
let mut out = Vec::new();
|
||||
for (comp, path, m) in self.placements() {
|
||||
for b in st.bodies.iter().filter(|b| self.body_component(&b.name, b.feature) == comp) {
|
||||
for b in st.bodies.iter().filter(|b| self.model_body_component(b) == comp) {
|
||||
let name = self.world_body_name(st, &b.name, comp, &path);
|
||||
let w = match self.body_offsets.get(&b.name) {
|
||||
let w = match self.model_body_offset(b) {
|
||||
Some(o) => mat_mul(&m, o),
|
||||
None => m,
|
||||
};
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -304,7 +304,8 @@ impl crate::Feature {
|
||||
| K::Align { bodies: list, .. }
|
||||
| K::Remove { bodies: list }
|
||||
| K::Stitch { bodies: list, .. }
|
||||
| K::Move { bodies: list, .. } => {
|
||||
| K::Move { bodies: list, .. }
|
||||
| K::BodyTransfer { bodies: list, .. } => {
|
||||
for n in list {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ pub use rules::*;
|
||||
#[path = "config_styles.rs"]
|
||||
mod styles;
|
||||
pub use styles::*;
|
||||
#[path = "config_certificates.rs"]
|
||||
mod certificates;
|
||||
pub use certificates::*;
|
||||
#[path = "config_clone.rs"]
|
||||
mod clone;
|
||||
pub use clone::*;
|
||||
@@ -235,6 +238,7 @@ impl Document {
|
||||
look,
|
||||
name: Some(target.name.clone()),
|
||||
strict_name: true,
|
||||
certificate: target.certificate.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Certificates are producer identities, never a nearest-surface selection.
|
||||
use crate::{DocError, Document, FeatureKind, ModelBody, Result, SolvedSketch};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::Seg2;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum FaceCertificate {
|
||||
ExtrudeSide { sketch: u64, curve: String },
|
||||
Imported { payload_sha256: String, item: u64, face: u64 },
|
||||
}
|
||||
|
||||
impl FaceCertificate {
|
||||
pub fn remap_features(&mut self, features: &std::collections::BTreeMap<u64, u64>) {
|
||||
if let Self::ExtrudeSide { sketch, .. } = self
|
||||
&& let Some(mapped) = features.get(sketch)
|
||||
{
|
||||
*sketch = *mapped;
|
||||
}
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let valid = match self {
|
||||
Self::ExtrudeSide { sketch, curve } => *sketch != 0 && !curve.is_empty() && curve.len() <= 128,
|
||||
Self::Imported { payload_sha256, item, face } => {
|
||||
*item != 0
|
||||
&& *face != 0
|
||||
&& payload_sha256.len() == 64
|
||||
&& payload_sha256.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
}
|
||||
};
|
||||
if valid { Ok(()) } else { Err(DocError::Invalid("invalid face source certificate".into())) }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()> {
|
||||
if value.is_null() {
|
||||
return Ok(());
|
||||
}
|
||||
let fields = value.as_object().ok_or_else(|| DocError::Invalid("face certificate must be an object".into()))?;
|
||||
let nonzero = |field: &str| fields.get(field).and_then(serde_json::Value::as_u64).is_some_and(|id| id != 0);
|
||||
let valid = match fields.get("kind").and_then(serde_json::Value::as_str) {
|
||||
Some("extrude_side") => {
|
||||
fields.len() == 3
|
||||
&& fields.keys().all(|key| ["kind", "sketch", "curve"].contains(&key.as_str()))
|
||||
&& nonzero("sketch")
|
||||
&& fields.get("curve").and_then(serde_json::Value::as_str).is_some_and(|curve| !curve.is_empty() && curve.len() <= 128)
|
||||
}
|
||||
Some("imported") => {
|
||||
fields.len() == 4
|
||||
&& fields.keys().all(|key| ["kind", "payload_sha256", "item", "face"].contains(&key.as_str()))
|
||||
&& nonzero("item")
|
||||
&& nonzero("face")
|
||||
&& fields
|
||||
.get("payload_sha256")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|digest| digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if valid { Ok(()) } else { Err(DocError::Invalid("malformed or oversized face source certificate".into())) }
|
||||
}
|
||||
|
||||
/// Match a generated segment to one exact analytic sketch curve. Coincident
|
||||
/// candidates are ambiguous and never resolved by distance or candidate order.
|
||||
pub(crate) fn exact_sketch_curve(sketch: &SolvedSketch, generated: &Seg2) -> Option<String> {
|
||||
if sketch.sketch.curves.len() > 2000 {
|
||||
return None;
|
||||
}
|
||||
let mut found = None;
|
||||
for (index, curve) in sketch.sketch.curves.iter().enumerate() {
|
||||
if curve.construction
|
||||
|| !matches!(
|
||||
curve.kind,
|
||||
solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Arc { .. } | solvecraft_sketch::CurveKind::Circle { .. }
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let matches = if let solvecraft_sketch::CurveKind::Circle { c, r } = curve.kind {
|
||||
sketch
|
||||
.sketch
|
||||
.point(c)
|
||||
.is_some_and(|center| exact_segment_contains(&Seg2::Arc { center, radius: r, start: 0., sweep: std::f64::consts::TAU }, generated))
|
||||
} else {
|
||||
sketch.sketch.segs(index).iter().any(|original| exact_segment_contains(original, generated))
|
||||
};
|
||||
if matches {
|
||||
if found.is_some() {
|
||||
return None;
|
||||
}
|
||||
found = Some(curve.id.clone());
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn exact_segment_contains(original: &Seg2, generated: &Seg2) -> bool {
|
||||
match (original, generated) {
|
||||
(Seg2::Line { a, b }, Seg2::Line { a: c, b: d }) => {
|
||||
let direction = *b - *a;
|
||||
let scale = direction.len().max((*d - *c).len()).max(1.);
|
||||
let interval = |point: solvecraft_geom::Vec2| {
|
||||
let t = (point - *a).dot(direction) / direction.len2();
|
||||
(-1e-10..=1. + 1e-10).contains(&t)
|
||||
};
|
||||
direction.len() > 1e-12
|
||||
&& direction.cross(*c - *a).abs() <= 1e-10 * scale * scale
|
||||
&& direction.cross(*d - *a).abs() <= 1e-10 * scale * scale
|
||||
&& interval(*c)
|
||||
&& interval(*d)
|
||||
}
|
||||
(Seg2::Arc { center: a, radius: ar, start: os, sweep: ow }, Seg2::Arc { center: b, radius: br, start: gs, sweep: gw }) => {
|
||||
let tau = std::f64::consts::TAU;
|
||||
let tolerance = 1e-10;
|
||||
let length = ow.abs();
|
||||
let generated_length = gw.abs();
|
||||
if a.dist(*b) > 1e-10 * ar.max(*br).max(1.)
|
||||
|| (ar - br).abs() > 1e-10 * ar.max(*br).max(1.)
|
||||
|| length > tau + tolerance
|
||||
|| generated_length > length + tolerance
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if length >= tau - tolerance {
|
||||
return true;
|
||||
}
|
||||
let original_low = if *ow >= 0. { *os } else { os + ow };
|
||||
let generated_low = if *gw >= 0. { *gs } else { gs + gw };
|
||||
let offset = (generated_low - original_low).rem_euclid(tau);
|
||||
let offset = if offset > tau - tolerance { 0. } else { offset };
|
||||
offset + generated_length <= length + tolerance
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn configuration_face_target(&self, body: &ModelBody, index: usize, point: solvecraft_geom::Vec3) -> Result<crate::config::ConfigFaceTarget> {
|
||||
let names = crate::naming::face_names(body);
|
||||
let mut name = names
|
||||
.get(index)
|
||||
.map(|name| crate::naming::strip_piece(name).to_string())
|
||||
.ok_or_else(|| DocError::Unknown("face source name missing".into()))?;
|
||||
let origin = match body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) {
|
||||
Some(solvecraft_kernel::FaceSource::Imported { producer: Some(producer), .. })
|
||||
| Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. }) => *producer,
|
||||
_ => name
|
||||
.strip_prefix('F')
|
||||
.and_then(|name| name.split_once(':'))
|
||||
.and_then(|(id, _)| id.parse().ok())
|
||||
.ok_or_else(|| DocError::Invalid("face source producer missing".into()))?,
|
||||
};
|
||||
let certificate = self.configuration_face_certificate(body, index)?;
|
||||
if let Some(FaceCertificate::ExtrudeSide { curve, .. }) = &certificate {
|
||||
name = format!("F{origin}:side:{curve}");
|
||||
} else if !name.starts_with(&format!("F{origin}:")) {
|
||||
name = format!("F{origin}:face:{index}");
|
||||
}
|
||||
let inverse = crate::mat_inverse(&body.source_transform).ok_or_else(|| DocError::Invalid("invalid definition placement".into()))?;
|
||||
Ok(crate::config::ConfigFaceTarget {
|
||||
body: body.definition_name().into(),
|
||||
origin_feature: origin,
|
||||
name,
|
||||
point: crate::apply_point(&inverse, point),
|
||||
certificate,
|
||||
})
|
||||
}
|
||||
pub fn configuration_face_certificate(&self, body: &ModelBody, index: usize) -> Result<Option<FaceCertificate>> {
|
||||
let Some(source) = body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let solvecraft_kernel::FaceSource::Imported { item, face, payload_sha256: Some(digest), producer: Some(producer) } = source {
|
||||
if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Import { .. })) {
|
||||
return Err(DocError::Invalid("import source producer missing".into()));
|
||||
}
|
||||
let certificate = FaceCertificate::Imported { payload_sha256: digest.clone(), item: *item, face: *face };
|
||||
certificate.validate()?;
|
||||
return Ok(Some(certificate));
|
||||
}
|
||||
let certificate = match source {
|
||||
solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch, curve } => {
|
||||
if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Extrude { .. })) {
|
||||
return Err(DocError::Invalid("side source producer missing".into()));
|
||||
}
|
||||
FaceCertificate::ExtrudeSide { sketch: *sketch, curve: curve.clone() }
|
||||
}
|
||||
_ => return Err(DocError::Invalid("face source does not match producer".into())),
|
||||
};
|
||||
certificate.validate()?;
|
||||
Ok(Some(certificate))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn payload_digest(payload: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
format!("{:x}", Sha256::digest(payload.as_bytes()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn certificate_budgets_apply_before_saved_target_and_paint_decode() {
|
||||
for certificate in [
|
||||
json!({"kind":"extrude_side","sketch":1,"curve":"x".repeat(1_000_000)}),
|
||||
json!({"kind":"imported","payload_sha256":"0".repeat(64),"item":0,"face":1}),
|
||||
json!({"kind":"imported","payload_sha256":"g".repeat(64),"item":1,"face":1}),
|
||||
json!({"kind":"extrude_side","sketch":1,"curve":"c1","extra":[[[[[]]]]]}),
|
||||
] {
|
||||
assert!(validate_certificate_json(&certificate).is_err());
|
||||
let target = json!({"configs":{"columns":[{"face_appearance":{"body":"B","origin_feature":1,"name":"F1:side:c1","point":[0,0,0],"certificate":certificate}}]}});
|
||||
assert!(crate::config::validate_styles_json(&target).is_err());
|
||||
let paint = json!({"appearances":{"faces":[{"certificate":certificate}]}});
|
||||
assert!(crate::config::validate_styles_json(&paint).is_err());
|
||||
}
|
||||
assert!(validate_certificate_json(&json!({"kind":"imported","payload_sha256":"a".repeat(64),"item":1,"face":2})).is_ok());
|
||||
assert!(validate_certificate_json(&json!({"kind":"extrude_side","sketch":1,"curve":"c1"})).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn analytic_arc_interval_refuses_unsampled_gaps_and_accepts_wrap_reversal() {
|
||||
let radians = |degrees: f64| degrees.to_radians();
|
||||
let arc = |start, sweep| Seg2::Arc { center: solvecraft_geom::Vec2::default(), radius: 10., start: radians(start), sweep: radians(sweep) };
|
||||
assert!(!exact_segment_contains(&arc(10., 350.), &arc(20., 360.)), "five point probes missed the missing ten-degree gap");
|
||||
assert!(!exact_segment_contains(&arc(10., 350.), &arc(20., -360.)));
|
||||
assert!(exact_segment_contains(&arc(350., 40.), &arc(355., 20.)));
|
||||
assert!(exact_segment_contains(&arc(350., 40.), &arc(15., -20.)));
|
||||
assert!(exact_segment_contains(&arc(30., -40.), &arc(15., -20.)));
|
||||
assert!(!exact_segment_contains(&arc(350., 40.), &arc(345., 20.)));
|
||||
assert!(exact_segment_contains(&arc(0., 360.), &arc(20., -360.)));
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,9 @@ impl ConfigTable {
|
||||
.map(|role| format!("F{origin}:{role}"))
|
||||
.unwrap_or_else(|| target.name.clone());
|
||||
target.origin_feature = *origin;
|
||||
if let Some(certificate) = &mut target.certificate {
|
||||
certificate.remap_features(&maps.features);
|
||||
}
|
||||
Column::FaceAppearance(target)
|
||||
})
|
||||
}
|
||||
@@ -140,6 +143,7 @@ mod tests {
|
||||
origin_feature: 3,
|
||||
name: "F3:+z".into(),
|
||||
point: solvecraft_geom::Vec3::new(1., 1., 2.),
|
||||
certificate: None,
|
||||
}),
|
||||
],
|
||||
rows: vec![ConfigRow {
|
||||
@@ -196,6 +200,7 @@ mod tests {
|
||||
origin_feature: 3,
|
||||
name: "F3:+z".into(),
|
||||
point: solvecraft_geom::Vec3::ZERO,
|
||||
certificate: None,
|
||||
}),
|
||||
],
|
||||
rows: vec![ConfigRow { name: "Default".into(), cells: vec!["2 mm".into(), String::new()] }],
|
||||
|
||||
@@ -13,6 +13,8 @@ pub struct ConfigFaceTarget {
|
||||
pub name: String,
|
||||
/// Definition-frame point for diagnostics and rendering; never a resolver fallback.
|
||||
pub point: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub certificate: Option<super::FaceCertificate>,
|
||||
}
|
||||
impl ConfigFaceTarget {
|
||||
pub fn key(&self) -> String {
|
||||
@@ -23,6 +25,9 @@ impl ConfigFaceTarget {
|
||||
face.body == self.body && face.name.as_ref().is_some_and(|name| crate::naming::strip_piece(name) == self.name)
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if let Some(certificate) = &self.certificate {
|
||||
certificate.validate()?;
|
||||
}
|
||||
if self.body.is_empty()
|
||||
|| self.body.len() > 128
|
||||
|| self.name.is_empty()
|
||||
@@ -36,6 +41,25 @@ impl ConfigFaceTarget {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_face_target_json(target: &Value) -> Result<()> {
|
||||
let fields = target.as_object().ok_or_else(|| DocError::Invalid("face target must be an object".into()))?;
|
||||
if !(4..=5).contains(&fields.len())
|
||||
|| fields.keys().any(|key| !["body", "origin_feature", "name", "point", "certificate"].contains(&key.as_str()))
|
||||
|| fields.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128)
|
||||
|| fields.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512)
|
||||
|| fields.get("origin_feature").and_then(Value::as_u64).is_none_or(|id| id == 0)
|
||||
|| fields.get("point").and_then(Value::as_array).is_none_or(|point| {
|
||||
point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9))
|
||||
})
|
||||
{
|
||||
return Err(DocError::Invalid("invalid or oversized configuration face target".into()));
|
||||
}
|
||||
if let Some(certificate) = fields.get("certificate") {
|
||||
super::validate_certificate_json(certificate)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Budget malformed style nodes before decoding their typed representation.
|
||||
pub fn validate_styles_json(value: &Value) -> Result<()> {
|
||||
if let Some(columns) = value.get("configs").and_then(|v| v.get("columns")).and_then(Value::as_array) {
|
||||
@@ -44,18 +68,7 @@ pub fn validate_styles_json(value: &Value) -> Result<()> {
|
||||
}
|
||||
for column in columns {
|
||||
if let Some(target) = column.get("face_appearance") {
|
||||
let fields = target.as_object().ok_or_else(|| DocError::Invalid("face target must be an object".into()))?;
|
||||
if fields.len() != 4
|
||||
|| fields.keys().any(|key| !["body", "origin_feature", "name", "point"].contains(&key.as_str()))
|
||||
|| fields.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128)
|
||||
|| fields.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512)
|
||||
|| fields.get("origin_feature").and_then(Value::as_u64).is_none_or(|id| id == 0)
|
||||
|| fields.get("point").and_then(Value::as_array).is_none_or(|point| {
|
||||
point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9))
|
||||
})
|
||||
{
|
||||
return Err(DocError::Invalid("invalid or oversized configuration face target".into()));
|
||||
}
|
||||
validate_face_target_json(target)?;
|
||||
}
|
||||
if let Some(id) = column.get("component_appearance")
|
||||
&& id.as_u64().is_none_or(|id| id == 0)
|
||||
@@ -64,6 +77,23 @@ pub fn validate_styles_json(value: &Value) -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(faces) = value.get("appearances").and_then(|value| value.get("faces")).and_then(Value::as_array) {
|
||||
for face in faces {
|
||||
if let Some(certificate) = face.get("certificate") {
|
||||
super::validate_certificate_json(certificate)?;
|
||||
if !certificate.is_null()
|
||||
&& (face.get("strict_name").and_then(Value::as_bool) != Some(true)
|
||||
|| face.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128)
|
||||
|| face.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512)
|
||||
|| face.get("point").and_then(Value::as_array).is_none_or(|point| {
|
||||
point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9))
|
||||
}))
|
||||
{
|
||||
return Err(DocError::Invalid("invalid certified face appearance".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -77,6 +107,17 @@ impl Document {
|
||||
return Err(DocError::Invalid("configure a face lineage, not a numbered split piece".into()));
|
||||
}
|
||||
let feature = self.feature(target.origin_feature).ok_or_else(|| DocError::Unknown("face origin feature is gone".into()))?;
|
||||
if let Some(certificate) = &target.certificate {
|
||||
let valid = match (certificate, &feature.kind) {
|
||||
(super::FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => {
|
||||
sketch == producer_sketch && extent.taper.is_none() && role == format!("side:{curve}")
|
||||
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Circle { .. } | solvecraft_sketch::CurveKind::Arc { .. }))))
|
||||
}
|
||||
(super::FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => role.strip_prefix("face:").is_some_and(|index| index.parse::<usize>().is_ok()),
|
||||
_ => false,
|
||||
};
|
||||
return if valid { Ok(()) } else { Err(DocError::Invalid("face certificate no longer matches its producer".into())) };
|
||||
}
|
||||
let valid = match &feature.kind {
|
||||
FeatureKind::Box { .. } => ["+x", "-x", "+y", "-y", "+z", "-z"].contains(&role),
|
||||
FeatureKind::Cylinder { .. } => ["top", "bottom", "side"].contains(&role),
|
||||
@@ -108,12 +149,38 @@ impl Document {
|
||||
if body.body.is_mesh() || body.definition_name() != target.body {
|
||||
return Err(DocError::Invalid("configured face definition does not match".into()));
|
||||
}
|
||||
if let Some(super::FaceCertificate::ExtrudeSide { sketch, curve }) = &target.certificate {
|
||||
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
|
||||
matches!(source, Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch: current_sketch, curve: current_curve }) if *producer == target.origin_feature && current_sketch == sketch && current_curve == curve).then_some(index)
|
||||
}).collect();
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown("constructed side certificate no longer exists".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
if let Some(super::FaceCertificate::Imported { payload_sha256, item, face }) = &target.certificate {
|
||||
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
|
||||
matches!(source, Some(solvecraft_kernel::FaceSource::Imported { item: current_item, face: current_face, payload_sha256: Some(digest), producer: Some(producer) }) if current_item == item && current_face == face && digest == payload_sha256 && *producer == target.origin_feature).then_some(index)
|
||||
}).collect();
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown("immutable import face certificate no longer exists".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
let names = crate::naming::face_names(body);
|
||||
let indices: Vec<usize> =
|
||||
names.iter().enumerate().filter(|(_, name)| crate::naming::strip_piece(name) == target.name).map(|(index, _)| index).collect();
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown(format!("configured face `{}` on `{}` no longer exists", target.name, target.body)));
|
||||
}
|
||||
if let Some(certificate) = &target.certificate {
|
||||
for index in &indices {
|
||||
if self.configuration_face_certificate(body, *index)?.as_ref() != Some(certificate) {
|
||||
return Err(DocError::Invalid("face pieces do not share the exact certified source".into()));
|
||||
}
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
if target.name.ends_with(":side") {
|
||||
if indices.len() != 1 {
|
||||
return Err(DocError::Invalid("split curved face lineage is not yet certified".into()));
|
||||
|
||||
@@ -174,8 +174,8 @@ pub struct EmbossFace {
|
||||
|
||||
impl EmbossFace {
|
||||
/// The selected sketch placement, shared by evaluation and interactive guides.
|
||||
pub fn sketch_frame(&self, doc: &Document, author: u64, sketch_owner: u64, body_feature: u64) -> Result<crate::Mat> {
|
||||
let owner = doc.body_component(&self.body, body_feature);
|
||||
pub fn sketch_frame(&self, doc: &Document, author: u64, sketch_owner: u64, body: &crate::ModelBody) -> Result<crate::Mat> {
|
||||
let owner = doc.model_body_component(body);
|
||||
if sketch_owner == owner {
|
||||
return Self::component_frame(doc, owner, &self.path);
|
||||
}
|
||||
@@ -202,10 +202,10 @@ impl EmbossFace {
|
||||
}
|
||||
|
||||
/// Resolve this exact placement without expanding every assembly instance.
|
||||
pub fn body_frame(&self, doc: &Document, feature: u64) -> Result<crate::Mat> {
|
||||
let owner = doc.body_component(&self.body, feature);
|
||||
pub fn body_frame(&self, doc: &Document, body: &crate::ModelBody) -> Result<crate::Mat> {
|
||||
let owner = doc.model_body_component(body);
|
||||
let mut frame = Self::component_frame(doc, owner, &self.path)?;
|
||||
if let Some(offset) = doc.body_offsets.get(&self.body) {
|
||||
if let Some(offset) = doc.model_body_offset(body) {
|
||||
frame = crate::mat_mul(&frame, offset);
|
||||
}
|
||||
Ok(frame)
|
||||
@@ -244,6 +244,14 @@ impl EmbossFace {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum FeatureKind {
|
||||
/// A recorded timeline ownership boundary. Geometry is converted from its
|
||||
/// current local frame into the destination component frame exactly once.
|
||||
BodyTransfer {
|
||||
bodies: Vec<String>,
|
||||
from: u64,
|
||||
to: u64,
|
||||
transform: crate::Mat,
|
||||
},
|
||||
Sketch {
|
||||
plane: PlaneRef,
|
||||
sketch: Sketch,
|
||||
@@ -1121,7 +1129,8 @@ impl FeatureKind {
|
||||
| K::SurfaceTrim { .. }
|
||||
| K::Move { .. }
|
||||
| K::Import { .. }
|
||||
| K::MeshImport { .. } => Vec::new(),
|
||||
| K::MeshImport { .. }
|
||||
| K::BodyTransfer { .. } => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1146,6 +1155,7 @@ impl FeatureKind {
|
||||
/// Fusion-like type name (timeline tooltip, `inspect`).
|
||||
pub fn type_name(&self) -> &'static str {
|
||||
match self {
|
||||
FeatureKind::BodyTransfer { .. } => "BodyTransferFeature",
|
||||
FeatureKind::Sketch { .. } => "Sketch",
|
||||
FeatureKind::Extrude { .. } => "ExtrudeFeature",
|
||||
FeatureKind::Revolve { .. } => "RevolveFeature",
|
||||
@@ -1210,6 +1220,7 @@ impl FeatureKind {
|
||||
/// Default name prefix (`Extrude` → `Extrude1`).
|
||||
pub fn base_name(&self) -> &'static str {
|
||||
match self {
|
||||
FeatureKind::BodyTransfer { .. } => "BodyTransfer",
|
||||
FeatureKind::Sketch { .. } => "Sketch",
|
||||
FeatureKind::Extrude { .. } => "Extrude",
|
||||
FeatureKind::Revolve { .. } => "Revolve",
|
||||
@@ -1465,7 +1476,7 @@ impl FeatureKind {
|
||||
FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v),
|
||||
FeatureKind::SurfaceExtend { distance, .. } => v.push(distance),
|
||||
FeatureKind::Patch { .. } => {}
|
||||
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => {}
|
||||
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } | FeatureKind::BodyTransfer { .. } => {}
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -1701,9 +1712,36 @@ impl Document {
|
||||
|
||||
/// The component a body belongs to: where it was moved, else its feature's.
|
||||
pub fn body_component(&self, body: &str, feature: u64) -> u64 {
|
||||
self.features
|
||||
.iter()
|
||||
.take(self.marker.unwrap_or(usize::MAX))
|
||||
.rev()
|
||||
.find_map(|f| {
|
||||
if f.suppressed {
|
||||
return None;
|
||||
}
|
||||
match &f.kind {
|
||||
FeatureKind::BodyTransfer { bodies, to, .. } if bodies.iter().any(|b| b == body) => Some(*to),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| self.legacy_body_component(body, feature))
|
||||
}
|
||||
|
||||
pub fn legacy_body_component(&self, body: &str, feature: u64) -> u64 {
|
||||
self.body_components.get(body).copied().unwrap_or_else(|| self.feature(feature).map(|f| f.component).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Ownership in this evaluated timeline state, rather than a later transfer.
|
||||
pub fn model_body_component(&self, body: &crate::ModelBody) -> u64 {
|
||||
if body.transferred || body.source_name.is_some() { body.component } else { self.legacy_body_component(&body.name, body.feature) }
|
||||
}
|
||||
|
||||
/// Recorded transfers already converted geometry into the new local frame.
|
||||
pub fn model_body_offset(&self, body: &crate::ModelBody) -> Option<&crate::Mat> {
|
||||
if body.transferred { None } else { self.body_offsets.get(&body.name) }
|
||||
}
|
||||
|
||||
/// Add a component under `parent` and return its id.
|
||||
pub fn add_component(&mut self, name: Option<&str>, parent: u64) -> Result<u64> {
|
||||
if parent != 0 && !self.components.iter().any(|c| c.id == parent) {
|
||||
@@ -1784,6 +1822,12 @@ impl Document {
|
||||
if self.features.len() >= MAX_FEATURES {
|
||||
return Err(DocError::Invalid("too many features".into()));
|
||||
}
|
||||
let component = if let FeatureKind::BodyTransfer { bodies, from, to, transform } = &kind {
|
||||
crate::validate_body_transfer(self, bodies, *from, *to, transform)?;
|
||||
*to
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let id = self.next_id.max(1);
|
||||
self.next_id = id + 1;
|
||||
let name = match name {
|
||||
@@ -1796,7 +1840,7 @@ impl Document {
|
||||
suppressed: false,
|
||||
body_names: Vec::new(),
|
||||
param_names: Vec::new(),
|
||||
component: 0,
|
||||
component,
|
||||
edge_refs: Vec::new(),
|
||||
edge_names: Vec::new(),
|
||||
face_names: Vec::new(),
|
||||
|
||||
+270
-48
@@ -33,6 +33,8 @@ pub struct ModelBody {
|
||||
pub(crate) names: crate::naming::NameCell,
|
||||
/// The component the body belongs to (set after each feature).
|
||||
pub component: u64,
|
||||
/// A timeline transfer has converted this body into its evaluated owner's frame.
|
||||
pub transferred: bool,
|
||||
/// The look it was imported with, carried through later features.
|
||||
pub imported: Option<Arc<crate::appearance::ImportedLook>>,
|
||||
}
|
||||
@@ -49,6 +51,7 @@ impl ModelBody {
|
||||
key: 0,
|
||||
names: Default::default(),
|
||||
component: 0,
|
||||
transferred: false,
|
||||
imported: None,
|
||||
}
|
||||
}
|
||||
@@ -274,6 +277,8 @@ impl ModelState {
|
||||
b.name.hash(&mut h);
|
||||
b.key.hash(&mut h);
|
||||
b.feature.hash(&mut h);
|
||||
b.component.hash(&mut h);
|
||||
b.transferred.hash(&mut h);
|
||||
}
|
||||
for s in &self.sketches {
|
||||
s.feature.hash(&mut h);
|
||||
@@ -292,6 +297,13 @@ impl ModelState {
|
||||
let depth = before.bodies.iter().filter_map(|b| b.names.origin.as_ref().map(|o| o.depth)).max().unwrap_or(0) + 1;
|
||||
let origin = Arc::new(crate::naming::NamingOrigin { feature: f.clone(), before: before.clone(), depth, mats });
|
||||
for b in &mut self.bodies {
|
||||
if !matches!(f.kind, FeatureKind::BodyTransfer { .. })
|
||||
&& let Some(old) = before.bodies.iter().find(|old| old.name == b.name && old.transferred)
|
||||
{
|
||||
b.transferred = true;
|
||||
b.component = old.component;
|
||||
b.source_transform = old.source_transform;
|
||||
}
|
||||
let kept = before.bodies.iter().find(|x| x.name == b.name && x.body.same(&b.body));
|
||||
match kept {
|
||||
Some(x) => {
|
||||
@@ -302,7 +314,9 @@ impl ModelState {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(key, &b.name).hash(&mut h);
|
||||
b.key = h.finish();
|
||||
b.names = crate::naming::NameCell::new(Some(origin.clone()));
|
||||
if !matches!(f.kind, FeatureKind::BodyTransfer { .. }) {
|
||||
b.names = crate::naming::NameCell::new(Some(origin.clone()));
|
||||
}
|
||||
// Faces split on purpose stay split through later features (for export).
|
||||
if b.body.split_lines().is_none()
|
||||
&& let Some(lines) = before.bodies.iter().find(|x| x.name == b.name).and_then(|x| x.body.split_lines())
|
||||
@@ -494,6 +508,26 @@ fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelStat
|
||||
/// Where components are placed, for a sketch that sees other components' geometry (on a face,
|
||||
/// or with projections): it is resolved in its own component's view of them.
|
||||
fn placement_digest(doc: &Document, f: &Feature) -> u64 {
|
||||
let path = match &f.kind {
|
||||
FeatureKind::Sweep { path_sketch, .. }
|
||||
| FeatureKind::Pipe { path_sketch, .. }
|
||||
| FeatureKind::Pattern { pattern: crate::PatternKind::Path { path_sketch, .. }, .. } => Some(*path_sketch),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(path) = path
|
||||
&& doc.sketch(path).is_ok_and(|sketch| !sketch.wires.is_empty())
|
||||
{
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
let owner = doc.feature(path).map(|feature| feature.component).unwrap_or(0);
|
||||
for frame in [doc.component_transform(f.component), doc.component_transform(owner)] {
|
||||
frame.iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
|
||||
}
|
||||
if let FeatureKind::Sweep { sketch, .. } = &f.kind {
|
||||
let owner = doc.feature(*sketch).map(|feature| feature.component).unwrap_or(0);
|
||||
doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
|
||||
}
|
||||
return hash.finish();
|
||||
}
|
||||
let FeatureKind::Sketch { plane, sketch } = &f.kind else { return 0 };
|
||||
if doc.occurrences.is_empty() || (!matches!(plane, PlaneRef::Face { .. }) && sketch.links.is_empty()) {
|
||||
return 0;
|
||||
@@ -673,7 +707,7 @@ impl Model {
|
||||
Ok(()) => {
|
||||
// Copies are named after their sources through the instance transforms.
|
||||
let mats = match &f.kind {
|
||||
FeatureKind::Pattern { pattern, .. } => pattern_transforms(&vals, &state, pattern).unwrap_or_default(),
|
||||
FeatureKind::Pattern { pattern, .. } => pattern_transforms(doc, &vals, &state, f.component, pattern).unwrap_or_default(),
|
||||
FeatureKind::Mirror { plane, .. } => {
|
||||
doc.resolve_plane_in(&vals, plane, 0, Some(&state)).map(|pl| vec![mirror_matrix(&pl)]).unwrap_or_default()
|
||||
}
|
||||
@@ -681,7 +715,9 @@ impl Model {
|
||||
};
|
||||
next.stamp(&state, f, key, mats);
|
||||
for b in &mut next.bodies {
|
||||
b.component = doc.body_component(&b.name, b.feature);
|
||||
if !b.transferred {
|
||||
b.component = doc.legacy_body_component(&b.name, b.feature);
|
||||
}
|
||||
}
|
||||
state = Arc::new(next);
|
||||
None
|
||||
@@ -723,7 +759,7 @@ fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
|
||||
Document::eval_in(vals, e, k)
|
||||
}
|
||||
|
||||
fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region2>> {
|
||||
pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region2>> {
|
||||
let ps = &ss.profiles;
|
||||
if ps.is_empty() {
|
||||
return Err(DocError::Invalid(format!("{} has no closed profiles", ss.name)));
|
||||
@@ -794,7 +830,55 @@ fn copy_body_name(state: &ModelState, f: &Feature, k: usize, source: &str) -> St
|
||||
}
|
||||
|
||||
/// Add tool bodies to the model according to the operation.
|
||||
fn sheet_boolean_unchanged(body: &Body, tools: &[Body], op: Operation) -> Result<()> {
|
||||
if sheet_boolean_changes(body, tools, op)? {
|
||||
return Err(DocError::Invalid("this sheet machining cannot be preserved in its flat material: use a parallel, untapered extrude pocket/cut, or unfold before intersecting; unsupported 3D sheet machining is refused to prevent material restoration".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sheet_boolean_changes(body: &Body, tools: &[Body], op: Operation) -> Result<bool> {
|
||||
let united;
|
||||
let tools: &[Body] = if op == Operation::Intersect {
|
||||
united = intersection_tool_union(tools)?;
|
||||
&united
|
||||
} else {
|
||||
tools
|
||||
};
|
||||
for tool in tools {
|
||||
let test = if op == Operation::Cut { BoolOp::Intersect } else { BoolOp::Cut };
|
||||
if kernel::boolean(body, tool, test)?.is_some() {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Selected profiles form one machining tool, including disconnected tool material.
|
||||
/// Balanced unions avoid repeatedly rebuilding a growing aggregate boundary.
|
||||
fn intersection_tool_union(tools: &[Body]) -> Result<Vec<Body>> {
|
||||
if tools.len() > 1000 || tools.iter().fold(0usize, |n, b| n.saturating_add(b.face_count())) > 16_384 {
|
||||
return Err(DocError::Invalid("intersection tools exceed the bounded geometry budget".into()));
|
||||
}
|
||||
let mut groups = tools.to_vec();
|
||||
while groups.len() > 1 {
|
||||
let mut next = Vec::new();
|
||||
let mut iter = groups.into_iter();
|
||||
while let Some(a) = iter.next() {
|
||||
match iter.next() {
|
||||
Some(b) => {
|
||||
next.push(kernel::boolean(&a, &b, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("intersection tool union is empty".into()))?)
|
||||
}
|
||||
None => next.push(a),
|
||||
}
|
||||
}
|
||||
groups = next;
|
||||
}
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation, targets: &[String]) -> Result<()> {
|
||||
let tools = if op == Operation::Intersect { intersection_tool_union(&tools)? } else { tools };
|
||||
// Participants: the feature's own targets, else its explicit participants, else the bodies
|
||||
// of its component (and what it made itself): other components are left alone.
|
||||
let targets: &[String] = if targets.is_empty() { &f.participants } else { targets };
|
||||
@@ -820,6 +904,13 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(op, Operation::Cut | Operation::Intersect) {
|
||||
for &i in &target_idx {
|
||||
if let Some(mb) = state.bodies.get(i).filter(|b| state.sheets.iter().any(|s| s.body == b.name)) {
|
||||
sheet_boolean_unchanged(&mb.body, &tools, op)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
match op {
|
||||
Operation::Join => {
|
||||
let Some(&first) = target_idx.first() else { return Ok(()) };
|
||||
@@ -859,6 +950,8 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation
|
||||
i += 1;
|
||||
keep
|
||||
});
|
||||
let live: std::collections::BTreeSet<&str> = state.bodies.iter().map(|b| b.name.as_str()).collect();
|
||||
state.threads.retain(|t| live.contains(t.body.as_str()));
|
||||
// A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)").
|
||||
let mut split: Vec<ModelBody> = Vec::new();
|
||||
for mb in std::mem::take(&mut state.bodies) {
|
||||
@@ -1382,8 +1475,57 @@ fn hole_tools(
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
/// The angular interval used by both the solid and its interactive extent handles.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct RevolveExtent {
|
||||
pub start: f64,
|
||||
pub sweep: f64,
|
||||
pub rotation_axis: Vec3,
|
||||
pub display_start: f64,
|
||||
/// A curved target trims the swept solid and has no single angular boundary.
|
||||
pub curved_to: bool,
|
||||
}
|
||||
|
||||
pub fn revolve_extent(
|
||||
st: &ModelState,
|
||||
plane: &Plane,
|
||||
regions: &[Region2],
|
||||
(origin, axis): (Vec3, Vec3),
|
||||
(angle, angle2): (f64, Option<f64>),
|
||||
symmetric: bool,
|
||||
to: Option<Vec3>,
|
||||
) -> Result<RevolveExtent> {
|
||||
let (start, sweep) = match (to, angle2, symmetric) {
|
||||
(Some(point), _, _) => {
|
||||
let points: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
|
||||
let centre = plane.to_world(points.iter().fold(Vec2::default(), |sum, point| sum + *point) * (1.0 / points.len().max(1) as f64));
|
||||
let flat = |point: Vec3| {
|
||||
let v = point - origin;
|
||||
v - axis * v.dot(axis)
|
||||
};
|
||||
let (u, v) = (flat(centre), flat(point));
|
||||
let sweep = (-axis).dot(u.cross(v)).atan2(u.dot(v));
|
||||
(0.0, if sweep <= 1e-9 { sweep + std::f64::consts::TAU } else { sweep })
|
||||
}
|
||||
(None, Some(other), _) => (-other, angle + other),
|
||||
(None, None, true) => (-angle / 2.0, angle),
|
||||
(None, None, false) => (0.0, angle),
|
||||
};
|
||||
if !(sweep.is_finite() && start.is_finite() && sweep.abs() > 1e-9 && sweep.abs() <= std::f64::consts::TAU + 1e-9) {
|
||||
return Err(DocError::Invalid("the revolve angle must be more than 0 and at most 360°".into()));
|
||||
}
|
||||
let points: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
|
||||
let centre = plane.to_world(points.iter().fold(Vec2::default(), |sum, point| sum + *point) * (1.0 / points.len().max(1) as f64));
|
||||
let unit_axis = axis.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))?;
|
||||
let radial_centre = regions.first().map(|region| plane.to_world(region.centroid())).unwrap_or(centre);
|
||||
let radial = radial_centre - origin - unit_axis * (radial_centre - origin).dot(unit_axis);
|
||||
let rotation_axis = if unit_axis.cross(radial).dot(plane.normal()) < 0.0 { -unit_axis } else { unit_axis };
|
||||
let display_start = start * -unit_axis.dot(rotation_axis);
|
||||
Ok(RevolveExtent { start, sweep, rotation_axis, display_start, curved_to: to.is_some_and(|point| curved_target_any(st, point).is_some()) })
|
||||
}
|
||||
|
||||
/// The tool bodies a feature adds or removes (extrude, revolve, primitives), in the current state.
|
||||
fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -> Result<Vec<Body>> {
|
||||
fn feature_tools(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -> Result<Vec<Body>> {
|
||||
match &f.kind {
|
||||
FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone();
|
||||
@@ -1486,41 +1628,36 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
|
||||
let start = if hi > 0.0 { lo + off } else { hi + off };
|
||||
tools = trim_to_body(tools, &tb.body, |q: Vec3| ((q - ss.plane.origin).dot(n) - start).abs())?;
|
||||
}
|
||||
if *operation == Operation::NewBody && extent.taper.is_none() {
|
||||
tools = tools
|
||||
.into_iter()
|
||||
.map(|body| {
|
||||
let curves: Vec<_> = body
|
||||
.face_sources()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|source| match source {
|
||||
Some(kernel::FaceSource::ExtrudeSide { segment }) => crate::config::exact_sketch_curve(&ss, segment),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
body.with_extrude_certificates(f.id, *sketch, &curves).map_err(DocError::from)
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
}
|
||||
Ok(tools)
|
||||
}
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, to, .. } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone();
|
||||
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let (o, d) = revolve_axis(&ss, axis)?;
|
||||
// Where the turn starts (from the profile) and how far it goes.
|
||||
let (start, ang) = match (to, angle2, symmetric) {
|
||||
(Some(p), _, _) => {
|
||||
let (ow, aw) = (ss.plane.to_world(o), ss.plane.dir_to_world(d));
|
||||
let pts: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
|
||||
let c = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, q| a + *q) * (1.0 / pts.len().max(1) as f64));
|
||||
let flat = |q: Vec3| {
|
||||
let v = q - ow;
|
||||
v - aw * v.dot(aw)
|
||||
};
|
||||
let (u, v) = (flat(c), flat(*p));
|
||||
// The revolve turns clockwise about its axis direction (the kernel's sense).
|
||||
let a = (aw * -1.0).dot(u.cross(v)).atan2(u.dot(v));
|
||||
let a = if a <= 1e-9 { a + std::f64::consts::TAU } else { a };
|
||||
(0.0, a)
|
||||
}
|
||||
(None, Some(a2), _) => {
|
||||
let (a, b) = (val(vals, angle, Kind::Angle)?, val(vals, a2, Kind::Angle)?);
|
||||
(-b, a + b)
|
||||
}
|
||||
(None, None, true) => {
|
||||
let a = val(vals, angle, Kind::Angle)?;
|
||||
(-a / 2.0, a)
|
||||
}
|
||||
(None, None, false) => (0.0, val(vals, angle, Kind::Angle)?),
|
||||
let angles = if to.is_some() {
|
||||
(0.0, None)
|
||||
} else {
|
||||
(val(vals, angle, Kind::Angle)?, angle2.as_ref().map(|a| val(vals, a, Kind::Angle)).transpose()?)
|
||||
};
|
||||
if !(ang.abs() > 1e-9 && ang.abs() <= std::f64::consts::TAU + 1e-9) {
|
||||
return Err(DocError::Invalid("the revolve angle must be more than 0 and at most 360°".into()));
|
||||
}
|
||||
let extent = revolve_extent(st, &ss.plane, ®ions, (ss.plane.to_world(o), ss.plane.dir_to_world(d)), angles, *symmetric, *to)?;
|
||||
let (start, ang) = (extent.start, extent.sweep);
|
||||
let (operation, targets) = feature_op(f);
|
||||
let axis_w = ss.plane.dir_to_world(d);
|
||||
let origin_w = ss.plane.to_world(o);
|
||||
@@ -1633,6 +1770,15 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
|
||||
let ps =
|
||||
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
|
||||
let profile;
|
||||
let ss = if !ps.sketch.wires.is_empty() {
|
||||
profile = profile_in_frame(doc, ss, f.component)?;
|
||||
&profile
|
||||
} else {
|
||||
ss
|
||||
};
|
||||
let mapped = path_in_frame(doc, ps, f.component)?;
|
||||
let ps = mapped.as_ref();
|
||||
let regions = solvecraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
|
||||
let start = regions.first().map(|r| ss.plane.to_world(r.centroid())).unwrap_or_default();
|
||||
// A path through 3D sketch curves follows them as points.
|
||||
@@ -1645,6 +1791,8 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
|
||||
FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => {
|
||||
let ps =
|
||||
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
|
||||
let mapped = path_in_frame(doc, ps, f.component)?;
|
||||
let ps = mapped.as_ref();
|
||||
let r = val(vals, diameter, Kind::Length)? / 2.0;
|
||||
if !(r > 1e-6) {
|
||||
return Err(DocError::Invalid("pipe diameter must be positive".into()));
|
||||
@@ -1709,6 +1857,51 @@ fn pipe_region(vals: &BTreeMap<String, Value>, r: f64, wall: &Option<String>) ->
|
||||
Ok(Region2 { outer: solvecraft_geom::Loop2::circle(Vec2::ZERO, r), holes })
|
||||
}
|
||||
|
||||
fn map_wire(wire: &mut solvecraft_sketch::Wire, matrix: &Mat) {
|
||||
for point in wire.pts.iter_mut().chain(&mut wire.fit) {
|
||||
*point = crate::apply_point(matrix, *point);
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_in_frame(doc: &Document, sketch: &SolvedSketch, consumer: u64) -> Result<SolvedSketch> {
|
||||
let owner = doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0);
|
||||
let inverse =
|
||||
crate::mat_inverse(&doc.component_transform(consumer)).ok_or_else(|| DocError::Invalid("profile consumer frame is invalid".into()))?;
|
||||
let relative = crate::mat_mul(&inverse, &doc.component_transform(owner));
|
||||
let mut mapped = sketch.clone();
|
||||
mapped.plane = Plane::new(
|
||||
crate::apply_point(&relative, sketch.plane.origin),
|
||||
crate::apply_vector(&relative, sketch.plane.x),
|
||||
crate::apply_vector(&relative, sketch.plane.y),
|
||||
)
|
||||
.ok_or_else(|| DocError::Invalid("profile sketch frame is invalid".into()))?;
|
||||
Ok(mapped)
|
||||
}
|
||||
|
||||
/// Stored wire coordinates are converted once into the feature consumer frame.
|
||||
fn path_in_frame<'a>(doc: &Document, sketch: &'a SolvedSketch, consumer: u64) -> Result<std::borrow::Cow<'a, SolvedSketch>> {
|
||||
use std::borrow::Cow;
|
||||
if sketch.sketch.wires.is_empty() {
|
||||
return Ok(Cow::Borrowed(sketch));
|
||||
}
|
||||
let owner = doc.feature(sketch.feature).map(|f| f.component).unwrap_or(0);
|
||||
let inverse =
|
||||
crate::mat_inverse(&doc.component_transform(consumer)).ok_or_else(|| DocError::Invalid("path consumer frame is not invertible".into()))?;
|
||||
let relative = crate::mat_mul(&inverse, &doc.component_transform(owner));
|
||||
let mut mapped = sketch.clone();
|
||||
mapped.plane = Plane::new(
|
||||
crate::apply_point(&relative, sketch.plane.origin),
|
||||
crate::apply_vector(&relative, sketch.plane.x),
|
||||
crate::apply_vector(&relative, sketch.plane.y),
|
||||
)
|
||||
.ok_or_else(|| DocError::Invalid("path sketch frame is invalid".into()))?;
|
||||
for wire in &mut mapped.sketch.wires {
|
||||
let matrix = if wire.space == solvecraft_sketch::WireSpace::Component { &relative } else { &inverse };
|
||||
map_wire(wire, matrix);
|
||||
}
|
||||
Ok(Cow::Owned(mapped))
|
||||
}
|
||||
|
||||
/// A path that uses 3D sketch curves (wires), as one chain of world points from the end nearest
|
||||
/// `start` (no start: the first curve runs toward the second). `None` when the path is all
|
||||
/// planar curves.
|
||||
@@ -1890,10 +2083,10 @@ const MAX_INSTANCES: usize = 1000;
|
||||
/// The placements (column-major matrices) of a pattern's copies, the original excluded.
|
||||
pub fn pattern_matrices(doc: &Document, st: &ModelState, p: &crate::PatternKind) -> Result<Vec<[[f64; 4]; 4]>> {
|
||||
let (vals, _) = doc.param_values();
|
||||
pattern_transforms(&vals, st, p)
|
||||
pattern_transforms(doc, &vals, st, 0, p)
|
||||
}
|
||||
|
||||
fn pattern_transforms(vals: &BTreeMap<String, Value>, st: &ModelState, p: &crate::PatternKind) -> Result<Vec<Mat>> {
|
||||
fn pattern_transforms(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, component: u64, p: &crate::PatternKind) -> Result<Vec<Mat>> {
|
||||
let count = |e: &str| -> Result<usize> {
|
||||
let n = val(vals, e, Kind::Unitless)?.round();
|
||||
if !(1.0..=MAX_INSTANCES as f64).contains(&n) {
|
||||
@@ -1928,6 +2121,8 @@ fn pattern_transforms(vals: &BTreeMap<String, Value>, st: &ModelState, p: &crate
|
||||
let n = count(c)?;
|
||||
let d = val(vals, spacing, Kind::Length)?;
|
||||
let ps = st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("path sketch {path_sketch}")))?;
|
||||
let mapped = path_in_frame(doc, ps, component)?;
|
||||
let ps = mapped.as_ref();
|
||||
let mut segs = path_segments(ps, path, Vec3::new(f64::NAN, f64::NAN, f64::NAN))?;
|
||||
if *flip {
|
||||
segs = segs.iter().rev().map(reverse_seg).collect();
|
||||
@@ -2093,7 +2288,7 @@ fn replay(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let tools = feature_tools(vals, src, st)?;
|
||||
let tools = feature_tools(doc, vals, src, st)?;
|
||||
for m in mats {
|
||||
for t in &tools {
|
||||
copies.push(kernel::transform_matrix(t, *m)?);
|
||||
@@ -2348,6 +2543,7 @@ fn chamfer_with(
|
||||
|
||||
fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
|
||||
match &f.kind {
|
||||
FeatureKind::BodyTransfer { bodies, from, to, transform } => crate::body_transfer::evaluate(doc, st, bodies, *from, *to, *transform),
|
||||
FeatureKind::SheetBase { .. }
|
||||
| FeatureKind::SheetContour { .. }
|
||||
| FeatureKind::SheetFlange { .. }
|
||||
@@ -2405,9 +2601,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
| FeatureKind::Loft { operation, targets, .. }
|
||||
| FeatureKind::Coil { operation, targets, .. }
|
||||
| FeatureKind::Sweep { operation, targets, .. } => {
|
||||
let tools = feature_tools(vals, f, st)?;
|
||||
let tools = feature_tools(doc, vals, f, st)?;
|
||||
// Cuts through sheet metal become cut-outs of its flat pattern.
|
||||
if *operation == Operation::Cut && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() {
|
||||
if matches!(operation, Operation::Cut | Operation::Intersect) && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() {
|
||||
let handled = more::sheet_cut(f, st, &tools, targets)?;
|
||||
if !handled.is_empty() {
|
||||
let effective = if targets.is_empty() { &f.participants } else { targets };
|
||||
@@ -2436,7 +2632,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
}
|
||||
FeatureKind::Hole { thread, diameter, .. } => {
|
||||
let (spots, dir) = hole_spots(st, f)?;
|
||||
let tools = feature_tools(vals, f, st)?;
|
||||
let tools = feature_tools(doc, vals, f, st)?;
|
||||
apply_op(st, f, tools, Operation::Cut, &[])?;
|
||||
if let Some(d) = thread {
|
||||
// A point on each hole's wall, a little below the entry.
|
||||
@@ -2637,7 +2833,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Pattern { features, pattern, bodies } => {
|
||||
let mats = pattern_transforms(vals, st, pattern)?;
|
||||
let mats = pattern_transforms(doc, vals, st, f.component, pattern)?;
|
||||
if bodies.is_empty() {
|
||||
return replay(doc, vals, f, st, features, &mats, warning);
|
||||
}
|
||||
@@ -2886,10 +3082,11 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
}
|
||||
FeatureKind::Import { step, .. } => {
|
||||
let imp = kernel::step_import_shared(step)?;
|
||||
let digest = crate::config::payload_digest(step);
|
||||
for (k, b) in imp.bodies.iter().enumerate() {
|
||||
let base = f.body_names.get(k).filter(|n| !n.trim().is_empty()).unwrap_or(&b.name);
|
||||
let name = unique_body_name(st, base);
|
||||
st.bodies.push(ModelBody::new(name, b.body.clone(), f.id));
|
||||
st.bodies.push(ModelBody::new(name, b.body.clone().with_import_certificate(&digest, f.id)?, f.id));
|
||||
}
|
||||
if !imp.warnings.is_empty() {
|
||||
let n = imp.warnings.len();
|
||||
@@ -2946,9 +3143,18 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram
|
||||
None => m,
|
||||
}
|
||||
};
|
||||
let body_m: Vec<Mat> = st.bodies.iter().map(|b| rel(doc.body_component(&b.name, b.feature), Some(&b.name))).collect();
|
||||
let body_m: Vec<Mat> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let m = rel(doc.model_body_component(b), None);
|
||||
doc.model_body_offset(b).map(|offset| crate::mat_mul(&m, offset)).unwrap_or(m)
|
||||
})
|
||||
.collect();
|
||||
let sketch_m: Vec<Mat> = st.sketches.iter().map(|s| rel(comp_of_feature(s.feature), None)).collect();
|
||||
if body_m.iter().chain(&sketch_m).all(crate::is_identity) {
|
||||
let world_wires_need_mapping = !crate::is_identity(&inv)
|
||||
&& st.sketches.iter().any(|sketch| sketch.sketch.wires.iter().any(|wire| wire.space == solvecraft_sketch::WireSpace::World));
|
||||
if body_m.iter().chain(&sketch_m).all(crate::is_identity) && !world_wires_need_mapping {
|
||||
return Cow::Borrowed(st);
|
||||
}
|
||||
let mut out = st.clone();
|
||||
@@ -2960,6 +3166,7 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram
|
||||
let names = crate::naming::face_names(b);
|
||||
let mut nb = ModelBody::new(b.name.clone(), t, b.feature);
|
||||
nb.component = b.component;
|
||||
nb.transferred = b.transferred;
|
||||
nb.source_name = b.source_name.clone();
|
||||
nb.source_transform = crate::mat_mul(m, &b.source_transform);
|
||||
nb.imported = b.imported.clone();
|
||||
@@ -2973,6 +3180,10 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram
|
||||
{
|
||||
s.plane = np;
|
||||
}
|
||||
for wire in &mut s.sketch.wires {
|
||||
let matrix = if wire.space == solvecraft_sketch::WireSpace::Component { m } else { &inv };
|
||||
map_wire(wire, matrix);
|
||||
}
|
||||
}
|
||||
Cow::Owned(out)
|
||||
}
|
||||
@@ -3002,16 +3213,17 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
|
||||
for (comp, path, m) in doc.placements() {
|
||||
let primary = path == first_path(comp);
|
||||
let ident = crate::is_identity(&m);
|
||||
for b in st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == comp) {
|
||||
for b in st.bodies.iter().filter(|b| doc.model_body_component(b) == comp) {
|
||||
let name = doc.world_body_name(st, &b.name, comp, &path);
|
||||
// A body moved in from another component keeps its place (its offset).
|
||||
let bm = doc.body_offsets.get(&b.name).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
|
||||
let bm = doc.model_body_offset(b).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
|
||||
let body =
|
||||
if crate::is_identity(&bm) { b.body.clone() } else { kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone()) };
|
||||
let mut placed = ModelBody::new(name, body, b.feature);
|
||||
placed.source_name = Some(b.definition_name().to_string());
|
||||
placed.source_transform = crate::mat_mul(&bm, &b.source_transform);
|
||||
placed.component = comp;
|
||||
placed.transferred = b.transferred;
|
||||
placed.imported = b.imported.clone();
|
||||
// Placement preserves topology: face ownership remains that of the
|
||||
// source model, including modifiers on component instances.
|
||||
@@ -3027,12 +3239,22 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
|
||||
s2.plane = np;
|
||||
}
|
||||
}
|
||||
for wire in &mut s2.sketch.wires {
|
||||
if wire.space == solvecraft_sketch::WireSpace::Component {
|
||||
map_wire(wire, &m);
|
||||
}
|
||||
}
|
||||
out.sketches.push(s2);
|
||||
}
|
||||
for t in st.threads.iter().filter(|t| comp_of_feature(t.feature) == comp) {
|
||||
for t in st
|
||||
.threads
|
||||
.iter()
|
||||
.filter(|t| st.body(&t.body).map(|body| doc.model_body_component(body)).unwrap_or_else(|| comp_of_feature(t.feature)) == comp)
|
||||
{
|
||||
let mut t2 = t.clone();
|
||||
t2.axis_point = crate::apply_point(&m, t.axis_point);
|
||||
t2.axis = crate::apply_vector(&m, t.axis);
|
||||
let frame = st.body(&t.body).and_then(|body| doc.model_body_offset(body)).map(|offset| crate::mat_mul(&m, offset)).unwrap_or(m);
|
||||
t2.axis_point = crate::apply_point(&frame, t.axis_point);
|
||||
t2.axis = crate::apply_vector(&frame, t.axis);
|
||||
out.threads.push(t2);
|
||||
}
|
||||
}
|
||||
|
||||
+122
-38
@@ -4,6 +4,7 @@
|
||||
use super::*;
|
||||
use crate::EmbossFace;
|
||||
use crate::apply_point;
|
||||
use crate::sheet;
|
||||
use solvecraft_geom::{Loop2, Seg2};
|
||||
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
|
||||
@@ -22,11 +23,11 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
let i =
|
||||
st.bodies.iter().position(|b| b.name == face.body).ok_or_else(|| DocError::Unknown(format!("emboss body `{}`", face.body)))?;
|
||||
let mb = st.bodies.get(i).cloned().ok_or_else(|| DocError::Invalid("emboss body".into()))?;
|
||||
let body_frame = face.body_frame(doc, mb.feature)?;
|
||||
let body_frame = face.body_frame(doc, &mb)?;
|
||||
let inverse = crate::mat_inverse(&body_frame).ok_or_else(|| DocError::Invalid("invalid cylinder body frame".into()))?;
|
||||
let sketch_owner = doc.feature(ss.feature).map(|f| f.component).unwrap_or(0);
|
||||
let author_frame = EmbossFace::component_frame(doc, f.component, &face.author_path)?;
|
||||
let sketch_frame = face.sketch_frame(doc, f.component, sketch_owner, mb.feature)?;
|
||||
let sketch_frame = face.sketch_frame(doc, f.component, sketch_owner, &mb)?;
|
||||
let sketch_transform = crate::mat_mul(&inverse, &sketch_frame);
|
||||
let plane = Plane::new(
|
||||
crate::apply_point(&sketch_transform, ss.plane.origin),
|
||||
@@ -289,12 +290,22 @@ fn point_on_polyline(pts: &[Vec2], d: f64) -> Vec2 {
|
||||
}
|
||||
|
||||
/// Rebuild a sheet body's solid from its model.
|
||||
fn replace_sheet_body(mb: &mut ModelBody, body: Body) {
|
||||
let mut next = ModelBody::new(mb.name.clone(), body, mb.feature);
|
||||
next.component = mb.component;
|
||||
next.transferred = mb.transferred;
|
||||
next.source_name = mb.source_name.clone();
|
||||
next.source_transform = mb.source_transform;
|
||||
next.imported = mb.imported.clone();
|
||||
*mb = next;
|
||||
}
|
||||
|
||||
fn rebuild_sheet(st: &mut ModelState, idx: usize) -> Result<()> {
|
||||
let sheet = st.sheets.get(idx).cloned().ok_or_else(|| DocError::Invalid("sheet".into()))?;
|
||||
let solid = sheet.solid()?;
|
||||
let i = st.bodies.iter().position(|b| b.name == sheet.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", sheet.body)))?;
|
||||
if let Some(mb) = st.bodies.get_mut(i) {
|
||||
*mb = ModelBody::new(mb.name.clone(), solid, mb.feature);
|
||||
replace_sheet_body(mb, solid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -513,7 +524,7 @@ pub(super) fn sheet_eval(
|
||||
sh.holes = region.holes.iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect();
|
||||
let solid = sh.solid()?;
|
||||
if let Some(b) = st.bodies.iter_mut().find(|b| &b.name == body) {
|
||||
*b = ModelBody::new(b.name.clone(), solid, b.feature);
|
||||
replace_sheet_body(b, solid);
|
||||
}
|
||||
st.sheets.retain(|s| &s.body != body);
|
||||
st.sheets.push(sh);
|
||||
@@ -528,14 +539,15 @@ fn poly_area(p: &[Vec2]) -> f64 {
|
||||
(0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::<f64>() / 2.0
|
||||
}
|
||||
|
||||
/// An extrude cut through sheet metal: the profiles become cut-outs of the flat pattern
|
||||
/// (parallel to a panel, through its thickness). Returns the sheet bodies handled.
|
||||
/// Exact prismatic machining, retained in sheet material/depth metadata.
|
||||
pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], targets: &[String]) -> Result<Vec<String>> {
|
||||
let FeatureKind::Extrude { sketch, profiles, .. } = &f.kind else { return Ok(Vec::new()) };
|
||||
let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) };
|
||||
if extent.taper.is_some() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) };
|
||||
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let n = ss.plane.normal();
|
||||
// How far the tools reach along the sketch normal.
|
||||
let mut span = (f64::MAX, f64::MIN);
|
||||
for t in tools {
|
||||
for p in t.tessellate((t.size() * 1e-3).max(1e-3))?.positions {
|
||||
@@ -543,51 +555,123 @@ pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], target
|
||||
span = (span.0.min(d), span.1.max(d));
|
||||
}
|
||||
}
|
||||
let mut handled = Vec::new();
|
||||
let mut overlap_work = crate::sheet::FlatWork::new();
|
||||
let targets = if targets.is_empty() { &f.participants } else { targets };
|
||||
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
|
||||
for i in 0..st.sheets.len() {
|
||||
let Some(sh) = st.sheets.get(i).cloned() else { continue };
|
||||
let mut plans = Vec::new();
|
||||
let mut work = sheet::FlatWork::new();
|
||||
for original in &st.sheets {
|
||||
let eligible = all
|
||||
|| if targets.is_empty() {
|
||||
st.bodies.iter().any(|b| b.name == sh.body && (b.component == f.component || b.feature == f.id))
|
||||
st.bodies.iter().any(|b| b.name == original.body && (b.component == f.component || b.feature == f.id))
|
||||
} else {
|
||||
targets.contains(&sh.body)
|
||||
targets.contains(&original.body)
|
||||
};
|
||||
if !eligible {
|
||||
continue;
|
||||
}
|
||||
let candidates: Vec<(Option<usize>, Region2)> = if sh.flat {
|
||||
if sh.frame.normal().cross(n).len() > 1e-6 { Vec::new() } else { sh.flat_regions()?.into_iter().map(|region| (None, region)).collect() }
|
||||
} else {
|
||||
sh.parallel_panels(&ss.plane)
|
||||
let Some(mb) = st.body(&original.body) else {
|
||||
continue;
|
||||
};
|
||||
let mut added = Vec::new();
|
||||
for (pi, panel) in candidates {
|
||||
// The panel's faces along the sketch normal must both be inside the cut.
|
||||
let w = sh.world(pi);
|
||||
let z0 = (apply_point(&w, Vec3::ZERO) - ss.plane.origin).dot(n);
|
||||
let z1 = (apply_point(&w, Vec3::Z * sh.t) - ss.plane.origin).dot(n);
|
||||
if z0.min(z1) < span.0 - 1e-6 || z0.max(z1) > span.1 + 1e-6 {
|
||||
continue;
|
||||
}
|
||||
for r in ®ions {
|
||||
let flat = sh.region_to_flat(pi, &ss.plane, r)?;
|
||||
let overlaps = crate::sheet::flat_regions_overlap(&panel, &flat, &mut overlap_work)?;
|
||||
if overlaps && !added.iter().any(|x: &Region2| x == &flat) {
|
||||
added.push(flat);
|
||||
let mut sh = original.clone();
|
||||
if *operation == Operation::Intersect && (!sh.flat && !sh.flanges.is_empty()) {
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
|
||||
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
|
||||
continue;
|
||||
}
|
||||
if !sh.flat && !sh.flanges.is_empty() && !super::sheet_boolean_changes(&mb.body, tools, *operation)? {
|
||||
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
|
||||
continue;
|
||||
}
|
||||
if let Some(bends) = sh.unmodeled_solid(&ss.plane)? {
|
||||
for tool in tools {
|
||||
if kernel::boolean(&bends, tool, kernel::BoolOp::Intersect)?.is_some() {
|
||||
return Err(DocError::Invalid(
|
||||
"this machining intersects a folded bend or a nonparallel panel; unfold the sheet before machining that material".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if added.is_empty() {
|
||||
continue;
|
||||
let candidates = if sh.flat {
|
||||
if sh.frame.normal().cross(n).len() > 1e-6 { Vec::new() } else { sh.flat_regions()?.into_iter().map(|r| (None, r)).collect() }
|
||||
} else {
|
||||
sh.material_panels(Some(&ss.plane), &mut work)?
|
||||
};
|
||||
let mut modeled = false;
|
||||
for (pi, panel) in candidates {
|
||||
let w = if sh.flat { sh.world(None) } else { sh.world(pi) };
|
||||
let zorigin = (apply_point(&w, Vec3::ZERO) - ss.plane.origin).dot(n);
|
||||
let sign = crate::apply_vector(&w, Vec3::Z).dot(n);
|
||||
if sign.abs() < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let (a, b) = ((span.0 - zorigin) / sign, (span.1 - zorigin) / sign);
|
||||
let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t));
|
||||
if *operation == Operation::Intersect {
|
||||
if z1 <= z0 {
|
||||
sh.holes.push(panel);
|
||||
modeled = true;
|
||||
continue;
|
||||
}
|
||||
if z0 > 1e-7 || z1 < sh.t - 1e-7 {
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
|
||||
continue;
|
||||
}
|
||||
let mapped = regions.iter().map(|r| sh.region_to_flat(pi, &ss.plane, r)).collect::<Result<Vec<_>>>()?;
|
||||
let tool = sheet::material_union(mapped, &mut work)?;
|
||||
let retained = sheet::material_boolean(std::slice::from_ref(&panel), &tool, kernel::BoolOp::Intersect, &mut work)?;
|
||||
let removed = sheet::material_boolean(&[panel], &retained, kernel::BoolOp::Cut, &mut work)?;
|
||||
sh.holes.extend(removed);
|
||||
modeled = true;
|
||||
} else if z1 > z0 {
|
||||
for r in ®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)
|
||||
}
|
||||
|
||||
+235
-2
@@ -28,13 +28,16 @@
|
||||
//! - 9: configured component/semantic-face styles, associative free-form offsets and
|
||||
//! explicit cylindrical emboss targets. Existing version 8 geometry and rules are
|
||||
//! preserved; new fields use defaults rather than guessing a target or an association.
|
||||
//! - 10: timeline body transfers, explicit 3D wire coordinate spaces and certified face
|
||||
//! bindings. Legacy ownership maps remain unchanged. Retained wire associations determine
|
||||
//! their space; saved coordinates are never rewritten during migration.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The format this build writes.
|
||||
pub const FORMAT: u32 = 9;
|
||||
pub const FORMAT: u32 = 10;
|
||||
|
||||
pub fn format_string(n: u32) -> String {
|
||||
format!("solvecraft/{n}")
|
||||
@@ -47,7 +50,7 @@ pub fn version_of(s: &str) -> Option<u32> {
|
||||
|
||||
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
|
||||
type JsonStep = fn(&mut Value) -> Result<()>;
|
||||
const JSON_STEPS: [JsonStep; 8] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(())];
|
||||
const JSON_STEPS: [JsonStep; 9] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(())];
|
||||
|
||||
/// Old XZ references retain their exact frame, including nested offset and angled planes.
|
||||
/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
|
||||
@@ -142,10 +145,29 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
crate::config::validate_styles_json(&v)?;
|
||||
validate_emboss_targets(&v)?;
|
||||
validate_freeform_offsets(&v)?;
|
||||
let legacy_wires = validate_wires(&v, version)?;
|
||||
validate_body_transfers(&v)?;
|
||||
for step in JSON_STEPS.iter().skip(version as usize - 1).take((FORMAT - version) as usize) {
|
||||
step(&mut v)?;
|
||||
}
|
||||
let mut d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
|
||||
let mut legacy_by_feature = std::collections::BTreeMap::<usize, Vec<usize>>::new();
|
||||
for (feature, wire) in legacy_wires {
|
||||
legacy_by_feature.entry(feature).or_default().push(wire);
|
||||
}
|
||||
for (feature_index, wire_indices) in legacy_by_feature {
|
||||
if let Some(crate::Feature { kind: crate::FeatureKind::Sketch { sketch, .. }, .. }) = d.features.get_mut(feature_index) {
|
||||
let mut spaces = std::collections::BTreeMap::new();
|
||||
for link in &sketch.links {
|
||||
spaces.entry(link.id.as_str()).or_insert_with(|| solvecraft_sketch::WireSpace::from_legacy_link(link));
|
||||
}
|
||||
for wire_index in wire_indices {
|
||||
if let Some(wire) = sketch.wires.get_mut(wire_index) {
|
||||
wire.space = wire.link.as_deref().and_then(|id| spaces.get(id)).copied().unwrap_or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if d.features.len() > crate::document::MAX_FEATURES
|
||||
|| d.params.len() > crate::document::MAX_PARAMS
|
||||
|| d.components.len() > crate::document::MAX_FEATURES
|
||||
@@ -157,9 +179,21 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name)));
|
||||
}
|
||||
}
|
||||
let component_ids: std::collections::BTreeSet<_> = d.components.iter().map(|component| component.id).chain(std::iter::once(0)).collect();
|
||||
for feature in &d.features {
|
||||
if let crate::FeatureKind::Sketch { sketch, .. } = &feature.kind {
|
||||
sketch.validate_freeform_offsets().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
|
||||
sketch.validate_wires().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
|
||||
}
|
||||
if let crate::FeatureKind::BodyTransfer { from, to, .. } = &feature.kind
|
||||
&& [from, to].into_iter().any(|id| !component_ids.contains(id))
|
||||
{
|
||||
return Err(DocError::Invalid("damaged design: body transfer references an unknown component".into()));
|
||||
}
|
||||
if let crate::FeatureKind::BodyTransfer { to, .. } = &feature.kind
|
||||
&& feature.component != *to
|
||||
{
|
||||
return Err(DocError::Invalid("damaged design: body transfer owner must match its destination".into()));
|
||||
}
|
||||
}
|
||||
// Fill in what older designs (and hand-edited files) may lack; a no-op otherwise.
|
||||
@@ -168,6 +202,112 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Bound arrays before typed allocation; only old files with absent markers need migration.
|
||||
fn validate_wires(value: &Value, version: u32) -> Result<Vec<(usize, usize)>> {
|
||||
let invalid = || DocError::Invalid("damaged design: 3D curve arrays, ids or coordinate spaces are invalid".into());
|
||||
let mut legacy = Vec::new();
|
||||
let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(legacy) };
|
||||
if features.len() > crate::document::MAX_FEATURES {
|
||||
return Err(invalid());
|
||||
}
|
||||
for (feature_index, feature) in features.iter().enumerate() {
|
||||
if feature.get("type").and_then(Value::as_str) != Some("sketch") {
|
||||
continue;
|
||||
}
|
||||
let Some(wires) = feature.get("sketch").and_then(|sketch| sketch.get("wires")) else { continue };
|
||||
let wires = wires.as_array().ok_or_else(invalid)?;
|
||||
if wires.len() > 20_000 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
let mut work = 0usize;
|
||||
for (wire_index, wire) in wires.iter().enumerate() {
|
||||
let id = wire.get("id").and_then(Value::as_str).ok_or_else(invalid)?;
|
||||
if id.is_empty() || id.len() > 512 || !ids.insert(id) {
|
||||
return Err(invalid());
|
||||
}
|
||||
if let Some(link) = wire.get("link").filter(|link| !link.is_null())
|
||||
&& link.as_str().is_none_or(|link| link.is_empty() || link.len() > 512)
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
match wire.get("space") {
|
||||
None if version < 10 => legacy.push((feature_index, wire_index)),
|
||||
None => {}
|
||||
Some(Value::String(space)) if matches!(space.as_str(), "world" | "component") => {}
|
||||
_ => return Err(invalid()),
|
||||
}
|
||||
let pts = wire.get("pts").and_then(Value::as_array).ok_or_else(invalid)?;
|
||||
let fit = match wire.get("fit") {
|
||||
None => &[][..],
|
||||
Some(fit) => fit.as_array().ok_or_else(invalid)?.as_slice(),
|
||||
};
|
||||
work = work.saturating_add(pts.len()).saturating_add(fit.len());
|
||||
if work > 1_000_000 || pts.is_empty() || pts.len() > 100_000 || fit.len() > 500 || (pts.len() == 1 && fit.len() != 1) {
|
||||
return Err(invalid());
|
||||
}
|
||||
for point in pts.iter().chain(fit) {
|
||||
let point = point.as_array().ok_or_else(invalid)?;
|
||||
if point.len() != 3 || point.iter().any(|coordinate| coordinate.as_f64().is_none_or(|n| !n.is_finite() || n.abs() >= MAX_REAL)) {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(legacy)
|
||||
}
|
||||
|
||||
fn validate_body_transfers(value: &Value) -> Result<()> {
|
||||
let invalid = || DocError::Invalid("damaged design: body transfer needs bounded unique bodies, components and a rigid placement".into());
|
||||
let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) };
|
||||
for feature in features {
|
||||
if feature.get("type").and_then(Value::as_str) != Some("body_transfer") {
|
||||
continue;
|
||||
}
|
||||
let bodies = feature.get("bodies").and_then(Value::as_array).ok_or_else(invalid)?;
|
||||
if bodies.is_empty() || bodies.len() > 1000 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut names = std::collections::BTreeSet::new();
|
||||
for body in bodies {
|
||||
let body = body.as_str().ok_or_else(invalid)?;
|
||||
if body.trim().is_empty() || body.len() > 512 || body.chars().any(char::is_control) || !names.insert(body) {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
for key in ["from", "to"] {
|
||||
if feature.get(key).and_then(Value::as_u64).is_none() {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
let owner = match feature.get("component") {
|
||||
None => 0,
|
||||
Some(value) => value.as_u64().ok_or_else(invalid)?,
|
||||
};
|
||||
if feature.get("to").and_then(Value::as_u64) != Some(owner) {
|
||||
return Err(DocError::Invalid("damaged design: body transfer owner must match its destination".into()));
|
||||
}
|
||||
let matrix = feature.get("transform").and_then(Value::as_array).ok_or_else(invalid)?;
|
||||
if matrix.len() != 4 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut placement = [[0.0; 4]; 4];
|
||||
for (out, column) in placement.iter_mut().zip(matrix) {
|
||||
let column = column.as_array().ok_or_else(invalid)?;
|
||||
if column.len() != 4 {
|
||||
return Err(invalid());
|
||||
}
|
||||
for (out, number) in out.iter_mut().zip(column) {
|
||||
*out = number.as_f64().ok_or_else(invalid)?;
|
||||
}
|
||||
}
|
||||
if !crate::body_transfer::valid_transfer_matrix(&placement) {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate new explicit targets before typed decoding. Older planar features have none.
|
||||
fn validate_emboss_targets(value: &Value) -> Result<()> {
|
||||
let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) };
|
||||
@@ -335,6 +475,99 @@ fn normalise(d: &mut Document) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_nine_wire_spaces_preserve_saved_points_and_explicit_markers() {
|
||||
use solvecraft_geom::Vec3;
|
||||
use solvecraft_sketch::{LinkKind, LinkSource, Sketch, WireSpace};
|
||||
let mut sketch = Sketch::new();
|
||||
sketch.add_drawn_wire(&[Vec3::new(12.0, 3.0, 4.0)]).unwrap();
|
||||
let link = sketch
|
||||
.add_link_with_wires(
|
||||
LinkKind::Include,
|
||||
LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO },
|
||||
&[],
|
||||
&[vec![Vec3::new(2.0, 7.0, 9.0), Vec3::new(2.0, 8.0, 9.0)]],
|
||||
)
|
||||
.unwrap();
|
||||
sketch.set_link_lost(&link, true);
|
||||
let mut doc = Document::new("legacy wire spaces");
|
||||
doc.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
|
||||
let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
json["format"] = Value::from("solvecraft/9");
|
||||
for wire in json["features"][0]["sketch"]["wires"].as_array_mut().unwrap() {
|
||||
wire.as_object_mut().unwrap().remove("space");
|
||||
}
|
||||
let old = json["features"][0]["sketch"]["wires"].clone();
|
||||
let migrated = read(&json.to_string()).unwrap();
|
||||
let crate::FeatureKind::Sketch { sketch, .. } = &migrated.features[0].kind else { panic!("sketch") };
|
||||
assert_eq!(sketch.wires[0].space, WireSpace::World);
|
||||
assert_eq!(sketch.wires[1].space, WireSpace::Component);
|
||||
let saved: Value = serde_json::from_str(&migrated.to_json()).unwrap();
|
||||
for (a, b) in old.as_array().unwrap().iter().zip(saved["features"][0]["sketch"]["wires"].as_array().unwrap()) {
|
||||
assert_eq!(a["pts"], b["pts"]);
|
||||
assert_eq!(a["fit"], b["fit"]);
|
||||
assert_eq!(a["link"], b["link"]);
|
||||
}
|
||||
assert_eq!(read(&migrated.to_json()).unwrap(), migrated);
|
||||
json["features"][0]["sketch"]["wires"][1]["space"] = Value::from("world");
|
||||
let explicit = read(&json.to_string()).unwrap();
|
||||
let crate::FeatureKind::Sketch { sketch, .. } = &explicit.features[0].kind else { panic!("sketch") };
|
||||
assert_eq!(sketch.wires[1].space, WireSpace::World);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_wire_and_transfer_shapes_are_refused_before_typed_decode() {
|
||||
let base: Value = serde_json::from_str(&Document::new("bounds").to_json()).unwrap();
|
||||
let wire = serde_json::json!({"id":"w1", "pts":[[1,2,3],[4,5,6]],"space":"world"});
|
||||
for damaged in [
|
||||
serde_json::json!({"space":"guess"}),
|
||||
serde_json::json!({"pts":[[1,2]]}),
|
||||
serde_json::json!({"pts":[[1e9,2,3],[4,5,6]]}),
|
||||
serde_json::json!({"fit": vec![[1,2,3];501]}),
|
||||
] {
|
||||
let mut changed = wire.clone();
|
||||
for (key, value) in damaged.as_object().unwrap() {
|
||||
changed[key] = value.clone();
|
||||
}
|
||||
let mut file = base.clone();
|
||||
file["features"] = serde_json::json!([{"type":"sketch","sketch":{"wires":[changed]}}]);
|
||||
assert!(validate_wires(&file, FORMAT).is_err());
|
||||
}
|
||||
let transfer = serde_json::json!({"type":"body_transfer","component":1,"bodies":["Body1"],"from":0,"to":1,"transform":crate::IDENTITY});
|
||||
for damaged in [
|
||||
serde_json::json!({"bodies":["Body1","Body1"]}),
|
||||
serde_json::json!({"from":-1}),
|
||||
serde_json::json!({"component":0}),
|
||||
serde_json::json!({"transform":[[2,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]}),
|
||||
serde_json::json!({"bodies":["\n"]}),
|
||||
] {
|
||||
let mut changed = transfer.clone();
|
||||
for (key, value) in damaged.as_object().unwrap() {
|
||||
changed[key] = value.clone();
|
||||
}
|
||||
let mut file = base.clone();
|
||||
file["features"] = serde_json::json!([changed]);
|
||||
assert!(validate_body_transfers(&file).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_transfer_roundtrips_and_unknown_component_is_refused() {
|
||||
let mut doc = Document::new("recorded ownership boundary");
|
||||
doc.add_feature(crate::FeatureKind::BodyTransfer { bodies: vec!["Body1".into()], from: 0, to: 0, transform: crate::IDENTITY }, None).unwrap();
|
||||
assert_eq!(read(&doc.to_json()).unwrap(), doc);
|
||||
let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
assert_eq!(json["features"][0]["type"], "body_transfer");
|
||||
json["features"][0]["to"] = Value::from(12345);
|
||||
json["features"][0]["component"] = Value::from(12345);
|
||||
assert!(read(&json.to_string()).unwrap_err().to_string().contains("unknown component"));
|
||||
json["features"][0]["to"] = Value::from(0);
|
||||
assert!(read(&json.to_string()).unwrap_err().to_string().contains("owner must match"));
|
||||
json["features"][0]["component"] = Value::from(0);
|
||||
json["features"][0]["transform"][0][0] = Value::from(2);
|
||||
assert!(read(&json.to_string()).unwrap_err().to_string().contains("rigid placement"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_eight_to_object_offset_keeps_existing_model_parameter_names() {
|
||||
use crate::{Direction, Extent, FeatureKind, Operation, PlaneRef, ProfileSel};
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
|
||||
pub mod appearance;
|
||||
mod assembly;
|
||||
mod body_transfer;
|
||||
mod body_transfer_sheet;
|
||||
pub use body_transfer::{valid_transfer_matrix, validate_body_transfer};
|
||||
pub mod canvas;
|
||||
mod clipboard;
|
||||
pub use clipboard::{clone_expression, remap_topology};
|
||||
@@ -29,8 +32,8 @@ pub mod sheet;
|
||||
pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse, mat_mul, rigid};
|
||||
pub use document::*;
|
||||
pub use eval::{
|
||||
EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, object_height, parse_metric_thread,
|
||||
pattern_matrices, state_in_frame, state_in_frame_at, through_all_distance, world_state,
|
||||
EdgeRef, FeatureResult, Model, ModelBody, ModelState, RevolveExtent, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, object_height,
|
||||
parse_metric_thread, pattern_matrices, revolve_extent, select_profiles, state_in_frame, state_in_frame_at, through_all_distance, world_state,
|
||||
};
|
||||
pub use params::{ParamRow, ParamUser, cycles};
|
||||
pub use solvecraft_kernel as kernel;
|
||||
|
||||
@@ -274,7 +274,8 @@ impl FeatureKind {
|
||||
| FeatureKind::Loft { .. }
|
||||
| FeatureKind::Sweep { .. }
|
||||
| FeatureKind::Import { .. }
|
||||
| FeatureKind::MeshImport { .. } => {}
|
||||
| FeatureKind::MeshImport { .. }
|
||||
| FeatureKind::BodyTransfer { .. } => {}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
+199
-16
@@ -83,24 +83,32 @@ fn planar_region(region: &Region2) -> Result<Region2> {
|
||||
}
|
||||
|
||||
fn region_complexity(regions: &[Region2]) -> Result<(usize, usize)> {
|
||||
if regions.len() > MAX_FLAT_PIECES {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
regions_complexity(regions.iter())
|
||||
}
|
||||
|
||||
fn regions_complexity<'a>(regions: impl Iterator<Item = &'a Region2>) -> Result<(usize, usize)> {
|
||||
let (mut edges, mut points) = (0usize, 0usize);
|
||||
for lp in regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)) {
|
||||
edges = edges.saturating_add(lp.segs.len());
|
||||
if edges > MAX_FLAT_POINTS {
|
||||
let mut pieces = 0usize;
|
||||
for region in regions {
|
||||
pieces += 1;
|
||||
if pieces > MAX_FLAT_PIECES || region.holes.len() > MAX_FLAT_PIECES {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
points = points.saturating_add(flat_polyline(lp)?.len());
|
||||
if points > MAX_FLAT_POINTS {
|
||||
return Err(flat_budget_error());
|
||||
for lp in std::iter::once(®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))
|
||||
}
|
||||
|
||||
fn material_boolean(a: &[Region2], b: &[Region2], op: kernel::BoolOp, work: &mut FlatWork) -> Result<Vec<Region2>> {
|
||||
pub(crate) fn material_boolean(a: &[Region2], b: &[Region2], op: kernel::BoolOp, work: &mut FlatWork) -> Result<Vec<Region2>> {
|
||||
let (ae, ap) = region_complexity(a)?;
|
||||
let (be, bp) = region_complexity(b)?;
|
||||
let edges = ae.saturating_add(be);
|
||||
@@ -112,7 +120,7 @@ fn material_boolean(a: &[Region2], b: &[Region2], op: kernel::BoolOp, work: &mut
|
||||
|
||||
// Merge in balanced rounds so hundreds of disjoint holes or adjoining panels do not
|
||||
// repeatedly arrange a growing accumulated boundary against just one small region.
|
||||
fn material_union(regions: Vec<Region2>, work: &mut FlatWork) -> Result<Vec<Region2>> {
|
||||
pub(crate) fn material_union(regions: Vec<Region2>, work: &mut FlatWork) -> Result<Vec<Region2>> {
|
||||
region_complexity(®ions)?;
|
||||
let mut groups: Vec<Vec<Region2>> = regions.into_iter().map(|r| vec![r]).collect();
|
||||
while groups.len() > 1 {
|
||||
@@ -290,6 +298,10 @@ pub struct SheetBody {
|
||||
pub flanges: Vec<SheetFlange>,
|
||||
/// Cut-outs (flat coordinates) through the sheet.
|
||||
pub holes: Vec<Region2>,
|
||||
/// Planar machining in flat coordinates, with depths measured from the flat bottom.
|
||||
/// Derived from feature history; these records are not a document schema change.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub pockets: Vec<SheetPocket>,
|
||||
/// Unfolded.
|
||||
pub flat: bool,
|
||||
/// Gap between flanges that meet at a corner.
|
||||
@@ -299,6 +311,13 @@ pub struct SheetBody {
|
||||
pub base_curves: Vec<(String, Vec2, Vec2)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct SheetPocket {
|
||||
pub region: Region2,
|
||||
pub z0: f64,
|
||||
pub z1: f64,
|
||||
}
|
||||
|
||||
/// A piece of the sheet: a panel (None = the base) or a flange's bend zone.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Piece {
|
||||
@@ -564,6 +583,7 @@ impl SheetBody {
|
||||
}
|
||||
cuts.push(region);
|
||||
}
|
||||
cuts.extend(self.through_pocket_regions(work)?);
|
||||
let cuts = material_union(cuts, work)?;
|
||||
let mut out = Vec::new();
|
||||
let mut points = 0usize;
|
||||
@@ -734,20 +754,148 @@ impl SheetBody {
|
||||
}
|
||||
let mut work = FlatWork::new();
|
||||
let cells = self.cells_with_work(&mut work)?;
|
||||
Ok(kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?)
|
||||
let body = kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?;
|
||||
self.machine_pockets(body, &mut work)
|
||||
}
|
||||
|
||||
/// The flat pattern as one extrusion (outline with its cut-outs).
|
||||
pub fn flat_solid(&self) -> Result<Body> {
|
||||
let regions = self.flat_regions()?;
|
||||
let pl = plane_of(&self.frame_mat(), Vec3::ZERO, Vec3::X, Vec3::Y)?;
|
||||
Ok(kernel::extrude_planar_regions(&pl, ®ions, 0.0, self.t)?)
|
||||
let body = kernel::extrude_planar_regions(&pl, ®ions, 0.0, self.t)?;
|
||||
self.machine_pockets(body, &mut FlatWork::new())
|
||||
}
|
||||
|
||||
/// Replay exact planar machining after forming the full-thickness material.
|
||||
fn machine_pockets(&self, mut body: Body, work: &mut FlatWork) -> Result<Body> {
|
||||
if self.pockets.len() > 1000 {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
regions_complexity(self.pockets.iter().map(|p| &p.region))?;
|
||||
let panels = if self.flat {
|
||||
vec![(None, self.flat_regions()?)]
|
||||
} else {
|
||||
self.material_panels(None, work)?.into_iter().map(|(pi, r)| (pi, vec![r])).collect()
|
||||
};
|
||||
for pocket in &self.pockets {
|
||||
if !pocket.z0.is_finite() || !pocket.z1.is_finite() || pocket.z0 < 0.0 || pocket.z1 > self.t || pocket.z0 >= pocket.z1 {
|
||||
return Err(DocError::Invalid("invalid sheet pocket depth".into()));
|
||||
}
|
||||
if !self.flat {
|
||||
for flange in &self.flanges {
|
||||
if flat_regions_overlap(&pocket.region, &self.zone(flange), work)? {
|
||||
return Err(DocError::Invalid(
|
||||
"a sheet pocket crosses a bend zone; planar pocket machining cannot yet preserve curved bend material".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (pi, panel) in &panels {
|
||||
let clipped = material_boolean(panel, std::slice::from_ref(&pocket.region), kernel::BoolOp::Intersect, work)?;
|
||||
if clipped.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let w = if self.flat { self.frame_mat() } else { self.world(*pi) };
|
||||
let plane = plane_of(&w, Vec3::ZERO, Vec3::X, Vec3::Y)?;
|
||||
// BRep replay is costlier than planar clipping; share the command budget.
|
||||
work.charge(100_000)?;
|
||||
let tool = kernel::extrude_planar_regions(&plane, &clipped, pocket.z0, pocket.z1)?;
|
||||
body = kernel::boolean(&body, &tool, kernel::BoolOp::Cut)?
|
||||
.ok_or_else(|| DocError::Invalid("sheet pocket removes all sheet material".into()))?;
|
||||
}
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Actual curved material, used to certify unsupported folded machining before mutation.
|
||||
pub(crate) fn unmodeled_solid(&self, plane: &Plane) -> Result<Option<Body>> {
|
||||
if self.flat || self.flanges.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut work = FlatWork::new();
|
||||
let cells: Vec<_> = self
|
||||
.cells_with_work(&mut work)?
|
||||
.into_iter()
|
||||
.filter(|c| match c.piece {
|
||||
Piece::Zone(_) => true,
|
||||
Piece::Panel(pi) => apply_vector(&self.world(pi), Vec3::Z).cross(plane.normal()).len() > 1e-6,
|
||||
})
|
||||
.collect();
|
||||
if cells.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?))
|
||||
}
|
||||
|
||||
fn through_pocket_regions(&self, work: &mut FlatWork) -> Result<Vec<Region2>> {
|
||||
if self.pockets.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if self.pockets.len() > 1000 {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
regions_complexity(self.pockets.iter().map(|p| &p.region))?;
|
||||
let mut levels = vec![0.0, self.t];
|
||||
for p in &self.pockets {
|
||||
if !p.z0.is_finite() || !p.z1.is_finite() || p.z0 < 0.0 || p.z1 > self.t || p.z0 >= p.z1 {
|
||||
return Err(DocError::Invalid("invalid sheet pocket depth".into()));
|
||||
}
|
||||
levels.extend([p.z0, p.z1]);
|
||||
}
|
||||
levels.sort_by(f64::total_cmp);
|
||||
levels.dedup();
|
||||
work.charge(levels.len().saturating_mul(self.pockets.len()))?;
|
||||
// Most pockets leave an untouched thickness layer. Certify that cheap case
|
||||
// before arranging hundreds of unrelated profiles at every depth level.
|
||||
if levels.windows(2).any(|zs| !self.pockets.iter().any(|p| p.z0 <= zs[0] && p.z1 >= zs[1])) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut through: Option<Vec<Region2>> = None;
|
||||
for zs in levels.windows(2) {
|
||||
let active: Vec<_> = self.pockets.iter().filter(|p| p.z0 <= zs[0] && p.z1 >= zs[1]).map(|p| p.region.clone()).collect();
|
||||
if active.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let removed = material_union(active, work)?;
|
||||
through = Some(match through {
|
||||
None => removed,
|
||||
Some(previous) => material_boolean(&previous, &removed, kernel::BoolOp::Intersect, work)?,
|
||||
});
|
||||
if through.as_ref().is_some_and(Vec::is_empty) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
}
|
||||
Ok(through.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Pocket operations clipped to the surviving flat material, with exact depth intervals.
|
||||
pub fn machining(&self) -> Result<Vec<SheetPocket>> {
|
||||
let material = self.flat_regions()?;
|
||||
let mut work = FlatWork::new();
|
||||
let mut out = Vec::new();
|
||||
for pocket in &self.pockets {
|
||||
for region in material_boolean(&material, std::slice::from_ref(&pocket.region), kernel::BoolOp::Intersect, &mut work)? {
|
||||
if out.len() >= 1000 {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
out.push(SheetPocket { region, z0: pocket.z0, z1: pocket.z1 });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Actual flat material boundaries after cut-outs. The same panel and cut regions build
|
||||
/// the folded solid; planar unions remove panel/bend seams and turn edge-crossing cuts
|
||||
/// into notches. Lines and circular arcs remain exact through clipping and merging.
|
||||
pub fn flat_regions(&self) -> Result<Vec<Region2>> {
|
||||
let regions = self.flat_material()?;
|
||||
if regions.is_empty() {
|
||||
return Err(DocError::Invalid("empty flat pattern".into()));
|
||||
}
|
||||
Ok(regions)
|
||||
}
|
||||
|
||||
pub(crate) fn flat_material(&self) -> Result<Vec<Region2>> {
|
||||
let mut work = FlatWork::new();
|
||||
// Merge structural pieces before cutting: clipping each panel first introduces
|
||||
// coincident notch endpoints at seams, while a single material cut has no seams.
|
||||
@@ -783,11 +931,9 @@ impl SheetBody {
|
||||
}
|
||||
cuts.push(region);
|
||||
}
|
||||
cuts.extend(self.through_pocket_regions(&mut work)?);
|
||||
let cuts = material_union(cuts, &mut work)?;
|
||||
let regions = if cuts.is_empty() { regions } else { material_boolean(®ions, &cuts, kernel::BoolOp::Cut, &mut work)? };
|
||||
if regions.is_empty() {
|
||||
return Err(DocError::Invalid("empty flat pattern".into()));
|
||||
}
|
||||
Ok(regions)
|
||||
}
|
||||
/// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out.
|
||||
@@ -1332,6 +1478,22 @@ impl SheetBody {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn material_panels(&self, plane: Option<&Plane>, work: &mut FlatWork) -> Result<Vec<(Option<usize>, Region2)>> {
|
||||
Ok(self
|
||||
.cells_with_work(work)?
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
let Piece::Panel(pi) = c.piece else {
|
||||
return None;
|
||||
};
|
||||
if plane.is_some_and(|p| apply_vector(&self.world(pi), Vec3::Z).cross(p.normal()).len() > 1e-6) {
|
||||
return None;
|
||||
}
|
||||
Some((pi, Region2 { outer: Loop2 { segs: c.outer }, holes: c.holes.into_iter().map(|segs| Loop2 { segs }).collect() }))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn point_in(poly: &[Vec2], p: Vec2) -> bool {
|
||||
@@ -1465,6 +1627,7 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region
|
||||
base: Region2 { outer: region.outer, holes: Vec::new() },
|
||||
flanges: Vec::new(),
|
||||
holes,
|
||||
pockets: Vec::new(),
|
||||
flat: false,
|
||||
gap: rv.gap,
|
||||
base_curves: Vec::new(),
|
||||
@@ -1516,6 +1679,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
|
||||
base: Region2 { outer: Loop2 { segs: Vec::new() }, holes: Vec::new() },
|
||||
flanges: Vec::new(),
|
||||
holes: Vec::new(),
|
||||
pockets: Vec::new(),
|
||||
flat: false,
|
||||
gap: rv.gap,
|
||||
base_curves: Vec::new(),
|
||||
@@ -1780,4 +1944,23 @@ mod tests {
|
||||
assert!(s.faces(&cells, 0.0, 2.5, &mut FlatWork::new()).unwrap_err().to_string().contains("budget"));
|
||||
assert!(s.faces(&cells[..1], 0.0, 2.5, &mut FlatWork(0)).unwrap_err().to_string().contains("budget"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pocket_geometry_and_depth_budgets_are_checked_before_replay() {
|
||||
let pocket = SheetPocket {
|
||||
region: rect(Vec2::new(20.0, 20.0), Vec2::new(30.0, 20.0), Vec2::new(30.0, 30.0), Vec2::new(20.0, 30.0)),
|
||||
z0: 0.0,
|
||||
z1: 1.0,
|
||||
};
|
||||
let mut s = plate(100.0, 60.0);
|
||||
s.pockets = vec![pocket.clone(); 1001];
|
||||
assert!(s.flat_solid().unwrap_err().to_string().contains("budget"));
|
||||
s.pockets = vec![pocket.clone()];
|
||||
s.pockets[0].region.outer.segs = vec![Seg2::Line { a: Vec2::ZERO, b: Vec2::X }; MAX_FLAT_POINTS + 1];
|
||||
assert!(s.flat_solid().unwrap_err().to_string().contains("budget"));
|
||||
for (z0, z1) in [(f64::NAN, 1.0), (-1.0, 1.0), (0.0, 3.0), (1.0, 1.0)] {
|
||||
s.pockets = vec![SheetPocket { z0, z1, ..pocket.clone() }];
|
||||
assert!(s.flat_solid().unwrap_err().to_string().contains("depth"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,7 +235,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let mut root_bodies: Vec<String> =
|
||||
s.model.state().bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == 0).map(|b| b.name.clone()).collect();
|
||||
s.model.state().bodies.iter().filter(|b| s.doc.model_body_component(b) == 0).map(|b| b.name.clone()).collect();
|
||||
root_bodies.extend(ancestor_bodies);
|
||||
s.visibility.hidden_occurrences = hidden;
|
||||
s.visibility.hidden_bodies = root_bodies;
|
||||
|
||||
@@ -13,6 +13,8 @@ use crate::{Result, Session};
|
||||
|
||||
#[path = "component_properties.rs"]
|
||||
mod properties;
|
||||
#[path = "component_transfer.rs"]
|
||||
mod transfer;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "occurrence_tests.rs"]
|
||||
@@ -133,25 +135,7 @@ fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.from_bodies";
|
||||
let bodies = string_list(p, "bodies");
|
||||
if bodies.is_empty() || bodies.len() > 1000 {
|
||||
return Err(bad(cmd, "`bodies` must list 1…1000 body names"));
|
||||
}
|
||||
let st = s.model.state();
|
||||
for b in &bodies {
|
||||
if st.body(b).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{b}`")));
|
||||
}
|
||||
}
|
||||
let parent = s.active_component;
|
||||
let mut made = Vec::new();
|
||||
for b in bodies {
|
||||
let id = s.doc_mut().add_component(Some(&b), parent)?;
|
||||
s.doc_mut().body_components.insert(b.clone(), id);
|
||||
made.push(json!({"component": id, "body": b}));
|
||||
}
|
||||
Ok(json!({ "components": made }))
|
||||
transfer::from_bodies(s, p)
|
||||
}
|
||||
|
||||
fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -176,7 +160,7 @@ fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let doc = &s.doc;
|
||||
let node = |id: u64, name: &str, parent: Option<u64>| {
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == id).map(|b| b.name.as_str()).collect();
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.model_body_component(b) == id).map(|b| b.name.as_str()).collect();
|
||||
let features: Vec<&str> = doc
|
||||
.features
|
||||
.iter()
|
||||
@@ -223,6 +207,15 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "the design itself can't be deleted"));
|
||||
}
|
||||
let gone: Vec<u64> = s.doc.components.iter().filter(|c| s.doc.component_within(c.id, id)).map(|c| c.id).collect();
|
||||
if s.doc.features.iter().any(|feature| {
|
||||
!gone.contains(&feature.component)
|
||||
&& matches!(&feature.kind, FeatureKind::BodyTransfer { from, to, .. } if gone.contains(from) || gone.contains(to))
|
||||
}) {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
"component supplies a recorded body transfer outside this subtree; remove that transfer history before deleting its source",
|
||||
));
|
||||
}
|
||||
let feats: Vec<u64> = s.doc.features.iter().filter(|f| gone.contains(&f.component)).map(|f| f.id).collect();
|
||||
let d = s.doc_mut();
|
||||
for f in &feats {
|
||||
@@ -241,36 +234,11 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.move_bodies";
|
||||
let to = component(s, p, "component", cmd)?;
|
||||
let bodies = string_list(p, "bodies");
|
||||
let st = s.model.state();
|
||||
if bodies.is_empty() || bodies.iter().any(|b| st.body(b).is_none()) {
|
||||
return Err(bad(cmd, "`bodies` must list existing bodies"));
|
||||
}
|
||||
for b in &bodies {
|
||||
let feature = st.body(b).map(|x| x.feature).unwrap_or(0);
|
||||
let own = s.doc.feature(feature).map(|f| f.component).unwrap_or(0);
|
||||
// The body keeps its place in the world: where it is shown now (its component's frame and
|
||||
// any earlier offset), seen from the new component's frame.
|
||||
let from = s.doc.body_component(b, feature);
|
||||
let now = solvecraft_doc::mat_mul(&s.doc.component_transform(from), s.doc.body_offsets.get(b).unwrap_or(&solvecraft_doc::IDENTITY));
|
||||
let offset = solvecraft_doc::mat_inverse(&s.doc.component_transform(to))
|
||||
.map(|inv| solvecraft_doc::mat_mul(&inv, &now))
|
||||
.unwrap_or(solvecraft_doc::IDENTITY);
|
||||
let doc = s.doc_mut();
|
||||
if own == to {
|
||||
doc.body_components.remove(b);
|
||||
} else {
|
||||
doc.body_components.insert(b.clone(), to);
|
||||
}
|
||||
if solvecraft_doc::is_identity(&offset) {
|
||||
doc.body_offsets.remove(b);
|
||||
} else {
|
||||
doc.body_offsets.insert(b.clone(), offset);
|
||||
}
|
||||
}
|
||||
Ok(json!({"bodies": bodies, "component": to}))
|
||||
transfer::move_bodies(s, p)
|
||||
}
|
||||
|
||||
pub(crate) fn transfer_constructor_result(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
transfer::move_result(s, p)
|
||||
}
|
||||
|
||||
fn move_sketches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -232,20 +232,29 @@ pub(super) fn paste(
|
||||
}
|
||||
// Body assignment may put externally authored geometry into this graph. Freeze it explicitly.
|
||||
let owned_features: BTreeSet<_> = feats.iter().map(|f| f.id).collect();
|
||||
for body in state.bodies.iter().filter(|b| selected.contains(&source.body_component(&b.name, b.feature)) && !owned_features.contains(&b.feature))
|
||||
{
|
||||
for body in state.bodies.iter().filter(|b| selected.contains(&source.model_body_component(b)) && !owned_features.contains(&b.feature)) {
|
||||
if body.body.is_mesh() {
|
||||
return Err(bad(CMD, "Paste New of a reassigned mesh body is not supported yet"));
|
||||
}
|
||||
let comp = source.body_component(&body.name, body.feature);
|
||||
let offset = source.body_offsets.get(&body.name).copied().unwrap_or(solvecraft_doc::IDENTITY);
|
||||
let offset = source.model_body_offset(body).copied().unwrap_or(solvecraft_doc::IDENTITY);
|
||||
// An external transfer establishes the local baseline in this graph.
|
||||
// Freeze AFTER that boundary, then replay it as an identity ownership event.
|
||||
let entry = feats.iter().find(|f| {
|
||||
!f.suppressed
|
||||
&& matches!(&f.kind, FeatureKind::BodyTransfer { bodies, from, to, .. }
|
||||
if !selected.contains(from) && bodies.contains(&body.name)
|
||||
&& s.model.result(f.id).is_some_and(|result| !result.skipped && result.error.is_none()
|
||||
&& result.state.body(&body.name).is_some_and(|entered| source.model_body_component(entered) == *to)))
|
||||
});
|
||||
let before = feats.iter().find_map(|f| {
|
||||
let before = s.model.state_before(f.id);
|
||||
let prior = before.body(&body.name)?;
|
||||
let after = s.model.result(f.id)?.state.body(&body.name);
|
||||
if after.is_none_or(|after| !prior.body.same(&after.body)) { Some(before) } else { None }
|
||||
});
|
||||
let base = before.as_ref().and_then(|st| st.body(&body.name)).unwrap_or(body);
|
||||
let entered = entry.and_then(|f| s.model.result(f.id)).map(|r| r.state.clone());
|
||||
let base = entered.as_ref().and_then(|st| st.body(&body.name)).or_else(|| before.as_ref().and_then(|st| st.body(&body.name))).unwrap_or(body);
|
||||
let baseline_component = source.model_body_component(base);
|
||||
let geometry = solvecraft_kernel::transform_matrix(&base.body, offset)?;
|
||||
let step = solvecraft_kernel::step_export(&[&geometry], "SolveCraft independent reassigned body")?;
|
||||
let new_name = unique(&body.name, &mut used);
|
||||
@@ -256,7 +265,7 @@ pub(super) fn paste(
|
||||
Some(&feature_name),
|
||||
)?;
|
||||
if let Some(f) = scratch.doc_mut().feature_mut(id) {
|
||||
f.component = *components.get(&comp).ok_or_else(|| bad(CMD, "missing assigned component"))?;
|
||||
f.component = *components.get(&baseline_component).ok_or_else(|| bad(CMD, "reassigned baseline is outside copied component graph"))?;
|
||||
f.body_names = vec![new_name];
|
||||
}
|
||||
new_ids.push(id);
|
||||
@@ -316,6 +325,15 @@ pub(super) fn paste(
|
||||
f.body_names = outputs.get(&old.id).cloned().unwrap_or_default();
|
||||
}
|
||||
f.remap_clone(&features, &names, &bodies, ¶meters)?;
|
||||
if let FeatureKind::BodyTransfer { from, to, transform, .. } = &mut f.kind {
|
||||
*to = *components.get(to).ok_or_else(|| bad(CMD, "transfer destination is outside the copied graph"))?;
|
||||
if let Some(mapped) = components.get(from) {
|
||||
*from = *mapped;
|
||||
} else {
|
||||
*from = *to;
|
||||
*transform = solvecraft_doc::IDENTITY;
|
||||
}
|
||||
}
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
|
||||
&& !face.path.is_empty()
|
||||
&& let FeatureKind::Emboss { face: Some(old_face), .. } = &old.kind
|
||||
@@ -361,7 +379,7 @@ pub(super) fn paste(
|
||||
let moved_out: Vec<_> = state
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| owned_features.contains(&b.feature) && !selected.contains(&source.body_component(&b.name, b.feature)))
|
||||
.filter(|b| owned_features.contains(&b.feature) && !selected.contains(&source.model_body_component(b)))
|
||||
.filter_map(|b| bodies.get(&b.name).cloned())
|
||||
.collect();
|
||||
if !moved_out.is_empty() {
|
||||
@@ -376,17 +394,24 @@ pub(super) fn paste(
|
||||
if let Some(new) = bodies.get(&face.body) {
|
||||
let mut face = face.clone();
|
||||
if frozen.contains(&face.body) {
|
||||
if face.strict_name {
|
||||
return Err(bad(CMD, "cannot copy strict face paint through uncertified foreign re-export"));
|
||||
}
|
||||
if let Some(name) = &face.name {
|
||||
face.point = solvecraft_doc::naming::point_on_face(&state, name, face.point)
|
||||
.map(|(point, _)| point)
|
||||
.ok_or_else(|| bad(CMD, "source frozen face appearance lost its identity"))?;
|
||||
}
|
||||
face.point = solvecraft_doc::apply_point(source.body_offsets.get(&face.body).unwrap_or(&solvecraft_doc::IDENTITY), face.point);
|
||||
let source_body = state.body(&face.body).ok_or_else(|| bad(CMD, "source frozen face appearance lost its body"))?;
|
||||
face.point = solvecraft_doc::apply_point(source.model_body_offset(source_body).unwrap_or(&solvecraft_doc::IDENTITY), face.point);
|
||||
face.name = None;
|
||||
} else if let Some(name) = &mut face.name {
|
||||
*name = solvecraft_doc::remap_topology(name, &features, &bodies);
|
||||
}
|
||||
face.body = new.clone();
|
||||
if let Some(certificate) = &mut face.certificate {
|
||||
certificate.remap_features(&features);
|
||||
}
|
||||
scratch.doc_mut().appearances.faces.push(face);
|
||||
}
|
||||
}
|
||||
@@ -519,9 +544,9 @@ pub(super) fn paste(
|
||||
scratch.doc_mut().configs.columns = all_columns;
|
||||
style_validation?;
|
||||
let mut proof_work = 0usize;
|
||||
let expected_count = state.bodies.iter().filter(|b| selected.contains(&source.body_component(&b.name, b.feature))).count();
|
||||
let expected_count = state.bodies.iter().filter(|b| selected.contains(&source.model_body_component(b))).count();
|
||||
let copied_components: BTreeSet<_> = components.values().copied().collect();
|
||||
let actual_count = copied.bodies.iter().filter(|b| copied_components.contains(&scratch.doc.body_component(&b.name, b.feature))).count();
|
||||
let actual_count = copied.bodies.iter().filter(|b| copied_components.contains(&scratch.doc.model_body_component(b))).count();
|
||||
if actual_count != expected_count {
|
||||
return Err(bad(CMD, "copy produced an incomplete or unexpected set of bodies"));
|
||||
}
|
||||
@@ -533,11 +558,11 @@ pub(super) fn paste(
|
||||
return Err(bad(CMD, "copy would modify a source body; external destructive dependencies are not supported"));
|
||||
}
|
||||
}
|
||||
for body in state.bodies.iter().filter(|b| selected.contains(&source.body_component(&b.name, b.feature))) {
|
||||
for body in state.bodies.iter().filter(|b| selected.contains(&source.model_body_component(b))) {
|
||||
let new = bodies.get(&body.name).ok_or_else(|| bad(CMD, format!("body {} has no clone identity", body.name)))?;
|
||||
let cloned = copied_bodies.get(new.as_str()).ok_or_else(|| bad(CMD, format!("copy did not reproduce body {}", body.name)))?;
|
||||
let a = solvecraft_kernel::transform_matrix(&body.body, *source.body_offsets.get(&body.name).unwrap_or(&solvecraft_doc::IDENTITY))?;
|
||||
let b = solvecraft_kernel::transform_matrix(&cloned.body, *scratch.doc.body_offsets.get(new).unwrap_or(&solvecraft_doc::IDENTITY))?;
|
||||
let a = solvecraft_kernel::transform_matrix(&body.body, *source.model_body_offset(body).unwrap_or(&solvecraft_doc::IDENTITY))?;
|
||||
let b = solvecraft_kernel::transform_matrix(&cloned.body, *scratch.doc.model_body_offset(cloned).unwrap_or(&solvecraft_doc::IDENTITY))?;
|
||||
let ma = solvecraft_kernel::measure(&a)?;
|
||||
let mb = solvecraft_kernel::measure(&b)?;
|
||||
let tolerance = 1e-5 * (1.0 + (ma.bbox.max - ma.bbox.min).len()) + 64.0 * f64::EPSILON * (ma.bbox.min.len() + ma.bbox.max.len());
|
||||
|
||||
@@ -238,6 +238,7 @@ fn configuration_rows_follow_copied_parameters_material_and_strict_face_lineage(
|
||||
origin_feature: source,
|
||||
name: format!("F{source}:+z"),
|
||||
point: solvecraft_geom::Vec3::new(1.0, 1.0, 2.0),
|
||||
certificate: None,
|
||||
};
|
||||
run(&mut s, "config.configure", json!({"params":[length],"materials":["Cube"],"component_appearances":[part],"face_appearances":[target]}));
|
||||
let face_key = s.doc.config_key(&solvecraft_doc::config::Column::FaceAppearance(target.clone()));
|
||||
@@ -275,6 +276,7 @@ fn configured_extrude_cap_copy_keeps_independent_semantic_lineage() {
|
||||
origin_feature: feature,
|
||||
name: format!("F{feature}:top"),
|
||||
point: solvecraft_geom::Vec3::new(1.0, 1.0, 2.0),
|
||||
certificate: None,
|
||||
};
|
||||
run(&mut s, "config.configure", json!({"face_appearances":[target]}));
|
||||
let key = s.doc.config_key(&solvecraft_doc::config::Column::FaceAppearance(target));
|
||||
@@ -391,3 +393,75 @@ fn generated_copy_parameters_cannot_heal_an_unrelated_missing_reference() {
|
||||
assert_eq!(s.selection, selection);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_transfer_entry_and_internal_rotated_transfer_clone_once() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":4,"width":3,"height":2,"body_name":"Foreign"}));
|
||||
let parent = component(&mut s, "Assembly");
|
||||
let a = component(&mut s, "A");
|
||||
let ao = s.doc.occurrence_of(a).unwrap().id;
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":ao,"translate":[10,0,0],"angle":90,"axis":[0,0,1],"capture":true}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":a,"bodies":["Foreign"]}));
|
||||
run(&mut s, "component.activate", json!({"component":parent}));
|
||||
let b = component(&mut s, "B");
|
||||
let bo = s.doc.occurrence_of(b).unwrap().id;
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":bo,"translate":[0,20,0],"angle":90,"axis":[1,0,0],"capture":true}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":b,"bodies":["Foreign"]}));
|
||||
run(&mut s, "solid.move", json!({"bodies":["Foreign"],"translate":[0,0,5]}));
|
||||
let before = s.model.state().body("Foreign").unwrap().mesh().bounds();
|
||||
let copied = copy(&mut s, parent);
|
||||
assert_eq!(copied["frozen_bodies"], json!(["Foreign"]));
|
||||
let state = s.model.state();
|
||||
let body = state.body("Foreign (copy)").unwrap();
|
||||
let after = body.mesh().bounds();
|
||||
assert!(before.min.dist(after.min) < 1e-6);
|
||||
assert!(before.max.dist(after.max) < 1e-6);
|
||||
assert!(body.transferred);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!(reopened.model.state().body("Foreign (copy)").unwrap().mesh().bounds().min.dist(before.min) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suppressed_external_entry_does_not_supply_clone_baseline() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":4,"width":3,"height":2,"body_name":"Foreign"}));
|
||||
let part = component(&mut s, "Part");
|
||||
let occurrence = s.doc.occurrence_of(part).unwrap().id;
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[25,0,0],"angle":90,"axis":[0,0,1],"capture":true}));
|
||||
let skipped = s
|
||||
.doc_mut()
|
||||
.add_feature(
|
||||
solvecraft_doc::FeatureKind::BodyTransfer { bodies: vec!["Foreign".into()], from: 0, to: part, transform: solvecraft_doc::IDENTITY },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
s.doc_mut().feature_mut(skipped).unwrap().suppressed = true;
|
||||
s.refresh();
|
||||
run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Foreign"]}));
|
||||
let before = s.model.state().body("Foreign").unwrap().mesh().bounds();
|
||||
copy(&mut s, part);
|
||||
let after = s.model.state().body("Foreign (copy)").unwrap().mesh().bounds();
|
||||
assert!(before.min.dist(after.min) < 1e-6);
|
||||
assert!(before.max.dist(after.max) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transferred_frozen_face_paint_ignores_consumed_legacy_offset() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Foreign"}));
|
||||
run(&mut s, "appearance.assign", json!({"body":"Foreign","faces":[[1,1,2]],"color":"#00ff00"}));
|
||||
s.doc_mut().body_offsets.insert(
|
||||
"Foreign".into(),
|
||||
solvecraft_doc::rigid(solvecraft_geom::Vec3::new(7.0, 0.0, 0.0), solvecraft_geom::Vec3::ZERO, solvecraft_geom::Vec3::Z, 0.0),
|
||||
);
|
||||
s.refresh();
|
||||
let part = component(&mut s, "Part");
|
||||
run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Foreign"]}));
|
||||
let original = s.doc.appearances.faces.iter().find(|face| face.body == "Foreign").unwrap();
|
||||
let expected = solvecraft_doc::naming::point_on_face(&s.model.state(), original.name.as_ref().unwrap(), original.point).unwrap().0;
|
||||
copy(&mut s, part);
|
||||
let paint = s.doc.appearances.faces.iter().find(|face| face.body == "Foreign (copy)").unwrap();
|
||||
assert!(paint.point.dist(expected) < 1e-6, "{:?} versus {expected:?}", paint.point);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ fn placed_doc(s: &Session) -> Document {
|
||||
|
||||
/// A property total must cover the entire assembly. The display traversal intentionally
|
||||
/// truncates hostile designs, so use an explicit error instead of accepting its prefix.
|
||||
fn placements(doc: &Document, cmd: &str) -> Result<Vec<(u64, Vec<u64>, Mat)>> {
|
||||
pub(super) fn placements(doc: &Document, cmd: &str) -> Result<Vec<(u64, Vec<u64>, Mat)>> {
|
||||
let mut out = vec![(0, Vec::new(), solvecraft_doc::IDENTITY)];
|
||||
let mut stack = vec![(0, Vec::<u64>::new(), solvecraft_doc::IDENTITY)];
|
||||
while let Some((component, path, transform)) = stack.pop() {
|
||||
@@ -112,8 +112,8 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
|
||||
};
|
||||
for (component, path, transform) in selected_placements {
|
||||
placement_count += 1;
|
||||
for body in state.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == component) {
|
||||
let transform: Mat = doc.body_offsets.get(&body.name).map(|offset| solvecraft_doc::mat_mul(&transform, offset)).unwrap_or(transform);
|
||||
for body in state.bodies.iter().filter(|b| doc.model_body_component(b) == component) {
|
||||
let transform: Mat = doc.model_body_offset(body).map(|offset| solvecraft_doc::mat_mul(&transform, offset)).unwrap_or(transform);
|
||||
let placed = solvecraft_kernel::transform_matrix(&body.body, transform)?;
|
||||
let measured = solvecraft_kernel::measure(&placed)?;
|
||||
let density = doc.density(&body.name);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Atomic timeline ownership changes, preserving the selected world geometry.
|
||||
use super::*;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
type UniqueFrames = BTreeMap<u64, Option<(Vec<u64>, Mat)>>;
|
||||
|
||||
fn unique_frames(s: &Session, cmd: &str) -> Result<UniqueFrames> {
|
||||
let doc = s.placed_document();
|
||||
let mut children = BTreeMap::<u64, Vec<&Occurrence>>::new();
|
||||
for occurrence in &doc.occurrences {
|
||||
children.entry(occurrence.parent).or_default().push(occurrence);
|
||||
}
|
||||
let mut frames = BTreeMap::from([(0, Some((Vec::new(), solvecraft_doc::IDENTITY)))]);
|
||||
let mut stack = vec![(0, Vec::new(), solvecraft_doc::IDENTITY)];
|
||||
let mut work = 0usize;
|
||||
while let Some((component, path, matrix)) = stack.pop() {
|
||||
for occurrence in children.get(&component).into_iter().flatten() {
|
||||
work += 1;
|
||||
if path.len() >= 64 || work > 100_000 {
|
||||
return Err(bad(cmd, "transfer assembly exceeds 64 levels or 100000 placements"));
|
||||
}
|
||||
let mut child_path = path.clone();
|
||||
child_path.push(occurrence.id);
|
||||
let frame = solvecraft_doc::mat_mul(&matrix, &occurrence.transform);
|
||||
frames.entry(occurrence.component).and_modify(|entry| *entry = None).or_insert_with(|| Some((child_path.clone(), frame)));
|
||||
stack.push((occurrence.component, child_path, frame));
|
||||
}
|
||||
}
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
fn body_names(s: &Session, p: &Value, cmd: &str, allow_boundary: bool) -> Result<Vec<String>> {
|
||||
if p.get("bodies").and_then(Value::as_array).is_none_or(|names| names.iter().any(|name| !name.is_string())) {
|
||||
return Err(bad(cmd, "bodies must be an array of body names"));
|
||||
}
|
||||
let names = string_list(p, "bodies");
|
||||
if names.is_empty() || names.len() > 1000 || names.iter().collect::<BTreeSet<_>>().len() != names.len() {
|
||||
return Err(bad(cmd, "bodies must list 1..1000 distinct existing definition bodies"));
|
||||
}
|
||||
if s.doc.marker.is_some() && !allow_boundary {
|
||||
return Err(bad(cmd, "finish timeline rollback before transferring bodies"));
|
||||
}
|
||||
let state = s.model.state();
|
||||
for name in &names {
|
||||
if state.body(name).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{name}`")));
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
fn transfer(s: &mut Session, name: &str, to: u64, frames: &UniqueFrames, cmd: &str) -> Result<u64> {
|
||||
let state = s.model.state();
|
||||
let body = state.body(name).ok_or_else(|| bad(cmd, "body is missing"))?;
|
||||
let from = s.doc.model_body_component(body);
|
||||
let doc = s.placed_document();
|
||||
let unique = |component| {
|
||||
frames.get(&component).and_then(Option::as_ref).ok_or_else(|| {
|
||||
bad(cmd, "body transfer requires one source and destination placement; shared instances cannot preserve a unique world pose")
|
||||
})
|
||||
};
|
||||
let source = unique(from)?;
|
||||
let destination = unique(to)?;
|
||||
let old = doc.model_body_offset(body).map(|offset| solvecraft_doc::mat_mul(&source.1, offset)).unwrap_or(source.1);
|
||||
let inv = solvecraft_doc::mat_inverse(&destination.1).ok_or_else(|| bad(cmd, "destination frame is not invertible"))?;
|
||||
let transform = solvecraft_doc::mat_mul(&inv, &old);
|
||||
if !solvecraft_doc::valid_transfer_matrix(&transform) {
|
||||
return Err(bad(cmd, "body transfer exceeds supported rigid coordinate range"));
|
||||
}
|
||||
let inherited = (!s.doc.appearances.bodies.contains_key(name)).then(|| s.doc.appearances.components.get(&from).cloned()).flatten();
|
||||
let paths: BTreeSet<u64> = source.0.iter().chain(&destination.0).copied().collect();
|
||||
let captures: Vec<_> = paths.into_iter().filter_map(|id| s.pending_moves.get(&id).map(|transform| (id, *transform))).collect();
|
||||
if captures.iter().any(|(_, transform)| !solvecraft_doc::valid_transfer_matrix(transform)) {
|
||||
return Err(bad(cmd, "pending transfer position is not a supported rigid transform"));
|
||||
}
|
||||
drop(doc);
|
||||
for (id, transform) in captures {
|
||||
if let Some(occurrence) = s.doc_mut().occurrences.iter_mut().find(|occurrence| occurrence.id == id) {
|
||||
occurrence.transform = transform;
|
||||
}
|
||||
s.pending_moves.remove(&id);
|
||||
}
|
||||
if let Some(look) = inherited {
|
||||
s.doc_mut().appearances.bodies.insert(name.to_string(), look);
|
||||
}
|
||||
let id = s.doc_mut().add_feature(FeatureKind::BodyTransfer { bodies: vec![name.to_string()], from, to, transform }, None)?;
|
||||
if let Some(feature) = s.doc_mut().feature_mut(id) {
|
||||
feature.component = to;
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn finish(s: &mut Session, scratch: &mut Session, features: &[u64], cmd: &str) -> Result<()> {
|
||||
scratch.refresh();
|
||||
for id in features {
|
||||
if let Some(error) = scratch.model.result(*id).and_then(|result| result.error.as_ref()) {
|
||||
return Err(bad(cmd, error));
|
||||
}
|
||||
}
|
||||
s.accept_computed(std::mem::take(scratch));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.from_bodies";
|
||||
let bodies = body_names(s, p, cmd, false)?;
|
||||
let mut scratch = s.scratch();
|
||||
let parent = s.active_component;
|
||||
let mut made = Vec::new();
|
||||
let mut targets = Vec::new();
|
||||
for name in &bodies {
|
||||
let component = scratch.doc_mut().add_component(Some(name), parent)?;
|
||||
targets.push(component);
|
||||
}
|
||||
let frames = unique_frames(&scratch, cmd)?;
|
||||
let mut features = Vec::new();
|
||||
for (name, component) in bodies.into_iter().zip(targets) {
|
||||
let feature = transfer(&mut scratch, &name, component, &frames, cmd)?;
|
||||
features.push(feature);
|
||||
made.push(json!({"component":component,"body":name,"feature":feature}));
|
||||
}
|
||||
finish(s, &mut scratch, &features, cmd)?;
|
||||
Ok(json!({"components":made}))
|
||||
}
|
||||
|
||||
pub(super) fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
move_at_boundary(s, p, false)
|
||||
}
|
||||
|
||||
/// A solid constructor can attach its result immediately after the new feature,
|
||||
/// including inside the scratch timeline used by Edit Feature.
|
||||
pub(super) fn move_result(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
move_at_boundary(s, p, true)
|
||||
}
|
||||
|
||||
fn move_at_boundary(s: &mut Session, p: &Value, allow_boundary: bool) -> Result<Value> {
|
||||
let cmd = "component.move_bodies";
|
||||
let to = super::component(s, p, "component", cmd)?;
|
||||
let bodies = body_names(s, p, cmd, allow_boundary)?;
|
||||
let mut scratch = s.scratch();
|
||||
let frames = unique_frames(&scratch, cmd)?;
|
||||
let mut features = Vec::new();
|
||||
for name in &bodies {
|
||||
features.push(transfer(&mut scratch, name, to, &frames, cmd)?);
|
||||
}
|
||||
finish(s, &mut scratch, &features, cmd)?;
|
||||
Ok(json!({"bodies":bodies,"component":to,"features":features}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "component_transfer_tests.rs"]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,287 @@
|
||||
use super::*;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_assignment_preserves_sample_joined_material_and_timeline() {
|
||||
let mut s = Session::default();
|
||||
s.run_script(&crate::sample::script()).unwrap();
|
||||
let before = s.model.state();
|
||||
assert_eq!(before.bodies.len(), 1);
|
||||
let volume = solvecraft_kernel::measure(&before.body("Plate").unwrap().body).unwrap().volume;
|
||||
let created = run(&mut s, "component.from_bodies", json!({"bodies":["Plate"]}));
|
||||
let to = created["components"][0]["component"].as_u64().unwrap();
|
||||
let feature = created["components"][0]["feature"].as_u64().unwrap();
|
||||
let after = s.model.state();
|
||||
assert_eq!(after.bodies.len(), 1);
|
||||
let body = after.body("Plate").unwrap();
|
||||
assert_eq!(body.component, to);
|
||||
assert!(body.transferred);
|
||||
assert!((solvecraft_kernel::measure(&body.body).unwrap().volume - volume).abs() < 1e-6);
|
||||
assert!(body.mesh().contains(Vec3::new(40.0, 35.0, 12.0)));
|
||||
let index = s.doc.feature_index(feature).unwrap();
|
||||
s.doc_mut().marker = Some(index);
|
||||
s.refresh();
|
||||
assert_eq!(s.world_state().body("Plate").unwrap().component, 0);
|
||||
s.doc_mut().marker = None;
|
||||
s.refresh();
|
||||
assert_eq!(s.world_state().body("Plate").unwrap().component, to);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transformed_pending_destination_repeated_transfer_preserves_world_pose() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
let created = run(&mut s, "component.create", json!({"name":"Destination"}));
|
||||
let to = created["component"].as_u64().unwrap();
|
||||
let occurrence = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,0],"angle":90,"axis":[0,0,1],"capture":false}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":to,"bodies":["Block"]}));
|
||||
let state = s.world_state();
|
||||
let bounds = state.body("Block").unwrap().mesh().bounds();
|
||||
assert!(bounds.min.dist(Vec3::ZERO) < 1e-6);
|
||||
assert!(bounds.max.dist(Vec3::new(10.0, 10.0, 10.0)) < 1e-6);
|
||||
assert!(!s.pending_moves.contains_key(&occurrence));
|
||||
let mut destination_reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
destination_reopened.refresh();
|
||||
assert!(destination_reopened.world_state().body("Block").unwrap().mesh().bounds().min.dist(Vec3::ZERO) < 1e-6);
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(Vec3::ZERO) < 1e-6);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!(reopened.world_state().body("Block").unwrap().mesh().bounds().min.dist(Vec3::ZERO) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_destination_refuses_without_mutating_document_or_session() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
let c = run(&mut s, "component.create", json!({"name":"Shared"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.copy", json!({"component":c,"translate":[20,0,0]}));
|
||||
let document = s.doc.to_json();
|
||||
let active = s.active_component;
|
||||
assert!(s.execute("component.move_bodies", &json!({"component":c,"bodies":["Block"]})).is_err());
|
||||
assert_eq!(s.doc.to_json(), document);
|
||||
assert_eq!(s.active_component, active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_join_cut_and_new_destination_modeling_keep_material() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
run(&mut s, "solid.box", json!({"corner":[8,0,0],"length":10,"width":10,"height":10,"operation":"join"}));
|
||||
run(&mut s, "solid.box", json!({"corner":[2,2,0],"length":2,"width":2,"height":10,"operation":"cut"}));
|
||||
let original = s.model.state();
|
||||
assert_eq!(original.bodies.len(), 1);
|
||||
let v = solvecraft_kernel::measure(&original.body("Block").unwrap().body).unwrap().volume;
|
||||
assert!((v - 1760.0).abs() < 1e-6);
|
||||
run(&mut s, "material.assign", json!({"bodies":["Block"],"material":"Brass"}));
|
||||
run(&mut s, "appearance.assign", json!({"bodies":["Block"],"color":"#bc8537"}));
|
||||
let created = run(&mut s, "component.from_bodies", json!({"bodies":["Block"]}));
|
||||
let c = created["components"][0]["component"].as_u64().unwrap();
|
||||
assert_eq!(s.doc.materials["Block"], "Brass");
|
||||
assert_eq!(s.doc.appearances.bodies["Block"].color, [188, 133, 55]);
|
||||
run(&mut s, "component.activate", json!({"component":c}));
|
||||
run(&mut s, "solid.box", json!({"corner":[18,0,0],"length":2,"width":10,"height":10,"operation":"join"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
assert_eq!(s.model.state().body("Block").unwrap().component, c);
|
||||
let final_volume = solvecraft_kernel::measure(&s.model.state().body("Block").unwrap().body).unwrap().volume;
|
||||
assert!((final_volume - 1960.0).abs() < 1e-6);
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
assert_eq!(s.model.state().body("Block").unwrap().component, 0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.model.state().body("Block").unwrap().component, c);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!((solvecraft_kernel::measure(&reopened.model.state().body("Block").unwrap().body).unwrap().volume - final_volume).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ownership_only_boundary_invalidates_old_boolean_cache() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
run(&mut s, "solid.box", json!({"corner":[8,0,0],"length":10,"width":10,"height":10,"operation":"join"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
let c = s.doc_mut().add_component(Some("Destination"), 0).unwrap();
|
||||
let id = s
|
||||
.doc_mut()
|
||||
.add_feature(FeatureKind::BodyTransfer { bodies: vec!["Block".into()], from: 0, to: c, transform: solvecraft_doc::IDENTITY }, None)
|
||||
.unwrap();
|
||||
let index = s.doc.feature_index(id).unwrap();
|
||||
let transfer = s.doc_mut().features.remove(index);
|
||||
s.doc_mut().features.insert(1, transfer);
|
||||
s.refresh();
|
||||
assert_eq!(s.model.state().bodies.len(), 2);
|
||||
assert_eq!(s.model.state().body("Block").unwrap().component, c);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert_eq!(reopened.model.state().bodies.len(), 2);
|
||||
assert_eq!(reopened.model.state().body("Block").unwrap().component, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_transfer_cannot_change_evaluated_world_owner() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
let c = s.doc_mut().add_component(Some("Destination"), 0).unwrap();
|
||||
let id = s
|
||||
.doc_mut()
|
||||
.add_feature(FeatureKind::BodyTransfer { bodies: vec!["Block".into()], from: c, to: c, transform: solvecraft_doc::IDENTITY }, None)
|
||||
.unwrap();
|
||||
s.refresh();
|
||||
assert!(s.model.result(id).unwrap().error.is_some());
|
||||
assert_eq!(s.world_state().body("Block").unwrap().component, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transferred_thread_follows_body_frame_without_changing_world_axis() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":4,"height":20,"body_name":"Shaft"}));
|
||||
run(&mut s, "solid.thread", json!({"face":[4,0,10],"designation":"M8"}));
|
||||
let before = s.world_state().threads[0].clone();
|
||||
let c = run(&mut s, "component.create", json!({"name":"Threaded"}));
|
||||
let to = c["component"].as_u64().unwrap();
|
||||
let occurrence = c["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,10],"axis":[1,0,0],"angle":90,"capture":true}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":to,"bodies":["Shaft"]}));
|
||||
let after = s.world_state().threads[0].clone();
|
||||
assert!(after.axis_point.dist(before.axis_point) < 1e-6);
|
||||
assert!(after.axis.dist(before.axis) < 1e-6);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!(reopened.world_state().threads[0].axis.dist(before.axis) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_component_appearance_and_legacy_assignment_survive_transfer() {
|
||||
let mut s = Session::default();
|
||||
let source = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
s.doc_mut().appearances.components.insert(source, solvecraft_doc::appearance::Look { name: "Green".into(), color: [0, 150, 0], opacity: 0.7 });
|
||||
s.refresh();
|
||||
let look = s.doc.model_body_look(s.model.state().body("Block").unwrap()).unwrap();
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
assert_eq!(s.doc.model_body_look(s.model.state().body("Block").unwrap()), Some(look));
|
||||
// An unrecorded legacy assignment retains its established replay policy.
|
||||
let mut legacy = Session::default();
|
||||
run(&mut legacy, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Old"}));
|
||||
let c = legacy.doc_mut().add_component(Some("Legacy"), 0).unwrap();
|
||||
legacy.doc_mut().body_components.insert("Old".into(), c);
|
||||
legacy.doc_mut().body_offsets.insert("Old".into(), solvecraft_doc::rigid(Vec3::new(7.0, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0));
|
||||
legacy.refresh();
|
||||
assert!(!legacy.model.state().body("Old").unwrap().transferred);
|
||||
run(&mut legacy, "component.move_bodies", json!({"component":0,"bodies":["Old"]}));
|
||||
assert!((legacy.world_state().body("Old").unwrap().mesh().bounds().min.x - 7.0).abs() < 1e-6);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&legacy.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!((reopened.world_state().body("Old").unwrap().mesh().bounds().min.x - 7.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_external_transfer_source_refuses_without_dangling_archive() {
|
||||
let mut s = Session::default();
|
||||
let a = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let b = run(&mut s, "component.create", json!({"name":"Destination"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "component.move_bodies", json!({"component":b,"bodies":["Block"]}));
|
||||
let document = s.doc.to_json();
|
||||
assert!(s.execute("component.delete", &json!({"component":a})).is_err());
|
||||
assert_eq!(s.doc.to_json(), document);
|
||||
assert!(solvecraft_doc::Document::from_json(&document).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn earlier_selected_cylinder_emboss_keeps_source_frame_after_transfer_and_edit() {
|
||||
let mut s = Session::default();
|
||||
let source = run(&mut s, "component.create", json!({"name":"Source"}));
|
||||
let occurrence = source["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":true}));
|
||||
run(&mut s, "sketch.create", json!({"name":"Badge","plane":{"origin":[30,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]}}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let emboss = run(&mut s, "solid.emboss", json!({"sketch":"Badge","face":[30,10,7.5],"depth":1}))["feature"].as_u64().unwrap();
|
||||
let before = solvecraft_kernel::measure(&s.world_state().body("Sleeve").unwrap().body).unwrap().volume;
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let destination = run(&mut s, "component.create", json!({"name":"Destination"}));
|
||||
let to = destination["component"].as_u64().unwrap();
|
||||
let destination_occurrence = destination["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":destination_occurrence,"translate":[0,40,0],"axis":[1,0,0],"angle":90,"capture":true}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":to,"bodies":["Sleeve"]}));
|
||||
assert!(s.doc.features.iter().all(|feature| s.model.result(feature.id).is_some_and(|result| result.error.is_none())));
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!(reopened.doc.features.iter().all(|feature| reopened.model.result(feature.id).is_some_and(|result| result.error.is_none())));
|
||||
assert!((solvecraft_kernel::measure(&reopened.world_state().body("Sleeve").unwrap().body).unwrap().volume - before).abs() < 1e-5);
|
||||
let depth = reopened.doc.feature(emboss).unwrap().param_names[0].clone();
|
||||
run(&mut reopened, "parameters.change", json!({"name":depth,"expression":"2 mm"}));
|
||||
assert!(solvecraft_kernel::measure(&reopened.world_state().body("Sleeve").unwrap().body).unwrap().volume > before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_undo_redo_restores_original_pending_source_world_pose() {
|
||||
let mut s = Session::default();
|
||||
let created = run(&mut s, "component.create", json!({"name":"Source"}));
|
||||
let source = created["component"].as_u64().unwrap();
|
||||
let occurrence = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[10,0,0],"capture":false}));
|
||||
let pending = s.pending_moves.clone();
|
||||
let before = s.world_state().body("Block").unwrap().mesh().bounds();
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(s.world_state().body("Block").unwrap().component, source);
|
||||
assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(before.min) < 1e-6);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(s.pending_moves.is_empty());
|
||||
assert_eq!(s.world_state().body("Block").unwrap().component, 0);
|
||||
assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(before.min) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_checkpoint_restores_pending_world_pose_and_history() {
|
||||
let mut s = Session::default();
|
||||
let created = run(&mut s, "component.create", json!({"name":"Source"}));
|
||||
let source = created["component"].as_u64().unwrap();
|
||||
let occurrence = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[10,0,0],"capture":false}));
|
||||
let pending = s.pending_moves.clone();
|
||||
let before = s.world_state().body("Block").unwrap().mesh().bounds();
|
||||
let undo_count = s.undo.len();
|
||||
let checkpoint = s.checkpoint();
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
s.restore_checkpoint(checkpoint).unwrap();
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(s.undo.len(), undo_count);
|
||||
assert_eq!(s.world_state().body("Block").unwrap().component, source);
|
||||
assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(before.min) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edited_transfer_destination_updates_history_owner_copy_and_reopen() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"}));
|
||||
let a = run(&mut s, "component.create", json!({"name":"A","parent":0}))["component"].as_u64().unwrap();
|
||||
let b = run(&mut s, "component.create", json!({"name":"B","parent":0}))["component"].as_u64().unwrap();
|
||||
let transferred = run(&mut s, "component.move_bodies", json!({"component":a,"bodies":["Block"]}));
|
||||
let feature = transferred["features"][0].as_u64().unwrap();
|
||||
run(&mut s, "timeline.edit", json!({"feature":feature,"set":{"to":b,"component":a}}));
|
||||
assert_eq!(s.doc.feature(feature).unwrap().component, b);
|
||||
assert_eq!(s.model.state().body("Block").unwrap().component, b);
|
||||
let saved = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
assert!(s.execute("timeline.edit", &json!({"feature":feature,"set":{"to":u64::MAX}})).is_err());
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
let copied = run(&mut s, "component.paste_new", json!({"component":b}))["component"].as_u64().unwrap();
|
||||
assert_eq!(s.model.state().body("Block (copy)").unwrap().component, copied);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert_eq!(reopened.model.state().body("Block").unwrap().component, b);
|
||||
assert_eq!(reopened.model.state().body("Block (copy)").unwrap().component, copied);
|
||||
}
|
||||
@@ -63,6 +63,15 @@ fn column_of(s: &Session, p: &Value, cmd: &str) -> Result<Column> {
|
||||
.cloned()
|
||||
.ok_or_else(|| bad(cmd, "no face column"));
|
||||
}
|
||||
if let Some(target) = value.get("target") {
|
||||
solvecraft_doc::config::validate_face_target_json(target).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
let target: solvecraft_doc::config::ConfigFaceTarget =
|
||||
serde_json::from_value(target.clone()).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
let state = s.model.state();
|
||||
let body = state.body(&target.body).ok_or_else(|| bad(cmd, "no face definition"))?;
|
||||
s.doc.configuration_face_indices(body, &target).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
return Ok(Column::FaceAppearance(target));
|
||||
}
|
||||
let body_name = value.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "face body required"))?;
|
||||
let point = value.get("point").and_then(crate::params::vec3).ok_or_else(|| bad(cmd, "finite face point required"))?;
|
||||
let world = s.world_state();
|
||||
@@ -71,20 +80,7 @@ fn column_of(s: &Session, p: &Value, cmd: &str) -> Result<Column> {
|
||||
return Err(bad(cmd, "mesh faces have no supported semantic lineage"));
|
||||
}
|
||||
let index = solvecraft_doc::appearance::face_index_at(body, point).ok_or_else(|| bad(cmd, "no face at picked point"))?;
|
||||
let names = solvecraft_doc::naming::face_names(body);
|
||||
let name = names.get(index).map(|name| solvecraft_doc::naming::strip_piece(name).to_string()).ok_or_else(|| bad(cmd, "face name missing"))?;
|
||||
let origin_feature = name
|
||||
.strip_prefix('F')
|
||||
.and_then(|name| name.split_once(':'))
|
||||
.and_then(|(id, _)| id.parse::<u64>().ok())
|
||||
.ok_or_else(|| bad(cmd, "face lineage missing"))?;
|
||||
let inverse = solvecraft_doc::mat_inverse(&body.source_transform).ok_or_else(|| bad(cmd, "invalid definition placement transform"))?;
|
||||
let target = solvecraft_doc::config::ConfigFaceTarget {
|
||||
body: body.definition_name().into(),
|
||||
origin_feature,
|
||||
name,
|
||||
point: solvecraft_doc::apply_point(&inverse, point),
|
||||
};
|
||||
let target = s.doc.configuration_face_target(body, index, point).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
s.doc.certify_config_face(&target).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
s.doc.configuration_face_indices(body, &target).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
return Ok(Column::FaceAppearance(target));
|
||||
@@ -415,6 +411,10 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "config_certificate_tests.rs"]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod certificate_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "config_style_tests.rs"]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{
|
||||
FeatureKind,
|
||||
config::{Column, FaceCertificate},
|
||||
};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
fn run(session: &mut Session, command: &str, params: Value) -> Value {
|
||||
session.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command} {params}: {error}"))
|
||||
}
|
||||
fn paint(session: &mut Session, body: &str, point: Value) {
|
||||
let table = run(session, "config.configure", json!({"face_appearances":[{"body":body,"point":point}]}));
|
||||
let key = table["columns"][0].as_str().unwrap();
|
||||
run(session, "config.row", json!({"name":"Painted","values":{key:"ABS - Red"}}));
|
||||
run(session, "config.activate", json!({"row":"Painted"}));
|
||||
}
|
||||
fn painted_radius(session: &Session, name: &str, radius: f64) -> usize {
|
||||
let state = session.model.state();
|
||||
let body =
|
||||
state.body(name).unwrap_or_else(|| panic!("missing {name}; bodies {:?}", state.bodies.iter().map(|body| &body.name).collect::<Vec<_>>()));
|
||||
let painted = session.doc.face_colors(body);
|
||||
let mesh = body.body.tessellate(0.001).unwrap();
|
||||
let inverse = solvecraft_doc::mat_inverse(&body.source_transform).unwrap();
|
||||
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
if painted.iter().any(|(index, _)| *index == *face as usize) {
|
||||
for vertex in mesh.tri(triangle).unwrap() {
|
||||
let vertex = solvecraft_doc::apply_point(&inverse, vertex);
|
||||
assert!((vertex.x.hypot(vertex.y) - radius).abs() < 1e-7, "{name}: {vertex:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(!painted.is_empty());
|
||||
painted.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_annulus_side_rows_follow_radius_edits_and_clone() {
|
||||
let mut s = Session::default();
|
||||
let part = run(&mut s, "component.create", json!({"name":"RingPart"}))["component"].as_u64().unwrap();
|
||||
let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"outer"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities":["outer"],"type":"radius","value":10}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":9,"id":"inner"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities":["inner"],"type":"radius","value":9}));
|
||||
let FeatureKind::Sketch { sketch: source_sketch, .. } = &s.doc.feature(sketch).unwrap().kind else { panic!() };
|
||||
let dimensions: Vec<_> = source_sketch.constraints.iter().filter_map(|dimension| dimension.param.clone()).collect();
|
||||
assert_eq!(dimensions.len(), 2);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":10,"profiles":[{"point":[9.5,0]}],"body_name":"Ring"}));
|
||||
paint(&mut s, "Ring", json!([10, 0, 5]));
|
||||
assert_eq!(painted_radius(&s, "Ring", 10.), 2, "both constructed arc pieces share outer curve");
|
||||
let Column::FaceAppearance(target) = &s.doc.configs.columns[0] else { panic!() };
|
||||
assert_eq!(target.certificate, Some(FaceCertificate::ExtrudeSide { sketch, curve: "outer".into() }));
|
||||
run(&mut s, "parameters.change", json!({"name":dimensions[0],"expression":"12 mm"}));
|
||||
run(&mut s, "parameters.change", json!({"name":dimensions[1],"expression":"9.25 mm"}));
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Ring", 12.), 2);
|
||||
run(&mut s, "component.paste_new", json!({"component":part,"translate":[30,0,0]}));
|
||||
assert_eq!(s.doc.configs.columns.len(), 2);
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Ring (copy)", 12.), 2);
|
||||
let reopened = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
let mut reopened = Session::new(reopened);
|
||||
run(&mut reopened, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&reopened, "Ring", 12.), 2);
|
||||
assert_eq!(painted_radius(&reopened, "Ring (copy)", 12.), 2);
|
||||
run(&mut reopened, "config.activate", json!({"row":"Default"}));
|
||||
assert!(reopened.doc.appearances.faces.is_empty());
|
||||
}
|
||||
|
||||
fn imported(session: &mut Session, step: String, name: &str) -> u64 {
|
||||
let owner = session.active_component;
|
||||
let id = session.doc_mut().add_feature(FeatureKind::Import { file: "fixture.step".into(), step, components: vec![] }, Some(name)).unwrap();
|
||||
let feature = session.doc_mut().feature_mut(id).unwrap();
|
||||
feature.body_names = vec![name.into()];
|
||||
feature.component = owner;
|
||||
session.refresh();
|
||||
assert!(session.model.result(id).unwrap().error.is_none());
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_v9_imported_emboss_anchor_and_ordinary_face_paint_replay_without_renaming() {
|
||||
let mut s = Session::default();
|
||||
let cylinder = solvecraft_kernel::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap();
|
||||
let step = solvecraft_kernel::step_export(&[&cylinder], "legacy anchored cylinder").unwrap();
|
||||
let source = imported(&mut s, step, "Sleeve");
|
||||
run(&mut s, "material.assign", json!({"bodies":["Sleeve"],"material":"Steel"}));
|
||||
let before_volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
let legacy_names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap());
|
||||
assert!(legacy_names.iter().all(|name| name.starts_with(&format!("F{source}:face:"))));
|
||||
run(&mut s, "appearance.assign", json!({"faces":[{"body":"Sleeve","point":[6,0,20]}],"appearance":"ABS - Blue"}));
|
||||
assert!(s.doc.appearances.faces[0].certificate.is_none());
|
||||
let badge = run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,20]},"name":"Badge","project_edges":false}))["sketch"].as_u64().unwrap();
|
||||
let FeatureKind::Sketch { plane: saved_plane, .. } = &s.doc.feature(badge).unwrap().kind else { panic!() };
|
||||
let saved_plane = saved_plane.clone();
|
||||
assert!(matches!(&saved_plane, solvecraft_doc::PlaneRef::Face { name: Some(name), .. } if legacy_names.contains(name)));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,-2],"p1":[2,2]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","depth":1,"targets":["Sleeve"],"name":"Raised Badge"}));
|
||||
let anchored = s.doc.find_feature("Raised Badge").unwrap().clone();
|
||||
assert!(matches!(anchored.kind, FeatureKind::Emboss { face: None, .. }));
|
||||
let volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
assert!((volume - std::f64::consts::PI * 2000. - 16.).abs() < 0.1, "volume {volume}");
|
||||
assert!((volume - before_volume - 16.).abs() < 0.01, "addedvolume {} baseline {before_volume}", volume - before_volume);
|
||||
let paint = s.doc.appearances.faces.clone();
|
||||
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
legacy["format"] = json!("solvecraft/9");
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.to_string()).unwrap());
|
||||
assert_eq!(reopened.doc.feature(anchored.id).unwrap(), &anchored);
|
||||
let FeatureKind::Sketch { plane, .. } = &reopened.doc.feature(badge).unwrap().kind else { panic!() };
|
||||
assert_eq!(plane, &saved_plane);
|
||||
assert_eq!(reopened.doc.appearances.faces, paint);
|
||||
assert_eq!(reopened.doc.materials.get("Sleeve"), Some(&"Steel".to_string()));
|
||||
assert!(reopened.model.result(anchored.id).unwrap().error.is_none());
|
||||
let state = reopened.model.state();
|
||||
let body = state.body("Sleeve").unwrap();
|
||||
assert!((body.body.tessellate_with(1e-4, true).unwrap().measure().volume - volume).abs() < 1e-8);
|
||||
assert!(!reopened.doc.face_colors(body).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_side_refuses_reused_body_name_from_a_new_producer() {
|
||||
let mut s = Session::default();
|
||||
let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"circle"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let source = run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Round"}))["feature"].as_u64().unwrap();
|
||||
paint(&mut s, "Round", json!([10, 0, 5]));
|
||||
let Column::FaceAppearance(target) = s.doc.configs.columns[0].clone() else { panic!() };
|
||||
let mut replacement = s.doc.feature(source).unwrap().clone();
|
||||
replacement.id = s.doc.next_id;
|
||||
assert!(matches!(replacement.kind, FeatureKind::Extrude { sketch: original, .. } if original == sketch));
|
||||
s.doc_mut().feature_mut(source).unwrap().suppressed = true;
|
||||
s.doc_mut().next_id += 1;
|
||||
s.doc_mut().features.push(replacement);
|
||||
s.refresh();
|
||||
let state = s.model.state();
|
||||
let body = state.body("Round").unwrap();
|
||||
assert!(s.doc.configuration_face_indices(body, &target).is_err());
|
||||
assert!(s.doc.face_colors(body).is_empty());
|
||||
let before = s.doc.clone();
|
||||
let history = s.undo.len();
|
||||
assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_v9_annulus_outer_side_emboss_keeps_its_original_heuristic_anchor() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"outer"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":9,"id":"inner"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":20,"profiles":[{"point":[9.5,0]}],"body_name":"Sleeve"}));
|
||||
let names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap());
|
||||
// Preserve the legacy centroid-family name even though strict paint uses
|
||||
// the separately certified outer source curve.
|
||||
let name = solvecraft_doc::naming::face_names_at(&s.model.state(), &[Vec3::new(0., 10., 7.5)]).remove(0);
|
||||
assert!(name.contains("side:inner"));
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"],"name":"Raised Badge"}));
|
||||
let feature = s.doc.find_feature("Raised Badge").unwrap();
|
||||
assert_eq!(feature.face_names, vec![name]);
|
||||
assert!(feature.face_names.iter().all(|name| names.contains(name)));
|
||||
let feature_id = feature.id;
|
||||
let volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
assert!((volume - std::f64::consts::PI * 380. - 21.).abs() < 0.2, "volume {volume}");
|
||||
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
legacy["format"] = json!("solvecraft/9");
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.to_string()).unwrap());
|
||||
assert!(reopened.model.result(feature_id).unwrap().error.is_none());
|
||||
assert!((reopened.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume - volume).abs() < 1e-8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_side_transfer_preserves_geometry_and_unsupported_taper_refuses_atomically() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let source = run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Round"}))["feature"].as_u64().unwrap();
|
||||
paint(&mut s, "Round", json!([10, 0, 5]));
|
||||
let created = run(&mut s, "component.create", json!({"name":"Placed"}));
|
||||
let destination = created["component"].as_u64().unwrap();
|
||||
let occurrence = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,0],"angle":90,"axis":[0,0,1],"capture":false}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":destination,"bodies":["Round"]}));
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Round", 10.), 2);
|
||||
let before = s.world_state().body("Round").unwrap().mesh().bounds();
|
||||
let mut s = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Round", 10.), 2);
|
||||
assert!(
|
||||
s.world_state().body("Round").unwrap().mesh().bounds().min.dist(before.min) < 1e-8,
|
||||
"before {before:?}, reopened {:?}",
|
||||
s.world_state().body("Round").unwrap().mesh().bounds()
|
||||
);
|
||||
// A successful ordinary modifier has no exact untapered source certificate.
|
||||
let FeatureKind::Extrude { extent, .. } = &mut s.doc_mut().feature_mut(source).unwrap().kind else { panic!() };
|
||||
extent.taper = Some("5 deg".into());
|
||||
s.refresh();
|
||||
assert!(s.model.result(source).unwrap().error.is_none());
|
||||
assert!(s.doc.face_colors(s.model.state().body("Round").unwrap()).is_empty());
|
||||
let document = s.doc.clone();
|
||||
let history = s.undo.len();
|
||||
assert!(s.execute("config.preview", &json!({"row":"Painted"})).is_err());
|
||||
assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err());
|
||||
assert_eq!(s.doc, document);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_import_payload_binding_roundtrip_clone_and_replacement_refusal() {
|
||||
let geometry = solvecraft_kernel::cylinder(Vec3::ZERO, Vec3::Z, 10., 10.).unwrap();
|
||||
let step = solvecraft_kernel::step_export(&[&geometry], "certificate fixture").unwrap();
|
||||
let mut s = Session::default();
|
||||
let part = run(&mut s, "component.create", json!({"name":"ImportedPart"}))["component"].as_u64().unwrap();
|
||||
let source = imported(&mut s, step.clone(), "Imported");
|
||||
paint(&mut s, "Imported", json!([10, 0, 5]));
|
||||
let pieces = painted_radius(&s, "Imported", 10.);
|
||||
let Column::FaceAppearance(target) = &s.doc.configs.columns[0] else { panic!() };
|
||||
assert!(
|
||||
matches!(&target.certificate, Some(FaceCertificate::Imported { item, face, payload_sha256 }) if *item != 0 && *face != 0 && payload_sha256.len() == 64)
|
||||
);
|
||||
run(&mut s, "component.paste_new", json!({"component":part,"translate":[30,0,0]}));
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Imported (copy)", 10.), pieces);
|
||||
let reopened = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
let mut s = Session::new(reopened);
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Imported", 10.), pieces);
|
||||
// Identical geometry, entity IDs and item IDs, but a replaced embedded payload.
|
||||
let FeatureKind::Import { step: payload, .. } = &mut s.doc_mut().feature_mut(source).unwrap().kind else { panic!() };
|
||||
*payload = step.replace("certificate fixture", "replacement fixture");
|
||||
assert_ne!(*payload, step);
|
||||
s.refresh();
|
||||
assert!(s.doc.face_colors(s.model.state().body("Imported").unwrap()).is_empty());
|
||||
assert_eq!(painted_radius(&s, "Imported (copy)", 10.), pieces);
|
||||
let before = s.doc.clone();
|
||||
let history = s.undo.len();
|
||||
assert!(s.execute("config.preview", &json!({"row":"Painted"})).is_err());
|
||||
assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
}
|
||||
@@ -133,6 +133,7 @@ fn strict_face_missing_lineage_never_retargets_and_rolls_back() {
|
||||
point: solvecraft_geom::Vec3::new(5., 5., 10.),
|
||||
name: Some(format!("F{other}:+z")),
|
||||
strict_name: true,
|
||||
certificate: None,
|
||||
look,
|
||||
});
|
||||
assert!(s.doc.face_colors(s.model.state().body(&body).unwrap()).is_empty());
|
||||
@@ -175,7 +176,7 @@ fn extrude_cap_certification_follows_actual_positive_negative_and_symmetric_name
|
||||
for face in faces {
|
||||
let name = solvecraft_doc::naming::strip_piece(&names[face.index]);
|
||||
let origin = name.strip_prefix('F').unwrap().split_once(':').unwrap().0.parse().unwrap();
|
||||
let target = ConfigFaceTarget { body: "Pad".into(), origin_feature: origin, name: name.into(), point: face.centroid };
|
||||
let target = ConfigFaceTarget { body: "Pad".into(), origin_feature: origin, name: name.into(), point: face.centroid, certificate: None };
|
||||
if name.ends_with(":start") || name.ends_with(":top") || name.ends_with(":bottom") {
|
||||
s.doc.certify_config_face(&target).unwrap();
|
||||
s.doc.configuration_face_indices(body, &target).unwrap();
|
||||
@@ -211,7 +212,13 @@ fn hostile_and_uncertified_face_targets_are_rejected() {
|
||||
let mut s = Session::default();
|
||||
let body = block(&mut s);
|
||||
let id = s.doc.features[0].id;
|
||||
let target = ConfigFaceTarget { body: body.clone(), origin_feature: id, name: format!("F{id}:face:0"), point: solvecraft_geom::Vec3::ZERO };
|
||||
let target = ConfigFaceTarget {
|
||||
body: body.clone(),
|
||||
origin_feature: id,
|
||||
name: format!("F{id}:face:0"),
|
||||
point: solvecraft_geom::Vec3::ZERO,
|
||||
certificate: None,
|
||||
};
|
||||
assert!(s.doc.certify_config_face(&target).is_err());
|
||||
for value in [
|
||||
json!({"body":body,"origin_feature":id,"name":"x".repeat(1_000_000),"point":[0,0,0]}),
|
||||
|
||||
@@ -65,7 +65,7 @@ impl ContactGeometry {
|
||||
if !sets.iter().any(|set| path.iter().any(|id| set.contains(id))) {
|
||||
continue;
|
||||
}
|
||||
for body in state.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == component) {
|
||||
for body in state.bodies.iter().filter(|b| s.doc.model_body_component(b) == component) {
|
||||
if body.body.is_mesh() {
|
||||
return Err(limit("contact sweep does not support mesh bodies; disable their contact set to move them"));
|
||||
}
|
||||
@@ -113,7 +113,7 @@ impl ContactGeometry {
|
||||
};
|
||||
out.bodies.push(CachedBody {
|
||||
path: path.clone(),
|
||||
offset: s.doc.body_offsets.get(&body.name).copied().unwrap_or(IDENTITY),
|
||||
offset: s.doc.model_body_offset(body).copied().unwrap_or(IDENTITY),
|
||||
mesh,
|
||||
size: body.body.size(),
|
||||
});
|
||||
|
||||
@@ -47,9 +47,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
fn undo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let Some(prev) = s.undo.pop() else { return Err(EngineError::Other("nothing to undo".into())) };
|
||||
let label = prev.label.clone();
|
||||
s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch });
|
||||
s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() });
|
||||
s.doc = prev.doc;
|
||||
s.active_sketch = prev.active_sketch;
|
||||
s.pending_moves = prev.pending_moves;
|
||||
s.selection.clear();
|
||||
s.refresh();
|
||||
Ok(json!({"undone": label}))
|
||||
@@ -58,9 +59,10 @@ fn undo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let Some(next) = s.redo.pop() else { return Err(EngineError::Other("nothing to redo".into())) };
|
||||
let label = next.label.clone();
|
||||
s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch });
|
||||
s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() });
|
||||
s.doc = next.doc;
|
||||
s.active_sketch = next.active_sketch;
|
||||
s.pending_moves = next.pending_moves;
|
||||
s.selection.clear();
|
||||
s.refresh();
|
||||
Ok(json!({"redone": label}))
|
||||
@@ -200,6 +202,10 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if std::mem::discriminant(&nf.kind) != std::mem::discriminant(&f.kind) {
|
||||
return Err(bad(cmd, "the feature type cannot change"));
|
||||
}
|
||||
if let FeatureKind::BodyTransfer { bodies, from, to, transform } = &nf.kind {
|
||||
solvecraft_doc::validate_body_transfer(&s.doc, bodies, *from, *to, transform)?;
|
||||
nf.component = *to;
|
||||
}
|
||||
if let (FeatureKind::Sketch { sketch: a, .. }, FeatureKind::Sketch { sketch: b, .. }) = (&nf.kind, &f.kind)
|
||||
&& a != b
|
||||
{
|
||||
@@ -360,7 +366,13 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
trial.active_sketch = None;
|
||||
trial.selection = s.selection.clone();
|
||||
trial.doc_mut().marker = Some(idx);
|
||||
// Keep the original identity as a suppressed placeholder. The constructor and
|
||||
// any ownership boundary it adds go AFTER this slot, so discarding its temporary
|
||||
// feature leaves auxiliary history after the rewritten original.
|
||||
if let Some(slot) = trial.doc_mut().feature_mut(id) {
|
||||
slot.suppressed = true;
|
||||
}
|
||||
trial.doc_mut().marker = Some(idx + 1);
|
||||
trial.refresh();
|
||||
(spec.enabled)(&trial).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
|
||||
let r = (spec.run)(&mut trial, ¶ms)?;
|
||||
@@ -399,6 +411,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
trial.doc_mut().next_id = s.doc.next_id;
|
||||
}
|
||||
if let Some(slot) = trial.doc_mut().feature_mut(id) {
|
||||
slot.suppressed = old.suppressed;
|
||||
slot.kind = new.kind;
|
||||
slot.edge_refs = new.edge_refs;
|
||||
slot.edge_names = new.edge_names;
|
||||
@@ -414,6 +427,40 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(e) = trial.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
let original_ids: std::collections::BTreeSet<u64> = s.doc.features.iter().map(|feature| feature.id).collect();
|
||||
let added_ids: std::collections::BTreeSet<u64> =
|
||||
trial.doc.features.iter().filter(|feature| !original_ids.contains(&feature.id)).map(|feature| feature.id).collect();
|
||||
let adds_transfer =
|
||||
trial.doc.features.iter().any(|feature| added_ids.contains(&feature.id) && matches!(feature.kind, FeatureKind::BodyTransfer { .. }));
|
||||
let baseline = if adds_transfer && s.doc.marker.is_some() {
|
||||
let mut baseline = s.scratch();
|
||||
baseline.doc_mut().marker = None;
|
||||
baseline.refresh();
|
||||
Some(baseline)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let proof = if (adds_transfer && trial.doc.marker.is_some()) || added_ids.iter().any(|id| trial.model.result(*id).is_none()) {
|
||||
let mut proof = trial.scratch();
|
||||
proof.doc_mut().marker = None;
|
||||
proof.refresh();
|
||||
Some(proof)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let validated = proof.as_ref().map(|proof| &proof.model).unwrap_or(&trial.model);
|
||||
let original_model = baseline.as_ref().map(|baseline| &baseline.model).unwrap_or(&s.model);
|
||||
for feature in &trial.doc.features {
|
||||
let auxiliary = added_ids.contains(&feature.id);
|
||||
let protected_transfer = matches!(feature.kind, FeatureKind::BodyTransfer { .. })
|
||||
&& original_model.result(feature.id).is_some_and(|result| !result.skipped && result.error.is_none());
|
||||
if (auxiliary || protected_transfer)
|
||||
&& let Some(error) = validated.result(feature.id).and_then(|result| result.error.as_ref())
|
||||
{
|
||||
return Err(bad(cmd, format!("{}: {error}", feature.name)));
|
||||
}
|
||||
}
|
||||
s.pending_moves = trial.pending_moves;
|
||||
s.doc = trial.doc;
|
||||
s.refresh();
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
|
||||
@@ -170,11 +170,31 @@ fn redefine_keeps_allocator_above_component_and_occurrence_ids() {
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "body_name": "Base"}));
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [10, 0, 0], "body_name": "Tool"}));
|
||||
run(&mut s, "solid.combine", json!({"target": "Base", "tools": ["Tool"], "name": "Join"}));
|
||||
let original = s.doc.clone();
|
||||
let original_feature = s.doc.find_feature("Join").unwrap().id;
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "Join", "command": "solid.combine", "params": {"target": "Base", "tools": ["Tool"], "new_component": true, "component_name": "Merged"}}),
|
||||
);
|
||||
let destination = s.doc.find_component("Merged").unwrap();
|
||||
assert_eq!(s.model.state().body("Base").unwrap().component, destination);
|
||||
assert_eq!(s.doc.find_feature("Join").unwrap().id, original_feature);
|
||||
let combine_index = s.doc.feature_index(original_feature).unwrap();
|
||||
assert!(
|
||||
matches!(&s.doc.features[combine_index + 1].kind, solvecraft_doc::FeatureKind::BodyTransfer { bodies, to, .. } if bodies == &["Base"] && *to == destination)
|
||||
);
|
||||
assert!((solvecraft_kernel::measure(&s.world_state().body("Base").unwrap().body).unwrap().volume - 6000.0).abs() < 1e-6);
|
||||
assert_eq!(s.world_state().bodies.len(), 1);
|
||||
run(&mut s, "timeline.rollback", json!({"position":combine_index + 1}));
|
||||
assert_eq!(s.world_state().body("Base").unwrap().component, 0, "the result transfers only AFTER Combine");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*s.doc, *original);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert_eq!(reopened.world_state().body("Base").unwrap().component, destination);
|
||||
assert!((solvecraft_kernel::measure(&reopened.world_state().body("Base").unwrap().body).unwrap().volume - 6000.0).abs() < 1e-6);
|
||||
let max_id =
|
||||
s.doc.components.iter().map(|c| c.id).chain(s.doc.occurrences.iter().map(|o| o.id)).chain(s.doc.features.iter().map(|f| f.id)).max().unwrap();
|
||||
assert!(s.doc.next_id > max_id);
|
||||
@@ -186,6 +206,30 @@ fn redefine_keeps_allocator_above_component_and_occurrence_ids() {
|
||||
assert_eq!(ids.len(), unique.len(), "feature, component and occurrence ids must remain unique: {ids:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefine_cannot_publish_a_second_transfer_that_breaks_existing_ownership() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Base"}));
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[1,0,0],"body_name":"Tool"}));
|
||||
run(&mut s, "solid.combine", json!({"target":"Base","tools":["Tool"],"name":"Join","new_component":true,"component_name":"Original"}));
|
||||
let full = s.doc.to_json();
|
||||
let join_index = s.doc.features.iter().position(|feature| feature.name == "Join").unwrap();
|
||||
for marker in [None, Some(join_index), Some(join_index + 1)] {
|
||||
let mut s = Session::new(solvecraft_doc::Document::from_json(&full).unwrap());
|
||||
s.doc_mut().marker = marker;
|
||||
s.refresh();
|
||||
let before = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let error = s.execute("timeline.redefine", &json!({"feature":"Join","command":"solid.combine","params":{"target":"Base","tools":["Tool"],"new_component":true,"component_name":"Duplicate"}})).unwrap_err();
|
||||
assert!(error.to_string().contains("transfer source component"), "marker {marker:?}: {error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
}
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&full).unwrap());
|
||||
assert_eq!(reopened.world_state().body("Base").unwrap().component, reopened.doc.find_component("Original").unwrap());
|
||||
assert!(reopened.doc.features.iter().all(|feature| reopened.model.result(feature.id).is_some_and(|result| result.error.is_none())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefine_maps_placement_inputs_in_the_feature_owner_frame() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -158,7 +158,7 @@ pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
|
||||
let bodies: Vec<_> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| if parts.is_empty() { s.doc.body_component(&b.name, b.feature) == s.active_component } else { parts.contains(&b.name) })
|
||||
.filter(|b| if parts.is_empty() { s.doc.model_body_component(b) == s.active_component } else { parts.contains(&b.name) })
|
||||
.collect();
|
||||
if bodies.is_empty() {
|
||||
return Some("new");
|
||||
@@ -324,7 +324,7 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &kind {
|
||||
let state = s.model.state();
|
||||
let body = state.body(&face.body).ok_or_else(|| EngineError::Other("selected cylinder body no longer exists".into()))?;
|
||||
let frame = face.body_frame(&s.doc, body.feature).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let frame = face.body_frame(&s.doc, body).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let inverse = solvecraft_doc::mat_inverse(&frame).ok_or_else(|| EngineError::Other("invalid cylinder placement".into()))?;
|
||||
let at = solvecraft_doc::apply_point(&solvecraft_doc::mat_mul(&inverse, &s.active_component_frame()), face.at);
|
||||
let mut scoped = solvecraft_doc::ModelState::default();
|
||||
@@ -727,7 +727,7 @@ fn combine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bool_(p, "new_component").unwrap_or(false) {
|
||||
let name = str_(p, "component_name").map(str::to_string).unwrap_or_else(|| format!("{target} Combined"));
|
||||
let c = s.execute("component.create", &json!({"name": name, "activate": false}))?;
|
||||
s.execute("component.move_bodies", &json!({"bodies": [target], "component": c["component"]}))?;
|
||||
super::component::transfer_constructor_result(s, &json!({"bodies": [target], "component": c["component"]}))?;
|
||||
out["component"] = c["component"].clone();
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.emboss", "Emboss", emboss)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("emboss")
|
||||
.params("sketch, profiles?; depth; mode?: emboss|deboss; targets?; face?: world point [x,y,z] selecting a planar or complete exterior cylindrical wall (required for wrapped orthogonal line profiles)"),
|
||||
.params("sketch, profiles?; depth; mode?: emboss|deboss; targets?; face?: world point [x,y,z] selecting a planar or complete exterior cylindrical wall (inferred when exactly one supported tangent cylinder matches the selected profiles)"),
|
||||
CommandSpec::new("solid.replace_face", "Replace Face", replace_face)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("offset_face")
|
||||
@@ -192,50 +192,120 @@ fn emboss(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// angular position, and certify their distance before storing an
|
||||
// exact surface anchor for later replay.
|
||||
let at = solvecraft_kernel::cylinder_emboss_anchor(body, index, at).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let original = s.model.state();
|
||||
let owner = s.doc.body_component(name, selected.feature);
|
||||
let paths: Vec<_> = s
|
||||
.doc
|
||||
.placements()
|
||||
.into_iter()
|
||||
.filter(|(comp, path, _)| *comp == owner && s.doc.world_body_name(&original, name, owner, path) == selected.name)
|
||||
.map(|(_, path, _)| path)
|
||||
.collect();
|
||||
if paths.len() != 1 {
|
||||
return Err(bad(cmd, "selected cylinder placement is ambiguous"));
|
||||
}
|
||||
let path = paths.into_iter().next().ok_or_else(|| bad(cmd, "selected cylinder placement"))?;
|
||||
if path.len() > 64 {
|
||||
return Err(bad(cmd, "selected cylinder placement is too deep"));
|
||||
}
|
||||
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(s.active_component));
|
||||
if author_path.len() > 64 {
|
||||
return Err(bad(cmd, "author placement is too deep"));
|
||||
}
|
||||
let sketch_owner = s.doc.feature(sketch).map(|f| f.component).ok_or_else(|| bad(cmd, "profile sketch no longer exists"))?;
|
||||
let sketch_path = if sketch_owner == owner {
|
||||
path.clone()
|
||||
} else if sketch_owner == s.active_component {
|
||||
author_path.clone()
|
||||
} else {
|
||||
s.doc.first_path(sketch_owner)
|
||||
};
|
||||
if path.iter().chain(&author_path).chain(&sketch_path).any(|id| s.pending_moves.contains_key(id)) {
|
||||
return Err(bad(cmd, "capture pending source, author and profile positions with Capture Position before cylinder emboss"));
|
||||
}
|
||||
let inverse = solvecraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid active frame"))?;
|
||||
let at = solvecraft_doc::apply_point(&inverse, at);
|
||||
let face = emboss_selection(s, selected, at)?;
|
||||
check_emboss_positions(s, sketch, selected, &face)?;
|
||||
targets = vec![name.to_owned()];
|
||||
Some(solvecraft_doc::EmbossFace { body: name.to_owned(), at, path, author_path })
|
||||
Some(face)
|
||||
}
|
||||
_ => return Err(bad(cmd, "only planar faces and complete exterior cylindrical walls support emboss")),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
let inferred = infer_emboss_selection(s, sketch, &profiles, &depth, deboss, &targets)?;
|
||||
if let Some(face) = &inferred {
|
||||
targets = vec![face.body.clone()];
|
||||
}
|
||||
inferred
|
||||
};
|
||||
add_feature(s, p, FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face })
|
||||
}
|
||||
|
||||
fn emboss_selection(s: &Session, selected: &solvecraft_doc::ModelBody, at: Vec3) -> Result<solvecraft_doc::EmbossFace> {
|
||||
let cmd = "solid.emboss";
|
||||
let name = selected.definition_name();
|
||||
let original = s.model.state();
|
||||
let owner = s.doc.model_body_component(selected);
|
||||
let paths: Vec<_> = s
|
||||
.doc
|
||||
.placements()
|
||||
.into_iter()
|
||||
.filter(|(comp, path, _)| *comp == owner && s.doc.world_body_name(&original, name, owner, path) == selected.name)
|
||||
.map(|(_, path, _)| path)
|
||||
.collect();
|
||||
if paths.len() != 1 {
|
||||
return Err(bad(cmd, "selected cylinder placement is ambiguous"));
|
||||
}
|
||||
let path = paths.into_iter().next().ok_or_else(|| bad(cmd, "selected cylinder placement"))?;
|
||||
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(s.active_component));
|
||||
if path.len() > 64 || author_path.len() > 64 {
|
||||
return Err(bad(cmd, "cylinder placement is too deep"));
|
||||
}
|
||||
let inverse = solvecraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid active frame"))?;
|
||||
Ok(solvecraft_doc::EmbossFace { body: name.to_owned(), at: solvecraft_doc::apply_point(&inverse, at), path, author_path })
|
||||
}
|
||||
|
||||
fn check_emboss_positions(s: &Session, sketch: u64, body: &solvecraft_doc::ModelBody, face: &solvecraft_doc::EmbossFace) -> Result<()> {
|
||||
let cmd = "solid.emboss";
|
||||
let owner = s.doc.model_body_component(body);
|
||||
let sketch_owner = s.doc.feature(sketch).map(|f| f.component).ok_or_else(|| bad(cmd, "profile sketch no longer exists"))?;
|
||||
let sketch_path = if sketch_owner == owner {
|
||||
face.path.clone()
|
||||
} else if sketch_owner == s.active_component {
|
||||
face.author_path.clone()
|
||||
} else {
|
||||
s.doc.first_path(sketch_owner)
|
||||
};
|
||||
if face.path.iter().chain(&face.author_path).chain(&sketch_path).any(|id| s.pending_moves.contains_key(id)) {
|
||||
return Err(bad(cmd, "capture pending source, author and profile positions with Capture Position before cylinder emboss"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn infer_emboss_selection(
|
||||
s: &Session,
|
||||
sketch: u64,
|
||||
profiles: &solvecraft_doc::ProfileSel,
|
||||
depth: &str,
|
||||
deboss: bool,
|
||||
targets: &[String],
|
||||
) -> Result<Option<solvecraft_doc::EmbossFace>> {
|
||||
let cmd = "solid.emboss";
|
||||
let original = s.model.state();
|
||||
let ss = original.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch is not evaluated"))?;
|
||||
let regions = solvecraft_doc::select_profiles(ss, profiles).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let sketch_owner = s.doc.feature(sketch).map(|f| f.component).ok_or_else(|| bad(cmd, "profile sketch no longer exists"))?;
|
||||
let d = s.doc.eval(&s.doc.with_design_unit(depth, Kind::Length), Kind::Length).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let world = s.world_state();
|
||||
let placed = s.placed_document();
|
||||
let mut chosen = None;
|
||||
let mut faces = 0usize;
|
||||
for selected in &world.bodies {
|
||||
let name = selected.definition_name();
|
||||
if !targets.is_empty() && !targets.iter().any(|n| n == name || n == &selected.name) {
|
||||
continue;
|
||||
}
|
||||
let raw = original.body(name).ok_or_else(|| bad(cmd, "target definition no longer exists"))?;
|
||||
faces = faces.saturating_add(raw.body.face_count());
|
||||
if faces > 50_000 {
|
||||
return Err(bad(cmd, "cylinder target search exceeds face work limit"));
|
||||
}
|
||||
if !solvecraft_kernel::has_cylinder_emboss_wall(&raw.body).map_err(|e| bad(cmd, e.to_string()))? {
|
||||
continue;
|
||||
}
|
||||
let mut face = emboss_selection(s, selected, Vec3::ZERO)?;
|
||||
let body_frame = face.body_frame(&placed, raw).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let inverse = solvecraft_doc::mat_inverse(&body_frame).ok_or_else(|| bad(cmd, "invalid cylinder body frame"))?;
|
||||
let displayed = world.sketch(sketch).ok_or_else(|| bad(cmd, "the profile sketch is not displayed"))?;
|
||||
let plane = solvecraft_geom::Plane::new(
|
||||
solvecraft_doc::apply_point(&inverse, displayed.plane.origin),
|
||||
solvecraft_doc::apply_vector(&inverse, displayed.plane.x),
|
||||
solvecraft_doc::apply_vector(&inverse, displayed.plane.y),
|
||||
)
|
||||
.ok_or_else(|| bad(cmd, "invalid cylinder sketch frame"))?;
|
||||
if let Some((_, at)) =
|
||||
solvecraft_kernel::infer_cylinder_emboss(&raw.body, &plane, ®ions, if deboss { -d } else { d }).map_err(|e| bad(cmd, e.to_string()))?
|
||||
{
|
||||
face.sketch_frame(&placed, s.active_component, sketch_owner, raw).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
check_emboss_positions(s, sketch, selected, &face)?;
|
||||
let author_inverse = solvecraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid author frame"))?;
|
||||
face.at = solvecraft_doc::apply_point(&solvecraft_doc::mat_mul(&author_inverse, &body_frame), at);
|
||||
if chosen.is_some() {
|
||||
return Err(bad(cmd, "multiple tangent cylinders match the selected profiles; select a target face or body"));
|
||||
}
|
||||
chosen = Some(face);
|
||||
}
|
||||
}
|
||||
Ok(chosen)
|
||||
}
|
||||
fn face_points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{k}` must list points [x, y, z]")))?;
|
||||
if a.is_empty() || a.len() > 1000 {
|
||||
|
||||
@@ -9,6 +9,17 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn auto_emboss_params(mut p: Value, auto: bool) -> Value {
|
||||
if auto {
|
||||
if let Some(params) = p.get_mut("params").and_then(Value::as_object_mut) {
|
||||
params.remove("face");
|
||||
} else if let Some(object) = p.as_object_mut() {
|
||||
object.remove("face");
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn measure(s: &mut Session) -> Value {
|
||||
run(s, "inspect.measure", json!({}))
|
||||
}
|
||||
@@ -169,7 +180,8 @@ fn cylinder_small_chord_pick_is_saved_on_the_exact_surface() {
|
||||
|
||||
#[test]
|
||||
fn cylinder_cross_component_emboss_keeps_source_owner_and_placements() {
|
||||
for pick_offset in [0., 80.] {
|
||||
for (pick_offset, auto) in [(0., false), (80., false), (0., true), (80., true)] {
|
||||
let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && matches!(id, "solid.emboss" | "timeline.redefine")));
|
||||
let mut s = Session::default();
|
||||
let a = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
@@ -255,6 +267,11 @@ fn cylinder_cross_component_emboss_keeps_source_owner_and_placements() {
|
||||
|
||||
#[test]
|
||||
fn cylinder_active_second_source_occurrence_authors_shared_geometry() {
|
||||
check_active_second_source(false);
|
||||
check_active_second_source(true);
|
||||
}
|
||||
fn check_active_second_source(auto: bool) {
|
||||
let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && id == "solid.emboss"));
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
@@ -279,7 +296,7 @@ fn cylinder_active_second_source_occurrence_authors_shared_geometry() {
|
||||
|
||||
#[test]
|
||||
fn cylinder_pending_leaf_or_ancestor_positions_require_capture_atomically() {
|
||||
for ancestor in [false, true] {
|
||||
for (ancestor, auto) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
let mut s = Session::default();
|
||||
let parent = run(&mut s, "component.create", json!({"name":"Parent"}))["occurrence"].as_u64().unwrap();
|
||||
let leaf = run(&mut s, "component.create", json!({"name":"Source"}))["occurrence"].as_u64().unwrap();
|
||||
@@ -291,7 +308,10 @@ fn cylinder_pending_leaf_or_ancestor_positions_require_capture_atomically() {
|
||||
let before = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let volume = exact_cylinder_volume(&s);
|
||||
let error = s.execute("solid.emboss", &json!({"sketch":"Badge","profiles":"all","face":[5,10,7.5],"depth":1})).unwrap_err().to_string();
|
||||
let error = s
|
||||
.execute("solid.emboss", &auto_emboss_params(json!({"sketch":"Badge","profiles":"all","face":[5,10,7.5],"depth":1}), auto))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("Capture Position"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
@@ -301,6 +321,10 @@ fn cylinder_pending_leaf_or_ancestor_positions_require_capture_atomically() {
|
||||
|
||||
#[test]
|
||||
fn cylinder_pending_third_component_profile_requires_capture_atomically() {
|
||||
check_pending_third_profile(false);
|
||||
check_pending_third_profile(true);
|
||||
}
|
||||
fn check_pending_third_profile(auto: bool) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
let profile_occurrence = run(&mut s, "component.create", json!({"name":"ProfileOwner"}))["occurrence"].as_u64().unwrap();
|
||||
@@ -314,7 +338,10 @@ fn cylinder_pending_third_component_profile_requires_capture_atomically() {
|
||||
let pending = s.pending_moves.clone();
|
||||
let volume = exact_cylinder_volume(&s);
|
||||
let error = s
|
||||
.execute("solid.emboss", &json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]}))
|
||||
.execute(
|
||||
"solid.emboss",
|
||||
&auto_emboss_params(json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]}), auto),
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("Capture Position"), "{error}");
|
||||
@@ -325,12 +352,21 @@ fn cylinder_pending_third_component_profile_requires_capture_atomically() {
|
||||
|
||||
#[test]
|
||||
fn cylinder_body_offset_keeps_exact_profile_and_anchor_frames() {
|
||||
check_body_offset(false);
|
||||
check_body_offset(true);
|
||||
}
|
||||
fn check_body_offset(auto: bool) {
|
||||
let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && id == "solid.emboss"));
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
let before = exact_cylinder_volume(&s);
|
||||
let holder = run(&mut s, "component.create", json!({"name":"Holder"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":"Holder:1","translate":[0,50,0]}));
|
||||
run(&mut s, "component.move_bodies", json!({"bodies":["Sleeve"],"component":holder}));
|
||||
std::sync::Arc::make_mut(&mut s.doc).body_components.insert("Sleeve".into(), holder);
|
||||
std::sync::Arc::make_mut(&mut s.doc)
|
||||
.body_offsets
|
||||
.insert("Sleeve".into(), [[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., -50., 0., 1.]]);
|
||||
s.refresh();
|
||||
assert!(s.doc.body_offsets.contains_key("Sleeve"));
|
||||
let offset = s.doc.body_offsets["Sleeve"];
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
@@ -399,3 +435,122 @@ fn coil_and_loft_to_a_point() {
|
||||
assert!(rel(volume(&mut s), 400.0 * 30.0 / 3.0) < 1e-6);
|
||||
assert!(s.execute("solid.loft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_auto_inference_saves_a_profile_anchor_and_replays_both_depth_signs() {
|
||||
use solvecraft_geom::Vec3;
|
||||
for mode in ["emboss", "deboss"] {
|
||||
let mut s = cylinder_badge();
|
||||
let before = exact_cylinder_volume(&s);
|
||||
let initial = s.doc.to_json();
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","depth":"badge_depth","mode":mode,"name":"Auto Badge"}));
|
||||
let feature = s.doc.find_feature("Auto Badge").unwrap().clone();
|
||||
let solvecraft_doc::FeatureKind::Emboss { face: Some(face), targets, .. } = &feature.kind else {
|
||||
panic!("missing inferred association");
|
||||
};
|
||||
assert_eq!(targets, &["Sleeve"]);
|
||||
assert_eq!(face.body, "Sleeve");
|
||||
assert!(face.at.z > 0. && face.at.z < 20.);
|
||||
assert!((face.at.x.hypot(face.at.y) - 10.).abs() < 1e-10);
|
||||
assert!(!feature.face_names.first().unwrap().is_empty());
|
||||
let expected = if mode == "emboss" { 21. } else { -19. };
|
||||
assert!((exact_cylinder_volume(&s) - before - expected).abs() < 0.02);
|
||||
let state = s.model.state();
|
||||
let body = &state.body("Sleeve").unwrap().body;
|
||||
assert_eq!(body.shell_count(), 1);
|
||||
assert_eq!(
|
||||
body.tessellate_with(0.001, true).unwrap().contains(Vec3::new(0., if mode == "emboss" { 10.5 } else { 9.5 }, 7.5)),
|
||||
mode == "emboss"
|
||||
);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), initial);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.find_feature("Auto Badge").unwrap().kind, feature.kind);
|
||||
let mut loaded = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Default::default() };
|
||||
loaded.refresh();
|
||||
assert!(loaded.model.results.iter().all(|r| r.error.is_none()), "{:?}", loaded.model.results);
|
||||
run(
|
||||
&mut loaded,
|
||||
"timeline.redefine",
|
||||
json!({"feature":feature.id,"command":"solid.emboss","params":{"sketch":"Badge","profiles":"all","depth":"badge_depth","mode":mode}}),
|
||||
);
|
||||
assert_eq!(loaded.doc.feature(feature.id).unwrap().kind, feature.kind);
|
||||
assert_eq!(loaded.doc.feature(feature.id).unwrap().face_names, feature.face_names);
|
||||
run(&mut loaded, "parameters.change", json!({"name":"badge_depth","expression":"2mm"}));
|
||||
assert!((exact_cylinder_volume(&loaded) - before - if mode == "emboss" { 44. } else { -36. }).abs() < 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_auto_inference_ignores_remote_finite_tangent_walls_in_each_body_order() {
|
||||
for remote_first in [false, true] {
|
||||
let mut s = Session::default();
|
||||
for (name, z) in if remote_first { [("Remote", 100.), ("Sleeve", 0.)] } else { [("Sleeve", 0.), ("Remote", 100.)] } {
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"base":[0,0,z],"body_name":name}));
|
||||
}
|
||||
run(&mut s, "solid.cylinder", json!({"radius":8,"height":10,"base":[0,0,120],"body_name":"Remote Step"}));
|
||||
run(&mut s, "solid.combine", json!({"target":"Remote","tools":["Remote Step"],"operation":"join"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before = exact_cylinder_volume(&s);
|
||||
let remote = s.model.state().body("Remote").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","depth":1}));
|
||||
assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02);
|
||||
assert_eq!(s.model.state().body("Remote").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume, remote);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_auto_inference_retains_selected_holes_and_nested_islands() {
|
||||
use solvecraft_geom::Vec3;
|
||||
for mode in ["emboss", "deboss"] {
|
||||
let mut s = cylinder_badge();
|
||||
run(&mut s, "sketch.edit", json!({"sketch":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-1,6],"p1":[1,9]}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-0.5,6.5],"p1":[0.5,8.5]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before = exact_cylinder_volume(&s);
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":[{"point":[1.5,7.5]},{"point":[0,7.5]}],"depth":1,"mode":mode}));
|
||||
let depth = if mode == "emboss" { 1. } else { -1. };
|
||||
let expected = 16. * (depth + depth * depth / 20.);
|
||||
assert!((exact_cylinder_volume(&s) - before - expected).abs() < 0.02);
|
||||
let st = s.model.state();
|
||||
let body = &st.body("Sleeve").unwrap().body;
|
||||
assert_eq!(body.shell_count(), 1);
|
||||
let mesh = body.tessellate_with(0.001, true).unwrap();
|
||||
for (s, raised) in [(0., true), (0.75, false), (1.5, true)] {
|
||||
let theta = s / 10.0_f64;
|
||||
let radius = if mode == "emboss" { 10.5 } else { 9.5 };
|
||||
assert_eq!(mesh.contains(Vec3::new(-radius * theta.sin(), radius * theta.cos(), 7.5)), if mode == "emboss" { raised } else { !raised });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_auto_inference_refuses_ambiguity_curves_and_seam_crossing_atomically() {
|
||||
let mut s = cylinder_badge();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Coincident"}));
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("solid.emboss", &json!({"sketch":"Badge","depth":1})).unwrap_err().to_string();
|
||||
assert!(error.contains("multiple tangent cylinders"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","depth":1,"targets":["Sleeve"]}));
|
||||
assert!(matches!(&s.doc.features.last().unwrap().kind,solvecraft_doc::FeatureKind::Emboss{face:Some(face),..} if face.body=="Sleeve"));
|
||||
for circle in [false, true] {
|
||||
let mut s = cylinder_badge();
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Unsupported"}));
|
||||
if circle {
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,7.5],"radius":1}));
|
||||
} else {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-20,5],"p1":[20,10]}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before = s.doc.to_json();
|
||||
let volume = exact_cylinder_volume(&s);
|
||||
let error = s.execute("solid.emboss", &json!({"sketch":"Unsupported","depth":1})).unwrap_err().to_string();
|
||||
assert!(error.contains(if circle { "orthogonal line" } else { "seam" }), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(exact_cylinder_volume(&s), volume);
|
||||
}
|
||||
}
|
||||
@@ -401,7 +401,7 @@ fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "too many face appearances"));
|
||||
}
|
||||
let name = st_names.iter().find(|(b, p, _)| b == body && p.dist(*pt) < 1e-12).and_then(|(_, _, n)| n.clone());
|
||||
a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone(), name, strict_name: false });
|
||||
a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone(), name, strict_name: false, certificate: None });
|
||||
}
|
||||
}
|
||||
for c in &comps {
|
||||
|
||||
@@ -85,7 +85,7 @@ pub(crate) fn occurrence_at(s: &Session, p: Vec3) -> Option<u64> {
|
||||
let st = s.model.state();
|
||||
let mut best: Option<(f64, u64)> = None;
|
||||
for b in &st.bodies {
|
||||
let comp = s.doc.body_component(&b.name, b.feature);
|
||||
let comp = s.doc.model_body_component(b);
|
||||
let occs: Vec<u64> = if comp == 0 { vec![0] } else { s.doc.occurrences.iter().filter(|o| o.component == comp).map(|o| o.id).collect() };
|
||||
for occ in occs {
|
||||
let t = if occ == 0 { solvecraft_doc::IDENTITY } else { joints::occurrence_world(&s.doc, occ) };
|
||||
|
||||
@@ -89,11 +89,11 @@ fn placed(s: &Session) -> Vec<(Vec<u64>, Body)> {
|
||||
if comp == 0 {
|
||||
continue;
|
||||
}
|
||||
for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp && !b.body.is_mesh()) {
|
||||
for b in st.bodies.iter().filter(|b| s.doc.model_body_component(b) == comp && !b.body.is_mesh()) {
|
||||
// Moving a body into another component preserves its world placement using an
|
||||
// offset from its authoring frame. Contact and explode centres must use that
|
||||
// same offset as display and physical properties.
|
||||
let frame = s.doc.body_offsets.get(&b.name).map(|offset| solvecraft_doc::mat_mul(&m, offset)).unwrap_or(m);
|
||||
let frame = s.doc.model_body_offset(b).map(|offset| solvecraft_doc::mat_mul(&m, offset)).unwrap_or(m);
|
||||
if let Ok(t) = solvecraft_kernel::transform_matrix(&b.body, frame) {
|
||||
out.push((path.clone(), t));
|
||||
}
|
||||
|
||||
@@ -305,7 +305,12 @@ fn hostile_moves_copies_and_reused_ids_do_not_leak_state() {
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
assert!(s.pending_moves.is_empty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.occurrence_placement(&[other]).unwrap().world[3][0], 0.0);
|
||||
assert_eq!(s.pending_moves.get(&other).unwrap()[3][0], 20.0);
|
||||
assert_eq!(s.doc.occurrences.iter().find(|occurrence| occurrence.id == other).unwrap().transform[3][0], 0.0);
|
||||
assert_eq!(s.occurrence_placement(&[other]).unwrap().world[3][0], 20.0);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(s.pending_moves.is_empty());
|
||||
assert_eq!(s.doc.occurrences.iter().find(|occurrence| occurrence.id == other).unwrap().transform[3][0], 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -464,7 +464,7 @@ mod tests {
|
||||
let st = s.world_state();
|
||||
let comp = s.doc.components.iter().find(|c| c.name.starts_with(prefix)).map(|c| c.id).unwrap_or(0);
|
||||
let (mut lo, mut hi) = (solvecraft_geom::Vec3::new(f64::MAX, f64::MAX, f64::MAX), solvecraft_geom::Vec3::new(f64::MIN, f64::MIN, f64::MIN));
|
||||
for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp) {
|
||||
for b in st.bodies.iter().filter(|b| s.doc.model_body_component(b) == comp) {
|
||||
let m = b.mesh().bounds();
|
||||
lo = solvecraft_geom::Vec3::new(lo.x.min(m.min.x), lo.y.min(m.min.y), lo.z.min(m.min.z));
|
||||
hi = solvecraft_geom::Vec3::new(hi.x.max(m.max.x), hi.y.max(m.max.y), hi.z.max(m.max.z));
|
||||
|
||||
@@ -293,8 +293,24 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
|
||||
"allowance": b.allowance, "length": b.length, "start": pt(&b.start), "end": pt(&b.end)})
|
||||
})
|
||||
.collect();
|
||||
let pockets: Vec<Value> = sh
|
||||
.machining()?
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let side = if p.z0 == 0.0 {
|
||||
"bottom"
|
||||
} else if p.z1 == sh.t {
|
||||
"top"
|
||||
} else {
|
||||
"internal"
|
||||
};
|
||||
json!({"outline": p.region.outer.polyline(1e-3).iter().map(pt).collect::<Vec<_>>(),
|
||||
"cutouts": p.region.holes.iter().map(|h| h.polyline(1e-3).iter().map(pt).collect::<Vec<_>>()).collect::<Vec<_>>(),
|
||||
"side": side, "depth_mm": p.z1-p.z0, "z0_mm": p.z0, "z1_mm": p.z1})
|
||||
})
|
||||
.collect();
|
||||
Ok(
|
||||
json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "bends": bends, "unfolded": sh.flat}),
|
||||
json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "pockets": pockets, "bends": bends, "unfolded": sh.flat}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -305,6 +321,22 @@ fn flat_pattern(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// A DXF (R12 entities, mm) of the flat pattern on named layers.
|
||||
pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
let regions = sh.flat_regions()?;
|
||||
let pockets = sh.machining()?;
|
||||
let pocket_layer = |p: &solvecraft_doc::sheet::SheetPocket| {
|
||||
let side = if p.z0 == 0.0 {
|
||||
"BOTTOM"
|
||||
} else if p.z1 == sh.t {
|
||||
"TOP"
|
||||
} else {
|
||||
"INTERNAL"
|
||||
};
|
||||
format!("POCKET_{side}_Z{:.6}_TO_{:.6}_MM", p.z0, p.z1)
|
||||
};
|
||||
let mut layers = std::collections::BTreeSet::from(["OUTLINE".to_string(), "BEND_UP".to_string(), "BEND_DOWN".to_string()]);
|
||||
for p in &pockets {
|
||||
layers.insert(pocket_layer(p));
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut w = |c: i32, v: &str| out.push_str(&format!("{c}\n{v}\n"));
|
||||
w(0, "SECTION");
|
||||
@@ -316,8 +348,14 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
w(2, "TABLES");
|
||||
w(0, "TABLE");
|
||||
w(2, "LAYER");
|
||||
w(70, "3");
|
||||
for (name, colour) in [("OUTLINE", "7"), ("BEND_UP", "1"), ("BEND_DOWN", "5")] {
|
||||
w(70, &layers.len().to_string());
|
||||
for name in &layers {
|
||||
let colour = match name.as_str() {
|
||||
"OUTLINE" => "7",
|
||||
"BEND_UP" => "1",
|
||||
"BEND_DOWN" => "5",
|
||||
_ => "3",
|
||||
};
|
||||
w(0, "LAYER");
|
||||
w(2, name);
|
||||
w(70, "0");
|
||||
@@ -337,8 +375,13 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
w(11, &f(b.x));
|
||||
w(21, &f(b.y));
|
||||
};
|
||||
let regions = sh.flat_regions()?;
|
||||
for h in regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)) {
|
||||
let mut loops: Vec<_> = regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|lp| (lp, "OUTLINE".to_string())).collect();
|
||||
for p in &pockets {
|
||||
for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) {
|
||||
loops.push((lp, pocket_layer(p)));
|
||||
}
|
||||
}
|
||||
for (h, layer) in loops {
|
||||
// A circle is often represented by two semicircles. Keep the whole analytic
|
||||
// boundary as one CIRCLE rather than approximating outer boundaries with chords.
|
||||
if let Some(solvecraft_geom::Seg2::Arc { center, radius, sweep, .. }) = h.segs.first() {
|
||||
@@ -346,7 +389,7 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
let circular = h.segs.iter().all(|s| matches!(s, solvecraft_geom::Seg2::Arc { center: c, radius: r, sweep: a, .. } if c.dist(*center) < 1e-9 && (*r-*radius).abs() < 1e-9 && a.signum() == sweep.signum()));
|
||||
if circular && (total.abs() - std::f64::consts::TAU).abs() < 1e-9 {
|
||||
w(0, "CIRCLE");
|
||||
w(8, "OUTLINE");
|
||||
w(8, &layer);
|
||||
w(10, &f(center.x));
|
||||
w(20, &f(center.y));
|
||||
w(40, &f(*radius));
|
||||
@@ -358,7 +401,7 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
solvecraft_geom::Seg2::Arc { center, radius, start, sweep } => {
|
||||
if (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9 {
|
||||
w(0, "CIRCLE");
|
||||
w(8, "OUTLINE");
|
||||
w(8, &layer);
|
||||
w(10, &f(center.x));
|
||||
w(20, &f(center.y));
|
||||
w(40, &f(radius));
|
||||
@@ -366,7 +409,7 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
}
|
||||
let (a0, a1) = if sweep >= 0.0 { (start, start + sweep) } else { (start + sweep, start) };
|
||||
w(0, "ARC");
|
||||
w(8, "OUTLINE");
|
||||
w(8, &layer);
|
||||
w(10, &f(center.x));
|
||||
w(20, &f(center.y));
|
||||
w(40, &f(radius));
|
||||
@@ -376,7 +419,7 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
other => {
|
||||
let pts = other.polyline(1e-3);
|
||||
for p in pts.windows(2) {
|
||||
line(&mut w, "OUTLINE", p[0], p[1]);
|
||||
line(&mut w, &layer, p[0], p[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,363 @@ fn base(s: &mut Session, w: f64, h: f64) {
|
||||
run(s, "sheet.flange", json!({"sketch": "Base"}));
|
||||
}
|
||||
|
||||
fn rectangle_tool(s: &mut Session, name: &str, p0: [f64; 2], p1: [f64; 2]) {
|
||||
run(s, "sketch.create", json!({"plane":"XY","name":name}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0":p0,"p1":p1}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shallow_sheet_pocket_preserves_material_depth_export_undo_and_reopen() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"}));
|
||||
assert!((volume(&mut s) - 14900.0).abs() < 1e-6);
|
||||
let f = flat(&mut s);
|
||||
assert_eq!(f["cutouts"], json!([]));
|
||||
assert_eq!(f["pockets"][0]["side"], "bottom");
|
||||
assert_eq!(f["pockets"][0]["depth_mm"], 1.0);
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
assert!(dxf.contains("POCKET_BOTTOM_Z0.000000_TO_1.000000_MM"));
|
||||
assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 8);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!((volume(&mut s) - 15000.0).abs() < 1e-6);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
for command in ["sheet.unfold", "sheet.refold"] {
|
||||
run(&mut s, command, json!({}));
|
||||
assert!((volume(&mut s) - 14900.0).abs() < 1e-6);
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
assert!(!body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, 0.5)));
|
||||
assert!(body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, 1.5)));
|
||||
}
|
||||
let mut reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((volume(&mut reopened) - 14900.0).abs() < 1e-6);
|
||||
assert_eq!(flat(&mut reopened)["pockets"], f["pockets"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planar_sheet_intersection_retains_exact_outline_and_islands() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Retain", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Retain","through_all":true,"direction":"symmetric","operation":"intersect"}));
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({}));
|
||||
}
|
||||
assert!((volume(&mut s) - 250.0).abs() < 1e-6);
|
||||
assert_eq!(flat(&mut s)["flat_size_mm"], json!([10.0, 10.0, 2.5]));
|
||||
}
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 4);
|
||||
let mut imported = Session::default();
|
||||
run(&mut imported, "sketch.insert_dxf", json!({"name":"Retained","text":dxf}));
|
||||
run(&mut imported, "sketch.finish", json!({}));
|
||||
run(&mut imported, "solid.extrude", json!({"sketch":"Retained","distance":2.5}));
|
||||
assert!((volume(&mut imported) - 250.0).abs() < 1e-6);
|
||||
// An annular retained region keeps its central hole, with no first-solid shortcut.
|
||||
let mut annulus = Session::default();
|
||||
base(&mut annulus, 100.0, 60.0);
|
||||
run(&mut annulus, "sketch.create", json!({"plane":"XY","name":"Ring"}));
|
||||
run(&mut annulus, "sketch.circle.center", json!({"center":[50,30],"radius":10}));
|
||||
run(&mut annulus, "sketch.circle.center", json!({"center":[50,30],"radius":5}));
|
||||
run(&mut annulus, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut annulus,
|
||||
"solid.extrude",
|
||||
json!({"sketch":"Ring","profiles":[{"point":[57,30]}],"through_all":true,"direction":"symmetric","operation":"intersect"}),
|
||||
);
|
||||
run(&mut annulus, "sheet.unfold", json!({}));
|
||||
let area: f64 = annulus.model.state().sheets[0].flat_regions().unwrap().iter().map(|r| r.area()).sum();
|
||||
assert!((area - PI * 75.0).abs() < 1e-8, "exact retained area: {area}");
|
||||
// inspect.measure integrates a tessellation; exact arc area is checked above.
|
||||
let measured = volume(&mut annulus);
|
||||
assert!((measured - PI * 75.0 * 2.5).abs() < 0.1, "measured={measured}");
|
||||
assert_eq!(flat(&mut annulus)["cutouts"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_opposite_side_pockets_become_true_through_material() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Bottom", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Bottom","distance":1.5,"operation":"cut"}));
|
||||
rectangle_tool(&mut s, "Top", [25.0, 20.0], [35.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Top","start_offset":2.5,"direction":"negative","distance":1.5,"operation":"cut"}));
|
||||
let expected = 15000.0 - 100.0 * 1.5 * 2.0 + 50.0 * 0.5;
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({}));
|
||||
}
|
||||
assert!((volume(&mut s) - expected).abs() < 1e-6);
|
||||
assert_eq!(flat(&mut s)["cutouts"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(flat(&mut s)["pockets"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pockets_survive_forming_away_from_bends_and_refuse_bend_overlap_atomically() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"}));
|
||||
run(&mut s, "sheet.flange", json!({"edges":[[50,0,2.5]],"height":20}));
|
||||
assert!((volume(&mut s) - (18972.62196081845 - 100.0)).abs() < 0.2);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
let unfolded = volume(&mut s);
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - (18972.62196081845 - 100.0)).abs() < 0.2);
|
||||
assert!(unfolded > 14900.0);
|
||||
let mut cross = Session::default();
|
||||
base(&mut cross, 100.0, 60.0);
|
||||
rectangle_tool(&mut cross, "Pocket", [45.0, 20.0], [55.0, 30.0]);
|
||||
run(&mut cross, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"}));
|
||||
let before = cross.doc.to_json();
|
||||
let undo = cross.undo.len();
|
||||
let error = cross.execute("sheet.fold", &json!({"points":[[50,0,2.5],[50,60,2.5]]})).unwrap_err().to_string();
|
||||
assert!(error.contains("pocket") && error.contains("bend"), "{error}");
|
||||
assert_eq!(cross.doc.to_json(), before);
|
||||
assert_eq!(cross.undo.len(), undo);
|
||||
assert!((volume(&mut cross) - 14900.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_sheet_machining_is_atomic_but_true_no_change_is_allowed() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Taper", [20.0, 20.0], [30.0, 30.0]);
|
||||
let before = s.doc.to_json();
|
||||
let undo = s.undo.len();
|
||||
let error = s.execute("solid.extrude", &json!({"sketch":"Taper","distance":1,"operation":"cut","taper":"5 deg"})).unwrap_err().to_string();
|
||||
assert!(error.contains("sheet") && error.contains("preserv"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
rectangle_tool(&mut s, "Outside", [120.0, 20.0], [130.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Outside","distance":1,"operation":"cut","taper":"5 deg"}));
|
||||
assert!((volume(&mut s) - 15000.0).abs() < 1e-6);
|
||||
rectangle_tool(&mut s, "All", [-20.0, -20.0], [120.0, 80.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"All","through_all":true,"direction":"symmetric","operation":"intersect"}));
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - 15000.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sheet_intersection_removes_geometry_and_metadata_together() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Outside", [120.0, 20.0], [130.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Outside","through_all":true,"direction":"symmetric","operation":"intersect"}));
|
||||
assert!(s.model.state().bodies.is_empty());
|
||||
assert!(s.model.state().sheets.is_empty());
|
||||
assert!(s.execute("sheet.unfold", &json!({})).is_err());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!((volume(&mut s) - 15000.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_preserves_both_material_regions_of_a_logical_ring_sheet() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Ring"}));
|
||||
for radius in [10.0, 5.0] {
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[50,30],"radius":radius}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch":"Ring","profiles":[{"point":[57,30]}],"through_all":true,"direction":"symmetric","operation":"cut"}),
|
||||
);
|
||||
rectangle_tool(&mut s, "Crop", [35.0, 15.0], [65.0, 45.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Crop","through_all":true,"direction":"symmetric","operation":"intersect"}));
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({}));
|
||||
}
|
||||
let state = s.model.state();
|
||||
let regions = state.sheets[0].flat_regions().unwrap();
|
||||
assert_eq!(regions.len(), 2);
|
||||
let area: f64 = regions.iter().map(|r| r.area()).sum();
|
||||
assert!((area - (900.0 - PI * 75.0)).abs() < 1e-8, "area={area}");
|
||||
let body = &state.bodies[0].body;
|
||||
assert!(body.contains(solvecraft_geom::Vec3::new(50.0, 30.0, 1.0)));
|
||||
assert!(!body.contains(solvecraft_geom::Vec3::new(57.0, 30.0, 1.0)));
|
||||
assert!(body.contains(solvecraft_geom::Vec3::new(64.0, 30.0, 1.0)));
|
||||
assert!(!body.contains(solvecraft_geom::Vec3::new(70.0, 30.0, 1.0)));
|
||||
assert!((volume(&mut s) - (900.0 - PI * 75.0) * 2.5).abs() < 0.2);
|
||||
}
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machining_a_folded_parallel_panel_preserves_its_pocket_on_unfold() {
|
||||
use solvecraft_doc::{apply_point, apply_vector};
|
||||
use solvecraft_geom::Vec3;
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sheet.flange", json!({"edges":[[50,0,2.5]],"height":20}));
|
||||
let sh = s.model.state().sheets[0].clone();
|
||||
let points = sh.panel(&sh.flanges[0]).outer.polyline(1e-3);
|
||||
let c = points.iter().copied().fold(solvecraft_geom::Vec2::ZERO, |sum, p| sum + p) / (points.len() as f64);
|
||||
let w = sh.world(Some(0));
|
||||
let origin = apply_point(&w, Vec3::ZERO);
|
||||
let x = apply_vector(&w, Vec3::X);
|
||||
let y = apply_vector(&w, Vec3::Y);
|
||||
run(
|
||||
&mut s,
|
||||
"sketch.create",
|
||||
json!({"name":"PanelPocket","plane":{"origin":[origin.x,origin.y,origin.z],"x_dir":[x.x,x.y,x.z],"y_dir":[y.x,y.y,y.z]}}),
|
||||
);
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[c.x-2.0,c.y-2.0],"p1":[c.x+2.0,c.y+2.0]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"PanelPocket","distance":1,"operation":"cut"}));
|
||||
assert!((volume(&mut s) - (18972.62196081845 - 16.0)).abs() < 0.2);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
assert!(!body.contains(Vec3::new(c.x, c.y, 0.5)));
|
||||
assert!(body.contains(Vec3::new(c.x, c.y, 1.5)));
|
||||
assert_eq!(flat(&mut s)["pockets"][0]["depth_mm"], 1.0);
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - (18972.62196081845 - 16.0)).abs() < 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flipped_sheet_pocket_keeps_depth_in_the_local_thickness_direction() {
|
||||
let mut s = Session::default();
|
||||
rectangle_tool(&mut s, "Base", [0.0, 0.0], [100.0, 60.0]);
|
||||
run(&mut s, "sheet.flange", json!({"sketch":"Base","flip":true}));
|
||||
rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"direction":"negative","operation":"cut"}));
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({}));
|
||||
}
|
||||
assert!((volume(&mut s) - 14900.0).abs() < 1e-6);
|
||||
let state = s.model.state();
|
||||
assert!(!state.bodies[0].body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, -0.5)));
|
||||
assert!(state.bodies[0].body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, -1.5)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_profile_intersect_keeps_tool_union_for_solids_and_sheet_groups() {
|
||||
for sheet in [false, true] {
|
||||
let mut s = Session::default();
|
||||
if sheet {
|
||||
base(&mut s, 100.0, 60.0);
|
||||
} else {
|
||||
run(&mut s, "solid.box", json!({"length":100,"width":60,"height":2.5,"body_name":"Blank"}));
|
||||
}
|
||||
let target = s.model.state().bodies[0].name.clone();
|
||||
run(&mut s, "solid.box", json!({"corner":[200,0,0],"length":10,"width":10,"height":10,"body_name":"Control"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"TwoIslands"}));
|
||||
for x in [20.0, 60.0] {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[x,20],"p1":[x+10.0,30]}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"TwoIslands","operation":"intersect","distance":3,"targets":[target]}));
|
||||
assert!((volume(&mut s) - 1500.0).abs() < 1e-6);
|
||||
if sheet {
|
||||
for command in ["sheet.unfold", "sheet.refold"] {
|
||||
run(&mut s, command, json!({"body":target}));
|
||||
assert!((volume(&mut s) - 1500.0).abs() < 1e-6);
|
||||
assert_eq!(flat(&mut s)["outline"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
let state = s.model.state();
|
||||
for x in [25.0, 65.0] {
|
||||
assert!(state.bodies.iter().any(|b| b.body.contains(solvecraft_geom::Vec3::new(x, 25.0, 1.0))));
|
||||
}
|
||||
assert!(!state.bodies.iter().any(|b| b.body.contains(solvecraft_geom::Vec3::new(50.0, 25.0, 1.0))));
|
||||
assert!(state.bodies.iter().any(|b| b.name == "Control" && b.body.contains(solvecraft_geom::Vec3::new(205.0, 5.0, 5.0))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_sheet_material_prunes_cosmetic_threads_and_undo_restores_them() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"ThreadPlate"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[100,60]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[50,30],"radius":5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch":"ThreadPlate","profiles":[{"point":[10,10]}]}));
|
||||
run(&mut s, "solid.thread", json!({"face":[55,30,1],"designation":"M10"}));
|
||||
assert_eq!(s.model.state().threads.len(), 1);
|
||||
rectangle_tool(&mut s, "Outside", [120.0, 20.0], [130.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Outside","through_all":true,"direction":"symmetric","operation":"intersect"}));
|
||||
assert!(s.model.state().bodies.is_empty());
|
||||
assert!(s.model.state().sheets.is_empty());
|
||||
assert!(s.model.state().threads.is_empty());
|
||||
assert!(solvecraft_doc::world_state(&s.doc, &s.model.state()).threads.is_empty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.model.state().threads.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_pocket_transfer_keeps_local_depth_world_pose_and_history() {
|
||||
use solvecraft_geom::Vec3;
|
||||
fn check(s: &Session, name: &str, owner: u64, local_origin: Vec3, local_y: Vec3, transferred: bool) {
|
||||
let state = s.model.state();
|
||||
let mb = state.body(name).unwrap();
|
||||
assert_eq!(mb.component, owner);
|
||||
assert_eq!(mb.transferred, transferred);
|
||||
let sh = state.sheets.iter().find(|sh| sh.body == name).unwrap();
|
||||
assert!(sh.frame.origin.dist(local_origin) < 1e-8, "{:?}", sh.frame);
|
||||
assert!(sh.frame.x.dist(Vec3::X) < 1e-8);
|
||||
assert!(sh.frame.y.dist(local_y) < 1e-8);
|
||||
assert_eq!(sh.pockets.len(), 1);
|
||||
assert_eq!((sh.pockets[0].z0, sh.pockets[0].z1), (0.0, 1.0));
|
||||
let cut = sh.frame.to_world(solvecraft_geom::Vec2::new(25.0, 25.0)) + sh.frame.normal() * 0.5;
|
||||
let floor = sh.frame.to_world(solvecraft_geom::Vec2::new(25.0, 25.0)) + sh.frame.normal() * 1.5;
|
||||
assert!(!mb.body.contains(cut));
|
||||
assert!(mb.body.contains(floor));
|
||||
let world = s.world_state();
|
||||
let body = &world.body(name).unwrap().body;
|
||||
let bounds = body.tessellate(1e-3).unwrap().bounds();
|
||||
assert!(bounds.min.dist(Vec3::ZERO) < 1e-6, "{bounds:?}");
|
||||
assert!(bounds.max.dist(Vec3::new(100.0, 60.0, 2.5)) < 1e-6, "{bounds:?}");
|
||||
assert!(!body.contains(Vec3::new(25.0, 25.0, 0.5)));
|
||||
assert!(body.contains(Vec3::new(25.0, 25.0, 1.5)));
|
||||
assert!((solvecraft_kernel::measure(body).unwrap().volume - 14900.0).abs() < 1e-6);
|
||||
}
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"}));
|
||||
let name = s.model.state().sheets[0].body.clone();
|
||||
check(&s, &name, 0, Vec3::ZERO, Vec3::Y, false);
|
||||
let destination = run(&mut s, "component.create", json!({"name":"Machined","parent":0}));
|
||||
let component = destination["component"].as_u64().unwrap();
|
||||
let occurrence = destination["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,10],"axis":[1,0,0],"angle":"90 deg","capture":true}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":component,"bodies":[name]}));
|
||||
assert!(!s.doc.body_offsets.contains_key(&name), "recorded transfer must not leave a legacy placement offset");
|
||||
let origin = Vec3::new(-30.0, -10.0, 20.0);
|
||||
let y = Vec3::new(0.0, 0.0, -1.0);
|
||||
check(&s, &name, component, origin, y, true);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
check(&s, &name, 0, Vec3::ZERO, Vec3::Y, false);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
check(&s, &name, component, origin, y, true);
|
||||
run(&mut s, "component.activate", json!({"component":component}));
|
||||
for command in ["sheet.unfold", "sheet.refold"] {
|
||||
run(&mut s, command, json!({"body":name}));
|
||||
check(&s, &name, component, origin, y, true);
|
||||
let f = run(&mut s, "sheet.flat_pattern", json!({"body":name}));
|
||||
assert_eq!(f["pockets"][0]["side"], "bottom");
|
||||
assert_eq!(f["pockets"][0]["depth_mm"], 1.0);
|
||||
}
|
||||
let mut reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
check(&reopened, &name, component, origin, y, true);
|
||||
run(&mut reopened, "component.activate", json!({"component":component}));
|
||||
run(&mut reopened, "sheet.unfold", json!({"body":name}));
|
||||
check(&reopened, &name, component, origin, y, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_and_edge_flange_follow_the_k_factor() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::Vec3;
|
||||
use solvecraft_sketch::WireSpace;
|
||||
|
||||
use super::sketch::edit;
|
||||
use super::sketch::{edit, target_sketch};
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, req_vec3, vec3};
|
||||
use crate::{Result, Session};
|
||||
@@ -35,32 +36,71 @@ fn needs_3d(sk: &solvecraft_sketch::Sketch, cmd: &str) -> Result<()> {
|
||||
if sk.view.three_d { Ok(()) } else { Err(bad(cmd, "turn on 3D Sketch (sketch.options sketch_3d: true) to draw off the sketch plane")) }
|
||||
}
|
||||
|
||||
/// World command inputs belong to the target sketch's owner, which may differ from the
|
||||
/// active component when a script supplies an explicit sketch.
|
||||
fn inverse_owner_frame(s: &Session, p: &Value, cmd: &str) -> Result<solvecraft_doc::Mat> {
|
||||
let id = target_sketch(s, p, cmd)?;
|
||||
let owner = s.doc.feature(id).map(|f| f.component).ok_or_else(|| bad(cmd, "target sketch is missing"))?;
|
||||
if owner != 0 && owner != s.active_component {
|
||||
if s.doc.occurrences.len() > 100_000 {
|
||||
return Err(bad(cmd, "sketch owner placement expansion exceeds supported bounds"));
|
||||
}
|
||||
let mut children: std::collections::BTreeMap<u64, Vec<&solvecraft_doc::Occurrence>> = std::collections::BTreeMap::new();
|
||||
for occurrence in &s.doc.occurrences {
|
||||
children.entry(occurrence.parent).or_default().push(occurrence);
|
||||
}
|
||||
let mut stack = vec![(0, 0usize)];
|
||||
let mut count = 0;
|
||||
let mut visited = 0;
|
||||
while let Some((component, depth)) = stack.pop() {
|
||||
for o in children.get(&component).into_iter().flatten() {
|
||||
visited += 1;
|
||||
if depth >= 64 || visited > 100_000 {
|
||||
return Err(bad(cmd, "sketch owner placement expansion exceeds supported bounds"));
|
||||
}
|
||||
if o.component == owner {
|
||||
count += 1;
|
||||
if count > 1 {
|
||||
return Err(bad(cmd, "activate the desired sketch owner occurrence before editing world 3D points"));
|
||||
}
|
||||
}
|
||||
stack.push((o.component, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
let frame = if owner == s.active_component { s.active_component_frame() } else { s.placed_document().component_transform(owner) };
|
||||
solvecraft_doc::mat_inverse(&frame).ok_or_else(|| bad(cmd, "the sketch owner placement is not invertible"))
|
||||
}
|
||||
|
||||
fn line3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.line3d";
|
||||
let pts = points(cmd, p, 2)?;
|
||||
let inverse = inverse_owner_frame(s, p, cmd)?;
|
||||
let pts: Vec<Vec3> = points(cmd, p, 2)?.into_iter().map(|at| solvecraft_doc::apply_point(&inverse, at)).collect();
|
||||
let (ids, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
needs_3d(sk, cmd)?;
|
||||
pts.windows(2).map(|w| sk.add_drawn_wire(w).map_err(Into::into)).collect::<Result<Vec<String>>>()
|
||||
pts.windows(2).map(|w| sk.add_drawn_wire_in(w, WireSpace::Component).map_err(Into::into)).collect::<Result<Vec<String>>>()
|
||||
})?;
|
||||
Ok(json!({"curves": ids, "sketch": info}))
|
||||
}
|
||||
|
||||
fn spline3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.spline3d";
|
||||
let pts = points(cmd, p, 2)?;
|
||||
let inverse = inverse_owner_frame(s, p, cmd)?;
|
||||
let pts: Vec<Vec3> = points(cmd, p, 2)?.into_iter().map(|at| solvecraft_doc::apply_point(&inverse, at)).collect();
|
||||
let (id, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
needs_3d(sk, cmd)?;
|
||||
Ok(sk.add_drawn_wire(&pts)?)
|
||||
Ok(sk.add_drawn_wire_in(&pts, WireSpace::Component)?)
|
||||
})?;
|
||||
Ok(json!({"curves": [id], "sketch": info}))
|
||||
}
|
||||
|
||||
fn point3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.point3d";
|
||||
let at = req_vec3(cmd, p, "point")?;
|
||||
let inverse = inverse_owner_frame(s, p, cmd)?;
|
||||
let at = solvecraft_doc::apply_point(&inverse, req_vec3(cmd, p, "point")?);
|
||||
let (id, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
needs_3d(sk, cmd)?;
|
||||
Ok(sk.add_drawn_wire(&[at])?)
|
||||
Ok(sk.add_drawn_wire_in(&[at], WireSpace::Component)?)
|
||||
})?;
|
||||
Ok(json!({"point": id, "sketch": info}))
|
||||
}
|
||||
@@ -68,23 +108,41 @@ fn point3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn move3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.move3d";
|
||||
let wire = p.get("wire").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`wire` must be a 3D curve or point id"))?.to_string();
|
||||
let index = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let to = p.get("to").and_then(vec3);
|
||||
let by = p.get("by").and_then(vec3);
|
||||
let index = match p.get("index") {
|
||||
None => 0,
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.and_then(|v| usize::try_from(v).ok())
|
||||
.filter(|v| *v < solvecraft_sketch::MAX_FIT_POINTS)
|
||||
.ok_or_else(|| bad(cmd, "`index` must be a fit point index below 500"))?,
|
||||
};
|
||||
let parse = |name| p.get(name).map(|v| vec3(v).ok_or_else(|| bad(cmd, format!("`{name}` must be [x,y,z]")))).transpose();
|
||||
let to = parse("to")?;
|
||||
let by = parse("by")?;
|
||||
let inverse = inverse_owner_frame(s, p, cmd)?;
|
||||
let (n, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let from = sk
|
||||
let drawn = sk
|
||||
.wire_index(&wire)
|
||||
.and_then(|w| sk.wires.get(w))
|
||||
.filter(|w| !w.fit.is_empty())
|
||||
.and_then(|w| w.fit.get(index))
|
||||
.copied()
|
||||
.ok_or_else(|| bad(cmd, format!("`{wire}` has no drawn point {index}")))?;
|
||||
.filter(|w| !w.fit.is_empty() && w.link.is_none())
|
||||
.ok_or_else(|| bad(cmd, format!("`{wire}` is not an editable drawn 3D curve")))?;
|
||||
let from = drawn.fit.get(index).copied().ok_or_else(|| bad(cmd, format!("`{wire}` has no drawn point {index}")))?;
|
||||
let to = match (to, by) {
|
||||
(Some(t), _) => t,
|
||||
(None, Some(d)) => from + d,
|
||||
(Some(t), _) => {
|
||||
if drawn.space == WireSpace::Component {
|
||||
solvecraft_doc::apply_point(&inverse, t)
|
||||
} else {
|
||||
t
|
||||
}
|
||||
}
|
||||
(None, Some(d)) => from + if drawn.space == WireSpace::Component { solvecraft_doc::apply_vector(&inverse, d) } else { d },
|
||||
_ => return Err(bad(cmd, "give `to` or `by`")),
|
||||
};
|
||||
Ok(sk.move_drawn_point(&wire, index, to)?)
|
||||
})?;
|
||||
Ok(json!({"moved": n, "sketch": info}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_3d_frame_tests.rs"]
|
||||
mod frame_tests;
|
||||
@@ -0,0 +1,312 @@
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::Vec3;
|
||||
use solvecraft_sketch::WireSpace;
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, params: Value) -> Value {
|
||||
s.execute(cmd, ¶ms).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn near(actual: Vec3, expected: Vec3) {
|
||||
assert!(actual.dist(expected) < 1e-7, "{actual:?} != {expected:?}");
|
||||
}
|
||||
|
||||
fn second_frame() -> (Session, u64, u64, u64) {
|
||||
let mut s = Session::default();
|
||||
let c = run(&mut s, "component.create", json!({"name":"Wire owner"}));
|
||||
let component = c["component"].as_u64().unwrap();
|
||||
let first = c["occurrence"].as_u64().unwrap();
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
|
||||
// One rigid transform: (x,y,z) -> (20-y,30+x,40+z), independent of implementation math.
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"axis":[0,0,1],"angle":"90 deg","translate":[20,30,40]}));
|
||||
run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]}));
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Space path"}));
|
||||
run(&mut s, "sketch.options", json!({"sketch_3d":true}));
|
||||
(s, component, first, second)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_second_occurrence_world_inputs_move_and_reopen() {
|
||||
let (mut s, component, first, second) = second_frame();
|
||||
let sketch = s.active_sketch.unwrap();
|
||||
let r = run(&mut s, "sketch.line3d", json!({"points":[[17,32,45],[17,32,55],[14,32,55]]}));
|
||||
let a = r["curves"][0].as_str().unwrap().to_string();
|
||||
let b = r["curves"][1].as_str().unwrap().to_string();
|
||||
let spline = run(&mut s, "sketch.spline3d", json!({"points":[[14,32,55],[12,34,56],[10,35,58]]}))["curves"][0].as_str().unwrap().to_string();
|
||||
let point = run(&mut s, "sketch.point3d", json!({"point":[17,32,45]}))["point"].as_str().unwrap().to_string();
|
||||
let sk = s.doc.sketch(sketch).unwrap();
|
||||
for w in &sk.wires {
|
||||
assert_eq!(w.space, WireSpace::Component);
|
||||
}
|
||||
near(sk.wires[sk.wire_index(&a).unwrap()].fit[0], Vec3::new(2.0, 3.0, 5.0));
|
||||
near(sk.wires[sk.wire_index(&point).unwrap()].fit[0], Vec3::new(2.0, 3.0, 5.0));
|
||||
let world = s.world_state();
|
||||
let ws = &world.sketch(sketch).unwrap().sketch;
|
||||
let sw = &ws.wires[ws.wire_index(&spline).unwrap()];
|
||||
near(sw.fit[0], Vec3::new(14.0, 32.0, 55.0));
|
||||
near(sw.pts[0], sw.fit[0]);
|
||||
near(*sw.pts.last().unwrap(), Vec3::new(10.0, 35.0, 58.0));
|
||||
let before = s.doc.to_json();
|
||||
let moved = run(&mut s, "sketch.move3d", json!({"wire":a,"index":1,"by":[1,0,0]}));
|
||||
assert_eq!(moved["moved"], 2);
|
||||
let sk = s.doc.sketch(sketch).unwrap();
|
||||
near(sk.wires[sk.wire_index(&a).unwrap()].fit[1], Vec3::new(2.0, 2.0, 15.0));
|
||||
near(sk.wires[sk.wire_index(&b).unwrap()].fit[0], Vec3::new(2.0, 2.0, 15.0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
run(&mut s, "sketch.move3d", json!({"wire":point,"to":[18,33,46]}));
|
||||
near(s.doc.sketch(sketch).unwrap().wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(3.0, 2.0, 6.0));
|
||||
let restored = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(restored.sketch(sketch).unwrap().wires, s.doc.sketch(sketch).unwrap().wires);
|
||||
// Same definition at the first placement has the local geometry; selected second is exact.
|
||||
run(&mut s, "component.activate", json!({"component":component,"occurrence":first,"path":[first]}));
|
||||
near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(3.0, 2.0, 6.0));
|
||||
run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]}));
|
||||
near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(18.0, 33.0, 46.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_pending_moves_other_owner_and_legacy_world_do_not_join() {
|
||||
let (mut s, _, _, second) = second_frame();
|
||||
let sketch = s.active_sketch.unwrap();
|
||||
let legacy = s.doc_mut().sketch_mut(sketch).unwrap().add_drawn_wire(&[Vec3::new(2.0, 3.0, 5.0)]).unwrap();
|
||||
let local = run(&mut s, "sketch.point3d", json!({"point":[17,32,45]}))["point"].as_str().unwrap().to_string();
|
||||
assert_eq!(run(&mut s, "sketch.move3d", json!({"wire":local,"by":[1,0,0]}))["moved"], 1);
|
||||
near(s.doc.sketch(sketch).unwrap().wires.iter().find(|w| w.id == legacy).unwrap().fit[0], Vec3::new(2.0, 3.0, 5.0));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false}));
|
||||
let point = run(&mut s, "sketch.point3d", json!({"point":[22,32,45]}))["point"].as_str().unwrap().to_string();
|
||||
near(s.doc.sketch(sketch).unwrap().wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(2.0, 3.0, 5.0));
|
||||
near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == legacy).unwrap().fit[0], Vec3::new(2.0, 3.0, 5.0));
|
||||
near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(22.0, 32.0, 45.0));
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(22.0, 32.0, 45.0));
|
||||
// Explicit target in root must not inherit the unrelated active component frame.
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let root = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Root path"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.options", json!({"sketch_3d":true}));
|
||||
let unrelated = run(&mut s, "component.create", json!({"name":"Unrelated"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":unrelated,"translate":[100,200,300]}));
|
||||
run(&mut s, "sketch.point3d", json!({"sketch":root,"point":[9,8,7]}));
|
||||
near(s.doc.sketch(root).unwrap().wires[0].fit[0], Vec3::new(9.0, 8.0, 7.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_hostile_inputs_rollback_full_session() {
|
||||
let (mut s, _, _, second) = second_frame();
|
||||
let id = run(&mut s, "sketch.point3d", json!({"point":[17,32,45]}))["point"].as_str().unwrap().to_string();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[1,0,0],"capture":false}));
|
||||
run(&mut s, "select.set", json!({"items":[{"type":"occurrence","path":[second]}]}));
|
||||
let doc = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let selection = s.selection.clone();
|
||||
let history = (s.undo.len(), s.redo.len());
|
||||
for (cmd, p) in [
|
||||
("sketch.move3d", json!({"wire":id,"index":u64::MAX,"to":[0,0,0]})),
|
||||
("sketch.move3d", json!({"wire":id,"index":-1,"to":[0,0,0]})),
|
||||
("sketch.move3d", json!({"wire":id,"index":"0","to":[0,0,0]})),
|
||||
("sketch.move3d", json!({"wire":id,"to":[null,0,0],"by":[1,0,0]})),
|
||||
("sketch.point3d", json!({"point":[1e100,0,0]})),
|
||||
("sketch.line3d", json!({"points":[[17,32,45],[17,32,45]]})),
|
||||
("sketch.spline3d", json!({"points":vec![[0,0,0];501]})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd}: {p}");
|
||||
assert_eq!(s.doc.to_json(), doc);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(s.selection, selection);
|
||||
assert_eq!((s.undo.len(), s.redo.len()), history);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_include_other_component_refresh_break_lost_and_reopen() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10}));
|
||||
let component = run(&mut s, "component.create", json!({"name":"Receiver"}));
|
||||
let occurrence = component["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[20,0,0]}));
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Include target"}));
|
||||
let sketch = s.active_sketch.unwrap();
|
||||
run(&mut s, "sketch.include_3d", json!({"refs":[{"edge":[10,10,5],"body":"Body1"}]}));
|
||||
let initial = s.doc.sketch(sketch).unwrap().wires[0].clone();
|
||||
assert_eq!(initial.space, WireSpace::Component);
|
||||
assert!(initial.pts.iter().all(|p| (p.x + 10.0).abs() < 1e-7 && (p.y - 10.0).abs() < 1e-7));
|
||||
let link = initial.link.clone().unwrap();
|
||||
// A real sketch edit exercises engine refresh, not only document evaluation.
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,0],[1,0]]}));
|
||||
let sk = s.doc.sketch(sketch).unwrap();
|
||||
assert_eq!(sk.wires[0], initial);
|
||||
assert!(!sk.link(&link).unwrap().lost);
|
||||
let world = s.world_state();
|
||||
assert!(world.sketch(sketch).unwrap().sketch.wires[0].pts.iter().all(|p| (p.x - 10.0).abs() < 1e-7));
|
||||
let before = s.doc.to_json();
|
||||
run(&mut s, "sketch.break_link", json!({"link":link}));
|
||||
assert_eq!(s.doc.sketch(sketch).unwrap().wires[0].space, WireSpace::Component);
|
||||
assert!(s.doc.sketch(sketch).unwrap().wires[0].link.is_none());
|
||||
let restored = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(restored.sketch(sketch).unwrap().wires[0], s.doc.sketch(sketch).unwrap().wires[0]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
// Lost references retain the last coordinates and explicit marker, with no guessing.
|
||||
let sk = s.doc_mut().sketch_mut(sketch).unwrap();
|
||||
sk.links.iter_mut().find(|l| l.id == link).unwrap().source = solvecraft_sketch::LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO };
|
||||
run(&mut s, "sketch.line", json!({"points":[[2,0],[3,0]]}));
|
||||
let sk = s.doc.sketch(sketch).unwrap();
|
||||
assert!(sk.link(&link).unwrap().lost);
|
||||
assert_eq!(sk.wires[0], initial);
|
||||
let restored = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(restored.sketch(sketch).unwrap().wires[0], initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_pipe_component_and_world_paths_have_analytic_volume_and_position() {
|
||||
for space in [WireSpace::Component, WireSpace::World] {
|
||||
let (mut s, _, first, second) = second_frame();
|
||||
if space == WireSpace::World {
|
||||
// A single placed owner lets an immutable world path stay physically fixed;
|
||||
// replicated solids still follow their occurrence placements by design.
|
||||
run(&mut s, "selection.delete", json!({"items":[{"type":"occurrence","path":[first]}]}));
|
||||
}
|
||||
let sketch = s.active_sketch.unwrap();
|
||||
let wire = if space == WireSpace::Component {
|
||||
run(&mut s, "sketch.line3d", json!({"points":[[17,32,45],[17,32,55]]}))["curves"][0].as_str().unwrap().to_string()
|
||||
} else {
|
||||
s.doc_mut().sketch_mut(sketch).unwrap().add_drawn_wire(&[Vec3::new(17.0, 32.0, 45.0), Vec3::new(17.0, 32.0, 55.0)]).unwrap()
|
||||
};
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.pipe", json!({"path_sketch":sketch,"path":[wire],"diameter":4}));
|
||||
let world = s.world_state();
|
||||
let body = world.bodies.last().unwrap();
|
||||
// Property measurement uses fine adaptive tessellation; the viewport mesh alone
|
||||
// undershoots a small circular profile by about 0.64% at its display chord tolerance.
|
||||
let measure = solvecraft_kernel::measure(&body.body).unwrap();
|
||||
assert!((measure.volume - 40.0 * std::f64::consts::PI).abs() < 0.05, "{}", measure.volume);
|
||||
let bounds = body.mesh().bounds();
|
||||
near((bounds.min + bounds.max) / 2.0, Vec3::new(17.0, 32.0, 50.0));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0]}));
|
||||
let world = s.world_state();
|
||||
let body = world.bodies.last().unwrap();
|
||||
let bounds = body.mesh().bounds();
|
||||
near((bounds.min + bounds.max) / 2.0, Vec3::new(if space == WireSpace::Component { 22.0 } else { 17.0 }, 32.0, 50.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_nonactive_repeated_owner_refuses_ambiguous_world_input() {
|
||||
let (mut s, _, _, _) = second_frame();
|
||||
let target = s.active_sketch.unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("sketch.point3d", &json!({"sketch":target,"point":[0,0,5]})).unwrap_err();
|
||||
assert!(error.to_string().contains("activate the desired sketch owner occurrence"));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_wide_owner_graph_has_bounded_indexed_traversal() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name":"Target"}));
|
||||
let target = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
// A legal wide assembly used to force one whole occurrence scan for each leaf.
|
||||
let doc = s.doc_mut();
|
||||
for i in 0..9998u64 {
|
||||
doc.components.push(solvecraft_doc::Component { id: 10_000 + i, name: format!("Sibling{i}"), parent: 0 });
|
||||
doc.occurrences.push(solvecraft_doc::Occurrence {
|
||||
id: 100_000 + i,
|
||||
component: 10_000 + i,
|
||||
parent: 0,
|
||||
name: format!("Sibling{i}"),
|
||||
transform: solvecraft_doc::IDENTITY,
|
||||
grounded: false,
|
||||
});
|
||||
}
|
||||
assert_eq!(super::inverse_owner_frame(&s, &json!({"sketch":target}), "sketch.point3d").unwrap(), solvecraft_doc::IDENTITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_cross_owner_sweep_follows_world_path_edit_undo_and_reopen() {
|
||||
let mut s = Session::default();
|
||||
let path_sketch = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Root path"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.options", json!({"sketch_3d":true}));
|
||||
let wire = run(&mut s, "sketch.line3d", json!({"points":[[17,32,45],[17,32,55]]}))["curves"][0].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let owner = run(&mut s, "component.create", json!({"name":"Sweep owner"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":owner,"axis":[0,0,1],"angle":"90 deg","translate":[20,30,40]}));
|
||||
let profile = run(&mut s, "sketch.create", json!({"plane":"XY","offset":5,"name":"Profile"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[2,3],"radius":2}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.sweep", json!({"sketch":profile,"path_sketch":path_sketch,"path":[wire],"operation":"new"}));
|
||||
let check = |s: &Session, length: f64| {
|
||||
let world = s.world_state();
|
||||
let body = world.bodies.last().unwrap();
|
||||
let measure = solvecraft_kernel::measure(&body.body).unwrap();
|
||||
assert!((measure.volume - 4.0 * std::f64::consts::PI * length).abs() < 0.05, "{}", measure.volume);
|
||||
near(measure.centroid, Vec3::new(17.0, 32.0, 45.0 + length / 2.0));
|
||||
};
|
||||
check(&s, 10.0);
|
||||
let saved = s.doc.to_json();
|
||||
run(&mut s, "sketch.edit", json!({"sketch":path_sketch}));
|
||||
run(&mut s, "sketch.move3d", json!({"sketch":path_sketch,"wire":wire,"index":1,"by":[0,0,10]}));
|
||||
check(&s, 20.0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
check(&s, 10.0);
|
||||
let mut reopened = Session { doc: solvecraft_doc::format::read(&saved).unwrap().into(), ..Default::default() };
|
||||
reopened.refresh();
|
||||
check(&reopened, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_three_owner_sweep_profile_move_invalidates_warm_cache() {
|
||||
let mut s = Session::default();
|
||||
let profile_owner = run(&mut s, "component.create", json!({"name":"Profile owner","parent":0}));
|
||||
let profile_occurrence = profile_owner["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":profile_occurrence,"translate":[4,7,5]}));
|
||||
let profile = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Foreign profile"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":2}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
|
||||
let path_owner = run(&mut s, "component.create", json!({"name":"Path owner","parent":0}));
|
||||
let path_occurrence = path_owner["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":path_occurrence,"translate":[-12,9,-3]}));
|
||||
let path_sketch = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Foreign path"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.options", json!({"sketch_3d":true}));
|
||||
let wire = run(&mut s, "sketch.line3d", json!({"points":[[4,7,5],[4,7,15]]}))["curves"][0].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
|
||||
let consumer = run(&mut s, "component.create", json!({"name":"Sweep consumer","parent":0}));
|
||||
let consumer_occurrence = consumer["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":consumer_occurrence,"axis":[0,0,1],"angle":"90 deg","translate":[100,-30,20]}));
|
||||
run(&mut s, "solid.sweep", json!({"sketch":profile,"path_sketch":path_sketch,"path":[wire],"operation":"new"}));
|
||||
let measure = |session: &Session, x: f64| {
|
||||
let state = session.world_state();
|
||||
let body = state.bodies.last().unwrap();
|
||||
assert_eq!(body.component, consumer["component"].as_u64().unwrap());
|
||||
let result = solvecraft_kernel::measure(&body.body).unwrap();
|
||||
assert!((result.volume - 40.0 * std::f64::consts::PI).abs() < 0.05, "{}", result.volume);
|
||||
near(result.centroid, Vec3::new(x, 7.0, 10.0));
|
||||
result
|
||||
};
|
||||
measure(&s, 4.0);
|
||||
// A repeated refresh warms the exact same evaluator cache before changing only
|
||||
// the foreign profile placement, whose frame must be part of Sweep's digest.
|
||||
s.refresh();
|
||||
measure(&s, 4.0);
|
||||
let saved = s.doc.to_json();
|
||||
let fixed_path = s.doc.component_transform(path_owner["component"].as_u64().unwrap());
|
||||
let fixed_consumer = s.doc.component_transform(consumer["component"].as_u64().unwrap());
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":profile_occurrence,"translate":[3,0,0]}));
|
||||
assert_eq!(s.doc.component_transform(path_owner["component"].as_u64().unwrap()), fixed_path);
|
||||
assert_eq!(s.doc.component_transform(consumer["component"].as_u64().unwrap()), fixed_consumer);
|
||||
let warm = measure(&s, 7.0);
|
||||
let mut cold = Session { doc: solvecraft_doc::format::read(&s.doc.to_json()).unwrap().into(), ..Default::default() };
|
||||
cold.refresh();
|
||||
let reopened = measure(&cold, 7.0);
|
||||
near(warm.centroid, reopened.centroid);
|
||||
assert!((warm.volume - reopened.volume).abs() < 1e-8);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
measure(&s, 4.0);
|
||||
}
|
||||
@@ -123,27 +123,49 @@ fn sketch_plane(doc: &Document, id: u64) -> Result<Plane> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep candidate feature edits while overlaying uncommitted occurrence placements.
|
||||
fn placed_candidate<'a>(s: &Session, doc: &'a Document) -> std::borrow::Cow<'a, Document> {
|
||||
if s.pending_moves.is_empty() {
|
||||
return std::borrow::Cow::Borrowed(doc);
|
||||
}
|
||||
let mut placed = doc.clone();
|
||||
for o in &mut placed.occurrences {
|
||||
if let Some(m) = s.pending_moves.get(&o.id) {
|
||||
o.transform = *m;
|
||||
}
|
||||
}
|
||||
std::borrow::Cow::Owned(placed)
|
||||
}
|
||||
|
||||
fn owner_frame(s: &Session, doc: &Document, comp: u64) -> solvecraft_doc::Mat {
|
||||
if comp == s.active_component { s.active_component_frame() } else { doc.component_transform(comp) }
|
||||
}
|
||||
|
||||
/// Re-resolve the links of a sketch being edited against the model before it, so edits see
|
||||
/// current reference geometry. Returns warnings.
|
||||
pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) -> Vec<String> {
|
||||
if sk.links.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Ok(plane) = sketch_plane(doc, id) else { return Vec::new() };
|
||||
let doc = placed_candidate(s, doc);
|
||||
let Ok(plane) = sketch_plane(&doc, id) else { return Vec::new() };
|
||||
let (vals, _) = doc.param_values();
|
||||
let st = s.model.state_before(id);
|
||||
solvecraft_doc::project::refresh_links(doc, &vals, &st, &plane, sk)
|
||||
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||||
let st = solvecraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp));
|
||||
solvecraft_doc::project::refresh_links(&doc, &vals, &st, &plane, sk)
|
||||
}
|
||||
|
||||
/// Add a link for `src` to sketch `id` in `doc` (not stored in the session). Returns the link id.
|
||||
pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, kind: LinkKind, src: LinkSource) -> Result<String> {
|
||||
let plane = sketch_plane(doc, id)?;
|
||||
let doc = placed_candidate(s, doc);
|
||||
let plane = sketch_plane(&doc, id)?;
|
||||
let (vals, _) = doc.param_values();
|
||||
let st = s.model.state_before(id);
|
||||
// The model as the sketch's component sees it.
|
||||
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||||
let st = solvecraft_doc::state_in_frame(doc, &st, comp);
|
||||
let r = resolve(doc, &vals, &st, &plane, kind, &src)?;
|
||||
let st = solvecraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp));
|
||||
let r = resolve(&doc, &vals, &st, &plane, kind, &src)?;
|
||||
let name = if let LinkSource::Face { at, .. } = &r.source {
|
||||
solvecraft_doc::naming::face_names_at(&st, &[*at]).into_iter().next().filter(|n| !n.is_empty())
|
||||
} else {
|
||||
|
||||
@@ -133,6 +133,7 @@ pub struct Snapshot {
|
||||
pub label: String,
|
||||
pub doc: Arc<Document>,
|
||||
pub active_sketch: Option<u64>,
|
||||
pub pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
|
||||
}
|
||||
|
||||
/// What some commands would make, evaluated on a scratch copy of a session
|
||||
@@ -184,12 +185,13 @@ pub struct Session {
|
||||
next_checkpoint: u64,
|
||||
}
|
||||
|
||||
/// The design, active sketch and undo history at an `edit.checkpoint`.
|
||||
/// The design, active sketch, pending positions and undo history at an `edit.checkpoint`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Checkpoint {
|
||||
pub id: u64,
|
||||
pub doc: Arc<Document>,
|
||||
pub active_sketch: Option<u64>,
|
||||
pub pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
|
||||
pub undo: Vec<Snapshot>,
|
||||
pub redo: Vec<Snapshot>,
|
||||
}
|
||||
@@ -332,6 +334,13 @@ impl Session {
|
||||
) {
|
||||
sketch.plane = mapped;
|
||||
}
|
||||
for wire in &mut sketch.sketch.wires {
|
||||
if wire.space == solvecraft_doc::sketch::WireSpace::Component {
|
||||
for point in wire.pts.iter_mut().chain(&mut wire.fit) {
|
||||
*point = solvecraft_doc::apply_point(&delta, *point);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,7 +371,12 @@ impl Session {
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
(spec.enabled)(self).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
|
||||
let before = Snapshot { label: spec.label.to_string(), doc: self.doc.clone(), active_sketch: self.active_sketch };
|
||||
let before = Snapshot {
|
||||
label: spec.label.to_string(),
|
||||
doc: self.doc.clone(),
|
||||
active_sketch: self.active_sketch,
|
||||
pending_moves: self.pending_moves.clone(),
|
||||
};
|
||||
let null = Value::Object(Default::default());
|
||||
let params = if params.is_null() { &null } else { params };
|
||||
// Picks in the world go into their component's frame (once: commands run by commands
|
||||
@@ -394,9 +408,10 @@ impl Session {
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
if changed {
|
||||
if changed || self.pending_moves != before.pending_moves {
|
||||
self.doc = before.doc;
|
||||
self.active_sketch = before.active_sketch;
|
||||
self.pending_moves = before.pending_moves;
|
||||
self.refresh();
|
||||
}
|
||||
Err(e)
|
||||
@@ -404,7 +419,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember the design, active sketch and undo history; returns the checkpoint's id.
|
||||
/// Remember the design, active sketch, pending positions and undo history; returns the checkpoint's id.
|
||||
pub fn checkpoint(&mut self) -> u64 {
|
||||
let id = self.next_checkpoint;
|
||||
self.next_checkpoint += 1;
|
||||
@@ -413,6 +428,7 @@ impl Session {
|
||||
doc: self.doc.clone(),
|
||||
active_sketch: self.active_sketch,
|
||||
undo: self.undo.clone(),
|
||||
pending_moves: self.pending_moves.clone(),
|
||||
redo: self.redo.clone(),
|
||||
});
|
||||
if self.checkpoints.len() > MAX_CHECKPOINTS {
|
||||
@@ -428,6 +444,7 @@ impl Session {
|
||||
let Some(c) = self.checkpoints.drain(i..).next() else { return Err(EngineError::Other(format!("no checkpoint {id}"))) };
|
||||
self.doc = c.doc;
|
||||
self.active_sketch = c.active_sketch;
|
||||
self.pending_moves = c.pending_moves;
|
||||
self.undo = c.undo;
|
||||
self.redo = c.redo;
|
||||
self.selection.clear();
|
||||
|
||||
@@ -79,7 +79,7 @@ impl Session {
|
||||
let bodies = state
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| self.doc.body_component(&b.name, b.feature) == component)
|
||||
.filter(|b| self.doc.model_body_component(b) == component)
|
||||
.map(|b| OccurrenceBody { source: b.name.clone(), world: self.doc.world_body_name(&state, &b.name, component, path) })
|
||||
.collect();
|
||||
Ok(OccurrencePlacement {
|
||||
|
||||
@@ -864,7 +864,14 @@ fn holes_at_sketch_points_threads_and_components() {
|
||||
let text = s.doc.to_json();
|
||||
let back = solvecraft_doc::Document::from_json(&text).unwrap();
|
||||
assert_eq!(back.components.len(), 2);
|
||||
assert_eq!(back.body_components.len(), 1);
|
||||
let assigned = r["components"][0]["component"].as_u64().unwrap();
|
||||
let transfer = r["components"][0]["feature"].as_u64().unwrap();
|
||||
assert!(
|
||||
matches!(&back.feature(transfer).unwrap().kind, solvecraft_doc::FeatureKind::BodyTransfer { bodies, to, .. } if bodies == &["Shaft"] && *to == assigned)
|
||||
);
|
||||
let reopened = Session::new(back);
|
||||
assert_eq!(reopened.model.state().body("Shaft").unwrap().component, assigned);
|
||||
assert_eq!(reopened.world_state().threads.len(), s.world_state().threads.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -985,11 +992,22 @@ fn components_occurrences_and_world_placement() {
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
assert!(s.pending_moves.is_empty());
|
||||
assert!((bbox(&mut s, "ArmBody")["min"][2].as_f64().unwrap() - 5.0).abs() < 1e-9);
|
||||
// Bodies and sketches move between components.
|
||||
run(&mut s, "component.move_bodies", json!({"bodies": ["Base"], "component": "Arm"}));
|
||||
// A definition placed twice cannot receive one world body without duplicating it.
|
||||
let document = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let error = s.execute("component.move_bodies", &json!({"bodies": ["Base"], "component": "Arm"})).unwrap_err();
|
||||
assert!(error.to_string().contains("one source and destination placement"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), document);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
// The independent Paste New result has one placement and can receive it.
|
||||
let copied = pn["component"].as_u64().unwrap();
|
||||
run(&mut s, "component.move_bodies", json!({"bodies": ["Base"], "component": copied}));
|
||||
let l = run(&mut s, "component.list", json!({}));
|
||||
let arm = l["components"].as_array().unwrap().iter().find(|x| x["id"] == cid).unwrap().clone();
|
||||
assert!(arm["bodies"].as_array().unwrap().iter().any(|b| b == "Base"), "{arm}");
|
||||
assert!(!arm["bodies"].as_array().unwrap().iter().any(|b| b == "Base"), "{arm}");
|
||||
let receiver = l["components"].as_array().unwrap().iter().find(|x| x["id"] == copied).unwrap();
|
||||
assert!(receiver["bodies"].as_array().unwrap().iter().any(|b| b == "Base"), "{receiver}");
|
||||
assert_eq!(bbox(&mut s, "Base")["min"], json!([0.0, 0.0, 0.0]));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S1"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "component.move_sketches", json!({"sketches": ["S1"], "component": cid}));
|
||||
@@ -999,8 +1017,9 @@ fn components_occurrences_and_world_placement() {
|
||||
assert_eq!(s.doc.find_feature("S1").unwrap().component, 0);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
run(&mut s, "timeline.roll_to", json!({"position": 1}));
|
||||
// Only Base is left; it now lives in Arm, which is placed twice (original and instance).
|
||||
assert_eq!(s.world_state().bodies.len(), 2);
|
||||
// The ownership boundary is later: before it Base remains the sole root body.
|
||||
assert_eq!(s.world_state().bodies.len(), 1);
|
||||
assert_eq!(s.world_state().body("Base").unwrap().component, 0);
|
||||
run(&mut s, "timeline.roll_to", json!({}));
|
||||
// Saved and loaded; older designs get occurrences.
|
||||
let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
|
||||
@@ -27,6 +27,15 @@ pub struct Body {
|
||||
/// Lines the user split faces along (Split Face), as polylines: export keeps the faces on
|
||||
/// either side apart where it would otherwise join pieces of one surface.
|
||||
pub(crate) splits: Option<std::sync::Arc<Vec<Vec<Vec3>>>>,
|
||||
/// Exact construction/import source identities, in this body's face order.
|
||||
pub(crate) face_sources: Option<std::sync::Arc<Vec<Option<FaceSource>>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum FaceSource {
|
||||
ExtrudeSide { segment: solvecraft_geom::Seg2 },
|
||||
CertifiedExtrudeSide { producer: u64, sketch: u64, curve: String },
|
||||
Imported { item: u64, face: u64, payload_sha256: Option<String>, producer: Option<u64> },
|
||||
}
|
||||
|
||||
/// How a body looks beyond its colour, for export: its opacity and its faces' own colours.
|
||||
@@ -287,6 +296,7 @@ impl Body {
|
||||
&& self.color == other.color
|
||||
&& self.paint == other.paint
|
||||
&& self.splits == other.splits
|
||||
&& self.face_sources == other.face_sources
|
||||
}
|
||||
|
||||
pub(crate) fn new(solid: Solid) -> Result<Body> {
|
||||
@@ -324,9 +334,61 @@ impl Body {
|
||||
color: None,
|
||||
paint: None,
|
||||
splits: None,
|
||||
face_sources: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn face_sources(&self) -> Option<&[Option<FaceSource>]> {
|
||||
self.face_sources.as_deref().map(Vec::as_slice)
|
||||
}
|
||||
|
||||
/// Bind constructed edges to uniquely proven source curves without changing face names.
|
||||
pub fn with_extrude_certificates(mut self, producer: u64, sketch: u64, curves: &[Option<String>]) -> Result<Self> {
|
||||
if producer == 0 || sketch == 0 || curves.iter().flatten().any(|curve| curve.is_empty() || curve.len() > 128) {
|
||||
return Err(KernelError::Invalid("invalid constructed side certificate".into()));
|
||||
}
|
||||
if let Some(sources) = &self.face_sources {
|
||||
if sources.len() != curves.len() {
|
||||
return Err(KernelError::Invalid("side certificate count mismatch".into()));
|
||||
}
|
||||
let sources = sources
|
||||
.iter()
|
||||
.zip(curves)
|
||||
.map(|(source, curve)| match (source, curve) {
|
||||
(Some(FaceSource::ExtrudeSide { .. }), Some(curve)) => {
|
||||
Some(FaceSource::CertifiedExtrudeSide { producer, sketch, curve: curve.clone() })
|
||||
}
|
||||
(Some(FaceSource::ExtrudeSide { .. }), None) => None,
|
||||
_ => source.clone(),
|
||||
})
|
||||
.collect();
|
||||
self.face_sources = Some(std::sync::Arc::new(sources));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn with_import_certificate(mut self, digest: &str, producer: u64) -> Result<Self> {
|
||||
if producer == 0 || digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) {
|
||||
return Err(KernelError::Invalid("invalid immutable import certificate".into()));
|
||||
}
|
||||
if let Some(sources) = &self.face_sources {
|
||||
let mut sources = sources.as_ref().clone();
|
||||
for source in sources.iter_mut().flatten() {
|
||||
if let FaceSource::Imported { payload_sha256, producer: origin, .. } = source {
|
||||
*payload_sha256 = Some(digest.into());
|
||||
*origin = Some(producer);
|
||||
}
|
||||
}
|
||||
self.face_sources = Some(std::sync::Arc::new(sources));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub(crate) fn with_face_sources(mut self, sources: Option<std::sync::Arc<Vec<Option<FaceSource>>>>) -> Self {
|
||||
self.face_sources = sources;
|
||||
self
|
||||
}
|
||||
|
||||
/// A deep copy of the solid that can be mutated without affecting other bodies.
|
||||
pub(crate) fn deep_copy(&self) -> Solid {
|
||||
mt::builder::clone(&*self.solid)
|
||||
|
||||
@@ -15,6 +15,10 @@ fn finite(x: f64, what: &str) -> Result<f64> {
|
||||
|
||||
/// A closed wire in 3D from a sketch loop.
|
||||
pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
|
||||
wire_sources(plane, lp).map(|(wire, _)| wire)
|
||||
}
|
||||
|
||||
fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, Seg2)>)> {
|
||||
let n = lp.segs.len();
|
||||
if n == 0 || n > MAX_SEGS {
|
||||
return Err(KernelError::Invalid(format!("loop with {n} segments")));
|
||||
@@ -24,6 +28,7 @@ pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
|
||||
}
|
||||
let verts: Vec<mt::Vertex> = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect();
|
||||
let mut edges = Vec::with_capacity(n);
|
||||
let mut sources = Vec::with_capacity(n);
|
||||
for (i, s) in lp.segs.iter().enumerate() {
|
||||
let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % n)) else { continue };
|
||||
let e: mt::Edge = match *s {
|
||||
@@ -35,7 +40,9 @@ pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
|
||||
// Split big arcs so every piece is well conditioned.
|
||||
if sweep.abs() > std::f64::consts::PI * 1.5 {
|
||||
let vm = builder::vertex(p3(plane.to_world(s.mid())));
|
||||
edges.push(builder::circle_arc(a, &vm, p3(plane.to_world(s.point_at(0.25)))));
|
||||
let first = builder::circle_arc(a, &vm, p3(plane.to_world(s.point_at(0.25))));
|
||||
sources.push((first.clone(), *s));
|
||||
edges.push(first);
|
||||
builder::circle_arc(&vm, b, p3(plane.to_world(s.point_at(0.75))))
|
||||
} else {
|
||||
builder::circle_arc(a, b, p3(plane.to_world(s.mid())))
|
||||
@@ -45,9 +52,10 @@ pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
|
||||
crate::freeform::edge(plane, s, a, b).ok_or_else(|| KernelError::Invalid("profile curve".into()))?
|
||||
}
|
||||
};
|
||||
sources.push((e.clone(), *s));
|
||||
edges.push(e);
|
||||
}
|
||||
Ok(edges.into())
|
||||
Ok((edges.into(), sources))
|
||||
}
|
||||
|
||||
/// A planar face from a region (outer loop with holes).
|
||||
@@ -95,9 +103,35 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
|
||||
for r in regions {
|
||||
region_ok(r)?;
|
||||
let base = plane.offset(lo);
|
||||
let f = face(&base, r)?;
|
||||
let (outer, mut sources) = wire_sources(&base, &r.outer.ccw())?;
|
||||
let mut wires = vec![outer];
|
||||
for hole in &r.holes {
|
||||
let (wire, mut hole_sources) = wire_sources(&base, &hole.ccw().reversed())?;
|
||||
wires.push(wire);
|
||||
sources.append(&mut hole_sources);
|
||||
}
|
||||
let f = guard("extrude profile", || builder::try_attach_plane(&wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))?;
|
||||
let solid: Solid = guard("extrude", || Ok(builder::tsweep(&f, v3(n * (hi - lo)))))?;
|
||||
out.push(Body::new(solid)?);
|
||||
let body = Body::new(solid)?;
|
||||
// tsweep retains each actual input boundary edge on its generated side.
|
||||
// Match topology identity, never face order, surface samples or centroids.
|
||||
let provenance = guard("extrude source identities", || {
|
||||
let source_edges: std::collections::HashMap<_, _> = sources.iter().map(|(edge, segment)| (edge.id(), *segment)).collect();
|
||||
let mut result = Vec::new();
|
||||
for generated in body.solid.face_iter() {
|
||||
let matching: Vec<_> = generated.absolute_boundaries().iter().flatten().filter_map(|edge| source_edges.get(&edge.id())).collect();
|
||||
let source = if generated.id() == f.id() {
|
||||
None
|
||||
} else if let [segment] = matching.as_slice() {
|
||||
Some(crate::FaceSource::ExtrudeSide { segment: **segment })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
result.push(source);
|
||||
}
|
||||
Ok(result)
|
||||
})?;
|
||||
out.push(body.with_face_sources(Some(std::sync::Arc::new(provenance))));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
+276
-19
@@ -17,6 +17,21 @@ fn unwrap(a: f64, around: f64) -> f64 {
|
||||
around + (a - around + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI
|
||||
}
|
||||
|
||||
fn wall_angles(face: &mt::Face, o: Vec3, axis: Vec3, radial: Vec3, tangent: Vec3, tol: f64) -> Option<(f64, f64)> {
|
||||
if face.boundaries().len() != 1 || face.edge_iter().count() != 4 {
|
||||
return None;
|
||||
}
|
||||
for edge in face.edge_iter() {
|
||||
if let Some(Some(mid)) = crate::offset::arc_mid(&edge, tol * 10.) {
|
||||
let am = angle(mid, o, axis, radial, tangent);
|
||||
let aa = unwrap(angle(from_p3(edge.front().point()), o, axis, radial, tangent), am);
|
||||
let ab = unwrap(angle(from_p3(edge.back().point()), o, axis, radial, tangent), am);
|
||||
return Some((aa.min(ab), aa.max(ab)));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Certify a tessellated pick and project it onto the cylinder used by emboss.
|
||||
pub fn cylinder_emboss_anchor(body: &Body, face: usize, at: Vec3) -> Result<Vec3> {
|
||||
if !at.is_finite() || at.len() > 1e9 {
|
||||
@@ -40,7 +55,7 @@ pub fn cylinder_emboss_anchor(body: &Body, face: usize, at: Vec3) -> Result<Vec3
|
||||
/// Wrap sketch arc length around an exterior cylinder, retaining exact radial depth.
|
||||
/// Profiles must contain orthogonal lines and lie inside one uninterrupted wall face.
|
||||
pub fn emboss_cylinder(body: &Body, plane: &Plane, regions: &[Region2], depth: f64) -> Result<Body> {
|
||||
emboss_impl(body, None, plane, regions, depth)
|
||||
emboss_impl(body, None, plane, regions, depth, false).and_then(EmbossOutcome::body)
|
||||
}
|
||||
|
||||
/// As [`emboss_cylinder`], requiring the saved selected point to identify that wall.
|
||||
@@ -48,10 +63,101 @@ pub fn emboss_cylinder_at(body: &Body, at: Vec3, plane: &Plane, regions: &[Regio
|
||||
if !at.is_finite() || at.len() > 1e9 {
|
||||
return Err(fail("invalid selected face point"));
|
||||
}
|
||||
emboss_impl(body, Some(at), plane, regions, depth)
|
||||
emboss_impl(body, Some(at), plane, regions, depth, false).and_then(EmbossOutcome::body)
|
||||
}
|
||||
|
||||
fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Region2], depth: f64) -> Result<Body> {
|
||||
enum EmbossOutcome {
|
||||
Body(Body),
|
||||
Anchor(usize, Vec3),
|
||||
NoMatch,
|
||||
}
|
||||
impl EmbossOutcome {
|
||||
fn body(self) -> Result<Body> {
|
||||
match self {
|
||||
Self::Body(body) => Ok(body),
|
||||
Self::Anchor(..) | Self::NoMatch => Err(fail("unexpected qualification result")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Qualify a tangent cylindrical target without changing it. A tangent exterior
|
||||
/// cylinder with an unsupported profile or depth returns an explicit error;
|
||||
/// a body without such a tangent wall returns `None` for planar compatibility.
|
||||
/// The anchor lies on a validated profile boundary, clear of seams and endcaps.
|
||||
pub fn infer_cylinder_emboss(body: &Body, plane: &Plane, regions: &[Region2], depth: f64) -> Result<Option<(usize, Vec3)>> {
|
||||
if body.is_mesh() {
|
||||
return Ok(None);
|
||||
}
|
||||
guard("cylinder emboss inference", || {
|
||||
let tol = (body.size() * 1e-8).max(1e-8);
|
||||
let mut tangent = false;
|
||||
for (index, face) in body.solid.face_iter().enumerate() {
|
||||
if index >= 50_000 {
|
||||
return Err(fail("target search exceeds face work limit"));
|
||||
}
|
||||
if let Some(Surf::Cylinder { o, a, r, convex: true }) = surf_of(face, tol) {
|
||||
let w = plane.origin - o;
|
||||
let normal = plane.normal();
|
||||
let signed = w.dot(normal);
|
||||
if normal.dot(a).abs() < 1e-8 && (signed.abs() - r).abs() <= tol * 10.0 {
|
||||
let radial = if signed < 0.0 { -normal } else { normal };
|
||||
// Every supported uninterrupted wall uses one angle interval
|
||||
// around this tangent normal, contained in (-pi,pi). A remote
|
||||
// footprint entirely outside even that global interval cannot
|
||||
// select this body, regardless of other unsupported faces.
|
||||
if !profile_interval_overlaps(plane, regions, o, a.cross(radial), -std::f64::consts::PI * r, std::f64::consts::PI * r)? {
|
||||
continue;
|
||||
}
|
||||
if let Some((lo, hi)) = wall_angles(face, o, a, radial, a.cross(radial), tol) {
|
||||
if lo >= -tol / r || hi <= tol / r {
|
||||
continue;
|
||||
}
|
||||
if !profile_interval_overlaps(plane, regions, o, a.cross(radial), lo * r, hi * r)? {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let relevant = match face_axial_bounds(face, o, a)? {
|
||||
Some((lo, hi)) => profile_interval_overlaps(plane, regions, o, a, lo, hi)?,
|
||||
None => true,
|
||||
};
|
||||
if relevant && signed < 0.0 {
|
||||
return Err(fail("sketch normal must point outwards from the exterior wall"));
|
||||
}
|
||||
tangent |= relevant;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !tangent {
|
||||
return Ok(None);
|
||||
}
|
||||
match emboss_impl(body, None, plane, regions, depth, true)? {
|
||||
EmbossOutcome::Anchor(index, at) => Ok(Some((index, at))),
|
||||
EmbossOutcome::NoMatch => Ok(None),
|
||||
EmbossOutcome::Body(_) => Err(fail("unexpected construction result")),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a body contains an analytic exterior cylindrical wall. This bounded
|
||||
/// preflight avoids resolving cylinder placement context for ordinary planar targets.
|
||||
pub fn has_cylinder_emboss_wall(body: &Body) -> Result<bool> {
|
||||
if body.is_mesh() {
|
||||
return Ok(false);
|
||||
}
|
||||
guard("cylinder emboss target", || {
|
||||
let tol = (body.size() * 1e-8).max(1e-8);
|
||||
for (index, face) in body.solid.face_iter().enumerate() {
|
||||
if index >= 50_000 {
|
||||
return Err(fail("target search exceeds face work limit"));
|
||||
}
|
||||
if matches!(surf_of(face, tol), Some(Surf::Cylinder { convex: true, .. })) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
})
|
||||
}
|
||||
fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Region2], depth: f64, qualify: bool) -> Result<EmbossOutcome> {
|
||||
body.require_brep("cylinder emboss")?;
|
||||
if !depth.is_finite() || depth.abs() < 1e-6 || depth.abs() > 1e6 || regions.is_empty() || regions.len() > 64 {
|
||||
return Err(fail("needs finite nonzero depth and 1..64 profiles"));
|
||||
@@ -77,8 +183,8 @@ fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Regi
|
||||
for (fi, s) in surfaces.iter().enumerate() {
|
||||
let Surf::Cylinder { o, a, r, convex: true } = *s else { continue };
|
||||
let w = plane.origin - o;
|
||||
let radial = w - a * w.dot(a);
|
||||
if (radial.len() - r).abs() > tol * 10.0 || plane.normal().dot(radial / r) < 1.0 - 1e-8 {
|
||||
let radial = plane.normal();
|
||||
if radial.dot(a).abs() > 1e-8 || (w.dot(radial) - r).abs() > tol * 10.0 {
|
||||
continue;
|
||||
}
|
||||
let radial = radial.normalized().ok_or_else(|| fail("radial frame"))?;
|
||||
@@ -87,17 +193,9 @@ fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Regi
|
||||
if f.boundaries().len() != 1 || f.edge_iter().count() != 4 {
|
||||
continue;
|
||||
}
|
||||
let mut angular = None;
|
||||
for e in f.edge_iter() {
|
||||
if let Some(Some(mid)) = crate::offset::arc_mid(&e, tol * 10.0) {
|
||||
let am = angle(mid, o, a, radial, tangent);
|
||||
let aa = unwrap(angle(from_p3(e.front().point()), o, a, radial, tangent), am);
|
||||
let ab = unwrap(angle(from_p3(e.back().point()), o, a, radial, tangent), am);
|
||||
angular = Some((aa.min(ab), aa.max(ab)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some((alo, ahi)) = angular else { continue };
|
||||
let Some((alo, ahi)) = wall_angles(f, o, a, radial, tangent, tol) else {
|
||||
continue;
|
||||
};
|
||||
if alo >= -tol / r || ahi <= tol / r {
|
||||
continue;
|
||||
}
|
||||
@@ -108,6 +206,9 @@ fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Regi
|
||||
}
|
||||
let (fi, o, axis, r, radial, tangent, alo, ahi) =
|
||||
chosen.ok_or_else(|| fail("sketch must be tangent to one complete exterior cylindrical wall, clear of its seam"))?;
|
||||
if qualify && !profile_interval_overlaps(plane, regions, o, tangent, alo * r, ahi * r)? {
|
||||
return Ok(EmbossOutcome::NoMatch);
|
||||
}
|
||||
let mut caps = Vec::new();
|
||||
let mut inner: f64 = 0.0;
|
||||
for s in &surfaces {
|
||||
@@ -131,6 +232,12 @@ fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Regi
|
||||
}
|
||||
let lo = caps.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let hi = caps.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
// An infinite cylinder can be tangent while its finite wall is far from
|
||||
// the selected footprint. Conservative analytic/control-polygon bounds
|
||||
// exclude that body before unsupported-profile or depth diagnostics.
|
||||
if qualify && !profile_interval_overlaps(plane, regions, o, axis, lo, hi)? {
|
||||
return Ok(EmbossOutcome::NoMatch);
|
||||
}
|
||||
let rr = r + depth;
|
||||
if rr <= inner + tol * 10.0 {
|
||||
return Err(fail("depth reaches the axis or inner wall"));
|
||||
@@ -144,14 +251,21 @@ fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Regi
|
||||
}
|
||||
}
|
||||
let z0 = (plane.origin - o).dot(axis);
|
||||
let map2 = |p: Vec2| Vec2::new(plane.dir_to_world(p).dot(tangent), z0 + plane.dir_to_world(p).dot(axis));
|
||||
let s0 = (plane.origin - o).dot(tangent);
|
||||
let map2 = |p: Vec2| Vec2::new(s0 + plane.dir_to_world(p).dot(tangent), z0 + plane.dir_to_world(p).dot(axis));
|
||||
let mut count = 0usize;
|
||||
let mut mapped = Vec::new();
|
||||
for reg in regions {
|
||||
let mut loops = Vec::new();
|
||||
for lp in std::iter::once(®.outer).chain(®.holes) {
|
||||
count = count.saturating_add(lp.segs.len());
|
||||
if count > 4096 || lp.segs.len() < 4 || !lp.is_closed(tol) {
|
||||
if count > 4096 {
|
||||
return Err(fail("profile geometry budget"));
|
||||
}
|
||||
if lp.segs.iter().any(|segment| !matches!(segment, Seg2::Line { .. })) {
|
||||
return Err(fail("only orthogonal line profiles are supported on cylinders"));
|
||||
}
|
||||
if lp.segs.len() < 4 || !lp.is_closed(tol) {
|
||||
return Err(fail("profile geometry budget or open loop"));
|
||||
}
|
||||
let mut segs = Vec::new();
|
||||
@@ -181,6 +295,12 @@ fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Regi
|
||||
mapped.push(Region2 { outer, holes });
|
||||
}
|
||||
validate_profiles(&mapped, tol)?;
|
||||
if qualify {
|
||||
let segment = mapped.first().and_then(|region| region.outer.segs.first()).ok_or_else(|| fail("empty profile"))?;
|
||||
let p = (segment.start() + segment.end()) * 0.5;
|
||||
let at = o + axis * p.y + (radial * (p.x / r).cos() + tangent * (p.x / r).sin()) * r;
|
||||
return Ok(EmbossOutcome::Anchor(fi, at));
|
||||
}
|
||||
let at = |p: Vec2, radius: f64| o + axis * p.y + (radial * (p.x / r).cos() + tangent * (p.x / r).sin()) * radius;
|
||||
let selected = faces.get(fi).ok_or_else(|| fail("face"))?;
|
||||
let mut base_patches = vec![selected.absolute_boundaries().clone()];
|
||||
@@ -252,10 +372,108 @@ fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Regi
|
||||
if !result.validity().is_empty() {
|
||||
return Err(fail("rebuilt cylinder is not a closed manifold"));
|
||||
}
|
||||
Ok(result)
|
||||
Ok(EmbossOutcome::Body(result))
|
||||
})
|
||||
}
|
||||
|
||||
fn profile_interval_overlaps(plane: &Plane, regions: &[Region2], o: Vec3, axis: Vec3, lo: f64, hi: f64) -> Result<bool> {
|
||||
let z0 = (plane.origin - o).dot(axis);
|
||||
let direction = Vec2::new(plane.x.dot(axis), plane.y.dot(axis));
|
||||
let mut count = 0usize;
|
||||
let mut overlaps = false;
|
||||
for region in regions {
|
||||
let mut min = f64::INFINITY;
|
||||
let mut max = f64::NEG_INFINITY;
|
||||
for segment in ®ion.outer.segs {
|
||||
count = count.saturating_add(1);
|
||||
if count > 4096 {
|
||||
return Err(fail("profile geometry budget"));
|
||||
}
|
||||
let mut add = |p: Vec2| -> Result<()> {
|
||||
if !p.is_finite() || p.len() > 1e7 {
|
||||
return Err(fail("nonfinite or excessive profile coordinates"));
|
||||
}
|
||||
let z = z0 + p.dot(direction);
|
||||
min = min.min(z);
|
||||
max = max.max(z);
|
||||
Ok(())
|
||||
};
|
||||
match *segment {
|
||||
Seg2::Line { a, b } => {
|
||||
add(a)?;
|
||||
add(b)?;
|
||||
}
|
||||
Seg2::Arc { center, radius, .. } => {
|
||||
add(center)?;
|
||||
if !radius.is_finite() || radius <= 0.0 || radius > 1e7 {
|
||||
return Err(fail("invalid profile radius"));
|
||||
}
|
||||
let z = z0 + center.dot(direction);
|
||||
min = min.min(z - radius * direction.len());
|
||||
max = max.max(z + radius * direction.len());
|
||||
}
|
||||
Seg2::Cubic { p0, p1, p2, p3 } => {
|
||||
for p in [p0, p1, p2, p3] {
|
||||
add(p)?;
|
||||
}
|
||||
}
|
||||
Seg2::Conic { a, apex, b, w } if w.is_finite() && w > 0.0 => {
|
||||
for p in [a, apex, b] {
|
||||
add(p)?;
|
||||
}
|
||||
}
|
||||
Seg2::Bezier { n, p } if (1..=7).contains(&n) => {
|
||||
for p in p.iter().take(usize::from(n) + 1) {
|
||||
add(*p)?;
|
||||
}
|
||||
}
|
||||
_ => return Err(fail("unsupported profile geometry")),
|
||||
}
|
||||
}
|
||||
overlaps |= min < hi && max > lo;
|
||||
}
|
||||
Ok(overlaps)
|
||||
}
|
||||
|
||||
fn face_axial_bounds(face: &mt::Face, o: Vec3, axis: Vec3) -> Result<Option<(f64, f64)>> {
|
||||
let mut lo = f64::INFINITY;
|
||||
let mut hi = f64::NEG_INFINITY;
|
||||
let mut count = 0usize;
|
||||
let mut add = |point: Vec3| -> Result<()> {
|
||||
count = count.saturating_add(1);
|
||||
if count > 100_000 || !point.is_finite() {
|
||||
return Err(fail("target boundary work limit or nonfinite geometry"));
|
||||
}
|
||||
let z = (point - o).dot(axis);
|
||||
lo = lo.min(z);
|
||||
hi = hi.max(z);
|
||||
Ok(())
|
||||
};
|
||||
for edge in face.edge_iter() {
|
||||
match edge.curve() {
|
||||
mt::Curve::Line(_) => {
|
||||
add(from_p3(edge.front().point()))?;
|
||||
add(from_p3(edge.back().point()))?;
|
||||
}
|
||||
mt::Curve::BSplineCurve(curve) => {
|
||||
for point in curve.control_points() {
|
||||
add(from_p3(*point))?;
|
||||
}
|
||||
}
|
||||
mt::Curve::NurbsCurve(curve) => {
|
||||
for point in curve.control_points() {
|
||||
if !point.w.is_finite() || point.w <= 0.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
add(Vec3::new(point.x / point.w, point.y / point.w, point.z / point.w))?;
|
||||
}
|
||||
}
|
||||
mt::Curve::IntersectionCurve(_) => return Ok(None),
|
||||
}
|
||||
}
|
||||
Ok((lo.is_finite() && hi.is_finite()).then_some((lo, hi)))
|
||||
}
|
||||
|
||||
fn wrapped_loop(lp: &Loop2, r: f64, rr: f64, at: &impl Fn(Vec2, f64) -> Vec3) -> Result<(mt::Wire, mt::Wire, Vec<mt::Face>)> {
|
||||
let base: Vec<_> = lp.segs.iter().map(|s| builder::vertex(p3(at(s.start(), r)))).collect();
|
||||
let top: Vec<_> = lp.segs.iter().map(|s| builder::vertex(p3(at(s.start(), rr)))).collect();
|
||||
@@ -346,6 +564,45 @@ mod tests {
|
||||
Plane::new(Vec3::Y * 10., -Vec3::X, Vec3::Z).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn inference_uses_finite_wall_footprint_and_an_interior_profile_anchor() {
|
||||
let near = crate::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap();
|
||||
let far = crate::cylinder(Vec3::Z * 100., Vec3::Z, 10., 20.).unwrap();
|
||||
let other = crate::cylinder(Vec3::Z * 120., Vec3::Z, 8., 10.).unwrap();
|
||||
let unsupported = crate::ops::join_pieces(vec![far.clone(), other]).unwrap().unwrap();
|
||||
let regions = [profile()];
|
||||
for body in [&far, &unsupported] {
|
||||
assert!(infer_cylinder_emboss(body, &plane(), ®ions, 1.).unwrap().is_none());
|
||||
}
|
||||
for x in [20., 80.] {
|
||||
let base = crate::cylinder(Vec3::X * x, Vec3::Z, 10., 20.).unwrap();
|
||||
let step = crate::cylinder(Vec3::X * x + Vec3::Z * 20., Vec3::Z, 8., 10.).unwrap();
|
||||
let remote = crate::ops::join_pieces(vec![base, step]).unwrap().unwrap();
|
||||
let inferred = infer_cylinder_emboss(&remote, &plane(), ®ions, 1.);
|
||||
if x == 20. {
|
||||
// The offset maps this footprint across the remote wall's seam;
|
||||
// partial relevance must retain conservative unsupported refusal.
|
||||
assert!(inferred.is_err());
|
||||
} else {
|
||||
assert!(inferred.unwrap().is_none());
|
||||
}
|
||||
}
|
||||
let (index, anchor) = infer_cylinder_emboss(&near, &plane(), ®ions, 1.).unwrap().unwrap();
|
||||
assert!(anchor.z > 0. && anchor.z < 20.);
|
||||
assert!((anchor.x.hypot(anchor.y) - 10.).abs() < 1e-9);
|
||||
assert!(cylinder_emboss_anchor(&near, index, anchor).unwrap().dist(anchor) < 1e-10);
|
||||
let shifted = Plane::new(Vec3::new(-3., 10., 0.), -Vec3::X, Vec3::Z).unwrap();
|
||||
let shifted_profile =
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(-5., 5.), Vec2::new(-1., 5.), Vec2::new(-1., 10.), Vec2::new(-5., 10.)]), holes: vec![] };
|
||||
let (_, other_anchor) = infer_cylinder_emboss(&near, &shifted, std::slice::from_ref(&shifted_profile), 1.).unwrap().unwrap();
|
||||
assert!(other_anchor.dist(anchor) < 1e-10);
|
||||
let before = near.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
let result = emboss_cylinder_at(&near, other_anchor, &shifted, &[shifted_profile], 1.).unwrap();
|
||||
assert!((result.tessellate_with(1e-4, true).unwrap().measure().volume - before - 21.).abs() < 0.02);
|
||||
assert!(infer_cylinder_emboss(&near, &plane(), ®ions, -10.).is_err());
|
||||
let inward = Plane::new(Vec3::Y * 10., Vec3::X, Vec3::Z).unwrap();
|
||||
assert!(infer_cylinder_emboss(&near, &inward, ®ions, 1.).unwrap_err().to_string().contains("outwards"));
|
||||
}
|
||||
#[test]
|
||||
fn wrapped_nested_island_uses_its_enclosing_hole_patch() {
|
||||
let body = crate::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap();
|
||||
let mut ring = profile();
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
use crate::{FaceSource, extrude, transform_matrix};
|
||||
use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
|
||||
#[test]
|
||||
fn constructed_annulus_sides_follow_exact_source_edges() {
|
||||
let ring = Region2 { outer: Loop2::circle(Vec2::default(), 10.), holes: vec![Loop2::circle(Vec2::default(), 9.).reversed()] };
|
||||
for (start, end) in [(0., 7.), (-7., 0.), (-7., 7.)] {
|
||||
let bodies = extrude(&Plane::XY, std::slice::from_ref(&ring), start, end).unwrap();
|
||||
let body = &bodies[0];
|
||||
let sources = body.face_sources().unwrap();
|
||||
assert_eq!(sources.len(), body.face_count());
|
||||
let mesh = body.tessellate(0.001).unwrap();
|
||||
let mut inner = 0;
|
||||
let mut outer = 0;
|
||||
for (index, source) in sources.iter().enumerate() {
|
||||
let Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { radius, .. } }) = source else { continue };
|
||||
if (*radius - 10.).abs() < 1e-12 {
|
||||
outer += 1;
|
||||
} else {
|
||||
assert!((*radius - 9.).abs() < 1e-12);
|
||||
inner += 1;
|
||||
}
|
||||
let mut vertices = 0;
|
||||
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
if *face as usize != index {
|
||||
continue;
|
||||
}
|
||||
for point in mesh.tri(triangle).unwrap() {
|
||||
assert!((point.x.hypot(point.y) - radius).abs() < 1e-8, "face {index}, source {radius}, {point:?}");
|
||||
vertices += 1;
|
||||
}
|
||||
}
|
||||
assert!(vertices > 0);
|
||||
}
|
||||
assert_eq!((outer, inner), (2, 2), "full circles split into two actual edges each");
|
||||
assert_eq!(sources.iter().filter(|source| source.is_none()).count(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructed_side_identity_survives_affine_placement_without_recognition() {
|
||||
let region = Region2 { outer: Loop2::circle(Vec2::default(), 10.), holes: vec![] };
|
||||
let bodies = extrude(&Plane::named("XY").unwrap(), &[region], 0., 7.).unwrap();
|
||||
let source = &bodies[0];
|
||||
let placed = transform_matrix(source, [[0., 1., 0., 0.], [-1., 0., 0., 0.], [0., 0., 1., 0.], [25., 30., 40., 1.]]).unwrap();
|
||||
assert_eq!(placed.face_sources(), source.face_sources());
|
||||
assert!((placed.size() - source.size()).abs() < 1e-8);
|
||||
assert!(placed.faces(0.001).unwrap().iter().all(|face| face.centroid.dist(Vec3::new(25., 30., 43.5)) < 11.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructed_large_arc_split_and_reversal_preserve_one_source_segment() {
|
||||
let arc = Seg2::Arc { center: Vec2::default(), radius: 10., start: 0., sweep: std::f64::consts::PI * 5. / 3. };
|
||||
let outline = Loop2 { segs: vec![arc, Seg2::Line { a: arc.end(), b: arc.start() }] };
|
||||
for outline in [outline.clone(), outline.reversed()] {
|
||||
let body = extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![] }], 0., 7.).unwrap().remove(0);
|
||||
let sources = body.face_sources().unwrap();
|
||||
let arcs: Vec<_> = sources
|
||||
.iter()
|
||||
.filter_map(|source| match source {
|
||||
Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { radius, sweep, .. } }) => Some((radius, sweep)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(arcs.len(), 2, "large input arc explicitly creates two actual boundary edges");
|
||||
assert!(arcs.iter().all(|(radius, sweep)| (**radius - 10.).abs() < 1e-12 && (sweep.abs() - std::f64::consts::PI * 5. / 3.).abs() < 1e-12));
|
||||
assert_eq!(sources.iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeSide { segment: Seg2::Line { .. } }))).count(), 1);
|
||||
let mesh = body.tessellate(0.001).unwrap();
|
||||
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
if matches!(sources[*face as usize], Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { .. } })) {
|
||||
assert!(mesh.tri(triangle).unwrap().iter().all(|point| (point.x.hypot(point.y) - 10.).abs() < 1e-7));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ mod coplanar;
|
||||
mod curveblend;
|
||||
mod delete_face;
|
||||
mod emboss;
|
||||
#[cfg(test)]
|
||||
mod face_source_tests;
|
||||
mod freeform;
|
||||
pub mod fuzz;
|
||||
mod heal;
|
||||
@@ -52,10 +54,10 @@ mod topo;
|
||||
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet};
|
||||
pub use blend_corner::fillet_radii;
|
||||
pub use blend_variable::{fillet_chord, fillet_variable, fillet_variable_points};
|
||||
pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, Paint};
|
||||
pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
|
||||
pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
|
||||
pub use delete_face::delete_faces;
|
||||
pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at};
|
||||
pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss};
|
||||
pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh};
|
||||
pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step};
|
||||
pub use iges_out::iges_export_bodies;
|
||||
|
||||
@@ -108,6 +108,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
|
||||
color: None,
|
||||
paint: None,
|
||||
splits: None,
|
||||
face_sources: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -199,6 +200,7 @@ impl Body {
|
||||
color: self.color,
|
||||
paint: self.paint.clone(),
|
||||
splits: self.splits.clone(),
|
||||
face_sources: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -531,7 +531,8 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
|
||||
m[3][3],
|
||||
);
|
||||
// An affine placement retains face order and appearance metadata.
|
||||
Body::new(placed_topology(&body.solid, mat)?).map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned()))
|
||||
Body::new(placed_topology(&body.solid, mat)?)
|
||||
.map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(body.face_sources.clone()))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -862,9 +862,13 @@ fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Vec<Prism>> {
|
||||
}
|
||||
|
||||
fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||
// This path repairs joins with a common outer cap (a boss flush with a rim).
|
||||
// Other extents and subtractive operations retain their existing B-rep path.
|
||||
if op != BoolOp::Union {
|
||||
// Common-cap joins and partial-depth cuts of aligned stepped prisms can
|
||||
// retain their exact section boundaries instead of intersecting meshes.
|
||||
// Other extents retain their existing B-rep path.
|
||||
if !matches!(op, BoolOp::Union | BoolOp::Cut) {
|
||||
return None;
|
||||
}
|
||||
if op == BoolOp::Cut && a.face_count().saturating_add(b.face_count()) > 2048 {
|
||||
return None;
|
||||
}
|
||||
fn section(ps: &[Prism], lo: f64, hi: f64, tol: f64) -> &[Region2] {
|
||||
@@ -882,28 +886,46 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>>
|
||||
}
|
||||
}
|
||||
}
|
||||
let ends = |ps: &[Prism]| ps.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.lo), hi.max(p.hi)));
|
||||
let (d, plane, pa, pb) = dirs.into_iter().take(8).find_map(|d| {
|
||||
let plane = Plane::from_normal(Vec3::ZERO, d)?;
|
||||
Some((d, plane, stack_of(a, d, &plane, tol)?, stack_of(b, d, &plane, tol)?))
|
||||
let pa = stack_of(a, d, &plane, tol)?;
|
||||
let pb = stack_of(b, d, &plane, tol)?;
|
||||
let (ea, eb) = (ends(&pa), ends(&pb));
|
||||
if (ea.0 - eb.0).abs() >= tol && (ea.1 - eb.1).abs() >= tol {
|
||||
return None;
|
||||
}
|
||||
Some((d, plane, pa, pb))
|
||||
})?;
|
||||
let ends = |ps: &[Prism]| ps.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.lo), hi.max(p.hi)));
|
||||
let (ea, eb) = (ends(&pa), ends(&pb));
|
||||
if (ea.0 - eb.0).abs() >= tol && (ea.1 - eb.1).abs() >= tol {
|
||||
return None;
|
||||
}
|
||||
let mut levels: Vec<f64> = pa.iter().chain(&pb).flat_map(|p| [p.lo, p.hi]).collect();
|
||||
levels.sort_by(f64::total_cmp);
|
||||
levels.dedup_by(|x, y| (*x - *y).abs() < tol);
|
||||
let mut slabs = Vec::new();
|
||||
let mut work = 0usize;
|
||||
let mut output_edges = 0usize;
|
||||
let edges = |regions: &[Region2]| regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|l| l.segs.len()).sum::<usize>();
|
||||
for w in levels.windows(2) {
|
||||
let (lo, hi) = (*w.first()?, *w.get(1)?);
|
||||
let (x, y) = (section(&pa, lo, hi, tol), section(&pb, lo, hi, tol));
|
||||
if op == BoolOp::Cut {
|
||||
let count = edges(x).saturating_add(edges(y));
|
||||
work = work.saturating_add(count.saturating_mul(count));
|
||||
if work > 32_000_000 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let s = match (x.is_empty(), y.is_empty(), op) {
|
||||
(true, _, BoolOp::Cut | BoolOp::Intersect) | (_, true, BoolOp::Intersect) => Vec::new(),
|
||||
(true, _, BoolOp::Union) => y.to_vec(),
|
||||
(_, true, _) => x.to_vec(),
|
||||
_ => boolean2d(x, y, op, tol)?,
|
||||
};
|
||||
if op == BoolOp::Cut {
|
||||
output_edges = output_edges.saturating_add(edges(&s));
|
||||
if output_edges > 8192 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
slabs.push(oriented(&s));
|
||||
}
|
||||
if slabs.iter().all(Vec::is_empty) {
|
||||
@@ -923,6 +945,42 @@ mod planar_tests {
|
||||
Region2 { outer: Loop2::polygon(&[a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)]), holes: vec![] }
|
||||
}
|
||||
#[test]
|
||||
fn partial_depth_cuts_touching_a_through_hole_use_exact_stacked_sections() {
|
||||
let plane = Plane::named("XY").unwrap();
|
||||
let mut plate = rectangle(Vec2::ZERO, Vec2::new(100., 60.));
|
||||
plate.holes.push(rectangle(Vec2::new(25., 20.), Vec2::new(30., 30.)).outer);
|
||||
let stock = crate::extrude(&plane, &[plate], 0., 2.5).unwrap().pop().unwrap();
|
||||
let low = crate::extrude(&plane, &[rectangle(Vec2::new(20., 20.), Vec2::new(30., 30.))], 0., 1.5).unwrap().pop().unwrap();
|
||||
let high = crate::extrude(&plane, &[rectangle(Vec2::new(25., 20.), Vec2::new(35., 30.))], 1., 2.5).unwrap().pop().unwrap();
|
||||
for tools in [[&low, &high], [&high, &low]] {
|
||||
let mut result = stock.clone();
|
||||
for tool in tools {
|
||||
result = stack_boolean(&result, tool, BoolOp::Cut).expect("exact stack path").unwrap().unwrap();
|
||||
}
|
||||
assert!(result.validity().is_empty());
|
||||
assert_eq!(result.shell_count(), 1);
|
||||
assert!((crate::measure(&result).unwrap().volume - 14_725.).abs() < 1e-6);
|
||||
let mesh = result.tessellate(1e-4).unwrap();
|
||||
for (point, material) in [
|
||||
(Vec3::new(22.5, 25., 0.5), false),
|
||||
(Vec3::new(22.5, 25., 2.), true),
|
||||
(Vec3::new(32.5, 25., 0.5), true),
|
||||
(Vec3::new(32.5, 25., 2.), false),
|
||||
(Vec3::new(27.5, 25., 1.25), false),
|
||||
(Vec3::new(10., 10., 1.25), true),
|
||||
] {
|
||||
assert_eq!(mesh.contains(point), material, "{point:?}");
|
||||
}
|
||||
let imported = crate::step_import(&crate::step_export(&[&result], "layered cut").unwrap()).unwrap();
|
||||
assert!(imported.warnings.is_empty());
|
||||
assert!((crate::measure(&imported.bodies.first().unwrap().body).unwrap().volume - 14_725.).abs() < 1e-6);
|
||||
let next = crate::extrude(&plane, &[rectangle(Vec2::new(20., 20.), Vec2::new(25., 30.))], 1.5, 2.5).unwrap().pop().unwrap();
|
||||
let opened = crate::boolean(&imported.bodies.first().unwrap().body, &next, BoolOp::Cut).unwrap().unwrap();
|
||||
assert!((crate::measure(&opened).unwrap().volume - 14_675.).abs() < 1e-6);
|
||||
assert!(opened.validity().is_empty());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn compound_planar_extrusion_normalizes_overlap_and_retains_islands() {
|
||||
let plane = Plane::named("XY").unwrap();
|
||||
let a = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0));
|
||||
|
||||
@@ -878,7 +878,15 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul
|
||||
let shells = std::mem::take(&mut imp.shells);
|
||||
let solid = mt::Solid::new_unchecked(shells.into_iter().map(mt::Shell::from).collect());
|
||||
Ok((
|
||||
Body { solid: std::sync::Arc::new(solid), coarse_missing: None, mesh: None, color: body.color, paint: None, splits: body.splits.clone() },
|
||||
Body {
|
||||
solid: std::sync::Arc::new(solid),
|
||||
coarse_missing: None,
|
||||
mesh: None,
|
||||
color: body.color,
|
||||
paint: None,
|
||||
splits: body.splits.clone(),
|
||||
face_sources: None,
|
||||
},
|
||||
imp,
|
||||
))
|
||||
})
|
||||
|
||||
@@ -34,6 +34,10 @@ const MAX_VISITS: usize = 100_000;
|
||||
/// A body read from a STEP file.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ImportedBody {
|
||||
/// Immutable STEP source item, scoped to its embedded payload.
|
||||
pub source_item: u64,
|
||||
/// Original STEP face entity of each kernel face, including seam pieces.
|
||||
pub face_origin: Vec<u64>,
|
||||
/// Solid name (the brep's label), else the product name.
|
||||
pub name: String,
|
||||
pub body: Body,
|
||||
@@ -510,7 +514,21 @@ impl<'a> Reader<'a> {
|
||||
.collect();
|
||||
let paint = crate::Paint { opacity: self.opacity.get(&item).copied().unwrap_or(1.0), faces };
|
||||
let body = body.with_color(color).with_paint(Some(paint));
|
||||
self.bodies.push(ImportedBody { name, body, color, path: path.to_vec(), closed, file_faces });
|
||||
let identities = face_origin
|
||||
.iter()
|
||||
.map(|face| Some(crate::FaceSource::Imported { item, face: *face, payload_sha256: None, producer: None }))
|
||||
.collect();
|
||||
let body = body.with_face_sources(Some(std::sync::Arc::new(identities)));
|
||||
self.bodies.push(ImportedBody {
|
||||
name,
|
||||
body,
|
||||
color,
|
||||
path: path.to_vec(),
|
||||
closed,
|
||||
file_faces,
|
||||
source_item: item,
|
||||
face_origin,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
let label = self.label(item);
|
||||
|
||||
@@ -29,6 +29,7 @@ fn surface_body(faces: Vec<mt::Face>) -> Body {
|
||||
color: None,
|
||||
paint: None,
|
||||
splits: None,
|
||||
face_sources: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@ fn body_of(faces: Vec<mt::Face>) -> Result<Body> {
|
||||
color: None,
|
||||
paint: None,
|
||||
splits: None,
|
||||
face_sources: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions};
|
||||
pub use shape::{Shape, intersections};
|
||||
pub use solver::{Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_holding};
|
||||
pub use text::{MAX_TEXT_CHARS, text_geometry};
|
||||
pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, fit_polyline};
|
||||
pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireSpace, fit_polyline};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
+180
-19
@@ -1,4 +1,4 @@
|
||||
//! 3D sketch curves (wires): polylines in world coordinates that a sketch carries besides its
|
||||
//! 3D sketch curves (wires): polylines with explicit coordinates that a sketch carries besides its
|
||||
//! planar geometry — included 3D edges, curves projected onto surfaces, intersection curves.
|
||||
//! They are reference geometry: drawn and usable as paths, not part of profiles or the solver.
|
||||
|
||||
@@ -11,11 +11,35 @@ use crate::model::{Result, Sketch, SketchError};
|
||||
/// Most points one wire may have.
|
||||
pub const MAX_WIRE_POINTS: usize = 100_000;
|
||||
|
||||
/// Coordinate meaning of a stored wire and its editable fit points.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WireSpace {
|
||||
/// Legacy drawn or detached geometry remains physically fixed in the world.
|
||||
#[default]
|
||||
World,
|
||||
/// Geometry follows the sketch's owning component placement.
|
||||
Component,
|
||||
}
|
||||
|
||||
impl WireSpace {
|
||||
/// Coordinate semantics of a retained legacy producer, including a currently lost link.
|
||||
pub fn from_legacy_link(link: &Link) -> Self {
|
||||
match (&link.kind, &link.source) {
|
||||
(LinkKind::Include, LinkSource::Edge { .. } | LinkSource::SketchCurve { .. })
|
||||
| (_, LinkSource::OnSurface { .. } | LinkSource::Intersection { .. } | LinkSource::Iso { .. }) => Self::Component,
|
||||
_ => Self::World,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Wire {
|
||||
pub id: String,
|
||||
/// World-space polyline.
|
||||
/// Polyline in `space` coordinates.
|
||||
pub pts: Vec<Vec3>,
|
||||
#[serde(default)]
|
||||
pub space: WireSpace,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<String>,
|
||||
/// Drawn in a 3D sketch: the points the curve was drawn through (one: a point; two: a
|
||||
@@ -68,8 +92,11 @@ pub fn fit_polyline(fit: &[Vec3]) -> Vec<Vec3> {
|
||||
}
|
||||
|
||||
fn clean(w: &[Vec3]) -> Option<Vec<Vec3>> {
|
||||
if w.len() > MAX_WIRE_POINTS {
|
||||
return None;
|
||||
}
|
||||
let mut out: Vec<Vec3> = Vec::new();
|
||||
for p in w.iter().take(MAX_WIRE_POINTS) {
|
||||
for p in w {
|
||||
if !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9) {
|
||||
return None;
|
||||
}
|
||||
@@ -80,7 +107,77 @@ fn clean(w: &[Vec3]) -> Option<Vec<Vec3>> {
|
||||
(out.len() >= 2).then_some(out)
|
||||
}
|
||||
|
||||
fn clean_wires(wires: &[Vec<Vec3>]) -> Result<Vec<Vec<Vec3>>> {
|
||||
if wires.len() > crate::link::MAX_LINK_ENTITIES || wires.iter().fold(0usize, |n, w| n.saturating_add(w.len())) > 1_000_000 {
|
||||
return Err(SketchError::Invalid("3D linked curves exceed the stored geometry budget".into()));
|
||||
}
|
||||
wires.iter().map(|w| clean(w).ok_or_else(|| SketchError::Invalid("3D linked curve points out of range or coincident".into()))).collect()
|
||||
}
|
||||
|
||||
fn drawn_polyline(fit: &[Vec3]) -> Result<Vec<Vec3>> {
|
||||
if fit.is_empty() || fit.len() > MAX_FIT_POINTS {
|
||||
return Err(SketchError::Invalid(format!("a 3D curve needs 1 to {MAX_FIT_POINTS} points")));
|
||||
}
|
||||
if fit.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) {
|
||||
return Err(SketchError::Invalid("3D point out of range".into()));
|
||||
}
|
||||
if fit.windows(2).any(|w| w[0].dist(w[1]) < 1e-9) {
|
||||
return Err(SketchError::Invalid("the points of a 3D curve must differ".into()));
|
||||
}
|
||||
let pts = fit_polyline(fit);
|
||||
if pts.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) {
|
||||
return Err(SketchError::Invalid("3D spline samples out of range".into()));
|
||||
}
|
||||
Ok(pts)
|
||||
}
|
||||
|
||||
impl Sketch {
|
||||
fn wire_budget(&self, replacing: Option<&str>, new_count: usize, new_points: usize) -> Result<()> {
|
||||
let mut count = new_count;
|
||||
let mut points = new_points;
|
||||
for w in self.wires.iter().filter(|w| replacing.is_none() || w.link.as_deref() != replacing) {
|
||||
count = count.saturating_add(1);
|
||||
points = points.saturating_add(w.pts.len()).saturating_add(w.fit.len());
|
||||
}
|
||||
if count > crate::link::MAX_LINK_ENTITIES || points > 1_000_000 {
|
||||
return Err(SketchError::Invalid("3D curves exceed the stored geometry budget".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate saved arrays without regenerating legacy geometry or changing point values.
|
||||
pub fn validate_wires(&self) -> Result<()> {
|
||||
if self.wires.len() > crate::link::MAX_LINK_ENTITIES {
|
||||
return Err(SketchError::Invalid("too many 3D curves".into()));
|
||||
}
|
||||
let mut work = 0usize;
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for w in &self.wires {
|
||||
work = work.saturating_add(w.pts.len()).saturating_add(w.fit.len());
|
||||
if work > 1_000_000
|
||||
|| w.id.is_empty()
|
||||
|| w.id.len() > 512
|
||||
|| !ids.insert(&w.id)
|
||||
|| w.link.as_ref().is_some_and(|id| id.is_empty() || id.len() > 512)
|
||||
|| w.pts.is_empty()
|
||||
|| w.pts.len() > MAX_WIRE_POINTS
|
||||
|| w.fit.len() > MAX_FIT_POINTS
|
||||
|| (w.pts.len() == 1 && w.fit.len() != 1)
|
||||
|| w.pts.iter().chain(&w.fit).any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9))
|
||||
{
|
||||
return Err(SketchError::Invalid("3D curve arrays, ids or point bounds are invalid".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Producer semantics for legacy wires with retained association provenance. Breaking a
|
||||
/// link removes this evidence, so detached wires conservatively retain world coordinates.
|
||||
pub fn legacy_wire_space(&self, wire: &Wire) -> WireSpace {
|
||||
let Some(link) = wire.link.as_deref().and_then(|id| self.link(id)) else { return WireSpace::World };
|
||||
WireSpace::from_legacy_link(link)
|
||||
}
|
||||
|
||||
pub fn wire_index(&self, id: &str) -> Option<usize> {
|
||||
self.wires.iter().position(|w| w.id == id)
|
||||
}
|
||||
@@ -92,7 +189,8 @@ impl Sketch {
|
||||
|
||||
/// Add a link with planar geometry and/or 3D wires.
|
||||
pub fn add_link_with_wires(&mut self, kind: LinkKind, source: LinkSource, geom: &[LinkGeom], wires: &[Vec<Vec3>]) -> Result<String> {
|
||||
let ws: Vec<Vec<Vec3>> = wires.iter().filter_map(|w| clean(w)).collect();
|
||||
let ws = clean_wires(wires)?;
|
||||
self.wire_budget(None, ws.len(), ws.iter().map(Vec::len).sum())?;
|
||||
if ws.is_empty() {
|
||||
return self.add_link(kind, source, geom);
|
||||
}
|
||||
@@ -112,7 +210,8 @@ impl Sketch {
|
||||
if self.link(id).is_none() {
|
||||
return Err(SketchError::Unknown(id.into()));
|
||||
}
|
||||
let ws: Vec<Vec<Vec3>> = wires.iter().filter_map(|w| clean(w)).collect();
|
||||
let ws = clean_wires(wires)?;
|
||||
self.wire_budget(Some(id), ws.len(), ws.iter().map(Vec::len).sum())?;
|
||||
let old: Vec<String> = self.link_wires(id).iter().filter_map(|i| self.wires.get(*i).map(|w| w.id.clone())).collect();
|
||||
self.wires.retain(|w| w.link.as_deref() != Some(id));
|
||||
for (k, pts) in ws.into_iter().enumerate() {
|
||||
@@ -120,24 +219,22 @@ impl Sketch {
|
||||
Some(o) => o.clone(),
|
||||
None => self.fresh("w"),
|
||||
};
|
||||
self.wires.push(Wire { id: wid, pts, link: Some(id.to_string()), fit: Vec::new() });
|
||||
self.wires.push(Wire { id: wid, pts, space: WireSpace::Component, link: Some(id.to_string()), fit: Vec::new() });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a curve drawn in a 3D sketch through `fit` (see [`Wire::fit`]).
|
||||
pub fn add_drawn_wire(&mut self, fit: &[Vec3]) -> Result<String> {
|
||||
if fit.is_empty() || fit.len() > MAX_FIT_POINTS {
|
||||
return Err(SketchError::Invalid(format!("a 3D curve needs 1 to {MAX_FIT_POINTS} points")));
|
||||
}
|
||||
if fit.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) {
|
||||
return Err(SketchError::Invalid("3D point out of range".into()));
|
||||
}
|
||||
if fit.windows(2).any(|w| w[0].dist(w[1]) < 1e-9) {
|
||||
return Err(SketchError::Invalid("the points of a 3D curve must differ".into()));
|
||||
}
|
||||
self.add_drawn_wire_in(fit, WireSpace::World)
|
||||
}
|
||||
|
||||
/// Add a drawn curve without changing the coordinate meaning supplied by its author.
|
||||
pub fn add_drawn_wire_in(&mut self, fit: &[Vec3], space: WireSpace) -> Result<String> {
|
||||
let pts = drawn_polyline(fit)?;
|
||||
self.wire_budget(None, 1, pts.len() + fit.len())?;
|
||||
let id = self.fresh(if fit.len() == 1 { "q" } else { "w" });
|
||||
self.wires.push(Wire { id: id.clone(), pts: fit_polyline(fit), link: None, fit: fit.to_vec() });
|
||||
self.wires.push(Wire { id: id.clone(), pts, space, link: None, fit: fit.to_vec() });
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
@@ -148,17 +245,32 @@ impl Sketch {
|
||||
return Err(SketchError::Invalid("3D point out of range".into()));
|
||||
}
|
||||
let w = self.wire_index(wire).ok_or_else(|| SketchError::Unknown(wire.into()))?;
|
||||
let space = self
|
||||
.wires
|
||||
.get(w)
|
||||
.filter(|w| w.link.is_none())
|
||||
.map(|w| w.space)
|
||||
.ok_or_else(|| SketchError::Invalid("linked 3D geometry cannot be moved".into()))?;
|
||||
let from = self.wires.get(w).and_then(|w| w.fit.get(index)).copied().ok_or_else(|| SketchError::Unknown(format!("{wire} point {index}")))?;
|
||||
let mut n = 0;
|
||||
for w in self.wires.iter_mut().filter(|w| !w.fit.is_empty()) {
|
||||
let mut changes = Vec::new();
|
||||
for (index, w) in self.wires.iter().enumerate().filter(|(_, w)| !w.fit.is_empty() && w.link.is_none() && w.space == space) {
|
||||
let mut fit = w.fit.clone();
|
||||
let mut changed = false;
|
||||
for p in w.fit.iter_mut().filter(|p| p.dist(from) < 1e-9) {
|
||||
for p in fit.iter_mut().filter(|p| p.dist(from) < 1e-9) {
|
||||
*p = to;
|
||||
changed = true;
|
||||
n += 1;
|
||||
}
|
||||
if changed {
|
||||
w.pts = fit_polyline(&w.fit);
|
||||
let pts = drawn_polyline(&fit)?;
|
||||
changes.push((index, fit, pts));
|
||||
}
|
||||
}
|
||||
for (index, fit, pts) in changes {
|
||||
if let Some(w) = self.wires.get_mut(index) {
|
||||
w.fit = fit;
|
||||
w.pts = pts;
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
@@ -170,3 +282,52 @@ impl Sketch {
|
||||
self.prune_links();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wire_space_defaults_and_preserves_detached_provenance() {
|
||||
let mut sk = Sketch::new();
|
||||
let id = sk.add_drawn_wire(&[Vec3::new(1.0, 2.0, 3.0)]).unwrap();
|
||||
assert_eq!(sk.wires[0].space, WireSpace::World);
|
||||
let mut raw = serde_json::to_value(&sk.wires[0]).unwrap();
|
||||
raw.as_object_mut().unwrap().remove("space");
|
||||
assert_eq!(serde_json::from_value::<Wire>(raw).unwrap().space, WireSpace::World);
|
||||
let link = sk
|
||||
.add_link_with_wires(
|
||||
LinkKind::Include,
|
||||
LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO },
|
||||
&[],
|
||||
&[vec![Vec3::new(1.0, 2.0, 3.0), Vec3::new(1.0, 2.0, 4.0)]],
|
||||
)
|
||||
.unwrap();
|
||||
sk.set_link_lost(&link, true);
|
||||
let wire = sk.wires.iter().find(|w| w.link.as_deref() == Some(&link)).unwrap();
|
||||
assert_eq!(sk.legacy_wire_space(wire), WireSpace::Component);
|
||||
let wid = wire.id.clone();
|
||||
sk.break_link(&link).unwrap();
|
||||
let wire = sk.wires.iter().find(|w| w.id == wid).unwrap();
|
||||
assert_eq!(wire.space, WireSpace::Component);
|
||||
assert_eq!(sk.legacy_wire_space(wire), WireSpace::World);
|
||||
assert_eq!(sk.wires.iter().find(|w| w.id == id).unwrap().space, WireSpace::World);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_space_invalid_producers_and_move_are_atomic() {
|
||||
let mut sk = Sketch::new();
|
||||
let wire = sk.add_drawn_wire_in(&[Vec3::ZERO, Vec3::Z], WireSpace::Component).unwrap();
|
||||
sk.add_drawn_wire(&[Vec3::ZERO]).unwrap();
|
||||
assert_eq!(sk.move_drawn_point(&wire, 0, Vec3::X).unwrap(), 1);
|
||||
assert_eq!(sk.wires[1].fit[0], Vec3::ZERO);
|
||||
let before = sk.clone();
|
||||
assert!(sk.move_drawn_point(&wire, 0, Vec3::Z).is_err());
|
||||
assert_eq!(sk, before);
|
||||
assert!(sk.add_link_with_wires(LinkKind::Include, LinkSource::Origin, &[], &[vec![Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)]]).is_err());
|
||||
assert_eq!(sk, before);
|
||||
assert!(sk.add_link_with_wires(LinkKind::Include, LinkSource::Origin, &[], &[vec![Vec3::ZERO; MAX_WIRE_POINTS + 1]]).is_err());
|
||||
assert_eq!(sk, before);
|
||||
sk.validate_wires().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1120,7 +1120,7 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
|
||||
.state()
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| doc.body_component(&b.name, b.feature) == comp)
|
||||
.filter(|b| doc.model_body_component(b) == comp)
|
||||
.map(|b| Entry {
|
||||
key: b.name.clone(),
|
||||
row_key: format!("b:{}", b.name),
|
||||
|
||||
+282
-42
@@ -234,6 +234,12 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
let focus = std::mem::take(&mut d.focus);
|
||||
let half_height = app.cam.half_height();
|
||||
let axis = revolve_axis(app, d);
|
||||
if matches!(d.kind, Kind::Revolve { .. }) {
|
||||
if let Some(axis) = axis {
|
||||
draw_revolve(app, ui, painter, proj, d, base, bs, axis, focus);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Two sides: the second distance gets its own arrow the other way.
|
||||
if let (Kind::Extrude { direction: crate::dialogs::TWO_SIDES, distance2, .. }, Some(n)) = (&mut d.kind, dir)
|
||||
&& let Some(l2) = app.session.doc.eval(distance2, ValueKind::Length).ok().filter(|v| v.is_finite())
|
||||
@@ -285,11 +291,6 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
});
|
||||
let before = value.clone();
|
||||
let mut box_at = bs + vec2(16.0, -30.0);
|
||||
if let (Some(axis), Some(angle)) = (axis, len.filter(|_| kind == ValueKind::Angle))
|
||||
&& let Some(at) = rotator(ui, painter, proj, base, axis, angle, value)
|
||||
{
|
||||
box_at = at + vec2(14.0, -12.0);
|
||||
}
|
||||
if let (Some(n), Some(l)) = (dir, len.filter(|_| kind == ValueKind::Length)) {
|
||||
let n = n * sign;
|
||||
let baseline = to_span.map(f64::abs).unwrap_or(0.0);
|
||||
@@ -464,56 +465,295 @@ pub(crate) fn revolve_axis(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Vec3)> {
|
||||
Some((vector("origin")?, vector("dir")?.normalized()?))
|
||||
}
|
||||
|
||||
/// The revolve angle rotator: an arc from the profile around the axis with a handle at its end
|
||||
/// that drags the angle (5° steps; Alt or Ctrl: free). Returns the handle's screen position.
|
||||
/// Draw angular extents in the same clockwise sense as the modeled solid.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn rotator(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, p0: Vec3, (o, a): (Vec3, Vec3), angle: f64, value: &mut String) -> Option<Pos2> {
|
||||
let t = Tokens::get();
|
||||
let c = o + a * (p0 - o).dot(a);
|
||||
let r = (p0 - c).len();
|
||||
if r < 1e-9 {
|
||||
return None;
|
||||
fn draw_revolve(
|
||||
app: &SolveApp,
|
||||
ui: &mut egui::Ui,
|
||||
painter: &egui::Painter,
|
||||
proj: &Proj,
|
||||
d: &mut Dialog,
|
||||
base: Vec3,
|
||||
bs: Pos2,
|
||||
axis: (Vec3, Vec3),
|
||||
focus: bool,
|
||||
) {
|
||||
let to_mode = d.inputs.iter().any(|input| input.label == "To Object");
|
||||
let extent = revolve_display_extent(app, d, axis).unwrap_or(solvecraft_engine::doc::RevolveExtent {
|
||||
start: 0.0,
|
||||
sweep: f64::NAN,
|
||||
rotation_axis: axis.1,
|
||||
display_start: 0.0,
|
||||
curved_to: false,
|
||||
});
|
||||
let mut box_at = bs + vec2(16.0, -30.0);
|
||||
if !extent.curved_to && extent.sweep.is_finite() {
|
||||
let (origin, direction) = axis;
|
||||
let centre = origin + direction * (base - origin).dot(direction);
|
||||
let radius = (base - centre).len();
|
||||
if radius > 1e-9 {
|
||||
let e1 = (base - centre) / radius;
|
||||
let e2 = extent.rotation_axis.cross(e1);
|
||||
let handed = -direction.dot(extent.rotation_axis);
|
||||
let at = |angle: f64| centre + (e1 * angle.cos() + e2 * angle.sin()) * radius;
|
||||
let points: Vec<_> = (0..=48).filter_map(|i| proj.to_screen(at(extent.display_start + extent.sweep * i as f64 / 48.0))).collect();
|
||||
painter.add(egui::Shape::line(points, Stroke::new(2.0, Tokens::get().manipulator)));
|
||||
if let Some(end) = proj.to_screen(at(extent.display_start + extent.sweep)) {
|
||||
box_at = end + vec2(14.0, -12.0);
|
||||
}
|
||||
if !to_mode {
|
||||
let symmetric = !d.extra.contains_key("angle2") && d.extra.get("direction").and_then(serde_json::Value::as_str) == Some("symmetric");
|
||||
let other_angle = d
|
||||
.extra
|
||||
.get("angle2")
|
||||
.and_then(|value| value.as_str())
|
||||
.and_then(|value| app.session.doc.eval(value, ValueKind::Angle).ok())
|
||||
.unwrap_or(0.0);
|
||||
let range = if extent.sweep < 0.0 { (-std::f64::consts::TAU, 0.0) } else { (0.0, std::f64::consts::TAU) };
|
||||
if let Kind::Revolve { angle, .. } = &mut d.kind {
|
||||
let boundary = extent.display_start + extent.sweep;
|
||||
if let Some(end) = rotation_handle(
|
||||
ui,
|
||||
painter,
|
||||
proj,
|
||||
centre,
|
||||
e1,
|
||||
e2,
|
||||
direction,
|
||||
radius,
|
||||
boundary,
|
||||
if symmetric { 1.0 / (1.0 - handed * 0.5) } else { 1.0 },
|
||||
if symmetric { 0.0 } else { -other_angle * (1.0 - handed) },
|
||||
(range.0 - other_angle, range.1 - other_angle),
|
||||
"revolve",
|
||||
angle,
|
||||
) {
|
||||
box_at = end + vec2(14.0, -12.0);
|
||||
}
|
||||
if symmetric {
|
||||
rotation_handle(
|
||||
ui,
|
||||
painter,
|
||||
proj,
|
||||
centre,
|
||||
e1,
|
||||
e2,
|
||||
direction,
|
||||
radius,
|
||||
extent.display_start,
|
||||
-2.0 * handed,
|
||||
0.0,
|
||||
range,
|
||||
"revolve2",
|
||||
angle,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(other) = d.extra.get_mut("angle2") {
|
||||
let mut value = other.as_str().map(str::to_owned).unwrap_or_else(|| other.to_string());
|
||||
let before = value.clone();
|
||||
let first_angle = extent.sweep - other_angle;
|
||||
rotation_handle(
|
||||
ui,
|
||||
painter,
|
||||
proj,
|
||||
centre,
|
||||
e1,
|
||||
e2,
|
||||
direction,
|
||||
radius,
|
||||
extent.display_start,
|
||||
-handed,
|
||||
0.0,
|
||||
(range.0 - first_angle, range.1 - first_angle),
|
||||
"revolve2",
|
||||
&mut value,
|
||||
);
|
||||
if value != before {
|
||||
*other = serde_json::Value::String(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let e1 = (p0 - c) / r;
|
||||
let e2 = a.cross(e1);
|
||||
let at = |th: f64| c + (e1 * th.cos() + e2 * th.sin()) * r;
|
||||
let n = 48;
|
||||
let pts: Vec<Pos2> = (0..=n).filter_map(|i| proj.to_screen(at(angle * i as f64 / n as f64))).collect();
|
||||
let end = proj.to_screen(at(angle))?;
|
||||
let handle = egui::Rect::from_center_size(end, vec2(20.0, 20.0));
|
||||
let resp = ui.interact(handle, egui::Id::new("sc_rotator"), egui::Sense::drag());
|
||||
let hot = resp.hovered() || resp.dragged();
|
||||
let col = if hot { t.accent } else { t.manipulator };
|
||||
painter.add(egui::Shape::line(pts, Stroke::new(2.0, col)));
|
||||
if let (Some(cs), Some(ps)) = (proj.to_screen(c), proj.to_screen(p0)) {
|
||||
painter.add(egui::Shape::dashed_line(&[cs, ps], Stroke::new(1.0, col), 4.0, 3.0));
|
||||
}
|
||||
painter.circle(end, if hot { 7.0 } else { 5.5 }, col, Stroke::new(1.5, Color32::WHITE));
|
||||
egui::Area::new(egui::Id::new("sc_canvas_area")).fixed_pos(box_at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
|
||||
if to_mode {
|
||||
let text = if extent.curved_to {
|
||||
"To surface".into()
|
||||
} else if extent.sweep.is_finite() {
|
||||
format!("Sweep {:.1}°", extent.sweep.to_degrees())
|
||||
} else {
|
||||
"Choose target".into()
|
||||
};
|
||||
ui.label(text).on_hover_text("Determined by To Object. Choose Angle to edit the angular extent.");
|
||||
} else if let Kind::Revolve { angle, .. } = &mut d.kind {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Angle").color(Tokens::get().text_dim).size(11.5));
|
||||
let id = egui::Id::new("sc_canvas_value");
|
||||
let response = ui.add(egui::TextEdit::singleline(angle).id(id).desired_width(72.0));
|
||||
crate::params_dialog::complete(ui, &response, angle);
|
||||
if focus {
|
||||
response.request_focus();
|
||||
let n = angle.chars().count();
|
||||
let mut state = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default();
|
||||
state
|
||||
.cursor
|
||||
.set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n))));
|
||||
state.store(ui.ctx(), id);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn revolve_display_extent(app: &SolveApp, d: &Dialog, axis: (Vec3, Vec3)) -> Option<solvecraft_engine::doc::RevolveExtent> {
|
||||
let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()? else { return None };
|
||||
let state = app.session.world_state();
|
||||
let ss = state.sketch(*sketch)?;
|
||||
let regions: Vec<_> = d
|
||||
.inputs
|
||||
.first()?
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Sel::Profile { sketch: id, index } if id == sketch => ss.profiles.get(*index).map(|profile| profile.region.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let regions = solvecraft_engine::sketch::merge_regions(®ions);
|
||||
let plane = profile_plane(app, *sketch)?;
|
||||
let to_mode = d.inputs.iter().any(|input| input.label == "To Object");
|
||||
let to = if to_mode {
|
||||
let point = crate::dialogs::object_point(&d.inputs, "To Object")?;
|
||||
Some(Vec3::new(point.get(0)?.as_f64()?, point.get(1)?.as_f64()?, point.get(2)?.as_f64()?))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Kind::Revolve { angle, .. } = &d.kind else { return None };
|
||||
let angles = if to_mode {
|
||||
(0.0, None)
|
||||
} else {
|
||||
let angle = app.session.doc.eval(angle, ValueKind::Angle).ok()?;
|
||||
let other = d.extra.get("angle2").map(|value| app.session.doc.eval(value.as_str().unwrap_or_default(), ValueKind::Angle)).transpose().ok()?;
|
||||
(angle, other)
|
||||
};
|
||||
solvecraft_engine::doc::revolve_extent(
|
||||
&state,
|
||||
&plane,
|
||||
®ions,
|
||||
axis,
|
||||
angles,
|
||||
d.extra.get("direction").and_then(serde_json::Value::as_str) == Some("symmetric"),
|
||||
to,
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn rotation_handle(
|
||||
ui: &mut egui::Ui,
|
||||
painter: &egui::Painter,
|
||||
proj: &Proj,
|
||||
centre: Vec3,
|
||||
e1: Vec3,
|
||||
e2: Vec3,
|
||||
axis: Vec3,
|
||||
radius: f64,
|
||||
boundary: f64,
|
||||
scale: f64,
|
||||
offset: f64,
|
||||
range: (f64, f64),
|
||||
name: &str,
|
||||
value: &mut String,
|
||||
) -> Option<Pos2> {
|
||||
let end = proj.to_screen(centre + (e1 * boundary.cos() + e2 * boundary.sin()) * radius)?;
|
||||
crate::scenario::publish_handle(name, end);
|
||||
let response = ui.interact(egui::Rect::from_center_size(end, vec2(20.0, 20.0)), egui::Id::new(("sc_rotator", name)), egui::Sense::drag());
|
||||
let hot = response.hovered() || response.dragged();
|
||||
let color = if hot { Tokens::get().accent } else { Tokens::get().manipulator };
|
||||
painter.circle(end, if hot { 7.0 } else { 5.5 }, color, Stroke::new(1.5, Color32::WHITE));
|
||||
if hot {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
if response.dragged()
|
||||
&& let Some(pointer) = response.interact_pointer_pos()
|
||||
{
|
||||
let (ro, rd) = proj.ray(p);
|
||||
let den = rd.dot(a);
|
||||
if den.abs() > 1e-9 {
|
||||
let q = ro + rd * ((c - ro).dot(a) / den) - c;
|
||||
let mut th = q.dot(e2).atan2(q.dot(e1)).to_degrees().rem_euclid(360.0);
|
||||
let free = ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command);
|
||||
if !free {
|
||||
th = (th / 5.0).round() * 5.0;
|
||||
let (origin, direction) = proj.ray(pointer);
|
||||
let den = direction.dot(axis);
|
||||
let (a, b) = ((range.0 - offset) / scale, (range.1 - offset) / scale);
|
||||
let bounds = (a.min(b), a.max(b));
|
||||
// Near edge-on, a plane intersection magnifies subpixel pointer error.
|
||||
let raw = if den.abs() > 0.05 {
|
||||
let point = origin + direction * ((centre - origin).dot(axis) / den) - centre;
|
||||
Some(point.dot(e2).atan2(point.dot(e1)))
|
||||
} else {
|
||||
projected_rotation_angle(proj, pointer, centre, e1, e2, radius, boundary, bounds)
|
||||
};
|
||||
if let Some(raw) = raw {
|
||||
let boundary_angle = (-4..=4)
|
||||
.map(|k| raw + k as f64 * std::f64::consts::TAU)
|
||||
.filter(|angle| *angle >= bounds.0 - 1e-4 && *angle <= bounds.1 + 1e-4)
|
||||
.min_by(|a, b| (a - boundary).abs().total_cmp(&(b - boundary).abs()))
|
||||
.unwrap_or_else(|| raw.clamp(bounds.0, bounds.1));
|
||||
let mut angle = (boundary_angle.clamp(bounds.0, bounds.1) * scale + offset).to_degrees();
|
||||
if !ui.input(|input| input.modifiers.alt || input.modifiers.ctrl || input.modifiers.command) {
|
||||
angle = (angle / 5.0).round() * 5.0;
|
||||
}
|
||||
if th < 0.5 {
|
||||
th = 360.0;
|
||||
if angle.abs() < 0.05 {
|
||||
angle = 0.0;
|
||||
}
|
||||
let s = format!("{th:.1}");
|
||||
*value = format!("{} deg", s.trim_end_matches('0').trim_end_matches('.'));
|
||||
let text = format!("{angle:.1}");
|
||||
*value = format!("{} deg", text.trim_end_matches('0').trim_end_matches('.'));
|
||||
}
|
||||
}
|
||||
Some(end)
|
||||
}
|
||||
|
||||
/// An edge-on ring has two equally valid depths; retain the branch nearest the current handle.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn projected_rotation_angle(
|
||||
proj: &Proj,
|
||||
pointer: Pos2,
|
||||
centre: Vec3,
|
||||
e1: Vec3,
|
||||
e2: Vec3,
|
||||
radius: f64,
|
||||
current: f64,
|
||||
(lo, hi): (f64, f64),
|
||||
) -> Option<f64> {
|
||||
let distance = |angle: f64| {
|
||||
proj.to_screen(centre + (e1 * angle.cos() + e2 * angle.sin()) * radius)
|
||||
.map(|point| f64::from(point.distance_sq(pointer)))
|
||||
.unwrap_or(f64::INFINITY)
|
||||
};
|
||||
let step = (hi - lo) / 720.0;
|
||||
let mut best = None;
|
||||
for i in 0..=720 {
|
||||
let angle = lo + step * i as f64;
|
||||
let score = distance(angle);
|
||||
if !score.is_finite() {
|
||||
continue;
|
||||
}
|
||||
if best.is_none_or(|(old_angle, old_score): (f64, f64)| {
|
||||
score < old_score - 1e-4 || (score - old_score).abs() <= 1e-4 && (angle - current).abs() < (old_angle - current).abs()
|
||||
}) {
|
||||
best = Some((angle, score));
|
||||
}
|
||||
}
|
||||
let (angle, _) = best?;
|
||||
let (mut a, mut b) = ((angle - step).max(lo), (angle + step).min(hi));
|
||||
for _ in 0..24 {
|
||||
let (left, right) = (a + (b - a) / 3.0, b - (b - a) / 3.0);
|
||||
if distance(left) <= distance(right) {
|
||||
b = right;
|
||||
} else {
|
||||
a = left;
|
||||
}
|
||||
}
|
||||
Some((a + b) / 2.0)
|
||||
}
|
||||
fn arrow(painter: &egui::Painter, a: Pos2, b: Pos2, col: Color32) {
|
||||
let v = b - a;
|
||||
let l = v.length();
|
||||
|
||||
@@ -248,14 +248,7 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec<String> {
|
||||
/// Bodies in a component and its subcomponents.
|
||||
pub fn component_bodies(app: &SolveApp, id: u64) -> Vec<String> {
|
||||
let doc = &app.session.doc;
|
||||
app.session
|
||||
.model
|
||||
.state()
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| doc.component_within(doc.body_component(&b.name, b.feature), id))
|
||||
.map(|b| b.name.clone())
|
||||
.collect()
|
||||
app.session.model.state().bodies.iter().filter(|b| doc.component_within(doc.model_body_component(b), id)).map(|b| b.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// The eight radial commands, clockwise from the top.
|
||||
|
||||
@@ -1054,9 +1054,10 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
let active = i == d.active;
|
||||
// A chip: "↖ Select" (outlined) while empty, "N selected" (filled) once picked.
|
||||
let filled = !inp.items.is_empty();
|
||||
let custom_axis = i == 1 && inp.items.is_empty() && matches!(d.kind, Kind::Revolve { axis: Some(_), .. });
|
||||
let filled = !inp.items.is_empty() || custom_axis;
|
||||
let text = if filled {
|
||||
RichText::new(format!("↖ {} selected", inp.items.len())).color(Color32::WHITE)
|
||||
RichText::new(if custom_axis { "↖ Custom Axis".into() } else { format!("↖ {} selected", inp.items.len()) }).color(Color32::WHITE)
|
||||
} else {
|
||||
RichText::new("↖ Select").color(t.text)
|
||||
};
|
||||
@@ -1069,7 +1070,20 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
|
||||
.sense(egui::Sense::CLICK);
|
||||
let chip = ui.add(b);
|
||||
publish_row(&chip);
|
||||
if chip.on_hover_text(format!("{} (click to pick into this input)", hint(inp))).clicked() {
|
||||
let tooltip = if custom_axis {
|
||||
if let Kind::Revolve { axis: Some(axis), .. } = &d.kind {
|
||||
format!(
|
||||
"Custom axis\nOrigin: {}\nDirection: {}\nClick to select a replacement axis",
|
||||
axis.get("origin").map(ToString::to_string).unwrap_or_default(),
|
||||
axis.get("dir").map(ToString::to_string).unwrap_or_default()
|
||||
)
|
||||
} else {
|
||||
hint(inp).to_string()
|
||||
}
|
||||
} else {
|
||||
format!("{} (click to pick into this input)", hint(inp))
|
||||
};
|
||||
if chip.on_hover_text(tooltip).clicked() {
|
||||
activate = Some(i);
|
||||
}
|
||||
if !inp.items.is_empty() && ui.small_button("×").on_hover_text("Clear the selection").clicked() {
|
||||
@@ -1600,7 +1614,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
ui.add(
|
||||
egui::Label::new("Pick a cylinder wall to wrap the profiles around it. Leave Target Face empty for planar embossing.").wrap(),
|
||||
egui::Label::new(
|
||||
"Orthogonal line profiles tangent to one supported cylinder wrap automatically. Pick a wall to choose a target.",
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
ui.end_row();
|
||||
}
|
||||
@@ -2259,7 +2276,7 @@ pub(crate) fn emboss_profile_plane(app: &SolveApp, sketch: u64) -> Result<Option
|
||||
let body = model.body(&face.body).ok_or("the Emboss target body is missing")?;
|
||||
let owner = app.session.doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0);
|
||||
let plane = sketch.plane;
|
||||
let frame = face.sketch_frame(&app.session.doc, feature.component, owner, body.feature).map_err(|error| error.to_string())?;
|
||||
let frame = face.sketch_frame(&app.session.doc, feature.component, owner, body).map_err(|error| error.to_string())?;
|
||||
solvecraft_engine::geom::Plane::new(
|
||||
solvecraft_engine::doc::apply_point(&frame, plane.origin),
|
||||
solvecraft_engine::doc::apply_vector(&frame, plane.x),
|
||||
@@ -2953,7 +2970,7 @@ fn emboss_face_sel(s: &Session, component: u64, face: &solvecraft_engine::doc::E
|
||||
let source = s.model.state();
|
||||
let body = source.body(&face.body)?;
|
||||
let index = solvecraft_engine::doc::naming::face_names(body).iter().position(|name| name == persistent)?;
|
||||
let owner = s.doc.body_component(&face.body, body.feature);
|
||||
let owner = s.doc.model_body_component(body);
|
||||
let world_name = s.doc.world_body_name(&source, &face.body, owner, &face.path);
|
||||
let author = solvecraft_engine::doc::EmbossFace::component_frame(&s.doc, component, &face.author_path).ok()?;
|
||||
let point = solvecraft_engine::doc::apply_point(&author, face.at);
|
||||
|
||||
@@ -273,7 +273,7 @@ fn pt(v: Vec3) -> Value {
|
||||
fn occurrence_of_body(s: &Session, body: &str) -> Option<u64> {
|
||||
let st = s.world_state();
|
||||
let b = st.body(body)?;
|
||||
let comp = s.doc.body_component(&b.name, b.feature);
|
||||
let comp = s.doc.model_body_component(b);
|
||||
if comp == 0 { Some(0) } else { s.doc.occurrence_of(comp).map(|o| o.id) }
|
||||
}
|
||||
|
||||
|
||||
@@ -807,28 +807,35 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
continue;
|
||||
}
|
||||
ui.menu_button(format!("Faces on {}", body.name), |ui| {
|
||||
let names = solvecraft_engine::doc::naming::face_names(body);
|
||||
if let Ok(faces) = body.body.faces((body.body.size() * 2e-3).max(1e-3)) {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mesh = body.mesh();
|
||||
let mut points = std::collections::BTreeMap::new();
|
||||
for (triangle, index) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
if let std::collections::btree_map::Entry::Vacant(entry) = points.entry(*index as usize)
|
||||
&& let Some([a, b, c]) = mesh.tri(triangle)
|
||||
{
|
||||
entry.insert((a + b + c) * (1. / 3.));
|
||||
}
|
||||
}
|
||||
for face in faces {
|
||||
let Some(name) = names.get(face.index) else { continue };
|
||||
let name = solvecraft_engine::doc::naming::strip_piece(name);
|
||||
if !seen.insert(name.to_string()) {
|
||||
continue;
|
||||
}
|
||||
let Some((id, role)) = name.strip_prefix('F').and_then(|name| name.split_once(':')) else { continue };
|
||||
let Ok(origin_feature) = id.parse() else { continue };
|
||||
let target = solvecraft_engine::doc::config::ConfigFaceTarget {
|
||||
body: body.name.clone(),
|
||||
origin_feature,
|
||||
name: name.into(),
|
||||
point: face.centroid,
|
||||
// A curved face centroid can lie on another surface.
|
||||
// Pick an interior triangle point on this actual face.
|
||||
let Some(point) = points.get(&face.index).copied() else { continue };
|
||||
let Ok(target) = app.session.doc.configuration_face_target(body, face.index, point) else { continue };
|
||||
let supported = if target.certificate.is_some() {
|
||||
// Capture read this exact face's current source certificate.
|
||||
// Enumerate its pieces once in the command, not per menu face.
|
||||
app.session.doc.certify_config_face(&target).is_ok()
|
||||
} else {
|
||||
app.session.doc.configuration_face_indices(body, &target).is_ok()
|
||||
};
|
||||
if app.session.doc.certify_config_face(&target).is_err() {
|
||||
if !supported || !seen.insert(target.name.clone()) {
|
||||
continue;
|
||||
}
|
||||
let Some((_, role)) = target.name.split_once(':') else { continue };
|
||||
if ui.button(role).clicked() {
|
||||
pick = Some(json!({"face_appearance":{"body":body.name,"point":face.centroid}}));
|
||||
pick = Some(json!({"face_appearance":{"target":target}}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
@@ -926,7 +933,7 @@ pub fn commands(app: &SolveApp, k: &Mo, inputs: &[SelInput]) -> Result<Vec<(Stri
|
||||
if let solvecraft_engine::Sel::Body { name } = x
|
||||
&& let Some(b) = app.session.world_state().body(name)
|
||||
{
|
||||
let comp = app.session.doc.body_component(&b.name, b.feature);
|
||||
let comp = app.session.doc.model_body_component(b);
|
||||
if let Some(o) = app.session.doc.occurrence_of(comp).map(|o| o.id)
|
||||
&& !occs.contains(&o)
|
||||
{
|
||||
@@ -1127,6 +1134,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_annulus_picker_exposes_both_certificates_despite_shared_legacy_name() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.resize", json!({"width":1400,"height":900}));
|
||||
h.run(&json!([
|
||||
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[0,0],"radius":10,"id":"outer"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[0,0],"radius":9.999,"id":"inner"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.finish","params":{}}},
|
||||
{"call":"engine.execute","params":{"command":"solid.extrude","params":{"distance":10,"profiles":[{"point":[9.9995,0]}],"body_name":"Ring"}}},
|
||||
{"start":"config.table"},{"widget":"Add Column"},{"widget":"Face Appearance"},{"widget":"Faces on Ring"}
|
||||
]))
|
||||
.unwrap();
|
||||
assert!(h.widget("side:outer").is_some());
|
||||
assert!(h.widget("side:inner").is_some());
|
||||
h.run(&json!([{"widget":"side:outer"},{"widget":"Library"},{"widget":"ABS - Red"},{"widget":"Apply"}])).unwrap();
|
||||
let solvecraft_engine::doc::config::Column::FaceAppearance(target) = &h.app.session.doc.configs.columns[0] else { panic!() };
|
||||
assert!(matches!(&target.certificate, Some(solvecraft_engine::doc::config::FaceCertificate::ExtrudeSide { curve, .. }) if curve == "outer"));
|
||||
let state = h.app.session.model.state();
|
||||
let body = state.body("Ring").unwrap();
|
||||
let indices = h.app.session.doc.configuration_face_indices(body, target).unwrap();
|
||||
assert_eq!(indices.len(), 2);
|
||||
let painted = h.app.session.doc.face_colors(body);
|
||||
assert_eq!(painted.len(), 2);
|
||||
assert!(painted.iter().all(|(index, _)| indices.contains(index)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_style_picker_applies_then_inherits_material() {
|
||||
let steps: Value = serde_json::from_str(include_str!("../tests/scenarios/s80_configuration_styles.json")).unwrap();
|
||||
|
||||
@@ -714,9 +714,17 @@ pub fn overlay(painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool {
|
||||
if !(lo.0.is_finite() && hi.0 > lo.0 && hi.1 > lo.1) {
|
||||
return true;
|
||||
}
|
||||
// Fit with a margin, leaving the dialog's side free.
|
||||
let area = Rect::from_min_max(rect.min + vec2(40.0, 60.0), rect.max - vec2(320.0, 60.0));
|
||||
let area = if area.width() < 100.0 { rect.shrink(40.0) } else { area };
|
||||
// Fit all retained islands to the exposed viewport. The command panel can grow
|
||||
// to its width limit, so its actual edge, rather than a fixed reserve, bounds the view.
|
||||
let panel_left = painter
|
||||
.ctx()
|
||||
.memory(|m| m.area_rect(egui::Id::new(("sc_dialog", "FLAT PATTERN"))))
|
||||
.map(|panel| panel.left())
|
||||
.unwrap_or(rect.right() - crate::frame::DIALOG_MAX_W);
|
||||
let area = Rect::from_min_max(rect.min + vec2(40.0, 60.0), pos2(panel_left.min(rect.right()) - 20.0, rect.bottom() - 60.0));
|
||||
if area.width() <= 0.0 || area.height() <= 0.0 {
|
||||
return true;
|
||||
}
|
||||
let scale = (f64::from(area.width()) / (hi.0 - lo.0)).min(f64::from(area.height()) / (hi.1 - lo.1));
|
||||
let ox = f64::from(area.center().x) - (lo.0 + hi.0) * 0.5 * scale;
|
||||
let oy = f64::from(area.center().y) + (lo.1 + hi.1) * 0.5 * scale;
|
||||
@@ -733,6 +741,26 @@ pub fn overlay(painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool {
|
||||
painter.add(egui::Shape::closed_line(pts, Stroke::new(1.4, t.text)));
|
||||
}
|
||||
}
|
||||
// A pocket retains material below (or above) the cut. Dashed contours and a depth
|
||||
// annotation distinguish it from the solid through-cut boundaries.
|
||||
let pockets: Vec<&Value> = v["pockets"].as_array().into_iter().flatten().collect();
|
||||
for pocket in &pockets {
|
||||
let outer: Vec<(f64, f64)> = pocket["outline"].as_array().into_iter().flatten().filter_map(read).collect();
|
||||
for contour in std::iter::once(&pocket["outline"]).chain(pocket["cutouts"].as_array().into_iter().flatten()) {
|
||||
let mut pts: Vec<Pos2> = contour.as_array().into_iter().flatten().filter_map(read).map(to).collect();
|
||||
if pts.len() >= 3 {
|
||||
pts.push(pts[0]);
|
||||
painter.extend(egui::Shape::dashed_line(&pts, Stroke::new(1.6, t.accent), 3.0, 3.0));
|
||||
}
|
||||
}
|
||||
if !outer.is_empty() {
|
||||
let bounds = outer.iter().map(|p| to(*p)).fold(Rect::NOTHING, |r, p| r.union(Rect::from_min_max(p, p)));
|
||||
let anchor = bounds.center_top() - vec2(0.0, 6.0);
|
||||
if let Some(label) = pocket_label(pocket) {
|
||||
painter.text(anchor, Align2::CENTER_BOTTOM, label, FontId::proportional(12.0), t.accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
for b in v["bends"].as_array().into_iter().flatten() {
|
||||
let (Some(a), Some(e)) = (read(&b["start"]), read(&b["end"])) else { continue };
|
||||
let col = if b["direction"] == "up" { up_col } else { down_col };
|
||||
@@ -746,9 +774,33 @@ pub fn overlay(painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool {
|
||||
painter.line_segment([pos2(rect.left() + 14.0, y + 7.0), pos2(rect.left() + 38.0, y + 7.0)], Stroke::new(2.0, col));
|
||||
painter.text(pos2(rect.left() + 44.0, y), Align2::LEFT_TOP, label, FontId::proportional(12.0), t.text_dim);
|
||||
}
|
||||
if !pockets.is_empty() {
|
||||
y += 20.0;
|
||||
painter.extend(egui::Shape::dashed_line(
|
||||
&[pos2(rect.left() + 14.0, y + 7.0), pos2(rect.left() + 38.0, y + 7.0)],
|
||||
Stroke::new(1.6, t.accent),
|
||||
3.0,
|
||||
3.0,
|
||||
));
|
||||
painter.text(pos2(rect.left() + 44.0, y), Align2::LEFT_TOP, "Pocket boundary (material remains)", FontId::proportional(12.0), t.text_dim);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn pocket_label(pocket: &Value) -> Option<String> {
|
||||
let depth = pocket["depth_mm"].as_f64()?;
|
||||
if !depth.is_finite() || depth <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let side = match pocket["side"].as_str()? {
|
||||
"top" => "Top",
|
||||
"bottom" => "Bottom",
|
||||
"internal" => "Internal",
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!("{side} pocket: {depth} mm deep"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "dialogs_sheet_tests.rs"]
|
||||
mod tests;
|
||||
@@ -127,6 +127,130 @@ fn flat_exports_use_the_save_picker() {
|
||||
assert_eq!(picks[1].1, vec!["dxf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shallow_pocket_overlay_labels_depth_without_drawing_a_through_void() {
|
||||
let mut app = plate();
|
||||
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
|
||||
app.run("sketch.create", json!({"plane": "XY", "name": "Pocket"})).unwrap();
|
||||
app.run("sketch.rectangle.two_point", json!({"p0": [20, 20], "p1": [30, 30]})).unwrap();
|
||||
app.run("sketch.finish", json!({})).unwrap();
|
||||
app.run("solid.extrude", json!({"sketch": "Pocket", "distance": 1, "operation": "cut"})).unwrap();
|
||||
let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap();
|
||||
assert_eq!(flat["cutouts"], json!([]));
|
||||
assert_eq!(flat["pockets"][0]["depth_mm"], 1.0);
|
||||
app.start("sheet.flat_pattern");
|
||||
let mut dialog = app.dialog.clone().unwrap();
|
||||
let Kind::Sheet(Sm::FlatPattern { data, .. }) = &mut dialog.kind else { panic!("flat dialog") };
|
||||
*data = Some(Ok(flat));
|
||||
let ctx = egui::Context::default();
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("flat-pocket-test")));
|
||||
assert!(overlay(&painter, Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)), &dialog));
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
let texts: Vec<&str> = output
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
egui::Shape::Text(text) => Some(text.galley.text()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(texts.contains(&"Bottom pocket: 1 mm deep"));
|
||||
assert!(texts.contains(&"Pocket boundary (material remains)"));
|
||||
let paths: Vec<_> = output
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
egui::Shape::Path(path) => Some(path),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!paths.is_empty(), "the retained sheet outline is drawn");
|
||||
assert!(paths.iter().all(|p| p.fill == Color32::TRANSPARENT), "pockets must not be rendered as filled voids");
|
||||
let lines = output.shapes.iter().filter(|s| matches!(s.shape, egui::Shape::LineSegment { .. })).count();
|
||||
assert!(lines > 6, "pocket contours and legend use dashed lines, not through-cut strokes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_pattern_islands_fit_before_the_actual_command_panel() {
|
||||
let mut app = plate();
|
||||
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
|
||||
app.run("sketch.create", json!({"plane": "XY", "name": "Retain"})).unwrap();
|
||||
for x in [20, 60] {
|
||||
app.run("sketch.rectangle.two_point", json!({"p0": [x, 20], "p1": [x + 10, 30]})).unwrap();
|
||||
}
|
||||
app.run("sketch.finish", json!({})).unwrap();
|
||||
app.run("solid.extrude", json!({"sketch": "Retain", "profiles": "all", "through_all": true, "direction": "symmetric", "operation": "intersect"}))
|
||||
.unwrap();
|
||||
let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap();
|
||||
assert_eq!(flat["outline"].as_array().unwrap().len(), 2);
|
||||
app.start("sheet.flat_pattern");
|
||||
let mut dialog = app.dialog.clone().unwrap();
|
||||
let Kind::Sheet(Sm::FlatPattern { data, .. }) = &mut dialog.kind else { panic!("flat dialog") };
|
||||
*data = Some(Ok(flat));
|
||||
let ctx = egui::Context::default();
|
||||
let id = egui::Id::new(("sc_dialog", "FLAT PATTERN"));
|
||||
let mut output = egui::FullOutput::default();
|
||||
for _ in 0..3 {
|
||||
output = ctx.run_ui(egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), ..Default::default() }, |ui| {
|
||||
crate::frame::docked("FLAT PATTERN", id, pos2(1400.0, 294.0), 540.0, crate::frame::Width::Normal).min_width(360.0).show(ui.ctx(), |ui| {
|
||||
ui.label("Flat size: 50 × 10 × 2.5 mm");
|
||||
});
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Background, egui::Id::new("island-fit")));
|
||||
assert!(overlay(&painter, Rect::from_min_max(pos2(250.0, 126.0), pos2(1400.0, 832.0)), &dialog));
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
let panel = ctx.memory(|m| m.area_rect(id)).unwrap();
|
||||
let outlines: Vec<_> = output
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
egui::Shape::Path(p) if p.closed && p.fill == Color32::TRANSPARENT => Some(p),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(outlines.len(), 2, "both retained islands are drawn");
|
||||
assert!(
|
||||
outlines.iter().flat_map(|p| &p.points).all(|p| p.x <= panel.left() - 19.0),
|
||||
"both complete islands must remain exposed before the command panel: {panel:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pocket_export_button_uses_the_picker_and_preserves_depth_layers() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
for (command, params) in [
|
||||
("sketch.create", json!({"plane":"XY","name":"Base"})),
|
||||
("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[100,60]})),
|
||||
("sketch.finish", json!({})),
|
||||
("sheet.flange", json!({"sketch":"Base"})),
|
||||
("sketch.create", json!({"plane":"XY","name":"Pocket"})),
|
||||
("sketch.rectangle.two_point", json!({"p0":[20,20],"p1":[30,30]})),
|
||||
("sketch.finish", json!({})),
|
||||
("solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"})),
|
||||
] {
|
||||
h.app.run(command, params).unwrap();
|
||||
}
|
||||
let path = h.dir.join("picked-pocket.dxf");
|
||||
let chosen = path.to_string_lossy().into_owned();
|
||||
h.app.services.pick_save = Some(Box::new(move |name, extensions| {
|
||||
assert!(name.ends_with("-Body1.dxf"));
|
||||
assert_eq!(extensions, &["dxf"]);
|
||||
Some(chosen.clone())
|
||||
}));
|
||||
h.app.start("sheet.flat_pattern");
|
||||
h.settle();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"widget":"Export DXF…"})).unwrap();
|
||||
let exported = std::fs::read_to_string(&path).expect("actual Export DXF button writes the picker destination");
|
||||
assert!(exported.contains("POCKET_BOTTOM_Z0.000000_TO_1.000000_MM"));
|
||||
assert!(exported.contains("OUTLINE"));
|
||||
assert_eq!(h.app.session.undo.len(), undo, "export does not alter the part");
|
||||
assert!(h.app.dialog.is_some(), "export keeps the flat viewer open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn bend_table_picker_writes_the_current_body() {
|
||||
|
||||
@@ -67,10 +67,14 @@ pub mod recovery_ui;
|
||||
pub mod ref_images;
|
||||
#[cfg(test)]
|
||||
mod revolve_edit_tests;
|
||||
#[cfg(test)]
|
||||
mod revolve_manipulator_tests;
|
||||
pub mod scenario;
|
||||
pub mod selection;
|
||||
pub mod shortcut_box;
|
||||
pub mod sketch3d;
|
||||
#[cfg(test)]
|
||||
mod sketch3d_frame_ui_tests;
|
||||
pub mod sketch_dims;
|
||||
#[cfg(test)]
|
||||
mod sketch_edit_tests;
|
||||
|
||||
@@ -309,10 +309,33 @@ fn delete_key_handles_every_kind_and_asks_about_dependents() {
|
||||
fn menu_delete_items_use_the_same_path() {
|
||||
let mut app = sample_app();
|
||||
let b = body(&app);
|
||||
app.run("component.from_bodies", json!({"bodies": [b]})).unwrap();
|
||||
let original_volume = solvecraft_engine::kernel::measure(&app.session.world_state().body(&b).unwrap().body).unwrap().volume;
|
||||
let created = app.run("component.from_bodies", json!({"bodies": [b]})).unwrap();
|
||||
let c = app.session.doc.components[0].id;
|
||||
let transfer = created["components"][0]["feature"].as_u64().unwrap();
|
||||
let transfer_name = app.session.doc.feature(transfer).unwrap().name.clone();
|
||||
let saved = app.session.doc.to_json();
|
||||
act(&mut app, &Target::Component { id: c }, "Delete");
|
||||
let confirmation = app.menu.confirm.as_ref().expect("recorded transfer history needs confirmation");
|
||||
assert!(confirmation.report["deleted"].as_array().unwrap().iter().any(|name| name.as_str() == Some(transfer_name.as_str())));
|
||||
assert!(crate::delete::confirm(&mut app, false));
|
||||
assert_eq!(app.session.doc.to_json(), saved, "cancel keeps component and transfer history");
|
||||
act(&mut app, &Target::Component { id: c }, "Delete");
|
||||
assert!(crate::delete::confirm(&mut app, true));
|
||||
assert!(app.session.doc.components.is_empty());
|
||||
assert!(app.session.doc.feature(transfer).is_none());
|
||||
let restored = app.session.world_state();
|
||||
assert_eq!(restored.bodies.len(), 1, "the original joined plate stays intact");
|
||||
let plate = restored.body(&b).unwrap();
|
||||
assert_eq!(plate.component, 0);
|
||||
assert!((solvecraft_engine::kernel::measure(&plate.body).unwrap().volume - original_volume).abs() < 1e-6);
|
||||
assert!(plate.mesh().contains(solvecraft_engine::geom::Vec3::new(40.0, 35.0, 12.0)));
|
||||
let mut reopened = solvecraft_engine::Session::new(solvecraft_engine::doc::Document::from_json(&app.session.doc.to_json()).unwrap());
|
||||
reopened.refresh();
|
||||
let reopened_state = reopened.world_state();
|
||||
assert_eq!(reopened_state.bodies.len(), 1);
|
||||
assert_eq!(reopened_state.body(&b).unwrap().component, 0);
|
||||
assert!((solvecraft_engine::kernel::measure(&reopened_state.body(&b).unwrap().body).unwrap().volume - original_volume).abs() < 1e-6);
|
||||
// A sketch from the browser: asks, since its features go too.
|
||||
let id = app.session.doc.features.iter().find(|f| f.name == "Base").unwrap().id;
|
||||
act(&mut app, &Target::Sketch { id }, "Delete");
|
||||
|
||||
@@ -3,6 +3,28 @@ use crate::scenario::Harness;
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::Sel;
|
||||
|
||||
#[test]
|
||||
fn replaced_body_name_uses_evaluated_owner_in_browser_and_component_actions() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})).unwrap();
|
||||
let transferred = h.app.run("component.from_bodies", json!({"bodies":["Block"]})).unwrap();
|
||||
let owner = transferred["components"][0]["component"].as_u64().unwrap();
|
||||
assert_eq!(context_menu::component_bodies(&h.app, owner), ["Block"]);
|
||||
h.app.run("solid.remove", json!({"bodies":["Block"]})).unwrap();
|
||||
h.app.run("solid.box", json!({"corner":[30,0,0],"length":5,"width":5,"height":5,"body_name":"Block"})).unwrap();
|
||||
h.frames(2);
|
||||
let state = h.app.session.model.state();
|
||||
let body = state.body("Block").unwrap();
|
||||
assert_eq!(h.app.session.doc.model_body_component(body), 0);
|
||||
assert_eq!(context_menu::component_bodies(&h.app, 0), ["Block"]);
|
||||
assert!(context_menu::component_bodies(&h.app, owner).is_empty());
|
||||
assert_eq!(super::entries(&h.app, 0, "bodies").iter().map(|entry| entry.key.as_str()).collect::<Vec<_>>(), ["Block"]);
|
||||
assert!(super::entries(&h.app, owner, "bodies").is_empty());
|
||||
let bounds = h.app.session.world_state().body("Block").unwrap().mesh().bounds();
|
||||
assert!((bounds.min.x - 30.0).abs() < 1e-9);
|
||||
assert!((bounds.max.x - 35.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
fn fixture() -> (Harness, u64, u64, u64) {
|
||||
let mut h = Harness::new();
|
||||
let part = h.app.run("component.create", json!({"name":"Block"})).unwrap();
|
||||
|
||||
@@ -8,6 +8,58 @@ use solvecraft_engine::{Sel, Session};
|
||||
use crate::dialogs::{Dialog, Kind, apply_commands};
|
||||
use crate::{Services, SolveApp};
|
||||
|
||||
#[test]
|
||||
fn cylinder_emboss_empty_target_face_applies_automatic_wrap_and_deboss() {
|
||||
for deboss in [false, true] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap();
|
||||
let initial = h.app.session.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
let sketch =
|
||||
h.app.run("sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})).unwrap()["sketch"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.session.selection = vec![Sel::Profile { sketch, index: 0 }];
|
||||
h.app.start("solid.emboss");
|
||||
h.frames(2);
|
||||
if deboss {
|
||||
h.settle();
|
||||
h.step(&json!({"click":{"handle":"dialog:Type"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Type:Deboss"}})).unwrap();
|
||||
}
|
||||
let dialog = h.app.dialog.as_ref().unwrap();
|
||||
assert!(dialog.inputs[1].items.is_empty());
|
||||
let commands = apply_commands(&h.app, dialog).unwrap();
|
||||
assert!(commands[0].1.get("face").is_none());
|
||||
let preview = h.app.session.preview_world(&commands).unwrap();
|
||||
let expected = if deboss { -19.0 } else { 21.0 };
|
||||
let preview_delta = preview.after.body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume - initial;
|
||||
assert!(
|
||||
(preview_delta - expected).abs() < 0.05,
|
||||
"deboss={deboss}, preview delta={preview_delta}, expected={expected}, commands={commands:?}"
|
||||
);
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
|
||||
let feature = h.app.session.doc.features.last().unwrap().clone();
|
||||
let solvecraft_engine::doc::FeatureKind::Emboss { face: Some(face), deboss: actual, .. } = &feature.kind else {
|
||||
panic!("automatic Apply must save its cylinder anchor: {:?}", feature.kind)
|
||||
};
|
||||
assert_eq!(*actual, deboss);
|
||||
assert_eq!(face.body, "Sleeve");
|
||||
assert!(
|
||||
(h.app.session.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume - initial - expected)
|
||||
.abs()
|
||||
< 0.05
|
||||
);
|
||||
h.app.edit_feature(feature.id);
|
||||
h.frames(2);
|
||||
assert!(matches!(h.app.dialog.as_ref().unwrap().inputs[1].items.first(), Some(Sel::Face { body, .. }) if body == "Sleeve"));
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert_eq!(h.app.session.doc.feature(feature.id).unwrap().kind, feature.kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_emboss_target_face_and_edit_keep_the_anchored_operation() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
|
||||
@@ -103,6 +103,28 @@ impl PreviewState {
|
||||
Err(e) => self.error = Some(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors validate only the current inputs. Keep the last good geometry while
|
||||
/// computing new inputs, but never let an obsolete result block their Apply.
|
||||
fn poll(&mut self, key: u64) {
|
||||
if self.shown_key != Some(key) {
|
||||
self.error = None;
|
||||
}
|
||||
if let Some((k, rx)) = &self.running {
|
||||
let k = *k;
|
||||
let job = match rx.try_recv() {
|
||||
Ok(job) => Some(job),
|
||||
Err(TryRecvError::Empty) => None,
|
||||
Err(TryRecvError::Disconnected) => Some(Err("the preview failed".into())),
|
||||
};
|
||||
if let Some(job) = job {
|
||||
self.running = None;
|
||||
if k == key {
|
||||
self.apply(k, job);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the preview in step with the open dialog: start a computation when its inputs changed,
|
||||
@@ -127,20 +149,7 @@ pub fn update(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
format!("{colors:?}").hash(&mut h);
|
||||
let key = h.finish();
|
||||
let pv = &mut app.preview;
|
||||
if let Some((k, rx)) = &pv.running {
|
||||
let k = *k;
|
||||
match rx.try_recv() {
|
||||
Ok(job) => {
|
||||
pv.running = None;
|
||||
pv.apply(k, job);
|
||||
}
|
||||
Err(TryRecvError::Empty) => {}
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
pv.running = None;
|
||||
pv.apply(k, Err("the preview failed".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
pv.poll(key);
|
||||
if pv.running.is_none() && pv.shown_key != Some(key) {
|
||||
pv.started = crate::now_ms();
|
||||
let scratch = app.session.scratch();
|
||||
@@ -367,6 +376,41 @@ mod tests {
|
||||
Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correcting_inputs_clears_old_errors_and_discards_late_old_jobs() {
|
||||
let mut pv = PreviewState {
|
||||
shown_key: Some(1),
|
||||
error: Some("unknown_angle".into()),
|
||||
active: true,
|
||||
replaced: vec!["Turn".into()],
|
||||
key: 9,
|
||||
..PreviewState::default()
|
||||
};
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
pv.running = Some((1, rx));
|
||||
pv.poll(2);
|
||||
assert!(pv.error.is_none(), "the corrected inputs can Apply while their preview computes");
|
||||
assert!(pv.running.is_some());
|
||||
assert!(pv.active);
|
||||
assert_eq!(pv.replaced, ["Turn"]);
|
||||
assert_eq!(pv.key, 9, "the last good geometry stays visible");
|
||||
tx.send(Err("unknown_angle".into())).unwrap();
|
||||
pv.poll(2);
|
||||
assert!(pv.error.is_none(), "a late obsolete result cannot disable Apply again");
|
||||
assert!(pv.running.is_none());
|
||||
assert_eq!(pv.shown_key, Some(1));
|
||||
assert_eq!(pv.key, 9);
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
pv.running = Some((2, rx));
|
||||
tx.send(Err("current invalid expression".into())).unwrap();
|
||||
pv.poll(2);
|
||||
assert_eq!(pv.error.as_deref(), Some("current invalid expression"));
|
||||
assert_eq!(pv.shown_key, Some(2));
|
||||
pv.poll(2);
|
||||
assert_eq!(pv.error.as_deref(), Some("current invalid expression"), "current errors still prevent invalid Apply");
|
||||
}
|
||||
|
||||
fn count(bytes: &[u8], stride: usize, col: [u8; 4]) -> usize {
|
||||
bytes.chunks(stride).filter(|v| v.get(24..28) == Some(&col[..])).count()
|
||||
}
|
||||
|
||||
@@ -109,6 +109,12 @@ fn second_active_occurrence_noop_preserves_definition_axis_target_and_instances(
|
||||
let (origin, _) = crate::canvas::revolve_axis(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert!((origin - expected_origin).len() < 1e-8);
|
||||
assert!(origin.len() > 50.0, "fixture must distinguish first and second placements");
|
||||
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let preview = h.app.session.preview_world(&commands).unwrap();
|
||||
let placed = preview.after.bodies.iter().find(|body| body.mesh().bounds().center().x > 50.0).unwrap();
|
||||
assert!(placed.mesh().triangles.len() > 10);
|
||||
assert_eq!(placed.mesh().bounds(), before.iter().find(|(_, bounds)| bounds.center().x > 50.0).unwrap().1);
|
||||
assert!(h.widget("↖ Custom Axis").is_some());
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
|
||||
let after: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect();
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::doc::{FeatureKind, kernel};
|
||||
|
||||
fn fixture(mut h: Harness, extra: Value) -> Harness {
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Profile"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[10,0],"p1":[15,5]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let mut params = json!({"sketch":"Profile","axis":"Y","angle":"90 deg","operation":"new","name":"Turn","body_name":"Main"});
|
||||
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
h.app.run("solid.revolve", params).unwrap();
|
||||
h.frames(4);
|
||||
h
|
||||
}
|
||||
fn open(h: &mut Harness) {
|
||||
h.step(&json!({"call":"ui.view","params":{"view":"front"}})).unwrap();
|
||||
h.step(&json!({"call":"ui.view","params":{"view":"fit"}})).unwrap();
|
||||
h.step(&json!({"call":"ui.scroll","params":{"x":700,"y":450,"delta":-400}})).unwrap();
|
||||
h.step(&json!({"call":"ui.editFeature","params":{"feature":"Turn"}})).unwrap();
|
||||
h.frames(8);
|
||||
}
|
||||
fn at_angle(degrees: f64) -> Value {
|
||||
let a = degrees.to_radians();
|
||||
json!([12.5 * a.cos(), 2.5, 12.5 * a.sin()])
|
||||
}
|
||||
fn handle_at(h: &mut Harness, name: &str, world: Value) {
|
||||
let actual = h.at(&json!({"handle":name})).unwrap();
|
||||
let expected = h.at(&json!({"world":world})).unwrap();
|
||||
assert!((actual[0] - expected[0]).hypot(actual[1] - expected[1]) < 1.0, "{actual:?} != {expected:?}");
|
||||
}
|
||||
fn volume(h: &Harness) -> f64 {
|
||||
kernel::measure(&h.app.session.world_state().body("Main").unwrap().body).unwrap().volume
|
||||
}
|
||||
fn angle(h: &Harness) -> String {
|
||||
let FeatureKind::Revolve { angle, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
angle.clone()
|
||||
}
|
||||
fn expected_volume(degrees: f64) -> f64 {
|
||||
0.5 * (225.0 - 100.0) * 5.0 * degrees.to_radians()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_positive_negative_axis_and_negative_sweep_drags_follow_geometry() {
|
||||
for (params, sign) in [(json!({}), 1.0), (json!({"axis":{"origin":[0,0,0],"dir":[0,-2,0]}}), 1.0), (json!({"angle":"-90 deg"}), -1.0)] {
|
||||
let mut h = fixture(Harness::new(), params.clone());
|
||||
let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
|
||||
let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone();
|
||||
let undo = h.app.session.undo.len();
|
||||
open(&mut h);
|
||||
handle_at(&mut h, "revolve", at_angle(sign * 90.0));
|
||||
h.step(&json!({"drag":[{"handle":"revolve"},{"world":at_angle(sign * 60.0)}]})).unwrap();
|
||||
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let preview = h.app.session.preview_world(&commands).unwrap();
|
||||
assert!((kernel::measure(&preview.after.body("Main").unwrap().body).unwrap().volume - expected_volume(60.0)).abs() < 0.03);
|
||||
let bounds = preview.after.body("Main").unwrap().mesh().bounds();
|
||||
if sign > 0.0 {
|
||||
assert!(bounds.min.z > -1e-8 && bounds.max.z > 12.9);
|
||||
} else {
|
||||
assert!(bounds.max.z < 1e-6 && bounds.min.z < -12.9, "{params}: {bounds:?}");
|
||||
}
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(angle(&h), if params.get("angle").is_some() { "-60 deg" } else { "60 deg" });
|
||||
assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names);
|
||||
assert!((volume(&h) - expected_volume(60.0)).abs() < 0.03);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_boundary_drags_edit_included_angle_and_cancel_is_lossless() {
|
||||
for (handle, sign) in [("revolve", 1.0), ("revolve2", -1.0)] {
|
||||
let mut h = fixture(Harness::new(), json!({"angle":"120 deg","direction":"symmetric"}));
|
||||
let original = serde_json::to_value(&*h.app.session.doc).unwrap();
|
||||
let undo = h.app.session.undo.len();
|
||||
open(&mut h);
|
||||
handle_at(&mut h, "revolve", at_angle(60.0));
|
||||
handle_at(&mut h, "revolve2", at_angle(-60.0));
|
||||
h.step(&json!({"drag":[{"handle":handle},{"world":at_angle(sign * 45.0)}]})).unwrap();
|
||||
let crate::dialogs::Kind::Revolve { angle: entered, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(entered, "90 deg");
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&*h.app.session.doc).unwrap(), original);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
open(&mut h);
|
||||
h.step(&json!({"drag":[{"handle":handle},{"world":at_angle(sign * 45.0)}]})).unwrap();
|
||||
let crate::dialogs::Kind::Revolve { angle: entered, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(entered, "90 deg", "second {handle} drag");
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!((volume(&h) - expected_volume(90.0)).abs() < 0.1, "{handle}: {} angle {}", volume(&h), angle(&h));
|
||||
let bounds = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
|
||||
assert!((bounds.max.z - 15.0 / 2.0f64.sqrt()).abs() < 0.03 && (bounds.min.z + 15.0 / 2.0f64.sqrt()).abs() < 0.03);
|
||||
let path = h.dir.join("symmetric.solvecraft");
|
||||
h.app.run("file.save", json!({"path":path})).unwrap();
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
assert_eq!(angle(&h), "90 deg");
|
||||
assert!((volume(&h) - expected_volume(90.0)).abs() < 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_object_displays_derived_sweep_without_dormant_edit_until_angle_selected() {
|
||||
let mut h = fixture(Harness::new(), json!({"angle":"45 deg","angle2":"30 deg","to":[0,2,12]}));
|
||||
let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
|
||||
open(&mut h);
|
||||
assert!(h.widget("Sweep 90.0°").is_some());
|
||||
assert!(crate::scenario::handle_at("revolve").is_none());
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"180 deg"})).unwrap();
|
||||
let crate::dialogs::Kind::Revolve { angle, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(angle, "45 deg");
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
|
||||
open(&mut h);
|
||||
h.step(&json!({"click":{"handle":"dialog:Type"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Type:Angle"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Angle"}})).unwrap();
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"90 deg"})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
let FeatureKind::Revolve { to, angle2, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert!(to.is_none());
|
||||
assert_eq!(angle2.as_deref(), Some("30 deg"));
|
||||
assert!((volume(&h) - expected_volume(120.0)).abs() < 0.03);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_side_boundary_preserves_other_angle_and_parameter_binding() {
|
||||
let mut h = fixture(Harness::new(), json!({"angle":"60 deg","angle2":"30 deg"}));
|
||||
h.app.run("solid.box", json!({"length":1,"width":1,"height":"(d2 / (1 rad)) * 1 mm","corner":[30,0,0],"body_name":"Dependent"})).unwrap();
|
||||
let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone();
|
||||
open(&mut h);
|
||||
handle_at(&mut h, "revolve", at_angle(60.0));
|
||||
handle_at(&mut h, "revolve2", at_angle(-30.0));
|
||||
h.step(&json!({"drag":[{"handle":"revolve2"},{"world":at_angle(-45.0)}]})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
let FeatureKind::Revolve { angle, angle2, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(angle, "60 deg");
|
||||
assert_eq!(angle2.as_deref(), Some("45 deg"));
|
||||
assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names);
|
||||
assert!((h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z - std::f64::consts::FRAC_PI_4).abs() < 1e-8);
|
||||
assert!((volume(&h) - expected_volume(105.0)).abs() < 0.03);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_second_placement_custom_axis_handle_and_preview_stay_in_its_frame() {
|
||||
let mut h = Harness::new();
|
||||
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
|
||||
let mut h = fixture(h, json!({"axis":{"origin":[0,0,0],"dir":[0,2,0]}}));
|
||||
let second = h.app.run("occurrence.copy", json!({"component":owner["component"],"translate":[100,0,20]})).unwrap()["occurrence"].clone();
|
||||
h.app.run("occurrence.move", json!({"occurrence":second,"axis":[0,0,1],"angle":"17 deg"})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":owner["component"],"occurrence":second,"path":[second]})).unwrap();
|
||||
let frame = h.app.session.active_component_frame();
|
||||
let world = |degrees: f64| {
|
||||
let a = degrees.to_radians();
|
||||
let p = solvecraft_engine::doc::apply_point(&frame, solvecraft_engine::geom::Vec3::new(12.5 * a.cos(), 2.5, 12.5 * a.sin()));
|
||||
json!([p.x, p.y, p.z])
|
||||
};
|
||||
let axis = match &h.app.session.doc.find_feature("Turn").unwrap().kind {
|
||||
FeatureKind::Revolve { axis, .. } => axis.clone(),
|
||||
_ => panic!("revolve"),
|
||||
};
|
||||
open(&mut h);
|
||||
assert!(h.widget("↖ Custom Axis").is_some());
|
||||
handle_at(&mut h, "revolve", world(90.0));
|
||||
h.step(&json!({"drag":[{"handle":"revolve"},{"world":world(60.0)}]})).unwrap();
|
||||
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let preview = h.app.session.preview_world(&commands).unwrap();
|
||||
let placed = preview.after.bodies.iter().find(|body| body.mesh().bounds().center().x > 50.0).unwrap();
|
||||
assert!((kernel::measure(&placed.body).unwrap().volume - expected_volume(60.0)).abs() < 0.03);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
let FeatureKind::Revolve { axis: after, angle, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(after, &axis);
|
||||
assert_eq!(angle, "60 deg");
|
||||
assert_eq!(h.app.session.active_occurrence_path.as_deref(), Some(&[second.as_u64().unwrap()][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curved_to_object_has_surface_feedback_without_an_angular_handle_or_input() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.cylinder", json!({"radius":3,"height":5,"base":[0,0,12],"axis":"Y","operation":"new","body_name":"Target"})).unwrap();
|
||||
let mut h = fixture(h, json!({}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"click":{"handle":"dialog:Type"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Type:To Object"}})).unwrap();
|
||||
let mut dialog = h.app.dialog.take().unwrap();
|
||||
dialog.active = dialog.inputs.iter().position(|input| input.label == "To Object").unwrap();
|
||||
dialog.pick(&h.app.session, solvecraft_engine::Sel::Vertex { body: "Target".into(), point: solvecraft_engine::geom::Vec3::new(0.0, 2.0, 15.0) });
|
||||
h.app.dialog = Some(dialog);
|
||||
h.frames(8);
|
||||
assert!(h.widget("To surface").is_some());
|
||||
assert!(crate::scenario::handle_at("revolve").is_none());
|
||||
assert!(crate::scenario::handle_at("revolve2").is_none());
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"180 deg"})).unwrap();
|
||||
let crate::dialogs::Kind::Revolve { angle: entered, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(entered, "90 deg");
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert_eq!(angle(&h), "90 deg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_drag_and_invalid_expression_stay_editable_before_commit() {
|
||||
let mut h = fixture(Harness::new(), json!({}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"drag":[{"handle":"revolve"},{"world":at_angle(37.3)}],"ctrl":true})).unwrap();
|
||||
let crate::dialogs::Kind::Revolve { angle: entered, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") };
|
||||
assert!((h.app.session.doc.eval(entered, solvecraft_engine::doc::expr::Kind::Angle).unwrap().to_degrees() - 37.3).abs() < 0.2);
|
||||
h.step(&json!({"click":{"handle":"dialog:Angle"}})).unwrap();
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"unknown_angle"})).unwrap();
|
||||
h.frames(4);
|
||||
assert!(h.widget("unknown_angle").is_some());
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_some());
|
||||
h.step(&json!({"click":{"handle":"dialog:Angle"}})).unwrap();
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"60 deg"})).unwrap();
|
||||
let crate::dialogs::Kind::Revolve { angle: entered, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(entered, "60 deg");
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().map(|dialog| &dialog.error));
|
||||
assert_eq!(angle(&h), "60 deg");
|
||||
assert!((volume(&h) - expected_volume(60.0)).abs() < 0.03);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_side_can_be_dragged_to_zero_without_becoming_full_turn() {
|
||||
let mut h = fixture(Harness::new(), json!({"angle":"60 deg","angle2":"30 deg"}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"drag":[{"handle":"revolve2"},{"world":at_angle(0.0)}]})).unwrap();
|
||||
assert_eq!(h.app.dialog.as_ref().unwrap().extra.get("angle2"), Some(&json!("0 deg")));
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
let FeatureKind::Revolve { angle2, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(angle2.as_deref(), Some("0 deg"));
|
||||
assert!((volume(&h) - expected_volume(60.0)).abs() < 0.03);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_on_ring_drag_uses_nearest_projected_branch() {
|
||||
let mut h = fixture(Harness::new(), json!({}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"call":"ui.view","params":{"view":"top"}})).unwrap();
|
||||
handle_at(&mut h, "revolve", at_angle(90.0));
|
||||
h.step(&json!({"drag":[{"handle":"revolve"},{"world":at_angle(60.0)}]})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(angle(&h), "60 deg");
|
||||
assert!((volume(&h) - expected_volume(60.0)).abs() < 0.03);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_reversed_axis_symmetric_interval_is_shown_and_preserved_exactly() {
|
||||
let mut h = fixture(Harness::new(), json!({"axis":{"origin":[0,0,0],"dir":[0,-2,0]},"direction":"symmetric","angle":"120 deg"}));
|
||||
let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
|
||||
let before = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
|
||||
assert!(before.min.x < -14.9 && before.max.x < 7.6 && before.min.z > -1e-6 && before.max.z > 14.9);
|
||||
open(&mut h);
|
||||
handle_at(&mut h, "revolve", at_angle(180.0));
|
||||
handle_at(&mut h, "revolve2", at_angle(60.0));
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), before);
|
||||
open(&mut h);
|
||||
h.step(&json!({"drag":[{"handle":"revolve"},{"world":at_angle(135.0)}]})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(angle(&h), "90 deg");
|
||||
let after = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
|
||||
assert!((after.min.x + 15.0 / 2.0f64.sqrt()).abs() < 0.03 && (after.max.x - 15.0 / 2.0f64.sqrt()).abs() < 0.03);
|
||||
assert!(after.min.z > 7.0 && after.max.z > 14.9);
|
||||
assert!((volume(&h) - expected_volume(90.0)).abs() < 0.1);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Real 3D drawing and triad input in a rotated second component occurrence.
|
||||
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
|
||||
#[test]
|
||||
fn second_occurrence_drawn_wire_fit_picks_and_world_triad_drag_stay_aligned() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let component = h.app.run("component.create", json!({"name":"Part"})).unwrap()["component"].as_u64().unwrap();
|
||||
let first = h.app.session.doc.occurrence_of(component).unwrap().id;
|
||||
let second = h.app.run("occurrence.copy", json!({"component":component})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":second,"translate":[20,30,40],"axis":[0,0,1],"angle":"90 deg","capture":true})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":component,"occurrence":second})).unwrap();
|
||||
h.app.run("solid.box", json!({"corner":[17,32,45],"length":2,"width":2,"height":10,"body_name":"SnapVertices"})).unwrap();
|
||||
h.app.run("browser.visibility", json!({"paths":[[first]],"visible":false})).unwrap();
|
||||
let sketch = h.app.run("sketch.create", json!({"plane":"XY","name":"Wire"})).unwrap()["sketch"].as_u64().unwrap();
|
||||
h.app.ui.show_origin = false;
|
||||
h.settle();
|
||||
h.step(&json!({"widget":"3D Sketch"})).unwrap();
|
||||
assert!(
|
||||
crate::sketch3d::active(&h.app),
|
||||
"palette click must enable 3D: raw={:?}, world={:?}, status={:?}, active={:?}",
|
||||
h.app.session.model.state().sketch(sketch).unwrap().sketch.view,
|
||||
h.app.session.world_state().sketch(sketch).unwrap().sketch.view,
|
||||
h.app.status,
|
||||
h.app.session.active_sketch
|
||||
);
|
||||
h.call("ui.view", json!({"view":"home"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.app.start("sketch.line");
|
||||
h.frames(2);
|
||||
for world in [Vec3::new(17.0, 32.0, 45.0), Vec3::new(17.0, 32.0, 55.0)] {
|
||||
let [x, y] = h.at(&json!({"world":[world.x,world.y,world.z]})).unwrap();
|
||||
let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap());
|
||||
let picked = crate::sketch3d::point_at(&h.app, &proj, egui::pos2(x as f32, y as f32));
|
||||
assert!(picked.is_some_and(|(p, _)| p.dist(world) < 1e-5), "world={world:?}, picked={picked:?}, status={:?}", h.app.status);
|
||||
}
|
||||
h.step(&json!({"click":{"world":[17,32,45]}})).unwrap();
|
||||
h.step(&json!({"click":{"world":[17,32,55]}})).unwrap();
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.tool.is_none());
|
||||
let raw = h.app.session.model.state();
|
||||
let wire = raw.sketch(sketch).unwrap().sketch.wires.first().expect("actual off-plane clicks create a wire");
|
||||
assert!(wire.fit[0].dist(Vec3::new(2.0, 3.0, 5.0)) < 1e-6, "{:?}", wire.fit);
|
||||
assert!(wire.fit[1].dist(Vec3::new(2.0, 3.0, 15.0)) < 1e-6, "{:?}", wire.fit);
|
||||
let world = h.app.session.world_state();
|
||||
let shown = &world.sketch(sketch).unwrap().sketch.wires[0];
|
||||
assert!(shown.fit[0].dist(Vec3::new(17.0, 32.0, 45.0)) < 1e-6, "{:?}", shown.fit);
|
||||
assert!(shown.fit[1].dist(Vec3::new(17.0, 32.0, 55.0)) < 1e-6, "{:?}", shown.fit);
|
||||
h.step(&json!({"click":{"world":[17,32,45]}})).unwrap();
|
||||
let [x0, y0] = h.at(&json!({"handle":"triad_x"})).unwrap();
|
||||
let before = h.at(&json!({"world":[17,32,45]})).unwrap();
|
||||
let after = h.at(&json!({"world":[21,32,45]})).unwrap();
|
||||
let history = h.app.session.undo.len();
|
||||
h.call("ui.drag", json!({"x0":x0,"y0":y0,"x1":x0+after[0]-before[0],"y1":y0+after[1]-before[1],"steps":8}));
|
||||
let moved = h.app.session.model.state();
|
||||
let fit = &moved.sketch(sketch).unwrap().sketch.wires[0].fit;
|
||||
assert!(fit[0].dist(Vec3::new(2.0, -1.0, 5.0)) < 1e-5, "{fit:?}");
|
||||
assert!(fit[1].dist(Vec3::new(2.0, 3.0, 15.0)) < 1e-6);
|
||||
let placed = h.app.session.world_state();
|
||||
assert!(placed.sketch(sketch).unwrap().sketch.wires[0].fit[0].dist(Vec3::new(21.0, 32.0, 45.0)) < 1e-5);
|
||||
assert_eq!(h.app.session.undo.len(), history + 1);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
let restored = h.app.session.model.state();
|
||||
assert!(restored.sketch(sketch).unwrap().sketch.wires[0].fit[0].dist(Vec3::new(2.0, 3.0, 5.0)) < 1e-6);
|
||||
}
|
||||
Reference in new issue
Block a user