Merge remote-tracking branch 'origin/main' into fix/model-render-section
# Conflicts: # crates/ui-egui/src/viewport.rs
This commit is contained in:
commit
5a2174899f
186 files changed
+29730
-1578
No files matched your search
@@ -16,7 +16,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("body.rename", "Rename Body", rename_body).params("body: its name, name: the new name (features that use the body follow)"),
|
||||
CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", redefine_sketch)
|
||||
.icon("plane")
|
||||
.params("sketch: id or name; plane: XY|XZ|YZ | construction plane name | {face: [x,y,z]} | {origin, normal} (the sketch keeps its geometry)"),
|
||||
.params("sketch: id or name; plane: as sketch.create; author_path?: exact owner occurrence ids; explicitly rebinds or freezes the plane while preserving geometry and dimension names"),
|
||||
CommandSpec::new("browser.group", "New Group", group)
|
||||
.icon("folder")
|
||||
.params("folder: bodies|sketches|construction; component?: id (default 0); name?; items?: [keys] (they leave other groups)"),
|
||||
@@ -36,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"),
|
||||
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
|
||||
"visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \
|
||||
or folder?: bodies|sketches|construction|origin (all of it). View state: not saved, not an undo step",
|
||||
or folder?: bodies|sketches|construction|origin (all of it); or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). View state: not saved, not an undo step",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -191,6 +191,66 @@ fn shown_item(s: &Session, key: &str) -> Option<Shown> {
|
||||
fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "browser.visibility";
|
||||
let visible = bool_(p, "visible").ok_or_else(|| bad(cmd, "`visible` must be true or false"))?;
|
||||
if let Some(value) = p.get("paths") {
|
||||
if p.get("items").is_some() || p.get("folder").is_some() {
|
||||
return Err(bad(cmd, "give paths, items or folder, not several"));
|
||||
}
|
||||
let paths =
|
||||
value.as_array().filter(|a| !a.is_empty() && a.len() <= 10_000).ok_or_else(|| bad(cmd, "paths must list 1..10000 occurrence paths"))?;
|
||||
let paths: Vec<Vec<u64>> = paths
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let path = value
|
||||
.as_array()
|
||||
.filter(|a| !a.is_empty() && a.len() <= 64)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad(cmd, "each path must list 1..64 occurrence ids"))?;
|
||||
s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok(path)
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
if bool_(p, "isolate").unwrap_or(false) {
|
||||
if !visible {
|
||||
return Err(bad(cmd, "isolate requires visible:true"));
|
||||
}
|
||||
let mut hidden = Vec::new();
|
||||
let mut ancestor_bodies = Vec::new();
|
||||
let mut stack = vec![Vec::<u64>::new()];
|
||||
let mut count = 0;
|
||||
while let Some(parent) = stack.pop() {
|
||||
for child in s.occurrence_children(&parent)? {
|
||||
count += 1;
|
||||
if count > 100_000 {
|
||||
return Err(bad(cmd, "isolate exceeds 100000 placements"));
|
||||
}
|
||||
if paths.iter().any(|selected| child.path.starts_with(selected)) {
|
||||
continue;
|
||||
}
|
||||
if paths.iter().any(|selected| selected.starts_with(&child.path)) {
|
||||
ancestor_bodies.extend(child.bodies.into_iter().map(|b| b.world));
|
||||
stack.push(child.path);
|
||||
} else {
|
||||
hidden.push(child.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut root_bodies: Vec<String> =
|
||||
s.model.state().bodies.iter().filter(|b| s.doc.model_body_component(b) == 0).map(|b| b.name.clone()).collect();
|
||||
root_bodies.extend(ancestor_bodies);
|
||||
s.visibility.hidden_occurrences = hidden;
|
||||
s.visibility.hidden_bodies = root_bodies;
|
||||
s.revision += 1;
|
||||
return Ok(json!({"visible":true,"isolated":paths,"visibility":s.visibility}));
|
||||
}
|
||||
for path in &paths {
|
||||
s.visibility.hidden_occurrences.retain(|p| p != path);
|
||||
if !visible {
|
||||
s.visibility.hidden_occurrences.push(path.clone());
|
||||
}
|
||||
}
|
||||
s.revision += 1;
|
||||
return Ok(json!({"visible":visible,"changed":paths.len(),"visibility":s.visibility}));
|
||||
}
|
||||
let items: Vec<Shown> = match (str_(p, "folder"), p.get("items")) {
|
||||
(Some(_), Some(_)) => return Err(bad(cmd, "give `items` or `folder`, not both")),
|
||||
(Some("bodies"), None) => s.world_state().bodies.iter().map(|b| Shown::Body(b.name.clone())).collect(),
|
||||
@@ -211,6 +271,9 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, None) => return Err(bad(cmd, "`items` or `folder` is required")),
|
||||
};
|
||||
let v = &mut s.visibility;
|
||||
if visible && str_(p, "folder") == Some("bodies") {
|
||||
v.hidden_occurrences.clear();
|
||||
}
|
||||
for it in &items {
|
||||
match it {
|
||||
Shown::Sketch(id) => {
|
||||
@@ -247,6 +310,7 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({ "units": u, "kept": [] }));
|
||||
}
|
||||
// What the geometry reads: every feature's length inputs.
|
||||
let matched_configuration = s.doc.configuration_matches().then(|| s.doc.configs.active.clone()).flatten();
|
||||
let before = s.doc.length_inputs();
|
||||
s.doc_mut().units = u.to_string();
|
||||
// Unit-less user parameters holding a bare number read it in the design's units where they
|
||||
@@ -278,6 +342,10 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
s.doc_mut().preserve_configuration_units(&old, &kept);
|
||||
if let Some(row) = matched_configuration {
|
||||
s.doc_mut().apply_configuration(&row)?;
|
||||
}
|
||||
Ok(json!({ "units": u, "kept": kept }))
|
||||
}
|
||||
|
||||
@@ -393,6 +461,19 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Later features refer to bodies by name.
|
||||
let mut failed = None;
|
||||
for f in d.features.iter_mut().filter(|f| f.id != fid) {
|
||||
if let FeatureKind::Sketch { plane, sketch } = &mut f.kind
|
||||
&& let Some(anchor) = super::sketch::face::anchor_mut(plane)
|
||||
&& anchor.target.body == old
|
||||
{
|
||||
let ids = std::collections::BTreeMap::from([(anchor.target.origin_feature, anchor.target.origin_feature)]);
|
||||
let bodies = std::collections::BTreeMap::from([(old.clone(), name.to_string())]);
|
||||
anchor.remap_target(&ids, &bodies)?;
|
||||
for link in &mut sketch.links {
|
||||
if link.auto_face && matches!(&link.source,solvecraft_sketch::LinkSource::Face {body,..} if *body==old) {
|
||||
link.face_name = Some(anchor.target.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let Ok(mut v) = serde_json::to_value(&f.kind) else { continue };
|
||||
if replace_str(&mut v, &old, name, 0) {
|
||||
match serde_json::from_value(v) {
|
||||
@@ -413,6 +494,17 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for f in d.appearances.faces.iter_mut().filter(|f| f.body == old) {
|
||||
f.body = name.to_string();
|
||||
}
|
||||
for column in &mut d.configs.columns {
|
||||
match column {
|
||||
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();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(o) = d.body_offsets.remove(&old) {
|
||||
d.body_offsets.insert(name.to_string(), o);
|
||||
}
|
||||
@@ -440,13 +532,89 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("plane").is_none() {
|
||||
return Err(bad(cmd, "`plane` is required"));
|
||||
}
|
||||
let plane = super::sketch::plane_ref(s, p, cmd)?;
|
||||
let (vals, _) = s.doc.param_values();
|
||||
s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?;
|
||||
if let Some(FeatureKind::Sketch { plane: slot, .. }) = s.doc_mut().feature_mut(id).map(|f| &mut f.kind) {
|
||||
let feature = s.doc.feature(id).ok_or_else(|| bad(cmd, "sketch disappeared"))?;
|
||||
let component = feature.component;
|
||||
let index = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "sketch disappeared"))?;
|
||||
let author_path = super::sketch::author_path_for_rebind(s, feature, p, cmd)?;
|
||||
let marker = s.doc.marker;
|
||||
let mut baseline = s.scratch();
|
||||
baseline.doc_mut().marker = Some(index + 1);
|
||||
baseline.refresh();
|
||||
let visible_geometry = baseline.model.state().sketch(id).map(|resolved| resolved.sketch.clone());
|
||||
let mut trial = s.scratch();
|
||||
trial.active_component = component;
|
||||
trial.active_occurrence_path = (!author_path.is_empty()).then_some(author_path);
|
||||
// A new attachment may only depend on geometry preceding this sketch.
|
||||
trial.doc_mut().marker = Some(index);
|
||||
trial.refresh();
|
||||
let old_anchor = trial
|
||||
.doc
|
||||
.feature(id)
|
||||
.and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
|
||||
_ => None,
|
||||
})
|
||||
.cloned();
|
||||
let plane = super::sketch::plane_ref(&trial, p, cmd)?;
|
||||
if super::sketch::face::is_face(p) {
|
||||
super::sketch::face::capture_positions(&mut trial, &plane)?;
|
||||
}
|
||||
if let Some(anchor) = old_anchor
|
||||
&& super::sketch::face::anchor(&plane)
|
||||
.is_none_or(|next| next.target != anchor.target || next.component != anchor.component || next.path != anchor.path)
|
||||
{
|
||||
let sketch = trial.doc_mut().sketch_mut(id)?;
|
||||
if let Some(mut visible) = visible_geometry {
|
||||
let constraints: std::collections::BTreeMap<_, _> =
|
||||
sketch.constraints.iter().map(|constraint| (constraint.id.clone(), constraint.clone())).collect();
|
||||
for constraint in &mut visible.constraints {
|
||||
if let Some(original) = constraints.get(&constraint.id) {
|
||||
if std::mem::discriminant(&constraint.kind) != std::mem::discriminant(&original.kind) {
|
||||
return Err(bad(cmd, "a dimension role changed during border refresh; repair it explicitly before rebinding"));
|
||||
}
|
||||
// Keep evaluated entity indices after linked topology rebuilds.
|
||||
constraint.param = original.param.clone();
|
||||
constraint.driven = original.driven;
|
||||
constraint.text = original.text;
|
||||
if let Some(value) = original.kind.value() {
|
||||
constraint.kind.set_value(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
*sketch = visible;
|
||||
}
|
||||
let affected: Vec<_> = sketch
|
||||
.links
|
||||
.iter()
|
||||
.filter(|link| {
|
||||
link.auto_face
|
||||
&& link.face_name.as_ref() == Some(&anchor.target.name)
|
||||
&& matches!(&link.source,solvecraft_sketch::LinkSource::Face {body,..} if *body==anchor.target.body)
|
||||
})
|
||||
.map(|link| link.id.clone())
|
||||
.collect();
|
||||
for link in affected {
|
||||
sketch.break_link(&link)?;
|
||||
}
|
||||
}
|
||||
if let Some(FeatureKind::Sketch { plane: slot, .. }) = trial.doc_mut().feature_mut(id).map(|feature| &mut feature.kind) {
|
||||
*slot = plane;
|
||||
}
|
||||
Ok(json!({ "sketch": id }))
|
||||
trial.doc_mut().marker = Some(index + 1);
|
||||
trial.refresh();
|
||||
if let Some(error) = trial.model.result(id).and_then(|result| result.error.as_ref()) {
|
||||
return Err(bad(cmd, error.clone()));
|
||||
}
|
||||
trial.doc_mut().marker = marker;
|
||||
trial.refresh();
|
||||
s.doc = trial.doc;
|
||||
s.pending_moves = trial.pending_moves;
|
||||
|
||||
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() { "exact_face" } else { "fixed" }),
|
||||
_ => None,
|
||||
});
|
||||
Ok(json!({ "sketch": id,"attachment":attachment }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -62,11 +62,68 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let feats = s.clipboard.clone();
|
||||
let comp = s.active_component;
|
||||
let mut sketches: BTreeMap<u64, u64> = BTreeMap::new();
|
||||
let mut bodies: BTreeMap<String, String> = BTreeMap::new();
|
||||
let mut used: std::collections::BTreeSet<String> = s.model.state().bodies.iter().map(|body| body.name.clone()).collect();
|
||||
let mut notices = Vec::new();
|
||||
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(comp));
|
||||
solvecraft_doc::EmbossFace::component_frame(&s.placed_document(), comp, &author_path)?;
|
||||
let mut names: BTreeMap<String, String> = BTreeMap::new();
|
||||
let mut made = Vec::new();
|
||||
for f in feats {
|
||||
let mut kind = f.kind.clone();
|
||||
kind.remap_refs(&sketches, &names);
|
||||
if let FeatureKind::Sketch { plane, .. } = &mut kind
|
||||
&& let Some(anchor) = super::sketch::face::anchor_mut(plane)
|
||||
{
|
||||
let internal = bodies.contains_key(&anchor.target.body);
|
||||
if !internal && translate.is_some_and(|translate| translate.len() > 1e-12) {
|
||||
return Err(bad(cmd, "translated paste retains an external exact face attachment; explicitly convert it to a fixed plane first"));
|
||||
}
|
||||
if !internal {
|
||||
notices.push(format!("pasted sketch retains external exact face attachment to {}", anchor.target.body));
|
||||
}
|
||||
anchor.remap_target(&sketches, &bodies)?;
|
||||
if internal {
|
||||
anchor.component = comp;
|
||||
anchor.path = author_path.clone();
|
||||
}
|
||||
anchor.author_path = author_path.clone();
|
||||
let target = anchor.target.clone();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &mut kind {
|
||||
for link in &mut sketch.links {
|
||||
if link.auto_face {
|
||||
if let solvecraft_sketch::LinkSource::Face { body, .. } = &mut link.source
|
||||
&& let Some(mapped) = bodies.get(body)
|
||||
{
|
||||
*body = mapped.clone();
|
||||
}
|
||||
link.face_name = Some(target.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let outputs = if f.body_names.is_empty() {
|
||||
s.model
|
||||
.result(f.id)
|
||||
.map(|result| result.state.bodies.iter().filter(|body| body.feature == f.id).map(|body| body.name.clone()).collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
f.body_names.clone()
|
||||
};
|
||||
let copied_names: Vec<_> = outputs
|
||||
.iter()
|
||||
.map(|original| {
|
||||
let base = format!("{original} (copy)");
|
||||
let mut name = base.clone();
|
||||
let mut suffix = 2usize;
|
||||
while !used.insert(name.clone()) {
|
||||
name = format!("{base} {suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
bodies.insert(original.clone(), name.clone());
|
||||
name
|
||||
})
|
||||
.collect();
|
||||
let doc = s.doc_mut();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &mut kind {
|
||||
// Each dimension of the copy gets its own parameter, with the same expression.
|
||||
@@ -90,12 +147,10 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = doc.add_feature(kind, None)?;
|
||||
if let Some(nf) = doc.feature_mut(id) {
|
||||
nf.component = comp;
|
||||
nf.body_names = f.body_names.iter().map(|b| format!("{b} (copy)")).collect();
|
||||
nf.body_names = copied_names;
|
||||
}
|
||||
let new_name = doc.feature(id).map(|x| x.name.clone()).unwrap_or_default();
|
||||
if matches!(f.kind, FeatureKind::Sketch { .. }) {
|
||||
sketches.insert(f.id, id);
|
||||
}
|
||||
sketches.insert(f.id, id);
|
||||
names.insert(f.name.clone(), new_name.clone());
|
||||
made.push(json!({"id": id, "name": new_name, "from": f.name}));
|
||||
}
|
||||
@@ -105,7 +160,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.filter_map(|m| m["id"].as_u64())
|
||||
.filter_map(|id| s.model.result(id).and_then(|r| r.error.clone()).map(|e| json!({"feature": id, "error": e})))
|
||||
.collect();
|
||||
Ok(json!({"pasted": made, "errors": errors}))
|
||||
Ok(json!({"pasted": made, "errors": errors, "notices":notices}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+226
-127
@@ -8,31 +8,47 @@ use solvecraft_doc::{FeatureKind, Mat, Occurrence};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, num, str_, string_list, vec3};
|
||||
use crate::params::{bad, bool_, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[path = "component_properties.rs"]
|
||||
pub(super) mod properties;
|
||||
#[path = "component_transfer.rs"]
|
||||
mod transfer;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "occurrence_tests.rs"]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod occurrence_tests;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.create", "New Component", new_component)
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("component")
|
||||
.params("name?, parent?: component id or name (default: the active one); activate?: bool (default true)"),
|
||||
.params("name?, parent?: component id or name (default: the active one); parent_path?: exact parent placement; activate?: bool (default true)"),
|
||||
CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies)
|
||||
.icon("component")
|
||||
.params("bodies: [names] → one new component per body, named after it"),
|
||||
CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design)"),
|
||||
CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"),
|
||||
CommandSpec::new("component.list", "List Components", list).noundo(),
|
||||
CommandSpec::new("component.properties", "Component Physical Properties", properties::component)
|
||||
.icon("measure").noundo()
|
||||
.params("component?: id or name (default active); descendants?: bool (default true). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates"),
|
||||
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence)
|
||||
.icon("measure").noundo()
|
||||
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true). Physical properties of this placement in world coordinates, including pending moves"),
|
||||
CommandSpec::new("component.rename", "Rename Component", rename).params("component, name"),
|
||||
CommandSpec::new("component.delete", "Delete Component", delete).params("component: deletes it, its subcomponents, occurrences and features"),
|
||||
CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).params("bodies: [names], component"),
|
||||
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; parent?: component (default: the same parent); translate?: [x,y,z]"),
|
||||
CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: copied with its features into a new, independent component; translate?: [x,y,z]"),
|
||||
.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: 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; 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)"),
|
||||
.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)"),
|
||||
CommandSpec::new("component.capture_position", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"),
|
||||
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("drops pending occurrence moves"),
|
||||
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
|
||||
];
|
||||
|
||||
fn key(v: Option<&Value>) -> Option<String> {
|
||||
@@ -57,12 +73,61 @@ fn translation(t: Vec3) -> Mat {
|
||||
solvecraft_doc::rigid(t, Vec3::ZERO, Vec3::Z, 0.0)
|
||||
}
|
||||
|
||||
fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str) -> Result<Vec3> {
|
||||
let v = match p.get(key) {
|
||||
None => default,
|
||||
Some(value) => vec3(value).ok_or_else(|| bad(cmd, format!("{key} must contain three finite numbers")))?,
|
||||
};
|
||||
if !v.is_finite() || (coordinate && [v.x, v.y, v.z].iter().any(|x| x.abs() > 1e9)) {
|
||||
return Err(bad(cmd, format!("{key} exceeds supported numeric range")));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
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())
|
||||
&& m[3][..3].iter().all(|x| x.abs() <= 1e9)
|
||||
&& [x, y, z].iter().all(|axis| (axis.dot(*axis) - 1.0).abs() < 1e-6)
|
||||
&& x.dot(y).abs() < 1e-6
|
||||
&& x.dot(z).abs() < 1e-6
|
||||
&& y.dot(z).abs() < 1e-6
|
||||
&& x.cross(y).dot(z) > 0.0
|
||||
&& [m[0][3], m[1][3], m[2][3]].iter().all(|x| x.abs() < 1e-9)
|
||||
&& (m[3][3] - 1.0).abs() < 1e-9
|
||||
}
|
||||
|
||||
fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.create";
|
||||
let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { s.active_component };
|
||||
let parent_path = if let Some(value) = p.get("parent_path") {
|
||||
let path = value
|
||||
.as_array()
|
||||
.filter(|a| a.len() < 64)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad(cmd, "parent_path must list up to 63 occurrence ids"))?;
|
||||
let component = if path.is_empty() { 0 } else { s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?.component };
|
||||
if component != parent {
|
||||
return Err(bad(cmd, "parent_path belongs to another component"));
|
||||
}
|
||||
path
|
||||
} else if parent == s.active_component {
|
||||
s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(parent))
|
||||
} else {
|
||||
s.doc.first_path(parent)
|
||||
};
|
||||
if parent_path.len() >= 64 {
|
||||
return Err(bad(cmd, "new component exceeds occurrence depth limit"));
|
||||
}
|
||||
let id = s.doc_mut().add_component(str_(p, "name"), parent)?;
|
||||
if p.get("activate").and_then(Value::as_bool).unwrap_or(true) {
|
||||
let mut path = parent_path;
|
||||
if let Some(o) = s.doc.occurrence_of(id) {
|
||||
path.push(o.id);
|
||||
}
|
||||
s.active_component = id;
|
||||
s.active_occurrence_path = Some(path);
|
||||
}
|
||||
let name = s.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).unwrap_or_default();
|
||||
let occ = s.doc.occurrence_of(id).map(|o| o.id);
|
||||
@@ -70,30 +135,23 @@ fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.from_bodies";
|
||||
let bodies = string_list(p, "bodies");
|
||||
if bodies.is_empty() || bodies.len() > 1000 {
|
||||
return Err(bad(cmd, "`bodies` must list 1…1000 body names"));
|
||||
}
|
||||
let st = s.model.state();
|
||||
for b in &bodies {
|
||||
if st.body(b).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{b}`")));
|
||||
}
|
||||
}
|
||||
let parent = s.active_component;
|
||||
let mut made = Vec::new();
|
||||
for b in bodies {
|
||||
let id = s.doc_mut().add_component(Some(&b), parent)?;
|
||||
s.doc_mut().body_components.insert(b.clone(), id);
|
||||
made.push(json!({"component": id, "body": b}));
|
||||
}
|
||||
Ok(json!({ "components": made }))
|
||||
transfer::from_bodies(s, p)
|
||||
}
|
||||
|
||||
fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = component(s, p, "component", "component.activate")?;
|
||||
let path = if p.get("path").is_some() || p.get("occurrence").is_some() {
|
||||
let occurrence = occurrence(s, p, "component.activate")?;
|
||||
let path = occurrence_path(s, p, occurrence, "component.activate")?;
|
||||
if s.occurrence_placement(&path)?.component != id {
|
||||
return Err(bad("component.activate", "path belongs to another component"));
|
||||
}
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
s.active_component = id;
|
||||
s.active_occurrence_path = path;
|
||||
s.revision += 1;
|
||||
Ok(json!({ "active": id }))
|
||||
}
|
||||
@@ -102,7 +160,7 @@ fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let doc = &s.doc;
|
||||
let node = |id: u64, name: &str, parent: Option<u64>| {
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == id).map(|b| b.name.as_str()).collect();
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.model_body_component(b) == id).map(|b| b.name.as_str()).collect();
|
||||
let features: Vec<&str> = doc
|
||||
.features
|
||||
.iter()
|
||||
@@ -149,12 +207,22 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "the design itself can't be deleted"));
|
||||
}
|
||||
let gone: Vec<u64> = s.doc.components.iter().filter(|c| s.doc.component_within(c.id, id)).map(|c| c.id).collect();
|
||||
if s.doc.features.iter().any(|feature| {
|
||||
!gone.contains(&feature.component)
|
||||
&& matches!(&feature.kind, FeatureKind::BodyTransfer { from, to, .. } if gone.contains(from) || gone.contains(to))
|
||||
}) {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
"component supplies a recorded body transfer outside this subtree; remove that transfer history before deleting its source",
|
||||
));
|
||||
}
|
||||
let feats: Vec<u64> = s.doc.features.iter().filter(|f| gone.contains(&f.component)).map(|f| f.id).collect();
|
||||
let d = s.doc_mut();
|
||||
for f in &feats {
|
||||
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));
|
||||
@@ -166,36 +234,11 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.move_bodies";
|
||||
let to = component(s, p, "component", cmd)?;
|
||||
let bodies = string_list(p, "bodies");
|
||||
let st = s.model.state();
|
||||
if bodies.is_empty() || bodies.iter().any(|b| st.body(b).is_none()) {
|
||||
return Err(bad(cmd, "`bodies` must list existing bodies"));
|
||||
}
|
||||
for b in &bodies {
|
||||
let feature = st.body(b).map(|x| x.feature).unwrap_or(0);
|
||||
let own = s.doc.feature(feature).map(|f| f.component).unwrap_or(0);
|
||||
// The body keeps its place in the world: where it is shown now (its component's frame and
|
||||
// any earlier offset), seen from the new component's frame.
|
||||
let from = s.doc.body_component(b, feature);
|
||||
let now = solvecraft_doc::mat_mul(&s.doc.component_transform(from), s.doc.body_offsets.get(b).unwrap_or(&solvecraft_doc::IDENTITY));
|
||||
let offset = solvecraft_doc::mat_inverse(&s.doc.component_transform(to))
|
||||
.map(|inv| solvecraft_doc::mat_mul(&inv, &now))
|
||||
.unwrap_or(solvecraft_doc::IDENTITY);
|
||||
let doc = s.doc_mut();
|
||||
if own == to {
|
||||
doc.body_components.remove(b);
|
||||
} else {
|
||||
doc.body_components.insert(b.clone(), to);
|
||||
}
|
||||
if solvecraft_doc::is_identity(&offset) {
|
||||
doc.body_offsets.remove(b);
|
||||
} else {
|
||||
doc.body_offsets.insert(b.clone(), offset);
|
||||
}
|
||||
}
|
||||
Ok(json!({"bodies": bodies, "component": to}))
|
||||
transfer::move_bodies(s, p)
|
||||
}
|
||||
|
||||
pub(crate) fn transfer_constructor_result(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
transfer::move_result(s, p)
|
||||
}
|
||||
|
||||
fn move_sketches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -222,16 +265,30 @@ fn move_sketches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn copy_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "occurrence.copy";
|
||||
let comp = if p.get("occurrence").is_some() {
|
||||
let selected = if p.get("occurrence").is_some() {
|
||||
let o = occurrence(s, p, cmd)?;
|
||||
s.doc.occurrences.iter().find(|x| x.id == o).map(|x| x.component).unwrap_or(0)
|
||||
Some(o)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let comp = if let Some(id) = selected {
|
||||
s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.component).unwrap_or(0)
|
||||
} else {
|
||||
component(s, p, "component", cmd)?
|
||||
};
|
||||
let src = s.doc.occurrence_of(comp).cloned().ok_or_else(|| bad(cmd, "that component has no occurrence"))?;
|
||||
let src = if let Some(id) = selected { s.doc.occurrences.iter().find(|o| o.id == id) } else { s.doc.occurrence_of(comp) }
|
||||
.cloned()
|
||||
.ok_or_else(|| bad(cmd, "that component has no occurrence"))?;
|
||||
if p.get("path").is_some() {
|
||||
occurrence_path(s, p, src.id, cmd)?;
|
||||
}
|
||||
let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { src.parent };
|
||||
let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let m = solvecraft_doc::mat_mul(&translation(t), &src.transform);
|
||||
let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?;
|
||||
let base = s.pending_moves.get(&src.id).unwrap_or(&src.transform);
|
||||
let m = solvecraft_doc::mat_mul(&translation(t), base);
|
||||
if !valid_position(&m) {
|
||||
return Err(bad(cmd, "copy transform exceeds supported numeric range"));
|
||||
}
|
||||
let id = s.doc_mut().add_occurrence(comp, parent, m)?;
|
||||
Ok(json!({"occurrence": id, "component": comp}))
|
||||
}
|
||||
@@ -243,68 +300,55 @@ fn paste_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "pick a component, not the design"));
|
||||
}
|
||||
let src = s.doc.components.iter().find(|c| c.id == comp).cloned().ok_or_else(|| bad(cmd, "component"))?;
|
||||
let occ = s.doc.occurrence_of(comp).cloned();
|
||||
let new_id = s.doc_mut().add_component(Some(&format!("{} (copy)", src.name)), src.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;
|
||||
let occ = if p.get("occurrence").is_some() {
|
||||
let id = occurrence(s, p, cmd)?;
|
||||
if p.get("path").is_some() {
|
||||
occurrence_path(s, p, id, cmd)?;
|
||||
}
|
||||
let occurrence = s.doc.occurrences.iter().find(|o| o.id == id).cloned().ok_or_else(|| bad(cmd, "no occurrence"))?;
|
||||
if occurrence.component != comp {
|
||||
return Err(bad(cmd, "occurrence belongs to another component"));
|
||||
}
|
||||
Some(occurrence)
|
||||
} else {
|
||||
s.doc.occurrence_of(comp).cloned()
|
||||
};
|
||||
let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?;
|
||||
let base = occ.as_ref().map(|o| s.pending_moves.get(&o.id).copied().unwrap_or(o.transform)).unwrap_or(solvecraft_doc::IDENTITY);
|
||||
let transform = solvecraft_doc::mat_mul(&translation(t), &base);
|
||||
if !valid_position(&transform) {
|
||||
return Err(bad(cmd, "copy transform exceeds supported numeric range"));
|
||||
}
|
||||
let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let base = occ.map(|o| o.transform).unwrap_or(solvecraft_doc::IDENTITY);
|
||||
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.component == new_id) {
|
||||
o.transform = solvecraft_doc::mat_mul(&translation(t), &base);
|
||||
}
|
||||
Ok(json!({"component": new_id, "features": new_ids}))
|
||||
let parent = occ.as_ref().map(|o| o.parent).unwrap_or(src.parent);
|
||||
super::component_clone::paste(s, comp, occ, transform, parent, p)
|
||||
}
|
||||
|
||||
/// 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),
|
||||
}
|
||||
pub(super) fn occurrence_path(s: &Session, p: &Value, id: u64, cmd: &str) -> Result<Vec<u64>> {
|
||||
let path = if let Some(value) = p.get("path") {
|
||||
value
|
||||
.as_array()
|
||||
.filter(|a| !a.is_empty() && a.len() <= 64)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad(cmd, "`path` must list 1..64 occurrence ids"))?
|
||||
} else {
|
||||
let o = s.doc.occurrences.iter().find(|o| o.id == id).ok_or_else(|| bad(cmd, "no occurrence"))?;
|
||||
let mut path = s.doc.first_path(o.parent);
|
||||
// A world frame requires an explicit containing placement when any ancestor is instanced.
|
||||
for ancestor in &path {
|
||||
if let Some(a) = s.doc.occurrences.iter().find(|a| a.id == *ancestor)
|
||||
&& s.doc.occurrences.iter().filter(|x| x.component == a.component).count() > 1
|
||||
{
|
||||
return Err(bad(cmd, "occurrence has multiple placements; supply its `path`"));
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(|x| remap(x, ids, names, depth + 1)),
|
||||
_ => {}
|
||||
path.push(id);
|
||||
path
|
||||
};
|
||||
if path.last() != Some(&id) {
|
||||
return Err(bad(cmd, "path and occurrence disagree"));
|
||||
}
|
||||
s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -314,19 +358,56 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if o.grounded {
|
||||
return Err(bad(cmd, format!("{} is grounded", o.name)));
|
||||
}
|
||||
let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let axis = p.get("axis").and_then(vec3).unwrap_or(Vec3::Z);
|
||||
let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?;
|
||||
let axis = move_vector(p, "axis", Vec3::Z, false, cmd)?;
|
||||
let angle = match p.get("angle") {
|
||||
Some(Value::String(e)) => s.doc.eval(e, solvecraft_doc::expr::Kind::Angle).map_err(|e| bad(cmd, e.to_string()))?,
|
||||
_ => num(p, "angle").unwrap_or(0.0),
|
||||
None => 0.0,
|
||||
Some(value) => value.as_f64().ok_or_else(|| bad(cmd, "angle must be a number or angle expression"))?,
|
||||
};
|
||||
if !(t.is_finite() && angle.is_finite()) {
|
||||
if !(t.is_finite() && axis.is_finite() && angle.is_finite()) {
|
||||
return Err(bad(cmd, "non-finite move"));
|
||||
}
|
||||
let origin = p.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
if angle != 0.0 && axis.normalized().is_none() {
|
||||
return Err(bad(cmd, "rotation axis must have a finite nonzero length"));
|
||||
}
|
||||
let origin = move_vector(p, "origin", Vec3::ZERO, true, cmd)?;
|
||||
let delta = solvecraft_doc::rigid(t, origin, axis, angle);
|
||||
let cur = s.pending_moves.get(&id).copied().unwrap_or(o.transform);
|
||||
let space = match p.get("space") {
|
||||
None => "local",
|
||||
Some(Value::String(space)) => space.as_str(),
|
||||
_ => return Err(bad(cmd, "space must be local or world")),
|
||||
};
|
||||
if !matches!(space, "local" | "world") {
|
||||
return Err(bad(cmd, "space must be local or world"));
|
||||
}
|
||||
let path = if p.get("path").is_some() || space == "world" { Some(occurrence_path(s, p, id, cmd)?) } else { None };
|
||||
let delta = if space == "world" {
|
||||
let placement = s.occurrence_placement(path.as_deref().unwrap_or(&[]))?;
|
||||
let inverse = solvecraft_doc::mat_inverse(&placement.parent_world).ok_or_else(|| bad(cmd, "parent frame is singular"))?;
|
||||
solvecraft_doc::mat_mul(&solvecraft_doc::mat_mul(&inverse, &delta), &placement.parent_world)
|
||||
} else {
|
||||
delta
|
||||
};
|
||||
let cur = if let Some(value) = p.get("base_transform") {
|
||||
let base: Mat = serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "base_transform must be a 4x4 affine matrix"))?;
|
||||
if !valid_position(&base)
|
||||
|| base[0][3].abs() > 1e-12
|
||||
|| base[1][3].abs() > 1e-12
|
||||
|| base[2][3].abs() > 1e-12
|
||||
|| (base[3][3] - 1.0).abs() > 1e-12
|
||||
|| solvecraft_doc::mat_inverse(&base).is_none()
|
||||
{
|
||||
return Err(bad(cmd, "base_transform must be finite, affine and invertible"));
|
||||
}
|
||||
base
|
||||
} else {
|
||||
s.pending_moves.get(&id).copied().unwrap_or(o.transform)
|
||||
};
|
||||
let next = solvecraft_doc::mat_mul(&delta, &cur);
|
||||
if !valid_position(&next) {
|
||||
return Err(bad(cmd, "move transform exceeds supported numeric range"));
|
||||
}
|
||||
if bool_(p, "capture").unwrap_or(true) {
|
||||
s.pending_moves.remove(&id);
|
||||
if let Some(x) = s.doc_mut().occurrences.iter_mut().find(|x| x.id == id) {
|
||||
@@ -342,6 +423,9 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn ground(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "occurrence.ground";
|
||||
let id = occurrence(s, p, cmd)?;
|
||||
if p.get("path").is_some() {
|
||||
occurrence_path(s, p, id, cmd)?;
|
||||
}
|
||||
let want = bool_(p, "grounded");
|
||||
let o = s.doc_mut().occurrences.iter_mut().find(|o| o.id == id).ok_or_else(|| bad(cmd, "occurrence"))?;
|
||||
o.grounded = want.unwrap_or(!o.grounded);
|
||||
@@ -361,9 +445,24 @@ fn capture(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "captured": n }))
|
||||
}
|
||||
|
||||
fn revert(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let n = s.pending_moves.len();
|
||||
s.pending_moves.clear();
|
||||
fn revert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let before = s.pending_moves.len();
|
||||
if let Some(value) = p.get("occurrences") {
|
||||
let ids = value
|
||||
.as_array()
|
||||
.filter(|a| !a.is_empty() && a.len() <= 10_000)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad("component.revert_position", "occurrences must list 1..10000 ids"))?;
|
||||
if ids.iter().any(|id| !s.doc.occurrences.iter().any(|o| o.id == *id)) {
|
||||
return Err(bad("component.revert_position", "no occurrence"));
|
||||
}
|
||||
for id in ids {
|
||||
s.pending_moves.remove(&id);
|
||||
}
|
||||
} else {
|
||||
s.pending_moves.clear();
|
||||
}
|
||||
let n = before - s.pending_moves.len();
|
||||
s.revision += 1;
|
||||
Ok(json!({ "reverted": n }))
|
||||
}
|
||||
@@ -374,7 +473,7 @@ pub fn to_active_frame(s: &Session, kind: &mut FeatureKind) {
|
||||
if s.active_component == 0 || crate::frames::mapped() {
|
||||
return;
|
||||
}
|
||||
let m = s.doc.component_transform(s.active_component);
|
||||
let m = s.active_component_frame();
|
||||
if solvecraft_doc::is_identity(&m) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
//! 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.model_body_component(b)) && !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 offset = source.model_body_offset(body).copied().unwrap_or(solvecraft_doc::IDENTITY);
|
||||
// An external transfer establishes the local baseline in this graph.
|
||||
// Freeze AFTER that boundary, then replay it as an identity ownership event.
|
||||
let entry = feats.iter().find(|f| {
|
||||
!f.suppressed
|
||||
&& matches!(&f.kind, FeatureKind::BodyTransfer { bodies, from, to, .. }
|
||||
if !selected.contains(from) && bodies.contains(&body.name)
|
||||
&& s.model.result(f.id).is_some_and(|result| !result.skipped && result.error.is_none()
|
||||
&& result.state.body(&body.name).is_some_and(|entered| source.model_body_component(entered) == *to)))
|
||||
});
|
||||
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 entered = entry.and_then(|f| s.model.result(f.id)).map(|r| r.state.clone());
|
||||
let base = entered.as_ref().and_then(|st| st.body(&body.name)).or_else(|| before.as_ref().and_then(|st| st.body(&body.name))).unwrap_or(body);
|
||||
let baseline_component = source.model_body_component(base);
|
||||
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(&baseline_component).ok_or_else(|| bad(CMD, "reassigned baseline is outside copied component graph"))?;
|
||||
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::BodyTransfer { from, to, transform, .. } = &mut f.kind {
|
||||
*to = *components.get(to).ok_or_else(|| bad(CMD, "transfer destination is outside the copied graph"))?;
|
||||
if let Some(mapped) = components.get(from) {
|
||||
*from = *mapped;
|
||||
} else {
|
||||
*from = *to;
|
||||
*transform = solvecraft_doc::IDENTITY;
|
||||
}
|
||||
}
|
||||
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 (FeatureKind::Sketch { plane, .. }, FeatureKind::Sketch { plane: old_plane, .. }) = (&mut f.kind, &old.kind)
|
||||
&& let (Some(anchor), Some(original)) = (super::sketch::face::anchor_mut(plane), super::sketch::face::anchor(old_plane))
|
||||
{
|
||||
if bodies.contains_key(&original.target.body) {
|
||||
anchor.component =
|
||||
*components.get(&original.component).ok_or_else(|| bad(CMD, "anchored source component is outside copied graph"))?;
|
||||
anchor.path = cloned_path(&source, &original.path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
anchor.author_path = cloned_path(&source, &original.author_path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
if let Some(slot) = scratch.doc_mut().feature_mut(f.id) {
|
||||
*slot = f;
|
||||
}
|
||||
}
|
||||
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.model_body_component(b)))
|
||||
.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 face.strict_name {
|
||||
return Err(bad(CMD, "cannot copy strict face paint through uncertified foreign re-export"));
|
||||
}
|
||||
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"))?;
|
||||
}
|
||||
let source_body = state.body(&face.body).ok_or_else(|| bad(CMD, "source frozen face appearance lost its body"))?;
|
||||
face.point = solvecraft_doc::apply_point(source.model_body_offset(source_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();
|
||||
if let Some(certificate) = &mut face.certificate {
|
||||
certificate.remap_features(&features);
|
||||
}
|
||||
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));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let FeatureKind::Sketch { plane, .. } = &feature.kind
|
||||
&& let Some(anchor) = super::sketch::face::anchor(plane)
|
||||
&& !bodies.contains_key(&anchor.target.body)
|
||||
{
|
||||
notices.push(format!(
|
||||
"{} retains external exact face attachment to {} (producer {})",
|
||||
feature.name, anchor.target.body, anchor.target.origin_feature
|
||||
));
|
||||
}
|
||||
for id in feature.kind.input_sketches() {
|
||||
if !features.contains_key(&id) {
|
||||
notices.push(format!("{} retains an external sketch reference to {id}", feature.name));
|
||||
}
|
||||
}
|
||||
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.model_body_component(b))).count();
|
||||
let copied_components: BTreeSet<_> = components.values().copied().collect();
|
||||
let actual_count = copied.bodies.iter().filter(|b| copied_components.contains(&scratch.doc.model_body_component(b))).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.model_body_component(b))) {
|
||||
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.model_body_offset(body).unwrap_or(&solvecraft_doc::IDENTITY))?;
|
||||
let b = solvecraft_kernel::transform_matrix(&cloned.body, *scratch.doc.model_body_offset(cloned).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,467 @@
|
||||
#![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),
|
||||
certificate: None,
|
||||
};
|
||||
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),
|
||||
certificate: None,
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_transfer_entry_and_internal_rotated_transfer_clone_once() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":4,"width":3,"height":2,"body_name":"Foreign"}));
|
||||
let parent = component(&mut s, "Assembly");
|
||||
let a = component(&mut s, "A");
|
||||
let ao = s.doc.occurrence_of(a).unwrap().id;
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":ao,"translate":[10,0,0],"angle":90,"axis":[0,0,1],"capture":true}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":a,"bodies":["Foreign"]}));
|
||||
run(&mut s, "component.activate", json!({"component":parent}));
|
||||
let b = component(&mut s, "B");
|
||||
let bo = s.doc.occurrence_of(b).unwrap().id;
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":bo,"translate":[0,20,0],"angle":90,"axis":[1,0,0],"capture":true}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":b,"bodies":["Foreign"]}));
|
||||
run(&mut s, "solid.move", json!({"bodies":["Foreign"],"translate":[0,0,5]}));
|
||||
let before = s.model.state().body("Foreign").unwrap().mesh().bounds();
|
||||
let copied = copy(&mut s, parent);
|
||||
assert_eq!(copied["frozen_bodies"], json!(["Foreign"]));
|
||||
let state = s.model.state();
|
||||
let body = state.body("Foreign (copy)").unwrap();
|
||||
let after = body.mesh().bounds();
|
||||
assert!(before.min.dist(after.min) < 1e-6);
|
||||
assert!(before.max.dist(after.max) < 1e-6);
|
||||
assert!(body.transferred);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!(reopened.model.state().body("Foreign (copy)").unwrap().mesh().bounds().min.dist(before.min) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suppressed_external_entry_does_not_supply_clone_baseline() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":4,"width":3,"height":2,"body_name":"Foreign"}));
|
||||
let part = component(&mut s, "Part");
|
||||
let occurrence = s.doc.occurrence_of(part).unwrap().id;
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[25,0,0],"angle":90,"axis":[0,0,1],"capture":true}));
|
||||
let skipped = s
|
||||
.doc_mut()
|
||||
.add_feature(
|
||||
solvecraft_doc::FeatureKind::BodyTransfer { bodies: vec!["Foreign".into()], from: 0, to: part, transform: solvecraft_doc::IDENTITY },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
s.doc_mut().feature_mut(skipped).unwrap().suppressed = true;
|
||||
s.refresh();
|
||||
run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Foreign"]}));
|
||||
let before = s.model.state().body("Foreign").unwrap().mesh().bounds();
|
||||
copy(&mut s, part);
|
||||
let after = s.model.state().body("Foreign (copy)").unwrap().mesh().bounds();
|
||||
assert!(before.min.dist(after.min) < 1e-6);
|
||||
assert!(before.max.dist(after.max) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transferred_frozen_face_paint_ignores_consumed_legacy_offset() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Foreign"}));
|
||||
run(&mut s, "appearance.assign", json!({"body":"Foreign","faces":[[1,1,2]],"color":"#00ff00"}));
|
||||
s.doc_mut().body_offsets.insert(
|
||||
"Foreign".into(),
|
||||
solvecraft_doc::rigid(solvecraft_geom::Vec3::new(7.0, 0.0, 0.0), solvecraft_geom::Vec3::ZERO, solvecraft_geom::Vec3::Z, 0.0),
|
||||
);
|
||||
s.refresh();
|
||||
let part = component(&mut s, "Part");
|
||||
run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Foreign"]}));
|
||||
let original = s.doc.appearances.faces.iter().find(|face| face.body == "Foreign").unwrap();
|
||||
let expected = solvecraft_doc::naming::point_on_face(&s.model.state(), original.name.as_ref().unwrap(), original.point).unwrap().0;
|
||||
copy(&mut s, part);
|
||||
let paint = s.doc.appearances.faces.iter().find(|face| face.body == "Foreign (copy)").unwrap();
|
||||
assert!(paint.point.dist(expected) < 1e-6, "{:?} versus {expected:?}", paint.point);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Physical properties of an assembly placement, with original body material assignments.
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{Document, Mat};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn descendants(p: &Value, cmd: &str) -> Result<bool> {
|
||||
match p.get("descendants") {
|
||||
None => Ok(true),
|
||||
Some(Value::Bool(v)) => Ok(*v),
|
||||
Some(_) => Err(bad(cmd, "`descendants` must be a boolean")),
|
||||
}
|
||||
}
|
||||
|
||||
fn placed_doc(s: &Session) -> Document {
|
||||
let mut doc = (*s.doc).clone();
|
||||
for occurrence in &mut doc.occurrences {
|
||||
if let Some(transform) = s.pending_moves.get(&occurrence.id) {
|
||||
occurrence.transform = *transform;
|
||||
}
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
/// A property total must cover the entire assembly. The display traversal intentionally
|
||||
/// truncates hostile designs, so use an explicit error instead of accepting its prefix.
|
||||
pub(in crate::cmd) fn placements(doc: &Document, cmd: &str) -> Result<Vec<(u64, Vec<u64>, Mat)>> {
|
||||
let mut out = vec![(0, Vec::new(), solvecraft_doc::IDENTITY)];
|
||||
let mut stack = vec![(0, Vec::<u64>::new(), solvecraft_doc::IDENTITY)];
|
||||
let mut children: std::collections::BTreeMap<u64, Vec<_>> = std::collections::BTreeMap::new();
|
||||
for occurrence in &doc.occurrences {
|
||||
children.entry(occurrence.parent).or_default().push(occurrence);
|
||||
}
|
||||
while let Some((component, path, transform)) = stack.pop() {
|
||||
for occurrence in children.get(&component).into_iter().flatten() {
|
||||
if path.len() >= 64 || out.len() >= 100_000 {
|
||||
return Err(bad(cmd, "assembly exceeds physical-properties traversal limits (64 levels or 100000 placements)"));
|
||||
}
|
||||
let mut child_path = path.clone();
|
||||
child_path.push(occurrence.id);
|
||||
let world = solvecraft_doc::mat_mul(&transform, &occurrence.transform);
|
||||
out.push((occurrence.component, child_path.clone(), world));
|
||||
stack.push((occurrence.component, child_path, world));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.properties";
|
||||
let id = if p.get("component").is_some() { super::component(s, p, "component", cmd)? } else { s.active_component };
|
||||
let doc = placed_doc(s);
|
||||
let path = doc.first_path(id);
|
||||
let name = if id == 0 {
|
||||
doc.name.clone()
|
||||
} else {
|
||||
doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).ok_or_else(|| bad(cmd, "no active component"))?
|
||||
};
|
||||
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, cmd)?;
|
||||
out["component"] = json!(id);
|
||||
out["name"] = json!(name);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "occurrence.properties";
|
||||
let id = super::occurrence(s, p, cmd)?;
|
||||
let doc = placed_doc(s);
|
||||
let path = if p.get("path").is_some() {
|
||||
super::occurrence_path(s, p, id, cmd)?
|
||||
} else {
|
||||
let paths: Vec<Vec<u64>> =
|
||||
placements(&doc, cmd)?.into_iter().filter(|(_, path, _)| path.last() == Some(&id)).map(|(_, path, _)| path).collect();
|
||||
match paths.as_slice() {
|
||||
[path] => path.clone(),
|
||||
[] => return Err(bad(cmd, "occurrence has no placement")),
|
||||
_ => return Err(bad(cmd, "occurrence has multiple placements; supply its `path`")),
|
||||
}
|
||||
};
|
||||
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, cmd)?;
|
||||
out["occurrence"] = json!(id);
|
||||
out["name"] = json!(doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.as_str()));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, cmd: &str) -> Result<Value> {
|
||||
let state = s.model.state();
|
||||
let mut bodies = Vec::new();
|
||||
let (mut volume, mut area, mut mass) = (0.0, 0.0, 0.0);
|
||||
let mut moment = Vec3::ZERO;
|
||||
let (mut min, mut max) =
|
||||
(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY));
|
||||
let (mut faces, mut edges, mut vertices) = (0, 0, 0);
|
||||
let mut placement_count = 0;
|
||||
let selected_placements = if selected.is_empty() {
|
||||
if descendants { placements(doc, cmd)? } else { vec![(0, Vec::new(), solvecraft_doc::IDENTITY)] }
|
||||
} else {
|
||||
let placement = s.occurrence_placement(selected).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let mut out = vec![(placement.component, selected.to_vec(), placement.world)];
|
||||
let mut stack = if descendants { out.clone() } else { Vec::new() };
|
||||
while let Some((component, path, transform)) = stack.pop() {
|
||||
for occurrence in doc.occurrences.iter().filter(|o| o.parent == component) {
|
||||
if path.len() >= 64 || out.len() >= 100_000 {
|
||||
return Err(bad(cmd, "assembly exceeds physical-properties traversal limits (64 levels or 100000 placements)"));
|
||||
}
|
||||
let mut child = path.clone();
|
||||
child.push(occurrence.id);
|
||||
let world = solvecraft_doc::mat_mul(&transform, &occurrence.transform);
|
||||
out.push((occurrence.component, child.clone(), world));
|
||||
stack.push((occurrence.component, child, world));
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
for (component, path, transform) in selected_placements {
|
||||
placement_count += 1;
|
||||
for body in state.bodies.iter().filter(|b| doc.model_body_component(b) == component) {
|
||||
let transform: Mat = doc.model_body_offset(body).map(|offset| solvecraft_doc::mat_mul(&transform, offset)).unwrap_or(transform);
|
||||
let placed = solvecraft_kernel::transform_matrix(&body.body, transform)?;
|
||||
let measured = solvecraft_kernel::measure(&placed)?;
|
||||
let density = doc.density(&body.name);
|
||||
let body_mass = measured.volume * density / 1000.0;
|
||||
volume += measured.volume;
|
||||
area += measured.area;
|
||||
mass += body_mass;
|
||||
moment += measured.centroid * body_mass;
|
||||
min = Vec3::new(min.x.min(measured.bbox.min.x), min.y.min(measured.bbox.min.y), min.z.min(measured.bbox.min.z));
|
||||
max = Vec3::new(max.x.max(measured.bbox.max.x), max.y.max(measured.bbox.max.y), max.z.max(measured.bbox.max.z));
|
||||
faces += measured.merged.faces;
|
||||
edges += measured.merged.edges;
|
||||
vertices += measured.merged.vertices;
|
||||
bodies.push(json!({"name": body.name, "component": component, "path": path,
|
||||
"material": doc.materials.get(&body.name).map(String::as_str).unwrap_or("Default"),
|
||||
"density_g_cm3": density, "mass_g": body_mass, "volume_mm3": measured.volume,
|
||||
"area_mm2": measured.area, "center_of_mass": measured.centroid,
|
||||
"bbox": {"min": measured.bbox.min, "max": measured.bbox.max}}));
|
||||
}
|
||||
}
|
||||
if !(volume.is_finite() && area.is_finite() && mass.is_finite() && moment.is_finite()) {
|
||||
return Err(bad(cmd, "physical properties exceed supported numeric range"));
|
||||
}
|
||||
let centroid = if mass > 0.0 { Some(moment / mass) } else { None };
|
||||
if centroid.is_some_and(|point| !point.is_finite()) {
|
||||
return Err(bad(cmd, "center of mass exceeds supported numeric range"));
|
||||
}
|
||||
let bbox = if bodies.is_empty() { Value::Null } else { json!({"min": min, "max": max}) };
|
||||
Ok(json!({"path": selected, "coordinate_frame": "world", "descendants": descendants,
|
||||
"placement_count": placement_count, "body_count": bodies.len(), "bodies": bodies,
|
||||
"total": {"volume_mm3": volume, "area_mm2": area, "mass_g": mass,
|
||||
"center_of_mass": centroid, "bbox": bbox, "faces": faces, "edges": edges, "vertices": vertices}}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "component_properties_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,129 @@
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, params: Value) -> Value {
|
||||
s.execute(cmd, ¶ms).unwrap()
|
||||
}
|
||||
fn close(actual: &Value, expected: f64) {
|
||||
assert!((actual.as_f64().unwrap() - expected).abs() < 1e-6, "{actual} != {expected}");
|
||||
}
|
||||
fn box_in(s: &mut Session, name: &str) -> (u64, u64, String) {
|
||||
let c = run(s, "component.create", json!({"name": name}));
|
||||
run(s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let body = s.model.state().bodies.last().unwrap().name.clone();
|
||||
(c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap(), body)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assembly_counts_instances_and_resolves_original_materials() {
|
||||
let mut s = Session::default();
|
||||
let (component, occurrence, body) = box_in(&mut s, "Block");
|
||||
run(&mut s, "material.assign", json!({"bodies": [body], "material": "Steel"}));
|
||||
let copy = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20, 0, 0]}));
|
||||
let rho = s.doc.density(&body);
|
||||
let root = run(&mut s, "component.properties", json!({"component": "root"}));
|
||||
assert_eq!(root["body_count"], 2);
|
||||
close(&root["total"]["mass_g"], 2.0 * rho);
|
||||
close(&root["total"]["volume_mm3"], 2000.0);
|
||||
close(&root["total"]["center_of_mass"][0], 15.0);
|
||||
let first = run(&mut s, "component.properties", json!({"component": component}));
|
||||
assert_eq!(first["body_count"], 1);
|
||||
close(&first["total"]["center_of_mass"][0], 5.0);
|
||||
let second = run(&mut s, "occurrence.properties", json!({"occurrence": copy["occurrence"]}));
|
||||
close(&second["total"]["center_of_mass"][0], 25.0);
|
||||
assert_eq!(second["bodies"][0]["material"], "Steel");
|
||||
assert_eq!(first["path"], json!([occurrence]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mass_centroid_is_density_weighted_and_pending_moves_are_read_only() {
|
||||
let mut s = Session::default();
|
||||
let (a, oa, steel) = box_in(&mut s, "Steel block");
|
||||
run(&mut s, "material.assign", json!({"bodies": [steel], "material": "Steel"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let (_, ob, plastic) = box_in(&mut s, "Plastic block");
|
||||
run(&mut s, "material.assign", json!({"bodies": [plastic], "material": "ABS Plastic"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": ob, "translate": [30, 0, 0], "capture": false}));
|
||||
let doc = s.doc.clone();
|
||||
let moves = s.pending_moves.clone();
|
||||
let r = run(&mut s, "component.properties", json!({"component": 0}));
|
||||
let ra = s.doc.density(&steel);
|
||||
let rb = s.doc.density(&plastic);
|
||||
close(&r["total"]["center_of_mass"][0], (5.0 * ra + 35.0 * rb) / (ra + rb));
|
||||
close(&r["total"]["bbox"]["max"][0], 40.0);
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc, &doc));
|
||||
assert_eq!(s.pending_moves, moves);
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": oa, "translate": [10, 0, 0], "axis": [0, 0, 1], "angle": "90 deg"}));
|
||||
let r = run(&mut s, "component.properties", json!({"component": a}));
|
||||
close(&r["total"]["center_of_mass"][0], 5.0);
|
||||
close(&r["total"]["center_of_mass"][1], 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_instances_require_a_path_and_can_exclude_descendants() {
|
||||
let mut s = Session::default();
|
||||
let parent = run(&mut s, "component.create", json!({"name": "Assembly"}));
|
||||
let (_, child_occurrence, _) = box_in(&mut s, "Child");
|
||||
let copy = run(&mut s, "occurrence.copy", json!({"component": parent["component"], "translate": [100, 0, 0]}));
|
||||
assert!(s.execute("occurrence.properties", &json!({"occurrence": child_occurrence})).is_err());
|
||||
let r = run(&mut s, "occurrence.properties", json!({"occurrence": child_occurrence, "path": [copy["occurrence"], child_occurrence]}));
|
||||
close(&r["total"]["center_of_mass"][0], 105.0);
|
||||
let r = run(&mut s, "component.properties", json!({"component": parent["component"]}));
|
||||
assert_eq!(r["body_count"], 1);
|
||||
let r = run(&mut s, "component.properties", json!({"component": parent["component"], "descendants": false}));
|
||||
assert_eq!(r["body_count"], 0);
|
||||
assert!(r["total"]["center_of_mass"].is_null());
|
||||
assert!(r["total"]["bbox"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_reassignment_offsets_preserve_world_properties() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let body = s.model.state().bodies.first().unwrap().name.clone();
|
||||
let c = run(&mut s, "component.create", json!({"name": "Moved"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": c["occurrence"], "translate": [100, 0, 0]}));
|
||||
run(&mut s, "component.move_bodies", json!({"bodies": [body], "component": c["component"]}));
|
||||
let r = run(&mut s, "component.properties", json!({"component": c["component"]}));
|
||||
close(&r["total"]["center_of_mass"][0], 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_reject_hostile_identifiers_and_options() {
|
||||
let mut s = Session::default();
|
||||
let (_, occurrence, _) = box_in(&mut s, "Block");
|
||||
for p in [json!({"component": []}), json!({"component": "missing"}), json!({"descendants": 1})] {
|
||||
assert!(s.execute("component.properties", &p).is_err());
|
||||
}
|
||||
for path in [json!([]), json!(["bad"]), json!([0]), json!(vec![occurrence; 65])] {
|
||||
assert!(s.execute("occurrence.properties", &json!({"occurrence": occurrence, "path": path})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_exponentially_instanced_assembly_refuses_partial_totals() {
|
||||
let mut s = Session::default();
|
||||
let mut last_occurrence = 0;
|
||||
// Only 17 component definitions and 34 occurrences, but over 100000 placements.
|
||||
for _ in 0..17 {
|
||||
let parent = s.active_component;
|
||||
let component = s.doc_mut().add_component(None, parent).unwrap();
|
||||
last_occurrence = s.doc_mut().add_occurrence(component, parent, solvecraft_doc::IDENTITY).unwrap();
|
||||
s.active_component = component;
|
||||
}
|
||||
for command in ["component.properties", "occurrence.properties"] {
|
||||
let error = s.execute(command, &json!({"component": 0, "occurrence": last_occurrence})).unwrap_err();
|
||||
assert!(error.to_string().contains("traversal limits"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excessively_nested_assembly_refuses_partial_totals() {
|
||||
let mut s = Session::default();
|
||||
for _ in 0..65 {
|
||||
let parent = s.active_component;
|
||||
s.active_component = s.doc_mut().add_component(None, parent).unwrap();
|
||||
}
|
||||
let error = s.execute("component.properties", &json!({"component": 0})).unwrap_err();
|
||||
assert!(error.to_string().contains("traversal limits"), "{error}");
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Atomic timeline ownership changes, preserving the selected world geometry.
|
||||
use super::*;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
type UniqueFrames = BTreeMap<u64, Option<(Vec<u64>, Mat)>>;
|
||||
|
||||
fn unique_frames(s: &Session, cmd: &str) -> Result<UniqueFrames> {
|
||||
let doc = s.placed_document();
|
||||
let mut children = BTreeMap::<u64, Vec<&Occurrence>>::new();
|
||||
for occurrence in &doc.occurrences {
|
||||
children.entry(occurrence.parent).or_default().push(occurrence);
|
||||
}
|
||||
let mut frames = BTreeMap::from([(0, Some((Vec::new(), solvecraft_doc::IDENTITY)))]);
|
||||
let mut stack = vec![(0, Vec::new(), solvecraft_doc::IDENTITY)];
|
||||
let mut work = 0usize;
|
||||
while let Some((component, path, matrix)) = stack.pop() {
|
||||
for occurrence in children.get(&component).into_iter().flatten() {
|
||||
work += 1;
|
||||
if path.len() >= 64 || work > 100_000 {
|
||||
return Err(bad(cmd, "transfer assembly exceeds 64 levels or 100000 placements"));
|
||||
}
|
||||
let mut child_path = path.clone();
|
||||
child_path.push(occurrence.id);
|
||||
let frame = solvecraft_doc::mat_mul(&matrix, &occurrence.transform);
|
||||
frames.entry(occurrence.component).and_modify(|entry| *entry = None).or_insert_with(|| Some((child_path.clone(), frame)));
|
||||
stack.push((occurrence.component, child_path, frame));
|
||||
}
|
||||
}
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
fn body_names(s: &Session, p: &Value, cmd: &str, allow_boundary: bool) -> Result<Vec<String>> {
|
||||
if p.get("bodies").and_then(Value::as_array).is_none_or(|names| names.iter().any(|name| !name.is_string())) {
|
||||
return Err(bad(cmd, "bodies must be an array of body names"));
|
||||
}
|
||||
let names = string_list(p, "bodies");
|
||||
if names.is_empty() || names.len() > 1000 || names.iter().collect::<BTreeSet<_>>().len() != names.len() {
|
||||
return Err(bad(cmd, "bodies must list 1..1000 distinct existing definition bodies"));
|
||||
}
|
||||
if s.doc.marker.is_some() && !allow_boundary {
|
||||
return Err(bad(cmd, "finish timeline rollback before transferring bodies"));
|
||||
}
|
||||
let state = s.model.state();
|
||||
for name in &names {
|
||||
if state.body(name).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{name}`")));
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
fn transfer(s: &mut Session, name: &str, to: u64, frames: &UniqueFrames, cmd: &str) -> Result<u64> {
|
||||
let state = s.model.state();
|
||||
let body = state.body(name).ok_or_else(|| bad(cmd, "body is missing"))?;
|
||||
let from = s.doc.model_body_component(body);
|
||||
let doc = s.placed_document();
|
||||
let unique = |component| {
|
||||
frames.get(&component).and_then(Option::as_ref).ok_or_else(|| {
|
||||
bad(cmd, "body transfer requires one source and destination placement; shared instances cannot preserve a unique world pose")
|
||||
})
|
||||
};
|
||||
let source = unique(from)?;
|
||||
let destination = unique(to)?;
|
||||
let old = doc.model_body_offset(body).map(|offset| solvecraft_doc::mat_mul(&source.1, offset)).unwrap_or(source.1);
|
||||
let inv = solvecraft_doc::mat_inverse(&destination.1).ok_or_else(|| bad(cmd, "destination frame is not invertible"))?;
|
||||
let transform = solvecraft_doc::mat_mul(&inv, &old);
|
||||
if !solvecraft_doc::valid_transfer_matrix(&transform) {
|
||||
return Err(bad(cmd, "body transfer exceeds supported rigid coordinate range"));
|
||||
}
|
||||
let inherited = (!s.doc.appearances.bodies.contains_key(name)).then(|| s.doc.appearances.components.get(&from).cloned()).flatten();
|
||||
let paths: BTreeSet<u64> = source.0.iter().chain(&destination.0).copied().collect();
|
||||
let captures: Vec<_> = paths.into_iter().filter_map(|id| s.pending_moves.get(&id).map(|transform| (id, *transform))).collect();
|
||||
if captures.iter().any(|(_, transform)| !solvecraft_doc::valid_transfer_matrix(transform)) {
|
||||
return Err(bad(cmd, "pending transfer position is not a supported rigid transform"));
|
||||
}
|
||||
drop(doc);
|
||||
for (id, transform) in captures {
|
||||
if let Some(occurrence) = s.doc_mut().occurrences.iter_mut().find(|occurrence| occurrence.id == id) {
|
||||
occurrence.transform = transform;
|
||||
}
|
||||
s.pending_moves.remove(&id);
|
||||
}
|
||||
if let Some(look) = inherited {
|
||||
s.doc_mut().appearances.bodies.insert(name.to_string(), look);
|
||||
}
|
||||
let id = s.doc_mut().add_feature(FeatureKind::BodyTransfer { bodies: vec![name.to_string()], from, to, transform }, None)?;
|
||||
if let Some(feature) = s.doc_mut().feature_mut(id) {
|
||||
feature.component = to;
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn finish(s: &mut Session, scratch: &mut Session, features: &[u64], cmd: &str) -> Result<()> {
|
||||
scratch.refresh();
|
||||
for id in features {
|
||||
if let Some(error) = scratch.model.result(*id).and_then(|result| result.error.as_ref()) {
|
||||
return Err(bad(cmd, error));
|
||||
}
|
||||
}
|
||||
s.accept_computed(std::mem::take(scratch));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.from_bodies";
|
||||
let bodies = body_names(s, p, cmd, false)?;
|
||||
let mut scratch = s.scratch();
|
||||
let parent = s.active_component;
|
||||
let mut made = Vec::new();
|
||||
let mut targets = Vec::new();
|
||||
for name in &bodies {
|
||||
let component = scratch.doc_mut().add_component(Some(name), parent)?;
|
||||
targets.push(component);
|
||||
}
|
||||
let frames = unique_frames(&scratch, cmd)?;
|
||||
let mut features = Vec::new();
|
||||
for (name, component) in bodies.into_iter().zip(targets) {
|
||||
let feature = transfer(&mut scratch, &name, component, &frames, cmd)?;
|
||||
features.push(feature);
|
||||
made.push(json!({"component":component,"body":name,"feature":feature}));
|
||||
}
|
||||
finish(s, &mut scratch, &features, cmd)?;
|
||||
Ok(json!({"components":made}))
|
||||
}
|
||||
|
||||
pub(super) fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
move_at_boundary(s, p, false)
|
||||
}
|
||||
|
||||
/// A solid constructor can attach its result immediately after the new feature,
|
||||
/// including inside the scratch timeline used by Edit Feature.
|
||||
pub(super) fn move_result(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
move_at_boundary(s, p, true)
|
||||
}
|
||||
|
||||
fn move_at_boundary(s: &mut Session, p: &Value, allow_boundary: bool) -> Result<Value> {
|
||||
let cmd = "component.move_bodies";
|
||||
let to = super::component(s, p, "component", cmd)?;
|
||||
let bodies = body_names(s, p, cmd, allow_boundary)?;
|
||||
let mut scratch = s.scratch();
|
||||
let frames = unique_frames(&scratch, cmd)?;
|
||||
let mut features = Vec::new();
|
||||
for name in &bodies {
|
||||
features.push(transfer(&mut scratch, name, to, &frames, cmd)?);
|
||||
}
|
||||
finish(s, &mut scratch, &features, cmd)?;
|
||||
Ok(json!({"bodies":bodies,"component":to,"features":features}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "component_transfer_tests.rs"]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,287 @@
|
||||
use super::*;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_assignment_preserves_sample_joined_material_and_timeline() {
|
||||
let mut s = Session::default();
|
||||
s.run_script(&crate::sample::script()).unwrap();
|
||||
let before = s.model.state();
|
||||
assert_eq!(before.bodies.len(), 1);
|
||||
let volume = solvecraft_kernel::measure(&before.body("Plate").unwrap().body).unwrap().volume;
|
||||
let created = run(&mut s, "component.from_bodies", json!({"bodies":["Plate"]}));
|
||||
let to = created["components"][0]["component"].as_u64().unwrap();
|
||||
let feature = created["components"][0]["feature"].as_u64().unwrap();
|
||||
let after = s.model.state();
|
||||
assert_eq!(after.bodies.len(), 1);
|
||||
let body = after.body("Plate").unwrap();
|
||||
assert_eq!(body.component, to);
|
||||
assert!(body.transferred);
|
||||
assert!((solvecraft_kernel::measure(&body.body).unwrap().volume - volume).abs() < 1e-6);
|
||||
assert!(body.mesh().contains(Vec3::new(40.0, 35.0, 12.0)));
|
||||
let index = s.doc.feature_index(feature).unwrap();
|
||||
s.doc_mut().marker = Some(index);
|
||||
s.refresh();
|
||||
assert_eq!(s.world_state().body("Plate").unwrap().component, 0);
|
||||
s.doc_mut().marker = None;
|
||||
s.refresh();
|
||||
assert_eq!(s.world_state().body("Plate").unwrap().component, to);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transformed_pending_destination_repeated_transfer_preserves_world_pose() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
let created = run(&mut s, "component.create", json!({"name":"Destination"}));
|
||||
let to = created["component"].as_u64().unwrap();
|
||||
let occurrence = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,0],"angle":90,"axis":[0,0,1],"capture":false}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":to,"bodies":["Block"]}));
|
||||
let state = s.world_state();
|
||||
let bounds = state.body("Block").unwrap().mesh().bounds();
|
||||
assert!(bounds.min.dist(Vec3::ZERO) < 1e-6);
|
||||
assert!(bounds.max.dist(Vec3::new(10.0, 10.0, 10.0)) < 1e-6);
|
||||
assert!(!s.pending_moves.contains_key(&occurrence));
|
||||
let mut destination_reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
destination_reopened.refresh();
|
||||
assert!(destination_reopened.world_state().body("Block").unwrap().mesh().bounds().min.dist(Vec3::ZERO) < 1e-6);
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(Vec3::ZERO) < 1e-6);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!(reopened.world_state().body("Block").unwrap().mesh().bounds().min.dist(Vec3::ZERO) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_destination_refuses_without_mutating_document_or_session() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
let c = run(&mut s, "component.create", json!({"name":"Shared"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.copy", json!({"component":c,"translate":[20,0,0]}));
|
||||
let document = s.doc.to_json();
|
||||
let active = s.active_component;
|
||||
assert!(s.execute("component.move_bodies", &json!({"component":c,"bodies":["Block"]})).is_err());
|
||||
assert_eq!(s.doc.to_json(), document);
|
||||
assert_eq!(s.active_component, active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_join_cut_and_new_destination_modeling_keep_material() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
run(&mut s, "solid.box", json!({"corner":[8,0,0],"length":10,"width":10,"height":10,"operation":"join"}));
|
||||
run(&mut s, "solid.box", json!({"corner":[2,2,0],"length":2,"width":2,"height":10,"operation":"cut"}));
|
||||
let original = s.model.state();
|
||||
assert_eq!(original.bodies.len(), 1);
|
||||
let v = solvecraft_kernel::measure(&original.body("Block").unwrap().body).unwrap().volume;
|
||||
assert!((v - 1760.0).abs() < 1e-6);
|
||||
run(&mut s, "material.assign", json!({"bodies":["Block"],"material":"Brass"}));
|
||||
run(&mut s, "appearance.assign", json!({"bodies":["Block"],"color":"#bc8537"}));
|
||||
let created = run(&mut s, "component.from_bodies", json!({"bodies":["Block"]}));
|
||||
let c = created["components"][0]["component"].as_u64().unwrap();
|
||||
assert_eq!(s.doc.materials["Block"], "Brass");
|
||||
assert_eq!(s.doc.appearances.bodies["Block"].color, [188, 133, 55]);
|
||||
run(&mut s, "component.activate", json!({"component":c}));
|
||||
run(&mut s, "solid.box", json!({"corner":[18,0,0],"length":2,"width":10,"height":10,"operation":"join"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
assert_eq!(s.model.state().body("Block").unwrap().component, c);
|
||||
let final_volume = solvecraft_kernel::measure(&s.model.state().body("Block").unwrap().body).unwrap().volume;
|
||||
assert!((final_volume - 1960.0).abs() < 1e-6);
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
assert_eq!(s.model.state().body("Block").unwrap().component, 0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.model.state().body("Block").unwrap().component, c);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!((solvecraft_kernel::measure(&reopened.model.state().body("Block").unwrap().body).unwrap().volume - final_volume).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ownership_only_boundary_invalidates_old_boolean_cache() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
run(&mut s, "solid.box", json!({"corner":[8,0,0],"length":10,"width":10,"height":10,"operation":"join"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
let c = s.doc_mut().add_component(Some("Destination"), 0).unwrap();
|
||||
let id = s
|
||||
.doc_mut()
|
||||
.add_feature(FeatureKind::BodyTransfer { bodies: vec!["Block".into()], from: 0, to: c, transform: solvecraft_doc::IDENTITY }, None)
|
||||
.unwrap();
|
||||
let index = s.doc.feature_index(id).unwrap();
|
||||
let transfer = s.doc_mut().features.remove(index);
|
||||
s.doc_mut().features.insert(1, transfer);
|
||||
s.refresh();
|
||||
assert_eq!(s.model.state().bodies.len(), 2);
|
||||
assert_eq!(s.model.state().body("Block").unwrap().component, c);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert_eq!(reopened.model.state().bodies.len(), 2);
|
||||
assert_eq!(reopened.model.state().body("Block").unwrap().component, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_transfer_cannot_change_evaluated_world_owner() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
let c = s.doc_mut().add_component(Some("Destination"), 0).unwrap();
|
||||
let id = s
|
||||
.doc_mut()
|
||||
.add_feature(FeatureKind::BodyTransfer { bodies: vec!["Block".into()], from: c, to: c, transform: solvecraft_doc::IDENTITY }, None)
|
||||
.unwrap();
|
||||
s.refresh();
|
||||
assert!(s.model.result(id).unwrap().error.is_some());
|
||||
assert_eq!(s.world_state().body("Block").unwrap().component, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transferred_thread_follows_body_frame_without_changing_world_axis() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":4,"height":20,"body_name":"Shaft"}));
|
||||
run(&mut s, "solid.thread", json!({"face":[4,0,10],"designation":"M8"}));
|
||||
let before = s.world_state().threads[0].clone();
|
||||
let c = run(&mut s, "component.create", json!({"name":"Threaded"}));
|
||||
let to = c["component"].as_u64().unwrap();
|
||||
let occurrence = c["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,10],"axis":[1,0,0],"angle":90,"capture":true}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":to,"bodies":["Shaft"]}));
|
||||
let after = s.world_state().threads[0].clone();
|
||||
assert!(after.axis_point.dist(before.axis_point) < 1e-6);
|
||||
assert!(after.axis.dist(before.axis) < 1e-6);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!(reopened.world_state().threads[0].axis.dist(before.axis) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_component_appearance_and_legacy_assignment_survive_transfer() {
|
||||
let mut s = Session::default();
|
||||
let source = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
s.doc_mut().appearances.components.insert(source, solvecraft_doc::appearance::Look { name: "Green".into(), color: [0, 150, 0], opacity: 0.7 });
|
||||
s.refresh();
|
||||
let look = s.doc.model_body_look(s.model.state().body("Block").unwrap()).unwrap();
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
assert_eq!(s.doc.model_body_look(s.model.state().body("Block").unwrap()), Some(look));
|
||||
// An unrecorded legacy assignment retains its established replay policy.
|
||||
let mut legacy = Session::default();
|
||||
run(&mut legacy, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Old"}));
|
||||
let c = legacy.doc_mut().add_component(Some("Legacy"), 0).unwrap();
|
||||
legacy.doc_mut().body_components.insert("Old".into(), c);
|
||||
legacy.doc_mut().body_offsets.insert("Old".into(), solvecraft_doc::rigid(Vec3::new(7.0, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0));
|
||||
legacy.refresh();
|
||||
assert!(!legacy.model.state().body("Old").unwrap().transferred);
|
||||
run(&mut legacy, "component.move_bodies", json!({"component":0,"bodies":["Old"]}));
|
||||
assert!((legacy.world_state().body("Old").unwrap().mesh().bounds().min.x - 7.0).abs() < 1e-6);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&legacy.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!((reopened.world_state().body("Old").unwrap().mesh().bounds().min.x - 7.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_external_transfer_source_refuses_without_dangling_archive() {
|
||||
let mut s = Session::default();
|
||||
let a = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let b = run(&mut s, "component.create", json!({"name":"Destination"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "component.move_bodies", json!({"component":b,"bodies":["Block"]}));
|
||||
let document = s.doc.to_json();
|
||||
assert!(s.execute("component.delete", &json!({"component":a})).is_err());
|
||||
assert_eq!(s.doc.to_json(), document);
|
||||
assert!(solvecraft_doc::Document::from_json(&document).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn earlier_selected_cylinder_emboss_keeps_source_frame_after_transfer_and_edit() {
|
||||
let mut s = Session::default();
|
||||
let source = run(&mut s, "component.create", json!({"name":"Source"}));
|
||||
let occurrence = source["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":true}));
|
||||
run(&mut s, "sketch.create", json!({"name":"Badge","plane":{"origin":[30,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!({}));
|
||||
let emboss = run(&mut s, "solid.emboss", json!({"sketch":"Badge","face":[30,10,7.5],"depth":1}))["feature"].as_u64().unwrap();
|
||||
let before = solvecraft_kernel::measure(&s.world_state().body("Sleeve").unwrap().body).unwrap().volume;
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let destination = run(&mut s, "component.create", json!({"name":"Destination"}));
|
||||
let to = destination["component"].as_u64().unwrap();
|
||||
let destination_occurrence = destination["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":destination_occurrence,"translate":[0,40,0],"axis":[1,0,0],"angle":90,"capture":true}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":to,"bodies":["Sleeve"]}));
|
||||
assert!(s.doc.features.iter().all(|feature| s.model.result(feature.id).is_some_and(|result| result.error.is_none())));
|
||||
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(|feature| reopened.model.result(feature.id).is_some_and(|result| result.error.is_none())));
|
||||
assert!((solvecraft_kernel::measure(&reopened.world_state().body("Sleeve").unwrap().body).unwrap().volume - before).abs() < 1e-5);
|
||||
let depth = reopened.doc.feature(emboss).unwrap().param_names[0].clone();
|
||||
run(&mut reopened, "parameters.change", json!({"name":depth,"expression":"2 mm"}));
|
||||
assert!(solvecraft_kernel::measure(&reopened.world_state().body("Sleeve").unwrap().body).unwrap().volume > before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_undo_redo_restores_original_pending_source_world_pose() {
|
||||
let mut s = Session::default();
|
||||
let created = run(&mut s, "component.create", json!({"name":"Source"}));
|
||||
let source = created["component"].as_u64().unwrap();
|
||||
let occurrence = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[10,0,0],"capture":false}));
|
||||
let pending = s.pending_moves.clone();
|
||||
let before = s.world_state().body("Block").unwrap().mesh().bounds();
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(s.world_state().body("Block").unwrap().component, source);
|
||||
assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(before.min) < 1e-6);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(s.pending_moves.is_empty());
|
||||
assert_eq!(s.world_state().body("Block").unwrap().component, 0);
|
||||
assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(before.min) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_checkpoint_restores_pending_world_pose_and_history() {
|
||||
let mut s = Session::default();
|
||||
let created = run(&mut s, "component.create", json!({"name":"Source"}));
|
||||
let source = created["component"].as_u64().unwrap();
|
||||
let occurrence = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[10,0,0],"capture":false}));
|
||||
let pending = s.pending_moves.clone();
|
||||
let before = s.world_state().body("Block").unwrap().mesh().bounds();
|
||||
let undo_count = s.undo.len();
|
||||
let checkpoint = s.checkpoint();
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
s.restore_checkpoint(checkpoint).unwrap();
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(s.undo.len(), undo_count);
|
||||
assert_eq!(s.world_state().body("Block").unwrap().component, source);
|
||||
assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(before.min) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edited_transfer_destination_updates_history_owner_copy_and_reopen() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"}));
|
||||
let a = run(&mut s, "component.create", json!({"name":"A","parent":0}))["component"].as_u64().unwrap();
|
||||
let b = run(&mut s, "component.create", json!({"name":"B","parent":0}))["component"].as_u64().unwrap();
|
||||
let transferred = run(&mut s, "component.move_bodies", json!({"component":a,"bodies":["Block"]}));
|
||||
let feature = transferred["features"][0].as_u64().unwrap();
|
||||
run(&mut s, "timeline.edit", json!({"feature":feature,"set":{"to":b,"component":a}}));
|
||||
assert_eq!(s.doc.feature(feature).unwrap().component, b);
|
||||
assert_eq!(s.model.state().body("Block").unwrap().component, b);
|
||||
let saved = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
assert!(s.execute("timeline.edit", &json!({"feature":feature,"set":{"to":u64::MAX}})).is_err());
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
let copied = run(&mut s, "component.paste_new", json!({"component":b}))["component"].as_u64().unwrap();
|
||||
assert_eq!(s.model.state().body("Block (copy)").unwrap().component, copied);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert_eq!(reopened.model.state().body("Block").unwrap().component, b);
|
||||
assert_eq!(reopened.model.state().body("Block (copy)").unwrap().component, copied);
|
||||
}
|
||||
+561
-11
@@ -10,21 +10,90 @@ use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("config.configure", "Configure", configure).at("SOLID", "CONFIGURE").icon("params").params(
|
||||
"params?: [parameter names]; features?: [feature names] (columns to add; with no rows yet, a \"Default\" row takes the current values)",
|
||||
"params?: [parameter names]; features?: [feature names]; materials?: [body names]; appearances?: [body names] (columns to add; with no rows yet, a \"Default\" row takes the current values)",
|
||||
),
|
||||
CommandSpec::new("config.table", "Display Configuration Table", show)
|
||||
.at("SOLID", "CONFIGURE")
|
||||
.icon("params")
|
||||
.noundo()
|
||||
.params("→ columns (param:<name> or suppress:<feature>), rows, the active row and whether the design still matches it"),
|
||||
CommandSpec::new("config.column", "Configuration Column", column).params("param? | feature? (name); delete?: bool"),
|
||||
CommandSpec::new("config.column", "Configuration Column", column).params("param? | feature? | material? | appearance? (target name); delete?: bool"),
|
||||
CommandSpec::new("config.row", "Configuration Row", row).params(
|
||||
"name; from? (row to copy; default the current values); values?: {param name or feature name: expression or true/false}; rename?; delete?: bool",
|
||||
"name; from? (row to copy; default current values); values?: {column key: expression, suppression, material name, appearance name or Look object}; rename?; delete?: bool",
|
||||
),
|
||||
CommandSpec::new("config.activate", "Activate Configuration", activate).params("row: name — sets its parameter values and suppressions"),
|
||||
CommandSpec::new("config.preview", "Preview Configuration", preview).noundo().params("row: name — validates a rebuild without changing the design or history"),
|
||||
CommandSpec::new("config.rule", "Configuration Rule", rule).params("rule?: {id,name,enabled,condition,effect}; id? + delete?; id? + index? (ordered move)"),
|
||||
CommandSpec::new("config.rules", "Configuration Rules", show).noundo().params("ordered typed rules, rows and active state"),
|
||||
];
|
||||
|
||||
fn column_of(s: &Session, p: &Value, cmd: &str) -> Result<Column> {
|
||||
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"));
|
||||
}
|
||||
if let Some(target) = value.get("target") {
|
||||
solvecraft_doc::config::validate_face_target_json(target).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
let target: solvecraft_doc::config::ConfigFaceTarget =
|
||||
serde_json::from_value(target.clone()).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
let state = s.model.state();
|
||||
let body = state.body(&target.body).ok_or_else(|| bad(cmd, "no face definition"))?;
|
||||
s.doc.configuration_face_indices(body, &target).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
return Ok(Column::FaceAppearance(target));
|
||||
}
|
||||
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 target = s.doc.configuration_face_target(body, index, point).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
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) {
|
||||
let c = if appearance { Column::Appearance(body.to_string()) } else { Column::Material(body.to_string()) };
|
||||
if !s.doc.configs.columns.contains(&c) && s.model.state().body(body).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{body}`")));
|
||||
}
|
||||
return Ok(c);
|
||||
}
|
||||
}
|
||||
if let Some(n) = str_(p, "param") {
|
||||
if !s.doc.all_param_exprs().iter().any(|(m, _, _)| m == n) {
|
||||
return Err(bad(cmd, format!("no parameter `{n}`")));
|
||||
@@ -38,7 +107,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 {
|
||||
@@ -63,6 +134,25 @@ fn configure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = column_of(s, &json!({ "feature": n }), cmd)?;
|
||||
add_column(s, c, cmd)?;
|
||||
}
|
||||
for key in ["materials", "appearances"] {
|
||||
for n in string_list(p, key) {
|
||||
let field = if key == "materials" { "material" } else { "appearance" };
|
||||
let c = column_of(s, &json!({ field: n }), cmd)?;
|
||||
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;
|
||||
@@ -80,7 +170,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, "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> {
|
||||
@@ -98,6 +207,9 @@ fn column(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
add_column(s, c, cmd)?;
|
||||
}
|
||||
if !s.doc.configs.rules.is_empty() {
|
||||
validate_rows(s, cmd)?;
|
||||
}
|
||||
show(s, p)
|
||||
}
|
||||
|
||||
@@ -107,6 +219,9 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let idx = s.doc.configs.rows.iter().position(|r| r.name == name);
|
||||
if bool_(p, "delete").unwrap_or(false) {
|
||||
let i = idx.ok_or_else(|| bad(cmd, format!("no row `{name}`")))?;
|
||||
if s.doc.configs.rows.len() == 1 && !s.doc.configs.rules.is_empty() {
|
||||
return Err(bad(cmd, "delete rules before deleting the last configuration row"));
|
||||
}
|
||||
let t = &mut s.doc_mut().configs;
|
||||
t.rows.remove(i);
|
||||
if t.active.as_deref() == Some(name.as_str()) {
|
||||
@@ -122,33 +237,58 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(vals) = p.get("values") {
|
||||
let vals = vals.as_object().ok_or_else(|| bad(cmd, "`values` must map columns to values"))?;
|
||||
for (k, v) in vals {
|
||||
let text = match v {
|
||||
let mut text = match v {
|
||||
Value::String(t) => t.clone(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Number(n) => n.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 {
|
||||
return Err(bad(cmd, "value too long"));
|
||||
}
|
||||
let key = k.strip_prefix("param:").or_else(|| k.strip_prefix("suppress:")).unwrap_or(k);
|
||||
let col = s
|
||||
.doc
|
||||
.configs
|
||||
.columns
|
||||
.iter()
|
||||
.position(|c| match c {
|
||||
Column::Param(n) => n == key,
|
||||
Column::Suppress(id) => s.doc.feature(*id).is_some_and(|f| f.name == key),
|
||||
.position(|c| {
|
||||
s.doc.config_key(c) == *k
|
||||
|| 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(_) | Column::ComponentAppearance(_) | Column::FaceAppearance(_) => false,
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| bad(cmd, format!("`{k}` is not a column (config.column adds one)")))?;
|
||||
match s.doc.configs.columns.get(col) {
|
||||
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(_) | 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(),
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(cell) = cells.get_mut(col) {
|
||||
*cell = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
let rename = str_(p, "rename").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).map(str::to_string);
|
||||
if p.get("rename").is_some() && (rename.is_none() || idx.is_none()) {
|
||||
return Err(bad(cmd, "rename requires an existing row and a nonempty name up to 128 bytes"));
|
||||
}
|
||||
if rename.as_ref().is_some_and(|n| n != &name && s.doc.configs.rows.iter().any(|r| &r.name == n)) {
|
||||
return Err(bad(cmd, "configuration name already exists"));
|
||||
}
|
||||
let t = &mut s.doc_mut().configs;
|
||||
let validated_name = rename.clone().unwrap_or_else(|| name.clone());
|
||||
match idx {
|
||||
Some(i) => {
|
||||
if let Some(r) = t.rows.get_mut(i) {
|
||||
@@ -168,20 +308,118 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
t.rows.push(ConfigRow { name, cells });
|
||||
}
|
||||
}
|
||||
if !s.doc.configs.rules.is_empty() {
|
||||
let mut trial = s.scratch();
|
||||
activate(&mut trial, &json!({"row":validated_name})).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
}
|
||||
show(s, p)
|
||||
}
|
||||
|
||||
fn validate_rows(s: &Session, cmd: &str) -> Result<()> {
|
||||
if !s.doc.configs.rules.is_empty() && s.doc.configs.rows.is_empty() {
|
||||
return Err(bad(cmd, "rules require at least one configuration row"));
|
||||
}
|
||||
for row in &s.doc.configs.rows {
|
||||
let mut trial = s.scratch();
|
||||
activate(&mut trial, &json!({"row":row.name})).map_err(|e| bad(cmd, format!("row `{}`: {e}", row.name)))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rule(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "config.rule";
|
||||
if let Some(value) = p.get("rule") {
|
||||
solvecraft_doc::config::validate_rules_json(&json!({"configs":{"rules":[value]}})).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let mut rule: solvecraft_doc::config::ConfigRule = serde_json::from_value(value.clone()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
if rule.length_unit.is_none() {
|
||||
rule.length_unit = s
|
||||
.doc
|
||||
.configs
|
||||
.rules
|
||||
.iter()
|
||||
.find(|existing| existing.id == rule.id)
|
||||
.and_then(|existing| existing.length_unit.clone())
|
||||
.or_else(|| Some(s.doc.units.clone()));
|
||||
}
|
||||
let rules = &mut s.doc_mut().configs.rules;
|
||||
if let Some(index) = rules.iter().position(|r| r.id == rule.id) {
|
||||
rules[index] = rule;
|
||||
} else {
|
||||
if rules.len() >= solvecraft_doc::config::MAX_RULES {
|
||||
return Err(bad(cmd, "too many rules"));
|
||||
}
|
||||
rules.push(rule);
|
||||
}
|
||||
} else {
|
||||
let id = str_(p, "id").ok_or_else(|| bad(cmd, "give rule or id"))?;
|
||||
let rules = &mut s.doc_mut().configs.rules;
|
||||
let index = rules.iter().position(|r| r.id == id).ok_or_else(|| bad(cmd, "unknown rule"))?;
|
||||
if bool_(p, "delete").unwrap_or(false) {
|
||||
rules.remove(index);
|
||||
} else {
|
||||
let position = p
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|v| usize::try_from(v).ok())
|
||||
.filter(|v| *v < rules.len())
|
||||
.ok_or_else(|| bad(cmd, "valid index required"))?;
|
||||
let rule = rules.remove(index);
|
||||
rules.insert(position, rule);
|
||||
}
|
||||
}
|
||||
validate_rows(s, cmd)?;
|
||||
show(s, p)
|
||||
}
|
||||
|
||||
fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "config.activate";
|
||||
let r = str_(p, "row").ok_or_else(|| bad(cmd, "`row` is required"))?.to_string();
|
||||
s.doc_mut().apply_configuration(&r).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let mut trial = s.scratch();
|
||||
let trace = trial.doc_mut().apply_configuration_with_trace(&r).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
trial.refresh();
|
||||
let new_errors: Vec<String> = trial
|
||||
.model
|
||||
.results
|
||||
.iter()
|
||||
.filter_map(|result| {
|
||||
result
|
||||
.error
|
||||
.as_ref()
|
||||
.filter(|error| s.model.result(result.id).is_none_or(|before| before.error.as_ref() != Some(*error)))
|
||||
.map(|error| format!("{}: {error}", result.name))
|
||||
})
|
||||
.collect();
|
||||
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)
|
||||
}
|
||||
|
||||
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut trial = s.scratch();
|
||||
let mut out = activate(&mut trial, p)?;
|
||||
out["preview"] = json!(true);
|
||||
out["parameters"] = json!(trial.doc.param_rows());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "config_certificate_tests.rs"]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod certificate_tests;
|
||||
#[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};
|
||||
@@ -199,6 +437,186 @@ mod tests {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_activation_tolerates_identical_existing_errors_but_rejects_reenabled_failure() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name":"radius","expression":"10 mm"}));
|
||||
run(&mut s, "solid.sphere", json!({"radius":"radius"}));
|
||||
let feature = s.doc.features[0].name.clone();
|
||||
run(&mut s, "config.configure", json!({"params":["radius"],"features":[feature]}));
|
||||
run(&mut s, "config.row", json!({"name":"Existing error","values":{"radius":"-1 mm"}}));
|
||||
run(&mut s, "parameters.change", json!({"name":"radius","expression":"-1 mm"}));
|
||||
assert!(s.model.results[0].error.is_some());
|
||||
let existing = run(&mut s, "config.activate", json!({"row":"Existing error"}));
|
||||
assert_eq!(existing["errors"].as_array().unwrap().len(), 1);
|
||||
s.doc_mut().features[0].suppressed = true;
|
||||
s.refresh();
|
||||
assert!(s.model.results[0].error.is_none());
|
||||
run(&mut s, "config.row", json!({"name":"Reenabled failure","values":{"radius":"-1 mm",format!("suppress:{feature}"):false}}));
|
||||
let before = s.doc.clone();
|
||||
assert!(s.execute("config.activate", &json!({"row":"Reenabled failure"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert!(s.model.results[0].error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rows_and_literal_rule_thresholds_keep_physical_units_on_switch() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10}));
|
||||
let length = s.doc.features[0].param_names[0].clone();
|
||||
run(&mut s, "config.configure", json!({"params":[length]}));
|
||||
run(
|
||||
&mut s,
|
||||
"config.rule",
|
||||
json!({"rule":{"id":"limit","name":"Metric stock","condition":{"type":"parameter","parameter":length,"operator":"le","expression":"20"},"effect":{"type":"parameter_range","parameter":length,"minimum":"5","maximum":"20"}}}),
|
||||
);
|
||||
run(&mut s, "document.units", json!({"units":"in"}));
|
||||
assert_eq!(s.doc.configs.rows[0].cells[0], "10 mm");
|
||||
let rule = serde_json::to_value(&s.doc.configs.rules[0]).unwrap();
|
||||
assert_eq!(rule["condition"]["expression"], "20 mm");
|
||||
assert_eq!(rule["effect"]["minimum"], "5 mm");
|
||||
let preview = run(&mut s, "config.preview", json!({"row":"Default"}));
|
||||
assert_eq!(preview["trace"][0]["matched"], true);
|
||||
run(&mut s, "config.activate", json!({"row":"Default"}));
|
||||
assert!((volume(&mut s) - 1000.0).abs() < 1e-6);
|
||||
assert!(s.doc.configuration_matches());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.units, "mm");
|
||||
assert_eq!(s.doc.configs.rows[0].cells[0], "10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_mixed_rule_expressions_keep_authoring_units_without_rewriting_source() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":20,"height":10}));
|
||||
let length = s.doc.features[0].param_names[0].clone();
|
||||
let width = s.doc.features[0].param_names[1].clone();
|
||||
run(&mut s, "config.configure", json!({"params":[length,width]}));
|
||||
let expression = format!("{width} + 1");
|
||||
run(
|
||||
&mut s,
|
||||
"config.rule",
|
||||
json!({"rule":{"id":"drive","name":"Stock extra","condition":{"type":"always"},"effect":{"type":"drive_parameter","parameter":length,"expression":expression}}}),
|
||||
);
|
||||
run(
|
||||
&mut s,
|
||||
"config.rule",
|
||||
json!({"rule":{"id":"range","name":"Stock bound","condition":{"type":"parameter","parameter":length,"operator":"lt","expression":format!("{width} + 2")},"effect":{"type":"parameter_range","parameter":length,"minimum":width,"maximum":format!("{width} + 2")}}}),
|
||||
);
|
||||
run(&mut s, "config.activate", json!({"row":"Default"}));
|
||||
let before = volume(&mut s);
|
||||
assert!((before - 4200.0).abs() < 1e-6);
|
||||
run(&mut s, "document.units", json!({"units":"in"}));
|
||||
assert!((volume(&mut s) - before).abs() < 1e-6, "matched row rederives drives in their authoring unit");
|
||||
assert!(s.doc.configuration_matches());
|
||||
let rule = serde_json::to_value(&s.doc.configs.rules[0]).unwrap();
|
||||
assert_eq!(rule["effect"]["expression"], expression);
|
||||
assert_eq!(rule["length_unit"], "mm");
|
||||
let preview = run(&mut s, "config.preview", json!({"row":"Default"}));
|
||||
assert_eq!(preview["trace"][1]["matched"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rule_edits_enforce_existing_rows_and_are_undoable() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name":"length","expression":"10 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length":"length","width":10,"height":10}));
|
||||
run(&mut s, "config.configure", json!({"params":["length"]}));
|
||||
let rule = json!({"id":"limit","name":"Length limit","condition":{"type":"always"},"effect":{"type":"parameter_range","parameter":"length","minimum":"5 mm","maximum":"20 mm"}});
|
||||
run(&mut s, "config.rule", json!({"rule":rule}));
|
||||
let before = s.doc.clone();
|
||||
assert!(s.execute("config.row", &json!({"name":"Default","delete":true})).is_err());
|
||||
assert_eq!(s.doc, before, "last row remains while rules exist");
|
||||
assert!(s.execute("config.row", &json!({"name":"Too large","values":{"length":"30 mm"}})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
let impossible = json!({"id":"limit","name":"Length limit","condition":{"type":"always"},"effect":{"type":"parameter_range","parameter":"length","minimum":"15 mm","maximum":"20 mm"}});
|
||||
assert!(s.execute("config.rule", &json!({"rule":impossible})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
let preview = run(&mut s, "config.preview", json!({"row":"Default"}));
|
||||
assert_eq!(preview["trace"][0]["name"], "Length limit");
|
||||
assert_eq!(preview["trace"][0]["matched"], true);
|
||||
assert_eq!(s.doc, before);
|
||||
run(&mut s, "config.rule", json!({"id":"limit","delete":true}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc, before);
|
||||
let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened.configs.rules, s.doc.configs.rules);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_preview_and_failed_geometry_leave_design_unchanged() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name":"length", "expression":"10 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length":"length", "width":10, "height":10}));
|
||||
run(&mut s, "config.configure", json!({"params":["length"]}));
|
||||
run(&mut s, "config.row", json!({"name":"Large", "values":{"length":"20 mm"}}));
|
||||
run(&mut s, "config.row", json!({"name":"Degenerate", "values":{"length":"0 mm"}}));
|
||||
let before = s.doc.clone();
|
||||
let revision = s.revision;
|
||||
assert_eq!(run(&mut s, "config.preview", json!({"row":"Large"}))["preview"], true);
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.revision, revision);
|
||||
assert!(s.execute("config.preview", &json!({"row":"Degenerate"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert!(s.execute("config.activate", &json!({"row":"Degenerate"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert!((volume(&mut s) - 1000.0).abs() < 1e-6);
|
||||
run(&mut s, "config.activate", json!({"row":"Large"}));
|
||||
assert!((volume(&mut s) - 2000.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_feature_inputs_and_suppression_match_typed_cells() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10, "width":10, "height":10}));
|
||||
let feature = s.doc.features[0].name.clone();
|
||||
let length = s.doc.features[0].param_names[0].clone();
|
||||
s.doc_mut().units = "in".into();
|
||||
run(&mut s, "config.configure", json!({"params":[length], "features":[feature]}));
|
||||
assert_eq!(s.doc.configs.rows[0].cells[0], "10 mm", "Default captures stored feature literals in their base unit");
|
||||
run(&mut s, "config.activate", json!({"row":"Default"}));
|
||||
assert!((volume(&mut s) - 1000.0).abs() < 1e-6, "captured Default preserves geometry in an inch design");
|
||||
run(&mut s, "config.row", json!({"name":"Inches", "values":{length.clone():"2", format!("suppress:{feature}"):"NO"}}));
|
||||
run(&mut s, "config.activate", json!({"row":"Inches"}));
|
||||
assert_eq!(s.doc.config_current(&solvecraft_doc::config::Column::Param(length.clone())).as_deref(), Some("2 in"));
|
||||
assert!(s.doc.configuration_matches());
|
||||
assert!((volume(&mut s) - 5080.0).abs() < 1e-6);
|
||||
let feature_id = s.doc.features[0].id;
|
||||
run(
|
||||
&mut s,
|
||||
"config.rule",
|
||||
json!({"rule":{"id":"inch-condition","name":"Inch condition","condition":{"type":"parameter","parameter":length,"operator":"gt","expression":"5"},"effect":{"type":"drive_suppression","feature":feature_id,"value":true}}}),
|
||||
);
|
||||
run(
|
||||
&mut s,
|
||||
"config.rule",
|
||||
json!({"rule":{"id":"inch-range","name":"Inch range","condition":{"type":"always"},"effect":{"type":"parameter_range","parameter":length,"minimum":"0","maximum":"5"}}}),
|
||||
);
|
||||
let preview = run(&mut s, "config.preview", json!({"row":"Inches"}));
|
||||
assert_eq!(preview["trace"][0]["matched"], false, "bare comparison means inches for a feature length");
|
||||
assert_eq!(preview["trace"][1]["matched"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_parameters_cannot_be_deleted_and_deleted_features_prune_columns() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name":"spare", "expression":"10 mm"}));
|
||||
run(&mut s, "config.configure", json!({"params":["spare"]}));
|
||||
assert!(s.doc_mut().remove_param("spare").is_err());
|
||||
run(&mut s, "config.column", json!({"param":"spare", "delete":true}));
|
||||
s.doc_mut().remove_param("spare").unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10}));
|
||||
let feature = &s.doc.features[0];
|
||||
let id = feature.id;
|
||||
let length = feature.param_names[0].clone();
|
||||
let name = feature.name.clone();
|
||||
run(&mut s, "config.configure", json!({"params":[length], "features":[name]}));
|
||||
s.doc_mut().delete_feature(id).unwrap();
|
||||
assert!(s.doc.configs.columns.is_empty());
|
||||
assert!(s.doc.configs.rows.iter().all(|row| row.cells.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configurations_switch_sizes_and_suppressions() {
|
||||
let mut s = Session::default();
|
||||
@@ -236,4 +654,136 @@ mod tests {
|
||||
run(&mut s, "config.column", json!({"feature": fillet, "delete": true}));
|
||||
assert_eq!(run(&mut s, "config.table", json!({}))["columns"].as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_materials_and_looks_are_undoable_and_persistent() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let body = s.model.state().bodies.first().unwrap().name.clone();
|
||||
run(&mut s, "config.configure", json!({"materials": [body], "appearances": [body]}));
|
||||
let material = format!("material:{body}");
|
||||
let appearance = format!("appearance:{body}");
|
||||
run(&mut s, "config.row", json!({"name": "Metal", "values": {material.clone(): "steel", appearance.clone(): "ABS - Red"}}));
|
||||
run(
|
||||
&mut s,
|
||||
"config.row",
|
||||
json!({"name": "Custom", "from": "Metal", "values": {
|
||||
material.clone(): "Aluminum", appearance.clone(): {"name": "Ocean", "color": [1, 2, 3], "opacity": 0.4}
|
||||
}}),
|
||||
);
|
||||
run(&mut s, "config.activate", json!({"row": "Metal"}));
|
||||
assert_eq!(s.doc.materials.get(&body).map(String::as_str), Some("Steel"));
|
||||
assert_eq!(s.doc.appearances.bodies.get(&body).unwrap().color, [196, 40, 40]);
|
||||
let mass = run(&mut s, "inspect.measure", json!({}));
|
||||
assert!((mass["bodies"][0]["mass_g"].as_f64().unwrap() - 7.85).abs() < 1e-6);
|
||||
assert_eq!(run(&mut s, "config.table", json!({}))["matches"], true);
|
||||
run(&mut s, "config.activate", json!({"row": "Custom"}));
|
||||
let mass = run(&mut s, "inspect.measure", json!({}));
|
||||
assert!((mass["bodies"][0]["mass_g"].as_f64().unwrap() - 2.7).abs() < 1e-6);
|
||||
assert_eq!(s.doc.appearances.bodies.get(&body).unwrap().opacity, 0.4);
|
||||
let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened, *s.doc);
|
||||
assert!(reopened.configuration_matches());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.materials.get(&body).map(String::as_str), Some("Steel"));
|
||||
run(&mut s, "config.activate", json!({"row": "Default"}));
|
||||
assert!(!s.doc.materials.contains_key(&body));
|
||||
assert!(!s.doc.appearances.bodies.contains_key(&body));
|
||||
run(&mut s, "config.activate", json!({"row": "Metal"}));
|
||||
run(&mut s, "appearance.assign", json!({"bodies": [body], "appearance": "ABS - Blue"}));
|
||||
assert!(!s.doc.configuration_matches());
|
||||
run(&mut s, "config.column", json!({"appearance": body, "delete": true}));
|
||||
assert_eq!(s.doc.configs.columns.len(), 1);
|
||||
assert!(s.doc.configs.rows.iter().all(|r| r.cells.len() == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_styles_reject_hostile_values_without_changes() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let body = s.model.state().bodies.first().unwrap().name.clone();
|
||||
run(&mut s, "config.configure", json!({"materials": [body], "appearances": [body]}));
|
||||
let material = format!("material:{body}");
|
||||
let appearance = format!("appearance:{body}");
|
||||
for params in [
|
||||
json!({"name": "Bad", "values": {material: "Unobtainium"}}),
|
||||
json!({"name": "Bad", "values": {appearance.clone(): "#oops"}}),
|
||||
json!({"name": "Bad", "values": {appearance.clone(): {"name": "Invalid", "color": [1, 2, 300], "opacity": 1}}}),
|
||||
json!({"name": "Bad", "values": {appearance: {"name": "Invalid", "color": [1, 2, 3], "opacity": -1}}}),
|
||||
json!({"name": "Default", "rename": ""}),
|
||||
] {
|
||||
let before = s.doc.clone();
|
||||
assert!(s.execute("config.row", ¶ms).is_err(), "{params}");
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
assert!(s.execute("config.column", &json!({"appearance": "missing"})).is_err());
|
||||
assert!(s.execute("config.column", &json!({"appearance": body, "material": body})).is_err());
|
||||
run(&mut s, "config.row", json!({"name": "Other"}));
|
||||
assert!(s.execute("config.row", &json!({"name": "Other", "rename": "Default"})).is_err());
|
||||
// A malformed persisted row must fail atomically rather than silently apply its prefix.
|
||||
s.doc_mut().configs.rows.first_mut().unwrap().cells.pop();
|
||||
let before = s.doc.clone();
|
||||
assert!(s.execute("config.activate", &json!({"row": "Default"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_captures_unicode_custom_appearance_names() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let body = s.model.state().bodies.first().unwrap().name.clone();
|
||||
let name = "é".repeat(128);
|
||||
run(&mut s, "appearance.assign", json!({"bodies": [body], "appearance": name, "color": [40, 90, 186]}));
|
||||
run(&mut s, "config.configure", json!({"appearances": [body]}));
|
||||
run(&mut s, "appearance.assign", json!({"bodies": [body]}));
|
||||
run(&mut s, "config.activate", json!({"row": "Default"}));
|
||||
assert_eq!(s.doc.appearances.bodies.get(&body).unwrap().name, name);
|
||||
assert!(s.doc.configuration_matches());
|
||||
let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
assert!(reopened.configuration_matches());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_styles_survive_parameter_and_body_renames() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name": "Steel", "expression": "10 mm"}));
|
||||
run(&mut s, "parameters.change", json!({"name": "double", "expression": "Steel * 2"}));
|
||||
run(&mut s, "solid.box", json!({"length": "double", "width": 10, "height": 10, "body_name": "Block"}));
|
||||
run(&mut s, "config.configure", json!({"params": ["Steel", "double"], "materials": ["Block"], "appearances": ["Block"]}));
|
||||
run(
|
||||
&mut s,
|
||||
"config.row",
|
||||
json!({"name": "Styled", "values": {
|
||||
"param:Steel": "20 mm", "param:double": "Steel * 3", "material:Block": "Steel", "appearance:Block": "Steel - Satin"
|
||||
}}),
|
||||
);
|
||||
run(&mut s, "config.activate", json!({"row": "Styled"}));
|
||||
let styles: Vec<Vec<String>> = s.doc.configs.rows.iter().map(|r| r.cells.iter().skip(2).cloned().collect()).collect();
|
||||
run(&mut s, "parameters.rename", json!({"name": "Steel", "new_name": "gauge"}));
|
||||
let after: Vec<Vec<String>> = s.doc.configs.rows.iter().map(|r| r.cells.iter().skip(2).cloned().collect()).collect();
|
||||
assert_eq!(after, styles, "renaming an expression reference must preserve style cells verbatim");
|
||||
let table = run(&mut s, "config.table", json!({}));
|
||||
assert_eq!(table["columns"][0], "param:gauge");
|
||||
assert_eq!(table["rows"][1]["values"]["param:double"], "gauge * 3");
|
||||
assert!(s.doc.configuration_matches());
|
||||
run(&mut s, "body.rename", json!({"body": "Block", "name": "Housing"}));
|
||||
let table = run(&mut s, "config.table", json!({}));
|
||||
assert_eq!(table["columns"][2], "material:Housing");
|
||||
assert_eq!(table["columns"][3], "appearance:Housing");
|
||||
assert!(s.doc.configuration_matches());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(run(&mut s, "config.table", json!({}))["columns"][2], "material:Block");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
let mut reopened = Session::new(solvecraft_doc::format::read(&s.doc.to_json()).unwrap());
|
||||
run(&mut reopened, "config.activate", json!({"row": "Default"}));
|
||||
assert!(!reopened.doc.materials.contains_key("Housing"));
|
||||
assert!(!reopened.doc.appearances.bodies.contains_key("Housing"));
|
||||
run(&mut reopened, "config.activate", json!({"row": "Styled"}));
|
||||
assert_eq!(reopened.doc.materials.get("Housing").map(String::as_str), Some("Steel"));
|
||||
assert_eq!(reopened.doc.appearances.bodies.get("Housing").unwrap().name, "Steel - Satin");
|
||||
assert!(!reopened.doc.materials.contains_key("Block"));
|
||||
assert!(!reopened.doc.appearances.bodies.contains_key("Block"));
|
||||
assert!((volume(&mut reopened) - 6000.0).abs() < 1e-6);
|
||||
assert!(reopened.doc.configuration_matches());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{
|
||||
FeatureKind,
|
||||
config::{Column, FaceCertificate},
|
||||
};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
fn run(session: &mut Session, command: &str, params: Value) -> Value {
|
||||
session.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command} {params}: {error}"))
|
||||
}
|
||||
fn paint(session: &mut Session, body: &str, point: Value) {
|
||||
let table = run(session, "config.configure", json!({"face_appearances":[{"body":body,"point":point}]}));
|
||||
let key = table["columns"][0].as_str().unwrap();
|
||||
run(session, "config.row", json!({"name":"Painted","values":{key:"ABS - Red"}}));
|
||||
run(session, "config.activate", json!({"row":"Painted"}));
|
||||
}
|
||||
fn painted_radius(session: &Session, name: &str, radius: f64) -> usize {
|
||||
let state = session.model.state();
|
||||
let body =
|
||||
state.body(name).unwrap_or_else(|| panic!("missing {name}; bodies {:?}", state.bodies.iter().map(|body| &body.name).collect::<Vec<_>>()));
|
||||
let painted = session.doc.face_colors(body);
|
||||
let mesh = body.body.tessellate(0.001).unwrap();
|
||||
let inverse = solvecraft_doc::mat_inverse(&body.source_transform).unwrap();
|
||||
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
if painted.iter().any(|(index, _)| *index == *face as usize) {
|
||||
for vertex in mesh.tri(triangle).unwrap() {
|
||||
let vertex = solvecraft_doc::apply_point(&inverse, vertex);
|
||||
assert!((vertex.x.hypot(vertex.y) - radius).abs() < 1e-7, "{name}: {vertex:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(!painted.is_empty());
|
||||
painted.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_annulus_side_rows_follow_radius_edits_and_clone() {
|
||||
let mut s = Session::default();
|
||||
let part = run(&mut s, "component.create", json!({"name":"RingPart"}))["component"].as_u64().unwrap();
|
||||
let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"outer"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities":["outer"],"type":"radius","value":10}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":9,"id":"inner"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities":["inner"],"type":"radius","value":9}));
|
||||
let FeatureKind::Sketch { sketch: source_sketch, .. } = &s.doc.feature(sketch).unwrap().kind else { panic!() };
|
||||
let dimensions: Vec<_> = source_sketch.constraints.iter().filter_map(|dimension| dimension.param.clone()).collect();
|
||||
assert_eq!(dimensions.len(), 2);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":10,"profiles":[{"point":[9.5,0]}],"body_name":"Ring"}));
|
||||
paint(&mut s, "Ring", json!([10, 0, 5]));
|
||||
assert_eq!(painted_radius(&s, "Ring", 10.), 2, "both constructed arc pieces share outer curve");
|
||||
let Column::FaceAppearance(target) = &s.doc.configs.columns[0] else { panic!() };
|
||||
assert_eq!(target.certificate, Some(FaceCertificate::ExtrudeSide { sketch, curve: "outer".into() }));
|
||||
run(&mut s, "parameters.change", json!({"name":dimensions[0],"expression":"12 mm"}));
|
||||
run(&mut s, "parameters.change", json!({"name":dimensions[1],"expression":"9.25 mm"}));
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Ring", 12.), 2);
|
||||
run(&mut s, "component.paste_new", json!({"component":part,"translate":[30,0,0]}));
|
||||
assert_eq!(s.doc.configs.columns.len(), 2);
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Ring (copy)", 12.), 2);
|
||||
let reopened = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
let mut reopened = Session::new(reopened);
|
||||
run(&mut reopened, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&reopened, "Ring", 12.), 2);
|
||||
assert_eq!(painted_radius(&reopened, "Ring (copy)", 12.), 2);
|
||||
run(&mut reopened, "config.activate", json!({"row":"Default"}));
|
||||
assert!(reopened.doc.appearances.faces.is_empty());
|
||||
}
|
||||
|
||||
fn imported(session: &mut Session, step: String, name: &str) -> u64 {
|
||||
let owner = session.active_component;
|
||||
let id = session.doc_mut().add_feature(FeatureKind::Import { file: "fixture.step".into(), step, components: vec![] }, Some(name)).unwrap();
|
||||
let feature = session.doc_mut().feature_mut(id).unwrap();
|
||||
feature.body_names = vec![name.into()];
|
||||
feature.component = owner;
|
||||
session.refresh();
|
||||
assert!(session.model.result(id).unwrap().error.is_none());
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_v9_imported_emboss_anchor_and_ordinary_face_paint_replay_without_renaming() {
|
||||
let mut s = Session::default();
|
||||
let cylinder = solvecraft_kernel::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap();
|
||||
let step = solvecraft_kernel::step_export(&[&cylinder], "legacy anchored cylinder").unwrap();
|
||||
let source = imported(&mut s, step, "Sleeve");
|
||||
run(&mut s, "material.assign", json!({"bodies":["Sleeve"],"material":"Steel"}));
|
||||
let before_volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
let legacy_names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap());
|
||||
assert!(legacy_names.iter().all(|name| name.starts_with(&format!("F{source}:face:"))));
|
||||
run(&mut s, "appearance.assign", json!({"faces":[{"body":"Sleeve","point":[6,0,20]}],"appearance":"ABS - Blue"}));
|
||||
assert!(s.doc.appearances.faces[0].certificate.is_none());
|
||||
let badge = run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,20]},"name":"Badge","project_edges":false}))["sketch"].as_u64().unwrap();
|
||||
let FeatureKind::Sketch { plane: saved_plane, .. } = &s.doc.feature(badge).unwrap().kind else { panic!() };
|
||||
let saved_plane = saved_plane.clone();
|
||||
assert!(matches!(&saved_plane, solvecraft_doc::PlaneRef::Face { name: Some(name), .. } if legacy_names.contains(name)));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,-2],"p1":[2,2]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","depth":1,"targets":["Sleeve"],"name":"Raised Badge"}));
|
||||
let anchored = s.doc.find_feature("Raised Badge").unwrap().clone();
|
||||
assert!(matches!(anchored.kind, FeatureKind::Emboss { face: None, .. }));
|
||||
let volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
assert!((volume - std::f64::consts::PI * 2000. - 16.).abs() < 0.1, "volume {volume}");
|
||||
assert!((volume - before_volume - 16.).abs() < 0.01, "addedvolume {} baseline {before_volume}", volume - before_volume);
|
||||
let paint = s.doc.appearances.faces.clone();
|
||||
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
legacy["format"] = json!("solvecraft/9");
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.to_string()).unwrap());
|
||||
assert_eq!(reopened.doc.feature(anchored.id).unwrap(), &anchored);
|
||||
let FeatureKind::Sketch { plane, .. } = &reopened.doc.feature(badge).unwrap().kind else { panic!() };
|
||||
assert_eq!(plane, &saved_plane);
|
||||
assert_eq!(reopened.doc.appearances.faces, paint);
|
||||
assert_eq!(reopened.doc.materials.get("Sleeve"), Some(&"Steel".to_string()));
|
||||
assert!(reopened.model.result(anchored.id).unwrap().error.is_none());
|
||||
let state = reopened.model.state();
|
||||
let body = state.body("Sleeve").unwrap();
|
||||
assert!((body.body.tessellate_with(1e-4, true).unwrap().measure().volume - volume).abs() < 1e-8);
|
||||
assert!(!reopened.doc.face_colors(body).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_side_refuses_reused_body_name_from_a_new_producer() {
|
||||
let mut s = Session::default();
|
||||
let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"circle"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let source = run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Round"}))["feature"].as_u64().unwrap();
|
||||
paint(&mut s, "Round", json!([10, 0, 5]));
|
||||
let Column::FaceAppearance(target) = s.doc.configs.columns[0].clone() else { panic!() };
|
||||
let mut replacement = s.doc.feature(source).unwrap().clone();
|
||||
replacement.id = s.doc.next_id;
|
||||
assert!(matches!(replacement.kind, FeatureKind::Extrude { sketch: original, .. } if original == sketch));
|
||||
s.doc_mut().feature_mut(source).unwrap().suppressed = true;
|
||||
s.doc_mut().next_id += 1;
|
||||
s.doc_mut().features.push(replacement);
|
||||
s.refresh();
|
||||
let state = s.model.state();
|
||||
let body = state.body("Round").unwrap();
|
||||
assert!(s.doc.configuration_face_indices(body, &target).is_err());
|
||||
assert!(s.doc.face_colors(body).is_empty());
|
||||
let before = s.doc.clone();
|
||||
let history = s.undo.len();
|
||||
assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_v9_annulus_outer_side_emboss_keeps_its_original_heuristic_anchor() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"outer"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":9,"id":"inner"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":20,"profiles":[{"point":[9.5,0]}],"body_name":"Sleeve"}));
|
||||
let names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap());
|
||||
// Preserve the legacy centroid-family name even though strict paint uses
|
||||
// the separately certified outer source curve.
|
||||
let name = solvecraft_doc::naming::face_names_at(&s.model.state(), &[Vec3::new(0., 10., 7.5)]).remove(0);
|
||||
assert!(name.contains("side:inner"));
|
||||
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"],"name":"Raised Badge"}));
|
||||
let feature = s.doc.find_feature("Raised Badge").unwrap();
|
||||
assert_eq!(feature.face_names, vec![name]);
|
||||
assert!(feature.face_names.iter().all(|name| names.contains(name)));
|
||||
let feature_id = feature.id;
|
||||
let volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
assert!((volume - std::f64::consts::PI * 380. - 21.).abs() < 0.2, "volume {volume}");
|
||||
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
legacy["format"] = json!("solvecraft/9");
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.to_string()).unwrap());
|
||||
assert!(reopened.model.result(feature_id).unwrap().error.is_none());
|
||||
assert!((reopened.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume - volume).abs() < 1e-8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_side_transfer_preserves_geometry_and_unsupported_taper_refuses_atomically() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let source = run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Round"}))["feature"].as_u64().unwrap();
|
||||
paint(&mut s, "Round", json!([10, 0, 5]));
|
||||
let created = run(&mut s, "component.create", json!({"name":"Placed"}));
|
||||
let destination = created["component"].as_u64().unwrap();
|
||||
let occurrence = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,0],"angle":90,"axis":[0,0,1],"capture":false}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":destination,"bodies":["Round"]}));
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Round", 10.), 2);
|
||||
let before = s.world_state().body("Round").unwrap().mesh().bounds();
|
||||
let mut s = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Round", 10.), 2);
|
||||
assert!(
|
||||
s.world_state().body("Round").unwrap().mesh().bounds().min.dist(before.min) < 1e-8,
|
||||
"before {before:?}, reopened {:?}",
|
||||
s.world_state().body("Round").unwrap().mesh().bounds()
|
||||
);
|
||||
// A successful ordinary modifier has no exact untapered source certificate.
|
||||
let FeatureKind::Extrude { extent, .. } = &mut s.doc_mut().feature_mut(source).unwrap().kind else { panic!() };
|
||||
extent.taper = Some("5 deg".into());
|
||||
s.refresh();
|
||||
assert!(s.model.result(source).unwrap().error.is_none());
|
||||
assert!(s.doc.face_colors(s.model.state().body("Round").unwrap()).is_empty());
|
||||
let document = s.doc.clone();
|
||||
let history = s.undo.len();
|
||||
assert!(s.execute("config.preview", &json!({"row":"Painted"})).is_err());
|
||||
assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err());
|
||||
assert_eq!(s.doc, document);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_import_payload_binding_roundtrip_clone_and_replacement_refusal() {
|
||||
let geometry = solvecraft_kernel::cylinder(Vec3::ZERO, Vec3::Z, 10., 10.).unwrap();
|
||||
let step = solvecraft_kernel::step_export(&[&geometry], "certificate fixture").unwrap();
|
||||
let mut s = Session::default();
|
||||
let part = run(&mut s, "component.create", json!({"name":"ImportedPart"}))["component"].as_u64().unwrap();
|
||||
let source = imported(&mut s, step.clone(), "Imported");
|
||||
paint(&mut s, "Imported", json!([10, 0, 5]));
|
||||
let pieces = painted_radius(&s, "Imported", 10.);
|
||||
let Column::FaceAppearance(target) = &s.doc.configs.columns[0] else { panic!() };
|
||||
assert!(
|
||||
matches!(&target.certificate, Some(FaceCertificate::Imported { item, face, payload_sha256 }) if *item != 0 && *face != 0 && payload_sha256.len() == 64)
|
||||
);
|
||||
run(&mut s, "component.paste_new", json!({"component":part,"translate":[30,0,0]}));
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Imported (copy)", 10.), pieces);
|
||||
let reopened = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
let mut s = Session::new(reopened);
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Imported", 10.), pieces);
|
||||
// Identical geometry, entity IDs and item IDs, but a replaced embedded payload.
|
||||
let FeatureKind::Import { step: payload, .. } = &mut s.doc_mut().feature_mut(source).unwrap().kind else { panic!() };
|
||||
*payload = step.replace("certificate fixture", "replacement fixture");
|
||||
assert_ne!(*payload, step);
|
||||
s.refresh();
|
||||
assert!(s.doc.face_colors(s.model.state().body("Imported").unwrap()).is_empty());
|
||||
assert_eq!(painted_radius(&s, "Imported (copy)", 10.), pieces);
|
||||
let before = s.doc.clone();
|
||||
let history = s.undo.len();
|
||||
assert!(s.execute("config.preview", &json!({"row":"Painted"})).is_err());
|
||||
assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
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,
|
||||
certificate: None,
|
||||
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, certificate: None };
|
||||
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,
|
||||
certificate: None,
|
||||
};
|
||||
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());
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Bounded, chronological checks of a joint trajectory. This samples tessellated geometry;
|
||||
//! it is deliberately not advertised as exact continuous collision detection.
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use solvecraft_doc::joints;
|
||||
use solvecraft_doc::{IDENTITY, Mat, apply_point, mat_mul};
|
||||
use solvecraft_geom::{Aabb3, Mesh, Vec3};
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Session};
|
||||
|
||||
const MAX_SAMPLES: usize = 4096;
|
||||
const MAX_PLACEMENTS: usize = 4096;
|
||||
const MAX_BODIES: usize = 256;
|
||||
const MAX_TRIANGLES: usize = 50_000;
|
||||
const MAX_WORK: usize = 50_000_000;
|
||||
|
||||
fn limit(message: &str) -> crate::EngineError {
|
||||
bad("contact.sweep", message)
|
||||
}
|
||||
|
||||
struct CachedBody {
|
||||
path: Vec<u64>,
|
||||
offset: Mat,
|
||||
mesh: Arc<Mesh>,
|
||||
size: f64,
|
||||
}
|
||||
|
||||
pub(super) struct ContactGeometry {
|
||||
bodies: Vec<CachedBody>,
|
||||
pairs: Vec<(usize, usize, String, String)>,
|
||||
step: f64,
|
||||
}
|
||||
|
||||
impl ContactGeometry {
|
||||
pub(super) fn new(s: &Session) -> Result<Self> {
|
||||
let assembly = &s.doc.assembly;
|
||||
let sets: Vec<Vec<u64>> = if assembly.contact_all {
|
||||
vec![s.doc.occurrences.iter().map(|o| o.id).collect()]
|
||||
} else {
|
||||
assembly.contact_sets.iter().filter(|set| !set.suppressed).map(|set| set.occurrences.clone()).collect()
|
||||
};
|
||||
let mut out = Self { bodies: Vec::new(), pairs: Vec::new(), step: f64::INFINITY };
|
||||
if sets.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
let state = s.model.state();
|
||||
let mut meshes: HashMap<String, Arc<Mesh>> = HashMap::new();
|
||||
let mut stack = vec![(0, Vec::<u64>::new())];
|
||||
let mut count = 0;
|
||||
while let Some((component, path)) = stack.pop() {
|
||||
count += 1;
|
||||
if count > MAX_PLACEMENTS || path.len() > 64 {
|
||||
return Err(limit("assembly exceeds contact sweep placement budget"));
|
||||
}
|
||||
for occurrence in s.doc.occurrences.iter().filter(|o| o.parent == component) {
|
||||
let mut child = path.clone();
|
||||
child.push(occurrence.id);
|
||||
stack.push((occurrence.component, child));
|
||||
if stack.len() > MAX_PLACEMENTS {
|
||||
return Err(limit("assembly exceeds contact sweep placement budget"));
|
||||
}
|
||||
}
|
||||
if !sets.iter().any(|set| path.iter().any(|id| set.contains(id))) {
|
||||
continue;
|
||||
}
|
||||
for body in state.bodies.iter().filter(|b| s.doc.model_body_component(b) == component) {
|
||||
if body.body.is_mesh() {
|
||||
return Err(limit("contact sweep does not support mesh bodies; disable their contact set to move them"));
|
||||
}
|
||||
if out.bodies.len() >= MAX_BODIES {
|
||||
return Err(limit("too many bodies for a contact sweep"));
|
||||
}
|
||||
let mesh = if let Some(mesh) = meshes.get(&body.name) {
|
||||
mesh.clone()
|
||||
} else {
|
||||
let mesh = body.body.tessellate((body.body.size() * 1e-3).max(1e-3))?;
|
||||
if mesh.triangles.len() > MAX_TRIANGLES || mesh.positions.len().saturating_add(mesh.triangles.len().saturating_mul(4)) > 200_000 {
|
||||
return Err(limit("body exceeds contact sweep tessellation budget"));
|
||||
}
|
||||
let extent = mesh.bounds().size();
|
||||
// Whole-body widths along bounded distinct facet normals catch tilted
|
||||
// plates without interpreting skinny mesh triangles as thin material.
|
||||
// Local thin walls of a wider body remain a sampling limitation.
|
||||
let mut thickness = extent.x.min(extent.y).min(extent.z);
|
||||
let mut directions: Vec<Vec3> = Vec::new();
|
||||
for triangle in &mesh.triangles {
|
||||
let Some([a, b, c]) = mesh.tri(triangle) else {
|
||||
return Err(limit("invalid contact tessellation"));
|
||||
};
|
||||
let Some(direction) = (b - a).cross(c - a).normalized() else { continue };
|
||||
if directions.len() < 32 && !directions.iter().any(|d| d.dot(direction).abs() > 1.0 - 1e-6) {
|
||||
directions.push(direction);
|
||||
}
|
||||
}
|
||||
for direction in directions {
|
||||
let (mut low, mut high) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
for point in &mesh.positions {
|
||||
let projected = point.dot(direction);
|
||||
low = low.min(projected);
|
||||
high = high.max(projected);
|
||||
}
|
||||
thickness = thickness.min(high - low);
|
||||
}
|
||||
if !thickness.is_finite() || thickness <= 1e-9 {
|
||||
return Err(limit("body is too thin for a bounded contact sweep"));
|
||||
}
|
||||
out.step = out.step.min(thickness * 0.25);
|
||||
let mesh = Arc::new(mesh);
|
||||
meshes.insert(body.name.clone(), mesh.clone());
|
||||
mesh
|
||||
};
|
||||
out.bodies.push(CachedBody {
|
||||
path: path.clone(),
|
||||
offset: s.doc.model_body_offset(body).copied().unwrap_or(IDENTITY),
|
||||
mesh,
|
||||
size: body.body.size(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut pairs = BTreeSet::new();
|
||||
for set in &sets {
|
||||
let tagged: Vec<(usize, u64)> =
|
||||
out.bodies.iter().enumerate().filter_map(|(i, b)| b.path.iter().rev().find(|id| set.contains(id)).map(|id| (i, *id))).collect();
|
||||
for (i, (a, oa)) in tagged.iter().enumerate() {
|
||||
for (b, ob) in tagged.iter().skip(i + 1) {
|
||||
if oa != ob {
|
||||
pairs.insert((*a, *b, *oa, *ob));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let name = |id| s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_default();
|
||||
out.pairs = pairs.into_iter().map(|(a, b, oa, ob)| (a, b, name(oa), name(ob))).collect();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn has_pairs(&self) -> bool {
|
||||
!self.pairs.is_empty()
|
||||
}
|
||||
|
||||
fn frame(body: &CachedBody, s: &Session) -> Result<Mat> {
|
||||
let mut frame = IDENTITY;
|
||||
for id in &body.path {
|
||||
let occurrence = s.doc.occurrences.iter().find(|o| o.id == *id).ok_or_else(|| limit("contact occurrence disappeared"))?;
|
||||
frame = mat_mul(&frame, &occurrence.transform);
|
||||
}
|
||||
Ok(mat_mul(&frame, &body.offset))
|
||||
}
|
||||
|
||||
fn bounds(&self, s: &Session) -> Result<Aabb3> {
|
||||
let mut bounds = Aabb3::EMPTY;
|
||||
for body in &self.bodies {
|
||||
let frame = Self::frame(body, s)?;
|
||||
for point in &body.mesh.positions {
|
||||
bounds.add(apply_point(&frame, *point));
|
||||
}
|
||||
}
|
||||
Ok(bounds)
|
||||
}
|
||||
|
||||
pub(super) fn samples(&self, start: &Session, end: &Session) -> Result<usize> {
|
||||
let a = joints::joint_values(&start.doc.assembly);
|
||||
let b = joints::joint_values(&end.doc.assembly);
|
||||
let bounds = self.bounds(start)?.union(&self.bounds(end)?);
|
||||
let state = start.model.state();
|
||||
let (mut linear, mut angular, mut radius) = (0.0, 0.0, 0.0_f64);
|
||||
for joint in start.doc.assembly.joints.iter().filter(|j| !j.suppressed) {
|
||||
for (index, _) in joint.kind.dofs().iter().enumerate() {
|
||||
let delta = (a.get(&joint.id).and_then(|q| q.get(index)).copied().unwrap_or(0.0)
|
||||
- b.get(&joint.id).and_then(|q| q.get(index)).copied().unwrap_or(0.0))
|
||||
.abs();
|
||||
if joint.kind.is_angle(index) {
|
||||
angular += delta;
|
||||
if delta == 0.0 {
|
||||
continue;
|
||||
}
|
||||
for session in [start, end] {
|
||||
for origin in [&joint.a, &joint.b] {
|
||||
let frame = session.doc.origin_world(&state, origin)?;
|
||||
let pivot = apply_point(&frame, Vec3::ZERO);
|
||||
let far = Vec3::new(
|
||||
(bounds.min.x - pivot.x).abs().max((bounds.max.x - pivot.x).abs()),
|
||||
(bounds.min.y - pivot.y).abs().max((bounds.max.y - pivot.y).abs()),
|
||||
(bounds.min.z - pivot.z).abs().max((bounds.max.z - pivot.z).abs()),
|
||||
);
|
||||
radius = radius.max(far.len());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
linear += delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
let required = ((linear + angular * (radius + linear)) / self.step).max(angular / 1_f64.to_radians()).ceil().max(1.0);
|
||||
if !required.is_finite() || required > MAX_SAMPLES as f64 {
|
||||
return Err(limit("motion exceeds contact sweep sampling budget; drive the joint in smaller increments"));
|
||||
}
|
||||
let vertices: usize = self.bodies.iter().map(|body| body.mesh.positions.len()).sum();
|
||||
if (required as usize).saturating_mul(vertices.saturating_add(self.pairs.len())) > MAX_WORK {
|
||||
return Err(limit("motion exceeds contact sweep transform budget; simplify the contact set or use smaller increments"));
|
||||
}
|
||||
Ok(required as usize)
|
||||
}
|
||||
|
||||
pub(super) fn first_hit(&self, s: &Session, work: &mut usize) -> Result<Option<(String, String, f64)>> {
|
||||
let meshes: Vec<Mesh> = self
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|body| {
|
||||
let frame = Self::frame(body, s)?;
|
||||
let positions = body.mesh.positions.iter().map(|p| apply_point(&frame, *p)).collect();
|
||||
Ok(Mesh { positions, triangles: body.mesh.triangles.clone(), ..Mesh::default() })
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
for (a, b, aname, bname) in &self.pairs {
|
||||
let (Some(ma), Some(mb), Some(ba), Some(bb)) = (meshes.get(*a), meshes.get(*b), self.bodies.get(*a), self.bodies.get(*b)) else {
|
||||
return Err(limit("invalid contact geometry pair"));
|
||||
};
|
||||
let (aabb, bbb) = (ma.bounds(), mb.bounds());
|
||||
if aabb.max.x < bbb.min.x
|
||||
|| bbb.max.x < aabb.min.x
|
||||
|| aabb.max.y < bbb.min.y
|
||||
|| bbb.max.y < aabb.min.y
|
||||
|| aabb.max.z < bbb.min.z
|
||||
|| bbb.max.z < aabb.min.z
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let cost = ma
|
||||
.positions
|
||||
.len()
|
||||
.saturating_add(ma.triangles.len().saturating_mul(4))
|
||||
.saturating_mul(mb.triangles.len())
|
||||
.saturating_add(mb.positions.len().saturating_add(mb.triangles.len().saturating_mul(4)).saturating_mul(ma.triangles.len()));
|
||||
*work = work.saturating_add(cost);
|
||||
if *work > MAX_WORK {
|
||||
return Err(limit("motion exceeds contact sweep collision budget; simplify the contact set or use smaller increments"));
|
||||
}
|
||||
if let Some(depth) = super::mesh_overlap(ma, mb, 1e-4 * ba.size.max(bb.size).max(1.0)) {
|
||||
return Ok(Some((aname.clone(), bname.clone(), depth)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "contact_sweep_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,170 @@
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
fn run(s: &mut Session, command: &str, p: Value) -> Value {
|
||||
s.execute(command, &p).unwrap()
|
||||
}
|
||||
fn occurrence(s: &Session, name: &str) -> u64 {
|
||||
s.doc.occurrence_of(s.doc.find_component(name).unwrap()).unwrap().id
|
||||
}
|
||||
fn slider() -> Session {
|
||||
let mut s = Session::default();
|
||||
for (name, x) in [("A", 0), ("B", 20)] {
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
run(&mut s, "component.create", json!({"name": name}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
|
||||
}
|
||||
let a = occurrence(&s, "A");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
|
||||
run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"}));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
|
||||
run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]}));
|
||||
s
|
||||
}
|
||||
fn rail_value(s: &Session) -> f64 {
|
||||
s.doc.assembly.joints.iter().find(|j| j.name == "Rail").unwrap().values[0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slider_cannot_tunnel_to_a_collision_free_endpoint() {
|
||||
let mut s = slider();
|
||||
let result = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -30}));
|
||||
assert!(result["stopped_by_contact"].is_object(), "{result}");
|
||||
assert!(rail_value(&s).abs() < 2e-3, "{}", rail_value(&s));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
|
||||
assert!((rail_value(&s) - 10.0).abs() < 1e-6, "can move away from contact");
|
||||
run(&mut s, "contact.disable_all", json!({}));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -100000}));
|
||||
assert!((rail_value(&s) + 100000.0).abs() < 1e-6, "disabled contact skips sweep budget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolute_cannot_cross_a_blocker_between_clear_endpoints() {
|
||||
let mut s = Session::default();
|
||||
for (name, corner) in [("Arm", [20, -2, -2]), ("Blocker", [-2, 20, -2])] {
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
run(&mut s, "component.create", json!({"name": name}));
|
||||
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": corner}));
|
||||
}
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "revolute", "a": {"occurrence": 0, "point": [0,0,0]}, "b": {"occurrence": "Arm", "point": [0,0,0]}, "flip": true, "name": "Pivot"}),
|
||||
);
|
||||
run(&mut s, "contact.create", json!({"occurrences": ["Arm", "Blocker"]}));
|
||||
let result = run(&mut s, "joint.drive", json!({"joint": "Pivot", "value": "180 deg"}));
|
||||
assert!(result["stopped_by_contact"].is_object(), "{result}");
|
||||
let angle = s.doc.assembly.joints.iter().find(|j| j.name == "Pivot").unwrap().values[0];
|
||||
assert!(angle > 60_f64.to_radians() && angle < 90_f64.to_radians(), "angle={angle}");
|
||||
let arm = solvecraft_doc::apply_point(&solvecraft_doc::joints::occurrence_world(&s.doc, occurrence(&s, "Arm")), Vec3::new(22.0, 0.0, 0.0));
|
||||
assert!(arm.y > 20.0 && arm.x > 2.0, "{arm:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_slider_uses_effective_travel_for_contact() {
|
||||
let mut s = slider();
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
run(&mut s, "component.create", json!({"name": "Input"}));
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "slider", "a": {"occurrence": 0, "point": [0,0,0]}, "b": {"occurrence": "Input", "point": [0,0,0]}, "flip": true, "name": "Input Joint"}),
|
||||
);
|
||||
run(&mut s, "joint.drive", json!({"joint": "Input Joint", "value": 1}));
|
||||
run(&mut s, "joint.motion_link", json!({"a": "Input Joint", "b": "Rail", "ratio": 10}));
|
||||
let result = run(&mut s, "joint.drive", json!({"joint": "Input Joint", "value": -3}));
|
||||
assert!(result["stopped_by_contact"].is_object(), "{result}");
|
||||
let input = s.doc.assembly.joints.iter().find(|j| j.name == "Input Joint").unwrap().values[0];
|
||||
assert!(input.abs() < 2e-4, "{input}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_play_and_sparse_export_share_swept_contact_checks() {
|
||||
let mut s = slider();
|
||||
run(&mut s, "motion.study", json!({"name": "Crossing", "steps": 100, "keys": [{"joint": "Rail", "points": [[0,10], [100,-30]]}]}));
|
||||
let doc = s.doc.clone();
|
||||
let exported = run(&mut s, "motion.export", json!({"study": "Crossing", "samples": 2}));
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc), "export leaves document untouched");
|
||||
assert!(exported["samples"][1]["stopped_by_contact"].is_object(), "{exported}");
|
||||
let played = run(&mut s, "motion.play", json!({"study": "Crossing", "step": 100}));
|
||||
assert!(played["stopped_by_contact"].is_object(), "{played}");
|
||||
assert!(rail_value(&s).abs() < 2e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_budget_sweep_errors_and_restores_document() {
|
||||
let mut s = slider();
|
||||
let doc = s.doc.clone();
|
||||
let error = s.execute("joint.drive", &json!({"joint": "Rail", "value": -100000})).unwrap_err();
|
||||
assert!(error.to_string().contains("sampling budget"), "{error}");
|
||||
assert_eq!(*s.doc, *doc);
|
||||
assert!((rail_value(&s) - 10.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_tilted_plates_cannot_cross_between_axis_bound_samples() {
|
||||
let mut s = Session::default();
|
||||
let normal = solvecraft_doc::apply_vector(&solvecraft_doc::rigid(Vec3::ZERO, Vec3::ZERO, Vec3::new(1.0, 1.0, 0.0), 55_f64.to_radians()), Vec3::Z);
|
||||
for name in ["Plate A", "Plate B"] {
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
run(&mut s, "component.create", json!({"name": name}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 0.05, "corner": [-5,-5,-0.025], "body_name": name}));
|
||||
run(&mut s, "solid.move", json!({"bodies": [name], "axis": [1,1,0], "angle": "55 deg"}));
|
||||
}
|
||||
let (a, b) = (occurrence(&s, "Plate A"), occurrence(&s, "Plate B"));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": normal * 2.0}));
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
|
||||
run(
|
||||
&mut s,
|
||||
"joint.as_built",
|
||||
json!({"type": "slider", "a": "Plate A", "b": "Plate B", "at": {"occurrence": 0, "point": [0,0,0], "z": normal}, "name": "Slide"}),
|
||||
);
|
||||
run(&mut s, "contact.create", json!({"occurrences": ["Plate A", "Plate B"]}));
|
||||
let result = run(&mut s, "joint.drive", json!({"joint": "Slide", "value": -4}));
|
||||
assert!(result["stopped_by_contact"].is_object(), "{result}");
|
||||
let value = s.doc.assembly.joints.iter().find(|j| j.name == "Slide").unwrap().values[0];
|
||||
assert!(value > -2.0 && value < -1.9, "{value}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_curved_bodies_keep_a_practical_sampling_step() {
|
||||
for curved in ["sphere", "fillet"] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "Blocker"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0,-5,-5]}));
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
run(&mut s, "component.create", json!({"name": "Curved"}));
|
||||
if curved == "sphere" {
|
||||
run(&mut s, "solid.sphere", json!({"radius": 5, "center": [20,0,0]}));
|
||||
} else {
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [15,-5,-5]}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[15,-5,0]], "radius": 2}));
|
||||
}
|
||||
run(
|
||||
&mut s,
|
||||
"joint.as_built",
|
||||
json!({"type": "slider", "a": 0, "b": "Curved", "at": {"occurrence": 0, "point": [0,0,0], "z": [1,0,0]}, "name": "Slide"}),
|
||||
);
|
||||
run(&mut s, "contact.create", json!({"occurrences": ["Blocker", "Curved"]}));
|
||||
let geometry = super::ContactGeometry::new(&s).unwrap();
|
||||
assert!(geometry.step > 1.0, "{curved}: tessellation must not force tiny steps: {}", geometry.step);
|
||||
let result = run(&mut s, "joint.drive", json!({"joint": "Slide", "value": -30}));
|
||||
assert!(result["stopped_by_contact"].is_object(), "{curved}: {result}");
|
||||
let value = s.doc.assembly.joints.iter().find(|j| j.name == "Slide").unwrap().values[0];
|
||||
assert!(value > -5.02 && value < -4.9, "{curved}: {value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initially_overlapping_assembly_can_move_out_for_recovery() {
|
||||
let mut s = slider();
|
||||
run(&mut s, "contact.disable_all", json!({}));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
|
||||
run(&mut s, "contact.enable_sets", json!({"enabled": true}));
|
||||
assert!(!run(&mut s, "contact.check", json!({}))["collisions"].as_array().unwrap().is_empty());
|
||||
let result = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
|
||||
assert!(result["stopped_by_contact"].is_null(), "recovery must not stick: {result}");
|
||||
assert!((rail_value(&s) - 10.0).abs() < 1e-6);
|
||||
assert!(run(&mut s, "contact.check", json!({}))["collisions"].as_array().unwrap().is_empty());
|
||||
}
|
||||
@@ -65,8 +65,12 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut features: Vec<u64> = Vec::new();
|
||||
let mut bodies: Vec<String> = Vec::new();
|
||||
let mut faces: Vec<(String, Vec<Vec3>)> = Vec::new();
|
||||
let mut selected_occurrences = Vec::new();
|
||||
for x in &items {
|
||||
match x {
|
||||
Sel::Occurrence { path } => {
|
||||
selected_occurrences.push(s.occurrence_placement(path)?.occurrence.to_string());
|
||||
}
|
||||
Sel::SketchCurve { id } | Sel::SketchPoint { id } | Sel::SketchConstraint { id } => entities.push(id.clone()),
|
||||
Sel::Feature { id } => {
|
||||
if s.doc.feature(*id).is_none() {
|
||||
@@ -118,7 +122,10 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// An occurrence goes alone while its component has others; the last one takes the component.
|
||||
let mut components: Vec<u64> = Vec::new();
|
||||
for k in keys(p, "occurrences") {
|
||||
selected_occurrences.extend(keys(p, "occurrences"));
|
||||
selected_occurrences.sort();
|
||||
selected_occurrences.dedup();
|
||||
for k in selected_occurrences {
|
||||
let Some(o) = s.doc.occurrences.iter().find(|o| o.id.to_string() == k || o.name == k).cloned() else {
|
||||
return Err(bad(cmd, format!("no occurrence `{k}`")));
|
||||
};
|
||||
|
||||
+217
-20
@@ -35,8 +35,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("feature: id|name → features deleted with it, and features that would fail without it"),
|
||||
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"),
|
||||
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params(
|
||||
"feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}} → recomputed: features rebuilt, \
|
||||
restored: features whose earlier result for the same inputs was reused",
|
||||
"feature: id|name, set: {fields to change, merged into the feature's definition as timeline.inspect returns it, \
|
||||
e.g. {\"extent\": {\"distance\": \"30\"}} (extrude), {\"angle\": \"90 deg\"} (revolve); \
|
||||
expressions are text} → recomputed: features rebuilt, restored: features whose earlier result for the same inputs was reused",
|
||||
),
|
||||
CommandSpec::new("timeline.inspect", "Inspect Feature", inspect_feature).noundo().params(
|
||||
"feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
|
||||
inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning",
|
||||
),
|
||||
CommandSpec::new("select.set", "Select", select_set)
|
||||
.noundo()
|
||||
@@ -47,9 +52,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
fn undo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let Some(prev) = s.undo.pop() else { return Err(EngineError::Other("nothing to undo".into())) };
|
||||
let label = prev.label.clone();
|
||||
s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch });
|
||||
s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() });
|
||||
s.doc = prev.doc;
|
||||
s.active_sketch = prev.active_sketch;
|
||||
s.pending_moves = prev.pending_moves;
|
||||
s.selection.clear();
|
||||
s.refresh();
|
||||
Ok(json!({"undone": label}))
|
||||
@@ -58,9 +64,10 @@ fn undo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let Some(next) = s.redo.pop() else { return Err(EngineError::Other("nothing to redo".into())) };
|
||||
let label = next.label.clone();
|
||||
s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch });
|
||||
s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() });
|
||||
s.doc = next.doc;
|
||||
s.active_sketch = next.active_sketch;
|
||||
s.pending_moves = next.pending_moves;
|
||||
s.selection.clear();
|
||||
s.refresh();
|
||||
Ok(json!({"redone": label}))
|
||||
@@ -188,6 +195,52 @@ fn merge(dst: &mut Value, src: &Value, depth: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fields of a stored feature left out of its definition: bookkeeping timeline.edit recomputes, and
|
||||
/// the name and suppression, which timeline.rename and timeline.suppress change.
|
||||
const NOT_EDITABLE: &[&str] = &["id", "type", "name", "suppressed", "param_names", "edge_refs", "edge_names", "face_names"];
|
||||
|
||||
fn inspect_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.inspect";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let f = s.doc.feature(id).ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let mut def = serde_json::to_value(f).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let mut omitted = Vec::new();
|
||||
if let Some(o) = def.as_object_mut() {
|
||||
for k in NOT_EDITABLE {
|
||||
o.remove(*k);
|
||||
}
|
||||
// Large payloads that timeline.edit cannot change.
|
||||
let heavy: &[(&str, &str)] = match &f.kind {
|
||||
FeatureKind::Sketch { .. } => &[("sketch", "the sketch geometry (sketch.inspect; edit it with the sketch commands)")],
|
||||
FeatureKind::Import { .. } => &[("step", "the imported STEP text"), ("components", "the imported product tree")],
|
||||
FeatureKind::MeshImport { .. } => &[("meshes", "the imported meshes")],
|
||||
_ => &[],
|
||||
};
|
||||
for (k, why) in heavy {
|
||||
if o.remove(*k).is_some() {
|
||||
omitted.push(json!({"field": k, "what": why}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let inputs: Vec<Value> = f
|
||||
.kind
|
||||
.inputs()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (label, e, k))| {
|
||||
json!({"label": label, "param": f.param_names.get(i).filter(|n| !n.is_empty()), "expression": e, "unit": k.default_unit()})
|
||||
})
|
||||
.collect();
|
||||
let marker = s.doc.marker.unwrap_or(usize::MAX);
|
||||
let rolled_back = s.doc.features.iter().position(|x| x.id == id).is_some_and(|i| i >= marker);
|
||||
let r = s.model.result(id);
|
||||
Ok(json!({
|
||||
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": rolled_back,
|
||||
"definition": def, "omitted": omitted, "inputs": inputs,
|
||||
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.edit";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
@@ -200,6 +253,10 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if std::mem::discriminant(&nf.kind) != std::mem::discriminant(&f.kind) {
|
||||
return Err(bad(cmd, "the feature type cannot change"));
|
||||
}
|
||||
if let FeatureKind::BodyTransfer { bodies, from, to, transform } = &nf.kind {
|
||||
solvecraft_doc::validate_body_transfer(&s.doc, bodies, *from, *to, transform)?;
|
||||
nf.component = *to;
|
||||
}
|
||||
if let (FeatureKind::Sketch { sketch: a, .. }, FeatureKind::Sketch { sketch: b, .. }) = (&nf.kind, &f.kind)
|
||||
&& a != b
|
||||
{
|
||||
@@ -238,6 +295,9 @@ fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut sel: Vec<Sel> = Vec::new();
|
||||
for it in items {
|
||||
let one: Sel = serde_json::from_value(it).map_err(|e| bad("select.set", e.to_string()))?;
|
||||
if let Sel::Occurrence { path } = &one {
|
||||
s.occurrence_bodies(path, true).map_err(|e| bad("select.set", e.to_string()))?;
|
||||
}
|
||||
sel.push(one);
|
||||
}
|
||||
if !bool_(p, "add").unwrap_or(false) {
|
||||
@@ -296,36 +356,169 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"feature": id, "position": to}))
|
||||
}
|
||||
|
||||
fn input_keys(kind: &FeatureKind) -> Vec<(String, solvecraft_doc::expr::Kind)> {
|
||||
let mut keys: Vec<_> = kind.inputs().into_iter().map(|(label, _, unit)| (label.to_string(), unit)).collect();
|
||||
if let FeatureKind::Fillet { style, .. } = kind {
|
||||
if let Some((key, _)) = keys.first_mut() {
|
||||
*key = "fillet.radius".into();
|
||||
}
|
||||
if let solvecraft_doc::FilletStyle::Variable { mid, .. } = style {
|
||||
for ((key, _), (position, _)) in keys.iter_mut().skip(2).zip(mid) {
|
||||
*key = format!("fillet.mid.{}", position.to_bits());
|
||||
}
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.redefine";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let cid = str_(p, "command").ok_or_else(|| bad(cmd, "`command` must name the feature's command"))?;
|
||||
let spec = super::find_command(cid).ok_or_else(|| bad(cmd, format!("no command `{cid}`")))?;
|
||||
if spec.id.starts_with("timeline.") || spec.id.starts_with("select.") || spec.id.starts_with("sketch.") {
|
||||
// Only modeling constructors may run here. In particular, a scratch session does not
|
||||
// isolate filesystem writes performed by export commands.
|
||||
let constructor = super::features::COMMANDS
|
||||
.iter()
|
||||
.chain(super::features_more::COMMANDS)
|
||||
.chain(super::construct::COMMANDS)
|
||||
.chain(super::surface::COMMANDS)
|
||||
.any(|candidate| candidate.id == spec.id)
|
||||
|| matches!(
|
||||
spec.id,
|
||||
"sheet.flange"
|
||||
| "sheet.hem"
|
||||
| "sheet.fold"
|
||||
| "sheet.unfold"
|
||||
| "sheet.refold"
|
||||
| "sheet.convert"
|
||||
| "plastic.boss"
|
||||
| "plastic.lip"
|
||||
| "plastic.snap_fit"
|
||||
| "plastic.rest"
|
||||
);
|
||||
if !constructor {
|
||||
return Err(bad(cmd, format!("`{cid}` does not make a feature")));
|
||||
}
|
||||
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let mut params = p.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let idx = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let old = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let marker = s.doc.marker;
|
||||
// Build the new definition where the feature sits, against the model before it.
|
||||
s.doc_mut().marker = Some(idx);
|
||||
s.refresh();
|
||||
let r = (spec.run)(s, ¶ms);
|
||||
let made = r.as_ref().ok().and_then(|v| v.get("feature")).and_then(Value::as_u64);
|
||||
let new = made.and_then(|nid| s.doc.feature(nid).cloned());
|
||||
if let Some(nid) = made {
|
||||
s.doc_mut().features.retain(|f| f.id != nid);
|
||||
if let FeatureKind::Revolve { convention, .. } = &old.kind
|
||||
&& cid == "solid.revolve"
|
||||
&& let Some(parameters) = params.as_object_mut()
|
||||
{
|
||||
parameters.entry("convention").or_insert_with(|| json!(convention));
|
||||
}
|
||||
if !old.participants.is_empty()
|
||||
&& let Some(parameters) = params.as_object_mut()
|
||||
{
|
||||
parameters.entry("participants").or_insert_with(|| json!(old.participants));
|
||||
}
|
||||
// Run the constructor on a scratch session: failed edits must not disturb sketch mode,
|
||||
// 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();
|
||||
// Keep the original identity as a suppressed placeholder. The constructor and
|
||||
// any ownership boundary it adds go AFTER this slot, so discarding its temporary
|
||||
// feature leaves auxiliary history after the rewritten original.
|
||||
if let Some(slot) = trial.doc_mut().feature_mut(id) {
|
||||
slot.suppressed = true;
|
||||
}
|
||||
trial.doc_mut().marker = Some(idx + 1);
|
||||
trial.refresh();
|
||||
(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 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();
|
||||
}
|
||||
s.doc_mut().marker = marker;
|
||||
r?;
|
||||
let new = new.ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
|
||||
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) {
|
||||
return Err(bad(cmd, format!("{} is not a {} feature", old.name, cid)));
|
||||
}
|
||||
if let Some(slot) = s.doc_mut().feature_mut(id) {
|
||||
slot.kind = new.kind;
|
||||
// Optional inputs can move other inputs' positions (e.g. Distance 2 goes before Taper).
|
||||
// Keep each existing model parameter attached to its semantic input, rather than its
|
||||
// old index. Constructor-assigned names for new inputs cannot reuse the old dimensions.
|
||||
let mut old_inputs: Vec<_> = input_keys(&old.kind).into_iter().zip(&old.param_names).collect();
|
||||
let param_names: Vec<String> = input_keys(&new.kind)
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (label, kind))| {
|
||||
if let Some(k) = old_inputs.iter().position(|((old_label, old_kind), _)| old_label == label && old_kind == kind) {
|
||||
old_inputs.remove(k).1.clone()
|
||||
} else {
|
||||
new.param_names.get(i).cloned().unwrap_or_default()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
trial.doc_mut().features.retain(|f| f.id != made);
|
||||
let added = trial.doc.features.len().saturating_sub(s.doc.features.len());
|
||||
trial.doc_mut().marker = s.doc.marker.map(|marker| if marker > idx { marker.saturating_add(added) } else { marker });
|
||||
if made == s.doc.next_id && trial.doc.next_id == made.saturating_add(1) {
|
||||
trial.doc_mut().next_id = s.doc.next_id;
|
||||
}
|
||||
if let Some(slot) = trial.doc_mut().feature_mut(id) {
|
||||
slot.suppressed = old.suppressed;
|
||||
slot.kind = new.kind;
|
||||
slot.edge_refs = new.edge_refs;
|
||||
slot.edge_names = new.edge_names;
|
||||
slot.face_names = new.face_names;
|
||||
slot.param_names = param_names;
|
||||
if params.get("participants").is_some() {
|
||||
slot.participants = new.participants;
|
||||
}
|
||||
}
|
||||
// New optional inputs need model parameter names, while existing dimensions keep theirs.
|
||||
trial.doc_mut().name_feature_inputs(id);
|
||||
trial.refresh();
|
||||
if let Some(e) = trial.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
let original_ids: std::collections::BTreeSet<u64> = s.doc.features.iter().map(|feature| feature.id).collect();
|
||||
let added_ids: std::collections::BTreeSet<u64> =
|
||||
trial.doc.features.iter().filter(|feature| !original_ids.contains(&feature.id)).map(|feature| feature.id).collect();
|
||||
let adds_transfer =
|
||||
trial.doc.features.iter().any(|feature| added_ids.contains(&feature.id) && matches!(feature.kind, FeatureKind::BodyTransfer { .. }));
|
||||
let baseline = if adds_transfer && s.doc.marker.is_some() {
|
||||
let mut baseline = s.scratch();
|
||||
baseline.doc_mut().marker = None;
|
||||
baseline.refresh();
|
||||
Some(baseline)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let proof = if (adds_transfer && trial.doc.marker.is_some()) || added_ids.iter().any(|id| trial.model.result(*id).is_none()) {
|
||||
let mut proof = trial.scratch();
|
||||
proof.doc_mut().marker = None;
|
||||
proof.refresh();
|
||||
Some(proof)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let validated = proof.as_ref().map(|proof| &proof.model).unwrap_or(&trial.model);
|
||||
let original_model = baseline.as_ref().map(|baseline| &baseline.model).unwrap_or(&s.model);
|
||||
for feature in &trial.doc.features {
|
||||
let auxiliary = added_ids.contains(&feature.id);
|
||||
let protected_transfer = matches!(feature.kind, FeatureKind::BodyTransfer { .. })
|
||||
&& original_model.result(feature.id).is_some_and(|result| !result.skipped && result.error.is_none());
|
||||
if (auxiliary || protected_transfer)
|
||||
&& let Some(error) = validated.result(feature.id).and_then(|result| result.error.as_ref())
|
||||
{
|
||||
return Err(bad(cmd, format!("{}: {error}", feature.name)));
|
||||
}
|
||||
}
|
||||
s.pending_moves = trial.pending_moves;
|
||||
s.doc = trial.doc;
|
||||
s.refresh();
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
@@ -344,3 +537,7 @@ fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let broken: Vec<String> = newly_broken(&s.model, &after).into_iter().map(|(n, _)| n).collect();
|
||||
Ok(json!({"deleted_with_it": names(&gone), "would_fail": broken}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "edit_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,312 @@
|
||||
//! Edit Feature must replace the selections as well as the numerical definition.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Sel, Session};
|
||||
|
||||
fn run(s: &mut Session, id: &str, params: Value) -> Value {
|
||||
s.execute(id, ¶ms).unwrap_or_else(|e| panic!("{id} {params}: {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefining_a_blend_replaces_persistent_edge_selection_and_survives_resize() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "name": "Base"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 20]], "radius": 2, "name": "Round"}));
|
||||
let round_id = s.doc.find_feature("Round").unwrap().id;
|
||||
let body_name = s.model.state().bodies[0].name.clone();
|
||||
s.doc_mut().feature_mut(round_id).unwrap().participants = vec![body_name];
|
||||
s.refresh();
|
||||
let old = s.doc.find_feature("Round").unwrap().clone();
|
||||
let before = (*s.doc).clone();
|
||||
run(&mut s, "timeline.redefine", json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[0, 15, 20]], "radius": 2}}));
|
||||
let edited = s.doc.find_feature("Round").unwrap().clone();
|
||||
assert_eq!(edited.id, old.id);
|
||||
assert_eq!(edited.param_names, old.param_names);
|
||||
assert_eq!(edited.participants, old.participants);
|
||||
assert_ne!(edited.edge_names, old.edge_names, "the new edge must be the reference used on recompute");
|
||||
assert_ne!(edited.edge_refs, old.edge_refs);
|
||||
assert_eq!(s.doc.next_id, before.next_id, "temporary definitions must not consume feature ids");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*s.doc, before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.find_feature("Round").unwrap().edge_names, edited.edge_names);
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"length": "60", "height": "25"}}));
|
||||
let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
let expected = 60.0 * 30.0 * 25.0 - (4.0 - std::f64::consts::PI) * 30.0;
|
||||
assert!((volume - expected).abs() < expected * 1e-4, "{volume} != {expected}");
|
||||
assert!(s.model.result(old.id).unwrap().warning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefining_preserves_body_names_participants_and_marker_without_temporary_features() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 5, "name": "Base", "body_name": "Housing"}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [30, 0, 0], "name": "Other"}));
|
||||
let id = s.doc.find_feature("Base").unwrap().id;
|
||||
run(&mut s, "timeline.rollback", json!({"position": 1}));
|
||||
let before = s.doc.feature(id).unwrap().clone();
|
||||
run(&mut s, "timeline.redefine", json!({"feature": id, "command": "solid.box", "params": {"length": 25, "width": 10, "height": 5}}));
|
||||
let after = s.doc.feature(id).unwrap();
|
||||
assert_eq!(s.doc.features.len(), 2);
|
||||
assert_eq!(s.doc.marker, Some(1));
|
||||
assert_eq!(after.body_names, before.body_names);
|
||||
assert_eq!(after.participants, before.participants);
|
||||
assert_eq!(after.param_names, before.param_names);
|
||||
assert_eq!(after.name, before.name);
|
||||
assert!(s.model.state().body("Housing").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_or_non_feature_redefinitions_leave_session_and_history_intact() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 5, "name": "Base"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
s.selection = vec![Sel::Feature { id: s.doc.find_feature("Base").unwrap().id }];
|
||||
let doc = s.doc.clone();
|
||||
let sketch = s.active_sketch;
|
||||
let selection = s.selection.clone();
|
||||
let undo = s.undo.len();
|
||||
for (command, params) in [
|
||||
("solid.cylinder", json!({"radius": 5, "height": 10})),
|
||||
("solid.box", json!({"length": "unknown_parameter", "width": 10, "height": 5})),
|
||||
("file.export", json!({"path": "should-not-be-created.step"})),
|
||||
("edit.undo", json!({})),
|
||||
] {
|
||||
assert!(s.execute("timeline.redefine", &json!({"feature": "Base", "command": command, "params": params})).is_err());
|
||||
assert_eq!(*s.doc, *doc, "{command}");
|
||||
assert_eq!(s.active_sketch, sketch, "{command}");
|
||||
assert_eq!(s.selection, selection, "{command}");
|
||||
assert_eq!(s.undo.len(), undo, "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefining_an_extrude_names_new_optional_inputs() {
|
||||
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": 5, "name": "Pad"}));
|
||||
let distance = s.doc.find_feature("Pad").unwrap().param_names[0].clone();
|
||||
run(&mut s, "timeline.redefine", json!({"feature": "Pad", "command": "solid.extrude", "params": {"distance": 5, "start_offset": 2}}));
|
||||
let f = s.doc.find_feature("Pad").unwrap();
|
||||
assert_eq!(f.param_names.len(), f.kind.inputs().len());
|
||||
assert_eq!(f.param_names[0], distance);
|
||||
assert!(f.param_names.iter().all(|name| !name.is_empty()));
|
||||
assert_eq!(s.doc.features.len(), 2);
|
||||
let offset = s.doc.find_feature("Pad").unwrap().param_names.last().unwrap().clone();
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "Pad", "command": "solid.extrude", "params": {"distance": 5, "distance2": 2, "start_offset": 2}}),
|
||||
);
|
||||
let f = s.doc.find_feature("Pad").unwrap();
|
||||
let inputs = f.kind.inputs();
|
||||
let offset_index = inputs.iter().position(|(label, _, _)| *label == "Start offset").unwrap();
|
||||
assert_eq!(f.param_names[offset_index], offset, "adding Distance 2 must not steal the offset dimension's parameter name");
|
||||
let distance2_index = inputs.iter().position(|(label, _, _)| *label == "Distance 2").unwrap();
|
||||
assert_ne!(f.param_names[distance2_index], offset);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefining_a_shell_replaces_persistent_face_selection() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "name": "Base"}));
|
||||
run(&mut s, "solid.shell", json!({"faces": [[20, 15, 20]], "thickness": 2, "name": "Hollow"}));
|
||||
let before = s.doc.find_feature("Hollow").unwrap().face_names.clone();
|
||||
assert!(!before.is_empty());
|
||||
run(&mut s, "timeline.redefine", json!({"feature": "Hollow", "command": "solid.shell", "params": {"faces": [[20, 15, 0]], "thickness": 2}}));
|
||||
let after = s.doc.find_feature("Hollow").unwrap().face_names.clone();
|
||||
assert!(!after.is_empty());
|
||||
assert_ne!(before, after);
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"height": "30"}}));
|
||||
let id = s.doc.find_feature("Hollow").unwrap().id;
|
||||
assert!(s.model.result(id).unwrap().error.is_none());
|
||||
assert!(s.model.result(id).unwrap().warning.is_none());
|
||||
assert_eq!(s.doc.find_feature("Hollow").unwrap().face_names, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefine_rejects_sheet_exports_before_they_touch_any_files() {
|
||||
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": [30, 20]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"name": "Plate"}));
|
||||
let doc = s.doc.clone();
|
||||
let base = std::env::temp_dir().join(format!("solvecraft-redefine-export-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
for command in ["sheet.export_dxf", "sheet.export_bend_table"] {
|
||||
for existing in [false, true] {
|
||||
let path = base.join(format!("{}-{existing}.out", command.replace('.', "-")));
|
||||
if existing {
|
||||
std::fs::write(&path, b"original file").unwrap();
|
||||
} else if path.exists() {
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
}
|
||||
let error = s.execute("timeline.redefine", &json!({"feature": "Plate", "command": command, "params": {"path": path}})).unwrap_err();
|
||||
assert!(error.to_string().contains("does not make a feature"), "{error}");
|
||||
assert_eq!(*s.doc, *doc);
|
||||
if existing {
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"original file");
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
} else {
|
||||
assert!(!path.exists(), "{command} must not create a file before refusing the edit");
|
||||
}
|
||||
}
|
||||
}
|
||||
for command in ["sheet.manage_rules", "sheet.rules", "sheet.flat_pattern", "parameters.list", "plastic.manage_rules"] {
|
||||
let error = s.execute("timeline.redefine", &json!({"feature": "Plate", "command": command, "params": {"name": "unexpected"}})).unwrap_err();
|
||||
assert!(error.to_string().contains("does not make a feature"), "{error}");
|
||||
assert_eq!(*s.doc, *doc);
|
||||
}
|
||||
std::fs::remove_dir(&base).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefine_keeps_allocator_above_component_and_occurrence_ids() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "body_name": "Base"}));
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [10, 0, 0], "body_name": "Tool"}));
|
||||
run(&mut s, "solid.combine", json!({"target": "Base", "tools": ["Tool"], "name": "Join"}));
|
||||
let original = s.doc.clone();
|
||||
let original_feature = s.doc.find_feature("Join").unwrap().id;
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "Join", "command": "solid.combine", "params": {"target": "Base", "tools": ["Tool"], "new_component": true, "component_name": "Merged"}}),
|
||||
);
|
||||
let destination = s.doc.find_component("Merged").unwrap();
|
||||
assert_eq!(s.model.state().body("Base").unwrap().component, destination);
|
||||
assert_eq!(s.doc.find_feature("Join").unwrap().id, original_feature);
|
||||
let combine_index = s.doc.feature_index(original_feature).unwrap();
|
||||
assert!(
|
||||
matches!(&s.doc.features[combine_index + 1].kind, solvecraft_doc::FeatureKind::BodyTransfer { bodies, to, .. } if bodies == &["Base"] && *to == destination)
|
||||
);
|
||||
assert!((solvecraft_kernel::measure(&s.world_state().body("Base").unwrap().body).unwrap().volume - 6000.0).abs() < 1e-6);
|
||||
assert_eq!(s.world_state().bodies.len(), 1);
|
||||
run(&mut s, "timeline.rollback", json!({"position":combine_index + 1}));
|
||||
assert_eq!(s.world_state().body("Base").unwrap().component, 0, "the result transfers only AFTER Combine");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*s.doc, *original);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert_eq!(reopened.world_state().body("Base").unwrap().component, destination);
|
||||
assert!((solvecraft_kernel::measure(&reopened.world_state().body("Base").unwrap().body).unwrap().volume - 6000.0).abs() < 1e-6);
|
||||
let max_id =
|
||||
s.doc.components.iter().map(|c| c.id).chain(s.doc.occurrences.iter().map(|o| o.id)).chain(s.doc.features.iter().map(|f| f.id)).max().unwrap();
|
||||
assert!(s.doc.next_id > max_id);
|
||||
run(&mut s, "component.create", json!({"name": "Next", "parent": 0}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2}));
|
||||
let ids: Vec<u64> =
|
||||
s.doc.components.iter().map(|c| c.id).chain(s.doc.occurrences.iter().map(|o| o.id)).chain(s.doc.features.iter().map(|f| f.id)).collect();
|
||||
let unique: std::collections::BTreeSet<u64> = ids.iter().copied().collect();
|
||||
assert_eq!(ids.len(), unique.len(), "feature, component and occurrence ids must remain unique: {ids:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefine_cannot_publish_a_second_transfer_that_breaks_existing_ownership() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Base"}));
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[1,0,0],"body_name":"Tool"}));
|
||||
run(&mut s, "solid.combine", json!({"target":"Base","tools":["Tool"],"name":"Join","new_component":true,"component_name":"Original"}));
|
||||
let full = s.doc.to_json();
|
||||
let join_index = s.doc.features.iter().position(|feature| feature.name == "Join").unwrap();
|
||||
for marker in [None, Some(join_index), Some(join_index + 1)] {
|
||||
let mut s = Session::new(solvecraft_doc::Document::from_json(&full).unwrap());
|
||||
s.doc_mut().marker = marker;
|
||||
s.refresh();
|
||||
let before = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let error = s.execute("timeline.redefine", &json!({"feature":"Join","command":"solid.combine","params":{"target":"Base","tools":["Tool"],"new_component":true,"component_name":"Duplicate"}})).unwrap_err();
|
||||
assert!(error.to_string().contains("transfer source component"), "marker {marker:?}: {error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
}
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&full).unwrap());
|
||||
assert_eq!(reopened.world_state().body("Base").unwrap().component, reopened.doc.find_component("Original").unwrap());
|
||||
assert!(reopened.doc.features.iter().all(|feature| reopened.model.result(feature.id).is_some_and(|result| result.error.is_none())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefine_maps_placement_inputs_in_the_feature_owner_frame() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "name": "RootBox", "body_name": "RootBody"}));
|
||||
let owner = run(&mut s, "component.create", json!({"name": "Owner"}));
|
||||
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [10, 0, 0], "name": "OwnedBox", "body_name": "OwnedBody"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[12, 0, 4]], "radius": 0.5, "body": "OwnedBody", "name": "OwnerRound"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": owner["occurrence"], "translate": [100, 0, 0]}));
|
||||
let other = run(&mut s, "component.create", json!({"name": "Other", "parent": 0}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": other["occurrence"], "translate": [200, 0, 0]}));
|
||||
let active = s.active_component;
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "RootBox", "command": "solid.box", "params": {"length": 4, "width": 4, "height": 4, "corner": [2, 0, 0]}}),
|
||||
);
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "OwnedBox", "command": "solid.box", "params": {"length": 4, "width": 4, "height": 4, "corner": [110, 0, 0]}}),
|
||||
);
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "OwnerRound", "command": "solid.fillet", "params": {"edges": [[112, 0, 4]], "radius": 0.75, "body": "OwnedBody"}}),
|
||||
);
|
||||
assert_eq!(s.active_component, active, "editing must not activate the owner in the real session");
|
||||
assert_eq!(s.doc.find_feature("OwnedBox").unwrap().component, owner["component"].as_u64().unwrap());
|
||||
let root = run(&mut s, "inspect.measure", json!({"bodies": ["RootBody"]}));
|
||||
let owned = run(&mut s, "inspect.measure", json!({"bodies": ["OwnedBody"]}));
|
||||
assert!((root["bodies"][0]["bbox"]["min"][0].as_f64().unwrap() - 2.0).abs() < 1e-8);
|
||||
assert!((owned["bodies"][0]["bbox"]["min"][0].as_f64().unwrap() - 110.0).abs() < 1e-8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefining_fillet_style_keeps_primary_parameter_and_downstream_references() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[10, 10, 30]], "radius": 2, "name": "Round"}));
|
||||
let parameter = s.doc.find_feature("Round").unwrap().param_names[0].clone();
|
||||
run(&mut s, "solid.box", json!({"length": parameter, "width": 2, "height": 2, "corner": [50, 0, 0], "name": "Dependent"}));
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[10, 10, 30]], "radius": 3, "type": "chord"}}),
|
||||
);
|
||||
assert_eq!(s.doc.find_feature("Round").unwrap().param_names[0], parameter);
|
||||
let dependent = s.doc.find_feature("Dependent").unwrap().id;
|
||||
assert!(s.model.result(dependent).unwrap().error.is_none());
|
||||
let value = run(&mut s, "expr.evaluate", json!({"expression": parameter}));
|
||||
assert_eq!(value["value"], 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_variable_fillet_control_preserves_parameters_at_existing_positions() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.fillet",
|
||||
json!({"edges": [[10, 10, 30]], "radius": 2, "radius2": 2, "type": "variable", "start": [10, 0, 30], "mid": [[0.25, 3], [0.75, 4]], "name": "Round"}),
|
||||
);
|
||||
let parameters = s.doc.find_feature("Round").unwrap().param_names.clone();
|
||||
run(&mut s, "solid.box", json!({"length": parameters[2], "width": parameters[3], "height": 2, "corner": [50, 0, 0], "name": "Dependent"}));
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[10, 10, 30]], "radius": 2, "radius2": 2, "type": "variable", "start": [10, 0, 30], "mid": [[0.1, 2.5], [0.25, 3], [0.75, 4]]}}),
|
||||
);
|
||||
let edited = s.doc.find_feature("Round").unwrap();
|
||||
assert_eq!(edited.param_names[0..2], parameters[0..2]);
|
||||
assert_eq!(edited.param_names[3..5], parameters[2..4]);
|
||||
assert!(!parameters.contains(&edited.param_names[2]), "the inserted control needs a new dimension");
|
||||
let dependent = s.doc.find_feature("Dependent").unwrap().id;
|
||||
assert!(s.model.result(dependent).unwrap().error.is_none());
|
||||
let length = run(&mut s, "expr.evaluate", json!({"expression": parameters[2]}));
|
||||
let width = run(&mut s, "expr.evaluate", json!({"expression": parameters[3]}));
|
||||
assert_eq!(length["value"], 3.0);
|
||||
assert_eq!(width["value"], 4.0);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! edges become lines, circular edges circles or arcs, anything else a chain of lines.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{EmbossFace, apply_point, mat_inverse, mat_mul};
|
||||
use solvecraft_geom::{Mesh, Vec2, Vec3};
|
||||
|
||||
use crate::params::bad;
|
||||
@@ -19,7 +20,7 @@ fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
|
||||
|
||||
/// The outward normal of the planar body face at `p`.
|
||||
pub(super) fn face_normal(s: &Session, p: Vec3) -> Option<Vec3> {
|
||||
let (m, f, d) = face_at(s, p)?;
|
||||
let (m, f, d, _) = face_at(s, p)?;
|
||||
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
|
||||
if d > tol * 10.0 {
|
||||
return None;
|
||||
@@ -34,7 +35,7 @@ pub(super) fn face_normal(s: &Session, p: Vec3) -> Option<Vec3> {
|
||||
|
||||
/// Is the body face at `p` planar (`None`: no face there)?
|
||||
pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option<bool> {
|
||||
let (m, f, d) = face_at(s, p)?;
|
||||
let (m, f, d, _) = face_at(s, p)?;
|
||||
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
|
||||
if d > tol * 10.0 {
|
||||
return None;
|
||||
@@ -52,9 +53,10 @@ pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option<bool> {
|
||||
}
|
||||
|
||||
/// The body face under a point: (mesh, face index, distance).
|
||||
fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64)> {
|
||||
fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64, String)> {
|
||||
let st = s.model.state();
|
||||
let mut best: Option<(std::sync::Arc<Mesh>, u32, f64)> = None;
|
||||
let mut best: Option<(std::sync::Arc<Mesh>, u32, f64, String)> = None;
|
||||
let mut ambiguous = false;
|
||||
for b in &st.bodies {
|
||||
let m = b.mesh();
|
||||
let mut near: Option<(u32, f64)> = None;
|
||||
@@ -65,13 +67,16 @@ fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64)> {
|
||||
near = Some((*f, d));
|
||||
}
|
||||
}
|
||||
if let Some((f, d)) = near
|
||||
&& best.as_ref().is_none_or(|x| d < x.2)
|
||||
{
|
||||
best = Some((m.clone(), f, d));
|
||||
if let Some((f, d)) = near {
|
||||
if best.as_ref().is_none_or(|x| d < x.2 - 1e-8) {
|
||||
best = Some((m.clone(), f, d, b.name.clone()));
|
||||
ambiguous = false;
|
||||
} else if best.as_ref().is_some_and(|x| (d - x.2).abs() <= 1e-8) {
|
||||
ambiguous = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
if ambiguous { None } else { best }
|
||||
}
|
||||
|
||||
/// Circle through three points.
|
||||
@@ -93,7 +98,7 @@ fn xy(v: Vec2) -> Value {
|
||||
/// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns
|
||||
/// the sketch id and a point inside the face (sketch coordinates). The sketch is left active.
|
||||
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<(u64, Vec2)> {
|
||||
let (m, f, d) = face_at(s, point).ok_or_else(|| bad(cmd, "no body face at `face`"))?;
|
||||
let (m, f, d, body) = face_at(s, point).ok_or_else(|| bad(cmd, "no body face at `face`"))?;
|
||||
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
|
||||
if d > tol * 10.0 {
|
||||
return Err(bad(cmd, "no body face at `face`"));
|
||||
@@ -118,14 +123,45 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<
|
||||
let spec = crate::find_command(id).ok_or_else(|| bad(cmd, id))?;
|
||||
(spec.run)(s, &p)
|
||||
};
|
||||
let r = run(s, "sketch.create", json!({"plane": {"face": [inside.x, inside.y, inside.z]}, "project_edges": true}))?;
|
||||
// Command frame normalization already made `point` definition-local. Capture takes
|
||||
// world coordinates; convert exactly once using the picked body's actual occurrence.
|
||||
let state = s.model.state();
|
||||
let doc = s.placed_document();
|
||||
let picked = state.body(&body).ok_or_else(|| bad(cmd, "picked body disappeared"))?;
|
||||
let component = doc.model_body_component(picked);
|
||||
let path = if component == s.active_component {
|
||||
s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(component))
|
||||
} else {
|
||||
// The command mapper discarded the original placement when making this pick
|
||||
// definition-local. A repeated cross-owner source cannot be re-found safely.
|
||||
let placements = super::component::properties::placements(&doc, cmd)?;
|
||||
if placements.iter().filter(|(owner, _, _)| *owner == component).count() != 1 {
|
||||
return Err(bad(cmd, "activate the picked occurrence before generating a face profile from a repeated component"));
|
||||
}
|
||||
doc.first_path(component)
|
||||
};
|
||||
let placement = EmbossFace::component_frame(&doc, component, &path)?;
|
||||
let placement = doc.model_body_offset(picked).map(|offset| mat_mul(&placement, offset)).unwrap_or(placement);
|
||||
let world_inside = apply_point(&placement, inside);
|
||||
let world_body = doc.world_body_name(&state, &body, component, &path);
|
||||
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(s.active_component));
|
||||
let author = EmbossFace::component_frame(&doc, s.active_component, &author_path)?;
|
||||
let author_inverse = mat_inverse(&author).ok_or_else(|| bad(cmd, "invalid face profile author frame"))?;
|
||||
let author_inside = apply_point(&author_inverse, world_inside);
|
||||
drop(doc);
|
||||
let r = super::sketch::create_face_profile(
|
||||
s,
|
||||
&json!({"plane": {"face": [world_inside.x, world_inside.y, world_inside.z], "body": world_body, "index": f, "path":path}, "project_edges": true}),
|
||||
&body,
|
||||
f as usize,
|
||||
)?;
|
||||
let sid = r.get("sketch").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?;
|
||||
s.refresh();
|
||||
let plane = s.model.state().sketch(sid).map(|ss| ss.plane).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?;
|
||||
// The face's edges came in as linked projections: they follow the face when earlier
|
||||
// features change, so nothing is copied.
|
||||
if r.get("projected").is_some_and(|x| !x.is_null()) {
|
||||
return Ok((sid, plane.to_local(inside)));
|
||||
return Ok((sid, plane.to_local(author_inside)));
|
||||
}
|
||||
for e in &edges {
|
||||
let pts: Vec<Vec2> = e.iter().map(|p| plane.to_local(*p)).collect();
|
||||
@@ -163,5 +199,9 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok((sid, plane.to_local(inside)))
|
||||
Ok((sid, plane.to_local(author_inside)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "face_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,187 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{Document, FeatureKind, PlaneRef};
|
||||
use solvecraft_geom::Vec3;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}"))
|
||||
}
|
||||
fn check(s: &mut Session, want: f64, hole: bool, height: f64) {
|
||||
s.refresh();
|
||||
assert!(s.model.results.iter().all(|result| result.error.is_none()));
|
||||
let state = s.model.state();
|
||||
let got: f64 = state.bodies.iter().map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).sum();
|
||||
// Legacy linked outlines fit tessellated curves; enforce the existing 1e-4 material envelope.
|
||||
assert!((got - want).abs() < want * 1e-4, "{got} vs {want}");
|
||||
assert!(state.bodies.iter().any(|b| b.body.contains(Vec3::new(5.0, 5.0, height - 1.0))));
|
||||
assert!(!state.bodies.iter().any(|b| b.body.contains(Vec3::new(5.0, 5.0, height + 1.0))));
|
||||
if hole {
|
||||
assert!(!state.bodies.iter().any(|b| b.body.contains(Vec3::new(20.0, 15.0, height - 1.0))));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn generated_face_profiles_keep_certified_and_internal_legacy_links_live_after_edit_and_reopen() {
|
||||
for hole in [false, true] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"name":"Base"}));
|
||||
if hole {
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[20,15],"radius":5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"through_all":true,"operation":"cut"}));
|
||||
}
|
||||
let result = run(&mut s, "solid.extrude", json!({"face":[5,5,20],"distance":10,"operation":"new","name":"Pad"}));
|
||||
let pad = s.doc.feature(result["feature"].as_u64().unwrap()).unwrap();
|
||||
let FeatureKind::Extrude { sketch, .. } = pad.kind else { panic!("extrude") };
|
||||
let generated = s.doc.feature(sketch).unwrap();
|
||||
let FeatureKind::Sketch { plane, sketch: profile } = &generated.kind else { panic!("sketch") };
|
||||
assert!(matches!(plane,PlaneRef::Face{anchor,..} if anchor.is_some()!=hole));
|
||||
assert!(!profile.links.is_empty());
|
||||
assert!(profile.links.iter().all(|link| link.auto_face != hole && !link.lost));
|
||||
let area = 1200.0 - if hole { 25.0 * PI } else { 0.0 };
|
||||
check(&mut s, area * 30.0, hole, 30.0);
|
||||
run(&mut s, "timeline.edit", json!({"feature":"Base","set":{"height":"25","length":"50"}}));
|
||||
let area = 1500.0 - if hole { 25.0 * PI } else { 0.0 };
|
||||
check(&mut s, area * 35.0, hole, 35.0);
|
||||
let edited = s.model.state();
|
||||
assert!(edited.bodies.iter().any(|body| body.body.contains(Vec3::new(49.0, 5.0, 34.0))));
|
||||
assert!(!edited.bodies.iter().any(|body| body.body.contains(Vec3::new(51.0, 5.0, 34.0))));
|
||||
assert!(edited.sketch(sketch).unwrap().sketch.links.iter().all(|link| !link.lost));
|
||||
let solved = edited.sketch(sketch).unwrap().plane;
|
||||
assert!((solved.origin.z - 25.0).abs() < 1e-6);
|
||||
let before = s.model.state();
|
||||
let before_volume: f64 = before.bodies.iter().map(|body| solvecraft_kernel::measure(&body.body).unwrap().volume).sum();
|
||||
let before_bounds: Vec<_> = before.bodies.iter().map(|body| (body.name.clone(), body.mesh().bounds())).collect();
|
||||
let mut reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
check(&mut reopened, area * 35.0, hole, 35.0);
|
||||
let after = reopened.model.state();
|
||||
let after_volume: f64 = after.bodies.iter().map(|body| solvecraft_kernel::measure(&body.body).unwrap().volume).sum();
|
||||
assert!((before_volume - after_volume).abs() < 1e-6);
|
||||
for (name, bounds) in before_bounds {
|
||||
let cold = after.body(&name).unwrap().mesh().bounds();
|
||||
assert!(bounds.min.dist(cold.min) < 1e-9 && bounds.max.dist(cold.max) < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn internal_capture_does_not_bypass_missing_ambiguous_or_invalid_path_picks() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"A"}));
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"B"}));
|
||||
let before = s.doc.to_json();
|
||||
assert!(s.execute("solid.extrude", &json!({"face":[5,5,20],"distance":10,"operation":"new"})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
for pick in [json!({"face":[5,5,20]}), json!({"face":[500,5,20]}), json!({"face":[5,5,20],"body":"A","index":0,"path":[999999]})] {
|
||||
assert!(super::super::sketch::face::capture_internal(&s, &pick, "internal").is_err(), "{pick}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_profiles_and_press_pull_convert_rotated_occurrence_picks_once() {
|
||||
for hole in [false, true] {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Arm"}));
|
||||
let cid = component["component"].as_u64().unwrap();
|
||||
let occ = component["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"name":"Base","body_name":"Stock"}));
|
||||
if hole {
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[20,15],"radius":5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"through_all":true,"operation":"cut"}));
|
||||
}
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occ,"translate":[30,20,10],"axis":[1,0,0],"angle":"90 deg","capture":true}));
|
||||
run(&mut s, "component.activate", json!({"component":cid,"occurrence":occ}));
|
||||
run(&mut s, "solid.extrude", json!({"face":[35,0,15],"distance":10,"operation":"join"}));
|
||||
let area = 1200.0 - if hole { 25.0 * PI } else { 0.0 };
|
||||
check(&mut s, area * 30.0, hole, 30.0);
|
||||
run(&mut s, "solid.press_pull", json!({"face":[35,-10,15],"distance":5}));
|
||||
check(&mut s, area * 35.0, hole, 35.0);
|
||||
let height = if hole {
|
||||
// Curved coincident Cut is an existing kernel limit; it must be atomic.
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("solid.press_pull", &json!({"face":[35,-15,15],"distance":-5})).unwrap_err();
|
||||
assert!(error.to_string().contains("coincident faces on curved bodies"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
check(&mut s, area * 35.0, hole, 35.0);
|
||||
35.0
|
||||
} else {
|
||||
run(&mut s, "solid.press_pull", json!({"face":[35,-15,15],"distance":-5}));
|
||||
check(&mut s, area * 30.0, hole, 30.0);
|
||||
30.0
|
||||
};
|
||||
let world = s.world_state();
|
||||
let occupied = Vec3::new(35.0, 21.0 - height, 15.0);
|
||||
let absent = Vec3::new(35.0, 19.0 - height, 15.0);
|
||||
assert!(world.bodies.iter().any(|body| body.body.contains(occupied)));
|
||||
assert!(!world.bodies.iter().any(|body| body.body.contains(absent)));
|
||||
if hole {
|
||||
assert!(!world.bodies.iter().any(|body| body.body.contains(Vec3::new(50.0, 21.0 - height, 25.0))));
|
||||
}
|
||||
let before_volume: f64 = world.bodies.iter().map(|body| solvecraft_kernel::measure(&body.body).unwrap().volume).sum();
|
||||
let before_bounds: Vec<_> = world.bodies.iter().map(|body| (body.name.clone(), body.mesh().bounds())).collect();
|
||||
let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
let after = reopened.world_state();
|
||||
assert!(after.bodies.iter().any(|body| body.body.contains(occupied)));
|
||||
let v: f64 = after.bodies.iter().map(|body| solvecraft_kernel::measure(&body.body).unwrap().volume).sum();
|
||||
assert!((v - area * height).abs() < area * height * 1e-4);
|
||||
assert!((v - before_volume).abs() < 1e-6);
|
||||
for (name, bounds) in before_bounds {
|
||||
let cold = after.body(&name).unwrap().mesh().bounds();
|
||||
assert!(bounds.min.dist(cold.min) < 1e-9 && bounds.max.dist(cold.max) < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_internal_capture_refuses_an_uncaptured_source_in_another_component() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Source"}));
|
||||
let occurrence = component["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Stock"}));
|
||||
run(&mut s, "solid.press_pull", json!({"face":[5,5,20],"distance":5}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":false}));
|
||||
let state = s.model.state();
|
||||
let index = state
|
||||
.body("Stock")
|
||||
.unwrap()
|
||||
.body
|
||||
.faces(1e-4)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 25.0).abs() < 1e-6)
|
||||
.unwrap()
|
||||
.index;
|
||||
let before = s.doc.to_json();
|
||||
let error =
|
||||
super::super::sketch::face::capture_internal(&s, &json!({"face":[35,5,25],"body":"Stock","index":index,"path":[occurrence]}), "internal")
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("capture the source occurrence position"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_source_face_profile_requires_and_uses_the_exact_active_occurrence() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Repeated"}));
|
||||
let cid = component["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let copied = run(&mut s, "occurrence.copy", json!({"component":cid,"translate":[50,0,0]}));
|
||||
let occurrence = copied["occurrence"].as_u64().unwrap();
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("solid.extrude", &json!({"face":[55,5,10],"distance":5,"operation":"new"})).unwrap_err();
|
||||
assert!(error.to_string().contains("activate the picked occurrence"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "component.activate", json!({"component":cid,"occurrence":occurrence}));
|
||||
run(&mut s, "solid.extrude", json!({"face":[55,5,10],"distance":5,"operation":"new"}));
|
||||
let state = s.world_state();
|
||||
for x in [5.0, 55.0] {
|
||||
assert!(state.bodies.iter().any(|body| body.body.contains(Vec3::new(x, 5.0, 14.0))));
|
||||
assert!(!state.bodies.iter().any(|body| body.body.contains(Vec3::new(x, 5.0, 16.0))));
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"),
|
||||
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.sweep", "Sweep", sweep)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sweep")
|
||||
@@ -158,7 +158,7 @@ pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
|
||||
let bodies: Vec<_> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| if parts.is_empty() { s.doc.body_component(&b.name, b.feature) == s.active_component } else { parts.contains(&b.name) })
|
||||
.filter(|b| if parts.is_empty() { s.doc.model_body_component(b) == s.active_component } else { parts.contains(&b.name) })
|
||||
.collect();
|
||||
if bodies.is_empty() {
|
||||
return Some("new");
|
||||
@@ -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).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()) {
|
||||
@@ -465,6 +482,10 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let symmetric =
|
||||
matches!(str_(p, "direction").map(str::to_ascii_lowercase).as_deref(), Some("symmetric")) || bool_(p, "symmetric").unwrap_or(false);
|
||||
let convention = match p.get("convention") {
|
||||
None => solvecraft_doc::RevolveConvention::SketchNormal,
|
||||
Some(value) => serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "`convention` must be sketch_normal or legacy_axis_start"))?,
|
||||
};
|
||||
let kind = FeatureKind::Revolve {
|
||||
sketch,
|
||||
profiles,
|
||||
@@ -474,6 +495,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
targets: string_list(p, "targets"),
|
||||
angle2,
|
||||
symmetric,
|
||||
convention,
|
||||
to,
|
||||
};
|
||||
add_feature(s, p, kind)
|
||||
@@ -710,7 +732,7 @@ fn combine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bool_(p, "new_component").unwrap_or(false) {
|
||||
let name = str_(p, "component_name").map(str::to_string).unwrap_or_else(|| format!("{target} Combined"));
|
||||
let c = s.execute("component.create", &json!({"name": name, "activate": false}))?;
|
||||
s.execute("component.move_bodies", &json!({"bodies": [target], "component": c["component"]}))?;
|
||||
super::component::transfer_constructor_result(s, &json!({"bodies": [target], "component": c["component"]}))?;
|
||||
out["component"] = c["component"].clone();
|
||||
}
|
||||
Ok(out)
|
||||
@@ -1325,3 +1347,7 @@ fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
#[path = "patterns_tests.rs"]
|
||||
mod patterns_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "revolve_convention_tests.rs"]
|
||||
mod revolve_convention_tests;
|
||||
@@ -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 (inferred when exactly one supported tangent cylinder matches the selected profiles)"),
|
||||
CommandSpec::new("solid.replace_face", "Replace Face", replace_face)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("offset_face")
|
||||
@@ -143,14 +143,169 @@ 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 face = emboss_selection(s, selected, at)?;
|
||||
check_emboss_positions(s, sketch, selected, &face)?;
|
||||
targets = vec![name.to_owned()];
|
||||
Some(face)
|
||||
}
|
||||
_ => return Err(bad(cmd, "only planar faces and complete exterior cylindrical walls support emboss")),
|
||||
}
|
||||
} else {
|
||||
let inferred = infer_emboss_selection(s, sketch, &profiles, &depth, deboss, &targets)?;
|
||||
if let Some(face) = &inferred {
|
||||
targets = vec![face.body.clone()];
|
||||
}
|
||||
inferred
|
||||
};
|
||||
add_feature(s, p, FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face })
|
||||
}
|
||||
|
||||
fn emboss_selection(s: &Session, selected: &solvecraft_doc::ModelBody, at: Vec3) -> Result<solvecraft_doc::EmbossFace> {
|
||||
let cmd = "solid.emboss";
|
||||
let name = selected.definition_name();
|
||||
let original = s.model.state();
|
||||
let owner = s.doc.model_body_component(selected);
|
||||
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"))?;
|
||||
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(s.active_component));
|
||||
if path.len() > 64 || author_path.len() > 64 {
|
||||
return Err(bad(cmd, "cylinder placement is too deep"));
|
||||
}
|
||||
let inverse = solvecraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid active frame"))?;
|
||||
Ok(solvecraft_doc::EmbossFace { body: name.to_owned(), at: solvecraft_doc::apply_point(&inverse, at), path, author_path })
|
||||
}
|
||||
|
||||
fn check_emboss_positions(s: &Session, sketch: u64, body: &solvecraft_doc::ModelBody, face: &solvecraft_doc::EmbossFace) -> Result<()> {
|
||||
let cmd = "solid.emboss";
|
||||
let owner = s.doc.model_body_component(body);
|
||||
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 {
|
||||
face.path.clone()
|
||||
} else if sketch_owner == s.active_component {
|
||||
face.author_path.clone()
|
||||
} else {
|
||||
s.doc.first_path(sketch_owner)
|
||||
};
|
||||
if face.path.iter().chain(&face.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"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn infer_emboss_selection(
|
||||
s: &Session,
|
||||
sketch: u64,
|
||||
profiles: &solvecraft_doc::ProfileSel,
|
||||
depth: &str,
|
||||
deboss: bool,
|
||||
targets: &[String],
|
||||
) -> Result<Option<solvecraft_doc::EmbossFace>> {
|
||||
let cmd = "solid.emboss";
|
||||
let original = s.model.state();
|
||||
let ss = original.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch is not evaluated"))?;
|
||||
let regions = solvecraft_doc::select_profiles(ss, profiles).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let sketch_owner = s.doc.feature(sketch).map(|f| f.component).ok_or_else(|| bad(cmd, "profile sketch no longer exists"))?;
|
||||
let d = s.doc.eval(&s.doc.with_design_unit(depth, Kind::Length), Kind::Length).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let world = s.world_state();
|
||||
let placed = s.placed_document();
|
||||
let mut chosen = None;
|
||||
let mut faces = 0usize;
|
||||
for selected in &world.bodies {
|
||||
let name = selected.definition_name();
|
||||
if !targets.is_empty() && !targets.iter().any(|n| n == name || n == &selected.name) {
|
||||
continue;
|
||||
}
|
||||
let raw = original.body(name).ok_or_else(|| bad(cmd, "target definition no longer exists"))?;
|
||||
faces = faces.saturating_add(raw.body.face_count());
|
||||
if faces > 50_000 {
|
||||
return Err(bad(cmd, "cylinder target search exceeds face work limit"));
|
||||
}
|
||||
if !solvecraft_kernel::has_cylinder_emboss_wall(&raw.body).map_err(|e| bad(cmd, e.to_string()))? {
|
||||
continue;
|
||||
}
|
||||
let mut face = emboss_selection(s, selected, Vec3::ZERO)?;
|
||||
let body_frame = face.body_frame(&placed, raw).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let inverse = solvecraft_doc::mat_inverse(&body_frame).ok_or_else(|| bad(cmd, "invalid cylinder body frame"))?;
|
||||
let displayed = world.sketch(sketch).ok_or_else(|| bad(cmd, "the profile sketch is not displayed"))?;
|
||||
let plane = solvecraft_geom::Plane::new(
|
||||
solvecraft_doc::apply_point(&inverse, displayed.plane.origin),
|
||||
solvecraft_doc::apply_vector(&inverse, displayed.plane.x),
|
||||
solvecraft_doc::apply_vector(&inverse, displayed.plane.y),
|
||||
)
|
||||
.ok_or_else(|| bad(cmd, "invalid cylinder sketch frame"))?;
|
||||
if let Some((_, at)) =
|
||||
solvecraft_kernel::infer_cylinder_emboss(&raw.body, &plane, ®ions, if deboss { -d } else { d }).map_err(|e| bad(cmd, e.to_string()))?
|
||||
{
|
||||
face.sketch_frame(&placed, s.active_component, sketch_owner, raw).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
check_emboss_positions(s, sketch, selected, &face)?;
|
||||
let author_inverse = solvecraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid author frame"))?;
|
||||
face.at = solvecraft_doc::apply_point(&solvecraft_doc::mat_mul(&author_inverse, &body_frame), at);
|
||||
if chosen.is_some() {
|
||||
return Err(bad(cmd, "multiple tangent cylinders match the selected profiles; select a target face or body"));
|
||||
}
|
||||
chosen = Some(face);
|
||||
}
|
||||
}
|
||||
Ok(chosen)
|
||||
}
|
||||
fn face_points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{k}` must list points [x, y, z]")))?;
|
||||
if a.is_empty() || a.len() > 1000 {
|
||||
|
||||
@@ -9,6 +9,17 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn auto_emboss_params(mut p: Value, auto: bool) -> Value {
|
||||
if auto {
|
||||
if let Some(params) = p.get_mut("params").and_then(Value::as_object_mut) {
|
||||
params.remove("face");
|
||||
} else if let Some(object) = p.as_object_mut() {
|
||||
object.remove("face");
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn measure(s: &mut Session) -> Value {
|
||||
run(s, "inspect.measure", json!({}))
|
||||
}
|
||||
@@ -81,6 +92,293 @@ 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, auto) in [(0., false), (80., false), (0., true), (80., true)] {
|
||||
let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && matches!(id, "solid.emboss" | "timeline.redefine")));
|
||||
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() {
|
||||
check_active_second_source(false);
|
||||
check_active_second_source(true);
|
||||
}
|
||||
fn check_active_second_source(auto: bool) {
|
||||
let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && id == "solid.emboss"));
|
||||
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, auto) in [(false, false), (true, false), (false, true), (true, 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", &auto_emboss_params(json!({"sketch":"Badge","profiles":"all","face":[5,10,7.5],"depth":1}), auto))
|
||||
.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() {
|
||||
check_pending_third_profile(false);
|
||||
check_pending_third_profile(true);
|
||||
}
|
||||
fn check_pending_third_profile(auto: bool) {
|
||||
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",
|
||||
&auto_emboss_params(json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]}), auto),
|
||||
)
|
||||
.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() {
|
||||
check_body_offset(false);
|
||||
check_body_offset(true);
|
||||
}
|
||||
fn check_body_offset(auto: bool) {
|
||||
let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && id == "solid.emboss"));
|
||||
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]}));
|
||||
std::sync::Arc::make_mut(&mut s.doc).body_components.insert("Sleeve".into(), holder);
|
||||
std::sync::Arc::make_mut(&mut s.doc)
|
||||
.body_offsets
|
||||
.insert("Sleeve".into(), [[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., -50., 0., 1.]]);
|
||||
s.refresh();
|
||||
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();
|
||||
@@ -137,3 +435,122 @@ fn coil_and_loft_to_a_point() {
|
||||
assert!(rel(volume(&mut s), 400.0 * 30.0 / 3.0) < 1e-6);
|
||||
assert!(s.execute("solid.loft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_auto_inference_saves_a_profile_anchor_and_replays_both_depth_signs() {
|
||||
use solvecraft_geom::Vec3;
|
||||
for mode in ["emboss", "deboss"] {
|
||||
let mut s = cylinder_badge();
|
||||
let before = exact_cylinder_volume(&s);
|
||||
let initial = s.doc.to_json();
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","depth":"badge_depth","mode":mode,"name":"Auto Badge"}));
|
||||
let feature = s.doc.find_feature("Auto Badge").unwrap().clone();
|
||||
let solvecraft_doc::FeatureKind::Emboss { face: Some(face), targets, .. } = &feature.kind else {
|
||||
panic!("missing inferred association");
|
||||
};
|
||||
assert_eq!(targets, &["Sleeve"]);
|
||||
assert_eq!(face.body, "Sleeve");
|
||||
assert!(face.at.z > 0. && face.at.z < 20.);
|
||||
assert!((face.at.x.hypot(face.at.y) - 10.).abs() < 1e-10);
|
||||
assert!(!feature.face_names.first().unwrap().is_empty());
|
||||
let expected = if mode == "emboss" { 21. } else { -19. };
|
||||
assert!((exact_cylinder_volume(&s) - before - expected).abs() < 0.02);
|
||||
let state = s.model.state();
|
||||
let body = &state.body("Sleeve").unwrap().body;
|
||||
assert_eq!(body.shell_count(), 1);
|
||||
assert_eq!(
|
||||
body.tessellate_with(0.001, true).unwrap().contains(Vec3::new(0., if mode == "emboss" { 10.5 } else { 9.5 }, 7.5)),
|
||||
mode == "emboss"
|
||||
);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), initial);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.find_feature("Auto Badge").unwrap().kind, feature.kind);
|
||||
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,
|
||||
"timeline.redefine",
|
||||
json!({"feature":feature.id,"command":"solid.emboss","params":{"sketch":"Badge","profiles":"all","depth":"badge_depth","mode":mode}}),
|
||||
);
|
||||
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, "parameters.change", json!({"name":"badge_depth","expression":"2mm"}));
|
||||
assert!((exact_cylinder_volume(&loaded) - before - if mode == "emboss" { 44. } else { -36. }).abs() < 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_auto_inference_ignores_remote_finite_tangent_walls_in_each_body_order() {
|
||||
for remote_first in [false, true] {
|
||||
let mut s = Session::default();
|
||||
for (name, z) in if remote_first { [("Remote", 100.), ("Sleeve", 0.)] } else { [("Sleeve", 0.), ("Remote", 100.)] } {
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"base":[0,0,z],"body_name":name}));
|
||||
}
|
||||
run(&mut s, "solid.cylinder", json!({"radius":8,"height":10,"base":[0,0,120],"body_name":"Remote Step"}));
|
||||
run(&mut s, "solid.combine", json!({"target":"Remote","tools":["Remote Step"],"operation":"join"}));
|
||||
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!({}));
|
||||
let before = exact_cylinder_volume(&s);
|
||||
let remote = s.model.state().body("Remote").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","depth":1}));
|
||||
assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02);
|
||||
assert_eq!(s.model.state().body("Remote").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume, remote);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_auto_inference_retains_selected_holes_and_nested_islands() {
|
||||
use solvecraft_geom::Vec3;
|
||||
for mode in ["emboss", "deboss"] {
|
||||
let mut s = cylinder_badge();
|
||||
run(&mut s, "sketch.edit", json!({"sketch":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-1,6],"p1":[1,9]}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-0.5,6.5],"p1":[0.5,8.5]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before = exact_cylinder_volume(&s);
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":[{"point":[1.5,7.5]},{"point":[0,7.5]}],"depth":1,"mode":mode}));
|
||||
let depth = if mode == "emboss" { 1. } else { -1. };
|
||||
let expected = 16. * (depth + depth * depth / 20.);
|
||||
assert!((exact_cylinder_volume(&s) - before - expected).abs() < 0.02);
|
||||
let st = s.model.state();
|
||||
let body = &st.body("Sleeve").unwrap().body;
|
||||
assert_eq!(body.shell_count(), 1);
|
||||
let mesh = body.tessellate_with(0.001, true).unwrap();
|
||||
for (s, raised) in [(0., true), (0.75, false), (1.5, true)] {
|
||||
let theta = s / 10.0_f64;
|
||||
let radius = if mode == "emboss" { 10.5 } else { 9.5 };
|
||||
assert_eq!(mesh.contains(Vec3::new(-radius * theta.sin(), radius * theta.cos(), 7.5)), if mode == "emboss" { raised } else { !raised });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_auto_inference_refuses_ambiguity_curves_and_seam_crossing_atomically() {
|
||||
let mut s = cylinder_badge();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Coincident"}));
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("solid.emboss", &json!({"sketch":"Badge","depth":1})).unwrap_err().to_string();
|
||||
assert!(error.contains("multiple tangent cylinders"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","depth":1,"targets":["Sleeve"]}));
|
||||
assert!(matches!(&s.doc.features.last().unwrap().kind,solvecraft_doc::FeatureKind::Emboss{face:Some(face),..} if face.body=="Sleeve"));
|
||||
for circle in [false, true] {
|
||||
let mut s = cylinder_badge();
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Unsupported"}));
|
||||
if circle {
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,7.5],"radius":1}));
|
||||
} else {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-20,5],"p1":[20,10]}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before = s.doc.to_json();
|
||||
let volume = exact_cylinder_volume(&s);
|
||||
let error = s.execute("solid.emboss", &json!({"sketch":"Unsupported","depth":1})).unwrap_err().to_string();
|
||||
assert!(error.contains(if circle { "orthogonal line" } else { "seam" }), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(exact_cylinder_volume(&s), volume);
|
||||
}
|
||||
}
|
||||
@@ -62,13 +62,14 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let want = string_list(p, "bodies");
|
||||
let st = s.world_state();
|
||||
let mut bodies: Vec<Value> = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).map(measure_json).collect();
|
||||
let selected: Vec<_> = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).collect();
|
||||
let mut bodies: Vec<Value> = selected.iter().map(|b| measure_json(b)).collect();
|
||||
// Mass from each body's material.
|
||||
for b in &mut bodies {
|
||||
let name = b["name"].as_str().unwrap_or_default().to_string();
|
||||
for (b, body) in bodies.iter_mut().zip(selected) {
|
||||
if let Some(v) = b["volume_mm3"].as_f64() {
|
||||
let rho = s.doc.density(&name);
|
||||
b["material"] = json!(s.doc.materials.get(&name).cloned().unwrap_or_else(|| "Default".into()));
|
||||
let source = body.definition_name();
|
||||
let rho = s.doc.density(source);
|
||||
b["material"] = json!(s.doc.materials.get(source).cloned().unwrap_or_else(|| "Default".into()));
|
||||
b["density_g_cm3"] = json!(rho);
|
||||
b["mass_g"] = json!(v * rho / 1000.0);
|
||||
}
|
||||
@@ -118,7 +119,7 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
|
||||
CurveKind::Circle { c: cc, r } => json!({"id": c.id, "type": "circle", "center": pid(cc), "center_at": sk.point(cc), "radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Arc { c: cc, a, b } => json!({"id": c.id, "type": "arc", "center": pid(cc), "start": pid(a), "end": pid(b), "center_at": sk.point(cc), "start_at": sk.point(a), "end_at": sk.point(b), "radius": sk.radius(i), "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Ellipse { c: cc, m, r } => json!({"id": c.id, "type": "ellipse", "center": pid(cc), "major": pid(m), "center_at": sk.point(cc), "major_at": sk.point(m), "minor_radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Spline { ref pts, control, degree } => json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::<Vec<_>>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::<Vec<_>>(), "control": control, "degree": degree, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Spline { ref pts, control, degree, periodic } => json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::<Vec<_>>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::<Vec<_>>(), "control": control, "degree": degree, "periodic": periodic, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Conic { a, b, apex, rho } => json!({"id": c.id, "type": "conic", "start": pid(a), "end": pid(b), "apex": pid(apex), "start_at": sk.point(a), "end_at": sk.point(b), "apex_at": sk.point(apex), "rho": rho, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
};
|
||||
if let (Some(l), Some(o)) = (&c.link, v.as_object_mut()) {
|
||||
@@ -214,7 +215,7 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|b| {
|
||||
let mut v = if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) };
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("visible".into(), json!(!s.visibility.hidden_bodies.contains(&b.name)));
|
||||
o.insert("visible".into(), json!(s.body_visible(&b.name)));
|
||||
}
|
||||
v
|
||||
})
|
||||
@@ -400,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, certificate: None });
|
||||
}
|
||||
}
|
||||
for c in &comps {
|
||||
@@ -413,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!({
|
||||
@@ -495,7 +512,7 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let b = st.body(name).ok_or_else(|| bad("appearance.list", format!("no body `{name}`")))?;
|
||||
out["body"] = json!({
|
||||
"name": name,
|
||||
"appearance": s.doc.body_look(&b.name, b.feature).map(|l| look_json(&l)),
|
||||
"appearance": s.doc.model_body_look(b).map(|l| look_json(&l)),
|
||||
"color": s.doc.body_color(b),
|
||||
"faces": s.doc.face_colors(b).iter().map(|(i, l)| json!({"face": i, "appearance": look_json(l)})).collect::<Vec<_>>(),
|
||||
});
|
||||
|
||||
@@ -85,7 +85,7 @@ pub(crate) fn occurrence_at(s: &Session, p: Vec3) -> Option<u64> {
|
||||
let st = s.model.state();
|
||||
let mut best: Option<(f64, u64)> = None;
|
||||
for b in &st.bodies {
|
||||
let comp = s.doc.body_component(&b.name, b.feature);
|
||||
let comp = s.doc.model_body_component(b);
|
||||
let occs: Vec<u64> = if comp == 0 { vec![0] } else { s.doc.occurrences.iter().filter(|o| o.component == comp).map(|o| o.id).collect() };
|
||||
for occ in occs {
|
||||
let t = if occ == 0 { solvecraft_doc::IDENTITY } else { joints::occurrence_world(&s.doc, occ) };
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
mod browser;
|
||||
mod clipboard;
|
||||
mod component;
|
||||
mod component_clone;
|
||||
mod component_clone_assembly;
|
||||
mod construct;
|
||||
mod delete;
|
||||
mod edit;
|
||||
@@ -27,6 +29,7 @@ mod parameters;
|
||||
mod plastic;
|
||||
mod sheet;
|
||||
mod sketch;
|
||||
pub(crate) use sketch::author_path_for_rebind;
|
||||
mod sketch_3d;
|
||||
mod sketch_canvas;
|
||||
mod sketch_constraints;
|
||||
|
||||
@@ -12,6 +12,9 @@ use super::joints::{joint_id, occurrence_id, resolve, value};
|
||||
use crate::params::{bad, bool_, str_, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
#[path = "contact_sweep.rs"]
|
||||
mod contact_sweep;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("contact.enable_sets", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("joint").params(
|
||||
"enabled?: bool (default true) — driving joints and motion studies stop where bodies in one contact set would pass through each other",
|
||||
@@ -86,8 +89,12 @@ fn placed(s: &Session) -> Vec<(Vec<u64>, Body)> {
|
||||
if comp == 0 {
|
||||
continue;
|
||||
}
|
||||
for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp && !b.body.is_mesh()) {
|
||||
if let Ok(t) = solvecraft_kernel::transform_matrix(&b.body, m) {
|
||||
for b in st.bodies.iter().filter(|b| s.doc.model_body_component(b) == comp && !b.body.is_mesh()) {
|
||||
// Moving a body into another component preserves its world placement using an
|
||||
// offset from its authoring frame. Contact and explode centres must use that
|
||||
// same offset as display and physical properties.
|
||||
let frame = s.doc.model_body_offset(b).map(|offset| solvecraft_doc::mat_mul(&m, offset)).unwrap_or(m);
|
||||
if let Ok(t) = solvecraft_kernel::transform_matrix(&b.body, frame) {
|
||||
out.push((path.clone(), t));
|
||||
}
|
||||
}
|
||||
@@ -131,8 +138,11 @@ pub fn collisions(s: &Session) -> Vec<(String, String, f64)> {
|
||||
/// tested against the other closed mesh: inside by ray parity, and deeper than the tolerance.
|
||||
fn overlap(a: &Body, b: &Body) -> Option<f64> {
|
||||
let (ma, mb) = (a.tessellate((a.size() * 1e-3).max(1e-3)).ok()?, b.tessellate((b.size() * 1e-3).max(1e-3)).ok()?);
|
||||
mesh_overlap(&ma, &mb, 1e-4 * a.size().max(b.size()).max(1.0))
|
||||
}
|
||||
|
||||
fn mesh_overlap(ma: &solvecraft_geom::Mesh, mb: &solvecraft_geom::Mesh, tol: f64) -> Option<f64> {
|
||||
let (ba, bb) = (ma.bounds(), mb.bounds());
|
||||
let tol = 1e-4 * a.size().max(b.size()).max(1.0);
|
||||
if ba.max.x < bb.min.x + tol
|
||||
|| bb.max.x < ba.min.x + tol
|
||||
|| ba.max.y < bb.min.y + tol
|
||||
@@ -142,7 +152,7 @@ fn overlap(a: &Body, b: &Body) -> Option<f64> {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let depth = deepest(&ma, &mb, tol).max(deepest(&mb, &ma, tol));
|
||||
let depth = deepest(ma, mb, tol).max(deepest(mb, ma, tol));
|
||||
(depth > tol).then_some(depth)
|
||||
}
|
||||
|
||||
@@ -247,9 +257,12 @@ fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
/// Set joint values, stopping short where contact sets would collide: the values are moved
|
||||
/// from where they are toward `want` as far as they go without a collision.
|
||||
fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)]) -> Result<Value> {
|
||||
let baseline = s.doc.clone();
|
||||
let start: Vec<(u64, Vec<f64>)> =
|
||||
want.iter().map(|(id, _)| (*id, s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| j.values.clone()).unwrap_or_default())).collect();
|
||||
let set = |s: &mut Session, t: f64| {
|
||||
// Trial order must not affect free anchors or linked joint placements.
|
||||
s.doc = baseline.clone();
|
||||
let doc = s.doc_mut();
|
||||
for ((id, a), (_, b)) in start.iter().zip(want) {
|
||||
if let Some(j) = doc.assembly.joints.iter_mut().find(|j| j.id == *id) {
|
||||
@@ -260,25 +273,45 @@ fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)]) -> Result<Value
|
||||
}
|
||||
resolve(s)
|
||||
};
|
||||
let mut sol = set(s, 1.0);
|
||||
let cache = if s.doc.assembly.contact_enabled { Some(contact_sweep::ContactGeometry::new(s)?) } else { None };
|
||||
let sol;
|
||||
let mut stopped = None;
|
||||
if s.doc.assembly.contact_enabled
|
||||
&& let Some(hit) = collisions(s).into_iter().next()
|
||||
{
|
||||
// Already colliding where it started: don't search, just report.
|
||||
if let Some(cache) = cache.filter(|cache| cache.has_pairs()) {
|
||||
let mut end = s.scratch();
|
||||
set(&mut end, 1.0);
|
||||
let samples = cache.samples(s, &end)?;
|
||||
let mut work = 0;
|
||||
set(s, 0.0);
|
||||
if collisions(s).is_empty() {
|
||||
let (mut lo, mut hi) = (0.0, 1.0);
|
||||
for _ in 0..14 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
set(s, mid);
|
||||
if collisions(s).is_empty() { lo = mid } else { hi = mid }
|
||||
// Keep the existing recovery behaviour: an initially overlapping assembly may move
|
||||
// out of its invalid placement. Sweeps constrain motions that start collision-free.
|
||||
if cache.first_hit(s, &mut work)?.is_none() {
|
||||
let mut lo = 0.0;
|
||||
let mut bracket = None;
|
||||
for sample in 1..=samples {
|
||||
let hi = sample as f64 / samples as f64;
|
||||
set(s, hi);
|
||||
if let Some(hit) = cache.first_hit(s, &mut work)? {
|
||||
bracket = Some((lo, hi, hit));
|
||||
break;
|
||||
}
|
||||
lo = hi;
|
||||
}
|
||||
if let Some((mut lo, mut hi, hit)) = bracket {
|
||||
for _ in 0..14 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
set(s, mid);
|
||||
if cache.first_hit(s, &mut work)?.is_none() { lo = mid } else { hi = mid }
|
||||
}
|
||||
sol = set(s, lo);
|
||||
stopped = Some(json!({"a": hit.0, "b": hit.1, "fraction": lo}));
|
||||
} else {
|
||||
sol = set(s, 1.0);
|
||||
}
|
||||
sol = set(s, lo);
|
||||
stopped = Some(json!({"a": hit.0, "b": hit.1, "fraction": lo}));
|
||||
} else {
|
||||
sol = set(s, 1.0);
|
||||
}
|
||||
} else {
|
||||
sol = set(s, 1.0);
|
||||
}
|
||||
let values: Vec<Value> = want
|
||||
.iter()
|
||||
|
||||
@@ -71,6 +71,40 @@ fn contact_sets_stop_a_driven_joint_at_touching() {
|
||||
assert!(s.execute("contact.create", &json!({"occurrences": ["A"]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contact_uses_preserved_body_placements_after_component_reassignment() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Transferred"}));
|
||||
let component = run(&mut s, "component.create", json!({"name": "Carrier"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": component["occurrence"], "translate": [100, 0, 0]}));
|
||||
run(&mut s, "component.move_bodies", json!({"bodies": ["Transferred"], "component": component["component"]}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let probe = run(&mut s, "component.create", json!({"name": "Probe"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [5, 0, 0]}));
|
||||
run(&mut s, "contact.create", json!({"occurrences": [component["occurrence"], probe["occurrence"]]}));
|
||||
let collisions = run(&mut s, "contact.check", json!({}));
|
||||
assert_eq!(collisions["collisions"].as_array().map(Vec::len), Some(1), "body stayed at x=0: {collisions}");
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": probe["occurrence"], "translate": [100, 0, 0]}));
|
||||
let collisions = run(&mut s, "contact.check", json!({}));
|
||||
assert_eq!(collisions["collisions"].as_array().map(Vec::len), Some(0), "probe moved away: {collisions}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_explode_centres_follow_reassigned_bodies() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Transferred"}));
|
||||
let component = run(&mut s, "component.create", json!({"name": "Carrier"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": component["occurrence"], "translate": [100, 0, 0]}));
|
||||
run(&mut s, "component.move_bodies", json!({"bodies": ["Transferred"], "component": component["component"]}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Neighbour"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
|
||||
let exploded = run(&mut s, "explode.create", json!({"scale": 1}));
|
||||
let moves = exploded["moves"].as_array().unwrap();
|
||||
let carrier = moves.iter().find(|m| m["occurrence"] == component["occurrence"]).unwrap();
|
||||
assert!((carrier["translate"][0].as_f64().unwrap() + 10.0).abs() < 1e-6, "{exploded}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_study_plays_and_exports() {
|
||||
let mut s = rail();
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
// Exact occurrence workflows through the same commands used by the Browser.
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn run(s: &mut Session, command: &str, p: Value) -> Value {
|
||||
s.execute(command, &p).unwrap()
|
||||
}
|
||||
fn created(s: &mut Session, name: &str) -> (u64, u64) {
|
||||
let c = run(s, "component.create", json!({"name": name}));
|
||||
(c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap())
|
||||
}
|
||||
fn near(value: &Value, expected: f64) {
|
||||
assert!((value.as_f64().unwrap() - expected).abs() < 1e-6, "{value} != {expected}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_copy_uses_selected_source_transform() {
|
||||
let mut s = Session::default();
|
||||
let (component, first) = created(&mut s, "Block");
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": second, "translate": [30,0,0]}));
|
||||
let third = run(&mut s, "occurrence.copy", json!({"occurrence": second, "translate": [5,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
let placement = s.doc.occurrences.iter().find(|o| o.id == third).unwrap();
|
||||
assert!((placement.transform[3][0] - 55.0).abs() < 1e-6);
|
||||
assert_eq!(s.doc.occurrences.iter().find(|o| o.id == first).unwrap().transform[3][0], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_world_move_under_rotated_parent_uses_selected_path() {
|
||||
let mut s = Session::default();
|
||||
let (_, parent) = created(&mut s, "Parent");
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": parent, "axis": [0,0,1], "angle": "90 deg"}));
|
||||
let (_, child) = created(&mut s, "Child");
|
||||
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": child, "path": [parent, child], "space": "world", "translate": [10,0,0]}));
|
||||
let result = run(&mut s, "occurrence.properties", json!({"occurrence": child, "path": [parent,child]}));
|
||||
near(&result["total"]["center_of_mass"][0], 9.5);
|
||||
near(&result["total"]["center_of_mass"][1], 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_authoring_at_second_placement_keeps_definition_shared() {
|
||||
let mut s = Session::default();
|
||||
let (component, first) = created(&mut s, "Block");
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component": component, "occurrence": second, "path": [second]}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [20,0,0], "operation": "new"}));
|
||||
for (occurrence, x) in [(first, 1.0), (second, 21.0)] {
|
||||
let result = run(&mut s, "occurrence.properties", json!({"occurrence": occurrence, "path": [occurrence]}));
|
||||
near(&result["total"]["center_of_mass"][0], x);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_selection_survives_rename_move_capture_and_serde() {
|
||||
let mut s = Session::default();
|
||||
let (component, _) = created(&mut s, "Block");
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
let selected = json!({"type": "occurrence", "path": [second]});
|
||||
run(&mut s, "select.set", json!({"items": [selected]}));
|
||||
run(&mut s, "component.rename", json!({"component": component, "name": "Renamed"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": second, "translate": [5,0,0], "capture": false}));
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
assert_eq!(serde_json::to_value(&s.selection).unwrap(), json!([selected]));
|
||||
let roundtrip: Vec<crate::Sel> = serde_json::from_value(json!([selected])).unwrap();
|
||||
assert_eq!(roundtrip, s.selection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_selection_delete_removes_only_selected_instance() {
|
||||
let mut s = Session::default();
|
||||
let (component, first) = created(&mut s, "Block");
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "select.set", json!({"items": [{"type": "occurrence", "path": [second]}]}));
|
||||
let items = s.selection.clone();
|
||||
run(&mut s, "selection.delete", json!({"items": items}));
|
||||
assert!(s.doc.occurrences.iter().any(|o| o.id == first));
|
||||
assert!(!s.doc.occurrences.iter().any(|o| o.id == second));
|
||||
assert_eq!(s.world_state().bodies.len(), 1);
|
||||
assert!(s.doc.components.iter().any(|c| c.id == component));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_move_includes_pending_parent_and_leaf_and_shared_scope() {
|
||||
let mut s = Session::default();
|
||||
let (parent_component, parent) = created(&mut s, "Parent");
|
||||
let (_, child) = created(&mut s, "Child");
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":parent,"axis":[0,0,1],"angle":"90 deg","translate":[3,0,0],"capture":false}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":child,"translate":[2,0,0],"capture":false}));
|
||||
let before = s.occurrence_placement(&[parent, child]).unwrap().world;
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":child,"path":[parent,child],"space":"world","translate":[10,0,0],"capture":false}));
|
||||
let after = s.occurrence_placement(&[parent, child]).unwrap().world;
|
||||
assert!((after[3][0] - before[3][0] - 10.0).abs() < 1e-8);
|
||||
assert!((after[3][1] - before[3][1]).abs() < 1e-8);
|
||||
run(
|
||||
&mut s,
|
||||
"occurrence.move",
|
||||
json!({"occurrence":child,"path":[parent,child],"space":"world","axis":[0,0,1],"angle":"90 deg","origin":[0,0,0],"capture":false}),
|
||||
);
|
||||
let rotated = s.occurrence_placement(&[parent, child]).unwrap().world;
|
||||
assert!((rotated[3][0] + after[3][1]).abs() < 1e-8);
|
||||
assert!((rotated[3][1] - after[3][0]).abs() < 1e-8);
|
||||
run(&mut s, "component.activate", json!({"component":parent_component}));
|
||||
let second_parent = run(&mut s, "occurrence.copy", json!({"occurrence":parent,"translate":[30,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
assert_eq!(s.occurrence_scope_count(&[second_parent, child]).unwrap(), 2);
|
||||
assert!(s.execute("occurrence.move", &json!({"occurrence":child,"space":"world","translate":[1,0,0]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_paths_are_atomic_for_session_state() {
|
||||
let mut s = Session::default();
|
||||
let (component, first) = created(&mut s, "Part");
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false}));
|
||||
run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]}));
|
||||
let pending = s.pending_moves.clone();
|
||||
let active = s.active_occurrence_path.clone();
|
||||
let vis = s.visibility.clone();
|
||||
let doc = s.doc.clone();
|
||||
for (cmd, p) in [
|
||||
("occurrence.move", json!({"occurrence":second,"path":[first],"space":"world","translate":[10,0,0]})),
|
||||
("occurrence.move", json!({"occurrence":second,"space":17,"translate":[10,0,0]})),
|
||||
("component.activate", json!({"component":component,"occurrence":second,"path":[first,second]})),
|
||||
("browser.visibility", json!({"visible":true,"paths":[[second],[9999]]})),
|
||||
("select.set", json!({"items":[{"type":"occurrence","path":[first,second]}]})),
|
||||
("occurrence.copy", json!({"occurrence":second,"path":[first]})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd}");
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(s.active_occurrence_path, active);
|
||||
assert_eq!(s.visibility, vis);
|
||||
assert_eq!(s.doc, doc);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_visibility_isolate_rename_and_async_accept() {
|
||||
let mut s = Session::default();
|
||||
let (component, first) = created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
let first_body = s.occurrence_bodies(&[first], true).unwrap()[0].clone();
|
||||
let second_body = s.occurrence_bodies(&[second], true).unwrap()[0].clone();
|
||||
run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]}));
|
||||
assert!(s.body_visible(&first_body));
|
||||
assert!(!s.body_visible(&second_body));
|
||||
let mut scratch = s.scratch();
|
||||
assert_eq!(scratch.visibility, s.visibility);
|
||||
run(&mut scratch, "component.rename", json!({"component":component,"name":"Changed"}));
|
||||
s.accept_computed(scratch);
|
||||
assert!(!s.occurrence_visible(&[second]));
|
||||
run(&mut s, "browser.visibility", json!({"visible":true,"paths":[[second]],"isolate":true}));
|
||||
assert!(!s.body_visible(&first_body));
|
||||
assert!(s.body_visible(&second_body));
|
||||
run(&mut s, "browser.visibility", json!({"visible":true,"folder":"bodies"}));
|
||||
assert!(s.body_visible(&first_body));
|
||||
assert!(s.body_visible(&second_body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesized_name_collision_keeps_source_and_instance_separate() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1}));
|
||||
let root = s.model.state().bodies[0].name.clone();
|
||||
let (_, first) = created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
let candidate = s.occurrence_bodies(&[second], false).unwrap()[0].clone();
|
||||
run(&mut s, "body.rename", json!({"body":root,"name":candidate}));
|
||||
let instance = s.occurrence_bodies(&[second], false).unwrap()[0].clone();
|
||||
assert_ne!(instance, candidate);
|
||||
let frames = s.doc.body_frames(&s.model.state());
|
||||
let world = s.world_state();
|
||||
assert!(frames.iter().all(|(name, _, _, _)| world.body(name).is_some()));
|
||||
assert_eq!(world.bodies.len(), world.bodies.iter().map(|b| &b.name).collect::<std::collections::BTreeSet<_>>().len());
|
||||
run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]}));
|
||||
assert!(s.body_visible(&candidate));
|
||||
assert!(!s.body_visible(&instance));
|
||||
let source = s.occurrence_placement(&[second]).unwrap().bodies[0].source.clone();
|
||||
run(&mut s, "material.assign", json!({"bodies":[instance],"material":"Brass"}));
|
||||
assert_eq!(s.doc.materials.get(&source).map(String::as_str), Some("Brass"));
|
||||
assert!(!s.doc.materials.contains_key(&candidate));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_nested_labels_use_full_path_and_reopen_undo_preserve_placements() {
|
||||
let mut s = Session::default();
|
||||
let (parent_component, parent) = created(&mut s, "Parent");
|
||||
let (_, child) = created(&mut s, "Child");
|
||||
run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1}));
|
||||
let parent2 = s.doc_mut().add_occurrence(parent_component, 0, solvecraft_doc::IDENTITY).unwrap();
|
||||
let parent3 = s.doc_mut().add_occurrence(parent_component, 0, solvecraft_doc::IDENTITY).unwrap();
|
||||
for o in s.doc_mut().occurrences.iter_mut().filter(|o| o.id == parent2 || o.id == parent3) {
|
||||
o.name = "Same".into();
|
||||
}
|
||||
s.refresh();
|
||||
let a = s.occurrence_bodies(&[parent2, child], false).unwrap()[0].clone();
|
||||
let b = s.occurrence_bodies(&[parent3, child], false).unwrap()[0].clone();
|
||||
assert_ne!(a, b);
|
||||
let mut reopened = Session::new(solvecraft_doc::format::read(&s.doc.to_json()).unwrap());
|
||||
assert_eq!(reopened.occurrence_bodies(&[parent2, child], false).unwrap()[0], a);
|
||||
assert_eq!(reopened.occurrence_bodies(&[parent3, child], false).unwrap()[0], b);
|
||||
run(&mut reopened, "selection.delete", json!({"occurrences":[parent3]}));
|
||||
assert!(reopened.occurrence_placement(&[parent3, child]).is_err());
|
||||
run(&mut reopened, "edit.undo", json!({}));
|
||||
assert!(reopened.occurrence_placement(&[parent3, child]).is_ok());
|
||||
assert!(reopened.occurrence_placement(&[parent, child]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_duplicated_assembly_exact_resolution_and_expansion_limit() {
|
||||
let mut s = Session::default();
|
||||
let mut parent = 0;
|
||||
let mut path = Vec::new();
|
||||
for i in 0..18 {
|
||||
let component = s.doc_mut().add_component(Some(&format!("Level{i}")), parent).unwrap();
|
||||
let occurrence = s.doc.occurrence_of(component).unwrap().id;
|
||||
path.push(occurrence);
|
||||
s.doc_mut().add_occurrence(component, parent, solvecraft_doc::IDENTITY).unwrap();
|
||||
parent = component;
|
||||
}
|
||||
assert!(s.occurrence_placement(&path).is_ok());
|
||||
let top = [path[0]];
|
||||
assert!(s.occurrence_bodies(&top, true).is_err());
|
||||
assert!(s.execute("select.set", &json!({"items":[{"type":"occurrence","path":top}]})).is_err());
|
||||
assert!(s.selection.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_instances_map_world_keys_to_shared_sources() {
|
||||
let mut s = Session::default();
|
||||
let (_, first) = created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
|
||||
let placement = s.occurrence_placement(&[second]).unwrap();
|
||||
let body = &placement.bodies[0];
|
||||
assert_ne!(body.source, body.world);
|
||||
run(&mut s, "material.assign", json!({"bodies":[body.world],"material":"Brass"}));
|
||||
assert_eq!(s.doc.materials.get(&body.source).map(String::as_str), Some("Brass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_baseline_move_preserves_other_pending_positions_without_drift() {
|
||||
let mut s = Session::default();
|
||||
let (_, first) = created(&mut s, "Part");
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":first,"translate":[5,0,0],"capture":false}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[3,0,0],"capture":false}));
|
||||
let baseline = s.occurrence_placement(&[second]).unwrap().local;
|
||||
for offset in [10.0, 15.0, 0.0] {
|
||||
run(
|
||||
&mut s,
|
||||
"occurrence.move",
|
||||
json!({"occurrence":second,"path":[second],"space":"world","base_transform":baseline,"translate":[offset,0,0],"capture":false}),
|
||||
);
|
||||
assert_eq!(s.pending_moves.get(&first).unwrap()[3][0], 5.0);
|
||||
assert_eq!(s.pending_moves.get(&second).unwrap()[3][0], 23.0 + offset);
|
||||
}
|
||||
let pending = s.pending_moves.clone();
|
||||
assert!(s.execute("occurrence.move", &json!({"occurrence":second,"base_transform":[[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,1]]})).is_err());
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
for base in [
|
||||
[[2.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]],
|
||||
[[-1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]],
|
||||
] {
|
||||
assert!(s.execute("occurrence.move", &json!({"occurrence":second,"base_transform":base})).is_err());
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
}
|
||||
assert!(s.execute("component.revert_position", &json!({"occurrences":[second,9999]})).is_err());
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
run(&mut s, "component.revert_position", json!({"occurrences":[second]}));
|
||||
assert!(!s.pending_moves.contains_key(&second));
|
||||
assert_eq!(s.pending_moves.get(&first).unwrap()[3][0], 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_moves_copies_and_reused_ids_do_not_leak_state() {
|
||||
let mut s = Session::default();
|
||||
let (_, first) = created(&mut s, "Part");
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":first,"translate":[10,0,0],"capture":false}));
|
||||
let pending = s.pending_moves.clone();
|
||||
let doc = s.doc.clone();
|
||||
for (cmd, p) in [
|
||||
("occurrence.move", json!({"occurrence":first,"origin":[1e308,0,0],"angle":"180 deg"})),
|
||||
("occurrence.move", json!({"occurrence":first,"translate":[1e9,0,0],"capture":false})),
|
||||
("occurrence.move", json!({"occurrence":first,"translate":[1,2]})),
|
||||
("occurrence.move", json!({"occurrence":first,"angle":false})),
|
||||
("occurrence.move", json!({"occurrence":first,"axis":[1e308,0,0],"angle":"90 deg"})),
|
||||
("occurrence.move", json!({"occurrence":first,"axis":[0,0,0],"angle":"90 deg"})),
|
||||
("occurrence.copy", json!({"occurrence":first,"translate":[1e9,0,0]})),
|
||||
("occurrence.copy", json!({"occurrence":first,"translate":"oops"})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd}");
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(s.doc, doc);
|
||||
}
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.pending_moves.is_empty());
|
||||
let (_, other) = created(&mut s, "Other");
|
||||
assert_eq!(first, other);
|
||||
assert_eq!(s.occurrence_placement(&[other]).unwrap().world[3][0], 0.0);
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":other,"translate":[20,0,0],"capture":false}));
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
assert!(s.pending_moves.is_empty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.pending_moves.get(&other).unwrap()[3][0], 20.0);
|
||||
assert_eq!(s.doc.occurrences.iter().find(|occurrence| occurrence.id == other).unwrap().transform[3][0], 0.0);
|
||||
assert_eq!(s.occurrence_placement(&[other]).unwrap().world[3][0], 20.0);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(s.pending_moves.is_empty());
|
||||
assert_eq!(s.doc.occurrences.iter().find(|occurrence| occurrence.id == other).unwrap().transform[3][0], 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolate_nested_child_hides_ancestor_material_and_sibling() {
|
||||
let mut s = Session::default();
|
||||
let (_, parent) = created(&mut s, "Parent");
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10}));
|
||||
let (child_component, child) = created(&mut s, "Child");
|
||||
run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1}));
|
||||
let parent_component = s.doc.occurrences.iter().find(|o| o.id == child).unwrap().parent;
|
||||
let sibling = s
|
||||
.doc_mut()
|
||||
.add_occurrence(
|
||||
child_component,
|
||||
parent_component,
|
||||
solvecraft_doc::rigid(crate::geom::Vec3::new(20.0, 0.0, 0.0), crate::geom::Vec3::ZERO, crate::geom::Vec3::Z, 0.0),
|
||||
)
|
||||
.unwrap();
|
||||
s.refresh();
|
||||
run(&mut s, "browser.visibility", json!({"visible":true,"paths":[[parent,child]],"isolate":true}));
|
||||
for (path, expected) in [(vec![parent], false), (vec![parent, child], true), (vec![parent, sibling], false)] {
|
||||
let body = s.occurrence_bodies(&path, false).unwrap()[0].clone();
|
||||
assert_eq!(s.body_visible(&body), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_new_child_uses_inactive_parent_placement() {
|
||||
let mut s = Session::default();
|
||||
let (parent_component, parent) = created(&mut s, "Parent");
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":parent,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let child =
|
||||
run(&mut s, "component.create", json!({"parent":parent_component,"parent_path":[second],"name":"Child"}))["occurrence"].as_u64().unwrap();
|
||||
assert_eq!(s.active_occurrence_path, Some(vec![second, child]));
|
||||
assert_eq!(s.active_component_frame()[3][0], 20.0);
|
||||
let doc = s.doc.clone();
|
||||
let active = s.active_occurrence_path.clone();
|
||||
assert!(s.execute("component.create", &json!({"parent":parent_component,"parent_path":[999],"name":"Invalid"})).is_err());
|
||||
assert_eq!(s.doc, doc);
|
||||
assert_eq!(s.active_occurrence_path, active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_new_uses_selected_pending_placement() {
|
||||
let mut s = Session::default();
|
||||
let (component, first) = created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[30,0,0],"capture":false}));
|
||||
let copied =
|
||||
run(&mut s, "component.paste_new", json!({"component":component,"occurrence":second,"path":[second],"translate":[5,0,0]}))["component"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
assert_eq!(s.doc.occurrence_of(copied).unwrap().transform[3][0], 55.0);
|
||||
let result = run(&mut s, "component.properties", json!({"component":copied}));
|
||||
near(&result["total"]["center_of_mass"][0], 55.5);
|
||||
let result = run(&mut s, "occurrence.properties", json!({"occurrence":first,"path":[first]}));
|
||||
near(&result["total"]["center_of_mass"][0], 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_styles_follow_source_identity_after_collision_rename_and_pending_move() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1}));
|
||||
let root = s.model.state().bodies[0].name.clone();
|
||||
let (component, first) = created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
let candidate = s.occurrence_bodies(&[second], false).unwrap()[0].clone();
|
||||
run(&mut s, "body.rename", json!({"body":root,"name":candidate}));
|
||||
let instance = s.occurrence_bodies(&[second], false).unwrap()[0].clone();
|
||||
let source = s.occurrence_placement(&[second]).unwrap().bodies[0].source.clone();
|
||||
run(&mut s, "material.assign", json!({"bodies":[instance],"material":"Brass"}));
|
||||
let world = s.world_state();
|
||||
assert_eq!(s.doc.body_color(world.body(&instance).unwrap()), s.doc.body_color(world.body(&source).unwrap()));
|
||||
run(&mut s, "appearance.assign", json!({"components":[component.to_string()],"color":"#0000ff","opacity":0.7}));
|
||||
assert_eq!(s.doc.body_color(s.world_state().body(&instance).unwrap()), Some([0.0, 0.0, 1.0]));
|
||||
run(&mut s, "appearance.assign", json!({"bodies":[instance],"color":"#ff0000","opacity":0.5}));
|
||||
run(&mut s, "appearance.assign", json!({"faces":[{"body":instance,"point":[21,1,2]}],"color":"#00ff00","opacity":0.4}));
|
||||
// Exercise the point-based face fallback as well as persistent face-name matching.
|
||||
for face in &mut s.doc_mut().appearances.faces {
|
||||
face.name = None;
|
||||
}
|
||||
run(&mut s, "body.rename", json!({"body":source,"name":"Shared Renamed"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false}));
|
||||
let world = s.world_state();
|
||||
for id in [first, second] {
|
||||
let bodyname = s.occurrence_bodies(&[id], false).unwrap()[0].clone();
|
||||
let body = world.body(&bodyname).unwrap();
|
||||
assert_eq!(body.definition_name(), "Shared Renamed");
|
||||
assert_eq!(body.component, component);
|
||||
let look = s.doc.model_body_look(body).unwrap();
|
||||
assert_eq!(look.color, [255, 0, 0]);
|
||||
assert_eq!(look.opacity, 0.5);
|
||||
assert!(s.doc.face_colors(body).iter().any(|(_, l)| l.color == [0, 255, 0] && l.opacity == 0.4));
|
||||
let measured = run(&mut s, "inspect.measure", json!({"bodies":[bodyname]}));
|
||||
assert_eq!(measured["bodies"][0]["material"], "Brass");
|
||||
assert!((measured["bodies"][0]["mass_g"].as_f64().unwrap() - 0.068).abs() < 1e-8);
|
||||
}
|
||||
let definition = s.model.state();
|
||||
assert_eq!(solvecraft_kernel::measure(&definition.body("Shared Renamed").unwrap().body).unwrap().bbox.min, crate::geom::Vec3::ZERO);
|
||||
let painted = s.doc.painted(&world);
|
||||
let secondname = s.occurrence_bodies(&[second], false).unwrap()[0].clone();
|
||||
let body = painted.body(&secondname).unwrap();
|
||||
assert_eq!(body.body.color(), Some([1.0, 0.0, 0.0]));
|
||||
assert!(body.body.paint().unwrap().faces.iter().any(|f| f.color == [0.0, 1.0, 0.0]));
|
||||
let bytes = solvecraft_io::export(&painted, &[secondname], solvecraft_io::Format::Step, "InstanceStyles").unwrap();
|
||||
let imported = solvecraft_kernel::step_import(&String::from_utf8(bytes).unwrap()).unwrap();
|
||||
assert_eq!(imported.bodies.len(), 1);
|
||||
assert_eq!(imported.bodies[0].body.color(), Some([1.0, 0.0, 0.0]));
|
||||
assert!(imported.bodies[0].body.paint().unwrap().faces.iter().any(|f| f.color == [0.0, 1.0, 0.0]));
|
||||
}
|
||||
@@ -464,7 +464,7 @@ mod tests {
|
||||
let st = s.world_state();
|
||||
let comp = s.doc.components.iter().find(|c| c.name.starts_with(prefix)).map(|c| c.id).unwrap_or(0);
|
||||
let (mut lo, mut hi) = (solvecraft_geom::Vec3::new(f64::MAX, f64::MAX, f64::MAX), solvecraft_geom::Vec3::new(f64::MIN, f64::MIN, f64::MIN));
|
||||
for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp) {
|
||||
for b in st.bodies.iter().filter(|b| s.doc.model_body_component(b) == comp) {
|
||||
let m = b.mesh().bounds();
|
||||
lo = solvecraft_geom::Vec3::new(lo.x.min(m.min.x), lo.y.min(m.min.y), lo.z.min(m.min.z));
|
||||
hi = solvecraft_geom::Vec3::new(hi.x.max(m.max.x), hi.y.max(m.max.y), hi.z.max(m.max.z));
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{Document, FeatureKind, RevolveConvention};
|
||||
use solvecraft_geom::Vec3;
|
||||
use std::f64::consts::{FRAC_PI_2, PI, SQRT_2};
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn profile(s: &mut Session, opposed: bool) {
|
||||
run(s, "sketch.create", json!({"name":"Profile","plane":"XY"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0":[2,0],"p1":[4,10]}));
|
||||
if opposed {
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0":[-4,0],"p1":[-2,10]}));
|
||||
}
|
||||
run(s, "sketch.finish", json!({}));
|
||||
}
|
||||
|
||||
fn turn(s: &mut Session, sign: f64, settings: Value) {
|
||||
let mut p = json!({"sketch":"Profile","axis":{"origin":[0,0,0],"dir":[0,sign,0]},"angle":"90 deg","direction":"symmetric","operation":"new","name":"Turn","body_name":"Turned"});
|
||||
p.as_object_mut().unwrap().extend(settings.as_object().unwrap().clone());
|
||||
run(s, "solid.revolve", p);
|
||||
}
|
||||
|
||||
fn check_centered(s: &Session, opposed: bool) {
|
||||
let state = s.model.state();
|
||||
let expected = 30.0 * PI * if opposed { 2.0 } else { 1.0 };
|
||||
let volume: f64 = state.bodies.iter().map(|mb| solvecraft_kernel::measure(&mb.body).unwrap().volume).sum();
|
||||
assert!((volume - expected).abs() < 0.03, "{volume} vs {expected}");
|
||||
for sign in if opposed { vec![1.0, -1.0] } else { vec![1.0] } {
|
||||
let body = &state.bodies.iter().find(|mb| mb.body.contains(Vec3::new(sign * 3.0, 5.0, 0.0))).unwrap().body;
|
||||
assert!(!body.contains(Vec3::new(0.0, 5.0, 3.0)));
|
||||
assert!(body.contains(Vec3::new(sign * 3.0 * (PI / 6.0).cos(), 5.0, 1.5)));
|
||||
assert!(body.contains(Vec3::new(sign * 3.0 * (PI / 6.0).cos(), 5.0, -1.5)));
|
||||
let bounds = body.tessellate(1e-4).unwrap().bounds();
|
||||
assert!((bounds.min.z + 2.0 * SQRT_2).abs() < 1e-6, "{bounds:?}");
|
||||
assert!((bounds.max.z - 2.0 * SQRT_2).abs() < 1e-6, "{bounds:?}");
|
||||
let inner_x = if sign > 0.0 { bounds.min.x } else { -bounds.max.x };
|
||||
assert!((inner_x - SQRT_2).abs() < 1e-6, "{bounds:?}");
|
||||
let centroid = solvecraft_kernel::measure(body).unwrap().centroid;
|
||||
let radial = (2.0 / 3.0) * (64.0 - 8.0) / (16.0 - 4.0);
|
||||
let want_x = radial * (PI / 4.0).sin() / (PI / 4.0);
|
||||
assert!((centroid.x - sign * want_x).abs() < 0.005, "{centroid:?}");
|
||||
assert!((centroid.y - 5.0).abs() < 1e-6);
|
||||
assert!(centroid.z.abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_symmetric_revolves_center_material_for_both_axis_signs_and_negative_angles() {
|
||||
for sign in [-1.0, 1.0] {
|
||||
for angle in ["90 deg", "-90 deg"] {
|
||||
let mut s = Session::default();
|
||||
profile(&mut s, false);
|
||||
turn(&mut s, sign, json!({"angle":angle}));
|
||||
assert!(matches!(s.doc.find_feature("Turn").unwrap().kind, FeatureKind::Revolve { convention: RevolveConvention::SketchNormal, .. }));
|
||||
check_centered(&s, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opposed_profiles_use_independent_sweep_axes() {
|
||||
for sign in [-1.0, 1.0] {
|
||||
let mut s = Session::default();
|
||||
profile(&mut s, true);
|
||||
turn(&mut s, sign, json!({}));
|
||||
check_centered(&s, true);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_sided_revolves_use_the_canonical_negative_and_positive_limits() {
|
||||
for sign in [-1.0, 1.0] {
|
||||
let mut s = Session::default();
|
||||
profile(&mut s, false);
|
||||
turn(&mut s, sign, json!({"direction":"positive","angle":"60 deg","angle2":"30 deg"}));
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
let bounds = body.tessellate(1e-4).unwrap().bounds();
|
||||
assert!((bounds.min.x - 1.0).abs() < 1e-6, "{bounds:?}");
|
||||
assert!((bounds.max.x - 4.0).abs() < 1e-4);
|
||||
assert!((bounds.min.z + 2.0).abs() < 1e-6);
|
||||
assert!((bounds.max.z - 2.0 * 3.0f64.sqrt()).abs() < 1e-6);
|
||||
assert!(body.contains(Vec3::new(3.0, 5.0, 0.0)));
|
||||
assert!(!body.contains(Vec3::new(0.0, 5.0, 3.0)));
|
||||
assert!((solvecraft_kernel::measure(body).unwrap().volume - 30.0 * PI).abs() < 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_missing_convention_keeps_geometry_through_edit_redefine_undo_and_reopen() {
|
||||
let mut s = Session::default();
|
||||
profile(&mut s, false);
|
||||
turn(&mut s, -1.0, json!({"convention":"legacy_axis_start"}));
|
||||
let mut saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
saved["format"] = json!("solvecraft/10");
|
||||
for f in saved["features"].as_array_mut().unwrap() {
|
||||
f.as_object_mut().unwrap().remove("convention");
|
||||
}
|
||||
let mut s = Session::new(Document::from_json(&saved.to_string()).unwrap());
|
||||
let original = s.doc.find_feature("Turn").unwrap().kind.clone();
|
||||
let check = |s: &Session| {
|
||||
assert_eq!(s.doc.find_feature("Turn").unwrap().kind, original);
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
assert!(body.contains(Vec3::new(0.0, 5.0, 3.0)));
|
||||
assert!(!body.contains(Vec3::new(3.0, 5.0, 0.0)));
|
||||
let bounds = body.tessellate(1e-4).unwrap().bounds();
|
||||
assert!((bounds.min.x + 2.0 * SQRT_2).abs() < 1e-6);
|
||||
assert!((bounds.max.x - 2.0 * SQRT_2).abs() < 1e-6);
|
||||
assert!((bounds.min.z - SQRT_2).abs() < 1e-6);
|
||||
};
|
||||
check(&s);
|
||||
let angle_input = s.doc.find_feature("Turn").unwrap().param_names[0].clone();
|
||||
run(&mut s, "parameters.change", json!({"name":angle_input,"expression":"60 deg"}));
|
||||
let changed = s.model.state();
|
||||
assert!(changed.bodies[0].body.contains(Vec3::new(1.5, 5.0, 3.0 * 3.0f64.sqrt() / 2.0)));
|
||||
assert!(!changed.bodies[0].body.contains(Vec3::new(3.0, 5.0, 0.0)));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
check(&s);
|
||||
run(&mut s, "timeline.edit", json!({"feature":"Turn","set":{"angle":"90 deg"}}));
|
||||
check(&s);
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature":"Turn","command":"solid.revolve","params":{"sketch":"Profile","axis":{"origin":[0,0,0],"dir":[0,-1,0]},"angle":"90 deg","direction":"symmetric","body_name":"Turned"}}),
|
||||
);
|
||||
check(&s);
|
||||
run(&mut s, "timeline.edit", json!({"feature":"Turn","set":{"convention":"sketch_normal"}}));
|
||||
check_centered(&s, false);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
check(&s);
|
||||
let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
check(&reopened);
|
||||
// A newly authored feature in this migrated document uses current semantics.
|
||||
turn(&mut s, -1.0, json!({"name":"NewTurn","body_name":"NewTurned"}));
|
||||
assert!(matches!(s.doc.find_feature("NewTurn").unwrap().kind, FeatureKind::Revolve { convention: RevolveConvention::SketchNormal, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extent_handle_angles_match_new_and_legacy_material() {
|
||||
let mut s = Session::default();
|
||||
profile(&mut s, false);
|
||||
let state = s.model.state();
|
||||
let ss = &state.sketches[0];
|
||||
let regions = vec![ss.profiles[0].region.clone()];
|
||||
for convention in [RevolveConvention::SketchNormal, RevolveConvention::LegacyAxisStart] {
|
||||
let extent = solvecraft_doc::revolve_extent_with_convention(
|
||||
&state,
|
||||
&ss.plane,
|
||||
®ions,
|
||||
(Vec3::ZERO, -Vec3::Y),
|
||||
(FRAC_PI_2, None),
|
||||
true,
|
||||
None,
|
||||
convention,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(extent.rotation_axis, -Vec3::Y);
|
||||
assert!((extent.display_start - if convention == RevolveConvention::SketchNormal { -PI / 4.0 } else { PI / 4.0 }).abs() < 1e-12);
|
||||
assert!((extent.sweep - FRAC_PI_2).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_revolve_preserves_a_translated_rotated_sketch_frame() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"name":"Profile","plane":{"origin":[10,20,30],"x_dir":[0,0,1],"y_dir":[1,0,0]}}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[2,0],"p1":[4,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.revolve", json!({"sketch":"Profile","axis":{"origin":[10,20,30],"dir":[-1,0,0]},"angle":"90 deg","direction":"symmetric"}));
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
let bounds = body.tessellate(1e-4).unwrap().bounds();
|
||||
assert!(bounds.min.dist(Vec3::new(10.0, 20.0 - 2.0 * SQRT_2, 30.0 + SQRT_2)) < 1e-6, "{bounds:?}");
|
||||
assert!(bounds.max.dist(Vec3::new(20.0, 20.0 + 2.0 * SQRT_2, 34.0)) < 1e-4, "{bounds:?}");
|
||||
assert!(body.contains(Vec3::new(15.0, 20.0, 33.0)));
|
||||
assert!(!body.contains(Vec3::new(15.0, 23.0, 30.0)));
|
||||
assert!((solvecraft_kernel::measure(body).unwrap().volume - 30.0 * PI).abs() < 0.03);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_revolves_keep_their_convention_and_angular_material() {
|
||||
for convention in ["sketch_normal", "legacy_axis_start"] {
|
||||
let mut s = Session::default();
|
||||
profile(&mut s, false);
|
||||
turn(&mut s, -1.0, json!({"convention":convention}));
|
||||
run(&mut s, "timeline.copy", json!({"features":["Profile","Turn"]}));
|
||||
run(&mut s, "timeline.paste", json!({"translate":[0,20,0]}));
|
||||
let state = s.model.state();
|
||||
let probe = if convention == "sketch_normal" { Vec3::new(3.0, 25.0, 0.0) } else { Vec3::new(0.0, 25.0, 3.0) };
|
||||
assert!(state.bodies.iter().any(|mb| mb.body.contains(probe)));
|
||||
let modes: Vec<_> = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.filter_map(|f| if let FeatureKind::Revolve { convention, .. } = f.kind { Some(convention) } else { None })
|
||||
.collect();
|
||||
assert_eq!(modes.len(), 2);
|
||||
assert_eq!(modes[0], modes[1]);
|
||||
let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.state().bodies.iter().any(|mb| mb.body.contains(probe)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centered_coplanar_cut_uses_the_actual_angular_interval() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"corner":[2,0,-4],"length":2,"width":10,"height":8,"body_name":"Stock"}));
|
||||
profile(&mut s, false);
|
||||
turn(&mut s, -1.0, json!({"operation":"cut","targets":["Stock"]}));
|
||||
let state = s.model.state();
|
||||
// Removed sector clipped by x>=2: 10∫[-π/4,π/4](16−4sec²θ)/2 dθ.
|
||||
// The sector crosses the whole stock at z=0, leaving two disconnected solids.
|
||||
let expected = 200.0 - 40.0 * PI;
|
||||
let volumes: Vec<_> = state.bodies.iter().map(|mb| solvecraft_kernel::measure(&mb.body).unwrap().volume).collect();
|
||||
let total: f64 = volumes.iter().sum();
|
||||
assert!((total - expected).abs() < 0.05, "{volumes:?}: total{total}, expected{expected}");
|
||||
assert_eq!(state.bodies.len(), 2);
|
||||
assert!(volumes.iter().all(|v| (*v - expected / 2.0).abs() < 0.025));
|
||||
let contains = |p| state.bodies.iter().any(|mb| mb.body.contains(p));
|
||||
assert!(!contains(Vec3::new(3.0, 5.0, 0.0)));
|
||||
assert!(contains(Vec3::new(3.0, 5.0, 3.5)));
|
||||
assert!(contains(Vec3::new(3.0, 5.0, -3.5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_sided_and_full_revolves_keep_the_existing_material() {
|
||||
for angle in ["90 deg", "360 deg"] {
|
||||
for sign in [-1.0, 1.0] {
|
||||
for convention in ["legacy_axis_start", "sketch_normal"] {
|
||||
let mut s = Session::default();
|
||||
profile(&mut s, false);
|
||||
turn(&mut s, sign, json!({"angle":angle,"direction":"positive","convention":convention}));
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
assert!(body.contains(Vec3::new(3.0 / SQRT_2, 5.0, 3.0 / SQRT_2)));
|
||||
assert_eq!(body.contains(Vec3::new(-3.0 / SQRT_2, 5.0, -3.0 / SQRT_2)), angle == "360 deg");
|
||||
let expected = if angle == "360 deg" { 120.0 * PI } else { 30.0 * PI };
|
||||
assert!((solvecraft_kernel::measure(body).unwrap().volume - expected).abs() < 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_targets_use_each_profiles_canonical_axis_for_both_axis_signs() {
|
||||
for sign in [-1.0, 1.0] {
|
||||
for opposed in [false, true] {
|
||||
let mut s = Session::default();
|
||||
profile(&mut s, opposed);
|
||||
turn(&mut s, sign, json!({"to":[0,5,3]}));
|
||||
let state = s.model.state();
|
||||
let expected = 30.0 * PI * if opposed { 2.0 } else { 1.0 };
|
||||
let volume: f64 = state.bodies.iter().map(|mb| solvecraft_kernel::measure(&mb.body).unwrap().volume).sum();
|
||||
assert!((volume - expected).abs() < 0.05, "{volume}");
|
||||
assert!(state.bodies.iter().any(|mb| mb.body.contains(Vec3::new(3.0 / SQRT_2, 5.0, 3.0 / SQRT_2))));
|
||||
assert!(!state.bodies.iter().any(|mb| mb.body.contains(Vec3::new(3.0 / SQRT_2, 5.0, -3.0 / SQRT_2))));
|
||||
assert_eq!(state.bodies.iter().any(|mb| mb.body.contains(Vec3::new(-3.0 / SQRT_2, 5.0, 3.0 / SQRT_2))), opposed);
|
||||
assert!(!state.bodies.iter().any(|mb| mb.body.contains(Vec3::new(-3.0 / SQRT_2, 5.0, -3.0 / SQRT_2))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_axis_target_is_an_actionable_atomic_error_for_new_convention() {
|
||||
let mut s = Session::default();
|
||||
profile(&mut s, false);
|
||||
let before = s.doc.to_json();
|
||||
let e = s.execute("solid.revolve", &json!({"sketch":"Profile","axis":"Y","to":[0,5,0]})).unwrap_err().to_string();
|
||||
assert!(e.contains("target lies on the axis"), "{e}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curved_targets_keep_only_the_material_before_the_post_for_both_axis_signs() {
|
||||
for sign in [-1.0, 1.0] {
|
||||
for opposed in [false, true] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"base":[0,14,-5],"radius":4,"height":15,"body_name":"Post"}));
|
||||
run(&mut s, "sketch.create", json!({"name":"Profile","plane":"XZ"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[10,0],"p1":[15,5]}));
|
||||
if opposed {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-15,0],"p1":[-10,5]}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.revolve", json!({"sketch":"Profile","axis":{"origin":[0,0,0],"dir":[0,0,sign]},"to":[4,14,2],"body_name":"Arc"}));
|
||||
let state = s.model.state();
|
||||
let post = &state.body("Post").unwrap().body;
|
||||
let arcs: Vec<_> = state.bodies.iter().filter(|mb| mb.name != "Post").collect();
|
||||
assert_eq!(arcs.len(), if opposed { 2 } else { 1 });
|
||||
for side in if opposed { vec![1.0, -1.0] } else { vec![1.0] } {
|
||||
let probe = Vec3::new(side * 12.0 * (PI / 6.0).cos(), 6.0, -2.5);
|
||||
let arc = &arcs.iter().find(|mb| mb.body.contains(probe)).unwrap().body;
|
||||
assert!(!arc.contains(Vec3::new(-side * 12.0 * (PI / 6.0).cos(), 6.0, -2.5)));
|
||||
assert!(!arc.contains(Vec3::new(side * 12.0 * (PI / 6.0).cos(), -6.0, -2.5)));
|
||||
let full = PI * (225.0 - 100.0) * 5.0;
|
||||
let angle = solvecraft_kernel::measure(arc).unwrap().volume / full * 360.0;
|
||||
assert!(angle > 60.0 && angle < 90.0, "{angle}");
|
||||
let overlap = solvecraft_kernel::boolean(arc, post, solvecraft_kernel::BoolOp::Intersect).unwrap();
|
||||
assert!(overlap.is_none_or(|b| solvecraft_kernel::measure(&b).unwrap().volume < 1e-3));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+133
-20
@@ -45,6 +45,9 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf)
|
||||
.noundo()
|
||||
.params("body?, path? → DXF (mm): OUTLINE, BEND_UP and BEND_DOWN (centre lines) layers"),
|
||||
CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table)
|
||||
.noundo()
|
||||
.params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates"),
|
||||
];
|
||||
|
||||
fn points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
@@ -142,8 +145,9 @@ fn fold(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sheet.fold";
|
||||
let (sketch, curve, a, b) = match p.get("points") {
|
||||
Some(v) => {
|
||||
let pts = v.as_array().map(|a| a.iter().filter_map(vec3).collect::<Vec<Vec3>>()).unwrap_or_default();
|
||||
let [a, b] = pts[..] else { return Err(bad(cmd, "`points` must be two points [x, y, z]")) };
|
||||
let pts = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`points` must be two points [x, y, z]"))?;
|
||||
let a = pts.first().and_then(vec3).ok_or_else(|| bad(cmd, "`points` must be two finite points [x, y, z]"))?;
|
||||
let b = pts.get(1).and_then(vec3).ok_or_else(|| bad(cmd, "`points` must be two finite points [x, y, z]"))?;
|
||||
(None, None, a, b)
|
||||
}
|
||||
None => {
|
||||
@@ -270,11 +274,17 @@ fn sheet_of(s: &Session, p: &Value, cmd: &str) -> Result<SheetBody> {
|
||||
}
|
||||
|
||||
fn flat_json(sh: &SheetBody) -> Result<Value> {
|
||||
let (w, h) = sh.flat_size()?;
|
||||
let loops = sh.outline()?;
|
||||
let regions = sh.flat_regions()?;
|
||||
let loops: Vec<Vec<Vec2>> = regions.iter().map(|r| r.outer.polyline(1e-3)).collect();
|
||||
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
|
||||
for p in loops.iter().flatten() {
|
||||
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
|
||||
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
|
||||
}
|
||||
let (w, h) = (hi.x - lo.x, hi.y - lo.y);
|
||||
let pt = |v: &Vec2| json!([v.x, v.y]);
|
||||
let outline: Vec<Value> = loops.iter().map(|l| Value::Array(l.iter().map(pt).collect())).collect();
|
||||
let holes: Vec<Value> = sh.holes.iter().map(|h| Value::Array(h.outer.polyline(1e-3).iter().map(pt).collect())).collect();
|
||||
let holes: Vec<Value> = regions.iter().flat_map(|r| &r.holes).map(|h| Value::Array(h.polyline(1e-3).iter().map(pt).collect())).collect();
|
||||
let bends: Vec<Value> = sh
|
||||
.bends()
|
||||
.iter()
|
||||
@@ -283,8 +293,24 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
|
||||
"allowance": b.allowance, "length": b.length, "start": pt(&b.start), "end": pt(&b.end)})
|
||||
})
|
||||
.collect();
|
||||
let pockets: Vec<Value> = sh
|
||||
.machining()?
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let side = if p.z0 == 0.0 {
|
||||
"bottom"
|
||||
} else if p.z1 == sh.t {
|
||||
"top"
|
||||
} else {
|
||||
"internal"
|
||||
};
|
||||
json!({"outline": p.region.outer.polyline(1e-3).iter().map(pt).collect::<Vec<_>>(),
|
||||
"cutouts": p.region.holes.iter().map(|h| h.polyline(1e-3).iter().map(pt).collect::<Vec<_>>()).collect::<Vec<_>>(),
|
||||
"side": side, "depth_mm": p.z1-p.z0, "z0_mm": p.z0, "z1_mm": p.z1})
|
||||
})
|
||||
.collect();
|
||||
Ok(
|
||||
json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "bends": bends, "unfolded": sh.flat}),
|
||||
json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "pockets": pockets, "bends": bends, "unfolded": sh.flat}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -295,6 +321,22 @@ fn flat_pattern(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// A DXF (R12 entities, mm) of the flat pattern on named layers.
|
||||
pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
let regions = sh.flat_regions()?;
|
||||
let pockets = sh.machining()?;
|
||||
let pocket_layer = |p: &solvecraft_doc::sheet::SheetPocket| {
|
||||
let side = if p.z0 == 0.0 {
|
||||
"BOTTOM"
|
||||
} else if p.z1 == sh.t {
|
||||
"TOP"
|
||||
} else {
|
||||
"INTERNAL"
|
||||
};
|
||||
format!("POCKET_{side}_Z{:.6}_TO_{:.6}_MM", p.z0, p.z1)
|
||||
};
|
||||
let mut layers = std::collections::BTreeSet::from(["OUTLINE".to_string(), "BEND_UP".to_string(), "BEND_DOWN".to_string()]);
|
||||
for p in &pockets {
|
||||
layers.insert(pocket_layer(p));
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut w = |c: i32, v: &str| out.push_str(&format!("{c}\n{v}\n"));
|
||||
w(0, "SECTION");
|
||||
@@ -306,7 +348,14 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
w(2, "TABLES");
|
||||
w(0, "TABLE");
|
||||
w(2, "LAYER");
|
||||
for (name, colour) in [("OUTLINE", "7"), ("BEND_UP", "1"), ("BEND_DOWN", "5")] {
|
||||
w(70, &layers.len().to_string());
|
||||
for name in &layers {
|
||||
let colour = match name.as_str() {
|
||||
"OUTLINE" => "7",
|
||||
"BEND_UP" => "1",
|
||||
"BEND_DOWN" => "5",
|
||||
_ => "3",
|
||||
};
|
||||
w(0, "LAYER");
|
||||
w(2, name);
|
||||
w(70, "0");
|
||||
@@ -326,31 +375,51 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
w(11, &f(b.x));
|
||||
w(21, &f(b.y));
|
||||
};
|
||||
for l in sh.outline()? {
|
||||
let m = l.len();
|
||||
for i in 0..m {
|
||||
if let (Some(a), Some(b)) = (l.get(i), l.get((i + 1) % m)) {
|
||||
line(&mut w, "OUTLINE", *a, *b);
|
||||
}
|
||||
let mut loops: Vec<_> = regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|lp| (lp, "OUTLINE".to_string())).collect();
|
||||
for p in &pockets {
|
||||
for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) {
|
||||
loops.push((lp, pocket_layer(p)));
|
||||
}
|
||||
}
|
||||
for h in &sh.holes {
|
||||
for seg in &h.outer.segs {
|
||||
for (h, layer) in loops {
|
||||
// A circle is often represented by two semicircles. Keep the whole analytic
|
||||
// boundary as one CIRCLE rather than approximating outer boundaries with chords.
|
||||
if let Some(solvecraft_geom::Seg2::Arc { center, radius, sweep, .. }) = h.segs.first() {
|
||||
let total: f64 = h.segs.iter().filter_map(|s| if let solvecraft_geom::Seg2::Arc { sweep, .. } = s { Some(*sweep) } else { None }).sum();
|
||||
let circular = h.segs.iter().all(|s| matches!(s, solvecraft_geom::Seg2::Arc { center: c, radius: r, sweep: a, .. } if c.dist(*center) < 1e-9 && (*r-*radius).abs() < 1e-9 && a.signum() == sweep.signum()));
|
||||
if circular && (total.abs() - std::f64::consts::TAU).abs() < 1e-9 {
|
||||
w(0, "CIRCLE");
|
||||
w(8, &layer);
|
||||
w(10, &f(center.x));
|
||||
w(20, &f(center.y));
|
||||
w(40, &f(*radius));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for seg in &h.segs {
|
||||
match *seg {
|
||||
solvecraft_geom::Seg2::Arc { center, radius, start, sweep } => {
|
||||
if (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9 {
|
||||
w(0, "CIRCLE");
|
||||
w(8, &layer);
|
||||
w(10, &f(center.x));
|
||||
w(20, &f(center.y));
|
||||
w(40, &f(radius));
|
||||
continue;
|
||||
}
|
||||
let (a0, a1) = if sweep >= 0.0 { (start, start + sweep) } else { (start + sweep, start) };
|
||||
w(0, "ARC");
|
||||
w(8, "OUTLINE");
|
||||
w(8, &layer);
|
||||
w(10, &f(center.x));
|
||||
w(20, &f(center.y));
|
||||
w(40, &f(radius));
|
||||
w(50, &f(a0.to_degrees()));
|
||||
w(51, &f(a1.to_degrees()));
|
||||
w(50, &f(a0.to_degrees().rem_euclid(360.0)));
|
||||
w(51, &f(a1.to_degrees().rem_euclid(360.0)));
|
||||
}
|
||||
other => {
|
||||
let pts: Vec<Vec2> = (0..=8).map(|k| other.point_at(k as f64 / 8.0)).collect();
|
||||
let pts = other.polyline(1e-3);
|
||||
for p in pts.windows(2) {
|
||||
line(&mut w, "OUTLINE", p[0], p[1]);
|
||||
line(&mut w, &layer, p[0], p[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -375,6 +444,50 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"dxf": text, "bends": sh.bends().len()}))
|
||||
}
|
||||
|
||||
fn export_bend_table(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sheet.export_bend_table";
|
||||
let format = str_(p, "format").unwrap_or("csv").to_ascii_lowercase();
|
||||
if !matches!(format.as_str(), "csv" | "json") {
|
||||
return Err(bad(cmd, "`format` must be csv or json"));
|
||||
}
|
||||
let sh = sheet_of(s, p, cmd)?;
|
||||
let table = flat_json(&sh)?;
|
||||
let text = if format == "json" {
|
||||
serde_json::to_string_pretty(&table).map_err(|e| EngineError::Other(e.to_string()))?
|
||||
} else {
|
||||
// Always quote text fields: body/rule names may include commas, quotes or newlines.
|
||||
let quote = |s: &str| format!("\"{}\"", s.replace('"', "\"\""));
|
||||
let mut csv =
|
||||
"body,rule,thickness_mm,k_factor,bend,angle_deg,direction,radius_mm,allowance_mm,length_mm,start_x_mm,start_y_mm,end_x_mm,end_y_mm\r\n"
|
||||
.to_string();
|
||||
for b in sh.bends() {
|
||||
csv.push_str(&format!(
|
||||
"{},{},{:.9},{:.9},{},{:.9},{},{:.9},{:.9},{:.9},{:.9},{:.9},{:.9},{:.9}\r\n",
|
||||
quote(&sh.body),
|
||||
quote(&sh.rule),
|
||||
sh.t,
|
||||
sh.k,
|
||||
b.index,
|
||||
b.angle_deg,
|
||||
if b.up { "up" } else { "down" },
|
||||
b.radius,
|
||||
b.allowance,
|
||||
b.length,
|
||||
b.start.x,
|
||||
b.start.y,
|
||||
b.end.x,
|
||||
b.end.y
|
||||
));
|
||||
}
|
||||
csv
|
||||
};
|
||||
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
|
||||
solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
return Ok(json!({"path": path, "format": format, "bends": sh.bends().len()}));
|
||||
}
|
||||
Ok(json!({"format": format, "text": text, "bends": sh.bends().len()}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sheet_tests.rs"]
|
||||
mod tests;
|
||||
@@ -31,6 +31,363 @@ fn base(s: &mut Session, w: f64, h: f64) {
|
||||
run(s, "sheet.flange", json!({"sketch": "Base"}));
|
||||
}
|
||||
|
||||
fn rectangle_tool(s: &mut Session, name: &str, p0: [f64; 2], p1: [f64; 2]) {
|
||||
run(s, "sketch.create", json!({"plane":"XY","name":name}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0":p0,"p1":p1}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shallow_sheet_pocket_preserves_material_depth_export_undo_and_reopen() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"}));
|
||||
assert!((volume(&mut s) - 14900.0).abs() < 1e-6);
|
||||
let f = flat(&mut s);
|
||||
assert_eq!(f["cutouts"], json!([]));
|
||||
assert_eq!(f["pockets"][0]["side"], "bottom");
|
||||
assert_eq!(f["pockets"][0]["depth_mm"], 1.0);
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
assert!(dxf.contains("POCKET_BOTTOM_Z0.000000_TO_1.000000_MM"));
|
||||
assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 8);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!((volume(&mut s) - 15000.0).abs() < 1e-6);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
for command in ["sheet.unfold", "sheet.refold"] {
|
||||
run(&mut s, command, json!({}));
|
||||
assert!((volume(&mut s) - 14900.0).abs() < 1e-6);
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
assert!(!body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, 0.5)));
|
||||
assert!(body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, 1.5)));
|
||||
}
|
||||
let mut reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((volume(&mut reopened) - 14900.0).abs() < 1e-6);
|
||||
assert_eq!(flat(&mut reopened)["pockets"], f["pockets"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planar_sheet_intersection_retains_exact_outline_and_islands() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Retain", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Retain","through_all":true,"direction":"symmetric","operation":"intersect"}));
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({}));
|
||||
}
|
||||
assert!((volume(&mut s) - 250.0).abs() < 1e-6);
|
||||
assert_eq!(flat(&mut s)["flat_size_mm"], json!([10.0, 10.0, 2.5]));
|
||||
}
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 4);
|
||||
let mut imported = Session::default();
|
||||
run(&mut imported, "sketch.insert_dxf", json!({"name":"Retained","text":dxf}));
|
||||
run(&mut imported, "sketch.finish", json!({}));
|
||||
run(&mut imported, "solid.extrude", json!({"sketch":"Retained","distance":2.5}));
|
||||
assert!((volume(&mut imported) - 250.0).abs() < 1e-6);
|
||||
// An annular retained region keeps its central hole, with no first-solid shortcut.
|
||||
let mut annulus = Session::default();
|
||||
base(&mut annulus, 100.0, 60.0);
|
||||
run(&mut annulus, "sketch.create", json!({"plane":"XY","name":"Ring"}));
|
||||
run(&mut annulus, "sketch.circle.center", json!({"center":[50,30],"radius":10}));
|
||||
run(&mut annulus, "sketch.circle.center", json!({"center":[50,30],"radius":5}));
|
||||
run(&mut annulus, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut annulus,
|
||||
"solid.extrude",
|
||||
json!({"sketch":"Ring","profiles":[{"point":[57,30]}],"through_all":true,"direction":"symmetric","operation":"intersect"}),
|
||||
);
|
||||
run(&mut annulus, "sheet.unfold", json!({}));
|
||||
let area: f64 = annulus.model.state().sheets[0].flat_regions().unwrap().iter().map(|r| r.area()).sum();
|
||||
assert!((area - PI * 75.0).abs() < 1e-8, "exact retained area: {area}");
|
||||
// inspect.measure integrates a tessellation; exact arc area is checked above.
|
||||
let measured = volume(&mut annulus);
|
||||
assert!((measured - PI * 75.0 * 2.5).abs() < 0.1, "measured={measured}");
|
||||
assert_eq!(flat(&mut annulus)["cutouts"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_opposite_side_pockets_become_true_through_material() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Bottom", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Bottom","distance":1.5,"operation":"cut"}));
|
||||
rectangle_tool(&mut s, "Top", [25.0, 20.0], [35.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Top","start_offset":2.5,"direction":"negative","distance":1.5,"operation":"cut"}));
|
||||
let expected = 15000.0 - 100.0 * 1.5 * 2.0 + 50.0 * 0.5;
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({}));
|
||||
}
|
||||
assert!((volume(&mut s) - expected).abs() < 1e-6);
|
||||
assert_eq!(flat(&mut s)["cutouts"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(flat(&mut s)["pockets"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pockets_survive_forming_away_from_bends_and_refuse_bend_overlap_atomically() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"}));
|
||||
run(&mut s, "sheet.flange", json!({"edges":[[50,0,2.5]],"height":20}));
|
||||
assert!((volume(&mut s) - (18972.62196081845 - 100.0)).abs() < 0.2);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
let unfolded = volume(&mut s);
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - (18972.62196081845 - 100.0)).abs() < 0.2);
|
||||
assert!(unfolded > 14900.0);
|
||||
let mut cross = Session::default();
|
||||
base(&mut cross, 100.0, 60.0);
|
||||
rectangle_tool(&mut cross, "Pocket", [45.0, 20.0], [55.0, 30.0]);
|
||||
run(&mut cross, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"}));
|
||||
let before = cross.doc.to_json();
|
||||
let undo = cross.undo.len();
|
||||
let error = cross.execute("sheet.fold", &json!({"points":[[50,0,2.5],[50,60,2.5]]})).unwrap_err().to_string();
|
||||
assert!(error.contains("pocket") && error.contains("bend"), "{error}");
|
||||
assert_eq!(cross.doc.to_json(), before);
|
||||
assert_eq!(cross.undo.len(), undo);
|
||||
assert!((volume(&mut cross) - 14900.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_sheet_machining_is_atomic_but_true_no_change_is_allowed() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Taper", [20.0, 20.0], [30.0, 30.0]);
|
||||
let before = s.doc.to_json();
|
||||
let undo = s.undo.len();
|
||||
let error = s.execute("solid.extrude", &json!({"sketch":"Taper","distance":1,"operation":"cut","taper":"5 deg"})).unwrap_err().to_string();
|
||||
assert!(error.contains("sheet") && error.contains("preserv"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
rectangle_tool(&mut s, "Outside", [120.0, 20.0], [130.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Outside","distance":1,"operation":"cut","taper":"5 deg"}));
|
||||
assert!((volume(&mut s) - 15000.0).abs() < 1e-6);
|
||||
rectangle_tool(&mut s, "All", [-20.0, -20.0], [120.0, 80.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"All","through_all":true,"direction":"symmetric","operation":"intersect"}));
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - 15000.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sheet_intersection_removes_geometry_and_metadata_together() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Outside", [120.0, 20.0], [130.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Outside","through_all":true,"direction":"symmetric","operation":"intersect"}));
|
||||
assert!(s.model.state().bodies.is_empty());
|
||||
assert!(s.model.state().sheets.is_empty());
|
||||
assert!(s.execute("sheet.unfold", &json!({})).is_err());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!((volume(&mut s) - 15000.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_preserves_both_material_regions_of_a_logical_ring_sheet() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Ring"}));
|
||||
for radius in [10.0, 5.0] {
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[50,30],"radius":radius}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch":"Ring","profiles":[{"point":[57,30]}],"through_all":true,"direction":"symmetric","operation":"cut"}),
|
||||
);
|
||||
rectangle_tool(&mut s, "Crop", [35.0, 15.0], [65.0, 45.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Crop","through_all":true,"direction":"symmetric","operation":"intersect"}));
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({}));
|
||||
}
|
||||
let state = s.model.state();
|
||||
let regions = state.sheets[0].flat_regions().unwrap();
|
||||
assert_eq!(regions.len(), 2);
|
||||
let area: f64 = regions.iter().map(|r| r.area()).sum();
|
||||
assert!((area - (900.0 - PI * 75.0)).abs() < 1e-8, "area={area}");
|
||||
let body = &state.bodies[0].body;
|
||||
assert!(body.contains(solvecraft_geom::Vec3::new(50.0, 30.0, 1.0)));
|
||||
assert!(!body.contains(solvecraft_geom::Vec3::new(57.0, 30.0, 1.0)));
|
||||
assert!(body.contains(solvecraft_geom::Vec3::new(64.0, 30.0, 1.0)));
|
||||
assert!(!body.contains(solvecraft_geom::Vec3::new(70.0, 30.0, 1.0)));
|
||||
assert!((volume(&mut s) - (900.0 - PI * 75.0) * 2.5).abs() < 0.2);
|
||||
}
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machining_a_folded_parallel_panel_preserves_its_pocket_on_unfold() {
|
||||
use solvecraft_doc::{apply_point, apply_vector};
|
||||
use solvecraft_geom::Vec3;
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sheet.flange", json!({"edges":[[50,0,2.5]],"height":20}));
|
||||
let sh = s.model.state().sheets[0].clone();
|
||||
let points = sh.panel(&sh.flanges[0]).outer.polyline(1e-3);
|
||||
let c = points.iter().copied().fold(solvecraft_geom::Vec2::ZERO, |sum, p| sum + p) / (points.len() as f64);
|
||||
let w = sh.world(Some(0));
|
||||
let origin = apply_point(&w, Vec3::ZERO);
|
||||
let x = apply_vector(&w, Vec3::X);
|
||||
let y = apply_vector(&w, Vec3::Y);
|
||||
run(
|
||||
&mut s,
|
||||
"sketch.create",
|
||||
json!({"name":"PanelPocket","plane":{"origin":[origin.x,origin.y,origin.z],"x_dir":[x.x,x.y,x.z],"y_dir":[y.x,y.y,y.z]}}),
|
||||
);
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[c.x-2.0,c.y-2.0],"p1":[c.x+2.0,c.y+2.0]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"PanelPocket","distance":1,"operation":"cut"}));
|
||||
assert!((volume(&mut s) - (18972.62196081845 - 16.0)).abs() < 0.2);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
assert!(!body.contains(Vec3::new(c.x, c.y, 0.5)));
|
||||
assert!(body.contains(Vec3::new(c.x, c.y, 1.5)));
|
||||
assert_eq!(flat(&mut s)["pockets"][0]["depth_mm"], 1.0);
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - (18972.62196081845 - 16.0)).abs() < 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flipped_sheet_pocket_keeps_depth_in_the_local_thickness_direction() {
|
||||
let mut s = Session::default();
|
||||
rectangle_tool(&mut s, "Base", [0.0, 0.0], [100.0, 60.0]);
|
||||
run(&mut s, "sheet.flange", json!({"sketch":"Base","flip":true}));
|
||||
rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"direction":"negative","operation":"cut"}));
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({}));
|
||||
}
|
||||
assert!((volume(&mut s) - 14900.0).abs() < 1e-6);
|
||||
let state = s.model.state();
|
||||
assert!(!state.bodies[0].body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, -0.5)));
|
||||
assert!(state.bodies[0].body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, -1.5)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_profile_intersect_keeps_tool_union_for_solids_and_sheet_groups() {
|
||||
for sheet in [false, true] {
|
||||
let mut s = Session::default();
|
||||
if sheet {
|
||||
base(&mut s, 100.0, 60.0);
|
||||
} else {
|
||||
run(&mut s, "solid.box", json!({"length":100,"width":60,"height":2.5,"body_name":"Blank"}));
|
||||
}
|
||||
let target = s.model.state().bodies[0].name.clone();
|
||||
run(&mut s, "solid.box", json!({"corner":[200,0,0],"length":10,"width":10,"height":10,"body_name":"Control"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"TwoIslands"}));
|
||||
for x in [20.0, 60.0] {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[x,20],"p1":[x+10.0,30]}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"TwoIslands","operation":"intersect","distance":3,"targets":[target]}));
|
||||
assert!((volume(&mut s) - 1500.0).abs() < 1e-6);
|
||||
if sheet {
|
||||
for command in ["sheet.unfold", "sheet.refold"] {
|
||||
run(&mut s, command, json!({"body":target}));
|
||||
assert!((volume(&mut s) - 1500.0).abs() < 1e-6);
|
||||
assert_eq!(flat(&mut s)["outline"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
let state = s.model.state();
|
||||
for x in [25.0, 65.0] {
|
||||
assert!(state.bodies.iter().any(|b| b.body.contains(solvecraft_geom::Vec3::new(x, 25.0, 1.0))));
|
||||
}
|
||||
assert!(!state.bodies.iter().any(|b| b.body.contains(solvecraft_geom::Vec3::new(50.0, 25.0, 1.0))));
|
||||
assert!(state.bodies.iter().any(|b| b.name == "Control" && b.body.contains(solvecraft_geom::Vec3::new(205.0, 5.0, 5.0))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_sheet_material_prunes_cosmetic_threads_and_undo_restores_them() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"ThreadPlate"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[100,60]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[50,30],"radius":5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch":"ThreadPlate","profiles":[{"point":[10,10]}]}));
|
||||
run(&mut s, "solid.thread", json!({"face":[55,30,1],"designation":"M10"}));
|
||||
assert_eq!(s.model.state().threads.len(), 1);
|
||||
rectangle_tool(&mut s, "Outside", [120.0, 20.0], [130.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Outside","through_all":true,"direction":"symmetric","operation":"intersect"}));
|
||||
assert!(s.model.state().bodies.is_empty());
|
||||
assert!(s.model.state().sheets.is_empty());
|
||||
assert!(s.model.state().threads.is_empty());
|
||||
assert!(solvecraft_doc::world_state(&s.doc, &s.model.state()).threads.is_empty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.model.state().threads.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_pocket_transfer_keeps_local_depth_world_pose_and_history() {
|
||||
use solvecraft_geom::Vec3;
|
||||
fn check(s: &Session, name: &str, owner: u64, local_origin: Vec3, local_y: Vec3, transferred: bool) {
|
||||
let state = s.model.state();
|
||||
let mb = state.body(name).unwrap();
|
||||
assert_eq!(mb.component, owner);
|
||||
assert_eq!(mb.transferred, transferred);
|
||||
let sh = state.sheets.iter().find(|sh| sh.body == name).unwrap();
|
||||
assert!(sh.frame.origin.dist(local_origin) < 1e-8, "{:?}", sh.frame);
|
||||
assert!(sh.frame.x.dist(Vec3::X) < 1e-8);
|
||||
assert!(sh.frame.y.dist(local_y) < 1e-8);
|
||||
assert_eq!(sh.pockets.len(), 1);
|
||||
assert_eq!((sh.pockets[0].z0, sh.pockets[0].z1), (0.0, 1.0));
|
||||
let cut = sh.frame.to_world(solvecraft_geom::Vec2::new(25.0, 25.0)) + sh.frame.normal() * 0.5;
|
||||
let floor = sh.frame.to_world(solvecraft_geom::Vec2::new(25.0, 25.0)) + sh.frame.normal() * 1.5;
|
||||
assert!(!mb.body.contains(cut));
|
||||
assert!(mb.body.contains(floor));
|
||||
let world = s.world_state();
|
||||
let body = &world.body(name).unwrap().body;
|
||||
let bounds = body.tessellate(1e-3).unwrap().bounds();
|
||||
assert!(bounds.min.dist(Vec3::ZERO) < 1e-6, "{bounds:?}");
|
||||
assert!(bounds.max.dist(Vec3::new(100.0, 60.0, 2.5)) < 1e-6, "{bounds:?}");
|
||||
assert!(!body.contains(Vec3::new(25.0, 25.0, 0.5)));
|
||||
assert!(body.contains(Vec3::new(25.0, 25.0, 1.5)));
|
||||
assert!((solvecraft_kernel::measure(body).unwrap().volume - 14900.0).abs() < 1e-6);
|
||||
}
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"}));
|
||||
let name = s.model.state().sheets[0].body.clone();
|
||||
check(&s, &name, 0, Vec3::ZERO, Vec3::Y, false);
|
||||
let destination = run(&mut s, "component.create", json!({"name":"Machined","parent":0}));
|
||||
let component = destination["component"].as_u64().unwrap();
|
||||
let occurrence = destination["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,10],"axis":[1,0,0],"angle":"90 deg","capture":true}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":component,"bodies":[name]}));
|
||||
assert!(!s.doc.body_offsets.contains_key(&name), "recorded transfer must not leave a legacy placement offset");
|
||||
let origin = Vec3::new(-30.0, -10.0, 20.0);
|
||||
let y = Vec3::new(0.0, 0.0, -1.0);
|
||||
check(&s, &name, component, origin, y, true);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
check(&s, &name, 0, Vec3::ZERO, Vec3::Y, false);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
check(&s, &name, component, origin, y, true);
|
||||
run(&mut s, "component.activate", json!({"component":component}));
|
||||
for command in ["sheet.unfold", "sheet.refold"] {
|
||||
run(&mut s, command, json!({"body":name}));
|
||||
check(&s, &name, component, origin, y, true);
|
||||
let f = run(&mut s, "sheet.flat_pattern", json!({"body":name}));
|
||||
assert_eq!(f["pockets"][0]["side"], "bottom");
|
||||
assert_eq!(f["pockets"][0]["depth_mm"], 1.0);
|
||||
}
|
||||
let mut reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
check(&reopened, &name, component, origin, y, true);
|
||||
run(&mut reopened, "component.activate", json!({"component":component}));
|
||||
run(&mut reopened, "sheet.unfold", json!({"body":name}));
|
||||
check(&reopened, &name, component, origin, y, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_and_edge_flange_follow_the_k_factor() {
|
||||
let mut s = Session::default();
|
||||
@@ -344,3 +701,405 @@ fn fold_keeps_the_larger_side_either_way() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bend_table_exports_shop_dimensions_and_tracks_parameter_changes() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "Shop, \"steel\"", "thickness": 2, "bend_radius": 3, "k_factor": 0.4}));
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20}));
|
||||
let undo = s.undo.len();
|
||||
let csv = run(&mut s, "sheet.export_bend_table", json!({}));
|
||||
let text = csv["text"].as_str().unwrap();
|
||||
assert!(text.starts_with("body,rule,thickness_mm,k_factor,bend,angle_deg,direction,"));
|
||||
assert!(text.contains("\"Shop, \"\"steel\"\"\""), "{text}");
|
||||
assert_eq!(text.lines().count(), 2);
|
||||
assert_eq!(csv["bends"], 1);
|
||||
let exported = run(&mut s, "sheet.export_bend_table", json!({"format": "JSON"}));
|
||||
let table: Value = serde_json::from_str(exported["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(table["thickness"], 2.0);
|
||||
assert_eq!(table["bends"][0]["direction"], "up");
|
||||
assert!((table["bends"][0]["allowance"].as_f64().unwrap() - FRAC_PI_2 * 3.8).abs() < 1e-9);
|
||||
assert_eq!(s.undo.len(), undo, "export does not create an undo step");
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "Shop, \"steel\"", "k_factor": 0.5}));
|
||||
let updated = run(&mut s, "sheet.export_bend_table", json!({"format": "json"}));
|
||||
let table: Value = serde_json::from_str(updated["text"].as_str().unwrap()).unwrap();
|
||||
assert!((table["bends"][0]["allowance"].as_f64().unwrap() - FRAC_PI_2 * 4.0).abs() < 1e-9);
|
||||
assert!(s.execute("sheet.export_bend_table", &json!({"format": "xml"})).is_err());
|
||||
assert!(s.execute("sheet.export_bend_table", &json!({"body": "Missing"})).is_err());
|
||||
let mut empty = Session::default();
|
||||
assert!(empty.execute("sheet.export_bend_table", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_fold_points_are_rejected_atomically() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
let before = serde_json::to_value(&s.doc).unwrap();
|
||||
for points in [
|
||||
json!([[40, 0, 2.5], null, [40, 60, 2.5]]),
|
||||
json!([[40, 0, 2.5], [40, 60, 2.5], "junk"]),
|
||||
json!([[40, 0, 2.5], [40, 60]]),
|
||||
json!([[40, 0, 2.5]]),
|
||||
json!("not points"),
|
||||
] {
|
||||
assert!(s.execute("sheet.fold", &json!({"points": points})).is_err());
|
||||
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_dxf_preserves_exact_cutout_entities() {
|
||||
use solvecraft_geom::{Loop2, Region2, Seg2, Vec2};
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
let mut sh = s.model.state().sheets[0].clone();
|
||||
sh.holes = vec![Region2 {
|
||||
outer: Loop2 { segs: vec![Seg2::Arc { center: Vec2::new(40.0, 30.0), radius: 5.0, start: -PI, sweep: -2.0 * PI }] },
|
||||
holes: Vec::new(),
|
||||
}];
|
||||
let dxf = super::flat_dxf(&sh).unwrap();
|
||||
assert_eq!(dxf.matches("0\nCIRCLE\n").count(), 1, "{dxf}");
|
||||
assert!(!dxf.contains("0\nARC\n"), "a full circle must not become a zero-length ARC");
|
||||
let read = solvecraft_io::read_dxf(&dxf).unwrap();
|
||||
assert_eq!(read.len(), 5);
|
||||
assert!(read.iter().any(|g| matches!(g, solvecraft_io::Geom2::Circle(c, r) if *c == Vec2::new(40.0, 30.0) && (*r - 5.0).abs() < 1e-9)));
|
||||
sh.holes[0].outer = Loop2 {
|
||||
segs: vec![
|
||||
Seg2::Line { a: Vec2::new(10.0, 10.0), b: Vec2::new(30.0, 10.0) },
|
||||
Seg2::Arc { center: Vec2::new(20.0, 10.0), radius: 10.0, start: 0.0, sweep: PI },
|
||||
],
|
||||
};
|
||||
let dxf = super::flat_dxf(&sh).unwrap();
|
||||
assert_eq!(dxf.matches("0\nLINE\n").count(), 5, "four outline lines and one exact cutout line");
|
||||
assert_eq!(dxf.matches("0\nARC\n").count(), 1);
|
||||
assert!(dxf.contains("50\n0.000000\n51\n180.000000\n"));
|
||||
let read = solvecraft_io::read_dxf(&dxf).unwrap();
|
||||
assert_eq!(read.len(), 6);
|
||||
assert!(read.iter().any(|g| matches!(g, solvecraft_io::Geom2::Arc { c, a, b } if *c == Vec2::new(20.0, 10.0) && a.dist(Vec2::new(30.0, 10.0)) < 1e-9 && b.dist(Vec2::new(10.0, 10.0)) < 1e-9)));
|
||||
}
|
||||
|
||||
// A profile chosen by a point outside its inner loop must keep that inner loop through
|
||||
// sheet construction, flattening and manufacturing export.
|
||||
#[test]
|
||||
fn inherited_base_profile_holes_survive_every_sheet_state() {
|
||||
for flip in [false, true] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Perforated"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [40, 20], "p1": [60, 40]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Perforated", "profiles": [{"point": [10, 10]}], "flip": flip}));
|
||||
assert!((volume(&mut s) - 14000.0).abs() < 1e-6);
|
||||
let f = flat(&mut s);
|
||||
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1));
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 8);
|
||||
let mut reopened = Session::default();
|
||||
run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "Blank"}));
|
||||
run(&mut reopened, "sketch.finish", json!({}));
|
||||
let pick = if flip { [10, -10] } else { [10, 10] };
|
||||
run(&mut reopened, "solid.extrude", json!({"sketch": "Blank", "profiles": [{"point": pick}], "distance": 2.5}));
|
||||
assert!((volume(&mut reopened) - 14000.0).abs() < 1e-5);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - 14000.0).abs() < 1e-6);
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - 14000.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_notch_uses_the_same_material_boundary_in_solid_flat_and_dxf() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Notch"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-5, 20], "p1": [10, 40]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Notch", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
assert!((volume(&mut s) - 14500.0).abs() < 1e-6);
|
||||
let f = flat(&mut s);
|
||||
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(f["outline"][0].as_array().map(Vec::len), Some(8));
|
||||
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(0));
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 8);
|
||||
let mut reopened = Session::default();
|
||||
run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "Blank"}));
|
||||
run(&mut reopened, "sketch.finish", json!({}));
|
||||
run(&mut reopened, "solid.extrude", json!({"sketch": "Blank", "distance": 2.5}));
|
||||
assert!((volume(&mut reopened) - 14500.0).abs() < 1e-5);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - 14500.0).abs() < 1e-6);
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - 14500.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_round_hole_survives_flange_and_unfold_in_flipped_frames() {
|
||||
for flip in [false, true] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Perforated"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50, 30], "radius": 5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Perforated", "profiles": [{"point": [10, 10]}], "flip": flip}));
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, if flip { -2.5 } else { 2.5 }]], "height": 20}));
|
||||
let folded = volume(&mut s);
|
||||
assert!((folded - (18972.62196081845 - PI * 25.0 * 2.5)).abs() < 0.2, "{folded}");
|
||||
let f = flat(&mut s);
|
||||
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1));
|
||||
let sh = s.model.state().sheets[0].clone();
|
||||
let regions = sh.flat_regions().unwrap();
|
||||
assert_eq!(regions[0].holes[0].segs.len(), 2);
|
||||
assert!(regions[0].holes[0].segs.iter().all(|s| matches!(s, solvecraft_geom::Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9)));
|
||||
let expected = regions.iter().map(solvecraft_geom::Region2::area).sum::<f64>() * 2.5;
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - expected).abs() < 0.1);
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - folded).abs() < 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossing_only_disconnecting_cut_preserves_all_material_states() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Split"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [45, -5], "p1": [55, 65]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Split", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
assert!((volume(&mut s) - 13500.0).abs() < 1e-6);
|
||||
assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2));
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
let mut reopened = Session::default();
|
||||
run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "SplitBlank"}));
|
||||
run(&mut reopened, "sketch.finish", json!({}));
|
||||
run(&mut reopened, "solid.extrude", json!({"sketch": "SplitBlank", "distance": 2.5}));
|
||||
assert!((volume(&mut reopened) - 13500.0).abs() < 1e-6);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - 13500.0).abs() < 1e-6);
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - 13500.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitting_an_unfolded_flanged_sheet_preserves_both_bent_pieces() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50,0,2.5]], "height": 20}));
|
||||
let folded = volume(&mut s);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
let unfolded = volume(&mut s);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "BlankSplit"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [45,-100], "p1": [55,100]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "BlankSplit", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
assert!((volume(&mut s) - unfolded * 0.9).abs() < 0.1);
|
||||
assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - folded * 0.9).abs() < 0.2);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - unfolded * 0.9).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_and_nested_sheet_cuts_are_redundant() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
for (i, radius) in [3.0, 5.0, 5.0].into_iter().enumerate() {
|
||||
let name = format!("Hole{i}");
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": name}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [20, 30], "radius": radius}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": name, "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
}
|
||||
assert!((volume(&mut s) - (6000.0 - PI * 25.0) * 2.5).abs() < 0.1);
|
||||
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_round_cuts_keep_exact_union_in_export_and_roundtrip() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
for (i, x) in [20.0, 22.0].into_iter().enumerate() {
|
||||
let name = format!("Overlap{i}");
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": name}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [x, 30], "radius": 5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": name, "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
}
|
||||
let intersection = 50.0 * (0.2f64).acos() - (96.0f64).sqrt();
|
||||
let expected = (6000.0 - (50.0 * PI - intersection)) * 2.5;
|
||||
assert!((volume(&mut s) - expected).abs() < 0.1);
|
||||
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1));
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
assert!(dxf.contains("0\nARC\n"));
|
||||
let mut reopened = Session::default();
|
||||
run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "Blank"}));
|
||||
run(&mut reopened, "sketch.finish", json!({}));
|
||||
run(&mut reopened, "solid.extrude", json!({"sketch": "Blank", "profiles": [{"point": [10, 10]}], "distance": 2.5}));
|
||||
assert!((volume(&mut reopened) - expected).abs() < 0.1);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - expected).abs() < 0.1);
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - expected).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_across_a_concave_notch_uses_crossings_not_just_contained_corners() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "U"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0,0],[100,0],[100,60],[70,60],[70,20],[30,20],[30,60],[0,60],[0,0]]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "U"}));
|
||||
assert!((volume(&mut s) - 11000.0).abs() < 1e-6);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "ArmNotches"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [20,30], "p1": [80,40]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "ArmNotches", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
assert!((volume(&mut s) - 10500.0).abs() < 1e-6);
|
||||
let f = flat(&mut s);
|
||||
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(0));
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - 10500.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_cut_inside_exact_circle_survives_unfold_refold_and_dxf() {
|
||||
for initially_flat in [false, true] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Disc"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0,0], "radius": 10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Disc"}));
|
||||
let circle = s.model.state().sheets[0].base.outer.clone();
|
||||
let displayed = circle.polyline(1e-3);
|
||||
let at = (displayed[0] + displayed[1]).normalized().unwrap() * 9.9998;
|
||||
assert!(!circle.contains(at));
|
||||
if initially_flat {
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
}
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "TinyCut"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [at.x-0.000025,at.y-0.000025], "p1": [at.x+0.000025,at.y+0.000025]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "TinyCut", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
for stage in ["sheet.unfold", "sheet.refold"] {
|
||||
let regions = s.model.state().sheets[0].flat_regions().unwrap();
|
||||
assert_eq!(regions[0].holes.len(), 1);
|
||||
assert!((regions[0].holes[0].signed_area().abs() - 2.5e-9).abs() < 1e-13);
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 5);
|
||||
if stage == "sheet.unfold" && initially_flat {
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
}
|
||||
run(&mut s, stage, json!({}));
|
||||
}
|
||||
assert_eq!(s.model.state().sheets[0].flat_regions().unwrap()[0].holes.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_box_cut_preserves_logical_ring_sheet_compound() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
let sheet_name = s.model.state().sheets[0].body.clone();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Ring"}));
|
||||
for radius in [10, 5] {
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50,30], "radius": radius}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "Ring", "profiles": [{"point": [57,30]}], "through_all": true, "operation": "cut", "direction": "symmetric"}),
|
||||
);
|
||||
let material = s.model.state().sheets[0].flat_regions().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [200,0,0], "body_name": "SeparateBox"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "BoxCut"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [205,5], "p1": [210,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "BoxCut", "participants": ["SeparateBox"], "through_all": true, "operation": "cut", "direction": "symmetric"}),
|
||||
);
|
||||
assert_eq!(s.model.state().bodies.len(), 2);
|
||||
assert_eq!(s.model.state().sheets[0].flat_regions().unwrap(), material);
|
||||
for stage in ["sheet.unfold", "sheet.refold"] {
|
||||
run(&mut s, stage, json!({"body": sheet_name}));
|
||||
assert_eq!(s.model.state().bodies.len(), 2);
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies.iter().find(|b| b.name == sheet_name).unwrap().body;
|
||||
assert!(body.contains(solvecraft_geom::Vec3::new(50.0, 30.0, 1.25)));
|
||||
assert!(!body.contains(solvecraft_geom::Vec3::new(57.0, 30.0, 1.25)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn specialized_sheet_cuts_respect_component_and_explicit_participants() {
|
||||
for explicit in [false, true] {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
let name = s.model.state().sheets[0].body.clone();
|
||||
run(&mut s, "component.create", json!({"name": "Other"}));
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 10, "body_name": "OtherBox"}));
|
||||
if explicit {
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
}
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [20,20], "p1": [30,30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let mut params = json!({"sketch": "Cut", "through_all": true, "operation": "cut", "direction": "symmetric"});
|
||||
if explicit {
|
||||
params["participants"] = json!(["OtherBox"]);
|
||||
}
|
||||
run(&mut s, "solid.extrude", params);
|
||||
let m = run(&mut s, "inspect.measure", json!({"bodies": [name]}));
|
||||
assert!((m["total"]["volume_mm3"].as_f64().unwrap() - 15000.0).abs() < 1e-6);
|
||||
assert!(s.model.state().sheets[0].holes.is_empty());
|
||||
let m = run(&mut s, "inspect.measure", json!({"bodies": ["OtherBox"]}));
|
||||
assert!((m["total"]["volume_mm3"].as_f64().unwrap() - 59000.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_cut_preserves_retained_island_through_unfold_and_refold() {
|
||||
fn material_probes(s: &Session) {
|
||||
let body = &s.model.state().bodies[0].body;
|
||||
assert!(body.contains(solvecraft_geom::Vec3::new(50.0, 30.0, 1.25)));
|
||||
assert!(!body.contains(solvecraft_geom::Vec3::new(57.0, 30.0, 1.25)));
|
||||
assert!(body.contains(solvecraft_geom::Vec3::new(65.0, 30.0, 1.25)));
|
||||
}
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Ring"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50,30], "radius": 10}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50,30], "radius": 5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "Ring", "profiles": [{"point": [57,30]}], "through_all": true, "operation": "cut", "direction": "symmetric"}),
|
||||
);
|
||||
let expected = (6000.0 - PI * 75.0) * 2.5;
|
||||
assert!((volume(&mut s) - expected).abs() < 0.2);
|
||||
material_probes(&s);
|
||||
assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2));
|
||||
let regions = s.model.state().sheets[0].flat_regions().unwrap();
|
||||
assert!(regions.iter().any(|r| r.contains(solvecraft_geom::Vec2::new(50.0, 30.0))));
|
||||
assert!(!regions.iter().any(|r| r.contains(solvecraft_geom::Vec2::new(57.0, 30.0))));
|
||||
assert!(regions.iter().any(|r| r.contains(solvecraft_geom::Vec2::new(65.0, 30.0))));
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
let mut reopened = Session::default();
|
||||
run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "RingBlank"}));
|
||||
run(&mut reopened, "sketch.finish", json!({}));
|
||||
run(&mut reopened, "solid.extrude", json!({"sketch": "RingBlank", "profiles": [{"point": [10,10]}, {"point": [50,30]}], "distance": 2.5}));
|
||||
assert!((volume(&mut reopened) - expected).abs() < 0.2);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - expected).abs() < 0.2);
|
||||
material_probes(&s);
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - expected).abs() < 0.2);
|
||||
material_probes(&s);
|
||||
}
|
||||
+133
-35
@@ -9,6 +9,12 @@ use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
#[path = "sketch_dimensioned.rs"]
|
||||
mod dimensioned;
|
||||
#[path = "sketch_face.rs"]
|
||||
pub(super) mod face;
|
||||
use dimensioned::draw as draw_dimensioned;
|
||||
|
||||
/// The `connect` parameter every drawing command takes (see `connect_points`).
|
||||
macro_rules! connect_doc {
|
||||
() => {
|
||||
@@ -20,8 +26,8 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sketch")
|
||||
.params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [x,y,z]} (planar face at point), offset?: expr, name?"),
|
||||
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).icon("sketch").params("sketch: id or name"),
|
||||
.params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source, fixed:true explicitly freezes the selected plane; offset?: expr, project_edges?: bool, name?"),
|
||||
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).noundo().icon("sketch").params("sketch: id or name"),
|
||||
CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
|
||||
CommandSpec::new("sketch.line", "Line", draw_line)
|
||||
.at("SKETCH", "CREATE")
|
||||
@@ -29,6 +35,9 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.key("L")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!())),
|
||||
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned)
|
||||
.enabled(in_sketch)
|
||||
.params("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value; creates geometry and all driving dimensions atomically in one undo step"),
|
||||
CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("rect")
|
||||
@@ -216,10 +225,16 @@ 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(", "))));
|
||||
}
|
||||
sk.validate_periodic_splines()?;
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
Ok((out, json!({"solved": rep.ok(), "dof": rep.dof})))
|
||||
@@ -345,35 +360,56 @@ pub(super) fn result(sk_out: (Vec<String>, Vec<String>), info: Value) -> Value {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Sketch lifecycle
|
||||
|
||||
pub(crate) fn author_path_for_rebind(s: &Session, feature: &solvecraft_doc::Feature, p: &Value, cmd: &str) -> Result<Vec<u64>> {
|
||||
let mut saved = None;
|
||||
if let FeatureKind::Sketch { plane, .. } = &feature.kind {
|
||||
let mut plane = plane;
|
||||
loop {
|
||||
match plane {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane = base,
|
||||
PlaneRef::Face { anchor: Some(anchor), .. } => {
|
||||
saved = Some(anchor.author_path.clone());
|
||||
break;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
let path = if let Some(value) = p.get("author_path") {
|
||||
value
|
||||
.as_array()
|
||||
.filter(|path| path.len() <= 64)
|
||||
.ok_or_else(|| bad(cmd, "author_path must contain at most64 ids"))?
|
||||
.iter()
|
||||
.map(|id| id.as_u64().filter(|id| *id != 0).ok_or_else(|| bad(cmd, "invalid author_path id")))
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
} else if let Some(path) = saved {
|
||||
path
|
||||
} else if feature.component == s.active_component && s.active_occurrence_path.is_some() {
|
||||
s.active_occurrence_path.clone().unwrap_or_default()
|
||||
} else {
|
||||
let placements = super::component::properties::placements(&s.placed_document(), cmd)?;
|
||||
let paths: Vec<_> = placements.into_iter().filter(|(component, _, _)| *component == feature.component).map(|(_, path, _)| path).collect();
|
||||
if paths.len() != 1 {
|
||||
return Err(bad(cmd, "sketch author placement is ambiguous; provide exact author_path or activate the intended occurrence"));
|
||||
}
|
||||
paths.into_iter().next().ok_or_else(|| bad(cmd, "sketch author placement is missing"))?
|
||||
};
|
||||
if feature.component != 0 && path.is_empty() {
|
||||
return Err(bad(cmd, "a component sketch needs an explicit complete author_path"));
|
||||
}
|
||||
solvecraft_doc::EmbossFace::component_frame(&s.placed_document(), feature.component, &path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
|
||||
let base = match p.get("plane") {
|
||||
None => PlaneRef::Origin { name: "XY".into() },
|
||||
// An origin plane, or a construction plane by name or id.
|
||||
Some(v @ (Value::String(_) | Value::Number(_))) => super::features::plane_param(s, Some(v), cmd)?,
|
||||
Some(v @ Value::Object(o)) => {
|
||||
if let Some(fp) = o.get("face").and_then(vec3) {
|
||||
// A planar face of a body at this point, as the active component sees the model
|
||||
// (another component's face where it is placed).
|
||||
let model = s.model.state();
|
||||
let st = solvecraft_doc::state_in_frame(&s.doc, &model, s.active_component);
|
||||
let mut found = None;
|
||||
let mut face_name = None;
|
||||
for b in &st.bodies {
|
||||
let tol = (b.body.size() * 1e-3).max(1e-3);
|
||||
for f in b.body.faces(tol).unwrap_or_default() {
|
||||
if let Some(n) = f.plane_normal
|
||||
&& (fp - f.centroid).dot(n).abs() < tol * 10.0
|
||||
&& b.body.tessellate(tol).map(|m| face_contains(&m, f.index, fp, tol * 10.0)).unwrap_or(false)
|
||||
{
|
||||
// Sketch origin: the model origin projected onto the face plane.
|
||||
let d = (f.centroid + n * (fp - f.centroid).dot(n)).dot(n);
|
||||
found = Plane::from_normal(n * d, n);
|
||||
face_name = solvecraft_doc::naming::face_names(b).get(f.index).cloned();
|
||||
}
|
||||
}
|
||||
}
|
||||
let pl = found.ok_or_else(|| bad(cmd, "no planar face at that point"))?;
|
||||
PlaneRef::Face { plane: pl, at: fp, name: face_name }
|
||||
if o.contains_key("face") {
|
||||
face::capture(s, v, cmd)?
|
||||
} else {
|
||||
let origin = v.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let x = v.get("x_dir").and_then(vec3).ok_or_else(|| bad(cmd, "plane needs x_dir"))?;
|
||||
@@ -390,7 +426,7 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
|
||||
}
|
||||
|
||||
/// Does triangle set of face `fi` contain point `p` (within `tol` of the face plane)?
|
||||
fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> bool {
|
||||
pub(super) fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> bool {
|
||||
m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| **f as usize == fi).any(|(t, _)| {
|
||||
let Some([a, b, c]) = m.tri(t) else { return false };
|
||||
let n = (b - a).cross(c - a);
|
||||
@@ -410,13 +446,28 @@ fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> boo
|
||||
|
||||
fn create_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.create";
|
||||
let plane = plane_ref(s, p, cmd)?;
|
||||
create_sketch_with_plane(s, p, plane_ref(s, p, cmd)?, None)
|
||||
}
|
||||
|
||||
/// Used only by face Extrude/Revolve/PressPull, preserving pre-certificate linked profiles.
|
||||
pub(super) fn create_face_profile(s: &mut Session, p: &Value, body: &str, index: usize) -> Result<Value> {
|
||||
let value = p.get("plane").ok_or_else(|| bad("face profile", "missing picked face plane"))?;
|
||||
let plane = face::capture_internal(s, value, "face profile")?;
|
||||
create_sketch_with_plane(s, p, plane, Some((body, index)))
|
||||
}
|
||||
|
||||
fn create_sketch_with_plane(s: &mut Session, p: &Value, plane: PlaneRef, internal_body: Option<(&str, usize)>) -> Result<Value> {
|
||||
if face::is_face(p) {
|
||||
face::capture_positions(s, &plane)?;
|
||||
}
|
||||
// Validate the plane resolves.
|
||||
let (vals, _) = s.doc.param_values();
|
||||
s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?;
|
||||
let name = str_(p, "name");
|
||||
let mut kind = FeatureKind::Sketch { plane, sketch: Sketch::new() };
|
||||
super::component::to_active_frame(s, &mut kind);
|
||||
if !face::is_face(p) {
|
||||
super::component::to_active_frame(s, &mut kind);
|
||||
}
|
||||
let id = s.doc_mut().add_feature(kind, name)?;
|
||||
let comp = s.active_component;
|
||||
if let Some(f) = s.doc_mut().feature_mut(id) {
|
||||
@@ -431,8 +482,23 @@ fn create_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& bool_(p, "project_edges").unwrap_or(s.auto_project)
|
||||
{
|
||||
link = super::sketch_project::auto_project_face(s, id, at)?;
|
||||
if link.is_none()
|
||||
&& let Some((body, index)) = internal_body
|
||||
{
|
||||
link = Some(super::sketch_project::project_internal_face(s, id, body, index)?);
|
||||
}
|
||||
}
|
||||
Ok(json!({"sketch": id, "name": name, "projected": link}))
|
||||
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } => Some(if face::anchor(plane).is_some() {
|
||||
"exact_face"
|
||||
} else if internal_body.is_some() {
|
||||
"internal_legacy_face"
|
||||
} else {
|
||||
"fixed"
|
||||
}),
|
||||
_ => None,
|
||||
});
|
||||
Ok(json!({"sketch": id, "name": name, "projected": link,"attachment":attachment}))
|
||||
}
|
||||
|
||||
fn edit_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -445,7 +511,17 @@ fn edit_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
|
||||
return Err(bad("sketch.edit", format!("`{key}` is not a sketch")));
|
||||
}
|
||||
let (id, name) = (f.id, f.name.clone());
|
||||
let (id, name, component) = (f.id, f.name.clone(), f.component);
|
||||
let authored_path = match &f.kind {
|
||||
FeatureKind::Sketch { plane, .. } => face::anchor(plane).map(|anchor| anchor.author_path.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(path) = authored_path {
|
||||
solvecraft_doc::EmbossFace::component_frame(&s.placed_document(), component, &path)
|
||||
.map_err(|error| bad("sketch.edit", format!("{error}; explicitly rebind the author placement before editing")))?;
|
||||
s.active_component = component;
|
||||
s.active_occurrence_path = (!path.is_empty()).then_some(path);
|
||||
}
|
||||
s.active_sketch = Some(id);
|
||||
s.revision += 1;
|
||||
Ok(json!({"sketch": id, "name": name}))
|
||||
@@ -792,6 +868,7 @@ fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, start: f64, n: usize, inscribed: bool) -> Result<Value> {
|
||||
let mut support_circle = String::new();
|
||||
let (out, info) = draw(s, p, cmd, |sk, _| {
|
||||
let corners: Vec<Vec2> = (0..n).map(|i| center + Vec2::from_angle(start + std::f64::consts::TAU * i as f64 / n as f64) * r_vertex).collect();
|
||||
let (l, pts) = polygon_lines(sk, &corners)?;
|
||||
@@ -804,6 +881,7 @@ fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, s
|
||||
// Construction circle tying the polygon to its centre.
|
||||
let rc = if inscribed { r_vertex } else { r_vertex * (std::f64::consts::PI / n as f64).cos() };
|
||||
let circ = sk.add_circle(center, rc, None, None)?;
|
||||
support_circle = sk.curves.get(circ).map(|c| c.id.clone()).unwrap_or_default();
|
||||
if let Some(cu) = sk.curves.get_mut(circ) {
|
||||
cu.construction = true;
|
||||
}
|
||||
@@ -818,13 +896,15 @@ fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, s
|
||||
}
|
||||
Ok((ids_of(sk, &l), cons))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
let mut value = result(out, info);
|
||||
value["support_circle"] = json!(support_circle);
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn sides(p: &Value, cmd: &str) -> Result<usize> {
|
||||
let n = num(p, "sides").unwrap_or(6.0);
|
||||
if !(3.0..=200.0).contains(&n) {
|
||||
return Err(bad(cmd, "sides must be 3…200"));
|
||||
if !(3.0..=200.0).contains(&n) || n.fract() != 0.0 {
|
||||
return Err(bad(cmd, "sides must be an integer from 3 to 200"));
|
||||
}
|
||||
Ok(n as usize)
|
||||
}
|
||||
@@ -1333,9 +1413,18 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// The other reading of an angle between two lines (see below).
|
||||
let mut alt: Option<(ConstraintKind, f64)> = None;
|
||||
let (mut k, mut cur): (ConstraintKind, f64) = match (e0, e1) {
|
||||
((None, Some(c)), None) if ty == "sweep" => {
|
||||
let angle = arc_len(sk, c).zip(sk.radius(c)).map(|(len, r)| len / r).ok_or_else(|| bad(cmd, "sweep needs an arc"))?;
|
||||
let angle = if sk.curves.get(c).is_some_and(|c| c.reversed) { -angle } else { angle };
|
||||
(ConstraintKind::ArcSweep { c, value: angle }, angle)
|
||||
}
|
||||
((None, Some(c)), None) if is_line(c) => {
|
||||
let (a, b) = line_pts(sk, c).unwrap_or_default();
|
||||
match ty.as_str() {
|
||||
"angle" => {
|
||||
let angle = (b - a).angle();
|
||||
(ConstraintKind::LineAngle { l: c, value: angle }, angle)
|
||||
}
|
||||
"horizontal" => {
|
||||
(ConstraintKind::DistanceX { p: line_ends(sk, c).0, q: line_ends(sk, c).1, value: (b.x - a.x).abs() }, (b.x - a.x).abs())
|
||||
}
|
||||
@@ -1384,6 +1473,15 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(ConstraintKind::PointLineDistance { p: q, l, value: d }, d)
|
||||
}
|
||||
}
|
||||
((None, Some(a)), Some((None, Some(b)))) if ty == "overall_length" => {
|
||||
let current = sk
|
||||
.center(a)
|
||||
.zip(sk.center(b))
|
||||
.zip(sk.radius(a).zip(sk.radius(b)))
|
||||
.map(|((ca, cb), (ra, rb))| ca.dist(cb) + ra + rb)
|
||||
.ok_or_else(|| bad(cmd, "overall slot length needs two arc caps"))?;
|
||||
(ConstraintKind::SlotOverallLength { a, b, value: current }, current)
|
||||
}
|
||||
((None, Some(a)), Some((None, Some(b)))) if is_line(a) && is_line(b) => {
|
||||
let (a0, a1) = line_pts(sk, a).unwrap_or_default();
|
||||
let (b0, b1) = line_pts(sk, b).unwrap_or_default();
|
||||
@@ -1724,7 +1822,7 @@ fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2) {
|
||||
}
|
||||
k => {
|
||||
// Lines, splines and conics slide with the cursor.
|
||||
for q in k.point_ids() {
|
||||
for q in k.point_ids().into_iter().collect::<std::collections::BTreeSet<_>>() {
|
||||
if let Some(at) = sk.point(q) {
|
||||
set(sk, q, at + d);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::Vec3;
|
||||
use solvecraft_sketch::WireSpace;
|
||||
|
||||
use super::sketch::edit;
|
||||
use super::sketch::{edit, target_sketch};
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, req_vec3, vec3};
|
||||
use crate::{Result, Session};
|
||||
@@ -35,32 +36,71 @@ fn needs_3d(sk: &solvecraft_sketch::Sketch, cmd: &str) -> Result<()> {
|
||||
if sk.view.three_d { Ok(()) } else { Err(bad(cmd, "turn on 3D Sketch (sketch.options sketch_3d: true) to draw off the sketch plane")) }
|
||||
}
|
||||
|
||||
/// World command inputs belong to the target sketch's owner, which may differ from the
|
||||
/// active component when a script supplies an explicit sketch.
|
||||
fn inverse_owner_frame(s: &Session, p: &Value, cmd: &str) -> Result<solvecraft_doc::Mat> {
|
||||
let id = target_sketch(s, p, cmd)?;
|
||||
let owner = s.doc.feature(id).map(|f| f.component).ok_or_else(|| bad(cmd, "target sketch is missing"))?;
|
||||
if owner != 0 && owner != s.active_component {
|
||||
if s.doc.occurrences.len() > 100_000 {
|
||||
return Err(bad(cmd, "sketch owner placement expansion exceeds supported bounds"));
|
||||
}
|
||||
let mut children: std::collections::BTreeMap<u64, Vec<&solvecraft_doc::Occurrence>> = std::collections::BTreeMap::new();
|
||||
for occurrence in &s.doc.occurrences {
|
||||
children.entry(occurrence.parent).or_default().push(occurrence);
|
||||
}
|
||||
let mut stack = vec![(0, 0usize)];
|
||||
let mut count = 0;
|
||||
let mut visited = 0;
|
||||
while let Some((component, depth)) = stack.pop() {
|
||||
for o in children.get(&component).into_iter().flatten() {
|
||||
visited += 1;
|
||||
if depth >= 64 || visited > 100_000 {
|
||||
return Err(bad(cmd, "sketch owner placement expansion exceeds supported bounds"));
|
||||
}
|
||||
if o.component == owner {
|
||||
count += 1;
|
||||
if count > 1 {
|
||||
return Err(bad(cmd, "activate the desired sketch owner occurrence before editing world 3D points"));
|
||||
}
|
||||
}
|
||||
stack.push((o.component, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
let frame = if owner == s.active_component { s.active_component_frame() } else { s.placed_document().component_transform(owner) };
|
||||
solvecraft_doc::mat_inverse(&frame).ok_or_else(|| bad(cmd, "the sketch owner placement is not invertible"))
|
||||
}
|
||||
|
||||
fn line3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.line3d";
|
||||
let pts = points(cmd, p, 2)?;
|
||||
let inverse = inverse_owner_frame(s, p, cmd)?;
|
||||
let pts: Vec<Vec3> = points(cmd, p, 2)?.into_iter().map(|at| solvecraft_doc::apply_point(&inverse, at)).collect();
|
||||
let (ids, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
needs_3d(sk, cmd)?;
|
||||
pts.windows(2).map(|w| sk.add_drawn_wire(w).map_err(Into::into)).collect::<Result<Vec<String>>>()
|
||||
pts.windows(2).map(|w| sk.add_drawn_wire_in(w, WireSpace::Component).map_err(Into::into)).collect::<Result<Vec<String>>>()
|
||||
})?;
|
||||
Ok(json!({"curves": ids, "sketch": info}))
|
||||
}
|
||||
|
||||
fn spline3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.spline3d";
|
||||
let pts = points(cmd, p, 2)?;
|
||||
let inverse = inverse_owner_frame(s, p, cmd)?;
|
||||
let pts: Vec<Vec3> = points(cmd, p, 2)?.into_iter().map(|at| solvecraft_doc::apply_point(&inverse, at)).collect();
|
||||
let (id, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
needs_3d(sk, cmd)?;
|
||||
Ok(sk.add_drawn_wire(&pts)?)
|
||||
Ok(sk.add_drawn_wire_in(&pts, WireSpace::Component)?)
|
||||
})?;
|
||||
Ok(json!({"curves": [id], "sketch": info}))
|
||||
}
|
||||
|
||||
fn point3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.point3d";
|
||||
let at = req_vec3(cmd, p, "point")?;
|
||||
let inverse = inverse_owner_frame(s, p, cmd)?;
|
||||
let at = solvecraft_doc::apply_point(&inverse, req_vec3(cmd, p, "point")?);
|
||||
let (id, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
needs_3d(sk, cmd)?;
|
||||
Ok(sk.add_drawn_wire(&[at])?)
|
||||
Ok(sk.add_drawn_wire_in(&[at], WireSpace::Component)?)
|
||||
})?;
|
||||
Ok(json!({"point": id, "sketch": info}))
|
||||
}
|
||||
@@ -68,23 +108,41 @@ fn point3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn move3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.move3d";
|
||||
let wire = p.get("wire").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`wire` must be a 3D curve or point id"))?.to_string();
|
||||
let index = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let to = p.get("to").and_then(vec3);
|
||||
let by = p.get("by").and_then(vec3);
|
||||
let index = match p.get("index") {
|
||||
None => 0,
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.and_then(|v| usize::try_from(v).ok())
|
||||
.filter(|v| *v < solvecraft_sketch::MAX_FIT_POINTS)
|
||||
.ok_or_else(|| bad(cmd, "`index` must be a fit point index below 500"))?,
|
||||
};
|
||||
let parse = |name| p.get(name).map(|v| vec3(v).ok_or_else(|| bad(cmd, format!("`{name}` must be [x,y,z]")))).transpose();
|
||||
let to = parse("to")?;
|
||||
let by = parse("by")?;
|
||||
let inverse = inverse_owner_frame(s, p, cmd)?;
|
||||
let (n, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let from = sk
|
||||
let drawn = sk
|
||||
.wire_index(&wire)
|
||||
.and_then(|w| sk.wires.get(w))
|
||||
.filter(|w| !w.fit.is_empty())
|
||||
.and_then(|w| w.fit.get(index))
|
||||
.copied()
|
||||
.ok_or_else(|| bad(cmd, format!("`{wire}` has no drawn point {index}")))?;
|
||||
.filter(|w| !w.fit.is_empty() && w.link.is_none())
|
||||
.ok_or_else(|| bad(cmd, format!("`{wire}` is not an editable drawn 3D curve")))?;
|
||||
let from = drawn.fit.get(index).copied().ok_or_else(|| bad(cmd, format!("`{wire}` has no drawn point {index}")))?;
|
||||
let to = match (to, by) {
|
||||
(Some(t), _) => t,
|
||||
(None, Some(d)) => from + d,
|
||||
(Some(t), _) => {
|
||||
if drawn.space == WireSpace::Component {
|
||||
solvecraft_doc::apply_point(&inverse, t)
|
||||
} else {
|
||||
t
|
||||
}
|
||||
}
|
||||
(None, Some(d)) => from + if drawn.space == WireSpace::Component { solvecraft_doc::apply_vector(&inverse, d) } else { d },
|
||||
_ => return Err(bad(cmd, "give `to` or `by`")),
|
||||
};
|
||||
Ok(sk.move_drawn_point(&wire, index, to)?)
|
||||
})?;
|
||||
Ok(json!({"moved": n, "sketch": info}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_3d_frame_tests.rs"]
|
||||
mod frame_tests;
|
||||
@@ -0,0 +1,312 @@
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::Vec3;
|
||||
use solvecraft_sketch::WireSpace;
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, params: Value) -> Value {
|
||||
s.execute(cmd, ¶ms).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn near(actual: Vec3, expected: Vec3) {
|
||||
assert!(actual.dist(expected) < 1e-7, "{actual:?} != {expected:?}");
|
||||
}
|
||||
|
||||
fn second_frame() -> (Session, u64, u64, u64) {
|
||||
let mut s = Session::default();
|
||||
let c = run(&mut s, "component.create", json!({"name":"Wire owner"}));
|
||||
let component = c["component"].as_u64().unwrap();
|
||||
let first = c["occurrence"].as_u64().unwrap();
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
|
||||
// One rigid transform: (x,y,z) -> (20-y,30+x,40+z), independent of implementation math.
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"axis":[0,0,1],"angle":"90 deg","translate":[20,30,40]}));
|
||||
run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]}));
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Space path"}));
|
||||
run(&mut s, "sketch.options", json!({"sketch_3d":true}));
|
||||
(s, component, first, second)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_second_occurrence_world_inputs_move_and_reopen() {
|
||||
let (mut s, component, first, second) = second_frame();
|
||||
let sketch = s.active_sketch.unwrap();
|
||||
let r = run(&mut s, "sketch.line3d", json!({"points":[[17,32,45],[17,32,55],[14,32,55]]}));
|
||||
let a = r["curves"][0].as_str().unwrap().to_string();
|
||||
let b = r["curves"][1].as_str().unwrap().to_string();
|
||||
let spline = run(&mut s, "sketch.spline3d", json!({"points":[[14,32,55],[12,34,56],[10,35,58]]}))["curves"][0].as_str().unwrap().to_string();
|
||||
let point = run(&mut s, "sketch.point3d", json!({"point":[17,32,45]}))["point"].as_str().unwrap().to_string();
|
||||
let sk = s.doc.sketch(sketch).unwrap();
|
||||
for w in &sk.wires {
|
||||
assert_eq!(w.space, WireSpace::Component);
|
||||
}
|
||||
near(sk.wires[sk.wire_index(&a).unwrap()].fit[0], Vec3::new(2.0, 3.0, 5.0));
|
||||
near(sk.wires[sk.wire_index(&point).unwrap()].fit[0], Vec3::new(2.0, 3.0, 5.0));
|
||||
let world = s.world_state();
|
||||
let ws = &world.sketch(sketch).unwrap().sketch;
|
||||
let sw = &ws.wires[ws.wire_index(&spline).unwrap()];
|
||||
near(sw.fit[0], Vec3::new(14.0, 32.0, 55.0));
|
||||
near(sw.pts[0], sw.fit[0]);
|
||||
near(*sw.pts.last().unwrap(), Vec3::new(10.0, 35.0, 58.0));
|
||||
let before = s.doc.to_json();
|
||||
let moved = run(&mut s, "sketch.move3d", json!({"wire":a,"index":1,"by":[1,0,0]}));
|
||||
assert_eq!(moved["moved"], 2);
|
||||
let sk = s.doc.sketch(sketch).unwrap();
|
||||
near(sk.wires[sk.wire_index(&a).unwrap()].fit[1], Vec3::new(2.0, 2.0, 15.0));
|
||||
near(sk.wires[sk.wire_index(&b).unwrap()].fit[0], Vec3::new(2.0, 2.0, 15.0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
run(&mut s, "sketch.move3d", json!({"wire":point,"to":[18,33,46]}));
|
||||
near(s.doc.sketch(sketch).unwrap().wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(3.0, 2.0, 6.0));
|
||||
let restored = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(restored.sketch(sketch).unwrap().wires, s.doc.sketch(sketch).unwrap().wires);
|
||||
// Same definition at the first placement has the local geometry; selected second is exact.
|
||||
run(&mut s, "component.activate", json!({"component":component,"occurrence":first,"path":[first]}));
|
||||
near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(3.0, 2.0, 6.0));
|
||||
run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]}));
|
||||
near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(18.0, 33.0, 46.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_pending_moves_other_owner_and_legacy_world_do_not_join() {
|
||||
let (mut s, _, _, second) = second_frame();
|
||||
let sketch = s.active_sketch.unwrap();
|
||||
let legacy = s.doc_mut().sketch_mut(sketch).unwrap().add_drawn_wire(&[Vec3::new(2.0, 3.0, 5.0)]).unwrap();
|
||||
let local = run(&mut s, "sketch.point3d", json!({"point":[17,32,45]}))["point"].as_str().unwrap().to_string();
|
||||
assert_eq!(run(&mut s, "sketch.move3d", json!({"wire":local,"by":[1,0,0]}))["moved"], 1);
|
||||
near(s.doc.sketch(sketch).unwrap().wires.iter().find(|w| w.id == legacy).unwrap().fit[0], Vec3::new(2.0, 3.0, 5.0));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false}));
|
||||
let point = run(&mut s, "sketch.point3d", json!({"point":[22,32,45]}))["point"].as_str().unwrap().to_string();
|
||||
near(s.doc.sketch(sketch).unwrap().wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(2.0, 3.0, 5.0));
|
||||
near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == legacy).unwrap().fit[0], Vec3::new(2.0, 3.0, 5.0));
|
||||
near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(22.0, 32.0, 45.0));
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(22.0, 32.0, 45.0));
|
||||
// Explicit target in root must not inherit the unrelated active component frame.
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let root = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Root path"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.options", json!({"sketch_3d":true}));
|
||||
let unrelated = run(&mut s, "component.create", json!({"name":"Unrelated"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":unrelated,"translate":[100,200,300]}));
|
||||
run(&mut s, "sketch.point3d", json!({"sketch":root,"point":[9,8,7]}));
|
||||
near(s.doc.sketch(root).unwrap().wires[0].fit[0], Vec3::new(9.0, 8.0, 7.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_hostile_inputs_rollback_full_session() {
|
||||
let (mut s, _, _, second) = second_frame();
|
||||
let id = run(&mut s, "sketch.point3d", json!({"point":[17,32,45]}))["point"].as_str().unwrap().to_string();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[1,0,0],"capture":false}));
|
||||
run(&mut s, "select.set", json!({"items":[{"type":"occurrence","path":[second]}]}));
|
||||
let doc = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let selection = s.selection.clone();
|
||||
let history = (s.undo.len(), s.redo.len());
|
||||
for (cmd, p) in [
|
||||
("sketch.move3d", json!({"wire":id,"index":u64::MAX,"to":[0,0,0]})),
|
||||
("sketch.move3d", json!({"wire":id,"index":-1,"to":[0,0,0]})),
|
||||
("sketch.move3d", json!({"wire":id,"index":"0","to":[0,0,0]})),
|
||||
("sketch.move3d", json!({"wire":id,"to":[null,0,0],"by":[1,0,0]})),
|
||||
("sketch.point3d", json!({"point":[1e100,0,0]})),
|
||||
("sketch.line3d", json!({"points":[[17,32,45],[17,32,45]]})),
|
||||
("sketch.spline3d", json!({"points":vec![[0,0,0];501]})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd}: {p}");
|
||||
assert_eq!(s.doc.to_json(), doc);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(s.selection, selection);
|
||||
assert_eq!((s.undo.len(), s.redo.len()), history);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_include_other_component_refresh_break_lost_and_reopen() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10}));
|
||||
let component = run(&mut s, "component.create", json!({"name":"Receiver"}));
|
||||
let occurrence = component["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[20,0,0]}));
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Include target"}));
|
||||
let sketch = s.active_sketch.unwrap();
|
||||
run(&mut s, "sketch.include_3d", json!({"refs":[{"edge":[10,10,5],"body":"Body1"}]}));
|
||||
let initial = s.doc.sketch(sketch).unwrap().wires[0].clone();
|
||||
assert_eq!(initial.space, WireSpace::Component);
|
||||
assert!(initial.pts.iter().all(|p| (p.x + 10.0).abs() < 1e-7 && (p.y - 10.0).abs() < 1e-7));
|
||||
let link = initial.link.clone().unwrap();
|
||||
// A real sketch edit exercises engine refresh, not only document evaluation.
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,0],[1,0]]}));
|
||||
let sk = s.doc.sketch(sketch).unwrap();
|
||||
assert_eq!(sk.wires[0], initial);
|
||||
assert!(!sk.link(&link).unwrap().lost);
|
||||
let world = s.world_state();
|
||||
assert!(world.sketch(sketch).unwrap().sketch.wires[0].pts.iter().all(|p| (p.x - 10.0).abs() < 1e-7));
|
||||
let before = s.doc.to_json();
|
||||
run(&mut s, "sketch.break_link", json!({"link":link}));
|
||||
assert_eq!(s.doc.sketch(sketch).unwrap().wires[0].space, WireSpace::Component);
|
||||
assert!(s.doc.sketch(sketch).unwrap().wires[0].link.is_none());
|
||||
let restored = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(restored.sketch(sketch).unwrap().wires[0], s.doc.sketch(sketch).unwrap().wires[0]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
// Lost references retain the last coordinates and explicit marker, with no guessing.
|
||||
let sk = s.doc_mut().sketch_mut(sketch).unwrap();
|
||||
sk.links.iter_mut().find(|l| l.id == link).unwrap().source = solvecraft_sketch::LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO };
|
||||
run(&mut s, "sketch.line", json!({"points":[[2,0],[3,0]]}));
|
||||
let sk = s.doc.sketch(sketch).unwrap();
|
||||
assert!(sk.link(&link).unwrap().lost);
|
||||
assert_eq!(sk.wires[0], initial);
|
||||
let restored = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(restored.sketch(sketch).unwrap().wires[0], initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_pipe_component_and_world_paths_have_analytic_volume_and_position() {
|
||||
for space in [WireSpace::Component, WireSpace::World] {
|
||||
let (mut s, _, first, second) = second_frame();
|
||||
if space == WireSpace::World {
|
||||
// A single placed owner lets an immutable world path stay physically fixed;
|
||||
// replicated solids still follow their occurrence placements by design.
|
||||
run(&mut s, "selection.delete", json!({"items":[{"type":"occurrence","path":[first]}]}));
|
||||
}
|
||||
let sketch = s.active_sketch.unwrap();
|
||||
let wire = if space == WireSpace::Component {
|
||||
run(&mut s, "sketch.line3d", json!({"points":[[17,32,45],[17,32,55]]}))["curves"][0].as_str().unwrap().to_string()
|
||||
} else {
|
||||
s.doc_mut().sketch_mut(sketch).unwrap().add_drawn_wire(&[Vec3::new(17.0, 32.0, 45.0), Vec3::new(17.0, 32.0, 55.0)]).unwrap()
|
||||
};
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.pipe", json!({"path_sketch":sketch,"path":[wire],"diameter":4}));
|
||||
let world = s.world_state();
|
||||
let body = world.bodies.last().unwrap();
|
||||
// Property measurement uses fine adaptive tessellation; the viewport mesh alone
|
||||
// undershoots a small circular profile by about 0.64% at its display chord tolerance.
|
||||
let measure = solvecraft_kernel::measure(&body.body).unwrap();
|
||||
assert!((measure.volume - 40.0 * std::f64::consts::PI).abs() < 0.05, "{}", measure.volume);
|
||||
let bounds = body.mesh().bounds();
|
||||
near((bounds.min + bounds.max) / 2.0, Vec3::new(17.0, 32.0, 50.0));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0]}));
|
||||
let world = s.world_state();
|
||||
let body = world.bodies.last().unwrap();
|
||||
let bounds = body.mesh().bounds();
|
||||
near((bounds.min + bounds.max) / 2.0, Vec3::new(if space == WireSpace::Component { 22.0 } else { 17.0 }, 32.0, 50.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_nonactive_repeated_owner_refuses_ambiguous_world_input() {
|
||||
let (mut s, _, _, _) = second_frame();
|
||||
let target = s.active_sketch.unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("sketch.point3d", &json!({"sketch":target,"point":[0,0,5]})).unwrap_err();
|
||||
assert!(error.to_string().contains("activate the desired sketch owner occurrence"));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_wide_owner_graph_has_bounded_indexed_traversal() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name":"Target"}));
|
||||
let target = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
// A legal wide assembly used to force one whole occurrence scan for each leaf.
|
||||
let doc = s.doc_mut();
|
||||
for i in 0..9998u64 {
|
||||
doc.components.push(solvecraft_doc::Component { id: 10_000 + i, name: format!("Sibling{i}"), parent: 0 });
|
||||
doc.occurrences.push(solvecraft_doc::Occurrence {
|
||||
id: 100_000 + i,
|
||||
component: 10_000 + i,
|
||||
parent: 0,
|
||||
name: format!("Sibling{i}"),
|
||||
transform: solvecraft_doc::IDENTITY,
|
||||
grounded: false,
|
||||
});
|
||||
}
|
||||
assert_eq!(super::inverse_owner_frame(&s, &json!({"sketch":target}), "sketch.point3d").unwrap(), solvecraft_doc::IDENTITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_cross_owner_sweep_follows_world_path_edit_undo_and_reopen() {
|
||||
let mut s = Session::default();
|
||||
let path_sketch = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Root path"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.options", json!({"sketch_3d":true}));
|
||||
let wire = run(&mut s, "sketch.line3d", json!({"points":[[17,32,45],[17,32,55]]}))["curves"][0].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let owner = run(&mut s, "component.create", json!({"name":"Sweep owner"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":owner,"axis":[0,0,1],"angle":"90 deg","translate":[20,30,40]}));
|
||||
let profile = run(&mut s, "sketch.create", json!({"plane":"XY","offset":5,"name":"Profile"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[2,3],"radius":2}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.sweep", json!({"sketch":profile,"path_sketch":path_sketch,"path":[wire],"operation":"new"}));
|
||||
let check = |s: &Session, length: f64| {
|
||||
let world = s.world_state();
|
||||
let body = world.bodies.last().unwrap();
|
||||
let measure = solvecraft_kernel::measure(&body.body).unwrap();
|
||||
assert!((measure.volume - 4.0 * std::f64::consts::PI * length).abs() < 0.05, "{}", measure.volume);
|
||||
near(measure.centroid, Vec3::new(17.0, 32.0, 45.0 + length / 2.0));
|
||||
};
|
||||
check(&s, 10.0);
|
||||
let saved = s.doc.to_json();
|
||||
run(&mut s, "sketch.edit", json!({"sketch":path_sketch}));
|
||||
run(&mut s, "sketch.move3d", json!({"sketch":path_sketch,"wire":wire,"index":1,"by":[0,0,10]}));
|
||||
check(&s, 20.0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
check(&s, 10.0);
|
||||
let mut reopened = Session { doc: solvecraft_doc::format::read(&saved).unwrap().into(), ..Default::default() };
|
||||
reopened.refresh();
|
||||
check(&reopened, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_three_owner_sweep_profile_move_invalidates_warm_cache() {
|
||||
let mut s = Session::default();
|
||||
let profile_owner = run(&mut s, "component.create", json!({"name":"Profile owner","parent":0}));
|
||||
let profile_occurrence = profile_owner["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":profile_occurrence,"translate":[4,7,5]}));
|
||||
let profile = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Foreign profile"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":2}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
|
||||
let path_owner = run(&mut s, "component.create", json!({"name":"Path owner","parent":0}));
|
||||
let path_occurrence = path_owner["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":path_occurrence,"translate":[-12,9,-3]}));
|
||||
let path_sketch = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Foreign path"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.options", json!({"sketch_3d":true}));
|
||||
let wire = run(&mut s, "sketch.line3d", json!({"points":[[4,7,5],[4,7,15]]}))["curves"][0].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
|
||||
let consumer = run(&mut s, "component.create", json!({"name":"Sweep consumer","parent":0}));
|
||||
let consumer_occurrence = consumer["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":consumer_occurrence,"axis":[0,0,1],"angle":"90 deg","translate":[100,-30,20]}));
|
||||
run(&mut s, "solid.sweep", json!({"sketch":profile,"path_sketch":path_sketch,"path":[wire],"operation":"new"}));
|
||||
let measure = |session: &Session, x: f64| {
|
||||
let state = session.world_state();
|
||||
let body = state.bodies.last().unwrap();
|
||||
assert_eq!(body.component, consumer["component"].as_u64().unwrap());
|
||||
let result = solvecraft_kernel::measure(&body.body).unwrap();
|
||||
assert!((result.volume - 40.0 * std::f64::consts::PI).abs() < 0.05, "{}", result.volume);
|
||||
near(result.centroid, Vec3::new(x, 7.0, 10.0));
|
||||
result
|
||||
};
|
||||
measure(&s, 4.0);
|
||||
// A repeated refresh warms the exact same evaluator cache before changing only
|
||||
// the foreign profile placement, whose frame must be part of Sweep's digest.
|
||||
s.refresh();
|
||||
measure(&s, 4.0);
|
||||
let saved = s.doc.to_json();
|
||||
let fixed_path = s.doc.component_transform(path_owner["component"].as_u64().unwrap());
|
||||
let fixed_consumer = s.doc.component_transform(consumer["component"].as_u64().unwrap());
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":profile_occurrence,"translate":[3,0,0]}));
|
||||
assert_eq!(s.doc.component_transform(path_owner["component"].as_u64().unwrap()), fixed_path);
|
||||
assert_eq!(s.doc.component_transform(consumer["component"].as_u64().unwrap()), fixed_consumer);
|
||||
let warm = measure(&s, 7.0);
|
||||
let mut cold = Session { doc: solvecraft_doc::format::read(&s.doc.to_json()).unwrap().into(), ..Default::default() };
|
||||
cold.refresh();
|
||||
let reopened = measure(&cold, 7.0);
|
||||
near(warm.centroid, reopened.centroid);
|
||||
assert!((warm.volume - reopened.volume).abs() < 1e-8);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
measure(&s, 4.0);
|
||||
}
|
||||
@@ -434,8 +434,9 @@ fn anchor(sk: &Sketch, k: &ConstraintKind) -> Option<Vec2> {
|
||||
}
|
||||
Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => Some((pt(p)? + pt(q)?) * 0.5),
|
||||
PointLineDistance { p, .. } | LinearDiameter { p, .. } => pt(p),
|
||||
Length { l, .. } => mid(l),
|
||||
Radius { c, .. } | Diameter { c, .. } | ArcLength { c, .. } => mid(c),
|
||||
Length { l, .. } | LineAngle { l, .. } => mid(l),
|
||||
Radius { c, .. } | Diameter { c, .. } | ArcLength { c, .. } | ArcSweep { c, .. } => mid(c),
|
||||
SlotOverallLength { a, b, .. } => Some((sk.center(a)? + sk.center(b)?) * 0.5),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ pub static COMMANDS: &[CommandSpec] =
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spline")
|
||||
.enabled(in_sketch)
|
||||
.params("points: [[x,y] or point refs…] (2…500): the spline passes through them"),
|
||||
.params("points: [[x,y] or point refs…] (2…500), periodic?: boolean (default false); periodic cubic fit closes smoothly through at least 3 distinct samples"),
|
||||
CommandSpec::new("sketch.spline.control_point", "Control Point Spline", spline_cv3)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spline_cv")
|
||||
@@ -167,7 +167,7 @@ fn blend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let first_new = sk.points.len();
|
||||
let c1 = sk.add_point(qa + ta * (l / 3.0), None)?;
|
||||
let c2 = sk.add_point(qb + tb * (l / 3.0), None)?;
|
||||
let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3 }, None)?;
|
||||
let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3, periodic: false }, None)?;
|
||||
let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: sp })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: sp })?];
|
||||
solve_new_only(sk, first_new);
|
||||
Ok((ids_of(sk, &[sp]), cons))
|
||||
@@ -252,10 +252,24 @@ fn point_list(sk: &mut Sketch, p: &Value, cmd: &str) -> Result<Vec<usize>> {
|
||||
}
|
||||
|
||||
fn spline(s: &mut Session, p: &Value, cmd: &str, control: bool, degree: u8) -> Result<Value> {
|
||||
let periodic = match p.get("periodic") {
|
||||
None => false,
|
||||
Some(Value::Bool(v)) => *v,
|
||||
_ => return Err(bad(cmd, "periodic must be boolean")),
|
||||
};
|
||||
if periodic && (control || degree != 3) {
|
||||
return Err(bad(cmd, "periodic mode requires a cubic fit spline"));
|
||||
}
|
||||
let p = &super::sketch_project::snap_points(s, p, cmd)?;
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let pts = point_list(sk, p, cmd)?;
|
||||
let c = sk.add_curve(CurveKind::Spline { pts, control, degree }, None)?;
|
||||
let mut pts = point_list(sk, p, cmd)?;
|
||||
if periodic
|
||||
&& sk.point(*pts.first().ok_or_else(|| bad(cmd, "missing first point"))?)
|
||||
!= sk.point(*pts.last().ok_or_else(|| bad(cmd, "missing last point"))?)
|
||||
{
|
||||
pts.push(*pts.first().ok_or_else(|| bad(cmd, "missing first point"))?);
|
||||
}
|
||||
let c = sk.add_curve(CurveKind::Spline { pts, control, degree, periodic }, None)?;
|
||||
mark_construction(sk, &[c], p);
|
||||
Ok((ids_of(sk, &[c]), Vec::new()))
|
||||
})?;
|
||||
@@ -344,12 +358,12 @@ fn leaving_dir(sk: &Sketch, ci: usize, pi: usize) -> Option<Vec2> {
|
||||
None
|
||||
}
|
||||
}
|
||||
CurveKind::Spline { ref pts, control, .. } => {
|
||||
CurveKind::Spline { ref pts, control, periodic, .. } => {
|
||||
let q: Vec<Vec2> = pts.iter().filter_map(|i| sk.point(*i)).collect();
|
||||
if pts.last() == Some(&pi) {
|
||||
solvecraft_sketch::spline_end_tangent(&q, control, true)
|
||||
solvecraft_sketch::spline_end_tangent_mode(&q, control, periodic, true)
|
||||
} else if pts.first() == Some(&pi) {
|
||||
solvecraft_sketch::spline_end_tangent(&q, control, false).map(|t| -t)
|
||||
solvecraft_sketch::spline_end_tangent_mode(&q, control, periodic, false).map(|t| -t)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Shape creation and its typed driving dimensions form one command and one undo step.
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::expr::Kind;
|
||||
|
||||
use crate::{
|
||||
Result, Session,
|
||||
params::{bad, expr, str_},
|
||||
};
|
||||
|
||||
pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.draw.dimensioned";
|
||||
let create = str_(p, "command").ok_or_else(|| bad(cmd, "`command` must name a supported sketch creator"))?;
|
||||
let roles: &[&str] = match create {
|
||||
"sketch.line" => &["length", "angle"],
|
||||
"sketch.rectangle.two_point" | "sketch.rectangle.center" | "sketch.rectangle.three_point" => &["width", "height"],
|
||||
"sketch.circle.center" | "sketch.circle.two_point" | "sketch.circle.three_point" => &["radius", "diameter"],
|
||||
"sketch.arc.center_point" => &["radius", "sweep"],
|
||||
"sketch.slot.center_to_center" | "sketch.slot.overall" => &["length", "width"],
|
||||
"sketch.polygon.inscribed" | "sketch.polygon.circumscribed" | "sketch.polygon.edge" => &["radius"],
|
||||
_ => return Err(bad(cmd, "unsupported sketch creator")),
|
||||
};
|
||||
let mut params = p.get("params").filter(|v| v.is_object()).cloned().ok_or_else(|| bad(cmd, "`params` must be an object"))?;
|
||||
let dimensions = p.get("dimensions").and_then(Value::as_object).ok_or_else(|| bad(cmd, "`dimensions` must be an object"))?;
|
||||
if dimensions.len() > roles.len() || dimensions.keys().any(|key| !roles.contains(&key.as_str())) {
|
||||
return Err(bad(cmd, "unsupported dimension role for this creator"));
|
||||
}
|
||||
if dimensions.contains_key("radius") && dimensions.contains_key("diameter") {
|
||||
return Err(bad(cmd, "choose radius or diameter, not both"));
|
||||
}
|
||||
let mut overall_length = None;
|
||||
let mut slot_width = None;
|
||||
for (role, value) in dimensions {
|
||||
let e = expr(&json!({"value": value}), "value")
|
||||
.filter(|e| e.len() <= 4096)
|
||||
.ok_or_else(|| bad(cmd, "dimension values must be bounded expressions or finite numbers"))?;
|
||||
let kind = if role == "angle" || role == "sweep" { Kind::Angle } else { Kind::Length };
|
||||
let evaluated = s.doc.eval(&e, kind)?;
|
||||
if role == "length" {
|
||||
overall_length = Some(evaluated);
|
||||
}
|
||||
if role == "width" {
|
||||
slot_width = Some(evaluated);
|
||||
}
|
||||
if !evaluated.is_finite() || evaluated.abs() > 1e9 || kind == Kind::Length && evaluated <= 1e-9 {
|
||||
return Err(bad(cmd, format!("invalid {role} dimension")));
|
||||
}
|
||||
if role == "sweep" && !(evaluated.abs() > 1e-9 && evaluated.abs() < std::f64::consts::TAU - 1e-9) {
|
||||
return Err(bad(cmd, "sweep must be nonzero and less than a full turn"));
|
||||
}
|
||||
if role == "sweep" {
|
||||
params["sweep"] = json!(evaluated.to_degrees());
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.remove("end");
|
||||
}
|
||||
}
|
||||
}
|
||||
if create == "sketch.slot.overall"
|
||||
&& let Some(length) = overall_length
|
||||
&& let Some(width) = slot_width.or_else(|| params.get("width").and_then(Value::as_f64))
|
||||
&& length <= width + 1e-9
|
||||
{
|
||||
return Err(bad(cmd, "overall slot length must exceed its width"));
|
||||
}
|
||||
if create == "sketch.line" {
|
||||
if params.get("closed").and_then(Value::as_bool).unwrap_or(false)
|
||||
|| params.get("points").and_then(Value::as_array).is_none_or(|points| points.len() != 2)
|
||||
{
|
||||
return Err(bad(cmd, "dimensioned line creation needs exactly two points"));
|
||||
}
|
||||
if dimensions.contains_key("angle") {
|
||||
params["infer"] = json!(false);
|
||||
}
|
||||
}
|
||||
let mut scratch = s.scratch();
|
||||
let mut out = scratch.execute(create, ¶ms)?;
|
||||
let curves = out.get("curves").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let curve = |index: usize| -> Result<Value> {
|
||||
curves.get(index).filter(|v| v.is_string()).cloned().ok_or_else(|| bad(cmd, "creator did not return the expected curve"))
|
||||
};
|
||||
let mut added = serde_json::Map::new();
|
||||
for (role, value) in dimensions {
|
||||
let (entities, ty) = match (create, role.as_str()) {
|
||||
("sketch.line", "length") => (vec![curve(0)?], "length"),
|
||||
("sketch.line", "angle") => (vec![curve(0)?], "angle"),
|
||||
(c, "width" | "height") if c.starts_with("sketch.rectangle.") => (vec![curve(usize::from(role == "height"))?], "length"),
|
||||
(c, "radius" | "diameter") if c.starts_with("sketch.circle.") => (vec![curve(0)?], role.as_str()),
|
||||
("sketch.arc.center_point", "radius" | "sweep") => (vec![curve(0)?], role.as_str()),
|
||||
("sketch.slot.center_to_center", "length") => (vec![curve(0)?], "length"),
|
||||
("sketch.slot.overall", "length") => (vec![curve(1)?, curve(3)?], "overall_length"),
|
||||
("sketch.slot.center_to_center" | "sketch.slot.overall", "width") => (vec![curve(1)?], "diameter"),
|
||||
(c, "radius") if c.starts_with("sketch.polygon.") => {
|
||||
let support =
|
||||
out.get("support_circle").filter(|v| v.is_string()).cloned().ok_or_else(|| bad(cmd, "polygon support circle missing"))?;
|
||||
(vec![support], "radius")
|
||||
}
|
||||
_ => return Err(bad(cmd, "unsupported dimension role")),
|
||||
};
|
||||
let mut dimension = json!({"entities": entities, "type": ty, "value": value});
|
||||
if let Some(sketch) = params.get("sketch") {
|
||||
dimension["sketch"] = sketch.clone();
|
||||
}
|
||||
let result = scratch.execute("sketch.dimension", &dimension)?;
|
||||
added.insert(role.clone(), result);
|
||||
}
|
||||
// Session::execute records the wrapper's before snapshot. Nested history must not leak.
|
||||
scratch.undo.clear();
|
||||
s.accept_computed(scratch);
|
||||
out["dimensions"] = Value::Object(added);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_dimensioned_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,167 @@
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_sketch::ConstraintKind;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command} {params}: {error}"))
|
||||
}
|
||||
fn session() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
s
|
||||
}
|
||||
fn sketch(s: &Session) -> solvecraft_sketch::Sketch {
|
||||
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.clone()
|
||||
}
|
||||
fn draw(s: &mut Session, command: &str, params: Value, dimensions: Value) -> Value {
|
||||
run(s, "sketch.draw.dimensioned", json!({"command":command,"params":params,"dimensions":dimensions}))
|
||||
}
|
||||
fn close(a: f64, b: f64) {
|
||||
assert!((a - b).abs() < 1e-6, "{a} != {b}");
|
||||
}
|
||||
fn measure(s: &Session, kind: fn(&ConstraintKind) -> bool) -> f64 {
|
||||
let sk = sketch(s);
|
||||
let c = sk.constraints.iter().find(|c| kind(&c.kind)).unwrap();
|
||||
solvecraft_sketch::measure_dimension(&sk, &c.kind).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_line_angle_survives_quadrants_move_reopen_and_one_undo() {
|
||||
let mut s = session();
|
||||
run(&mut s, "parameters.add", json!({"name":"heading","expression":"-45 deg","unit":"deg"}));
|
||||
let undo = s.undo.len();
|
||||
let out = draw(&mut s, "sketch.line", json!({"points":[[0,0],[10,0]],"infer":true}), json!({"length":"10 mm","angle":"heading"}));
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert!(!sketch(&s).constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Horizontal { .. } | ConstraintKind::Vertical { .. })));
|
||||
close(measure(&s, |k| matches!(k, ConstraintKind::LineAngle { .. })), -45f64.to_radians());
|
||||
for degrees in [135.0_f64, 270.0, -135.0, 0.0, 90.0] {
|
||||
let result = run(&mut s, "parameters.change", json!({"name":"heading","expression":format!("{degrees} deg")}));
|
||||
assert_eq!(result["errors"], json!([]));
|
||||
close(measure(&s, |k| matches!(k, ConstraintKind::LineAngle { .. })), degrees.to_radians());
|
||||
close(measure(&s, |k| matches!(k, ConstraintKind::Length { .. })), 10.0);
|
||||
}
|
||||
let encoded = serde_json::to_value(&*s.doc).unwrap();
|
||||
let doc: solvecraft_doc::Document = serde_json::from_value(encoded).unwrap();
|
||||
let mut reopened = Session { doc: std::sync::Arc::new(doc), active_sketch: s.active_sketch, ..Session::default() };
|
||||
reopened.refresh();
|
||||
close(measure(&reopened, |k| matches!(k, ConstraintKind::LineAngle { .. })), 90f64.to_radians());
|
||||
run(&mut reopened, "sketch.move", json!({"entities":out["curves"],"translate":[4,3],"angle":20}));
|
||||
close(measure(&reopened, |k| matches!(k, ConstraintKind::LineAngle { .. })), 90f64.to_radians());
|
||||
let mut atomic = session();
|
||||
draw(&mut atomic, "sketch.line", json!({"points":[[0,0],[5,5]]}), json!({"length":10,"angle":45}));
|
||||
run(&mut atomic, "edit.undo", json!({}));
|
||||
assert!(sketch(&atomic).curves.is_empty());
|
||||
assert!(atomic.doc.params.iter().all(|p| !p.model));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_arc_sweep_retains_radius_and_logical_endpoint_identity() {
|
||||
let mut s = session();
|
||||
run(&mut s, "parameters.add", json!({"name":"turn","expression":"-90 deg","unit":"deg"}));
|
||||
let out = draw(&mut s, "sketch.arc.center_point", json!({"center":[0,0],"start":[10,0],"end":[0,-10]}), json!({"radius":10,"sweep":"turn"}));
|
||||
let id = out["curves"][0].as_str().unwrap().to_string();
|
||||
let original = sketch(&s).resolve_point(&format!("{id}.start")).unwrap();
|
||||
for degrees in [-90.0_f64, -270.0, 90.0, 270.0, -45.0] {
|
||||
let result = run(&mut s, "parameters.change", json!({"name":"turn","expression":format!("{degrees} deg")}));
|
||||
assert_eq!(result["errors"], json!([]));
|
||||
let sk = sketch(&s);
|
||||
assert_eq!(sk.resolve_point(&format!("{id}.start")), Some(original));
|
||||
assert_eq!(sk.curves[sk.curve_index(&id).unwrap()].reversed, degrees < 0.0);
|
||||
close(measure(&s, |k| matches!(k, ConstraintKind::ArcSweep { .. })), degrees.to_radians());
|
||||
close(measure(&s, |k| matches!(k, ConstraintKind::Radius { .. })), 10.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_and_polygon_keep_permanent_typed_dimensions() {
|
||||
for command in ["sketch.slot.center_to_center", "sketch.slot.overall"] {
|
||||
let mut s = session();
|
||||
let out = draw(&mut s, command, json!({"p0":[2,3],"p1":[22,3],"width":4,"connect":false}), json!({"length":20,"width":4}));
|
||||
let length = out["dimensions"]["length"]["param"].as_str().unwrap();
|
||||
let result = run(&mut s, "parameters.change", json!({"name":length,"expression":30}));
|
||||
assert_eq!(result["errors"], json!([]));
|
||||
close(measure(&s, |k| matches!(k, ConstraintKind::Length { .. } | ConstraintKind::SlotOverallLength { .. })), 30.0);
|
||||
let width = out["dimensions"]["width"]["param"].as_str().unwrap();
|
||||
let result = run(&mut s, "parameters.change", json!({"name":width,"expression":6}));
|
||||
assert_eq!(result["errors"], json!([]));
|
||||
close(measure(&s, |k| matches!(k, ConstraintKind::Diameter { .. })), 6.0);
|
||||
close(measure(&s, |k| matches!(k, ConstraintKind::Length { .. } | ConstraintKind::SlotOverallLength { .. })), 30.0);
|
||||
}
|
||||
for command in ["sketch.polygon.inscribed", "sketch.polygon.circumscribed"] {
|
||||
let mut s = session();
|
||||
let out = draw(&mut s, command, json!({"center":[4,5],"radius":10,"sides":6,"connect":false}), json!({"radius":10}));
|
||||
let support = out["support_circle"].as_str().unwrap();
|
||||
assert!(sketch(&s).curves[sketch(&s).curve_index(support).unwrap()].construction);
|
||||
let radius = out["dimensions"]["radius"]["param"].as_str().unwrap();
|
||||
let result = run(&mut s, "parameters.change", json!({"name":radius,"expression":15}));
|
||||
assert_eq!(result["errors"], json!([]));
|
||||
close(measure(&s, |k| matches!(k, ConstraintKind::Radius { .. })), 15.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_dimensioned_shapes_are_atomic_including_late_constraint_failure() {
|
||||
let mut s = session();
|
||||
let before = serde_json::to_value(&*s.doc).unwrap();
|
||||
let history = s.undo.len();
|
||||
for params in [
|
||||
json!({"command":"edit.undo","params":{},"dimensions":{}}),
|
||||
json!({"command":"sketch.line","params":{"points":[[0,0],[10,0]]},"dimensions":{"length":"missing+1"}}),
|
||||
json!({"command":"sketch.line","params":{"points":[[0,0],[10,0]]},"dimensions":{"length":-1}}),
|
||||
json!({"command":"sketch.line","params":{"points":[[0,0],[10,0]]},"dimensions":{"length":"1".repeat(4097)}}),
|
||||
json!({"command":"sketch.arc.center_point","params":{"center":[0,0],"start":[10,0]},"dimensions":{"sweep":360}}),
|
||||
json!({"command":"sketch.polygon.inscribed","params":{"center":[0,0],"radius":10,"sides":3.5},"dimensions":{"radius":10}}),
|
||||
json!({"command":"sketch.line","params":{"points":[[0,0],[10,0]]},"dimensions":{"length":20,"angle":0}}),
|
||||
] {
|
||||
// The last case connects both endpoints to fixed existing points and fails only after creation.
|
||||
if params["dimensions"]["length"] == 20 {
|
||||
run(&mut s, "sketch.point", json!({"point":[10,0]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entity":"p1","fixed":true}));
|
||||
}
|
||||
let snapshot = serde_json::to_value(&*s.doc).unwrap();
|
||||
let undo = s.undo.len();
|
||||
// Preserve a live redo branch, which scratch-only dimension failure must not consume.
|
||||
run(&mut s, "sketch.point", json!({"point":[100,100]}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
s.selection = vec![crate::Sel::SketchPoint { id: "origin".into() }];
|
||||
let redo = s.redo.len();
|
||||
let selection = s.selection.clone();
|
||||
let pending = s.pending_moves.clone();
|
||||
let active = (s.active_sketch, s.active_component, s.active_occurrence_path.clone());
|
||||
assert!(s.execute("sketch.draw.dimensioned", ¶ms).is_err(), "{params}");
|
||||
assert_eq!(serde_json::to_value(&*s.doc).unwrap(), snapshot);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
assert_eq!(s.redo.len(), redo);
|
||||
assert_eq!(s.selection, selection);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!((s.active_sketch, s.active_component, s.active_occurrence_path.clone()), active);
|
||||
}
|
||||
assert_eq!(before["features"].as_array().unwrap().len(), 1);
|
||||
assert!(s.undo.len() >= history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensioned_authoring_uses_selected_occurrence_and_keeps_local_geometry() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Shared"}))["component"].as_u64().unwrap();
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component":component,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]}));
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[20,0,0],"operation":"new"}));
|
||||
s.auto_project = false;
|
||||
run(&mut s, "sketch.create", json!({"plane":{"face":[21,1,2]}}));
|
||||
let out = draw(&mut s, "sketch.line", json!({"points":[[0,0],[5,5]]}), json!({"length":5,"angle":45}));
|
||||
let model = s.model.state();
|
||||
let local = model.sketch(s.active_sketch.unwrap()).unwrap();
|
||||
close(local.plane.origin.x, 0.0);
|
||||
close(local.plane.origin.z, 2.0);
|
||||
let world = s.world_state();
|
||||
let displayed = world.sketch(s.active_sketch.unwrap()).unwrap();
|
||||
close(displayed.plane.origin.x, 20.0);
|
||||
close(displayed.plane.origin.z, 2.0);
|
||||
assert_eq!(s.active_occurrence_path, Some(vec![second]));
|
||||
let sk = sketch(&s);
|
||||
let line = sk.curve_index(out["curves"][0].as_str().unwrap()).unwrap();
|
||||
let solvecraft_sketch::CurveKind::Line { a, b } = sk.curves[line].kind else { panic!("line") };
|
||||
close(sk.points[a].pos.x, 0.0);
|
||||
close(sk.points[b].pos.x, 5.0 / std::f64::consts::SQRT_2);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Exact planar face capture for sketch attachment. World labels are lookup keys only.
|
||||
use crate::params::{bad, vec3};
|
||||
use crate::{Result, Session};
|
||||
use serde_json::Value;
|
||||
use solvecraft_doc::{EmbossFace, PlaneRef, apply_point, apply_vector, mat_inverse, mat_mul};
|
||||
use solvecraft_geom::Plane;
|
||||
|
||||
pub(in crate::cmd) fn is_face(p: &Value) -> bool {
|
||||
p.get("plane").and_then(|plane| plane.get("face")).is_some()
|
||||
}
|
||||
|
||||
pub(in crate::cmd) fn capture(s: &Session, value: &Value, cmd: &str) -> Result<PlaneRef> {
|
||||
capture_impl(s, value, cmd, false)
|
||||
}
|
||||
|
||||
/// Internal face-profile compatibility only; never exposed as a command parameter.
|
||||
pub(in crate::cmd) fn capture_internal(s: &Session, value: &Value, cmd: &str) -> Result<PlaneRef> {
|
||||
capture_impl(s, value, cmd, true)
|
||||
}
|
||||
|
||||
fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result<PlaneRef> {
|
||||
let point = value
|
||||
.get("face")
|
||||
.and_then(vec3)
|
||||
.filter(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite())
|
||||
.ok_or_else(|| bad(cmd, "face point must be three finite world coordinates"))?;
|
||||
let fixed = match value.get("fixed") {
|
||||
None => false,
|
||||
Some(Value::Bool(fixed)) => *fixed,
|
||||
_ => return Err(bad(cmd, "fixed must be a boolean")),
|
||||
};
|
||||
let state = s.model.state();
|
||||
let doc = s.placed_document();
|
||||
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(s.active_component));
|
||||
let author_frame = EmbossFace::component_frame(&doc, s.active_component, &author_path)?;
|
||||
let author_inverse = mat_inverse(&author_frame).ok_or_else(|| bad(cmd, "invalid sketch author frame"))?;
|
||||
let requested_body = match value.get("body") {
|
||||
None => None,
|
||||
Some(Value::String(body)) if !body.is_empty() => Some(body.as_str()),
|
||||
_ => return Err(bad(cmd, "body must be the picked world body name")),
|
||||
};
|
||||
let requested_index = match value.get("index") {
|
||||
None => None,
|
||||
Some(value) => {
|
||||
Some(value.as_u64().and_then(|index| usize::try_from(index).ok()).ok_or_else(|| bad(cmd, "index must be a nonnegative face index"))?)
|
||||
}
|
||||
};
|
||||
if requested_body.is_some() != requested_index.is_some() {
|
||||
return Err(bad(cmd, "an exact face pick requires both body and index"));
|
||||
}
|
||||
let requested_path = match value.get("path") {
|
||||
None => None,
|
||||
Some(value) => Some(
|
||||
value
|
||||
.as_array()
|
||||
.filter(|path| path.len() <= 64)
|
||||
.ok_or_else(|| bad(cmd, "path must contain at most64 occurrence ids"))?
|
||||
.iter()
|
||||
.map(|id| id.as_u64().filter(|id| *id != 0).ok_or_else(|| bad(cmd, "path ids must be nonzero integers")))
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
),
|
||||
};
|
||||
let selected_path = requested_path
|
||||
.clone()
|
||||
.or_else(|| requested_body.and_then(|name| state.body(name)).map(|body| doc.first_path(doc.model_body_component(body))));
|
||||
let placements = if let Some(path) = selected_path {
|
||||
let component = path
|
||||
.last()
|
||||
.and_then(|id| doc.occurrences.iter().find(|occurrence| occurrence.id == *id))
|
||||
.map(|occurrence| occurrence.component)
|
||||
.unwrap_or(0);
|
||||
let frame = EmbossFace::component_frame(&doc, component, &path)?;
|
||||
vec![(component, path, frame)]
|
||||
} else {
|
||||
crate::cmd::component::properties::placements(&doc, cmd)?
|
||||
};
|
||||
let mut by_component: std::collections::BTreeMap<u64, Vec<_>> = std::collections::BTreeMap::new();
|
||||
for body in &state.bodies {
|
||||
by_component.entry(doc.model_body_component(body)).or_default().push(body);
|
||||
}
|
||||
let mut chosen = None;
|
||||
let mut work = state.bodies.len().saturating_add(doc.occurrences.len());
|
||||
for (component, path, placement) in placements {
|
||||
let Some(bodies) = by_component.get(&component) else {
|
||||
continue;
|
||||
};
|
||||
for body in bodies {
|
||||
if let Some(source) = requested_body.and_then(|name| state.body(name))
|
||||
&& source.name != body.name
|
||||
{
|
||||
continue;
|
||||
}
|
||||
work = work.saturating_add(1);
|
||||
if let Some(requested) = requested_body {
|
||||
work = work.saturating_add(doc.occurrences.len()).saturating_add(state.bodies.len()).saturating_add(path.len());
|
||||
if work > 2_000_000 {
|
||||
return Err(bad(cmd, "face capture work limit exceeded; supply exact path"));
|
||||
}
|
||||
if requested != doc.world_body_name(&state, &body.name, component, &path) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let frame = doc.model_body_offset(body).map(|offset| mat_mul(&placement, offset)).unwrap_or(placement);
|
||||
let inverse = mat_inverse(&frame).ok_or_else(|| bad(cmd, "invalid selected face frame"))?;
|
||||
let local_point = apply_point(&inverse, point);
|
||||
let tolerance = (body.body.size() * 1e-4).max(1e-5);
|
||||
let faces = body.body.faces(tolerance)?;
|
||||
let mesh = body.body.tessellate(tolerance)?;
|
||||
work = work.saturating_add(mesh.triangles.len().saturating_mul(if requested_index.is_some() { 1 } else { faces.len() }));
|
||||
if work > 2_000_000 {
|
||||
return Err(bad(cmd, "face capture work limit exceeded; use an exact body and face index"));
|
||||
}
|
||||
for face in faces {
|
||||
if requested_index.is_some_and(|index| index != face.index) {
|
||||
continue;
|
||||
}
|
||||
let Some(exact_plane) = (if fixed {
|
||||
body.body.planar_face(face.index).or_else(|| body.body.face_plane(face.index))
|
||||
} else {
|
||||
body.body.face_plane(face.index).or_else(|| body.body.planar_face(face.index))
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let normal = exact_plane.normal();
|
||||
if (local_point - face.centroid).dot(normal).abs() > tolerance * 10.0
|
||||
|| !super::face_contains(&mesh, face.index, local_point, tolerance * 10.0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if chosen.is_some() {
|
||||
return Err(bad(cmd, "face point is ambiguous; pick an exact body and face index"));
|
||||
}
|
||||
let relative = mat_mul(&author_inverse, &frame);
|
||||
let normal = apply_vector(&relative, normal).normalized().ok_or_else(|| bad(cmd, "invalid planar face normal"))?;
|
||||
let centroid = apply_point(&relative, face.centroid);
|
||||
let picked = Plane::from_normal(normal * centroid.dot(normal), normal).ok_or_else(|| bad(cmd, "invalid selected planar face"))?;
|
||||
chosen = Some((body, face.index, path.clone(), picked));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (body, index, path, plane) =
|
||||
chosen.ok_or_else(|| bad(cmd, "the selected planar face no longer exists; choose another face or an explicit fixed plane"))?;
|
||||
if fixed {
|
||||
return Ok(PlaneRef::Custom { plane });
|
||||
}
|
||||
let at = apply_point(&author_inverse, point);
|
||||
let name = solvecraft_doc::naming::face_names(body).get(index).cloned();
|
||||
let anchor = match doc.capture_face_anchor(body, index, &plane, &path, s.active_component, &author_path) {
|
||||
Ok(anchor) => Some(Box::new(anchor)),
|
||||
Err(solvecraft_doc::DocError::Invalid(message))
|
||||
if internal
|
||||
&& matches!(
|
||||
message.as_str(),
|
||||
"this face has no exact planar source; choose a fixed plane instead"
|
||||
| "this face has no supported construction certificate; choose a fixed plane instead"
|
||||
) =>
|
||||
{
|
||||
if path.iter().any(|id| s.pending_moves.contains_key(id) && !author_path.contains(id)) {
|
||||
return Err(bad(cmd, "capture the source occurrence position before generating a legacy face profile across components"));
|
||||
}
|
||||
None
|
||||
}
|
||||
Err(error) => return Err(bad(cmd, format!("{error}; choose a supported exact face or explicitly use fixed:true"))),
|
||||
};
|
||||
Ok(PlaneRef::Face { plane, at, name, anchor })
|
||||
}
|
||||
|
||||
/// The sole durable face attachment beneath an offset/angle wrapper.
|
||||
pub(in crate::cmd) fn anchor(mut plane: &PlaneRef) -> Option<&solvecraft_doc::FaceAnchor> {
|
||||
loop {
|
||||
match plane {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane = base,
|
||||
PlaneRef::Face { anchor, .. } => return anchor.as_deref(),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::cmd) fn anchor_mut(mut plane: &mut PlaneRef) -> Option<&mut solvecraft_doc::FaceAnchor> {
|
||||
loop {
|
||||
match plane {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane = base,
|
||||
PlaneRef::Face { anchor, .. } => return anchor.as_deref_mut(),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::cmd) fn capture_positions(s: &mut Session, plane: &PlaneRef) -> Result<()> {
|
||||
let mut involved = std::collections::BTreeSet::new();
|
||||
if let Some(anchor) = anchor(plane) {
|
||||
involved.extend(anchor.path.iter().copied());
|
||||
involved.extend(anchor.author_path.iter().copied());
|
||||
} else {
|
||||
involved.extend(s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(s.active_component)));
|
||||
}
|
||||
let changes: Vec<_> = involved.into_iter().filter_map(|id| s.pending_moves.get(&id).copied().map(|matrix| (id, matrix))).collect();
|
||||
for (id, matrix) in &changes {
|
||||
let occurrence = s
|
||||
.doc_mut()
|
||||
.occurrences
|
||||
.iter_mut()
|
||||
.find(|occurrence| occurrence.id == *id)
|
||||
.ok_or_else(|| bad("sketch attachment", "pending author/source path disappeared"))?;
|
||||
occurrence.transform = *matrix;
|
||||
}
|
||||
for (id, _) in changes {
|
||||
s.pending_moves.remove(&id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_face_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,401 @@
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command} {params}: {error}"))
|
||||
}
|
||||
fn top_pick(s: &Session, body: &str) -> Value {
|
||||
let world = s.world_state();
|
||||
let body = world.body(body).unwrap();
|
||||
let face = body
|
||||
.body
|
||||
.faces(0.01)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter(|face| face.plane_normal.is_some_and(|normal| normal.dot(Vec3::Z) > 0.999))
|
||||
.max_by(|a, b| a.centroid.z.total_cmp(&b.centroid.z))
|
||||
.unwrap();
|
||||
json!({"body":body.name,"index":face.index,"face":[face.centroid.x,face.centroid.y,face.centroid.z]})
|
||||
}
|
||||
fn draw(s: &mut Session) {
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0":[1,1],"p1":[3,3]}));
|
||||
run(s, "sketch.dimension", json!({"entities":["l1"],"value":2}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
}
|
||||
#[test]
|
||||
fn exact_selected_base_follows_height_and_never_retargets_control() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":30,"body_name":"Control","name":"ControlFeature"}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
let sketch = run(&mut s, "sketch.create", json!({"name":"Attached","plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap();
|
||||
draw(&mut s);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":sketch,"distance":2,"operation":"new","body_name":"Badge"}));
|
||||
let height = s.doc.find_feature("BaseFeature").unwrap().param_names[2].clone();
|
||||
run(&mut s, "parameters.change", json!({"name":height,"expression":"20 mm"}));
|
||||
assert!((s.model.state().sketch(sketch).unwrap().plane.origin.z - 20.).abs() < 1e-6);
|
||||
assert!((s.world_state().body("Badge").unwrap().mesh().bounds().max.z - 22.).abs() < 1e-6);
|
||||
run(&mut s, "timeline.delete", json!({"features":["BaseFeature"]}));
|
||||
assert!(s.model.result(sketch).unwrap().error.is_some());
|
||||
assert!(s.model.state().body("Badge").is_none());
|
||||
assert!((s.world_state().body("Control").unwrap().mesh().bounds().max.z - 30.).abs() < 1e-6);
|
||||
let before = s.doc.to_json();
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&before).unwrap());
|
||||
assert!(reopened.model.result(sketch).unwrap().error.is_some());
|
||||
}
|
||||
#[test]
|
||||
fn explicit_rebind_preserves_geometry_dimensions_context_and_one_undo() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":30,"body_name":"Control"}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap();
|
||||
draw(&mut s);
|
||||
run(&mut s, "timeline.delete", json!({"features":["BaseFeature"]}));
|
||||
let geometry = s.doc.sketch(id).unwrap().clone();
|
||||
let before = s.doc.to_json();
|
||||
let context = (s.active_component, s.active_occurrence_path.clone(), s.active_sketch);
|
||||
let pick = top_pick(&s, "Control");
|
||||
let commands = vec![("sketch.redefine".into(), json!({"sketch":id,"plane":pick}))];
|
||||
let preview = s.preview_visible_world(&commands).unwrap();
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert!(preview.after.sketch(id).is_some());
|
||||
run(&mut s, "sketch.redefine", commands[0].1.clone());
|
||||
assert_eq!(s.doc.sketch(id).unwrap(), &geometry);
|
||||
assert_eq!((s.active_component, s.active_occurrence_path.clone(), s.active_sketch), context);
|
||||
assert!((s.model.state().sketch(id).unwrap().plane.origin.z - 30.).abs() < 1e-6);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert!(s.model.result(id).unwrap().error.is_some());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((reopened.model.state().sketch(id).unwrap().plane.origin.z - 30.).abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn unsupported_face_has_explicit_fixed_alternative_and_no_live_projection() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"}));
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[4,4,10],"operation":"join"}));
|
||||
let mut pick = top_pick(&s, "Base");
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("sketch.create", &json!({"plane":pick})).unwrap_err();
|
||||
assert!(error.to_string().contains("fixed"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
pick["fixed"] = json!(true);
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap();
|
||||
assert!(matches!(s.doc.feature(id).unwrap().kind, solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Custom { .. }, .. }));
|
||||
assert!(s.doc.sketch(id).unwrap().links.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn malformed_exact_face_and_path_leave_every_session_field_unchanged() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
for invalid in [
|
||||
json!({"body":"Missing","index":pick["index"],"face":pick["face"]}),
|
||||
json!({"body":"Base","index":-1,"face":pick["face"]}),
|
||||
json!({"body":"Base","index":pick["index"],"face":pick["face"],"path":[999]}),
|
||||
json!({"body":"Base","face":pick["face"]}),
|
||||
] {
|
||||
let before = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let context = (s.active_component, s.active_occurrence_path.clone(), s.active_sketch);
|
||||
assert!(s.execute("sketch.create", &json!({"plane":invalid})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!((s.active_component, s.active_occurrence_path.clone(), s.active_sketch), context);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_certified_borders_follow_width_and_height_with_stable_entities() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":8,"height":10,"body_name":"Base","name":"BaseFeature"}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap();
|
||||
assert!(s.doc.sketch(id).unwrap().links.iter().any(|link| link.auto_face));
|
||||
let old_ids: Vec<_> = s.doc.sketch(id).unwrap().curves.iter().map(|curve| curve.id.clone()).collect();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let dimensions = s.doc.find_feature("BaseFeature").unwrap().param_names.clone();
|
||||
run(&mut s, "parameters.change", json!({"name":dimensions[0],"expression":"20 mm"}));
|
||||
run(&mut s, "parameters.change", json!({"name":dimensions[2],"expression":"15 mm"}));
|
||||
let resolved = s.model.state();
|
||||
let sketch = resolved.sketch(id).unwrap();
|
||||
assert!((sketch.plane.origin.z - 15.).abs() < 1e-6);
|
||||
assert!(sketch.sketch.links.iter().all(|link| !link.lost));
|
||||
assert_eq!(sketch.sketch.curves.iter().map(|curve| curve.id.clone()).collect::<Vec<_>>(), old_ids);
|
||||
let max_x = sketch.sketch.points.iter().map(|point| point.pos.x).fold(f64::NEG_INFINITY, f64::max);
|
||||
let min_x = sketch.sketch.points.iter().map(|point| point.pos.x).fold(f64::INFINITY, f64::min);
|
||||
let max_y = sketch.sketch.points.iter().map(|point| point.pos.y).fold(f64::NEG_INFINITY, f64::max);
|
||||
let min_y = sketch.sketch.points.iter().map(|point| point.pos.y).fold(f64::INFINITY, f64::min);
|
||||
assert!(((max_x - min_x) * (max_y - min_y) - 160.).abs() < 1e-5);
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.state().sketch(id).unwrap().sketch.links.iter().all(|link| !link.lost));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_offset_retains_exact_attachment_during_height_change() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":pick,"offset":"3 mm","project_edges":false}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let height = s.doc.find_feature("BaseFeature").unwrap().param_names[2].clone();
|
||||
run(&mut s, "parameters.change", json!({"name":height,"expression":"20 mm"}));
|
||||
assert!((s.model.state().sketch(id).unwrap().plane.origin.z - 23.).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_nested_source_and_cross_owner_pending_paths_capture_only_involved_positions() {
|
||||
let mut s = Session::default();
|
||||
let parent = run(&mut s, "component.create", json!({"name":"Parent","parent":0}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":parent["occurrence"],"translate":[40,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":parent["component"],"occurrence":second,"path":[second]}));
|
||||
let child = run(&mut s, "component.create", json!({"name":"Child"}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"}));
|
||||
let child_occ = child["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[10,0,0],"capture":false}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":child_occ,"translate":[0,20,0],"capture":false}));
|
||||
let unrelated = run(&mut s, "component.create", json!({"name":"Unrelated","parent":0}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":unrelated["occurrence"],"translate":[300,0,0],"capture":false}));
|
||||
let author = run(&mut s, "component.create", json!({"name":"Author","parent":0}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":author["occurrence"],"translate":[100,0,0],"angle":"90 deg","capture":false}));
|
||||
let world = s.world_state();
|
||||
let key = s
|
||||
.placed_document()
|
||||
.body_frames_with_source(&s.model.state())
|
||||
.into_iter()
|
||||
.find(|(_, source, _, path, _)| source == "Base" && *path == vec![second, child_occ])
|
||||
.unwrap()
|
||||
.0;
|
||||
assert!(world.body(&key).is_some());
|
||||
let mut pick = top_pick(&s, &key);
|
||||
pick["path"] = json!([second, child_occ]);
|
||||
let before = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap();
|
||||
let solvecraft_doc::FeatureKind::Sketch { plane, .. } = &s.doc.feature(id).unwrap().kind else { panic!("not sketch") };
|
||||
let anchor = super::anchor(plane).unwrap();
|
||||
assert_eq!(anchor.path, vec![second, child_occ]);
|
||||
assert_eq!(anchor.author_path, vec![author["occurrence"].as_u64().unwrap()]);
|
||||
assert_eq!(s.pending_moves.len(), 1);
|
||||
assert!(s.pending_moves.contains_key(&unrelated["occurrence"].as_u64().unwrap()));
|
||||
let saved = s.doc.to_json();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
let mut reopened = Session::new(solvecraft_doc::Document::from_json(&saved).unwrap());
|
||||
assert!(reopened.model.result(id).unwrap().error.is_none());
|
||||
run(&mut reopened, "component.activate", json!({"component":0}));
|
||||
let original = reopened.doc.to_json();
|
||||
let undo = reopened.undo.len();
|
||||
let dirty = reopened.is_dirty();
|
||||
run(&mut reopened, "sketch.edit", json!({"sketch":id}));
|
||||
assert_eq!(reopened.doc.to_json(), original);
|
||||
assert_eq!(reopened.undo.len(), undo);
|
||||
assert_eq!(reopened.is_dirty(), dirty);
|
||||
assert_eq!(reopened.active_component, author["component"].as_u64().unwrap());
|
||||
assert_eq!(reopened.active_occurrence_path, Some(vec![author["occurrence"].as_u64().unwrap()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_rebind_freezes_current_resized_borders_and_only_detaches_auto_links() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":8,"height":10,"body_name":"Base","name":"BaseFeature"}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.project", json!({"refs":[{"origin":true}]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let length = s.doc.find_feature("BaseFeature").unwrap().param_names[0].clone();
|
||||
run(&mut s, "parameters.change", json!({"name":length,"expression":"20 mm"}));
|
||||
let visible = s.model.state().sketch(id).unwrap().sketch.clone();
|
||||
let auto: Vec<_> = visible.links.iter().filter(|link| link.auto_face).map(|link| link.id.clone()).collect();
|
||||
let manual: Vec<_> = visible.links.iter().filter(|link| !link.auto_face).map(|link| link.id.clone()).collect();
|
||||
let before = s.doc.to_json();
|
||||
let mut pick = top_pick(&s, "Base");
|
||||
pick["fixed"] = json!(true);
|
||||
run(&mut s, "sketch.redefine", json!({"sketch":id,"plane":pick}));
|
||||
let fixed = s.doc.sketch(id).unwrap();
|
||||
assert!(auto.iter().all(|id| fixed.link(id).is_none()));
|
||||
assert!(manual.iter().all(|id| fixed.link(id).is_some()));
|
||||
assert_eq!(
|
||||
fixed.curves.iter().map(|curve| curve.id.clone()).collect::<Vec<_>>(),
|
||||
visible.curves.iter().map(|curve| curve.id.clone()).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(fixed.points.iter().map(|point| point.pos).collect::<Vec<_>>(), visible.points.iter().map(|point| point.pos).collect::<Vec<_>>());
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.result(id).unwrap().error.is_none());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_anchor_timeline_copy_remaps_producer_and_refuses_external_translation_atomically() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
let id = run(&mut s, "sketch.create", json!({"name":"Attached","plane":pick}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "timeline.copy", json!({"features":["Attached"]}));
|
||||
let before = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let error = s.execute("timeline.paste", &json!({"translate":[30,0,0]})).unwrap_err();
|
||||
assert!(error.to_string().contains("external exact face"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
run(&mut s, "timeline.copy", json!({"features":["BaseFeature","Attached"]}));
|
||||
let pasted = run(&mut s, "timeline.paste", json!({"translate":[30,0,0]}));
|
||||
assert!(pasted["errors"].as_array().unwrap().is_empty(), "{pasted}");
|
||||
let new_id = pasted["pasted"][1]["id"].as_u64().unwrap();
|
||||
let solvecraft_doc::FeatureKind::Sketch { plane, .. } = &s.doc.feature(new_id).unwrap().kind else { panic!("not sketch") };
|
||||
let anchor = super::anchor(plane).unwrap();
|
||||
assert_ne!(anchor.target.origin_feature, s.doc.find_feature("BaseFeature").unwrap().id);
|
||||
assert_ne!(anchor.target.body, "Base");
|
||||
assert!(s.model.state().sketch(new_id).unwrap().sketch.links.iter().all(|link| !link.lost));
|
||||
assert!(s.model.state().sketch(id).unwrap().sketch.links.iter().all(|link| !link.lost));
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.result(new_id).unwrap().error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_anchor_deep_clone_is_independent_and_rename_preserves_certified_projection() {
|
||||
let mut s = Session::default();
|
||||
let part = run(&mut s, "component.create", json!({"name":"Part","parent":0}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
let id = run(&mut s, "sketch.create", json!({"name":"Attached","plane":pick}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let height = s.doc.find_feature("BaseFeature").unwrap().param_names[2].clone();
|
||||
let pasted = run(&mut s, "component.paste_new", json!({"component":part["component"],"translate":[30,0,0]}));
|
||||
let owner = pasted["component"].as_u64().unwrap();
|
||||
let new_feature = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.find(|feature| feature.component == owner && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. }))
|
||||
.unwrap();
|
||||
let new_id = new_feature.id;
|
||||
let solvecraft_doc::FeatureKind::Sketch { plane, .. } = &new_feature.kind else { panic!("not sketch") };
|
||||
let anchor = super::anchor(plane).unwrap();
|
||||
let body = anchor.target.body.clone();
|
||||
assert_eq!(anchor.component, owner);
|
||||
run(&mut s, "parameters.change", json!({"name":height,"expression":"20 mm"}));
|
||||
assert!((s.model.state().sketch(id).unwrap().plane.origin.z - 20.).abs() < 1e-6);
|
||||
assert!((s.model.state().sketch(new_id).unwrap().plane.origin.z - 10.).abs() < 1e-6);
|
||||
run(&mut s, "body.rename", json!({"body":body,"name":"Independent"}));
|
||||
assert!(s.model.result(new_id).unwrap().error.is_none());
|
||||
assert!(s.model.state().sketch(new_id).unwrap().sketch.links.iter().all(|link| !link.lost));
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.result(new_id).unwrap().error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_or_fixed_rebind_needs_explicit_author_path_for_repeated_owner() {
|
||||
let mut s = Session::default();
|
||||
let part = run(&mut s, "component.create", json!({"name":"Part","parent":0}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"}));
|
||||
let mut pick = top_pick(&s, "Base");
|
||||
pick["fixed"] = json!(true);
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":part["occurrence"],"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("sketch.redefine", &json!({"sketch":id,"plane":pick})).unwrap_err();
|
||||
assert!(error.to_string().contains("ambiguous"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "sketch.redefine", json!({"sketch":id,"plane":pick,"author_path":[second]}));
|
||||
let solvecraft_doc::FeatureKind::Sketch { plane, .. } = &s.doc.feature(id).unwrap().kind else { panic!("not sketch") };
|
||||
assert_eq!(super::anchor(plane).unwrap().author_path, vec![second]);
|
||||
assert_eq!(s.active_component, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_producer_with_same_body_name_cannot_heal_lost_anchor() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"Original"}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "timeline.suppress", json!({"feature":"Original","suppressed":true}));
|
||||
run(&mut s, "timeline.rollback", json!({"position":1}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":30,"body_name":"Base","name":"Replacement"}));
|
||||
run(&mut s, "timeline.rollback", json!({}));
|
||||
assert!(s.model.state().body("Base").is_some());
|
||||
assert!(s.model.result(id).unwrap().error.is_some());
|
||||
let before = s.doc.to_json();
|
||||
let pick = top_pick(&s, "Base");
|
||||
run(&mut s, "sketch.redefine", json!({"sketch":id,"plane":pick}));
|
||||
assert!((s.model.state().sketch(id).unwrap().plane.origin.z - 30.).abs() < 1e-6);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert!(s.model.result(id).unwrap().error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freezing_rebuilt_arc_borders_keeps_authored_dimension_entity_indices() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name":"turn","expression":"270 deg","unit":"deg"}));
|
||||
let profile = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
|
||||
let arc = run(
|
||||
&mut s,
|
||||
"sketch.draw.dimensioned",
|
||||
json!({"command":"sketch.arc.center_point","params":{"center":[0,0],"start":[10,0],"sweep":270},"dimensions":{"radius":10,"sweep":"turn"}}),
|
||||
)["curves"][0]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
run(&mut s, "sketch.line", json!({"points":[format!("{arc}.end"),format!("{arc}.start")],"infer":false}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":profile,"distance":10,"operation":"new","body_name":"Sector"}));
|
||||
let pick = top_pick(&s, "Sector");
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap();
|
||||
let circle = run(&mut s, "sketch.circle.center", json!({"center":[20,20],"radius":2}))["curves"][0].as_str().unwrap().to_owned();
|
||||
run(&mut s, "sketch.dimension", json!({"entities":[circle],"type":"radius","value":"2 mm"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before_count = s.model.state().sketch(id).unwrap().sketch.curves.len();
|
||||
run(&mut s, "parameters.change", json!({"name":"turn","expression":"300 deg"}));
|
||||
assert!(s.model.result(id).unwrap().error.is_none());
|
||||
let visible = s.model.state().sketch(id).unwrap().sketch.clone();
|
||||
assert_ne!(visible.curves.len(), before_count, "fixture must rebuild projected topology");
|
||||
let mut pick = top_pick(&s, "Sector");
|
||||
pick["fixed"] = json!(true);
|
||||
let before = s.doc.to_json();
|
||||
run(&mut s, "sketch.redefine", json!({"sketch":id,"plane":pick}));
|
||||
let frozen = s.doc.sketch(id).unwrap();
|
||||
let circle_index = frozen.curve_index(&circle).unwrap();
|
||||
let dimension = frozen
|
||||
.constraints
|
||||
.iter()
|
||||
.find(|constraint| matches!(constraint.kind, solvecraft_sketch::ConstraintKind::Radius { c, .. } if c == circle_index))
|
||||
.unwrap();
|
||||
assert!((solvecraft_sketch::measure_dimension(frozen, &dimension.kind).unwrap() - 2.).abs() < 1e-6);
|
||||
assert_eq!(frozen.curves.iter().map(|curve| &curve.id).collect::<Vec<_>>(), visible.curves.iter().map(|curve| &curve.id).collect::<Vec<_>>());
|
||||
assert!(s.model.result(id).unwrap().error.is_none());
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.result(id).unwrap().error.is_none());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angled_attachment_tracks_source_plane_and_reopens() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"}));
|
||||
let pick = top_pick(&s, "Base");
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let solvecraft_doc::FeatureKind::Sketch { plane, .. } = &mut s.doc_mut().feature_mut(id).unwrap().kind else { panic!("not sketch") };
|
||||
*plane = solvecraft_doc::PlaneRef::AtAngle { base: Box::new(plane.clone()), axis_origin: Vec3::ZERO, axis_dir: Vec3::X, angle: "90 deg".into() };
|
||||
s.refresh();
|
||||
let height = s.doc.find_feature("BaseFeature").unwrap().param_names[2].clone();
|
||||
run(&mut s, "parameters.change", json!({"name":height,"expression":"20 mm"}));
|
||||
let placed = s.model.state().sketch(id).unwrap().plane;
|
||||
assert!((placed.origin.y + 20.).abs() < 1e-6);
|
||||
assert!(placed.origin.z.abs() < 1e-6);
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert_eq!(reopened.model.state().sketch(id).unwrap().plane, placed);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use crate::params::bad;
|
||||
|
||||
/// Exact pieces of a free-form curve (polyline lines for the few kinds without them).
|
||||
fn pieces(sk: &Sketch, ci: usize) -> Vec<Seg2> {
|
||||
sk.exact_segs(ci).unwrap_or_else(|| sk.polyline(ci).windows(2).map(|w| Seg2::Line { a: w[0], b: w[1] }).collect())
|
||||
sk.exact_segs(ci).unwrap_or_else(|| sk.segs(ci))
|
||||
}
|
||||
|
||||
/// Position along the pieces (piece index + local parameter) closest to `p`, and the distance.
|
||||
@@ -44,7 +44,7 @@ fn project(sk: &Sketch, ci: usize, p: Vec2) -> Vec2 {
|
||||
}
|
||||
|
||||
/// Where other curves meet free-form curve `ci`: (position along it, the point, the other curve).
|
||||
fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2]) -> Vec<(f64, Vec2, usize)> {
|
||||
fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2], only_freeform: bool) -> Vec<(f64, Vec2, usize)> {
|
||||
let mine = sk.polyline(ci);
|
||||
let tol = 1e-6;
|
||||
let mut out: Vec<(f64, Vec2, usize)> = Vec::new();
|
||||
@@ -57,8 +57,8 @@ fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2]) -> Vec<(f64, Vec2, usize)> {
|
||||
let (t, u) = ((c - a).cross(s) / den, (c - a).cross(r) / den);
|
||||
((-1e-9..=1.0 + 1e-9).contains(&t) && (-1e-9..=1.0 + 1e-9).contains(&u)).then(|| a + r * t)
|
||||
};
|
||||
for (oi, _) in sk.curves.iter().enumerate() {
|
||||
if oi == ci {
|
||||
for (oi, curve) in sk.curves.iter().enumerate() {
|
||||
if oi == ci || (only_freeform && !curve.kind.is_freeform()) {
|
||||
continue;
|
||||
}
|
||||
let other = sk.polyline(oi);
|
||||
@@ -90,7 +90,7 @@ fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2]) -> Vec<(f64, Vec2, usize)> {
|
||||
q = n;
|
||||
}
|
||||
let (u, d) = locate(segs, q);
|
||||
if d < tol && !out.iter().any(|(_, y, _)| y.dist(q) < tol * 10.0) {
|
||||
if d < tol && project(sk, oi, q).dist(q) < tol && !out.iter().any(|(_, y, _)| y.dist(q) < tol * 10.0) {
|
||||
out.push((u, q, oi));
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,15 @@ fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2]) -> Vec<(f64, Vec2, usize)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Crossings for an analytic target with free-form cutters. Analytic-to-analytic crossings
|
||||
/// remain in the shape intersection routine; this supplies the missing mixed pairs.
|
||||
pub(super) fn analytic_cut_points(sk: &Sketch, ci: usize) -> Vec<(Vec2, usize)> {
|
||||
if !sk.curves.iter().any(|c| c.kind.is_freeform()) {
|
||||
return Vec::new();
|
||||
}
|
||||
cuts(sk, ci, &pieces(sk, ci), true).into_iter().map(|(_, q, oi)| (q, oi)).collect()
|
||||
}
|
||||
|
||||
/// Exact sub-pieces between positions `ua` < `ub` (positions may run past the end on closed
|
||||
/// curves; they wrap).
|
||||
fn span(segs: &[Seg2], ua: f64, ub: f64) -> Vec<Seg2> {
|
||||
@@ -147,7 +156,7 @@ fn add_pieces(sk: &mut Sketch, segs: &[Seg2], start: Option<usize>, end: Option<
|
||||
Seg2::Line { .. } => CurveKind::Line { a: prev, b: to },
|
||||
Seg2::Cubic { p1, p2, .. } => {
|
||||
let (c1, c2) = (sk.add_point(p1, None)?, sk.add_point(p2, None)?);
|
||||
CurveKind::Spline { pts: vec![prev, c1, c2, to], control: true, degree: 3 }
|
||||
CurveKind::Spline { pts: vec![prev, c1, c2, to], control: true, degree: 3, periodic: false }
|
||||
}
|
||||
Seg2::Conic { apex, w, .. } => {
|
||||
let x = sk.add_point(apex, None)?;
|
||||
@@ -160,7 +169,7 @@ fn add_pieces(sk: &mut Sketch, segs: &[Seg2], start: Option<usize>, end: Option<
|
||||
pts.push(sk.add_point(*q, None)?);
|
||||
}
|
||||
pts.push(to);
|
||||
CurveKind::Spline { pts, control: true, degree: n }
|
||||
CurveKind::Spline { pts, control: true, degree: n, periodic: false }
|
||||
}
|
||||
Seg2::Arc { .. } => return Err(bad("trim", "unexpected arc piece")),
|
||||
};
|
||||
@@ -176,18 +185,45 @@ fn add_pieces(sk: &mut Sketch, segs: &[Seg2], start: Option<usize>, end: Option<
|
||||
|
||||
/// Trim (`remove`) or break a free-form curve at the crossings around `at`.
|
||||
pub(super) fn trim_or_break(sk: &mut Sketch, ci: usize, at: Vec2, remove: bool, cmd: &str) -> Result<Value> {
|
||||
modify(sk, ci, at, remove, None, cmd)
|
||||
}
|
||||
|
||||
/// Break at explicit positions, projected onto the exact curve. Closed curves can be opened
|
||||
/// at one point; open curves require interior positions.
|
||||
pub(super) fn break_at_points(sk: &mut Sketch, ci: usize, points: &[Vec2], cmd: &str) -> Result<Value> {
|
||||
modify(sk, ci, Vec2::ZERO, false, Some(points), cmd)
|
||||
}
|
||||
|
||||
fn modify(sk: &mut Sketch, ci: usize, at: Vec2, remove: bool, points: Option<&[Vec2]>, cmd: &str) -> Result<Value> {
|
||||
let segs = pieces(sk, ci);
|
||||
if segs.is_empty() {
|
||||
return Err(bad(cmd, "the curve has no shape"));
|
||||
}
|
||||
let n = segs.len() as f64;
|
||||
let closed = segs.first().zip(segs.last()).is_some_and(|(a, b)| a.start().dist(b.end()) < 1e-9);
|
||||
let cs: Vec<(f64, Vec2, usize)> = cuts(sk, ci, &segs).into_iter().filter(|c| closed || (c.0 > 1e-9 && c.0 < n - 1e-9)).collect();
|
||||
let cs: Vec<(f64, Vec2, Option<usize>)> = if let Some(points) = points {
|
||||
let mut positions: Vec<f64> = points.iter().map(|q| locate(&segs, *q).0).collect();
|
||||
if positions.iter().any(|u| !u.is_finite() || (!closed && (*u <= 1e-9 || *u >= n - 1e-9))) {
|
||||
return Err(bad(cmd, "break points must project inside the curve, away from its ends"));
|
||||
}
|
||||
if closed {
|
||||
for u in &mut positions {
|
||||
if *u >= n - 1e-9 {
|
||||
*u = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
positions.sort_by(f64::total_cmp);
|
||||
positions.dedup_by(|a, b| (*a - *b).abs() <= 1e-9);
|
||||
positions.into_iter().map(|u| (u, point_at(&segs, u), None)).collect()
|
||||
} else {
|
||||
cuts(sk, ci, &segs, false).into_iter().filter(|c| closed || (c.0 > 1e-9 && c.0 < n - 1e-9)).map(|(u, q, c)| (u, q, Some(c))).collect()
|
||||
};
|
||||
let (uq, _) = locate(&segs, at);
|
||||
let ends = sk.curves.get(ci).and_then(|c| c.kind.ends());
|
||||
let construction = sk.curves.get(ci).is_some_and(|c| c.construction);
|
||||
// Intervals between consecutive cuts (closed curves wrap round).
|
||||
let mut bounds: Vec<(f64, Option<usize>)> = cs.iter().map(|c| (c.0, Some(c.2))).collect();
|
||||
let mut bounds: Vec<(f64, Option<usize>)> = cs.iter().map(|c| (c.0, c.2)).collect();
|
||||
if bounds.is_empty() {
|
||||
if remove {
|
||||
sk.remove_curves(&[ci]);
|
||||
@@ -239,7 +275,7 @@ pub(super) fn trim_or_break(sk: &mut Sketch, ci: usize, at: Vec2, remove: bool,
|
||||
// Cut points stay on the curves that cut there.
|
||||
for ((_, _, oi), (_, pi)) in cs.iter().zip(&cut_pt) {
|
||||
let used = sk.curves.iter().any(|c| c.kind.uses(*pi));
|
||||
if used {
|
||||
if used && let Some(oi) = oi {
|
||||
add_c(sk, ConstraintKind::PointOnCurve { p: *pi, c: *oi })?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ fn add_geometry(sk: &mut Sketch, geom: &[Geom2], f: &dyn Fn(Vec2) -> Vec2, k: f6
|
||||
idx.push(i);
|
||||
}
|
||||
}
|
||||
(idx.len() >= 2).then_some(CurveKind::Spline { pts: idx, control: *control, degree: *degree })
|
||||
(idx.len() >= 2).then_some(CurveKind::Spline { pts: idx, control: *control, degree: *degree, periodic: false })
|
||||
}
|
||||
Geom2::Conic { a, apex, b, rho } => {
|
||||
let (a, x, b) = (pt(sk, *a)?, pt(sk, *apex)?, pt(sk, *b)?);
|
||||
|
||||
@@ -9,9 +9,12 @@ use solvecraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, interse
|
||||
|
||||
use super::sketch::{add_c, edit, ids_of};
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2};
|
||||
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")
|
||||
@@ -54,7 +60,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("break")
|
||||
.enabled(in_sketch)
|
||||
.params("curve: id, at: [x,y] (split at the nearest intersections around it) | params: split at points: [[x,y]…]"),
|
||||
.params("curve: id, at: [x,y] (split at intersections) | points: [[x,y]…] (1…1000 positions projected onto the curve; duplicates are merged)"),
|
||||
CommandSpec::new("sketch.scale", "Sketch Scale", scale)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("scale")
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -135,6 +144,13 @@ fn cut_points(sk: &Sketch, ci: usize) -> Vec<(f64, Vec2, usize)> {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (q, oi) in super::sketch_freeform::analytic_cut_points(sk, ci) {
|
||||
if let Some(t) = on_bounded(&sh, q)
|
||||
&& !out.iter().any(|(_, x, _)| x.dist(q) < TOL * 10.0)
|
||||
{
|
||||
out.push((t, q, oi));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -314,17 +330,24 @@ fn trim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.break";
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let points = if p.get("points").is_some() { Some(vec2_list(cmd, p, "points", 1000)?) } else { None };
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
|
||||
let ci = curve_arg(sk, p, cmd)?;
|
||||
if sk.curves.get(ci).is_some_and(|c| c.kind.is_freeform()) {
|
||||
let at = req_vec2(cmd, p, "at")?;
|
||||
let r = super::sketch_freeform::trim_or_break(sk, ci, at, false, cmd)?;
|
||||
let r = if let Some(points) = &points {
|
||||
super::sketch_freeform::break_at_points(sk, ci, points, cmd)?
|
||||
} else {
|
||||
let at = req_vec2(cmd, p, "at")?;
|
||||
super::sketch_freeform::trim_or_break(sk, ci, at, false, cmd)?
|
||||
};
|
||||
doc.prune_model_params();
|
||||
return Ok(r["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default());
|
||||
}
|
||||
let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?;
|
||||
let (ts, cutters): (Vec<f64>, Vec<Option<usize>>) = if let Some(list) = p.get("points").and_then(Value::as_array) {
|
||||
let mut v: Vec<f64> = list.iter().take(1000).filter_map(vec2).map(|q| sh.param(sh.project(q))).collect();
|
||||
let (ts, cutters): (Vec<f64>, Vec<Option<usize>>) = if let Some(list) = &points {
|
||||
let mut v: Vec<f64> = list.iter().map(|q| sh.param(sh.project(*q))).collect();
|
||||
v.sort_by(|a, b| a.total_cmp(b));
|
||||
v.dedup_by(|a, b| (*a - *b).abs() <= 1e-9);
|
||||
let n = v.len();
|
||||
(v, vec![None; n])
|
||||
} else {
|
||||
@@ -351,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()) {
|
||||
@@ -372,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}))
|
||||
@@ -795,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();
|
||||
@@ -841,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();
|
||||
@@ -903,7 +970,7 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
idx.push(pb);
|
||||
idx.dedup();
|
||||
if idx.len() >= 2 {
|
||||
made.push(sk.add_curve(CurveKind::Spline { pts: idx, control: false, degree: 3 }, None)?);
|
||||
made.push(sk.add_curve(CurveKind::Spline { pts: idx, control: false, degree: 3, periodic: false }, None)?);
|
||||
}
|
||||
}
|
||||
Piece::Line(..) => {
|
||||
@@ -933,6 +1000,41 @@ 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, doc| {
|
||||
let output = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must name an offset output"))?;
|
||||
let record = sk
|
||||
.freeform_offsets
|
||||
.iter()
|
||||
.position(|record| record.output == output)
|
||||
.ok_or_else(|| bad(cmd, "that curve has no free-form offset association"))?;
|
||||
// A parameter edit may have changed the evaluated source without
|
||||
// rewriting the stored fit points. Break must freeze the displayed,
|
||||
// current dimensioned geometry, rather than its stale stored predecessor.
|
||||
let (values, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&values, sk)?;
|
||||
if !solvecraft_sketch::solve(sk).ok() {
|
||||
return Err(bad(cmd, "cannot break an offset with conflicting source dimensions"));
|
||||
}
|
||||
sk.refresh_offsets()?;
|
||||
if !solvecraft_sketch::solve(sk).ok() {
|
||||
return Err(bad(cmd, "cannot break an offset with conflicting derived dimensions"));
|
||||
}
|
||||
sk.validate_periodic_splines()?;
|
||||
let ci = sk.curve_index(output).ok_or_else(|| bad(cmd, "missing offset output"))?;
|
||||
let indices = sk.curves[ci].kind.point_ids();
|
||||
sk.freeform_offsets.remove(record);
|
||||
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
|
||||
|
||||
|
||||
@@ -37,6 +37,102 @@ fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_freeform_break_preserves_exact_geometry_and_undo() {
|
||||
for (command, params) in [
|
||||
("sketch.spline.control_point", json!({"points": [[0,0], [0,12], [20,12], [20,0]], "connect": false})),
|
||||
("sketch.spline.control_point_5", json!({"points": [[0,0], [0,12], [5,20], [12,20], [20,12], [20,0]], "connect": false})),
|
||||
("sketch.spline.fit_point", json!({"points": [[0,0], [6,12], [14,8], [20,0]], "connect": false})),
|
||||
("sketch.conic", json!({"start": [0,0], "end": [20,0], "apex": [10,20], "rho": 0.65, "connect": false})),
|
||||
] {
|
||||
let mut s = new_sketch();
|
||||
let id = ids(&run(&mut s, command, params)["curves"])[0].clone();
|
||||
let before = sketch(&s);
|
||||
let exact = before.exact_segs(before.curve_index(&id).unwrap()).unwrap();
|
||||
let a = exact.first().unwrap().point_at(0.25);
|
||||
let b = exact.last().unwrap().point_at(0.75);
|
||||
let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [b, a, a]}));
|
||||
assert!(ids(&out["curves"]).len() >= 3, "{command}: {out}");
|
||||
let after = sketch(&s);
|
||||
let segs: Vec<_> = after.curves.iter().enumerate().flat_map(|(i, _)| after.exact_segs(i).unwrap_or_default()).collect();
|
||||
for old in &exact {
|
||||
for k in 0..=40 {
|
||||
let q = old.point_at(k as f64 / 40.0);
|
||||
let d = segs.iter().map(|seg| seg.point_at(seg.closest_param(q)).dist(q)).fold(f64::INFINITY, f64::min);
|
||||
assert!(d < 1e-6, "{command}: geometry moved {d} at {q:?}");
|
||||
}
|
||||
}
|
||||
let encoded = serde_json::to_value(&after).unwrap();
|
||||
let restored: solvecraft_sketch::Sketch = serde_json::from_value(encoded).unwrap();
|
||||
assert_eq!(restored.curves.len(), after.curves.len());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(sketch(&s).curve_index(&id).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_ellipse_break_keeps_closed_profile() {
|
||||
let mut s = new_sketch();
|
||||
let id = ids(&run(&mut s, "sketch.ellipse", json!({"center": [0,0], "major": [10,0], "minor": [0,4]}))["curves"])[0].clone();
|
||||
let area = profiles(&s)[0];
|
||||
let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [[10,0], [0,4], [10,0]]}));
|
||||
assert_eq!(ids(&out["curves"]).len(), 4, "{out}");
|
||||
let after = sketch(&s);
|
||||
assert!(after.curves.iter().all(|c| matches!(c.kind, CurveKind::Conic { .. })));
|
||||
assert_eq!(profiles(&s).len(), 1);
|
||||
assert!((profiles(&s)[0] - area).abs() < 1e-6);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let original = sketch(&s);
|
||||
let q = original.exact_segs(original.curve_index(&id).unwrap()).unwrap()[0].point_at(0.4);
|
||||
let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [q]}));
|
||||
assert_eq!(ids(&out["curves"]).len(), 5, "a single cut inside a quadrant: {out}");
|
||||
assert_eq!(profiles(&s).len(), 1);
|
||||
assert!((profiles(&s)[0] - area).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_break_rejects_bad_points_atomically_and_merges_duplicates() {
|
||||
for freeform in [false, true] {
|
||||
let mut s = new_sketch();
|
||||
let out = if freeform {
|
||||
run(&mut s, "sketch.spline.control_point", json!({"points": [[0,0], [0,10], [20,10], [20,0]], "connect": false}))
|
||||
} else {
|
||||
run(&mut s, "sketch.line", json!({"points": [[0,0], [20,0]], "connect": false}))
|
||||
};
|
||||
let id = ids(&out["curves"])[0].clone();
|
||||
let before = serde_json::to_value(sketch(&s)).unwrap();
|
||||
for points in [json!([]), json!("wrong"), json!([[8, 4], ["bad", 0]]), json!([[0, 0]]), json!([[1e20, 0]]), json!(vec![[10, 5]; 1001])] {
|
||||
assert!(s.execute("sketch.break", &json!({"curve": id, "points": points})).is_err());
|
||||
assert_eq!(serde_json::to_value(sketch(&s)).unwrap(), before);
|
||||
}
|
||||
let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [[10,7], [10,7]]}));
|
||||
assert_eq!(ids(&out["curves"]).len(), 2, "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analytic_trim_and_break_use_freeform_cutters() {
|
||||
let mut s = new_sketch();
|
||||
let line = ids(&run(&mut s, "sketch.line", json!({"points": [[-5,5], [25,5]], "connect": false}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.spline.control_point", json!({"points": [[0,0], [0,10], [20,10], [20,0]], "connect": false}));
|
||||
let out = run(&mut s, "sketch.break", json!({"curve": line, "at": [10,5]}));
|
||||
assert_eq!(ids(&out["curves"]).len(), 3, "{out}");
|
||||
let sk = sketch(&s);
|
||||
for id in ids(&out["curves"]) {
|
||||
let shape = sk.shape(sk.curve_index(&id).unwrap()).unwrap();
|
||||
assert!(matches!(shape, solvecraft_sketch::Shape::Line { a, b } if (a.y - 5.0).abs() < 1e-6 && (b.y - 5.0).abs() < 1e-6));
|
||||
}
|
||||
|
||||
let mut s = new_sketch();
|
||||
let circle = ids(&run(&mut s, "sketch.circle.center", json!({"center": [0,0], "radius": 5}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.ellipse", json!({"center": [0,0], "major": [8,0], "minor": [0,3]}));
|
||||
run(&mut s, "sketch.trim", json!({"curve": circle, "at": [5,0]}));
|
||||
let sk = sketch(&s);
|
||||
let ci = sk.curve_index(&circle).unwrap();
|
||||
assert!(matches!(sk.curves[ci].kind, CurveKind::Arc { .. }));
|
||||
assert!(sk.shape(ci).unwrap().dist(Vec2::new(-5.0, 0.0)) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_extend_break() {
|
||||
let mut s = new_sketch();
|
||||
@@ -87,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();
|
||||
@@ -219,13 +367,9 @@ fn ellipse_splines_conic() {
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": "q", "b": eid}));
|
||||
let sk = sketch(&s);
|
||||
let q = sk.point(sk.point_index("q").unwrap()).unwrap();
|
||||
let poly = sk.polyline(sk.curve_index(&eid).unwrap());
|
||||
let d = poly.windows(2).map(|w| {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let t = ((q - a).dot(b - a) / (b - a).len2()).clamp(0.0, 1.0);
|
||||
q.dist(a + (b - a) * t)
|
||||
});
|
||||
assert!(d.fold(f64::INFINITY, f64::min) < 1e-6, "{q:?}");
|
||||
let exact = sk.exact_segs(sk.curve_index(&eid).unwrap()).unwrap();
|
||||
let distance = exact.iter().map(|seg| q.dist(seg.point_at(seg.closest_param(q)))).fold(f64::INFINITY, f64::min);
|
||||
assert!(distance < 1e-6, "on the exact ellipse: {q:?}");
|
||||
// Fit spline closed by a line: one profile.
|
||||
let sp = run(&mut s, "sketch.spline.fit_point", json!({"points": [[100, 0], [110, 8], [120, 3], [130, 0]]}));
|
||||
let sid = ids(&sp["curves"])[0].clone();
|
||||
@@ -471,3 +615,214 @@ 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());
|
||||
assert_eq!(sketch(&s).points.len(), 3, "all private derived nodes must be removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periodic_closed_offset_chain_break_drag_reopen_and_material_volume() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities":["e1"],"type":"major","value":10}));
|
||||
let source = sketch(&s).points.clone();
|
||||
let a = run(&mut s, "sketch.offset", json!({"curves":["e1"],"distance":-1,"chain":false}))["curves"][0].as_str().unwrap().to_string();
|
||||
let before_second = s.doc.to_json();
|
||||
let b = run(&mut s, "sketch.offset", json!({"curves":[a],"distance":-0.5,"chain":false}))["curves"][0].as_str().unwrap().to_string();
|
||||
assert_eq!(&sketch(&s).points[..source.len()], source.as_slice());
|
||||
let check = |session: &Session| {
|
||||
let sk = sketch(session);
|
||||
assert_eq!(sk.freeform_offsets.iter().map(|r| r.certificate).collect::<Vec<_>>(), [2, 2]);
|
||||
assert_eq!(solvecraft_sketch::find_profiles(&sk).len(), 3);
|
||||
for id in [&a, &b] {
|
||||
assert!(matches!(sk.curves[sk.curve_index(id).unwrap()].kind, CurveKind::Spline { periodic: true, .. }));
|
||||
}
|
||||
};
|
||||
check(&s);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before_second);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
check(&s);
|
||||
run(&mut s, "parameters.change", json!({"name":"d1","expression":12}));
|
||||
check(&s);
|
||||
let mut reopened =
|
||||
Session { doc: solvecraft_doc::format::read(&s.doc.to_json()).unwrap().into(), active_sketch: s.active_sketch, ..Default::default() };
|
||||
reopened.refresh();
|
||||
check(&reopened);
|
||||
let attached = reopened.doc.to_json();
|
||||
let exact_before = sketch(&reopened).exact_segs(sketch(&reopened).curve_index(&b).unwrap()).unwrap();
|
||||
run(&mut reopened, "sketch.offset.break", json!({"curve":b}));
|
||||
let sk = sketch(&reopened);
|
||||
assert_eq!(sk.exact_segs(sk.curve_index(&b).unwrap()).unwrap(), exact_before);
|
||||
let points = sk.curves[sk.curve_index(&b).unwrap()].kind.point_ids();
|
||||
let first = sk.points[points[0]].id.clone();
|
||||
let from = sk.points[points[0]].pos;
|
||||
run(&mut reopened, "sketch.drag", json!({"entity":first,"from":from,"to":[from.x+0.2,from.y+0.1]}));
|
||||
let moved = sketch(&reopened);
|
||||
moved.validate_periodic_splines().unwrap();
|
||||
let indices = moved.curves[moved.curve_index(&b).unwrap()].kind.point_ids();
|
||||
assert_eq!(moved.point(indices[0]), moved.point(*indices.last().unwrap()));
|
||||
assert!(moved.point(indices[0]).unwrap().dist(from) > 0.1);
|
||||
assert_eq!(solvecraft_doc::format::read(&reopened.doc.to_json()).unwrap().to_json(), reopened.doc.to_json());
|
||||
run(&mut reopened, "edit.undo", json!({}));
|
||||
run(&mut reopened, "edit.undo", json!({}));
|
||||
assert_eq!(reopened.doc.to_json(), attached);
|
||||
// An independent ring area multiplied by height probes actual profile material.
|
||||
let regions = solvecraft_sketch::find_profiles(&sketch(&s));
|
||||
let selected = regions.iter().enumerate().min_by(|(_, x), (_, y)| x.area.total_cmp(&y.area)).unwrap();
|
||||
let n = 4096;
|
||||
let h = std::f64::consts::TAU / n as f64;
|
||||
let perimeter = (0..=n)
|
||||
.map(|i| {
|
||||
let t = i as f64 * h;
|
||||
(144.0 * t.sin().powi(2) + 25.0 * t.cos().powi(2)).sqrt()
|
||||
* if i == 0 || i == n {
|
||||
1.0
|
||||
} else if i % 2 == 0 {
|
||||
2.0
|
||||
} else {
|
||||
4.0
|
||||
}
|
||||
})
|
||||
.sum::<f64>()
|
||||
* h
|
||||
/ 3.0;
|
||||
let analytic_ring = 0.5 * perimeter + 1.25 * std::f64::consts::PI;
|
||||
assert!((selected.1.area - analytic_ring).abs() < 0.03);
|
||||
let expected = analytic_ring * 3.0;
|
||||
let sketch_id = s.active_sketch.unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":sketch_id,"profiles":[selected.0],"distance":3,"operation":"new"}));
|
||||
let state = s.world_state();
|
||||
let volume = solvecraft_kernel::measure(&state.bodies.last().unwrap().body).unwrap().volume;
|
||||
assert!((volume - expected).abs() < 0.05, "{volume} vs {expected}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periodic_fit_creator_keeps_legacy_and_refuses_hostile_intent_atomically() {
|
||||
let mut s = new_sketch();
|
||||
let p = json!({"points":[[0,0],[4,0],[3,2],[-1,1]],"periodic":true});
|
||||
let id = run(&mut s, "sketch.spline.fit_point", p.clone())["curves"][0].as_str().unwrap().to_string();
|
||||
let sk = sketch(&s);
|
||||
let i = sk.curve_index(&id).unwrap();
|
||||
assert!(matches!(sk.curves[i].kind, CurveKind::Spline { periodic: true, .. }));
|
||||
sk.validate_periodic_splines().unwrap();
|
||||
assert_eq!(solvecraft_sketch::find_profiles(&sk).len(), 1);
|
||||
let before = s.doc.to_json();
|
||||
let history = s.undo.len();
|
||||
for (cmd, p) in [
|
||||
("sketch.spline.control_point", p),
|
||||
("sketch.spline.fit_point", json!({"points":[[0,0],[1,0],[0,0],[1,0]],"periodic":true})),
|
||||
("sketch.spline.fit_point", json!({"points":[[0,0],[1,0],[1,1]],"periodic":"yes"})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
}
|
||||
let indices = sk.curves[i].kind.point_ids();
|
||||
let point = sk.points[indices[1]].id.clone();
|
||||
let target = sk.points[indices[0]].pos;
|
||||
let redo = s.redo.len();
|
||||
assert!(s.execute("sketch.drag", &json!({"entity":point,"from":sk.points[indices[1]].pos,"to":target})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
assert_eq!(s.redo.len(), redo);
|
||||
let legacy =
|
||||
run(&mut s, "sketch.spline.fit_point", json!({"points":[[0,0],[4,0],[3,2],[-1,1],[0,0]]}))["curves"][0].as_str().unwrap().to_string();
|
||||
assert!(matches!(sketch(&s).curves[sketch(&s).curve_index(&legacy).unwrap()].kind, CurveKind::Spline { periodic: false, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
}
|
||||
@@ -123,29 +123,78 @@ fn sketch_plane(doc: &Document, id: u64) -> Result<Plane> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep candidate feature edits while overlaying uncommitted occurrence placements.
|
||||
fn placed_candidate<'a>(s: &Session, doc: &'a Document) -> std::borrow::Cow<'a, Document> {
|
||||
if s.pending_moves.is_empty() {
|
||||
return std::borrow::Cow::Borrowed(doc);
|
||||
}
|
||||
let mut placed = doc.clone();
|
||||
for o in &mut placed.occurrences {
|
||||
if let Some(m) = s.pending_moves.get(&o.id) {
|
||||
o.transform = *m;
|
||||
}
|
||||
}
|
||||
std::borrow::Cow::Owned(placed)
|
||||
}
|
||||
|
||||
fn owner_frame(s: &Session, doc: &Document, comp: u64) -> solvecraft_doc::Mat {
|
||||
if comp == s.active_component { s.active_component_frame() } else { doc.component_transform(comp) }
|
||||
}
|
||||
|
||||
/// Re-resolve the links of a sketch being edited against the model before it, so edits see
|
||||
/// current reference geometry. Returns warnings.
|
||||
pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) -> Vec<String> {
|
||||
if sk.links.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Ok(plane) = sketch_plane(doc, id) else { return Vec::new() };
|
||||
let doc = placed_candidate(s, doc);
|
||||
let Ok(plane) = sketch_plane(&doc, id) else { return Vec::new() };
|
||||
let (vals, _) = doc.param_values();
|
||||
let st = s.model.state_before(id);
|
||||
solvecraft_doc::project::refresh_links(doc, &vals, &st, &plane, sk)
|
||||
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||||
let attachment = doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
solvecraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
|
||||
_ => None,
|
||||
});
|
||||
let st = match attachment {
|
||||
Some(anchor) => solvecraft_doc::state_in_face_anchor_frame(&doc, &st, anchor, comp),
|
||||
None => Ok(solvecraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp))),
|
||||
};
|
||||
let Ok(st) = st else {
|
||||
return vec!["lost face attachment; reattach explicitly".into()];
|
||||
};
|
||||
match attachment {
|
||||
Some(anchor) => solvecraft_doc::project::refresh_face_anchor_links(&doc, &vals, &st, &plane, sk, anchor),
|
||||
None => solvecraft_doc::project::refresh_links(&doc, &vals, &st, &plane, sk),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a link for `src` to sketch `id` in `doc` (not stored in the session). Returns the link id.
|
||||
pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, kind: LinkKind, src: LinkSource) -> Result<String> {
|
||||
let plane = sketch_plane(doc, id)?;
|
||||
let doc = placed_candidate(s, doc);
|
||||
let plane = sketch_plane(&doc, id)?;
|
||||
let (vals, _) = doc.param_values();
|
||||
let st = s.model.state_before(id);
|
||||
// The model as the sketch's component sees it.
|
||||
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||||
let st = solvecraft_doc::state_in_frame(doc, &st, comp);
|
||||
let r = resolve(doc, &vals, &st, &plane, kind, &src)?;
|
||||
let name = if let LinkSource::Face { at, .. } = &r.source {
|
||||
solvecraft_doc::naming::face_names_at(&st, &[*at]).into_iter().next().filter(|n| !n.is_empty())
|
||||
let attachment = doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
solvecraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
|
||||
_ => None,
|
||||
});
|
||||
let st = match attachment {
|
||||
Some(anchor) => solvecraft_doc::state_in_face_anchor_frame(&doc, &st, anchor, comp),
|
||||
None => Ok(solvecraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp))),
|
||||
};
|
||||
let st = st?;
|
||||
let r = resolve(&doc, &vals, &st, &plane, kind, &src)?;
|
||||
let name = if let LinkSource::Face { body, at } = &r.source {
|
||||
st.body(body).and_then(|body| {
|
||||
if let Some(index) = r.face_index {
|
||||
solvecraft_doc::naming::face_names(body).get(index).filter(|name| !name.is_empty()).cloned()
|
||||
} else {
|
||||
solvecraft_doc::naming::face_name_at_body(body, *at)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -433,17 +482,53 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Project the edges of the face a new sketch was created on (Fusion's default).
|
||||
pub(super) fn auto_project_face(s: &mut Session, id: u64, at: Vec3) -> Result<Option<String>> {
|
||||
/// Private compatibility projection for generated face profiles. Named legacy links follow
|
||||
/// edits, but cannot provide the construction certificate required by public attachments.
|
||||
pub(super) fn project_internal_face(s: &mut Session, id: u64, body: &str, index: usize) -> Result<String> {
|
||||
let mut doc = (*s.doc).clone();
|
||||
let plane = sketch_plane(&doc, id)?;
|
||||
let raw = s.model.state_before(id);
|
||||
let component = doc.feature(id).map(|feature| feature.component).unwrap_or(0);
|
||||
let state = solvecraft_doc::state_in_frame_at(&doc, &raw, component, owner_frame(s, &doc, component));
|
||||
let resolved = solvecraft_doc::project::resolve_face_indices(&state, &plane, LinkKind::Project, body, &[index])?;
|
||||
let face_name = state.body(body).and_then(|body| solvecraft_doc::naming::face_names(body).get(index).cloned());
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
let l = match add_link(s, &doc, id, &mut sk, LinkKind::Project, LinkSource::Face { body: String::new(), at }) {
|
||||
Ok(l) => l,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
let link = sk.add_link_with_wires(LinkKind::Project, resolved.source, &resolved.geom, &resolved.wires)?;
|
||||
if let Some(entry) = sk.links.iter_mut().find(|entry| entry.id == link) {
|
||||
entry.face_name = face_name;
|
||||
}
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
Ok(Some(l))
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
/// Project the edges of the face a new sketch was created on (Fusion's default).
|
||||
pub(super) fn auto_project_face(s: &mut Session, id: u64, _at: Vec3) -> Result<Option<String>> {
|
||||
let mut doc = (*s.doc).clone();
|
||||
let Some(anchor) = doc
|
||||
.feature(id)
|
||||
.and_then(|feature| match &feature.kind {
|
||||
solvecraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
|
||||
_ => None,
|
||||
})
|
||||
.cloned()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let raw = s.model.state_before(id);
|
||||
let author = doc.feature(id).map(|feature| feature.component).unwrap_or(0);
|
||||
let state = solvecraft_doc::state_in_face_anchor_frame(&doc, &raw, &anchor, author)?;
|
||||
let plane = doc.resolve_face_anchor(&anchor, author, &raw)?;
|
||||
let resolved = solvecraft_doc::project::resolve_anchored_face(&doc, &state, &plane, LinkKind::Project, &anchor)?;
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
let link = sk.add_link_with_wires(LinkKind::Project, resolved.source, &resolved.geom, &resolved.wires)?;
|
||||
if let Some(link) = sk.links.iter_mut().find(|entry| entry.id == link) {
|
||||
link.face_name = Some(anchor.target.name.clone());
|
||||
link.auto_face = true;
|
||||
}
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
Ok(Some(link))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -13,6 +13,68 @@ fn ids(v: &Value) -> Vec<String> {
|
||||
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_projection_captures_the_requested_coincident_body_producer() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Control","name":"ControlFeature"}));
|
||||
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"body_name":"Target","name":"TargetFeature"}));
|
||||
let producer = s.doc.find_feature("TargetFeature").unwrap().id;
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":"XY","offset":10}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.project", json!({"refs":[{"body":"Target","face":[1,1,10]}]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let initial_state = s.model.state();
|
||||
let initial = &initial_state.sketch(id).unwrap().sketch.links[0];
|
||||
assert_eq!(initial.face_name, Some(format!("F{producer}:+z")));
|
||||
assert!(!initial.lost);
|
||||
let height = s.doc.find_feature("TargetFeature").unwrap().param_names[2].clone();
|
||||
run(&mut s, "parameters.change", json!({"name":height,"expression":"20 mm"}));
|
||||
let verify = |session: &Session| {
|
||||
let state = session.model.state();
|
||||
let sk = &state.sketch(id).unwrap().sketch;
|
||||
assert!(!sk.links[0].lost);
|
||||
assert_eq!(sk.links[0].face_name, Some(format!("F{producer}:+z")));
|
||||
assert!(matches!(&sk.links[0].source,solvecraft_sketch::LinkSource::Face{body,at} if body=="Target" && (at.z-20.).abs()<1e-6));
|
||||
assert!(sk.points.iter().any(|point| point.pos.x > 19.));
|
||||
assert!(sk.points.iter().any(|point| point.pos.y > 19.));
|
||||
assert!((session.world_state().body("Control").unwrap().mesh().bounds().max.z - 10.).abs() < 1e-6);
|
||||
};
|
||||
verify(&s);
|
||||
verify(&Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_projection_retains_selected_face_when_closest_point_lands_on_shared_edge() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"body_name":"Target"}));
|
||||
let state = s.model.state();
|
||||
let body = state.body("Target").unwrap();
|
||||
let source = solvecraft_sketch::LinkSource::Face { body: "Target".into(), at: solvecraft_geom::Vec3::new(-0.1, 5., 10.5) };
|
||||
let resolved = solvecraft_doc::project::resolve(
|
||||
&s.doc,
|
||||
&std::collections::BTreeMap::new(),
|
||||
&state,
|
||||
&solvecraft_geom::Plane::XY,
|
||||
solvecraft_sketch::LinkKind::Project,
|
||||
&source,
|
||||
)
|
||||
.unwrap();
|
||||
let index = resolved.face_index.unwrap();
|
||||
let expected = solvecraft_doc::naming::face_names(body)[index].clone();
|
||||
let solvecraft_sketch::LinkSource::Face { at, .. } = resolved.source else { panic!("not face") };
|
||||
assert!(at.x.abs() < 1e-6 && (at.z - 10.).abs() < 1e-6, "closestpoint must be on shared top/wall edge: {at:?}");
|
||||
let plane = body.body.face_plane(index).unwrap();
|
||||
let id = run(&mut s, "sketch.create", json!({"plane":{"origin":plane.origin,"x_dir":plane.x,"y_dir":plane.y}}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.project", json!({"refs":[{"body":"Target","face":[-0.1,5.,10.5]}]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let current = s.model.state();
|
||||
let sk = ¤t.sketch(id).unwrap().sketch;
|
||||
assert_eq!(sk.links[0].face_name, Some(expected));
|
||||
assert!(!sk.links[0].lost);
|
||||
assert_eq!(sk.curves.len(), 4);
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert_eq!(reopened.model.state().sketch(id).unwrap().sketch.curves, sk.curves);
|
||||
}
|
||||
|
||||
/// A 40 x 30 x 20 box from a sketch whose width is the user parameter `width`.
|
||||
fn box_part(s: &mut Session) {
|
||||
run(s, "parameters.change", json!({"name": "width", "expression": "40 mm"}));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+81
-31
@@ -26,6 +26,8 @@ enum K {
|
||||
Make,
|
||||
/// A direction: turned with the command's frame.
|
||||
Dir,
|
||||
/// An arbitrary axis object in the authoring frame: {origin, dir}.
|
||||
Axis,
|
||||
/// A plane object (`{face}`, `{origin, normal}`, `{origin, x_dir, y_dir}`) or a plane name.
|
||||
Plane,
|
||||
/// A body name, or a list of them.
|
||||
@@ -43,7 +45,8 @@ use K::*;
|
||||
/// The parameters each command takes in world coordinates.
|
||||
fn table(id: &str) -> &'static [(&'static str, K)] {
|
||||
match id {
|
||||
"solid.extrude" | "solid.revolve" => &[("face", Pick), ("targets", Body), ("participants", Body)],
|
||||
"solid.extrude" => &[("face", Pick), ("from", Make), ("to", Make), ("targets", Body), ("participants", Body)],
|
||||
"solid.revolve" => &[("face", Pick), ("axis", Axis), ("to", Make), ("targets", Body), ("participants", Body)],
|
||||
"solid.box" | "solid.sphere" => &[("corner", Make), ("center", Make)],
|
||||
"solid.cylinder" | "solid.coil" | "solid.torus" => &[("base", Make), ("center", Make), ("axis", Dir)],
|
||||
"solid.fillet" | "solid.chamfer" => &[("edges", Picks), ("body", Body)],
|
||||
@@ -51,6 +54,7 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
|
||||
"solid.scale" => &[("origin", Make), ("bodies", Body)],
|
||||
"solid.offset_face" | "solid.shell" => &[("faces", Picks), ("body", Body)],
|
||||
"solid.thread" => &[("face", Pick)],
|
||||
"solid.emboss" => &[("face", Make)],
|
||||
"solid.draft" => &[("faces", Picks), ("neutral", Plane), ("pull", Dir), ("body", Body)],
|
||||
"solid.press_pull" => &[("face", Pick), ("edges", Picks)],
|
||||
"solid.replace_face" => &[("faces", Picks), ("target_face", Pick), ("target", Plane), ("body", Body)],
|
||||
@@ -88,38 +92,20 @@ struct Frames {
|
||||
|
||||
fn frames(s: &Session) -> Frames {
|
||||
let st = s.model.state();
|
||||
let doc: std::borrow::Cow<solvecraft_doc::Document> = if s.pending_moves.is_empty() {
|
||||
std::borrow::Cow::Borrowed(&*s.doc)
|
||||
} else {
|
||||
let mut d = (*s.doc).clone();
|
||||
for o in &mut d.occurrences {
|
||||
if let Some(m) = s.pending_moves.get(&o.id) {
|
||||
o.transform = *m;
|
||||
}
|
||||
}
|
||||
std::borrow::Cow::Owned(d)
|
||||
};
|
||||
let doc = s.placed_document();
|
||||
// World name, the component's own body name, and its frame.
|
||||
let bodies = doc
|
||||
.body_frames(&st)
|
||||
.into_iter()
|
||||
.map(|(world, _, _, m)| {
|
||||
let own = st
|
||||
.bodies
|
||||
.iter()
|
||||
.find(|b| world == b.name || world.starts_with(&format!("{} (", b.name)))
|
||||
.map(|b| b.name.clone())
|
||||
.unwrap_or_else(|| world.clone());
|
||||
(world, own, m)
|
||||
})
|
||||
.collect();
|
||||
let bodies = doc.body_frames_with_source(&st).into_iter().map(|(world, own, _, _, m)| (world, own, m)).collect();
|
||||
let sketch_comp = s.active_sketch.and_then(|id| doc.feature(id)).map(|f| f.component).unwrap_or(s.active_component);
|
||||
Frames { bodies, active: doc.component_transform(s.active_component), sketch: doc.component_transform(sketch_comp) }
|
||||
let active = s.active_component_frame();
|
||||
Frames { bodies, active, sketch: if sketch_comp == s.active_component { active } else { doc.component_transform(sketch_comp) } }
|
||||
}
|
||||
|
||||
/// Does any placement move anything (so the world differs from the components' frames)?
|
||||
/// Does world geometry or its instance naming differ from source components?
|
||||
pub fn placed(s: &Session) -> bool {
|
||||
!s.pending_moves.is_empty() || !s.doc.body_offsets.is_empty() || s.doc.placements().iter().any(|(_, _, m)| !is_identity(m))
|
||||
let mut components = std::collections::BTreeSet::new();
|
||||
!s.pending_moves.is_empty()
|
||||
|| !s.doc.body_offsets.is_empty()
|
||||
|| s.doc.occurrences.iter().any(|o| !is_identity(&o.transform) || !components.insert(o.component))
|
||||
}
|
||||
|
||||
fn vec3(v: &Value) -> Option<Vec3> {
|
||||
@@ -138,7 +124,7 @@ fn pt(p: Vec3) -> Value {
|
||||
/// The frame of the body a world point lies on (the nearest), or of the named body.
|
||||
fn frame_at(s: &Session, f: &Frames, p: Vec3, body: Option<&str>) -> Option<Mat> {
|
||||
if let Some(b) = body
|
||||
&& let Some((_, _, m)) = f.bodies.iter().find(|(w, own, _)| w == b || (own == b && !w.contains(" (")))
|
||||
&& let Some((_, _, m)) = f.bodies.iter().find(|(w, _, _)| w == b)
|
||||
{
|
||||
return Some(*m);
|
||||
}
|
||||
@@ -345,17 +331,63 @@ impl Drop for Nested {
|
||||
pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
|
||||
if id == "timeline.redefine" {
|
||||
let cmd = params.get("command")?.as_str()?;
|
||||
let inner = to_local(s, cmd, params.get("params")?)?;
|
||||
let key = params.get("feature").and_then(|v| v.as_str().map(str::to_string).or_else(|| v.as_u64().map(|id| id.to_string())))?;
|
||||
let feature = s.doc.find_feature(&key)?;
|
||||
// Edit Feature authors in the existing feature's frame, even when another
|
||||
// component is active. Body-relative picks still use the picked body's frame.
|
||||
let mut context = s.scratch();
|
||||
context.active_component = feature.component;
|
||||
if let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &feature.kind {
|
||||
context.active_occurrence_path = (!face.author_path.is_empty()).then(|| face.author_path.clone());
|
||||
}
|
||||
let mut inner = to_local(&context, cmd, params.get("params")?)?;
|
||||
// An untouched explicit axis is displayed in world coordinates by Edit
|
||||
// Feature. Keep its exact saved values instead of an affine round trip.
|
||||
if cmd == "solid.revolve"
|
||||
&& let solvecraft_doc::FeatureKind::Revolve { axis: solvecraft_doc::AxisRef::Line { origin, dir }, .. } = &feature.kind
|
||||
{
|
||||
let frame = context.active_component_frame();
|
||||
let displayed = serde_json::json!({"origin":pt(apply_point(&frame,*origin)),"dir":pt(apply_vector(&frame,*dir))});
|
||||
if params.get("params").and_then(|p| p.get("axis")) == Some(&displayed) {
|
||||
inner["axis"] = serde_json::json!({"origin":pt(*origin),"dir":pt(*dir)});
|
||||
}
|
||||
}
|
||||
let points = match (&feature.kind, cmd) {
|
||||
(solvecraft_doc::FeatureKind::Revolve { to, .. }, "solid.revolve") => [("to", *to), ("from", None)],
|
||||
(solvecraft_doc::FeatureKind::Extrude { extent, .. }, "solid.extrude") => [("to", extent.to), ("from", extent.from)],
|
||||
_ => [("to", None), ("from", None)],
|
||||
};
|
||||
for (key, point) in points {
|
||||
if let Some(point) = point {
|
||||
let displayed = pt(apply_point(&context.active_component_frame(), point));
|
||||
if params.get("params").and_then(|p| p.get(key)) == Some(&displayed) {
|
||||
inner[key] = pt(point);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = params.clone();
|
||||
out["params"] = inner;
|
||||
return Some(out);
|
||||
}
|
||||
// Exact face capture retains the original world pick and occurrence identity.
|
||||
if matches!(id, "sketch.create" | "sketch.redefine") && params.get("plane").and_then(|plane| plane.get("face")).is_some() {
|
||||
return None;
|
||||
}
|
||||
let keys = table(id);
|
||||
if keys.is_empty() || !placed(s) {
|
||||
return None;
|
||||
}
|
||||
let obj = params.as_object()?;
|
||||
let mut m = Mapper { s, f: frames(s), first: None, changed: false, here: false };
|
||||
let mut command_frames = frames(s);
|
||||
if id == "sketch.redefine" {
|
||||
let key = params.get("sketch").and_then(|value| value.as_str().map(str::to_string).or_else(|| value.as_u64().map(|id| id.to_string())))?;
|
||||
let feature = s.doc.find_feature(&key)?;
|
||||
let path = crate::cmd::author_path_for_rebind(s, feature, params, id).ok()?;
|
||||
let frame = solvecraft_doc::EmbossFace::component_frame(&s.placed_document(), feature.component, &path).ok()?;
|
||||
command_frames.active = frame;
|
||||
command_frames.sketch = frame;
|
||||
}
|
||||
let mut m = Mapper { s, f: command_frames, first: None, changed: false, here: false };
|
||||
let body = obj.get("body").and_then(Value::as_str).map(str::to_string);
|
||||
let mut out: Map<String, Value> = obj.clone();
|
||||
// Picks first: their frame turns the directions.
|
||||
@@ -371,6 +403,24 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
|
||||
Picks => m.picks(v, body.as_deref()),
|
||||
Make => m.make(v),
|
||||
Dir => m.dir(v),
|
||||
Axis => {
|
||||
if let Some(axis) = v.as_object() {
|
||||
let mut axis = axis.clone();
|
||||
if let Some(origin) = axis.get("origin").and_then(|o| m.make(o)) {
|
||||
axis.insert("origin".into(), origin);
|
||||
}
|
||||
if let Some(dir) = axis.get("dir").and_then(vec3)
|
||||
&& !is_identity(&m.f.active)
|
||||
&& let Some(inv) = mat_inverse(&m.f.active)
|
||||
{
|
||||
axis.insert("dir".into(), pt(apply_vector(&inv, dir)));
|
||||
m.changed = true;
|
||||
}
|
||||
Some(Value::Object(axis))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Plane => m.plane(v),
|
||||
Body => m.body(v),
|
||||
Here | HerePlane => {
|
||||
|
||||
@@ -75,12 +75,17 @@ fn world_picks_map_into_the_component() {
|
||||
// The edge along X at the top front: world (65, 0, 10).
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[65, 0, 10]], "radius": 2}));
|
||||
assert!(volume(&mut s) < v0 - 1.0, "the fillet cut material");
|
||||
// A sketch on the moved top face sits at world z = 10 over the moved block.
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [70, 10, 10]}, "name": "Pad"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [20, 10], "radius": 2}));
|
||||
// The modified face has no certified lineage; explicitly freeze its picked plane.
|
||||
// Its world pick still maps into the moved component exactly once.
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [70, 10, 10],"fixed":true}, "name": "Pad"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [20, 10], "radius": 2, "id": "PadCircle"}));
|
||||
let sketch = run(&mut s, "sketch.inspect", json!({"sketch": "Pad"}));
|
||||
let center = sketch["curves"].as_array().unwrap().iter().find(|c| c["id"] == "PadCircle").unwrap()["center"].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let pad = sketch_point_world(&mut s, "Pad", "p1");
|
||||
assert!((pad.z - 10.0).abs() < 1e-6 && pad.x > 50.0, "{pad:?}");
|
||||
// A projected boundary's first point is an enumeration detail; check the circle
|
||||
// that was actually authored in the moved component's local sketch coordinates.
|
||||
let pad = sketch_point_world(&mut s, "Pad", ¢er);
|
||||
assert!(near(pad, Vec3::new(70.0, 10.0, 10.0)), "{pad:?}");
|
||||
let v1 = volume(&mut s);
|
||||
let h = run(&mut s, "solid.hole", json!({"position": [60, 10, 10], "diameter": 4, "depth": 5}));
|
||||
assert!(volume(&mut s) < v1 - 1.0, "the hole cut material: {h}");
|
||||
|
||||
+76
-10
@@ -12,6 +12,7 @@ pub mod frames;
|
||||
pub mod fuzz;
|
||||
pub mod legacy_ids;
|
||||
pub mod licences;
|
||||
pub mod occurrences;
|
||||
pub mod params;
|
||||
pub mod recovery;
|
||||
pub mod sample;
|
||||
@@ -74,6 +75,10 @@ pub type Result<T> = std::result::Result<T, EngineError>;
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Sel {
|
||||
/// An exact placement, including its containing occurrence placements.
|
||||
Occurrence {
|
||||
path: Vec<u64>,
|
||||
},
|
||||
Body {
|
||||
name: String,
|
||||
},
|
||||
@@ -128,6 +133,7 @@ pub struct Snapshot {
|
||||
pub label: String,
|
||||
pub doc: Arc<Document>,
|
||||
pub active_sketch: Option<u64>,
|
||||
pub pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
|
||||
}
|
||||
|
||||
/// What some commands would make, evaluated on a scratch copy of a session
|
||||
@@ -159,9 +165,12 @@ pub struct Session {
|
||||
pub auto_project: bool,
|
||||
/// The component new sketches and features go into (0 = the root).
|
||||
pub active_component: u64,
|
||||
/// The placement used when authoring shared component geometry.
|
||||
pub active_occurrence_path: Option<Vec<u64>>,
|
||||
/// Occurrence moves not captured yet (Capture Position keeps them, a recompute drops them).
|
||||
pub pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
|
||||
world_cache: std::sync::Mutex<Option<(u64, Arc<solvecraft_doc::ModelState>)>>,
|
||||
occurrence_visibility_cache: std::sync::Mutex<Option<occurrences::VisibilityCache>>,
|
||||
/// Features copied with `timeline.copy` (pasted by `timeline.paste`).
|
||||
pub clipboard: Vec<solvecraft_doc::Feature>,
|
||||
/// Section Analysis: a view cut by a plane (origin, unit normal; the normal side is hidden).
|
||||
@@ -176,12 +185,13 @@ pub struct Session {
|
||||
next_checkpoint: u64,
|
||||
}
|
||||
|
||||
/// The design, active sketch and undo history at an `edit.checkpoint`.
|
||||
/// The design, active sketch, pending positions and undo history at an `edit.checkpoint`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Checkpoint {
|
||||
pub id: u64,
|
||||
pub doc: Arc<Document>,
|
||||
pub active_sketch: Option<u64>,
|
||||
pub pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
|
||||
pub undo: Vec<Snapshot>,
|
||||
pub redo: Vec<Snapshot>,
|
||||
}
|
||||
@@ -192,6 +202,8 @@ pub(crate) struct Checkpoint {
|
||||
pub struct Visibility {
|
||||
/// Bodies by name.
|
||||
pub hidden_bodies: Vec<String>,
|
||||
/// Exact occurrence placements hidden with all descendants.
|
||||
pub hidden_occurrences: Vec<Vec<u64>>,
|
||||
/// Origin items (O, X, Y, Z, XY, XZ, YZ) and construction geometry, by name.
|
||||
pub hidden_origin: Vec<String>,
|
||||
/// Sketches by feature id.
|
||||
@@ -246,8 +258,10 @@ impl Session {
|
||||
revision: 1,
|
||||
auto_project: true,
|
||||
active_component: 0,
|
||||
active_occurrence_path: None,
|
||||
pending_moves: Default::default(),
|
||||
world_cache: Default::default(),
|
||||
occurrence_visibility_cache: Default::default(),
|
||||
clipboard: Vec::new(),
|
||||
section: None,
|
||||
analysis: None,
|
||||
@@ -295,7 +309,7 @@ impl Session {
|
||||
{
|
||||
return w.clone();
|
||||
}
|
||||
let w = if self.pending_moves.is_empty() {
|
||||
let mut w = if self.pending_moves.is_empty() {
|
||||
solvecraft_doc::world_state(&self.doc, &st)
|
||||
} else {
|
||||
let mut d = (*self.doc).clone();
|
||||
@@ -306,6 +320,30 @@ impl Session {
|
||||
}
|
||||
solvecraft_doc::world_state(&d, &st)
|
||||
};
|
||||
// Sketch definitions are displayed once, at the placement currently being authored.
|
||||
if self.active_occurrence_path.is_some() && self.active_component != 0 {
|
||||
let first = self.occurrence_placement(&self.doc.first_path(self.active_component)).map(|p| p.world).unwrap_or(solvecraft_doc::IDENTITY);
|
||||
if let Some(inverse) = solvecraft_doc::mat_inverse(&first) {
|
||||
let delta = solvecraft_doc::mat_mul(&self.active_component_frame(), &inverse);
|
||||
for sketch in w.sketches.iter_mut().filter(|sk| self.doc.feature(sk.feature).is_some_and(|f| f.component == self.active_component)) {
|
||||
let plane = sketch.plane;
|
||||
if let Some(mapped) = solvecraft_geom::Plane::new(
|
||||
solvecraft_doc::apply_point(&delta, plane.origin),
|
||||
solvecraft_doc::apply_vector(&delta, plane.x),
|
||||
solvecraft_doc::apply_vector(&delta, plane.y),
|
||||
) {
|
||||
sketch.plane = mapped;
|
||||
}
|
||||
for wire in &mut sketch.sketch.wires {
|
||||
if wire.space == solvecraft_doc::sketch::WireSpace::Component {
|
||||
for point in wire.pts.iter_mut().chain(&mut wire.fit) {
|
||||
*point = solvecraft_doc::apply_point(&delta, *point);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let w = Arc::new(w);
|
||||
if let Ok(mut c) = self.world_cache.lock() {
|
||||
*c = Some((self.revision, w.clone()));
|
||||
@@ -316,6 +354,7 @@ impl Session {
|
||||
pub fn refresh(&mut self) {
|
||||
self.model.evaluate(&self.doc);
|
||||
self.revision += 1;
|
||||
self.prune_occurrence_state();
|
||||
// Joints follow the geometry their origins snap to.
|
||||
if !self.doc.assembly.joints.is_empty() {
|
||||
cmd::joints::resolve(self);
|
||||
@@ -332,7 +371,12 @@ impl Session {
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
(spec.enabled)(self).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
|
||||
let before = Snapshot { label: spec.label.to_string(), doc: self.doc.clone(), active_sketch: self.active_sketch };
|
||||
let before = Snapshot {
|
||||
label: spec.label.to_string(),
|
||||
doc: self.doc.clone(),
|
||||
active_sketch: self.active_sketch,
|
||||
pending_moves: self.pending_moves.clone(),
|
||||
};
|
||||
let null = Value::Object(Default::default());
|
||||
let params = if params.is_null() { &null } else { params };
|
||||
// Picks in the world go into their component's frame (once: commands run by commands
|
||||
@@ -364,9 +408,10 @@ impl Session {
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
if changed {
|
||||
if changed || self.pending_moves != before.pending_moves {
|
||||
self.doc = before.doc;
|
||||
self.active_sketch = before.active_sketch;
|
||||
self.pending_moves = before.pending_moves;
|
||||
self.refresh();
|
||||
}
|
||||
Err(e)
|
||||
@@ -374,7 +419,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember the design, active sketch and undo history; returns the checkpoint's id.
|
||||
/// Remember the design, active sketch, pending positions and undo history; returns the checkpoint's id.
|
||||
pub fn checkpoint(&mut self) -> u64 {
|
||||
let id = self.next_checkpoint;
|
||||
self.next_checkpoint += 1;
|
||||
@@ -383,6 +428,7 @@ impl Session {
|
||||
doc: self.doc.clone(),
|
||||
active_sketch: self.active_sketch,
|
||||
undo: self.undo.clone(),
|
||||
pending_moves: self.pending_moves.clone(),
|
||||
redo: self.redo.clone(),
|
||||
});
|
||||
if self.checkpoints.len() > MAX_CHECKPOINTS {
|
||||
@@ -398,6 +444,7 @@ impl Session {
|
||||
let Some(c) = self.checkpoints.drain(i..).next() else { return Err(EngineError::Other(format!("no checkpoint {id}"))) };
|
||||
self.doc = c.doc;
|
||||
self.active_sketch = c.active_sketch;
|
||||
self.pending_moves = c.pending_moves;
|
||||
self.undo = c.undo;
|
||||
self.redo = c.redo;
|
||||
self.selection.clear();
|
||||
@@ -421,21 +468,23 @@ impl Session {
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
active_sketch: self.active_sketch,
|
||||
selection: Vec::new(),
|
||||
selection: self.selection.clone(),
|
||||
path: None,
|
||||
saved: self.saved.clone(),
|
||||
log: Vec::new(),
|
||||
revision: self.revision,
|
||||
auto_project: self.auto_project,
|
||||
active_component: self.active_component,
|
||||
active_occurrence_path: self.active_occurrence_path.clone(),
|
||||
pending_moves: self.pending_moves.clone(),
|
||||
world_cache: Default::default(),
|
||||
occurrence_visibility_cache: Default::default(),
|
||||
clipboard: self.clipboard.clone(),
|
||||
section: None,
|
||||
analysis: None,
|
||||
checkpoints: Vec::new(),
|
||||
next_checkpoint: 1,
|
||||
visibility: Visibility::default(),
|
||||
visibility: self.visibility.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,6 +518,7 @@ impl Session {
|
||||
self.revision = computed.revision;
|
||||
self.invalidate_view(previous_revision);
|
||||
self.active_component = computed.active_component;
|
||||
self.active_occurrence_path = computed.active_occurrence_path;
|
||||
self.pending_moves = computed.pending_moves;
|
||||
for message in computed.log {
|
||||
self.echo(message);
|
||||
@@ -480,10 +530,26 @@ impl Session {
|
||||
/// A `timeline.redefine` is shown even while the timeline is rolled back to the feature
|
||||
/// being edited.
|
||||
pub fn preview(&self, commands: &[(String, Value)]) -> Result<Preview> {
|
||||
self.scratch().preview_in_place(commands).map(|after| Preview { before: self.model.state(), after })
|
||||
self.scratch().preview_in_place(commands).map(|after| Preview { before: self.model.state(), after: after.model.state() })
|
||||
}
|
||||
|
||||
fn preview_in_place(mut self, commands: &[(String, Value)]) -> Result<Arc<ModelState>> {
|
||||
/// The same evaluated preview in occurrence coordinates, including uncaptured moves.
|
||||
pub fn preview_world(&self, commands: &[(String, Value)]) -> Result<Preview> {
|
||||
self.scratch().preview_in_place(commands).map(|after| Preview { before: self.world_state(), after: after.world_state() })
|
||||
}
|
||||
|
||||
/// World preview with each session's visibility applied, including new body placements.
|
||||
pub fn preview_visible_world(&self, commands: &[(String, Value)]) -> Result<Preview> {
|
||||
let visible = |session: &Session| {
|
||||
let world = session.world_state();
|
||||
let mut shown = (*world).clone();
|
||||
shown.bodies.retain(|body| session.body_visible(&body.name));
|
||||
Arc::new(shown)
|
||||
};
|
||||
self.scratch().preview_in_place(commands).map(|after| Preview { before: visible(self), after: visible(&after) })
|
||||
}
|
||||
|
||||
fn preview_in_place(mut self, commands: &[(String, Value)]) -> Result<Self> {
|
||||
if commands.is_empty() {
|
||||
return Err(EngineError::Other("nothing to preview".into()));
|
||||
}
|
||||
@@ -501,7 +567,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(self.model.state())
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Measure one or two selections (see `inspect.measure` with `items`); errors come back as
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Exact placement identity and session view/authoring state.
|
||||
use crate::{EngineError, Result, Sel, Session};
|
||||
use solvecraft_doc::{IDENTITY, Mat, mat_mul};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OccurrenceBody {
|
||||
pub source: String,
|
||||
pub world: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OccurrencePlacement {
|
||||
pub occurrence: u64,
|
||||
pub component: u64,
|
||||
pub path: Vec<u64>,
|
||||
pub name: String,
|
||||
pub world: Mat,
|
||||
pub local: Mat,
|
||||
pub parent_world: Mat,
|
||||
pub grounded: bool,
|
||||
pub bodies: Vec<OccurrenceBody>,
|
||||
}
|
||||
|
||||
fn invalid(message: &str) -> EngineError {
|
||||
EngineError::Other(message.to_string())
|
||||
}
|
||||
|
||||
pub(crate) struct VisibilityCache {
|
||||
revision: u64,
|
||||
paths: Vec<Vec<u64>>,
|
||||
bodies: std::collections::BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Document with uncaptured occurrence positions applied, without changing saved geometry.
|
||||
pub fn placed_document(&self) -> std::borrow::Cow<'_, solvecraft_doc::Document> {
|
||||
if self.pending_moves.is_empty() {
|
||||
return std::borrow::Cow::Borrowed(&self.doc);
|
||||
}
|
||||
let mut doc = (*self.doc).clone();
|
||||
for o in &mut doc.occurrences {
|
||||
if let Some(transform) = self.pending_moves.get(&o.id) {
|
||||
o.transform = *transform;
|
||||
}
|
||||
}
|
||||
std::borrow::Cow::Owned(doc)
|
||||
}
|
||||
/// Resolve only the supplied path, never an exponentially expanded assembly.
|
||||
pub fn occurrence_placement(&self, path: &[u64]) -> Result<OccurrencePlacement> {
|
||||
if path.is_empty() || path.len() > 64 {
|
||||
return Err(invalid("occurrence path must contain 1..64 ids"));
|
||||
}
|
||||
let mut component = 0;
|
||||
let mut world = IDENTITY;
|
||||
let mut parent_world = IDENTITY;
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for id in path {
|
||||
if !seen.insert(*id) {
|
||||
return Err(invalid("occurrence path repeats an id"));
|
||||
}
|
||||
let o = self
|
||||
.doc
|
||||
.occurrences
|
||||
.iter()
|
||||
.find(|o| o.id == *id && o.parent == component)
|
||||
.ok_or_else(|| invalid("occurrence path has a missing or mismatched parent"))?;
|
||||
if !self.doc.components.iter().any(|c| c.id == o.component) {
|
||||
return Err(invalid("occurrence path has a missing component"));
|
||||
}
|
||||
parent_world = world;
|
||||
world = mat_mul(&world, self.pending_moves.get(id).unwrap_or(&o.transform));
|
||||
if world.iter().flatten().any(|x| !x.is_finite()) {
|
||||
return Err(invalid("occurrence path has a non-finite transform"));
|
||||
}
|
||||
component = o.component;
|
||||
}
|
||||
let leaf = self.doc.occurrences.iter().find(|o| Some(&o.id) == path.last()).ok_or_else(|| invalid("occurrence path has no leaf"))?;
|
||||
let state = self.model.state();
|
||||
let bodies = state
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| self.doc.model_body_component(b) == component)
|
||||
.map(|b| OccurrenceBody { source: b.name.clone(), world: self.doc.world_body_name(&state, &b.name, component, path) })
|
||||
.collect();
|
||||
Ok(OccurrencePlacement {
|
||||
occurrence: leaf.id,
|
||||
component,
|
||||
path: path.to_vec(),
|
||||
name: leaf.name.clone(),
|
||||
world,
|
||||
local: self.pending_moves.get(&leaf.id).copied().unwrap_or(leaf.transform),
|
||||
parent_world,
|
||||
grounded: leaf.grounded,
|
||||
bodies,
|
||||
})
|
||||
}
|
||||
|
||||
/// Children are definitions shared by every placement of the parent component.
|
||||
pub fn occurrence_children(&self, path: &[u64]) -> Result<Vec<OccurrencePlacement>> {
|
||||
let component = if path.is_empty() { 0 } else { self.occurrence_placement(path)?.component };
|
||||
self.doc
|
||||
.occurrences
|
||||
.iter()
|
||||
.filter(|o| o.parent == component)
|
||||
.map(|o| {
|
||||
let mut child = path.to_vec();
|
||||
child.push(o.id);
|
||||
self.occurrence_placement(&child)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Bounded descendant expansion. Never return an incomplete selection or bounds list.
|
||||
pub fn occurrence_bodies(&self, path: &[u64], descendants: bool) -> Result<Vec<String>> {
|
||||
let mut stack = vec![self.occurrence_placement(path)?];
|
||||
let mut out = Vec::new();
|
||||
let mut count = 0;
|
||||
while let Some(placement) = stack.pop() {
|
||||
count += 1;
|
||||
if count > 100_000 || out.len() + placement.bodies.len() > 100_000 {
|
||||
return Err(invalid("occurrence expansion exceeds 100000 placements or bodies"));
|
||||
}
|
||||
out.extend(placement.bodies.into_iter().map(|b| b.world));
|
||||
if descendants {
|
||||
stack.extend(self.occurrence_children(&placement.path)?);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Selected authoring placement, or the compatible first-placement frame.
|
||||
pub fn active_component_frame(&self) -> Mat {
|
||||
if let Some(path) = &self.active_occurrence_path
|
||||
&& let Ok(p) = self.occurrence_placement(path)
|
||||
&& p.component == self.active_component
|
||||
{
|
||||
return p.world;
|
||||
}
|
||||
let path = self.doc.first_path(self.active_component);
|
||||
self.occurrence_placement(&path).map(|p| p.world).unwrap_or(IDENTITY)
|
||||
}
|
||||
|
||||
pub fn occurrence_visible(&self, path: &[u64]) -> bool {
|
||||
!self.visibility.hidden_occurrences.iter().any(|hidden| path.starts_with(hidden))
|
||||
}
|
||||
|
||||
pub fn occurrence_hidden(&self, path: &[u64]) -> bool {
|
||||
self.visibility.hidden_occurrences.iter().any(|hidden| hidden == path)
|
||||
}
|
||||
|
||||
/// Number of placements affected by editing this shared occurrence definition.
|
||||
pub fn occurrence_scope_count(&self, path: &[u64]) -> Result<usize> {
|
||||
let id = self.occurrence_placement(path)?.occurrence;
|
||||
self.occurrence_id_scope_count(id)
|
||||
}
|
||||
|
||||
pub fn occurrence_id_scope_count(&self, id: u64) -> Result<usize> {
|
||||
if !self.doc.occurrences.iter().any(|o| o.id == id) {
|
||||
return Err(invalid("no occurrence"));
|
||||
}
|
||||
let mut stack = vec![(0, 0usize)];
|
||||
let mut visited = 0;
|
||||
let mut count = 0;
|
||||
while let Some((component, depth)) = stack.pop() {
|
||||
for o in self.doc.occurrences.iter().filter(|o| o.parent == component) {
|
||||
visited += 1;
|
||||
if depth >= 64 || visited > 100_000 {
|
||||
return Err(invalid("occurrence scope exceeds 64 levels or 100000 placements"));
|
||||
}
|
||||
if o.id == id {
|
||||
count += 1;
|
||||
}
|
||||
stack.push((o.component, depth + 1));
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn body_visible(&self, name: &str) -> bool {
|
||||
if self.visibility.hidden_bodies.iter().any(|n| n == name) {
|
||||
return false;
|
||||
}
|
||||
if self.visibility.hidden_occurrences.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if let Ok(mut cache) = self.occurrence_visibility_cache.lock() {
|
||||
if cache.as_ref().is_none_or(|c| c.revision != self.revision || c.paths != self.visibility.hidden_occurrences) {
|
||||
let bodies = self
|
||||
.doc
|
||||
.body_frames(&self.model.state())
|
||||
.into_iter()
|
||||
.filter(|(_, _, path, _)| !self.occurrence_visible(path))
|
||||
.map(|(name, _, _, _)| name)
|
||||
.collect();
|
||||
*cache = Some(VisibilityCache { revision: self.revision, paths: self.visibility.hidden_occurrences.clone(), bodies });
|
||||
}
|
||||
return cache.as_ref().is_none_or(|c| !c.bodies.contains(name));
|
||||
}
|
||||
self.doc.body_frames(&self.model.state()).iter().find(|(n, _, _, _)| n == name).is_none_or(|(_, _, path, _)| self.occurrence_visible(path))
|
||||
}
|
||||
|
||||
pub(crate) fn prune_occurrence_state(&mut self) {
|
||||
self.pending_moves.retain(|id, _| self.doc.occurrences.iter().any(|o| o.id == *id));
|
||||
if self.active_component != 0 && !self.doc.components.iter().any(|c| c.id == self.active_component) {
|
||||
self.active_component = 0;
|
||||
self.active_occurrence_path = None;
|
||||
}
|
||||
if self.active_occurrence_path.as_ref().is_some_and(|p| self.occurrence_placement(p).map_or(true, |p| p.component != self.active_component)) {
|
||||
self.active_occurrence_path = None;
|
||||
}
|
||||
let selection = std::mem::take(&mut self.selection);
|
||||
self.selection = selection.into_iter().filter(|s| !matches!(s, Sel::Occurrence {path} if self.occurrence_placement(path).is_err())).collect();
|
||||
let hidden = std::mem::take(&mut self.visibility.hidden_occurrences);
|
||||
self.visibility.hidden_occurrences = hidden.into_iter().filter(|p| self.occurrence_placement(p).is_ok()).collect();
|
||||
}
|
||||
}
|
||||
@@ -33,3 +33,79 @@ fn suppressing_a_patterned_feature_removes_its_copies() {
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Standoff", "suppressed": false}));
|
||||
assert!((volume(&s, "Case") - full).abs() < 1e-6 * full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs() {
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_str(include_str!("../../../examples/acceptance/sensor_enclosure.json")).unwrap();
|
||||
s.run_script(&script).unwrap();
|
||||
for (name, expression) in [("wall", "2.5 mm"), ("height", "30 mm"), ("board_d", "50 mm")] {
|
||||
run(&mut s, "parameters.change", json!({"name":name,"expression":expression}));
|
||||
}
|
||||
assert!(s.model.results.iter().all(|result| result.error.is_none()));
|
||||
for (name, z) in [("PCB standoff", 2.5), ("Lid boss", 2.5), ("Lid lip", 30.), ("Lid screw", 34.)] {
|
||||
let feature =
|
||||
s.doc.features.iter().find(|feature| feature.name == name && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap();
|
||||
assert!((s.model.state().sketch(feature.id).unwrap().plane.origin.z - z).abs() < 1e-6);
|
||||
}
|
||||
let usb =
|
||||
s.doc.features.iter().find(|feature| feature.name == "USB" && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap();
|
||||
assert!((s.model.state().sketch(usb.id).unwrap().plane.origin.y + 35.5).abs() < 1e-6);
|
||||
let assert_material = |session: &Session| {
|
||||
let state = session.world_state();
|
||||
let case = state.body("Case").unwrap().mesh();
|
||||
let lid = state.body("Lid").unwrap().mesh();
|
||||
assert!(case.contains(solvecraft_geom::Vec3::new(-24.5, -21.5, 7.)));
|
||||
assert!(!case.contains(solvecraft_geom::Vec3::new(-26.5, -21.5, 7.)));
|
||||
assert!(!case.contains(solvecraft_geom::Vec3::new(-10., -35.25, 11.35)));
|
||||
assert!(case.contains(solvecraft_geom::Vec3::new(-10., -35.25, 15.)));
|
||||
assert!(
|
||||
lid.contains(solvecraft_geom::Vec3::new(0., 32.2, 29.)),
|
||||
"lid bounds {:?}; lip {:?}; probes {:?}",
|
||||
lid.bounds(),
|
||||
session
|
||||
.model
|
||||
.state()
|
||||
.sketch(
|
||||
session
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.find(|feature| feature.name == "Lid lip" && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. }))
|
||||
.unwrap()
|
||||
.id
|
||||
)
|
||||
.unwrap()
|
||||
.sketch
|
||||
.links,
|
||||
(20..35).map(|y| (y, lid.contains(solvecraft_geom::Vec3::new(0., f64::from(y) + 0.2, 29.)))).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(!lid.contains(solvecraft_geom::Vec3::new(0., 32.9, 29.)));
|
||||
assert!(!lid.contains(solvecraft_geom::Vec3::new(-33.5, -28.5, 32.)));
|
||||
assert!(lid.contains(solvecraft_geom::Vec3::new(-31.5, -28.5, 32.)));
|
||||
};
|
||||
assert_material(&s);
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.results.iter().all(|result| result.error.is_none()));
|
||||
assert_material(&reopened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosure_inward_offset_collapse_reports_an_error_instead_of_a_stale_lip() {
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_str(include_str!("../../../examples/acceptance/sensor_enclosure.json")).unwrap();
|
||||
s.run_script(&script).unwrap();
|
||||
run(&mut s, "parameters.change", json!({"name":"wall","expression":"3 mm"}));
|
||||
// R4 outer corners leave R1 inner corners. The requested 1.4 mm inward
|
||||
// offset needs a topology transition, which this offset operation refuses.
|
||||
let lip = s.doc.features.iter().find(|f| f.name == "Lid lip" && matches!(f.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap();
|
||||
let result = s.model.result(lip.id).unwrap();
|
||||
assert!(result.error.as_deref().is_some_and(|error| error.contains("offset collapses an arc")), "{result:?}");
|
||||
assert!(result.warning.as_deref().is_none_or(|warning| !warning.contains("identity is ambiguous")), "{result:?}");
|
||||
let state = s.model.state();
|
||||
assert!(state.sketch(lip.id).is_none());
|
||||
assert!(!state.body("Lid").unwrap().mesh().contains(solvecraft_geom::Vec3::new(0., 27.2, 24.)));
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.result(lip.id).unwrap().error.as_deref().is_some_and(|error| error.contains("offset collapses an arc")));
|
||||
assert!(reopened.model.state().sketch(lip.id).is_none());
|
||||
}
|
||||
@@ -30,7 +30,8 @@ fn enclosure() -> Session {
|
||||
{"command": "solid.extrude", "params": {"distance": "height", "body_name": "Enclosure"}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[40, 30, 15], [-40, 30, 15], [-40, -30, 15], [40, -30, 15]], "radius": 6}},
|
||||
{"command": "solid.shell", "params": {"faces": [[0, 0, 30]], "thickness": "wall"}},
|
||||
{"command": "sketch.create", "params": {"plane": {"face": [40, 10, 20]}, "name": "USB port"}},
|
||||
{"command": "sketch.create", "params": {"plane": "YZ", "offset":"width / 2", "name": "USB port"}},
|
||||
{"command": "sketch.project", "params": {"refs":[{"face":[40,10,20],"body":"Enclosure"}]}},
|
||||
{"command": "sketch.rectangle.center", "params": {"center": [0, 12], "corner": [6, 15.5]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": "-wall", "operation": "cut", "profiles": [1]}}
|
||||
@@ -58,6 +59,18 @@ fn a_port_picked_by_index_stays_a_cut_after_a_width_change() {
|
||||
assert!(after[0].1 > before[0].1 + 1000.0, "{before:?} {after:?}");
|
||||
let id = s.doc.features.last().unwrap().id;
|
||||
assert!(s.model.result(id).is_some_and(|r| r.error.is_none()), "{:?}", s.model.result(id));
|
||||
let port = s.doc.find_feature("USB port").unwrap().id;
|
||||
assert!((s.model.state().sketch(port).unwrap().plane.origin.x - 50.).abs() < 1e-6);
|
||||
let world = s.world_state();
|
||||
let mesh = world.body("Enclosure").unwrap().mesh();
|
||||
assert!(!mesh.contains(solvecraft_geom::Vec3::new(49., 0., 12.)));
|
||||
assert!(mesh.contains(solvecraft_geom::Vec3::new(49., 0., 20.)));
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.result(id).unwrap().error.is_none());
|
||||
let world = reopened.world_state();
|
||||
let mesh = world.body("Enclosure").unwrap().mesh();
|
||||
assert!(!mesh.contains(solvecraft_geom::Vec3::new(49., 0., 12.)));
|
||||
assert!(mesh.contains(solvecraft_geom::Vec3::new(49., 0., 20.)));
|
||||
}
|
||||
|
||||
/// A pick the sketch no longer has is an error on the feature, never a different profile.
|
||||
|
||||
@@ -21,14 +21,14 @@ pub fn script() -> Value {
|
||||
{"command": "sketch.circle.center", "params": {"center": [68, 25], "diameter": 8}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": "thickness", "operation": "cut", "name": "Holes"}},
|
||||
{"command": "sketch.create", "params": {"plane": {"face": [40, 25, 8]}, "name": "Boss"}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "offset": "thickness", "name": "Boss"}},
|
||||
{"command": "sketch.circle.center", "params": {"center": [40, 25], "diameter": 24}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 14, "operation": "join", "name": "Boss"}},
|
||||
{"command": "sketch.create", "params": {"plane": {"face": [40, 37, 22]}, "name": "Bore"}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "offset": "thickness + 14 mm", "name": "Bore"}},
|
||||
{"command": "sketch.circle.center", "params": {"center": [40, 25], "diameter": 12}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 22, "direction": "negative", "operation": "cut", "name": "Bore"}},
|
||||
{"command": "solid.extrude", "params": {"distance": "thickness + 14 mm", "direction": "negative", "operation": "cut", "name": "Bore"}},
|
||||
]})
|
||||
}
|
||||
|
||||
@@ -139,6 +139,44 @@ fn vents() -> Value {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn plate_boss_and_bore_planes_follow_thickness_without_weak_face_references() {
|
||||
let mut session = crate::Session::default();
|
||||
session.run_script(&super::script()).unwrap();
|
||||
let boss = session
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.find(|feature| feature.name == "Boss" && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. }))
|
||||
.unwrap()
|
||||
.id;
|
||||
let bore = session
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.find(|feature| feature.name == "Bore" && matches!(feature.kind, solvecraft_doc::FeatureKind::Sketch { .. }))
|
||||
.unwrap()
|
||||
.id;
|
||||
session.execute("parameters.change", &serde_json::json!({"name":"thickness","expression":"12 mm"})).unwrap();
|
||||
assert!(session.model.results.iter().all(|result| result.error.is_none()));
|
||||
assert!((session.model.state().sketch(boss).unwrap().plane.origin.z - 12.).abs() < 1e-6);
|
||||
assert!((session.model.state().sketch(bore).unwrap().plane.origin.z - 26.).abs() < 1e-6);
|
||||
let world = session.world_state();
|
||||
let plate = world.body("Plate").unwrap().mesh();
|
||||
assert!((plate.bounds().max.z - 26.).abs() < 1e-6);
|
||||
assert!(plate.contains(solvecraft_geom::Vec3::new(5., 25., 10.)));
|
||||
assert!(plate.contains(solvecraft_geom::Vec3::new(40., 35., 24.)));
|
||||
assert!(!plate.contains(solvecraft_geom::Vec3::new(40., 25., 24.)));
|
||||
assert!(!plate.contains(solvecraft_geom::Vec3::new(40., 25., 2.)));
|
||||
assert!(plate.contains(solvecraft_geom::Vec3::new(40., 35., 2.)));
|
||||
let reopened = crate::Session::new(solvecraft_doc::Document::from_json(&session.doc.to_json()).unwrap());
|
||||
assert!((reopened.model.state().sketch(bore).unwrap().plane.origin.z - 26.).abs() < 1e-6);
|
||||
let reopened_world = reopened.world_state();
|
||||
let reopened_plate = reopened_world.body("Plate").unwrap().mesh();
|
||||
assert!(!reopened_plate.contains(solvecraft_geom::Vec3::new(40., 25., 2.)));
|
||||
assert!(reopened_plate.contains(solvecraft_geom::Vec3::new(40., 35., 2.)));
|
||||
}
|
||||
|
||||
/// Every built-in sample builds without a failing feature or joint.
|
||||
#[test]
|
||||
fn samples_build_cleanly() {
|
||||
|
||||
+148
-21
@@ -28,7 +28,19 @@ fn join_extrude_from_a_chamfered_top_face() {
|
||||
run(&mut s, "solid.extrude", json!({"distance": 20}));
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 20]], "distance": 2}));
|
||||
let base = volume(&mut s);
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20]}}));
|
||||
let before = s.doc.to_json();
|
||||
let active = s.active_sketch;
|
||||
assert!(s.execute("sketch.create", &json!({"plane":{"face":[0,0,20]}})).is_err(), "modified faces require an explicit fixed plane");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.active_sketch, active);
|
||||
assert!(rel(volume(&mut s), base) < 1e-12);
|
||||
let id = run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20],"fixed":true}}))["sketch"].as_u64().unwrap();
|
||||
let solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Custom { plane }, sketch } = &s.doc.feature(id).unwrap().kind else {
|
||||
panic!("explicit fixed face must become a custom plane");
|
||||
};
|
||||
assert!((plane.origin.z - 20.).abs() < 1e-9);
|
||||
assert!(sketch.links.is_empty(), "fresh fixed plane does not create live auto-projections");
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":8}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
@@ -407,6 +419,46 @@ fn sample_edges_chain_and_faces() {
|
||||
assert!(m.edge_faces.iter().all(|f| !f.is_empty()));
|
||||
}
|
||||
|
||||
/// timeline.inspect returns a feature's definition in the shape timeline.edit's `set` takes, so
|
||||
/// an agent can read the field names instead of guessing them.
|
||||
#[test]
|
||||
fn timeline_inspect_shows_what_timeline_edit_changes() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "Slab"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 10]], "radius": 2, "name": "Round"}));
|
||||
let i = run(&mut s, "timeline.inspect", json!({"feature": "Slab"}));
|
||||
assert_eq!(i["type"], "ExtrudeFeature", "{i}");
|
||||
assert_eq!(i["definition"]["extent"]["distance"], "10", "{i}");
|
||||
assert_eq!(i["definition"]["operation"], "new_body", "{i}");
|
||||
assert!(i["definition"].get("type").is_none() && i["definition"].get("param_names").is_none(), "{i}");
|
||||
assert_eq!(i["inputs"][0]["label"], "Distance", "{i}");
|
||||
assert_eq!(i["inputs"][0]["unit"], "mm", "{i}");
|
||||
let d = i["inputs"][0]["param"].as_str().unwrap().to_string();
|
||||
// The definition's field names are what timeline.edit takes.
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Slab", "set": {"extent": {"distance": "12"}}}));
|
||||
let i = run(&mut s, "timeline.inspect", json!({"feature": i["id"]}));
|
||||
assert_eq!(i["definition"]["extent"]["distance"], "12", "{i}");
|
||||
assert_eq!(i["inputs"][0]["param"], d.as_str(), "{i}");
|
||||
let f = run(&mut s, "timeline.inspect", json!({"feature": "Round"}));
|
||||
assert_eq!(f["definition"]["radius"], "2", "{f}");
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Round", "set": {"radius": "3"}}));
|
||||
assert_eq!(run(&mut s, "timeline.inspect", json!({"feature": "Round"}))["definition"]["radius"], "3");
|
||||
// A sketch leaves its geometry out (sketch.inspect has it) and says so.
|
||||
let k = run(&mut s, "timeline.inspect", json!({"feature": "Base"}));
|
||||
assert!(k["definition"].get("sketch").is_none() && k["definition"].get("plane").is_some(), "{k}");
|
||||
assert_eq!(k["omitted"][0]["field"], "sketch", "{k}");
|
||||
// Read-only: no undo step; bad input is refused.
|
||||
let undo = s.undo.len();
|
||||
run(&mut s, "timeline.inspect", json!({"feature": "Slab"}));
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
for bad in [json!({}), json!({"feature": "Nope"}), json!({"feature": [1]}), json!({"feature": 1e300}), json!({"feature": null})] {
|
||||
assert!(s.execute("timeline.inspect", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_edits_reresolve_references() {
|
||||
let mut s = Session::default();
|
||||
@@ -482,7 +534,12 @@ fn step_open_then_model_on_it() {
|
||||
run(&mut s, "solid.hole", json!({"position": at(28.0, 15.0, 20.0), "diameter": 6}));
|
||||
want -= PI * 9.0 * 20.0;
|
||||
assert!(rel(volume(&mut s), want) < 5e-4, "{} vs {want}", volume(&mut s));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0)}}));
|
||||
// Modified imported faces no longer have an immutable construction certificate.
|
||||
// The public attachment refuses them; the user can explicitly choose a fixed plane.
|
||||
let before_fixed = s.doc.to_json();
|
||||
assert!(s.execute("sketch.create", &json!({"plane":{"face":at(10.0,15.0,20.0)}})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before_fixed);
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0), "fixed": true}}));
|
||||
let c = [x0 + 10.0, y0 + 15.0];
|
||||
// Sketch coordinates on the face are relative to the model origin projected onto it.
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [c[0] - 4.0, c[1] - 4.0], "p1": [c[0] + 4.0, c[1] + 4.0]}));
|
||||
@@ -864,7 +921,14 @@ fn holes_at_sketch_points_threads_and_components() {
|
||||
let text = s.doc.to_json();
|
||||
let back = solvecraft_doc::Document::from_json(&text).unwrap();
|
||||
assert_eq!(back.components.len(), 2);
|
||||
assert_eq!(back.body_components.len(), 1);
|
||||
let assigned = r["components"][0]["component"].as_u64().unwrap();
|
||||
let transfer = r["components"][0]["feature"].as_u64().unwrap();
|
||||
assert!(
|
||||
matches!(&back.feature(transfer).unwrap().kind, solvecraft_doc::FeatureKind::BodyTransfer { bodies, to, .. } if bodies == &["Shaft"] && *to == assigned)
|
||||
);
|
||||
let reopened = Session::new(back);
|
||||
assert_eq!(reopened.model.state().body("Shaft").unwrap().component, assigned);
|
||||
assert_eq!(reopened.world_state().threads.len(), s.world_state().threads.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -985,11 +1049,22 @@ fn components_occurrences_and_world_placement() {
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
assert!(s.pending_moves.is_empty());
|
||||
assert!((bbox(&mut s, "ArmBody")["min"][2].as_f64().unwrap() - 5.0).abs() < 1e-9);
|
||||
// Bodies and sketches move between components.
|
||||
run(&mut s, "component.move_bodies", json!({"bodies": ["Base"], "component": "Arm"}));
|
||||
// A definition placed twice cannot receive one world body without duplicating it.
|
||||
let document = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let error = s.execute("component.move_bodies", &json!({"bodies": ["Base"], "component": "Arm"})).unwrap_err();
|
||||
assert!(error.to_string().contains("one source and destination placement"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), document);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
// The independent Paste New result has one placement and can receive it.
|
||||
let copied = pn["component"].as_u64().unwrap();
|
||||
run(&mut s, "component.move_bodies", json!({"bodies": ["Base"], "component": copied}));
|
||||
let l = run(&mut s, "component.list", json!({}));
|
||||
let arm = l["components"].as_array().unwrap().iter().find(|x| x["id"] == cid).unwrap().clone();
|
||||
assert!(arm["bodies"].as_array().unwrap().iter().any(|b| b == "Base"), "{arm}");
|
||||
assert!(!arm["bodies"].as_array().unwrap().iter().any(|b| b == "Base"), "{arm}");
|
||||
let receiver = l["components"].as_array().unwrap().iter().find(|x| x["id"] == copied).unwrap();
|
||||
assert!(receiver["bodies"].as_array().unwrap().iter().any(|b| b == "Base"), "{receiver}");
|
||||
assert_eq!(bbox(&mut s, "Base")["min"], json!([0.0, 0.0, 0.0]));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S1"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "component.move_sketches", json!({"sketches": ["S1"], "component": cid}));
|
||||
@@ -999,8 +1074,9 @@ fn components_occurrences_and_world_placement() {
|
||||
assert_eq!(s.doc.find_feature("S1").unwrap().component, 0);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
run(&mut s, "timeline.roll_to", json!({"position": 1}));
|
||||
// Only Base is left; it now lives in Arm, which is placed twice (original and instance).
|
||||
assert_eq!(s.world_state().bodies.len(), 2);
|
||||
// The ownership boundary is later: before it Base remains the sole root body.
|
||||
assert_eq!(s.world_state().bodies.len(), 1);
|
||||
assert_eq!(s.world_state().body("Base").unwrap().component, 0);
|
||||
run(&mut s, "timeline.roll_to", json!({}));
|
||||
// Saved and loaded; older designs get occurrences.
|
||||
let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
@@ -1568,20 +1644,71 @@ mod naming {
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Top", "distance": 5, "operation": "join", "name": "Peg"}));
|
||||
let top = |s: &mut Session| run(s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0);
|
||||
assert!((top(&mut s) - 15.0).abs() < 1e-6);
|
||||
// Upstream, a tall boss over the picked point: the nearest upward face is now its top,
|
||||
// but the sketch keeps to the block's top face by name.
|
||||
run(&mut s, "timeline.roll_to", json!({"feature": "Block"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "B"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [8, -5], "p1": [45, 35]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "B", "distance": 20, "operation": "join", "name": "Boss"}));
|
||||
run(&mut s, "timeline.roll_to", json!({}));
|
||||
// (Its projected face outline changed, which it says; its plane did not.)
|
||||
assert!(!warning(&s, "Top").unwrap_or_default().contains("no longer exists"));
|
||||
// The peg starts on the block's top (z = 10), inside the boss: the part stays 20 tall.
|
||||
let attached = |s: &Session| s.model.state().sketch(s.doc.find_feature("Top").unwrap().id).map(|sketch| sketch.plane.origin.z);
|
||||
assert_eq!(attached(&s), Some(10.));
|
||||
run(&mut s, "timeline.edit", json!({"feature":"Block","set":{"extent":{"distance":"12"}}}));
|
||||
assert_eq!(attached(&s), Some(12.));
|
||||
assert!((top(&mut s) - 17.).abs() < 1e-6);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(attached(&s), Some(10.));
|
||||
assert!((top(&mut s) - 15.).abs() < 1e-6);
|
||||
let reopened = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
let mut model = solvecraft_doc::Model::new();
|
||||
model.evaluate(&reopened);
|
||||
assert_eq!(model.state().sketch(reopened.find_feature("Top").unwrap().id).unwrap().plane.origin.z, 10.);
|
||||
assert!((model.state().bodies[0].mesh().bounds().max.z - 15.).abs() < 1e-6);
|
||||
// A historical format-10 attachment uses its saved generic face name and no strong anchor.
|
||||
let mut old = (*s.doc).clone();
|
||||
let solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Face { anchor, name, .. }, sketch } =
|
||||
&mut old.features.iter_mut().find(|f| f.name == "Top").unwrap().kind
|
||||
else {
|
||||
panic!("fixture must contain a face sketch");
|
||||
};
|
||||
*anchor = None;
|
||||
for link in &mut sketch.links {
|
||||
if link.auto_face {
|
||||
link.auto_face = false;
|
||||
link.face_name = name.clone();
|
||||
}
|
||||
}
|
||||
old.format = "solvecraft/10".into();
|
||||
let mut legacy = Session::new(solvecraft_doc::Document::from_json(&old.to_json()).unwrap());
|
||||
assert_eq!(attached(&legacy), Some(10.));
|
||||
assert!((top(&mut legacy) - 15.).abs() < 1e-6);
|
||||
// The upstream join consumes the certified body; new attachments cannot follow its substitute roof.
|
||||
for target in [&mut s, &mut legacy] {
|
||||
run(target, "timeline.roll_to", json!({"feature": "Block"}));
|
||||
run(target, "sketch.create", json!({"plane": "XY", "name": "B"}));
|
||||
run(target, "sketch.rectangle.two_point", json!({"p0": [8, -5], "p1": [45, 35]}));
|
||||
run(target, "sketch.finish", json!({}));
|
||||
run(target, "solid.extrude", json!({"sketch": "B", "distance": 20, "operation": "join", "name": "Boss"}));
|
||||
run(target, "timeline.roll_to", json!({}));
|
||||
}
|
||||
assert_eq!(attached(&legacy), Some(10.), "historical weak naming retains its saved geometry behavior");
|
||||
assert!((top(&mut legacy) - 20.).abs() < 1e-6);
|
||||
let legacy_reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.doc.to_json()).unwrap());
|
||||
assert_eq!(attached(&legacy_reopened), Some(10.));
|
||||
assert!((legacy_reopened.model.state().bodies[0].mesh().bounds().max.z - 20.).abs() < 1e-6);
|
||||
let solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Face { anchor, .. }, .. } =
|
||||
&legacy_reopened.doc.find_feature("Top").unwrap().kind
|
||||
else {
|
||||
panic!("saved historical face reference must remain present");
|
||||
};
|
||||
assert!(anchor.is_none(), "opening old face references must not infer a strict attachment");
|
||||
let failed = |s: &Session, name: &str| s.model.results.iter().find(|r| r.id == s.doc.find_feature(name).unwrap().id).unwrap().error.is_some();
|
||||
assert!(failed(&s, "Top"));
|
||||
assert!(failed(&s, "Peg"), "lost sketch cannot supply downstream geometry");
|
||||
assert_eq!(attached(&s), None);
|
||||
assert!((top(&mut s) - 20.0).abs() < 1e-6, "{}", top(&mut s));
|
||||
let sk = s.model.state().sketch(s.doc.find_feature("Top").map(|f| f.id).unwrap_or(0)).map(|x| x.plane.origin.z);
|
||||
assert_eq!(sk.map(|z| (z * 1e6).round() / 1e6), Some(10.0));
|
||||
run(&mut s, "timeline.suppress", json!({"feature":"Boss","suppressed":true}));
|
||||
assert!(!failed(&s, "Top"));
|
||||
assert!(!failed(&s, "Peg"));
|
||||
assert_eq!(attached(&s), Some(10.));
|
||||
assert!((top(&mut s) - 15.).abs() < 1e-6);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(failed(&s, "Top"));
|
||||
assert!(failed(&s, "Peg"));
|
||||
assert_eq!(attached(&s), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -24,8 +24,9 @@ fn moved_component_picks_and_frames() {
|
||||
// Root active: a fillet picked at the moved edge in the world.
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[25, 54, 4]], "radius": 1}));
|
||||
assert!(rel(vol(&mut s, "ArmBody"), 160.0 - (1.0 - PI / 4.0) * 10.0) < 1e-4, "{}", vol(&mut s, "ArmBody"));
|
||||
// A sketch on the moved body's top face: its points sit on that face in the world.
|
||||
let sk = run(&mut s, "sketch.create", json!({"plane": {"face": [25, 52, 4]}}));
|
||||
// An explicit fixed plane on the modified body's top keeps the world pick;
|
||||
// unsupported modifier lineage cannot silently create a weak attachment.
|
||||
let sk = run(&mut s, "sketch.create", json!({"plane": {"face": [25, 52, 4],"fixed":true}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 1}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let si = run(&mut s, "sketch.inspect", json!({"sketch": sk["sketch"]}));
|
||||
|
||||
@@ -44,7 +44,7 @@ pub fn bounds(s: &Session) -> Aabb3 {
|
||||
let st = s.world_state();
|
||||
let mut b = Aabb3::EMPTY;
|
||||
let v = &s.visibility;
|
||||
for body in st.bodies.iter().filter(|x| !v.hidden_bodies.contains(&x.name)) {
|
||||
for body in st.bodies.iter().filter(|x| s.body_visible(&x.name)) {
|
||||
b = b.union(&body.mesh().bounds());
|
||||
}
|
||||
for ss in st.sketches.iter().filter(|x| !v.hidden_sketches.contains(&x.feature)) {
|
||||
@@ -167,8 +167,17 @@ pub fn scene_with_section(s: &Session, cam: &Camera, section: Option<(Vec3, Vec3
|
||||
for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] {
|
||||
sc.lines.push(SceneLine { points: vec![Vec3::ZERO, d * axis], color: col, width: 1.6, on_top: false });
|
||||
}
|
||||
for body in st.bodies.iter().filter(|b| !s.visibility.hidden_bodies.contains(&b.name)) {
|
||||
let selected = s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == body.name));
|
||||
let selected_occurrences: Vec<String> = s
|
||||
.selection
|
||||
.iter()
|
||||
.filter_map(|x| match x {
|
||||
Sel::Occurrence { path } => s.occurrence_bodies(path, true).ok(),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
for body in st.bodies.iter().filter(|b| s.body_visible(&b.name)) {
|
||||
let selected = selected_occurrences.contains(&body.name) || s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == body.name));
|
||||
let mesh = body.mesh();
|
||||
// The body's appearance (or imported colour) and faces with looks of their own, as in
|
||||
// the viewport.
|
||||
|
||||
Reference in new issue
Block a user