feat: strengthen sketches, feature editing, and independent assemblies

This commit is contained in:
bherbruck committed 2026-10-11 02:44:31 -04:00
1 parent 07932d0f42
commit 0398a813c8
65 files changed
+7082 -268

No files matched your search

+3
View File
@@ -486,6 +486,9 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
solvecraft_doc::config::Column::Material(body) | solvecraft_doc::config::Column::Appearance(body) if *body == old => {
*body = name.to_string();
}
solvecraft_doc::config::Column::FaceAppearance(target) if target.body == old => {
target.body = name.to_string();
}
_ => {}
}
}
+4 -61
View File
@@ -41,7 +41,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).params("sketches: [ids or names], component"),
CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence)
.params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]. Copies the selected effective transform, including pending moves"),
CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: copied with its own features into a new, independent component; occurrence?, path?: selected source placement; translate?: [x,y,z]"),
CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"),
CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence)
.params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position). Nested occurrence definitions remain shared"),
CommandSpec::new("occurrence.ground", "Ground", ground).params("occurrence; grounded?: bool (default toggles)"),
@@ -82,7 +82,7 @@ fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str)
Ok(v)
}
fn valid_position(m: &Mat) -> bool {
pub(super) fn valid_position(m: &Mat) -> bool {
let column = |i: usize| Vec3::new(m[i][0], m[i][1], m[i][2]);
let (x, y, z) = (column(0), column(1), column(2));
m.iter().flatten().all(|x| x.is_finite())
@@ -229,6 +229,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let _ = d.delete_feature(*f);
}
d.components.retain(|c| !gone.contains(&c.id));
d.remove_configuration_components(&gone);
d.occurrences.retain(|o| !gone.contains(&o.component) && !gone.contains(&o.parent));
let moved_out: Vec<String> = d.body_components.iter().filter(|(_, c)| gone.contains(c)).map(|(b, _)| b.clone()).collect();
d.body_components.retain(|_, c| !gone.contains(c));
@@ -351,65 +352,7 @@ fn paste_new(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "copy transform exceeds supported numeric range"));
}
let parent = occ.as_ref().map(|o| o.parent).unwrap_or(src.parent);
let new_id = s.doc_mut().add_component(Some(&format!("{} (copy)", src.name)), parent)?;
// Copy the component's features with fresh ids and names; references between them follow.
let feats: Vec<solvecraft_doc::Feature> = s.doc.features.iter().filter(|f| f.component == comp).cloned().collect();
let mut idmap: std::collections::HashMap<u64, u64> = Default::default();
let mut namemap: std::collections::HashMap<String, String> = Default::default();
let mut new_ids = Vec::new();
for f in &feats {
let nid = s.doc_mut().add_feature(f.kind.clone(), Some(&format!("{} (copy)", f.name)))?;
idmap.insert(f.id, nid);
let nname = s.doc.feature(nid).map(|x| x.name.clone()).unwrap_or_default();
namemap.insert(f.name.clone(), nname);
new_ids.push(nid);
}
for nid in &new_ids {
let Some(f) = s.doc.feature(*nid).cloned() else { continue };
let mut v = serde_json::to_value(&f.kind).map_err(|e| bad(cmd, e.to_string()))?;
remap(&mut v, &idmap, &namemap, 0);
let kind: FeatureKind = serde_json::from_value(v).map_err(|e| bad(cmd, e.to_string()))?;
if let Some(slot) = s.doc_mut().feature_mut(*nid) {
slot.kind = kind;
slot.component = new_id;
}
}
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.component == new_id) {
o.transform = transform;
}
Ok(json!({"component": new_id, "features": new_ids}))
}
/// Point copied features at their copied sketches and features.
fn remap(v: &mut Value, ids: &std::collections::HashMap<u64, u64>, names: &std::collections::HashMap<String, String>, depth: usize) {
if depth > 32 {
return;
}
match v {
Value::Object(o) => {
for (k, x) in o.iter_mut() {
match (k.as_str(), &*x) {
("sketch" | "path_sketch", Value::Number(n)) => {
if let Some(m) = n.as_u64().and_then(|i| ids.get(&i)) {
*x = json!(m);
}
}
("features", Value::Array(_)) => {
if let Value::Array(a) = x {
for e in a.iter_mut() {
if let Some(m) = e.as_str().and_then(|n| names.get(n)) {
*e = json!(m);
}
}
}
}
_ => remap(x, ids, names, depth + 1),
}
}
}
Value::Array(a) => a.iter_mut().for_each(|x| remap(x, ids, names, depth + 1)),
_ => {}
}
super::component_clone::paste(s, comp, occ, transform, parent, p)
}
pub(super) fn occurrence_path(s: &Session, p: &Value, id: u64, cmd: &str) -> Result<Vec<u64>> {
+579
View File
@@ -0,0 +1,579 @@
//! Independent copies of a component definition graph, assembled and evaluated atomically.
use std::collections::{BTreeMap, BTreeSet};
use crate::params::bad;
use crate::{Result, Sel, Session};
use serde_json::{Value, json};
use solvecraft_doc::{Document, Feature, FeatureKind, Mat, Occurrence};
const CMD: &str = "component.paste_new";
const MAX_WORK: usize = 10_000;
fn cloned_path(source: &Document, path: &[u64], root: u64, destination: &[u64], occurrences: &BTreeMap<u64, u64>) -> Result<Vec<u64>> {
let start = path
.iter()
.position(|id| source.occurrences.iter().any(|o| o.id == *id && o.component == root))
.ok_or_else(|| bad(CMD, "copied Emboss placement is outside the copied component tree"))?;
let mut mapped = destination.to_vec();
for id in path.iter().skip(start + 1) {
mapped.push(*occurrences.get(id).ok_or_else(|| bad(CMD, "copied Emboss path has an external descendant"))?);
}
if mapped.len() > 64 {
return Err(bad(CMD, "copied Emboss path exceeds placement depth limit"));
}
Ok(mapped)
}
fn unique(base: &str, used: &mut BTreeSet<String>) -> String {
let stem = format!("{base} (copy)");
let mut name = stem.clone();
let mut n = 2;
while used.contains(&name) {
name = format!("{stem} {n}");
n += 1;
}
used.insert(name.clone());
name
}
fn graph(doc: &Document, root: u64) -> Result<Vec<u64>> {
let definitions: BTreeSet<_> = doc.components.iter().map(|c| c.id).collect();
let mut children: BTreeMap<u64, Vec<u64>> = BTreeMap::new();
for occurrence in &doc.occurrences {
children.entry(occurrence.parent).or_default().push(occurrence.component);
}
let mut output = Vec::new();
let mut seen = BTreeSet::new();
let mut stack = vec![(root, Vec::<u64>::new())];
let mut work = 0;
while let Some((id, mut ancestors)) = stack.pop() {
work += 1;
if work > MAX_WORK || ancestors.len() >= 64 {
return Err(bad(CMD, "component graph exceeds copy work limit"));
}
if ancestors.contains(&id) {
return Err(bad(CMD, "component graph contains a cycle"));
}
if !definitions.contains(&id) {
return Err(bad(CMD, "component graph has a missing definition"));
}
if !seen.insert(id) {
continue;
}
output.push(id);
ancestors.push(id);
if let Some(children) = children.get(&id) {
if work.saturating_add(stack.len()).saturating_add(children.len()) > MAX_WORK {
return Err(bad(CMD, "component graph exceeds copy work limit"));
}
for child in children.iter().rev() {
stack.push((*child, ancestors.clone()));
}
}
}
// A shared node may be reached by a longer path after its first DFS visit. Check the
// longest path over the complete DAG, rather than trusting the first placement depth.
let mut indegree: BTreeMap<u64, usize> = output.iter().map(|id| (*id, 0)).collect();
for parent in &output {
if let Some(edges) = children.get(parent) {
for child in edges {
if let Some(degree) = indegree.get_mut(child) {
*degree += 1;
}
}
}
}
let mut ready = std::collections::VecDeque::from([root]);
let mut depth = BTreeMap::from([(root, 1usize)]);
let mut sorted = Vec::new();
while let Some(parent) = ready.pop_front() {
sorted.push(parent);
let parent_depth = *depth.get(&parent).unwrap_or(&1);
if let Some(edges) = children.get(&parent) {
for child in edges {
let next = parent_depth + 1;
if next > 64 {
return Err(bad(CMD, "component graph exceeds copy depth limit"));
}
depth.entry(*child).and_modify(|d| *d = (*d).max(next)).or_insert(next);
if let Some(degree) = indegree.get_mut(child) {
*degree = degree.saturating_sub(1);
if *degree == 0 {
ready.push_back(*child);
}
}
}
}
}
if sorted.len() != output.len() {
return Err(bad(CMD, "component graph contains a cycle"));
}
Ok(sorted)
}
pub(super) fn paste(
s: &mut Session,
component: u64,
source_occurrence: Option<Occurrence>,
transform: Mat,
parent: u64,
params: &Value,
) -> Result<Value> {
let source = s.doc.clone();
let source_parameters: BTreeMap<_, _> = source.params.iter().map(|p| (p.name.as_str(), p)).collect();
let generated_parameters: BTreeSet<_> = source
.params
.iter()
.filter(|p| p.model)
.map(|p| p.name.as_str())
.chain(source.features.iter().flat_map(|f| f.param_names.iter().map(String::as_str)))
.collect();
let definitions = graph(&source, component)?;
let definition_records: BTreeMap<_, _> = source.components.iter().map(|c| (c.id, c)).collect();
let selected: BTreeSet<_> = definitions.iter().copied().collect();
if source.marker.is_some_and(|m| m < source.features.len()) {
return Err(bad(CMD, "move the timeline marker to the end before Paste New"));
}
let feats: Vec<Feature> = source.features.iter().filter(|f| selected.contains(&f.component)).cloned().collect();
if feats.len() + source.features.len() > MAX_WORK {
return Err(bad(CMD, "too many features to copy"));
}
for f in &feats {
if s.model.result(f.id).is_some_and(|r| r.error.is_some()) {
return Err(bad(CMD, format!("source feature {} has an evaluation error", f.name)));
}
}
let state = s.model.state();
if state.bodies.len() > MAX_WORK {
return Err(bad(CMD, "body set exceeds copy work limit"));
}
if source.next_id.checked_add(100_000).is_none() {
return Err(bad(CMD, "copy id allocation exceeds supported range"));
}
let mut scratch = s.scratch();
let mut components = BTreeMap::new();
let mut occurrences = BTreeMap::new();
let mut features = BTreeMap::new();
let mut names = BTreeMap::new();
let mut bodies = BTreeMap::new();
let mut parameters = BTreeMap::new();
let mut used: BTreeSet<String> = source
.features
.iter()
.map(|f| f.name.clone())
.chain(source.components.iter().map(|c| c.name.clone()))
.chain(state.bodies.iter().map(|b| b.name.clone()))
.collect();
let mut new_ids = Vec::new();
let mut frozen = Vec::new();
// Allocate definitions, discarding their automatic placeholder occurrence except root.
for old in &definitions {
let c = definition_records.get(old).ok_or_else(|| bad(CMD, "missing definition"))?;
let mapped_parent = if *old == component {
parent
} else {
components.get(&c.parent).copied().unwrap_or(*components.get(&component).ok_or_else(|| bad(CMD, "missing clone root"))?)
};
let name = unique(&c.name, &mut used);
let new = scratch.doc_mut().add_component(Some(&name), mapped_parent)?;
components.insert(*old, new);
if *old != component {
scratch.doc_mut().occurrences.retain(|o| o.component != new);
}
}
let root = *components.get(&component).ok_or_else(|| bad(CMD, "missing clone root"))?;
let root_occurrence = scratch.doc.occurrence_of(root).map(|o| o.id).ok_or_else(|| bad(CMD, "missing cloned placement"))?;
let mut clone_path = if let Some(o) = &source_occurrence {
let mut path = super::component::occurrence_path(s, params, o.id, CMD)?;
path.pop();
path
} else {
source.first_path(parent)
};
clone_path.push(root_occurrence);
if let Some(o) = scratch.doc_mut().occurrences.iter_mut().find(|o| o.id == root_occurrence) {
o.transform = transform;
o.grounded = source_occurrence.as_ref().is_some_and(|o| o.grounded);
}
if let Some(o) = &source_occurrence {
occurrences.insert(o.id, root_occurrence);
}
for old in source.occurrences.iter().filter(|o| selected.contains(&o.parent)) {
let child = *components.get(&old.component).ok_or_else(|| bad(CMD, "missing child definition"))?;
let owner = *components.get(&old.parent).ok_or_else(|| bad(CMD, "missing parent definition"))?;
let pose = s.pending_moves.get(&old.id).copied().unwrap_or(old.transform);
if !super::component::valid_position(&pose) {
return Err(bad(CMD, "invalid internal pending occurrence transform"));
}
let id = scratch.doc_mut().add_occurrence(child, owner, pose)?;
if let Some(o) = scratch.doc_mut().occurrences.iter_mut().find(|o| o.id == id) {
o.grounded = old.grounded;
}
occurrences.insert(old.id, id);
}
// Collect historical outputs too: a later Combine may have consumed a named tool body.
let mut outputs: BTreeMap<u64, Vec<String>> = BTreeMap::new();
for f in &feats {
if let Some(result) = s.model.result(f.id) {
let created: Vec<_> = result.state.bodies.iter().filter(|b| b.feature == f.id).map(|b| b.name.clone()).collect();
for name in &created {
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used));
}
outputs.insert(f.id, created);
}
for name in &f.body_names {
if !name.is_empty() {
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used));
}
}
if bodies.len() > MAX_WORK {
return Err(bad(CMD, "historical body set exceeds copy work limit"));
}
}
// Body assignment may put externally authored geometry into this graph. Freeze it explicitly.
let owned_features: BTreeSet<_> = feats.iter().map(|f| f.id).collect();
for body in state.bodies.iter().filter(|b| selected.contains(&source.body_component(&b.name, b.feature)) && !owned_features.contains(&b.feature))
{
if body.body.is_mesh() {
return Err(bad(CMD, "Paste New of a reassigned mesh body is not supported yet"));
}
let comp = source.body_component(&body.name, body.feature);
let offset = source.body_offsets.get(&body.name).copied().unwrap_or(solvecraft_doc::IDENTITY);
let before = feats.iter().find_map(|f| {
let before = s.model.state_before(f.id);
let prior = before.body(&body.name)?;
let after = s.model.result(f.id)?.state.body(&body.name);
if after.is_none_or(|after| !prior.body.same(&after.body)) { Some(before) } else { None }
});
let base = before.as_ref().and_then(|st| st.body(&body.name)).unwrap_or(body);
let geometry = solvecraft_kernel::transform_matrix(&base.body, offset)?;
let step = solvecraft_kernel::step_export(&[&geometry], "SolveCraft independent reassigned body")?;
let new_name = unique(&body.name, &mut used);
bodies.insert(body.name.clone(), new_name.clone());
let feature_name = unique(&format!("{} Base", body.name), &mut used);
let id = scratch.doc_mut().add_feature(
FeatureKind::Import { file: format!("{} independent base", body.name), step, components: Vec::new() },
Some(&feature_name),
)?;
if let Some(f) = scratch.doc_mut().feature_mut(id) {
f.component = *components.get(&comp).ok_or_else(|| bad(CMD, "missing assigned component"))?;
f.body_names = vec![new_name];
}
new_ids.push(id);
frozen.push(body.name.clone());
}
// Allocate all history IDs before rewriting any forward reference.
for f in &feats {
let name = unique(&f.name, &mut used);
let id = scratch.doc_mut().add_feature(f.kind.clone(), Some(&name))?;
if let Some(new) = scratch.doc.feature(id) {
for (old, pn) in f.param_names.iter().zip(&new.param_names) {
parameters.insert(old.clone(), pn.clone());
}
}
features.insert(f.id, id);
names.insert(f.name.clone(), name);
new_ids.push(id);
}
// Constraint dimensions own independent generated parameters; public user parameters stay shared.
for f in &feats {
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
for c in &sketch.constraints {
if let Some(name) = &c.param
&& !parameters.contains_key(name)
&& let Some(p) = source_parameters.get(name.as_str())
&& p.model
{
if scratch.doc.params.len() >= solvecraft_doc::MAX_PARAMS {
return Err(bad(CMD, "too many copied parameters"));
}
let new = scratch.doc_mut().new_model_param(&p.expr, &p.unit);
parameters.insert(name.clone(), new);
}
}
}
}
for (old, new) in &parameters {
if let Some(comment) = source.param_comments.get(old) {
scratch.doc_mut().param_comments.insert(new.clone(), comment.clone());
}
if let Some(p) = source_parameters.get(old.as_str())
&& p.model
{
let expr = solvecraft_doc::clone_expression(&p.expr, &parameters);
if let Some(p) = scratch.doc_mut().params.iter_mut().find(|p| p.name == *new) {
p.expr = expr;
p.comment = source_parameters.get(old.as_str()).map(|p| p.comment.clone()).unwrap_or_default();
}
}
}
for old in &feats {
let mut f = old.clone();
f.id = *features.get(&old.id).ok_or_else(|| bad(CMD, "missing feature mapping"))?;
f.name = names.get(&old.name).cloned().ok_or_else(|| bad(CMD, "missing feature name"))?;
f.component = *components.get(&old.component).ok_or_else(|| bad(CMD, "missing component mapping"))?;
if f.body_names.is_empty() {
f.body_names = outputs.get(&old.id).cloned().unwrap_or_default();
}
f.remap_clone(&features, &names, &bodies, &parameters)?;
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
&& !face.path.is_empty()
&& let FeatureKind::Emboss { face: Some(old_face), .. } = &old.kind
&& bodies.contains_key(&old_face.body)
{
face.path = cloned_path(&source, &old_face.path, component, &clone_path, &occurrences)?;
}
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
&& !face.author_path.is_empty()
{
face.author_path = cloned_path(&source, &face.author_path, component, &clone_path, &occurrences)?;
}
if let Some(slot) = scratch.doc_mut().feature_mut(f.id) {
*slot = f;
}
}
for (old, new) in &bodies {
if let Some(mat) = source.materials.get(old) {
scratch.doc_mut().materials.insert(new.clone(), mat.clone());
}
if let Some(look) = source.appearances.bodies.get(old) {
scratch.doc_mut().appearances.bodies.insert(new.clone(), look.clone());
}
if !frozen.contains(old) {
if let Some(comp) = source.body_components.get(old).and_then(|c| components.get(c)) {
scratch.doc_mut().body_components.insert(new.clone(), *comp);
}
if let Some(offset) = source.body_offsets.get(old) {
scratch.doc_mut().body_offsets.insert(new.clone(), *offset);
}
}
}
for (old, new) in &components {
if let Some(part) = source.parts.get(old) {
scratch.doc_mut().parts.insert(*new, part.clone());
}
if let Some(look) = source.appearances.components.get(old) {
scratch.doc_mut().appearances.components.insert(*new, look.clone());
}
}
// A creator's history can belong here while its final body was reassigned outside.
// Retain the independent history, but reproduce the current ownership at its end.
let moved_out: Vec<_> = state
.bodies
.iter()
.filter(|b| owned_features.contains(&b.feature) && !selected.contains(&source.body_component(&b.name, b.feature)))
.filter_map(|b| bodies.get(&b.name).cloned())
.collect();
if !moved_out.is_empty() {
let name = unique("Assigned-out bodies", &mut used);
let id = scratch.doc_mut().add_feature(FeatureKind::Remove { bodies: moved_out }, Some(&name))?;
if let Some(f) = scratch.doc_mut().feature_mut(id) {
f.component = root;
}
new_ids.push(id);
}
for face in &source.appearances.faces {
if let Some(new) = bodies.get(&face.body) {
let mut face = face.clone();
if frozen.contains(&face.body) {
if let Some(name) = &face.name {
face.point = solvecraft_doc::naming::point_on_face(&state, name, face.point)
.map(|(point, _)| point)
.ok_or_else(|| bad(CMD, "source frozen face appearance lost its identity"))?;
}
face.point = solvecraft_doc::apply_point(source.body_offsets.get(&face.body).unwrap_or(&solvecraft_doc::IDENTITY), face.point);
face.name = None;
} else if let Some(name) = &mut face.name {
*name = solvecraft_doc::remap_topology(name, &features, &bodies);
}
face.body = new.clone();
scratch.doc_mut().appearances.faces.push(face);
}
}
// Typed configuration and assembly helpers preserve original targets.
scratch.doc_mut().configs.clone_targets(&solvecraft_doc::config::ConfigCloneMaps {
features: features.clone(),
components: components.clone(),
bodies: bodies.clone(),
parameters: parameters.clone(),
})?;
let mut notices = super::component_clone_assembly::clone_assembly(scratch.doc_mut(), &source, &occurrences, &features, &bodies)?;
let mut reference_work = 0usize;
for feature in &feats {
if let FeatureKind::Emboss { face: Some(face), .. } = &feature.kind
&& !face.path.is_empty()
&& !bodies.contains_key(&face.body)
{
notices.push(format!("{} retains external selected occurrence path {:?}", feature.name, face.path));
}
if let FeatureKind::Sketch { sketch, .. } = &feature.kind {
use solvecraft_sketch::LinkSource as L;
let mut pending: Vec<_> = sketch.links.iter().map(|l| &l.source).collect();
let mut work = 0;
while let Some(link) = pending.pop() {
work += 1;
reference_work += 1;
if work > MAX_WORK || reference_work > 100_000 {
return Err(bad(CMD, "linked references exceed copy work limit"));
}
match link {
L::Edge { body, .. }
| L::Face { body, .. }
| L::Body { body }
| L::Vertex { body, .. }
| L::Iso { body, .. }
| L::Spun { body, .. } => {
if !bodies.contains_key(body) {
notices.push(format!("{} retains external linked body {body}", feature.name));
}
}
L::SketchCurve { sketch, .. } | L::SketchPoint { sketch, .. } => {
if !features.contains_key(sketch) {
notices.push(format!("{} retains external linked sketch {sketch}", feature.name));
}
}
L::OnSurface { sketch, body, .. } => {
if !features.contains_key(sketch) || !bodies.contains_key(body) {
notices.push(format!("{} retains external surface link", feature.name));
}
}
L::Intersection { a, b } => {
pending.push(a);
pending.push(b);
}
L::Plane { name } if source.find_feature(name).is_some() && !names.contains_key(name) => {
notices.push(format!("{} retains external linked plane {name}", feature.name));
}
_ => {}
}
}
}
for id in feature.kind.input_sketches() {
if !features.contains_key(&id) {
notices.push(format!("{} retains an external sketch reference to {id}", feature.name));
}
}
if let FeatureKind::Pattern { features: references, .. } | FeatureKind::Mirror { features: references, .. } = &feature.kind {
for name in references {
if !names.contains_key(name) {
notices.push(format!("{} retains external feature reference {name}", feature.name));
}
}
}
for expression in feature.kind.expressions() {
for name in solvecraft_doc::expr::references(expression) {
reference_work += 1;
if reference_work > 100_000 {
return Err(bad(CMD, "expression references exceed copy work limit"));
}
if !parameters.contains_key(&name) && generated_parameters.contains(name.as_str()) {
notices.push(format!("{} retains external model parameter {name}", feature.name));
}
}
}
}
notices.sort();
notices.dedup();
scratch.refresh();
let original_errors: BTreeMap<_, _> = s.model.results.iter().map(|result| (result.id, result.error.as_ref())).collect();
let refreshed_errors: BTreeMap<_, _> = scratch.model.results.iter().map(|result| (result.id, result.error.as_ref())).collect();
if source.features.iter().any(|feature| original_errors.get(&feature.id) != refreshed_errors.get(&feature.id)) {
return Err(bad(CMD, "copy would change an original feature's error state"));
}
// Base imports acquire new face identities. Resolve copied face overrides against the
// verified new geometry once, rather than weakening strict persistent-name semantics.
let resolved = scratch.model.state();
let mut face_updates = Vec::new();
for (index, face) in scratch.doc.appearances.faces.iter().enumerate() {
if bodies.iter().any(|(old, new)| frozen.contains(old) && *new == face.body) {
let body = resolved.body(&face.body).ok_or_else(|| bad(CMD, "missing frozen face body"))?;
let face_index =
solvecraft_doc::appearance::face_index_at(body, face.point).ok_or_else(|| bad(CMD, "cannot reproduce frozen face appearance"))?;
let name = solvecraft_doc::naming::face_names(body).get(face_index).cloned().ok_or_else(|| bad(CMD, "missing frozen face identity"))?;
face_updates.push((index, name));
}
}
for (index, name) in face_updates {
if let Some(face) = scratch.doc_mut().appearances.faces.get_mut(index) {
face.name = Some(name);
}
}
for id in &new_ids {
if let Some(error) = scratch.model.result(*id).and_then(|r| r.error.as_ref()) {
return Err(bad(CMD, format!("copied feature failed: {error}")));
}
}
let copied = scratch.model.state();
let copied_bodies: BTreeMap<_, _> = copied.bodies.iter().map(|body| (body.name.as_str(), body)).collect();
// Adding placements can invalidate cached source evaluation. Compare clone effects
// against that same evaluation's source prefix, not older shared B-rep pointers.
if scratch.doc.features.get(..source.features.len()) != Some(source.features.as_slice())
|| scratch.doc.params.get(..source.params.len()) != Some(source.params.as_slice())
{
return Err(bad(CMD, "copy would change original feature history or parameters"));
}
let original_state = new_ids.first().map(|id| scratch.model.state_before(*id)).unwrap_or_else(|| copied.clone());
let all_columns = std::mem::take(&mut scratch.doc_mut().configs.columns);
scratch.doc_mut().configs.columns = all_columns.iter().skip(source.configs.columns.len()).cloned().collect();
let style_validation = scratch.doc.configuration_style_status(&copied);
scratch.doc_mut().configs.columns = all_columns;
style_validation?;
let mut proof_work = 0usize;
let expected_count = state.bodies.iter().filter(|b| selected.contains(&source.body_component(&b.name, b.feature))).count();
let copied_components: BTreeSet<_> = components.values().copied().collect();
let actual_count = copied.bodies.iter().filter(|b| copied_components.contains(&scratch.doc.body_component(&b.name, b.feature))).count();
if actual_count != expected_count {
return Err(bad(CMD, "copy produced an incomplete or unexpected set of bodies"));
}
for original in &original_state.bodies {
let Some(after) = copied_bodies.get(original.name.as_str()) else {
return Err(bad(CMD, "copy would remove a source body outside its independent history"));
};
if !original.body.same(&after.body) {
return Err(bad(CMD, "copy would modify a source body; external destructive dependencies are not supported"));
}
}
for body in state.bodies.iter().filter(|b| selected.contains(&source.body_component(&b.name, b.feature))) {
let new = bodies.get(&body.name).ok_or_else(|| bad(CMD, format!("body {} has no clone identity", body.name)))?;
let cloned = copied_bodies.get(new.as_str()).ok_or_else(|| bad(CMD, format!("copy did not reproduce body {}", body.name)))?;
let a = solvecraft_kernel::transform_matrix(&body.body, *source.body_offsets.get(&body.name).unwrap_or(&solvecraft_doc::IDENTITY))?;
let b = solvecraft_kernel::transform_matrix(&cloned.body, *scratch.doc.body_offsets.get(new).unwrap_or(&solvecraft_doc::IDENTITY))?;
let ma = solvecraft_kernel::measure(&a)?;
let mb = solvecraft_kernel::measure(&b)?;
let tolerance = 1e-5 * (1.0 + (ma.bbox.max - ma.bbox.min).len()) + 64.0 * f64::EPSILON * (ma.bbox.min.len() + ma.bbox.max.len());
let mut material_matches = true;
let mesh_a = a.display_mesh((a.size() * 1e-3).max(1e-3))?;
let mesh_b = b.display_mesh((b.size() * 1e-3).max(1e-3))?;
proof_work = proof_work.saturating_add(mesh_a.triangles.len().saturating_add(mesh_b.triangles.len()).saturating_mul(27));
if proof_work > 50_000_000 {
return Err(bad(CMD, "geometry verification exceeds copy work limit"));
}
for x in [0.2, 0.5, 0.8] {
for y in [0.2, 0.5, 0.8] {
for z in [0.2, 0.5, 0.8] {
let d = ma.bbox.max - ma.bbox.min;
let p = ma.bbox.min + solvecraft_geom::Vec3::new(d.x * x, d.y * y, d.z * z);
material_matches &= mesh_a.contains(p) == mesh_b.contains(p);
}
}
}
if (ma.volume - mb.volume).abs() > 1e-5 * (1.0 + ma.volume.abs())
|| (ma.area - mb.area).abs() > 1e-5 * (1.0 + ma.area.abs())
|| ma.centroid.dist(mb.centroid) > tolerance
|| ma.bbox.min.dist(mb.bbox.min) > tolerance
|| ma.bbox.max.dist(mb.bbox.max) > tolerance
|| !material_matches
{
return Err(bad(CMD, format!("copy geometry differs for body {}", body.name)));
}
}
scratch.selection = vec![Sel::Occurrence { path: clone_path }];
s.accept_computed(scratch);
Ok(
json!({"component":root,"occurrence":root_occurrence,"features":new_ids,"components":components.len(),"bodies":expected_count,"frozen_bodies":frozen,"notices":notices}),
)
}
#[cfg(test)]
#[path = "component_clone_tests.rs"]
mod tests;
@@ -0,0 +1,360 @@
//! Typed remapping of assembly relationships for an independent component-tree copy.
use std::collections::{BTreeMap, BTreeSet};
use solvecraft_doc::Document;
use solvecraft_doc::joints::{Assembly, JointOrigin, Snap};
use crate::{EngineError, Result};
const MAX_RECORDS: usize = 10_000;
const MAX_REFERENCES: usize = 100_000;
const MAX_TEXT: usize = 1_048_576;
const MAX_NAME: usize = 8192;
fn invalid(message: &str) -> EngineError {
EngineError::Other(format!("Paste New assembly: {message}"))
}
fn add_references(count: &mut usize, more: usize) -> Result<()> {
*count = count.saturating_add(more);
if *count > MAX_REFERENCES {
return Err(invalid("assembly references exceed the processing budget"));
}
Ok(())
}
fn finite_origin(origin: &JointOrigin) -> Result<()> {
let finite = match &origin.snap {
Snap::Frame { origin, z, x } => origin.is_finite() && z.is_finite() && x.is_none_or(|x| x.is_finite()),
Snap::FaceCenter { pick, .. } | Snap::CircleCenter { pick, .. } => pick.is_finite(),
Snap::Point { point, z } => point.is_finite() && z.is_none_or(|z| z.is_finite()),
};
if !finite {
return Err(invalid("joint origin contains non-finite geometry"));
}
Ok(())
}
fn budget(assembly: &Assembly) -> Result<()> {
let counts = [
assembly.origins.len(),
assembly.joints.len(),
assembly.links.len(),
assembly.contact_sets.len(),
assembly.studies.len(),
assembly.exploded.len(),
];
if counts.iter().any(|n| *n > MAX_RECORDS) {
return Err(invalid("too many assembly records"));
}
let mut references = counts.iter().fold(0usize, |n, c| n.saturating_add(*c));
let mut bytes = 0usize;
let mut text = |s: &str| -> Result<()> {
bytes = bytes.saturating_add(s.len());
if s.len() > MAX_NAME || bytes > MAX_TEXT {
return Err(invalid("assembly names exceed the processing budget"));
}
Ok(())
};
for origin in &assembly.origins {
finite_origin(&origin.origin)?;
text(&origin.name)?;
if let Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } = &origin.origin.snap {
text(face)?;
}
}
for joint in &assembly.joints {
text(&joint.name)?;
add_references(&mut references, joint.values.len().saturating_add(joint.limits.len()))?;
if !joint.values.iter().all(|v| v.is_finite())
|| !joint.limits.iter().flatten().all(|(lo, hi)| lo.is_finite() && hi.is_finite() && lo <= hi)
|| !joint.offset.is_finite()
|| !joint.angle.is_finite()
{
return Err(invalid("joint contains invalid motion values"));
}
for origin in [&joint.a, &joint.b] {
finite_origin(origin)?;
if let Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } = &origin.snap {
text(face)?;
}
}
}
for contact in &assembly.contact_sets {
text(&contact.name)?;
add_references(&mut references, contact.occurrences.len())?;
}
for study in &assembly.studies {
text(&study.name)?;
add_references(&mut references, study.keys.len())?;
for key in &study.keys {
add_references(&mut references, key.points.len())?;
if !key.points.iter().all(|(step, value)| step.is_finite() && value.is_finite()) {
return Err(invalid("motion study contains non-finite keys"));
}
}
}
for view in &assembly.exploded {
text(&view.name)?;
add_references(&mut references, view.moves.len())?;
if !view.moves.iter().all(|(_, p)| p.is_finite()) {
return Err(invalid("exploded view contains non-finite moves"));
}
}
if !assembly.links.iter().all(|l| l.ratio.is_finite() && l.offset.is_finite()) {
return Err(invalid("motion link contains non-finite values"));
}
if references > MAX_REFERENCES {
return Err(invalid("assembly references exceed the processing budget"));
}
Ok(())
}
fn unique_name(base: &str, used: &mut BTreeSet<String>) -> Result<String> {
if !used.contains(base) {
used.insert(base.to_string());
return Ok(base.to_string());
}
for index in 1..=MAX_RECORDS {
let name = if index == 1 { format!("{base} (copy)") } else { format!("{base} (copy {index})") };
if name.len() > MAX_NAME {
return Err(invalid("copied assembly name is too long"));
}
if used.insert(name.clone()) {
return Ok(name);
}
}
Err(invalid("could not allocate a unique assembly name"))
}
fn next_id(id: &mut u64) -> Result<u64> {
*id = id.checked_add(1).ok_or_else(|| invalid("assembly identifier space is exhausted"))?;
Ok(*id)
}
fn unique_ids(ids: impl Iterator<Item = u64>) -> Result<u64> {
let mut seen = BTreeSet::new();
let mut largest = 0;
for id in ids {
if id == 0 || !seen.insert(id) {
return Err(invalid("assembly identifiers must be nonzero and unique"));
}
largest = largest.max(id);
}
Ok(largest)
}
fn validate_map<K: Ord, V: Ord>(map: &BTreeMap<K, V>) -> Result<()> {
if map.len() > MAX_RECORDS || map.values().collect::<BTreeSet<_>>().len() != map.len() {
return Err(invalid("copy maps exceed their budget or contain duplicate destinations"));
}
Ok(())
}
/// Remap certified naming tokens, rather than replacing digit substrings (F1 must not
/// alter F10). Body-based legacy names are remapped only through an explicit identity map.
fn face_name(name: &str, features: &BTreeMap<u64, u64>, bodies: &BTreeMap<String, String>) -> Option<String> {
let body = name.match_indices(':').filter_map(|(end, _)| bodies.get_key_value(name.get(..end)?)).next_back();
let mut out = String::new();
let mut last = 0usize;
let mut mapped = false;
if let Some((old, new)) = body {
out.push_str(new);
last = old.len();
mapped = true;
}
let bytes = name.as_bytes();
let mut i = last;
while i < bytes.len() {
let boundary = i == 0
|| bytes.get(i.wrapping_sub(1)).is_some_and(|c| matches!(*c, b'@' | b'|'))
|| name.get(..i).is_some_and(|prefix| prefix.ends_with(":blend:") || prefix.ends_with(":inner:"));
if bytes.get(i) == Some(&b'F') && boundary {
let mut end = i + 1;
while bytes.get(end).is_some_and(u8::is_ascii_digit) {
end += 1;
}
if end > i + 1 && bytes.get(end).is_some_and(|c| matches!(*c, b':' | b'.')) {
let id: u64 = name.get(i + 1..end)?.parse().ok()?;
let replacement = features.get(&id)?;
out.push_str(name.get(last..i)?);
out.push_str(&format!("F{replacement}"));
last = end;
mapped |= i == 0;
i = end;
continue;
}
}
i += 1;
}
if !mapped {
return None;
}
out.push_str(name.get(last..)?);
Some(out)
}
fn origin(
origin: &JointOrigin,
occurrences: &BTreeMap<u64, u64>,
features: &BTreeMap<u64, u64>,
bodies: &BTreeMap<String, String>,
) -> Option<JointOrigin> {
let mut out = origin.clone();
out.occurrence = *occurrences.get(&origin.occurrence)?;
match &mut out.snap {
Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } => *face = face_name(face, features, bodies)?,
_ => {}
}
Some(out)
}
fn report(messages: &mut Vec<String>, kind: &str, name: &str) {
messages.push(format!("Skipped {kind} `{name}`: references outside the copied component tree or geometry without a copied identity."));
}
/// Called with a scratch document after geometry cloning. Only wholly internal records
/// are copied; cross-boundary relationships are reported and omitted without truncation.
/// The assembly is swapped in only after every map, allocation and aggregate check passes.
pub(super) fn clone_assembly(
doc: &mut Document,
source: &Document,
occurrences: &BTreeMap<u64, u64>,
features: &BTreeMap<u64, u64>,
bodies: &BTreeMap<String, String>,
) -> Result<Vec<String>> {
budget(&source.assembly)?;
budget(&doc.assembly)?;
validate_map(occurrences)?;
validate_map(features)?;
validate_map(bodies)?;
if doc.occurrences.len() > MAX_REFERENCES {
return Err(invalid("too many destination occurrences"));
}
let mut map_bytes = 0usize;
for (old, new) in bodies {
map_bytes = map_bytes.saturating_add(old.len()).saturating_add(new.len());
if old.is_empty() || new.is_empty() || old.len() > MAX_NAME || new.len() > MAX_NAME || map_bytes > MAX_TEXT {
return Err(invalid("invalid or excessive body identity map"));
}
}
let available: BTreeSet<u64> = doc.occurrences.iter().map(|o| o.id).chain(std::iter::once(0)).collect();
if occurrences.values().any(|id| !available.contains(id)) {
return Err(invalid("copy occurrence map points to a missing occurrence"));
}
unique_ids(source.assembly.joints.iter().map(|j| j.id))?;
let mut joint_id = unique_ids(doc.assembly.joints.iter().map(|j| j.id))?;
let aux = |a: &Assembly| {
a.contact_sets.iter().map(|x| x.id).chain(a.studies.iter().map(|x| x.id)).chain(a.exploded.iter().map(|x| x.id)).collect::<Vec<_>>()
};
unique_ids(aux(&source.assembly).into_iter())?;
let mut auxiliary_id = unique_ids(aux(&doc.assembly).into_iter())?;
let mut a = doc.assembly.clone();
let mut messages = Vec::new();
let mut origin_names = a.origins.iter().map(|o| o.name.clone()).collect();
for old in &source.assembly.origins {
if !occurrences.contains_key(&old.origin.occurrence) {
continue;
}
if let Some(new) = origin(&old.origin, occurrences, features, bodies) {
let mut item = old.clone();
item.name = unique_name(&old.name, &mut origin_names)?;
item.origin = new;
a.origins.push(item);
} else {
report(&mut messages, "joint origin", &old.name);
}
}
let mut joint_names = a.joints.iter().map(|j| j.name.clone()).collect();
let mut joints = BTreeMap::new();
let mut touched_joints = BTreeSet::new();
for old in &source.assembly.joints {
if !occurrences.contains_key(&old.a.occurrence) && !occurrences.contains_key(&old.b.occurrence) {
continue;
}
touched_joints.insert(old.id);
match (origin(&old.a, occurrences, features, bodies), origin(&old.b, occurrences, features, bodies)) {
(Some(aa), Some(bb)) => {
let mut item = old.clone();
item.id = next_id(&mut joint_id)?;
item.name = unique_name(&old.name, &mut joint_names)?;
item.a = aa;
item.b = bb;
joints.insert(old.id, item.id);
a.joints.push(item);
}
_ => report(&mut messages, "joint", &old.name),
}
}
for old in &source.assembly.links {
if !touched_joints.contains(&old.a) && !touched_joints.contains(&old.b) {
continue;
}
match (joints.get(&old.a), joints.get(&old.b)) {
(Some(aa), Some(bb)) => {
let mut item = old.clone();
item.a = *aa;
item.b = *bb;
a.links.push(item);
}
_ => report(&mut messages, "motion link", &format!("{} → {}", old.a, old.b)),
}
}
let mut contact_names = a.contact_sets.iter().map(|x| x.name.clone()).collect();
for old in &source.assembly.contact_sets {
if !old.occurrences.iter().any(|o| occurrences.contains_key(o)) {
continue;
}
if let Some(ids) = old.occurrences.iter().map(|o| occurrences.get(o).copied()).collect::<Option<Vec<_>>>() {
let mut item = old.clone();
item.id = next_id(&mut auxiliary_id)?;
item.name = unique_name(&old.name, &mut contact_names)?;
item.occurrences = ids;
a.contact_sets.push(item);
} else {
report(&mut messages, "contact set", &old.name);
}
}
let mut study_names = a.studies.iter().map(|x| x.name.clone()).collect();
for old in &source.assembly.studies {
if !old.keys.iter().any(|k| touched_joints.contains(&k.joint)) {
continue;
}
if old.keys.iter().all(|k| joints.contains_key(&k.joint)) {
let mut item = old.clone();
item.id = next_id(&mut auxiliary_id)?;
item.name = unique_name(&old.name, &mut study_names)?;
for key in &mut item.keys {
key.joint = *joints.get(&key.joint).ok_or_else(|| invalid("missing copied study joint"))?;
}
a.studies.push(item);
} else {
report(&mut messages, "motion study", &old.name);
}
}
let mut view_names = a.exploded.iter().map(|x| x.name.clone()).collect();
for old in &source.assembly.exploded {
if !old.moves.iter().any(|(o, _)| occurrences.contains_key(o)) {
continue;
}
if old.moves.iter().all(|(o, _)| occurrences.contains_key(o)) {
let mut item = old.clone();
item.id = next_id(&mut auxiliary_id)?;
item.name = unique_name(&old.name, &mut view_names)?;
for (o, _) in &mut item.moves {
*o = *occurrences.get(o).ok_or_else(|| invalid("missing copied exploded occurrence"))?;
}
a.exploded.push(item);
} else {
report(&mut messages, "exploded view", &old.name);
}
}
budget(&a)?;
doc.assembly = a;
Ok(messages)
}
#[cfg(test)]
#[path = "component_clone_assembly_tests.rs"]
mod tests;
@@ -0,0 +1,193 @@
#![allow(clippy::unwrap_used, clippy::panic)]
use super::*;
use solvecraft_doc::joints::{Joint, JointKind, MotionLink, NamedOrigin};
use solvecraft_doc::motion::{ContactSet, ExplodedView, MotionStudy, StudyKey};
use solvecraft_doc::{IDENTITY, Occurrence};
use solvecraft_geom::Vec3;
fn at(occurrence: u64) -> JointOrigin {
JointOrigin { occurrence, snap: Snap::Point { point: Vec3::ZERO, z: None } }
}
fn joint(id: u64, a: u64, b: u64) -> Joint {
Joint {
id,
name: format!("Joint{id}"),
kind: JointKind::Revolute,
a: at(a),
b: at(b),
values: vec![0.25],
limits: vec![Some((-1.0, 1.0))],
flip: true,
offset: 2.0,
angle: 0.1,
suppressed: false,
}
}
fn occurrence(id: u64) -> Occurrence {
Occurrence { id, component: id, parent: 0, name: format!("Occurrence{id}"), transform: IDENTITY, grounded: false }
}
fn documents() -> (Document, Document) {
let mut source = Document { occurrences: [1, 2, 8, 9].map(occurrence).to_vec(), ..Document::default() };
source.assembly.joints = vec![joint(1, 1, 2), joint(2, 1, 2), joint(3, 2, 9), joint(4, 8, 9)];
source.assembly.origins = vec![
NamedOrigin {
name: "Typed origin".into(),
origin: JointOrigin { occurrence: 1, snap: Snap::FaceCenter { pick: Vec3::new(1.0, 2.0, 3.0), face: Some("F1:start@F10.2".into()) } },
},
NamedOrigin {
name: "Foreign geometry".into(),
origin: JointOrigin { occurrence: 1, snap: Snap::CircleCenter { pick: Vec3::ZERO, face: Some("F99:end".into()) } },
},
NamedOrigin { name: "Outside".into(), origin: at(9) },
];
source.assembly.links =
vec![MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.3 }, MotionLink { a: 1, ia: 0, b: 3, ib: 0, ratio: 1.0, offset: 0.0 }];
source.assembly.contact_sets = vec![
ContactSet { id: 1, name: "Internal contact".into(), occurrences: vec![1, 2], suppressed: true },
ContactSet { id: 2, name: "Cross contact".into(), occurrences: vec![1, 9], suppressed: false },
];
let key = |joint| StudyKey { joint, index: 0, points: vec![(0.0, 0.0), (10.0, 0.5)] };
source.assembly.studies = vec![
MotionStudy { id: 3, name: "Internal study".into(), steps: 10, keys: vec![key(1), key(2)] },
MotionStudy { id: 4, name: "Cross study".into(), steps: 10, keys: vec![key(1), key(3)] },
];
source.assembly.exploded = vec![
ExplodedView { id: 5, name: "Internal explode".into(), moves: vec![(1, Vec3::X * 10.0), (2, Vec3::Y * 20.0)] },
ExplodedView { id: 6, name: "Cross explode".into(), moves: vec![(1, Vec3::X), (9, Vec3::Y)] },
];
let mut destination = source.clone();
destination.occurrences.extend([101, 102].map(occurrence));
(source, destination)
}
#[test]
fn internal_relationships_receive_independent_ids_and_remapped_typed_references() {
let (source, mut destination) = documents();
let report =
clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 101), (2, 102)]), &BTreeMap::from([(1, 11), (10, 110)]), &BTreeMap::new())
.unwrap();
let a = &destination.assembly;
assert_eq!(a.joints.len(), 6);
assert_eq!((a.joints[4].id, a.joints[5].id), (5, 6));
assert_eq!((a.joints[4].a.occurrence, a.joints[4].b.occurrence), (101, 102));
assert_eq!(a.joints[4].values, source.assembly.joints[0].values);
assert_eq!(a.joints[4].limits, source.assembly.joints[0].limits);
assert_eq!(a.joints[4].name, "Joint1 (copy)");
assert_eq!(a.origins.len(), 4);
assert!(matches!(&a.origins[3].origin.snap, Snap::FaceCenter { face: Some(face), .. } if face == "F11:start@F110.2"));
assert_eq!((a.links[2].a, a.links[2].b, a.links[2].ratio), (5, 6, -2.0));
assert_eq!(a.contact_sets[2].id, 7);
assert_eq!(a.contact_sets[2].occurrences, vec![101, 102]);
assert!(a.contact_sets[2].suppressed);
assert_eq!(a.studies[2].id, 8);
assert_eq!(a.studies[2].keys.iter().map(|k| k.joint).collect::<Vec<_>>(), vec![5, 6]);
assert_eq!(a.exploded[2].id, 9);
assert_eq!(a.exploded[2].moves, vec![(101, Vec3::X * 10.0), (102, Vec3::Y * 20.0)]);
assert_eq!(report.len(), 6);
for kind in ["joint origin", "joint `Joint3`", "motion link", "contact set", "motion study", "exploded view"] {
assert!(report.iter().any(|m| m.contains(kind)), "{kind}: {report:?}");
}
assert!(!report.iter().any(|m| m.contains("Outside") || m.contains("Joint4")));
assert_eq!(destination.assembly.joints[..4], source.assembly.joints);
destination.assembly.joints[4].values[0] = 0.75;
assert_eq!(source.assembly.joints[0].values[0], 0.25);
}
#[test]
fn naming_remaps_exact_tokens_unicode_and_legacy_body_identity() {
let features = BTreeMap::from([(1, 21), (10, 30)]);
let bodies = BTreeMap::from([("Wheel: inner".into(), "Wheel copy: inner".into())]);
assert_eq!(face_name("F1:blend:F10:start@F1.3", &features, &bodies).as_deref(), Some("F21:blend:F30:start@F21.3"));
assert_eq!(face_name("F1:side:曲線F10", &features, &bodies).as_deref(), Some("F21:side:曲線F10"));
assert_eq!(face_name("F1:side:F100.7", &features, &bodies).as_deref(), Some("F21:side:F100.7"));
assert_eq!(face_name("Wheel: inner:face:7", &features, &bodies).as_deref(), Some("Wheel copy: inner:face:7"));
assert!(face_name("F100:start", &features, &bodies).is_none());
assert!(face_name("F1:start@F99.1", &features, &bodies).is_none());
assert!(face_name("Foreign:face:0", &features, &bodies).is_none());
assert!(face_name("Foreign:face:0@F1.1", &features, &bodies).is_none());
let bodies = BTreeMap::from([("F1:Housing".into(), "F1:Housing (copy)".into())]);
assert_eq!(face_name("F1:Housing:face:0@F10.1", &features, &bodies).as_deref(), Some("F1:Housing (copy):face:0@F30.1"));
}
#[test]
fn malformed_maps_identifier_exhaustion_and_aggregate_budgets_are_atomic() {
let (source, destination) = documents();
let mut cases = Vec::new();
cases.push((source.clone(), destination.clone(), BTreeMap::from([(1, 101), (2, 101)])));
cases.push((source.clone(), destination.clone(), BTreeMap::from([(1, 999)])));
let mut overflow = destination.clone();
overflow.assembly.joints[0].id = u64::MAX;
cases.push((source.clone(), overflow, BTreeMap::from([(1, 101), (2, 102)])));
let mut oversized = source.clone();
oversized.assembly.contact_sets[0].occurrences = vec![1; MAX_REFERENCES + 1];
cases.push((oversized, destination.clone(), BTreeMap::from([(1, 101), (2, 102)])));
let mut invalid = source.clone();
invalid.assembly.links[0].ratio = f64::NAN;
cases.push((invalid, destination, BTreeMap::from([(1, 101), (2, 102)])));
for (source, mut destination, occurrences) in cases {
let before = destination.assembly.clone();
assert!(clone_assembly(&mut destination, &source, &occurrences, &BTreeMap::from([(1, 11), (10, 110)]), &BTreeMap::new()).is_err());
assert_eq!(destination.assembly, before);
}
}
#[test]
fn second_copy_has_unique_names_and_keeps_global_contact_switches_unchanged() {
let (source, mut destination) = documents();
destination.assembly.contact_enabled = true;
destination.assembly.contact_all = true;
let features = BTreeMap::from([(1, 11), (10, 110)]);
clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 101), (2, 102)]), &features, &BTreeMap::new()).unwrap();
destination.occurrences.extend([201, 202].map(occurrence));
clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 201), (2, 202)]), &features, &BTreeMap::new()).unwrap();
assert_eq!(destination.assembly.joints[6].name, "Joint1 (copy 2)");
assert!(destination.assembly.contact_enabled && destination.assembly.contact_all);
let names: BTreeSet<&str> = destination.assembly.origins.iter().map(|x| x.name.as_str()).collect();
assert_eq!(names.len(), destination.assembly.origins.len());
}
#[test]
fn paste_new_internal_motion_study_drives_only_the_copied_assembly_and_reopens() {
use serde_json::{Value, json};
fn run(s: &mut crate::Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|e| panic!("{command}: {e}"))
}
let mut s = crate::Session::default();
let parent = run(&mut s, "component.create", json!({"name":"Mechanism"}))["component"].as_u64().unwrap();
let fixed = run(&mut s, "component.create", json!({"name":"Support"}))["occurrence"].as_u64().unwrap();
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"SupportBox"}));
run(&mut s, "occurrence.ground", json!({"occurrence":fixed,"grounded":true}));
run(&mut s, "component.activate", json!({"component":parent}));
let moving = run(&mut s, "component.create", json!({"name":"Carriage"}))["occurrence"].as_u64().unwrap();
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[0,0,5],"body_name":"CarriageBox"}));
run(&mut s, "joint.origin", json!({"name":"Support origin","occurrence":fixed,"point":[0,0,0]}));
let source_joint = run(&mut s, "joint.as_built", json!({"type":"slider","a":fixed,"b":moving,"name":"Rail"}))["joint"].as_u64().unwrap();
run(&mut s, "contact.create", json!({"name":"Internal contact","occurrences":[fixed,moving]}));
run(&mut s, "motion.study", json!({"name":"Travel","steps":10,"keys":[{"joint":source_joint,"points":[[0,0],[10,6]]}]}));
run(
&mut s,
"explode.create",
json!({"name":"Exploded mechanism","moves":[{"occurrence":fixed,"translate":[10,0,0]},{"occurrence":moving,"translate":[0,10,0]}]}),
);
let original = s.doc.occurrences.iter().find(|o| o.id == moving).unwrap().transform;
run(&mut s, "component.activate", json!({"component":"root"}));
let result = run(&mut s, "component.paste_new", json!({"component":parent,"translate":[20,0,0]}));
assert_eq!(result["notices"], json!([]));
assert_eq!(s.doc.assembly.joints.len(), 2);
assert_eq!(s.doc.assembly.origins.len(), 2);
assert_eq!(s.doc.assembly.contact_sets.len(), 2);
assert_eq!(s.doc.assembly.exploded.len(), 2);
let copied_joint = s.doc.assembly.joints.iter().find(|j| j.name == "Rail (copy)").unwrap().id;
let copied_moving = s.doc.assembly.joints.iter().find(|j| j.id == copied_joint).unwrap().b.occurrence;
assert_ne!(copied_moving, moving);
run(&mut s, "motion.play", json!({"study":"Travel (copy)","step":10}));
assert_eq!(s.doc.assembly.joints.iter().find(|j| j.id == source_joint).unwrap().values, vec![0.0]);
assert_eq!(s.doc.assembly.joints.iter().find(|j| j.id == copied_joint).unwrap().values, vec![6.0]);
assert_eq!(s.doc.occurrences.iter().find(|o| o.id == moving).unwrap().transform, original);
let reopened = Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(reopened.assembly, s.doc.assembly);
assert!(reopened.assembly.studies.iter().any(|m| m.name == "Travel (copy)" && m.keys[0].joint == copied_joint));
}
@@ -0,0 +1,393 @@
#![allow(clippy::unwrap_used)]
use crate::Session;
use serde_json::{Value, json};
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap()
}
fn component(s: &mut Session, name: &str) -> u64 {
run(s, "component.create", json!({"name":name}))["component"].as_u64().unwrap()
}
fn volume(s: &mut Session, id: u64) -> f64 {
run(s, "component.properties", json!({"component":id}))["total"]["volume_mm3"].as_f64().unwrap()
}
fn box2(s: &mut Session) -> u64 {
run(s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Cube"}))["feature"].as_u64().unwrap()
}
fn copy(s: &mut Session, id: u64) -> Value {
run(s, "component.paste_new", json!({"component":id,"translate":[20,0,0]}))
}
fn near(a: f64, b: f64) {
assert!((a - b).abs() < 1e-6, "{a} != {b}");
}
#[test]
fn coincident_independent_primitives_keep_their_own_semantic_faces() {
let mut s = Session::default();
let first = box2(&mut s);
let second = run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Other"}))["feature"].as_u64().unwrap();
let state = s.model.state();
let a = state.body("Cube").unwrap();
let b = state.body("Other").unwrap();
let a_names = solvecraft_doc::naming::face_names(a);
let b_names = solvecraft_doc::naming::face_names(b);
assert!(a_names.contains(&format!("F{first}:+z")));
assert!(b_names.contains(&format!("F{second}:+z")));
assert!(!b_names.contains(&format!("F{first}:+z")));
}
#[test]
fn nested_definition_dag_and_histories_are_independent() {
let mut s = Session::default();
let parent = component(&mut s, "Assembly");
let child = component(&mut s, "Child");
let feature = box2(&mut s);
run(&mut s, "occurrence.copy", json!({"component":child}));
let copied = copy(&mut s, parent);
let new = copied["component"].as_u64().unwrap();
assert_eq!(copied["components"], 2);
near(volume(&mut s, new), 16.0);
let kids: Vec<_> = s.doc.occurrences.iter().filter(|o| o.parent == new).collect();
assert_eq!(kids.len(), 2);
assert_eq!(kids[0].component, kids[1].component);
assert_ne!(kids[0].component, child);
let parameter = s.doc.feature(feature).unwrap().param_names[0].clone();
run(&mut s, "parameters.change", json!({"name":parameter,"expression":"3 mm"}));
near(volume(&mut s, parent), 24.0);
near(volume(&mut s, new), 16.0);
let saved = s.doc.to_json();
let reopened = solvecraft_doc::Document::from_json(&saved).unwrap();
assert_eq!(reopened.occurrences.iter().filter(|o| o.parent == new).count(), 2);
}
#[test]
fn reassigned_body_is_an_independent_base_feature() {
let mut s = Session::default();
let feature = box2(&mut s);
let part = component(&mut s, "Part");
run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Cube"]}));
let copied = copy(&mut s, part);
let new = copied["component"].as_u64().unwrap();
assert_eq!(copied["frozen_bodies"], json!(["Cube"]));
near(volume(&mut s, new), 8.0);
let parameter = s.doc.feature(feature).unwrap().param_names[0].clone();
run(&mut s, "parameters.change", json!({"name":parameter,"expression":"3 mm"}));
near(volume(&mut s, part), 12.0);
near(volume(&mut s, new), 8.0);
}
#[test]
fn clone_preserves_physical_material_and_independent_styles() {
let mut s = Session::default();
let part = component(&mut s, "Part");
box2(&mut s);
run(&mut s, "material.assign", json!({"bodies":["Cube"],"material":"Brass"}));
run(&mut s, "appearance.assign", json!({"components":[part.to_string()],"color":"#123456","opacity":0.4}));
run(&mut s, "appearance.assign", json!({"bodies":["Cube"],"color":"#abcdef"}));
run(&mut s, "appearance.assign", json!({"body":"Cube","faces":[[1,1,2]],"color":"#00ff00"}));
let copied = copy(&mut s, part);
let new = copied["component"].as_u64().unwrap();
let props = run(&mut s, "component.properties", json!({"component":new}));
near(props["total"]["mass_g"].as_f64().unwrap(), 0.068);
let copied_body = s.model.state().bodies.iter().find(|b| b.component == new).unwrap().name.clone();
assert_eq!(s.doc.appearances.bodies[&copied_body], s.doc.appearances.bodies["Cube"]);
let state = s.model.state();
let body = state.body(&copied_body).unwrap();
assert!(s.doc.face_colors(body).iter().any(|(_, look)| look.color == [0, 255, 0]));
run(&mut s, "appearance.assign", json!({"bodies":[copied_body],"color":"#ff0000"}));
assert_eq!(s.doc.appearances.bodies["Cube"].color, [0xab, 0xcd, 0xef]);
assert_eq!(s.doc.appearances.components[&new].opacity, 0.4);
}
#[test]
fn reassigned_body_owned_move_replays_once_and_original_stays_unchanged() {
let mut s = Session::default();
box2(&mut s);
let part = component(&mut s, "Part");
run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Cube"]}));
run(&mut s, "solid.move", json!({"bodies":["Cube"],"translate":[10,0,0]}));
let before = run(&mut s, "component.properties", json!({"component":part}));
let copied = copy(&mut s, part);
let new = copied["component"].as_u64().unwrap();
let after = run(&mut s, "component.properties", json!({"component":new}));
near(after["total"]["center_of_mass"][0].as_f64().unwrap(), 31.0);
assert_eq!(run(&mut s, "component.properties", json!({"component":part})), before);
}
#[test]
fn copied_external_destructive_target_is_atomic() {
let mut s = Session::default();
box2(&mut s);
let part = component(&mut s, "Part");
run(&mut s, "solid.move", json!({"bodies":["Cube"],"translate":[10,0,0]}));
let saved = s.doc.to_json();
let selection = s.selection.clone();
let active = s.active_component;
let pending = s.pending_moves.clone();
assert!(s.execute("component.paste_new", &json!({"component":part})).is_err());
assert_eq!(s.doc.to_json(), saved);
assert_eq!(s.selection, selection);
assert_eq!(s.active_component, active);
assert_eq!(s.pending_moves, pending);
}
#[test]
fn generated_feature_parameter_dependencies_are_independent() {
let mut s = Session::default();
let part = component(&mut s, "Part");
let source = box2(&mut s);
let source_names = s.doc.feature(source).unwrap().param_names.clone();
run(&mut s, "parameters.change", json!({"name":source_names[1],"expression":format!("{} * 2",source_names[0])}));
let result = copy(&mut s, part);
let clone = result["component"].as_u64().unwrap();
near(volume(&mut s, clone), 16.0);
let clone_names = s
.doc
.features
.iter()
.find(|f| f.component == clone && matches!(f.kind, solvecraft_doc::FeatureKind::Box { .. }))
.unwrap()
.param_names
.clone();
assert_ne!(clone_names, source_names);
run(&mut s, "parameters.change", json!({"name":source_names[0],"expression":"3 mm"}));
near(volume(&mut s, part), 36.0);
near(volume(&mut s, clone), 16.0);
run(&mut s, "parameters.change", json!({"name":clone_names[0],"expression":"4 mm"}));
near(volume(&mut s, clone), 64.0);
near(volume(&mut s, part), 36.0);
}
#[test]
fn selected_nested_pending_placement_survives_copy_and_undo() {
let mut s = Session::default();
let parent = component(&mut s, "Parent");
let parent_first = s.doc.occurrence_of(parent).unwrap().id;
let child = component(&mut s, "Child");
let leaf = s.doc.occurrence_of(child).unwrap().id;
box2(&mut s);
let second = run(&mut s, "occurrence.copy", json!({"component":parent,"translate":[50,0,0]}))["occurrence"].as_u64().unwrap();
run(&mut s, "occurrence.move", json!({"occurrence":leaf,"capture":false,"translate":[5,0,0]}));
run(&mut s, "occurrence.move", json!({"occurrence":second,"capture":false,"translate":[2,0,0]}));
let pending = s.pending_moves.clone();
let prior_context = (s.active_component, s.active_occurrence_path.clone(), s.active_sketch);
let copied = run(&mut s, "component.paste_new", json!({"component":child,"occurrence":leaf,"path":[second,leaf],"translate":[10,0,0]}));
let new_leaf = copied["occurrence"].as_u64().unwrap();
assert_eq!((s.active_component, s.active_occurrence_path.clone(), s.active_sketch), prior_context);
assert_eq!(s.selection, vec![crate::Sel::Occurrence { path: vec![second, new_leaf] }]);
assert_eq!(s.pending_moves, pending);
assert_eq!(s.occurrence_placement(&[parent_first, leaf]).unwrap().world[3][0], 5.0);
let props = run(&mut s, "occurrence.properties", json!({"occurrence":new_leaf,"path":[second,new_leaf]}));
near(props["total"]["center_of_mass"][0].as_f64().unwrap(), 68.0);
run(&mut s, "edit.undo", json!({}));
assert!(!s.doc.occurrences.iter().any(|o| o.id == new_leaf));
assert_eq!(s.pending_moves, pending);
}
#[test]
fn copied_sheet_base_targets_its_own_sketch_history() {
let mut s = Session::default();
let part = component(&mut s, "Sheet");
let sketch = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Base"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch":"Base"}));
let source_volume = volume(&mut s, part);
let copied = copy(&mut s, part);
let new = copied["component"].as_u64().unwrap();
near(volume(&mut s, new), source_volume);
let clone_sketch = s.doc.features.iter().find(|f| f.component == new && matches!(f.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap().id;
assert_ne!(clone_sketch, sketch);
let base = s.doc.features.iter().find(|f| f.component == new && matches!(f.kind, solvecraft_doc::FeatureKind::SheetBase { .. })).unwrap();
assert_eq!(base.kind.input_sketches(), vec![clone_sketch]);
// Mutate only source geometry, then verify the copied base remains unchanged.
if let solvecraft_doc::FeatureKind::Sketch { sketch, .. } = &mut s.doc_mut().feature_mut(sketch).unwrap().kind {
for p in &mut sketch.points {
p.pos.x *= 2.0;
}
}
s.refresh();
near(volume(&mut s, new), source_volume);
}
#[test]
fn renamed_pattern_outputs_and_later_body_operands_follow_clone() {
let mut s = Session::default();
let part = component(&mut s, "PatternPart");
box2(&mut s);
run(&mut s, "solid.pattern.rectangular", json!({"bodies":["Cube"],"dir1":[1,0,0],"count1":3,"spacing1":5}));
let last = s.model.state().bodies.last().unwrap().name.clone();
run(&mut s, "body.rename", json!({"body":last,"name":"Custom Output"}));
run(&mut s, "solid.move", json!({"bodies":["Custom Output"],"translate":[0,5,0]}));
let copied = copy(&mut s, part);
let new = copied["component"].as_u64().unwrap();
near(volume(&mut s, new), 24.0);
let state = s.model.state();
let target = state.bodies.iter().find(|b| b.name == "Custom Output (copy)").unwrap();
near(solvecraft_kernel::measure(&target.body).unwrap().centroid.y, 6.0);
assert_eq!(s.doc.features.iter().filter(|f| f.component == new && matches!(f.kind, solvecraft_doc::FeatureKind::Move { .. })).count(), 1);
}
#[test]
fn configuration_rows_follow_copied_parameters_material_and_strict_face_lineage() {
let mut s = Session::default();
let part = component(&mut s, "Configured");
let source = box2(&mut s);
let length = s.doc.feature(source).unwrap().param_names[0].clone();
let target = solvecraft_doc::config::ConfigFaceTarget {
body: "Cube".into(),
origin_feature: source,
name: format!("F{source}:+z"),
point: solvecraft_geom::Vec3::new(1.0, 1.0, 2.0),
};
run(&mut s, "config.configure", json!({"params":[length],"materials":["Cube"],"component_appearances":[part],"face_appearances":[target]}));
let face_key = s.doc.config_key(&solvecraft_doc::config::Column::FaceAppearance(target.clone()));
run(
&mut s,
"config.row",
json!({"name":"Large","values":{length.clone():"4 mm","material:Cube":"Brass",face_key:{"name":"Custom","color":[0,255,0],"opacity":1.0}}}),
);
let result = copy(&mut s, part);
let new = result["component"].as_u64().unwrap();
assert_eq!(s.doc.configs.columns.len(), 8);
run(&mut s, "config.activate", json!({"row":"Large"}));
near(volume(&mut s, part), 16.0);
near(volume(&mut s, new), 16.0);
let props = run(&mut s, "component.properties", json!({"component":new}));
near(props["total"]["mass_g"].as_f64().unwrap(), 0.136);
let state = s.model.state();
let body = state.bodies.iter().find(|b| b.component == new).unwrap();
assert!(s.doc.face_colors(body).iter().any(|(_, look)| look.color == [0, 255, 0]));
assert!(s.doc.configuration_style_status(&state).is_ok());
assert!(s.doc.configuration_matches());
}
#[test]
fn configured_extrude_cap_copy_keeps_independent_semantic_lineage() {
let mut s = Session::default();
let part = component(&mut s, "Extruded");
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[2,2]}));
run(&mut s, "sketch.finish", json!({}));
let feature = run(&mut s, "solid.extrude", json!({"distance":2}))["feature"].as_u64().unwrap();
let body_name = s.model.state().bodies.last().unwrap().name.clone();
let target = solvecraft_doc::config::ConfigFaceTarget {
body: body_name,
origin_feature: feature,
name: format!("F{feature}:top"),
point: solvecraft_geom::Vec3::new(1.0, 1.0, 2.0),
};
run(&mut s, "config.configure", json!({"face_appearances":[target]}));
let key = s.doc.config_key(&solvecraft_doc::config::Column::FaceAppearance(target));
run(&mut s, "config.row", json!({"name":"Painted","values":{key:{"name":"Custom","color":[0,255,0],"opacity":1.0}}}));
let copied = copy(&mut s, part)["component"].as_u64().unwrap();
run(&mut s, "config.activate", json!({"row":"Painted"}));
let state = s.model.state();
let body = state.bodies.iter().find(|b| b.component == copied).unwrap();
assert!(solvecraft_doc::naming::face_names(body).contains(&format!("F{}:top", body.feature)));
assert!(s.doc.face_colors(body).iter().any(|(_, look)| look.color == [0, 255, 0]));
assert!(s.doc.configuration_style_status(&state).is_ok());
let saved = s.doc.to_json();
let reopened = solvecraft_doc::Document::from_json(&saved).unwrap();
let mut model = solvecraft_doc::Model::default();
model.evaluate(&reopened);
assert!(reopened.configuration_style_status(&model.state()).is_ok());
}
#[test]
fn selected_second_occurrence_emboss_copy_remaps_target_and_author_paths_and_reopens() {
let mut s = Session::default();
let part = component(&mut s, "SleevePart");
let cylinder = run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}))["feature"].as_u64().unwrap();
let second = run(&mut s, "occurrence.copy", json!({"component":part,"translate":[80,0,0]}))["occurrence"].as_u64().unwrap();
run(&mut s, "component.activate", json!({"component":part,"occurrence":second,"path":[second]}));
run(&mut s, "sketch.create", json!({"name":"Badge","plane":{"origin":[80,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]}}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.emboss", json!({"sketch":"Badge","face":[80,10,7.5],"depth":1}));
let original_volume = volume(&mut s, part);
let copied = run(&mut s, "component.paste_new", json!({"component":part,"occurrence":second,"path":[second],"translate":[20,0,0]}));
let new = copied["component"].as_u64().unwrap();
let placement = copied["occurrence"].as_u64().unwrap();
let emboss = s.doc.features.iter().find(|f| f.component == new && matches!(f.kind, solvecraft_doc::FeatureKind::Emboss { .. })).unwrap();
let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &emboss.kind else { panic!("missing selected face") };
assert_eq!(face.body, "Sleeve (copy)");
assert_eq!(face.path, vec![placement]);
assert_eq!(face.author_path, vec![placement]);
near(volume(&mut s, new), original_volume);
let clone_volume = volume(&mut s, new);
let height = s.doc.feature(cylinder).unwrap().param_names[1].clone();
run(&mut s, "parameters.change", json!({"name":height,"expression":"30 mm"}));
near(volume(&mut s, new), clone_volume);
assert!(volume(&mut s, part) > original_volume);
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
reopened.refresh();
assert!(reopened.doc.features.iter().all(|f| reopened.model.result(f.id).is_some_and(|r| r.error.is_none())));
near(volume(&mut reopened, new), clone_volume);
}
#[test]
fn reassigned_sample_plate_paste_new_preserves_complex_frozen_geometry() {
let mut s = Session::default();
s.run_script(&crate::sample::script()).unwrap();
let body = s.model.state().bodies.first().unwrap().name.clone();
let created = run(&mut s, "component.from_bodies", json!({"bodies":[body]}));
let part = created["components"][0]["component"].as_u64().unwrap();
run(&mut s, "component.activate", json!({"component":part}));
let occurrence = s.doc.occurrence_of(part).unwrap().id;
run(&mut s, "occurrence.ground", json!({"occurrence":occurrence,"grounded":true}));
let original_volume = volume(&mut s, part);
let source_mesh = s.model.state().body(&body).unwrap().mesh();
let boss_probe = solvecraft_geom::Vec3::new(40.0, 35.0, 12.0);
let source_boss = source_mesh.contains(boss_probe);
let copied = run(&mut s, "component.paste_new", json!({"component":part,"translate":[10,10,0]}));
assert_eq!(s.doc.components.len(), 2);
let new = copied["component"].as_u64().unwrap();
// Imported curved faces may tessellate differently; keep the same geometric
// accuracy required by copy, and independently probe plate and hole material.
assert!((volume(&mut s, new) - original_volume).abs() < 1e-5 * original_volume);
near(volume(&mut s, part), original_volume);
let state = s.model.state();
let copied_body = state.bodies.iter().find(|body| body.component == new).unwrap();
let mesh = copied_body.mesh();
for (point, expected) in
[([5.0, 25.0, 4.0], true), ([12.0, 25.0, 4.0], false), ([68.0, 25.0, 4.0], false), ([40.0, 25.0, 12.0], false), ([0.0, 0.0, 4.0], false)]
{
assert_eq!(mesh.contains(solvecraft_geom::Vec3::new(point[0], point[1], point[2])), expected, "{point:?}");
}
assert_eq!(mesh.contains(boss_probe), source_boss);
assert_eq!(state.body(&body).unwrap().mesh().contains(boss_probe), source_boss);
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
reopened.refresh();
assert!((volume(&mut reopened, new) - original_volume).abs() < 1e-5 * original_volume);
}
#[test]
fn generated_copy_parameters_cannot_heal_an_unrelated_missing_reference() {
let mut s = Session::default();
let part = component(&mut s, "Valid");
let valid = box2(&mut s);
run(&mut s, "component.activate", json!({"component":0}));
let kind = s.doc.feature(valid).unwrap().kind.clone();
let broken = s.doc_mut().add_feature(kind, Some("Broken reference")).unwrap();
let mut probe = (*s.doc).clone();
let missing = probe.new_model_param("2 mm", "mm");
let feature = s.doc_mut().feature_mut(broken).unwrap();
feature.component = 0;
if let solvecraft_doc::FeatureKind::Box { width, .. } = &mut feature.kind {
*width = missing;
}
s.refresh();
let original_error = s.model.result(broken).unwrap().error.clone();
assert!(original_error.is_some());
let saved = s.doc.to_json();
let context = (s.active_component, s.active_occurrence_path.clone(), s.active_sketch);
let selection = s.selection.clone();
let pending = s.pending_moves.clone();
let error = s.execute("component.paste_new", &json!({"component":part})).unwrap_err();
assert!(error.to_string().contains("original feature's error state"), "{error}");
assert_eq!(s.doc.to_json(), saved);
assert_eq!(s.model.result(broken).unwrap().error, original_error);
assert_eq!((s.active_component, s.active_occurrence_path.clone(), s.active_sketch), context);
assert_eq!(s.selection, selection);
assert_eq!(s.pending_moves, pending);
}
+107 -7
View File
@@ -28,8 +28,66 @@ pub static COMMANDS: &[CommandSpec] = &[
];
fn column_of(s: &Session, p: &Value, cmd: &str) -> Result<Column> {
if ["param", "feature", "material", "appearance"].iter().filter(|k| p.get(**k).is_some()).count() != 1 {
return Err(bad(cmd, "give exactly one of `param`, `feature`, `material`, or `appearance`"));
if ["param", "feature", "material", "appearance", "component_appearance", "face_appearance"].iter().filter(|k| p.get(**k).is_some()).count() != 1
{
return Err(bad(cmd, "give exactly one configuration target"));
}
if let Some(value) = p.get("component_appearance") {
let key = value
.as_str()
.map(str::to_string)
.or_else(|| value.as_u64().map(|id| id.to_string()))
.ok_or_else(|| bad(cmd, "component name or ID required"))?;
if bool_(p, "delete") == Some(true)
&& let Some(column) = s.doc.configs.columns.iter().find(|column| matches!(column,Column::ComponentAppearance(id) if id.to_string()==key))
{
return Ok(column.clone());
}
let id = s
.doc
.find_component(&key)
.or_else(|| s.doc.occurrences.iter().find(|occurrence| occurrence.name == key).map(|occurrence| occurrence.component))
.filter(|id| *id != 0)
.ok_or_else(|| bad(cmd, "no component definition"))?;
return Ok(Column::ComponentAppearance(id));
}
if let Some(value) = p.get("face_appearance") {
if bool_(p, "delete") == Some(true) {
let key = value.as_str().ok_or_else(|| bad(cmd, "face column key required for deletion"))?;
return s
.doc
.configs
.columns
.iter()
.find(|column| matches!(column, Column::FaceAppearance(_)) && s.doc.config_key(column) == key)
.cloned()
.ok_or_else(|| bad(cmd, "no face column"));
}
let body_name = value.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "face body required"))?;
let point = value.get("point").and_then(crate::params::vec3).ok_or_else(|| bad(cmd, "finite face point required"))?;
let world = s.world_state();
let body = world.body(body_name).ok_or_else(|| bad(cmd, "no face body"))?;
if body.body.is_mesh() {
return Err(bad(cmd, "mesh faces have no supported semantic lineage"));
}
let index = solvecraft_doc::appearance::face_index_at(body, point).ok_or_else(|| bad(cmd, "no face at picked point"))?;
let names = solvecraft_doc::naming::face_names(body);
let name = names.get(index).map(|name| solvecraft_doc::naming::strip_piece(name).to_string()).ok_or_else(|| bad(cmd, "face name missing"))?;
let origin_feature = name
.strip_prefix('F')
.and_then(|name| name.split_once(':'))
.and_then(|(id, _)| id.parse::<u64>().ok())
.ok_or_else(|| bad(cmd, "face lineage missing"))?;
let inverse = solvecraft_doc::mat_inverse(&body.source_transform).ok_or_else(|| bad(cmd, "invalid definition placement transform"))?;
let target = solvecraft_doc::config::ConfigFaceTarget {
body: body.definition_name().into(),
origin_feature,
name,
point: solvecraft_doc::apply_point(&inverse, point),
};
s.doc.certify_config_face(&target).map_err(|error| bad(cmd, error.to_string()))?;
s.doc.configuration_face_indices(body, &target).map_err(|error| bad(cmd, error.to_string()))?;
return Ok(Column::FaceAppearance(target));
}
for (key, appearance) in [("material", false), ("appearance", true)] {
if let Some(body) = str_(p, key) {
@@ -53,7 +111,9 @@ fn column_of(s: &Session, p: &Value, cmd: &str) -> Result<Column> {
/// Add a column, filling existing rows with the design's current value.
fn add_column(s: &mut Session, c: Column, cmd: &str) -> Result<()> {
if s.doc.configs.columns.contains(&c) {
if s.doc.configs.columns.iter().any(|existing| {
*existing == c || matches!((existing,&c),(Column::FaceAppearance(a),Column::FaceAppearance(b)) if a.body == b.body && a.name == b.name)
}) {
return Ok(());
}
if s.doc.configs.columns.len() >= MAX_COLUMNS {
@@ -85,6 +145,18 @@ fn configure(s: &mut Session, p: &Value) -> Result<Value> {
add_column(s, c, cmd)?;
}
}
for (plural, singular) in [("component_appearances", "component_appearance"), ("face_appearances", "face_appearance")] {
if let Some(values) = p.get(plural) {
let values = values
.as_array()
.filter(|values| values.len() <= MAX_COLUMNS)
.ok_or_else(|| bad(cmd, "configuration targets must be a bounded array"))?;
for value in values {
let c = column_of(s, &json!({singular:value}), cmd)?;
add_column(s, c, cmd)?;
}
}
}
if s.doc.configs.rows.is_empty() {
let cells = s.doc.configs.columns.iter().map(|c| s.doc.config_current(c).unwrap_or_default()).collect();
let t = &mut s.doc_mut().configs;
@@ -102,7 +174,26 @@ fn show(s: &mut Session, _p: &Value) -> Result<Value> {
.iter()
.map(|r| json!({"name": r.name, "values": keys.iter().zip(&r.cells).map(|(k, v)| (k.clone(), json!(v))).collect::<serde_json::Map<_, _>>()}))
.collect();
Ok(json!({"columns": keys, "rows": rows, "rules": t.rules, "active": t.active, "matches": s.doc.configuration_matches()}))
let targets: Vec<Value> = t
.columns
.iter()
.map(|column| {
let label = match column {
Column::ComponentAppearance(id) => {
format!("Component: {}", s.doc.components.iter().find(|c| c.id == *id).map(|c| c.name.clone()).unwrap_or_else(|| id.to_string()))
}
Column::FaceAppearance(target) => {
format!("Face: {} / {}", target.body, target.name.split_once(':').map(|(_, role)| role).unwrap_or(&target.name))
}
_ => s.doc.config_key(column),
};
json!({"key":s.doc.config_key(column),"label":label,"target":column})
})
.collect();
let styles = s.doc.configuration_style_status(&s.model.state());
Ok(
json!({"columns": keys, "targets":targets, "rows": rows, "rules": t.rules, "active": t.active, "matches": s.doc.configuration_matches() && styles.is_ok(), "style_status":styles.as_ref().ok(),"style_error":styles.err().map(|error|error.to_string())}),
)
}
fn column(s: &mut Session, p: &Value) -> Result<Value> {
@@ -154,7 +245,9 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
Value::String(t) => t.clone(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::Object(_) if k.starts_with("appearance:") => serde_json::to_string(v).map_err(|e| bad(cmd, e.to_string()))?,
Value::Object(_) if k.starts_with("appearance:") || k.starts_with("component_appearance:") || k.starts_with("face_appearance:") => {
serde_json::to_string(v).map_err(|e| bad(cmd, e.to_string()))?
}
_ => return Err(bad(cmd, format!("value for `{k}` must be an expression or true/false"))),
};
if text.len() > 4096 {
@@ -170,7 +263,7 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|| match c {
Column::Param(n) => n == k,
Column::Suppress(id) => s.doc.feature(*id).is_some_and(|f| f.name == *k),
Column::Material(_) | Column::Appearance(_) => false,
Column::Material(_) | Column::Appearance(_) | Column::ComponentAppearance(_) | Column::FaceAppearance(_) => false,
}
})
.ok_or_else(|| bad(cmd, format!("`{k}` is not a column (config.column adds one)")))?;
@@ -178,7 +271,7 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
Some(Column::Material(_)) => {
text = solvecraft_doc::config::material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into());
}
Some(Column::Appearance(_)) => {
Some(Column::Appearance(_) | Column::ComponentAppearance(_) | Column::FaceAppearance(_)) => {
text = match solvecraft_doc::config::appearance_cell(&text).map_err(|e| bad(cmd, e.to_string()))? {
Some(look) => serde_json::to_string(&look).map_err(|e| bad(cmd, e.to_string()))?,
None => String::new(),
@@ -303,12 +396,14 @@ fn activate(s: &mut Session, p: &Value) -> Result<Value> {
if !new_errors.is_empty() {
return Err(bad(cmd, format!("configuration `{r}` would fail to rebuild: {}", new_errors.join("; "))));
}
let styles = trial.doc.configuration_style_status(&trial.model.state()).map_err(|error| bad(cmd, format!("configuration `{r}`: {error}")))?;
s.doc = trial.doc;
s.refresh();
let errors: Vec<String> = s.model.results.iter().filter_map(|x| x.error.clone()).collect();
let mut out = show(s, p)?;
out["errors"] = json!(errors);
out["trace"] = json!(trace);
out["styles"] = json!(styles);
Ok(out)
}
@@ -320,6 +415,11 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
#[cfg(test)]
#[path = "config_style_tests.rs"]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod style_tests;
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
+225
View File
@@ -0,0 +1,225 @@
use crate::Session;
use serde_json::{Value, json};
use solvecraft_doc::config::{Column, ConfigFaceTarget};
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|error| panic!("{command} {params}: {error}"))
}
fn block(s: &mut Session) -> String {
run(s, "solid.box", json!({"length":10,"width":10,"height":10}));
s.model.state().bodies[0].name.clone()
}
fn face_column(s: &mut Session, body: &str, point: Value) -> String {
run(s, "config.configure", json!({"face_appearances":[{"body":body,"point":point}]}))["columns"][0].as_str().unwrap().into()
}
#[test]
fn component_appearance_shared_definition_inheritance_and_delete() {
let mut s = Session::default();
let component = run(&mut s, "component.create", json!({"name":"Part"}))["component"].as_u64().unwrap();
let body = block(&mut s);
run(&mut s, "material.assign", json!({"bodies":[body],"material":"Steel"}));
run(&mut s, "occurrence.copy", json!({"component":component,"translate":[30,0,0]}));
let table = run(&mut s, "config.configure", json!({"component_appearances":[component]}));
let key = table["columns"][0].as_str().unwrap().to_string();
run(&mut s, "config.row", json!({"name":"Red","values":{key.clone():"ABS - Red"}}));
run(&mut s, "config.activate", json!({"row":"Red"}));
let world = s.world_state();
assert_eq!(world.bodies.len(), 2);
assert!(world.bodies.iter().all(|body| s.doc.model_body_look(body).unwrap().name == "ABS - Red"));
run(&mut s, "appearance.assign", json!({"bodies":[body],"appearance":"ABS - Yellow"}));
assert!(s.world_state().bodies.iter().all(|body| s.doc.model_body_look(body).unwrap().name == "ABS - Yellow"));
run(&mut s, "config.activate", json!({"row":"Default"}));
assert!(!s.doc.appearances.components.contains_key(&component));
assert_eq!(s.doc.appearances.bodies[&body].name, "ABS - Yellow");
run(&mut s, "component.rename", json!({"component":component,"name":"Renamed"}));
assert_eq!(run(&mut s, "config.table", json!({}))["columns"][0], key);
run(&mut s, "appearance.assign", json!({"components":[component.to_string()],"appearance":"ABS - Blue"}));
run(&mut s, "component.delete", json!({"component":component}));
assert!(s.doc.configs.columns.is_empty());
assert!(s.doc.configs.rows.iter().all(|row| row.cells.is_empty()));
assert!(!s.doc.appearances.components.contains_key(&component));
}
#[test]
fn strict_face_rows_resize_rename_preview_suppress_and_roundtrip() {
let mut s = Session::default();
let body = block(&mut s);
let key = face_column(&mut s, &body, json!([5, 5, 10]));
let feature = s.doc.features[0].name.clone();
let height = s.doc.features[0].param_names[2].clone();
run(&mut s, "config.column", json!({"param":height}));
run(&mut s, "config.column", json!({"feature":feature}));
run(&mut s, "config.row", json!({"name":"Tall","values":{key:"ABS - Red",height:"20 mm"}}));
let before = s.doc.clone();
let revision = s.revision;
let history = s.undo.len();
let preview = run(&mut s, "config.preview", json!({"row":"Tall"}));
assert_eq!(s.doc, before);
assert_eq!(s.revision, revision);
assert_eq!(s.undo.len(), history);
assert_eq!(preview["styles"][0]["dormant"], false);
run(&mut s, "config.activate", json!({"row":"Tall"}));
let state = s.model.state();
let mb = state.body(&body).unwrap();
let colors = s.doc.face_colors(mb);
assert_eq!(colors.len(), 1);
let names = solvecraft_doc::naming::face_names(mb);
assert!(names[colors[0].0].ends_with(":+z"));
assert!(s.doc.appearances.faces[0].strict_name);
run(&mut s, "appearance.assign", json!({"faces":[{"body":body,"point":[5,5,20]}]}));
assert!(s.doc.appearances.faces.is_empty(), "clear resolves semantic name after configured resize");
run(&mut s, "config.activate", json!({"row":"Tall"}));
run(&mut s, "body.rename", json!({"body":body,"name":"Housing"}));
let key = s.doc.config_key(&s.doc.configs.columns[0]);
let suppress = format!("suppress:{feature}");
run(&mut s, "config.row", json!({"name":"Hidden","values":{key:"ABS - Blue",suppress:true}}));
let hidden = run(&mut s, "config.activate", json!({"row":"Hidden"}));
assert_eq!(hidden["styles"][0]["dormant"], true);
assert!(s.model.state().bodies.is_empty());
let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
assert_eq!(reopened, *s.doc);
run(&mut s, "config.activate", json!({"row":"Tall"}));
assert_eq!(s.doc.face_colors(s.model.state().body("Housing").unwrap())[0].1.name, "ABS - Red");
run(&mut s, "config.activate", json!({"row":"Default"}));
assert!(s.doc.appearances.faces.is_empty());
let before_delete = s.doc.clone();
run(&mut s, "timeline.delete", json!({"feature":feature}));
assert!(s.doc.configs.columns.is_empty());
assert!(s.doc.configs.rows.iter().all(|row| row.cells.is_empty()));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc, before_delete);
run(&mut s, "config.activate", json!({"row":"Tall"}));
assert!(s.doc.appearances.faces[0].strict_name);
}
#[test]
fn strict_face_split_pieces_share_lineage_without_index_targets() {
let mut s = Session::default();
let body = block(&mut s);
let key = face_column(&mut s, &body, json!([5, 5, 10]));
run(&mut s, "config.row", json!({"name":"Paint","values":{key:"ABS - Red"}}));
run(&mut s, "solid.split_face", json!({"body":body,"faces":[[5,5,10]],"plane":{"origin":[5,0,0],"normal":[1,0,0]}}));
run(&mut s, "config.activate", json!({"row":"Paint"}));
let state = s.model.state();
let body = state.body(&body).unwrap();
let colors = s.doc.face_colors(body);
let names = solvecraft_doc::naming::face_names(body);
assert!(colors.len() >= 2, "split should create multiple painted pieces: {names:?}");
assert!(colors.iter().all(|(index, look)| solvecraft_doc::naming::strip_piece(&names[*index]).ends_with(":+z") && look.name == "ABS - Red"));
}
#[test]
fn strict_face_missing_lineage_never_retargets_and_rolls_back() {
let mut s = Session::default();
let body = block(&mut s);
let key = face_column(&mut s, &body, json!([5, 5, 10]));
run(&mut s, "config.row", json!({"name":"Paint","values":{key:"ABS - Red"}}));
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[30,0,0],"operation":"new"}));
let other = s.doc.features.last().unwrap().id;
let Column::FaceAppearance(target) = &mut s.doc_mut().configs.columns[0] else { panic!() };
target.origin_feature = other;
target.name = format!("F{other}:+z");
let before = s.doc.clone();
let revision = s.revision;
let history = s.undo.len();
assert!(s.execute("config.activate", &json!({"row":"Paint"})).is_err());
assert!(s.execute("config.preview", &json!({"row":"Paint"})).is_err());
assert_eq!(s.doc, before);
assert_eq!(s.revision, revision);
assert_eq!(s.undo.len(), history);
let look = solvecraft_doc::appearance::library("ABS - Red").unwrap();
s.doc_mut().appearances.faces.push(solvecraft_doc::appearance::FaceLook {
body: body.clone(),
point: solvecraft_geom::Vec3::new(5., 5., 10.),
name: Some(format!("F{other}:+z")),
strict_name: true,
look,
});
assert!(s.doc.face_colors(s.model.state().body(&body).unwrap()).is_empty());
s.doc_mut().appearances.faces[0].strict_name = false;
assert_eq!(s.doc.face_colors(s.model.state().body(&body).unwrap()).len(), 1);
}
#[test]
fn face_occurrence_pick_uses_definition_identity_and_coordinates() {
let mut s = Session::default();
let component = run(&mut s, "component.create", json!({"name":"Part"}))["component"].as_u64().unwrap();
let body = block(&mut s);
run(&mut s, "occurrence.copy", json!({"component":component,"translate":[30,0,0]}));
let world = s.world_state();
let placed = world.bodies.iter().find(|body| body.name != body.definition_name()).unwrap();
let placed_name = placed.name.clone();
let point = solvecraft_doc::apply_point(&placed.source_transform, solvecraft_geom::Vec3::new(5., 5., 10.));
let key = face_column(&mut s, &placed_name, json!(point));
let Column::FaceAppearance(target) = &s.doc.configs.columns[0] else { panic!() };
assert_eq!(target.body, body);
assert!(target.point.dist(solvecraft_geom::Vec3::new(5., 5., 10.)) < 1e-8);
run(&mut s, "config.row", json!({"name":"Paint","values":{key:"ABS - Blue"}}));
run(&mut s, "config.activate", json!({"row":"Paint"}));
assert!(s.world_state().bodies.iter().all(|body| s.doc.face_colors(body).len() == 1));
}
#[test]
fn extrude_cap_certification_follows_actual_positive_negative_and_symmetric_names() {
for direction in ["positive", "negative", "symmetric"] {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"xy"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":10,"direction":direction,"body_name":"Pad"}));
let state = s.model.state();
let body = state.body("Pad").unwrap();
let faces = body.body.faces(0.01).unwrap();
let names = solvecraft_doc::naming::face_names(body);
let mut caps = 0;
for face in faces {
let name = solvecraft_doc::naming::strip_piece(&names[face.index]);
let origin = name.strip_prefix('F').unwrap().split_once(':').unwrap().0.parse().unwrap();
let target = ConfigFaceTarget { body: "Pad".into(), origin_feature: origin, name: name.into(), point: face.centroid };
if name.ends_with(":start") || name.ends_with(":top") || name.ends_with(":bottom") {
s.doc.certify_config_face(&target).unwrap();
s.doc.configuration_face_indices(body, &target).unwrap();
caps += 1;
} else {
assert!(s.doc.certify_config_face(&target).is_err());
}
}
assert_eq!(caps, 2, "{direction}");
}
}
#[test]
fn ordinary_extent_edits_never_transfer_strict_cap_paint_to_ambiguous_caps() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"xy"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Pad","name":"PadFeature","start_offset":"0 mm"}));
let key = face_column(&mut s, "Pad", json!([5, 5, 10]));
run(&mut s, "config.row", json!({"name":"Paint","values":{key:"ABS - Red"}}));
run(&mut s, "config.activate", json!({"row":"Paint"}));
assert_eq!(s.doc.face_colors(s.model.state().body("Pad").unwrap()).len(), 1);
run(&mut s, "timeline.redefine", json!({"feature":"PadFeature","command":"solid.extrude","params":{"distance":10,"start_offset":5}}));
assert!(s.doc.face_colors(s.model.state().body("Pad").unwrap()).is_empty(), "ordinary offset edit must not paint both new top-named caps");
let before = s.doc.clone();
assert!(s.execute("config.activate", &json!({"row":"Paint"})).is_err());
assert_eq!(s.doc, before);
}
#[test]
fn hostile_and_uncertified_face_targets_are_rejected() {
let mut s = Session::default();
let body = block(&mut s);
let id = s.doc.features[0].id;
let target = ConfigFaceTarget { body: body.clone(), origin_feature: id, name: format!("F{id}:face:0"), point: solvecraft_geom::Vec3::ZERO };
assert!(s.doc.certify_config_face(&target).is_err());
for value in [
json!({"body":body,"origin_feature":id,"name":"x".repeat(1_000_000),"point":[0,0,0]}),
json!({"body":body,"origin_feature":id,"name":format!("F{id}:+z"),"point":[0,0,1e20]}),
json!({"body":body,"origin_feature":id,"name":format!("F{id}:+z"),"point":[0,0,0],"extra":[[]]}),
] {
assert!(solvecraft_doc::config::validate_styles_json(&json!({"configs":{"columns":[{"face_appearance":value}]}})).is_err());
}
assert!(s.execute("config.column", &json!({"component_appearance":0})).is_err());
assert!(s.execute("config.column", &json!({"face_appearance":{"body":body,"point":[0,0,1e20]}})).is_err());
}
+12 -1
View File
@@ -355,6 +355,9 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
// selections, component authoring state or the original feature's definition.
let mut trial = s.scratch();
trial.active_component = old.component;
if let FeatureKind::Emboss { face: Some(face), .. } = &old.kind {
trial.active_occurrence_path = (!face.author_path.is_empty()).then(|| face.author_path.clone());
}
trial.active_sketch = None;
trial.selection = s.selection.clone();
trial.doc_mut().marker = Some(idx);
@@ -362,7 +365,15 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
(spec.enabled)(&trial).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
let r = (spec.run)(&mut trial, &params)?;
let made = r.get("feature").and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
let new = trial.doc.feature(made).cloned().ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
let mut new = trial.doc.feature(made).cloned().ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
if let (FeatureKind::Emboss { face: Some(before), .. }, FeatureKind::Emboss { face: Some(after), .. }) = (&old.kind, &mut new.kind)
&& before.body == after.body
&& before.path == after.path
&& before.author_path == after.author_path
&& before.at.dist(after.at) < 1e-8
{
*after = before.clone();
}
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) {
return Err(bad(cmd, format!("{} is not a {} feature", old.name, cid)));
}
+18 -1
View File
@@ -321,7 +321,24 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
};
let face_names = {
let pts = kind.face_points();
if pts.is_empty() { Vec::new() } else { solvecraft_doc::naming::face_names_at(&s.model.state(), &pts) }
if let FeatureKind::Emboss { face: Some(face), .. } = &kind {
let state = s.model.state();
let body = state.body(&face.body).ok_or_else(|| EngineError::Other("selected cylinder body no longer exists".into()))?;
let frame = face.body_frame(&s.doc, body.feature).map_err(|e| EngineError::Other(e.to_string()))?;
let inverse = solvecraft_doc::mat_inverse(&frame).ok_or_else(|| EngineError::Other("invalid cylinder placement".into()))?;
let at = solvecraft_doc::apply_point(&solvecraft_doc::mat_mul(&inverse, &s.active_component_frame()), face.at);
let mut scoped = solvecraft_doc::ModelState::default();
scoped.bodies.push(body.clone());
let names = solvecraft_doc::naming::face_names_at(&scoped, &[at]);
if names.len() != 1 || names.first().is_none_or(|name| name.is_empty()) {
return Err(EngineError::Other("selected cylinder face has no persistent identity".into()));
}
names
} else if pts.is_empty() {
Vec::new()
} else {
solvecraft_doc::naming::face_names_at(&s.model.state(), &pts)
}
};
let participants = string_list(p, "participants");
if participants.len() > 10_000 || participants.iter().any(|b| b != "*" && s.model.state().body(b).is_none()) {
+92 -7
View File
@@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.emboss", "Emboss", emboss)
.at("SOLID", "CREATE")
.icon("emboss")
.params("sketch (on a planar face), profiles?; depth; mode?: emboss|deboss (default emboss); targets?"),
.params("sketch, profiles?; depth; mode?: emboss|deboss; targets?; face?: world point [x,y,z] selecting a planar or complete exterior cylindrical wall (required for wrapped orthogonal line profiles)"),
CommandSpec::new("solid.replace_face", "Replace Face", replace_face)
.at("SOLID", "MODIFY")
.icon("offset_face")
@@ -143,12 +143,97 @@ fn emboss(s: &mut Session, p: &Value) -> Result<Value> {
Some("deboss") => true,
Some(m) => return Err(bad(cmd, format!("unknown mode `{m}` (emboss or deboss)"))),
};
if let Some(fp) = p.get("face").and_then(vec3)
&& super::face::face_normal(s, fp).is_none()
{
return Err(bad(cmd, "not supported yet: embossing onto a curved face"));
}
add_feature(s, p, FeatureKind::Emboss { sketch, profiles, depth, deboss, targets: string_list(p, "targets") })
let mut targets = string_list(p, "targets");
let face = if let Some(value) = p.get("face") {
let local = vec3(value).ok_or_else(|| bad(cmd, "`face` must be a finite point [x,y,z]"))?;
let at = solvecraft_doc::apply_point(&s.active_component_frame(), local);
let state = s.world_state();
let mut candidates = Vec::new();
for body in &state.bodies {
let definition = body.definition_name();
if !targets.is_empty() && !targets.iter().any(|n| n == definition || n == &body.name) {
continue;
}
let mesh = body.mesh();
let closest = mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.filter_map(|(tri, face)| {
let [a, b, c] = mesh.tri(tri)?;
let n = (b - a).cross(c - a).normalized()?;
let q = at - n * (at - a).dot(n);
let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-9);
inside.then_some(((at - a).dot(n).abs(), *face as usize))
})
.min_by(|a, b| a.0.total_cmp(&b.0));
if let Some((distance, index)) = closest
&& distance < (body.body.size() * 1e-3).max(1e-3)
{
candidates.push((body, index));
}
}
if candidates.len() != 1 {
return Err(bad(cmd, "`face` must identify exactly one target body surface"));
}
let (selected, index) = candidates.first().copied().ok_or_else(|| bad(cmd, "no target face"))?;
let name = selected.definition_name();
let body = &selected.body;
match solvecraft_kernel::face_surface(body, index) {
Some(solvecraft_kernel::FaceSurface::Plane { .. }) => {
targets = vec![name.to_owned()];
None
}
Some(solvecraft_kernel::FaceSurface::Cylinder { .. }) => {
if targets.len() > 1 {
return Err(bad(cmd, "cylinder emboss needs one selected target body"));
}
// Viewport picks lie on tessellated chords. Keep their axial and
// angular position, and certify their distance before storing an
// exact surface anchor for later replay.
let at = solvecraft_kernel::cylinder_emboss_anchor(body, index, at).map_err(|e| bad(cmd, e.to_string()))?;
let original = s.model.state();
let owner = s.doc.body_component(name, selected.feature);
let paths: Vec<_> = s
.doc
.placements()
.into_iter()
.filter(|(comp, path, _)| *comp == owner && s.doc.world_body_name(&original, name, owner, path) == selected.name)
.map(|(_, path, _)| path)
.collect();
if paths.len() != 1 {
return Err(bad(cmd, "selected cylinder placement is ambiguous"));
}
let path = paths.into_iter().next().ok_or_else(|| bad(cmd, "selected cylinder placement"))?;
if path.len() > 64 {
return Err(bad(cmd, "selected cylinder placement is too deep"));
}
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(s.active_component));
if author_path.len() > 64 {
return Err(bad(cmd, "author placement is too deep"));
}
let sketch_owner = s.doc.feature(sketch).map(|f| f.component).ok_or_else(|| bad(cmd, "profile sketch no longer exists"))?;
let sketch_path = if sketch_owner == owner {
path.clone()
} else if sketch_owner == s.active_component {
author_path.clone()
} else {
s.doc.first_path(sketch_owner)
};
if path.iter().chain(&author_path).chain(&sketch_path).any(|id| s.pending_moves.contains_key(id)) {
return Err(bad(cmd, "capture pending source, author and profile positions with Capture Position before cylinder emboss"));
}
let inverse = solvecraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid active frame"))?;
let at = solvecraft_doc::apply_point(&inverse, at);
targets = vec![name.to_owned()];
Some(solvecraft_doc::EmbossFace { body: name.to_owned(), at, path, author_path })
}
_ => return Err(bad(cmd, "only planar faces and complete exterior cylindrical walls support emboss")),
}
} else {
None
};
add_feature(s, p, FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face })
}
fn face_points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
@@ -81,6 +81,268 @@ fn emboss_and_deboss_on_a_face() {
assert!(s.execute("solid.emboss", &json!({"sketch": "Logo", "depth": 1, "mode": "sideways"})).is_err());
}
fn cylinder_badge() -> Session {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name":"badge_depth","expression":"1 mm"}));
run(&mut s, "solid.cylinder", json!({"radius":"10 mm","height":"20 mm","body_name":"Sleeve"}));
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
run(&mut s, "sketch.finish", json!({}));
s
}
fn exact_cylinder_volume(s: &Session) -> f64 {
s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume
}
#[test]
fn cylinder_emboss_persists_selection_and_replays_radial_depth() {
use solvecraft_geom::Vec3;
let mut s = cylinder_badge();
let before = exact_cylinder_volume(&s);
run(
&mut s,
"solid.emboss",
json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":"badge_depth","targets":["Sleeve"],"name":"Raised Badge"}),
);
assert!((exact_cylinder_volume(&s) - before - 21.0).abs() < 0.02);
let state = s.model.state();
let body = &state.body("Sleeve").unwrap().body;
assert_eq!(body.shell_count(), 1);
let mesh = body.tessellate_with(0.001, true).unwrap();
assert!(mesh.contains(Vec3::new(-10.5 * 0.15_f64.sin(), 10.5 * 0.15_f64.cos(), 7.5)));
let feature = s.doc.find_feature("Raised Badge").unwrap();
assert!(
matches!(&feature.kind,solvecraft_doc::FeatureKind::Emboss{face:Some(face),..} if face.body=="Sleeve"&&face.at.dist(Vec3::new(0.,10.,7.5))<1e-10)
);
let saved = s.doc.to_json();
let mut loaded = Session { doc: solvecraft_doc::Document::from_json(&saved).unwrap().into(), ..Default::default() };
loaded.refresh();
assert!(loaded.model.results.iter().all(|f| f.error.is_none()), "{:?}", loaded.model.results);
assert!((exact_cylinder_volume(&loaded) - before - 21.0).abs() < 0.02);
run(&mut loaded, "parameters.change", json!({"name":"badge_depth","expression":"2 mm"}));
assert!(loaded.model.results.iter().all(|f| f.error.is_none()), "{:?}", loaded.model.results);
assert!((exact_cylinder_volume(&loaded) - before - 44.0).abs() < 0.02);
}
#[test]
fn cylinder_deboss_is_connected_and_unsupported_profile_failure_is_atomic() {
let mut s = cylinder_badge();
let before = exact_cylinder_volume(&s);
run(&mut s, "solid.emboss", json!({"sketch":"Badge","face":[0,10,7.5],"depth":1,"mode":"deboss","targets":["Sleeve"]}));
assert!((exact_cylinder_volume(&s) - before + 19.0).abs() < 0.02);
assert_eq!(s.model.state().body("Sleeve").unwrap().body.shell_count(), 1);
let mut bad = cylinder_badge();
run(&mut bad, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Circle Badge"}));
run(&mut bad, "sketch.circle.center", json!({"center":[0,7.5],"radius":1}));
run(&mut bad, "sketch.finish", json!({}));
let saved = bad.doc.to_json();
let v = exact_cylinder_volume(&bad);
assert!(bad.execute("solid.emboss", &json!({"sketch":"Circle Badge","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]})).is_err());
assert_eq!(bad.doc.to_json(), saved);
assert_eq!(exact_cylinder_volume(&bad), v);
}
#[test]
fn cylinder_small_chord_pick_is_saved_on_the_exact_surface() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius":"0.1mm","height":"0.2mm","body_name":"Tiny"}));
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,0.1,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-0.02,0.05],"p1":[0.02,0.1]}));
run(&mut s, "sketch.finish", json!({}));
let mesh = s.model.state().bodies.first().unwrap().mesh();
let pick = mesh
.triangles
.iter()
.filter_map(|tri| mesh.tri(tri))
.map(|[a, b, c]| (a + b + c) / 3.)
.find(|p| p.y > 0.08 && p.z > 0.04 && p.z < 0.16)
.unwrap();
assert!(((pick.x * pick.x + pick.y * pick.y).sqrt() - 0.1).abs() > 1e-7);
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[pick.x,pick.y,pick.z],"depth":"0.01mm","targets":["Tiny"]}));
let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &s.doc.features.last().unwrap().kind else {
panic!("missing face");
};
assert!(((face.at.x * face.at.x + face.at.y * face.at.y).sqrt() - 0.1).abs() < 1e-12);
assert_eq!(s.model.state().bodies.first().unwrap().body.shell_count(), 1);
}
#[test]
fn cylinder_cross_component_emboss_keeps_source_owner_and_placements() {
for pick_offset in [0., 80.] {
let mut s = Session::default();
let a = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
let source_feature = s.model.state().bodies.first().unwrap().feature;
let source_names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap());
let before = exact_cylinder_volume(&s);
run(&mut s, "occurrence.move", json!({"occurrence":"Source:1","axis":[0,0,1],"angle":"90 deg","translate":[50,0,0]}));
let second = run(&mut s, "occurrence.copy", json!({"component":a,"translate":[80,0,0]}))["occurrence"].as_u64().unwrap();
run(&mut s, "component.activate", json!({"component":"root"}));
let author = run(&mut s, "component.create", json!({"name":"Author"}))["component"].as_u64().unwrap();
run(&mut s, "occurrence.move", json!({"occurrence":"Author:1","axis":[1,0,0],"angle":"90 deg","translate":[-100,40,30]}));
let author_second = run(&mut s, "occurrence.copy", json!({"component":author,"translate":[0,100,0]}))["occurrence"].as_u64().unwrap();
run(&mut s, "component.activate", json!({"component":author,"occurrence":author_second}));
run(&mut s, "parameters.add", json!({"name":"badge_depth","expression":"1mm"}));
run(&mut s, "sketch.create", json!({"plane":{"origin":[40.+pick_offset,0,0],"x_dir":[0,-1,0],"y_dir":[0,0,1]},"name":"Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
run(&mut s, "sketch.finish", json!({}));
let placements = serde_json::to_value(&s.doc.occurrences).unwrap();
let target = s
.world_state()
.bodies
.iter()
.find(|b| b.body.tessellate_with(0.001, true).unwrap().contains(solvecraft_geom::Vec3::new(40.5 + pick_offset, 0., 7.5)))
.unwrap()
.name
.clone();
run(
&mut s,
"solid.emboss",
json!({"sketch":"Badge","profiles":"all","face":[40.+pick_offset,0,7.5],"depth":"badge_depth","targets":[target.clone()]}),
);
let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &s.doc.features.last().unwrap().kind else {
panic!("missing selection");
};
assert_eq!(face.path, if pick_offset == 0. { s.doc.first_path(a) } else { vec![second] });
assert_eq!(face.author_path, vec![author_second]);
let state = s.model.state();
let body = state.body("Sleeve").unwrap();
assert_eq!(body.feature, source_feature);
assert_eq!(s.doc.body_component("Sleeve", body.feature), a);
assert_eq!(serde_json::to_value(&s.doc.occurrences).unwrap(), placements);
let selected = s.doc.features.last().unwrap().face_names.first().unwrap();
assert!(!selected.is_empty() && source_names.contains(selected), "{selected:?} not in {source_names:?}");
assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02);
let world = s.world_state();
for x in [39.5, 119.5] {
assert!(world.bodies.iter().any(|b| b.body.tessellate_with(0.001, true).unwrap().contains(solvecraft_geom::Vec3::new(x, 0., 7.5))));
}
let mut loaded = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Default::default() };
loaded.refresh();
assert!(loaded.model.results.iter().all(|r| r.error.is_none()), "{:?}", loaded.model.results);
run(&mut loaded, "parameters.change", json!({"name":"badge_depth","expression":"2mm"}));
assert!((exact_cylinder_volume(&loaded) - before - 44.).abs() < 0.02);
assert_eq!(loaded.doc.body_component("Sleeve", loaded.model.state().body("Sleeve").unwrap().feature), a);
assert_eq!(serde_json::to_value(&loaded.doc.occurrences).unwrap(), placements);
let feature = loaded.doc.features.last().unwrap().clone();
run(&mut loaded, "component.activate", json!({"component":"root"}));
run(
&mut loaded,
"timeline.redefine",
json!({"feature":feature.id,"command":"solid.emboss","params":{"sketch":"Badge","profiles":"all","face":[40.+pick_offset,0,7.5],"depth":"badge_depth","targets":[target.clone()]}}),
);
assert_eq!(loaded.doc.feature(feature.id).unwrap().kind, feature.kind);
assert_eq!(loaded.doc.feature(feature.id).unwrap().face_names, feature.face_names);
run(
&mut loaded,
"timeline.redefine",
json!({"feature":feature.id,"command":"solid.emboss","params":{"sketch":"Badge","profiles":"all","face":[40.+pick_offset,0,7.5],"depth":"3mm","targets":[target]}}),
);
assert!((exact_cylinder_volume(&loaded) - before - 69.).abs() < 0.02);
let mut broken = loaded.doc.as_ref().clone();
let id = broken.features.last().unwrap().id;
let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &mut broken.feature_mut(id).unwrap().kind else {
panic!("missing selection");
};
face.path = vec![u64::MAX];
let mut invalid = Session { doc: broken.into(), ..Default::default() };
invalid.refresh();
assert!(invalid.model.results.iter().find(|r| r.id == id).unwrap().error.as_ref().unwrap().contains("placement"));
assert!((exact_cylinder_volume(&invalid) - before).abs() < 0.02);
}
}
#[test]
fn cylinder_active_second_source_occurrence_authors_shared_geometry() {
let mut s = Session::default();
let component = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
let before = exact_cylinder_volume(&s);
let second = run(&mut s, "occurrence.copy", json!({"component":component,"translate":[80,0,0]}))["occurrence"].as_u64().unwrap();
run(&mut s, "component.activate", json!({"component":component,"occurrence":second}));
run(&mut s, "sketch.create", json!({"plane":{"origin":[80,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[80,10,7.5],"depth":1}));
assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02);
let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &s.doc.features.last().unwrap().kind else {
panic!("missing selection");
};
assert_eq!(face.path, vec![second]);
assert_eq!(face.author_path, vec![second]);
let mut loaded = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Default::default() };
loaded.refresh();
assert!(loaded.model.results.iter().all(|r| r.error.is_none()), "{:?}", loaded.model.results);
assert!((exact_cylinder_volume(&loaded) - before - 21.).abs() < 0.02);
}
#[test]
fn cylinder_pending_leaf_or_ancestor_positions_require_capture_atomically() {
for ancestor in [false, true] {
let mut s = Session::default();
let parent = run(&mut s, "component.create", json!({"name":"Parent"}))["occurrence"].as_u64().unwrap();
let leaf = run(&mut s, "component.create", json!({"name":"Source"}))["occurrence"].as_u64().unwrap();
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "occurrence.move", json!({"occurrence":if ancestor {parent}else{leaf},"translate":[5,0,0],"capture":false}));
let before = s.doc.to_json();
let pending = s.pending_moves.clone();
let volume = exact_cylinder_volume(&s);
let error = s.execute("solid.emboss", &json!({"sketch":"Badge","profiles":"all","face":[5,10,7.5],"depth":1})).unwrap_err().to_string();
assert!(error.contains("Capture Position"), "{error}");
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.pending_moves, pending);
assert_eq!(exact_cylinder_volume(&s), volume);
}
}
#[test]
fn cylinder_pending_third_component_profile_requires_capture_atomically() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
let profile_occurrence = run(&mut s, "component.create", json!({"name":"ProfileOwner"}))["occurrence"].as_u64().unwrap();
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "component.activate", json!({"component":"root"}));
run(&mut s, "component.create", json!({"name":"Author"}));
run(&mut s, "occurrence.move", json!({"occurrence":profile_occurrence,"translate":[5,0,0],"capture":false}));
let before = s.doc.to_json();
let pending = s.pending_moves.clone();
let volume = exact_cylinder_volume(&s);
let error = s
.execute("solid.emboss", &json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]}))
.unwrap_err()
.to_string();
assert!(error.contains("Capture Position"), "{error}");
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.pending_moves, pending);
assert_eq!(exact_cylinder_volume(&s), volume);
}
#[test]
fn cylinder_body_offset_keeps_exact_profile_and_anchor_frames() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
let before = exact_cylinder_volume(&s);
let holder = run(&mut s, "component.create", json!({"name":"Holder"}))["component"].as_u64().unwrap();
run(&mut s, "occurrence.move", json!({"occurrence":"Holder:1","translate":[0,50,0]}));
run(&mut s, "component.move_bodies", json!({"bodies":["Sleeve"],"component":holder}));
assert!(s.doc.body_offsets.contains_key("Sleeve"));
let offset = s.doc.body_offsets["Sleeve"];
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]}));
assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02);
assert_eq!(s.doc.body_offsets["Sleeve"], offset);
assert_eq!(s.doc.body_component("Sleeve", s.model.state().body("Sleeve").unwrap().feature), holder);
assert!(s.world_state().body("Sleeve").unwrap().body.tessellate_with(0.001, true).unwrap().contains(solvecraft_geom::Vec3::new(0., 10.5, 7.5)));
}
#[test]
fn replace_face_align_remove() {
let mut s = Session::default();
+20 -4
View File
@@ -401,7 +401,7 @@ fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "too many face appearances"));
}
let name = st_names.iter().find(|(b, p, _)| b == body && p.dist(*pt) < 1e-12).and_then(|(_, _, n)| n.clone());
a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone(), name });
a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone(), name, strict_name: false });
}
}
for c in &comps {
@@ -414,11 +414,27 @@ fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
if look.is_none() && !faces.is_empty() {
let st = s.model.state();
let doc = s.doc_mut();
let picked: Vec<(String, Option<usize>)> =
faces.iter().map(|(b, pt)| (b.clone(), st.body(b).and_then(|mb| solvecraft_doc::appearance::face_index_at(mb, *pt)))).collect();
let picked: Vec<(String, Option<usize>, Option<String>)> = faces
.iter()
.map(|(b, pt)| {
(
b.clone(),
st.body(b).and_then(|mb| solvecraft_doc::appearance::face_index_at(mb, *pt)),
st_names.iter().find(|(name, p, _)| name == b && p.dist(*pt) < 1e-12).and_then(|(_, _, name)| name.clone()),
)
})
.collect();
doc.appearances.faces.retain(|f| {
let idx = st.body(&f.body).and_then(|mb| solvecraft_doc::appearance::face_index_at(mb, f.point));
!picked.iter().any(|(b, i)| *b == f.body && i.is_some() && *i == idx)
!picked.iter().any(|(b, i, name)| {
*b == f.body
&& (f
.name
.as_ref()
.zip(name.as_ref())
.is_some_and(|(a, b)| solvecraft_doc::naming::strip_piece(a) == solvecraft_doc::naming::strip_piece(b))
|| (!f.strict_name && i.is_some() && *i == idx))
})
});
}
Ok(json!({
+2
View File
@@ -5,6 +5,8 @@
mod browser;
mod clipboard;
mod component;
mod component_clone;
mod component_clone_assembly;
mod construct;
mod delete;
mod edit;
+6 -1
View File
@@ -223,7 +223,12 @@ pub(super) fn edit<T>(
let out = f(&mut sk, &mut doc)?;
let (vals, _) = doc.param_values();
doc.apply_dimension_values(&vals, &mut sk)?;
let rep = solve(&mut sk);
sk.refresh_offsets()?;
let mut rep = solve(&mut sk);
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
sk.refresh_offsets()?;
rep = solve(&mut sk);
}
if strict && !rep.ok() {
return Err(EngineError::Other(format!("that would over-constrain the sketch (conflicts with {})", rep.failing.join(", "))));
}
+99 -23
View File
@@ -12,6 +12,9 @@ use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec2_list};
use crate::{Result, Session};
#[path = "sketch_support.rs"]
mod support;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.fillet", "Fillet", fillet)
.at("SKETCH", "MODIFY")
@@ -39,6 +42,9 @@ pub static COMMANDS: &[CommandSpec] = &[
.key("O")
.enabled(in_sketch)
.params("curves: [ids], distance: number (positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain)"),
CommandSpec::new("sketch.offset.break", "Break Offset Association", break_offset)
.enabled(in_sketch)
.params("curve: derived offset curve id (retains geometry and makes its points editable)"),
CommandSpec::new("sketch.trim", "Trim", trim)
.at("SKETCH", "MODIFY")
.icon("trim")
@@ -89,6 +95,9 @@ fn curve_arg(sk: &Sketch, p: &Value, cmd: &str) -> Result<usize> {
if sk.is_linked_curve(c) {
return Err(bad(cmd, format!("`{id}` is projected geometry: break its link first")));
}
if sk.freeform_offsets.iter().any(|offset| offset.output == id) {
return Err(bad(cmd, "break the offset association before editing its output"));
}
Ok(c)
}
@@ -365,7 +374,12 @@ fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
fn extend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.extend";
let at = req_vec2(cmd, p, "at")?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
let (values, _) = doc.param_values();
doc.apply_dimension_values(&values, sk)?;
if !solvecraft_sketch::solve(sk).ok() {
return Err(bad(cmd, "solve the existing sketch constraints before extending"));
}
let ci = curve_arg(sk, p, cmd)?;
let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?;
let (pa, pb) = match sk.curves.get(ci).map(|c| c.kind.clone()) {
@@ -386,41 +400,62 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
};
let (_, t1) = sh.range();
let mut best: Option<(f64, Vec2, usize)> = None;
let mut ambiguous = false;
let mut budget = 1_000_000usize;
let mut segment_count = 0usize;
for (oi, _) in sk.curves.iter().enumerate() {
if oi == ci {
continue;
}
// Lines and circles exactly; free-form curves (splines, ellipses, conics) by the
// pieces of their polyline.
let pieces: Vec<Shape> = match sk.shape(oi) {
Some(other) => vec![other],
None => sk.polyline(oi).windows(2).map(|w| Shape::Line { a: w[0], b: w[1] }).collect(),
};
for q in pieces.iter().flat_map(|other| intersections(&unbounded, other).into_iter().filter(|q| on_bounded(other, *q).is_some())) {
let t = sh.param(q);
let gap = match sh {
Shape::Line { a, b } => {
let l = a.dist(b);
if at_b { (t - 1.0) * l } else { -t * l }
}
Shape::Round { r, .. } => {
if at_b {
(t - t1) * r
} else {
(TAU - t) * r
let pieces = sk.exact_segs(oi).unwrap_or_else(|| sk.segs(oi));
segment_count += pieces.len();
if segment_count > 16_384 {
return Err(bad(cmd, "too many exact curve pieces to extend safely"));
}
for piece in &pieces {
for q in support::hits(&unbounded, piece, &mut budget)? {
let t = sh.param(q);
let gap = match sh {
Shape::Line { a, b } => {
let l = a.dist(b);
if at_b { (t - 1.0) * l } else { -t * l }
}
Shape::Round { r, .. } => {
// A hit already inside the arc cannot lengthen either end. In
// particular, extending the start must not turn a quarter into an eighth.
if t <= t1 + TOL / r.max(1e-12) || t >= TAU - TOL / r.max(1e-12) {
continue;
}
if at_b { (t - t1) * r } else { (TAU - t) * r }
}
};
if gap > TOL * 10.0 {
if best.is_none_or(|b| gap < b.0 - TOL) {
best = Some((gap, q, oi));
ambiguous = false;
} else if best.is_some_and(|b| (gap - b.0).abs() <= TOL && (b.2 != oi || b.1.dist(q) > TOL)) {
ambiguous = true;
}
}
};
if gap > TOL * 10.0 && best.is_none_or(|b| gap < b.0) {
best = Some((gap, q, oi));
}
}
}
if ambiguous {
return Err(bad(cmd, "multiple curves stop the extension at the same distance"));
}
let (_, q, oi) = best.ok_or_else(|| bad(cmd, "nothing to extend to"))?;
if let Some(pt) = sk.points.get_mut(end) {
pt.pos = q;
}
pin(sk, end, oi)?;
let before = sk.points.iter().map(|p| p.pos).collect::<Vec<_>>();
let hold = solvecraft_sketch::Hold { points: (0..sk.points.len()).filter(|i| *i != end).collect(), radii: (0..sk.curves.len()).collect() };
if !solvecraft_sketch::solve_holding(sk, &hold).ok()
|| sk.point(end).is_none_or(|point| point.dist(q) > TOL)
|| sk.points.iter().enumerate().any(|(i, point)| i != end && point.pos.dist(before[i]) > TOL)
{
return Err(bad(cmd, "the extension conflicts with constraints or cannot preserve its original support"));
}
Ok(sk.points.get(end).map(|x| x.id.clone()).unwrap_or_default())
})?;
Ok(json!({"moved": out, "sketch": info}))
@@ -809,7 +844,14 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
let d0 = num(p, "distance").filter(|d| d.abs() > 1e-9).ok_or_else(|| bad(cmd, "`distance` must be a non-zero number"))?;
let side = p.get("side").and_then(vec2);
let use_chain = bool_(p, "chain").unwrap_or(true);
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
let (values, _) = doc.param_values();
doc.apply_dimension_values(&values, sk)?;
sk.refresh_offsets()?;
if !solvecraft_sketch::solve(sk).ok() {
return Err(bad(cmd, "solve existing constraints before offsetting"));
}
sk.refresh_offsets()?;
let sel: Vec<usize> = ids.iter().map(|i| sk.curve_index(i).ok_or_else(|| bad(cmd, format!("unknown curve `{i}`")))).collect::<Result<_>>()?;
let first = *sel.first().ok_or_else(|| bad(cmd, "no curves"))?;
let mut made = Vec::new();
@@ -855,6 +897,17 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
}
None => d0,
};
if order.iter().any(|(i, _)| sk.shape(*i).is_none()) {
// A smooth free-form entity is offset from exact pieces. Junctions that
// would require trimming/intersecting independent normal offsets are
// refused rather than joined by a guessed average point.
if order.len() != 1 {
return Err(bad(cmd, "offset smooth free-form curves individually; mixed chain junctions are ambiguous"));
}
let (source, forward) = order[0];
let output = sk.add_freeform_offset(source, forward, d)?;
return Ok((ids_of(sk, &[output]), cons));
}
let pieces: Vec<Piece> =
order.iter().map(|(c, f)| offset_piece(sk, *c, *f, d)).collect::<Option<_>>().ok_or_else(|| bad(cmd, "the offset collapses an arc"))?;
let n = pieces.len();
@@ -947,6 +1000,29 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info}))
}
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 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"))?;
sk.refresh_offsets()?;
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);
for i in indices {
if let Some(point) = sk.points.get_mut(i) {
point.fixed = false;
}
}
Ok(output.to_string())
})?;
Ok(json!({"curve":out,"sketch":info}))
}
// ---------------------------------------------------------------------------------------------
// Copies: mirror, patterns, move, scale
@@ -183,6 +183,58 @@ fn trim_circle_makes_an_arc_and_a_profile() {
assert!(close(a[0], std::f64::consts::PI * 50.0), "{a:?}");
}
#[test]
fn exact_extend_ellipse_preserves_support_target_and_history() {
for (y, expected_x) in [(3.0, 12.0), (5.0, 20.0)] {
let mut s = new_sketch();
let ellipse = ids(&run(&mut s, "sketch.ellipse", json!({"center":[20,0],"major":[30,0],"minor_radius":5}))["curves"])[0].clone();
run(&mut s, "sketch.constraint.fix", json!({"entity":ellipse}));
let line = ids(&run(&mut s, "sketch.line", json!({"points":[[0,y],[5,y]],"connect":false,"infer":false}))["curves"])[0].clone();
let before = s.doc.to_json();
let target = sketch(&s).exact_segs(0).unwrap();
run(&mut s, "sketch.extend", json!({"curve":line,"at":[5,y]}));
let sk = sketch(&s);
let end = sk.point(sk.resolve_point(&format!("{line}.end")).unwrap()).unwrap();
assert!(end.dist(Vec2::new(expected_x, y)) < 1e-7, "exact ellipse contact {end:?}");
assert_eq!(sk.exact_segs(0).unwrap(), target);
let persisted = s.doc.to_json();
let reopened = solvecraft_doc::format::read(&persisted).unwrap();
assert_eq!(reopened.to_json(), persisted);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before);
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.to_json(), persisted);
}
}
#[test]
fn exact_extend_arc_rejects_interior_hit_atomically() {
let mut s = new_sketch();
let arc = ids(&run(&mut s, "sketch.arc.center_point", json!({"center":[0,0],"start":[10,0],"end":[0,10],"connect":false}))["curves"])[0].clone();
let x = 50.0_f64.sqrt();
let cutter = ids(&run(&mut s, "sketch.line", json!({"points":[[x,5],[x,9]],"connect":false,"infer":false}))["curves"])[0].clone();
run(&mut s, "sketch.constraint.fix", json!({"entity":cutter}));
let before = s.doc.to_json();
let history = s.undo.len();
assert!(s.execute("sketch.extend", &json!({"curve":arc,"at":[10,0]})).is_err());
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.undo.len(), history);
}
#[test]
fn exact_extend_refuses_ambiguous_stop_without_history_change() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points":[[0,0],[5,0]],"connect":false,"infer":false}));
run(&mut s, "sketch.line", json!({"points":[[10,-5],[10,5]],"connect":false,"infer":false}));
run(&mut s, "sketch.line", json!({"points":[[8,-2],[12,2]],"connect":false,"infer":false}));
let before = s.doc.to_json();
let history = s.undo.len();
let error = s.execute("sketch.extend", &json!({"curve":"l1","at":[5,0]})).unwrap_err();
assert!(error.to_string().contains("multiple curves"), "{error}");
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.undo.len(), history);
}
#[test]
fn fillet_and_chamfers() {
let mut s = new_sketch();
@@ -563,3 +615,101 @@ fn offset_free_form_curves() {
let r = run(&mut s, "sketch.offset", json!({"curves": [e], "distance": 1, "side": [100, 20]}));
assert_eq!(ids(&r["curves"]).len(), 1, "{r}");
}
#[test]
fn certified_offset_follows_dimension_drag_reopen_break_and_undo() {
let mut s = new_sketch();
let ellipse = ids(&run(&mut s, "sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5,"connect":false}))["curves"])[0].clone();
run(&mut s, "sketch.dimension", json!({"entities":[ellipse],"type":"major","value":10}));
let before = s.doc.to_json();
let out = run(&mut s, "sketch.offset", json!({"curves":[ellipse],"distance":-1,"chain":false}));
let output = out["curves"][0].as_str().unwrap().to_string();
assert_eq!(sketch(&s).freeform_offsets.len(), 1);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before);
run(&mut s, "edit.redo", json!({}));
let result = run(&mut s, "parameters.change", json!({"name":"d1","expression":12}));
assert_eq!(result["errors"], json!([]));
let verify = |s: &Session| {
let sk = sketch(s);
let source = sk.curve_index(&ellipse).unwrap();
let (c, m) = match sk.curves[source].kind {
CurveKind::Ellipse { c, m, .. } => (sk.point(c).unwrap(), sk.point(m).unwrap()),
_ => panic!("ellipse"),
};
let derived = sk.exact_segs(sk.curve_index(&output).unwrap()).unwrap();
let want = m + (m - c).normalized().unwrap();
assert!(derived[0].point_at(0.0).dist(want) < 1e-7, "updated offset {want:?}");
};
verify(&s);
run(&mut s, "sketch.drag", json!({"entity":ellipse,"from":[0,0],"to":[4,3]}));
verify(&s);
let mut reopened = Session {
doc: std::sync::Arc::new(solvecraft_doc::format::read(&s.doc.to_json()).unwrap()),
active_sketch: s.active_sketch,
..Session::default()
};
reopened.refresh();
verify(&reopened);
let attached = reopened.doc.to_json();
run(&mut reopened, "sketch.offset.break", json!({"curve":output}));
let sk = sketch(&reopened);
assert!(sk.freeform_offsets.is_empty());
assert!(sk.curves[sk.curve_index(&output).unwrap()].kind.point_ids().iter().all(|i| !sk.points[*i].fixed));
run(&mut reopened, "edit.undo", json!({}));
assert_eq!(reopened.doc.to_json(), attached);
}
#[test]
fn certified_offset_delete_output_undo_reopen_and_refuse_dependent_source() {
let mut s = new_sketch();
run(&mut s, "sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5}));
let out = run(&mut s, "sketch.offset", json!({"curves":["e1"],"distance":-1,"chain":false}));
let id = out["curves"][0].as_str().unwrap().to_string();
let attached = s.doc.to_json();
let history = s.undo.len();
assert!(s.execute("sketch.delete", &json!({"entities":["e1"]})).is_err());
assert_eq!(s.doc.to_json(), attached);
assert_eq!(s.undo.len(), history);
run(&mut s, "sketch.delete", json!({"entities":[id]}));
let sk = sketch(&s);
assert!(sk.freeform_offsets.is_empty());
assert!(sk.curve_index(&id).is_none());
assert!(sk.curve_index("e1").is_some());
let deleted = s.doc.to_json();
let reopened = solvecraft_doc::format::read(&deleted).unwrap();
assert_eq!(reopened.to_json(), deleted);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), attached);
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.to_json(), deleted);
// The central removal path also handles a deleted fit node, which removes its curve.
run(&mut s, "edit.undo", json!({}));
let sk = sketch(&s);
let node = sk.points[sk.curves[sk.curve_index(&id).unwrap()].kind.point_ids()[3]].id.clone();
run(&mut s, "sketch.delete", json!({"entities":[node]}));
assert!(sketch(&s).freeform_offsets.is_empty());
assert!(sketch(&s).curve_index(&id).is_none());
}
#[test]
fn analytic_offset_partial_delete_releases_survivors_preserves_source_and_undo() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[20,30]}));
let out = run(&mut s, "sketch.offset", json!({"curves":["l1"],"distance":-1}));
let outputs = ids(&out["curves"]);
assert_eq!(outputs.len(), 4);
let before = s.doc.to_json();
run(&mut s, "sketch.delete", json!({"entities":[outputs[0]]}));
let sk = sketch(&s);
assert!(sk.offsets.is_empty());
for id in outputs.iter().skip(1) {
let curve = &sk.curves[sk.curve_index(id).unwrap()];
assert!(curve.kind.point_ids().iter().all(|i| !sk.points[*i].fixed));
}
assert!(sk.curve_index("l1").is_some());
let saved = s.doc.to_json();
assert_eq!(solvecraft_doc::format::read(&saved).unwrap().to_json(), saved);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before);
}
+214
View File
@@ -0,0 +1,214 @@
//! Bounded roots on exact curve pieces against an infinite line or complete circle.
//! Rational pieces are intersected in homogeneous coordinates, without sampled chords.
use solvecraft_geom::{Seg2, Vec2};
use solvecraft_sketch::{Shape, intersections};
use crate::{Result, params::bad};
fn choose(n: usize, k: usize) -> f64 {
(0..k.min(n - k)).fold(1.0, |value, i| value * (n - i) as f64 / (i + 1) as f64)
}
fn product(a: &[f64], b: &[f64]) -> Vec<f64> {
let (n, m) = (a.len() - 1, b.len() - 1);
(0..=n + m)
.map(|k| (k.saturating_sub(m)..=k.min(n)).map(|i| a[i] * b[k - i] * choose(n, i) * choose(m, k - i) / choose(n + m, k)).sum())
.collect()
}
fn evaluate(c: &[f64], t: f64) -> f64 {
let mut values = [0.0; 15];
for (slot, value) in values.iter_mut().zip(c) {
*slot = *value;
}
for n in (1..c.len()).rev() {
for i in 0..n {
values[i] = values[i] * (1.0 - t) + values[i + 1] * t;
}
}
values[0]
}
/// Derivative roots partition a polynomial into monotone intervals. Testing its critical
/// points retains repeated/tangent roots that a sign-change-only search would miss.
fn roots(c: &[f64], budget: &mut usize) -> Result<Vec<f64>> {
if c.is_empty() || c.len() > 15 || c.iter().any(|v| !v.is_finite()) {
return Err(bad("sketch.extend", "invalid intersection polynomial"));
}
let scale = c.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
if scale < 1e-28 {
return Ok(Vec::new());
}
let eps = scale * 2e-13;
if c.len() == 1 {
return Ok(Vec::new());
}
let d: Vec<f64> = c.windows(2).map(|w| (w[1] - w[0]) * (c.len() - 1) as f64).collect();
let mut knots = roots(&d, budget)?;
knots.extend([0.0, 1.0]);
knots.sort_by(f64::total_cmp);
knots.dedup_by(|a, b| (*a - *b).abs() < 1e-13);
let mut found: Vec<f64> = knots.iter().copied().filter(|t| evaluate(c, *t).abs() <= eps).collect();
for pair in knots.windows(2) {
let (mut lo, mut hi) = (pair[0], pair[1]);
let (mut fl, fh) = (evaluate(c, lo), evaluate(c, hi));
if fl.abs() <= eps || fh.abs() <= eps || fl.signum() == fh.signum() {
continue;
}
for _ in 0..64 {
if *budget == 0 {
return Err(bad("sketch.extend", "intersection work limit exceeded"));
}
*budget -= 1;
let mid = (lo + hi) * 0.5;
let f = evaluate(c, mid);
if hi - lo < 2e-14 {
break;
}
if f.signum() == fl.signum() {
lo = mid;
fl = f;
} else {
hi = mid;
}
}
found.push((lo + hi) * 0.5);
}
found.sort_by(f64::total_cmp);
found.dedup_by(|a, b| (*a - *b).abs() < 1e-10);
Ok(found)
}
pub(super) fn hits(support: &Shape, seg: &Seg2, budget: &mut usize) -> Result<Vec<Vec2>> {
if let Seg2::Line { a, b } = *seg {
let other = Shape::Line { a, b };
if let Shape::Line { a: pa, b: pb } = *support
&& (pb - pa).normalized().is_some_and(|d| d.cross(a - pa).abs() < 1e-9 && d.cross(b - pa).abs() < 1e-9)
{
return Err(bad("sketch.extend", "the continuation overlaps another curve"));
}
return Ok(intersections(support, &other)
.into_iter()
.filter(|p| {
let d = b - a;
let t = (*p - a).dot(d) / d.len2().max(1e-30);
(-1e-10..=1.0 + 1e-10).contains(&t)
})
.collect());
}
if let Seg2::Arc { center, radius, start, sweep } = *seg {
let other = Shape::Round { c: center, r: radius, start, sweep };
if let Shape::Round { c, r, .. } = *support
&& c.dist(center) < 1e-9
&& (r - radius).abs() < 1e-9
{
return Err(bad("sketch.extend", "the continuation overlaps another curve"));
}
return Ok(intersections(support, &other)
.into_iter()
.filter(|p| {
let t = (((*p - center).angle() - start) * sweep.signum()).rem_euclid(std::f64::consts::TAU);
t <= sweep.abs() + 1e-10
})
.collect());
}
let (points, weights): (Vec<Vec2>, Vec<f64>) = match *seg {
Seg2::Cubic { p0, p1, p2, p3 } => (vec![p0, p1, p2, p3], vec![1.0; 4]),
Seg2::Conic { a, apex, b, w } if w.is_finite() && w > 0.0 => (vec![a, apex, b], vec![1.0, w, 1.0]),
Seg2::Bezier { n, p } if (1..=7).contains(&n) => (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]),
_ => return Err(bad("sketch.extend", "invalid exact curve piece")),
};
if points.iter().any(|p| !p.is_finite()) {
return Err(bad("sketch.extend", "nonfinite intersection geometry"));
}
let weight_scale = weights.iter().copied().fold(1.0_f64, f64::max);
let weights: Vec<f64> = weights.iter().map(|w| w / weight_scale).collect();
let origin = match *support {
Shape::Line { a, .. } => a,
Shape::Round { c, .. } => c,
};
let scale = points.iter().map(|p| p.dist(origin)).fold(1.0_f64, f64::max);
let x: Vec<f64> = points.iter().zip(&weights).map(|(p, w)| (p.x - origin.x) * w / scale).collect();
let y: Vec<f64> = points.iter().zip(&weights).map(|(p, w)| (p.y - origin.y) * w / scale).collect();
let c = match *support {
Shape::Line { a, b } => {
let d = (b - a).normalized().ok_or_else(|| bad("sketch.extend", "zero-length support"))?;
x.iter().zip(&y).map(|(x, y)| d.x * y - d.y * x).collect::<Vec<_>>()
}
Shape::Round { r, .. } => {
let (xx, yy, dd) = (product(&x, &x), product(&y, &y), product(&weights, &weights));
xx.iter().zip(&yy).zip(&dd).map(|((x, y), d)| x + y - (r / scale).powi(2) * d).collect()
}
};
if c.iter().all(|v| v.abs() < 1e-28) {
return Err(bad("sketch.extend", "the continuation overlaps another curve"));
}
let mut out = Vec::new();
for t in roots(&c, budget)? {
let p = seg.point_at(t);
if !p.is_finite() || support.project(p).dist(p) > 1e-7 {
return Err(bad("sketch.extend", "the intersection could not be resolved accurately"));
}
if !out.iter().any(|q: &Vec2| q.dist(p) < 1e-7) {
out.push(p);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_extend_negative_arc_target() {
let support = Shape::Line { a: Vec2::new(-2.0, 0.5), b: Vec2::new(-1.5, 0.5) };
let target = Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start: std::f64::consts::PI, sweep: -std::f64::consts::FRAC_PI_2 };
let found = hits(&support, &target, &mut 1000).unwrap();
assert_eq!(found.len(), 1);
assert!(found[0].dist(Vec2::new(-0.75_f64.sqrt(), 0.5)) < 1e-10);
}
#[test]
fn exact_extend_degree_seven_retains_tangent_and_crossing() {
// Degree elevation of quadratic y=(2t-1)^2 retains its double root at t=1/2.
let mut y = vec![1.0, -1.0, 1.0];
while y.len() < 8 {
let n = y.len();
let mut elevated = vec![y[0]];
for i in 1..n {
let a = i as f64 / n as f64;
elevated.push(a * y[i - 1] + (1.0 - a) * y[i]);
}
elevated.push(y[n - 1]);
y = elevated;
}
let mut points = [Vec2::ZERO; 8];
for (i, p) in points.iter_mut().enumerate() {
*p = Vec2::new(10.0 + i as f64 * 20.0 / 7.0, y[i]);
}
let target = Seg2::Bezier { n: 7, p: points };
let support = Shape::Line { a: Vec2::ZERO, b: Vec2::X };
let found = hits(&support, &target, &mut 10000).unwrap();
assert_eq!(found.len(), 1);
assert!(found[0].dist(Vec2::new(20.0, 0.0)) < 1e-7);
let crossing = Shape::Line { a: Vec2::new(0.0, 0.25), b: Vec2::new(1.0, 0.25) };
let found = hits(&crossing, &target, &mut 10000).unwrap();
assert_eq!(found.len(), 2);
assert!(found[0].dist(Vec2::new(15.0, 0.25)) < 1e-7);
assert!(found[1].dist(Vec2::new(25.0, 0.25)) < 1e-7);
assert!(hits(&crossing, &target, &mut 0).is_err());
let circle = Shape::Round { c: Vec2::new(20.0, -1.0), r: 1.0, start: 0.0, sweep: std::f64::consts::TAU };
let found = hits(&circle, &target, &mut 10000).unwrap();
assert_eq!(found.len(), 1, "degree fourteen tangent {found:?}");
assert!(found[0].dist(Vec2::new(20.0, 0.0)) < 1e-7);
}
#[test]
fn exact_extend_rational_quarter_hits_analytic_eight_six_point() {
let target =
Seg2::Conic { a: Vec2::new(10.0, 0.0), apex: Vec2::new(10.0, 10.0), b: Vec2::new(0.0, 10.0), w: std::f64::consts::FRAC_1_SQRT_2 };
let support = Shape::Line { a: Vec2::new(0.0, 6.0), b: Vec2::new(1.0, 6.0) };
let found = hits(&support, &target, &mut 10000).unwrap();
assert_eq!(found.len(), 1);
assert!(found[0].dist(Vec2::new(8.0, 6.0)) < 1e-7);
}
}