feat: stabilize face attachments and closed spline workflows

This commit is contained in:
bherbruck committed 2026-10-11 05:32:46 -04:00
1 parent c1d819f235
commit a6bb8fc924
83 files changed
+6271 -385

No files matched your search

+95 -6
View File
@@ -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)]
+60 -5
View File
@@ -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)]
+1 -1
View File
@@ -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;
+19
View File
@@ -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)"));
}
+6
View File
@@ -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()
{
+53 -13
View File
@@ -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;
+187
View File
@@ -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, &params).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))));
}
}
+10 -1
View File
@@ -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;
+1 -1
View File
@@ -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()) {
+1
View File
@@ -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,
&regions,
(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));
}
}
}
}
+95 -32
View File
@@ -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);
}
+21 -7
View File
@@ -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
}
+215
View File
@@ -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;
+401
View File
@@ -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, &params).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);
}
+2 -2
View File
@@ -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")),
};
+1 -1
View File
@@ -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)?);
+14 -2
View File
@@ -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]
+75 -12
View File
@@ -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 = &current.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"}));