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
@@ -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;
|
||||
Reference in new issue
Block a user