feat: stabilize face attachments and closed spline workflows
This commit is contained in:
1 parent
c1d819f235
commit
a6bb8fc924
83 files changed
+6271
-385
No files matched your search
@@ -0,0 +1,21 @@
|
||||
//! Presentation/projection state for an exact selected planar source placement.
|
||||
use crate::{DocError, Document, FaceAnchor, ModelBody, ModelState, Result};
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub fn state_in_face_anchor_frame<'a>(doc: &Document, state: &'a ModelState, anchor: &FaceAnchor, author: u64) -> Result<Cow<'a, ModelState>> {
|
||||
let frame = anchor.author_frame(doc, author)?;
|
||||
let raw = state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("anchored body `{}`", anchor.target.body)))?;
|
||||
let relative = anchor.source_to_author(doc, author, raw)?;
|
||||
let mut output = crate::state_in_frame_at(doc, state, author, frame).into_owned();
|
||||
let shape = if crate::is_identity(&relative) { raw.body.clone() } else { solvecraft_kernel::transform_matrix(&raw.body, relative)? };
|
||||
let mut body = ModelBody::new(raw.name.clone(), shape, raw.feature);
|
||||
body.component = raw.component;
|
||||
body.transferred = raw.transferred;
|
||||
body.source_name = raw.source_name.clone();
|
||||
body.source_transform = crate::mat_mul(&relative, &raw.source_transform);
|
||||
body.imported = raw.imported.clone();
|
||||
body.names = crate::naming::NameCell::known(crate::naming::face_names(raw));
|
||||
let slot = output.bodies.iter_mut().find(|body| body.name == raw.name).ok_or_else(|| DocError::Unknown("anchored body frame".into()))?;
|
||||
*slot = body;
|
||||
Ok(Cow::Owned(output))
|
||||
}
|
||||
+29
-14
@@ -146,10 +146,10 @@ fn remap_geo(
|
||||
names: &BTreeMap<String, String>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
depth: usize,
|
||||
) {
|
||||
) -> crate::Result<()> {
|
||||
use crate::construct::GeoRef;
|
||||
if depth > 64 {
|
||||
return;
|
||||
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
|
||||
}
|
||||
match g {
|
||||
GeoRef::SketchPoint { sketch, .. } | GeoRef::SketchCurve { sketch, .. } => {
|
||||
@@ -159,25 +159,40 @@ fn remap_geo(
|
||||
}
|
||||
GeoRef::Face { name: Some(n), .. } | GeoRef::Edge { name: Some(n), .. } => *n = remap_topology(n, ids, bodies),
|
||||
GeoRef::ConstructPoint { name } | GeoRef::Axis { name } => mapped_name(name, names),
|
||||
GeoRef::Plane { plane } => remap_plane(plane, ids, names, bodies, depth + 1),
|
||||
GeoRef::Plane { plane } => remap_plane(plane, ids, names, bodies, depth + 1)?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remap_plane(p: &mut PlaneRef, ids: &BTreeMap<u64, u64>, names: &BTreeMap<String, String>, bodies: &BTreeMap<String, String>, depth: usize) {
|
||||
fn remap_plane(
|
||||
p: &mut PlaneRef,
|
||||
ids: &BTreeMap<u64, u64>,
|
||||
names: &BTreeMap<String, String>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
depth: usize,
|
||||
) -> crate::Result<()> {
|
||||
if depth > 64 {
|
||||
return;
|
||||
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
|
||||
}
|
||||
match p {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => remap_plane(base, ids, names, bodies, depth + 1),
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => remap_plane(base, ids, names, bodies, depth + 1)?,
|
||||
PlaneRef::Construction { name } => mapped_name(name, names),
|
||||
PlaneRef::Face { name: Some(n), .. } => *n = remap_topology(n, ids, bodies),
|
||||
PlaneRef::Face { name, anchor, .. } => {
|
||||
if let Some(name) = name {
|
||||
*name = remap_topology(name, ids, bodies);
|
||||
}
|
||||
if let Some(anchor) = anchor {
|
||||
anchor.remap_target(ids, bodies)?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for r in p.refs_mut() {
|
||||
remap_geo(r, ids, names, bodies, depth + 1);
|
||||
remap_geo(r, ids, names, bodies, depth + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remap_link(
|
||||
@@ -348,7 +363,7 @@ impl crate::Feature {
|
||||
}
|
||||
match &mut self.kind {
|
||||
K::Sketch { plane, sketch } => {
|
||||
remap_plane(plane, ids, names, bodies, 0);
|
||||
remap_plane(plane, ids, names, bodies, 0)?;
|
||||
for c in &mut sketch.constraints {
|
||||
if let Some(n) = &mut c.param {
|
||||
mapped_name(n, parameters);
|
||||
@@ -365,17 +380,17 @@ impl crate::Feature {
|
||||
| K::Split { plane, .. }
|
||||
| K::SplitFace { plane, .. }
|
||||
| K::Mirror { plane, .. }
|
||||
| K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, names, bodies, 0),
|
||||
K::Draft { neutral, .. } => remap_plane(neutral, ids, names, bodies, 0),
|
||||
K::ReplaceFace { target, .. } => remap_plane(target, ids, names, bodies, 0),
|
||||
| K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, names, bodies, 0)?,
|
||||
K::Draft { neutral, .. } => remap_plane(neutral, ids, names, bodies, 0)?,
|
||||
K::ReplaceFace { target, .. } => remap_plane(target, ids, names, bodies, 0)?,
|
||||
K::ConstructionAxis { def } => {
|
||||
for r in def.refs_mut() {
|
||||
remap_geo(r, ids, names, bodies, 0);
|
||||
remap_geo(r, ids, names, bodies, 0)?;
|
||||
}
|
||||
}
|
||||
K::ConstructionPoint { def } => {
|
||||
for r in def.refs_mut() {
|
||||
remap_geo(r, ids, names, bodies, 0);
|
||||
remap_geo(r, ids, names, bodies, 0)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -3,16 +3,162 @@ use crate::{DocError, Document, FeatureKind, ModelBody, Result, SolvedSketch};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::Seg2;
|
||||
|
||||
fn valid_signature(outer: &[String], holes: &[Vec<String>]) -> bool {
|
||||
let valid = |ids: &[String]| {
|
||||
!ids.is_empty() && ids.len() <= 2000 && ids.iter().all(|id| !id.is_empty() && id.len() <= 128) && ids.windows(2).all(|pair| pair[0] < pair[1])
|
||||
};
|
||||
valid(outer)
|
||||
&& holes.len() <= 128
|
||||
&& holes.iter().all(|ids| valid(ids))
|
||||
&& outer.len() + holes.iter().map(Vec::len).sum::<usize>() <= 2000
|
||||
&& holes.windows(2).all(|pair| pair[0] < pair[1])
|
||||
&& holes.iter().flatten().all(|id| outer.binary_search(id).is_err())
|
||||
&& holes.iter().flatten().collect::<std::collections::BTreeSet<_>>().len() == holes.iter().map(Vec::len).sum::<usize>()
|
||||
}
|
||||
fn valid_signature_json(outer: Option<&serde_json::Value>, holes: Option<&serde_json::Value>) -> bool {
|
||||
let valid = |value: &serde_json::Value| {
|
||||
value.as_array().is_some_and(|ids| {
|
||||
!ids.is_empty()
|
||||
&& ids.len() <= 2000
|
||||
&& ids.iter().all(|id| id.as_str().is_some_and(|id| !id.is_empty() && id.len() <= 128))
|
||||
&& ids.windows(2).all(|pair| pair[0].as_str() < pair[1].as_str())
|
||||
})
|
||||
};
|
||||
let Some(outer) = outer.filter(|value| valid(value)).and_then(serde_json::Value::as_array) else {
|
||||
return false;
|
||||
};
|
||||
let Some(holes) = holes.and_then(serde_json::Value::as_array).filter(|holes| holes.len() <= 128) else {
|
||||
return false;
|
||||
};
|
||||
if !holes.iter().all(valid) {
|
||||
return false;
|
||||
}
|
||||
let total = outer.len() + holes.iter().filter_map(serde_json::Value::as_array).map(Vec::len).sum::<usize>();
|
||||
let ids: Vec<_> = outer.iter().filter_map(serde_json::Value::as_str).collect();
|
||||
total <= 2000
|
||||
&& holes
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_array)
|
||||
.flatten()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.len()
|
||||
== total - outer.len()
|
||||
&& holes
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_array)
|
||||
.flatten()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.all(|id| ids.binary_search(&id).is_err())
|
||||
&& holes.windows(2).all(|pair| {
|
||||
let a: Vec<_> = pair[0].as_array().into_iter().flatten().filter_map(serde_json::Value::as_str).collect();
|
||||
let b: Vec<_> = pair[1].as_array().into_iter().flatten().filter_map(serde_json::Value::as_str).collect();
|
||||
a < b
|
||||
})
|
||||
}
|
||||
pub(crate) fn cap_key(outer: &[String], holes: &[Vec<String>]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hash = Sha256::new();
|
||||
for ids in std::iter::once(outer).chain(holes.iter().map(Vec::as_slice)) {
|
||||
hash.update((ids.len() as u64).to_le_bytes());
|
||||
for id in ids {
|
||||
hash.update((id.len() as u64).to_le_bytes());
|
||||
hash.update(id.as_bytes());
|
||||
}
|
||||
}
|
||||
format!("{:x}", hash.finalize())
|
||||
}
|
||||
pub(crate) fn exact_region_signature(sketch: &SolvedSketch, region: &solvecraft_geom::Region2) -> Option<solvecraft_kernel::CapSignature> {
|
||||
if region.outer.segs.len() + region.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() > 4096 || region.holes.len() > 128 {
|
||||
return None;
|
||||
}
|
||||
let signature = |outline: &solvecraft_geom::Loop2| {
|
||||
let mut ids = outline.segs.iter().map(|segment| exact_sketch_curve(sketch, segment)).collect::<Option<Vec<_>>>()?;
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
Some(ids)
|
||||
};
|
||||
let outer = signature(®ion.outer)?;
|
||||
let mut holes = region.holes.iter().map(signature).collect::<Option<Vec<_>>>()?;
|
||||
holes.sort();
|
||||
valid_signature(&outer, &holes).then_some((outer, holes))
|
||||
}
|
||||
|
||||
/// Charge all source-curve comparisons before matching; over-budget tools stay usable but uncertified.
|
||||
type CapBindings = Vec<Vec<Option<solvecraft_kernel::CapSignature>>>;
|
||||
pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solvecraft_kernel::Body], budget: usize) -> Option<CapBindings> {
|
||||
use solvecraft_kernel::FaceSource;
|
||||
if sketch.sketch.curves.len() > 2000 {
|
||||
return None;
|
||||
}
|
||||
let mut faces = 0usize;
|
||||
let mut comparisons = 0usize;
|
||||
for body in bodies {
|
||||
for source in body.face_sources().into_iter().flatten() {
|
||||
faces = faces.checked_add(1)?;
|
||||
if faces > 50_000 {
|
||||
return None;
|
||||
}
|
||||
let segments = match source {
|
||||
Some(FaceSource::ExtrudeSide { .. }) => 1,
|
||||
Some(FaceSource::ExtrudeCap { region, .. }) => {
|
||||
region.outer.segs.len().checked_add(region.holes.iter().map(|hole| hole.segs.len()).sum::<usize>())?
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
comparisons = comparisons.checked_add(segments.checked_mul(sketch.sketch.curves.len())?)?;
|
||||
if comparisons > budget {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut counts = std::collections::BTreeMap::new();
|
||||
let mut bindings = Vec::with_capacity(bodies.len());
|
||||
for body in bodies {
|
||||
let mut cached: Option<(&solvecraft_geom::Region2, Option<solvecraft_kernel::CapSignature>)> = None;
|
||||
let mut values = Vec::new();
|
||||
for source in body.face_sources().into_iter().flatten() {
|
||||
let signature = if let Some(FaceSource::ExtrudeCap { region, high, .. }) = source {
|
||||
let signature = match &cached {
|
||||
Some((previous, signature)) if *previous == region => signature.clone(),
|
||||
_ => exact_region_signature(sketch, region),
|
||||
};
|
||||
cached = Some((region, signature.clone()));
|
||||
if let Some((outer, holes)) = &signature {
|
||||
*counts.entry((*high, outer.clone(), holes.clone())).or_insert(0usize) += 1;
|
||||
}
|
||||
signature
|
||||
} else {
|
||||
None
|
||||
};
|
||||
values.push(signature);
|
||||
}
|
||||
bindings.push(values);
|
||||
}
|
||||
for (body, values) in bodies.iter().zip(&mut bindings) {
|
||||
for (source, value) in body.face_sources().into_iter().flatten().zip(values) {
|
||||
if let (Some(FaceSource::ExtrudeCap { high, .. }), Some((outer, holes))) = (source, value.as_ref())
|
||||
&& counts.get(&(*high, outer.clone(), holes.clone())) != Some(&1)
|
||||
{
|
||||
*value = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(bindings)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum FaceCertificate {
|
||||
Box { axis: u8, high: bool },
|
||||
ExtrudeCap { sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>> },
|
||||
ExtrudeSide { sketch: u64, curve: String },
|
||||
Imported { payload_sha256: String, item: u64, face: u64 },
|
||||
}
|
||||
|
||||
impl FaceCertificate {
|
||||
pub fn remap_features(&mut self, features: &std::collections::BTreeMap<u64, u64>) {
|
||||
if let Self::ExtrudeSide { sketch, .. } = self
|
||||
if let Self::ExtrudeSide { sketch, .. } | Self::ExtrudeCap { sketch, .. } = self
|
||||
&& let Some(mapped) = features.get(sketch)
|
||||
{
|
||||
*sketch = *mapped;
|
||||
@@ -20,6 +166,8 @@ impl FaceCertificate {
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let valid = match self {
|
||||
Self::Box { axis, .. } => *axis <= 2,
|
||||
Self::ExtrudeCap { sketch, outer, holes, .. } => *sketch != 0 && valid_signature(outer, holes),
|
||||
Self::ExtrudeSide { sketch, curve } => *sketch != 0 && !curve.is_empty() && curve.len() <= 128,
|
||||
Self::Imported { payload_sha256, item, face } => {
|
||||
*item != 0
|
||||
@@ -39,6 +187,19 @@ pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()>
|
||||
let fields = value.as_object().ok_or_else(|| DocError::Invalid("face certificate must be an object".into()))?;
|
||||
let nonzero = |field: &str| fields.get(field).and_then(serde_json::Value::as_u64).is_some_and(|id| id != 0);
|
||||
let valid = match fields.get("kind").and_then(serde_json::Value::as_str) {
|
||||
Some("box") => {
|
||||
fields.len() == 3
|
||||
&& fields.keys().all(|key| ["kind", "axis", "high"].contains(&key.as_str()))
|
||||
&& fields.get("axis").and_then(serde_json::Value::as_u64).is_some_and(|axis| axis <= 2)
|
||||
&& fields.get("high").and_then(serde_json::Value::as_bool).is_some()
|
||||
}
|
||||
Some("extrude_cap") => {
|
||||
fields.len() == 5
|
||||
&& fields.keys().all(|key| ["kind", "sketch", "high", "outer", "holes"].contains(&key.as_str()))
|
||||
&& nonzero("sketch")
|
||||
&& fields.get("high").and_then(serde_json::Value::as_bool).is_some()
|
||||
&& valid_signature_json(fields.get("outer"), fields.get("holes"))
|
||||
}
|
||||
Some("extrude_side") => {
|
||||
fields.len() == 3
|
||||
&& fields.keys().all(|key| ["kind", "sketch", "curve"].contains(&key.as_str()))
|
||||
@@ -143,7 +304,9 @@ impl Document {
|
||||
.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,
|
||||
| Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. })
|
||||
| Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { producer, .. })
|
||||
| Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), .. }) => *producer,
|
||||
_ => name
|
||||
.strip_prefix('F')
|
||||
.and_then(|name| name.split_once(':'))
|
||||
@@ -178,17 +341,36 @@ impl Document {
|
||||
return Ok(Some(certificate));
|
||||
}
|
||||
let certificate = match source {
|
||||
solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch, curve } => {
|
||||
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())),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
certificate.validate()?;
|
||||
Ok(Some(certificate))
|
||||
}
|
||||
pub fn strong_face_target(&self, body: &ModelBody, index: usize, point: solvecraft_geom::Vec3) -> Result<crate::config::ConfigFaceTarget> {
|
||||
let mut target = self.configuration_face_target(body, index, point)?;
|
||||
match body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) {
|
||||
Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), axis, high, .. }) => {
|
||||
target.origin_feature = *producer;
|
||||
let axis_name = ["x", "y", "z"].get(*axis as usize).ok_or_else(|| DocError::Invalid("invalid box face axis".into()))?;
|
||||
target.name = format!("F{producer}:{}{axis_name}", if *high { "+" } else { "-" });
|
||||
target.certificate = Some(FaceCertificate::Box { axis: *axis, high: *high });
|
||||
}
|
||||
Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, .. }) => {
|
||||
target.origin_feature = *producer;
|
||||
target.name = format!("F{producer}:cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes));
|
||||
target.certificate = Some(FaceCertificate::ExtrudeCap { sketch: *sketch, high: *high, outer: outer.clone(), holes: holes.clone() });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.certify_config_face(&target)?;
|
||||
Ok(target)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn payload_digest(payload: &str) -> String {
|
||||
|
||||
@@ -109,6 +109,13 @@ impl Document {
|
||||
let feature = self.feature(target.origin_feature).ok_or_else(|| DocError::Unknown("face origin feature is gone".into()))?;
|
||||
if let Some(certificate) = &target.certificate {
|
||||
let valid = match (certificate, &feature.kind) {
|
||||
(super::FaceCertificate::Box { axis, high }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => {
|
||||
["x", "y", "z"].get(*axis as usize).is_some_and(|axis| role == format!("{}{axis}", if *high { "+" } else { "-" }))
|
||||
}
|
||||
(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => {
|
||||
sketch == producer_sketch && extent.taper.is_none()
|
||||
&& role == format!("cap:{}:{}", if *high { "high" } else { "low" }, super::cap_key(outer, holes))
|
||||
}
|
||||
(super::FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => {
|
||||
sketch == producer_sketch && extent.taper.is_none() && role == format!("side:{curve}")
|
||||
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Circle { .. } | solvecraft_sketch::CurveKind::Arc { .. }))))
|
||||
@@ -149,9 +156,49 @@ impl Document {
|
||||
if body.body.is_mesh() || body.definition_name() != target.body {
|
||||
return Err(DocError::Invalid("configured face definition does not match".into()));
|
||||
}
|
||||
if matches!(target.certificate, Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::ExtrudeCap { .. })) {
|
||||
let indices: Vec<_> = body
|
||||
.body
|
||||
.face_sources()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.filter_map(|(index, source)| {
|
||||
let matched = match (&target.certificate, source) {
|
||||
(
|
||||
Some(super::FaceCertificate::Box { axis, high }),
|
||||
Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), axis: current_axis, high: current_high, .. }),
|
||||
) => *producer == target.origin_feature && axis == current_axis && high == current_high,
|
||||
(
|
||||
Some(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }),
|
||||
Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap {
|
||||
producer,
|
||||
sketch: current_sketch,
|
||||
high: current_high,
|
||||
outer: current_outer,
|
||||
holes: current_holes,
|
||||
..
|
||||
}),
|
||||
) => {
|
||||
*producer == target.origin_feature
|
||||
&& sketch == current_sketch
|
||||
&& high == current_high
|
||||
&& outer == current_outer
|
||||
&& holes == current_holes
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
matched.then_some(index)
|
||||
})
|
||||
.collect();
|
||||
if indices.len() != 1 {
|
||||
return Err(DocError::Unknown("attached planar face certificate is missing or ambiguous".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
if let Some(super::FaceCertificate::ExtrudeSide { sketch, curve }) = &target.certificate {
|
||||
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
|
||||
matches!(source, Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch: current_sketch, curve: current_curve }) if *producer == target.origin_feature && current_sketch == sketch && current_curve == curve).then_some(index)
|
||||
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()));
|
||||
|
||||
@@ -56,15 +56,18 @@ pub enum PlaneRef {
|
||||
AlongPath { path: Box<GeoRef>, t: String },
|
||||
/// Through a straight edge (or line, axis) and square to a plane (or planar face).
|
||||
Perpendicular { plane: Box<GeoRef>, line: Box<GeoRef> },
|
||||
/// A planar body face: found again on each evaluation (the face with this plane's normal
|
||||
/// nearest `at`), so sketches on it follow when earlier features change; `plane` is the
|
||||
/// plane as picked (its frame, and the fallback when the face is gone). `name` is the face's
|
||||
/// persistent name (found first; see `naming`).
|
||||
/// A planar body face. Certified attachments resolve only their exact source and report
|
||||
/// an error when it is lost; `plane` then supplies a repair guide. Legacy references without
|
||||
/// an anchor try the persistent `name`, then the nearest parallel face at `at`, and retain
|
||||
/// the picked `plane` as their historical fallback.
|
||||
Face {
|
||||
plane: Plane,
|
||||
at: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
/// Exact attachment identity for newly certified faces; absent on legacy designs.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
anchor: Option<Box<crate::FaceAnchor>>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -116,6 +119,16 @@ pub enum Direction {
|
||||
Symmetric,
|
||||
}
|
||||
|
||||
/// Angular starts in older designs used the raw axis sign. New revolves measure starts
|
||||
/// along the same sketch-normal sweep direction as the kernel.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RevolveConvention {
|
||||
#[default]
|
||||
LegacyAxisStart,
|
||||
SketchNormal,
|
||||
}
|
||||
|
||||
/// Extent of an extrude.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Extent {
|
||||
@@ -282,6 +295,9 @@ pub enum FeatureKind {
|
||||
/// Symmetric: `angle` split evenly either side of the profile.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
symmetric: bool,
|
||||
/// Missing fields retain the angular placement of existing designs.
|
||||
#[serde(default)]
|
||||
convention: RevolveConvention,
|
||||
/// To Object: turn until the face, vertex or point here.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
to: Option<Vec3>,
|
||||
|
||||
+163
-31
@@ -529,7 +529,10 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 {
|
||||
return hash.finish();
|
||||
}
|
||||
let FeatureKind::Sketch { plane, sketch } = &f.kind else { return 0 };
|
||||
if doc.occurrences.is_empty() || (!matches!(plane, PlaneRef::Face { .. }) && sketch.links.is_empty()) {
|
||||
let anchor = crate::face_anchor::plane_face_anchor(plane);
|
||||
if (doc.occurrences.is_empty() && (anchor.is_none() || doc.body_offsets.is_empty()))
|
||||
|| (!matches!(plane, PlaneRef::Face { .. }) && anchor.is_none() && sketch.links.is_empty())
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
@@ -1494,17 +1497,53 @@ pub fn revolve_extent(
|
||||
(angle, angle2): (f64, Option<f64>),
|
||||
symmetric: bool,
|
||||
to: Option<Vec3>,
|
||||
) -> Result<RevolveExtent> {
|
||||
revolve_extent_with_convention(st, plane, regions, (origin, axis), (angle, angle2), symmetric, to, crate::RevolveConvention::LegacyAxisStart)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn revolve_extent_with_convention(
|
||||
st: &ModelState,
|
||||
plane: &Plane,
|
||||
regions: &[Region2],
|
||||
(origin, axis): (Vec3, Vec3),
|
||||
(angle, angle2): (f64, Option<f64>),
|
||||
symmetric: bool,
|
||||
to: Option<Vec3>,
|
||||
convention: crate::RevolveConvention,
|
||||
) -> Result<RevolveExtent> {
|
||||
let (start, sweep) = match (to, angle2, symmetric) {
|
||||
(Some(point), _, _) => {
|
||||
let points: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
|
||||
let centre = plane.to_world(points.iter().fold(Vec2::default(), |sum, point| sum + *point) * (1.0 / points.len().max(1) as f64));
|
||||
let centre = if convention == crate::RevolveConvention::SketchNormal {
|
||||
regions.first().map(|r| plane.to_world(r.centroid())).ok_or_else(|| DocError::Invalid("no profile selected".into()))?
|
||||
} else {
|
||||
plane.to_world(points.iter().fold(Vec2::default(), |sum, point| sum + *point) * (1.0 / points.len().max(1) as f64))
|
||||
};
|
||||
let projection_axis = if convention == crate::RevolveConvention::SketchNormal {
|
||||
axis.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))?
|
||||
} else {
|
||||
axis
|
||||
};
|
||||
let flat = |point: Vec3| {
|
||||
let v = point - origin;
|
||||
v - axis * v.dot(axis)
|
||||
v - projection_axis * v.dot(projection_axis)
|
||||
};
|
||||
let (u, v) = (flat(centre), flat(point));
|
||||
let sweep = (-axis).dot(u.cross(v)).atan2(u.dot(v));
|
||||
let angle_axis = if convention == crate::RevolveConvention::SketchNormal {
|
||||
if v.len() < 1e-9 {
|
||||
return Err(DocError::Invalid("the revolve target lies on the axis; pick a point away from the axis".into()));
|
||||
}
|
||||
kernel::canonical_revolve_axis(
|
||||
plane,
|
||||
regions.first().ok_or_else(|| DocError::Invalid("no profile selected".into()))?,
|
||||
plane.to_local(origin),
|
||||
Vec2::new(projection_axis.dot(plane.x), projection_axis.dot(plane.y)),
|
||||
)?
|
||||
} else {
|
||||
-axis
|
||||
};
|
||||
let sweep = angle_axis.dot(u.cross(v)).atan2(u.dot(v));
|
||||
(0.0, if sweep <= 1e-9 { sweep + std::f64::consts::TAU } else { sweep })
|
||||
}
|
||||
(None, Some(other), _) => (-other, angle + other),
|
||||
@@ -1519,8 +1558,22 @@ pub fn revolve_extent(
|
||||
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);
|
||||
let rotation_axis = match (convention, regions.first()) {
|
||||
(crate::RevolveConvention::SketchNormal, Some(region)) => {
|
||||
kernel::canonical_revolve_axis(plane, region, plane.to_local(origin), Vec2::new(unit_axis.dot(plane.x), unit_axis.dot(plane.y)))?
|
||||
}
|
||||
_ => {
|
||||
if unit_axis.cross(radial).dot(plane.normal()) < 0.0 {
|
||||
-unit_axis
|
||||
} else {
|
||||
unit_axis
|
||||
}
|
||||
}
|
||||
};
|
||||
let display_start = match convention {
|
||||
crate::RevolveConvention::LegacyAxisStart => start * -unit_axis.dot(rotation_axis),
|
||||
crate::RevolveConvention::SketchNormal => start,
|
||||
};
|
||||
Ok(RevolveExtent { start, sweep, rotation_axis, display_start, curved_to: to.is_some_and(|point| curved_target_any(st, point).is_some()) })
|
||||
}
|
||||
|
||||
@@ -1629,25 +1682,33 @@ fn feature_tools(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st
|
||||
tools = trim_to_body(tools, &tb.body, |q: Vec3| ((q - ss.plane.origin).dot(n) - start).abs())?;
|
||||
}
|
||||
if *operation == Operation::NewBody && extent.taper.is_none() {
|
||||
let cap_bindings = crate::config::extrude_cap_bindings(&ss, &tools, 10_000_000);
|
||||
tools = tools
|
||||
.into_iter()
|
||||
.map(|body| {
|
||||
.enumerate()
|
||||
.map(|(body_index, 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),
|
||||
Some(kernel::FaceSource::ExtrudeSide { segment, .. }) if cap_bindings.is_some() => {
|
||||
crate::config::exact_sketch_curve(&ss, segment)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
body.with_extrude_certificates(f.id, *sketch, &curves).map_err(DocError::from)
|
||||
let caps =
|
||||
cap_bindings.as_ref().and_then(|bindings| bindings.get(body_index)).cloned().unwrap_or_else(|| vec![None; curves.len()]);
|
||||
body.with_extrude_certificates(f.id, *sketch, &curves)?
|
||||
.with_extrude_cap_certificates(f.id, *sketch, &caps)
|
||||
.map_err(DocError::from)
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
}
|
||||
Ok(tools)
|
||||
}
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, to, .. } => {
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, convention, to, .. } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone();
|
||||
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let (o, d) = revolve_axis(&ss, axis)?;
|
||||
@@ -1656,26 +1717,66 @@ fn feature_tools(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st
|
||||
} else {
|
||||
(val(vals, angle, Kind::Angle)?, angle2.as_ref().map(|a| val(vals, a, Kind::Angle)).transpose()?)
|
||||
};
|
||||
let extent = revolve_extent(st, &ss.plane, ®ions, (ss.plane.to_world(o), ss.plane.dir_to_world(d)), angles, *symmetric, *to)?;
|
||||
let extent = revolve_extent_with_convention(
|
||||
st,
|
||||
&ss.plane,
|
||||
®ions,
|
||||
(ss.plane.to_world(o), ss.plane.dir_to_world(d)),
|
||||
angles,
|
||||
*symmetric,
|
||||
*to,
|
||||
*convention,
|
||||
)?;
|
||||
let (start, ang) = (extent.start, extent.sweep);
|
||||
let region_extents = regions
|
||||
.iter()
|
||||
.map(|r| {
|
||||
if *convention == crate::RevolveConvention::SketchNormal && to.is_some() {
|
||||
revolve_extent_with_convention(
|
||||
st,
|
||||
&ss.plane,
|
||||
std::slice::from_ref(r),
|
||||
(ss.plane.to_world(o), ss.plane.dir_to_world(d)),
|
||||
angles,
|
||||
*symmetric,
|
||||
*to,
|
||||
*convention,
|
||||
)
|
||||
} else {
|
||||
Ok(extent)
|
||||
}
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let (operation, targets) = feature_op(f);
|
||||
let axis_w = ss.plane.dir_to_world(d);
|
||||
let origin_w = ss.plane.to_world(o);
|
||||
let probe = |p: Vec2| {
|
||||
let q = ss.plane.to_world(p);
|
||||
(1..4)
|
||||
.map(|k| {
|
||||
let a = ang * k as f64 / 4.0;
|
||||
let v = q - origin_w;
|
||||
origin_w + v * a.cos() + axis_w.cross(v) * a.sin() + axis_w * (axis_w.dot(v) * (1.0 - a.cos()))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let regions: Vec<Region2> = regions.iter().map(|r| grow_profile_for_coplanar(st, r, operation, &targets, &probe)).collect();
|
||||
let regions: Vec<Region2> = regions
|
||||
.iter()
|
||||
.zip(®ion_extents)
|
||||
.map(|(r, region_extent)| {
|
||||
let rotation_axis = match convention {
|
||||
crate::RevolveConvention::LegacyAxisStart => axis_w,
|
||||
crate::RevolveConvention::SketchNormal => kernel::canonical_revolve_axis(&ss.plane, r, o, d)?,
|
||||
};
|
||||
let probe = |p: Vec2| {
|
||||
let q = ss.plane.to_world(p);
|
||||
(1..4)
|
||||
.map(|k| {
|
||||
let a = region_extent.sweep * k as f64 / 4.0
|
||||
+ if *convention == crate::RevolveConvention::SketchNormal { region_extent.start } else { 0.0 };
|
||||
let v = q - origin_w;
|
||||
origin_w + v * a.cos() + rotation_axis.cross(v) * a.sin() + rotation_axis * (rotation_axis.dot(v) * (1.0 - a.cos()))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
Ok(grow_profile_for_coplanar(st, r, operation, &targets, &probe))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
// To a curved face: turn past the whole target body, then cut back to its surface
|
||||
// (the piece that starts at the profile).
|
||||
let curved = to.and_then(|p| curved_target_any(st, p));
|
||||
if let Some(tb) = curved.and_then(|bi| st.bodies.get(bi)) {
|
||||
let curved_body = curved.and_then(|bi| st.bodies.get(bi));
|
||||
let build_curved = |regions: &[Region2], angle_axis: Vec3, tb: &ModelBody| -> Result<Vec<Body>> {
|
||||
let pts: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
|
||||
let c = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, q| a + *q) * (1.0 / pts.len().max(1) as f64));
|
||||
let flat = |q: Vec3| {
|
||||
@@ -1685,15 +1786,36 @@ fn feature_tools(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st
|
||||
let u0 = flat(c);
|
||||
let angle_of = |q: Vec3| {
|
||||
let v = flat(q);
|
||||
let a = (axis_w * -1.0).dot(u0.cross(v)).atan2(u0.dot(v));
|
||||
let a = angle_axis.dot(u0.cross(v)).atan2(u0.dot(v));
|
||||
if a < 0.0 { a + std::f64::consts::TAU } else { a }
|
||||
};
|
||||
let far = tb.mesh().positions.iter().map(|q| angle_of(*q)).fold(0.0, f64::max);
|
||||
let sweep = (far + 0.05).min(std::f64::consts::TAU - 1e-3);
|
||||
let bodies = kernel::revolve(&ss.plane, ®ions, o, d, sweep)?;
|
||||
let bodies = kernel::revolve(&ss.plane, regions, o, d, sweep)?;
|
||||
let n = ss.plane.normal();
|
||||
let tol_side = |q: Vec3| if flat(q).dot(u0) > 0.0 { ((q - ss.plane.origin).dot(n)).abs() } else { f64::MAX };
|
||||
return trim_to_body(bodies, &tb.body, tol_side);
|
||||
trim_to_body(bodies, &tb.body, tol_side)
|
||||
};
|
||||
if *convention == crate::RevolveConvention::LegacyAxisStart
|
||||
&& let Some(tb) = curved_body
|
||||
{
|
||||
return build_curved(®ions, -axis_w, tb);
|
||||
}
|
||||
if *convention == crate::RevolveConvention::SketchNormal {
|
||||
let mut out = Vec::new();
|
||||
for (region, region_extent) in regions.iter().zip(®ion_extents) {
|
||||
let rotation_axis = kernel::canonical_revolve_axis(&ss.plane, region, o, d)?;
|
||||
if let Some(tb) = curved_body {
|
||||
out.extend(build_curved(std::slice::from_ref(region), rotation_axis, tb)?);
|
||||
continue;
|
||||
}
|
||||
let bodies = kernel::revolve(&ss.plane, std::slice::from_ref(region), o, d, region_extent.sweep)?;
|
||||
let m = crate::rigid(Vec3::ZERO, origin_w, rotation_axis, region_extent.start);
|
||||
for body in bodies {
|
||||
out.push(if region_extent.start.abs() < 1e-12 { body } else { kernel::transform_matrix(&body, m)? });
|
||||
}
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
let bodies = kernel::revolve(&ss.plane, ®ions, o, d, ang)?;
|
||||
if start.abs() < 1e-12 {
|
||||
@@ -2565,13 +2687,21 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
| FeatureKind::Align { .. }
|
||||
| FeatureKind::Remove { .. } => more::eval(doc, vals, f, st),
|
||||
FeatureKind::Sketch { plane, sketch } => {
|
||||
let fst = state_in_frame(doc, st, f.component);
|
||||
let (plane, face_warning) = match plane {
|
||||
PlaneRef::Face { plane: picked, at, name } => face_plane(&fst, picked, *at, name.as_deref()),
|
||||
other => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
|
||||
let anchor = crate::face_anchor::plane_face_anchor(plane);
|
||||
let fst = match anchor {
|
||||
Some(anchor) => crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?,
|
||||
None => state_in_frame(doc, st, f.component),
|
||||
};
|
||||
let (plane, face_warning) = match (anchor, plane) {
|
||||
(Some(_), plane) => (doc.resolve_attached_plane(vals, plane, f.component, st, &fst)?, None),
|
||||
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref()),
|
||||
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
|
||||
};
|
||||
let mut sk = sketch.clone();
|
||||
let mut warns = crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk);
|
||||
let mut warns = match anchor {
|
||||
Some(anchor) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor),
|
||||
None => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk),
|
||||
};
|
||||
drop(fst);
|
||||
sk.refresh_offsets()?;
|
||||
if let Some(w) = face_warning {
|
||||
@@ -2590,6 +2720,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
if !warns.is_empty() {
|
||||
*warning = Some(warns.join("; "));
|
||||
}
|
||||
sk.validate_periodic_splines()?;
|
||||
let profiles = find_profiles(&sk);
|
||||
st.sketches.retain(|s| s.feature != f.id);
|
||||
st.sketches.push(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles });
|
||||
@@ -2953,6 +3084,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
|
||||
let corner = point(vals, corner)?;
|
||||
let b = kernel::box_solid(corner, corner + s)?;
|
||||
let b = if *operation == Operation::NewBody { b.with_box_certificate(f.id)? } else { b };
|
||||
apply_op(st, f, vec![b], *operation, &[])
|
||||
}
|
||||
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
//! Exact ordinary face attachments. Missing identities never invoke a proximity fallback.
|
||||
use crate::config::ConfigFaceTarget;
|
||||
use crate::{DocError, Document, EmbossFace, FeatureKind, Model, ModelBody, ModelState, PlaneRef, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use solvecraft_geom::Plane;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FaceAnchor {
|
||||
pub target: ConfigFaceTarget,
|
||||
pub component: u64,
|
||||
pub path: Vec<u64>,
|
||||
pub author_path: Vec<u64>,
|
||||
/// Origin and x direction in the exact canonical face plane's coordinates.
|
||||
pub coordinates: [f64; 2],
|
||||
pub x_axis: [f64; 2],
|
||||
}
|
||||
|
||||
impl FaceAnchor {
|
||||
pub fn remap_target(
|
||||
&mut self,
|
||||
features: &std::collections::BTreeMap<u64, u64>,
|
||||
bodies: &std::collections::BTreeMap<String, String>,
|
||||
) -> Result<()> {
|
||||
if bodies.contains_key(&self.target.body) && !features.contains_key(&self.target.origin_feature) {
|
||||
return Err(DocError::Invalid("cannot clone an attached face without its certified producer".into()));
|
||||
}
|
||||
self.target.name = crate::remap_topology(&self.target.name, features, bodies);
|
||||
if let Some(body) = bodies.get(&self.target.body) {
|
||||
self.target.body = body.clone();
|
||||
}
|
||||
if let Some(feature) = features.get(&self.target.origin_feature) {
|
||||
self.target.origin_feature = *feature;
|
||||
}
|
||||
if let Some(certificate) = &mut self.target.certificate {
|
||||
certificate.remap_features(features);
|
||||
}
|
||||
self.validate()
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.target.validate()?;
|
||||
if self.target.certificate.is_none()
|
||||
|| self.path.len() > 64
|
||||
|| self.author_path.len() > 64
|
||||
|| self.path.iter().chain(&self.author_path).any(|id| *id == 0)
|
||||
|| (self.component == 0 && !self.path.is_empty())
|
||||
|| (self.component != 0 && self.path.is_empty())
|
||||
|| self.coordinates.iter().any(|value| !value.is_finite() || value.abs() > 1e9)
|
||||
|| self.x_axis.iter().any(|value| !value.is_finite())
|
||||
|| (self.x_axis[0].hypot(self.x_axis[1]) - 1.).abs() > 1e-6
|
||||
{
|
||||
return Err(DocError::Invalid("invalid exact face attachment".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn author_frame(&self, doc: &Document, author: u64) -> Result<crate::Mat> {
|
||||
if author != 0 && self.author_path.is_empty() {
|
||||
return Err(DocError::Invalid(
|
||||
"attached sketch requires an explicit author occurrence path; activate a placement or choose a fixed plane".into(),
|
||||
));
|
||||
}
|
||||
EmbossFace::component_frame(doc, author, &self.author_path)
|
||||
}
|
||||
pub fn source_to_author(&self, doc: &Document, author: u64, body: &ModelBody) -> Result<crate::Mat> {
|
||||
self.validate()?;
|
||||
if doc.model_body_component(body) != self.component {
|
||||
return Err(DocError::Invalid("attached face's component ownership changed; reattach explicitly".into()));
|
||||
}
|
||||
let inverse = crate::mat_inverse(&self.author_frame(doc, author)?)
|
||||
.ok_or_else(|| DocError::Invalid("attached sketch author frame is invalid".into()))?;
|
||||
let source =
|
||||
EmbossFace { body: self.target.body.clone(), at: self.target.point, path: self.path.clone(), author_path: self.author_path.clone() };
|
||||
Ok(crate::mat_mul(&inverse, &source.body_frame(doc, body)?))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plane_face_anchor(plane: &PlaneRef) -> Option<&FaceAnchor> {
|
||||
match plane {
|
||||
PlaneRef::Face { anchor, .. } => anchor.as_deref(),
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane_face_anchor(base),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_face_reference(plane: &PlaneRef) -> bool {
|
||||
match plane {
|
||||
PlaneRef::Face { .. } => true,
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => has_face_reference(base),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn resolve_attached_plane(
|
||||
&self,
|
||||
vals: &std::collections::BTreeMap<String, crate::expr::Value>,
|
||||
plane: &PlaneRef,
|
||||
author: u64,
|
||||
state: &ModelState,
|
||||
framed: &ModelState,
|
||||
) -> Result<Plane> {
|
||||
fn replace(doc: &Document, plane: &mut PlaneRef, author: u64, state: &ModelState, depth: usize) -> Result<()> {
|
||||
if depth > 64 {
|
||||
return Err(DocError::Invalid("attached plane nesting exceeds budget".into()));
|
||||
}
|
||||
match plane {
|
||||
PlaneRef::Face { anchor: Some(anchor), .. } => *plane = PlaneRef::Custom { plane: doc.resolve_face_anchor(anchor, author, state)? },
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => replace(doc, base, author, state, depth + 1)?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut resolved = plane.clone();
|
||||
replace(self, &mut resolved, author, state, 0)?;
|
||||
self.resolve_plane_in(vals, &resolved, 0, Some(framed))
|
||||
}
|
||||
}
|
||||
|
||||
/// All status planes are world-space. A lost plane is a repair guide only.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FaceAttachmentStatus {
|
||||
Fixed { plane: Option<Plane> },
|
||||
Legacy { plane: Option<Plane> },
|
||||
Healthy { plane: Plane, target: ConfigFaceTarget, path: Vec<u64> },
|
||||
Lost { message: String, repair_plane: Option<Plane> },
|
||||
}
|
||||
|
||||
fn map_plane(matrix: &crate::Mat, plane: &Plane) -> Result<Plane> {
|
||||
Plane::new(crate::apply_point(matrix, plane.origin), crate::apply_vector(matrix, plane.x), crate::apply_vector(matrix, plane.y))
|
||||
.ok_or_else(|| DocError::Invalid("attached plane transform is degenerate".into()))
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Capture a known selected face in the definition model, without another nearest pick.
|
||||
pub fn capture_face_anchor(
|
||||
&self,
|
||||
body: &ModelBody,
|
||||
index: usize,
|
||||
picked: &Plane,
|
||||
target_path: &[u64],
|
||||
author: u64,
|
||||
author_path: &[u64],
|
||||
) -> Result<FaceAnchor> {
|
||||
let source = body
|
||||
.body
|
||||
.face_plane(index)
|
||||
.ok_or_else(|| DocError::Invalid("this face has no exact planar source; choose a fixed plane instead".into()))?;
|
||||
let target = self.strong_face_target(body, index, source.origin)?;
|
||||
if target.certificate.is_none() {
|
||||
return Err(DocError::Invalid("this face has no supported construction certificate; choose a fixed plane instead".into()));
|
||||
}
|
||||
self.configuration_face_indices(body, &target)?;
|
||||
let mut anchor = FaceAnchor {
|
||||
target,
|
||||
component: self.model_body_component(body),
|
||||
path: target_path.to_vec(),
|
||||
author_path: author_path.to_vec(),
|
||||
coordinates: [0., 0.],
|
||||
x_axis: [1., 0.],
|
||||
};
|
||||
let canonical = map_plane(&anchor.source_to_author(self, author, body)?, &source)?;
|
||||
if picked.normal().dot(canonical.normal()) < 1. - 1e-7 || (picked.origin - canonical.origin).dot(canonical.normal()).abs() > 1e-7 {
|
||||
return Err(DocError::Invalid("picked plane does not match its exact source face".into()));
|
||||
}
|
||||
let delta = picked.origin - canonical.origin;
|
||||
anchor.coordinates = [delta.dot(canonical.x), delta.dot(canonical.y)];
|
||||
anchor.x_axis = [picked.x.dot(canonical.x), picked.x.dot(canonical.y)];
|
||||
anchor.validate()?;
|
||||
Ok(anchor)
|
||||
}
|
||||
|
||||
pub fn resolve_face_anchor(&self, anchor: &FaceAnchor, author: u64, state: &ModelState) -> Result<Plane> {
|
||||
anchor.validate()?;
|
||||
let body =
|
||||
state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("attached body `{}` no longer exists", anchor.target.body)))?;
|
||||
let indices = self.configuration_face_indices(body, &anchor.target)?;
|
||||
let first = indices.first().copied().ok_or_else(|| DocError::Unknown("attached face no longer exists".into()))?;
|
||||
let plane = body.body.face_plane(first).ok_or_else(|| DocError::Invalid("attached face no longer has an exact planar source".into()))?;
|
||||
for index in indices.iter().skip(1) {
|
||||
let other = body.body.face_plane(*index).ok_or_else(|| DocError::Invalid("attached face pieces are not exactly planar".into()))?;
|
||||
if plane.normal().dot(other.normal()) < 1. - 1e-8 || (other.origin - plane.origin).dot(plane.normal()).abs() > 1e-7 {
|
||||
return Err(DocError::Invalid("attached face pieces no longer share one oriented plane".into()));
|
||||
}
|
||||
}
|
||||
let canonical = map_plane(&anchor.source_to_author(self, author, body)?, &plane)?;
|
||||
let origin = canonical.origin + canonical.x * anchor.coordinates[0] + canonical.y * anchor.coordinates[1];
|
||||
let x = canonical.x * anchor.x_axis[0] + canonical.y * anchor.x_axis[1];
|
||||
Plane::new(origin, x, canonical.normal().cross(x)).ok_or_else(|| DocError::Invalid("attached sketch basis is degenerate".into()))
|
||||
}
|
||||
|
||||
pub fn sketch_face_attachment(&self, feature: u64, model: &Model) -> FaceAttachmentStatus {
|
||||
let Some(feature) = self.feature(feature) else {
|
||||
return FaceAttachmentStatus::Lost { message: "sketch no longer exists".into(), repair_plane: None };
|
||||
};
|
||||
let FeatureKind::Sketch { plane, .. } = &feature.kind else {
|
||||
return FaceAttachmentStatus::Fixed { plane: None };
|
||||
};
|
||||
let Some(anchor) = plane_face_anchor(plane) else {
|
||||
let path = self.first_path(feature.component);
|
||||
let unique = path.iter().all(|id| {
|
||||
self.occurrences
|
||||
.iter()
|
||||
.find(|o| o.id == *id)
|
||||
.is_some_and(|selected| self.occurrences.iter().filter(|o| o.component == selected.component).count() == 1)
|
||||
});
|
||||
let world = unique.then(|| EmbossFace::component_frame(self, feature.component, &path).ok()).flatten();
|
||||
let current = model.state().sketch(feature.id).map(|sketch| sketch.plane).or_else(|| {
|
||||
let (vals, _) = self.param_values();
|
||||
self.resolve_plane(&vals, plane, 0).ok()
|
||||
});
|
||||
let current = world.and_then(|world| current.and_then(|plane| map_plane(&world, &plane).ok()));
|
||||
return if has_face_reference(plane) {
|
||||
FaceAttachmentStatus::Legacy { plane: current }
|
||||
} else {
|
||||
FaceAttachmentStatus::Fixed { plane: current }
|
||||
};
|
||||
};
|
||||
let (vals, _) = self.param_values();
|
||||
let picked = self.resolve_plane(&vals, plane, 0).ok();
|
||||
let world = anchor.author_frame(self, feature.component);
|
||||
let repair_plane = world.as_ref().ok().and_then(|matrix| picked.as_ref().and_then(|picked| map_plane(matrix, picked).ok()));
|
||||
let state = model.state_before(feature.id);
|
||||
match crate::state_in_face_anchor_frame(self, &state, anchor, feature.component)
|
||||
.and_then(|framed| self.resolve_attached_plane(&vals, plane, feature.component, &state, &framed))
|
||||
.and_then(|plane| map_plane(&world?, &plane))
|
||||
{
|
||||
Ok(plane) => FaceAttachmentStatus::Healthy { plane, target: anchor.target.clone(), path: anchor.path.clone() },
|
||||
Err(error) => FaceAttachmentStatus::Lost { message: format!("lost face attachment on `{}`: {error}", anchor.target.body), repair_plane },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_face_anchor_json(value: &Value) -> Result<()> {
|
||||
let fields = value.as_object().ok_or_else(|| DocError::Invalid("face anchor must be an object".into()))?;
|
||||
if fields.len() != 6 || fields.keys().any(|key| !["target", "component", "path", "author_path", "coordinates", "x_axis"].contains(&key.as_str()))
|
||||
{
|
||||
return Err(DocError::Invalid("unknown or missing face anchor fields".into()));
|
||||
}
|
||||
let target = fields.get("target").ok_or_else(|| DocError::Invalid("face anchor target missing".into()))?;
|
||||
crate::config::validate_face_target_json(target)?;
|
||||
if target.get("certificate").is_none_or(Value::is_null) {
|
||||
return Err(DocError::Invalid("face anchor needs a source certificate".into()));
|
||||
}
|
||||
let component =
|
||||
fields.get("component").and_then(Value::as_u64).ok_or_else(|| DocError::Invalid("face anchor component must be an integer".into()))?;
|
||||
for key in ["path", "author_path"] {
|
||||
if fields.get(key).and_then(Value::as_array).is_none_or(|path| {
|
||||
path.len() > 64
|
||||
|| path.iter().any(|id| id.as_u64().is_none_or(|id| id == 0))
|
||||
|| (key == "path" && component == 0 && !path.is_empty())
|
||||
|| (key == "path" && component != 0 && path.is_empty())
|
||||
}) {
|
||||
return Err(DocError::Invalid("face anchor path is invalid or oversized".into()));
|
||||
}
|
||||
}
|
||||
for key in ["coordinates", "x_axis"] {
|
||||
let values = fields
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.filter(|values| values.len() == 2)
|
||||
.ok_or_else(|| DocError::Invalid("face anchor vectors require exactly two values".into()))?;
|
||||
if values.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9)) {
|
||||
return Err(DocError::Invalid("face anchor vector is invalid".into()));
|
||||
}
|
||||
if key == "x_axis" && (values[0].as_f64().unwrap_or(0.).hypot(values[1].as_f64().unwrap_or(0.)) - 1.).abs() > 1e-6 {
|
||||
return Err(DocError::Invalid("face anchor basis must be normalized".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walk reference-bearing JSON before typed decoding, excluding bulk geometry payloads.
|
||||
pub fn validate_face_anchors_json(value: &Value) -> Result<()> {
|
||||
let mut stack = vec![(value, 0usize)];
|
||||
let mut visited = 0usize;
|
||||
while let Some((value, depth)) = stack.pop() {
|
||||
visited += 1;
|
||||
if visited > 2_000_000 || depth > 128 {
|
||||
return Err(DocError::Invalid("face anchor structure exceeds work limits".into()));
|
||||
}
|
||||
match value {
|
||||
Value::Object(fields) => {
|
||||
if fields.get("type").and_then(Value::as_str) == Some("face")
|
||||
&& let Some(anchor) = fields.get("anchor")
|
||||
&& !anchor.is_null()
|
||||
{
|
||||
validate_face_anchor_json(anchor)?;
|
||||
}
|
||||
for (key, child) in fields {
|
||||
if !["sketch", "meshes", "step", "configs", "appearances", "materials"].contains(&key.as_str())
|
||||
&& matches!(child, Value::Array(_) | Value::Object(_))
|
||||
{
|
||||
stack.push((child, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for child in values {
|
||||
if matches!(child, Value::Array(_) | Value::Object(_)) {
|
||||
stack.push((child, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solvecraft_geom::Vec3;
|
||||
fn fixture() -> (Document, ModelBody, usize, Plane) {
|
||||
let mut doc = Document::default();
|
||||
let kind: FeatureKind =
|
||||
serde_json::from_value(serde_json::json!({"type":"box","corner":[0,0,0],"length":"10","width":"10","height":"10"})).unwrap();
|
||||
let id = doc.add_feature(kind, Some("Base")).unwrap();
|
||||
let body = ModelBody::new(
|
||||
"Base".into(),
|
||||
solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(id).unwrap(),
|
||||
id,
|
||||
);
|
||||
let index = body
|
||||
.body
|
||||
.face_sources()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::Box { axis: 2, high: true, .. })))
|
||||
.unwrap();
|
||||
let canonical = body.body.face_plane(index).unwrap();
|
||||
let picked = Plane::new(canonical.origin + canonical.x * 2. + canonical.y * 3., canonical.y, -canonical.x).unwrap();
|
||||
(doc, body, index, picked)
|
||||
}
|
||||
#[test]
|
||||
fn exact_anchor_preserves_picked_basis_and_follows_height_without_parallel_rescue() {
|
||||
let (doc, body, index, picked) = fixture();
|
||||
let anchor = doc.capture_face_anchor(&body, index, &picked, &[], 0, &[]).unwrap();
|
||||
let anchor: FaceAnchor = serde_json::from_str(&serde_json::to_string(&anchor).unwrap()).unwrap();
|
||||
assert!(anchor.author_frame(&doc, 1).is_err(), "strong author attachment cannot infer a first placement");
|
||||
let mut missing_source = anchor.clone();
|
||||
missing_source.component = 1;
|
||||
assert!(missing_source.validate().is_err(), "strong source attachment needs an explicit path");
|
||||
assert!(validate_face_anchor_json(&serde_json::to_value(missing_source).unwrap()).is_err());
|
||||
let mut state = ModelState::default();
|
||||
state.bodies.push(body);
|
||||
let initial = doc.resolve_face_anchor(&anchor, 0, &state).unwrap();
|
||||
assert!((initial.origin - picked.origin).len() < 1e-9);
|
||||
assert!((initial.x - picked.x).len() < 1e-9);
|
||||
state.bodies[0] = ModelBody::new(
|
||||
"Base".into(),
|
||||
solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 20.))
|
||||
.unwrap()
|
||||
.with_box_certificate(anchor.target.origin_feature)
|
||||
.unwrap(),
|
||||
anchor.target.origin_feature,
|
||||
);
|
||||
let changed = doc.resolve_face_anchor(&anchor, 0, &state).unwrap();
|
||||
assert!((changed.origin.z - 20.).abs() < 1e-9);
|
||||
assert!((changed.origin.x - initial.origin.x).abs() < 1e-9);
|
||||
state.bodies[0] = ModelBody::new(
|
||||
"Control".into(),
|
||||
solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 30.))
|
||||
.unwrap()
|
||||
.with_box_certificate(anchor.target.origin_feature)
|
||||
.unwrap(),
|
||||
anchor.target.origin_feature,
|
||||
);
|
||||
assert!(doc.resolve_face_anchor(&anchor, 0, &state).is_err());
|
||||
state.bodies[0].name = "Base".into();
|
||||
state.bodies[0].body = solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 30.))
|
||||
.unwrap()
|
||||
.with_box_certificate(anchor.target.origin_feature + 1)
|
||||
.unwrap();
|
||||
assert!(doc.resolve_face_anchor(&anchor, 0, &state).is_err(), "reused name cannot replace the producer");
|
||||
}
|
||||
#[test]
|
||||
fn nested_anchor_offset_tracks_source_and_raw_budgets_precede_decode() {
|
||||
let (doc, body, index, picked) = fixture();
|
||||
let anchor = doc.capture_face_anchor(&body, index, &picked, &[], 0, &[]).unwrap();
|
||||
let plane = PlaneRef::Offset {
|
||||
base: Box::new(PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: Some(Box::new(anchor.clone())) }),
|
||||
distance: "2".into(),
|
||||
};
|
||||
let mut state = ModelState::default();
|
||||
state.bodies.push(body);
|
||||
let (vals, _) = doc.param_values();
|
||||
let result = doc.resolve_attached_plane(&vals, &plane, 0, &state, &state).unwrap();
|
||||
assert!((result.origin.z - 12.).abs() < 1e-9);
|
||||
state.bodies.clear();
|
||||
assert!(doc.resolve_attached_plane(&vals, &plane, 0, &state, &state).is_err());
|
||||
let mut raw = serde_json::to_value(anchor).unwrap();
|
||||
assert!(validate_face_anchor_json(&raw).is_ok());
|
||||
raw["path"] = serde_json::json!([1]);
|
||||
assert!(validate_face_anchor_json(&raw).is_err());
|
||||
raw["path"] = serde_json::json!([]);
|
||||
raw["target"]["name"] = Value::String("x".repeat(1_000_000));
|
||||
assert!(validate_face_anchor_json(&raw).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn extrude_cap_profile_reordering_never_retargets_an_ordinal_body() {
|
||||
use solvecraft_geom::Vec2;
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
for offset in [0., 30.] {
|
||||
let points: Vec<_> = [(0., 0.), (10., 0.), (10., 10.), (0., 10.)]
|
||||
.iter()
|
||||
.map(|(x, y)| sketch.add_point(Vec2::new(x + offset, *y), None).unwrap())
|
||||
.collect();
|
||||
for edge in 0..4 {
|
||||
sketch.add_line_pts(points[edge], points[(edge + 1) % 4], None).unwrap();
|
||||
}
|
||||
}
|
||||
let mut doc = Document::default();
|
||||
let source = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
|
||||
let extent: crate::Extent = serde_json::from_value(serde_json::json!({"distance":"10"})).unwrap();
|
||||
let producer = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch: source,
|
||||
profiles: crate::ProfileSel::Indices { indices: vec![0, 1] },
|
||||
extent,
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let state = model.state();
|
||||
assert_eq!(state.bodies.len(), 2);
|
||||
let solved = state.sketch(source).unwrap();
|
||||
let regions = crate::select_profiles(solved, &crate::ProfileSel::All).unwrap();
|
||||
let raw = solvecraft_kernel::extrude(&solved.plane, ®ions, 0., 10.).unwrap();
|
||||
assert!(
|
||||
crate::config::extrude_cap_bindings(solved, &raw, 0).is_none(),
|
||||
"exhausted certification work must leave the complete producer uncertified"
|
||||
);
|
||||
let bindings = crate::config::extrude_cap_bindings(solved, &raw, 10_000_000).unwrap();
|
||||
assert_eq!(bindings.iter().flatten().flatten().count(), 4, "two profiles each retain both exact caps within budget");
|
||||
let mut duplicate = raw.clone();
|
||||
duplicate.extend(raw);
|
||||
let ambiguous = crate::config::extrude_cap_bindings(solved, &duplicate, 10_000_000).unwrap();
|
||||
assert!(ambiguous.iter().flatten().all(Option::is_none), "duplicated profile identities cannot stamp ambiguous caps");
|
||||
let body = &state.bodies[0];
|
||||
let index = body
|
||||
.body
|
||||
.face_sources()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { high: true, .. })))
|
||||
.unwrap();
|
||||
let picked = body.body.face_plane(index).unwrap();
|
||||
let anchor = doc.capture_face_anchor(body, index, &picked, &[], 0, &[]).unwrap();
|
||||
let bounds = body.mesh().bounds();
|
||||
let FeatureKind::Extrude { profiles, .. } = &mut doc.feature_mut(producer).unwrap().kind else {
|
||||
panic!("fixture must retain its extrude feature")
|
||||
};
|
||||
*profiles = crate::ProfileSel::Indices { indices: vec![1, 0] };
|
||||
model.evaluate(&doc);
|
||||
let changed = model.state();
|
||||
assert_eq!(changed.bodies.len(), 2);
|
||||
if doc.resolve_face_anchor(&anchor, 0, &changed).is_ok() {
|
||||
let current = changed.body(&anchor.target.body).unwrap().mesh().bounds();
|
||||
assert!((current.min.x - bounds.min.x).abs() < 1e-9, "a same-role cap of the other profile must never replace the source");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn wrapped_legacy_face_is_reported_as_legacy_without_inferred_upgrade() {
|
||||
let (mut doc, _, _, picked) = fixture();
|
||||
let id = doc
|
||||
.add_feature(
|
||||
FeatureKind::Sketch {
|
||||
plane: PlaneRef::Offset {
|
||||
base: Box::new(PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: None }),
|
||||
distance: "2".into(),
|
||||
},
|
||||
sketch: solvecraft_sketch::Sketch::new(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let FaceAttachmentStatus::Legacy { plane: Some(plane) } = doc.sketch_face_attachment(id, &model) else {
|
||||
panic!("wrapped legacy attachment must retain its classification and fixed-plane escape");
|
||||
};
|
||||
assert!((plane.origin.z - 12.).abs() < 1e-9);
|
||||
let FeatureKind::Sketch { plane, .. } = &doc.feature(id).unwrap().kind else { panic!("fixture must retain its sketch feature") };
|
||||
assert!(plane_face_anchor(plane).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn warm_root_body_offset_edit_matches_cold_attached_sketch_and_downstream_bounds() {
|
||||
use solvecraft_geom::Vec2;
|
||||
let (mut doc, _, _, _) = fixture();
|
||||
let mut warm = Model::new();
|
||||
warm.evaluate(&doc);
|
||||
let source = warm.state();
|
||||
let body = &source.bodies[0];
|
||||
let index = body
|
||||
.body
|
||||
.face_sources()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::Box { axis: 2, high: true, .. })))
|
||||
.unwrap();
|
||||
let picked = body.body.face_plane(index).unwrap();
|
||||
let anchor = doc.capture_face_anchor(body, index, &picked, &[], 0, &[]).unwrap();
|
||||
let source_name = body.name.clone();
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
let points: Vec<_> =
|
||||
[(2., 2.), (4., 2.), (4., 4.), (2., 4.)].iter().map(|(x, y)| sketch.add_point(Vec2::new(*x, *y), None).unwrap()).collect();
|
||||
for edge in 0..4 {
|
||||
sketch.add_line_pts(points[edge], points[(edge + 1) % 4], None).unwrap();
|
||||
}
|
||||
let sk = doc
|
||||
.add_feature(
|
||||
FeatureKind::Sketch {
|
||||
plane: PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: Some(Box::new(anchor)) },
|
||||
sketch,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let extent = serde_json::from_value(serde_json::json!({"distance":"2"})).unwrap();
|
||||
let boss = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude { sketch: sk, profiles: crate::ProfileSel::All, extent, operation: crate::Operation::NewBody, targets: vec![] },
|
||||
Some("Boss"),
|
||||
)
|
||||
.unwrap();
|
||||
warm.evaluate(&doc);
|
||||
let initial = warm.state();
|
||||
let before = initial.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
|
||||
let mut offset = crate::IDENTITY;
|
||||
offset[3][0] = 5.;
|
||||
offset[3][2] = 3.;
|
||||
doc.body_offsets.insert(source_name, offset);
|
||||
warm.evaluate(&doc);
|
||||
let mut cold = Model::new();
|
||||
cold.evaluate(&doc);
|
||||
let warm_state = warm.state();
|
||||
let cold_state = cold.state();
|
||||
let after = warm_state.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
|
||||
let rebuilt = cold_state.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
|
||||
assert!(
|
||||
(after.min - before.min - Vec3::new(5., 0., 3.)).len() < 1e-7,
|
||||
"before={before:?}, warm={after:?}, cold={rebuilt:?}, warm_plane={:?}, cold_plane={:?}",
|
||||
warm_state.sketch(sk).map(|sk| sk.plane),
|
||||
cold_state.sketch(sk).map(|sk| sk.plane)
|
||||
);
|
||||
assert!((after.min - rebuilt.min).len() < 1e-7);
|
||||
assert!((after.max - rebuilt.max).len() < 1e-7);
|
||||
let FaceAttachmentStatus::Healthy { plane, .. } = doc.sketch_face_attachment(sk, &warm) else {
|
||||
panic!("offset source must remain attached");
|
||||
};
|
||||
assert!((plane.origin - warm_state.sketch(sk).unwrap().plane.origin).len() < 1e-9);
|
||||
assert!((warm_state.sketch(sk).unwrap().plane.origin - cold_state.sketch(sk).unwrap().plane.origin).len() < 1e-9);
|
||||
}
|
||||
}
|
||||
+214
-3
@@ -31,13 +31,15 @@
|
||||
//! - 10: timeline body transfers, explicit 3D wire coordinate spaces and certified face
|
||||
//! bindings. Legacy ownership maps remain unchanged. Retained wire associations determine
|
||||
//! their space; saved coordinates are never rewritten during migration.
|
||||
//! - 11: durable sketch face anchors, explicitly periodic fit splines and versioned revolve
|
||||
//! conventions. Absent markers retain legacy attachment and interpolation behavior.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The format this build writes.
|
||||
pub const FORMAT: u32 = 10;
|
||||
pub const FORMAT: u32 = 11;
|
||||
|
||||
pub fn format_string(n: u32) -> String {
|
||||
format!("solvecraft/{n}")
|
||||
@@ -50,7 +52,7 @@ pub fn version_of(s: &str) -> Option<u32> {
|
||||
|
||||
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
|
||||
type JsonStep = fn(&mut Value) -> Result<()>;
|
||||
const JSON_STEPS: [JsonStep; 9] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(())];
|
||||
const JSON_STEPS: [JsonStep; 10] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(())];
|
||||
|
||||
/// Old XZ references retain their exact frame, including nested offset and angled planes.
|
||||
/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
|
||||
@@ -143,6 +145,8 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
check_numbers(&v, 0)?;
|
||||
crate::config::validate_rules_json(&v)?;
|
||||
crate::config::validate_styles_json(&v)?;
|
||||
crate::face_anchor::validate_face_anchors_json(&v)?;
|
||||
validate_versioned_geometry(&v)?;
|
||||
validate_emboss_targets(&v)?;
|
||||
validate_freeform_offsets(&v)?;
|
||||
let legacy_wires = validate_wires(&v, version)?;
|
||||
@@ -184,6 +188,7 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
if let crate::FeatureKind::Sketch { sketch, .. } = &feature.kind {
|
||||
sketch.validate_freeform_offsets().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
|
||||
sketch.validate_wires().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
|
||||
sketch.validate_periodic_splines().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
|
||||
}
|
||||
if let crate::FeatureKind::BodyTransfer { from, to, .. } = &feature.kind
|
||||
&& [from, to].into_iter().any(|id| !component_ids.contains(id))
|
||||
@@ -351,6 +356,58 @@ fn validate_emboss_targets(value: &Value) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bound explicitly versioned geometry before allocating typed spline point arrays.
|
||||
fn validate_versioned_geometry(value: &Value) -> Result<()> {
|
||||
let invalid = || DocError::Invalid("damaged design: invalid periodic spline or revolve convention".into());
|
||||
let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) };
|
||||
for feature in features {
|
||||
if feature.get("type").and_then(Value::as_str) == Some("revolve")
|
||||
&& let Some(convention) = feature.get("convention")
|
||||
&& !matches!(convention.as_str(), Some("legacy_axis_start" | "sketch_normal"))
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
if feature.get("type").and_then(Value::as_str) != Some("sketch") {
|
||||
continue;
|
||||
}
|
||||
let Some(sketch) = feature.get("sketch") else { continue };
|
||||
if let Some(links) = sketch.get("links").and_then(Value::as_array) {
|
||||
for link in links {
|
||||
if link.get("auto_face").is_some_and(|marker| !marker.is_boolean()) {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(curves) = sketch.get("curves").and_then(Value::as_array) else { continue };
|
||||
let points = sketch.get("points").and_then(Value::as_array);
|
||||
let mut work = 0usize;
|
||||
for curve in curves {
|
||||
if curve.get("type").and_then(Value::as_str) != Some("spline") {
|
||||
continue;
|
||||
}
|
||||
let Some(marker) = curve.get("periodic") else { continue };
|
||||
let periodic = marker.as_bool().ok_or_else(invalid)?;
|
||||
if !periodic {
|
||||
continue;
|
||||
}
|
||||
let indices = curve.get("pts").and_then(Value::as_array).ok_or_else(invalid)?;
|
||||
work = work.saturating_add(indices.len());
|
||||
if !(4..=solvecraft_sketch::MAX_SPLINE_POINTS).contains(&indices.len())
|
||||
|| work > 1_000_000
|
||||
|| curve.get("control").is_some_and(|control| control.as_bool() != Some(false))
|
||||
|| curve.get("degree").is_some_and(|degree| degree.as_u64() != Some(3))
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
let count = points.ok_or_else(invalid)?.len();
|
||||
if indices.iter().any(|index| index.as_u64().is_none_or(|index| index >= count as u64)) {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_freeform_offsets(value: &Value) -> Result<()> {
|
||||
let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) };
|
||||
for feature in features {
|
||||
@@ -363,6 +420,27 @@ fn validate_freeform_offsets(value: &Value) -> Result<()> {
|
||||
if records.len() > 128 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let periodic_outputs: std::collections::BTreeSet<_> = records
|
||||
.iter()
|
||||
.filter(|record| record.get("certificate").and_then(Value::as_u64) == Some(2))
|
||||
.filter_map(|record| record.get("output").and_then(Value::as_str))
|
||||
.collect();
|
||||
let mut periodic_curves = std::collections::BTreeMap::new();
|
||||
if !periodic_outputs.is_empty()
|
||||
&& let Some(curves) = feature.get("sketch").and_then(|sketch| sketch.get("curves")).and_then(Value::as_array)
|
||||
{
|
||||
if curves.len() > 1_000_000 {
|
||||
return Err(invalid());
|
||||
}
|
||||
for curve in curves {
|
||||
if let Some(id) = curve.get("id").and_then(Value::as_str)
|
||||
&& periodic_outputs.contains(id)
|
||||
&& periodic_curves.insert(id, curve).is_some()
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut outputs = std::collections::BTreeSet::new();
|
||||
for record in records {
|
||||
let object = record.as_object().ok_or_else(invalid)?;
|
||||
@@ -396,12 +474,22 @@ fn validate_freeform_offsets(value: &Value) -> Result<()> {
|
||||
|| !(1e-9..=MAX_REAL).contains(&distance.abs())
|
||||
|| !tolerance.is_finite()
|
||||
|| !(tolerance > 0.0 && tolerance <= 0.002)
|
||||
|| record.get("certificate").and_then(Value::as_u64) != Some(1)
|
||||
|| !matches!(record.get("certificate").and_then(Value::as_u64), Some(1 | 2))
|
||||
|| record.get("forward").and_then(Value::as_bool).is_none()
|
||||
|| record.get("closed").and_then(Value::as_bool).is_none()
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
if record.get("certificate").and_then(Value::as_u64) == Some(2) {
|
||||
let output_curve = periodic_curves.get(output);
|
||||
if record.get("closed").and_then(Value::as_bool) != Some(true)
|
||||
|| output_curve.is_none_or(|curve| {
|
||||
curve.get("type").and_then(Value::as_str) != Some("spline") || curve.get("periodic").and_then(Value::as_bool) != Some(true)
|
||||
})
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut resolved = std::collections::BTreeSet::new();
|
||||
for record in records {
|
||||
@@ -673,6 +761,129 @@ mod tests {
|
||||
assert!(error.contains("damaged design"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_face_anchors_are_refused_before_typed_decoding() {
|
||||
let anchor = serde_json::json!({
|
||||
"target":{"body":"Base", "origin_feature":1, "name":"Base:+z", "point":[0,0,10],
|
||||
"certificate":{"kind":"box", "axis":2, "high":true}},
|
||||
"component":0, "path":[], "author_path":[], "coordinates":[0,0], "x_axis":[1,0]
|
||||
});
|
||||
let make = |anchor: Value| {
|
||||
serde_json::json!({"features":[{"type":"sketch", "plane":{
|
||||
"type":"face", "anchor":anchor
|
||||
}}]})
|
||||
};
|
||||
assert!(crate::face_anchor::validate_face_anchors_json(&make(anchor.clone())).is_ok());
|
||||
let mut nested = serde_json::json!({"features":[{"type":"sketch", "plane":{
|
||||
"type":"offset", "base":{"type":"at_angle", "base":{"type":"face", "anchor":anchor.clone()}}
|
||||
}}]});
|
||||
assert!(crate::face_anchor::validate_face_anchors_json(&nested).is_ok());
|
||||
nested["features"][0]["plane"]["base"]["base"]["anchor"]["coordinates"] = serde_json::json!([0, 0, 0]);
|
||||
assert!(crate::face_anchor::validate_face_anchors_json(&nested).is_err());
|
||||
let mut malformed = Vec::new();
|
||||
for (key, damaged) in [
|
||||
("component", serde_json::json!(-1)),
|
||||
("path", serde_json::json!([0])),
|
||||
("author_path", serde_json::json!((1..=65).collect::<Vec<u64>>())),
|
||||
("coordinates", serde_json::json!([1_000_000_001_u64, 0])),
|
||||
("coordinates", serde_json::json!([0])),
|
||||
("x_axis", serde_json::json!([0, 0])),
|
||||
("unknown", serde_json::json!(true)),
|
||||
] {
|
||||
let mut candidate = anchor.clone();
|
||||
candidate[key] = damaged;
|
||||
malformed.push(candidate);
|
||||
}
|
||||
let mut missing_certificate = anchor.clone();
|
||||
missing_certificate["target"].as_object_mut().unwrap().remove("certificate");
|
||||
malformed.push(missing_certificate);
|
||||
let mut invalid_role = anchor;
|
||||
invalid_role["target"]["certificate"]["axis"] = Value::from(3);
|
||||
malformed.push(invalid_role);
|
||||
for candidate in malformed {
|
||||
let value = make(candidate);
|
||||
assert!(crate::face_anchor::validate_face_anchors_json(&value).is_err());
|
||||
let error = read(&value.to_string()).unwrap_err().to_string();
|
||||
assert!(!error.contains("design file:"), "must refuse before typed decode: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periodic_markers_are_bounded_before_typed_decoding() {
|
||||
let curve = serde_json::json!({"id":"closed", "type":"spline", "pts":[0,1,2,3], "periodic":true});
|
||||
let make = |curve: Value| {
|
||||
serde_json::json!({"features":[{"type":"sketch", "sketch":{
|
||||
"points":[{}, {}, {}, {}], "curves":[curve]
|
||||
}}]})
|
||||
};
|
||||
assert!(validate_versioned_geometry(&make(curve.clone())).is_ok());
|
||||
for (key, malformed) in [
|
||||
("periodic", serde_json::json!("true")),
|
||||
("control", serde_json::json!(true)),
|
||||
("degree", serde_json::json!(2)),
|
||||
("pts", serde_json::json!([0, 1, 2])),
|
||||
("pts", serde_json::json!([0, 1, 2, u64::MAX])),
|
||||
("pts", serde_json::json!(vec![0; solvecraft_sketch::MAX_SPLINE_POINTS + 1])),
|
||||
] {
|
||||
let mut damaged = curve.clone();
|
||||
damaged[key] = malformed;
|
||||
assert!(validate_versioned_geometry(&make(damaged)).is_err(), "{key}");
|
||||
}
|
||||
let mut legacy = curve;
|
||||
legacy.as_object_mut().unwrap().remove("periodic");
|
||||
legacy["control"] = Value::Bool(true);
|
||||
assert!(validate_versioned_geometry(&make(legacy)).is_ok());
|
||||
for convention in [serde_json::json!(null), serde_json::json!(false), serde_json::json!("unknown")] {
|
||||
assert!(validate_versioned_geometry(&serde_json::json!({"features":[{"type":"revolve", "convention":convention}]})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periodic_offset_certificate_requires_closed_periodic_output() {
|
||||
let record = serde_json::json!({"source":"ellipse", "output":"offset", "forward":true,
|
||||
"distance":2.0, "divisions":8, "pieces":4, "closed":true,
|
||||
"tolerance":0.002, "certificate":2});
|
||||
let mut value = serde_json::json!({"features":[{"type":"sketch", "sketch":{
|
||||
"freeform_offsets":[record], "curves":[{"id":"offset", "type":"spline", "periodic":true}]
|
||||
}}]});
|
||||
assert!(validate_freeform_offsets(&value).is_ok());
|
||||
let duplicate = value["features"][0]["sketch"]["curves"][0].clone();
|
||||
value["features"][0]["sketch"]["curves"].as_array_mut().unwrap().push(duplicate);
|
||||
assert!(validate_freeform_offsets(&value).is_err(), "periodic output ID must be unique");
|
||||
value["features"][0]["sketch"]["curves"].as_array_mut().unwrap().pop();
|
||||
value["features"][0]["sketch"]["curves"][0]["periodic"] = Value::Bool(false);
|
||||
assert!(validate_freeform_offsets(&value).is_err());
|
||||
value["features"][0]["sketch"]["curves"][0]["periodic"] = Value::Bool(true);
|
||||
value["features"][0]["sketch"]["freeform_offsets"][0]["closed"] = Value::Bool(false);
|
||||
assert!(validate_freeform_offsets(&value).is_err());
|
||||
value["features"][0]["sketch"]["freeform_offsets"][0]["certificate"] = Value::from(1);
|
||||
assert!(validate_freeform_offsets(&value).is_ok(), "legacy certificate semantics remain valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periodic_file_validation_refuses_damaged_closed_geometry() {
|
||||
use solvecraft_geom::Vec2;
|
||||
use solvecraft_sketch::{CurveKind, Sketch};
|
||||
let mut sketch = Sketch::new();
|
||||
let mut pts = Vec::new();
|
||||
for point in [Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(0.0, 10.0), Vec2::ZERO] {
|
||||
pts.push(sketch.add_point(point, None).unwrap());
|
||||
}
|
||||
let first = pts[0];
|
||||
let neighbor = pts[1];
|
||||
let closing = pts[3];
|
||||
sketch.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true }, None).unwrap();
|
||||
let mut document = Document::new("periodic file validation");
|
||||
document.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
|
||||
assert_eq!(read(&document.to_json()).unwrap(), document);
|
||||
let mut value: Value = serde_json::from_str(&document.to_json()).unwrap();
|
||||
value["features"][0]["sketch"]["points"][closing]["pos"] = serde_json::json!([1, 0]);
|
||||
assert!(read(&value.to_string()).unwrap_err().to_string().contains("periodic"));
|
||||
value["features"][0]["sketch"]["points"][closing]["pos"] = value["features"][0]["sketch"]["points"][first]["pos"].clone();
|
||||
value["features"][0]["sketch"]["points"][neighbor]["pos"] = value["features"][0]["sketch"]["points"][first]["pos"].clone();
|
||||
assert!(read(&value.to_string()).unwrap_err().to_string().contains("periodic"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeform_offset_metadata_is_bounded_before_typed_decoding() {
|
||||
let valid = serde_json::json!({"source":"ellipse", "output":"offset", "forward":true,
|
||||
|
||||
@@ -18,6 +18,8 @@ pub mod construct;
|
||||
mod document;
|
||||
mod eval;
|
||||
pub mod expr;
|
||||
pub mod face_anchor;
|
||||
pub use face_anchor::{FaceAnchor, FaceAttachmentStatus};
|
||||
pub mod format;
|
||||
pub mod joints;
|
||||
pub mod motion;
|
||||
@@ -33,7 +35,8 @@ pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_iden
|
||||
pub use document::*;
|
||||
pub use eval::{
|
||||
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,
|
||||
parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles, state_in_frame, state_in_frame_at,
|
||||
through_all_distance, world_state,
|
||||
};
|
||||
pub use params::{ParamRow, ParamUser, cycles};
|
||||
pub use solvecraft_kernel as kernel;
|
||||
@@ -55,5 +58,10 @@ pub enum DocError {
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DocError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod named_projection_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
mod anchor_frame;
|
||||
pub use anchor_frame::state_in_face_anchor_frame;
|
||||
@@ -0,0 +1,50 @@
|
||||
use crate::{Document, ModelBody, ModelState};
|
||||
use solvecraft_geom::{Plane, Vec3};
|
||||
use solvecraft_sketch::{LinkKind, LinkSource, Sketch};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn named_box(name: &str, width: f64, height: f64) -> ModelBody {
|
||||
let body = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(width, width, height)).unwrap();
|
||||
let mut names = vec!["unrelated".to_string(); body.faces(0.01).unwrap().len()];
|
||||
let top = body.faces(0.01).unwrap().into_iter().find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.99)).unwrap().index;
|
||||
names[top] = "shared:top".into();
|
||||
let mut result = ModelBody::new(name.into(), body, 1);
|
||||
result.names = crate::naming::NameCell::known(Arc::new(names));
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_manual_projection_keeps_its_body_and_face_at_shared_boundaries() {
|
||||
let doc = Document::default();
|
||||
let mut initial = ModelState::default();
|
||||
initial.bodies.extend([named_box("Source", 20., 10.), named_box("Control", 5., 10.)]);
|
||||
let plane = Plane::XY.offset(20.);
|
||||
let source = LinkSource::Face { body: "Source".into(), at: Vec3::new(1., 1., 10.) };
|
||||
let resolved = crate::project::resolve(&doc, &BTreeMap::new(), &initial, &plane, LinkKind::Project, &source).unwrap();
|
||||
let mut sketch = Sketch::default();
|
||||
let link = sketch.add_link(LinkKind::Project, source, &resolved.geom).unwrap();
|
||||
sketch.links[0].face_name = Some("shared:top".into());
|
||||
sketch.links[0].source = LinkSource::Face { body: "Source".into(), at: Vec3::new(0., 0., 10.) };
|
||||
let mut current = ModelState::default();
|
||||
current.bodies.extend([named_box("Source", 20., 20.), named_box("Control", 5., 10.)]);
|
||||
assert!(crate::project::refresh_links(&doc, &BTreeMap::new(), ¤t, &plane, &mut sketch).is_empty());
|
||||
assert!(!sketch.links[0].lost);
|
||||
assert!(sketch.points.iter().any(|point| point.pos.x > 19.));
|
||||
assert!(sketch.points.iter().any(|point| point.pos.y > 19.));
|
||||
assert!(matches!(&sketch.links[0].source,LinkSource::Face{body,at} if body=="Source" && (at.z-20.).abs()<1e-6));
|
||||
// The closest named point can sit on the wall/top seam: retain its actual
|
||||
// face index rather than re-picking the adjacent vertical wall.
|
||||
let index = crate::naming::face_on_body(current.body("Source").unwrap(), "shared:top", Vec3::new(0., 0., 10.)).unwrap().0;
|
||||
assert!(current.body("Source").unwrap().body.faces(0.01).unwrap()[index].plane_normal.unwrap().z > 0.99);
|
||||
let saved = sketch.clone();
|
||||
let mut missing = ModelState::default();
|
||||
missing.bodies.push(named_box("Control", 5., 10.));
|
||||
let warnings = crate::project::refresh_links(&doc, &BTreeMap::new(), &missing, &plane, &mut sketch);
|
||||
assert!(sketch.links[0].lost);
|
||||
assert!(!warnings.is_empty());
|
||||
assert_eq!(sketch.curves, saved.curves);
|
||||
assert_eq!(sketch.points, saved.points);
|
||||
assert_eq!(sketch.links[0].id, link);
|
||||
assert!(matches!(&sketch.links[0].source,LinkSource::Face{body,..} if body=="Source"));
|
||||
}
|
||||
@@ -655,6 +655,21 @@ pub fn face_names_at(st: &ModelState, pts: &[Vec3]) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Capture a face name from the recorded projection body only.
|
||||
pub fn face_name_at_body(body: &ModelBody, point: Vec3) -> Option<String> {
|
||||
if body.body.is_mesh() || !point.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let mesh = body.mesh();
|
||||
let count = face_count(body);
|
||||
if mesh.triangles.len() > 2_000_000 || count > 50_000 {
|
||||
return None;
|
||||
}
|
||||
let tolerance = (body.body.size() * 1e-3).max(1e-3);
|
||||
let index = FaceIndex::new(&mesh, count).face_at(point, tolerance)?;
|
||||
face_names(body).get(index).filter(|name| !name.is_empty()).cloned()
|
||||
}
|
||||
|
||||
/// The point on the face with this name nearest `p` (a split face: its nearest piece, with
|
||||
/// `true` for "it was split"); `None` when no face has the name.
|
||||
pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool)> {
|
||||
@@ -679,3 +694,31 @@ pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool
|
||||
}
|
||||
best.map(|(_, q, split)| (q, split))
|
||||
}
|
||||
|
||||
/// Resolve a legacy named face only on its recorded source body. Return the
|
||||
/// selected face index as well as the closest point, which may lie on a shared
|
||||
/// edge and therefore must not be used to pick the face a second time.
|
||||
pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec3, bool)> {
|
||||
if body.body.is_mesh() {
|
||||
return None;
|
||||
}
|
||||
let names = face_names(body);
|
||||
let mesh = body.mesh();
|
||||
let stem = strip_piece(name);
|
||||
let mut best: Option<(f64, usize, Vec3, bool)> = None;
|
||||
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
let index = usize::try_from(*face).ok()?;
|
||||
let Some(candidate) = names.get(index) else { continue };
|
||||
let exact = candidate == name;
|
||||
if !exact && strip_piece(candidate) != stem {
|
||||
continue;
|
||||
}
|
||||
let Some([a, b, c]) = mesh.tri(triangle) else { continue };
|
||||
let q = closest(p, a, b, c);
|
||||
let distance = q.dist(p) - if exact { 1e-9 } else { 0. };
|
||||
if best.is_none_or(|(old, _, _, _)| distance < old) {
|
||||
best = Some((distance, index, q, !exact));
|
||||
}
|
||||
}
|
||||
best.map(|(_, index, q, split)| (index, q, split))
|
||||
}
|
||||
+135
-7
@@ -32,6 +32,9 @@ pub struct Resolved {
|
||||
pub source: LinkSource,
|
||||
/// The reference was not exactly where it was and was re-found on the nearest geometry.
|
||||
pub moved: bool,
|
||||
/// Exact face selected during this resolution, before its closest point is
|
||||
/// projected onto an edge shared with another face. Computed metadata only.
|
||||
pub face_index: Option<usize>,
|
||||
}
|
||||
|
||||
/// Resolutions of body-based references, keyed by the reference, the sketch plane and the
|
||||
@@ -94,6 +97,7 @@ fn resolve_uncached(
|
||||
) -> Result<Resolved> {
|
||||
let lost = |what: &str| DocError::Invalid(format!("{what} no longer exists"));
|
||||
let mut moved = false;
|
||||
let mut face_index = None;
|
||||
let mut source = src.clone();
|
||||
let mut wires: Vec<Vec<Vec3>> = Vec::new();
|
||||
// Included curves that leave the sketch plane stay 3D.
|
||||
@@ -125,6 +129,7 @@ fn resolve_uncached(
|
||||
LinkSource::Face { body, at } => {
|
||||
let (bi, mesh) = body_mesh(st, body, *at)?;
|
||||
let (f, q, d) = nearest_face(&mesh, *at).ok_or_else(|| lost("the face"))?;
|
||||
face_index = Some(usize::try_from(f).map_err(|_| DocError::Invalid("face index exceeds platform limit".into()))?);
|
||||
let size = mesh.bounds().diagonal().max(1e-9);
|
||||
if d > size * 0.25 {
|
||||
return Err(lost("the face"));
|
||||
@@ -267,19 +272,130 @@ fn resolve_uncached(
|
||||
if geom.is_empty() && wires.is_empty() {
|
||||
return Err(DocError::Invalid(format!("the {} projects to nothing on this sketch plane", src.describe())));
|
||||
}
|
||||
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved })
|
||||
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved, face_index })
|
||||
}
|
||||
|
||||
/// Re-resolve every link of a sketch (in place). Returns warnings for lost, moved or rebuilt
|
||||
/// links; lost links keep their last geometry.
|
||||
fn lost(reference: &str) -> DocError {
|
||||
DocError::Unknown(format!("{reference} no longer exists"))
|
||||
}
|
||||
|
||||
fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str, index: usize, at: Vec3) -> Result<Resolved> {
|
||||
let target = st.body(body).ok_or_else(|| lost("named projection body"))?;
|
||||
let mesh = target.mesh();
|
||||
if mesh.triangles.len() > 2_000_000 || mesh.edges.len() > 2_000_000 {
|
||||
return Err(DocError::Invalid("named face projection work limit exceeded".into()));
|
||||
}
|
||||
let face_index = Some(index);
|
||||
let index = u32::try_from(index).map_err(|_| DocError::Invalid("named projection face index exceeds limit".into()))?;
|
||||
let geom = if kind == LinkKind::Intersect {
|
||||
section(plane, &mesh, Some(index))
|
||||
} else {
|
||||
let mut geom = Vec::new();
|
||||
for edge in order::face_edges(&mesh, index, plane)? {
|
||||
if mesh.seams.get(edge).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
if let Some(points) = mesh.edges.get(edge) {
|
||||
geom.extend(fit_world(plane, points));
|
||||
}
|
||||
}
|
||||
merge_split_circles(geom)
|
||||
};
|
||||
if geom.is_empty() {
|
||||
return Err(DocError::Invalid("named face projects to nothing on this sketch plane".into()));
|
||||
}
|
||||
Ok(Resolved { geom, wires: Vec::new(), source: LinkSource::Face { body: body.into(), at }, moved: false, face_index })
|
||||
}
|
||||
|
||||
pub fn resolve_anchored_face(doc: &Document, st: &ModelState, plane: &Plane, kind: LinkKind, anchor: &crate::FaceAnchor) -> Result<Resolved> {
|
||||
let body = st.body(&anchor.target.body).ok_or_else(|| lost("anchored projection body"))?;
|
||||
let indices = doc.configuration_face_indices(body, &anchor.target)?;
|
||||
resolve_face_indices(st, plane, kind, &body.name, &indices)
|
||||
}
|
||||
|
||||
/// Project explicitly selected current face indices, without claiming persistent provenance.
|
||||
/// Callers retaining a link must supply their own replay identity policy.
|
||||
pub fn resolve_face_indices(st: &ModelState, plane: &Plane, kind: LinkKind, body_name: &str, indices: &[usize]) -> Result<Resolved> {
|
||||
let body = st.body(body_name).ok_or_else(|| lost("projection body"))?;
|
||||
let mesh = body.mesh();
|
||||
if mesh.triangles.len() > 2_000_000 || indices.len() > 10_000 {
|
||||
return Err(DocError::Invalid("anchored projection work limit exceeded".into()));
|
||||
}
|
||||
let work = mesh.triangles.len().saturating_add(mesh.edges.len()).saturating_mul(indices.len());
|
||||
if work > 10_000_000 {
|
||||
return Err(DocError::Invalid("anchored projection aggregate work limit exceeded".into()));
|
||||
}
|
||||
let selected: std::collections::BTreeSet<_> = indices.iter().copied().collect();
|
||||
let mut geometry = Vec::new();
|
||||
for index in indices {
|
||||
let index = u32::try_from(*index).map_err(|_| DocError::Invalid("anchored face index exceeds format bounds".into()))?;
|
||||
if kind == LinkKind::Intersect {
|
||||
geometry.extend(section(plane, &mesh, Some(index)));
|
||||
} else {
|
||||
for edge in order::face_edges(&mesh, index, plane)? {
|
||||
if mesh.seams.get(edge).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
if let Some(points) = mesh.edges.get(edge) {
|
||||
geometry.extend(fit_world(plane, points));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let point = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.find_map(|(triangle, face)| selected.contains(&(*face as usize)).then(|| mesh.tri(triangle)).flatten().map(|[a, b, c]| (a + b + c) / 3.0))
|
||||
.ok_or_else(|| lost("anchored projection face"))?;
|
||||
if geometry.is_empty() {
|
||||
return Err(lost("anchored projection boundaries"));
|
||||
}
|
||||
Ok(Resolved {
|
||||
geom: merge_split_circles(geometry),
|
||||
wires: Vec::new(),
|
||||
source: LinkSource::Face { body: body.name.clone(), at: point },
|
||||
moved: false,
|
||||
face_index: if indices.len() == 1 { indices.first().copied() } else { None },
|
||||
})
|
||||
}
|
||||
|
||||
pub fn refresh_face_anchor_links(
|
||||
doc: &Document,
|
||||
vals: &BTreeMap<String, Value>,
|
||||
st: &ModelState,
|
||||
plane: &Plane,
|
||||
sk: &mut Sketch,
|
||||
anchor: &crate::FaceAnchor,
|
||||
) -> Vec<String> {
|
||||
refresh_links_in(doc, vals, st, plane, sk, Some(anchor))
|
||||
}
|
||||
|
||||
pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, plane: &Plane, sk: &mut Sketch) -> Vec<String> {
|
||||
refresh_links_in(doc, vals, st, plane, sk, None)
|
||||
}
|
||||
|
||||
fn refresh_links_in(
|
||||
doc: &Document,
|
||||
vals: &BTreeMap<String, Value>,
|
||||
st: &ModelState,
|
||||
plane: &Plane,
|
||||
sk: &mut Sketch,
|
||||
anchor: Option<&crate::FaceAnchor>,
|
||||
) -> Vec<String> {
|
||||
let mut warn = Vec::new();
|
||||
let links = sk.links.clone();
|
||||
for l in &links {
|
||||
let mut source = l.source.clone();
|
||||
if let (Some(name), LinkSource::Face { at, .. }) = (&l.face_name, &mut source) {
|
||||
if let Some((p, split)) = crate::naming::point_on_face(st, name, *at) {
|
||||
let mut named_face = None;
|
||||
if !l.auto_face
|
||||
&& let (Some(name), LinkSource::Face { body, at }) = (&l.face_name, &mut source)
|
||||
{
|
||||
if let Some((index, p, split)) = st.body(body).and_then(|target| crate::naming::face_on_body(target, name, *at)) {
|
||||
*at = p;
|
||||
named_face = Some((body.clone(), index, p));
|
||||
if split {
|
||||
warn.push(format!("projected face `{name}` was split; using the nearest piece"));
|
||||
}
|
||||
@@ -289,7 +405,19 @@ pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelS
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match resolve(doc, vals, st, plane, l.kind, &source) {
|
||||
let resolved = if l.auto_face {
|
||||
anchor
|
||||
.filter(|anchor| {
|
||||
l.face_name.as_ref() == Some(&anchor.target.name) && matches!(&l.source,LinkSource::Face {body,..} if *body==anchor.target.body)
|
||||
})
|
||||
.ok_or_else(|| lost("exact auto-projected attachment"))
|
||||
.and_then(|anchor| resolve_anchored_face(doc, st, plane, l.kind, anchor))
|
||||
} else if let Some((body, index, at)) = named_face {
|
||||
resolve_named_face(st, plane, l.kind, &body, index, at)
|
||||
} else {
|
||||
resolve(doc, vals, st, plane, l.kind, &source)
|
||||
};
|
||||
match resolved {
|
||||
Ok(r) => {
|
||||
sk.set_link_source(&l.id, r.source);
|
||||
let _ = sk.set_link_wires(&l.id, &r.wires);
|
||||
@@ -442,9 +570,9 @@ fn project_sketch_curve(sk: &Sketch, from: &Plane, ci: usize, to: &Plane) -> Vec
|
||||
(Some(c), Some(m)) => vec![LinkGeom::Ellipse { c: map(c), major: map(m), minor: *r }],
|
||||
_ => Vec::new(),
|
||||
},
|
||||
Some(CurveKind::Spline { pts, control, degree }) if parallel => {
|
||||
Some(CurveKind::Spline { pts, control, degree, periodic }) if parallel => {
|
||||
let p: Option<Vec<Vec2>> = pts.iter().map(|q| sk.point(*q).map(map)).collect();
|
||||
p.map(|pts| vec![LinkGeom::Spline { pts, control: *control, degree: *degree }]).unwrap_or_default()
|
||||
p.map(|pts| vec![LinkGeom::Spline { pts, control: *control, degree: *degree, periodic: *periodic }]).unwrap_or_default()
|
||||
}
|
||||
Some(CurveKind::Conic { a, b, apex, rho }) if parallel => match (sk.point(*a), sk.point(*apex), sk.point(*b)) {
|
||||
(Some(a), Some(x), Some(b)) => vec![LinkGeom::Conic { a: map(a), apex: map(x), b: map(b), rho: *rho }],
|
||||
@@ -560,7 +688,7 @@ fn as_spline(pts: &[Vec2], tol: f64) -> Option<LinkGeom> {
|
||||
<= tol
|
||||
});
|
||||
if ok {
|
||||
return Some(LinkGeom::Spline { pts: fit, control: false, degree: 3 });
|
||||
return Some(LinkGeom::Spline { pts: fit, control: false, degree: 3, periodic: false });
|
||||
}
|
||||
}
|
||||
None
|
||||
|
||||
@@ -456,7 +456,7 @@ fn link_polyline(g: &solvecraft_sketch::LinkGeom) -> Vec<Vec2> {
|
||||
LinkGeom::Ellipse { c, major, minor } => {
|
||||
(0..=96).map(|i| solvecraft_sketch::ellipse_point(c, major, minor, i as f64 / 96.0 * std::f64::consts::TAU)).collect()
|
||||
}
|
||||
LinkGeom::Spline { ref pts, control, degree } => solvecraft_sketch::spline_polyline(pts, control, degree),
|
||||
LinkGeom::Spline { ref pts, control, degree, periodic } => solvecraft_sketch::spline_polyline_mode(pts, control, degree, periodic),
|
||||
LinkGeom::Point(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,7 @@ fn errors_are_reported_not_fatal() {
|
||||
targets: vec![],
|
||||
angle2: None,
|
||||
symmetric: false,
|
||||
convention: Default::default(),
|
||||
to: None,
|
||||
},
|
||||
None,
|
||||
|
||||
@@ -16,7 +16,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("body.rename", "Rename Body", rename_body).params("body: its name, name: the new name (features that use the body follow)"),
|
||||
CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", redefine_sketch)
|
||||
.icon("plane")
|
||||
.params("sketch: id or name; plane: XY|XZ|YZ | construction plane name | {face: [x,y,z]} | {origin, normal} (the sketch keeps its geometry)"),
|
||||
.params("sketch: id or name; plane: as sketch.create; author_path?: exact owner occurrence ids; explicitly rebinds or freezes the plane while preserving geometry and dimension names"),
|
||||
CommandSpec::new("browser.group", "New Group", group)
|
||||
.icon("folder")
|
||||
.params("folder: bodies|sketches|construction; component?: id (default 0); name?; items?: [keys] (they leave other groups)"),
|
||||
@@ -461,6 +461,19 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Later features refer to bodies by name.
|
||||
let mut failed = None;
|
||||
for f in d.features.iter_mut().filter(|f| f.id != fid) {
|
||||
if let FeatureKind::Sketch { plane, sketch } = &mut f.kind
|
||||
&& let Some(anchor) = super::sketch::face::anchor_mut(plane)
|
||||
&& anchor.target.body == old
|
||||
{
|
||||
let ids = std::collections::BTreeMap::from([(anchor.target.origin_feature, anchor.target.origin_feature)]);
|
||||
let bodies = std::collections::BTreeMap::from([(old.clone(), name.to_string())]);
|
||||
anchor.remap_target(&ids, &bodies)?;
|
||||
for link in &mut sketch.links {
|
||||
if link.auto_face && matches!(&link.source,solvecraft_sketch::LinkSource::Face {body,..} if *body==old) {
|
||||
link.face_name = Some(anchor.target.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let Ok(mut v) = serde_json::to_value(&f.kind) else { continue };
|
||||
if replace_str(&mut v, &old, name, 0) {
|
||||
match serde_json::from_value(v) {
|
||||
@@ -519,13 +532,89 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("plane").is_none() {
|
||||
return Err(bad(cmd, "`plane` is required"));
|
||||
}
|
||||
let plane = super::sketch::plane_ref(s, p, cmd)?;
|
||||
let (vals, _) = s.doc.param_values();
|
||||
s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?;
|
||||
if let Some(FeatureKind::Sketch { plane: slot, .. }) = s.doc_mut().feature_mut(id).map(|f| &mut f.kind) {
|
||||
let feature = s.doc.feature(id).ok_or_else(|| bad(cmd, "sketch disappeared"))?;
|
||||
let component = feature.component;
|
||||
let index = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "sketch disappeared"))?;
|
||||
let author_path = super::sketch::author_path_for_rebind(s, feature, p, cmd)?;
|
||||
let marker = s.doc.marker;
|
||||
let mut baseline = s.scratch();
|
||||
baseline.doc_mut().marker = Some(index + 1);
|
||||
baseline.refresh();
|
||||
let visible_geometry = baseline.model.state().sketch(id).map(|resolved| resolved.sketch.clone());
|
||||
let mut trial = s.scratch();
|
||||
trial.active_component = component;
|
||||
trial.active_occurrence_path = (!author_path.is_empty()).then_some(author_path);
|
||||
// A new attachment may only depend on geometry preceding this sketch.
|
||||
trial.doc_mut().marker = Some(index);
|
||||
trial.refresh();
|
||||
let old_anchor = trial
|
||||
.doc
|
||||
.feature(id)
|
||||
.and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
|
||||
_ => None,
|
||||
})
|
||||
.cloned();
|
||||
let plane = super::sketch::plane_ref(&trial, p, cmd)?;
|
||||
if super::sketch::face::is_face(p) {
|
||||
super::sketch::face::capture_positions(&mut trial, &plane)?;
|
||||
}
|
||||
if let Some(anchor) = old_anchor
|
||||
&& super::sketch::face::anchor(&plane)
|
||||
.is_none_or(|next| next.target != anchor.target || next.component != anchor.component || next.path != anchor.path)
|
||||
{
|
||||
let sketch = trial.doc_mut().sketch_mut(id)?;
|
||||
if let Some(mut visible) = visible_geometry {
|
||||
let constraints: std::collections::BTreeMap<_, _> =
|
||||
sketch.constraints.iter().map(|constraint| (constraint.id.clone(), constraint.clone())).collect();
|
||||
for constraint in &mut visible.constraints {
|
||||
if let Some(original) = constraints.get(&constraint.id) {
|
||||
if std::mem::discriminant(&constraint.kind) != std::mem::discriminant(&original.kind) {
|
||||
return Err(bad(cmd, "a dimension role changed during border refresh; repair it explicitly before rebinding"));
|
||||
}
|
||||
// Keep evaluated entity indices after linked topology rebuilds.
|
||||
constraint.param = original.param.clone();
|
||||
constraint.driven = original.driven;
|
||||
constraint.text = original.text;
|
||||
if let Some(value) = original.kind.value() {
|
||||
constraint.kind.set_value(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
*sketch = visible;
|
||||
}
|
||||
let affected: Vec<_> = sketch
|
||||
.links
|
||||
.iter()
|
||||
.filter(|link| {
|
||||
link.auto_face
|
||||
&& link.face_name.as_ref() == Some(&anchor.target.name)
|
||||
&& matches!(&link.source,solvecraft_sketch::LinkSource::Face {body,..} if *body==anchor.target.body)
|
||||
})
|
||||
.map(|link| link.id.clone())
|
||||
.collect();
|
||||
for link in affected {
|
||||
sketch.break_link(&link)?;
|
||||
}
|
||||
}
|
||||
if let Some(FeatureKind::Sketch { plane: slot, .. }) = trial.doc_mut().feature_mut(id).map(|feature| &mut feature.kind) {
|
||||
*slot = plane;
|
||||
}
|
||||
Ok(json!({ "sketch": id }))
|
||||
trial.doc_mut().marker = Some(index + 1);
|
||||
trial.refresh();
|
||||
if let Some(error) = trial.model.result(id).and_then(|result| result.error.as_ref()) {
|
||||
return Err(bad(cmd, error.clone()));
|
||||
}
|
||||
trial.doc_mut().marker = marker;
|
||||
trial.refresh();
|
||||
s.doc = trial.doc;
|
||||
s.pending_moves = trial.pending_moves;
|
||||
|
||||
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() { "exact_face" } else { "fixed" }),
|
||||
_ => None,
|
||||
});
|
||||
Ok(json!({ "sketch": id,"attachment":attachment }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -62,11 +62,68 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let feats = s.clipboard.clone();
|
||||
let comp = s.active_component;
|
||||
let mut sketches: BTreeMap<u64, u64> = BTreeMap::new();
|
||||
let mut bodies: BTreeMap<String, String> = BTreeMap::new();
|
||||
let mut used: std::collections::BTreeSet<String> = s.model.state().bodies.iter().map(|body| body.name.clone()).collect();
|
||||
let mut notices = Vec::new();
|
||||
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(comp));
|
||||
solvecraft_doc::EmbossFace::component_frame(&s.placed_document(), comp, &author_path)?;
|
||||
let mut names: BTreeMap<String, String> = BTreeMap::new();
|
||||
let mut made = Vec::new();
|
||||
for f in feats {
|
||||
let mut kind = f.kind.clone();
|
||||
kind.remap_refs(&sketches, &names);
|
||||
if let FeatureKind::Sketch { plane, .. } = &mut kind
|
||||
&& let Some(anchor) = super::sketch::face::anchor_mut(plane)
|
||||
{
|
||||
let internal = bodies.contains_key(&anchor.target.body);
|
||||
if !internal && translate.is_some_and(|translate| translate.len() > 1e-12) {
|
||||
return Err(bad(cmd, "translated paste retains an external exact face attachment; explicitly convert it to a fixed plane first"));
|
||||
}
|
||||
if !internal {
|
||||
notices.push(format!("pasted sketch retains external exact face attachment to {}", anchor.target.body));
|
||||
}
|
||||
anchor.remap_target(&sketches, &bodies)?;
|
||||
if internal {
|
||||
anchor.component = comp;
|
||||
anchor.path = author_path.clone();
|
||||
}
|
||||
anchor.author_path = author_path.clone();
|
||||
let target = anchor.target.clone();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &mut kind {
|
||||
for link in &mut sketch.links {
|
||||
if link.auto_face {
|
||||
if let solvecraft_sketch::LinkSource::Face { body, .. } = &mut link.source
|
||||
&& let Some(mapped) = bodies.get(body)
|
||||
{
|
||||
*body = mapped.clone();
|
||||
}
|
||||
link.face_name = Some(target.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let outputs = if f.body_names.is_empty() {
|
||||
s.model
|
||||
.result(f.id)
|
||||
.map(|result| result.state.bodies.iter().filter(|body| body.feature == f.id).map(|body| body.name.clone()).collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
f.body_names.clone()
|
||||
};
|
||||
let copied_names: Vec<_> = outputs
|
||||
.iter()
|
||||
.map(|original| {
|
||||
let base = format!("{original} (copy)");
|
||||
let mut name = base.clone();
|
||||
let mut suffix = 2usize;
|
||||
while !used.insert(name.clone()) {
|
||||
name = format!("{base} {suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
bodies.insert(original.clone(), name.clone());
|
||||
name
|
||||
})
|
||||
.collect();
|
||||
let doc = s.doc_mut();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &mut kind {
|
||||
// Each dimension of the copy gets its own parameter, with the same expression.
|
||||
@@ -90,12 +147,10 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = doc.add_feature(kind, None)?;
|
||||
if let Some(nf) = doc.feature_mut(id) {
|
||||
nf.component = comp;
|
||||
nf.body_names = f.body_names.iter().map(|b| format!("{b} (copy)")).collect();
|
||||
nf.body_names = copied_names;
|
||||
}
|
||||
let new_name = doc.feature(id).map(|x| x.name.clone()).unwrap_or_default();
|
||||
if matches!(f.kind, FeatureKind::Sketch { .. }) {
|
||||
sketches.insert(f.id, id);
|
||||
}
|
||||
sketches.insert(f.id, id);
|
||||
names.insert(f.name.clone(), new_name.clone());
|
||||
made.push(json!({"id": id, "name": new_name, "from": f.name}));
|
||||
}
|
||||
@@ -105,7 +160,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.filter_map(|m| m["id"].as_u64())
|
||||
.filter_map(|id| s.model.result(id).and_then(|r| r.error.clone()).map(|e| json!({"feature": id, "error": e})))
|
||||
.collect();
|
||||
Ok(json!({"pasted": made, "errors": errors}))
|
||||
Ok(json!({"pasted": made, "errors": errors, "notices":notices}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::params::{bad, bool_, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[path = "component_properties.rs"]
|
||||
mod properties;
|
||||
pub(super) mod properties;
|
||||
#[path = "component_transfer.rs"]
|
||||
mod transfer;
|
||||
|
||||
|
||||
@@ -346,6 +346,16 @@ pub(super) fn paste(
|
||||
{
|
||||
face.author_path = cloned_path(&source, &face.author_path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
if let (FeatureKind::Sketch { plane, .. }, FeatureKind::Sketch { plane: old_plane, .. }) = (&mut f.kind, &old.kind)
|
||||
&& let (Some(anchor), Some(original)) = (super::sketch::face::anchor_mut(plane), super::sketch::face::anchor(old_plane))
|
||||
{
|
||||
if bodies.contains_key(&original.target.body) {
|
||||
anchor.component =
|
||||
*components.get(&original.component).ok_or_else(|| bad(CMD, "anchored source component is outside copied graph"))?;
|
||||
anchor.path = cloned_path(&source, &original.path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
anchor.author_path = cloned_path(&source, &original.author_path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
if let Some(slot) = scratch.doc_mut().feature_mut(f.id) {
|
||||
*slot = f;
|
||||
}
|
||||
@@ -473,6 +483,15 @@ pub(super) fn paste(
|
||||
}
|
||||
}
|
||||
}
|
||||
if let FeatureKind::Sketch { plane, .. } = &feature.kind
|
||||
&& let Some(anchor) = super::sketch::face::anchor(plane)
|
||||
&& !bodies.contains_key(&anchor.target.body)
|
||||
{
|
||||
notices.push(format!(
|
||||
"{} retains external exact face attachment to {} (producer {})",
|
||||
feature.name, anchor.target.body, anchor.target.origin_feature
|
||||
));
|
||||
}
|
||||
for id in feature.kind.input_sketches() {
|
||||
if !features.contains_key(&id) {
|
||||
notices.push(format!("{} retains an external sketch reference to {id}", feature.name));
|
||||
|
||||
@@ -26,11 +26,15 @@ 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.
|
||||
pub(super) fn placements(doc: &Document, cmd: &str) -> Result<Vec<(u64, Vec<u64>, Mat)>> {
|
||||
pub(in crate::cmd) 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)];
|
||||
let mut children: std::collections::BTreeMap<u64, Vec<_>> = std::collections::BTreeMap::new();
|
||||
for occurrence in &doc.occurrences {
|
||||
children.entry(occurrence.parent).or_default().push(occurrence);
|
||||
}
|
||||
while let Some((component, path, transform)) = stack.pop() {
|
||||
for occurrence in doc.occurrences.iter().filter(|o| o.parent == component) {
|
||||
for occurrence in children.get(&component).into_iter().flatten() {
|
||||
if path.len() >= 64 || out.len() >= 100_000 {
|
||||
return Err(bad(cmd, "assembly exceeds physical-properties traversal limits (64 levels or 100000 placements)"));
|
||||
}
|
||||
|
||||
@@ -352,6 +352,12 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut params = p.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let idx = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let old = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?;
|
||||
if let FeatureKind::Revolve { convention, .. } = &old.kind
|
||||
&& cid == "solid.revolve"
|
||||
&& let Some(parameters) = params.as_object_mut()
|
||||
{
|
||||
parameters.entry("convention").or_insert_with(|| json!(convention));
|
||||
}
|
||||
if !old.participants.is_empty()
|
||||
&& let Some(parameters) = params.as_object_mut()
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! edges become lines, circular edges circles or arcs, anything else a chain of lines.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{EmbossFace, apply_point, mat_inverse, mat_mul};
|
||||
use solvecraft_geom::{Mesh, Vec2, Vec3};
|
||||
|
||||
use crate::params::bad;
|
||||
@@ -19,7 +20,7 @@ fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
|
||||
|
||||
/// The outward normal of the planar body face at `p`.
|
||||
pub(super) fn face_normal(s: &Session, p: Vec3) -> Option<Vec3> {
|
||||
let (m, f, d) = face_at(s, p)?;
|
||||
let (m, f, d, _) = face_at(s, p)?;
|
||||
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
|
||||
if d > tol * 10.0 {
|
||||
return None;
|
||||
@@ -34,7 +35,7 @@ pub(super) fn face_normal(s: &Session, p: Vec3) -> Option<Vec3> {
|
||||
|
||||
/// Is the body face at `p` planar (`None`: no face there)?
|
||||
pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option<bool> {
|
||||
let (m, f, d) = face_at(s, p)?;
|
||||
let (m, f, d, _) = face_at(s, p)?;
|
||||
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
|
||||
if d > tol * 10.0 {
|
||||
return None;
|
||||
@@ -52,9 +53,10 @@ pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option<bool> {
|
||||
}
|
||||
|
||||
/// The body face under a point: (mesh, face index, distance).
|
||||
fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64)> {
|
||||
fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64, String)> {
|
||||
let st = s.model.state();
|
||||
let mut best: Option<(std::sync::Arc<Mesh>, u32, f64)> = None;
|
||||
let mut best: Option<(std::sync::Arc<Mesh>, u32, f64, String)> = None;
|
||||
let mut ambiguous = false;
|
||||
for b in &st.bodies {
|
||||
let m = b.mesh();
|
||||
let mut near: Option<(u32, f64)> = None;
|
||||
@@ -65,13 +67,16 @@ fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64)> {
|
||||
near = Some((*f, d));
|
||||
}
|
||||
}
|
||||
if let Some((f, d)) = near
|
||||
&& best.as_ref().is_none_or(|x| d < x.2)
|
||||
{
|
||||
best = Some((m.clone(), f, d));
|
||||
if let Some((f, d)) = near {
|
||||
if best.as_ref().is_none_or(|x| d < x.2 - 1e-8) {
|
||||
best = Some((m.clone(), f, d, b.name.clone()));
|
||||
ambiguous = false;
|
||||
} else if best.as_ref().is_some_and(|x| (d - x.2).abs() <= 1e-8) {
|
||||
ambiguous = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
if ambiguous { None } else { best }
|
||||
}
|
||||
|
||||
/// Circle through three points.
|
||||
@@ -93,7 +98,7 @@ fn xy(v: Vec2) -> Value {
|
||||
/// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns
|
||||
/// the sketch id and a point inside the face (sketch coordinates). The sketch is left active.
|
||||
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<(u64, Vec2)> {
|
||||
let (m, f, d) = face_at(s, point).ok_or_else(|| bad(cmd, "no body face at `face`"))?;
|
||||
let (m, f, d, body) = face_at(s, point).ok_or_else(|| bad(cmd, "no body face at `face`"))?;
|
||||
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
|
||||
if d > tol * 10.0 {
|
||||
return Err(bad(cmd, "no body face at `face`"));
|
||||
@@ -118,14 +123,45 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<
|
||||
let spec = crate::find_command(id).ok_or_else(|| bad(cmd, id))?;
|
||||
(spec.run)(s, &p)
|
||||
};
|
||||
let r = run(s, "sketch.create", json!({"plane": {"face": [inside.x, inside.y, inside.z]}, "project_edges": true}))?;
|
||||
// Command frame normalization already made `point` definition-local. Capture takes
|
||||
// world coordinates; convert exactly once using the picked body's actual occurrence.
|
||||
let state = s.model.state();
|
||||
let doc = s.placed_document();
|
||||
let picked = state.body(&body).ok_or_else(|| bad(cmd, "picked body disappeared"))?;
|
||||
let component = doc.model_body_component(picked);
|
||||
let path = if component == s.active_component {
|
||||
s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(component))
|
||||
} else {
|
||||
// The command mapper discarded the original placement when making this pick
|
||||
// definition-local. A repeated cross-owner source cannot be re-found safely.
|
||||
let placements = super::component::properties::placements(&doc, cmd)?;
|
||||
if placements.iter().filter(|(owner, _, _)| *owner == component).count() != 1 {
|
||||
return Err(bad(cmd, "activate the picked occurrence before generating a face profile from a repeated component"));
|
||||
}
|
||||
doc.first_path(component)
|
||||
};
|
||||
let placement = EmbossFace::component_frame(&doc, component, &path)?;
|
||||
let placement = doc.model_body_offset(picked).map(|offset| mat_mul(&placement, offset)).unwrap_or(placement);
|
||||
let world_inside = apply_point(&placement, inside);
|
||||
let world_body = doc.world_body_name(&state, &body, component, &path);
|
||||
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(s.active_component));
|
||||
let author = EmbossFace::component_frame(&doc, s.active_component, &author_path)?;
|
||||
let author_inverse = mat_inverse(&author).ok_or_else(|| bad(cmd, "invalid face profile author frame"))?;
|
||||
let author_inside = apply_point(&author_inverse, world_inside);
|
||||
drop(doc);
|
||||
let r = super::sketch::create_face_profile(
|
||||
s,
|
||||
&json!({"plane": {"face": [world_inside.x, world_inside.y, world_inside.z], "body": world_body, "index": f, "path":path}, "project_edges": true}),
|
||||
&body,
|
||||
f as usize,
|
||||
)?;
|
||||
let sid = r.get("sketch").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?;
|
||||
s.refresh();
|
||||
let plane = s.model.state().sketch(sid).map(|ss| ss.plane).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?;
|
||||
// The face's edges came in as linked projections: they follow the face when earlier
|
||||
// features change, so nothing is copied.
|
||||
if r.get("projected").is_some_and(|x| !x.is_null()) {
|
||||
return Ok((sid, plane.to_local(inside)));
|
||||
return Ok((sid, plane.to_local(author_inside)));
|
||||
}
|
||||
for e in &edges {
|
||||
let pts: Vec<Vec2> = e.iter().map(|p| plane.to_local(*p)).collect();
|
||||
@@ -163,5 +199,9 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok((sid, plane.to_local(inside)))
|
||||
Ok((sid, plane.to_local(author_inside)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "face_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,187 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{Document, FeatureKind, PlaneRef};
|
||||
use solvecraft_geom::Vec3;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}"))
|
||||
}
|
||||
fn check(s: &mut Session, want: f64, hole: bool, height: f64) {
|
||||
s.refresh();
|
||||
assert!(s.model.results.iter().all(|result| result.error.is_none()));
|
||||
let state = s.model.state();
|
||||
let got: f64 = state.bodies.iter().map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).sum();
|
||||
// Legacy linked outlines fit tessellated curves; enforce the existing 1e-4 material envelope.
|
||||
assert!((got - want).abs() < want * 1e-4, "{got} vs {want}");
|
||||
assert!(state.bodies.iter().any(|b| b.body.contains(Vec3::new(5.0, 5.0, height - 1.0))));
|
||||
assert!(!state.bodies.iter().any(|b| b.body.contains(Vec3::new(5.0, 5.0, height + 1.0))));
|
||||
if hole {
|
||||
assert!(!state.bodies.iter().any(|b| b.body.contains(Vec3::new(20.0, 15.0, height - 1.0))));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn generated_face_profiles_keep_certified_and_internal_legacy_links_live_after_edit_and_reopen() {
|
||||
for hole in [false, true] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"name":"Base"}));
|
||||
if hole {
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[20,15],"radius":5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"through_all":true,"operation":"cut"}));
|
||||
}
|
||||
let result = run(&mut s, "solid.extrude", json!({"face":[5,5,20],"distance":10,"operation":"new","name":"Pad"}));
|
||||
let pad = s.doc.feature(result["feature"].as_u64().unwrap()).unwrap();
|
||||
let FeatureKind::Extrude { sketch, .. } = pad.kind else { panic!("extrude") };
|
||||
let generated = s.doc.feature(sketch).unwrap();
|
||||
let FeatureKind::Sketch { plane, sketch: profile } = &generated.kind else { panic!("sketch") };
|
||||
assert!(matches!(plane,PlaneRef::Face{anchor,..} if anchor.is_some()!=hole));
|
||||
assert!(!profile.links.is_empty());
|
||||
assert!(profile.links.iter().all(|link| link.auto_face != hole && !link.lost));
|
||||
let area = 1200.0 - if hole { 25.0 * PI } else { 0.0 };
|
||||
check(&mut s, area * 30.0, hole, 30.0);
|
||||
run(&mut s, "timeline.edit", json!({"feature":"Base","set":{"height":"25","length":"50"}}));
|
||||
let area = 1500.0 - if hole { 25.0 * PI } else { 0.0 };
|
||||
check(&mut s, area * 35.0, hole, 35.0);
|
||||
let edited = s.model.state();
|
||||
assert!(edited.bodies.iter().any(|body| body.body.contains(Vec3::new(49.0, 5.0, 34.0))));
|
||||
assert!(!edited.bodies.iter().any(|body| body.body.contains(Vec3::new(51.0, 5.0, 34.0))));
|
||||
assert!(edited.sketch(sketch).unwrap().sketch.links.iter().all(|link| !link.lost));
|
||||
let solved = edited.sketch(sketch).unwrap().plane;
|
||||
assert!((solved.origin.z - 25.0).abs() < 1e-6);
|
||||
let before = s.model.state();
|
||||
let before_volume: f64 = before.bodies.iter().map(|body| solvecraft_kernel::measure(&body.body).unwrap().volume).sum();
|
||||
let before_bounds: Vec<_> = before.bodies.iter().map(|body| (body.name.clone(), body.mesh().bounds())).collect();
|
||||
let mut reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
check(&mut reopened, area * 35.0, hole, 35.0);
|
||||
let after = reopened.model.state();
|
||||
let after_volume: f64 = after.bodies.iter().map(|body| solvecraft_kernel::measure(&body.body).unwrap().volume).sum();
|
||||
assert!((before_volume - after_volume).abs() < 1e-6);
|
||||
for (name, bounds) in before_bounds {
|
||||
let cold = after.body(&name).unwrap().mesh().bounds();
|
||||
assert!(bounds.min.dist(cold.min) < 1e-9 && bounds.max.dist(cold.max) < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn internal_capture_does_not_bypass_missing_ambiguous_or_invalid_path_picks() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"A"}));
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"B"}));
|
||||
let before = s.doc.to_json();
|
||||
assert!(s.execute("solid.extrude", &json!({"face":[5,5,20],"distance":10,"operation":"new"})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
for pick in [json!({"face":[5,5,20]}), json!({"face":[500,5,20]}), json!({"face":[5,5,20],"body":"A","index":0,"path":[999999]})] {
|
||||
assert!(super::super::sketch::face::capture_internal(&s, &pick, "internal").is_err(), "{pick}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_profiles_and_press_pull_convert_rotated_occurrence_picks_once() {
|
||||
for hole in [false, true] {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Arm"}));
|
||||
let cid = component["component"].as_u64().unwrap();
|
||||
let occ = component["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"name":"Base","body_name":"Stock"}));
|
||||
if hole {
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[20,15],"radius":5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"through_all":true,"operation":"cut"}));
|
||||
}
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occ,"translate":[30,20,10],"axis":[1,0,0],"angle":"90 deg","capture":true}));
|
||||
run(&mut s, "component.activate", json!({"component":cid,"occurrence":occ}));
|
||||
run(&mut s, "solid.extrude", json!({"face":[35,0,15],"distance":10,"operation":"join"}));
|
||||
let area = 1200.0 - if hole { 25.0 * PI } else { 0.0 };
|
||||
check(&mut s, area * 30.0, hole, 30.0);
|
||||
run(&mut s, "solid.press_pull", json!({"face":[35,-10,15],"distance":5}));
|
||||
check(&mut s, area * 35.0, hole, 35.0);
|
||||
let height = if hole {
|
||||
// Curved coincident Cut is an existing kernel limit; it must be atomic.
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("solid.press_pull", &json!({"face":[35,-15,15],"distance":-5})).unwrap_err();
|
||||
assert!(error.to_string().contains("coincident faces on curved bodies"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
check(&mut s, area * 35.0, hole, 35.0);
|
||||
35.0
|
||||
} else {
|
||||
run(&mut s, "solid.press_pull", json!({"face":[35,-15,15],"distance":-5}));
|
||||
check(&mut s, area * 30.0, hole, 30.0);
|
||||
30.0
|
||||
};
|
||||
let world = s.world_state();
|
||||
let occupied = Vec3::new(35.0, 21.0 - height, 15.0);
|
||||
let absent = Vec3::new(35.0, 19.0 - height, 15.0);
|
||||
assert!(world.bodies.iter().any(|body| body.body.contains(occupied)));
|
||||
assert!(!world.bodies.iter().any(|body| body.body.contains(absent)));
|
||||
if hole {
|
||||
assert!(!world.bodies.iter().any(|body| body.body.contains(Vec3::new(50.0, 21.0 - height, 25.0))));
|
||||
}
|
||||
let before_volume: f64 = world.bodies.iter().map(|body| solvecraft_kernel::measure(&body.body).unwrap().volume).sum();
|
||||
let before_bounds: Vec<_> = world.bodies.iter().map(|body| (body.name.clone(), body.mesh().bounds())).collect();
|
||||
let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
let after = reopened.world_state();
|
||||
assert!(after.bodies.iter().any(|body| body.body.contains(occupied)));
|
||||
let v: f64 = after.bodies.iter().map(|body| solvecraft_kernel::measure(&body.body).unwrap().volume).sum();
|
||||
assert!((v - area * height).abs() < area * height * 1e-4);
|
||||
assert!((v - before_volume).abs() < 1e-6);
|
||||
for (name, bounds) in before_bounds {
|
||||
let cold = after.body(&name).unwrap().mesh().bounds();
|
||||
assert!(bounds.min.dist(cold.min) < 1e-9 && bounds.max.dist(cold.max) < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_internal_capture_refuses_an_uncaptured_source_in_another_component() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Source"}));
|
||||
let occurrence = component["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Stock"}));
|
||||
run(&mut s, "solid.press_pull", json!({"face":[5,5,20],"distance":5}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":false}));
|
||||
let state = s.model.state();
|
||||
let index = state
|
||||
.body("Stock")
|
||||
.unwrap()
|
||||
.body
|
||||
.faces(1e-4)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 25.0).abs() < 1e-6)
|
||||
.unwrap()
|
||||
.index;
|
||||
let before = s.doc.to_json();
|
||||
let error =
|
||||
super::super::sketch::face::capture_internal(&s, &json!({"face":[35,5,25],"body":"Stock","index":index,"path":[occurrence]}), "internal")
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("capture the source occurrence position"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_source_face_profile_requires_and_uses_the_exact_active_occurrence() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Repeated"}));
|
||||
let cid = component["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let copied = run(&mut s, "occurrence.copy", json!({"component":cid,"translate":[50,0,0]}));
|
||||
let occurrence = copied["occurrence"].as_u64().unwrap();
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("solid.extrude", &json!({"face":[55,5,10],"distance":5,"operation":"new"})).unwrap_err();
|
||||
assert!(error.to_string().contains("activate the picked occurrence"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "component.activate", json!({"component":cid,"occurrence":occurrence}));
|
||||
run(&mut s, "solid.extrude", json!({"face":[55,5,10],"distance":5,"operation":"new"}));
|
||||
let state = s.world_state();
|
||||
for x in [5.0, 55.0] {
|
||||
assert!(state.bodies.iter().any(|body| body.body.contains(Vec3::new(x, 5.0, 14.0))));
|
||||
assert!(!state.bodies.iter().any(|body| body.body.contains(Vec3::new(x, 5.0, 16.0))));
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"),
|
||||
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.sweep", "Sweep", sweep)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sweep")
|
||||
@@ -482,6 +482,10 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let symmetric =
|
||||
matches!(str_(p, "direction").map(str::to_ascii_lowercase).as_deref(), Some("symmetric")) || bool_(p, "symmetric").unwrap_or(false);
|
||||
let convention = match p.get("convention") {
|
||||
None => solvecraft_doc::RevolveConvention::SketchNormal,
|
||||
Some(value) => serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "`convention` must be sketch_normal or legacy_axis_start"))?,
|
||||
};
|
||||
let kind = FeatureKind::Revolve {
|
||||
sketch,
|
||||
profiles,
|
||||
@@ -491,6 +495,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
targets: string_list(p, "targets"),
|
||||
angle2,
|
||||
symmetric,
|
||||
convention,
|
||||
to,
|
||||
};
|
||||
add_feature(s, p, kind)
|
||||
@@ -1342,3 +1347,7 @@ fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
#[path = "patterns_tests.rs"]
|
||||
mod patterns_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "revolve_convention_tests.rs"]
|
||||
mod revolve_convention_tests;
|
||||
@@ -119,7 +119,7 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
|
||||
CurveKind::Circle { c: cc, r } => json!({"id": c.id, "type": "circle", "center": pid(cc), "center_at": sk.point(cc), "radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Arc { c: cc, a, b } => json!({"id": c.id, "type": "arc", "center": pid(cc), "start": pid(a), "end": pid(b), "center_at": sk.point(cc), "start_at": sk.point(a), "end_at": sk.point(b), "radius": sk.radius(i), "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Ellipse { c: cc, m, r } => json!({"id": c.id, "type": "ellipse", "center": pid(cc), "major": pid(m), "center_at": sk.point(cc), "major_at": sk.point(m), "minor_radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Spline { ref pts, control, degree } => json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::<Vec<_>>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::<Vec<_>>(), "control": control, "degree": degree, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Spline { ref pts, control, degree, periodic } => json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::<Vec<_>>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::<Vec<_>>(), "control": control, "degree": degree, "periodic": periodic, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Conic { a, b, apex, rho } => json!({"id": c.id, "type": "conic", "start": pid(a), "end": pid(b), "apex": pid(apex), "start_at": sk.point(a), "end_at": sk.point(b), "apex_at": sk.point(apex), "rho": rho, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
};
|
||||
if let (Some(l), Some(o)) = (&c.link, v.as_object_mut()) {
|
||||
|
||||
@@ -29,6 +29,7 @@ mod parameters;
|
||||
mod plastic;
|
||||
mod sheet;
|
||||
mod sketch;
|
||||
pub(crate) use sketch::author_path_for_rebind;
|
||||
mod sketch_3d;
|
||||
mod sketch_canvas;
|
||||
mod sketch_constraints;
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{Document, FeatureKind, RevolveConvention};
|
||||
use solvecraft_geom::Vec3;
|
||||
use std::f64::consts::{FRAC_PI_2, PI, SQRT_2};
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn profile(s: &mut Session, opposed: bool) {
|
||||
run(s, "sketch.create", json!({"name":"Profile","plane":"XY"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0":[2,0],"p1":[4,10]}));
|
||||
if opposed {
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0":[-4,0],"p1":[-2,10]}));
|
||||
}
|
||||
run(s, "sketch.finish", json!({}));
|
||||
}
|
||||
|
||||
fn turn(s: &mut Session, sign: f64, settings: Value) {
|
||||
let mut p = json!({"sketch":"Profile","axis":{"origin":[0,0,0],"dir":[0,sign,0]},"angle":"90 deg","direction":"symmetric","operation":"new","name":"Turn","body_name":"Turned"});
|
||||
p.as_object_mut().unwrap().extend(settings.as_object().unwrap().clone());
|
||||
run(s, "solid.revolve", p);
|
||||
}
|
||||
|
||||
fn check_centered(s: &Session, opposed: bool) {
|
||||
let state = s.model.state();
|
||||
let expected = 30.0 * PI * if opposed { 2.0 } else { 1.0 };
|
||||
let volume: f64 = state.bodies.iter().map(|mb| solvecraft_kernel::measure(&mb.body).unwrap().volume).sum();
|
||||
assert!((volume - expected).abs() < 0.03, "{volume} vs {expected}");
|
||||
for sign in if opposed { vec![1.0, -1.0] } else { vec![1.0] } {
|
||||
let body = &state.bodies.iter().find(|mb| mb.body.contains(Vec3::new(sign * 3.0, 5.0, 0.0))).unwrap().body;
|
||||
assert!(!body.contains(Vec3::new(0.0, 5.0, 3.0)));
|
||||
assert!(body.contains(Vec3::new(sign * 3.0 * (PI / 6.0).cos(), 5.0, 1.5)));
|
||||
assert!(body.contains(Vec3::new(sign * 3.0 * (PI / 6.0).cos(), 5.0, -1.5)));
|
||||
let bounds = body.tessellate(1e-4).unwrap().bounds();
|
||||
assert!((bounds.min.z + 2.0 * SQRT_2).abs() < 1e-6, "{bounds:?}");
|
||||
assert!((bounds.max.z - 2.0 * SQRT_2).abs() < 1e-6, "{bounds:?}");
|
||||
let inner_x = if sign > 0.0 { bounds.min.x } else { -bounds.max.x };
|
||||
assert!((inner_x - SQRT_2).abs() < 1e-6, "{bounds:?}");
|
||||
let centroid = solvecraft_kernel::measure(body).unwrap().centroid;
|
||||
let radial = (2.0 / 3.0) * (64.0 - 8.0) / (16.0 - 4.0);
|
||||
let want_x = radial * (PI / 4.0).sin() / (PI / 4.0);
|
||||
assert!((centroid.x - sign * want_x).abs() < 0.005, "{centroid:?}");
|
||||
assert!((centroid.y - 5.0).abs() < 1e-6);
|
||||
assert!(centroid.z.abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_symmetric_revolves_center_material_for_both_axis_signs_and_negative_angles() {
|
||||
for sign in [-1.0, 1.0] {
|
||||
for angle in ["90 deg", "-90 deg"] {
|
||||
let mut s = Session::default();
|
||||
profile(&mut s, false);
|
||||
turn(&mut s, sign, json!({"angle":angle}));
|
||||
assert!(matches!(s.doc.find_feature("Turn").unwrap().kind, FeatureKind::Revolve { convention: RevolveConvention::SketchNormal, .. }));
|
||||
check_centered(&s, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opposed_profiles_use_independent_sweep_axes() {
|
||||
for sign in [-1.0, 1.0] {
|
||||
let mut s = Session::default();
|
||||
profile(&mut s, true);
|
||||
turn(&mut s, sign, json!({}));
|
||||
check_centered(&s, true);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_sided_revolves_use_the_canonical_negative_and_positive_limits() {
|
||||
for sign in [-1.0, 1.0] {
|
||||
let mut s = Session::default();
|
||||
profile(&mut s, false);
|
||||
turn(&mut s, sign, json!({"direction":"positive","angle":"60 deg","angle2":"30 deg"}));
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
let bounds = body.tessellate(1e-4).unwrap().bounds();
|
||||
assert!((bounds.min.x - 1.0).abs() < 1e-6, "{bounds:?}");
|
||||
assert!((bounds.max.x - 4.0).abs() < 1e-4);
|
||||
assert!((bounds.min.z + 2.0).abs() < 1e-6);
|
||||
assert!((bounds.max.z - 2.0 * 3.0f64.sqrt()).abs() < 1e-6);
|
||||
assert!(body.contains(Vec3::new(3.0, 5.0, 0.0)));
|
||||
assert!(!body.contains(Vec3::new(0.0, 5.0, 3.0)));
|
||||
assert!((solvecraft_kernel::measure(body).unwrap().volume - 30.0 * PI).abs() < 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_missing_convention_keeps_geometry_through_edit_redefine_undo_and_reopen() {
|
||||
let mut s = Session::default();
|
||||
profile(&mut s, false);
|
||||
turn(&mut s, -1.0, json!({"convention":"legacy_axis_start"}));
|
||||
let mut saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
saved["format"] = json!("solvecraft/10");
|
||||
for f in saved["features"].as_array_mut().unwrap() {
|
||||
f.as_object_mut().unwrap().remove("convention");
|
||||
}
|
||||
let mut s = Session::new(Document::from_json(&saved.to_string()).unwrap());
|
||||
let original = s.doc.find_feature("Turn").unwrap().kind.clone();
|
||||
let check = |s: &Session| {
|
||||
assert_eq!(s.doc.find_feature("Turn").unwrap().kind, original);
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
assert!(body.contains(Vec3::new(0.0, 5.0, 3.0)));
|
||||
assert!(!body.contains(Vec3::new(3.0, 5.0, 0.0)));
|
||||
let bounds = body.tessellate(1e-4).unwrap().bounds();
|
||||
assert!((bounds.min.x + 2.0 * SQRT_2).abs() < 1e-6);
|
||||
assert!((bounds.max.x - 2.0 * SQRT_2).abs() < 1e-6);
|
||||
assert!((bounds.min.z - SQRT_2).abs() < 1e-6);
|
||||
};
|
||||
check(&s);
|
||||
let angle_input = s.doc.find_feature("Turn").unwrap().param_names[0].clone();
|
||||
run(&mut s, "parameters.change", json!({"name":angle_input,"expression":"60 deg"}));
|
||||
let changed = s.model.state();
|
||||
assert!(changed.bodies[0].body.contains(Vec3::new(1.5, 5.0, 3.0 * 3.0f64.sqrt() / 2.0)));
|
||||
assert!(!changed.bodies[0].body.contains(Vec3::new(3.0, 5.0, 0.0)));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
check(&s);
|
||||
run(&mut s, "timeline.edit", json!({"feature":"Turn","set":{"angle":"90 deg"}}));
|
||||
check(&s);
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature":"Turn","command":"solid.revolve","params":{"sketch":"Profile","axis":{"origin":[0,0,0],"dir":[0,-1,0]},"angle":"90 deg","direction":"symmetric","body_name":"Turned"}}),
|
||||
);
|
||||
check(&s);
|
||||
run(&mut s, "timeline.edit", json!({"feature":"Turn","set":{"convention":"sketch_normal"}}));
|
||||
check_centered(&s, false);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
check(&s);
|
||||
let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
check(&reopened);
|
||||
// A newly authored feature in this migrated document uses current semantics.
|
||||
turn(&mut s, -1.0, json!({"name":"NewTurn","body_name":"NewTurned"}));
|
||||
assert!(matches!(s.doc.find_feature("NewTurn").unwrap().kind, FeatureKind::Revolve { convention: RevolveConvention::SketchNormal, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extent_handle_angles_match_new_and_legacy_material() {
|
||||
let mut s = Session::default();
|
||||
profile(&mut s, false);
|
||||
let state = s.model.state();
|
||||
let ss = &state.sketches[0];
|
||||
let regions = vec![ss.profiles[0].region.clone()];
|
||||
for convention in [RevolveConvention::SketchNormal, RevolveConvention::LegacyAxisStart] {
|
||||
let extent = solvecraft_doc::revolve_extent_with_convention(
|
||||
&state,
|
||||
&ss.plane,
|
||||
®ions,
|
||||
(Vec3::ZERO, -Vec3::Y),
|
||||
(FRAC_PI_2, None),
|
||||
true,
|
||||
None,
|
||||
convention,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(extent.rotation_axis, -Vec3::Y);
|
||||
assert!((extent.display_start - if convention == RevolveConvention::SketchNormal { -PI / 4.0 } else { PI / 4.0 }).abs() < 1e-12);
|
||||
assert!((extent.sweep - FRAC_PI_2).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_revolve_preserves_a_translated_rotated_sketch_frame() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"name":"Profile","plane":{"origin":[10,20,30],"x_dir":[0,0,1],"y_dir":[1,0,0]}}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[2,0],"p1":[4,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.revolve", json!({"sketch":"Profile","axis":{"origin":[10,20,30],"dir":[-1,0,0]},"angle":"90 deg","direction":"symmetric"}));
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
let bounds = body.tessellate(1e-4).unwrap().bounds();
|
||||
assert!(bounds.min.dist(Vec3::new(10.0, 20.0 - 2.0 * SQRT_2, 30.0 + SQRT_2)) < 1e-6, "{bounds:?}");
|
||||
assert!(bounds.max.dist(Vec3::new(20.0, 20.0 + 2.0 * SQRT_2, 34.0)) < 1e-4, "{bounds:?}");
|
||||
assert!(body.contains(Vec3::new(15.0, 20.0, 33.0)));
|
||||
assert!(!body.contains(Vec3::new(15.0, 23.0, 30.0)));
|
||||
assert!((solvecraft_kernel::measure(body).unwrap().volume - 30.0 * PI).abs() < 0.03);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_revolves_keep_their_convention_and_angular_material() {
|
||||
for convention in ["sketch_normal", "legacy_axis_start"] {
|
||||
let mut s = Session::default();
|
||||
profile(&mut s, false);
|
||||
turn(&mut s, -1.0, json!({"convention":convention}));
|
||||
run(&mut s, "timeline.copy", json!({"features":["Profile","Turn"]}));
|
||||
run(&mut s, "timeline.paste", json!({"translate":[0,20,0]}));
|
||||
let state = s.model.state();
|
||||
let probe = if convention == "sketch_normal" { Vec3::new(3.0, 25.0, 0.0) } else { Vec3::new(0.0, 25.0, 3.0) };
|
||||
assert!(state.bodies.iter().any(|mb| mb.body.contains(probe)));
|
||||
let modes: Vec<_> = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.filter_map(|f| if let FeatureKind::Revolve { convention, .. } = f.kind { Some(convention) } else { None })
|
||||
.collect();
|
||||
assert_eq!(modes.len(), 2);
|
||||
assert_eq!(modes[0], modes[1]);
|
||||
let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.state().bodies.iter().any(|mb| mb.body.contains(probe)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centered_coplanar_cut_uses_the_actual_angular_interval() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"corner":[2,0,-4],"length":2,"width":10,"height":8,"body_name":"Stock"}));
|
||||
profile(&mut s, false);
|
||||
turn(&mut s, -1.0, json!({"operation":"cut","targets":["Stock"]}));
|
||||
let state = s.model.state();
|
||||
// Removed sector clipped by x>=2: 10∫[-π/4,π/4](16−4sec²θ)/2 dθ.
|
||||
// The sector crosses the whole stock at z=0, leaving two disconnected solids.
|
||||
let expected = 200.0 - 40.0 * PI;
|
||||
let volumes: Vec<_> = state.bodies.iter().map(|mb| solvecraft_kernel::measure(&mb.body).unwrap().volume).collect();
|
||||
let total: f64 = volumes.iter().sum();
|
||||
assert!((total - expected).abs() < 0.05, "{volumes:?}: total{total}, expected{expected}");
|
||||
assert_eq!(state.bodies.len(), 2);
|
||||
assert!(volumes.iter().all(|v| (*v - expected / 2.0).abs() < 0.025));
|
||||
let contains = |p| state.bodies.iter().any(|mb| mb.body.contains(p));
|
||||
assert!(!contains(Vec3::new(3.0, 5.0, 0.0)));
|
||||
assert!(contains(Vec3::new(3.0, 5.0, 3.5)));
|
||||
assert!(contains(Vec3::new(3.0, 5.0, -3.5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_sided_and_full_revolves_keep_the_existing_material() {
|
||||
for angle in ["90 deg", "360 deg"] {
|
||||
for sign in [-1.0, 1.0] {
|
||||
for convention in ["legacy_axis_start", "sketch_normal"] {
|
||||
let mut s = Session::default();
|
||||
profile(&mut s, false);
|
||||
turn(&mut s, sign, json!({"angle":angle,"direction":"positive","convention":convention}));
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
assert!(body.contains(Vec3::new(3.0 / SQRT_2, 5.0, 3.0 / SQRT_2)));
|
||||
assert_eq!(body.contains(Vec3::new(-3.0 / SQRT_2, 5.0, -3.0 / SQRT_2)), angle == "360 deg");
|
||||
let expected = if angle == "360 deg" { 120.0 * PI } else { 30.0 * PI };
|
||||
assert!((solvecraft_kernel::measure(body).unwrap().volume - expected).abs() < 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_targets_use_each_profiles_canonical_axis_for_both_axis_signs() {
|
||||
for sign in [-1.0, 1.0] {
|
||||
for opposed in [false, true] {
|
||||
let mut s = Session::default();
|
||||
profile(&mut s, opposed);
|
||||
turn(&mut s, sign, json!({"to":[0,5,3]}));
|
||||
let state = s.model.state();
|
||||
let expected = 30.0 * PI * if opposed { 2.0 } else { 1.0 };
|
||||
let volume: f64 = state.bodies.iter().map(|mb| solvecraft_kernel::measure(&mb.body).unwrap().volume).sum();
|
||||
assert!((volume - expected).abs() < 0.05, "{volume}");
|
||||
assert!(state.bodies.iter().any(|mb| mb.body.contains(Vec3::new(3.0 / SQRT_2, 5.0, 3.0 / SQRT_2))));
|
||||
assert!(!state.bodies.iter().any(|mb| mb.body.contains(Vec3::new(3.0 / SQRT_2, 5.0, -3.0 / SQRT_2))));
|
||||
assert_eq!(state.bodies.iter().any(|mb| mb.body.contains(Vec3::new(-3.0 / SQRT_2, 5.0, 3.0 / SQRT_2))), opposed);
|
||||
assert!(!state.bodies.iter().any(|mb| mb.body.contains(Vec3::new(-3.0 / SQRT_2, 5.0, -3.0 / SQRT_2))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_axis_target_is_an_actionable_atomic_error_for_new_convention() {
|
||||
let mut s = Session::default();
|
||||
profile(&mut s, false);
|
||||
let before = s.doc.to_json();
|
||||
let e = s.execute("solid.revolve", &json!({"sketch":"Profile","axis":"Y","to":[0,5,0]})).unwrap_err().to_string();
|
||||
assert!(e.contains("target lies on the axis"), "{e}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curved_targets_keep_only_the_material_before_the_post_for_both_axis_signs() {
|
||||
for sign in [-1.0, 1.0] {
|
||||
for opposed in [false, true] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"base":[0,14,-5],"radius":4,"height":15,"body_name":"Post"}));
|
||||
run(&mut s, "sketch.create", json!({"name":"Profile","plane":"XZ"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[10,0],"p1":[15,5]}));
|
||||
if opposed {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-15,0],"p1":[-10,5]}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.revolve", json!({"sketch":"Profile","axis":{"origin":[0,0,0],"dir":[0,0,sign]},"to":[4,14,2],"body_name":"Arc"}));
|
||||
let state = s.model.state();
|
||||
let post = &state.body("Post").unwrap().body;
|
||||
let arcs: Vec<_> = state.bodies.iter().filter(|mb| mb.name != "Post").collect();
|
||||
assert_eq!(arcs.len(), if opposed { 2 } else { 1 });
|
||||
for side in if opposed { vec![1.0, -1.0] } else { vec![1.0] } {
|
||||
let probe = Vec3::new(side * 12.0 * (PI / 6.0).cos(), 6.0, -2.5);
|
||||
let arc = &arcs.iter().find(|mb| mb.body.contains(probe)).unwrap().body;
|
||||
assert!(!arc.contains(Vec3::new(-side * 12.0 * (PI / 6.0).cos(), 6.0, -2.5)));
|
||||
assert!(!arc.contains(Vec3::new(side * 12.0 * (PI / 6.0).cos(), -6.0, -2.5)));
|
||||
let full = PI * (225.0 - 100.0) * 5.0;
|
||||
let angle = solvecraft_kernel::measure(arc).unwrap().volume / full * 360.0;
|
||||
assert!(angle > 60.0 && angle < 90.0, "{angle}");
|
||||
let overlap = solvecraft_kernel::boolean(arc, post, solvecraft_kernel::BoolOp::Intersect).unwrap();
|
||||
assert!(overlap.is_none_or(|b| solvecraft_kernel::measure(&b).unwrap().volume < 1e-3));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ use crate::{EngineError, Result, Session};
|
||||
|
||||
#[path = "sketch_dimensioned.rs"]
|
||||
mod dimensioned;
|
||||
#[path = "sketch_face.rs"]
|
||||
pub(super) mod face;
|
||||
use dimensioned::draw as draw_dimensioned;
|
||||
|
||||
/// The `connect` parameter every drawing command takes (see `connect_points`).
|
||||
@@ -24,8 +26,8 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sketch")
|
||||
.params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [x,y,z]} (planar face at point), offset?: expr, name?"),
|
||||
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).icon("sketch").params("sketch: id or name"),
|
||||
.params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source, fixed:true explicitly freezes the selected plane; offset?: expr, project_edges?: bool, name?"),
|
||||
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).noundo().icon("sketch").params("sketch: id or name"),
|
||||
CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
|
||||
CommandSpec::new("sketch.line", "Line", draw_line)
|
||||
.at("SKETCH", "CREATE")
|
||||
@@ -232,6 +234,7 @@ pub(super) fn edit<T>(
|
||||
if strict && !rep.ok() {
|
||||
return Err(EngineError::Other(format!("that would over-constrain the sketch (conflicts with {})", rep.failing.join(", "))));
|
||||
}
|
||||
sk.validate_periodic_splines()?;
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
Ok((out, json!({"solved": rep.ok(), "dof": rep.dof})))
|
||||
@@ -357,36 +360,56 @@ pub(super) fn result(sk_out: (Vec<String>, Vec<String>), info: Value) -> Value {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Sketch lifecycle
|
||||
|
||||
pub(crate) fn author_path_for_rebind(s: &Session, feature: &solvecraft_doc::Feature, p: &Value, cmd: &str) -> Result<Vec<u64>> {
|
||||
let mut saved = None;
|
||||
if let FeatureKind::Sketch { plane, .. } = &feature.kind {
|
||||
let mut plane = plane;
|
||||
loop {
|
||||
match plane {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane = base,
|
||||
PlaneRef::Face { anchor: Some(anchor), .. } => {
|
||||
saved = Some(anchor.author_path.clone());
|
||||
break;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
let path = if let Some(value) = p.get("author_path") {
|
||||
value
|
||||
.as_array()
|
||||
.filter(|path| path.len() <= 64)
|
||||
.ok_or_else(|| bad(cmd, "author_path must contain at most64 ids"))?
|
||||
.iter()
|
||||
.map(|id| id.as_u64().filter(|id| *id != 0).ok_or_else(|| bad(cmd, "invalid author_path id")))
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
} else if let Some(path) = saved {
|
||||
path
|
||||
} else if feature.component == s.active_component && s.active_occurrence_path.is_some() {
|
||||
s.active_occurrence_path.clone().unwrap_or_default()
|
||||
} else {
|
||||
let placements = super::component::properties::placements(&s.placed_document(), cmd)?;
|
||||
let paths: Vec<_> = placements.into_iter().filter(|(component, _, _)| *component == feature.component).map(|(_, path, _)| path).collect();
|
||||
if paths.len() != 1 {
|
||||
return Err(bad(cmd, "sketch author placement is ambiguous; provide exact author_path or activate the intended occurrence"));
|
||||
}
|
||||
paths.into_iter().next().ok_or_else(|| bad(cmd, "sketch author placement is missing"))?
|
||||
};
|
||||
if feature.component != 0 && path.is_empty() {
|
||||
return Err(bad(cmd, "a component sketch needs an explicit complete author_path"));
|
||||
}
|
||||
solvecraft_doc::EmbossFace::component_frame(&s.placed_document(), feature.component, &path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
|
||||
let base = match p.get("plane") {
|
||||
None => PlaneRef::Origin { name: "XY".into() },
|
||||
// An origin plane, or a construction plane by name or id.
|
||||
Some(v @ (Value::String(_) | Value::Number(_))) => super::features::plane_param(s, Some(v), cmd)?,
|
||||
Some(v @ Value::Object(o)) => {
|
||||
if let Some(fp) = o.get("face").and_then(vec3) {
|
||||
// A planar face of a body at this point, as the active component sees the model
|
||||
// (another component's face where it is placed).
|
||||
let model = s.model.state();
|
||||
let placed = s.placed_document();
|
||||
let st = solvecraft_doc::state_in_frame_at(&placed, &model, s.active_component, s.active_component_frame());
|
||||
let mut found = None;
|
||||
let mut face_name = None;
|
||||
for b in &st.bodies {
|
||||
let tol = (b.body.size() * 1e-3).max(1e-3);
|
||||
for f in b.body.faces(tol).unwrap_or_default() {
|
||||
if let Some(n) = f.plane_normal
|
||||
&& (fp - f.centroid).dot(n).abs() < tol * 10.0
|
||||
&& b.body.tessellate(tol).map(|m| face_contains(&m, f.index, fp, tol * 10.0)).unwrap_or(false)
|
||||
{
|
||||
// Sketch origin: the model origin projected onto the face plane.
|
||||
let d = (f.centroid + n * (fp - f.centroid).dot(n)).dot(n);
|
||||
found = Plane::from_normal(n * d, n);
|
||||
face_name = solvecraft_doc::naming::face_names(b).get(f.index).cloned();
|
||||
}
|
||||
}
|
||||
}
|
||||
let pl = found.ok_or_else(|| bad(cmd, "no planar face at that point"))?;
|
||||
PlaneRef::Face { plane: pl, at: fp, name: face_name }
|
||||
if o.contains_key("face") {
|
||||
face::capture(s, v, cmd)?
|
||||
} else {
|
||||
let origin = v.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let x = v.get("x_dir").and_then(vec3).ok_or_else(|| bad(cmd, "plane needs x_dir"))?;
|
||||
@@ -403,7 +426,7 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
|
||||
}
|
||||
|
||||
/// Does triangle set of face `fi` contain point `p` (within `tol` of the face plane)?
|
||||
fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> bool {
|
||||
pub(super) fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> bool {
|
||||
m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| **f as usize == fi).any(|(t, _)| {
|
||||
let Some([a, b, c]) = m.tri(t) else { return false };
|
||||
let n = (b - a).cross(c - a);
|
||||
@@ -423,13 +446,28 @@ fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> boo
|
||||
|
||||
fn create_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.create";
|
||||
let plane = plane_ref(s, p, cmd)?;
|
||||
create_sketch_with_plane(s, p, plane_ref(s, p, cmd)?, None)
|
||||
}
|
||||
|
||||
/// Used only by face Extrude/Revolve/PressPull, preserving pre-certificate linked profiles.
|
||||
pub(super) fn create_face_profile(s: &mut Session, p: &Value, body: &str, index: usize) -> Result<Value> {
|
||||
let value = p.get("plane").ok_or_else(|| bad("face profile", "missing picked face plane"))?;
|
||||
let plane = face::capture_internal(s, value, "face profile")?;
|
||||
create_sketch_with_plane(s, p, plane, Some((body, index)))
|
||||
}
|
||||
|
||||
fn create_sketch_with_plane(s: &mut Session, p: &Value, plane: PlaneRef, internal_body: Option<(&str, usize)>) -> Result<Value> {
|
||||
if face::is_face(p) {
|
||||
face::capture_positions(s, &plane)?;
|
||||
}
|
||||
// Validate the plane resolves.
|
||||
let (vals, _) = s.doc.param_values();
|
||||
s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?;
|
||||
let name = str_(p, "name");
|
||||
let mut kind = FeatureKind::Sketch { plane, sketch: Sketch::new() };
|
||||
super::component::to_active_frame(s, &mut kind);
|
||||
if !face::is_face(p) {
|
||||
super::component::to_active_frame(s, &mut kind);
|
||||
}
|
||||
let id = s.doc_mut().add_feature(kind, name)?;
|
||||
let comp = s.active_component;
|
||||
if let Some(f) = s.doc_mut().feature_mut(id) {
|
||||
@@ -444,8 +482,23 @@ fn create_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& bool_(p, "project_edges").unwrap_or(s.auto_project)
|
||||
{
|
||||
link = super::sketch_project::auto_project_face(s, id, at)?;
|
||||
if link.is_none()
|
||||
&& let Some((body, index)) = internal_body
|
||||
{
|
||||
link = Some(super::sketch_project::project_internal_face(s, id, body, index)?);
|
||||
}
|
||||
}
|
||||
Ok(json!({"sketch": id, "name": name, "projected": link}))
|
||||
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } => Some(if face::anchor(plane).is_some() {
|
||||
"exact_face"
|
||||
} else if internal_body.is_some() {
|
||||
"internal_legacy_face"
|
||||
} else {
|
||||
"fixed"
|
||||
}),
|
||||
_ => None,
|
||||
});
|
||||
Ok(json!({"sketch": id, "name": name, "projected": link,"attachment":attachment}))
|
||||
}
|
||||
|
||||
fn edit_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -458,7 +511,17 @@ fn edit_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
|
||||
return Err(bad("sketch.edit", format!("`{key}` is not a sketch")));
|
||||
}
|
||||
let (id, name) = (f.id, f.name.clone());
|
||||
let (id, name, component) = (f.id, f.name.clone(), f.component);
|
||||
let authored_path = match &f.kind {
|
||||
FeatureKind::Sketch { plane, .. } => face::anchor(plane).map(|anchor| anchor.author_path.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(path) = authored_path {
|
||||
solvecraft_doc::EmbossFace::component_frame(&s.placed_document(), component, &path)
|
||||
.map_err(|error| bad("sketch.edit", format!("{error}; explicitly rebind the author placement before editing")))?;
|
||||
s.active_component = component;
|
||||
s.active_occurrence_path = (!path.is_empty()).then_some(path);
|
||||
}
|
||||
s.active_sketch = Some(id);
|
||||
s.revision += 1;
|
||||
Ok(json!({"sketch": id, "name": name}))
|
||||
@@ -1759,7 +1822,7 @@ fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2) {
|
||||
}
|
||||
k => {
|
||||
// Lines, splines and conics slide with the cursor.
|
||||
for q in k.point_ids() {
|
||||
for q in k.point_ids().into_iter().collect::<std::collections::BTreeSet<_>>() {
|
||||
if let Some(at) = sk.point(q) {
|
||||
set(sk, q, at + d);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ pub static COMMANDS: &[CommandSpec] =
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spline")
|
||||
.enabled(in_sketch)
|
||||
.params("points: [[x,y] or point refs…] (2…500): the spline passes through them"),
|
||||
.params("points: [[x,y] or point refs…] (2…500), periodic?: boolean (default false); periodic cubic fit closes smoothly through at least 3 distinct samples"),
|
||||
CommandSpec::new("sketch.spline.control_point", "Control Point Spline", spline_cv3)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spline_cv")
|
||||
@@ -167,7 +167,7 @@ fn blend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let first_new = sk.points.len();
|
||||
let c1 = sk.add_point(qa + ta * (l / 3.0), None)?;
|
||||
let c2 = sk.add_point(qb + tb * (l / 3.0), None)?;
|
||||
let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3 }, None)?;
|
||||
let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3, periodic: false }, None)?;
|
||||
let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: sp })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: sp })?];
|
||||
solve_new_only(sk, first_new);
|
||||
Ok((ids_of(sk, &[sp]), cons))
|
||||
@@ -252,10 +252,24 @@ fn point_list(sk: &mut Sketch, p: &Value, cmd: &str) -> Result<Vec<usize>> {
|
||||
}
|
||||
|
||||
fn spline(s: &mut Session, p: &Value, cmd: &str, control: bool, degree: u8) -> Result<Value> {
|
||||
let periodic = match p.get("periodic") {
|
||||
None => false,
|
||||
Some(Value::Bool(v)) => *v,
|
||||
_ => return Err(bad(cmd, "periodic must be boolean")),
|
||||
};
|
||||
if periodic && (control || degree != 3) {
|
||||
return Err(bad(cmd, "periodic mode requires a cubic fit spline"));
|
||||
}
|
||||
let p = &super::sketch_project::snap_points(s, p, cmd)?;
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let pts = point_list(sk, p, cmd)?;
|
||||
let c = sk.add_curve(CurveKind::Spline { pts, control, degree }, None)?;
|
||||
let mut pts = point_list(sk, p, cmd)?;
|
||||
if periodic
|
||||
&& sk.point(*pts.first().ok_or_else(|| bad(cmd, "missing first point"))?)
|
||||
!= sk.point(*pts.last().ok_or_else(|| bad(cmd, "missing last point"))?)
|
||||
{
|
||||
pts.push(*pts.first().ok_or_else(|| bad(cmd, "missing first point"))?);
|
||||
}
|
||||
let c = sk.add_curve(CurveKind::Spline { pts, control, degree, periodic }, None)?;
|
||||
mark_construction(sk, &[c], p);
|
||||
Ok((ids_of(sk, &[c]), Vec::new()))
|
||||
})?;
|
||||
@@ -344,12 +358,12 @@ fn leaving_dir(sk: &Sketch, ci: usize, pi: usize) -> Option<Vec2> {
|
||||
None
|
||||
}
|
||||
}
|
||||
CurveKind::Spline { ref pts, control, .. } => {
|
||||
CurveKind::Spline { ref pts, control, periodic, .. } => {
|
||||
let q: Vec<Vec2> = pts.iter().filter_map(|i| sk.point(*i)).collect();
|
||||
if pts.last() == Some(&pi) {
|
||||
solvecraft_sketch::spline_end_tangent(&q, control, true)
|
||||
solvecraft_sketch::spline_end_tangent_mode(&q, control, periodic, true)
|
||||
} else if pts.first() == Some(&pi) {
|
||||
solvecraft_sketch::spline_end_tangent(&q, control, false).map(|t| -t)
|
||||
solvecraft_sketch::spline_end_tangent_mode(&q, control, periodic, false).map(|t| -t)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Exact planar face capture for sketch attachment. World labels are lookup keys only.
|
||||
use crate::params::{bad, vec3};
|
||||
use crate::{Result, Session};
|
||||
use serde_json::Value;
|
||||
use solvecraft_doc::{EmbossFace, PlaneRef, apply_point, apply_vector, mat_inverse, mat_mul};
|
||||
use solvecraft_geom::Plane;
|
||||
|
||||
pub(in crate::cmd) fn is_face(p: &Value) -> bool {
|
||||
p.get("plane").and_then(|plane| plane.get("face")).is_some()
|
||||
}
|
||||
|
||||
pub(in crate::cmd) fn capture(s: &Session, value: &Value, cmd: &str) -> Result<PlaneRef> {
|
||||
capture_impl(s, value, cmd, false)
|
||||
}
|
||||
|
||||
/// Internal face-profile compatibility only; never exposed as a command parameter.
|
||||
pub(in crate::cmd) fn capture_internal(s: &Session, value: &Value, cmd: &str) -> Result<PlaneRef> {
|
||||
capture_impl(s, value, cmd, true)
|
||||
}
|
||||
|
||||
fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result<PlaneRef> {
|
||||
let point = value
|
||||
.get("face")
|
||||
.and_then(vec3)
|
||||
.filter(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite())
|
||||
.ok_or_else(|| bad(cmd, "face point must be three finite world coordinates"))?;
|
||||
let fixed = match value.get("fixed") {
|
||||
None => false,
|
||||
Some(Value::Bool(fixed)) => *fixed,
|
||||
_ => return Err(bad(cmd, "fixed must be a boolean")),
|
||||
};
|
||||
let state = s.model.state();
|
||||
let doc = s.placed_document();
|
||||
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(s.active_component));
|
||||
let author_frame = EmbossFace::component_frame(&doc, s.active_component, &author_path)?;
|
||||
let author_inverse = mat_inverse(&author_frame).ok_or_else(|| bad(cmd, "invalid sketch author frame"))?;
|
||||
let requested_body = match value.get("body") {
|
||||
None => None,
|
||||
Some(Value::String(body)) if !body.is_empty() => Some(body.as_str()),
|
||||
_ => return Err(bad(cmd, "body must be the picked world body name")),
|
||||
};
|
||||
let requested_index = match value.get("index") {
|
||||
None => None,
|
||||
Some(value) => {
|
||||
Some(value.as_u64().and_then(|index| usize::try_from(index).ok()).ok_or_else(|| bad(cmd, "index must be a nonnegative face index"))?)
|
||||
}
|
||||
};
|
||||
if requested_body.is_some() != requested_index.is_some() {
|
||||
return Err(bad(cmd, "an exact face pick requires both body and index"));
|
||||
}
|
||||
let requested_path = match value.get("path") {
|
||||
None => None,
|
||||
Some(value) => Some(
|
||||
value
|
||||
.as_array()
|
||||
.filter(|path| path.len() <= 64)
|
||||
.ok_or_else(|| bad(cmd, "path must contain at most64 occurrence ids"))?
|
||||
.iter()
|
||||
.map(|id| id.as_u64().filter(|id| *id != 0).ok_or_else(|| bad(cmd, "path ids must be nonzero integers")))
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
),
|
||||
};
|
||||
let selected_path = requested_path
|
||||
.clone()
|
||||
.or_else(|| requested_body.and_then(|name| state.body(name)).map(|body| doc.first_path(doc.model_body_component(body))));
|
||||
let placements = if let Some(path) = selected_path {
|
||||
let component = path
|
||||
.last()
|
||||
.and_then(|id| doc.occurrences.iter().find(|occurrence| occurrence.id == *id))
|
||||
.map(|occurrence| occurrence.component)
|
||||
.unwrap_or(0);
|
||||
let frame = EmbossFace::component_frame(&doc, component, &path)?;
|
||||
vec![(component, path, frame)]
|
||||
} else {
|
||||
crate::cmd::component::properties::placements(&doc, cmd)?
|
||||
};
|
||||
let mut by_component: std::collections::BTreeMap<u64, Vec<_>> = std::collections::BTreeMap::new();
|
||||
for body in &state.bodies {
|
||||
by_component.entry(doc.model_body_component(body)).or_default().push(body);
|
||||
}
|
||||
let mut chosen = None;
|
||||
let mut work = state.bodies.len().saturating_add(doc.occurrences.len());
|
||||
for (component, path, placement) in placements {
|
||||
let Some(bodies) = by_component.get(&component) else {
|
||||
continue;
|
||||
};
|
||||
for body in bodies {
|
||||
if let Some(source) = requested_body.and_then(|name| state.body(name))
|
||||
&& source.name != body.name
|
||||
{
|
||||
continue;
|
||||
}
|
||||
work = work.saturating_add(1);
|
||||
if let Some(requested) = requested_body {
|
||||
work = work.saturating_add(doc.occurrences.len()).saturating_add(state.bodies.len()).saturating_add(path.len());
|
||||
if work > 2_000_000 {
|
||||
return Err(bad(cmd, "face capture work limit exceeded; supply exact path"));
|
||||
}
|
||||
if requested != doc.world_body_name(&state, &body.name, component, &path) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let frame = doc.model_body_offset(body).map(|offset| mat_mul(&placement, offset)).unwrap_or(placement);
|
||||
let inverse = mat_inverse(&frame).ok_or_else(|| bad(cmd, "invalid selected face frame"))?;
|
||||
let local_point = apply_point(&inverse, point);
|
||||
let tolerance = (body.body.size() * 1e-4).max(1e-5);
|
||||
let faces = body.body.faces(tolerance)?;
|
||||
let mesh = body.body.tessellate(tolerance)?;
|
||||
work = work.saturating_add(mesh.triangles.len().saturating_mul(if requested_index.is_some() { 1 } else { faces.len() }));
|
||||
if work > 2_000_000 {
|
||||
return Err(bad(cmd, "face capture work limit exceeded; use an exact body and face index"));
|
||||
}
|
||||
for face in faces {
|
||||
if requested_index.is_some_and(|index| index != face.index) {
|
||||
continue;
|
||||
}
|
||||
let Some(exact_plane) = (if fixed {
|
||||
body.body.planar_face(face.index).or_else(|| body.body.face_plane(face.index))
|
||||
} else {
|
||||
body.body.face_plane(face.index).or_else(|| body.body.planar_face(face.index))
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let normal = exact_plane.normal();
|
||||
if (local_point - face.centroid).dot(normal).abs() > tolerance * 10.0
|
||||
|| !super::face_contains(&mesh, face.index, local_point, tolerance * 10.0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if chosen.is_some() {
|
||||
return Err(bad(cmd, "face point is ambiguous; pick an exact body and face index"));
|
||||
}
|
||||
let relative = mat_mul(&author_inverse, &frame);
|
||||
let normal = apply_vector(&relative, normal).normalized().ok_or_else(|| bad(cmd, "invalid planar face normal"))?;
|
||||
let centroid = apply_point(&relative, face.centroid);
|
||||
let picked = Plane::from_normal(normal * centroid.dot(normal), normal).ok_or_else(|| bad(cmd, "invalid selected planar face"))?;
|
||||
chosen = Some((body, face.index, path.clone(), picked));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (body, index, path, plane) =
|
||||
chosen.ok_or_else(|| bad(cmd, "the selected planar face no longer exists; choose another face or an explicit fixed plane"))?;
|
||||
if fixed {
|
||||
return Ok(PlaneRef::Custom { plane });
|
||||
}
|
||||
let at = apply_point(&author_inverse, point);
|
||||
let name = solvecraft_doc::naming::face_names(body).get(index).cloned();
|
||||
let anchor = match doc.capture_face_anchor(body, index, &plane, &path, s.active_component, &author_path) {
|
||||
Ok(anchor) => Some(Box::new(anchor)),
|
||||
Err(solvecraft_doc::DocError::Invalid(message))
|
||||
if internal
|
||||
&& matches!(
|
||||
message.as_str(),
|
||||
"this face has no exact planar source; choose a fixed plane instead"
|
||||
| "this face has no supported construction certificate; choose a fixed plane instead"
|
||||
) =>
|
||||
{
|
||||
if path.iter().any(|id| s.pending_moves.contains_key(id) && !author_path.contains(id)) {
|
||||
return Err(bad(cmd, "capture the source occurrence position before generating a legacy face profile across components"));
|
||||
}
|
||||
None
|
||||
}
|
||||
Err(error) => return Err(bad(cmd, format!("{error}; choose a supported exact face or explicitly use fixed:true"))),
|
||||
};
|
||||
Ok(PlaneRef::Face { plane, at, name, anchor })
|
||||
}
|
||||
|
||||
/// The sole durable face attachment beneath an offset/angle wrapper.
|
||||
pub(in crate::cmd) fn anchor(mut plane: &PlaneRef) -> Option<&solvecraft_doc::FaceAnchor> {
|
||||
loop {
|
||||
match plane {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane = base,
|
||||
PlaneRef::Face { anchor, .. } => return anchor.as_deref(),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::cmd) fn anchor_mut(mut plane: &mut PlaneRef) -> Option<&mut solvecraft_doc::FaceAnchor> {
|
||||
loop {
|
||||
match plane {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane = base,
|
||||
PlaneRef::Face { anchor, .. } => return anchor.as_deref_mut(),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::cmd) fn capture_positions(s: &mut Session, plane: &PlaneRef) -> Result<()> {
|
||||
let mut involved = std::collections::BTreeSet::new();
|
||||
if let Some(anchor) = anchor(plane) {
|
||||
involved.extend(anchor.path.iter().copied());
|
||||
involved.extend(anchor.author_path.iter().copied());
|
||||
} else {
|
||||
involved.extend(s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(s.active_component)));
|
||||
}
|
||||
let changes: Vec<_> = involved.into_iter().filter_map(|id| s.pending_moves.get(&id).copied().map(|matrix| (id, matrix))).collect();
|
||||
for (id, matrix) in &changes {
|
||||
let occurrence = s
|
||||
.doc_mut()
|
||||
.occurrences
|
||||
.iter_mut()
|
||||
.find(|occurrence| occurrence.id == *id)
|
||||
.ok_or_else(|| bad("sketch attachment", "pending author/source path disappeared"))?;
|
||||
occurrence.transform = *matrix;
|
||||
}
|
||||
for (id, _) in changes {
|
||||
s.pending_moves.remove(&id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_face_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,401 @@
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command} {params}: {error}"))
|
||||
}
|
||||
fn top_pick(s: &Session, body: &str) -> Value {
|
||||
let world = s.world_state();
|
||||
let body = world.body(body).unwrap();
|
||||
let face = body
|
||||
.body
|
||||
.faces(0.01)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter(|face| face.plane_normal.is_some_and(|normal| normal.dot(Vec3::Z) > 0.999))
|
||||
.max_by(|a, b| a.centroid.z.total_cmp(&b.centroid.z))
|
||||
.unwrap();
|
||||
json!({"body":body.name,"index":face.index,"face":[face.centroid.x,face.centroid.y,face.centroid.z]})
|
||||
}
|
||||
fn draw(s: &mut Session) {
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0":[1,1],"p1":[3,3]}));
|
||||
run(s, "sketch.dimension", json!({"entities":["l1"],"value":2}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
}
|
||||
#[test]
|
||||
fn exact_selected_base_follows_height_and_never_retargets_control() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":30,"body_name":"Control","name":"ControlFeature"}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
let sketch = run(&mut s, "sketch.create", json!({"name":"Attached","plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap();
|
||||
draw(&mut s);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":sketch,"distance":2,"operation":"new","body_name":"Badge"}));
|
||||
let height = s.doc.find_feature("BaseFeature").unwrap().param_names[2].clone();
|
||||
run(&mut s, "parameters.change", json!({"name":height,"expression":"20 mm"}));
|
||||
assert!((s.model.state().sketch(sketch).unwrap().plane.origin.z - 20.).abs() < 1e-6);
|
||||
assert!((s.world_state().body("Badge").unwrap().mesh().bounds().max.z - 22.).abs() < 1e-6);
|
||||
run(&mut s, "timeline.delete", json!({"features":["BaseFeature"]}));
|
||||
assert!(s.model.result(sketch).unwrap().error.is_some());
|
||||
assert!(s.model.state().body("Badge").is_none());
|
||||
assert!((s.world_state().body("Control").unwrap().mesh().bounds().max.z - 30.).abs() < 1e-6);
|
||||
let before = s.doc.to_json();
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&before).unwrap());
|
||||
assert!(reopened.model.result(sketch).unwrap().error.is_some());
|
||||
}
|
||||
#[test]
|
||||
fn explicit_rebind_preserves_geometry_dimensions_context_and_one_undo() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":30,"body_name":"Control"}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap();
|
||||
draw(&mut s);
|
||||
run(&mut s, "timeline.delete", json!({"features":["BaseFeature"]}));
|
||||
let geometry = s.doc.sketch(id).unwrap().clone();
|
||||
let before = s.doc.to_json();
|
||||
let context = (s.active_component, s.active_occurrence_path.clone(), s.active_sketch);
|
||||
let pick = top_pick(&s, "Control");
|
||||
let commands = vec![("sketch.redefine".into(), json!({"sketch":id,"plane":pick}))];
|
||||
let preview = s.preview_visible_world(&commands).unwrap();
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert!(preview.after.sketch(id).is_some());
|
||||
run(&mut s, "sketch.redefine", commands[0].1.clone());
|
||||
assert_eq!(s.doc.sketch(id).unwrap(), &geometry);
|
||||
assert_eq!((s.active_component, s.active_occurrence_path.clone(), s.active_sketch), context);
|
||||
assert!((s.model.state().sketch(id).unwrap().plane.origin.z - 30.).abs() < 1e-6);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert!(s.model.result(id).unwrap().error.is_some());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((reopened.model.state().sketch(id).unwrap().plane.origin.z - 30.).abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn unsupported_face_has_explicit_fixed_alternative_and_no_live_projection() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"}));
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[4,4,10],"operation":"join"}));
|
||||
let mut pick = top_pick(&s, "Base");
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("sketch.create", &json!({"plane":pick})).unwrap_err();
|
||||
assert!(error.to_string().contains("fixed"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
pick["fixed"] = json!(true);
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap();
|
||||
assert!(matches!(s.doc.feature(id).unwrap().kind, solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Custom { .. }, .. }));
|
||||
assert!(s.doc.sketch(id).unwrap().links.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn malformed_exact_face_and_path_leave_every_session_field_unchanged() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
for invalid in [
|
||||
json!({"body":"Missing","index":pick["index"],"face":pick["face"]}),
|
||||
json!({"body":"Base","index":-1,"face":pick["face"]}),
|
||||
json!({"body":"Base","index":pick["index"],"face":pick["face"],"path":[999]}),
|
||||
json!({"body":"Base","face":pick["face"]}),
|
||||
] {
|
||||
let before = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let context = (s.active_component, s.active_occurrence_path.clone(), s.active_sketch);
|
||||
assert!(s.execute("sketch.create", &json!({"plane":invalid})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!((s.active_component, s.active_occurrence_path.clone(), s.active_sketch), context);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_certified_borders_follow_width_and_height_with_stable_entities() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":8,"height":10,"body_name":"Base","name":"BaseFeature"}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap();
|
||||
assert!(s.doc.sketch(id).unwrap().links.iter().any(|link| link.auto_face));
|
||||
let old_ids: Vec<_> = s.doc.sketch(id).unwrap().curves.iter().map(|curve| curve.id.clone()).collect();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let dimensions = s.doc.find_feature("BaseFeature").unwrap().param_names.clone();
|
||||
run(&mut s, "parameters.change", json!({"name":dimensions[0],"expression":"20 mm"}));
|
||||
run(&mut s, "parameters.change", json!({"name":dimensions[2],"expression":"15 mm"}));
|
||||
let resolved = s.model.state();
|
||||
let sketch = resolved.sketch(id).unwrap();
|
||||
assert!((sketch.plane.origin.z - 15.).abs() < 1e-6);
|
||||
assert!(sketch.sketch.links.iter().all(|link| !link.lost));
|
||||
assert_eq!(sketch.sketch.curves.iter().map(|curve| curve.id.clone()).collect::<Vec<_>>(), old_ids);
|
||||
let max_x = sketch.sketch.points.iter().map(|point| point.pos.x).fold(f64::NEG_INFINITY, f64::max);
|
||||
let min_x = sketch.sketch.points.iter().map(|point| point.pos.x).fold(f64::INFINITY, f64::min);
|
||||
let max_y = sketch.sketch.points.iter().map(|point| point.pos.y).fold(f64::NEG_INFINITY, f64::max);
|
||||
let min_y = sketch.sketch.points.iter().map(|point| point.pos.y).fold(f64::INFINITY, f64::min);
|
||||
assert!(((max_x - min_x) * (max_y - min_y) - 160.).abs() < 1e-5);
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.state().sketch(id).unwrap().sketch.links.iter().all(|link| !link.lost));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_offset_retains_exact_attachment_during_height_change() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":pick,"offset":"3 mm","project_edges":false}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let height = s.doc.find_feature("BaseFeature").unwrap().param_names[2].clone();
|
||||
run(&mut s, "parameters.change", json!({"name":height,"expression":"20 mm"}));
|
||||
assert!((s.model.state().sketch(id).unwrap().plane.origin.z - 23.).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_nested_source_and_cross_owner_pending_paths_capture_only_involved_positions() {
|
||||
let mut s = Session::default();
|
||||
let parent = run(&mut s, "component.create", json!({"name":"Parent","parent":0}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":parent["occurrence"],"translate":[40,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":parent["component"],"occurrence":second,"path":[second]}));
|
||||
let child = run(&mut s, "component.create", json!({"name":"Child"}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"}));
|
||||
let child_occ = child["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[10,0,0],"capture":false}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":child_occ,"translate":[0,20,0],"capture":false}));
|
||||
let unrelated = run(&mut s, "component.create", json!({"name":"Unrelated","parent":0}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":unrelated["occurrence"],"translate":[300,0,0],"capture":false}));
|
||||
let author = run(&mut s, "component.create", json!({"name":"Author","parent":0}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":author["occurrence"],"translate":[100,0,0],"angle":"90 deg","capture":false}));
|
||||
let world = s.world_state();
|
||||
let key = s
|
||||
.placed_document()
|
||||
.body_frames_with_source(&s.model.state())
|
||||
.into_iter()
|
||||
.find(|(_, source, _, path, _)| source == "Base" && *path == vec![second, child_occ])
|
||||
.unwrap()
|
||||
.0;
|
||||
assert!(world.body(&key).is_some());
|
||||
let mut pick = top_pick(&s, &key);
|
||||
pick["path"] = json!([second, child_occ]);
|
||||
let before = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap();
|
||||
let solvecraft_doc::FeatureKind::Sketch { plane, .. } = &s.doc.feature(id).unwrap().kind else { panic!("not sketch") };
|
||||
let anchor = super::anchor(plane).unwrap();
|
||||
assert_eq!(anchor.path, vec![second, child_occ]);
|
||||
assert_eq!(anchor.author_path, vec![author["occurrence"].as_u64().unwrap()]);
|
||||
assert_eq!(s.pending_moves.len(), 1);
|
||||
assert!(s.pending_moves.contains_key(&unrelated["occurrence"].as_u64().unwrap()));
|
||||
let saved = s.doc.to_json();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
let mut reopened = Session::new(solvecraft_doc::Document::from_json(&saved).unwrap());
|
||||
assert!(reopened.model.result(id).unwrap().error.is_none());
|
||||
run(&mut reopened, "component.activate", json!({"component":0}));
|
||||
let original = reopened.doc.to_json();
|
||||
let undo = reopened.undo.len();
|
||||
let dirty = reopened.is_dirty();
|
||||
run(&mut reopened, "sketch.edit", json!({"sketch":id}));
|
||||
assert_eq!(reopened.doc.to_json(), original);
|
||||
assert_eq!(reopened.undo.len(), undo);
|
||||
assert_eq!(reopened.is_dirty(), dirty);
|
||||
assert_eq!(reopened.active_component, author["component"].as_u64().unwrap());
|
||||
assert_eq!(reopened.active_occurrence_path, Some(vec![author["occurrence"].as_u64().unwrap()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_rebind_freezes_current_resized_borders_and_only_detaches_auto_links() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":8,"height":10,"body_name":"Base","name":"BaseFeature"}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.project", json!({"refs":[{"origin":true}]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let length = s.doc.find_feature("BaseFeature").unwrap().param_names[0].clone();
|
||||
run(&mut s, "parameters.change", json!({"name":length,"expression":"20 mm"}));
|
||||
let visible = s.model.state().sketch(id).unwrap().sketch.clone();
|
||||
let auto: Vec<_> = visible.links.iter().filter(|link| link.auto_face).map(|link| link.id.clone()).collect();
|
||||
let manual: Vec<_> = visible.links.iter().filter(|link| !link.auto_face).map(|link| link.id.clone()).collect();
|
||||
let before = s.doc.to_json();
|
||||
let mut pick = top_pick(&s, "Base");
|
||||
pick["fixed"] = json!(true);
|
||||
run(&mut s, "sketch.redefine", json!({"sketch":id,"plane":pick}));
|
||||
let fixed = s.doc.sketch(id).unwrap();
|
||||
assert!(auto.iter().all(|id| fixed.link(id).is_none()));
|
||||
assert!(manual.iter().all(|id| fixed.link(id).is_some()));
|
||||
assert_eq!(
|
||||
fixed.curves.iter().map(|curve| curve.id.clone()).collect::<Vec<_>>(),
|
||||
visible.curves.iter().map(|curve| curve.id.clone()).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(fixed.points.iter().map(|point| point.pos).collect::<Vec<_>>(), visible.points.iter().map(|point| point.pos).collect::<Vec<_>>());
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.result(id).unwrap().error.is_none());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_anchor_timeline_copy_remaps_producer_and_refuses_external_translation_atomically() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
let id = run(&mut s, "sketch.create", json!({"name":"Attached","plane":pick}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "timeline.copy", json!({"features":["Attached"]}));
|
||||
let before = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let error = s.execute("timeline.paste", &json!({"translate":[30,0,0]})).unwrap_err();
|
||||
assert!(error.to_string().contains("external exact face"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
run(&mut s, "timeline.copy", json!({"features":["BaseFeature","Attached"]}));
|
||||
let pasted = run(&mut s, "timeline.paste", json!({"translate":[30,0,0]}));
|
||||
assert!(pasted["errors"].as_array().unwrap().is_empty(), "{pasted}");
|
||||
let new_id = pasted["pasted"][1]["id"].as_u64().unwrap();
|
||||
let solvecraft_doc::FeatureKind::Sketch { plane, .. } = &s.doc.feature(new_id).unwrap().kind else { panic!("not sketch") };
|
||||
let anchor = super::anchor(plane).unwrap();
|
||||
assert_ne!(anchor.target.origin_feature, s.doc.find_feature("BaseFeature").unwrap().id);
|
||||
assert_ne!(anchor.target.body, "Base");
|
||||
assert!(s.model.state().sketch(new_id).unwrap().sketch.links.iter().all(|link| !link.lost));
|
||||
assert!(s.model.state().sketch(id).unwrap().sketch.links.iter().all(|link| !link.lost));
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.result(new_id).unwrap().error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_anchor_deep_clone_is_independent_and_rename_preserves_certified_projection() {
|
||||
let mut s = Session::default();
|
||||
let part = run(&mut s, "component.create", json!({"name":"Part","parent":0}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
let id = run(&mut s, "sketch.create", json!({"name":"Attached","plane":pick}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let height = s.doc.find_feature("BaseFeature").unwrap().param_names[2].clone();
|
||||
let pasted = run(&mut s, "component.paste_new", json!({"component":part["component"],"translate":[30,0,0]}));
|
||||
let owner = pasted["component"].as_u64().unwrap();
|
||||
let new_feature = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.find(|feature| feature.component == owner && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. }))
|
||||
.unwrap();
|
||||
let new_id = new_feature.id;
|
||||
let solvecraft_doc::FeatureKind::Sketch { plane, .. } = &new_feature.kind else { panic!("not sketch") };
|
||||
let anchor = super::anchor(plane).unwrap();
|
||||
let body = anchor.target.body.clone();
|
||||
assert_eq!(anchor.component, owner);
|
||||
run(&mut s, "parameters.change", json!({"name":height,"expression":"20 mm"}));
|
||||
assert!((s.model.state().sketch(id).unwrap().plane.origin.z - 20.).abs() < 1e-6);
|
||||
assert!((s.model.state().sketch(new_id).unwrap().plane.origin.z - 10.).abs() < 1e-6);
|
||||
run(&mut s, "body.rename", json!({"body":body,"name":"Independent"}));
|
||||
assert!(s.model.result(new_id).unwrap().error.is_none());
|
||||
assert!(s.model.state().sketch(new_id).unwrap().sketch.links.iter().all(|link| !link.lost));
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.result(new_id).unwrap().error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_or_fixed_rebind_needs_explicit_author_path_for_repeated_owner() {
|
||||
let mut s = Session::default();
|
||||
let part = run(&mut s, "component.create", json!({"name":"Part","parent":0}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"}));
|
||||
let mut pick = top_pick(&s, "Base");
|
||||
pick["fixed"] = json!(true);
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":part["occurrence"],"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("sketch.redefine", &json!({"sketch":id,"plane":pick})).unwrap_err();
|
||||
assert!(error.to_string().contains("ambiguous"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "sketch.redefine", json!({"sketch":id,"plane":pick,"author_path":[second]}));
|
||||
let solvecraft_doc::FeatureKind::Sketch { plane, .. } = &s.doc.feature(id).unwrap().kind else { panic!("not sketch") };
|
||||
assert_eq!(super::anchor(plane).unwrap().author_path, vec![second]);
|
||||
assert_eq!(s.active_component, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_producer_with_same_body_name_cannot_heal_lost_anchor() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"Original"}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "timeline.suppress", json!({"feature":"Original","suppressed":true}));
|
||||
run(&mut s, "timeline.rollback", json!({"position":1}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":30,"body_name":"Base","name":"Replacement"}));
|
||||
run(&mut s, "timeline.rollback", json!({}));
|
||||
assert!(s.model.state().body("Base").is_some());
|
||||
assert!(s.model.result(id).unwrap().error.is_some());
|
||||
let before = s.doc.to_json();
|
||||
let pick = top_pick(&s, "Base");
|
||||
run(&mut s, "sketch.redefine", json!({"sketch":id,"plane":pick}));
|
||||
assert!((s.model.state().sketch(id).unwrap().plane.origin.z - 30.).abs() < 1e-6);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert!(s.model.result(id).unwrap().error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freezing_rebuilt_arc_borders_keeps_authored_dimension_entity_indices() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name":"turn","expression":"270 deg","unit":"deg"}));
|
||||
let profile = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
|
||||
let arc = run(
|
||||
&mut s,
|
||||
"sketch.draw.dimensioned",
|
||||
json!({"command":"sketch.arc.center_point","params":{"center":[0,0],"start":[10,0],"sweep":270},"dimensions":{"radius":10,"sweep":"turn"}}),
|
||||
)["curves"][0]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
run(&mut s, "sketch.line", json!({"points":[format!("{arc}.end"),format!("{arc}.start")],"infer":false}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":profile,"distance":10,"operation":"new","body_name":"Sector"}));
|
||||
let pick = top_pick(&s, "Sector");
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap();
|
||||
let circle = run(&mut s, "sketch.circle.center", json!({"center":[20,20],"radius":2}))["curves"][0].as_str().unwrap().to_owned();
|
||||
run(&mut s, "sketch.dimension", json!({"entities":[circle],"type":"radius","value":"2 mm"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before_count = s.model.state().sketch(id).unwrap().sketch.curves.len();
|
||||
run(&mut s, "parameters.change", json!({"name":"turn","expression":"300 deg"}));
|
||||
assert!(s.model.result(id).unwrap().error.is_none());
|
||||
let visible = s.model.state().sketch(id).unwrap().sketch.clone();
|
||||
assert_ne!(visible.curves.len(), before_count, "fixture must rebuild projected topology");
|
||||
let mut pick = top_pick(&s, "Sector");
|
||||
pick["fixed"] = json!(true);
|
||||
let before = s.doc.to_json();
|
||||
run(&mut s, "sketch.redefine", json!({"sketch":id,"plane":pick}));
|
||||
let frozen = s.doc.sketch(id).unwrap();
|
||||
let circle_index = frozen.curve_index(&circle).unwrap();
|
||||
let dimension = frozen
|
||||
.constraints
|
||||
.iter()
|
||||
.find(|constraint| matches!(constraint.kind, solvecraft_sketch::ConstraintKind::Radius { c, .. } if c == circle_index))
|
||||
.unwrap();
|
||||
assert!((solvecraft_sketch::measure_dimension(frozen, &dimension.kind).unwrap() - 2.).abs() < 1e-6);
|
||||
assert_eq!(frozen.curves.iter().map(|curve| &curve.id).collect::<Vec<_>>(), visible.curves.iter().map(|curve| &curve.id).collect::<Vec<_>>());
|
||||
assert!(s.model.result(id).unwrap().error.is_none());
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.result(id).unwrap().error.is_none());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angled_attachment_tracks_source_plane_and_reopens() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let solvecraft_doc::FeatureKind::Sketch { plane, .. } = &mut s.doc_mut().feature_mut(id).unwrap().kind else { panic!("not sketch") };
|
||||
*plane = solvecraft_doc::PlaneRef::AtAngle { base: Box::new(plane.clone()), axis_origin: Vec3::ZERO, axis_dir: Vec3::X, angle: "90 deg".into() };
|
||||
s.refresh();
|
||||
let height = s.doc.find_feature("BaseFeature").unwrap().param_names[2].clone();
|
||||
run(&mut s, "parameters.change", json!({"name":height,"expression":"20 mm"}));
|
||||
let placed = s.model.state().sketch(id).unwrap().plane;
|
||||
assert!((placed.origin.y + 20.).abs() < 1e-6);
|
||||
assert!(placed.origin.z.abs() < 1e-6);
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert_eq!(reopened.model.state().sketch(id).unwrap().plane, placed);
|
||||
}
|
||||
@@ -156,7 +156,7 @@ fn add_pieces(sk: &mut Sketch, segs: &[Seg2], start: Option<usize>, end: Option<
|
||||
Seg2::Line { .. } => CurveKind::Line { a: prev, b: to },
|
||||
Seg2::Cubic { p1, p2, .. } => {
|
||||
let (c1, c2) = (sk.add_point(p1, None)?, sk.add_point(p2, None)?);
|
||||
CurveKind::Spline { pts: vec![prev, c1, c2, to], control: true, degree: 3 }
|
||||
CurveKind::Spline { pts: vec![prev, c1, c2, to], control: true, degree: 3, periodic: false }
|
||||
}
|
||||
Seg2::Conic { apex, w, .. } => {
|
||||
let x = sk.add_point(apex, None)?;
|
||||
@@ -169,7 +169,7 @@ fn add_pieces(sk: &mut Sketch, segs: &[Seg2], start: Option<usize>, end: Option<
|
||||
pts.push(sk.add_point(*q, None)?);
|
||||
}
|
||||
pts.push(to);
|
||||
CurveKind::Spline { pts, control: true, degree: n }
|
||||
CurveKind::Spline { pts, control: true, degree: n, periodic: false }
|
||||
}
|
||||
Seg2::Arc { .. } => return Err(bad("trim", "unexpected arc piece")),
|
||||
};
|
||||
|
||||
@@ -89,7 +89,7 @@ fn add_geometry(sk: &mut Sketch, geom: &[Geom2], f: &dyn Fn(Vec2) -> Vec2, k: f6
|
||||
idx.push(i);
|
||||
}
|
||||
}
|
||||
(idx.len() >= 2).then_some(CurveKind::Spline { pts: idx, control: *control, degree: *degree })
|
||||
(idx.len() >= 2).then_some(CurveKind::Spline { pts: idx, control: *control, degree: *degree, periodic: false })
|
||||
}
|
||||
Geom2::Conic { a, apex, b, rho } => {
|
||||
let (a, x, b) = (pt(sk, *a)?, pt(sk, *apex)?, pt(sk, *b)?);
|
||||
|
||||
@@ -970,7 +970,7 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
idx.push(pb);
|
||||
idx.dedup();
|
||||
if idx.len() >= 2 {
|
||||
made.push(sk.add_curve(CurveKind::Spline { pts: idx, control: false, degree: 3 }, None)?);
|
||||
made.push(sk.add_curve(CurveKind::Spline { pts: idx, control: false, degree: 3, periodic: false }, None)?);
|
||||
}
|
||||
}
|
||||
Piece::Line(..) => {
|
||||
@@ -1002,14 +1002,26 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.offset.break";
|
||||
let (out, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
|
||||
let output = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must name an offset output"))?;
|
||||
let record = sk
|
||||
.freeform_offsets
|
||||
.iter()
|
||||
.position(|record| record.output == output)
|
||||
.ok_or_else(|| bad(cmd, "that curve has no free-form offset association"))?;
|
||||
// A parameter edit may have changed the evaluated source without
|
||||
// rewriting the stored fit points. Break must freeze the displayed,
|
||||
// current dimensioned geometry, rather than its stale stored predecessor.
|
||||
let (values, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&values, sk)?;
|
||||
if !solvecraft_sketch::solve(sk).ok() {
|
||||
return Err(bad(cmd, "cannot break an offset with conflicting source dimensions"));
|
||||
}
|
||||
sk.refresh_offsets()?;
|
||||
if !solvecraft_sketch::solve(sk).ok() {
|
||||
return Err(bad(cmd, "cannot break an offset with conflicting derived dimensions"));
|
||||
}
|
||||
sk.validate_periodic_splines()?;
|
||||
let ci = sk.curve_index(output).ok_or_else(|| bad(cmd, "missing offset output"))?;
|
||||
let indices = sk.curves[ci].kind.point_ids();
|
||||
sk.freeform_offsets.remove(record);
|
||||
|
||||
@@ -690,6 +690,119 @@ fn certified_offset_delete_output_undo_reopen_and_refuse_dependent_source() {
|
||||
run(&mut s, "sketch.delete", json!({"entities":[node]}));
|
||||
assert!(sketch(&s).freeform_offsets.is_empty());
|
||||
assert!(sketch(&s).curve_index(&id).is_none());
|
||||
assert_eq!(sketch(&s).points.len(), 3, "all private derived nodes must be removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periodic_closed_offset_chain_break_drag_reopen_and_material_volume() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities":["e1"],"type":"major","value":10}));
|
||||
let source = sketch(&s).points.clone();
|
||||
let a = run(&mut s, "sketch.offset", json!({"curves":["e1"],"distance":-1,"chain":false}))["curves"][0].as_str().unwrap().to_string();
|
||||
let before_second = s.doc.to_json();
|
||||
let b = run(&mut s, "sketch.offset", json!({"curves":[a],"distance":-0.5,"chain":false}))["curves"][0].as_str().unwrap().to_string();
|
||||
assert_eq!(&sketch(&s).points[..source.len()], source.as_slice());
|
||||
let check = |session: &Session| {
|
||||
let sk = sketch(session);
|
||||
assert_eq!(sk.freeform_offsets.iter().map(|r| r.certificate).collect::<Vec<_>>(), [2, 2]);
|
||||
assert_eq!(solvecraft_sketch::find_profiles(&sk).len(), 3);
|
||||
for id in [&a, &b] {
|
||||
assert!(matches!(sk.curves[sk.curve_index(id).unwrap()].kind, CurveKind::Spline { periodic: true, .. }));
|
||||
}
|
||||
};
|
||||
check(&s);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before_second);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
check(&s);
|
||||
run(&mut s, "parameters.change", json!({"name":"d1","expression":12}));
|
||||
check(&s);
|
||||
let mut reopened =
|
||||
Session { doc: solvecraft_doc::format::read(&s.doc.to_json()).unwrap().into(), active_sketch: s.active_sketch, ..Default::default() };
|
||||
reopened.refresh();
|
||||
check(&reopened);
|
||||
let attached = reopened.doc.to_json();
|
||||
let exact_before = sketch(&reopened).exact_segs(sketch(&reopened).curve_index(&b).unwrap()).unwrap();
|
||||
run(&mut reopened, "sketch.offset.break", json!({"curve":b}));
|
||||
let sk = sketch(&reopened);
|
||||
assert_eq!(sk.exact_segs(sk.curve_index(&b).unwrap()).unwrap(), exact_before);
|
||||
let points = sk.curves[sk.curve_index(&b).unwrap()].kind.point_ids();
|
||||
let first = sk.points[points[0]].id.clone();
|
||||
let from = sk.points[points[0]].pos;
|
||||
run(&mut reopened, "sketch.drag", json!({"entity":first,"from":from,"to":[from.x+0.2,from.y+0.1]}));
|
||||
let moved = sketch(&reopened);
|
||||
moved.validate_periodic_splines().unwrap();
|
||||
let indices = moved.curves[moved.curve_index(&b).unwrap()].kind.point_ids();
|
||||
assert_eq!(moved.point(indices[0]), moved.point(*indices.last().unwrap()));
|
||||
assert!(moved.point(indices[0]).unwrap().dist(from) > 0.1);
|
||||
assert_eq!(solvecraft_doc::format::read(&reopened.doc.to_json()).unwrap().to_json(), reopened.doc.to_json());
|
||||
run(&mut reopened, "edit.undo", json!({}));
|
||||
run(&mut reopened, "edit.undo", json!({}));
|
||||
assert_eq!(reopened.doc.to_json(), attached);
|
||||
// An independent ring area multiplied by height probes actual profile material.
|
||||
let regions = solvecraft_sketch::find_profiles(&sketch(&s));
|
||||
let selected = regions.iter().enumerate().min_by(|(_, x), (_, y)| x.area.total_cmp(&y.area)).unwrap();
|
||||
let n = 4096;
|
||||
let h = std::f64::consts::TAU / n as f64;
|
||||
let perimeter = (0..=n)
|
||||
.map(|i| {
|
||||
let t = i as f64 * h;
|
||||
(144.0 * t.sin().powi(2) + 25.0 * t.cos().powi(2)).sqrt()
|
||||
* if i == 0 || i == n {
|
||||
1.0
|
||||
} else if i % 2 == 0 {
|
||||
2.0
|
||||
} else {
|
||||
4.0
|
||||
}
|
||||
})
|
||||
.sum::<f64>()
|
||||
* h
|
||||
/ 3.0;
|
||||
let analytic_ring = 0.5 * perimeter + 1.25 * std::f64::consts::PI;
|
||||
assert!((selected.1.area - analytic_ring).abs() < 0.03);
|
||||
let expected = analytic_ring * 3.0;
|
||||
let sketch_id = s.active_sketch.unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":sketch_id,"profiles":[selected.0],"distance":3,"operation":"new"}));
|
||||
let state = s.world_state();
|
||||
let volume = solvecraft_kernel::measure(&state.bodies.last().unwrap().body).unwrap().volume;
|
||||
assert!((volume - expected).abs() < 0.05, "{volume} vs {expected}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periodic_fit_creator_keeps_legacy_and_refuses_hostile_intent_atomically() {
|
||||
let mut s = new_sketch();
|
||||
let p = json!({"points":[[0,0],[4,0],[3,2],[-1,1]],"periodic":true});
|
||||
let id = run(&mut s, "sketch.spline.fit_point", p.clone())["curves"][0].as_str().unwrap().to_string();
|
||||
let sk = sketch(&s);
|
||||
let i = sk.curve_index(&id).unwrap();
|
||||
assert!(matches!(sk.curves[i].kind, CurveKind::Spline { periodic: true, .. }));
|
||||
sk.validate_periodic_splines().unwrap();
|
||||
assert_eq!(solvecraft_sketch::find_profiles(&sk).len(), 1);
|
||||
let before = s.doc.to_json();
|
||||
let history = s.undo.len();
|
||||
for (cmd, p) in [
|
||||
("sketch.spline.control_point", p),
|
||||
("sketch.spline.fit_point", json!({"points":[[0,0],[1,0],[0,0],[1,0]],"periodic":true})),
|
||||
("sketch.spline.fit_point", json!({"points":[[0,0],[1,0],[1,1]],"periodic":"yes"})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
}
|
||||
let indices = sk.curves[i].kind.point_ids();
|
||||
let point = sk.points[indices[1]].id.clone();
|
||||
let target = sk.points[indices[0]].pos;
|
||||
let redo = s.redo.len();
|
||||
assert!(s.execute("sketch.drag", &json!({"entity":point,"from":sk.points[indices[1]].pos,"to":target})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
assert_eq!(s.redo.len(), redo);
|
||||
let legacy =
|
||||
run(&mut s, "sketch.spline.fit_point", json!({"points":[[0,0],[4,0],[3,2],[-1,1],[0,0]]}))["curves"][0].as_str().unwrap().to_string();
|
||||
assert!(matches!(sketch(&s).curves[sketch(&s).curve_index(&legacy).unwrap()].kind, CurveKind::Spline { periodic: false, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -152,8 +152,21 @@ pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) ->
|
||||
let (vals, _) = doc.param_values();
|
||||
let st = s.model.state_before(id);
|
||||
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)
|
||||
let attachment = doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
solvecraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
|
||||
_ => None,
|
||||
});
|
||||
let st = match attachment {
|
||||
Some(anchor) => solvecraft_doc::state_in_face_anchor_frame(&doc, &st, anchor, comp),
|
||||
None => Ok(solvecraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp))),
|
||||
};
|
||||
let Ok(st) = st else {
|
||||
return vec!["lost face attachment; reattach explicitly".into()];
|
||||
};
|
||||
match attachment {
|
||||
Some(anchor) => solvecraft_doc::project::refresh_face_anchor_links(&doc, &vals, &st, &plane, sk, anchor),
|
||||
None => 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.
|
||||
@@ -164,10 +177,24 @@ pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, ki
|
||||
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_at(&doc, &st, comp, owner_frame(s, &doc, comp));
|
||||
let attachment = doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
solvecraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
|
||||
_ => None,
|
||||
});
|
||||
let st = match attachment {
|
||||
Some(anchor) => solvecraft_doc::state_in_face_anchor_frame(&doc, &st, anchor, comp),
|
||||
None => Ok(solvecraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp))),
|
||||
};
|
||||
let st = st?;
|
||||
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())
|
||||
let name = if let LinkSource::Face { body, at } = &r.source {
|
||||
st.body(body).and_then(|body| {
|
||||
if let Some(index) = r.face_index {
|
||||
solvecraft_doc::naming::face_names(body).get(index).filter(|name| !name.is_empty()).cloned()
|
||||
} else {
|
||||
solvecraft_doc::naming::face_name_at_body(body, *at)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -455,17 +482,53 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Project the edges of the face a new sketch was created on (Fusion's default).
|
||||
pub(super) fn auto_project_face(s: &mut Session, id: u64, at: Vec3) -> Result<Option<String>> {
|
||||
/// Private compatibility projection for generated face profiles. Named legacy links follow
|
||||
/// edits, but cannot provide the construction certificate required by public attachments.
|
||||
pub(super) fn project_internal_face(s: &mut Session, id: u64, body: &str, index: usize) -> Result<String> {
|
||||
let mut doc = (*s.doc).clone();
|
||||
let plane = sketch_plane(&doc, id)?;
|
||||
let raw = s.model.state_before(id);
|
||||
let component = doc.feature(id).map(|feature| feature.component).unwrap_or(0);
|
||||
let state = solvecraft_doc::state_in_frame_at(&doc, &raw, component, owner_frame(s, &doc, component));
|
||||
let resolved = solvecraft_doc::project::resolve_face_indices(&state, &plane, LinkKind::Project, body, &[index])?;
|
||||
let face_name = state.body(body).and_then(|body| solvecraft_doc::naming::face_names(body).get(index).cloned());
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
let l = match add_link(s, &doc, id, &mut sk, LinkKind::Project, LinkSource::Face { body: String::new(), at }) {
|
||||
Ok(l) => l,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
let link = sk.add_link_with_wires(LinkKind::Project, resolved.source, &resolved.geom, &resolved.wires)?;
|
||||
if let Some(entry) = sk.links.iter_mut().find(|entry| entry.id == link) {
|
||||
entry.face_name = face_name;
|
||||
}
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
Ok(Some(l))
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
/// Project the edges of the face a new sketch was created on (Fusion's default).
|
||||
pub(super) fn auto_project_face(s: &mut Session, id: u64, _at: Vec3) -> Result<Option<String>> {
|
||||
let mut doc = (*s.doc).clone();
|
||||
let Some(anchor) = doc
|
||||
.feature(id)
|
||||
.and_then(|feature| match &feature.kind {
|
||||
solvecraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
|
||||
_ => None,
|
||||
})
|
||||
.cloned()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let raw = s.model.state_before(id);
|
||||
let author = doc.feature(id).map(|feature| feature.component).unwrap_or(0);
|
||||
let state = solvecraft_doc::state_in_face_anchor_frame(&doc, &raw, &anchor, author)?;
|
||||
let plane = doc.resolve_face_anchor(&anchor, author, &raw)?;
|
||||
let resolved = solvecraft_doc::project::resolve_anchored_face(&doc, &state, &plane, LinkKind::Project, &anchor)?;
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
let link = sk.add_link_with_wires(LinkKind::Project, resolved.source, &resolved.geom, &resolved.wires)?;
|
||||
if let Some(link) = sk.links.iter_mut().find(|entry| entry.id == link) {
|
||||
link.face_name = Some(anchor.target.name.clone());
|
||||
link.auto_face = true;
|
||||
}
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
Ok(Some(link))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -13,6 +13,68 @@ fn ids(v: &Value) -> Vec<String> {
|
||||
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_projection_captures_the_requested_coincident_body_producer() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Control","name":"ControlFeature"}));
|
||||
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"body_name":"Target","name":"TargetFeature"}));
|
||||
let producer = s.doc.find_feature("TargetFeature").unwrap().id;
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":"XY","offset":10}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.project", json!({"refs":[{"body":"Target","face":[1,1,10]}]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let initial_state = s.model.state();
|
||||
let initial = &initial_state.sketch(id).unwrap().sketch.links[0];
|
||||
assert_eq!(initial.face_name, Some(format!("F{producer}:+z")));
|
||||
assert!(!initial.lost);
|
||||
let height = s.doc.find_feature("TargetFeature").unwrap().param_names[2].clone();
|
||||
run(&mut s, "parameters.change", json!({"name":height,"expression":"20 mm"}));
|
||||
let verify = |session: &Session| {
|
||||
let state = session.model.state();
|
||||
let sk = &state.sketch(id).unwrap().sketch;
|
||||
assert!(!sk.links[0].lost);
|
||||
assert_eq!(sk.links[0].face_name, Some(format!("F{producer}:+z")));
|
||||
assert!(matches!(&sk.links[0].source,solvecraft_sketch::LinkSource::Face{body,at} if body=="Target" && (at.z-20.).abs()<1e-6));
|
||||
assert!(sk.points.iter().any(|point| point.pos.x > 19.));
|
||||
assert!(sk.points.iter().any(|point| point.pos.y > 19.));
|
||||
assert!((session.world_state().body("Control").unwrap().mesh().bounds().max.z - 10.).abs() < 1e-6);
|
||||
};
|
||||
verify(&s);
|
||||
verify(&Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_projection_retains_selected_face_when_closest_point_lands_on_shared_edge() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"body_name":"Target"}));
|
||||
let state = s.model.state();
|
||||
let body = state.body("Target").unwrap();
|
||||
let source = solvecraft_sketch::LinkSource::Face { body: "Target".into(), at: solvecraft_geom::Vec3::new(-0.1, 5., 10.5) };
|
||||
let resolved = solvecraft_doc::project::resolve(
|
||||
&s.doc,
|
||||
&std::collections::BTreeMap::new(),
|
||||
&state,
|
||||
&solvecraft_geom::Plane::XY,
|
||||
solvecraft_sketch::LinkKind::Project,
|
||||
&source,
|
||||
)
|
||||
.unwrap();
|
||||
let index = resolved.face_index.unwrap();
|
||||
let expected = solvecraft_doc::naming::face_names(body)[index].clone();
|
||||
let solvecraft_sketch::LinkSource::Face { at, .. } = resolved.source else { panic!("not face") };
|
||||
assert!(at.x.abs() < 1e-6 && (at.z - 10.).abs() < 1e-6, "closestpoint must be on shared top/wall edge: {at:?}");
|
||||
let plane = body.body.face_plane(index).unwrap();
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":{"origin":plane.origin,"x_dir":plane.x,"y_dir":plane.y}}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.project", json!({"refs":[{"body":"Target","face":[-0.1,5.,10.5]}]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let current = s.model.state();
|
||||
let sk = ¤t.sketch(id).unwrap().sketch;
|
||||
assert_eq!(sk.links[0].face_name, Some(expected));
|
||||
assert!(!sk.links[0].lost);
|
||||
assert_eq!(sk.curves.len(), 4);
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert_eq!(reopened.model.state().sketch(id).unwrap().sketch.curves, sk.curves);
|
||||
}
|
||||
|
||||
/// A 40 x 30 x 20 box from a sketch whose width is the user parameter `width`.
|
||||
fn box_part(s: &mut Session) {
|
||||
run(s, "parameters.change", json!({"name": "width", "expression": "40 mm"}));
|
||||
|
||||
@@ -369,12 +369,25 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
|
||||
out["params"] = inner;
|
||||
return Some(out);
|
||||
}
|
||||
// Exact face capture retains the original world pick and occurrence identity.
|
||||
if matches!(id, "sketch.create" | "sketch.redefine") && params.get("plane").and_then(|plane| plane.get("face")).is_some() {
|
||||
return None;
|
||||
}
|
||||
let keys = table(id);
|
||||
if keys.is_empty() || !placed(s) {
|
||||
return None;
|
||||
}
|
||||
let obj = params.as_object()?;
|
||||
let mut m = Mapper { s, f: frames(s), first: None, changed: false, here: false };
|
||||
let mut command_frames = frames(s);
|
||||
if id == "sketch.redefine" {
|
||||
let key = params.get("sketch").and_then(|value| value.as_str().map(str::to_string).or_else(|| value.as_u64().map(|id| id.to_string())))?;
|
||||
let feature = s.doc.find_feature(&key)?;
|
||||
let path = crate::cmd::author_path_for_rebind(s, feature, params, id).ok()?;
|
||||
let frame = solvecraft_doc::EmbossFace::component_frame(&s.placed_document(), feature.component, &path).ok()?;
|
||||
command_frames.active = frame;
|
||||
command_frames.sketch = frame;
|
||||
}
|
||||
let mut m = Mapper { s, f: command_frames, first: None, changed: false, here: false };
|
||||
let body = obj.get("body").and_then(Value::as_str).map(str::to_string);
|
||||
let mut out: Map<String, Value> = obj.clone();
|
||||
// Picks first: their frame turns the directions.
|
||||
|
||||
@@ -75,8 +75,9 @@ fn world_picks_map_into_the_component() {
|
||||
// The edge along X at the top front: world (65, 0, 10).
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[65, 0, 10]], "radius": 2}));
|
||||
assert!(volume(&mut s) < v0 - 1.0, "the fillet cut material");
|
||||
// A sketch on the moved top face sits at world z = 10 over the moved block.
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [70, 10, 10]}, "name": "Pad"}));
|
||||
// The modified face has no certified lineage; explicitly freeze its picked plane.
|
||||
// Its world pick still maps into the moved component exactly once.
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [70, 10, 10],"fixed":true}, "name": "Pad"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [20, 10], "radius": 2, "id": "PadCircle"}));
|
||||
let sketch = run(&mut s, "sketch.inspect", json!({"sketch": "Pad"}));
|
||||
let center = sketch["curves"].as_array().unwrap().iter().find(|c| c["id"] == "PadCircle").unwrap()["center"].as_str().unwrap().to_string();
|
||||
|
||||
@@ -33,3 +33,79 @@ fn suppressing_a_patterned_feature_removes_its_copies() {
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Standoff", "suppressed": false}));
|
||||
assert!((volume(&s, "Case") - full).abs() < 1e-6 * full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs() {
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_str(include_str!("../../../examples/acceptance/sensor_enclosure.json")).unwrap();
|
||||
s.run_script(&script).unwrap();
|
||||
for (name, expression) in [("wall", "2.5 mm"), ("height", "30 mm"), ("board_d", "50 mm")] {
|
||||
run(&mut s, "parameters.change", json!({"name":name,"expression":expression}));
|
||||
}
|
||||
assert!(s.model.results.iter().all(|result| result.error.is_none()));
|
||||
for (name, z) in [("PCB standoff", 2.5), ("Lid boss", 2.5), ("Lid lip", 30.), ("Lid screw", 34.)] {
|
||||
let feature =
|
||||
s.doc.features.iter().find(|feature| feature.name == name && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap();
|
||||
assert!((s.model.state().sketch(feature.id).unwrap().plane.origin.z - z).abs() < 1e-6);
|
||||
}
|
||||
let usb =
|
||||
s.doc.features.iter().find(|feature| feature.name == "USB" && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap();
|
||||
assert!((s.model.state().sketch(usb.id).unwrap().plane.origin.y + 35.5).abs() < 1e-6);
|
||||
let assert_material = |session: &Session| {
|
||||
let state = session.world_state();
|
||||
let case = state.body("Case").unwrap().mesh();
|
||||
let lid = state.body("Lid").unwrap().mesh();
|
||||
assert!(case.contains(solvecraft_geom::Vec3::new(-24.5, -21.5, 7.)));
|
||||
assert!(!case.contains(solvecraft_geom::Vec3::new(-26.5, -21.5, 7.)));
|
||||
assert!(!case.contains(solvecraft_geom::Vec3::new(-10., -35.25, 11.35)));
|
||||
assert!(case.contains(solvecraft_geom::Vec3::new(-10., -35.25, 15.)));
|
||||
assert!(
|
||||
lid.contains(solvecraft_geom::Vec3::new(0., 32.2, 29.)),
|
||||
"lid bounds {:?}; lip {:?}; probes {:?}",
|
||||
lid.bounds(),
|
||||
session
|
||||
.model
|
||||
.state()
|
||||
.sketch(
|
||||
session
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.find(|feature| feature.name == "Lid lip" && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. }))
|
||||
.unwrap()
|
||||
.id
|
||||
)
|
||||
.unwrap()
|
||||
.sketch
|
||||
.links,
|
||||
(20..35).map(|y| (y, lid.contains(solvecraft_geom::Vec3::new(0., f64::from(y) + 0.2, 29.)))).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(!lid.contains(solvecraft_geom::Vec3::new(0., 32.9, 29.)));
|
||||
assert!(!lid.contains(solvecraft_geom::Vec3::new(-33.5, -28.5, 32.)));
|
||||
assert!(lid.contains(solvecraft_geom::Vec3::new(-31.5, -28.5, 32.)));
|
||||
};
|
||||
assert_material(&s);
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.results.iter().all(|result| result.error.is_none()));
|
||||
assert_material(&reopened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosure_inward_offset_collapse_reports_an_error_instead_of_a_stale_lip() {
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_str(include_str!("../../../examples/acceptance/sensor_enclosure.json")).unwrap();
|
||||
s.run_script(&script).unwrap();
|
||||
run(&mut s, "parameters.change", json!({"name":"wall","expression":"3 mm"}));
|
||||
// R4 outer corners leave R1 inner corners. The requested 1.4 mm inward
|
||||
// offset needs a topology transition, which this offset operation refuses.
|
||||
let lip = s.doc.features.iter().find(|f| f.name == "Lid lip" && matches!(f.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap();
|
||||
let result = s.model.result(lip.id).unwrap();
|
||||
assert!(result.error.as_deref().is_some_and(|error| error.contains("offset collapses an arc")), "{result:?}");
|
||||
assert!(result.warning.as_deref().is_none_or(|warning| !warning.contains("identity is ambiguous")), "{result:?}");
|
||||
let state = s.model.state();
|
||||
assert!(state.sketch(lip.id).is_none());
|
||||
assert!(!state.body("Lid").unwrap().mesh().contains(solvecraft_geom::Vec3::new(0., 27.2, 24.)));
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.result(lip.id).unwrap().error.as_deref().is_some_and(|error| error.contains("offset collapses an arc")));
|
||||
assert!(reopened.model.state().sketch(lip.id).is_none());
|
||||
}
|
||||
@@ -30,7 +30,8 @@ fn enclosure() -> Session {
|
||||
{"command": "solid.extrude", "params": {"distance": "height", "body_name": "Enclosure"}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[40, 30, 15], [-40, 30, 15], [-40, -30, 15], [40, -30, 15]], "radius": 6}},
|
||||
{"command": "solid.shell", "params": {"faces": [[0, 0, 30]], "thickness": "wall"}},
|
||||
{"command": "sketch.create", "params": {"plane": {"face": [40, 10, 20]}, "name": "USB port"}},
|
||||
{"command": "sketch.create", "params": {"plane": "YZ", "offset":"width / 2", "name": "USB port"}},
|
||||
{"command": "sketch.project", "params": {"refs":[{"face":[40,10,20],"body":"Enclosure"}]}},
|
||||
{"command": "sketch.rectangle.center", "params": {"center": [0, 12], "corner": [6, 15.5]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": "-wall", "operation": "cut", "profiles": [1]}}
|
||||
@@ -58,6 +59,18 @@ fn a_port_picked_by_index_stays_a_cut_after_a_width_change() {
|
||||
assert!(after[0].1 > before[0].1 + 1000.0, "{before:?} {after:?}");
|
||||
let id = s.doc.features.last().unwrap().id;
|
||||
assert!(s.model.result(id).is_some_and(|r| r.error.is_none()), "{:?}", s.model.result(id));
|
||||
let port = s.doc.find_feature("USB port").unwrap().id;
|
||||
assert!((s.model.state().sketch(port).unwrap().plane.origin.x - 50.).abs() < 1e-6);
|
||||
let world = s.world_state();
|
||||
let mesh = world.body("Enclosure").unwrap().mesh();
|
||||
assert!(!mesh.contains(solvecraft_geom::Vec3::new(49., 0., 12.)));
|
||||
assert!(mesh.contains(solvecraft_geom::Vec3::new(49., 0., 20.)));
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.result(id).unwrap().error.is_none());
|
||||
let world = reopened.world_state();
|
||||
let mesh = world.body("Enclosure").unwrap().mesh();
|
||||
assert!(!mesh.contains(solvecraft_geom::Vec3::new(49., 0., 12.)));
|
||||
assert!(mesh.contains(solvecraft_geom::Vec3::new(49., 0., 20.)));
|
||||
}
|
||||
|
||||
/// A pick the sketch no longer has is an error on the feature, never a different profile.
|
||||
|
||||
@@ -21,14 +21,14 @@ pub fn script() -> Value {
|
||||
{"command": "sketch.circle.center", "params": {"center": [68, 25], "diameter": 8}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": "thickness", "operation": "cut", "name": "Holes"}},
|
||||
{"command": "sketch.create", "params": {"plane": {"face": [40, 25, 8]}, "name": "Boss"}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "offset": "thickness", "name": "Boss"}},
|
||||
{"command": "sketch.circle.center", "params": {"center": [40, 25], "diameter": 24}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 14, "operation": "join", "name": "Boss"}},
|
||||
{"command": "sketch.create", "params": {"plane": {"face": [40, 37, 22]}, "name": "Bore"}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "offset": "thickness + 14 mm", "name": "Bore"}},
|
||||
{"command": "sketch.circle.center", "params": {"center": [40, 25], "diameter": 12}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 22, "direction": "negative", "operation": "cut", "name": "Bore"}},
|
||||
{"command": "solid.extrude", "params": {"distance": "thickness + 14 mm", "direction": "negative", "operation": "cut", "name": "Bore"}},
|
||||
]})
|
||||
}
|
||||
|
||||
@@ -139,6 +139,44 @@ fn vents() -> Value {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn plate_boss_and_bore_planes_follow_thickness_without_weak_face_references() {
|
||||
let mut session = crate::Session::default();
|
||||
session.run_script(&super::script()).unwrap();
|
||||
let boss = session
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.find(|feature| feature.name == "Boss" && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. }))
|
||||
.unwrap()
|
||||
.id;
|
||||
let bore = session
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.find(|feature| feature.name == "Bore" && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. }))
|
||||
.unwrap()
|
||||
.id;
|
||||
session.execute("parameters.change", &serde_json::json!({"name":"thickness","expression":"12 mm"})).unwrap();
|
||||
assert!(session.model.results.iter().all(|result| result.error.is_none()));
|
||||
assert!((session.model.state().sketch(boss).unwrap().plane.origin.z - 12.).abs() < 1e-6);
|
||||
assert!((session.model.state().sketch(bore).unwrap().plane.origin.z - 26.).abs() < 1e-6);
|
||||
let world = session.world_state();
|
||||
let plate = world.body("Plate").unwrap().mesh();
|
||||
assert!((plate.bounds().max.z - 26.).abs() < 1e-6);
|
||||
assert!(plate.contains(solvecraft_geom::Vec3::new(5., 25., 10.)));
|
||||
assert!(plate.contains(solvecraft_geom::Vec3::new(40., 35., 24.)));
|
||||
assert!(!plate.contains(solvecraft_geom::Vec3::new(40., 25., 24.)));
|
||||
assert!(!plate.contains(solvecraft_geom::Vec3::new(40., 25., 2.)));
|
||||
assert!(plate.contains(solvecraft_geom::Vec3::new(40., 35., 2.)));
|
||||
let reopened = crate::Session::new(solvecraft_doc::Document::from_json(&session.doc.to_json()).unwrap());
|
||||
assert!((reopened.model.state().sketch(bore).unwrap().plane.origin.z - 26.).abs() < 1e-6);
|
||||
let reopened_world = reopened.world_state();
|
||||
let reopened_plate = reopened_world.body("Plate").unwrap().mesh();
|
||||
assert!(!reopened_plate.contains(solvecraft_geom::Vec3::new(40., 25., 2.)));
|
||||
assert!(reopened_plate.contains(solvecraft_geom::Vec3::new(40., 35., 2.)));
|
||||
}
|
||||
|
||||
/// Every built-in sample builds without a failing feature or joint.
|
||||
#[test]
|
||||
fn samples_build_cleanly() {
|
||||
|
||||
+83
-15
@@ -28,7 +28,19 @@ fn join_extrude_from_a_chamfered_top_face() {
|
||||
run(&mut s, "solid.extrude", json!({"distance": 20}));
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 20]], "distance": 2}));
|
||||
let base = volume(&mut s);
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20]}}));
|
||||
let before = s.doc.to_json();
|
||||
let active = s.active_sketch;
|
||||
assert!(s.execute("sketch.create", &json!({"plane":{"face":[0,0,20]}})).is_err(), "modified faces require an explicit fixed plane");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.active_sketch, active);
|
||||
assert!(rel(volume(&mut s), base) < 1e-12);
|
||||
let id = run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20],"fixed":true}}))["sketch"].as_u64().unwrap();
|
||||
let solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Custom { plane }, sketch } = &s.doc.feature(id).unwrap().kind else {
|
||||
panic!("explicit fixed face must become a custom plane");
|
||||
};
|
||||
assert!((plane.origin.z - 20.).abs() < 1e-9);
|
||||
assert!(sketch.links.is_empty(), "fresh fixed plane does not create live auto-projections");
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":8}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
@@ -482,7 +494,12 @@ fn step_open_then_model_on_it() {
|
||||
run(&mut s, "solid.hole", json!({"position": at(28.0, 15.0, 20.0), "diameter": 6}));
|
||||
want -= PI * 9.0 * 20.0;
|
||||
assert!(rel(volume(&mut s), want) < 5e-4, "{} vs {want}", volume(&mut s));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0)}}));
|
||||
// Modified imported faces no longer have an immutable construction certificate.
|
||||
// The public attachment refuses them; the user can explicitly choose a fixed plane.
|
||||
let before_fixed = s.doc.to_json();
|
||||
assert!(s.execute("sketch.create", &json!({"plane":{"face":at(10.0,15.0,20.0)}})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before_fixed);
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0), "fixed": true}}));
|
||||
let c = [x0 + 10.0, y0 + 15.0];
|
||||
// Sketch coordinates on the face are relative to the model origin projected onto it.
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [c[0] - 4.0, c[1] - 4.0], "p1": [c[0] + 4.0, c[1] + 4.0]}));
|
||||
@@ -1587,20 +1604,71 @@ mod naming {
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Top", "distance": 5, "operation": "join", "name": "Peg"}));
|
||||
let top = |s: &mut Session| run(s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0);
|
||||
assert!((top(&mut s) - 15.0).abs() < 1e-6);
|
||||
// Upstream, a tall boss over the picked point: the nearest upward face is now its top,
|
||||
// but the sketch keeps to the block's top face by name.
|
||||
run(&mut s, "timeline.roll_to", json!({"feature": "Block"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "B"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [8, -5], "p1": [45, 35]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "B", "distance": 20, "operation": "join", "name": "Boss"}));
|
||||
run(&mut s, "timeline.roll_to", json!({}));
|
||||
// (Its projected face outline changed, which it says; its plane did not.)
|
||||
assert!(!warning(&s, "Top").unwrap_or_default().contains("no longer exists"));
|
||||
// The peg starts on the block's top (z = 10), inside the boss: the part stays 20 tall.
|
||||
let attached = |s: &Session| s.model.state().sketch(s.doc.find_feature("Top").unwrap().id).map(|sketch| sketch.plane.origin.z);
|
||||
assert_eq!(attached(&s), Some(10.));
|
||||
run(&mut s, "timeline.edit", json!({"feature":"Block","set":{"extent":{"distance":"12"}}}));
|
||||
assert_eq!(attached(&s), Some(12.));
|
||||
assert!((top(&mut s) - 17.).abs() < 1e-6);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(attached(&s), Some(10.));
|
||||
assert!((top(&mut s) - 15.).abs() < 1e-6);
|
||||
let reopened = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
let mut model = solvecraft_doc::Model::new();
|
||||
model.evaluate(&reopened);
|
||||
assert_eq!(model.state().sketch(reopened.find_feature("Top").unwrap().id).unwrap().plane.origin.z, 10.);
|
||||
assert!((model.state().bodies[0].mesh().bounds().max.z - 15.).abs() < 1e-6);
|
||||
// A historical format-10 attachment uses its saved generic face name and no strong anchor.
|
||||
let mut old = (*s.doc).clone();
|
||||
let solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Face { anchor, name, .. }, sketch } =
|
||||
&mut old.features.iter_mut().find(|f| f.name == "Top").unwrap().kind
|
||||
else {
|
||||
panic!("fixture must contain a face sketch");
|
||||
};
|
||||
*anchor = None;
|
||||
for link in &mut sketch.links {
|
||||
if link.auto_face {
|
||||
link.auto_face = false;
|
||||
link.face_name = name.clone();
|
||||
}
|
||||
}
|
||||
old.format = "solvecraft/10".into();
|
||||
let mut legacy = Session::new(solvecraft_doc::Document::from_json(&old.to_json()).unwrap());
|
||||
assert_eq!(attached(&legacy), Some(10.));
|
||||
assert!((top(&mut legacy) - 15.).abs() < 1e-6);
|
||||
// The upstream join consumes the certified body; new attachments cannot follow its substitute roof.
|
||||
for target in [&mut s, &mut legacy] {
|
||||
run(target, "timeline.roll_to", json!({"feature": "Block"}));
|
||||
run(target, "sketch.create", json!({"plane": "XY", "name": "B"}));
|
||||
run(target, "sketch.rectangle.two_point", json!({"p0": [8, -5], "p1": [45, 35]}));
|
||||
run(target, "sketch.finish", json!({}));
|
||||
run(target, "solid.extrude", json!({"sketch": "B", "distance": 20, "operation": "join", "name": "Boss"}));
|
||||
run(target, "timeline.roll_to", json!({}));
|
||||
}
|
||||
assert_eq!(attached(&legacy), Some(10.), "historical weak naming retains its saved geometry behavior");
|
||||
assert!((top(&mut legacy) - 20.).abs() < 1e-6);
|
||||
let legacy_reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.doc.to_json()).unwrap());
|
||||
assert_eq!(attached(&legacy_reopened), Some(10.));
|
||||
assert!((legacy_reopened.model.state().bodies[0].mesh().bounds().max.z - 20.).abs() < 1e-6);
|
||||
let solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Face { anchor, .. }, .. } =
|
||||
&legacy_reopened.doc.find_feature("Top").unwrap().kind
|
||||
else {
|
||||
panic!("saved historical face reference must remain present");
|
||||
};
|
||||
assert!(anchor.is_none(), "opening old face references must not infer a strict attachment");
|
||||
let failed = |s: &Session, name: &str| s.model.results.iter().find(|r| r.id == s.doc.find_feature(name).unwrap().id).unwrap().error.is_some();
|
||||
assert!(failed(&s, "Top"));
|
||||
assert!(failed(&s, "Peg"), "lost sketch cannot supply downstream geometry");
|
||||
assert_eq!(attached(&s), None);
|
||||
assert!((top(&mut s) - 20.0).abs() < 1e-6, "{}", top(&mut s));
|
||||
let sk = s.model.state().sketch(s.doc.find_feature("Top").map(|f| f.id).unwrap_or(0)).map(|x| x.plane.origin.z);
|
||||
assert_eq!(sk.map(|z| (z * 1e6).round() / 1e6), Some(10.0));
|
||||
run(&mut s, "timeline.suppress", json!({"feature":"Boss","suppressed":true}));
|
||||
assert!(!failed(&s, "Top"));
|
||||
assert!(!failed(&s, "Peg"));
|
||||
assert_eq!(attached(&s), Some(10.));
|
||||
assert!((top(&mut s) - 15.).abs() < 1e-6);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(failed(&s, "Top"));
|
||||
assert!(failed(&s, "Peg"));
|
||||
assert_eq!(attached(&s), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -24,8 +24,9 @@ fn moved_component_picks_and_frames() {
|
||||
// Root active: a fillet picked at the moved edge in the world.
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[25, 54, 4]], "radius": 1}));
|
||||
assert!(rel(vol(&mut s, "ArmBody"), 160.0 - (1.0 - PI / 4.0) * 10.0) < 1e-4, "{}", vol(&mut s, "ArmBody"));
|
||||
// A sketch on the moved body's top face: its points sit on that face in the world.
|
||||
let sk = run(&mut s, "sketch.create", json!({"plane": {"face": [25, 52, 4]}}));
|
||||
// An explicit fixed plane on the modified body's top keeps the world pick;
|
||||
// unsupported modifier lineage cannot silently create a weak attachment.
|
||||
let sk = run(&mut s, "sketch.create", json!({"plane": {"face": [25, 52, 4],"fixed":true}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 1}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let si = run(&mut s, "sketch.inspect", json!({"sketch": sk["sketch"]}));
|
||||
|
||||
+145
-4
@@ -33,11 +33,27 @@ pub struct Body {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum FaceSource {
|
||||
ExtrudeSide { segment: solvecraft_geom::Seg2 },
|
||||
CertifiedExtrudeSide { producer: u64, sketch: u64, curve: String },
|
||||
ExtrudeSide { segment: solvecraft_geom::Seg2, plane: Option<solvecraft_geom::Plane> },
|
||||
CertifiedExtrudeSide { producer: u64, sketch: u64, curve: String, plane: Option<solvecraft_geom::Plane> },
|
||||
ExtrudeCap { high: bool, region: solvecraft_geom::Region2, plane: solvecraft_geom::Plane },
|
||||
CertifiedExtrudeCap { producer: u64, sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>>, plane: solvecraft_geom::Plane },
|
||||
Box { producer: Option<u64>, axis: u8, high: bool, plane: solvecraft_geom::Plane },
|
||||
Imported { item: u64, face: u64, payload_sha256: Option<String>, producer: Option<u64> },
|
||||
}
|
||||
|
||||
pub type CapSignature = (Vec<String>, Vec<Vec<String>>);
|
||||
type FaceSources = std::sync::Arc<Vec<Option<FaceSource>>>;
|
||||
|
||||
impl FaceSource {
|
||||
fn plane_mut(&mut self) -> Option<&mut solvecraft_geom::Plane> {
|
||||
match self {
|
||||
Self::ExtrudeSide { plane, .. } | Self::CertifiedExtrudeSide { plane, .. } => plane.as_mut(),
|
||||
Self::ExtrudeCap { plane, .. } | Self::CertifiedExtrudeCap { plane, .. } | Self::Box { plane, .. } => Some(plane),
|
||||
Self::Imported { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a body looks beyond its colour, for export: its opacity and its faces' own colours.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Paint {
|
||||
@@ -342,6 +358,131 @@ impl Body {
|
||||
self.face_sources.as_deref().map(Vec::as_slice)
|
||||
}
|
||||
|
||||
/// An exact construction/import plane; never inferred from tessellation.
|
||||
pub fn face_plane(&self, index: usize) -> Option<solvecraft_geom::Plane> {
|
||||
if index >= 50_000 {
|
||||
return None;
|
||||
}
|
||||
let source = self.face_sources()?.get(index)?.as_ref()?;
|
||||
match source {
|
||||
FaceSource::ExtrudeSide { plane, .. } | FaceSource::CertifiedExtrudeSide { plane, .. } => *plane,
|
||||
FaceSource::ExtrudeCap { plane, .. } | FaceSource::CertifiedExtrudeCap { plane, .. } | FaceSource::Box { plane, .. } => Some(*plane),
|
||||
FaceSource::Imported { .. } => self.planar_face(index),
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact current planar surface for an explicitly fixed snapshot, without source lineage.
|
||||
pub fn planar_face(&self, index: usize) -> Option<solvecraft_geom::Plane> {
|
||||
if index >= 50_000 {
|
||||
return None;
|
||||
}
|
||||
guard("exact planar face", || {
|
||||
let plane = self.solid.face_iter().nth(index).and_then(|face| match face.oriented_surface() {
|
||||
mt::Surface::Plane(plane) => {
|
||||
let u = plane.u_axis();
|
||||
let v = plane.v_axis();
|
||||
solvecraft_geom::Plane::new(from_p3(plane.origin()), Vec3::new(u.x, u.y, u.z), Vec3::new(v.x, v.y, v.z))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
Ok(plane)
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub fn with_box_certificate(mut self, producer: u64) -> Result<Self> {
|
||||
if producer == 0 {
|
||||
return Err(KernelError::Invalid("invalid box producer".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::Box { producer: id, .. } = source {
|
||||
*id = Some(producer);
|
||||
}
|
||||
}
|
||||
self.face_sources = Some(std::sync::Arc::new(sources));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Cap signatures are in face order; unknown or ambiguous source curves stay uncertified.
|
||||
pub fn with_extrude_cap_certificates(mut self, producer: u64, sketch: u64, signatures: &[Option<CapSignature>]) -> Result<Self> {
|
||||
if producer == 0 || sketch == 0 {
|
||||
return Err(KernelError::Invalid("invalid cap producer".into()));
|
||||
}
|
||||
if let Some(sources) = &self.face_sources {
|
||||
if sources.len() != signatures.len() {
|
||||
return Err(KernelError::Invalid("cap certificate count mismatch".into()));
|
||||
}
|
||||
let mut sources = sources.as_ref().clone();
|
||||
for (source, signature) in sources.iter_mut().zip(signatures) {
|
||||
if let Some(FaceSource::ExtrudeCap { high, plane, .. }) = source {
|
||||
let Some((outer, holes)) = signature else {
|
||||
*source = None;
|
||||
continue;
|
||||
};
|
||||
if outer.len() > 4096 || holes.len() > 4096 || holes.iter().any(|hole| hole.len() > 4096) {
|
||||
return Err(KernelError::Invalid("oversized cap curve signature".into()));
|
||||
}
|
||||
let count = outer.len() + holes.iter().map(Vec::len).sum::<usize>();
|
||||
if outer.is_empty()
|
||||
|| count > 4096
|
||||
|| holes.len() > 4096
|
||||
|| outer.iter().chain(holes.iter().flatten()).map(String::len).sum::<usize>() > 65_536
|
||||
|| outer.iter().chain(holes.iter().flatten()).any(|id| id.is_empty() || id.len() > 128)
|
||||
{
|
||||
return Err(KernelError::Invalid("invalid cap curve signature".into()));
|
||||
}
|
||||
let mut outer = outer.clone();
|
||||
outer.sort();
|
||||
outer.dedup();
|
||||
let mut holes = holes.clone();
|
||||
for hole in &mut holes {
|
||||
if hole.is_empty() {
|
||||
return Err(KernelError::Invalid("empty cap hole signature".into()));
|
||||
}
|
||||
hole.sort();
|
||||
hole.dedup();
|
||||
}
|
||||
holes.sort();
|
||||
if holes.windows(2).any(|pair| pair[0] == pair[1]) || holes.iter().flatten().any(|id| outer.binary_search(id).is_ok()) {
|
||||
return Err(KernelError::Invalid("ambiguous cap hole signature".into()));
|
||||
}
|
||||
*source = Some(FaceSource::CertifiedExtrudeCap { producer, sketch, high: *high, outer, holes, plane: *plane });
|
||||
}
|
||||
}
|
||||
self.face_sources = Some(std::sync::Arc::new(sources));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub(crate) fn transformed_sources(&self, m: [[f64; 4]; 4]) -> Result<Option<FaceSources>> {
|
||||
let Some(sources) = &self.face_sources else { return Ok(None) };
|
||||
let vector = |p: Vec3| {
|
||||
Vec3::new(
|
||||
m[0][0] * p.x + m[1][0] * p.y + m[2][0] * p.z,
|
||||
m[0][1] * p.x + m[1][1] * p.y + m[2][1] * p.z,
|
||||
m[0][2] * p.x + m[1][2] * p.y + m[2][2] * p.z,
|
||||
)
|
||||
};
|
||||
let translation = Vec3::new(m[3][0], m[3][1], m[3][2]);
|
||||
let reflected = vector(Vec3::X).dot(vector(Vec3::Y).cross(vector(Vec3::Z))) < 0.;
|
||||
let mut sources = sources.as_ref().clone();
|
||||
for source in sources.iter_mut().flatten() {
|
||||
if let Some(plane) = source.plane_mut() {
|
||||
*plane = solvecraft_geom::Plane::new(
|
||||
vector(plane.origin) + translation,
|
||||
vector(plane.x),
|
||||
vector(plane.y) * if reflected { -1. } else { 1. },
|
||||
)
|
||||
.ok_or_else(|| KernelError::Invalid("degenerate transformed face plane".into()))?;
|
||||
}
|
||||
}
|
||||
Ok(Some(std::sync::Arc::new(sources)))
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -355,8 +496,8 @@ impl Body {
|
||||
.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 { plane, .. }), Some(curve)) => {
|
||||
Some(FaceSource::CertifiedExtrudeSide { producer, sketch, curve: curve.clone(), plane: *plane })
|
||||
}
|
||||
(Some(FaceSource::ExtrudeSide { .. }), None) => None,
|
||||
_ => source.clone(),
|
||||
|
||||
+112
-10
@@ -90,6 +90,15 @@ fn region_ok(r: &Region2) -> Result<()> {
|
||||
|
||||
/// Extrude each region from `start` to `end` along the plane normal (offsets in mm, `end > start`).
|
||||
pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
|
||||
if !plane.origin.is_finite()
|
||||
|| !plane.x.is_finite()
|
||||
|| !plane.y.is_finite()
|
||||
|| (plane.x.len2() - 1.).abs() > 1e-8
|
||||
|| (plane.y.len2() - 1.).abs() > 1e-8
|
||||
|| plane.x.dot(plane.y).abs() > 1e-8
|
||||
{
|
||||
return Err(KernelError::Invalid("extrude frame is not finite and orthonormal".into()));
|
||||
}
|
||||
let (start, end) = (finite(start, "start")?, finite(end, "end")?);
|
||||
let (lo, hi) = if end >= start { (start, end) } else { (end, start) };
|
||||
if hi - lo < 1e-6 {
|
||||
@@ -112,6 +121,31 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
|
||||
}
|
||||
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)))))?;
|
||||
let upper = guard("extrude cap identities", || {
|
||||
let inputs: std::collections::HashSet<_> = sources.iter().map(|(edge, _)| edge.id()).collect();
|
||||
let mut visits = 0usize;
|
||||
let mut candidate = None;
|
||||
for (index, generated) in solid.face_iter().enumerate() {
|
||||
if index >= 50_000 {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut side = false;
|
||||
for edge in generated.absolute_boundaries().iter().flatten() {
|
||||
visits += 1;
|
||||
if visits > 200_000 {
|
||||
return Ok(None);
|
||||
}
|
||||
side |= inputs.contains(&edge.id());
|
||||
}
|
||||
if generated.id() != f.id() && !side {
|
||||
if candidate.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
candidate = Some(generated.id());
|
||||
}
|
||||
}
|
||||
Ok(candidate)
|
||||
})?;
|
||||
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.
|
||||
@@ -120,13 +154,35 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
|
||||
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
|
||||
};
|
||||
let source =
|
||||
if generated.id() == f.id() {
|
||||
upper.and_then(|_| {
|
||||
(r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() <= 4096).then(|| {
|
||||
crate::FaceSource::ExtrudeCap {
|
||||
high: false,
|
||||
region: r.clone(),
|
||||
plane: Plane { origin: base.origin, x: plane.x, y: -plane.y },
|
||||
}
|
||||
})
|
||||
})
|
||||
} else if Some(generated.id()) == upper {
|
||||
(r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() <= 4096)
|
||||
.then(|| crate::FaceSource::ExtrudeCap { high: true, region: r.clone(), plane: plane.offset(hi) })
|
||||
} else if let [segment] = matching.as_slice() {
|
||||
let basis = match **segment {
|
||||
Seg2::Line { a, b } => {
|
||||
let d = b - a;
|
||||
d.normalized().and_then(|direction| {
|
||||
let foot = a - direction * a.dot(direction);
|
||||
Plane::new(plane.to_world(foot), plane.dir_to_world(direction), n)
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Some(crate::FaceSource::ExtrudeSide { segment: **segment, plane: basis })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
result.push(source);
|
||||
}
|
||||
Ok(result)
|
||||
@@ -136,8 +192,25 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Revolve each region about the axis through sketch point `axis_origin` with sketch direction
|
||||
/// `axis_dir` by `angle` radians (|angle| ≥ 2π = full revolution).
|
||||
/// Choose the per-profile axis sign whose positive rotation follows the sketch normal.
|
||||
pub fn canonical_revolve_axis(plane: &Plane, region: &Region2, axis_origin: Vec2, axis_dir: Vec2) -> Result<Vec3> {
|
||||
if !axis_origin.is_finite() || !axis_dir.is_finite() || !plane.origin.is_finite() || !plane.x.is_finite() || !plane.y.is_finite() {
|
||||
return Err(KernelError::Invalid("non-finite revolve frame".into()));
|
||||
}
|
||||
if (plane.x.len2() - 1.).abs() > 1e-8 || (plane.y.len2() - 1.).abs() > 1e-8 || plane.x.dot(plane.y).abs() > 1e-8 {
|
||||
return Err(KernelError::Invalid("revolve frame is not orthonormal".into()));
|
||||
}
|
||||
region_ok(region)?;
|
||||
let direction = axis_dir.normalized().ok_or_else(|| KernelError::Invalid("revolve axis has no direction".into()))?;
|
||||
let axis = plane.dir_to_world(direction).normalized().ok_or_else(|| KernelError::Invalid("revolve frame has no axis".into()))?;
|
||||
let origin = plane.to_world(axis_origin);
|
||||
let center = plane.to_world(region.centroid());
|
||||
let radial = center - origin - axis * (center - origin).dot(axis);
|
||||
Ok(if axis.cross(radial).dot(plane.normal()) < 0. { -axis } else { axis })
|
||||
}
|
||||
|
||||
/// Revolve each region about the sketch axis by `angle` radians.
|
||||
/// This legacy API preserves its historical sweep sign behavior.
|
||||
pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec2, angle: f64) -> Result<Vec<Body>> {
|
||||
let angle = finite(angle, "angle")?;
|
||||
if angle.abs() < 1e-6 {
|
||||
@@ -264,7 +337,36 @@ pub fn box_solid(a: Vec3, b: Vec3) -> Result<Body> {
|
||||
holes: vec![],
|
||||
};
|
||||
let mut v = extrude(&Plane::XY, &[r], lo.z, hi.z)?;
|
||||
v.pop().ok_or_else(|| KernelError::Failed("box".into()))
|
||||
let body = v.pop().ok_or_else(|| KernelError::Failed("box".into()))?;
|
||||
let sources = body.face_sources().ok_or_else(|| KernelError::Failed("box construction source".into()))?;
|
||||
let mut roles = std::collections::HashSet::new();
|
||||
let mut certified = Vec::new();
|
||||
for source in sources {
|
||||
let role = match source {
|
||||
Some(crate::FaceSource::ExtrudeCap { high, .. }) => Some((2u8, *high)),
|
||||
Some(crate::FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.x == b.x && a.x == lo.x => Some((0, false)),
|
||||
Some(crate::FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.x == b.x && a.x == hi.x => Some((0, true)),
|
||||
Some(crate::FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.y == b.y && a.y == lo.y => Some((1, false)),
|
||||
Some(crate::FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.y == b.y && a.y == hi.y => Some((1, true)),
|
||||
_ => None,
|
||||
}
|
||||
.ok_or_else(|| KernelError::Failed("box role correspondence".into()))?;
|
||||
if !roles.insert(role) {
|
||||
return Err(KernelError::Failed("duplicate box role".into()));
|
||||
}
|
||||
let (axis, high) = role;
|
||||
let sign = if high { 1. } else { -1. };
|
||||
let plane = match axis {
|
||||
0 => Plane { origin: Vec3::X * if high { hi.x } else { lo.x }, x: Vec3::Y, y: Vec3::Z * sign },
|
||||
1 => Plane { origin: Vec3::Y * if high { hi.y } else { lo.y }, x: Vec3::X, y: -Vec3::Z * sign },
|
||||
_ => Plane { origin: Vec3::Z * if high { hi.z } else { lo.z }, x: Vec3::X, y: Vec3::Y * sign },
|
||||
};
|
||||
certified.push(Some(crate::FaceSource::Box { producer: None, axis, high, plane }));
|
||||
}
|
||||
if roles.len() != 6 {
|
||||
return Err(KernelError::Failed("box role count".into()));
|
||||
}
|
||||
Ok(body.with_face_sources(Some(std::sync::Arc::new(certified))))
|
||||
}
|
||||
|
||||
/// Where primitives start their circles (radians from the frame's x axis).
|
||||
|
||||
@@ -1,6 +1,219 @@
|
||||
use crate::{FaceSource, extrude, transform_matrix};
|
||||
use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
|
||||
fn assert_exact_planes(body: &crate::Body) {
|
||||
let mesh = body.tessellate(0.001).unwrap();
|
||||
for (index, _) in body.face_sources().unwrap().iter().enumerate() {
|
||||
let Some(plane) = body.face_plane(index) else { continue };
|
||||
let mut count = 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 - plane.origin).dot(plane.normal()).abs() < 1e-7);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
assert!(count > 0);
|
||||
let normal = body.faces(0.001).unwrap()[index].plane_normal.unwrap();
|
||||
assert!(normal.dot(plane.normal()) > 0.999999);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_certificates_are_six_construction_roles_with_dimension_stable_axes() {
|
||||
for (a, b) in [
|
||||
(Vec3::new(2., 3., 4.), Vec3::new(12., 23., 34.)),
|
||||
(Vec3::new(12., 23., 34.), Vec3::new(2., 3., 4.)),
|
||||
(Vec3::new(2., -8., 4.), Vec3::new(22., 43., 54.)),
|
||||
] {
|
||||
let body = crate::box_solid(a, b).unwrap().with_box_certificate(42).unwrap();
|
||||
let mut roles = std::collections::HashSet::new();
|
||||
for (index, source) in body.face_sources().unwrap().iter().enumerate() {
|
||||
let Some(FaceSource::Box { producer, axis, high, plane }) = source else { panic!("uncertified box face") };
|
||||
assert_eq!(*producer, Some(42));
|
||||
assert!(roles.insert((*axis, *high)));
|
||||
let unit = [Vec3::X, Vec3::Y, Vec3::Z][*axis as usize];
|
||||
let bound = if *high { a.max(b) } else { a.min(b) };
|
||||
assert_eq!(plane.origin, unit * bound.dot(unit));
|
||||
assert!(plane.normal().dist(unit * if *high { 1. } else { -1. }) < 1e-12);
|
||||
assert_eq!(body.face_plane(index), Some(*plane));
|
||||
}
|
||||
assert_eq!(roles.len(), 6);
|
||||
assert_exact_planes(&body);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_roles_follow_exact_offset_planes_and_canonical_profile_memberships() {
|
||||
let outer = Loop2::polygon(&[Vec2::new(-10., -10.), Vec2::new(10., -10.), Vec2::new(10., 10.), Vec2::new(-10., 10.)]);
|
||||
let h1 = Loop2::circle(Vec2::new(-4., 0.), 1.);
|
||||
let h2 = Loop2::circle(Vec2::new(4., 0.), 2.);
|
||||
let plane = Plane::new(Vec3::new(30., 40., 50.), Vec3::Y, Vec3::Z).unwrap();
|
||||
for (start, end) in [(-9., -2.), (7., -3.), (2., 11.)] {
|
||||
for holes in [vec![h1.clone(), h2.clone()], vec![h2.clone().reversed(), h1.clone().reversed()]] {
|
||||
let region = Region2 { outer: outer.clone().reversed(), holes };
|
||||
let raw = extrude(&plane, std::slice::from_ref(®ion), start, end).unwrap().remove(0);
|
||||
let signatures: Vec<_> = raw
|
||||
.face_sources()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|source| match source {
|
||||
Some(FaceSource::ExtrudeCap { .. }) => {
|
||||
Some((vec!["l4".into(), "l1".into(), "l3".into(), "l2".into()], vec![vec!["c2".into()], vec!["c1".into()]]))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let body = raw.with_extrude_cap_certificates(42, 11, &signatures).unwrap();
|
||||
let mut caps = std::collections::HashSet::new();
|
||||
for source in body.face_sources().unwrap().iter().flatten() {
|
||||
if let FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, plane: cap } = source {
|
||||
assert_eq!((*producer, *sketch), (42, 11));
|
||||
assert!(caps.insert(*high));
|
||||
assert_eq!(outer, &["l1", "l2", "l3", "l4"]);
|
||||
assert_eq!(holes, &[vec!["c1"], vec!["c2"]]);
|
||||
assert!(cap.origin.dist(plane.origin + plane.normal() * if *high { start.max(end) } else { start.min(end) }) < 1e-10);
|
||||
assert!(cap.normal().dot(plane.normal()) * if *high { 1. } else { -1. } > 0.999999);
|
||||
}
|
||||
}
|
||||
assert_eq!(caps.len(), 2);
|
||||
assert_exact_planes(&body);
|
||||
let expected = (400. - 5. * std::f64::consts::PI) * (end - start).abs();
|
||||
assert!((body.tessellate(1e-4).unwrap().measure().volume - expected).abs() < 0.03);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_planes_follow_reflected_and_sheared_affine_images() {
|
||||
let body = crate::box_solid(Vec3::new(2., 3., 4.), Vec3::new(12., 23., 34.)).unwrap().with_box_certificate(42).unwrap();
|
||||
for m in [
|
||||
[[0., 2., 0., 0.], [-1., 0., 0., 0.], [0., 0., 3., 0.], [25., 30., 40., 1.]],
|
||||
[[-1., 0., 0., 0.], [0.4, 2., 0., 0.], [0., 0.3, 1., 0.], [8., 9., 10., 1.]],
|
||||
] {
|
||||
let placed = transform_matrix(&body, m).unwrap();
|
||||
for (original, transformed) in body.face_sources().unwrap().iter().zip(placed.face_sources().unwrap()) {
|
||||
let (
|
||||
Some(FaceSource::Box { producer: a, axis: aa, high: ah, plane: ap }),
|
||||
Some(FaceSource::Box { producer: b, axis: ba, high: bh, plane: bp }),
|
||||
) = (original, transformed)
|
||||
else {
|
||||
panic!("box identity changed")
|
||||
};
|
||||
assert_eq!((a, aa, ah), (b, ba, bh));
|
||||
let p = ap.origin;
|
||||
let expected = Vec3::new(
|
||||
m[0][0] * p.x + m[1][0] * p.y + m[2][0] * p.z + m[3][0],
|
||||
m[0][1] * p.x + m[1][1] * p.y + m[2][1] * p.z + m[3][1],
|
||||
m[0][2] * p.x + m[1][2] * p.y + m[2][2] * p.z + m[3][2],
|
||||
);
|
||||
assert!(bp.origin.dist(expected) < 1e-10);
|
||||
}
|
||||
assert_exact_planes(&placed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn material_changes_discard_producer_certificates() {
|
||||
let base = crate::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(42).unwrap();
|
||||
let tool = crate::box_solid(Vec3::new(5., -1., -1.), Vec3::new(11., 11., 11.)).unwrap();
|
||||
let cut = crate::boolean(&base, &tool, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
assert!((cut.tessellate(1e-4).unwrap().measure().volume - 500.).abs() < 1e-8);
|
||||
assert!(cut.face_sources().into_iter().flatten().flatten().all(|source| matches!(
|
||||
source,
|
||||
FaceSource::ExtrudeSide { .. }
|
||||
| FaceSource::ExtrudeCap { .. }
|
||||
| FaceSource::Box { producer: None, .. }
|
||||
| FaceSource::Imported { producer: None, .. }
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_revolve_axis_is_per_profile_and_independent_of_input_axis_sign() {
|
||||
for x in [-4., 4.] {
|
||||
let region = Region2 {
|
||||
outer: Loop2::polygon(&[Vec2::new(x - 1., 0.), Vec2::new(x + 1., 0.), Vec2::new(x + 1., 2.), Vec2::new(x - 1., 2.)]),
|
||||
holes: vec![],
|
||||
};
|
||||
let axis = crate::canonical_revolve_axis(&Plane::XY, ®ion, Vec2::default(), Vec2::Y).unwrap();
|
||||
assert_eq!(axis, crate::canonical_revolve_axis(&Plane::XY, ®ion, Vec2::default(), -Vec2::Y).unwrap());
|
||||
assert!(axis.cross(Plane::XY.to_world(region.centroid())).dot(Vec3::Z) > 0.);
|
||||
assert_eq!(axis, Vec3::Y * if x < 0. { 1. } else { -1. });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_side_frame_stays_fixed_when_endpoints_and_depth_change() {
|
||||
for (lo, hi, start, end) in [(3., 23., 0., 7.), (-9., 43., -3., 19.)] {
|
||||
let region =
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(2., lo), Vec2::new(12., lo), Vec2::new(12., hi), Vec2::new(2., hi)]), holes: vec![] };
|
||||
let body = extrude(&Plane::XY, &[region], start, end).unwrap().remove(0);
|
||||
let plane = body
|
||||
.face_sources()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(index, source)| match source {
|
||||
Some(FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.x == 2. && b.x == 2. => body.face_plane(index),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(plane.origin, Vec3::X * 2.);
|
||||
assert_eq!(plane.x, -Vec3::Y);
|
||||
assert_eq!(plane.normal(), -Vec3::X);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflected_hole_walls_and_low_caps_keep_exact_outward_planes() {
|
||||
let outer = Loop2::polygon(&[Vec2::new(-10., -10.), Vec2::new(10., -10.), Vec2::new(10., 10.), Vec2::new(-10., 10.)]);
|
||||
let hole = Loop2::polygon(&[Vec2::new(-3., -2.), Vec2::new(3., -2.), Vec2::new(3., 2.), Vec2::new(-3., 2.)]);
|
||||
let body = extrude(&Plane::XY, &[Region2 { outer, holes: vec![hole] }], -4., 7.).unwrap().remove(0);
|
||||
let reflected = transform_matrix(&body, [[-1., 0., 0., 0.], [0., 2., 0., 0.], [0.2, 0., 1., 0.], [30., 40., 50., 1.]]).unwrap();
|
||||
assert_exact_planes(&reflected);
|
||||
assert_eq!(
|
||||
reflected.face_sources().unwrap().iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeSide { plane: Some(_), .. }))).count(),
|
||||
8
|
||||
);
|
||||
assert_eq!(
|
||||
reflected.face_sources().unwrap().iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeCap { high: false, .. }))).count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_basis_and_attached_coordinates_stay_continuous_through_vertical() {
|
||||
let mut previous = None;
|
||||
for epsilon in [1e-6, -1e-6] {
|
||||
let region =
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::ZERO, Vec2::new(epsilon, 10.), Vec2::new(-5., 10.), Vec2::new(-5., 0.)]), holes: vec![] };
|
||||
let body = extrude(&Plane::XY, &[region], 0., 5.).unwrap().remove(0);
|
||||
let plane = body
|
||||
.face_sources()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(index, source)| match source {
|
||||
Some(FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.dist(Vec2::ZERO) < 1e-12 && b.y == 10. && b.x == epsilon => {
|
||||
body.face_plane(index)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(plane.x.dot(Vec3::Y) > 0.999999);
|
||||
assert_eq!(plane.y, Vec3::Z);
|
||||
let attached = plane.to_world(Vec2::new(3., 2.));
|
||||
assert!(attached.dist(Vec3::new(epsilon * 0.3, 3., 2.)) < 1e-10);
|
||||
if let Some(old) = previous {
|
||||
assert!(attached.dist(old) < 1e-6);
|
||||
}
|
||||
previous = Some(attached);
|
||||
assert_exact_planes(&body);
|
||||
}
|
||||
}
|
||||
|
||||
#[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()] };
|
||||
@@ -13,7 +226,7 @@ fn constructed_annulus_sides_follow_exact_source_edges() {
|
||||
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 };
|
||||
let Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { radius, .. }, .. }) = source else { continue };
|
||||
if (*radius - 10.).abs() < 1e-12 {
|
||||
outer += 1;
|
||||
} else {
|
||||
@@ -33,7 +246,7 @@ fn constructed_annulus_sides_follow_exact_source_edges() {
|
||||
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);
|
||||
assert_eq!(sources.iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeCap { .. }))).count(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +256,16 @@ fn constructed_side_identity_survives_affine_placement_without_recognition() {
|
||||
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());
|
||||
for (original, placed) in source.face_sources().unwrap().iter().zip(placed.face_sources().unwrap()) {
|
||||
match (original, placed) {
|
||||
(Some(FaceSource::ExtrudeSide { segment: a, .. }), Some(FaceSource::ExtrudeSide { segment: b, .. })) => assert_eq!(a, b),
|
||||
(Some(FaceSource::ExtrudeCap { high: a, region: ar, .. }), Some(FaceSource::ExtrudeCap { high: b, region: br, .. })) => {
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(ar, br)
|
||||
}
|
||||
_ => panic!("source role changed"),
|
||||
}
|
||||
}
|
||||
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.));
|
||||
}
|
||||
@@ -58,16 +280,16 @@ fn constructed_large_arc_split_and_reversal_preserve_one_source_segment() {
|
||||
let arcs: Vec<_> = sources
|
||||
.iter()
|
||||
.filter_map(|source| match source {
|
||||
Some(FaceSource::ExtrudeSide { segment: Seg2::Arc { radius, sweep, .. } }) => Some((radius, sweep)),
|
||||
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);
|
||||
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 { .. } })) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +54,8 @@ 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, FaceSource, Paint};
|
||||
pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
|
||||
pub use body::{Body, CapSignature, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
|
||||
pub use build::{PathSeg, box_solid, canonical_revolve_axis, 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, has_cylinder_emboss_wall, infer_cylinder_emboss};
|
||||
pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh};
|
||||
|
||||
@@ -531,8 +531,9 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
|
||||
m[3][3],
|
||||
);
|
||||
// An affine placement retains face order and appearance metadata.
|
||||
let sources = body.transformed_sources(m)?;
|
||||
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()))
|
||||
.map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -168,6 +168,64 @@ pub fn fit_beziers(p: &[Vec2]) -> Vec<[Vec2; 4]> {
|
||||
fit_spans(p).into_iter().map(|(p0, p1, t0, t1)| [p0, p0 + t0 / 3.0, p1 - t1 / 3.0, p1]).collect()
|
||||
}
|
||||
|
||||
/// Fit pieces in the explicitly selected interpolation mode.
|
||||
pub fn fit_beziers_mode(p: &[Vec2], periodic: bool) -> crate::model::Result<Vec<[Vec2; 4]>> {
|
||||
if periodic { crate::periodic_beziers(p) } else { Ok(fit_beziers(p)) }
|
||||
}
|
||||
pub fn spline_point_mode(p: &[Vec2], control: bool, degree: u8, periodic: bool, t: f64) -> Vec2 {
|
||||
if !periodic {
|
||||
return spline_point(p, control, degree, t);
|
||||
}
|
||||
let Ok(pieces) = crate::periodic_beziers(p) else {
|
||||
return p.first().copied().unwrap_or_default();
|
||||
};
|
||||
let lengths: Vec<f64> = p.windows(2).map(|p| p[0].dist(p[1])).collect();
|
||||
let total = lengths.iter().sum::<f64>();
|
||||
let mut remaining = t.clamp(0.0, 1.0) * total;
|
||||
let mut i = 0;
|
||||
while i + 1 < pieces.len() && remaining > lengths[i] {
|
||||
remaining -= lengths[i];
|
||||
i += 1;
|
||||
}
|
||||
let Some([a, b, c, d]) = pieces.get(i).copied() else {
|
||||
return Vec2::ZERO;
|
||||
};
|
||||
let t = (remaining / lengths[i]).clamp(0.0, 1.0);
|
||||
a * (1.0 - t).powi(3) + b * (3.0 * t * (1.0 - t).powi(2)) + c * (3.0 * t * t * (1.0 - t)) + d * t.powi(3)
|
||||
}
|
||||
pub fn spline_polyline_mode(p: &[Vec2], control: bool, degree: u8, periodic: bool) -> Vec<Vec2> {
|
||||
if !periodic {
|
||||
return spline_polyline(p, control, degree);
|
||||
}
|
||||
let Ok(pieces) = crate::periodic_beziers(p) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::with_capacity(pieces.len() * SPAN_SAMPLES + 1);
|
||||
for [a, b, c, d] in pieces {
|
||||
for i in 0..SPAN_SAMPLES {
|
||||
let t = i as f64 / SPAN_SAMPLES as f64;
|
||||
out.push(a * (1.0 - t).powi(3) + b * (3.0 * t * (1.0 - t).powi(2)) + c * (3.0 * t * t * (1.0 - t)) + d * t.powi(3));
|
||||
}
|
||||
}
|
||||
if let Some(first) = p.first() {
|
||||
out.push(*first);
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn spline_end_tangent_mode(p: &[Vec2], control: bool, periodic: bool, at_end: bool) -> Option<Vec2> {
|
||||
if !periodic {
|
||||
return spline_end_tangent(p, control, at_end);
|
||||
}
|
||||
let pieces = crate::periodic_beziers(p).ok()?;
|
||||
if at_end {
|
||||
let c = pieces.last()?;
|
||||
(c[3] - c[2]).normalized()
|
||||
} else {
|
||||
let c = pieces.first()?;
|
||||
(c[1] - c[0]).normalized()
|
||||
}
|
||||
}
|
||||
|
||||
/// The Bézier pieces (each `degree + 1` points) of the clamped uniform B-spline over `ctrl`
|
||||
/// (Boehm knot insertion until every interior knot has multiplicity `degree`).
|
||||
pub fn bspline_beziers(ctrl: &[Vec2], degree: usize) -> Vec<Vec<Vec2>> {
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
//! Persistent free-form offsets with a whole-span normal-distance error certificate.
|
||||
//! The certificate bounds the second derivative of the exact normal offset and of
|
||||
//! the actual natural cubic interpolant. The endpoint interpolation theorem then
|
||||
//! the actual cubic interpolant (natural for legacy output, periodic for new closed output). The endpoint interpolation theorem then
|
||||
//! bounds their distance everywhere, rather than just at selected samples.
|
||||
use crate::{CurveKind, Sketch, SketchError, fit_beziers};
|
||||
use crate::{CurveKind, Sketch, SketchError, fit_beziers_mode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::{Seg2, Vec2};
|
||||
|
||||
pub const OFFSET_TOLERANCE: f64 = 0.002;
|
||||
const MAX_PIECES: usize = 256;
|
||||
const MAX_ASSOCIATIONS: usize = 128;
|
||||
const MAX_OFFSET_WORK: usize = 1_048_576;
|
||||
struct Fit {
|
||||
tolerance: f64,
|
||||
periodic: bool,
|
||||
}
|
||||
type Result<T> = std::result::Result<T, SketchError>;
|
||||
fn bad(message: &str) -> SketchError {
|
||||
SketchError::Invalid(message.into())
|
||||
@@ -285,7 +290,11 @@ fn verify_simple(points: &[Vec2], cubics: &[[Vec2; 4]], closed: bool) -> Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn geometry(sk: &Sketch, source: usize, forward: bool, d: f64, divisions: usize, tolerance: f64) -> Result<(Vec<Vec2>, usize, bool)> {
|
||||
fn geometry(sk: &Sketch, source: usize, forward: bool, d: f64, divisions: usize, fit: Fit, work: &mut usize) -> Result<(Vec<Vec2>, usize, bool)> {
|
||||
if *work == 0 {
|
||||
return Err(bad("offset transaction certificate work limit exceeded"));
|
||||
}
|
||||
let Fit { tolerance, periodic } = fit;
|
||||
let mut pieces = sk.exact_segs(source).ok_or_else(|| bad("offset source changed shape"))?;
|
||||
if pieces.is_empty()
|
||||
|| pieces.len() > MAX_PIECES
|
||||
@@ -333,11 +342,12 @@ fn geometry(sk: &Sketch, source: usize, forward: bool, d: f64, divisions: usize,
|
||||
*last = first;
|
||||
}
|
||||
}
|
||||
let cubics = fit_beziers(&points);
|
||||
let mut budget = 65_536;
|
||||
for (h, c) in restricted.iter().zip(&cubics) {
|
||||
certify(h, c, d, tolerance, 0, &mut budget)?;
|
||||
}
|
||||
let cubics = fit_beziers_mode(&points, periodic && closed)?;
|
||||
let allowance = (*work).min(65_536);
|
||||
let mut budget = allowance;
|
||||
let result = restricted.iter().zip(&cubics).try_for_each(|(h, c)| certify(h, c, d, tolerance, 0, &mut budget));
|
||||
*work -= allowance - budget;
|
||||
result?;
|
||||
verify_simple(&points, &cubics, closed)?;
|
||||
Ok((points, pieces.len(), closed))
|
||||
}
|
||||
@@ -377,7 +387,7 @@ impl Sketch {
|
||||
|| record.output.is_empty()
|
||||
|| record.output.len() > 512
|
||||
|| record.source == record.output
|
||||
|| record.certificate != 1
|
||||
|| !matches!(record.certificate, 1 | 2)
|
||||
|| record.pieces == 0
|
||||
|| record.pieces > MAX_PIECES
|
||||
|| record.divisions == 0
|
||||
@@ -400,8 +410,10 @@ impl Sketch {
|
||||
}
|
||||
let output = self.curve_index(&record.output).ok_or_else(|| bad("offset output vanished"))?;
|
||||
match &self.curves[output].kind {
|
||||
CurveKind::Spline { pts, control: false, degree: 3 }
|
||||
if pts.len() == record.pieces * record.divisions + 1
|
||||
CurveKind::Spline { pts, control: false, degree: 3, periodic }
|
||||
if *periodic == (record.certificate == 2)
|
||||
&& (record.certificate != 2 || record.closed)
|
||||
&& pts.len() == record.pieces * record.divisions + 1
|
||||
&& pts.iter().all(|i| *i < self.points.len())
|
||||
&& pts.iter().copied().collect::<std::collections::BTreeSet<_>>().len() == pts.len()
|
||||
&& pts
|
||||
@@ -420,11 +432,12 @@ impl Sketch {
|
||||
let source_pieces = self.exact_segs(source).ok_or_else(|| bad("offset source changed shape"))?.len();
|
||||
let mut result = None;
|
||||
let mut failure = None;
|
||||
let mut work = MAX_OFFSET_WORK;
|
||||
for divisions in [1, 2, 4, 8, 16, 32, 64, 128, 256] {
|
||||
if source_pieces.saturating_mul(divisions) >= crate::MAX_SPLINE_POINTS {
|
||||
break;
|
||||
}
|
||||
match geometry(self, source, forward, distance, divisions, OFFSET_TOLERANCE) {
|
||||
match geometry(self, source, forward, distance, divisions, Fit { tolerance: OFFSET_TOLERANCE, periodic: true }, &mut work) {
|
||||
Ok(g) => {
|
||||
result = Some((divisions, g));
|
||||
break;
|
||||
@@ -442,7 +455,7 @@ impl Sketch {
|
||||
p.fixed = true;
|
||||
}
|
||||
}
|
||||
let output = candidate.add_curve(CurveKind::Spline { pts: indices, control: false, degree: 3 }, None)?;
|
||||
let output = candidate.add_curve(CurveKind::Spline { pts: indices, control: false, degree: 3, periodic: closed }, None)?;
|
||||
candidate.freeform_offsets.push(FreeformOffset {
|
||||
source: source_id,
|
||||
output: candidate.curves[output].id.clone(),
|
||||
@@ -452,7 +465,7 @@ impl Sketch {
|
||||
pieces,
|
||||
closed,
|
||||
tolerance: OFFSET_TOLERANCE,
|
||||
certificate: 1,
|
||||
certificate: if closed { 2 } else { 1 },
|
||||
});
|
||||
*self = candidate;
|
||||
Ok(output)
|
||||
@@ -462,8 +475,9 @@ impl Sketch {
|
||||
self.validate_freeform_offsets()?;
|
||||
let mut candidate = self.clone();
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut work = MAX_OFFSET_WORK;
|
||||
for (record_index, record) in self.freeform_offsets.clone().into_iter().enumerate() {
|
||||
if record.certificate != 1
|
||||
if !matches!(record.certificate, 1 | 2)
|
||||
|| record.source == record.output
|
||||
|| !seen.insert(record.output.clone())
|
||||
|| candidate.freeform_offsets.iter().any(|later| later.output == record.source && !seen.contains(&later.output))
|
||||
@@ -486,7 +500,15 @@ impl Sketch {
|
||||
divisions = (record.divisions / ratio).max(1);
|
||||
}
|
||||
let (points, pieces, closed) = loop {
|
||||
match geometry(&candidate, source, record.forward, record.distance, divisions, record.tolerance) {
|
||||
match geometry(
|
||||
&candidate,
|
||||
source,
|
||||
record.forward,
|
||||
record.distance,
|
||||
divisions,
|
||||
Fit { tolerance: record.tolerance, periodic: record.certificate == 2 },
|
||||
&mut work,
|
||||
) {
|
||||
Ok(geometry) => break geometry,
|
||||
Err(error) => {
|
||||
if divisions >= 256 || source_pieces.saturating_mul(divisions.saturating_mul(2)) >= crate::MAX_SPLINE_POINTS {
|
||||
@@ -500,7 +522,13 @@ impl Sketch {
|
||||
return Err(bad("offset source topology changed"));
|
||||
}
|
||||
let old_indices = match &candidate.curves[output].kind {
|
||||
CurveKind::Spline { pts, control: false, degree: 3 } if pts.len() == record.pieces * record.divisions + 1 => pts.clone(),
|
||||
CurveKind::Spline { pts, control: false, degree: 3, periodic }
|
||||
if *periodic == (record.certificate == 2)
|
||||
&& (record.certificate != 2 || record.closed)
|
||||
&& pts.len() == record.pieces * record.divisions + 1 =>
|
||||
{
|
||||
pts.clone()
|
||||
}
|
||||
_ => return Err(bad("offset output was edited; break its association first")),
|
||||
};
|
||||
// Refinement never reuses or removes an old node ID. Existing dyadic
|
||||
@@ -526,7 +554,7 @@ impl Sketch {
|
||||
p.pos = pos;
|
||||
p.fixed = true;
|
||||
}
|
||||
candidate.curves[output].kind = CurveKind::Spline { pts: indices, control: false, degree: 3 };
|
||||
candidate.curves[output].kind = CurveKind::Spline { pts: indices, control: false, degree: 3, periodic: record.certificate == 2 };
|
||||
candidate.freeform_offsets[record_index].divisions = divisions;
|
||||
candidate.freeform_offsets[record_index].pieces = pieces;
|
||||
}
|
||||
@@ -689,9 +717,124 @@ mod tests {
|
||||
fn certified_offset_closed_chain_refuses_natural_spline_seam_atomically() {
|
||||
let (mut sk, source) = ellipse();
|
||||
let first = sk.add_freeform_offset(source, true, -1.0).unwrap();
|
||||
// Explicitly reconstruct the old saved semantics, not the new periodic output.
|
||||
if let CurveKind::Spline { periodic, .. } = &mut sk.curves[first].kind {
|
||||
*periodic = false;
|
||||
}
|
||||
sk.freeform_offsets[0].certificate = 1;
|
||||
sk.refresh_offsets().unwrap();
|
||||
let before = sk.clone();
|
||||
let error = sk.add_freeform_offset(first, true, -0.5).unwrap_err();
|
||||
assert!(error.to_string().contains("nonsmooth seam"), "{error}");
|
||||
assert_eq!(sk, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_offset_periodic_closed_chain_has_c2_seams_and_stable_roles() {
|
||||
let (mut sk, source) = ellipse();
|
||||
let source_points = sk.curves[source].kind.point_ids();
|
||||
let unchanged = source_points.iter().map(|i| sk.points[*i].clone()).collect::<Vec<_>>();
|
||||
let a = sk.add_freeform_offset(source, true, -1.0).unwrap();
|
||||
let b = sk.add_freeform_offset(a, true, -0.5).unwrap();
|
||||
assert_eq!(source_points.iter().map(|i| sk.points[*i].clone()).collect::<Vec<_>>(), unchanged);
|
||||
let old = sk.freeform_offsets.clone();
|
||||
let ids = [a, b].map(|i| sk.curves[i].kind.point_ids().iter().map(|p| sk.points[*p].id.clone()).collect::<Vec<_>>());
|
||||
let verify = |sk: &Sketch| {
|
||||
for output in [a, b] {
|
||||
let record = sk.freeform_offsets.iter().find(|r| r.output == sk.curves[output].id).unwrap();
|
||||
assert_eq!(record.certificate, 2);
|
||||
let points = sk.curves[output].kind.point_ids().iter().map(|p| sk.point(*p).unwrap()).collect::<Vec<_>>();
|
||||
let cubics = crate::periodic_beziers(&points).unwrap();
|
||||
let first = cubics[0];
|
||||
let last = *cubics.last().unwrap();
|
||||
let (h0, h1) = (points[0].dist(points[1]), points[points.len() - 2].dist(points[points.len() - 1]));
|
||||
assert_eq!(first[0], last[3]);
|
||||
assert!(((first[1] - first[0]) / h0 - (last[3] - last[2]) / h1).len() < 1e-10);
|
||||
assert!(((first[2] - first[1] * 2.0 + first[0]) / (h0 * h0) - (last[3] - last[2] * 2.0 + last[1]) / (h1 * h1)).len() < 1e-9);
|
||||
let source = sk.curve_index(&record.source).unwrap();
|
||||
for (i, piece) in sk.exact_segs(source).unwrap().iter().enumerate() {
|
||||
for j in 0..record.divisions {
|
||||
for t in [0.0, 0.13, 0.41, 0.79, 1.0] {
|
||||
let exact = normal_point(piece, (j as f64 + t) / record.divisions as f64, record.distance).unwrap();
|
||||
let got = sk.exact_segs(output).unwrap()[i * record.divisions + j].point_at(t);
|
||||
assert!(got.dist(exact) <= OFFSET_TOLERANCE);
|
||||
if source == 0 {
|
||||
// Independent ellipse implicit-gradient normal, rather
|
||||
// than using the offset implementation as its own oracle.
|
||||
let base = piece.point_at((j as f64 + t) / record.divisions as f64);
|
||||
let major = sk.point(source_points[1]).unwrap().x;
|
||||
let normal = Vec2::new(base.x / (major * major), base.y / 25.0).normalized().unwrap();
|
||||
assert!(got.dist(base + normal) <= OFFSET_TOLERANCE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
verify(&sk);
|
||||
// Steiner's formula is independent of fitted output: both offsets expand
|
||||
// the original ellipse by 1.5mm, within the accumulated two certificates.
|
||||
let n = 4096;
|
||||
let h = std::f64::consts::TAU / n as f64;
|
||||
let perimeter = (0..=n)
|
||||
.map(|i| {
|
||||
let t = i as f64 * h;
|
||||
(100.0 * t.sin().powi(2) + 25.0 * t.cos().powi(2)).sqrt()
|
||||
* if i == 0 || i == n {
|
||||
1.0
|
||||
} else if i % 2 == 0 {
|
||||
2.0
|
||||
} else {
|
||||
4.0
|
||||
}
|
||||
})
|
||||
.sum::<f64>()
|
||||
* h
|
||||
/ 3.0;
|
||||
let expected = 50.0 * std::f64::consts::PI + 1.5 * perimeter + 2.25 * std::f64::consts::PI;
|
||||
let area = sk.exact_segs(b).unwrap().iter().map(Seg2::area_term).sum::<f64>();
|
||||
assert!((area - expected).abs() < 0.03, "{area} vs {expected}");
|
||||
let profiles = crate::find_profiles(&sk);
|
||||
assert_eq!(profiles.len(), 3);
|
||||
if let CurveKind::Ellipse { m, .. } = sk.curves[source].kind {
|
||||
sk.points[m].pos.x = 12.0;
|
||||
}
|
||||
sk.refresh_offsets().unwrap();
|
||||
verify(&sk);
|
||||
for (j, i) in [a, b].into_iter().enumerate() {
|
||||
let now = &sk.freeform_offsets[j];
|
||||
let factor = now.pieces * now.divisions / (old[j].pieces * old[j].divisions);
|
||||
assert_eq!(sk.curves[i].kind.point_ids().iter().step_by(factor).map(|p| sk.points[*p].id.clone()).collect::<Vec<_>>(), ids[j]);
|
||||
}
|
||||
let encoded = serde_json::to_string(&sk).unwrap();
|
||||
let mut reopened: Sketch = serde_json::from_str(&encoded).unwrap();
|
||||
reopened.validate_periodic_splines().unwrap();
|
||||
reopened.refresh_offsets().unwrap();
|
||||
assert_eq!(sk, reopened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_offset_periodic_circle_and_reverse_have_analytic_normals() {
|
||||
for forward in [true, false] {
|
||||
let mut sk = Sketch::new();
|
||||
let center = sk.add_point(Vec2::ZERO, None).unwrap();
|
||||
let major = sk.add_point(Vec2::new(5.0, 0.0), None).unwrap();
|
||||
let source = sk.add_curve(CurveKind::Ellipse { c: center, m: major, r: 5.0 }, None).unwrap();
|
||||
let distance = if forward { -1.0 } else { 1.0 };
|
||||
let a = sk.add_freeform_offset(source, forward, distance).unwrap();
|
||||
let b = sk.add_freeform_offset(a, true, distance * 0.5).unwrap();
|
||||
for (output, radius) in [(a, 6.0), (b, 6.5)] {
|
||||
let pieces = sk.exact_segs(output).unwrap();
|
||||
for curve in &pieces {
|
||||
for t in [0.0, 0.13, 0.41, 0.79, 1.0] {
|
||||
assert!((curve.point_at(t).len() - radius).abs() <= 2.0 * OFFSET_TOLERANCE);
|
||||
}
|
||||
}
|
||||
let area = pieces.iter().map(Seg2::area_term).sum::<f64>().abs();
|
||||
assert!((area - std::f64::consts::PI * radius * radius).abs() < 0.03);
|
||||
}
|
||||
sk.refresh_offsets().unwrap();
|
||||
sk.validate_periodic_splines().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ mod linalg;
|
||||
mod link;
|
||||
mod model;
|
||||
mod offset;
|
||||
mod periodic;
|
||||
mod profiles;
|
||||
mod shape;
|
||||
mod solver;
|
||||
@@ -22,13 +23,15 @@ mod text;
|
||||
mod wire;
|
||||
|
||||
pub use curves::{
|
||||
MAX_SPLINE_POINTS, bspline_beziers, conic_point, ellipse_point, end_curvature, fit_beziers, spline_end_tangent, spline_point, spline_polyline,
|
||||
MAX_SPLINE_POINTS, bspline_beziers, conic_point, ellipse_point, end_curvature, fit_beziers, fit_beziers_mode, spline_end_tangent,
|
||||
spline_end_tangent_mode, spline_point, spline_point_mode, spline_polyline, spline_polyline_mode,
|
||||
};
|
||||
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text};
|
||||
pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE};
|
||||
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
|
||||
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError, SketchView};
|
||||
pub use offset::OffsetChain;
|
||||
pub use periodic::periodic_beziers;
|
||||
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};
|
||||
|
||||
+58
-17
@@ -13,6 +13,9 @@ use crate::model::{Curve, CurveKind, Result, Sketch, SketchError};
|
||||
|
||||
/// Points of one link closer than this are merged into one point.
|
||||
const MERGE_TOL: f64 = 1e-7;
|
||||
|
||||
#[path = "link_loops.rs"]
|
||||
mod loops;
|
||||
/// Most entities a single link may produce.
|
||||
pub const MAX_LINK_ENTITIES: usize = 20_000;
|
||||
|
||||
@@ -107,6 +110,9 @@ pub struct Link {
|
||||
/// Persistent model face identity, resolved by the document before geometry refresh.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub face_name: Option<String>,
|
||||
/// Automatically projected borders owned by a durable sketch face attachment.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub auto_face: bool,
|
||||
/// The reference could not be found on the last recompute; the geometry is the last good one.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub lost: bool,
|
||||
@@ -142,6 +148,7 @@ pub enum LinkGeom {
|
||||
pts: Vec<Vec2>,
|
||||
control: bool,
|
||||
degree: u8,
|
||||
periodic: bool,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -157,8 +164,11 @@ impl LinkGeom {
|
||||
LinkGeom::Ellipse { c, major, minor } => {
|
||||
ok(c) && ok(major) && c.dist(*major) > 1e-9 && minor.is_finite() && *minor > 1e-9 && *minor < 1e9
|
||||
}
|
||||
LinkGeom::Spline { pts, degree, .. } => {
|
||||
(2..=crate::curves::MAX_SPLINE_POINTS).contains(&pts.len()) && pts.iter().all(ok) && (1..=7).contains(degree)
|
||||
LinkGeom::Spline { pts, control, degree, periodic } => {
|
||||
(!*periodic || (!*control && *degree == 3 && crate::periodic_beziers(pts).is_ok()))
|
||||
&& (2..=crate::curves::MAX_SPLINE_POINTS).contains(&pts.len())
|
||||
&& pts.iter().all(ok)
|
||||
&& (1..=7).contains(degree)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,7 +191,7 @@ enum LCurve {
|
||||
Ellipse(usize, usize, f64),
|
||||
/// Start, end, first interior point and interior count (interior points are consecutive
|
||||
/// and never shared), control flag, degree.
|
||||
Spline(usize, usize, usize, usize, bool, u8),
|
||||
Spline(usize, usize, usize, usize, bool, u8, bool),
|
||||
}
|
||||
|
||||
fn layout(geom: &[LinkGeom]) -> Result<Layout> {
|
||||
@@ -234,14 +244,14 @@ fn layout(geom: &[LinkGeom]) -> Result<Layout> {
|
||||
l.curves.push(LCurve::Ellipse(c, m, minor));
|
||||
}
|
||||
}
|
||||
LinkGeom::Spline { pts, control, degree } => {
|
||||
LinkGeom::Spline { pts, control, degree, periodic } => {
|
||||
let (Some(first), Some(last)) = (pts.first(), pts.last()) else { continue };
|
||||
let s0 = pt(&mut l.pts, *first);
|
||||
let first_in = l.pts.len();
|
||||
let inner = pts.get(1..pts.len() - 1).unwrap_or_default();
|
||||
l.pts.extend(inner.iter().copied());
|
||||
let s1 = pt(&mut l.pts, *last);
|
||||
l.curves.push(LCurve::Spline(s0, s1, first_in, inner.len(), control, degree));
|
||||
l.curves.push(LCurve::Spline(s0, s1, first_in, inner.len(), control, degree, periodic));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,7 +303,7 @@ fn same_geometry(a: &[(LCurve, Vec<Vec2>)], b: &[(LCurve, Vec<Vec2>)]) -> bool {
|
||||
(LCurve::Arc(..), LCurve::Arc(..)) => all(),
|
||||
(LCurve::Conic(.., ra), LCurve::Conic(.., rb)) => all() && close(*ra, *rb),
|
||||
(LCurve::Ellipse(.., ra), LCurve::Ellipse(.., rb)) => all() && close(*ra, *rb),
|
||||
(LCurve::Spline(.., ca, da), LCurve::Spline(.., cb, db)) => ca == cb && da == db && all(),
|
||||
(LCurve::Spline(.., ca, da, pa), LCurve::Spline(.., cb, db, pb)) => ca == cb && da == db && pa == pb && all(),
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
@@ -420,7 +430,7 @@ impl Sketch {
|
||||
return Err(SketchError::TooLarge);
|
||||
}
|
||||
let id = self.fresh("j");
|
||||
self.links.push(Link { id: id.clone(), kind, source, face_name: None, lost: false });
|
||||
self.links.push(Link { id: id.clone(), kind, source, face_name: None, auto_face: false, lost: false });
|
||||
self.build_link(&id, &lay)?;
|
||||
Ok(id)
|
||||
}
|
||||
@@ -449,13 +459,13 @@ impl Sketch {
|
||||
LCurve::Arc(c, a, b) => (CurveKind::Arc { c: point(self, c)?, a: point(self, a)?, b: point(self, b)? }, "a"),
|
||||
LCurve::Conic(a, b, x, rho) => (CurveKind::Conic { a: point(self, a)?, b: point(self, b)?, apex: point(self, x)?, rho }, "k"),
|
||||
LCurve::Ellipse(c, m, r) => (CurveKind::Ellipse { c: point(self, c)?, m: point(self, m)?, r }, "e"),
|
||||
LCurve::Spline(a, b, f, n, control, degree) => {
|
||||
LCurve::Spline(a, b, f, n, control, degree, periodic) => {
|
||||
let mut pts = vec![point(self, a)?];
|
||||
for i in f..f + n {
|
||||
pts.push(point(self, i)?);
|
||||
}
|
||||
pts.push(point(self, b)?);
|
||||
(CurveKind::Spline { pts, control, degree }, "s")
|
||||
(CurveKind::Spline { pts, control, degree, periodic }, "s")
|
||||
}
|
||||
};
|
||||
let cid = self.fresh(prefix);
|
||||
@@ -484,6 +494,16 @@ impl Sketch {
|
||||
return Err(SketchError::Unknown(id.into()));
|
||||
}
|
||||
let lay = layout(geom)?;
|
||||
if self.link(id).is_some_and(|l| matches!(l.source, LinkSource::Face { .. }))
|
||||
&& lay.lone.is_empty()
|
||||
&& lay.curves.len() <= 512
|
||||
&& lay.pts.len() <= 512
|
||||
&& lay.curves.iter().all(|c| matches!(c, LCurve::Line(..) | LCurve::Arc(..) | LCurve::Circle(..)))
|
||||
&& !loops::valid(&lay.curves, &lay.pts)
|
||||
{
|
||||
self.set_link_lost(id, true);
|
||||
return Err(SketchError::Invalid("projected face boundaries cross, touch or have ambiguous topology; last geometry retained".into()));
|
||||
}
|
||||
let curves = self.link_curves(id);
|
||||
let points = self.link_points(id);
|
||||
// Current layout of the link, in the same terms.
|
||||
@@ -496,7 +516,7 @@ impl Sketch {
|
||||
Some(CurveKind::Arc { c, a, b }) => Some(LCurve::Arc(local(*c)?, local(*a)?, local(*b)?)),
|
||||
Some(CurveKind::Conic { a, b, apex, rho }) => Some(LCurve::Conic(local(*a)?, local(*b)?, local(*apex)?, *rho)),
|
||||
Some(CurveKind::Ellipse { c, m, r }) => Some(LCurve::Ellipse(local(*c)?, local(*m)?, *r)),
|
||||
Some(CurveKind::Spline { pts, control, degree }) => {
|
||||
Some(CurveKind::Spline { pts, control, degree, periodic }) => {
|
||||
let (a, b) = (local(*pts.first()?)?, local(*pts.last()?)?);
|
||||
let inner: Vec<usize> =
|
||||
pts.get(1..pts.len().saturating_sub(1)).unwrap_or_default().iter().map(|q| local(*q)).collect::<Option<_>>()?;
|
||||
@@ -505,7 +525,7 @@ impl Sketch {
|
||||
if inner.iter().enumerate().any(|(k, x)| *x != f + k) {
|
||||
return None;
|
||||
}
|
||||
Some(LCurve::Spline(a, b, f, inner.len(), *control, *degree))
|
||||
Some(LCurve::Spline(a, b, f, inner.len(), *control, *degree, *periodic))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
@@ -561,7 +581,7 @@ impl Sketch {
|
||||
LCurve::Arc(c, a, b) => LCurve::Arc(renum(c), renum(a), renum(b)),
|
||||
LCurve::Conic(a, b, x, r) => LCurve::Conic(renum(a), renum(b), renum(x), r),
|
||||
LCurve::Ellipse(c, m, r) => LCurve::Ellipse(renum(c), renum(m), r),
|
||||
LCurve::Spline(a, b, f, n, ct, d) => LCurve::Spline(renum(a), renum(b), renum(f), n, ct, d),
|
||||
LCurve::Spline(a, b, f, n, ct, d, periodic) => LCurve::Spline(renum(a), renum(b), renum(f), n, ct, d, periodic),
|
||||
})
|
||||
.collect();
|
||||
let same = match &cur {
|
||||
@@ -576,8 +596,8 @@ impl Sketch {
|
||||
(LCurve::Arc(a0, a1, a2), LCurve::Arc(b0, b1, b2)) => a0 == b0 && a1 == b1 && a2 == b2,
|
||||
(LCurve::Conic(a0, a1, a2, _), LCurve::Conic(b0, b1, b2, _)) => a0 == b0 && a1 == b1 && a2 == b2,
|
||||
(LCurve::Ellipse(a0, a1, _), LCurve::Ellipse(b0, b1, _)) => a0 == b0 && a1 == b1,
|
||||
(LCurve::Spline(a0, a1, a2, a3, a4, a5), LCurve::Spline(b0, b1, b2, b3, b4, b5)) => {
|
||||
a0 == b0 && a1 == b1 && (a2 == b2 || *a3 == 0) && a3 == b3 && a4 == b4 && a5 == b5
|
||||
(LCurve::Spline(a0, a1, a2, a3, a4, a5, a6), LCurve::Spline(b0, b1, b2, b3, b4, b5, b6)) => {
|
||||
a0 == b0 && a1 == b1 && (a2 == b2 || *a3 == 0) && a3 == b3 && a4 == b4 && a5 == b5 && a6 == b6
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
@@ -625,12 +645,33 @@ impl Sketch {
|
||||
let hi = ps.iter().fold(Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), |a, p| Vec2::new(a.x.max(p.x), a.y.max(p.y)));
|
||||
ps.iter().map(|p| Vec2::new((p.x - lo.x) / (hi.x - lo.x).max(1e-9), (p.y - lo.y) / (hi.y - lo.y).max(1e-9))).collect()
|
||||
};
|
||||
let (a, b) = (normalized(&old), normalized(&lay.pts));
|
||||
let loop_roles_required = self.link(id).is_some_and(|l| matches!(l.source, LinkSource::Face { .. }))
|
||||
&& cur.iter().chain(&lay.curves).all(|c| matches!(c, LCurve::Line(..) | LCurve::Arc(..) | LCurve::Circle(..)))
|
||||
&& lay.lone.is_empty();
|
||||
let loop_roles = loop_roles_required.then(|| loops::roles(cur, &old, &lay.curves, &lay.pts)).flatten();
|
||||
if loop_roles_required && loop_roles.is_none() {
|
||||
self.set_link_lost(id, true);
|
||||
return Err(SketchError::Invalid("projected face boundary identity is ambiguous; last geometry retained".into()));
|
||||
}
|
||||
let (a, b) = match &loop_roles {
|
||||
Some(roles) => (roles.old_normal.clone(), roles.fresh_normal.clone()),
|
||||
None => (normalized(&old), normalized(&lay.pts)),
|
||||
};
|
||||
let (old_roles, fresh_roles) = (point_roles(cur, old.len()), point_roles(&lay.curves, lay.pts.len()));
|
||||
let mut mapping = Vec::new();
|
||||
for (old_i, p) in a.iter().enumerate() {
|
||||
let mut candidates: Vec<(usize, f64)> =
|
||||
b.iter().enumerate().filter(|(i, _)| old_roles.get(old_i) == fresh_roles.get(*i)).map(|(i, q)| (i, p.dist(*q))).collect();
|
||||
let mut candidates: Vec<(usize, f64)> = b
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| {
|
||||
old_roles.get(old_i) == fresh_roles.get(*i)
|
||||
&& loop_roles.as_ref().is_none_or(|roles| {
|
||||
roles.old.get(old_i) == roles.fresh.get(*i)
|
||||
&& roles.old_lines.get(old_i).zip(roles.fresh_lines.get(*i)).is_some_and(|(a, b)| loops::same_lines(a, b))
|
||||
})
|
||||
})
|
||||
.map(|(i, q)| (i, p.dist(*q)))
|
||||
.collect();
|
||||
candidates.sort_by(|x, y| x.1.total_cmp(&y.1));
|
||||
if let Some((i, d)) = candidates.first()
|
||||
&& *d < 0.1
|
||||
|
||||
@@ -156,3 +156,178 @@ fn resized_dense_rim_matches_arc_centres_by_role_before_proximity() {
|
||||
}
|
||||
assert_eq!(ids, arcs.iter().map(|i| sk.curves[*i].id.clone()).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_face_resize_matches_each_loop_before_nearby_outer_vertices() {
|
||||
let rectangle = |x: f64, y: f64| {
|
||||
let p = [Vec2::new(-x, -y), Vec2::new(x, -y), Vec2::new(x, y), Vec2::new(-x, y)];
|
||||
(0..4).map(|i| LinkGeom::Line(p[i], p[(i + 1) % 4])).collect::<Vec<_>>()
|
||||
};
|
||||
let mut sk = Sketch::new();
|
||||
let old: Vec<_> = rectangle(38.0, 28.0).into_iter().chain(rectangle(40.0, 30.0)).collect();
|
||||
let link = sk.add_link(LinkKind::Project, face(), &old).unwrap();
|
||||
let ids: Vec<_> = sk.link_curves(&link).iter().map(|i| sk.curves[*i].id.clone()).collect();
|
||||
let points = sk.link_points(&link);
|
||||
let before: Vec<_> = points.iter().map(|i| sk.points[*i].pos).collect();
|
||||
let fresh: Vec<_> = rectangle(41.0, 36.0).into_iter().chain(rectangle(38.0, 33.0)).collect();
|
||||
assert!(!sk.update_link(&link, &fresh).unwrap());
|
||||
assert!(!sk.link(&link).unwrap().lost);
|
||||
for (i, p) in points.iter().zip(before) {
|
||||
let expected = if p.x.abs() == 38.0 { Vec2::new(p.x, p.y.signum() * 33.0) } else { Vec2::new(p.x.signum() * 41.0, p.y.signum() * 36.0) };
|
||||
assert_eq!(sk.points[*i].pos, expected);
|
||||
}
|
||||
assert_eq!(ids, sk.link_curves(&link).iter().map(|i| sk.curves[*i].id.clone()).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growing_face_retains_stationary_hole_arcs_and_shared_centre() {
|
||||
let geometry = |width: f64| {
|
||||
let p = [Vec2::ZERO, Vec2::new(width, 0.0), Vec2::new(width, 30.0), Vec2::new(0.0, 30.0)];
|
||||
let mut out: Vec<_> = (0..4).map(|i| LinkGeom::Line(p[i], p[(i + 1) % 4])).collect();
|
||||
let c = Vec2::new(20.0, 15.0);
|
||||
out.extend([
|
||||
LinkGeom::Arc { c, a: c + Vec2::X * 5.0, b: c - Vec2::X * 5.0 },
|
||||
LinkGeom::Arc { c, a: c - Vec2::X * 5.0, b: c + Vec2::X * 5.0 },
|
||||
]);
|
||||
out
|
||||
};
|
||||
let mut sk = Sketch::new();
|
||||
let link = sk.add_link(LinkKind::Project, face(), &geometry(40.0)).unwrap();
|
||||
let saved = sk.clone();
|
||||
assert!(!sk.update_link(&link, &geometry(50.0)).unwrap());
|
||||
assert!(!sk.link(&link).unwrap().lost);
|
||||
for i in sk.link_curves(&link) {
|
||||
assert_eq!(sk.curves[i].id, saved.curves[i].id);
|
||||
if matches!(sk.curves[i].kind, CurveKind::Arc { .. }) {
|
||||
assert_eq!(sk.curves[i], saved.curves[i]);
|
||||
for p in sk.curves[i].kind.point_ids() {
|
||||
assert_eq!(sk.points[p], saved.points[p]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touching_or_crossed_face_boundaries_refuse_without_retargeting_ids() {
|
||||
for x in [9.0, 11.0] {
|
||||
let mut sk = Sketch::new();
|
||||
let link = sk.add_link(LinkKind::Project, face(), &[LinkGeom::Circle(Vec2::ZERO, 10.0), LinkGeom::Circle(Vec2::ZERO, 1.0)]).unwrap();
|
||||
let saved = sk.clone();
|
||||
assert!(sk.update_link(&link, &[LinkGeom::Circle(Vec2::ZERO, 10.0), LinkGeom::Circle(Vec2::new(x, 0.0), 1.0)]).is_err());
|
||||
assert_eq!(sk.points, saved.points);
|
||||
assert_eq!(sk.curves, saved.curves);
|
||||
assert!(sk.link(&link).unwrap().lost);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resized_rounded_rim_retains_inner_outer_roles_with_shared_arc_centres() {
|
||||
let rounded = |x: f64, y: f64, r: f64| {
|
||||
let centres = [Vec2::new(x - r, -y + r), Vec2::new(x - r, y - r), Vec2::new(-x + r, y - r), Vec2::new(-x + r, -y + r)];
|
||||
let mut out = Vec::new();
|
||||
for i in 0..4 {
|
||||
let start = -std::f64::consts::FRAC_PI_2 + i as f64 * std::f64::consts::FRAC_PI_2;
|
||||
let previous = centres[(i + 3) % 4] + Vec2::from_angle(start) * r;
|
||||
let a = centres[i] + Vec2::from_angle(start) * r;
|
||||
let b = centres[i] + Vec2::from_angle(start + std::f64::consts::FRAC_PI_2) * r;
|
||||
out.push(LinkGeom::Line(previous, a));
|
||||
out.push(LinkGeom::Arc { c: centres[i], a, b });
|
||||
}
|
||||
out
|
||||
};
|
||||
let mut sk = Sketch::new();
|
||||
let old: Vec<_> = rounded(38.0, 28.0, 2.0).into_iter().chain(rounded(40.0, 30.0, 4.0)).collect();
|
||||
let link = sk.add_link(LinkKind::Project, face(), &old).unwrap();
|
||||
let curves = sk.link_curves(&link);
|
||||
let ids: Vec<_> = curves.iter().map(|i| sk.curves[*i].id.clone()).collect();
|
||||
let fresh: Vec<_> = rounded(41.0, 36.0, 4.0).into_iter().chain(rounded(38.0, 33.0, 1.0)).collect();
|
||||
assert!(!sk.update_link(&link, &fresh).unwrap());
|
||||
assert!(!sk.link(&link).unwrap().lost);
|
||||
assert_eq!(ids, sk.link_curves(&link).iter().map(|i| sk.curves[*i].id.clone()).collect::<Vec<_>>());
|
||||
for (old, fresh) in curves.iter().zip(rounded(38.0, 33.0, 1.0).into_iter().chain(rounded(41.0, 36.0, 4.0))) {
|
||||
match (&sk.curves[*old].kind, fresh) {
|
||||
(CurveKind::Line { a, b }, LinkGeom::Line(p, q)) => {
|
||||
assert!(sk.points[*a].pos.dist(p) < 1e-10);
|
||||
assert!(sk.points[*b].pos.dist(q) < 1e-10);
|
||||
}
|
||||
(CurveKind::Arc { c, a, b }, LinkGeom::Arc { c: p, a: q, b: r }) => {
|
||||
assert!(sk.points[*c].pos.dist(p) < 1e-10);
|
||||
assert!(sk.points[*a].pos.dist(q) < 1e-10);
|
||||
assert!(sk.points[*b].pos.dist(r) < 1e-10);
|
||||
}
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn far_translated_small_hole_crossing_long_edge_is_not_certified_separate() {
|
||||
let mut sk = Sketch::new();
|
||||
let p = [Vec2::new(-1e8, 1.0), Vec2::new(1e8, 1.0), Vec2::new(1e8, 10.0), Vec2::new(-1e8, 10.0)];
|
||||
let mut old: Vec<_> = (0..4).map(|i| LinkGeom::Line(p[i], p[(i + 1) % 4])).collect();
|
||||
old.push(LinkGeom::Circle(Vec2::new(1e6, 5.0), 2.0));
|
||||
let link = sk.add_link(LinkKind::Project, face(), &old).unwrap();
|
||||
let saved = sk.clone();
|
||||
let mut fresh: Vec<_> = (0..4).map(|i| LinkGeom::Line(p[i], p[(i + 1) % 4])).collect();
|
||||
fresh.push(LinkGeom::Circle(Vec2::new(1e6, 0.0), 2.0));
|
||||
assert!(sk.update_link(&link, &fresh).is_err());
|
||||
assert_eq!(sk.points, saved.points);
|
||||
assert_eq!(sk.curves, saved.curves);
|
||||
assert!(sk.link(&link).unwrap().lost);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_nested_sibling_candidates_remain_ambiguous_after_loop_classification() {
|
||||
let mut sk = Sketch::new();
|
||||
let link = sk
|
||||
.add_link(
|
||||
LinkKind::Project,
|
||||
face(),
|
||||
&[LinkGeom::Circle(Vec2::ZERO, 10.0), LinkGeom::Circle(Vec2::new(-2.0, 0.0), 1.0), LinkGeom::Circle(Vec2::new(2.0, 0.0), 1.0)],
|
||||
)
|
||||
.unwrap();
|
||||
let saved = sk.clone();
|
||||
assert!(
|
||||
sk.update_link(
|
||||
&link,
|
||||
&[LinkGeom::Circle(Vec2::ZERO, 10.0), LinkGeom::Circle(Vec2::new(0.0, -2.0), 1.0), LinkGeom::Circle(Vec2::new(0.0, 2.0), 1.0)]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(sk.points, saved.points);
|
||||
assert_eq!(sk.curves, saved.curves);
|
||||
assert!(sk.link(&link).unwrap().lost);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_circle_crossing_huge_round_boundary_is_not_certified_separate() {
|
||||
let mut sk = Sketch::new();
|
||||
let direction = Vec2::new(std::f64::consts::FRAC_1_SQRT_2, std::f64::consts::FRAC_1_SQRT_2);
|
||||
let link = sk.add_link(LinkKind::Project, face(), &[LinkGeom::Circle(Vec2::ZERO, 1e8), LinkGeom::Circle(direction * (1e8 + 10.0), 2.0)]).unwrap();
|
||||
let saved = sk.clone();
|
||||
assert!(sk.update_link(&link, &[LinkGeom::Circle(Vec2::ZERO, 1e8), LinkGeom::Circle(direction * (1e8 + 1.0), 2.0)]).is_err());
|
||||
assert_eq!(sk.points, saved.points);
|
||||
assert_eq!(sk.curves, saved.curves);
|
||||
assert!(sk.link(&link).unwrap().lost);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_loop_does_not_make_nearby_bowtie_edges_topologically_adjacent() {
|
||||
let mut sk = Sketch::new();
|
||||
let link = sk.add_link(LinkKind::Project, face(), &square(10.0)).unwrap();
|
||||
let saved = sk.clone();
|
||||
let p = [
|
||||
Vec2::ZERO,
|
||||
Vec2::new(0.04, 0.04),
|
||||
Vec2::new(0.0, 0.04),
|
||||
Vec2::new(0.04, 0.0),
|
||||
Vec2::new(9e8, 0.0),
|
||||
Vec2::new(9e8, 10.0),
|
||||
Vec2::new(0.0, 10.0),
|
||||
];
|
||||
let crossed: Vec<_> = (0..p.len()).map(|i| LinkGeom::Line(p[i], p[(i + 1) % p.len()])).collect();
|
||||
assert!(sk.update_link(&link, &crossed).is_err());
|
||||
assert_eq!(sk.points, saved.points);
|
||||
assert_eq!(sk.curves, saved.curves);
|
||||
assert!(sk.link(&link).unwrap().lost);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
//! Conservative analytic boundary roles for associative face identity.
|
||||
//! Endpoint walks exclude support centres. Crossings/touches refuse before nesting.
|
||||
use super::{LCurve, curve_points};
|
||||
use crate::{Shape, intersections};
|
||||
use solvecraft_geom::Vec2;
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
struct Boundary {
|
||||
curves: Vec<usize>,
|
||||
shapes: Vec<Shape>,
|
||||
lo: Vec2,
|
||||
hi: Vec2,
|
||||
depth: usize,
|
||||
signature: [usize; 3],
|
||||
}
|
||||
pub(super) struct Roles {
|
||||
pub old: Vec<Vec<usize>>,
|
||||
pub fresh: Vec<Vec<usize>>,
|
||||
pub old_normal: Vec<Vec2>,
|
||||
pub fresh_normal: Vec<Vec2>,
|
||||
pub old_lines: Vec<Vec<Vec2>>,
|
||||
pub fresh_lines: Vec<Vec<Vec2>>,
|
||||
}
|
||||
|
||||
pub(super) fn same_lines(a: &[Vec2], b: &[Vec2]) -> bool {
|
||||
a.len() == b.len() && a.iter().all(|x| b.iter().any(|y| x.dot(*y).abs() > 0.98))
|
||||
}
|
||||
fn on(s: &Shape, p: Vec2, tol: f64) -> bool {
|
||||
match *s {
|
||||
Shape::Line { a, b } => {
|
||||
let d = b - a;
|
||||
let t = (p - a).dot(d) / d.len2().max(1e-30);
|
||||
t >= -tol / d.len().max(tol) && t <= 1.0 + tol / d.len().max(tol) && (a + d * t).dist(p) <= tol
|
||||
}
|
||||
Shape::Round { c, r, start, sweep } => {
|
||||
(p.dist(c) - r).abs() <= tol && (sweep >= TAU - 1e-12 || ((p - c).angle() - start).rem_euclid(TAU) <= sweep + tol / r)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn endpoints(s: &Shape) -> [Vec2; 2] {
|
||||
let (_, end) = s.range();
|
||||
[s.point(0.0), s.point(end)]
|
||||
}
|
||||
fn stable_intersections(a: &Shape, b: &Shape, tol: f64) -> Vec<Vec2> {
|
||||
match (*a, *b) {
|
||||
(Shape::Line { a, b }, Shape::Round { c, r, .. }) | (Shape::Round { c, r, .. }, Shape::Line { a, b }) => {
|
||||
let Some(direction) = (b - a).normalized() else { return Vec::new() };
|
||||
// Work in the circle's local orthonormal frame. The world-coordinate
|
||||
// quadratic discriminant can lose every significant bit on long lines.
|
||||
let foot = a + direction * (c - a).dot(direction);
|
||||
let distance = foot.dist(c);
|
||||
if distance > r + tol {
|
||||
return Vec::new();
|
||||
}
|
||||
let half = ((r - distance).max(0.0) * (r + distance)).sqrt();
|
||||
vec![foot - direction * half, foot + direction * half]
|
||||
}
|
||||
(Shape::Round { c: ac, r: ar, .. }, Shape::Round { c: bc, r: br, .. }) => {
|
||||
let ((small, r), (large, radius)) = if ar <= br { ((ac, ar), (bc, br)) } else { ((bc, br), (ac, ar)) };
|
||||
let delta = large - small;
|
||||
let distance = delta.len();
|
||||
if distance <= tol || distance > radius + r + tol || distance < radius - r - tol {
|
||||
return Vec::new();
|
||||
}
|
||||
let direction = delta / distance;
|
||||
// Compute from the smaller circle; factoring d^2-R^2 avoids the
|
||||
// cancellation of R^2-a^2 for a small hole crossing a huge circle.
|
||||
let along = ((distance - radius) * (distance + radius) + r * r) / (2.0 * distance);
|
||||
let height = ((r - along).max(0.0) * (r + along).max(0.0)).sqrt();
|
||||
let foot = small + direction * along;
|
||||
vec![foot - direction.perp() * height, foot + direction.perp() * height]
|
||||
}
|
||||
_ => intersections(a, b),
|
||||
}
|
||||
}
|
||||
fn clear(a: &Shape, b: &Shape, shared: &[Vec2], tol: f64) -> bool {
|
||||
let allowed = |p: Vec2| {
|
||||
shared.iter().any(|q| {
|
||||
let joint_tol = 1e-7 + 128.0 * f64::EPSILON * (1.0 + p.len().max(q.len()));
|
||||
q.dist(p) <= joint_tol
|
||||
})
|
||||
};
|
||||
// Parallel coincident lines/circles have no isolated intersections.
|
||||
// Interior overlap is nevertheless ambiguous, including duplicate boundaries.
|
||||
for s in [a, b] {
|
||||
let other = if std::ptr::eq(s, a) { b } else { a };
|
||||
let (_, end) = s.range();
|
||||
for t in [0.0, 0.5 * end, end] {
|
||||
let p = s.point(t);
|
||||
if on(other, p, tol) && !allowed(p) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
stable_intersections(a, b, tol).into_iter().all(|p| !on(a, p, tol) || !on(b, p, tol) || allowed(p))
|
||||
}
|
||||
fn contains(boundary: &Boundary, p: Vec2, tol: f64) -> Option<bool> {
|
||||
let mut crossings = 0;
|
||||
for shape in &boundary.shapes {
|
||||
// Avoid ray/vertex coincidences rather than guessing a half-open arc rule.
|
||||
if endpoints(shape).iter().any(|q| (q.y - p.y).abs() <= tol) {
|
||||
return None;
|
||||
}
|
||||
match *shape {
|
||||
Shape::Line { a, b } => {
|
||||
if (a.y > p.y) != (b.y > p.y) {
|
||||
let x = a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y);
|
||||
if (x - p.x).abs() <= tol {
|
||||
return None;
|
||||
}
|
||||
crossings += usize::from(x > p.x);
|
||||
}
|
||||
}
|
||||
Shape::Round { c, r, start, sweep } => {
|
||||
let y = (p.y - c.y) / r;
|
||||
if (y.abs() - 1.0).abs() <= tol / r {
|
||||
return None;
|
||||
}
|
||||
if y.abs() < 1.0 {
|
||||
let angle = y.asin();
|
||||
for theta in [angle, std::f64::consts::PI - angle] {
|
||||
let off = (theta - start).rem_euclid(TAU);
|
||||
if off < sweep {
|
||||
let x = c.x + r * theta.cos();
|
||||
if (x - p.x).abs() <= tol {
|
||||
return None;
|
||||
}
|
||||
crossings += usize::from(x > p.x);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(crossings % 2 == 1)
|
||||
}
|
||||
fn boundaries(curves: &[LCurve], pts: &[Vec2]) -> Option<Vec<Boundary>> {
|
||||
let get = |i| pts.get(i).copied();
|
||||
let mut shapes = Vec::new();
|
||||
let mut ends = Vec::new();
|
||||
let mut incidence = vec![Vec::new(); pts.len()];
|
||||
for (i, curve) in curves.iter().enumerate() {
|
||||
let (shape, ep) = match *curve {
|
||||
LCurve::Line(a, b) => (Shape::Line { a: get(a)?, b: get(b)? }, Some((a, b))),
|
||||
LCurve::Arc(c, a, b) => {
|
||||
let (c, a_pos, b_pos) = (get(c)?, get(a)?, get(b)?);
|
||||
let sweep = ((b_pos - c).angle() - (a_pos - c).angle()).rem_euclid(TAU);
|
||||
(Shape::Round { c, r: c.dist(a_pos), start: (a_pos - c).angle(), sweep }, Some((a, b)))
|
||||
}
|
||||
LCurve::Circle(c, r) => (Shape::Round { c: get(c)?, r, start: 0.0, sweep: TAU }, None),
|
||||
// Existing free-form identity matching remains conservative and unchanged.
|
||||
_ => return None,
|
||||
};
|
||||
if let Some((a, b)) = ep {
|
||||
incidence.get_mut(a)?.push(i);
|
||||
incidence.get_mut(b)?.push(i);
|
||||
}
|
||||
shapes.push(shape);
|
||||
ends.push(ep);
|
||||
}
|
||||
if incidence.iter().any(|v| !v.is_empty() && v.len() != 2) {
|
||||
return None;
|
||||
}
|
||||
let mut visited = vec![false; curves.len()];
|
||||
let mut out = Vec::new();
|
||||
for first in 0..curves.len() {
|
||||
if *visited.get(first)? {
|
||||
continue;
|
||||
}
|
||||
let mut members = Vec::new();
|
||||
let mut pending = vec![first];
|
||||
while let Some(i) = pending.pop() {
|
||||
if *visited.get(i)? {
|
||||
continue;
|
||||
}
|
||||
*visited.get_mut(i)? = true;
|
||||
members.push(i);
|
||||
if let Some((a, b)) = *ends.get(i)? {
|
||||
pending.extend(incidence.get(a)?);
|
||||
pending.extend(incidence.get(b)?);
|
||||
}
|
||||
}
|
||||
let mut lo = Vec2::new(f64::INFINITY, f64::INFINITY);
|
||||
let mut hi = Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
let mut signature = [0; 3];
|
||||
let mut bound_shapes = Vec::new();
|
||||
for i in &members {
|
||||
let shape = *shapes.get(*i)?;
|
||||
let mut candidates = endpoints(&shape).to_vec();
|
||||
if let Shape::Round { c, r, .. } = shape {
|
||||
for angle in [0.0, std::f64::consts::FRAC_PI_2, std::f64::consts::PI, 3.0 * std::f64::consts::FRAC_PI_2] {
|
||||
let p = c + Vec2::from_angle(angle) * r;
|
||||
if on(&shape, p, 1e-7) {
|
||||
candidates.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
for p in candidates {
|
||||
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
|
||||
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
|
||||
}
|
||||
signature[match curves.get(*i)? {
|
||||
LCurve::Line(..) => 0,
|
||||
LCurve::Arc(..) => 1,
|
||||
_ => 2,
|
||||
}] += 1;
|
||||
bound_shapes.push(shape);
|
||||
}
|
||||
out.push(Boundary { curves: members, shapes: bound_shapes, lo, hi, depth: 0, signature });
|
||||
}
|
||||
let scale = out.iter().map(|b| (b.hi - b.lo).len()).fold(1.0, f64::max);
|
||||
let tol = (scale * 1e-10).max(1e-7);
|
||||
let mut work = 0usize;
|
||||
for i in 0..out.len() {
|
||||
for j in i..out.len() {
|
||||
for (a_i, a) in out.get(i)?.shapes.iter().enumerate() {
|
||||
for (b_i, b) in out.get(j)?.shapes.iter().enumerate() {
|
||||
if i == j && a_i >= b_i {
|
||||
continue;
|
||||
}
|
||||
work += 1;
|
||||
if work > 262_144 {
|
||||
return None;
|
||||
}
|
||||
let mut shared = Vec::new();
|
||||
if i == j {
|
||||
let ai = *out.get(i)?.curves.get(a_i)?;
|
||||
let bi = *out.get(j)?.curves.get(b_i)?;
|
||||
if let (Some((a0, a1)), Some((b0, b1))) = (*ends.get(ai)?, *ends.get(bi)?) {
|
||||
for point in [a0, a1] {
|
||||
if point == b0 || point == b1 {
|
||||
shared.push(get(point)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !clear(a, b, &shared, tol) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..out.len() {
|
||||
let mut depth = 0;
|
||||
for j in 0..out.len() {
|
||||
if i == j {
|
||||
continue;
|
||||
}
|
||||
let shape = out.get(i)?.shapes.first()?;
|
||||
let (_, end) = shape.range();
|
||||
let inside = [0.317, 0.613, 0.823].into_iter().find_map(|t| contains(out.get(j)?, shape.point(end * t), tol))?;
|
||||
depth += usize::from(inside);
|
||||
}
|
||||
out.get_mut(i)?.depth = depth;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub(super) fn valid(curves: &[LCurve], points: &[Vec2]) -> bool {
|
||||
boundaries(curves, points).is_some()
|
||||
}
|
||||
fn normalize(p: Vec2, lo: Vec2, hi: Vec2) -> Vec2 {
|
||||
Vec2::new((p.x - lo.x) / (hi.x - lo.x).max(1e-9), (p.y - lo.y) / (hi.y - lo.y).max(1e-9))
|
||||
}
|
||||
pub(super) fn roles(old_curves: &[LCurve], a: &[Vec2], fresh_curves: &[LCurve], b: &[Vec2]) -> Option<Roles> {
|
||||
let (previous, incoming) = (boundaries(old_curves, a)?, boundaries(fresh_curves, b)?);
|
||||
if previous.len() != incoming.len() {
|
||||
return None;
|
||||
}
|
||||
let bounds = |loops: &[Boundary]| {
|
||||
loops.iter().fold((Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)), |(lo, hi), x| {
|
||||
(Vec2::new(lo.x.min(x.lo.x), lo.y.min(x.lo.y)), Vec2::new(hi.x.max(x.hi.x), hi.y.max(x.hi.y)))
|
||||
})
|
||||
};
|
||||
let ((alo, ahi), (blo, bhi)) = (bounds(&previous), bounds(&incoming));
|
||||
let mut mapping = Vec::new();
|
||||
for x in &previous {
|
||||
let mut candidates: Vec<_> = incoming
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, y)| x.depth == y.depth && x.signature == y.signature)
|
||||
.map(|(i, y)| (i, normalize((x.lo + x.hi) * 0.5, alo, ahi).dist(normalize((y.lo + y.hi) * 0.5, blo, bhi))))
|
||||
.collect();
|
||||
candidates.sort_by(|x, y| x.1.total_cmp(&y.1));
|
||||
let (i, d) = *candidates.first()?;
|
||||
if mapping.contains(&i) || (candidates.len() > 1 && (d >= 0.1 || candidates.get(1).is_some_and(|(_, second)| d >= second * 0.5))) {
|
||||
return None;
|
||||
}
|
||||
let y = incoming.get(i)?;
|
||||
// Preserve the conservative bound on relative boundary changes. This does
|
||||
// not mistake a stationary hole's coordinates for the outer loop's scaling.
|
||||
let old_size = normalize(x.hi, alo, ahi) - normalize(x.lo, alo, ahi);
|
||||
let new_size = normalize(y.hi, blo, bhi) - normalize(y.lo, blo, bhi);
|
||||
if old_size.dist(new_size) >= 0.1 {
|
||||
return None;
|
||||
}
|
||||
mapping.push(i);
|
||||
}
|
||||
let membership = |loops: &[Boundary], curves: &[LCurve], pts: &[Vec2], remap: Option<&[usize]>| -> Option<(Vec<Vec<usize>>, Vec<Vec2>)> {
|
||||
let mut roles = vec![Vec::new(); pts.len()];
|
||||
let mut coordinates = vec![Vec2::ZERO; pts.len()];
|
||||
for (i, loop_) in loops.iter().enumerate() {
|
||||
let target = remap.and_then(|m| m.get(i)).copied().unwrap_or(i);
|
||||
for curve in &loop_.curves {
|
||||
for p in curve_points(curves.get(*curve)?) {
|
||||
if !roles.get(p)?.contains(&target) {
|
||||
roles.get_mut(p)?.push(target);
|
||||
*coordinates.get_mut(p)? += normalize(*pts.get(p)?, loop_.lo, loop_.hi);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (role, coordinate) in roles.iter_mut().zip(&mut coordinates) {
|
||||
if role.is_empty() {
|
||||
return None;
|
||||
}
|
||||
role.sort_unstable();
|
||||
*coordinate = *coordinate / role.len() as f64;
|
||||
}
|
||||
Some((roles, coordinates))
|
||||
};
|
||||
let ((old, old_normal), (fresh, fresh_normal)) =
|
||||
(membership(&previous, old_curves, a, Some(&mapping))?, membership(&incoming, fresh_curves, b, None)?);
|
||||
let line_roles = |loops: &[Boundary], curves: &[LCurve], pts: &[Vec2]| -> Option<Vec<Vec<Vec2>>> {
|
||||
let mut roles = vec![Vec::new(); pts.len()];
|
||||
for loop_ in loops {
|
||||
let size = loop_.hi - loop_.lo;
|
||||
for i in &loop_.curves {
|
||||
if let LCurve::Line(a, b) = *curves.get(*i)? {
|
||||
let d = *pts.get(b)? - *pts.get(a)?;
|
||||
let direction = Vec2::new(d.x / size.x.max(1e-9), d.y / size.y.max(1e-9)).normalized()?;
|
||||
roles.get_mut(a)?.push(direction);
|
||||
roles.get_mut(b)?.push(direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(roles)
|
||||
};
|
||||
let old_lines = line_roles(&previous, old_curves, a)?;
|
||||
let fresh_lines = line_roles(&incoming, fresh_curves, b)?;
|
||||
Some(Roles { old, fresh, old_normal, fresh_normal, old_lines, fresh_lines })
|
||||
}
|
||||
@@ -53,6 +53,9 @@ pub enum CurveKind {
|
||||
control: bool,
|
||||
#[serde(default = "three")]
|
||||
degree: u8,
|
||||
/// Explicit cyclic C2 fit interpolation; absent retains natural endpoints.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
periodic: bool,
|
||||
},
|
||||
/// Conic from `a` to `b` shaped by the `apex` point and `rho` in (0, 1) (0.5 parabola,
|
||||
/// less elliptical, more hyperbolic).
|
||||
@@ -111,6 +114,7 @@ impl CurveKind {
|
||||
pub fn ends(&self) -> Option<(usize, usize)> {
|
||||
match self {
|
||||
CurveKind::Line { a, b } | CurveKind::Arc { a, b, .. } | CurveKind::Conic { a, b, .. } => Some((*a, *b)),
|
||||
CurveKind::Spline { periodic: true, .. } => None,
|
||||
CurveKind::Spline { pts, .. } => Some((*pts.first()?, *pts.last()?)),
|
||||
CurveKind::Circle { .. } | CurveKind::Ellipse { .. } => None,
|
||||
}
|
||||
@@ -962,11 +966,13 @@ impl Sketch {
|
||||
)
|
||||
}
|
||||
CurveKind::Conic { a, b, apex, rho } => Some(vec![Seg2::Conic { a: pt(a)?, apex: pt(apex)?, b: pt(b)?, w: rho / (1.0 - rho).max(1e-9) }]),
|
||||
CurveKind::Spline { pts, control: false, .. } => {
|
||||
CurveKind::Spline { pts, control: false, periodic, .. } => {
|
||||
let p: Vec<Vec2> = pts.iter().map(pt).collect::<Option<_>>()?;
|
||||
Some(crate::curves::fit_beziers(&p).into_iter().map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 }).collect())
|
||||
Some(
|
||||
crate::curves::fit_beziers_mode(&p, *periodic).ok()?.into_iter().map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 }).collect(),
|
||||
)
|
||||
}
|
||||
CurveKind::Spline { pts, control: true, degree } if (*degree as usize) <= solvecraft_geom::MAX_BEZIER_DEGREE => {
|
||||
CurveKind::Spline { pts, control: true, degree, .. } if (*degree as usize) <= solvecraft_geom::MAX_BEZIER_DEGREE => {
|
||||
let p: Vec<Vec2> = pts.iter().map(pt).collect::<Option<_>>()?;
|
||||
let k = (*degree as usize).min(p.len().saturating_sub(1)).max(1);
|
||||
Some(crate::curves::bspline_beziers(&p, k).iter().filter_map(|c| solvecraft_geom::bezier(c)).collect())
|
||||
@@ -985,9 +991,9 @@ impl Sketch {
|
||||
(Some(c), Some(m)) => crate::curves::ellipse_polyline(c, m, *r),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
CurveKind::Spline { pts, control, degree } => {
|
||||
CurveKind::Spline { pts, control, degree, periodic } => {
|
||||
let p: Option<Vec<Vec2>> = pts.iter().map(pt).collect();
|
||||
p.map(|p| crate::curves::spline_polyline(&p, *control, *degree)).unwrap_or_default()
|
||||
p.map(|p| crate::curves::spline_polyline_mode(&p, *control, *degree, *periodic)).unwrap_or_default()
|
||||
}
|
||||
CurveKind::Conic { a, b, apex, rho } => match (pt(a), pt(apex), pt(b)) {
|
||||
(Some(a), Some(x), Some(b)) => crate::curves::conic_polyline(a, x, b, *rho),
|
||||
@@ -1023,10 +1029,20 @@ impl Sketch {
|
||||
}
|
||||
"e"
|
||||
}
|
||||
CurveKind::Spline { pts, degree, .. } => {
|
||||
CurveKind::Spline { pts, control, degree, periodic } => {
|
||||
if pts.len() < 2 || pts.len() > crate::curves::MAX_SPLINE_POINTS || !(1..=7).contains(degree) {
|
||||
return Err(SketchError::Invalid("a spline needs 2 to 500 points and degree 1 to 7".into()));
|
||||
}
|
||||
if *periodic {
|
||||
if *control || *degree != 3 {
|
||||
return Err(SketchError::Invalid("periodic mode requires a cubic fit spline".into()));
|
||||
}
|
||||
let p = pts
|
||||
.iter()
|
||||
.map(|i| self.point(*i).ok_or_else(|| SketchError::Invalid("missing periodic point".into())))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
crate::periodic_beziers(&p)?;
|
||||
}
|
||||
"s"
|
||||
}
|
||||
CurveKind::Conic { rho, .. } => {
|
||||
@@ -1092,7 +1108,34 @@ impl Sketch {
|
||||
let gone = |p: usize| p != 0 && which.contains(&p);
|
||||
let dead_curves: Vec<usize> =
|
||||
self.curves.iter().enumerate().filter(|(_, c)| c.kind.point_ids().into_iter().any(gone)).map(|(i, _)| i).collect();
|
||||
let private = dead_curves
|
||||
.iter()
|
||||
.filter_map(|i| self.curves.get(*i))
|
||||
.filter(|c| self.freeform_offsets.iter().any(|r| r.output == c.id))
|
||||
.flat_map(|c| c.kind.point_ids())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
self.drop_curves(&dead_curves);
|
||||
let mut removed = which.iter().copied().filter(|i| *i != 0).collect::<std::collections::BTreeSet<_>>();
|
||||
// Keep any node still named by a surviving curve or constraint.
|
||||
let referenced = self.curves.iter().flat_map(|c| c.kind.point_ids()).collect::<std::collections::BTreeSet<_>>();
|
||||
let constraint_points = std::cell::RefCell::new(std::collections::BTreeSet::new());
|
||||
for constraint in &self.constraints {
|
||||
let mut kind = constraint.kind.clone();
|
||||
kind.remap(
|
||||
&|p| {
|
||||
constraint_points.borrow_mut().insert(p);
|
||||
Some(p)
|
||||
},
|
||||
&Some,
|
||||
);
|
||||
}
|
||||
let constraint_points = constraint_points.into_inner();
|
||||
for index in private {
|
||||
if index != 0 && !referenced.contains(&index) && !constraint_points.contains(&index) {
|
||||
removed.insert(index);
|
||||
}
|
||||
}
|
||||
let gone = |p: usize| removed.contains(&p);
|
||||
let mut pmap = vec![None; self.points.len()];
|
||||
let mut n = 0;
|
||||
for (i, slot) in pmap.iter_mut().enumerate() {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Explicit cyclic C2 fit splines. Missing periodic markers retain natural interpolation.
|
||||
use crate::{MAX_SPLINE_POINTS, Sketch, SketchError};
|
||||
use solvecraft_geom::Vec2;
|
||||
|
||||
type Result<T> = std::result::Result<T, SketchError>;
|
||||
fn bad() -> SketchError {
|
||||
SketchError::Invalid("periodic fit spline needs 3 distinct cyclic samples, an exact closing point, and regular bounded chords".into())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_periodic_points(points: &[Vec2]) -> Result<()> {
|
||||
if points.len() < 4
|
||||
|| points.len() > MAX_SPLINE_POINTS
|
||||
|| points.first() != points.last()
|
||||
|| points.iter().any(|p| !p.is_finite() || p.x.abs() >= 1e9 || p.y.abs() >= 1e9)
|
||||
|| points.windows(2).any(|p| {
|
||||
let h = p[0].dist(p[1]);
|
||||
!h.is_finite() || h <= 1e-9
|
||||
})
|
||||
{
|
||||
return Err(bad());
|
||||
}
|
||||
let mut distinct = Vec::new();
|
||||
for p in points {
|
||||
if !distinct.contains(p) {
|
||||
distinct.push(*p);
|
||||
}
|
||||
if distinct.len() == 3 {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(bad())
|
||||
}
|
||||
pub fn periodic_beziers(points: &[Vec2]) -> Result<Vec<[Vec2; 4]>> {
|
||||
validate_periodic_points(points)?;
|
||||
let n = points.len() - 1;
|
||||
let h: Vec<f64> = points.windows(2).map(|p| p[0].dist(p[1])).collect();
|
||||
let mut diagonal = Vec::with_capacity(n);
|
||||
let mut rhs = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let previous = (i + n - 1) % n;
|
||||
diagonal.push(2.0 * (h[previous] + h[i]));
|
||||
rhs.push(((points[i + 1] - points[i]) / h[i] - (points[i] - points[previous]) / h[previous]) * 6.0);
|
||||
}
|
||||
// Remove the two corner entries with a rank-one correction. The underlying
|
||||
// cyclic matrix is strictly diagonally dominant; all pivots are still checked.
|
||||
let corner = h[n - 1];
|
||||
let gamma = -diagonal[0];
|
||||
diagonal[0] -= gamma;
|
||||
diagonal[n - 1] -= corner * corner / gamma;
|
||||
let solve = |mut rhs: Vec<Vec2>| -> Result<Vec<Vec2>> {
|
||||
let mut diagonal = diagonal.clone();
|
||||
for i in 1..n {
|
||||
if !diagonal[i - 1].is_finite() || diagonal[i - 1].abs() < 1e-12 * (h[i - 1] + h[(i + n - 2) % n]) {
|
||||
return Err(bad());
|
||||
}
|
||||
let factor = h[i - 1] / diagonal[i - 1];
|
||||
diagonal[i] -= factor * h[i - 1];
|
||||
rhs[i] = rhs[i] - rhs[i - 1] * factor;
|
||||
}
|
||||
for i in (0..n).rev() {
|
||||
if !diagonal[i].is_finite() || diagonal[i].abs() < 1e-12 * (h[i] + h[(i + n - 1) % n]) {
|
||||
return Err(bad());
|
||||
}
|
||||
let next = rhs.get(i + 1).copied().unwrap_or_default() * h[i];
|
||||
rhs[i] = (rhs[i] - next) / diagonal[i];
|
||||
if !rhs[i].is_finite() {
|
||||
return Err(bad());
|
||||
}
|
||||
}
|
||||
Ok(rhs)
|
||||
};
|
||||
let mut second = solve(rhs)?;
|
||||
let mut correction_rhs = vec![Vec2::ZERO; n];
|
||||
correction_rhs[0].x = gamma;
|
||||
correction_rhs[n - 1].x = corner;
|
||||
let correction = solve(correction_rhs)?;
|
||||
let denominator = 1.0 + correction[0].x + corner * correction[n - 1].x / gamma;
|
||||
if !denominator.is_finite() || denominator.abs() <= 1e-12 {
|
||||
return Err(bad());
|
||||
}
|
||||
let factor = (second[0] + second[n - 1] * (corner / gamma)) / denominator;
|
||||
for (value, correction) in second.iter_mut().zip(&correction) {
|
||||
*value -= factor * correction.x;
|
||||
}
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let next = (i + 1) % n;
|
||||
let chord = points[i + 1] - points[i];
|
||||
let t0 = chord - (second[i] * 2.0 + second[next]) * (h[i] * h[i] / 6.0);
|
||||
let t1 = chord + (second[i] + second[next] * 2.0) * (h[i] * h[i] / 6.0);
|
||||
let cubic = [points[i], points[i] + t0 / 3.0, points[i + 1] - t1 / 3.0, points[i + 1]];
|
||||
if cubic.iter().any(|p| !p.is_finite() || p.x.abs() >= 1e9 || p.y.abs() >= 1e9) {
|
||||
return Err(bad());
|
||||
}
|
||||
out.push(cubic);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl Sketch {
|
||||
pub fn validate_periodic_splines(&self) -> Result<()> {
|
||||
let mut work = 0usize;
|
||||
for curve in &self.curves {
|
||||
if let crate::CurveKind::Spline { pts, control, degree, periodic: true } = &curve.kind {
|
||||
work = work.saturating_add(pts.len());
|
||||
if *control || *degree != 3 || work > 1_000_000 || pts.len() > MAX_SPLINE_POINTS {
|
||||
return Err(bad());
|
||||
}
|
||||
let points = pts.iter().map(|i| self.point(*i).ok_or_else(bad)).collect::<Result<Vec<_>>>()?;
|
||||
validate_periodic_points(&points)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn periodic_c2_unequal_chords_and_legacy_natural_are_distinct() {
|
||||
let p = [Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(3.0, 2.0), Vec2::new(-1.0, 1.0), Vec2::new(0.0, 0.0)];
|
||||
let curves = periodic_beziers(&p).unwrap();
|
||||
for i in 0..curves.len() {
|
||||
let j = (i + 1) % curves.len();
|
||||
let (a, b) = (curves[i], curves[j]);
|
||||
let (ha, hb) = (p[i].dist(p[i + 1]), p[j].dist(p[j + 1]));
|
||||
assert_eq!(a[3], b[0]);
|
||||
assert!(((a[3] - a[2]) * (3.0 / ha) - (b[1] - b[0]) * (3.0 / hb)).len() < 1e-12);
|
||||
assert!(((a[3] - a[2] * 2.0 + a[1]) * (6.0 / (ha * ha)) - (b[2] - b[1] * 2.0 + b[0]) * (6.0 / (hb * hb))).len() < 1e-12);
|
||||
}
|
||||
let legacy = crate::fit_beziers(&p);
|
||||
assert!(
|
||||
(legacy[0][1] - legacy[0][0]).normalized().unwrap().dist((legacy.last().unwrap()[3] - legacy.last().unwrap()[2]).normalized().unwrap())
|
||||
> 0.01
|
||||
);
|
||||
let global = |t| crate::spline_point_mode(&p, false, 3, true, t);
|
||||
let h = 1e-5;
|
||||
assert!(((global(h) - global(0.0)) / h - (global(1.0) - global(1.0 - h)) / h).len() < 0.002);
|
||||
}
|
||||
#[test]
|
||||
fn periodic_rejects_two_samples_and_hostile_geometry() {
|
||||
let a = Vec2::ZERO;
|
||||
let b = Vec2::X;
|
||||
assert!(periodic_beziers(&[a, b, a, b, a]).is_err());
|
||||
assert!(periodic_beziers(&[a, b, Vec2::new(0.0, 1.0), b]).is_err());
|
||||
assert!(periodic_beziers(&[a, b, Vec2::new(f64::INFINITY, 1.0), a]).is_err());
|
||||
assert!(periodic_beziers(&vec![a; MAX_SPLINE_POINTS + 1]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periodic_solver_closes_shared_roles_and_refuses_immutable_or_collapsed_nodes() {
|
||||
let mut sk = Sketch::new();
|
||||
let first = sk.add_point(Vec2::ZERO, None).unwrap();
|
||||
let a = sk.add_point(Vec2::new(4.0, 0.0), None).unwrap();
|
||||
let b = sk.add_point(Vec2::new(3.0, 2.0), None).unwrap();
|
||||
let seam = sk.add_point(Vec2::ZERO, None).unwrap();
|
||||
let c = sk.add_point(Vec2::new(-4.0, 0.0), None).unwrap();
|
||||
let d = sk.add_point(Vec2::new(-3.0, -2.0), None).unwrap();
|
||||
let last = sk.add_point(Vec2::ZERO, None).unwrap();
|
||||
sk.add_curve(crate::CurveKind::Spline { pts: vec![first, a, b, seam], control: false, degree: 3, periodic: true }, None).unwrap();
|
||||
sk.add_curve(crate::CurveKind::Spline { pts: vec![seam, c, d, last], control: false, degree: 3, periodic: true }, None).unwrap();
|
||||
sk.points[first].pos = Vec2::new(0.2, 0.1);
|
||||
assert!(crate::solve_holding(&mut sk, &crate::Hold { points: vec![first], radii: vec![] }).ok());
|
||||
assert_eq!(sk.point(first), sk.point(seam));
|
||||
assert_eq!(sk.point(first), sk.point(last));
|
||||
sk.validate_periodic_splines().unwrap();
|
||||
let mut fixed = sk.clone();
|
||||
fixed.points[first].fixed = true;
|
||||
fixed.points[seam].fixed = true;
|
||||
fixed.points[seam].pos.x += 1e-8;
|
||||
let before = fixed.clone();
|
||||
assert!(!crate::solve(&mut fixed).ok());
|
||||
assert_eq!(fixed, before);
|
||||
let mut collapsed = sk;
|
||||
collapsed.points[a].pos = collapsed.points[first].pos;
|
||||
let before = collapsed.clone();
|
||||
assert!(!crate::solve_holding(&mut collapsed, &crate::Hold { points: vec![first, a], radii: vec![] }).ok());
|
||||
assert_eq!(collapsed, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periodic_link_projection_keeps_mode_and_break_geometry() {
|
||||
let p = vec![Vec2::ZERO, Vec2::new(4.0, 0.0), Vec2::new(3.0, 2.0), Vec2::new(-1.0, 1.0), Vec2::ZERO];
|
||||
let mut sk = Sketch::new();
|
||||
let source = crate::LinkSource::SketchCurve { sketch: 99, curve: "source".into() };
|
||||
let geometry = crate::LinkGeom::Spline { pts: p.clone(), control: false, degree: 3, periodic: true };
|
||||
let id = sk.add_link(crate::LinkKind::Project, source, &[geometry]).unwrap();
|
||||
let curve = sk.link_curves(&id)[0];
|
||||
assert!(matches!(sk.curves[curve].kind, crate::CurveKind::Spline { periodic: true, .. }));
|
||||
let before = sk.exact_segs(curve).unwrap();
|
||||
sk.break_link(&id).unwrap();
|
||||
assert_eq!(sk.exact_segs(curve).unwrap(), before);
|
||||
sk.validate_periodic_splines().unwrap();
|
||||
let mut singular = Sketch::new();
|
||||
let points =
|
||||
[Vec2::ZERO, Vec2::X, Vec2::ZERO, Vec2::new(0.0, 1.0), Vec2::ZERO].into_iter().map(|p| singular.add_point(p, None).unwrap()).collect();
|
||||
let curve = singular.add_curve(crate::CurveKind::Spline { pts: points, control: false, degree: 3, periodic: true }, None).unwrap();
|
||||
let before = singular.clone();
|
||||
assert!(singular.add_freeform_offset(curve, true, 0.1).is_err());
|
||||
assert_eq!(singular, before);
|
||||
}
|
||||
}
|
||||
+108
-12
@@ -169,9 +169,9 @@ impl State<'_> {
|
||||
};
|
||||
crate::curves::ellipse_polyline(self.p(*ci), self.p(*m), r)
|
||||
}
|
||||
Some(CurveKind::Spline { pts, control, degree }) => {
|
||||
let p: Vec<Vec2> = pts.iter().map(|q| self.p(*q)).collect();
|
||||
crate::curves::spline_polyline(&p, *control, *degree)
|
||||
Some(CurveKind::Spline { pts, control, degree, periodic }) => {
|
||||
let p = self.spline_points(pts, *periodic);
|
||||
crate::curves::spline_polyline_mode(&p, *control, *degree, *periodic)
|
||||
}
|
||||
Some(CurveKind::Conic { a, b, apex, rho }) => crate::curves::conic_polyline(self.p(*a), self.p(*apex), self.p(*b), *rho),
|
||||
_ => Vec::new(),
|
||||
@@ -179,6 +179,16 @@ impl State<'_> {
|
||||
}
|
||||
/// Point-on-curve constraints use the exact shape. A polyline residual would move an
|
||||
/// exact crossing onto its chord, changing geometry merely by splitting it.
|
||||
fn spline_points(&self, pts: &[usize], periodic: bool) -> Vec<Vec2> {
|
||||
let mut points: Vec<Vec2> = pts.iter().map(|i| self.p(*i)).collect();
|
||||
if periodic
|
||||
&& let Some(first) = points.first().copied()
|
||||
&& let Some(last) = points.last_mut()
|
||||
{
|
||||
*last = first;
|
||||
}
|
||||
points
|
||||
}
|
||||
fn freeform_dist(&self, c: usize, q: Vec2) -> f64 {
|
||||
let segs: Vec<Seg2> = match self.sk.curves.get(c).map(|c| &c.kind) {
|
||||
Some(CurveKind::Ellipse { c: ci, m, .. }) => {
|
||||
@@ -194,8 +204,8 @@ impl State<'_> {
|
||||
let y = delta.cross(u) / (a * r);
|
||||
return (x.hypot(y) - 1.0) * a.min(r);
|
||||
}
|
||||
Some(CurveKind::Spline { pts, control, degree }) => {
|
||||
let p: Vec<Vec2> = pts.iter().map(|i| self.p(*i)).collect();
|
||||
Some(CurveKind::Spline { pts, control, degree, periodic }) => {
|
||||
let p = self.spline_points(pts, *periodic);
|
||||
if p.len() < 2 {
|
||||
return q.dist(p.first().copied().unwrap_or_default());
|
||||
}
|
||||
@@ -203,7 +213,11 @@ impl State<'_> {
|
||||
let k = (*degree as usize).min(solvecraft_geom::MAX_BEZIER_DEGREE).min(p.len().saturating_sub(1)).max(1);
|
||||
crate::curves::bspline_beziers(&p, k).iter().filter_map(|span| solvecraft_geom::bezier(span)).collect()
|
||||
} else {
|
||||
crate::curves::fit_beziers(&p).into_iter().map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 }).collect()
|
||||
crate::curves::fit_beziers_mode(&p, *periodic)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 })
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
Some(CurveKind::Conic { a, b, apex, rho }) => {
|
||||
@@ -269,10 +283,10 @@ impl State<'_> {
|
||||
let (cc, r) = self.round(c);
|
||||
(cc - self.p(p)) / (r * r).max(1e-24)
|
||||
}
|
||||
CurveKind::Spline { pts, control, degree } => {
|
||||
let q: Vec<Vec2> = pts.iter().map(|i| self.p(*i)).collect();
|
||||
CurveKind::Spline { pts, control, degree, periodic } => {
|
||||
let q = self.spline_points(pts, *periodic);
|
||||
let at_end = pts.last() == Some(&p) && pts.first() != Some(&p);
|
||||
crate::curves::end_curvature(&|t| crate::curves::spline_point(&q, *control, *degree, t), at_end)
|
||||
crate::curves::end_curvature(&|t| crate::curves::spline_point_mode(&q, *control, *degree, *periodic, t), at_end)
|
||||
}
|
||||
CurveKind::Conic { a, b, apex, rho } => {
|
||||
let (pa, pb, px) = (self.p(*a), self.p(*b), self.p(*apex));
|
||||
@@ -285,9 +299,9 @@ impl State<'_> {
|
||||
match self.sk.curves.get(c).map(|c| &c.kind)? {
|
||||
CurveKind::Line { a, b } => (self.p(*b) - self.p(*a)).normalized(),
|
||||
CurveKind::Circle { .. } | CurveKind::Arc { .. } => (self.p(p) - self.round(c).0).perp().normalized(),
|
||||
CurveKind::Spline { pts, control, .. } => {
|
||||
let q: Vec<Vec2> = pts.iter().map(|i| self.p(*i)).collect();
|
||||
crate::curves::spline_end_tangent(&q, *control, pts.last() == Some(&p) && pts.first() != Some(&p))
|
||||
CurveKind::Spline { pts, control, periodic, .. } => {
|
||||
let q = self.spline_points(pts, *periodic);
|
||||
crate::curves::spline_end_tangent_mode(&q, *control, *periodic, pts.last() == Some(&p) && pts.first() != Some(&p))
|
||||
}
|
||||
CurveKind::Conic { a, b, apex, .. } => {
|
||||
if p == *a {
|
||||
@@ -540,6 +554,7 @@ pub fn measure_dimension(sk: &Sketch, k: &ConstraintKind) -> Option<f64> {
|
||||
/// One residual block: implicit arc radius equality, a user constraint, or a tangency at a known
|
||||
/// touch point (better conditioned than the distance form when an end point is on the curve).
|
||||
enum Block<'a> {
|
||||
Closure { a: usize, b: usize },
|
||||
Arc { c: usize, a: usize, b: usize },
|
||||
User(usize, &'a ConstraintKind),
|
||||
TangentAt(usize, &'a ConstraintKind, usize),
|
||||
@@ -547,6 +562,7 @@ enum Block<'a> {
|
||||
|
||||
fn copy_block<'a>(b: &Block<'a>) -> Block<'a> {
|
||||
match b {
|
||||
Block::Closure { a, b } => Block::Closure { a: *a, b: *b },
|
||||
Block::Arc { c, a, b } => Block::Arc { c: *c, a: *a, b: *b },
|
||||
Block::User(i, k) => Block::User(*i, k),
|
||||
Block::TangentAt(i, k, p) => Block::TangentAt(*i, k, *p),
|
||||
@@ -555,6 +571,10 @@ fn copy_block<'a>(b: &Block<'a>) -> Block<'a> {
|
||||
|
||||
fn block_residuals(s: &State, b: &Block, out: &mut Vec<f64>) {
|
||||
match b {
|
||||
Block::Closure { a, b } => {
|
||||
let d = s.p(*a) - s.p(*b);
|
||||
out.extend([d.x, d.y]);
|
||||
}
|
||||
Block::Arc { c, a, b } => {
|
||||
let cc = s.p(*c);
|
||||
out.push(cc.dist(s.p(*b)) - cc.dist(s.p(*a)));
|
||||
@@ -625,6 +645,10 @@ fn block_vars(sk: &Sketch, lay: &Layout, b: &Block) -> Vec<usize> {
|
||||
};
|
||||
use ConstraintKind::*;
|
||||
match b {
|
||||
Block::Closure { a, b } => {
|
||||
pt(&mut v, *a);
|
||||
pt(&mut v, *b);
|
||||
}
|
||||
Block::Arc { c, a, b } => {
|
||||
pt(&mut v, *c);
|
||||
pt(&mut v, *a);
|
||||
@@ -947,6 +971,14 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport {
|
||||
|
||||
let mut blocks: Vec<(Block, Vec<usize>)> = Vec::new();
|
||||
for c in &sk.curves {
|
||||
if let CurveKind::Spline { pts, periodic: true, .. } = &c.kind
|
||||
&& let (Some(a), Some(b)) = (pts.first(), pts.last())
|
||||
&& a != b
|
||||
{
|
||||
let bl = Block::Closure { a: *a, b: *b };
|
||||
let vars = block_vars(sk, &lay, &bl);
|
||||
blocks.push((bl, vars));
|
||||
}
|
||||
if let CurveKind::Arc { c, a, b } = c.kind {
|
||||
let bl = Block::Arc { c, a, b };
|
||||
let vars = block_vars(sk, &lay, &bl);
|
||||
@@ -1067,6 +1099,63 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport {
|
||||
let (r, _, owner) = system(sk, &lay, &consts, &mut x, false);
|
||||
check(&r, &owner, &consts, &mut failing);
|
||||
}
|
||||
// Weld the whole connected closure graph, preserving every immutable node.
|
||||
// Validate the actual prospective geometry before accepting a numerical solve.
|
||||
let has_periodic = sk.curves.iter().any(|c| matches!(c.kind, CurveKind::Spline { periodic: true, .. }));
|
||||
let periodic_positions = if has_periodic {
|
||||
let state = State { sk, lay: &lay, x: &x };
|
||||
let mut positions = (0..sk.points.len()).map(|i| state.p(i)).collect::<Vec<_>>();
|
||||
let mut parent = (0..positions.len()).collect::<Vec<_>>();
|
||||
for curve in &sk.curves {
|
||||
if let CurveKind::Spline { pts, periodic: true, .. } = &curve.kind
|
||||
&& let (Some(a), Some(b)) = (pts.first(), pts.last())
|
||||
&& *a < parent.len()
|
||||
&& *b < parent.len()
|
||||
{
|
||||
let (a, b) = (find(&mut parent, *a), find(&mut parent, *b));
|
||||
if let Some(slot) = parent.get_mut(b) {
|
||||
*slot = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut representatives = std::collections::BTreeMap::new();
|
||||
let mut valid = true;
|
||||
for i in 0..positions.len() {
|
||||
let root = find(&mut parent, i);
|
||||
if let Some(previous) = representatives.get(&root).copied() {
|
||||
let fixed = |p| lay.pvar.get(p).is_some_and(Option::is_none);
|
||||
if fixed(i) && fixed(previous) && positions[i] != positions[previous] {
|
||||
valid = false;
|
||||
}
|
||||
if fixed(i) && !fixed(previous) {
|
||||
representatives.insert(root, i);
|
||||
}
|
||||
} else {
|
||||
representatives.insert(root, i);
|
||||
}
|
||||
}
|
||||
for (i, position) in positions.iter_mut().enumerate() {
|
||||
if let Some(index) = representatives.get(&find(&mut parent, i)) {
|
||||
*position = state.p(*index);
|
||||
}
|
||||
}
|
||||
for curve in &sk.curves {
|
||||
if let CurveKind::Spline { pts, control, degree, periodic: true } = &curve.kind {
|
||||
let points = pts.iter().map(|i| positions.get(*i).copied()).collect::<Option<Vec<_>>>();
|
||||
if *control || *degree != 3 || points.as_ref().is_none_or(|p| crate::periodic::validate_periodic_points(p).is_err()) {
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
max_residual = max_residual.max(1.0);
|
||||
None
|
||||
} else {
|
||||
Some(positions)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let status = if max_residual < ACCEPT { SolveStatus::Solved } else { SolveStatus::Failed };
|
||||
let use_x = if status == SolveStatus::Solved { &x } else { &x0 };
|
||||
// Write back.
|
||||
@@ -1080,6 +1169,13 @@ pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport {
|
||||
*r = use_x.get(v).copied().unwrap_or(*r).abs().max(1e-9);
|
||||
}
|
||||
}
|
||||
if status == SolveStatus::Solved
|
||||
&& let Some(positions) = periodic_positions
|
||||
{
|
||||
for (point, position) in sk.points.iter_mut().zip(positions) {
|
||||
point.pos = position;
|
||||
}
|
||||
}
|
||||
if status == SolveStatus::Failed {
|
||||
for (ci, a_before, b_before, reversed) in orientation_before.into_iter().rev() {
|
||||
if let Some(curve) = sk.curves.get_mut(ci)
|
||||
|
||||
@@ -424,7 +424,7 @@ fn exact_profiles_for_high_degree_and_cut_free_form_curves() {
|
||||
.iter()
|
||||
.map(|p| sk.add_point(*p, None).unwrap())
|
||||
.collect();
|
||||
sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: true, degree: 5 }, None).unwrap();
|
||||
sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: true, degree: 5, periodic: false }, None).unwrap();
|
||||
sk.add_line_pts(ids[5], ids[0], None).unwrap();
|
||||
let pr = find_profiles(&sk);
|
||||
assert_eq!(pr.len(), 1);
|
||||
@@ -433,7 +433,7 @@ fn exact_profiles_for_high_degree_and_cut_free_form_curves() {
|
||||
// A fit spline arch over a base line, cut by a vertical line at x = 10.
|
||||
let mut sk = Sketch::default();
|
||||
let ids: Vec<usize> = [v(0.0, 0.0), v(6.0, 9.0), v(14.0, 9.0), v(20.0, 0.0)].iter().map(|p| sk.add_point(*p, None).unwrap()).collect();
|
||||
sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: false, degree: 3 }, None).unwrap();
|
||||
sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: false, degree: 3, periodic: false }, None).unwrap();
|
||||
sk.add_line_pts(ids[3], ids[0], None).unwrap();
|
||||
let (a, b) = (sk.add_point(v(10.0, -5.0), None).unwrap(), sk.add_point(v(10.0, 20.0), None).unwrap());
|
||||
sk.add_line_pts(a, b, None).unwrap();
|
||||
|
||||
@@ -196,7 +196,7 @@ impl Sketch {
|
||||
}
|
||||
let id = if geom.is_empty() {
|
||||
let id = self.fresh("j");
|
||||
self.links.push(Link { id: id.clone(), kind, source, face_name: None, lost: false });
|
||||
self.links.push(Link { id: id.clone(), kind, source, face_name: None, auto_face: false, lost: false });
|
||||
id
|
||||
} else {
|
||||
self.add_link(kind, source, geom)?
|
||||
|
||||
@@ -50,6 +50,7 @@ pub struct TreeState {
|
||||
pub occurrence_move: Option<OccMove>,
|
||||
/// The Redefine Sketch Plane panel (sketch id).
|
||||
pub redefine: Option<u64>,
|
||||
pub redefine_draft: Option<crate::sketch_attachment_ui::Draft>,
|
||||
/// Components picked in the tree (they have no viewport selection).
|
||||
pub picked_components: Vec<u64>,
|
||||
/// Canvases picked in the tree.
|
||||
@@ -112,6 +113,7 @@ pub fn set_sketch_visible(app: &mut SolveApp, id: u64, on: bool) {
|
||||
|
||||
pub fn redefine_sketch(app: &mut SolveApp, id: u64) {
|
||||
app.tree.redefine = Some(id);
|
||||
app.tree.redefine_draft = Some(crate::sketch_attachment_ui::Draft::new(app, id));
|
||||
}
|
||||
|
||||
fn find_group(app: &SolveApp, id: u64) -> Option<&solvecraft_engine::doc::BrowserGroup> {
|
||||
@@ -523,7 +525,8 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
occurrence_panel(app, ui);
|
||||
capture_prompt(app, ui.ctx());
|
||||
canvas_panel(app, ui.ctx());
|
||||
redefine_panel(app, ui.ctx());
|
||||
let repair_anchor = app.viewport.rect.map(|rect| rect.right_top() + vec2(-16.0, 16.0)).unwrap_or_else(|| ui.ctx().content_rect().right_top());
|
||||
crate::sketch_attachment_ui::panel(app, ui.ctx(), repair_anchor);
|
||||
}
|
||||
|
||||
fn component_name(app: &SolveApp, id: u64) -> String {
|
||||
@@ -1141,14 +1144,22 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
|
||||
.filter(|f| f.component == comp && matches!(f.kind, FeatureKind::Sketch { .. }))
|
||||
.map(|f| {
|
||||
let active = app.session.active_sketch == Some(f.id);
|
||||
let lost = app.session.model.result(f.id).is_some_and(|result| result.error.is_some())
|
||||
&& matches!(doc.sketch_face_attachment(f.id, &app.session.model), solvecraft_engine::doc::FaceAttachmentStatus::Lost { .. });
|
||||
Entry {
|
||||
key: f.id.to_string(),
|
||||
row_key: format!("s:{}", f.id),
|
||||
label: if active { format!("{} (editing)", f.name) } else { f.name.clone() },
|
||||
label: if lost {
|
||||
format!("{} (attachment lost)", f.name)
|
||||
} else if active {
|
||||
format!("{} (editing)", f.name)
|
||||
} else {
|
||||
f.name.clone()
|
||||
},
|
||||
icon: "sketch",
|
||||
visible: sketch_visible(app, f.id),
|
||||
selected: active || sel.iter().any(|x| matches!(x, Sel::Feature { id } if *id == f.id)),
|
||||
color: active.then_some(t.sketch_accent),
|
||||
color: if lost { Some(t.error) } else { active.then_some(t.sketch_accent) },
|
||||
locked: false,
|
||||
sel: Some(Sel::Feature { id: f.id }),
|
||||
target: Some(Target::Sketch { id: f.id }),
|
||||
@@ -1805,46 +1816,3 @@ fn occurrence_origin(app: &SolveApp, occurrence: u64) -> Option<solvecraft_engin
|
||||
let own = app.session.pending_moves.get(&occurrence).copied().unwrap_or(o.transform);
|
||||
Some(solvecraft_engine::doc::apply_point(&solvecraft_engine::doc::mat_mul(&parent, &own), solvecraft_engine::geom::Vec3::ZERO))
|
||||
}
|
||||
|
||||
/// Redefine Sketch Plane: pick an origin or construction plane, or the selected planar face.
|
||||
fn redefine_panel(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(id) = app.tree.redefine else { return };
|
||||
let name = app.session.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
let mut planes: Vec<String> = vec!["XY".into(), "XZ".into(), "YZ".into()];
|
||||
planes.extend(app.session.doc.features.iter().filter(|f| matches!(f.kind, FeatureKind::ConstructionPlane { .. })).map(|f| f.name.clone()));
|
||||
let face = app.session.selection.iter().find_map(|s| if let Sel::Face { point, .. } = s { Some(*point) } else { None });
|
||||
let mut open = true;
|
||||
let mut pick: Option<Value> = None;
|
||||
crate::frame::window(ctx, format!("Redefine Sketch Plane: {name}"), crate::frame::Width::Normal)
|
||||
.id(egui::Id::new("sc_redefine"))
|
||||
.pivot(egui::Align2::LEFT_TOP)
|
||||
.default_pos(panel_pos(app))
|
||||
.open(&mut open)
|
||||
.resizable(false)
|
||||
.collapsible(false)
|
||||
.show(ctx, |ui| {
|
||||
ui.label(RichText::new("Move the sketch to").color(Tokens::get().text_dim));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for p in &planes {
|
||||
if ui.button(p).clicked() {
|
||||
pick = Some(json!(p));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let b = ui.add_enabled(face.is_some(), egui::Button::new("Selected face"));
|
||||
if b.clicked()
|
||||
&& let Some(f) = face
|
||||
{
|
||||
pick = Some(json!({ "face": [f.x, f.y, f.z] }));
|
||||
}
|
||||
b.on_disabled_hover_text("Select a planar face in the viewport first");
|
||||
});
|
||||
if let Some(plane) = pick {
|
||||
if app.run("sketch.redefine", json!({ "sketch": id, "plane": plane })).is_ok() {
|
||||
app.tree.redefine = None;
|
||||
}
|
||||
} else if !open {
|
||||
app.tree.redefine = None;
|
||||
}
|
||||
}
|
||||
@@ -494,7 +494,10 @@ fn draw_revolve(
|
||||
if radius > 1e-9 {
|
||||
let e1 = (base - centre) / radius;
|
||||
let e2 = extent.rotation_axis.cross(e1);
|
||||
let handed = -direction.dot(extent.rotation_axis);
|
||||
let handed = match revolve_convention(d) {
|
||||
solvecraft_engine::doc::RevolveConvention::LegacyAxisStart => -direction.dot(extent.rotation_axis),
|
||||
solvecraft_engine::doc::RevolveConvention::SketchNormal => 1.0,
|
||||
};
|
||||
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)));
|
||||
@@ -608,6 +611,13 @@ fn draw_revolve(
|
||||
});
|
||||
}
|
||||
|
||||
fn revolve_convention(d: &Dialog) -> solvecraft_engine::doc::RevolveConvention {
|
||||
d.extra
|
||||
.get("convention")
|
||||
.and_then(|value| serde_json::from_value(value.clone()).ok())
|
||||
.unwrap_or(solvecraft_engine::doc::RevolveConvention::SketchNormal)
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -639,7 +649,7 @@ fn revolve_display_extent(app: &SolveApp, d: &Dialog, axis: (Vec3, Vec3)) -> Opt
|
||||
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(
|
||||
solvecraft_engine::doc::revolve_extent_with_convention(
|
||||
&state,
|
||||
&plane,
|
||||
®ions,
|
||||
@@ -647,6 +657,7 @@ fn revolve_display_extent(app: &SolveApp, d: &Dialog, axis: (Vec3, Vec3)) -> Opt
|
||||
angles,
|
||||
d.extra.get("direction").and_then(serde_json::Value::as_str) == Some("symmetric"),
|
||||
to,
|
||||
revolve_convention(d),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
@@ -1330,7 +1330,30 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
placement_row(&mut d, ui);
|
||||
input_rows(&mut d, ui);
|
||||
match &mut d.kind {
|
||||
Kind::Sketch => {}
|
||||
Kind::Sketch => {
|
||||
if d.error.is_some()
|
||||
&& let Some(selection @ Sel::Face { .. }) = d.inputs.first().and_then(|i| i.items.first())
|
||||
{
|
||||
row_label(ui, "Attachment");
|
||||
ui.label("This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.");
|
||||
ui.end_row();
|
||||
row_label(ui, "");
|
||||
if ui.button("Use fixed plane").clicked()
|
||||
&& let Some(mut plane) = crate::sketch_attachment_ui::face_plane(app, selection)
|
||||
{
|
||||
plane["fixed"] = json!(true);
|
||||
match app.run("sketch.create", json!({"plane":plane})) {
|
||||
Ok(_) => {
|
||||
app.pre_sketch_cam = Some(app.cam);
|
||||
look_at_sketch(app);
|
||||
cancel = true;
|
||||
}
|
||||
Err(error) => d.error = Some(error),
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, taper_present, to_offset, start, all } => {
|
||||
row_label(ui, "Start");
|
||||
let old = if object_on(&d.inputs, FROM_OBJECT) { 2 } else { usize::from(!start.is_empty()) };
|
||||
@@ -1931,10 +1954,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
// A plane picked for a new sketch starts it right away.
|
||||
if matches!(d.kind, Kind::Sketch)
|
||||
&& d.error.is_none()
|
||||
&& let Some(sel) = d.inputs.first().and_then(|i| i.items.first()).cloned()
|
||||
{
|
||||
start_sketch(app, &sel);
|
||||
cancel = true;
|
||||
match start_sketch(app, &sel) {
|
||||
Ok(()) => cancel = true,
|
||||
Err(error) => d.error = Some(error),
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
match run_dialog(app, &d) {
|
||||
@@ -2154,25 +2180,25 @@ fn params_table(app: &mut SolveApp, ui: &mut egui::Ui, new_name: &mut String, ne
|
||||
}
|
||||
|
||||
/// Start a sketch on a picked plane or planar face and turn the view to face it.
|
||||
pub fn start_sketch(app: &mut SolveApp, sel: &Sel) {
|
||||
pub fn start_sketch(app: &mut SolveApp, sel: &Sel) -> Result<(), String> {
|
||||
let plane = match sel {
|
||||
Sel::Plane { name } => json!(name),
|
||||
Sel::Face { point, .. } => json!({"face": pt(*point)}),
|
||||
_ => return,
|
||||
Sel::Face { .. } => crate::sketch_attachment_ui::face_plane(app, sel).ok_or("Selected face placement no longer exists")?,
|
||||
_ => return Err("Select an origin plane, construction plane or planar body face".into()),
|
||||
};
|
||||
let before = app.cam;
|
||||
if app.run("sketch.create", json!({ "plane": plane })).is_ok() {
|
||||
app.pre_sketch_cam = Some(before);
|
||||
look_at_sketch(app);
|
||||
// On a face: look at the picked point, not the plane's origin (a corner of the face),
|
||||
// which left the face filling the view off-centre like a big square.
|
||||
if let Sel::Face { point, .. } = sel
|
||||
&& let Some(mut to) = app.cam_anim.as_ref().map(|a| a.to)
|
||||
{
|
||||
to.target = *point;
|
||||
app.animate_to(to);
|
||||
}
|
||||
app.run("sketch.create", json!({ "plane": plane }))?;
|
||||
app.pre_sketch_cam = Some(before);
|
||||
look_at_sketch(app);
|
||||
// On a face: look at the picked point, not the plane's origin (a corner of the face),
|
||||
// which left the face filling the view off-centre like a big square.
|
||||
if let Sel::Face { point, .. } = sel
|
||||
&& let Some(mut to) = app.cam_anim.as_ref().map(|a| a.to)
|
||||
{
|
||||
to.target = *point;
|
||||
app.animate_to(to);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Animate the camera to look straight at the active sketch plane.
|
||||
@@ -3060,7 +3086,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets, angle2, symmetric, to } => {
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets, angle2, symmetric, convention, to } => {
|
||||
let mut d = start("solid.revolve")?;
|
||||
let frame = feature_frame(s, f.component);
|
||||
let stored_axis = match axis {
|
||||
@@ -3071,6 +3097,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
_ => None,
|
||||
};
|
||||
d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation), axis: stored_axis };
|
||||
d.extra.insert("convention".into(), json!(convention));
|
||||
if let Some(a2) = angle2 {
|
||||
d.extra.insert("angle2".into(), json!(a2));
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
|
||||
app.preview = crate::preview::PreviewState::default();
|
||||
app.viewport.hover = None;
|
||||
app.menu.props = None;
|
||||
crate::sketch_attachment_ui::clear(app);
|
||||
let out_rev = app.session.revision;
|
||||
let out = DocSlot {
|
||||
session: std::mem::replace(&mut app.session, slot.session),
|
||||
@@ -142,6 +143,7 @@ pub fn adopt(app: &mut SolveApp, session: Session) {
|
||||
crate::dialogs::cancel(app);
|
||||
app.tool = None;
|
||||
app.menu.props = None;
|
||||
crate::sketch_attachment_ui::clear(app);
|
||||
app.session = session;
|
||||
app.session.invalidate_view(rev);
|
||||
app.cam = Camera::default();
|
||||
|
||||
@@ -75,6 +75,9 @@ pub mod shortcut_box;
|
||||
pub mod sketch3d;
|
||||
#[cfg(test)]
|
||||
mod sketch3d_frame_ui_tests;
|
||||
pub(crate) mod sketch_attachment_ui;
|
||||
#[cfg(test)]
|
||||
mod sketch_attachment_ui_tests;
|
||||
pub mod sketch_dims;
|
||||
#[cfg(test)]
|
||||
mod sketch_edit_tests;
|
||||
@@ -414,6 +417,7 @@ impl SolveApp {
|
||||
self.session.echo(format!("{} done", command_label(id)));
|
||||
if matches!(id, "file.new" | "doc.open" | "doc.recover") {
|
||||
self.menu.props = None;
|
||||
sketch_attachment_ui::clear(self);
|
||||
// The old document is already replaced: discard its editor without
|
||||
// applying cancellation hooks (such as restoring its timeline marker).
|
||||
self.dialog = None;
|
||||
@@ -574,6 +578,13 @@ impl SolveApp {
|
||||
/// puts them back. Editing the sketch that is already open does nothing; editing another one
|
||||
/// finishes the current sketch first, so edit sessions never nest.
|
||||
pub fn edit_sketch(&mut self, id: u64) {
|
||||
if let solvecraft_engine::doc::FaceAttachmentStatus::Lost { message, .. } =
|
||||
self.session.placed_document().sketch_face_attachment(id, &self.session.model)
|
||||
{
|
||||
browser::redefine_sketch(self, id);
|
||||
self.set_status(message, true);
|
||||
return;
|
||||
}
|
||||
if self.session.active_sketch == Some(id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ impl PreviewState {
|
||||
pub fn update(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let cmds = match app.dialog.as_ref().filter(|d| d.previews()).map(|d| crate::dialogs::preview_commands(app, d)) {
|
||||
Some(Ok(c)) if !c.is_empty() => c,
|
||||
_ if crate::sketch_attachment_ui::commands(app).is_some() => crate::sketch_attachment_ui::commands(app).unwrap_or_default(),
|
||||
// A parameter being edited in Change Parameters.
|
||||
_ if app.params.as_ref().and_then(|p| p.preview_commands()).is_some() => {
|
||||
app.params.as_ref().and_then(|p| p.preview_commands()).unwrap_or_default()
|
||||
@@ -185,6 +186,15 @@ fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, V
|
||||
/// show their result in place of the bodies they change. When the feature fails (a boolean the
|
||||
/// kernel can't do yet), the tool body is still shown, with the error.
|
||||
pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job {
|
||||
if cmds.iter().any(|(command, _)| command == "sketch.redefine") {
|
||||
let preview = s.preview_visible_world(cmds).map_err(|error| error.to_string())?;
|
||||
return std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let mut built = build(&preview.before, &preview.after, colors);
|
||||
built.bounds = state_bounds(&preview.after);
|
||||
built
|
||||
}))
|
||||
.map_err(|_| "the preview failed".to_string());
|
||||
}
|
||||
// Joints and drives move occurrences: shown on the placed model.
|
||||
if let Some(job) = crate::dialogs_assembly::world_preview(s, cmds, colors) {
|
||||
return job;
|
||||
|
||||
@@ -51,15 +51,28 @@ fn to_object_noop_preserves_cut_targets_and_untargeted_body() {
|
||||
}
|
||||
let mut h = make_params(h, json!("Y"), json!({"to":[0,2,12],"angle":"45 deg","operation":"cut","targets":["A"]}));
|
||||
let state = h.app.session.world_state();
|
||||
let before_a = solvecraft_engine::doc::kernel::measure(&state.body("A").unwrap().body).unwrap().volume;
|
||||
assert!((before_a - (2000.0 - 490.873852)).abs() < 0.2);
|
||||
// A quarter annulus separates the stock into inner and outer solids. Both are
|
||||
// results of cutting A; checking only the first named solid loses material.
|
||||
let cut_volume = |state: &solvecraft_engine::doc::ModelState| -> f64 {
|
||||
state.bodies.iter().filter(|b| b.name != "B").map(|b| solvecraft_engine::doc::kernel::measure(&b.body).unwrap().volume).sum()
|
||||
};
|
||||
let before_a = cut_volume(&state);
|
||||
let removed = 0.5 * (15.0f64.powi(2) - 10.0f64.powi(2)) * 5.0 * std::f64::consts::FRAC_PI_2;
|
||||
assert!((before_a - (2000.0 - removed)).abs() < 0.2, "cut results {:?}: {before_a}", state.bodies.iter().map(|b| &b.name).collect::<Vec<_>>());
|
||||
let in_cut_stock = |p| state.bodies.iter().filter(|b| b.name != "B").any(|b| b.body.contains(p));
|
||||
assert!(in_cut_stock(solvecraft_engine::geom::Vec3::new(2.0, 2.0, 2.0)));
|
||||
assert!(in_cut_stock(solvecraft_engine::geom::Vec3::new(18.0, 2.0, 18.0)));
|
||||
assert!(!in_cut_stock(solvecraft_engine::geom::Vec3::new(9.0, 2.0, 9.0)));
|
||||
assert!(state.body("B").unwrap().body.contains(solvecraft_engine::geom::Vec3::new(9.0, 2.0, 9.0)));
|
||||
let before_bounds: Vec<_> = state.bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect();
|
||||
let undo = h.app.session.undo.len();
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none());
|
||||
let state = h.app.session.world_state();
|
||||
assert!((solvecraft_engine::doc::kernel::measure(&state.body("B").unwrap().body).unwrap().volume - 2000.0).abs() < 1e-7);
|
||||
assert!((solvecraft_engine::doc::kernel::measure(&state.body("A").unwrap().body).unwrap().volume - before_a).abs() < 1e-7);
|
||||
assert!((cut_volume(&state) - before_a).abs() < 1e-7);
|
||||
assert_eq!(state.bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect::<Vec<_>>(), before_bounds);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let FeatureKind::Revolve { targets, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(targets, &["A"]);
|
||||
|
||||
@@ -251,7 +251,21 @@ fn edge_on_ring_drag_uses_nearest_projected_branch() {
|
||||
|
||||
#[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 mut h = fixture(
|
||||
Harness::new(),
|
||||
json!({"axis":{"origin":[0,0,0],"dir":[0,-2,0]},"direction":"symmetric","angle":"120 deg","convention":"legacy_axis_start"}),
|
||||
);
|
||||
// Exercise an actual older design: no convention marker was stored by version 10.
|
||||
let mut saved: Value = serde_json::from_str(&h.app.session.doc.to_json()).unwrap();
|
||||
saved["format"] = json!("solvecraft/10");
|
||||
for feature in saved["features"].as_array_mut().unwrap() {
|
||||
feature.as_object_mut().unwrap().remove("convention");
|
||||
}
|
||||
let path = h.dir.join("legacy-revolve.solvecraft");
|
||||
std::fs::write(&path, serde_json::to_vec(&saved).unwrap()).unwrap();
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
let FeatureKind::Revolve { convention, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(*convention, solvecraft_engine::doc::RevolveConvention::LegacyAxisStart);
|
||||
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);
|
||||
@@ -270,3 +284,83 @@ fn legacy_reversed_axis_symmetric_interval_is_shown_and_preserved_exactly() {
|
||||
assert!(after.min.z > 7.0 && after.max.z > 14.9);
|
||||
assert!((volume(&h) - expected_volume(90.0)).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_symmetric_ring_preview_drag_and_reopen_match_material() {
|
||||
for axis in [json!("Y"), json!({"origin":[0,0,0],"dir":[0,-2,0]})] {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Profile"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[2,0],"p1":[4,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app
|
||||
.run("solid.revolve", json!({"sketch":"Profile","axis":axis,"angle":"90 deg","direction":"symmetric","name":"Turn","body_name":"Main"}))
|
||||
.unwrap();
|
||||
let before = 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();
|
||||
open(&mut h);
|
||||
let at = |degrees: f64| {
|
||||
let a = degrees.to_radians();
|
||||
json!([3.0 * a.cos(), 5.0, 3.0 * a.sin()])
|
||||
};
|
||||
handle_at(&mut h, "revolve", at(45.0));
|
||||
handle_at(&mut h, "revolve2", at(-45.0));
|
||||
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 body = &preview.after.body("Main").unwrap().body;
|
||||
assert!(body.contains(solvecraft_engine::geom::Vec3::new(3.0, 5.0, 0.0)));
|
||||
assert!(!body.contains(solvecraft_engine::geom::Vec3::new(0.0, 5.0, 3.0)));
|
||||
let bounds = preview.after.body("Main").unwrap().mesh().bounds();
|
||||
assert!((bounds.min.x - 2.0f64.sqrt()).abs() < 0.02 && (bounds.max.x - 4.0).abs() < 0.02, "{bounds:?}");
|
||||
assert!((bounds.min.z + 2.0 * 2.0f64.sqrt()).abs() < 0.02 && (bounds.max.z - 2.0 * 2.0f64.sqrt()).abs() < 0.02);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), before);
|
||||
open(&mut h);
|
||||
h.step(&json!({"drag":[{"handle":"revolve2"},{"world":at(-30.0)}]})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(angle(&h), "60 deg");
|
||||
assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names);
|
||||
assert!((volume(&h) - 60.0 * std::f64::consts::FRAC_PI_3).abs() < 0.03);
|
||||
let path = h.dir.join("canonical-revolve.solvecraft");
|
||||
h.app.run("file.save", json!({"path":path})).unwrap();
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
open(&mut h);
|
||||
handle_at(&mut h, "revolve", at(30.0));
|
||||
handle_at(&mut h, "revolve2", at(-30.0));
|
||||
let FeatureKind::Revolve { convention, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(*convention, solvecraft_engine::doc::RevolveConvention::SketchNormal);
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversed_axis_to_object_canvas_sweep_and_preview_follow_the_quarter_target() {
|
||||
let mut h = fixture(Harness::new(), json!({"axis":{"origin":[0,0,0],"dir":[0,-2,0]},"to":[0,2,12],"angle":"45 deg"}));
|
||||
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 before = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
|
||||
let p = 12.5 / 2.0f64.sqrt();
|
||||
open(&mut h);
|
||||
assert!(h.widget("Sweep 90.0°").is_some());
|
||||
assert!(crate::scenario::handle_at("revolve").is_none());
|
||||
assert!(crate::scenario::handle_at("revolve2").is_none());
|
||||
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 result = preview.after.body("Main").unwrap();
|
||||
assert_eq!(result.mesh().bounds(), before);
|
||||
assert!(result.body.contains(solvecraft_engine::geom::Vec3::new(p, 2.5, p)));
|
||||
for (x, z) in [(-p, p), (-p, -p), (p, -p)] {
|
||||
assert!(!result.body.contains(solvecraft_engine::geom::Vec3::new(x, 2.5, z)));
|
||||
}
|
||||
assert!((kernel::measure(&result.body).unwrap().volume - expected_volume(90.0)).abs() < 0.03);
|
||||
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.doc.find_feature("Turn").unwrap().param_names, names);
|
||||
let path = h.dir.join("reversed-to-object.solvecraft");
|
||||
h.app.run("file.save", json!({"path":path})).unwrap();
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
open(&mut h);
|
||||
assert!(h.widget("Sweep 90.0°").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);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), before);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Reviewable sketch-plane rebinding. Saved lost planes are repair guides, never model geometry.
|
||||
|
||||
use egui::{Pos2, RichText};
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::Sel;
|
||||
use solvecraft_engine::doc::face_anchor::plane_face_anchor;
|
||||
use solvecraft_engine::doc::{EmbossFace, FaceAttachmentStatus, FeatureKind, mat_inverse};
|
||||
use solvecraft_engine::geom::{Plane, Vec3};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Draft {
|
||||
pub sketch: u64,
|
||||
pub candidate: Option<Value>,
|
||||
pub label: String,
|
||||
pub error: Option<String>,
|
||||
pub author_path: Option<Vec<u64>>,
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
pub fn new(app: &SolveApp, sketch: u64) -> Self {
|
||||
let author_path = app.session.doc.feature(sketch).and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } if plane_face_anchor(plane).is_some() => {
|
||||
plane_face_anchor(plane).map(|anchor| anchor.author_path.clone())
|
||||
}
|
||||
_ if feature.component == app.session.active_component => app.session.active_occurrence_path.clone(),
|
||||
_ => None,
|
||||
});
|
||||
Self { sketch, candidate: None, label: String::new(), error: None, author_path }
|
||||
}
|
||||
|
||||
fn choose(&mut self, plane: Value, label: String) {
|
||||
self.candidate = Some(plane);
|
||||
self.label = label;
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
pub fn commands(&self) -> Option<Vec<(String, Value)>> {
|
||||
let mut params = json!({"sketch":self.sketch,"plane":self.candidate.as_ref()?});
|
||||
if let Some(path) = &self.author_path {
|
||||
params["author_path"] = json!(path);
|
||||
}
|
||||
Some(vec![("sketch.redefine".into(), params)])
|
||||
}
|
||||
|
||||
pub fn choose_saved_fixed(&mut self, app: &SolveApp, world: Plane) -> Result<(), String> {
|
||||
let doc = app.session.placed_document();
|
||||
let owner = doc.feature(self.sketch).ok_or("Sketch no longer exists")?.component;
|
||||
let path = self.author_path.clone().unwrap_or_else(|| doc.first_path(owner));
|
||||
if self.author_path.is_none()
|
||||
&& path.iter().any(|id| {
|
||||
doc.occurrences
|
||||
.iter()
|
||||
.find(|o| o.id == *id)
|
||||
.is_none_or(|selected| doc.occurrences.iter().filter(|o| o.component == selected.component).count() != 1)
|
||||
})
|
||||
{
|
||||
return Err("Select an exact author placement before converting this plane".into());
|
||||
}
|
||||
let frame = EmbossFace::component_frame(&doc, owner, &path).map_err(|error| error.to_string())?;
|
||||
mat_inverse(&frame).ok_or("Sketch author frame is invalid")?;
|
||||
self.author_path = Some(path);
|
||||
// Public commands take world geometry. Session maps Custom planes into
|
||||
// this exact author placement once, including redefine's saved path.
|
||||
self.choose(fixed_plane(world), "Fixed plane: independent of the source body".into());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(app: &mut SolveApp) {
|
||||
app.tree.redefine = None;
|
||||
app.tree.redefine_draft = None;
|
||||
}
|
||||
|
||||
pub fn commands(app: &SolveApp) -> Option<Vec<(String, Value)>> {
|
||||
let draft = app.tree.redefine_draft.as_ref()?;
|
||||
(app.tree.redefine == Some(draft.sketch)).then(|| draft.commands()).flatten()
|
||||
}
|
||||
|
||||
fn attachment(app: &SolveApp, sketch: u64) -> FaceAttachmentStatus {
|
||||
app.session.placed_document().sketch_face_attachment(sketch, &app.session.model)
|
||||
}
|
||||
|
||||
fn fixed_plane(plane: Plane) -> Value {
|
||||
json!({"origin":plane.origin,"x_dir":plane.x,"y_dir":plane.y})
|
||||
}
|
||||
|
||||
pub fn face_plane(app: &SolveApp, selection: &Sel) -> Option<Value> {
|
||||
match selection {
|
||||
Sel::Face { body, index, point } => {
|
||||
let doc = app.session.placed_document();
|
||||
let (_, _, _, path, _) = doc.body_frames_with_source(&app.session.model.state()).into_iter().find(|(world, _, _, _, _)| world == body)?;
|
||||
Some(json!({"face":point,"body":body,"index":index,"path":path}))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn face_label(app: &SolveApp, selection: &Sel) -> String {
|
||||
let Sel::Face { body, .. } = selection else { return String::new() };
|
||||
let placed = app.session.placed_document();
|
||||
if let Some((_, source, _, path, _)) =
|
||||
placed.body_frames_with_source(&app.session.model.state()).into_iter().find(|(world, _, _, _, _)| world == body)
|
||||
{
|
||||
format!("Face on {source} / {}", path_label(app, &path))
|
||||
} else {
|
||||
format!("Face on {body}")
|
||||
}
|
||||
}
|
||||
|
||||
fn path_label(app: &SolveApp, path: &[u64]) -> String {
|
||||
if path.is_empty() {
|
||||
return "Root".into();
|
||||
}
|
||||
(1..=path.len())
|
||||
.map(|end| app.session.occurrence_placement(&path[..end]).map(|placement| placement.name).unwrap_or_else(|_| "Missing occurrence".into()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ")
|
||||
}
|
||||
|
||||
pub fn panel(app: &mut SolveApp, ctx: &egui::Context, at: Pos2) {
|
||||
let Some(sketch) = app.tree.redefine else { return };
|
||||
if app.session.doc.feature(sketch).is_none() {
|
||||
clear(app);
|
||||
return;
|
||||
}
|
||||
let mut draft = app.tree.redefine_draft.take().filter(|draft| draft.sketch == sketch).unwrap_or_else(|| Draft::new(app, sketch));
|
||||
let status = attachment(app, sketch);
|
||||
let name = app.session.doc.feature(sketch).map(|feature| feature.name.as_str()).unwrap_or("Sketch");
|
||||
let selected = app.session.selection.iter().find(|selection| matches!(selection, Sel::Face { .. })).cloned();
|
||||
let mut open = true;
|
||||
let mut apply = false;
|
||||
let mut cancel = false;
|
||||
let t = Tokens::get();
|
||||
crate::frame::docked(
|
||||
format!("Redefine Sketch Plane: {name}"),
|
||||
egui::Id::new("sc_redefine"),
|
||||
at,
|
||||
app.viewport.rect.map(|rect| rect.bottom()).unwrap_or_else(|| ctx.content_rect().bottom()) - at.y - 8.0,
|
||||
crate::frame::Width::Normal,
|
||||
)
|
||||
.open(&mut open)
|
||||
.scroll([false, true])
|
||||
.show(ctx, |ui| {
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
|
||||
ui.strong(format!("Redefine Sketch Plane: {name}"));
|
||||
ui.separator();
|
||||
let saved_plane = match &status {
|
||||
FaceAttachmentStatus::Healthy { plane, target, path } => {
|
||||
ui.label(format!("Attached to {} / {}", target.body, path_label(app, path)));
|
||||
Some(*plane)
|
||||
}
|
||||
FaceAttachmentStatus::Lost { message, repair_plane } => {
|
||||
ui.label(RichText::new("Face attachment lost").color(t.error));
|
||||
ui.label(message);
|
||||
if repair_plane.is_some() {
|
||||
ui.label("The dashed sketch is a saved plane guide. Select a new face to repair the attachment.");
|
||||
} else {
|
||||
ui.label("The saved author occurrence is missing. Select a valid author placement before rebinding.");
|
||||
}
|
||||
*repair_plane
|
||||
}
|
||||
FaceAttachmentStatus::Legacy { plane } => {
|
||||
ui.label("Legacy face attachment. Rebinding to a certified face creates an exact attachment.");
|
||||
*plane
|
||||
}
|
||||
FaceAttachmentStatus::Fixed { plane } => {
|
||||
ui.label("Sketch plane has no body-face attachment.");
|
||||
*plane
|
||||
}
|
||||
};
|
||||
ui.separator();
|
||||
if matches!(&status, FaceAttachmentStatus::Lost { repair_plane: None, .. }) {
|
||||
let author = app.session.doc.feature(sketch).map(|feature| feature.component);
|
||||
let available = author == Some(app.session.active_component);
|
||||
if ui.add_enabled(available, egui::Button::new("Use active author placement")).clicked() {
|
||||
draft.author_path = Some(app.session.active_occurrence_path.clone().unwrap_or_default());
|
||||
draft.error = None;
|
||||
}
|
||||
if available && let Some(path) = &draft.author_path {
|
||||
ui.label(format!("Author placement: {}", path_label(app, path)));
|
||||
}
|
||||
}
|
||||
ui.label("Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.");
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for plane in ["XY", "XZ", "YZ"] {
|
||||
if ui.button(format!("Use {plane} plane")).clicked() {
|
||||
draft.choose(json!(plane), plane.into());
|
||||
}
|
||||
}
|
||||
});
|
||||
for feature in &app.session.doc.features {
|
||||
if matches!(feature.kind, FeatureKind::ConstructionPlane { .. }) && ui.button(&feature.name).clicked() {
|
||||
draft.choose(json!(feature.name), feature.name.clone());
|
||||
}
|
||||
}
|
||||
if ui.add_enabled(selected.is_some(), egui::Button::new("Reattach selected face")).clicked()
|
||||
&& let Some(selection @ Sel::Face { .. }) = &selected
|
||||
&& let Some(plane) = face_plane(app, selection)
|
||||
{
|
||||
let label = face_label(app, selection);
|
||||
draft.choose(plane, label);
|
||||
}
|
||||
if ui.add_enabled(saved_plane.is_some(), egui::Button::new("Use saved fixed plane")).clicked()
|
||||
&& let Some(plane) = saved_plane
|
||||
&& let Err(error) = draft.choose_saved_fixed(app, plane)
|
||||
{
|
||||
draft.error = Some(error);
|
||||
}
|
||||
if ui.add_enabled(selected.is_some(), egui::Button::new("Use selected face as fixed plane")).clicked()
|
||||
&& let Some(selection) = &selected
|
||||
&& let Some(mut plane) = face_plane(app, selection)
|
||||
{
|
||||
plane["fixed"] = json!(true);
|
||||
draft.choose(plane, "Fixed plane: independent of the selected body".into());
|
||||
}
|
||||
if draft.candidate.is_some() {
|
||||
ui.separator();
|
||||
ui.label(format!("Preview: {}", draft.label));
|
||||
}
|
||||
if let Some(error) = draft.error.as_ref().or(app.preview.error.as_ref()) {
|
||||
ui.label(RichText::new(error).color(t.error));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add_enabled(draft.candidate.is_some() && app.preview.error.is_none(), egui::Button::new("Apply")).clicked() {
|
||||
apply = true;
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if ctx.input(|input| input.key_pressed(egui::Key::Escape)) {
|
||||
cancel = true;
|
||||
app.esc_handled = true;
|
||||
}
|
||||
if apply && let Some(commands) = draft.commands() {
|
||||
for (command, params) in commands {
|
||||
match app.run(&command, params) {
|
||||
Ok(_) => cancel = true,
|
||||
Err(error) => draft.error = Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancel || !open {
|
||||
clear(app);
|
||||
} else {
|
||||
app.tree.redefine_draft = Some(draft);
|
||||
}
|
||||
}
|
||||
|
||||
/// Only a lost reference's explicit saved author frame supplies a guide. It is
|
||||
/// absent from ModelState, selection, snapping and all downstream evaluation.
|
||||
pub fn lost_guide(app: &SolveApp) -> Option<(Plane, &solvecraft_engine::sketch::Sketch)> {
|
||||
let sketch = app.tree.redefine?;
|
||||
let FaceAttachmentStatus::Lost { repair_plane: Some(plane), .. } = attachment(app, sketch) else { return None };
|
||||
let FeatureKind::Sketch { sketch, .. } = &app.session.doc.feature(sketch)?.kind else { return None };
|
||||
Some((plane, sketch))
|
||||
}
|
||||
|
||||
pub fn guide_lines(app: &SolveApp) -> Vec<Vec<Vec3>> {
|
||||
let Some((plane, sketch)) = lost_guide(app) else { return Vec::new() };
|
||||
(0..sketch.curves.len())
|
||||
.flat_map(|index| sketch.segs(index))
|
||||
.map(|segment| segment.polyline(0.05).into_iter().map(|point| plane.to_world(point)).collect())
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::doc::{FaceAttachmentStatus, FeatureKind, PlaneRef};
|
||||
|
||||
fn picked_sketch() -> (Harness, u64) {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.session.auto_project = false;
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"BaseBox","name":"Original"})).unwrap();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[0,0,20],"body_name":"ControlBox","name":"Control"})).unwrap();
|
||||
h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":false})).unwrap();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.settle();
|
||||
h.step(&json!({"start":"sketch.create"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[3,4,10]}})).unwrap();
|
||||
h.settle();
|
||||
let sketch = h.app.session.active_sketch.expect("actual picked face creates the sketch");
|
||||
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &h.app.session.doc.feature(sketch).unwrap().kind else {
|
||||
panic!("picked face must create a certified attachment");
|
||||
};
|
||||
assert_eq!(anchor.target.body, "BaseBox");
|
||||
assert!(anchor.path.is_empty());
|
||||
h.app.run("parameters.add", json!({"name":"badge_diameter","expression":"2 mm"})).unwrap();
|
||||
h.step(&json!({"start":"sketch.circle.center"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[5,5,10]}})).unwrap();
|
||||
h.step(&json!({"move":{"world":[7,5,10]}})).unwrap();
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"badge_diameter"})).unwrap();
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
h.app.finish_sketch();
|
||||
h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":true})).unwrap();
|
||||
assert!(!h.app.session.doc.sketch(sketch).unwrap().constraints.is_empty());
|
||||
(h, sketch)
|
||||
}
|
||||
|
||||
fn raw_sketch(h: &Harness, id: u64) -> Value {
|
||||
serde_json::to_value(h.app.session.doc.sketch(id).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_picked_face_and_noop_edit_preserve_exact_anchor_and_dimensions() {
|
||||
let (mut h, sketch) = picked_sketch();
|
||||
let before = serde_json::to_value(&h.app.session.doc.feature(sketch).unwrap().kind).unwrap();
|
||||
h.app.edit_sketch(sketch);
|
||||
h.settle();
|
||||
h.app.finish_sketch();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.feature(sketch).unwrap().kind).unwrap(), before);
|
||||
let path = h.dir.join("picked-face.solvecraft");
|
||||
h.app.run("file.save", json!({"path":path})).unwrap();
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.feature(sketch).unwrap().kind).unwrap(), before);
|
||||
assert!(matches!(h.app.session.doc.sketch_face_attachment(sketch, &h.app.session.model), FaceAttachmentStatus::Healthy { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_attachment_ghost_rebind_preview_cancel_apply_and_undo_preserve_sketch() {
|
||||
let (mut h, sketch) = picked_sketch();
|
||||
h.app.run("solid.extrude", json!({"sketch":sketch,"distance":2,"operation":"new_body","body_name":"Boss"})).unwrap();
|
||||
h.app.run("timeline.suppress", json!({"feature":"Original","suppressed":true})).unwrap();
|
||||
assert!(h.app.session.model.result(sketch).unwrap().error.is_some());
|
||||
assert!(h.app.session.world_state().body("Boss").is_none(), "lost attachment cannot produce a Boss on ControlBox");
|
||||
assert!(h.app.session.world_state().sketch(sketch).is_none(), "saved repair guides are not solved sketch geometry");
|
||||
let raw = raw_sketch(&h, sketch);
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
let redo = h.app.session.redo.len();
|
||||
h.app.edit_sketch(sketch);
|
||||
h.frames(4);
|
||||
assert_eq!(h.app.tree.redefine, Some(sketch));
|
||||
assert!(h.widget("Face attachment lost").is_some());
|
||||
let guides = crate::sketch_attachment_ui::guide_lines(&h.app);
|
||||
assert!(!guides.is_empty());
|
||||
assert!(guides.iter().flatten().all(|point| (point.z - 10.0).abs() < 1e-8));
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.settle();
|
||||
h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":false})).unwrap();
|
||||
h.app.session.selection.clear();
|
||||
h.frames(4);
|
||||
h.step(&json!({"click":{"world":[5,5,10]}})).unwrap();
|
||||
assert!(h.app.session.selection.is_empty(), "saved lost-plane guides must not be selectable geometry");
|
||||
assert!(h.app.session.active_sketch.is_none());
|
||||
h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":true})).unwrap();
|
||||
h.frames(4);
|
||||
h.step(&json!({"click":{"world":[3,4,30]}})).unwrap();
|
||||
assert!(matches!(h.app.session.selection.first(), Some(solvecraft_engine::Sel::Face { body, .. }) if body=="ControlBox"));
|
||||
h.frames(2);
|
||||
h.step(&json!({"widget":"Reattach selected face"})).unwrap();
|
||||
let commands = crate::sketch_attachment_ui::commands(&h.app)
|
||||
.unwrap_or_else(|| panic!("repair draft {:?}, selection {:?}", h.app.tree.redefine_draft, h.app.session.selection));
|
||||
assert_eq!(commands[0].1["plane"]["body"], "ControlBox");
|
||||
assert!(commands[0].1["plane"]["index"].as_u64().is_some());
|
||||
let preview = h.app.session.preview_visible_world(&commands).unwrap();
|
||||
let boss = preview.after.body("Boss").unwrap().mesh().bounds();
|
||||
assert!((boss.min.z - 30.0).abs() < 1e-7 && (boss.max.z - 32.0).abs() < 1e-7);
|
||||
assert_eq!(h.app.session.doc.to_json(), before, "preview is scratch only");
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.session.redo.len(), redo);
|
||||
assert!(crate::sketch_attachment_ui::guide_lines(&h.app).is_empty());
|
||||
|
||||
crate::browser::redefine_sketch(&mut h.app, sketch);
|
||||
h.frames(4);
|
||||
h.step(&json!({"widget":"Reattach selected face"})).unwrap();
|
||||
h.frames(8);
|
||||
h.step(&json!({"widget":"Apply"})).unwrap();
|
||||
assert!(h.app.tree.redefine.is_none(), "{:?}", h.app.tree.redefine_draft);
|
||||
assert_eq!(raw_sketch(&h, sketch), raw, "curves, dimensions and expressions remain exact");
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
assert!(h.app.session.model.result(sketch).unwrap().error.is_none());
|
||||
let boss = h.app.session.world_state().body("Boss").unwrap().mesh().bounds();
|
||||
assert!((boss.min.z - 30.0).abs() < 1e-7 && (boss.max.z - 32.0).abs() < 1e-7);
|
||||
let repaired = h.app.session.doc.to_json();
|
||||
let path = h.dir.join("rebound-face.solvecraft");
|
||||
h.app.run("file.save", json!({"path":path})).unwrap();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), repaired);
|
||||
assert_eq!(raw_sketch(&h, sketch), raw);
|
||||
assert!(h.app.session.model.result(sketch).unwrap().error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_drafts_leave_on_document_replacement_but_survive_failed_open() {
|
||||
let (mut h, sketch) = picked_sketch();
|
||||
crate::browser::redefine_sketch(&mut h.app, sketch);
|
||||
h.frames(2);
|
||||
h.step(&json!({"widget":"Use XZ plane"})).unwrap();
|
||||
assert!(crate::sketch_attachment_ui::commands(&h.app).is_some());
|
||||
assert!(h.app.run("doc.open", json!({"path":h.dir.join("missing.solvecraft")})).is_err());
|
||||
assert!(crate::sketch_attachment_ui::commands(&h.app).is_some());
|
||||
h.app.run("file.new", json!({})).unwrap();
|
||||
assert!(h.app.tree.redefine.is_none());
|
||||
assert!(h.app.tree.redefine_draft.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_modified_face_offers_explicit_fixed_plane_without_live_links() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Modified"})).unwrap();
|
||||
h.app.run("solid.fillet", json!({"edges":[[0,0,5]],"radius":1})).unwrap();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.settle();
|
||||
h.step(&json!({"start":"sketch.create"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[5,5,10]}})).unwrap();
|
||||
assert!(h.app.session.active_sketch.is_none());
|
||||
assert!(h.app.dialog.as_ref().unwrap().error.is_some());
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
h.frames(4);
|
||||
assert!(h.widget("Use fixed plane").is_some());
|
||||
h.step(&json!({"widget":"Use fixed plane"})).unwrap();
|
||||
let sketch = h.app.session.active_sketch.expect("explicit fixed choice succeeds for the actual planar surface");
|
||||
let FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch: geometry } = &h.app.session.doc.feature(sketch).unwrap().kind else {
|
||||
panic!("fixed choice must never create a durable-looking Face reference");
|
||||
};
|
||||
assert!((plane.origin.z - 10.0).abs() < 1e-7);
|
||||
assert!(geometry.links.is_empty(), "a fixed snapshot cannot carry live source projections");
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
}
|
||||
|
||||
fn occurrence_click(h: &mut Harness, path: &[u64], control: &str) {
|
||||
h.step(&json!({"click":{"occurrence":path,"control":control}})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_nested_second_placement_face_pick_keeps_target_and_author_paths() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.session.auto_project = false;
|
||||
let parent = h.app.run("component.create", json!({"name":"Parent"})).unwrap();
|
||||
let parent_component = parent["component"].as_u64().unwrap();
|
||||
let first_parent = parent["occurrence"].as_u64().unwrap();
|
||||
let child = h.app.run("component.create", json!({"name":"Child","parent":parent_component,"parent_path":[first_parent]})).unwrap();
|
||||
let child_component = child["component"].as_u64().unwrap();
|
||||
let child_occurrence = child["occurrence"].as_u64().unwrap();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"NestedBase"})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":0})).unwrap();
|
||||
let second_parent =
|
||||
h.app.run("occurrence.copy", json!({"occurrence":first_parent,"translate":[40,0,0]})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.frames(4);
|
||||
let path = vec![second_parent, child_occurrence];
|
||||
if !crate::browser::drawn_occurrences().iter().any(|(drawn, _, _, _)| drawn == &path) {
|
||||
occurrence_click(&mut h, &[second_parent], "fold");
|
||||
}
|
||||
occurrence_click(&mut h, &path, "activate");
|
||||
assert_eq!(h.app.session.active_component, child_component);
|
||||
assert_eq!(h.app.session.active_occurrence_path.as_ref(), Some(&path));
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.settle();
|
||||
h.step(&json!({"start":"sketch.create"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[43,4,10]}})).unwrap();
|
||||
let sketch = h.app.session.active_sketch.expect("actual second nested face pick");
|
||||
let kind = h.app.session.doc.feature(sketch).unwrap().kind.clone();
|
||||
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &kind else {
|
||||
panic!("strict face attachment");
|
||||
};
|
||||
assert_eq!(anchor.target.body, "NestedBase");
|
||||
assert_eq!(anchor.path, path);
|
||||
assert_eq!(anchor.author_path, path);
|
||||
assert_eq!(anchor.component, child_component);
|
||||
let FaceAttachmentStatus::Healthy { plane, .. } = h.app.session.placed_document().sketch_face_attachment(sketch, &h.app.session.model) else {
|
||||
panic!("selected saved placement resolves");
|
||||
};
|
||||
assert!((plane.origin.x - 40.0).abs() < 1e-7 && (plane.origin.z - 10.0).abs() < 1e-7);
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[1,1],"p1":[3,3]})).unwrap();
|
||||
h.app.run("sketch.dimension", json!({"entities":["l1"],"value":2})).unwrap();
|
||||
h.app.finish_sketch();
|
||||
let kind = h.app.session.doc.feature(sketch).unwrap().kind.clone();
|
||||
h.app.run("component.activate", json!({"component":0})).unwrap();
|
||||
h.app.edit_sketch(sketch);
|
||||
assert_eq!(h.app.session.active_component, child_component);
|
||||
assert_eq!(h.app.session.active_occurrence_path.as_ref(), Some(&path));
|
||||
h.app.finish_sketch();
|
||||
assert_eq!(h.app.session.doc.feature(sketch).unwrap().kind, kind);
|
||||
let file = h.dir.join("nested-face.solvecraft");
|
||||
h.app.run("file.save", json!({"path":file})).unwrap();
|
||||
h.app.run("doc.open", json!({"path":file})).unwrap();
|
||||
assert_eq!(h.app.session.doc.feature(sketch).unwrap().kind, kind);
|
||||
h.app.run("selection.delete", json!({"occurrences":[second_parent]})).unwrap();
|
||||
assert!(h.app.session.model.result(sketch).unwrap().error.is_some());
|
||||
let FaceAttachmentStatus::Lost { repair_plane, .. } = h.app.session.placed_document().sketch_face_attachment(sketch, &h.app.session.model) else {
|
||||
panic!("deleted second placement must not substitute first");
|
||||
};
|
||||
assert!(repair_plane.is_none(), "a missing saved author placement cannot be drawn at its sibling");
|
||||
h.app.edit_sketch(sketch);
|
||||
h.frames(4);
|
||||
assert!(crate::sketch_attachment_ui::guide_lines(&h.app).is_empty());
|
||||
assert_eq!(h.app.tree.redefine, Some(sketch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_anchor_saved_fixed_conversion_preserves_rotated_second_author_plane() {
|
||||
use solvecraft_engine::doc::{EmbossFace, apply_point, apply_vector};
|
||||
use solvecraft_engine::geom::Plane;
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
let mut h = Harness::new();
|
||||
h.app.session.auto_project = false;
|
||||
let created = h.app.run("component.create", json!({"name":"Author"})).unwrap();
|
||||
let component = created["component"].as_u64().unwrap();
|
||||
let first = created["occurrence"].as_u64().unwrap();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"})).unwrap();
|
||||
let second = h.app.run("occurrence.copy", json!({"occurrence":first,"translate":[40,0,0]})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":second,"axis":[0,1,0],"angle":"90 deg","capture":true})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":component,"path":[second],"occurrence":second})).unwrap();
|
||||
let doc = h.app.session.placed_document();
|
||||
let body_name = doc.body_frames_with_source(&h.app.session.model.state()).into_iter().find(|(_, _, _, path, _)| path == &[second]).unwrap().0;
|
||||
let world = h.app.session.world_state();
|
||||
let face = world
|
||||
.body(&body_name)
|
||||
.unwrap()
|
||||
.body
|
||||
.faces(0.01)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|face| world.body(&body_name).unwrap().body.face_plane(face.index).is_some_and(|plane| plane.normal().dot(Vec3::X) > 0.999))
|
||||
.unwrap();
|
||||
let id = h
|
||||
.app
|
||||
.run("sketch.create", json!({"plane":{"body":body_name,"index":face.index,"face":face.centroid,"path":[second]},"project_edges":false}))
|
||||
.unwrap()["sketch"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[1,1],"p1":[3,3]})).unwrap();
|
||||
h.app.finish_sketch();
|
||||
// Exercise a durable anchor under both public plane wrappers, as imported
|
||||
// documents can contain these without an interactive wrapper editor.
|
||||
let mut document = (*h.app.session.doc).clone();
|
||||
let FeatureKind::Sketch { plane, .. } = &mut document.feature_mut(id).unwrap().kind else {
|
||||
panic!("sketch");
|
||||
};
|
||||
*plane = PlaneRef::AtAngle {
|
||||
base: Box::new(PlaneRef::Offset { base: Box::new(plane.clone()), distance: "2 mm".into() }),
|
||||
axis_origin: Vec3::ZERO,
|
||||
axis_dir: Vec3::X,
|
||||
angle: "15 deg".into(),
|
||||
};
|
||||
h.app.session = solvecraft_engine::Session::new(document);
|
||||
let raw = h.app.session.doc.sketch(id).unwrap().clone();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
let FaceAttachmentStatus::Healthy { plane: expected, .. } = h.app.session.placed_document().sketch_face_attachment(id, &h.app.session.model)
|
||||
else {
|
||||
panic!("wrapped anchor must resolve");
|
||||
};
|
||||
crate::browser::redefine_sketch(&mut h.app, id);
|
||||
h.frames(4);
|
||||
assert_eq!(h.app.tree.redefine_draft.as_ref().unwrap().author_path, Some(vec![second]));
|
||||
h.step(&json!({"widget":"Use saved fixed plane"})).unwrap();
|
||||
let commands = crate::sketch_attachment_ui::commands(&h.app).unwrap();
|
||||
let preview = h.app.session.preview_visible_world(&commands).unwrap();
|
||||
// The shared sketch's primary world entry belongs to the untransformed
|
||||
// first placement. Verify that local plane at the specifically chosen
|
||||
// second placement, rather than treating the primary entry as that path.
|
||||
let frame = EmbossFace::component_frame(&h.app.session.placed_document(), component, &[second]).unwrap();
|
||||
let selected_plane =
|
||||
|local: Plane| Plane::new(apply_point(&frame, local.origin), apply_vector(&frame, local.x), apply_vector(&frame, local.y)).unwrap();
|
||||
let shown = selected_plane(preview.after.sketch(id).unwrap().plane);
|
||||
assert!(
|
||||
shown.origin.dist(expected.origin) < 1e-7,
|
||||
"expected {expected:?}, shown {shown:?}, primary {:?}, commands {commands:?}",
|
||||
preview.after.sketch(id).unwrap().plane
|
||||
);
|
||||
assert!(shown.x.dist(expected.x) < 1e-7 && shown.y.dist(expected.y) < 1e-7);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
h.frames(6);
|
||||
h.step(&json!({"widget":"Apply"})).unwrap();
|
||||
assert!(h.app.tree.redefine.is_none());
|
||||
assert_eq!(h.app.session.doc.sketch(id).unwrap(), &raw);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let shown = selected_plane(h.app.session.model.state().sketch(id).unwrap().plane);
|
||||
assert!(shown.origin.dist(expected.origin) < 1e-7);
|
||||
assert!(shown.x.dist(expected.x) < 1e-7 && shown.y.dist(expected.y) < 1e-7);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
}
|
||||
@@ -1,6 +1,66 @@
|
||||
use super::*;
|
||||
use solvecraft_engine::sketch::CurveKind;
|
||||
|
||||
#[test]
|
||||
fn clicked_periodic_fit_closes_at_start_and_right_click_keeps_natural() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.settle();
|
||||
h.step(&json!({"start":"sketch.spline.fit_point"})).unwrap();
|
||||
for point in [[2, 2, 0], [8, 2, 0], [9, 7, 0], [1, 6, 0]] {
|
||||
h.step(&json!({"click":{"world":point}})).unwrap();
|
||||
}
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
assert!(h.app.session.model.state().sketch(sid).unwrap().sketch.curves.is_empty());
|
||||
h.step(&json!({"move":{"world":[2,2,0]}})).unwrap();
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().hover.as_ref().unwrap().1.as_deref(), Some("Close smooth loop"));
|
||||
h.step(&json!({"click":{"world":[2,2,0]}})).unwrap();
|
||||
let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone();
|
||||
assert_eq!(sk.curves.len(), 1, "{:?}", h.app.status);
|
||||
assert!(matches!(sk.curves[0].kind, CurveKind::Spline { periodic: true, .. }));
|
||||
sk.validate_periodic_splines().unwrap();
|
||||
assert_eq!(solvecraft_engine::sketch::find_profiles(&sk).len(), 1);
|
||||
assert!(h.app.tool.as_ref().unwrap().pts.is_empty());
|
||||
for point in [[15, 2, 0], [20, 2, 0], [20, 6, 0]] {
|
||||
h.step(&json!({"click":{"world":point}})).unwrap();
|
||||
}
|
||||
h.step(&json!({"click":{"world":[20,6,0]},"button":"right"})).unwrap();
|
||||
let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone();
|
||||
assert_eq!(sk.curves.len(), 2);
|
||||
assert!(matches!(sk.curves[1].kind, CurveKind::Spline { periodic: false, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicked_ellipse_offset_then_closed_offset_preserves_exact_distance_and_undo() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5})).unwrap();
|
||||
h.settle();
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
h.step(&json!({"start":"sketch.offset"})).unwrap();
|
||||
// Major tip is also a source handle: Offset must choose the actual curve.
|
||||
h.step(&json!({"click":{"world":[10,0,0]}})).unwrap();
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().picks, vec!["e1"]);
|
||||
h.step(&json!({"click":{"world":[11,0,0]}})).unwrap();
|
||||
let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone();
|
||||
assert_eq!(sk.freeform_offsets.len(), 1, "{:?}", h.app.status);
|
||||
assert!((sk.freeform_offsets[0].distance + 1.0).abs() < 1e-4);
|
||||
let first = sk.freeform_offsets[0].output.clone();
|
||||
let saved = h.app.session.doc.to_json();
|
||||
h.step(&json!({"click":{"world":[11,0,0]}})).unwrap();
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().picks, vec![first]);
|
||||
h.step(&json!({"click":{"world":[11.5,0,0]}})).unwrap();
|
||||
let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone();
|
||||
assert_eq!(sk.freeform_offsets.len(), 2, "{:?}", h.app.status);
|
||||
assert!((sk.freeform_offsets[1].distance + 0.5).abs() < 1e-4);
|
||||
assert!(sk.freeform_offsets.iter().all(|r| r.certificate == 2));
|
||||
assert_eq!(solvecraft_engine::sketch::find_profiles(&sk).len(), 3);
|
||||
let last = sk.curve_index(&sk.freeform_offsets[1].output).unwrap();
|
||||
assert!(sk.exact_segs(last).unwrap()[0].point_at(0.0).dist(Vec2::new(11.5, 0.0)) < 1e-4);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicked_extend_endpoint_reaches_exact_ellipse_and_preserves_support() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
|
||||
@@ -403,7 +403,7 @@ pub fn hint(id: &str) -> Option<String> {
|
||||
"sketch.slot.center_point" => "Slot: click the centre, an arc centre, then the width",
|
||||
"sketch.slot.arc_three_point" => "Arc slot: click start, a point on the arc, the end, then the width",
|
||||
"sketch.slot.arc_center" => "Arc slot: click the centre, the start, the end, then the width",
|
||||
"sketch.spline.fit_point" => "Spline: click fit points; right-click to finish",
|
||||
"sketch.spline.fit_point" => "Spline: click fit points; click the start to close smoothly, or right-click to finish",
|
||||
"sketch.spline.control_point" | "sketch.spline.control_point_5" => "Control point spline: click control points; right-click to finish",
|
||||
"sketch.text" => "Text: click where the text starts",
|
||||
"sketch.blend_curve" => "Blend curve: click the ends of two curves",
|
||||
@@ -546,6 +546,29 @@ fn round_nice(x: f64) -> f64 {
|
||||
}
|
||||
|
||||
/// A click with an extra tool.
|
||||
pub fn periodic_start_snap(app: &SolveApp, tool: &Tool, proj: &Proj, pos: Pos2) -> Option<Vec2> {
|
||||
if tool.cmd != "sketch.spline.fit_point" || tool.pts.len() < 3 || crate::drag_snap::suppressed() {
|
||||
return None;
|
||||
}
|
||||
let first = tool.pts.first()?.0;
|
||||
let mut distinct = Vec::new();
|
||||
for p in &tool.pts {
|
||||
if !distinct.contains(&p.0) {
|
||||
distinct.push(p.0);
|
||||
}
|
||||
if distinct.len() == 3 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if distinct.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let state = app.session.world_state();
|
||||
let plane = &state.sketch(app.session.active_sketch?)?.plane;
|
||||
let screen = proj.to_screen(plane.to_world(first))?;
|
||||
(screen.distance(pos) <= 8.0).then_some(first)
|
||||
}
|
||||
|
||||
pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
|
||||
let Some(m) = mode(tool.cmd) else { return };
|
||||
let hits = pick(app, proj, pos);
|
||||
@@ -658,6 +681,16 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
|
||||
}
|
||||
}
|
||||
Mode::Points(n) => {
|
||||
if periodic_start_snap(app, tool, proj, pos).is_some() {
|
||||
// Only this deliberate click selects periodic interpolation. A
|
||||
// right-click finish and all previously saved splines stay natural.
|
||||
let params = json!({"points":tool.pts.iter().map(arg).collect::<Vec<_>>(),"periodic":true});
|
||||
if app.run(cmd, params).is_ok() {
|
||||
tool.pts.clear();
|
||||
crate::sketch_dims::reset(tool);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(p) = at else { return };
|
||||
tool.pts.push(p);
|
||||
if n > 0 && tool.pts.len() >= n {
|
||||
@@ -673,21 +706,30 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
|
||||
return;
|
||||
}
|
||||
if cmd == "sketch.offset" && !tool.picks.is_empty() {
|
||||
if let Some((p, _)) = at {
|
||||
// Dense derived fit nodes must not snap a nearby side click back
|
||||
// onto the source, reducing a deliberate offset to zero.
|
||||
let state = app.session.world_state();
|
||||
let side = active_sketch(app).and_then(|sid| state.sketch(sid)).and_then(|ss| {
|
||||
let (o, d) = proj.ray(pos);
|
||||
ss.plane.intersect_ray(o, d).map(|p| ss.plane.to_local(p))
|
||||
});
|
||||
if let Some(p) = side {
|
||||
offset_side(app, tool, p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some((id, is_point)) = sketch_entity(app, &hits) else { return };
|
||||
if cmd == "sketch.offset" {
|
||||
// First the curve, then the side point.
|
||||
if tool.picks.is_empty() && !is_point {
|
||||
if let Some(sid) = active_sketch(app)
|
||||
&& let Some(id) = hits.iter().find_map(|hit| match hit {
|
||||
Hit::SketchCurve { sketch, id, .. } if *sketch == sid => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
{
|
||||
tool.picks.push(id);
|
||||
} else if let Some((p, _)) = at {
|
||||
offset_side(app, tool, p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some((id, _)) = sketch_entity(app, &hits) else { return };
|
||||
if cmd == "sketch.centerline" {
|
||||
let _ = app.run(cmd, json!({"curves": [id]}));
|
||||
return;
|
||||
@@ -890,8 +932,14 @@ fn offset_side(app: &mut SolveApp, tool: &mut Tool, p: Vec2) -> bool {
|
||||
let st = app.session.world_state();
|
||||
let d = active_sketch(app)
|
||||
.and_then(|s| st.sketch(s))
|
||||
.and_then(|ss| ss.sketch.curve_index(&tool.picks[0]).and_then(|c| ss.sketch.shape(c)))
|
||||
.map(|sh| sh.dist(p))
|
||||
.and_then(|ss| {
|
||||
let curve = ss.sketch.curve_index(&tool.picks[0])?;
|
||||
if let Some(shape) = ss.sketch.shape(curve) {
|
||||
Some(shape.dist(p))
|
||||
} else {
|
||||
ss.sketch.exact_segs(curve)?.iter().map(|s| s.point_at(s.closest_param(p)).dist(p)).min_by(f64::total_cmp)
|
||||
}
|
||||
})
|
||||
.unwrap_or(0.0);
|
||||
if d > 1e-6 {
|
||||
let _ = app.run("sketch.offset", json!({"curves": [tool.picks[0]], "distance": d, "side": xy(p)}));
|
||||
|
||||
@@ -112,7 +112,9 @@ fn xy(p: &(Vec2, Option<String>)) -> Value {
|
||||
|
||||
pub fn on_hover(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
|
||||
crate::sketch3d::on_hover(app, proj, pos);
|
||||
let h = if let Some(tool) = app.tool.as_ref().filter(|t| t.cmd == "sketch.fillet" || t.cmd.starts_with("sketch.chamfer.")) {
|
||||
let h = if let Some(point) = app.tool.as_ref().and_then(|tool| crate::sketch_tools::periodic_start_snap(app, tool, proj, pos)) {
|
||||
Some((point, Some("Close smooth loop".into())))
|
||||
} else if let Some(tool) = app.tool.as_ref().filter(|t| t.cmd == "sketch.fillet" || t.cmd.starts_with("sketch.chamfer.")) {
|
||||
crate::sketch_tools::corner_point(app, proj, pos, tool.cmd).map(|(at, id)| (at, Some(id)))
|
||||
} else {
|
||||
sketch_point_at(app, proj, pos)
|
||||
|
||||
@@ -225,6 +225,7 @@ fn scene_key(app: &SolveApp) -> u64 {
|
||||
app.session.active_component.hash(&mut h);
|
||||
app.session.active_occurrence_path.hash(&mut h);
|
||||
app.preview.replaced.hash(&mut h);
|
||||
app.tree.redefine.hash(&mut h);
|
||||
crate::theme::is_dark().hash(&mut h);
|
||||
app.session.active_sketch.hash(&mut h);
|
||||
let (minor, _) = grid_step(app.cam.half_height());
|
||||
@@ -374,6 +375,12 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
}
|
||||
}
|
||||
}
|
||||
let guide_unit = grid_step(app.cam.half_height()).0;
|
||||
for points in crate::sketch_attachment_ui::guide_lines(app) {
|
||||
for (a, b) in dashes(&points, &[guide_unit * 0.25, guide_unit * 0.15]) {
|
||||
sc.line(a.to_f32(), b.to_f32(), c4(tk.warning), 1.5, true);
|
||||
}
|
||||
}
|
||||
sc
|
||||
}
|
||||
|
||||
@@ -594,6 +601,9 @@ pub fn model_bounds(app: &SolveApp) -> solvecraft_engine::geom::Aabb3 {
|
||||
}
|
||||
}
|
||||
}
|
||||
for point in crate::sketch_attachment_ui::guide_lines(app).into_iter().flatten() {
|
||||
b.add(point);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
@@ -1795,6 +1805,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::drag_snap::show(app, &painter, &proj);
|
||||
hover_highlight(app, &painter, &proj);
|
||||
points_2d(app, &painter, &proj);
|
||||
if let Some((plane, sketch)) = crate::sketch_attachment_ui::lost_guide(app)
|
||||
&& let Some((low, high)) = sketch.bounds()
|
||||
&& let Some(at) = proj.to_screen(plane.to_world(solvecraft_engine::geom::Vec2::new((low.x + high.x) * 0.5, high.y)))
|
||||
{
|
||||
painter.text(at - vec2(0.0, 12.0), Align2::CENTER_BOTTOM, "Saved sketch plane — attachment lost", FontId::proportional(12.0), t.warning);
|
||||
}
|
||||
if let Some(bx) = app.viewport.boxsel {
|
||||
let col = if bx.crossing { t.box_crossing } else { t.box_window };
|
||||
painter.rect_filled(bx.rect, 0.0, col.gamma_multiply(0.12));
|
||||
|
||||
@@ -60,6 +60,18 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "The shelled floor is picked rather than the origin plane. Its uncertified mesh face requires an explicit fixed-plane choice."
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"sketching": false,
|
||||
"dialog": "this face has no exact planar source"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widget": "Use fixed plane"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"sketching": true
|
||||
@@ -78,4 +90,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -80,6 +80,28 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "The sheet flange face is uncertified; choose the displayed fixed-plane option explicitly before drawing the fold line."
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"sketching": false,
|
||||
"dialog": "this face has no exact planar source"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widget": "Use fixed plane"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"sketching": true,
|
||||
"query": {
|
||||
"command": "sketch.inspect",
|
||||
"path": ["plane", "origin", 2],
|
||||
"value": 2.5
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "sketch.line"
|
||||
},
|
||||
@@ -149,4 +171,4 @@
|
||||
"file": "Untitled-Body1.dxf"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
Reference in new issue
Block a user