feat: strengthen sketches, feature editing, and independent assemblies
This commit is contained in:
1 parent
07932d0f42
commit
0398a813c8
65 files changed
+7082
-268
No files matched your search
@@ -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();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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 ¶meters {
|
||||
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, ¶meters);
|
||||
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, ¶meters)?;
|
||||
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, ¶ms).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);
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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, ¶ms).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());
|
||||
}
|
||||
@@ -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, ¶ms)?;
|
||||
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)));
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
mod browser;
|
||||
mod clipboard;
|
||||
mod component;
|
||||
mod component_clone;
|
||||
mod component_clone_assembly;
|
||||
mod construct;
|
||||
mod delete;
|
||||
mod edit;
|
||||
|
||||
@@ -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(", "))));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user