feat: strengthen sketches, feature editing, and independent assemblies
This commit is contained in:
1 parent
07932d0f42
commit
0398a813c8
65 files changed
+7082
-268
No files matched your search
@@ -45,6 +45,13 @@ pub struct FaceLook {
|
||||
/// The face's persistent name (found first).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// Strict semantic assignments never transfer to a nearby replacement face.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub strict_name: bool,
|
||||
}
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
|
||||
/// The look a body was imported with, carried through later features (which build new kernel
|
||||
@@ -134,6 +141,14 @@ impl Document {
|
||||
pub fn model_body_look(&self, body: &crate::ModelBody) -> Option<Look> {
|
||||
self.body_look(body.definition_name(), body.feature)
|
||||
}
|
||||
/// Effective body opacity, including paint carried from an imported body.
|
||||
pub fn model_body_opacity(&self, body: &crate::ModelBody) -> f64 {
|
||||
self.model_body_look(body)
|
||||
.map(|look| look.opacity)
|
||||
.or_else(|| body.body.paint().map(|paint| paint.opacity as f64))
|
||||
.or_else(|| body.imported.as_ref().and_then(|paint| paint.opacity.map(f64::from)))
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
/// A body's look (without face appearances): its own, else its component's, else its
|
||||
/// material's.
|
||||
pub fn body_look(&self, name: &str, feature: u64) -> Option<Look> {
|
||||
@@ -173,6 +188,24 @@ impl Document {
|
||||
};
|
||||
for f in self.appearances.faces.iter().filter(|f| f.body == b.definition_name()) {
|
||||
let (p, look) = (crate::apply_point(&b.source_transform, f.point), f.look.clone());
|
||||
if f.strict_name {
|
||||
let Some(name) = f.name.as_ref() else { continue };
|
||||
let Some(origin_feature) = name.strip_prefix('F').and_then(|name| name.split_once(':')).and_then(|(id, _)| id.parse().ok()) else {
|
||||
continue;
|
||||
};
|
||||
let target = crate::config::ConfigFaceTarget {
|
||||
body: f.body.clone(),
|
||||
origin_feature,
|
||||
name: crate::naming::strip_piece(name).into(),
|
||||
point: f.point,
|
||||
};
|
||||
let Ok(indices) = self.configuration_face_indices(b, &target) else { continue };
|
||||
for index in indices {
|
||||
out.retain(|(old, _)| *old != index);
|
||||
out.push((index, look.clone()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// By name (every piece of a split face), else by the point.
|
||||
if let Some(n) = &f.name {
|
||||
let stem = crate::naming::strip_piece(n);
|
||||
@@ -185,7 +218,9 @@ impl Document {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(i) = face_index_at(b, p) {
|
||||
if !f.strict_name
|
||||
&& let Some(i) = face_index_at(b, p)
|
||||
{
|
||||
out.retain(|(j, _)| *j != i);
|
||||
out.push((i, look));
|
||||
}
|
||||
|
||||
@@ -49,7 +49,10 @@ impl FeatureKind {
|
||||
FeatureKind::Extrude { sketch, .. }
|
||||
| FeatureKind::Revolve { sketch, .. }
|
||||
| FeatureKind::Emboss { sketch, .. }
|
||||
| FeatureKind::SheetBase { sketch, .. }
|
||||
| FeatureKind::SheetContour { sketch, .. }
|
||||
| FeatureKind::Rib { sketch, .. } => sk(sketch),
|
||||
FeatureKind::Patch { sketch: Some(s), .. } => sk(s),
|
||||
FeatureKind::Sweep { sketch, path_sketch, .. } => {
|
||||
sk(sketch);
|
||||
sk(path_sketch);
|
||||
@@ -74,3 +77,371 @@ impl FeatureKind {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remap persistent topology tokens without rewriting arbitrary user expressions or labels.
|
||||
pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<String, String>) -> String {
|
||||
// Imported names belong to the complete canonical body prefix, even when that prefix
|
||||
// happens to contain text resembling a feature token.
|
||||
if let Some((body, suffix)) = name.rsplit_once(":face:")
|
||||
&& let Some(new) = bodies.get(body)
|
||||
{
|
||||
return format!("{new}:face:{suffix}");
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut at = 0;
|
||||
while at < name.len() {
|
||||
let rest = &name[at..];
|
||||
if rest.starts_with('F') && (at == 0 || matches!(name.as_bytes().get(at - 1), Some(b'@') | Some(b':') | Some(b'|'))) {
|
||||
let end = rest.as_bytes().iter().skip(1).take_while(|b| b.is_ascii_digit()).count() + 1;
|
||||
if end > 1
|
||||
&& matches!(rest.as_bytes().get(end), Some(b':') | Some(b'.'))
|
||||
&& let Ok(id) = rest[1..end].parse::<u64>()
|
||||
&& let Some(new) = ids.get(&id)
|
||||
{
|
||||
out.push_str(&format!("F{new}"));
|
||||
at += end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Some(ch) = rest.chars().next() else { break };
|
||||
out.push(ch);
|
||||
at += ch.len_utf8();
|
||||
}
|
||||
// Import faces have a canonical body prefix rather than a feature token.
|
||||
if let Some((body, suffix)) = out.rsplit_once(":face:")
|
||||
&& let Some(new) = bodies.get(body)
|
||||
{
|
||||
return format!("{new}:face:{suffix}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn mapped_name(n: &mut String, names: &BTreeMap<String, String>) {
|
||||
if let Some(new) = names.get(n) {
|
||||
*n = new.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Simultaneous generated-parameter substitution: map values may themselves be old keys.
|
||||
pub fn clone_expression(source: &str, parameters: &BTreeMap<String, String>) -> String {
|
||||
let mut result = source.to_string();
|
||||
let mut temporary = Vec::new();
|
||||
for (index, (old, new)) in parameters.iter().enumerate() {
|
||||
let mut token = format!("__clone_reference_{index}");
|
||||
while source.contains(&token) || parameters.values().any(|n| n == &token) {
|
||||
token.push('_');
|
||||
}
|
||||
result = crate::expr::rename_reference(&result, old, &token);
|
||||
temporary.push((token, new));
|
||||
}
|
||||
for (token, new) in temporary {
|
||||
result = crate::expr::rename_reference(&result, &token, new);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn remap_geo(
|
||||
g: &mut crate::construct::GeoRef,
|
||||
ids: &BTreeMap<u64, u64>,
|
||||
names: &BTreeMap<String, String>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
depth: usize,
|
||||
) {
|
||||
use crate::construct::GeoRef;
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
match g {
|
||||
GeoRef::SketchPoint { sketch, .. } | GeoRef::SketchCurve { sketch, .. } => {
|
||||
if let Some(new) = ids.get(sketch) {
|
||||
*sketch = *new;
|
||||
}
|
||||
}
|
||||
GeoRef::Face { name: Some(n), .. } | GeoRef::Edge { name: Some(n), .. } => *n = remap_topology(n, ids, bodies),
|
||||
GeoRef::ConstructPoint { name } | GeoRef::Axis { name } => mapped_name(name, names),
|
||||
GeoRef::Plane { plane } => remap_plane(plane, ids, names, bodies, depth + 1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn remap_plane(p: &mut PlaneRef, ids: &BTreeMap<u64, u64>, names: &BTreeMap<String, String>, bodies: &BTreeMap<String, String>, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
match p {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => remap_plane(base, ids, names, bodies, depth + 1),
|
||||
PlaneRef::Construction { name } => mapped_name(name, names),
|
||||
PlaneRef::Face { name: Some(n), .. } => *n = remap_topology(n, ids, bodies),
|
||||
_ => {
|
||||
for r in p.refs_mut() {
|
||||
remap_geo(r, ids, names, bodies, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remap_link(
|
||||
source: &mut solvecraft_sketch::LinkSource,
|
||||
ids: &BTreeMap<u64, u64>,
|
||||
names: &BTreeMap<String, String>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
depth: usize,
|
||||
) {
|
||||
use solvecraft_sketch::LinkSource;
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
match source {
|
||||
LinkSource::Edge { body, .. }
|
||||
| LinkSource::Face { body, .. }
|
||||
| LinkSource::Body { body }
|
||||
| LinkSource::Vertex { body, .. }
|
||||
| LinkSource::Iso { body, .. }
|
||||
| LinkSource::Spun { body, .. } => mapped_name(body, bodies),
|
||||
LinkSource::SketchCurve { sketch, .. } | LinkSource::SketchPoint { sketch, .. } => {
|
||||
if let Some(new) = ids.get(sketch) {
|
||||
*sketch = *new;
|
||||
}
|
||||
}
|
||||
LinkSource::OnSurface { sketch, body, .. } => {
|
||||
if let Some(new) = ids.get(sketch) {
|
||||
*sketch = *new;
|
||||
}
|
||||
mapped_name(body, bodies);
|
||||
}
|
||||
LinkSource::Intersection { a, b } => {
|
||||
remap_link(a, ids, names, bodies, depth + 1);
|
||||
remap_link(b, ids, names, bodies, depth + 1);
|
||||
}
|
||||
LinkSource::Plane { name } => mapped_name(name, names),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Feature {
|
||||
/// Clone identities through typed reference fields. Sketch-local entity IDs stay local.
|
||||
pub fn remap_clone(
|
||||
&mut self,
|
||||
ids: &BTreeMap<u64, u64>,
|
||||
names: &BTreeMap<String, String>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
parameters: &BTreeMap<String, String>,
|
||||
) -> crate::Result<()> {
|
||||
use FeatureKind as K;
|
||||
match &self.kind {
|
||||
K::Sketch { plane, sketch } => {
|
||||
checked_plane(plane, 0)?;
|
||||
for link in &sketch.links {
|
||||
checked_link(&link.source, 0)?;
|
||||
}
|
||||
}
|
||||
K::ConstructionPlane { plane }
|
||||
| K::Split { plane, .. }
|
||||
| K::SplitFace { plane, .. }
|
||||
| K::Mirror { plane, .. }
|
||||
| K::SurfaceTrim { plane, .. } => checked_plane(plane, 0)?,
|
||||
K::Draft { neutral, .. } => checked_plane(neutral, 0)?,
|
||||
K::ReplaceFace { target, .. } => checked_plane(target, 0)?,
|
||||
K::ConstructionAxis { def } => {
|
||||
for r in def.refs() {
|
||||
checked_geo(r, 0)?;
|
||||
}
|
||||
}
|
||||
K::ConstructionPoint { def } => {
|
||||
for r in def.refs() {
|
||||
checked_geo(r, 0)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.kind.remap_refs(ids, names);
|
||||
for n in &mut self.body_names {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
for n in &mut self.participants {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
for n in &mut self.param_names {
|
||||
mapped_name(n, parameters);
|
||||
}
|
||||
for n in self.face_names.iter_mut().chain(&mut self.edge_names) {
|
||||
*n = remap_topology(n, ids, bodies);
|
||||
}
|
||||
for (_, e, _) in self.kind.inputs_mut() {
|
||||
*e = clone_expression(e, parameters);
|
||||
}
|
||||
for e in self.kind.point_expr_mut().into_iter().flatten() {
|
||||
*e = clone_expression(e, parameters);
|
||||
}
|
||||
match &mut self.kind {
|
||||
K::Emboss { face: Some(face), .. } => mapped_name(&mut face.body, bodies),
|
||||
K::Split { tool: Some(tool), .. } | K::SplitFace { tool: Some(tool), .. } | K::SurfaceTrim { tool: Some(tool), .. } => {
|
||||
mapped_name(&mut tool.body, bodies)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match &mut self.kind {
|
||||
K::Extrude { targets, .. }
|
||||
| K::Revolve { targets, .. }
|
||||
| K::Loft { targets, .. }
|
||||
| K::Sweep { targets, .. }
|
||||
| K::Pipe { targets, .. }
|
||||
| K::Emboss { targets, .. } => {
|
||||
for n in targets {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Combine { target, tools, .. } => {
|
||||
mapped_name(target, bodies);
|
||||
for n in tools {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Pattern { bodies: list, .. }
|
||||
| K::Mirror { bodies: list, .. }
|
||||
| K::Scale { bodies: list, .. }
|
||||
| K::BoundingSolid { bodies: list, .. }
|
||||
| K::Align { bodies: list, .. }
|
||||
| K::Remove { bodies: list }
|
||||
| K::Stitch { bodies: list, .. }
|
||||
| K::Move { bodies: list, .. } => {
|
||||
for n in list {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Thicken { bodies: list, targets, .. } => {
|
||||
for n in list.iter_mut().chain(targets) {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::BoundaryFill { tools, .. } => {
|
||||
for n in tools {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Fillet { body, .. }
|
||||
| K::Chamfer { body, .. }
|
||||
| K::Shell { body, .. }
|
||||
| K::Draft { body, .. }
|
||||
| K::SplitFace { body, .. }
|
||||
| K::OffsetFace { body, .. }
|
||||
| K::DeleteFace { body, .. }
|
||||
| K::ReplaceFace { body, .. }
|
||||
| K::SheetFlange { body, .. }
|
||||
| K::SheetHem { body, .. }
|
||||
| K::SheetFold { body, .. }
|
||||
| K::SheetUnfold { body, .. }
|
||||
| K::Boss { body, .. }
|
||||
| K::Lip { body, .. }
|
||||
| K::SnapFit { body, .. }
|
||||
| K::Rest { body, .. }
|
||||
| K::Patch { body, .. } => {
|
||||
if let Some(n) = body {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Split { body, .. } | K::SheetConvert { body, .. } | K::SurfaceExtend { body, .. } | K::SurfaceTrim { body, .. } => {
|
||||
mapped_name(body, bodies)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match &mut self.kind {
|
||||
K::Sketch { plane, sketch } => {
|
||||
remap_plane(plane, ids, names, bodies, 0);
|
||||
for c in &mut sketch.constraints {
|
||||
if let Some(n) = &mut c.param {
|
||||
mapped_name(n, parameters);
|
||||
}
|
||||
}
|
||||
for link in &mut sketch.links {
|
||||
remap_link(&mut link.source, ids, names, bodies, 0);
|
||||
if let Some(n) = &mut link.face_name {
|
||||
*n = remap_topology(n, ids, bodies);
|
||||
}
|
||||
}
|
||||
}
|
||||
K::ConstructionPlane { plane }
|
||||
| K::Split { plane, .. }
|
||||
| K::SplitFace { plane, .. }
|
||||
| K::Mirror { plane, .. }
|
||||
| K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, names, bodies, 0),
|
||||
K::Draft { neutral, .. } => remap_plane(neutral, ids, names, bodies, 0),
|
||||
K::ReplaceFace { target, .. } => remap_plane(target, ids, names, bodies, 0),
|
||||
K::ConstructionAxis { def } => {
|
||||
for r in def.refs_mut() {
|
||||
remap_geo(r, ids, names, bodies, 0);
|
||||
}
|
||||
}
|
||||
K::ConstructionPoint { def } => {
|
||||
for r in def.refs_mut() {
|
||||
remap_geo(r, ids, names, bodies, 0);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn checked_geo(g: &crate::construct::GeoRef, depth: usize) -> crate::Result<()> {
|
||||
if depth > 64 {
|
||||
return Err(crate::DocError::Invalid("copy reference nesting exceeds 64".into()));
|
||||
}
|
||||
if let crate::construct::GeoRef::Plane { plane } = g {
|
||||
checked_plane(plane, depth + 1)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn checked_plane(p: &PlaneRef, depth: usize) -> crate::Result<()> {
|
||||
if depth > 64 {
|
||||
return Err(crate::DocError::Invalid("copy reference nesting exceeds 64".into()));
|
||||
}
|
||||
match p {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => checked_plane(base, depth + 1)?,
|
||||
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } => {
|
||||
checked_geo(a, depth + 1)?;
|
||||
checked_geo(b, depth + 1)?;
|
||||
}
|
||||
PlaneRef::Perpendicular { plane, line } => {
|
||||
checked_geo(plane, depth + 1)?;
|
||||
checked_geo(line, depth + 1)?;
|
||||
}
|
||||
PlaneRef::ThreePoints { a, b, c } => {
|
||||
checked_geo(a, depth + 1)?;
|
||||
checked_geo(b, depth + 1)?;
|
||||
checked_geo(c, depth + 1)?;
|
||||
}
|
||||
PlaneRef::Tangent { face, .. } => checked_geo(face, depth + 1)?,
|
||||
PlaneRef::AlongPath { path, .. } => checked_geo(path, depth + 1)?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn checked_link(source: &solvecraft_sketch::LinkSource, depth: usize) -> crate::Result<()> {
|
||||
if depth > 64 {
|
||||
return Err(crate::DocError::Invalid("copy link nesting exceeds 64".into()));
|
||||
}
|
||||
if let solvecraft_sketch::LinkSource::Intersection { a, b } = source {
|
||||
checked_link(a, depth + 1)?;
|
||||
checked_link(b, depth + 1)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod clone_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn imported_names_and_topology_tokens_have_separate_identity() {
|
||||
let ids = BTreeMap::from([(1, 2), (10, 20)]);
|
||||
let bodies = BTreeMap::from([("F1:Housing".into(), "F1:Housing (copy)".into())]);
|
||||
assert_eq!(remap_topology("F1:Housing:face:0", &ids, &bodies), "F1:Housing (copy):face:0");
|
||||
assert_eq!(remap_topology("F1:+x|F10:[email protected]", &ids, &bodies), "F2:+x|F20:[email protected]");
|
||||
assert_eq!(remap_topology("UserF1:label", &ids, &bodies), "UserF1:label");
|
||||
}
|
||||
#[test]
|
||||
fn parameter_remap_is_simultaneous_and_token_scoped() {
|
||||
let map = BTreeMap::from([("d1".into(), "d2".into()), ("d2".into(), "d3".into())]);
|
||||
assert_eq!(clone_expression("d1 + d2 + d10 + 2 mm", &map), "d2 + d3 + d10 + 2 mm");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,12 @@ use crate::{DocError, Document, Result};
|
||||
#[path = "config_rules.rs"]
|
||||
mod rules;
|
||||
pub use rules::*;
|
||||
#[path = "config_styles.rs"]
|
||||
mod styles;
|
||||
pub use styles::*;
|
||||
#[path = "config_clone.rs"]
|
||||
mod clone;
|
||||
pub use clone::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -21,6 +27,10 @@ pub enum Column {
|
||||
Material(String),
|
||||
/// A body's appearance (library name, serialized Look, or empty to clear).
|
||||
Appearance(String),
|
||||
/// Shared component definition's direct appearance.
|
||||
ComponentAppearance(u64),
|
||||
/// A certified semantic face lineage in a shared definition.
|
||||
FaceAppearance(ConfigFaceTarget),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -132,6 +142,12 @@ impl Document {
|
||||
Column::Suppress(id) => self.feature(*id).map(|f| f.suppressed.to_string()),
|
||||
Column::Material(body) => Some(self.materials.get(body).cloned().unwrap_or_else(|| "Default".into())),
|
||||
Column::Appearance(body) => Some(self.appearances.bodies.get(body).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default()),
|
||||
Column::ComponentAppearance(id) => {
|
||||
Some(self.appearances.components.get(id).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default())
|
||||
}
|
||||
Column::FaceAppearance(target) => {
|
||||
Some(self.config_face_look(target).and_then(|look| serde_json::to_string(&look).ok()).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +158,8 @@ impl Document {
|
||||
Column::Suppress(id) => format!("suppress:{}", self.feature(*id).map(|f| f.name.clone()).unwrap_or_else(|| id.to_string())),
|
||||
Column::Material(body) => format!("material:{body}"),
|
||||
Column::Appearance(body) => format!("appearance:{body}"),
|
||||
Column::ComponentAppearance(id) => format!("component_appearance:{id}"),
|
||||
Column::FaceAppearance(target) => format!("face_appearance:{}", target.key()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +212,32 @@ impl Document {
|
||||
next.appearances.bodies.remove(body);
|
||||
}
|
||||
},
|
||||
Column::ComponentAppearance(id) => {
|
||||
if *id == 0 || !next.components.iter().any(|component| component.id == *id) {
|
||||
return Err(DocError::Unknown(format!("component {id} is gone")));
|
||||
}
|
||||
match appearance_cell(v)? {
|
||||
Some(look) => {
|
||||
next.appearances.components.insert(*id, look);
|
||||
}
|
||||
None => {
|
||||
next.appearances.components.remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Column::FaceAppearance(target) => {
|
||||
next.certify_config_face(target)?;
|
||||
next.appearances.faces.retain(|face| !target.matches(face));
|
||||
if let Some(look) = appearance_cell(v)? {
|
||||
next.appearances.faces.push(crate::appearance::FaceLook {
|
||||
body: target.body.clone(),
|
||||
point: target.point,
|
||||
look,
|
||||
name: Some(target.name.clone()),
|
||||
strict_name: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
next.configs.active = Some(row.to_string());
|
||||
@@ -228,6 +272,8 @@ impl Document {
|
||||
}
|
||||
Column::Material(body) => material_cell(v).is_ok_and(|m| self.materials.get(body) == m.as_ref()),
|
||||
Column::Appearance(body) => appearance_cell(v).is_ok_and(|look| self.appearances.bodies.get(body) == look.as_ref()),
|
||||
Column::ComponentAppearance(id) => appearance_cell(v).is_ok_and(|look| self.appearances.components.get(id) == look.as_ref()),
|
||||
Column::FaceAppearance(target) => appearance_cell(v).is_ok_and(|look| self.config_face_look(target) == look),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Configuration graph remapping for independent component copies.
|
||||
use super::{Column, ConfigTable};
|
||||
use crate::{DocError, Result};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ConfigCloneMaps {
|
||||
pub features: BTreeMap<u64, u64>,
|
||||
pub components: BTreeMap<u64, u64>,
|
||||
pub bodies: BTreeMap<String, String>,
|
||||
pub parameters: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Simultaneous token-reference substitution, including maps whose values are other keys.
|
||||
pub(super) fn remap_expression(source: &str, parameters: &BTreeMap<String, String>) -> String {
|
||||
let refs = crate::expr::references(source);
|
||||
let mut result = source.to_string();
|
||||
let mut replacements = Vec::new();
|
||||
for (index, reference) in refs.iter().enumerate() {
|
||||
if let Some(mapped) = parameters.get(reference) {
|
||||
let mut temporary = format!("_config_clone_{index}");
|
||||
while refs.contains(&temporary) || parameters.contains_key(&temporary) || parameters.values().any(|value| value == &temporary) {
|
||||
temporary.push('_');
|
||||
}
|
||||
result = crate::expr::rename_reference(&result, reference, &temporary);
|
||||
replacements.push((temporary, mapped));
|
||||
}
|
||||
}
|
||||
for (temporary, mapped) in replacements {
|
||||
result = crate::expr::rename_reference(&result, &temporary, mapped);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
impl ConfigTable {
|
||||
/// Append mapped columns/cells/rules atomically; source targets and active row remain intact.
|
||||
pub fn clone_targets(&mut self, maps: &ConfigCloneMaps) -> Result<()> {
|
||||
if maps.features.values().chain(maps.components.values()).any(|id| *id == 0)
|
||||
|| maps.parameters.values().any(|name| crate::Document::param_name_problem(name).is_some())
|
||||
|| maps.bodies.values().any(|name| name.is_empty() || name.len() > 128)
|
||||
{
|
||||
return Err(DocError::Invalid("invalid configuration clone identity mapping".into()));
|
||||
}
|
||||
if self.columns.len() > super::MAX_COLUMNS
|
||||
|| self.rows.len() > super::MAX_ROWS
|
||||
|| self.rules.len() > super::MAX_RULES
|
||||
|| self.rows.iter().any(|row| row.cells.len() != self.columns.len())
|
||||
{
|
||||
return Err(DocError::Invalid("cannot clone an incomplete configuration table".into()));
|
||||
}
|
||||
let mut next = self.clone();
|
||||
for (index, column) in self.columns.iter().enumerate() {
|
||||
if let Column::FaceAppearance(target) = column
|
||||
&& maps.bodies.contains_key(&target.body)
|
||||
&& !maps.features.contains_key(&target.origin_feature)
|
||||
{
|
||||
return Err(DocError::Invalid(format!(
|
||||
"cannot copy configured face `{}`: imported foreign body has no certified cloned lineage",
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
let mapped = match column {
|
||||
Column::Param(name) => maps.parameters.get(name).cloned().map(Column::Param),
|
||||
Column::Suppress(id) => maps.features.get(id).copied().map(Column::Suppress),
|
||||
Column::Material(name) => maps.bodies.get(name).cloned().map(Column::Material),
|
||||
Column::Appearance(name) => maps.bodies.get(name).cloned().map(Column::Appearance),
|
||||
Column::ComponentAppearance(id) => maps.components.get(id).copied().map(Column::ComponentAppearance),
|
||||
Column::FaceAppearance(target) => {
|
||||
maps.bodies.get(&target.body).zip(maps.features.get(&target.origin_feature)).map(|(body, origin)| {
|
||||
let mut target = target.clone();
|
||||
target.body = body.clone();
|
||||
target.name = target
|
||||
.name
|
||||
.strip_prefix(&format!("F{}:", target.origin_feature))
|
||||
.map(|role| format!("F{origin}:{role}"))
|
||||
.unwrap_or_else(|| target.name.clone());
|
||||
target.origin_feature = *origin;
|
||||
Column::FaceAppearance(target)
|
||||
})
|
||||
}
|
||||
};
|
||||
if let Some(mapped) = mapped {
|
||||
if mapped == *column {
|
||||
continue;
|
||||
}
|
||||
if next.columns.iter().any(|column| {
|
||||
*column == mapped
|
||||
|| matches!((column,&mapped),(Column::FaceAppearance(a),Column::FaceAppearance(b)) if a.body == b.body && a.name == b.name)
|
||||
}) {
|
||||
return Err(DocError::Invalid("configuration copy target collides with an existing column".into()));
|
||||
}
|
||||
if next.columns.len() >= super::MAX_COLUMNS {
|
||||
return Err(DocError::Invalid("configuration copy exceeds column limit".into()));
|
||||
}
|
||||
next.columns.push(mapped);
|
||||
for (source, destination) in self.rows.iter().zip(&mut next.rows) {
|
||||
let value = source.cells.get(index).cloned().unwrap_or_default();
|
||||
let value = if matches!(column, Column::Param(_)) { remap_expression(&value, &maps.parameters) } else { value };
|
||||
if value.len() > 4096 {
|
||||
return Err(DocError::Invalid("configuration copied value exceeds length limit".into()));
|
||||
}
|
||||
destination.cells.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
for rule in &self.rules {
|
||||
if let Some(mut copied) = rule.clone_for_targets(maps) {
|
||||
if next.rules.len() >= super::MAX_RULES {
|
||||
return Err(DocError::Invalid("configuration copy exceeds rule limit".into()));
|
||||
}
|
||||
let stem: String = rule.id.chars().take(40).collect();
|
||||
copied.id = (1..=super::MAX_RULES + 1)
|
||||
.map(|i| format!("{stem}_copy{i}"))
|
||||
.find(|id| !next.rules.iter().any(|rule| rule.id == *id))
|
||||
.ok_or_else(|| DocError::Invalid("no configuration rule ID available".into()))?;
|
||||
copied.name = format!("{} (copy)", rule.name.chars().take(120).collect::<String>());
|
||||
next.rules.push(copied);
|
||||
}
|
||||
}
|
||||
super::validate_rules_json(&serde_json::json!({"configs":{"rules":next.rules}}))?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{ConfigFaceTarget, ConfigRow, ConfigRule, RuleCondition, RuleEffect};
|
||||
#[test]
|
||||
fn clone_preserves_source_variations_and_remaps_only_typed_targets_and_tokens() {
|
||||
let mut table = ConfigTable {
|
||||
columns: vec![
|
||||
Column::Param("d1".into()),
|
||||
Column::Param("d2".into()),
|
||||
Column::Appearance("Old".into()),
|
||||
Column::ComponentAppearance(2),
|
||||
Column::FaceAppearance(ConfigFaceTarget {
|
||||
body: "Old".into(),
|
||||
origin_feature: 3,
|
||||
name: "F3:+z".into(),
|
||||
point: solvecraft_geom::Vec3::new(1., 1., 2.),
|
||||
}),
|
||||
],
|
||||
rows: vec![ConfigRow {
|
||||
name: "Default".into(),
|
||||
cells: vec!["d1 * 2 + d2".into(), "4 mm".into(), "d1".into(), String::new(), String::new()],
|
||||
}],
|
||||
active: Some("Default".into()),
|
||||
rules: vec![ConfigRule {
|
||||
id: "Rule1".into(),
|
||||
name: "Rule1".into(),
|
||||
enabled: true,
|
||||
length_unit: Some("mm".into()),
|
||||
condition: RuleCondition::Suppression { feature: 3, value: false },
|
||||
effect: RuleEffect::DriveParameter { parameter: "d1".into(), expression: "d2 * 2 + d1".into() },
|
||||
}],
|
||||
};
|
||||
let original = table.clone();
|
||||
let maps = ConfigCloneMaps {
|
||||
features: BTreeMap::from([(3, 7)]),
|
||||
components: BTreeMap::from([(2, 8)]),
|
||||
bodies: BTreeMap::from([("Old".into(), "New".into())]),
|
||||
parameters: BTreeMap::from([("d1".into(), "copy_d1".into()), ("d2".into(), "copy_d2".into())]),
|
||||
};
|
||||
table.clone_targets(&maps).unwrap();
|
||||
assert_eq!(&table.columns[..5], &original.columns);
|
||||
assert_eq!(&table.rows[0].cells[..5], &original.rows[0].cells);
|
||||
assert_eq!(table.columns[5], Column::Param("copy_d1".into()));
|
||||
assert_eq!(table.rows[0].cells[5], "copy_d1 * 2 + copy_d2");
|
||||
assert_eq!(table.rows[0].cells[7], "d1"); // appearance strings are never parameter expressions
|
||||
assert_eq!(table.columns[8], Column::ComponentAppearance(8));
|
||||
let Column::FaceAppearance(face) = &table.columns[9] else { panic!() };
|
||||
assert_eq!(face.name, "F7:+z");
|
||||
assert_eq!(face.body, "New");
|
||||
assert_eq!(table.rules[0], original.rules[0]);
|
||||
assert!(
|
||||
matches!(&table.rules[1].effect,RuleEffect::DriveParameter{parameter,expression} if parameter=="copy_d1" && expression=="copy_d2 * 2 + copy_d1")
|
||||
);
|
||||
assert!(matches!(table.rules[1].condition, RuleCondition::Suppression { feature: 7, .. }));
|
||||
assert_eq!(table.active, original.active);
|
||||
let chained = BTreeMap::from([("d1".into(), "d2".into()), ("d2".into(), "d3".into())]);
|
||||
assert_eq!(remap_expression("d1 * 2 + d2", &chained), "d2 * 2 + d3");
|
||||
let collision = ConfigCloneMaps { parameters: chained, ..Default::default() };
|
||||
let mut original_copy = original.clone();
|
||||
assert!(original_copy.clone_targets(&collision).is_err());
|
||||
assert_eq!(original_copy, original);
|
||||
}
|
||||
#[test]
|
||||
fn foreign_strict_lineage_failure_is_atomic() {
|
||||
let mut table = ConfigTable {
|
||||
columns: vec![
|
||||
Column::Param("width".into()),
|
||||
Column::FaceAppearance(ConfigFaceTarget {
|
||||
body: "Foreign".into(),
|
||||
origin_feature: 3,
|
||||
name: "F3:+z".into(),
|
||||
point: solvecraft_geom::Vec3::ZERO,
|
||||
}),
|
||||
],
|
||||
rows: vec![ConfigRow { name: "Default".into(), cells: vec!["2 mm".into(), String::new()] }],
|
||||
..Default::default()
|
||||
};
|
||||
let original = table.clone();
|
||||
let maps = ConfigCloneMaps {
|
||||
bodies: BTreeMap::from([("Foreign".into(), "Copied".into())]),
|
||||
parameters: BTreeMap::from([("width".into(), "copied_width".into())]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(table.clone_targets(&maps).unwrap_err().to_string().contains("certified"));
|
||||
assert_eq!(table, original);
|
||||
}
|
||||
}
|
||||
@@ -377,6 +377,44 @@ impl ConfigRule {
|
||||
}
|
||||
});
|
||||
}
|
||||
pub(super) fn clone_for_targets(&self, maps: &super::ConfigCloneMaps) -> Option<Self> {
|
||||
let mapped = match &self.effect {
|
||||
RuleEffect::DriveParameter { parameter, .. }
|
||||
| RuleEffect::ParameterRange { parameter, .. }
|
||||
| RuleEffect::AllowParameterValues { parameter, .. } => maps.parameters.contains_key(parameter),
|
||||
RuleEffect::DriveSuppression { feature, .. } => maps.features.contains_key(feature),
|
||||
};
|
||||
if !mapped {
|
||||
return None;
|
||||
}
|
||||
let mut copy = self.clone();
|
||||
copy.visit_parameters(&mut |text, expression| {
|
||||
*text = if expression {
|
||||
super::clone::remap_expression(text, &maps.parameters)
|
||||
} else {
|
||||
maps.parameters.get(text).cloned().unwrap_or_else(|| text.clone())
|
||||
};
|
||||
});
|
||||
fn remap(condition: &mut RuleCondition, maps: &super::ConfigCloneMaps) {
|
||||
match condition {
|
||||
RuleCondition::Suppression { feature, .. } => {
|
||||
*feature = maps.features.get(feature).copied().unwrap_or(*feature);
|
||||
}
|
||||
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
|
||||
for condition in conditions {
|
||||
remap(condition, maps);
|
||||
}
|
||||
}
|
||||
RuleCondition::Not { condition } => remap(condition, maps),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
remap(&mut copy.condition, maps);
|
||||
if let RuleEffect::DriveSuppression { feature, .. } = &mut copy.effect {
|
||||
*feature = maps.features.get(feature).copied().unwrap_or(*feature);
|
||||
}
|
||||
Some(copy)
|
||||
}
|
||||
fn visit_parameters(&mut self, visit: &mut impl FnMut(&mut String, bool)) {
|
||||
fn walk(c: &mut RuleCondition, visit: &mut impl FnMut(&mut String, bool)) {
|
||||
match c {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Stable configuration style targets. Display indices and placement names are never saved.
|
||||
use super::Column;
|
||||
use crate::{DocError, Document, FeatureKind, ModelState, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigFaceTarget {
|
||||
pub body: String,
|
||||
pub origin_feature: u64,
|
||||
pub name: String,
|
||||
/// Definition-frame point for diagnostics and rendering; never a resolver fallback.
|
||||
pub point: Vec3,
|
||||
}
|
||||
impl ConfigFaceTarget {
|
||||
pub fn key(&self) -> String {
|
||||
// Length-prefix encoding keeps punctuation in body/name unambiguous.
|
||||
format!("{}:{}{}", self.body.len(), self.body, self.name)
|
||||
}
|
||||
pub fn matches(&self, face: &crate::appearance::FaceLook) -> bool {
|
||||
face.body == self.body && face.name.as_ref().is_some_and(|name| crate::naming::strip_piece(name) == self.name)
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.body.is_empty()
|
||||
|| self.body.len() > 128
|
||||
|| self.name.is_empty()
|
||||
|| self.name.len() > 512
|
||||
|| self.origin_feature == 0
|
||||
|| [self.point.x, self.point.y, self.point.z].iter().any(|v| !v.is_finite() || v.abs() > 1e9)
|
||||
{
|
||||
return Err(DocError::Invalid("invalid configuration face target".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Budget malformed style nodes before decoding their typed representation.
|
||||
pub fn validate_styles_json(value: &Value) -> Result<()> {
|
||||
if let Some(columns) = value.get("configs").and_then(|v| v.get("columns")).and_then(Value::as_array) {
|
||||
if columns.len() > super::MAX_COLUMNS {
|
||||
return Err(DocError::Invalid("too many configuration columns".into()));
|
||||
}
|
||||
for column in columns {
|
||||
if let Some(target) = column.get("face_appearance") {
|
||||
let fields = target.as_object().ok_or_else(|| DocError::Invalid("face target must be an object".into()))?;
|
||||
if fields.len() != 4
|
||||
|| fields.keys().any(|key| !["body", "origin_feature", "name", "point"].contains(&key.as_str()))
|
||||
|| fields.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128)
|
||||
|| fields.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512)
|
||||
|| fields.get("origin_feature").and_then(Value::as_u64).is_none_or(|id| id == 0)
|
||||
|| fields.get("point").and_then(Value::as_array).is_none_or(|point| {
|
||||
point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9))
|
||||
})
|
||||
{
|
||||
return Err(DocError::Invalid("invalid or oversized configuration face target".into()));
|
||||
}
|
||||
}
|
||||
if let Some(id) = column.get("component_appearance")
|
||||
&& id.as_u64().is_none_or(|id| id == 0)
|
||||
{
|
||||
return Err(DocError::Invalid("configuration component ID must be nonzero".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Certify named generator lineage; generic index-derived naming is unsupported.
|
||||
pub fn certify_config_face(&self, target: &ConfigFaceTarget) -> Result<()> {
|
||||
target.validate()?;
|
||||
let prefix = format!("F{}:", target.origin_feature);
|
||||
let role = target.name.strip_prefix(&prefix).ok_or_else(|| DocError::Invalid("face lineage does not match its feature".into()))?;
|
||||
if crate::naming::strip_piece(&target.name) != target.name {
|
||||
return Err(DocError::Invalid("configure a face lineage, not a numbered split piece".into()));
|
||||
}
|
||||
let feature = self.feature(target.origin_feature).ok_or_else(|| DocError::Unknown("face origin feature is gone".into()))?;
|
||||
let valid = match &feature.kind {
|
||||
FeatureKind::Box { .. } => ["+x", "-x", "+y", "-y", "+z", "-z"].contains(&role),
|
||||
FeatureKind::Cylinder { .. } => ["top", "bottom", "side"].contains(&role),
|
||||
// Curved side lineage currently uses centroid/nearest-curve heuristics;
|
||||
// certify caps only until the naming layer supplies proven curve provenance.
|
||||
FeatureKind::Extrude { extent, .. } => {
|
||||
extent.from.is_none()
|
||||
&& extent.start_offset.as_deref().is_none_or(|value| {
|
||||
crate::expr::is_literal(value)
|
||||
&& crate::expr::eval_with(value, &|name| Err(DocError::Unknown(name.into())))
|
||||
.ok()
|
||||
.and_then(|value| value.to_kind(crate::expr::Kind::Length).ok())
|
||||
.is_some_and(|value| value.abs() < 1e-12)
|
||||
})
|
||||
&& extent.distance2.is_none()
|
||||
&& ["start", "top", "bottom"].contains(&role)
|
||||
}
|
||||
FeatureKind::Revolve { angle2, symmetric, .. } => angle2.is_none() && !symmetric && ["start", "end"].contains(&role),
|
||||
_ => false,
|
||||
};
|
||||
if !valid {
|
||||
return Err(DocError::Invalid("this face has no supported stable semantic lineage".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Verify the current named pieces describe one surface, not ambiguous generated roles.
|
||||
pub fn configuration_face_indices(&self, body: &crate::ModelBody, target: &ConfigFaceTarget) -> Result<Vec<usize>> {
|
||||
self.certify_config_face(target)?;
|
||||
if body.body.is_mesh() || body.definition_name() != target.body {
|
||||
return Err(DocError::Invalid("configured face definition does not match".into()));
|
||||
}
|
||||
let names = crate::naming::face_names(body);
|
||||
let indices: Vec<usize> =
|
||||
names.iter().enumerate().filter(|(_, name)| crate::naming::strip_piece(name) == target.name).map(|(index, _)| index).collect();
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown(format!("configured face `{}` on `{}` no longer exists", target.name, target.body)));
|
||||
}
|
||||
if target.name.ends_with(":side") {
|
||||
if indices.len() != 1 {
|
||||
return Err(DocError::Invalid("split curved face lineage is not yet certified".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
// Caps and box faces must share an oriented plane. This rejects generic cap-role
|
||||
// collisions while permitting the coplanar pieces of a split supported face.
|
||||
let mesh = body.mesh();
|
||||
let mut plane: Option<(Vec3, Vec3)> = None;
|
||||
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
if indices.binary_search(&(*face as usize)).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Some([a, b, c]) = mesh.tri(triangle) else { continue };
|
||||
let Some(normal) = (b - a).cross(c - a).normalized() else { continue };
|
||||
if let Some((point, base)) = plane {
|
||||
let magnitude = [point.x, point.y, point.z, a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z]
|
||||
.iter()
|
||||
.fold(body.body.size(), |scale, value| scale.max(value.abs()));
|
||||
let tolerance = (magnitude * f64::EPSILON * 128.0).max(1e-7);
|
||||
if normal.dot(base) < 1.0 - 1e-6 || [a, b, c].iter().any(|vertex| (*vertex - point).dot(base).abs() > tolerance) {
|
||||
return Err(DocError::Invalid("face lineage names more than one oriented surface".into()));
|
||||
}
|
||||
} else {
|
||||
plane = Some((a, normal));
|
||||
}
|
||||
}
|
||||
if plane.is_none() {
|
||||
return Err(DocError::Invalid("configured face has no valid surface".into()));
|
||||
}
|
||||
Ok(indices)
|
||||
}
|
||||
pub fn config_face_look(&self, target: &ConfigFaceTarget) -> Option<crate::appearance::Look> {
|
||||
self.appearances.faces.iter().rev().find(|face| target.matches(face)).map(|face| face.look.clone())
|
||||
}
|
||||
/// Validate against the final rebuilt definition state. Missing names never pick nearby faces.
|
||||
pub fn configuration_style_status(&self, state: &ModelState) -> Result<Vec<serde_json::Value>> {
|
||||
let mut result = Vec::new();
|
||||
for column in &self.configs.columns {
|
||||
match column {
|
||||
Column::ComponentAppearance(id) => {
|
||||
let component =
|
||||
self.components.iter().find(|c| c.id == *id).ok_or_else(|| DocError::Unknown(format!("component {id} is gone")))?;
|
||||
result.push(serde_json::json!({"key":self.config_key(column),"target":component.name,"shared":true,"dormant":false}));
|
||||
}
|
||||
Column::FaceAppearance(target) => {
|
||||
self.certify_config_face(target)?;
|
||||
let dormant = match state.body(&target.body) {
|
||||
None if self.feature(target.origin_feature).is_some_and(|feature| feature.suppressed) => true,
|
||||
None => return Err(DocError::Unknown(format!("configured face body `{}` is gone", target.body))),
|
||||
Some(body) => {
|
||||
self.configuration_face_indices(body, target)?;
|
||||
false
|
||||
}
|
||||
};
|
||||
result.push(serde_json::json!({"key":self.config_key(column),"target":format!("{} / {}",target.body,target.name),"shared":true,"dormant":dormant}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
/// Maintain columns and row cell alignment when component definitions are deleted.
|
||||
pub fn remove_configuration_components(&mut self, ids: &[u64]) {
|
||||
let keep: Vec<bool> = self.configs.columns.iter().map(|c| !matches!(c,Column::ComponentAppearance(id) if ids.contains(id))).collect();
|
||||
let mut mask = keep.iter();
|
||||
self.configs.columns.retain(|_| mask.next().copied().unwrap_or(false));
|
||||
for row in &mut self.configs.rows {
|
||||
let mut mask = keep.iter();
|
||||
row.cells.retain(|_| mask.next().copied().unwrap_or(false));
|
||||
}
|
||||
self.appearances.components.retain(|id, _| !ids.contains(id));
|
||||
}
|
||||
}
|
||||
@@ -161,6 +161,85 @@ pub enum AxisRef {
|
||||
Line { origin: Vec3, dir: Vec3 },
|
||||
}
|
||||
|
||||
/// Persistent cylindrical Emboss selection, authored in the feature component frame.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EmbossFace {
|
||||
pub body: String,
|
||||
pub at: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub path: Vec<u64>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub author_path: Vec<u64>,
|
||||
}
|
||||
|
||||
impl EmbossFace {
|
||||
/// The selected sketch placement, shared by evaluation and interactive guides.
|
||||
pub fn sketch_frame(&self, doc: &Document, author: u64, sketch_owner: u64, body_feature: u64) -> Result<crate::Mat> {
|
||||
let owner = doc.body_component(&self.body, body_feature);
|
||||
if sketch_owner == owner {
|
||||
return Self::component_frame(doc, owner, &self.path);
|
||||
}
|
||||
if sketch_owner == author {
|
||||
return Self::component_frame(doc, author, &self.author_path);
|
||||
}
|
||||
let path = doc.first_path(sketch_owner);
|
||||
let mut component = sketch_owner;
|
||||
for _ in 0..64 {
|
||||
if component == 0 {
|
||||
break;
|
||||
}
|
||||
let mut occurrences = doc.occurrences.iter().filter(|o| o.component == component);
|
||||
let occurrence = occurrences.next().ok_or_else(|| DocError::Invalid("sketch placement no longer exists".into()))?;
|
||||
if occurrences.next().is_some() {
|
||||
return Err(DocError::Invalid("sketch placement is ambiguous".into()));
|
||||
}
|
||||
component = occurrence.parent;
|
||||
}
|
||||
if component != 0 {
|
||||
return Err(DocError::Invalid("sketch placement exceeds depth limit".into()));
|
||||
}
|
||||
Self::component_frame(doc, sketch_owner, &path)
|
||||
}
|
||||
|
||||
/// Resolve this exact placement without expanding every assembly instance.
|
||||
pub fn body_frame(&self, doc: &Document, feature: u64) -> Result<crate::Mat> {
|
||||
let owner = doc.body_component(&self.body, feature);
|
||||
let mut frame = Self::component_frame(doc, owner, &self.path)?;
|
||||
if let Some(offset) = doc.body_offsets.get(&self.body) {
|
||||
frame = crate::mat_mul(&frame, offset);
|
||||
}
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
/// Resolve one saved component path without expanding other occurrences.
|
||||
pub fn component_frame(doc: &Document, owner: u64, path: &[u64]) -> Result<crate::Mat> {
|
||||
if path.len() > 64 || doc.occurrences.len() > 100_000 {
|
||||
return Err(DocError::Invalid("selected cylinder path exceeds work limits".into()));
|
||||
}
|
||||
let mut frame = crate::IDENTITY;
|
||||
let mut component = 0;
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for id in path {
|
||||
if *id == 0 || !seen.insert(*id) {
|
||||
return Err(DocError::Invalid("invalid selected cylinder path".into()));
|
||||
}
|
||||
let occurrence = doc
|
||||
.occurrences
|
||||
.iter()
|
||||
.find(|o| o.id == *id && o.parent == component)
|
||||
.ok_or_else(|| DocError::Invalid("selected cylinder placement no longer exists".into()))?;
|
||||
frame = crate::mat_mul(&frame, &occurrence.transform);
|
||||
component = occurrence.component;
|
||||
}
|
||||
if path.is_empty() {
|
||||
frame = doc.component_transform(owner);
|
||||
} else if component != owner {
|
||||
return Err(DocError::Invalid("selected cylinder path belongs to another component".into()));
|
||||
}
|
||||
Ok(frame)
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature definitions.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
@@ -437,13 +516,15 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
targets: Vec<String>,
|
||||
},
|
||||
/// Raise (emboss) or sink (deboss) sketch profiles on the planar face they lie on.
|
||||
/// Raise or sink profiles on a planar face, or wrap them on a selected cylindrical wall.
|
||||
Emboss {
|
||||
sketch: u64,
|
||||
profiles: ProfileSel,
|
||||
depth: String,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
deboss: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
face: Option<EmbossFace>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
targets: Vec<String>,
|
||||
},
|
||||
@@ -1220,6 +1301,7 @@ impl FeatureKind {
|
||||
FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(),
|
||||
FeatureKind::Revolve { to: Some(p), .. } => vec![p],
|
||||
FeatureKind::Thread { face, .. } => vec![face],
|
||||
FeatureKind::Emboss { face: Some(face), .. } => vec![&mut face.at],
|
||||
FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
@@ -1255,6 +1337,9 @@ impl FeatureKind {
|
||||
if let Some(d) = &extent.taper {
|
||||
v.push(d);
|
||||
}
|
||||
if let Some(d) = &extent.to_offset {
|
||||
v.push(d);
|
||||
}
|
||||
}
|
||||
FeatureKind::Revolve { angle, angle2, .. } => {
|
||||
v.push(angle);
|
||||
@@ -1595,17 +1680,12 @@ impl Document {
|
||||
let mut st = state.clone();
|
||||
for b in &mut st.bodies {
|
||||
let c = self.body_color(b);
|
||||
let look = self.model_body_look(b);
|
||||
let faces: Vec<solvecraft_kernel::FacePaint> = self
|
||||
.face_colors(b)
|
||||
.into_iter()
|
||||
.map(|(face, l)| solvecraft_kernel::FacePaint { face, color: l.rgb(), opacity: l.opacity as f32 })
|
||||
.collect();
|
||||
let opacity = look
|
||||
.map(|l| l.opacity as f32)
|
||||
.or_else(|| b.body.paint().map(|p| p.opacity))
|
||||
.or_else(|| b.imported.as_ref().and_then(|i| i.opacity))
|
||||
.unwrap_or(1.0);
|
||||
let opacity = self.model_body_opacity(b) as f32;
|
||||
let paint = solvecraft_kernel::Paint { opacity, faces };
|
||||
if c != b.body.color() || b.body.paint() != Some(&paint) {
|
||||
b.body = b.body.clone().with_color(c).with_paint(Some(paint));
|
||||
@@ -1752,9 +1832,13 @@ impl Document {
|
||||
.map(|c| match c {
|
||||
crate::config::Column::Suppress(id) => !gone.contains(id),
|
||||
crate::config::Column::Param(name) => !gone_params.contains(name),
|
||||
crate::config::Column::FaceAppearance(target) => !gone.contains(&target.origin_feature),
|
||||
_ => true,
|
||||
})
|
||||
.collect();
|
||||
self.appearances
|
||||
.faces
|
||||
.retain(|face| !face.strict_name || !face.name.as_ref().is_some_and(|name| gone.iter().any(|id| name.starts_with(&format!("F{id}:")))));
|
||||
if keep.contains(&false) {
|
||||
let mut k = keep.iter();
|
||||
self.configs.columns.retain(|_| k.next().copied().unwrap_or(true));
|
||||
|
||||
+33
-12
@@ -632,7 +632,10 @@ impl Model {
|
||||
let mut warning = None;
|
||||
// Face references by name: move each face point onto its named face.
|
||||
let renamed;
|
||||
let f = if f.face_names.iter().any(|n| !n.is_empty()) {
|
||||
// Cylindrical emboss resolves its named face in the selected body's frame
|
||||
// and certifies an analytic anchor; mesh projection here would undo that.
|
||||
let explicit_emboss = matches!(&f.kind, FeatureKind::Emboss { face: Some(_), .. });
|
||||
let f = if !explicit_emboss && f.face_names.iter().any(|n| !n.is_empty()) {
|
||||
let mut g = f.clone();
|
||||
let mut notes = Vec::new();
|
||||
for (p, n) in g.kind.face_points_mut().into_iter().zip(&f.face_names) {
|
||||
@@ -785,6 +788,11 @@ fn new_name(state: &mut ModelState, f: &Feature, k: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_body_name(state: &ModelState, f: &Feature, k: usize, source: &str) -> String {
|
||||
let base = f.body_names.get(k).filter(|name| !name.trim().is_empty()).map(String::as_str).unwrap_or(source);
|
||||
unique_body_name(state, base)
|
||||
}
|
||||
|
||||
/// Add tool bodies to the model according to the operation.
|
||||
fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation, targets: &[String]) -> Result<()> {
|
||||
// Participants: the feature's own targets, else its explicit participants, else the bodies
|
||||
@@ -876,7 +884,7 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation
|
||||
}
|
||||
|
||||
/// Distance that reaches through every target body from the sketch plane (either side).
|
||||
fn through_all_distance(st: &ModelState, plane: &Plane, targets: &[String]) -> Result<f64> {
|
||||
pub fn through_all_distance(st: &ModelState, plane: &Plane, targets: &[String]) -> Result<f64> {
|
||||
let mut far: f64 = 0.0;
|
||||
let mut any = false;
|
||||
for b in st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)) {
|
||||
@@ -1415,11 +1423,12 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
|
||||
if extent.distance2.is_some() || extent.start_offset.is_some() || extent.direction == crate::Direction::Symmetric {
|
||||
return Err(DocError::Invalid("not supported yet: taper with two-sided, symmetric or offset extents".into()));
|
||||
}
|
||||
let sign = if extent.direction == crate::Direction::Negative { -1.0 } else { 1.0 };
|
||||
let sign = if extent.to.is_none() && extent.direction == crate::Direction::Negative { -1.0 } else { 1.0 };
|
||||
let (len, sign) = if d < 0.0 { (-d, -sign) } else { (d, sign) };
|
||||
let start_plane = ss.plane.offset(from);
|
||||
return Ok(regions
|
||||
.iter()
|
||||
.map(|r| kernel::extrude_tapered(&ss.plane, r, len, sign, taper))
|
||||
.map(|r| kernel::extrude_tapered(&start_plane, r, len, sign, taper))
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?);
|
||||
}
|
||||
let off = from
|
||||
@@ -2092,7 +2101,14 @@ fn replay(
|
||||
}
|
||||
}
|
||||
// New bodies from a pattern join their source when they touch it (as one feature would).
|
||||
apply_op(st, f, copies, op, &targets)?;
|
||||
if f.body_names.is_empty() {
|
||||
apply_op(st, f, copies, op, &targets)?;
|
||||
} else {
|
||||
let start = st.bodies.iter().filter(|b| b.feature == f.id).count();
|
||||
let mut named = f.clone();
|
||||
named.body_names = f.body_names.iter().skip(start).cloned().collect();
|
||||
apply_op(st, &named, copies, op, &targets)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2105,7 +2121,7 @@ mod surface;
|
||||
/// Height above `plane` (along its normal) of the object at `p` as seen from `centre`: the face
|
||||
/// there if it is planar and parallel (its plane), else where a ray from `centre` along the
|
||||
/// normal meets the body there, else the point itself.
|
||||
fn object_height(st: &ModelState, plane: &Plane, centre: Vec3, p: Vec3) -> f64 {
|
||||
pub fn object_height(st: &ModelState, plane: &Plane, centre: Vec3, p: Vec3) -> f64 {
|
||||
let n = plane.normal();
|
||||
let h = |q: Vec3| (q - plane.origin).dot(n);
|
||||
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
|
||||
@@ -2367,7 +2383,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
}
|
||||
doc.apply_dimension_values(vals, &mut sk)?;
|
||||
let mut report = solve(&mut sk);
|
||||
if !sk.offsets.is_empty() {
|
||||
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
|
||||
// Source dimensions may have moved curves during the solve.
|
||||
sk.refresh_offsets()?;
|
||||
report = solve(&mut sk);
|
||||
@@ -2626,12 +2642,14 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
return replay(doc, vals, f, st, features, &mats, warning);
|
||||
}
|
||||
// Bodies: each copy is a new body named after its source.
|
||||
let mut output = 0;
|
||||
for n in bodies {
|
||||
let mb = st.body(n).cloned().ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
|
||||
for m in &mats {
|
||||
let copy = kernel::transform_matrix(&mb.body, *m)?;
|
||||
let name = unique_body_name(st, &mb.name);
|
||||
let name = copy_body_name(st, f, output, &mb.name);
|
||||
st.bodies.push(ModelBody::new(name, copy, f.id));
|
||||
output += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -2642,6 +2660,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
return replay(doc, vals, f, st, features, &[mirror_matrix(&pl)], warning);
|
||||
}
|
||||
let m = mirror_matrix(&pl);
|
||||
let mut output = 0;
|
||||
for n in bodies {
|
||||
let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { continue };
|
||||
@@ -2655,13 +2674,15 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
*slot = ModelBody::new(mb.name.clone(), p, mb.feature);
|
||||
}
|
||||
} else {
|
||||
let name = unique_body_name(st, &mb.name);
|
||||
let name = copy_body_name(st, f, output, &mb.name);
|
||||
st.bodies.push(ModelBody::new(name, p, f.id));
|
||||
output += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let name = unique_body_name(st, &mb.name);
|
||||
let name = copy_body_name(st, f, output, &mb.name);
|
||||
st.bodies.push(ModelBody::new(name, mirrored, f.id));
|
||||
output += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -2857,8 +2878,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::MeshImport { meshes, .. } => {
|
||||
for (name, b) in mesh_bodies(meshes)? {
|
||||
let name = unique_body_name(st, &name);
|
||||
for (k, (name, b)) in mesh_bodies(meshes)?.into_iter().enumerate() {
|
||||
let name = copy_body_name(st, f, k, &name);
|
||||
st.bodies.push(ModelBody::new(name, b, f.id));
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -2,18 +2,65 @@
|
||||
//! kernel's extrude, boolean, offset and transform operations).
|
||||
|
||||
use super::*;
|
||||
use crate::EmbossFace;
|
||||
use crate::apply_point;
|
||||
use solvecraft_geom::{Loop2, Seg2};
|
||||
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
|
||||
match &f.kind {
|
||||
FeatureKind::Emboss { sketch, profiles, depth, deboss, targets } => {
|
||||
FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
|
||||
let regions = select_profiles(ss, profiles)?;
|
||||
let d = val(vals, depth, Kind::Length)?;
|
||||
if !(d > 0.0) {
|
||||
return Err(DocError::Invalid("the emboss depth must be positive".into()));
|
||||
}
|
||||
if let Some(face) = face {
|
||||
if !targets.is_empty() && (targets.len() != 1 || targets.first() != Some(&face.body)) {
|
||||
return Err(DocError::Invalid("cylinder emboss must keep its selected target body".into()));
|
||||
}
|
||||
let i =
|
||||
st.bodies.iter().position(|b| b.name == face.body).ok_or_else(|| DocError::Unknown(format!("emboss body `{}`", face.body)))?;
|
||||
let mb = st.bodies.get(i).cloned().ok_or_else(|| DocError::Invalid("emboss body".into()))?;
|
||||
let body_frame = face.body_frame(doc, mb.feature)?;
|
||||
let inverse = crate::mat_inverse(&body_frame).ok_or_else(|| DocError::Invalid("invalid cylinder body frame".into()))?;
|
||||
let sketch_owner = doc.feature(ss.feature).map(|f| f.component).unwrap_or(0);
|
||||
let author_frame = EmbossFace::component_frame(doc, f.component, &face.author_path)?;
|
||||
let sketch_frame = face.sketch_frame(doc, f.component, sketch_owner, mb.feature)?;
|
||||
let sketch_transform = crate::mat_mul(&inverse, &sketch_frame);
|
||||
let plane = Plane::new(
|
||||
crate::apply_point(&sketch_transform, ss.plane.origin),
|
||||
crate::apply_vector(&sketch_transform, ss.plane.x),
|
||||
crate::apply_vector(&sketch_transform, ss.plane.y),
|
||||
)
|
||||
.ok_or_else(|| DocError::Invalid("invalid cylinder sketch frame".into()))?;
|
||||
let mut at = crate::apply_point(&crate::mat_mul(&inverse, &author_frame), face.at);
|
||||
let name = f
|
||||
.face_names
|
||||
.first()
|
||||
.filter(|name| !name.is_empty())
|
||||
.ok_or_else(|| DocError::Invalid("selected cylinder face has no persistent identity".into()))?;
|
||||
{
|
||||
let mut scoped = ModelState::default();
|
||||
scoped.bodies.push(mb.clone());
|
||||
let (picked, split) = crate::naming::point_on_face(&scoped, name, at)
|
||||
.ok_or_else(|| DocError::Invalid("selected cylinder face no longer exists".into()))?;
|
||||
if split {
|
||||
return Err(DocError::Invalid("selected cylinder face was split".into()));
|
||||
}
|
||||
let (index, _, _) = crate::project::nearest_face(&mb.mesh(), picked)
|
||||
.ok_or_else(|| DocError::Invalid("selected cylinder face cannot be resolved".into()))?;
|
||||
if crate::naming::face_names(&mb).get(index as usize) != Some(name) {
|
||||
return Err(DocError::Invalid("selected cylinder face is ambiguous".into()));
|
||||
}
|
||||
at = kernel::cylinder_emboss_anchor(&mb.body, index as usize, picked)?;
|
||||
}
|
||||
let result = kernel::emboss_cylinder_at(&mb.body, at, &plane, ®ions, if *deboss { -d } else { d })?;
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, result, mb.feature);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let (a, b) = if *deboss { (-d, 0.0) } else { (0.0, d) };
|
||||
let tools = kernel::extrude(&ss.plane, ®ions, a, b)?;
|
||||
if st.bodies.is_empty() {
|
||||
|
||||
+290
-4
@@ -25,13 +25,16 @@
|
||||
//! instead of encountering an unknown column variant.
|
||||
//! - 8: ordered configuration rules and persistent signed line-angle, arc-sweep and slot
|
||||
//! dimensions. Version 7 geometry and table values retain their meaning without changes.
|
||||
//! - 9: configured component/semantic-face styles, associative free-form offsets and
|
||||
//! explicit cylindrical emboss targets. Existing version 8 geometry and rules are
|
||||
//! preserved; new fields use defaults rather than guessing a target or an association.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The format this build writes.
|
||||
pub const FORMAT: u32 = 8;
|
||||
pub const FORMAT: u32 = 9;
|
||||
|
||||
pub fn format_string(n: u32) -> String {
|
||||
format!("solvecraft/{n}")
|
||||
@@ -44,7 +47,7 @@ pub fn version_of(s: &str) -> Option<u32> {
|
||||
|
||||
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
|
||||
type JsonStep = fn(&mut Value) -> Result<()>;
|
||||
const JSON_STEPS: [JsonStep; 7] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(())];
|
||||
const JSON_STEPS: [JsonStep; 8] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(())];
|
||||
|
||||
/// Old XZ references retain their exact frame, including nested offset and angled planes.
|
||||
/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
|
||||
@@ -136,6 +139,9 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
}
|
||||
check_numbers(&v, 0)?;
|
||||
crate::config::validate_rules_json(&v)?;
|
||||
crate::config::validate_styles_json(&v)?;
|
||||
validate_emboss_targets(&v)?;
|
||||
validate_freeform_offsets(&v)?;
|
||||
for step in JSON_STEPS.iter().skip(version as usize - 1).take((FORMAT - version) as usize) {
|
||||
step(&mut v)?;
|
||||
}
|
||||
@@ -151,12 +157,125 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name)));
|
||||
}
|
||||
}
|
||||
for feature in &d.features {
|
||||
if let crate::FeatureKind::Sketch { sketch, .. } = &feature.kind {
|
||||
sketch.validate_freeform_offsets().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
|
||||
}
|
||||
}
|
||||
// Fill in what older designs (and hand-edited files) may lack; a no-op otherwise.
|
||||
normalise(&mut d)?;
|
||||
d.format = format_string(FORMAT);
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Validate new explicit targets before typed decoding. Older planar features have none.
|
||||
fn validate_emboss_targets(value: &Value) -> Result<()> {
|
||||
let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) };
|
||||
if features.len() > crate::document::MAX_FEATURES {
|
||||
return Err(DocError::Invalid("document too large".into()));
|
||||
}
|
||||
for feature in features {
|
||||
if feature.get("type").and_then(Value::as_str) != Some("emboss") {
|
||||
continue;
|
||||
}
|
||||
let Some(target) = feature.get("face").filter(|target| !target.is_null()) else { continue };
|
||||
let invalid = || DocError::Invalid("damaged design: an emboss face needs a bounded body name and three finite coordinates".into());
|
||||
let object = target.as_object().ok_or_else(invalid)?;
|
||||
if object.keys().any(|key| !matches!(key.as_str(), "body" | "at" | "path" | "author_path")) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let body = object.get("body").and_then(Value::as_str).ok_or_else(invalid)?;
|
||||
let point = object.get("at").and_then(Value::as_array).ok_or_else(invalid)?;
|
||||
if body.trim().is_empty()
|
||||
|| body.len() > 512
|
||||
|| body.chars().any(char::is_control)
|
||||
|| point.len() != 3
|
||||
|| point.iter().any(|coordinate| coordinate.as_f64().is_none_or(|number| !number.is_finite() || number.abs() > MAX_REAL))
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
for path in ["path", "author_path"].into_iter().filter_map(|key| object.get(key)) {
|
||||
let path = path.as_array().ok_or_else(invalid)?;
|
||||
if path.len() > 64 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for id in path {
|
||||
let id = id.as_u64().filter(|id| *id != 0).ok_or_else(invalid)?;
|
||||
if !ids.insert(id) {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_freeform_offsets(value: &Value) -> Result<()> {
|
||||
let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) };
|
||||
for feature in features {
|
||||
if feature.get("type").and_then(Value::as_str) != Some("sketch") {
|
||||
continue;
|
||||
}
|
||||
let Some(records) = feature.get("sketch").and_then(|sketch| sketch.get("freeform_offsets")) else { continue };
|
||||
let invalid = || DocError::Invalid("damaged design: invalid free-form offset metadata".into());
|
||||
let records = records.as_array().ok_or_else(invalid)?;
|
||||
if records.len() > 128 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut outputs = std::collections::BTreeSet::new();
|
||||
for record in records {
|
||||
let object = record.as_object().ok_or_else(invalid)?;
|
||||
if object.len() != 9
|
||||
|| object.keys().any(|key| {
|
||||
!matches!(
|
||||
key.as_str(),
|
||||
"source" | "output" | "forward" | "distance" | "divisions" | "pieces" | "closed" | "tolerance" | "certificate"
|
||||
)
|
||||
})
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
let source = record.get("source").and_then(Value::as_str).ok_or_else(invalid)?;
|
||||
let output = record.get("output").and_then(Value::as_str).ok_or_else(invalid)?;
|
||||
if [source, output].iter().any(|id| id.is_empty() || id.len() > 512 || id.chars().any(char::is_control))
|
||||
|| source == output
|
||||
|| !outputs.insert(output)
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
let divisions = record.get("divisions").and_then(Value::as_u64).ok_or_else(invalid)?;
|
||||
let pieces = record.get("pieces").and_then(Value::as_u64).ok_or_else(invalid)?;
|
||||
let distance = record.get("distance").and_then(Value::as_f64).ok_or_else(invalid)?;
|
||||
let tolerance = record.get("tolerance").and_then(Value::as_f64).ok_or_else(invalid)?;
|
||||
if !(1..=256).contains(&divisions)
|
||||
|| !divisions.is_power_of_two()
|
||||
|| !(1..=256).contains(&pieces)
|
||||
|| pieces * divisions >= solvecraft_sketch::MAX_SPLINE_POINTS as u64
|
||||
|| !distance.is_finite()
|
||||
|| !(1e-9..=MAX_REAL).contains(&distance.abs())
|
||||
|| !tolerance.is_finite()
|
||||
|| !(tolerance > 0.0 && tolerance <= 0.002)
|
||||
|| record.get("certificate").and_then(Value::as_u64) != Some(1)
|
||||
|| record.get("forward").and_then(Value::as_bool).is_none()
|
||||
|| record.get("closed").and_then(Value::as_bool).is_none()
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
let mut resolved = std::collections::BTreeSet::new();
|
||||
for record in records {
|
||||
let source = record.get("source").and_then(Value::as_str).ok_or_else(invalid)?;
|
||||
let output = record.get("output").and_then(Value::as_str).ok_or_else(invalid)?;
|
||||
if outputs.contains(source) && !resolved.contains(source) {
|
||||
return Err(invalid());
|
||||
}
|
||||
resolved.insert(output);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Largest coordinate or size a design may hold (mm): a kilometre-scale part is already far
|
||||
/// outside what the kernel's tolerances serve.
|
||||
const MAX_REAL: f64 = 1e9;
|
||||
@@ -216,6 +335,173 @@ fn normalise(d: &mut Document) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_eight_to_object_offset_keeps_existing_model_parameter_names() {
|
||||
use crate::{Direction, Extent, FeatureKind, Operation, PlaneRef, ProfileSel};
|
||||
let mut document = Document::new("legacy named extent");
|
||||
document.set_param("offset", "2", Some("mm"), None).unwrap();
|
||||
let sketch = document
|
||||
.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: solvecraft_sketch::Sketch::new() }, None)
|
||||
.unwrap();
|
||||
document
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch,
|
||||
profiles: ProfileSel::All,
|
||||
extent: Extent {
|
||||
distance: "10".into(),
|
||||
direction: Direction::Negative,
|
||||
distance2: None,
|
||||
start_offset: None,
|
||||
through_all: false,
|
||||
taper: Some("0 deg".into()),
|
||||
to: Some(solvecraft_geom::Vec3::new(0.0, 0.0, 10.0)),
|
||||
to_offset: Some("offset + 1 mm".into()),
|
||||
from: Some(solvecraft_geom::Vec3::new(0.0, 0.0, 5.0)),
|
||||
},
|
||||
operation: Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
document
|
||||
.add_feature(
|
||||
FeatureKind::Box {
|
||||
corner: ["0".into(), "0".into(), "0".into()],
|
||||
length: "d1".into(),
|
||||
width: "4".into(),
|
||||
height: "6".into(),
|
||||
operation: Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut legacy: Value = serde_json::from_str(&document.to_json()).unwrap();
|
||||
legacy["format"] = Value::from("solvecraft/8");
|
||||
// Version 8 exposed distance and taper, but had no model slot for To Offset.
|
||||
legacy["features"][1]["param_names"] = serde_json::json!(["d1", "d2"]);
|
||||
legacy["features"][2]["param_names"] = serde_json::json!(["d3", "d4", "d5"]);
|
||||
let old_kind = legacy["features"][1].clone();
|
||||
let upgraded = read(&legacy.to_string()).unwrap();
|
||||
assert_eq!(&upgraded.features[1].param_names[..2], ["d1", "d2"]);
|
||||
assert_eq!(upgraded.features[2].param_names, ["d3", "d4", "d5"]);
|
||||
assert_eq!(upgraded.features[1].param_names.len(), 3);
|
||||
assert_eq!(upgraded.features[1].param_names[2], "d6");
|
||||
let saved: Value = serde_json::from_str(&upgraded.to_json()).unwrap();
|
||||
for field in ["extent", "sketch", "operation", "profiles"] {
|
||||
assert_eq!(saved["features"][1][field], old_kind[field]);
|
||||
}
|
||||
let (values, errors) = upgraded.param_values();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
assert_eq!(values.get("d1").unwrap().v, 10.0);
|
||||
assert_eq!(values.get("d3").unwrap().v, 10.0);
|
||||
assert_eq!(values.get("d6").unwrap().v, 3.0);
|
||||
assert_eq!(read(&upgraded.to_json()).unwrap(), upgraded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_offset_cannot_alias_and_overwrite_its_source_geometry() {
|
||||
use solvecraft_geom::Vec2;
|
||||
use solvecraft_sketch::{CurveKind, Sketch};
|
||||
let mut sketch = Sketch::new();
|
||||
let center = sketch.add_point(Vec2::ZERO, None).unwrap();
|
||||
let major = sketch.add_point(Vec2::new(10.0, 0.0), None).unwrap();
|
||||
let source = sketch.add_curve(CurveKind::Ellipse { c: center, m: major, r: 5.0 }, None).unwrap();
|
||||
let output = sketch.add_freeform_offset(source, true, -1.0).unwrap();
|
||||
assert!(sketch.validate_freeform_offsets().is_ok());
|
||||
if let CurveKind::Spline { pts, .. } = &mut sketch.curves[output].kind {
|
||||
pts[0] = center;
|
||||
} else {
|
||||
panic!("offset must produce a fit spline");
|
||||
}
|
||||
let mut document = Document::new("aliased offset geometry");
|
||||
document.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
|
||||
let error = read(&document.to_json()).unwrap_err().to_string();
|
||||
assert!(error.contains("damaged design"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structurally_valid_offset_records_must_reference_real_sketch_geometry() {
|
||||
let mut document = Document::new("missing offset geometry");
|
||||
document
|
||||
.add_feature(
|
||||
crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch: solvecraft_sketch::Sketch::new() },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut value: Value = serde_json::from_str(&document.to_json()).unwrap();
|
||||
value["features"][0]["sketch"]["freeform_offsets"] = serde_json::json!([{
|
||||
"source":"ellipse", "output":"offset", "forward":true, "distance":2.0,
|
||||
"divisions":8, "pieces":4, "closed":true, "tolerance":0.002, "certificate":1
|
||||
}]);
|
||||
assert!(validate_freeform_offsets(&value).is_ok());
|
||||
let error = read(&value.to_string()).unwrap_err().to_string();
|
||||
assert!(error.contains("damaged design"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeform_offset_metadata_is_bounded_before_typed_decoding() {
|
||||
let valid = serde_json::json!({"source":"ellipse", "output":"offset", "forward":true,
|
||||
"distance":2.0, "divisions":8, "pieces":4, "closed":true,
|
||||
"tolerance":0.002, "certificate":1});
|
||||
let mut malformed = Vec::new();
|
||||
for (key, value) in [
|
||||
("divisions", serde_json::json!(u64::MAX)),
|
||||
("pieces", serde_json::json!(u64::MAX)),
|
||||
("divisions", serde_json::json!(3)),
|
||||
("distance", serde_json::json!(0)),
|
||||
("tolerance", serde_json::json!(0.003)),
|
||||
("certificate", serde_json::json!(2)),
|
||||
("output", serde_json::json!("x".repeat(513))),
|
||||
("source", serde_json::json!("offset")),
|
||||
("unknown", serde_json::json!(true)),
|
||||
] {
|
||||
let mut record = valid.clone();
|
||||
record[key] = value;
|
||||
malformed.push(serde_json::json!([record]));
|
||||
}
|
||||
malformed.push(serde_json::json!(vec![valid.clone(); 129]));
|
||||
malformed.push(serde_json::json!([valid.clone(), valid.clone()]));
|
||||
let mut cycle = valid.clone();
|
||||
cycle["source"] = serde_json::json!("offset");
|
||||
cycle["output"] = serde_json::json!("ellipse");
|
||||
malformed.push(serde_json::json!([valid, cycle]));
|
||||
for records in malformed {
|
||||
let mut value: Value = serde_json::from_str(&Document::new("hostile offset").to_json()).unwrap();
|
||||
value["features"] = serde_json::json!([{"type":"sketch", "sketch":{"freeform_offsets":records}}]);
|
||||
let error = read(&value.to_string()).unwrap_err().to_string();
|
||||
assert!(error.contains("free-form offset metadata"), "{error}");
|
||||
value["format"] = Value::from(format_string(FORMAT + 1));
|
||||
assert!(read(&value.to_string()).unwrap_err().to_string().contains("newer SolveCraft"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_emboss_target_bounds_precede_typed_decoding() {
|
||||
for target in [
|
||||
serde_json::json!({"body":"", "at":[0,0,0]}),
|
||||
serde_json::json!({"body":"Body", "at":[0,0]}),
|
||||
serde_json::json!({"body":"Body", "at":[0,"invalid",0]}),
|
||||
serde_json::json!({"body":"Body", "at":[0,0,0], "unknown":true}),
|
||||
serde_json::json!({"body":"x".repeat(513), "at":[0,0,0]}),
|
||||
serde_json::json!({"body":"Body", "at":[0,0,0], "path":"bad"}),
|
||||
serde_json::json!({"body":"Body", "at":[0,0,0], "path":[0]}),
|
||||
serde_json::json!({"body":"Body", "at":[0,0,0], "path":[1,1]}),
|
||||
serde_json::json!({"body":"Body", "at":[0,0,0], "path":(1..=65).collect::<Vec<u64>>()}),
|
||||
serde_json::json!({"body":"Body", "at":[0,0,0], "author_path":[1,1]}),
|
||||
serde_json::json!({"body":"Body", "at":[0,0,0], "author_path":(1..=65).collect::<Vec<u64>>()}),
|
||||
] {
|
||||
let mut value: Value = serde_json::from_str(&Document::new("hostile target").to_json()).unwrap();
|
||||
value["features"] = serde_json::json!([{"type":"emboss", "face":target}]);
|
||||
let error = read(&value.to_string()).unwrap_err().to_string();
|
||||
assert!(error.contains("emboss face"), "{error}");
|
||||
value["format"] = Value::from(format_string(FORMAT + 1));
|
||||
let error = read(&value.to_string()).unwrap_err().to_string();
|
||||
assert!(error.contains("newer SolveCraft"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_six_configuration_rows_upgrade_without_changing_values_or_features() {
|
||||
use crate::config::{Column, ConfigRow};
|
||||
@@ -269,7 +555,7 @@ mod tests {
|
||||
let roundtrip = read(&value.to_string()).unwrap();
|
||||
assert_eq!(roundtrip, d);
|
||||
assert_eq!(serde_json::to_value(&roundtrip.configs).unwrap(), original_table);
|
||||
assert_eq!(roundtrip.format, "solvecraft/8");
|
||||
assert_eq!(roundtrip.format, format_string(FORMAT));
|
||||
assert!(roundtrip.configuration_matches());
|
||||
value["format"] = Value::from(format_string(FORMAT + 1));
|
||||
value["configs"]["columns"] = serde_json::json!([{"unknown_future_column":"Body"}]);
|
||||
@@ -322,7 +608,7 @@ mod tests {
|
||||
sketch.add_constraint(ConstraintKind::SlotOverallLength { a, b, value: 10.0 }, Some(overall)).unwrap();
|
||||
d.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
|
||||
let mut saved: Value = serde_json::from_str(&d.to_json()).unwrap();
|
||||
assert_eq!(saved["format"], "solvecraft/8");
|
||||
assert_eq!(saved["format"], format_string(FORMAT));
|
||||
assert_eq!(read(&saved.to_string()).unwrap(), d);
|
||||
saved["format"] = Value::from(format_string(FORMAT + 1));
|
||||
saved["features"][0]["sketch"]["constraints"][0]["type"] = Value::from("future_dimension");
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod appearance;
|
||||
mod assembly;
|
||||
pub mod canvas;
|
||||
mod clipboard;
|
||||
pub use clipboard::{clone_expression, remap_topology};
|
||||
pub mod config;
|
||||
pub mod construct;
|
||||
mod document;
|
||||
@@ -28,8 +29,8 @@ pub mod sheet;
|
||||
pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse, mat_mul, rigid};
|
||||
pub use document::*;
|
||||
pub use eval::{
|
||||
EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices,
|
||||
state_in_frame, state_in_frame_at, world_state,
|
||||
EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, object_height, parse_metric_thread,
|
||||
pattern_matrices, state_in_frame, state_in_frame_at, through_all_distance, world_state,
|
||||
};
|
||||
pub use params::{ParamRow, ParamUser, cycles};
|
||||
pub use solvecraft_kernel as kernel;
|
||||
|
||||
@@ -195,11 +195,22 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
|
||||
|| xb.max.z < bb.min.z - tol
|
||||
|| xb.min.z > bb.max.z + tol)
|
||||
};
|
||||
// Independently created solids own their faces even when another body
|
||||
// occupies the same coordinates. Boolean operations still inherit inputs.
|
||||
let independent_solid = matches!(
|
||||
o.feature.kind,
|
||||
FeatureKind::Box { operation: crate::Operation::NewBody, .. }
|
||||
| FeatureKind::Cylinder { operation: crate::Operation::NewBody, .. }
|
||||
| FeatureKind::Sphere { operation: crate::Operation::NewBody, .. }
|
||||
| FeatureKind::Torus { operation: crate::Operation::NewBody, .. }
|
||||
| FeatureKind::Extrude { operation: crate::Operation::NewBody, .. }
|
||||
| FeatureKind::Revolve { operation: crate::Operation::NewBody, .. }
|
||||
);
|
||||
let inputs: Vec<(&ModelBody, FaceIndex, Arc<Vec<String>>)> = o
|
||||
.before
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|x| !x.body.is_mesh() && overlaps(x))
|
||||
.filter(|x| !independent_solid && !x.body.is_mesh() && overlaps(x))
|
||||
.take(64)
|
||||
.map(|x| {
|
||||
let n = face_count(x);
|
||||
|
||||
@@ -65,6 +65,9 @@ impl FeatureKind {
|
||||
if let Some(d) = &mut extent.taper {
|
||||
v.push(("Taper angle", d, A));
|
||||
}
|
||||
if let Some(d) = &mut extent.to_offset {
|
||||
v.push(("To offset", d, L));
|
||||
}
|
||||
}
|
||||
FeatureKind::Revolve { angle, angle2, .. } => {
|
||||
v.push(("Angle", angle, A));
|
||||
|
||||
@@ -486,6 +486,9 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
solvecraft_doc::config::Column::Material(body) | solvecraft_doc::config::Column::Appearance(body) if *body == old => {
|
||||
*body = name.to_string();
|
||||
}
|
||||
solvecraft_doc::config::Column::FaceAppearance(target) if target.body == old => {
|
||||
target.body = name.to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).params("sketches: [ids or names], component"),
|
||||
CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence)
|
||||
.params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]. Copies the selected effective transform, including pending moves"),
|
||||
CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: copied with its own features into a new, independent component; occurrence?, path?: selected source placement; translate?: [x,y,z]"),
|
||||
CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"),
|
||||
CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence)
|
||||
.params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position). Nested occurrence definitions remain shared"),
|
||||
CommandSpec::new("occurrence.ground", "Ground", ground).params("occurrence; grounded?: bool (default toggles)"),
|
||||
@@ -82,7 +82,7 @@ fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str)
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn valid_position(m: &Mat) -> bool {
|
||||
pub(super) fn valid_position(m: &Mat) -> bool {
|
||||
let column = |i: usize| Vec3::new(m[i][0], m[i][1], m[i][2]);
|
||||
let (x, y, z) = (column(0), column(1), column(2));
|
||||
m.iter().flatten().all(|x| x.is_finite())
|
||||
@@ -229,6 +229,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let _ = d.delete_feature(*f);
|
||||
}
|
||||
d.components.retain(|c| !gone.contains(&c.id));
|
||||
d.remove_configuration_components(&gone);
|
||||
d.occurrences.retain(|o| !gone.contains(&o.component) && !gone.contains(&o.parent));
|
||||
let moved_out: Vec<String> = d.body_components.iter().filter(|(_, c)| gone.contains(c)).map(|(b, _)| b.clone()).collect();
|
||||
d.body_components.retain(|_, c| !gone.contains(c));
|
||||
@@ -351,65 +352,7 @@ fn paste_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "copy transform exceeds supported numeric range"));
|
||||
}
|
||||
let parent = occ.as_ref().map(|o| o.parent).unwrap_or(src.parent);
|
||||
let new_id = s.doc_mut().add_component(Some(&format!("{} (copy)", src.name)), parent)?;
|
||||
// Copy the component's features with fresh ids and names; references between them follow.
|
||||
let feats: Vec<solvecraft_doc::Feature> = s.doc.features.iter().filter(|f| f.component == comp).cloned().collect();
|
||||
let mut idmap: std::collections::HashMap<u64, u64> = Default::default();
|
||||
let mut namemap: std::collections::HashMap<String, String> = Default::default();
|
||||
let mut new_ids = Vec::new();
|
||||
for f in &feats {
|
||||
let nid = s.doc_mut().add_feature(f.kind.clone(), Some(&format!("{} (copy)", f.name)))?;
|
||||
idmap.insert(f.id, nid);
|
||||
let nname = s.doc.feature(nid).map(|x| x.name.clone()).unwrap_or_default();
|
||||
namemap.insert(f.name.clone(), nname);
|
||||
new_ids.push(nid);
|
||||
}
|
||||
for nid in &new_ids {
|
||||
let Some(f) = s.doc.feature(*nid).cloned() else { continue };
|
||||
let mut v = serde_json::to_value(&f.kind).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
remap(&mut v, &idmap, &namemap, 0);
|
||||
let kind: FeatureKind = serde_json::from_value(v).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
if let Some(slot) = s.doc_mut().feature_mut(*nid) {
|
||||
slot.kind = kind;
|
||||
slot.component = new_id;
|
||||
}
|
||||
}
|
||||
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.component == new_id) {
|
||||
o.transform = transform;
|
||||
}
|
||||
Ok(json!({"component": new_id, "features": new_ids}))
|
||||
}
|
||||
|
||||
/// Point copied features at their copied sketches and features.
|
||||
fn remap(v: &mut Value, ids: &std::collections::HashMap<u64, u64>, names: &std::collections::HashMap<String, String>, depth: usize) {
|
||||
if depth > 32 {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
for (k, x) in o.iter_mut() {
|
||||
match (k.as_str(), &*x) {
|
||||
("sketch" | "path_sketch", Value::Number(n)) => {
|
||||
if let Some(m) = n.as_u64().and_then(|i| ids.get(&i)) {
|
||||
*x = json!(m);
|
||||
}
|
||||
}
|
||||
("features", Value::Array(_)) => {
|
||||
if let Value::Array(a) = x {
|
||||
for e in a.iter_mut() {
|
||||
if let Some(m) = e.as_str().and_then(|n| names.get(n)) {
|
||||
*e = json!(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => remap(x, ids, names, depth + 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(|x| remap(x, ids, names, depth + 1)),
|
||||
_ => {}
|
||||
}
|
||||
super::component_clone::paste(s, comp, occ, transform, parent, p)
|
||||
}
|
||||
|
||||
pub(super) fn occurrence_path(s: &Session, p: &Value, id: u64, cmd: &str) -> Result<Vec<u64>> {
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
//! Independent copies of a component definition graph, assembled and evaluated atomically.
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Sel, Session};
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{Document, Feature, FeatureKind, Mat, Occurrence};
|
||||
|
||||
const CMD: &str = "component.paste_new";
|
||||
const MAX_WORK: usize = 10_000;
|
||||
|
||||
fn cloned_path(source: &Document, path: &[u64], root: u64, destination: &[u64], occurrences: &BTreeMap<u64, u64>) -> Result<Vec<u64>> {
|
||||
let start = path
|
||||
.iter()
|
||||
.position(|id| source.occurrences.iter().any(|o| o.id == *id && o.component == root))
|
||||
.ok_or_else(|| bad(CMD, "copied Emboss placement is outside the copied component tree"))?;
|
||||
let mut mapped = destination.to_vec();
|
||||
for id in path.iter().skip(start + 1) {
|
||||
mapped.push(*occurrences.get(id).ok_or_else(|| bad(CMD, "copied Emboss path has an external descendant"))?);
|
||||
}
|
||||
if mapped.len() > 64 {
|
||||
return Err(bad(CMD, "copied Emboss path exceeds placement depth limit"));
|
||||
}
|
||||
Ok(mapped)
|
||||
}
|
||||
|
||||
fn unique(base: &str, used: &mut BTreeSet<String>) -> String {
|
||||
let stem = format!("{base} (copy)");
|
||||
let mut name = stem.clone();
|
||||
let mut n = 2;
|
||||
while used.contains(&name) {
|
||||
name = format!("{stem} {n}");
|
||||
n += 1;
|
||||
}
|
||||
used.insert(name.clone());
|
||||
name
|
||||
}
|
||||
|
||||
fn graph(doc: &Document, root: u64) -> Result<Vec<u64>> {
|
||||
let definitions: BTreeSet<_> = doc.components.iter().map(|c| c.id).collect();
|
||||
let mut children: BTreeMap<u64, Vec<u64>> = BTreeMap::new();
|
||||
for occurrence in &doc.occurrences {
|
||||
children.entry(occurrence.parent).or_default().push(occurrence.component);
|
||||
}
|
||||
let mut output = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut stack = vec![(root, Vec::<u64>::new())];
|
||||
let mut work = 0;
|
||||
while let Some((id, mut ancestors)) = stack.pop() {
|
||||
work += 1;
|
||||
if work > MAX_WORK || ancestors.len() >= 64 {
|
||||
return Err(bad(CMD, "component graph exceeds copy work limit"));
|
||||
}
|
||||
if ancestors.contains(&id) {
|
||||
return Err(bad(CMD, "component graph contains a cycle"));
|
||||
}
|
||||
if !definitions.contains(&id) {
|
||||
return Err(bad(CMD, "component graph has a missing definition"));
|
||||
}
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
output.push(id);
|
||||
ancestors.push(id);
|
||||
if let Some(children) = children.get(&id) {
|
||||
if work.saturating_add(stack.len()).saturating_add(children.len()) > MAX_WORK {
|
||||
return Err(bad(CMD, "component graph exceeds copy work limit"));
|
||||
}
|
||||
for child in children.iter().rev() {
|
||||
stack.push((*child, ancestors.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A shared node may be reached by a longer path after its first DFS visit. Check the
|
||||
// longest path over the complete DAG, rather than trusting the first placement depth.
|
||||
let mut indegree: BTreeMap<u64, usize> = output.iter().map(|id| (*id, 0)).collect();
|
||||
for parent in &output {
|
||||
if let Some(edges) = children.get(parent) {
|
||||
for child in edges {
|
||||
if let Some(degree) = indegree.get_mut(child) {
|
||||
*degree += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ready = std::collections::VecDeque::from([root]);
|
||||
let mut depth = BTreeMap::from([(root, 1usize)]);
|
||||
let mut sorted = Vec::new();
|
||||
while let Some(parent) = ready.pop_front() {
|
||||
sorted.push(parent);
|
||||
let parent_depth = *depth.get(&parent).unwrap_or(&1);
|
||||
if let Some(edges) = children.get(&parent) {
|
||||
for child in edges {
|
||||
let next = parent_depth + 1;
|
||||
if next > 64 {
|
||||
return Err(bad(CMD, "component graph exceeds copy depth limit"));
|
||||
}
|
||||
depth.entry(*child).and_modify(|d| *d = (*d).max(next)).or_insert(next);
|
||||
if let Some(degree) = indegree.get_mut(child) {
|
||||
*degree = degree.saturating_sub(1);
|
||||
if *degree == 0 {
|
||||
ready.push_back(*child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if sorted.len() != output.len() {
|
||||
return Err(bad(CMD, "component graph contains a cycle"));
|
||||
}
|
||||
Ok(sorted)
|
||||
}
|
||||
|
||||
pub(super) fn paste(
|
||||
s: &mut Session,
|
||||
component: u64,
|
||||
source_occurrence: Option<Occurrence>,
|
||||
transform: Mat,
|
||||
parent: u64,
|
||||
params: &Value,
|
||||
) -> Result<Value> {
|
||||
let source = s.doc.clone();
|
||||
let source_parameters: BTreeMap<_, _> = source.params.iter().map(|p| (p.name.as_str(), p)).collect();
|
||||
let generated_parameters: BTreeSet<_> = source
|
||||
.params
|
||||
.iter()
|
||||
.filter(|p| p.model)
|
||||
.map(|p| p.name.as_str())
|
||||
.chain(source.features.iter().flat_map(|f| f.param_names.iter().map(String::as_str)))
|
||||
.collect();
|
||||
let definitions = graph(&source, component)?;
|
||||
let definition_records: BTreeMap<_, _> = source.components.iter().map(|c| (c.id, c)).collect();
|
||||
let selected: BTreeSet<_> = definitions.iter().copied().collect();
|
||||
if source.marker.is_some_and(|m| m < source.features.len()) {
|
||||
return Err(bad(CMD, "move the timeline marker to the end before Paste New"));
|
||||
}
|
||||
let feats: Vec<Feature> = source.features.iter().filter(|f| selected.contains(&f.component)).cloned().collect();
|
||||
if feats.len() + source.features.len() > MAX_WORK {
|
||||
return Err(bad(CMD, "too many features to copy"));
|
||||
}
|
||||
for f in &feats {
|
||||
if s.model.result(f.id).is_some_and(|r| r.error.is_some()) {
|
||||
return Err(bad(CMD, format!("source feature {} has an evaluation error", f.name)));
|
||||
}
|
||||
}
|
||||
let state = s.model.state();
|
||||
if state.bodies.len() > MAX_WORK {
|
||||
return Err(bad(CMD, "body set exceeds copy work limit"));
|
||||
}
|
||||
if source.next_id.checked_add(100_000).is_none() {
|
||||
return Err(bad(CMD, "copy id allocation exceeds supported range"));
|
||||
}
|
||||
let mut scratch = s.scratch();
|
||||
let mut components = BTreeMap::new();
|
||||
let mut occurrences = BTreeMap::new();
|
||||
let mut features = BTreeMap::new();
|
||||
let mut names = BTreeMap::new();
|
||||
let mut bodies = BTreeMap::new();
|
||||
let mut parameters = BTreeMap::new();
|
||||
let mut used: BTreeSet<String> = source
|
||||
.features
|
||||
.iter()
|
||||
.map(|f| f.name.clone())
|
||||
.chain(source.components.iter().map(|c| c.name.clone()))
|
||||
.chain(state.bodies.iter().map(|b| b.name.clone()))
|
||||
.collect();
|
||||
let mut new_ids = Vec::new();
|
||||
let mut frozen = Vec::new();
|
||||
// Allocate definitions, discarding their automatic placeholder occurrence except root.
|
||||
for old in &definitions {
|
||||
let c = definition_records.get(old).ok_or_else(|| bad(CMD, "missing definition"))?;
|
||||
let mapped_parent = if *old == component {
|
||||
parent
|
||||
} else {
|
||||
components.get(&c.parent).copied().unwrap_or(*components.get(&component).ok_or_else(|| bad(CMD, "missing clone root"))?)
|
||||
};
|
||||
let name = unique(&c.name, &mut used);
|
||||
let new = scratch.doc_mut().add_component(Some(&name), mapped_parent)?;
|
||||
components.insert(*old, new);
|
||||
if *old != component {
|
||||
scratch.doc_mut().occurrences.retain(|o| o.component != new);
|
||||
}
|
||||
}
|
||||
let root = *components.get(&component).ok_or_else(|| bad(CMD, "missing clone root"))?;
|
||||
let root_occurrence = scratch.doc.occurrence_of(root).map(|o| o.id).ok_or_else(|| bad(CMD, "missing cloned placement"))?;
|
||||
let mut clone_path = if let Some(o) = &source_occurrence {
|
||||
let mut path = super::component::occurrence_path(s, params, o.id, CMD)?;
|
||||
path.pop();
|
||||
path
|
||||
} else {
|
||||
source.first_path(parent)
|
||||
};
|
||||
clone_path.push(root_occurrence);
|
||||
if let Some(o) = scratch.doc_mut().occurrences.iter_mut().find(|o| o.id == root_occurrence) {
|
||||
o.transform = transform;
|
||||
o.grounded = source_occurrence.as_ref().is_some_and(|o| o.grounded);
|
||||
}
|
||||
if let Some(o) = &source_occurrence {
|
||||
occurrences.insert(o.id, root_occurrence);
|
||||
}
|
||||
for old in source.occurrences.iter().filter(|o| selected.contains(&o.parent)) {
|
||||
let child = *components.get(&old.component).ok_or_else(|| bad(CMD, "missing child definition"))?;
|
||||
let owner = *components.get(&old.parent).ok_or_else(|| bad(CMD, "missing parent definition"))?;
|
||||
let pose = s.pending_moves.get(&old.id).copied().unwrap_or(old.transform);
|
||||
if !super::component::valid_position(&pose) {
|
||||
return Err(bad(CMD, "invalid internal pending occurrence transform"));
|
||||
}
|
||||
let id = scratch.doc_mut().add_occurrence(child, owner, pose)?;
|
||||
if let Some(o) = scratch.doc_mut().occurrences.iter_mut().find(|o| o.id == id) {
|
||||
o.grounded = old.grounded;
|
||||
}
|
||||
occurrences.insert(old.id, id);
|
||||
}
|
||||
// Collect historical outputs too: a later Combine may have consumed a named tool body.
|
||||
let mut outputs: BTreeMap<u64, Vec<String>> = BTreeMap::new();
|
||||
for f in &feats {
|
||||
if let Some(result) = s.model.result(f.id) {
|
||||
let created: Vec<_> = result.state.bodies.iter().filter(|b| b.feature == f.id).map(|b| b.name.clone()).collect();
|
||||
for name in &created {
|
||||
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used));
|
||||
}
|
||||
outputs.insert(f.id, created);
|
||||
}
|
||||
for name in &f.body_names {
|
||||
if !name.is_empty() {
|
||||
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used));
|
||||
}
|
||||
}
|
||||
if bodies.len() > MAX_WORK {
|
||||
return Err(bad(CMD, "historical body set exceeds copy work limit"));
|
||||
}
|
||||
}
|
||||
// Body assignment may put externally authored geometry into this graph. Freeze it explicitly.
|
||||
let owned_features: BTreeSet<_> = feats.iter().map(|f| f.id).collect();
|
||||
for body in state.bodies.iter().filter(|b| selected.contains(&source.body_component(&b.name, b.feature)) && !owned_features.contains(&b.feature))
|
||||
{
|
||||
if body.body.is_mesh() {
|
||||
return Err(bad(CMD, "Paste New of a reassigned mesh body is not supported yet"));
|
||||
}
|
||||
let comp = source.body_component(&body.name, body.feature);
|
||||
let offset = source.body_offsets.get(&body.name).copied().unwrap_or(solvecraft_doc::IDENTITY);
|
||||
let before = feats.iter().find_map(|f| {
|
||||
let before = s.model.state_before(f.id);
|
||||
let prior = before.body(&body.name)?;
|
||||
let after = s.model.result(f.id)?.state.body(&body.name);
|
||||
if after.is_none_or(|after| !prior.body.same(&after.body)) { Some(before) } else { None }
|
||||
});
|
||||
let base = before.as_ref().and_then(|st| st.body(&body.name)).unwrap_or(body);
|
||||
let geometry = solvecraft_kernel::transform_matrix(&base.body, offset)?;
|
||||
let step = solvecraft_kernel::step_export(&[&geometry], "SolveCraft independent reassigned body")?;
|
||||
let new_name = unique(&body.name, &mut used);
|
||||
bodies.insert(body.name.clone(), new_name.clone());
|
||||
let feature_name = unique(&format!("{} Base", body.name), &mut used);
|
||||
let id = scratch.doc_mut().add_feature(
|
||||
FeatureKind::Import { file: format!("{} independent base", body.name), step, components: Vec::new() },
|
||||
Some(&feature_name),
|
||||
)?;
|
||||
if let Some(f) = scratch.doc_mut().feature_mut(id) {
|
||||
f.component = *components.get(&comp).ok_or_else(|| bad(CMD, "missing assigned component"))?;
|
||||
f.body_names = vec![new_name];
|
||||
}
|
||||
new_ids.push(id);
|
||||
frozen.push(body.name.clone());
|
||||
}
|
||||
// Allocate all history IDs before rewriting any forward reference.
|
||||
for f in &feats {
|
||||
let name = unique(&f.name, &mut used);
|
||||
let id = scratch.doc_mut().add_feature(f.kind.clone(), Some(&name))?;
|
||||
if let Some(new) = scratch.doc.feature(id) {
|
||||
for (old, pn) in f.param_names.iter().zip(&new.param_names) {
|
||||
parameters.insert(old.clone(), pn.clone());
|
||||
}
|
||||
}
|
||||
features.insert(f.id, id);
|
||||
names.insert(f.name.clone(), name);
|
||||
new_ids.push(id);
|
||||
}
|
||||
// Constraint dimensions own independent generated parameters; public user parameters stay shared.
|
||||
for f in &feats {
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
for c in &sketch.constraints {
|
||||
if let Some(name) = &c.param
|
||||
&& !parameters.contains_key(name)
|
||||
&& let Some(p) = source_parameters.get(name.as_str())
|
||||
&& p.model
|
||||
{
|
||||
if scratch.doc.params.len() >= solvecraft_doc::MAX_PARAMS {
|
||||
return Err(bad(CMD, "too many copied parameters"));
|
||||
}
|
||||
let new = scratch.doc_mut().new_model_param(&p.expr, &p.unit);
|
||||
parameters.insert(name.clone(), new);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (old, new) in ¶meters {
|
||||
if let Some(comment) = source.param_comments.get(old) {
|
||||
scratch.doc_mut().param_comments.insert(new.clone(), comment.clone());
|
||||
}
|
||||
if let Some(p) = source_parameters.get(old.as_str())
|
||||
&& p.model
|
||||
{
|
||||
let expr = solvecraft_doc::clone_expression(&p.expr, ¶meters);
|
||||
if let Some(p) = scratch.doc_mut().params.iter_mut().find(|p| p.name == *new) {
|
||||
p.expr = expr;
|
||||
p.comment = source_parameters.get(old.as_str()).map(|p| p.comment.clone()).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
for old in &feats {
|
||||
let mut f = old.clone();
|
||||
f.id = *features.get(&old.id).ok_or_else(|| bad(CMD, "missing feature mapping"))?;
|
||||
f.name = names.get(&old.name).cloned().ok_or_else(|| bad(CMD, "missing feature name"))?;
|
||||
f.component = *components.get(&old.component).ok_or_else(|| bad(CMD, "missing component mapping"))?;
|
||||
if f.body_names.is_empty() {
|
||||
f.body_names = outputs.get(&old.id).cloned().unwrap_or_default();
|
||||
}
|
||||
f.remap_clone(&features, &names, &bodies, ¶meters)?;
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
|
||||
&& !face.path.is_empty()
|
||||
&& let FeatureKind::Emboss { face: Some(old_face), .. } = &old.kind
|
||||
&& bodies.contains_key(&old_face.body)
|
||||
{
|
||||
face.path = cloned_path(&source, &old_face.path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
|
||||
&& !face.author_path.is_empty()
|
||||
{
|
||||
face.author_path = cloned_path(&source, &face.author_path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
if let Some(slot) = scratch.doc_mut().feature_mut(f.id) {
|
||||
*slot = f;
|
||||
}
|
||||
}
|
||||
for (old, new) in &bodies {
|
||||
if let Some(mat) = source.materials.get(old) {
|
||||
scratch.doc_mut().materials.insert(new.clone(), mat.clone());
|
||||
}
|
||||
if let Some(look) = source.appearances.bodies.get(old) {
|
||||
scratch.doc_mut().appearances.bodies.insert(new.clone(), look.clone());
|
||||
}
|
||||
if !frozen.contains(old) {
|
||||
if let Some(comp) = source.body_components.get(old).and_then(|c| components.get(c)) {
|
||||
scratch.doc_mut().body_components.insert(new.clone(), *comp);
|
||||
}
|
||||
if let Some(offset) = source.body_offsets.get(old) {
|
||||
scratch.doc_mut().body_offsets.insert(new.clone(), *offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (old, new) in &components {
|
||||
if let Some(part) = source.parts.get(old) {
|
||||
scratch.doc_mut().parts.insert(*new, part.clone());
|
||||
}
|
||||
if let Some(look) = source.appearances.components.get(old) {
|
||||
scratch.doc_mut().appearances.components.insert(*new, look.clone());
|
||||
}
|
||||
}
|
||||
// A creator's history can belong here while its final body was reassigned outside.
|
||||
// Retain the independent history, but reproduce the current ownership at its end.
|
||||
let moved_out: Vec<_> = state
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| owned_features.contains(&b.feature) && !selected.contains(&source.body_component(&b.name, b.feature)))
|
||||
.filter_map(|b| bodies.get(&b.name).cloned())
|
||||
.collect();
|
||||
if !moved_out.is_empty() {
|
||||
let name = unique("Assigned-out bodies", &mut used);
|
||||
let id = scratch.doc_mut().add_feature(FeatureKind::Remove { bodies: moved_out }, Some(&name))?;
|
||||
if let Some(f) = scratch.doc_mut().feature_mut(id) {
|
||||
f.component = root;
|
||||
}
|
||||
new_ids.push(id);
|
||||
}
|
||||
for face in &source.appearances.faces {
|
||||
if let Some(new) = bodies.get(&face.body) {
|
||||
let mut face = face.clone();
|
||||
if frozen.contains(&face.body) {
|
||||
if let Some(name) = &face.name {
|
||||
face.point = solvecraft_doc::naming::point_on_face(&state, name, face.point)
|
||||
.map(|(point, _)| point)
|
||||
.ok_or_else(|| bad(CMD, "source frozen face appearance lost its identity"))?;
|
||||
}
|
||||
face.point = solvecraft_doc::apply_point(source.body_offsets.get(&face.body).unwrap_or(&solvecraft_doc::IDENTITY), face.point);
|
||||
face.name = None;
|
||||
} else if let Some(name) = &mut face.name {
|
||||
*name = solvecraft_doc::remap_topology(name, &features, &bodies);
|
||||
}
|
||||
face.body = new.clone();
|
||||
scratch.doc_mut().appearances.faces.push(face);
|
||||
}
|
||||
}
|
||||
// Typed configuration and assembly helpers preserve original targets.
|
||||
scratch.doc_mut().configs.clone_targets(&solvecraft_doc::config::ConfigCloneMaps {
|
||||
features: features.clone(),
|
||||
components: components.clone(),
|
||||
bodies: bodies.clone(),
|
||||
parameters: parameters.clone(),
|
||||
})?;
|
||||
let mut notices = super::component_clone_assembly::clone_assembly(scratch.doc_mut(), &source, &occurrences, &features, &bodies)?;
|
||||
let mut reference_work = 0usize;
|
||||
for feature in &feats {
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &feature.kind
|
||||
&& !face.path.is_empty()
|
||||
&& !bodies.contains_key(&face.body)
|
||||
{
|
||||
notices.push(format!("{} retains external selected occurrence path {:?}", feature.name, face.path));
|
||||
}
|
||||
if let FeatureKind::Sketch { sketch, .. } = &feature.kind {
|
||||
use solvecraft_sketch::LinkSource as L;
|
||||
let mut pending: Vec<_> = sketch.links.iter().map(|l| &l.source).collect();
|
||||
let mut work = 0;
|
||||
while let Some(link) = pending.pop() {
|
||||
work += 1;
|
||||
reference_work += 1;
|
||||
if work > MAX_WORK || reference_work > 100_000 {
|
||||
return Err(bad(CMD, "linked references exceed copy work limit"));
|
||||
}
|
||||
match link {
|
||||
L::Edge { body, .. }
|
||||
| L::Face { body, .. }
|
||||
| L::Body { body }
|
||||
| L::Vertex { body, .. }
|
||||
| L::Iso { body, .. }
|
||||
| L::Spun { body, .. } => {
|
||||
if !bodies.contains_key(body) {
|
||||
notices.push(format!("{} retains external linked body {body}", feature.name));
|
||||
}
|
||||
}
|
||||
L::SketchCurve { sketch, .. } | L::SketchPoint { sketch, .. } => {
|
||||
if !features.contains_key(sketch) {
|
||||
notices.push(format!("{} retains external linked sketch {sketch}", feature.name));
|
||||
}
|
||||
}
|
||||
L::OnSurface { sketch, body, .. } => {
|
||||
if !features.contains_key(sketch) || !bodies.contains_key(body) {
|
||||
notices.push(format!("{} retains external surface link", feature.name));
|
||||
}
|
||||
}
|
||||
L::Intersection { a, b } => {
|
||||
pending.push(a);
|
||||
pending.push(b);
|
||||
}
|
||||
L::Plane { name } if source.find_feature(name).is_some() && !names.contains_key(name) => {
|
||||
notices.push(format!("{} retains external linked plane {name}", feature.name));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in feature.kind.input_sketches() {
|
||||
if !features.contains_key(&id) {
|
||||
notices.push(format!("{} retains an external sketch reference to {id}", feature.name));
|
||||
}
|
||||
}
|
||||
if let FeatureKind::Pattern { features: references, .. } | FeatureKind::Mirror { features: references, .. } = &feature.kind {
|
||||
for name in references {
|
||||
if !names.contains_key(name) {
|
||||
notices.push(format!("{} retains external feature reference {name}", feature.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
for expression in feature.kind.expressions() {
|
||||
for name in solvecraft_doc::expr::references(expression) {
|
||||
reference_work += 1;
|
||||
if reference_work > 100_000 {
|
||||
return Err(bad(CMD, "expression references exceed copy work limit"));
|
||||
}
|
||||
if !parameters.contains_key(&name) && generated_parameters.contains(name.as_str()) {
|
||||
notices.push(format!("{} retains external model parameter {name}", feature.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
notices.sort();
|
||||
notices.dedup();
|
||||
scratch.refresh();
|
||||
let original_errors: BTreeMap<_, _> = s.model.results.iter().map(|result| (result.id, result.error.as_ref())).collect();
|
||||
let refreshed_errors: BTreeMap<_, _> = scratch.model.results.iter().map(|result| (result.id, result.error.as_ref())).collect();
|
||||
if source.features.iter().any(|feature| original_errors.get(&feature.id) != refreshed_errors.get(&feature.id)) {
|
||||
return Err(bad(CMD, "copy would change an original feature's error state"));
|
||||
}
|
||||
// Base imports acquire new face identities. Resolve copied face overrides against the
|
||||
// verified new geometry once, rather than weakening strict persistent-name semantics.
|
||||
let resolved = scratch.model.state();
|
||||
let mut face_updates = Vec::new();
|
||||
for (index, face) in scratch.doc.appearances.faces.iter().enumerate() {
|
||||
if bodies.iter().any(|(old, new)| frozen.contains(old) && *new == face.body) {
|
||||
let body = resolved.body(&face.body).ok_or_else(|| bad(CMD, "missing frozen face body"))?;
|
||||
let face_index =
|
||||
solvecraft_doc::appearance::face_index_at(body, face.point).ok_or_else(|| bad(CMD, "cannot reproduce frozen face appearance"))?;
|
||||
let name = solvecraft_doc::naming::face_names(body).get(face_index).cloned().ok_or_else(|| bad(CMD, "missing frozen face identity"))?;
|
||||
face_updates.push((index, name));
|
||||
}
|
||||
}
|
||||
for (index, name) in face_updates {
|
||||
if let Some(face) = scratch.doc_mut().appearances.faces.get_mut(index) {
|
||||
face.name = Some(name);
|
||||
}
|
||||
}
|
||||
for id in &new_ids {
|
||||
if let Some(error) = scratch.model.result(*id).and_then(|r| r.error.as_ref()) {
|
||||
return Err(bad(CMD, format!("copied feature failed: {error}")));
|
||||
}
|
||||
}
|
||||
let copied = scratch.model.state();
|
||||
let copied_bodies: BTreeMap<_, _> = copied.bodies.iter().map(|body| (body.name.as_str(), body)).collect();
|
||||
// Adding placements can invalidate cached source evaluation. Compare clone effects
|
||||
// against that same evaluation's source prefix, not older shared B-rep pointers.
|
||||
if scratch.doc.features.get(..source.features.len()) != Some(source.features.as_slice())
|
||||
|| scratch.doc.params.get(..source.params.len()) != Some(source.params.as_slice())
|
||||
{
|
||||
return Err(bad(CMD, "copy would change original feature history or parameters"));
|
||||
}
|
||||
let original_state = new_ids.first().map(|id| scratch.model.state_before(*id)).unwrap_or_else(|| copied.clone());
|
||||
let all_columns = std::mem::take(&mut scratch.doc_mut().configs.columns);
|
||||
scratch.doc_mut().configs.columns = all_columns.iter().skip(source.configs.columns.len()).cloned().collect();
|
||||
let style_validation = scratch.doc.configuration_style_status(&copied);
|
||||
scratch.doc_mut().configs.columns = all_columns;
|
||||
style_validation?;
|
||||
let mut proof_work = 0usize;
|
||||
let expected_count = state.bodies.iter().filter(|b| selected.contains(&source.body_component(&b.name, b.feature))).count();
|
||||
let copied_components: BTreeSet<_> = components.values().copied().collect();
|
||||
let actual_count = copied.bodies.iter().filter(|b| copied_components.contains(&scratch.doc.body_component(&b.name, b.feature))).count();
|
||||
if actual_count != expected_count {
|
||||
return Err(bad(CMD, "copy produced an incomplete or unexpected set of bodies"));
|
||||
}
|
||||
for original in &original_state.bodies {
|
||||
let Some(after) = copied_bodies.get(original.name.as_str()) else {
|
||||
return Err(bad(CMD, "copy would remove a source body outside its independent history"));
|
||||
};
|
||||
if !original.body.same(&after.body) {
|
||||
return Err(bad(CMD, "copy would modify a source body; external destructive dependencies are not supported"));
|
||||
}
|
||||
}
|
||||
for body in state.bodies.iter().filter(|b| selected.contains(&source.body_component(&b.name, b.feature))) {
|
||||
let new = bodies.get(&body.name).ok_or_else(|| bad(CMD, format!("body {} has no clone identity", body.name)))?;
|
||||
let cloned = copied_bodies.get(new.as_str()).ok_or_else(|| bad(CMD, format!("copy did not reproduce body {}", body.name)))?;
|
||||
let a = solvecraft_kernel::transform_matrix(&body.body, *source.body_offsets.get(&body.name).unwrap_or(&solvecraft_doc::IDENTITY))?;
|
||||
let b = solvecraft_kernel::transform_matrix(&cloned.body, *scratch.doc.body_offsets.get(new).unwrap_or(&solvecraft_doc::IDENTITY))?;
|
||||
let ma = solvecraft_kernel::measure(&a)?;
|
||||
let mb = solvecraft_kernel::measure(&b)?;
|
||||
let tolerance = 1e-5 * (1.0 + (ma.bbox.max - ma.bbox.min).len()) + 64.0 * f64::EPSILON * (ma.bbox.min.len() + ma.bbox.max.len());
|
||||
let mut material_matches = true;
|
||||
let mesh_a = a.display_mesh((a.size() * 1e-3).max(1e-3))?;
|
||||
let mesh_b = b.display_mesh((b.size() * 1e-3).max(1e-3))?;
|
||||
proof_work = proof_work.saturating_add(mesh_a.triangles.len().saturating_add(mesh_b.triangles.len()).saturating_mul(27));
|
||||
if proof_work > 50_000_000 {
|
||||
return Err(bad(CMD, "geometry verification exceeds copy work limit"));
|
||||
}
|
||||
for x in [0.2, 0.5, 0.8] {
|
||||
for y in [0.2, 0.5, 0.8] {
|
||||
for z in [0.2, 0.5, 0.8] {
|
||||
let d = ma.bbox.max - ma.bbox.min;
|
||||
let p = ma.bbox.min + solvecraft_geom::Vec3::new(d.x * x, d.y * y, d.z * z);
|
||||
material_matches &= mesh_a.contains(p) == mesh_b.contains(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ma.volume - mb.volume).abs() > 1e-5 * (1.0 + ma.volume.abs())
|
||||
|| (ma.area - mb.area).abs() > 1e-5 * (1.0 + ma.area.abs())
|
||||
|| ma.centroid.dist(mb.centroid) > tolerance
|
||||
|| ma.bbox.min.dist(mb.bbox.min) > tolerance
|
||||
|| ma.bbox.max.dist(mb.bbox.max) > tolerance
|
||||
|| !material_matches
|
||||
{
|
||||
return Err(bad(CMD, format!("copy geometry differs for body {}", body.name)));
|
||||
}
|
||||
}
|
||||
scratch.selection = vec![Sel::Occurrence { path: clone_path }];
|
||||
s.accept_computed(scratch);
|
||||
Ok(
|
||||
json!({"component":root,"occurrence":root_occurrence,"features":new_ids,"components":components.len(),"bodies":expected_count,"frozen_bodies":frozen,"notices":notices}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "component_clone_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,360 @@
|
||||
//! Typed remapping of assembly relationships for an independent component-tree copy.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use solvecraft_doc::Document;
|
||||
use solvecraft_doc::joints::{Assembly, JointOrigin, Snap};
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
const MAX_RECORDS: usize = 10_000;
|
||||
const MAX_REFERENCES: usize = 100_000;
|
||||
const MAX_TEXT: usize = 1_048_576;
|
||||
const MAX_NAME: usize = 8192;
|
||||
|
||||
fn invalid(message: &str) -> EngineError {
|
||||
EngineError::Other(format!("Paste New assembly: {message}"))
|
||||
}
|
||||
|
||||
fn add_references(count: &mut usize, more: usize) -> Result<()> {
|
||||
*count = count.saturating_add(more);
|
||||
if *count > MAX_REFERENCES {
|
||||
return Err(invalid("assembly references exceed the processing budget"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finite_origin(origin: &JointOrigin) -> Result<()> {
|
||||
let finite = match &origin.snap {
|
||||
Snap::Frame { origin, z, x } => origin.is_finite() && z.is_finite() && x.is_none_or(|x| x.is_finite()),
|
||||
Snap::FaceCenter { pick, .. } | Snap::CircleCenter { pick, .. } => pick.is_finite(),
|
||||
Snap::Point { point, z } => point.is_finite() && z.is_none_or(|z| z.is_finite()),
|
||||
};
|
||||
if !finite {
|
||||
return Err(invalid("joint origin contains non-finite geometry"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn budget(assembly: &Assembly) -> Result<()> {
|
||||
let counts = [
|
||||
assembly.origins.len(),
|
||||
assembly.joints.len(),
|
||||
assembly.links.len(),
|
||||
assembly.contact_sets.len(),
|
||||
assembly.studies.len(),
|
||||
assembly.exploded.len(),
|
||||
];
|
||||
if counts.iter().any(|n| *n > MAX_RECORDS) {
|
||||
return Err(invalid("too many assembly records"));
|
||||
}
|
||||
let mut references = counts.iter().fold(0usize, |n, c| n.saturating_add(*c));
|
||||
let mut bytes = 0usize;
|
||||
let mut text = |s: &str| -> Result<()> {
|
||||
bytes = bytes.saturating_add(s.len());
|
||||
if s.len() > MAX_NAME || bytes > MAX_TEXT {
|
||||
return Err(invalid("assembly names exceed the processing budget"));
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
for origin in &assembly.origins {
|
||||
finite_origin(&origin.origin)?;
|
||||
text(&origin.name)?;
|
||||
if let Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } = &origin.origin.snap {
|
||||
text(face)?;
|
||||
}
|
||||
}
|
||||
for joint in &assembly.joints {
|
||||
text(&joint.name)?;
|
||||
add_references(&mut references, joint.values.len().saturating_add(joint.limits.len()))?;
|
||||
if !joint.values.iter().all(|v| v.is_finite())
|
||||
|| !joint.limits.iter().flatten().all(|(lo, hi)| lo.is_finite() && hi.is_finite() && lo <= hi)
|
||||
|| !joint.offset.is_finite()
|
||||
|| !joint.angle.is_finite()
|
||||
{
|
||||
return Err(invalid("joint contains invalid motion values"));
|
||||
}
|
||||
for origin in [&joint.a, &joint.b] {
|
||||
finite_origin(origin)?;
|
||||
if let Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } = &origin.snap {
|
||||
text(face)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
for contact in &assembly.contact_sets {
|
||||
text(&contact.name)?;
|
||||
add_references(&mut references, contact.occurrences.len())?;
|
||||
}
|
||||
for study in &assembly.studies {
|
||||
text(&study.name)?;
|
||||
add_references(&mut references, study.keys.len())?;
|
||||
for key in &study.keys {
|
||||
add_references(&mut references, key.points.len())?;
|
||||
if !key.points.iter().all(|(step, value)| step.is_finite() && value.is_finite()) {
|
||||
return Err(invalid("motion study contains non-finite keys"));
|
||||
}
|
||||
}
|
||||
}
|
||||
for view in &assembly.exploded {
|
||||
text(&view.name)?;
|
||||
add_references(&mut references, view.moves.len())?;
|
||||
if !view.moves.iter().all(|(_, p)| p.is_finite()) {
|
||||
return Err(invalid("exploded view contains non-finite moves"));
|
||||
}
|
||||
}
|
||||
if !assembly.links.iter().all(|l| l.ratio.is_finite() && l.offset.is_finite()) {
|
||||
return Err(invalid("motion link contains non-finite values"));
|
||||
}
|
||||
if references > MAX_REFERENCES {
|
||||
return Err(invalid("assembly references exceed the processing budget"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unique_name(base: &str, used: &mut BTreeSet<String>) -> Result<String> {
|
||||
if !used.contains(base) {
|
||||
used.insert(base.to_string());
|
||||
return Ok(base.to_string());
|
||||
}
|
||||
for index in 1..=MAX_RECORDS {
|
||||
let name = if index == 1 { format!("{base} (copy)") } else { format!("{base} (copy {index})") };
|
||||
if name.len() > MAX_NAME {
|
||||
return Err(invalid("copied assembly name is too long"));
|
||||
}
|
||||
if used.insert(name.clone()) {
|
||||
return Ok(name);
|
||||
}
|
||||
}
|
||||
Err(invalid("could not allocate a unique assembly name"))
|
||||
}
|
||||
|
||||
fn next_id(id: &mut u64) -> Result<u64> {
|
||||
*id = id.checked_add(1).ok_or_else(|| invalid("assembly identifier space is exhausted"))?;
|
||||
Ok(*id)
|
||||
}
|
||||
|
||||
fn unique_ids(ids: impl Iterator<Item = u64>) -> Result<u64> {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut largest = 0;
|
||||
for id in ids {
|
||||
if id == 0 || !seen.insert(id) {
|
||||
return Err(invalid("assembly identifiers must be nonzero and unique"));
|
||||
}
|
||||
largest = largest.max(id);
|
||||
}
|
||||
Ok(largest)
|
||||
}
|
||||
|
||||
fn validate_map<K: Ord, V: Ord>(map: &BTreeMap<K, V>) -> Result<()> {
|
||||
if map.len() > MAX_RECORDS || map.values().collect::<BTreeSet<_>>().len() != map.len() {
|
||||
return Err(invalid("copy maps exceed their budget or contain duplicate destinations"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remap certified naming tokens, rather than replacing digit substrings (F1 must not
|
||||
/// alter F10). Body-based legacy names are remapped only through an explicit identity map.
|
||||
fn face_name(name: &str, features: &BTreeMap<u64, u64>, bodies: &BTreeMap<String, String>) -> Option<String> {
|
||||
let body = name.match_indices(':').filter_map(|(end, _)| bodies.get_key_value(name.get(..end)?)).next_back();
|
||||
let mut out = String::new();
|
||||
let mut last = 0usize;
|
||||
let mut mapped = false;
|
||||
if let Some((old, new)) = body {
|
||||
out.push_str(new);
|
||||
last = old.len();
|
||||
mapped = true;
|
||||
}
|
||||
let bytes = name.as_bytes();
|
||||
let mut i = last;
|
||||
while i < bytes.len() {
|
||||
let boundary = i == 0
|
||||
|| bytes.get(i.wrapping_sub(1)).is_some_and(|c| matches!(*c, b'@' | b'|'))
|
||||
|| name.get(..i).is_some_and(|prefix| prefix.ends_with(":blend:") || prefix.ends_with(":inner:"));
|
||||
if bytes.get(i) == Some(&b'F') && boundary {
|
||||
let mut end = i + 1;
|
||||
while bytes.get(end).is_some_and(u8::is_ascii_digit) {
|
||||
end += 1;
|
||||
}
|
||||
if end > i + 1 && bytes.get(end).is_some_and(|c| matches!(*c, b':' | b'.')) {
|
||||
let id: u64 = name.get(i + 1..end)?.parse().ok()?;
|
||||
let replacement = features.get(&id)?;
|
||||
out.push_str(name.get(last..i)?);
|
||||
out.push_str(&format!("F{replacement}"));
|
||||
last = end;
|
||||
mapped |= i == 0;
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if !mapped {
|
||||
return None;
|
||||
}
|
||||
out.push_str(name.get(last..)?);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn origin(
|
||||
origin: &JointOrigin,
|
||||
occurrences: &BTreeMap<u64, u64>,
|
||||
features: &BTreeMap<u64, u64>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
) -> Option<JointOrigin> {
|
||||
let mut out = origin.clone();
|
||||
out.occurrence = *occurrences.get(&origin.occurrence)?;
|
||||
match &mut out.snap {
|
||||
Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } => *face = face_name(face, features, bodies)?,
|
||||
_ => {}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn report(messages: &mut Vec<String>, kind: &str, name: &str) {
|
||||
messages.push(format!("Skipped {kind} `{name}`: references outside the copied component tree or geometry without a copied identity."));
|
||||
}
|
||||
|
||||
/// Called with a scratch document after geometry cloning. Only wholly internal records
|
||||
/// are copied; cross-boundary relationships are reported and omitted without truncation.
|
||||
/// The assembly is swapped in only after every map, allocation and aggregate check passes.
|
||||
pub(super) fn clone_assembly(
|
||||
doc: &mut Document,
|
||||
source: &Document,
|
||||
occurrences: &BTreeMap<u64, u64>,
|
||||
features: &BTreeMap<u64, u64>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
) -> Result<Vec<String>> {
|
||||
budget(&source.assembly)?;
|
||||
budget(&doc.assembly)?;
|
||||
validate_map(occurrences)?;
|
||||
validate_map(features)?;
|
||||
validate_map(bodies)?;
|
||||
if doc.occurrences.len() > MAX_REFERENCES {
|
||||
return Err(invalid("too many destination occurrences"));
|
||||
}
|
||||
let mut map_bytes = 0usize;
|
||||
for (old, new) in bodies {
|
||||
map_bytes = map_bytes.saturating_add(old.len()).saturating_add(new.len());
|
||||
if old.is_empty() || new.is_empty() || old.len() > MAX_NAME || new.len() > MAX_NAME || map_bytes > MAX_TEXT {
|
||||
return Err(invalid("invalid or excessive body identity map"));
|
||||
}
|
||||
}
|
||||
let available: BTreeSet<u64> = doc.occurrences.iter().map(|o| o.id).chain(std::iter::once(0)).collect();
|
||||
if occurrences.values().any(|id| !available.contains(id)) {
|
||||
return Err(invalid("copy occurrence map points to a missing occurrence"));
|
||||
}
|
||||
unique_ids(source.assembly.joints.iter().map(|j| j.id))?;
|
||||
let mut joint_id = unique_ids(doc.assembly.joints.iter().map(|j| j.id))?;
|
||||
let aux = |a: &Assembly| {
|
||||
a.contact_sets.iter().map(|x| x.id).chain(a.studies.iter().map(|x| x.id)).chain(a.exploded.iter().map(|x| x.id)).collect::<Vec<_>>()
|
||||
};
|
||||
unique_ids(aux(&source.assembly).into_iter())?;
|
||||
let mut auxiliary_id = unique_ids(aux(&doc.assembly).into_iter())?;
|
||||
let mut a = doc.assembly.clone();
|
||||
let mut messages = Vec::new();
|
||||
let mut origin_names = a.origins.iter().map(|o| o.name.clone()).collect();
|
||||
for old in &source.assembly.origins {
|
||||
if !occurrences.contains_key(&old.origin.occurrence) {
|
||||
continue;
|
||||
}
|
||||
if let Some(new) = origin(&old.origin, occurrences, features, bodies) {
|
||||
let mut item = old.clone();
|
||||
item.name = unique_name(&old.name, &mut origin_names)?;
|
||||
item.origin = new;
|
||||
a.origins.push(item);
|
||||
} else {
|
||||
report(&mut messages, "joint origin", &old.name);
|
||||
}
|
||||
}
|
||||
let mut joint_names = a.joints.iter().map(|j| j.name.clone()).collect();
|
||||
let mut joints = BTreeMap::new();
|
||||
let mut touched_joints = BTreeSet::new();
|
||||
for old in &source.assembly.joints {
|
||||
if !occurrences.contains_key(&old.a.occurrence) && !occurrences.contains_key(&old.b.occurrence) {
|
||||
continue;
|
||||
}
|
||||
touched_joints.insert(old.id);
|
||||
match (origin(&old.a, occurrences, features, bodies), origin(&old.b, occurrences, features, bodies)) {
|
||||
(Some(aa), Some(bb)) => {
|
||||
let mut item = old.clone();
|
||||
item.id = next_id(&mut joint_id)?;
|
||||
item.name = unique_name(&old.name, &mut joint_names)?;
|
||||
item.a = aa;
|
||||
item.b = bb;
|
||||
joints.insert(old.id, item.id);
|
||||
a.joints.push(item);
|
||||
}
|
||||
_ => report(&mut messages, "joint", &old.name),
|
||||
}
|
||||
}
|
||||
for old in &source.assembly.links {
|
||||
if !touched_joints.contains(&old.a) && !touched_joints.contains(&old.b) {
|
||||
continue;
|
||||
}
|
||||
match (joints.get(&old.a), joints.get(&old.b)) {
|
||||
(Some(aa), Some(bb)) => {
|
||||
let mut item = old.clone();
|
||||
item.a = *aa;
|
||||
item.b = *bb;
|
||||
a.links.push(item);
|
||||
}
|
||||
_ => report(&mut messages, "motion link", &format!("{} → {}", old.a, old.b)),
|
||||
}
|
||||
}
|
||||
let mut contact_names = a.contact_sets.iter().map(|x| x.name.clone()).collect();
|
||||
for old in &source.assembly.contact_sets {
|
||||
if !old.occurrences.iter().any(|o| occurrences.contains_key(o)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(ids) = old.occurrences.iter().map(|o| occurrences.get(o).copied()).collect::<Option<Vec<_>>>() {
|
||||
let mut item = old.clone();
|
||||
item.id = next_id(&mut auxiliary_id)?;
|
||||
item.name = unique_name(&old.name, &mut contact_names)?;
|
||||
item.occurrences = ids;
|
||||
a.contact_sets.push(item);
|
||||
} else {
|
||||
report(&mut messages, "contact set", &old.name);
|
||||
}
|
||||
}
|
||||
let mut study_names = a.studies.iter().map(|x| x.name.clone()).collect();
|
||||
for old in &source.assembly.studies {
|
||||
if !old.keys.iter().any(|k| touched_joints.contains(&k.joint)) {
|
||||
continue;
|
||||
}
|
||||
if old.keys.iter().all(|k| joints.contains_key(&k.joint)) {
|
||||
let mut item = old.clone();
|
||||
item.id = next_id(&mut auxiliary_id)?;
|
||||
item.name = unique_name(&old.name, &mut study_names)?;
|
||||
for key in &mut item.keys {
|
||||
key.joint = *joints.get(&key.joint).ok_or_else(|| invalid("missing copied study joint"))?;
|
||||
}
|
||||
a.studies.push(item);
|
||||
} else {
|
||||
report(&mut messages, "motion study", &old.name);
|
||||
}
|
||||
}
|
||||
let mut view_names = a.exploded.iter().map(|x| x.name.clone()).collect();
|
||||
for old in &source.assembly.exploded {
|
||||
if !old.moves.iter().any(|(o, _)| occurrences.contains_key(o)) {
|
||||
continue;
|
||||
}
|
||||
if old.moves.iter().all(|(o, _)| occurrences.contains_key(o)) {
|
||||
let mut item = old.clone();
|
||||
item.id = next_id(&mut auxiliary_id)?;
|
||||
item.name = unique_name(&old.name, &mut view_names)?;
|
||||
for (o, _) in &mut item.moves {
|
||||
*o = *occurrences.get(o).ok_or_else(|| invalid("missing copied exploded occurrence"))?;
|
||||
}
|
||||
a.exploded.push(item);
|
||||
} else {
|
||||
report(&mut messages, "exploded view", &old.name);
|
||||
}
|
||||
}
|
||||
budget(&a)?;
|
||||
doc.assembly = a;
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "component_clone_assembly_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,193 @@
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use super::*;
|
||||
use solvecraft_doc::joints::{Joint, JointKind, MotionLink, NamedOrigin};
|
||||
use solvecraft_doc::motion::{ContactSet, ExplodedView, MotionStudy, StudyKey};
|
||||
use solvecraft_doc::{IDENTITY, Occurrence};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
fn at(occurrence: u64) -> JointOrigin {
|
||||
JointOrigin { occurrence, snap: Snap::Point { point: Vec3::ZERO, z: None } }
|
||||
}
|
||||
|
||||
fn joint(id: u64, a: u64, b: u64) -> Joint {
|
||||
Joint {
|
||||
id,
|
||||
name: format!("Joint{id}"),
|
||||
kind: JointKind::Revolute,
|
||||
a: at(a),
|
||||
b: at(b),
|
||||
values: vec![0.25],
|
||||
limits: vec![Some((-1.0, 1.0))],
|
||||
flip: true,
|
||||
offset: 2.0,
|
||||
angle: 0.1,
|
||||
suppressed: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn occurrence(id: u64) -> Occurrence {
|
||||
Occurrence { id, component: id, parent: 0, name: format!("Occurrence{id}"), transform: IDENTITY, grounded: false }
|
||||
}
|
||||
|
||||
fn documents() -> (Document, Document) {
|
||||
let mut source = Document { occurrences: [1, 2, 8, 9].map(occurrence).to_vec(), ..Document::default() };
|
||||
source.assembly.joints = vec![joint(1, 1, 2), joint(2, 1, 2), joint(3, 2, 9), joint(4, 8, 9)];
|
||||
source.assembly.origins = vec![
|
||||
NamedOrigin {
|
||||
name: "Typed origin".into(),
|
||||
origin: JointOrigin { occurrence: 1, snap: Snap::FaceCenter { pick: Vec3::new(1.0, 2.0, 3.0), face: Some("F1:[email protected]".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:[email protected]"));
|
||||
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:[email protected]", &features, &bodies).as_deref(), Some("F21:blend:F30:[email protected]"));
|
||||
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:[email protected]", &features, &bodies).is_none());
|
||||
assert!(face_name("Foreign:face:0", &features, &bodies).is_none());
|
||||
assert!(face_name("Foreign:face:[email protected]", &features, &bodies).is_none());
|
||||
let bodies = BTreeMap::from([("F1:Housing".into(), "F1:Housing (copy)".into())]);
|
||||
assert_eq!(face_name("F1:Housing:face:[email protected]", &features, &bodies).as_deref(), Some("F1:Housing (copy):face:[email protected]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_maps_identifier_exhaustion_and_aggregate_budgets_are_atomic() {
|
||||
let (source, destination) = documents();
|
||||
let mut cases = Vec::new();
|
||||
cases.push((source.clone(), destination.clone(), BTreeMap::from([(1, 101), (2, 101)])));
|
||||
cases.push((source.clone(), destination.clone(), BTreeMap::from([(1, 999)])));
|
||||
let mut overflow = destination.clone();
|
||||
overflow.assembly.joints[0].id = u64::MAX;
|
||||
cases.push((source.clone(), overflow, BTreeMap::from([(1, 101), (2, 102)])));
|
||||
let mut oversized = source.clone();
|
||||
oversized.assembly.contact_sets[0].occurrences = vec![1; MAX_REFERENCES + 1];
|
||||
cases.push((oversized, destination.clone(), BTreeMap::from([(1, 101), (2, 102)])));
|
||||
let mut invalid = source.clone();
|
||||
invalid.assembly.links[0].ratio = f64::NAN;
|
||||
cases.push((invalid, destination, BTreeMap::from([(1, 101), (2, 102)])));
|
||||
for (source, mut destination, occurrences) in cases {
|
||||
let before = destination.assembly.clone();
|
||||
assert!(clone_assembly(&mut destination, &source, &occurrences, &BTreeMap::from([(1, 11), (10, 110)]), &BTreeMap::new()).is_err());
|
||||
assert_eq!(destination.assembly, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_copy_has_unique_names_and_keeps_global_contact_switches_unchanged() {
|
||||
let (source, mut destination) = documents();
|
||||
destination.assembly.contact_enabled = true;
|
||||
destination.assembly.contact_all = true;
|
||||
let features = BTreeMap::from([(1, 11), (10, 110)]);
|
||||
clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 101), (2, 102)]), &features, &BTreeMap::new()).unwrap();
|
||||
destination.occurrences.extend([201, 202].map(occurrence));
|
||||
clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 201), (2, 202)]), &features, &BTreeMap::new()).unwrap();
|
||||
assert_eq!(destination.assembly.joints[6].name, "Joint1 (copy 2)");
|
||||
assert!(destination.assembly.contact_enabled && destination.assembly.contact_all);
|
||||
let names: BTreeSet<&str> = destination.assembly.origins.iter().map(|x| x.name.as_str()).collect();
|
||||
assert_eq!(names.len(), destination.assembly.origins.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_new_internal_motion_study_drives_only_the_copied_assembly_and_reopens() {
|
||||
use serde_json::{Value, json};
|
||||
fn run(s: &mut crate::Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}"))
|
||||
}
|
||||
let mut s = crate::Session::default();
|
||||
let parent = run(&mut s, "component.create", json!({"name":"Mechanism"}))["component"].as_u64().unwrap();
|
||||
let fixed = run(&mut s, "component.create", json!({"name":"Support"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"SupportBox"}));
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence":fixed,"grounded":true}));
|
||||
run(&mut s, "component.activate", json!({"component":parent}));
|
||||
let moving = run(&mut s, "component.create", json!({"name":"Carriage"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[0,0,5],"body_name":"CarriageBox"}));
|
||||
run(&mut s, "joint.origin", json!({"name":"Support origin","occurrence":fixed,"point":[0,0,0]}));
|
||||
let source_joint = run(&mut s, "joint.as_built", json!({"type":"slider","a":fixed,"b":moving,"name":"Rail"}))["joint"].as_u64().unwrap();
|
||||
run(&mut s, "contact.create", json!({"name":"Internal contact","occurrences":[fixed,moving]}));
|
||||
run(&mut s, "motion.study", json!({"name":"Travel","steps":10,"keys":[{"joint":source_joint,"points":[[0,0],[10,6]]}]}));
|
||||
run(
|
||||
&mut s,
|
||||
"explode.create",
|
||||
json!({"name":"Exploded mechanism","moves":[{"occurrence":fixed,"translate":[10,0,0]},{"occurrence":moving,"translate":[0,10,0]}]}),
|
||||
);
|
||||
let original = s.doc.occurrences.iter().find(|o| o.id == moving).unwrap().transform;
|
||||
run(&mut s, "component.activate", json!({"component":"root"}));
|
||||
let result = run(&mut s, "component.paste_new", json!({"component":parent,"translate":[20,0,0]}));
|
||||
assert_eq!(result["notices"], json!([]));
|
||||
assert_eq!(s.doc.assembly.joints.len(), 2);
|
||||
assert_eq!(s.doc.assembly.origins.len(), 2);
|
||||
assert_eq!(s.doc.assembly.contact_sets.len(), 2);
|
||||
assert_eq!(s.doc.assembly.exploded.len(), 2);
|
||||
let copied_joint = s.doc.assembly.joints.iter().find(|j| j.name == "Rail (copy)").unwrap().id;
|
||||
let copied_moving = s.doc.assembly.joints.iter().find(|j| j.id == copied_joint).unwrap().b.occurrence;
|
||||
assert_ne!(copied_moving, moving);
|
||||
run(&mut s, "motion.play", json!({"study":"Travel (copy)","step":10}));
|
||||
assert_eq!(s.doc.assembly.joints.iter().find(|j| j.id == source_joint).unwrap().values, vec![0.0]);
|
||||
assert_eq!(s.doc.assembly.joints.iter().find(|j| j.id == copied_joint).unwrap().values, vec![6.0]);
|
||||
assert_eq!(s.doc.occurrences.iter().find(|o| o.id == moving).unwrap().transform, original);
|
||||
let reopened = Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened.assembly, s.doc.assembly);
|
||||
assert!(reopened.assembly.studies.iter().any(|m| m.name == "Travel (copy)" && m.keys[0].joint == copied_joint));
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap()
|
||||
}
|
||||
fn component(s: &mut Session, name: &str) -> u64 {
|
||||
run(s, "component.create", json!({"name":name}))["component"].as_u64().unwrap()
|
||||
}
|
||||
fn volume(s: &mut Session, id: u64) -> f64 {
|
||||
run(s, "component.properties", json!({"component":id}))["total"]["volume_mm3"].as_f64().unwrap()
|
||||
}
|
||||
fn box2(s: &mut Session) -> u64 {
|
||||
run(s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Cube"}))["feature"].as_u64().unwrap()
|
||||
}
|
||||
fn copy(s: &mut Session, id: u64) -> Value {
|
||||
run(s, "component.paste_new", json!({"component":id,"translate":[20,0,0]}))
|
||||
}
|
||||
fn near(a: f64, b: f64) {
|
||||
assert!((a - b).abs() < 1e-6, "{a} != {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_independent_primitives_keep_their_own_semantic_faces() {
|
||||
let mut s = Session::default();
|
||||
let first = box2(&mut s);
|
||||
let second = run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Other"}))["feature"].as_u64().unwrap();
|
||||
let state = s.model.state();
|
||||
let a = state.body("Cube").unwrap();
|
||||
let b = state.body("Other").unwrap();
|
||||
let a_names = solvecraft_doc::naming::face_names(a);
|
||||
let b_names = solvecraft_doc::naming::face_names(b);
|
||||
assert!(a_names.contains(&format!("F{first}:+z")));
|
||||
assert!(b_names.contains(&format!("F{second}:+z")));
|
||||
assert!(!b_names.contains(&format!("F{first}:+z")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_definition_dag_and_histories_are_independent() {
|
||||
let mut s = Session::default();
|
||||
let parent = component(&mut s, "Assembly");
|
||||
let child = component(&mut s, "Child");
|
||||
let feature = box2(&mut s);
|
||||
run(&mut s, "occurrence.copy", json!({"component":child}));
|
||||
let copied = copy(&mut s, parent);
|
||||
let new = copied["component"].as_u64().unwrap();
|
||||
assert_eq!(copied["components"], 2);
|
||||
near(volume(&mut s, new), 16.0);
|
||||
let kids: Vec<_> = s.doc.occurrences.iter().filter(|o| o.parent == new).collect();
|
||||
assert_eq!(kids.len(), 2);
|
||||
assert_eq!(kids[0].component, kids[1].component);
|
||||
assert_ne!(kids[0].component, child);
|
||||
let parameter = s.doc.feature(feature).unwrap().param_names[0].clone();
|
||||
run(&mut s, "parameters.change", json!({"name":parameter,"expression":"3 mm"}));
|
||||
near(volume(&mut s, parent), 24.0);
|
||||
near(volume(&mut s, new), 16.0);
|
||||
let saved = s.doc.to_json();
|
||||
let reopened = solvecraft_doc::Document::from_json(&saved).unwrap();
|
||||
assert_eq!(reopened.occurrences.iter().filter(|o| o.parent == new).count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reassigned_body_is_an_independent_base_feature() {
|
||||
let mut s = Session::default();
|
||||
let feature = box2(&mut s);
|
||||
let part = component(&mut s, "Part");
|
||||
run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Cube"]}));
|
||||
let copied = copy(&mut s, part);
|
||||
let new = copied["component"].as_u64().unwrap();
|
||||
assert_eq!(copied["frozen_bodies"], json!(["Cube"]));
|
||||
near(volume(&mut s, new), 8.0);
|
||||
let parameter = s.doc.feature(feature).unwrap().param_names[0].clone();
|
||||
run(&mut s, "parameters.change", json!({"name":parameter,"expression":"3 mm"}));
|
||||
near(volume(&mut s, part), 12.0);
|
||||
near(volume(&mut s, new), 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_preserves_physical_material_and_independent_styles() {
|
||||
let mut s = Session::default();
|
||||
let part = component(&mut s, "Part");
|
||||
box2(&mut s);
|
||||
run(&mut s, "material.assign", json!({"bodies":["Cube"],"material":"Brass"}));
|
||||
run(&mut s, "appearance.assign", json!({"components":[part.to_string()],"color":"#123456","opacity":0.4}));
|
||||
run(&mut s, "appearance.assign", json!({"bodies":["Cube"],"color":"#abcdef"}));
|
||||
run(&mut s, "appearance.assign", json!({"body":"Cube","faces":[[1,1,2]],"color":"#00ff00"}));
|
||||
let copied = copy(&mut s, part);
|
||||
let new = copied["component"].as_u64().unwrap();
|
||||
let props = run(&mut s, "component.properties", json!({"component":new}));
|
||||
near(props["total"]["mass_g"].as_f64().unwrap(), 0.068);
|
||||
let copied_body = s.model.state().bodies.iter().find(|b| b.component == new).unwrap().name.clone();
|
||||
assert_eq!(s.doc.appearances.bodies[&copied_body], s.doc.appearances.bodies["Cube"]);
|
||||
let state = s.model.state();
|
||||
let body = state.body(&copied_body).unwrap();
|
||||
assert!(s.doc.face_colors(body).iter().any(|(_, look)| look.color == [0, 255, 0]));
|
||||
run(&mut s, "appearance.assign", json!({"bodies":[copied_body],"color":"#ff0000"}));
|
||||
assert_eq!(s.doc.appearances.bodies["Cube"].color, [0xab, 0xcd, 0xef]);
|
||||
assert_eq!(s.doc.appearances.components[&new].opacity, 0.4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reassigned_body_owned_move_replays_once_and_original_stays_unchanged() {
|
||||
let mut s = Session::default();
|
||||
box2(&mut s);
|
||||
let part = component(&mut s, "Part");
|
||||
run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Cube"]}));
|
||||
run(&mut s, "solid.move", json!({"bodies":["Cube"],"translate":[10,0,0]}));
|
||||
let before = run(&mut s, "component.properties", json!({"component":part}));
|
||||
let copied = copy(&mut s, part);
|
||||
let new = copied["component"].as_u64().unwrap();
|
||||
let after = run(&mut s, "component.properties", json!({"component":new}));
|
||||
near(after["total"]["center_of_mass"][0].as_f64().unwrap(), 31.0);
|
||||
assert_eq!(run(&mut s, "component.properties", json!({"component":part})), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_external_destructive_target_is_atomic() {
|
||||
let mut s = Session::default();
|
||||
box2(&mut s);
|
||||
let part = component(&mut s, "Part");
|
||||
run(&mut s, "solid.move", json!({"bodies":["Cube"],"translate":[10,0,0]}));
|
||||
let saved = s.doc.to_json();
|
||||
let selection = s.selection.clone();
|
||||
let active = s.active_component;
|
||||
let pending = s.pending_moves.clone();
|
||||
assert!(s.execute("component.paste_new", &json!({"component":part})).is_err());
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
assert_eq!(s.selection, selection);
|
||||
assert_eq!(s.active_component, active);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_feature_parameter_dependencies_are_independent() {
|
||||
let mut s = Session::default();
|
||||
let part = component(&mut s, "Part");
|
||||
let source = box2(&mut s);
|
||||
let source_names = s.doc.feature(source).unwrap().param_names.clone();
|
||||
run(&mut s, "parameters.change", json!({"name":source_names[1],"expression":format!("{} * 2",source_names[0])}));
|
||||
let result = copy(&mut s, part);
|
||||
let clone = result["component"].as_u64().unwrap();
|
||||
near(volume(&mut s, clone), 16.0);
|
||||
let clone_names = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.find(|f| f.component == clone && matches!(f.kind, solvecraft_doc::FeatureKind::Box { .. }))
|
||||
.unwrap()
|
||||
.param_names
|
||||
.clone();
|
||||
assert_ne!(clone_names, source_names);
|
||||
run(&mut s, "parameters.change", json!({"name":source_names[0],"expression":"3 mm"}));
|
||||
near(volume(&mut s, part), 36.0);
|
||||
near(volume(&mut s, clone), 16.0);
|
||||
run(&mut s, "parameters.change", json!({"name":clone_names[0],"expression":"4 mm"}));
|
||||
near(volume(&mut s, clone), 64.0);
|
||||
near(volume(&mut s, part), 36.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_nested_pending_placement_survives_copy_and_undo() {
|
||||
let mut s = Session::default();
|
||||
let parent = component(&mut s, "Parent");
|
||||
let parent_first = s.doc.occurrence_of(parent).unwrap().id;
|
||||
let child = component(&mut s, "Child");
|
||||
let leaf = s.doc.occurrence_of(child).unwrap().id;
|
||||
box2(&mut s);
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component":parent,"translate":[50,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":leaf,"capture":false,"translate":[5,0,0]}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"capture":false,"translate":[2,0,0]}));
|
||||
let pending = s.pending_moves.clone();
|
||||
let prior_context = (s.active_component, s.active_occurrence_path.clone(), s.active_sketch);
|
||||
let copied = run(&mut s, "component.paste_new", json!({"component":child,"occurrence":leaf,"path":[second,leaf],"translate":[10,0,0]}));
|
||||
let new_leaf = copied["occurrence"].as_u64().unwrap();
|
||||
assert_eq!((s.active_component, s.active_occurrence_path.clone(), s.active_sketch), prior_context);
|
||||
assert_eq!(s.selection, vec![crate::Sel::Occurrence { path: vec![second, new_leaf] }]);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(s.occurrence_placement(&[parent_first, leaf]).unwrap().world[3][0], 5.0);
|
||||
let props = run(&mut s, "occurrence.properties", json!({"occurrence":new_leaf,"path":[second,new_leaf]}));
|
||||
near(props["total"]["center_of_mass"][0].as_f64().unwrap(), 68.0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(!s.doc.occurrences.iter().any(|o| o.id == new_leaf));
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_sheet_base_targets_its_own_sketch_history() {
|
||||
let mut s = Session::default();
|
||||
let part = component(&mut s, "Sheet");
|
||||
let sketch = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Base"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch":"Base"}));
|
||||
let source_volume = volume(&mut s, part);
|
||||
let copied = copy(&mut s, part);
|
||||
let new = copied["component"].as_u64().unwrap();
|
||||
near(volume(&mut s, new), source_volume);
|
||||
let clone_sketch = s.doc.features.iter().find(|f| f.component == new && matches!(f.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap().id;
|
||||
assert_ne!(clone_sketch, sketch);
|
||||
let base = s.doc.features.iter().find(|f| f.component == new && matches!(f.kind, solvecraft_doc::FeatureKind::SheetBase { .. })).unwrap();
|
||||
assert_eq!(base.kind.input_sketches(), vec![clone_sketch]);
|
||||
// Mutate only source geometry, then verify the copied base remains unchanged.
|
||||
if let solvecraft_doc::FeatureKind::Sketch { sketch, .. } = &mut s.doc_mut().feature_mut(sketch).unwrap().kind {
|
||||
for p in &mut sketch.points {
|
||||
p.pos.x *= 2.0;
|
||||
}
|
||||
}
|
||||
s.refresh();
|
||||
near(volume(&mut s, new), source_volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renamed_pattern_outputs_and_later_body_operands_follow_clone() {
|
||||
let mut s = Session::default();
|
||||
let part = component(&mut s, "PatternPart");
|
||||
box2(&mut s);
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"bodies":["Cube"],"dir1":[1,0,0],"count1":3,"spacing1":5}));
|
||||
let last = s.model.state().bodies.last().unwrap().name.clone();
|
||||
run(&mut s, "body.rename", json!({"body":last,"name":"Custom Output"}));
|
||||
run(&mut s, "solid.move", json!({"bodies":["Custom Output"],"translate":[0,5,0]}));
|
||||
let copied = copy(&mut s, part);
|
||||
let new = copied["component"].as_u64().unwrap();
|
||||
near(volume(&mut s, new), 24.0);
|
||||
let state = s.model.state();
|
||||
let target = state.bodies.iter().find(|b| b.name == "Custom Output (copy)").unwrap();
|
||||
near(solvecraft_kernel::measure(&target.body).unwrap().centroid.y, 6.0);
|
||||
assert_eq!(s.doc.features.iter().filter(|f| f.component == new && matches!(f.kind, solvecraft_doc::FeatureKind::Move { .. })).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rows_follow_copied_parameters_material_and_strict_face_lineage() {
|
||||
let mut s = Session::default();
|
||||
let part = component(&mut s, "Configured");
|
||||
let source = box2(&mut s);
|
||||
let length = s.doc.feature(source).unwrap().param_names[0].clone();
|
||||
let target = solvecraft_doc::config::ConfigFaceTarget {
|
||||
body: "Cube".into(),
|
||||
origin_feature: source,
|
||||
name: format!("F{source}:+z"),
|
||||
point: solvecraft_geom::Vec3::new(1.0, 1.0, 2.0),
|
||||
};
|
||||
run(&mut s, "config.configure", json!({"params":[length],"materials":["Cube"],"component_appearances":[part],"face_appearances":[target]}));
|
||||
let face_key = s.doc.config_key(&solvecraft_doc::config::Column::FaceAppearance(target.clone()));
|
||||
run(
|
||||
&mut s,
|
||||
"config.row",
|
||||
json!({"name":"Large","values":{length.clone():"4 mm","material:Cube":"Brass",face_key:{"name":"Custom","color":[0,255,0],"opacity":1.0}}}),
|
||||
);
|
||||
let result = copy(&mut s, part);
|
||||
let new = result["component"].as_u64().unwrap();
|
||||
assert_eq!(s.doc.configs.columns.len(), 8);
|
||||
run(&mut s, "config.activate", json!({"row":"Large"}));
|
||||
near(volume(&mut s, part), 16.0);
|
||||
near(volume(&mut s, new), 16.0);
|
||||
let props = run(&mut s, "component.properties", json!({"component":new}));
|
||||
near(props["total"]["mass_g"].as_f64().unwrap(), 0.136);
|
||||
let state = s.model.state();
|
||||
let body = state.bodies.iter().find(|b| b.component == new).unwrap();
|
||||
assert!(s.doc.face_colors(body).iter().any(|(_, look)| look.color == [0, 255, 0]));
|
||||
assert!(s.doc.configuration_style_status(&state).is_ok());
|
||||
assert!(s.doc.configuration_matches());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_extrude_cap_copy_keeps_independent_semantic_lineage() {
|
||||
let mut s = Session::default();
|
||||
let part = component(&mut s, "Extruded");
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[2,2]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let feature = run(&mut s, "solid.extrude", json!({"distance":2}))["feature"].as_u64().unwrap();
|
||||
let body_name = s.model.state().bodies.last().unwrap().name.clone();
|
||||
let target = solvecraft_doc::config::ConfigFaceTarget {
|
||||
body: body_name,
|
||||
origin_feature: feature,
|
||||
name: format!("F{feature}:top"),
|
||||
point: solvecraft_geom::Vec3::new(1.0, 1.0, 2.0),
|
||||
};
|
||||
run(&mut s, "config.configure", json!({"face_appearances":[target]}));
|
||||
let key = s.doc.config_key(&solvecraft_doc::config::Column::FaceAppearance(target));
|
||||
run(&mut s, "config.row", json!({"name":"Painted","values":{key:{"name":"Custom","color":[0,255,0],"opacity":1.0}}}));
|
||||
let copied = copy(&mut s, part)["component"].as_u64().unwrap();
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
let state = s.model.state();
|
||||
let body = state.bodies.iter().find(|b| b.component == copied).unwrap();
|
||||
assert!(solvecraft_doc::naming::face_names(body).contains(&format!("F{}:top", body.feature)));
|
||||
assert!(s.doc.face_colors(body).iter().any(|(_, look)| look.color == [0, 255, 0]));
|
||||
assert!(s.doc.configuration_style_status(&state).is_ok());
|
||||
let saved = s.doc.to_json();
|
||||
let reopened = solvecraft_doc::Document::from_json(&saved).unwrap();
|
||||
let mut model = solvecraft_doc::Model::default();
|
||||
model.evaluate(&reopened);
|
||||
assert!(reopened.configuration_style_status(&model.state()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_second_occurrence_emboss_copy_remaps_target_and_author_paths_and_reopens() {
|
||||
let mut s = Session::default();
|
||||
let part = component(&mut s, "SleevePart");
|
||||
let cylinder = run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}))["feature"].as_u64().unwrap();
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component":part,"translate":[80,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":part,"occurrence":second,"path":[second]}));
|
||||
run(&mut s, "sketch.create", json!({"name":"Badge","plane":{"origin":[80,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]}}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","face":[80,10,7.5],"depth":1}));
|
||||
let original_volume = volume(&mut s, part);
|
||||
let copied = run(&mut s, "component.paste_new", json!({"component":part,"occurrence":second,"path":[second],"translate":[20,0,0]}));
|
||||
let new = copied["component"].as_u64().unwrap();
|
||||
let placement = copied["occurrence"].as_u64().unwrap();
|
||||
let emboss = s.doc.features.iter().find(|f| f.component == new && matches!(f.kind, solvecraft_doc::FeatureKind::Emboss { .. })).unwrap();
|
||||
let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &emboss.kind else { panic!("missing selected face") };
|
||||
assert_eq!(face.body, "Sleeve (copy)");
|
||||
assert_eq!(face.path, vec![placement]);
|
||||
assert_eq!(face.author_path, vec![placement]);
|
||||
near(volume(&mut s, new), original_volume);
|
||||
let clone_volume = volume(&mut s, new);
|
||||
let height = s.doc.feature(cylinder).unwrap().param_names[1].clone();
|
||||
run(&mut s, "parameters.change", json!({"name":height,"expression":"30 mm"}));
|
||||
near(volume(&mut s, new), clone_volume);
|
||||
assert!(volume(&mut s, part) > original_volume);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!(reopened.doc.features.iter().all(|f| reopened.model.result(f.id).is_some_and(|r| r.error.is_none())));
|
||||
near(volume(&mut reopened, new), clone_volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reassigned_sample_plate_paste_new_preserves_complex_frozen_geometry() {
|
||||
let mut s = Session::default();
|
||||
s.run_script(&crate::sample::script()).unwrap();
|
||||
let body = s.model.state().bodies.first().unwrap().name.clone();
|
||||
let created = run(&mut s, "component.from_bodies", json!({"bodies":[body]}));
|
||||
let part = created["components"][0]["component"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":part}));
|
||||
let occurrence = s.doc.occurrence_of(part).unwrap().id;
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence":occurrence,"grounded":true}));
|
||||
let original_volume = volume(&mut s, part);
|
||||
let source_mesh = s.model.state().body(&body).unwrap().mesh();
|
||||
let boss_probe = solvecraft_geom::Vec3::new(40.0, 35.0, 12.0);
|
||||
let source_boss = source_mesh.contains(boss_probe);
|
||||
let copied = run(&mut s, "component.paste_new", json!({"component":part,"translate":[10,10,0]}));
|
||||
assert_eq!(s.doc.components.len(), 2);
|
||||
let new = copied["component"].as_u64().unwrap();
|
||||
// Imported curved faces may tessellate differently; keep the same geometric
|
||||
// accuracy required by copy, and independently probe plate and hole material.
|
||||
assert!((volume(&mut s, new) - original_volume).abs() < 1e-5 * original_volume);
|
||||
near(volume(&mut s, part), original_volume);
|
||||
let state = s.model.state();
|
||||
let copied_body = state.bodies.iter().find(|body| body.component == new).unwrap();
|
||||
let mesh = copied_body.mesh();
|
||||
for (point, expected) in
|
||||
[([5.0, 25.0, 4.0], true), ([12.0, 25.0, 4.0], false), ([68.0, 25.0, 4.0], false), ([40.0, 25.0, 12.0], false), ([0.0, 0.0, 4.0], false)]
|
||||
{
|
||||
assert_eq!(mesh.contains(solvecraft_geom::Vec3::new(point[0], point[1], point[2])), expected, "{point:?}");
|
||||
}
|
||||
assert_eq!(mesh.contains(boss_probe), source_boss);
|
||||
assert_eq!(state.body(&body).unwrap().mesh().contains(boss_probe), source_boss);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!((volume(&mut reopened, new) - original_volume).abs() < 1e-5 * original_volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_copy_parameters_cannot_heal_an_unrelated_missing_reference() {
|
||||
let mut s = Session::default();
|
||||
let part = component(&mut s, "Valid");
|
||||
let valid = box2(&mut s);
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let kind = s.doc.feature(valid).unwrap().kind.clone();
|
||||
let broken = s.doc_mut().add_feature(kind, Some("Broken reference")).unwrap();
|
||||
let mut probe = (*s.doc).clone();
|
||||
let missing = probe.new_model_param("2 mm", "mm");
|
||||
let feature = s.doc_mut().feature_mut(broken).unwrap();
|
||||
feature.component = 0;
|
||||
if let solvecraft_doc::FeatureKind::Box { width, .. } = &mut feature.kind {
|
||||
*width = missing;
|
||||
}
|
||||
s.refresh();
|
||||
let original_error = s.model.result(broken).unwrap().error.clone();
|
||||
assert!(original_error.is_some());
|
||||
let saved = s.doc.to_json();
|
||||
let context = (s.active_component, s.active_occurrence_path.clone(), s.active_sketch);
|
||||
let selection = s.selection.clone();
|
||||
let pending = s.pending_moves.clone();
|
||||
let error = s.execute("component.paste_new", &json!({"component":part})).unwrap_err();
|
||||
assert!(error.to_string().contains("original feature's error state"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
assert_eq!(s.model.result(broken).unwrap().error, original_error);
|
||||
assert_eq!((s.active_component, s.active_occurrence_path.clone(), s.active_sketch), context);
|
||||
assert_eq!(s.selection, selection);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
}
|
||||
@@ -28,8 +28,66 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
];
|
||||
|
||||
fn column_of(s: &Session, p: &Value, cmd: &str) -> Result<Column> {
|
||||
if ["param", "feature", "material", "appearance"].iter().filter(|k| p.get(**k).is_some()).count() != 1 {
|
||||
return Err(bad(cmd, "give exactly one of `param`, `feature`, `material`, or `appearance`"));
|
||||
if ["param", "feature", "material", "appearance", "component_appearance", "face_appearance"].iter().filter(|k| p.get(**k).is_some()).count() != 1
|
||||
{
|
||||
return Err(bad(cmd, "give exactly one configuration target"));
|
||||
}
|
||||
if let Some(value) = p.get("component_appearance") {
|
||||
let key = value
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.or_else(|| value.as_u64().map(|id| id.to_string()))
|
||||
.ok_or_else(|| bad(cmd, "component name or ID required"))?;
|
||||
if bool_(p, "delete") == Some(true)
|
||||
&& let Some(column) = s.doc.configs.columns.iter().find(|column| matches!(column,Column::ComponentAppearance(id) if id.to_string()==key))
|
||||
{
|
||||
return Ok(column.clone());
|
||||
}
|
||||
let id = s
|
||||
.doc
|
||||
.find_component(&key)
|
||||
.or_else(|| s.doc.occurrences.iter().find(|occurrence| occurrence.name == key).map(|occurrence| occurrence.component))
|
||||
.filter(|id| *id != 0)
|
||||
.ok_or_else(|| bad(cmd, "no component definition"))?;
|
||||
return Ok(Column::ComponentAppearance(id));
|
||||
}
|
||||
if let Some(value) = p.get("face_appearance") {
|
||||
if bool_(p, "delete") == Some(true) {
|
||||
let key = value.as_str().ok_or_else(|| bad(cmd, "face column key required for deletion"))?;
|
||||
return s
|
||||
.doc
|
||||
.configs
|
||||
.columns
|
||||
.iter()
|
||||
.find(|column| matches!(column, Column::FaceAppearance(_)) && s.doc.config_key(column) == key)
|
||||
.cloned()
|
||||
.ok_or_else(|| bad(cmd, "no face column"));
|
||||
}
|
||||
let body_name = value.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "face body required"))?;
|
||||
let point = value.get("point").and_then(crate::params::vec3).ok_or_else(|| bad(cmd, "finite face point required"))?;
|
||||
let world = s.world_state();
|
||||
let body = world.body(body_name).ok_or_else(|| bad(cmd, "no face body"))?;
|
||||
if body.body.is_mesh() {
|
||||
return Err(bad(cmd, "mesh faces have no supported semantic lineage"));
|
||||
}
|
||||
let index = solvecraft_doc::appearance::face_index_at(body, point).ok_or_else(|| bad(cmd, "no face at picked point"))?;
|
||||
let names = solvecraft_doc::naming::face_names(body);
|
||||
let name = names.get(index).map(|name| solvecraft_doc::naming::strip_piece(name).to_string()).ok_or_else(|| bad(cmd, "face name missing"))?;
|
||||
let origin_feature = name
|
||||
.strip_prefix('F')
|
||||
.and_then(|name| name.split_once(':'))
|
||||
.and_then(|(id, _)| id.parse::<u64>().ok())
|
||||
.ok_or_else(|| bad(cmd, "face lineage missing"))?;
|
||||
let inverse = solvecraft_doc::mat_inverse(&body.source_transform).ok_or_else(|| bad(cmd, "invalid definition placement transform"))?;
|
||||
let target = solvecraft_doc::config::ConfigFaceTarget {
|
||||
body: body.definition_name().into(),
|
||||
origin_feature,
|
||||
name,
|
||||
point: solvecraft_doc::apply_point(&inverse, point),
|
||||
};
|
||||
s.doc.certify_config_face(&target).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
s.doc.configuration_face_indices(body, &target).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
return Ok(Column::FaceAppearance(target));
|
||||
}
|
||||
for (key, appearance) in [("material", false), ("appearance", true)] {
|
||||
if let Some(body) = str_(p, key) {
|
||||
@@ -53,7 +111,9 @@ fn column_of(s: &Session, p: &Value, cmd: &str) -> Result<Column> {
|
||||
|
||||
/// Add a column, filling existing rows with the design's current value.
|
||||
fn add_column(s: &mut Session, c: Column, cmd: &str) -> Result<()> {
|
||||
if s.doc.configs.columns.contains(&c) {
|
||||
if s.doc.configs.columns.iter().any(|existing| {
|
||||
*existing == c || matches!((existing,&c),(Column::FaceAppearance(a),Column::FaceAppearance(b)) if a.body == b.body && a.name == b.name)
|
||||
}) {
|
||||
return Ok(());
|
||||
}
|
||||
if s.doc.configs.columns.len() >= MAX_COLUMNS {
|
||||
@@ -85,6 +145,18 @@ fn configure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_column(s, c, cmd)?;
|
||||
}
|
||||
}
|
||||
for (plural, singular) in [("component_appearances", "component_appearance"), ("face_appearances", "face_appearance")] {
|
||||
if let Some(values) = p.get(plural) {
|
||||
let values = values
|
||||
.as_array()
|
||||
.filter(|values| values.len() <= MAX_COLUMNS)
|
||||
.ok_or_else(|| bad(cmd, "configuration targets must be a bounded array"))?;
|
||||
for value in values {
|
||||
let c = column_of(s, &json!({singular:value}), cmd)?;
|
||||
add_column(s, c, cmd)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.doc.configs.rows.is_empty() {
|
||||
let cells = s.doc.configs.columns.iter().map(|c| s.doc.config_current(c).unwrap_or_default()).collect();
|
||||
let t = &mut s.doc_mut().configs;
|
||||
@@ -102,7 +174,26 @@ fn show(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
.iter()
|
||||
.map(|r| json!({"name": r.name, "values": keys.iter().zip(&r.cells).map(|(k, v)| (k.clone(), json!(v))).collect::<serde_json::Map<_, _>>()}))
|
||||
.collect();
|
||||
Ok(json!({"columns": keys, "rows": rows, "rules": t.rules, "active": t.active, "matches": s.doc.configuration_matches()}))
|
||||
let targets: Vec<Value> = t
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
let label = match column {
|
||||
Column::ComponentAppearance(id) => {
|
||||
format!("Component: {}", s.doc.components.iter().find(|c| c.id == *id).map(|c| c.name.clone()).unwrap_or_else(|| id.to_string()))
|
||||
}
|
||||
Column::FaceAppearance(target) => {
|
||||
format!("Face: {} / {}", target.body, target.name.split_once(':').map(|(_, role)| role).unwrap_or(&target.name))
|
||||
}
|
||||
_ => s.doc.config_key(column),
|
||||
};
|
||||
json!({"key":s.doc.config_key(column),"label":label,"target":column})
|
||||
})
|
||||
.collect();
|
||||
let styles = s.doc.configuration_style_status(&s.model.state());
|
||||
Ok(
|
||||
json!({"columns": keys, "targets":targets, "rows": rows, "rules": t.rules, "active": t.active, "matches": s.doc.configuration_matches() && styles.is_ok(), "style_status":styles.as_ref().ok(),"style_error":styles.err().map(|error|error.to_string())}),
|
||||
)
|
||||
}
|
||||
|
||||
fn column(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -154,7 +245,9 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Value::String(t) => t.clone(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Object(_) if k.starts_with("appearance:") => serde_json::to_string(v).map_err(|e| bad(cmd, e.to_string()))?,
|
||||
Value::Object(_) if k.starts_with("appearance:") || k.starts_with("component_appearance:") || k.starts_with("face_appearance:") => {
|
||||
serde_json::to_string(v).map_err(|e| bad(cmd, e.to_string()))?
|
||||
}
|
||||
_ => return Err(bad(cmd, format!("value for `{k}` must be an expression or true/false"))),
|
||||
};
|
||||
if text.len() > 4096 {
|
||||
@@ -170,7 +263,7 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|| match c {
|
||||
Column::Param(n) => n == k,
|
||||
Column::Suppress(id) => s.doc.feature(*id).is_some_and(|f| f.name == *k),
|
||||
Column::Material(_) | Column::Appearance(_) => false,
|
||||
Column::Material(_) | Column::Appearance(_) | Column::ComponentAppearance(_) | Column::FaceAppearance(_) => false,
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| bad(cmd, format!("`{k}` is not a column (config.column adds one)")))?;
|
||||
@@ -178,7 +271,7 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(Column::Material(_)) => {
|
||||
text = solvecraft_doc::config::material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into());
|
||||
}
|
||||
Some(Column::Appearance(_)) => {
|
||||
Some(Column::Appearance(_) | Column::ComponentAppearance(_) | Column::FaceAppearance(_)) => {
|
||||
text = match solvecraft_doc::config::appearance_cell(&text).map_err(|e| bad(cmd, e.to_string()))? {
|
||||
Some(look) => serde_json::to_string(&look).map_err(|e| bad(cmd, e.to_string()))?,
|
||||
None => String::new(),
|
||||
@@ -303,12 +396,14 @@ fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !new_errors.is_empty() {
|
||||
return Err(bad(cmd, format!("configuration `{r}` would fail to rebuild: {}", new_errors.join("; "))));
|
||||
}
|
||||
let styles = trial.doc.configuration_style_status(&trial.model.state()).map_err(|error| bad(cmd, format!("configuration `{r}`: {error}")))?;
|
||||
s.doc = trial.doc;
|
||||
s.refresh();
|
||||
let errors: Vec<String> = s.model.results.iter().filter_map(|x| x.error.clone()).collect();
|
||||
let mut out = show(s, p)?;
|
||||
out["errors"] = json!(errors);
|
||||
out["trace"] = json!(trace);
|
||||
out["styles"] = json!(styles);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -320,6 +415,11 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "config_style_tests.rs"]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod style_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::config::{Column, ConfigFaceTarget};
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command} {params}: {error}"))
|
||||
}
|
||||
fn block(s: &mut Session) -> String {
|
||||
run(s, "solid.box", json!({"length":10,"width":10,"height":10}));
|
||||
s.model.state().bodies[0].name.clone()
|
||||
}
|
||||
fn face_column(s: &mut Session, body: &str, point: Value) -> String {
|
||||
run(s, "config.configure", json!({"face_appearances":[{"body":body,"point":point}]}))["columns"][0].as_str().unwrap().into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_appearance_shared_definition_inheritance_and_delete() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Part"}))["component"].as_u64().unwrap();
|
||||
let body = block(&mut s);
|
||||
run(&mut s, "material.assign", json!({"bodies":[body],"material":"Steel"}));
|
||||
run(&mut s, "occurrence.copy", json!({"component":component,"translate":[30,0,0]}));
|
||||
let table = run(&mut s, "config.configure", json!({"component_appearances":[component]}));
|
||||
let key = table["columns"][0].as_str().unwrap().to_string();
|
||||
run(&mut s, "config.row", json!({"name":"Red","values":{key.clone():"ABS - Red"}}));
|
||||
run(&mut s, "config.activate", json!({"row":"Red"}));
|
||||
let world = s.world_state();
|
||||
assert_eq!(world.bodies.len(), 2);
|
||||
assert!(world.bodies.iter().all(|body| s.doc.model_body_look(body).unwrap().name == "ABS - Red"));
|
||||
run(&mut s, "appearance.assign", json!({"bodies":[body],"appearance":"ABS - Yellow"}));
|
||||
assert!(s.world_state().bodies.iter().all(|body| s.doc.model_body_look(body).unwrap().name == "ABS - Yellow"));
|
||||
run(&mut s, "config.activate", json!({"row":"Default"}));
|
||||
assert!(!s.doc.appearances.components.contains_key(&component));
|
||||
assert_eq!(s.doc.appearances.bodies[&body].name, "ABS - Yellow");
|
||||
run(&mut s, "component.rename", json!({"component":component,"name":"Renamed"}));
|
||||
assert_eq!(run(&mut s, "config.table", json!({}))["columns"][0], key);
|
||||
run(&mut s, "appearance.assign", json!({"components":[component.to_string()],"appearance":"ABS - Blue"}));
|
||||
run(&mut s, "component.delete", json!({"component":component}));
|
||||
assert!(s.doc.configs.columns.is_empty());
|
||||
assert!(s.doc.configs.rows.iter().all(|row| row.cells.is_empty()));
|
||||
assert!(!s.doc.appearances.components.contains_key(&component));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_face_rows_resize_rename_preview_suppress_and_roundtrip() {
|
||||
let mut s = Session::default();
|
||||
let body = block(&mut s);
|
||||
let key = face_column(&mut s, &body, json!([5, 5, 10]));
|
||||
let feature = s.doc.features[0].name.clone();
|
||||
let height = s.doc.features[0].param_names[2].clone();
|
||||
run(&mut s, "config.column", json!({"param":height}));
|
||||
run(&mut s, "config.column", json!({"feature":feature}));
|
||||
run(&mut s, "config.row", json!({"name":"Tall","values":{key:"ABS - Red",height:"20 mm"}}));
|
||||
let before = s.doc.clone();
|
||||
let revision = s.revision;
|
||||
let history = s.undo.len();
|
||||
let preview = run(&mut s, "config.preview", json!({"row":"Tall"}));
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.revision, revision);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
assert_eq!(preview["styles"][0]["dormant"], false);
|
||||
run(&mut s, "config.activate", json!({"row":"Tall"}));
|
||||
let state = s.model.state();
|
||||
let mb = state.body(&body).unwrap();
|
||||
let colors = s.doc.face_colors(mb);
|
||||
assert_eq!(colors.len(), 1);
|
||||
let names = solvecraft_doc::naming::face_names(mb);
|
||||
assert!(names[colors[0].0].ends_with(":+z"));
|
||||
assert!(s.doc.appearances.faces[0].strict_name);
|
||||
run(&mut s, "appearance.assign", json!({"faces":[{"body":body,"point":[5,5,20]}]}));
|
||||
assert!(s.doc.appearances.faces.is_empty(), "clear resolves semantic name after configured resize");
|
||||
run(&mut s, "config.activate", json!({"row":"Tall"}));
|
||||
run(&mut s, "body.rename", json!({"body":body,"name":"Housing"}));
|
||||
let key = s.doc.config_key(&s.doc.configs.columns[0]);
|
||||
let suppress = format!("suppress:{feature}");
|
||||
run(&mut s, "config.row", json!({"name":"Hidden","values":{key:"ABS - Blue",suppress:true}}));
|
||||
let hidden = run(&mut s, "config.activate", json!({"row":"Hidden"}));
|
||||
assert_eq!(hidden["styles"][0]["dormant"], true);
|
||||
assert!(s.model.state().bodies.is_empty());
|
||||
let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened, *s.doc);
|
||||
run(&mut s, "config.activate", json!({"row":"Tall"}));
|
||||
assert_eq!(s.doc.face_colors(s.model.state().body("Housing").unwrap())[0].1.name, "ABS - Red");
|
||||
run(&mut s, "config.activate", json!({"row":"Default"}));
|
||||
assert!(s.doc.appearances.faces.is_empty());
|
||||
let before_delete = s.doc.clone();
|
||||
run(&mut s, "timeline.delete", json!({"feature":feature}));
|
||||
assert!(s.doc.configs.columns.is_empty());
|
||||
assert!(s.doc.configs.rows.iter().all(|row| row.cells.is_empty()));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc, before_delete);
|
||||
run(&mut s, "config.activate", json!({"row":"Tall"}));
|
||||
assert!(s.doc.appearances.faces[0].strict_name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_face_split_pieces_share_lineage_without_index_targets() {
|
||||
let mut s = Session::default();
|
||||
let body = block(&mut s);
|
||||
let key = face_column(&mut s, &body, json!([5, 5, 10]));
|
||||
run(&mut s, "config.row", json!({"name":"Paint","values":{key:"ABS - Red"}}));
|
||||
run(&mut s, "solid.split_face", json!({"body":body,"faces":[[5,5,10]],"plane":{"origin":[5,0,0],"normal":[1,0,0]}}));
|
||||
run(&mut s, "config.activate", json!({"row":"Paint"}));
|
||||
let state = s.model.state();
|
||||
let body = state.body(&body).unwrap();
|
||||
let colors = s.doc.face_colors(body);
|
||||
let names = solvecraft_doc::naming::face_names(body);
|
||||
assert!(colors.len() >= 2, "split should create multiple painted pieces: {names:?}");
|
||||
assert!(colors.iter().all(|(index, look)| solvecraft_doc::naming::strip_piece(&names[*index]).ends_with(":+z") && look.name == "ABS - Red"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_face_missing_lineage_never_retargets_and_rolls_back() {
|
||||
let mut s = Session::default();
|
||||
let body = block(&mut s);
|
||||
let key = face_column(&mut s, &body, json!([5, 5, 10]));
|
||||
run(&mut s, "config.row", json!({"name":"Paint","values":{key:"ABS - Red"}}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[30,0,0],"operation":"new"}));
|
||||
let other = s.doc.features.last().unwrap().id;
|
||||
let Column::FaceAppearance(target) = &mut s.doc_mut().configs.columns[0] else { panic!() };
|
||||
target.origin_feature = other;
|
||||
target.name = format!("F{other}:+z");
|
||||
let before = s.doc.clone();
|
||||
let revision = s.revision;
|
||||
let history = s.undo.len();
|
||||
assert!(s.execute("config.activate", &json!({"row":"Paint"})).is_err());
|
||||
assert!(s.execute("config.preview", &json!({"row":"Paint"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.revision, revision);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
let look = solvecraft_doc::appearance::library("ABS - Red").unwrap();
|
||||
s.doc_mut().appearances.faces.push(solvecraft_doc::appearance::FaceLook {
|
||||
body: body.clone(),
|
||||
point: solvecraft_geom::Vec3::new(5., 5., 10.),
|
||||
name: Some(format!("F{other}:+z")),
|
||||
strict_name: true,
|
||||
look,
|
||||
});
|
||||
assert!(s.doc.face_colors(s.model.state().body(&body).unwrap()).is_empty());
|
||||
s.doc_mut().appearances.faces[0].strict_name = false;
|
||||
assert_eq!(s.doc.face_colors(s.model.state().body(&body).unwrap()).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_occurrence_pick_uses_definition_identity_and_coordinates() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Part"}))["component"].as_u64().unwrap();
|
||||
let body = block(&mut s);
|
||||
run(&mut s, "occurrence.copy", json!({"component":component,"translate":[30,0,0]}));
|
||||
let world = s.world_state();
|
||||
let placed = world.bodies.iter().find(|body| body.name != body.definition_name()).unwrap();
|
||||
let placed_name = placed.name.clone();
|
||||
let point = solvecraft_doc::apply_point(&placed.source_transform, solvecraft_geom::Vec3::new(5., 5., 10.));
|
||||
let key = face_column(&mut s, &placed_name, json!(point));
|
||||
let Column::FaceAppearance(target) = &s.doc.configs.columns[0] else { panic!() };
|
||||
assert_eq!(target.body, body);
|
||||
assert!(target.point.dist(solvecraft_geom::Vec3::new(5., 5., 10.)) < 1e-8);
|
||||
run(&mut s, "config.row", json!({"name":"Paint","values":{key:"ABS - Blue"}}));
|
||||
run(&mut s, "config.activate", json!({"row":"Paint"}));
|
||||
assert!(s.world_state().bodies.iter().all(|body| s.doc.face_colors(body).len() == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extrude_cap_certification_follows_actual_positive_negative_and_symmetric_names() {
|
||||
for direction in ["positive", "negative", "symmetric"] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"xy"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":10,"direction":direction,"body_name":"Pad"}));
|
||||
let state = s.model.state();
|
||||
let body = state.body("Pad").unwrap();
|
||||
let faces = body.body.faces(0.01).unwrap();
|
||||
let names = solvecraft_doc::naming::face_names(body);
|
||||
let mut caps = 0;
|
||||
for face in faces {
|
||||
let name = solvecraft_doc::naming::strip_piece(&names[face.index]);
|
||||
let origin = name.strip_prefix('F').unwrap().split_once(':').unwrap().0.parse().unwrap();
|
||||
let target = ConfigFaceTarget { body: "Pad".into(), origin_feature: origin, name: name.into(), point: face.centroid };
|
||||
if name.ends_with(":start") || name.ends_with(":top") || name.ends_with(":bottom") {
|
||||
s.doc.certify_config_face(&target).unwrap();
|
||||
s.doc.configuration_face_indices(body, &target).unwrap();
|
||||
caps += 1;
|
||||
} else {
|
||||
assert!(s.doc.certify_config_face(&target).is_err());
|
||||
}
|
||||
}
|
||||
assert_eq!(caps, 2, "{direction}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_extent_edits_never_transfer_strict_cap_paint_to_ambiguous_caps() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"xy"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Pad","name":"PadFeature","start_offset":"0 mm"}));
|
||||
let key = face_column(&mut s, "Pad", json!([5, 5, 10]));
|
||||
run(&mut s, "config.row", json!({"name":"Paint","values":{key:"ABS - Red"}}));
|
||||
run(&mut s, "config.activate", json!({"row":"Paint"}));
|
||||
assert_eq!(s.doc.face_colors(s.model.state().body("Pad").unwrap()).len(), 1);
|
||||
run(&mut s, "timeline.redefine", json!({"feature":"PadFeature","command":"solid.extrude","params":{"distance":10,"start_offset":5}}));
|
||||
assert!(s.doc.face_colors(s.model.state().body("Pad").unwrap()).is_empty(), "ordinary offset edit must not paint both new top-named caps");
|
||||
let before = s.doc.clone();
|
||||
assert!(s.execute("config.activate", &json!({"row":"Paint"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_and_uncertified_face_targets_are_rejected() {
|
||||
let mut s = Session::default();
|
||||
let body = block(&mut s);
|
||||
let id = s.doc.features[0].id;
|
||||
let target = ConfigFaceTarget { body: body.clone(), origin_feature: id, name: format!("F{id}:face:0"), point: solvecraft_geom::Vec3::ZERO };
|
||||
assert!(s.doc.certify_config_face(&target).is_err());
|
||||
for value in [
|
||||
json!({"body":body,"origin_feature":id,"name":"x".repeat(1_000_000),"point":[0,0,0]}),
|
||||
json!({"body":body,"origin_feature":id,"name":format!("F{id}:+z"),"point":[0,0,1e20]}),
|
||||
json!({"body":body,"origin_feature":id,"name":format!("F{id}:+z"),"point":[0,0,0],"extra":[[]]}),
|
||||
] {
|
||||
assert!(solvecraft_doc::config::validate_styles_json(&json!({"configs":{"columns":[{"face_appearance":value}]}})).is_err());
|
||||
}
|
||||
assert!(s.execute("config.column", &json!({"component_appearance":0})).is_err());
|
||||
assert!(s.execute("config.column", &json!({"face_appearance":{"body":body,"point":[0,0,1e20]}})).is_err());
|
||||
}
|
||||
@@ -355,6 +355,9 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// selections, component authoring state or the original feature's definition.
|
||||
let mut trial = s.scratch();
|
||||
trial.active_component = old.component;
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &old.kind {
|
||||
trial.active_occurrence_path = (!face.author_path.is_empty()).then(|| face.author_path.clone());
|
||||
}
|
||||
trial.active_sketch = None;
|
||||
trial.selection = s.selection.clone();
|
||||
trial.doc_mut().marker = Some(idx);
|
||||
@@ -362,7 +365,15 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(spec.enabled)(&trial).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
|
||||
let r = (spec.run)(&mut trial, ¶ms)?;
|
||||
let made = r.get("feature").and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
|
||||
let new = trial.doc.feature(made).cloned().ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
|
||||
let mut new = trial.doc.feature(made).cloned().ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
|
||||
if let (FeatureKind::Emboss { face: Some(before), .. }, FeatureKind::Emboss { face: Some(after), .. }) = (&old.kind, &mut new.kind)
|
||||
&& before.body == after.body
|
||||
&& before.path == after.path
|
||||
&& before.author_path == after.author_path
|
||||
&& before.at.dist(after.at) < 1e-8
|
||||
{
|
||||
*after = before.clone();
|
||||
}
|
||||
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) {
|
||||
return Err(bad(cmd, format!("{} is not a {} feature", old.name, cid)));
|
||||
}
|
||||
|
||||
@@ -321,7 +321,24 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
|
||||
};
|
||||
let face_names = {
|
||||
let pts = kind.face_points();
|
||||
if pts.is_empty() { Vec::new() } else { solvecraft_doc::naming::face_names_at(&s.model.state(), &pts) }
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &kind {
|
||||
let state = s.model.state();
|
||||
let body = state.body(&face.body).ok_or_else(|| EngineError::Other("selected cylinder body no longer exists".into()))?;
|
||||
let frame = face.body_frame(&s.doc, body.feature).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let inverse = solvecraft_doc::mat_inverse(&frame).ok_or_else(|| EngineError::Other("invalid cylinder placement".into()))?;
|
||||
let at = solvecraft_doc::apply_point(&solvecraft_doc::mat_mul(&inverse, &s.active_component_frame()), face.at);
|
||||
let mut scoped = solvecraft_doc::ModelState::default();
|
||||
scoped.bodies.push(body.clone());
|
||||
let names = solvecraft_doc::naming::face_names_at(&scoped, &[at]);
|
||||
if names.len() != 1 || names.first().is_none_or(|name| name.is_empty()) {
|
||||
return Err(EngineError::Other("selected cylinder face has no persistent identity".into()));
|
||||
}
|
||||
names
|
||||
} else if pts.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
solvecraft_doc::naming::face_names_at(&s.model.state(), &pts)
|
||||
}
|
||||
};
|
||||
let participants = string_list(p, "participants");
|
||||
if participants.len() > 10_000 || participants.iter().any(|b| b != "*" && s.model.state().body(b).is_none()) {
|
||||
|
||||
@@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.emboss", "Emboss", emboss)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("emboss")
|
||||
.params("sketch (on a planar face), profiles?; depth; mode?: emboss|deboss (default emboss); targets?"),
|
||||
.params("sketch, profiles?; depth; mode?: emboss|deboss; targets?; face?: world point [x,y,z] selecting a planar or complete exterior cylindrical wall (required for wrapped orthogonal line profiles)"),
|
||||
CommandSpec::new("solid.replace_face", "Replace Face", replace_face)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("offset_face")
|
||||
@@ -143,12 +143,97 @@ fn emboss(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some("deboss") => true,
|
||||
Some(m) => return Err(bad(cmd, format!("unknown mode `{m}` (emboss or deboss)"))),
|
||||
};
|
||||
if let Some(fp) = p.get("face").and_then(vec3)
|
||||
&& super::face::face_normal(s, fp).is_none()
|
||||
{
|
||||
return Err(bad(cmd, "not supported yet: embossing onto a curved face"));
|
||||
}
|
||||
add_feature(s, p, FeatureKind::Emboss { sketch, profiles, depth, deboss, targets: string_list(p, "targets") })
|
||||
let mut targets = string_list(p, "targets");
|
||||
let face = if let Some(value) = p.get("face") {
|
||||
let local = vec3(value).ok_or_else(|| bad(cmd, "`face` must be a finite point [x,y,z]"))?;
|
||||
let at = solvecraft_doc::apply_point(&s.active_component_frame(), local);
|
||||
let state = s.world_state();
|
||||
let mut candidates = Vec::new();
|
||||
for body in &state.bodies {
|
||||
let definition = body.definition_name();
|
||||
if !targets.is_empty() && !targets.iter().any(|n| n == definition || n == &body.name) {
|
||||
continue;
|
||||
}
|
||||
let mesh = body.mesh();
|
||||
let closest = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter_map(|(tri, face)| {
|
||||
let [a, b, c] = mesh.tri(tri)?;
|
||||
let n = (b - a).cross(c - a).normalized()?;
|
||||
let q = at - n * (at - a).dot(n);
|
||||
let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-9);
|
||||
inside.then_some(((at - a).dot(n).abs(), *face as usize))
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0));
|
||||
if let Some((distance, index)) = closest
|
||||
&& distance < (body.body.size() * 1e-3).max(1e-3)
|
||||
{
|
||||
candidates.push((body, index));
|
||||
}
|
||||
}
|
||||
if candidates.len() != 1 {
|
||||
return Err(bad(cmd, "`face` must identify exactly one target body surface"));
|
||||
}
|
||||
let (selected, index) = candidates.first().copied().ok_or_else(|| bad(cmd, "no target face"))?;
|
||||
let name = selected.definition_name();
|
||||
let body = &selected.body;
|
||||
match solvecraft_kernel::face_surface(body, index) {
|
||||
Some(solvecraft_kernel::FaceSurface::Plane { .. }) => {
|
||||
targets = vec![name.to_owned()];
|
||||
None
|
||||
}
|
||||
Some(solvecraft_kernel::FaceSurface::Cylinder { .. }) => {
|
||||
if targets.len() > 1 {
|
||||
return Err(bad(cmd, "cylinder emboss needs one selected target body"));
|
||||
}
|
||||
// Viewport picks lie on tessellated chords. Keep their axial and
|
||||
// angular position, and certify their distance before storing an
|
||||
// exact surface anchor for later replay.
|
||||
let at = solvecraft_kernel::cylinder_emboss_anchor(body, index, at).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let original = s.model.state();
|
||||
let owner = s.doc.body_component(name, selected.feature);
|
||||
let paths: Vec<_> = s
|
||||
.doc
|
||||
.placements()
|
||||
.into_iter()
|
||||
.filter(|(comp, path, _)| *comp == owner && s.doc.world_body_name(&original, name, owner, path) == selected.name)
|
||||
.map(|(_, path, _)| path)
|
||||
.collect();
|
||||
if paths.len() != 1 {
|
||||
return Err(bad(cmd, "selected cylinder placement is ambiguous"));
|
||||
}
|
||||
let path = paths.into_iter().next().ok_or_else(|| bad(cmd, "selected cylinder placement"))?;
|
||||
if path.len() > 64 {
|
||||
return Err(bad(cmd, "selected cylinder placement is too deep"));
|
||||
}
|
||||
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(s.active_component));
|
||||
if author_path.len() > 64 {
|
||||
return Err(bad(cmd, "author placement is too deep"));
|
||||
}
|
||||
let sketch_owner = s.doc.feature(sketch).map(|f| f.component).ok_or_else(|| bad(cmd, "profile sketch no longer exists"))?;
|
||||
let sketch_path = if sketch_owner == owner {
|
||||
path.clone()
|
||||
} else if sketch_owner == s.active_component {
|
||||
author_path.clone()
|
||||
} else {
|
||||
s.doc.first_path(sketch_owner)
|
||||
};
|
||||
if path.iter().chain(&author_path).chain(&sketch_path).any(|id| s.pending_moves.contains_key(id)) {
|
||||
return Err(bad(cmd, "capture pending source, author and profile positions with Capture Position before cylinder emboss"));
|
||||
}
|
||||
let inverse = solvecraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid active frame"))?;
|
||||
let at = solvecraft_doc::apply_point(&inverse, at);
|
||||
targets = vec![name.to_owned()];
|
||||
Some(solvecraft_doc::EmbossFace { body: name.to_owned(), at, path, author_path })
|
||||
}
|
||||
_ => return Err(bad(cmd, "only planar faces and complete exterior cylindrical walls support emboss")),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
add_feature(s, p, FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face })
|
||||
}
|
||||
|
||||
fn face_points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
|
||||
@@ -81,6 +81,268 @@ fn emboss_and_deboss_on_a_face() {
|
||||
assert!(s.execute("solid.emboss", &json!({"sketch": "Logo", "depth": 1, "mode": "sideways"})).is_err());
|
||||
}
|
||||
|
||||
fn cylinder_badge() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name":"badge_depth","expression":"1 mm"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius":"10 mm","height":"20 mm","body_name":"Sleeve"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
s
|
||||
}
|
||||
|
||||
fn exact_cylinder_volume(s: &Session) -> f64 {
|
||||
s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_emboss_persists_selection_and_replays_radial_depth() {
|
||||
use solvecraft_geom::Vec3;
|
||||
let mut s = cylinder_badge();
|
||||
let before = exact_cylinder_volume(&s);
|
||||
run(
|
||||
&mut s,
|
||||
"solid.emboss",
|
||||
json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":"badge_depth","targets":["Sleeve"],"name":"Raised Badge"}),
|
||||
);
|
||||
assert!((exact_cylinder_volume(&s) - before - 21.0).abs() < 0.02);
|
||||
let state = s.model.state();
|
||||
let body = &state.body("Sleeve").unwrap().body;
|
||||
assert_eq!(body.shell_count(), 1);
|
||||
let mesh = body.tessellate_with(0.001, true).unwrap();
|
||||
assert!(mesh.contains(Vec3::new(-10.5 * 0.15_f64.sin(), 10.5 * 0.15_f64.cos(), 7.5)));
|
||||
let feature = s.doc.find_feature("Raised Badge").unwrap();
|
||||
assert!(
|
||||
matches!(&feature.kind,solvecraft_doc::FeatureKind::Emboss{face:Some(face),..} if face.body=="Sleeve"&&face.at.dist(Vec3::new(0.,10.,7.5))<1e-10)
|
||||
);
|
||||
let saved = s.doc.to_json();
|
||||
let mut loaded = Session { doc: solvecraft_doc::Document::from_json(&saved).unwrap().into(), ..Default::default() };
|
||||
loaded.refresh();
|
||||
assert!(loaded.model.results.iter().all(|f| f.error.is_none()), "{:?}", loaded.model.results);
|
||||
assert!((exact_cylinder_volume(&loaded) - before - 21.0).abs() < 0.02);
|
||||
run(&mut loaded, "parameters.change", json!({"name":"badge_depth","expression":"2 mm"}));
|
||||
assert!(loaded.model.results.iter().all(|f| f.error.is_none()), "{:?}", loaded.model.results);
|
||||
assert!((exact_cylinder_volume(&loaded) - before - 44.0).abs() < 0.02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_deboss_is_connected_and_unsupported_profile_failure_is_atomic() {
|
||||
let mut s = cylinder_badge();
|
||||
let before = exact_cylinder_volume(&s);
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","face":[0,10,7.5],"depth":1,"mode":"deboss","targets":["Sleeve"]}));
|
||||
assert!((exact_cylinder_volume(&s) - before + 19.0).abs() < 0.02);
|
||||
assert_eq!(s.model.state().body("Sleeve").unwrap().body.shell_count(), 1);
|
||||
let mut bad = cylinder_badge();
|
||||
run(&mut bad, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Circle Badge"}));
|
||||
run(&mut bad, "sketch.circle.center", json!({"center":[0,7.5],"radius":1}));
|
||||
run(&mut bad, "sketch.finish", json!({}));
|
||||
let saved = bad.doc.to_json();
|
||||
let v = exact_cylinder_volume(&bad);
|
||||
assert!(bad.execute("solid.emboss", &json!({"sketch":"Circle Badge","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]})).is_err());
|
||||
assert_eq!(bad.doc.to_json(), saved);
|
||||
assert_eq!(exact_cylinder_volume(&bad), v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_small_chord_pick_is_saved_on_the_exact_surface() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":"0.1mm","height":"0.2mm","body_name":"Tiny"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,0.1,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-0.02,0.05],"p1":[0.02,0.1]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let mesh = s.model.state().bodies.first().unwrap().mesh();
|
||||
let pick = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.filter_map(|tri| mesh.tri(tri))
|
||||
.map(|[a, b, c]| (a + b + c) / 3.)
|
||||
.find(|p| p.y > 0.08 && p.z > 0.04 && p.z < 0.16)
|
||||
.unwrap();
|
||||
assert!(((pick.x * pick.x + pick.y * pick.y).sqrt() - 0.1).abs() > 1e-7);
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[pick.x,pick.y,pick.z],"depth":"0.01mm","targets":["Tiny"]}));
|
||||
let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &s.doc.features.last().unwrap().kind else {
|
||||
panic!("missing face");
|
||||
};
|
||||
assert!(((face.at.x * face.at.x + face.at.y * face.at.y).sqrt() - 0.1).abs() < 1e-12);
|
||||
assert_eq!(s.model.state().bodies.first().unwrap().body.shell_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_cross_component_emboss_keeps_source_owner_and_placements() {
|
||||
for pick_offset in [0., 80.] {
|
||||
let mut s = Session::default();
|
||||
let a = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
let source_feature = s.model.state().bodies.first().unwrap().feature;
|
||||
let source_names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap());
|
||||
let before = exact_cylinder_volume(&s);
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":"Source:1","axis":[0,0,1],"angle":"90 deg","translate":[50,0,0]}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component":a,"translate":[80,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":"root"}));
|
||||
let author = run(&mut s, "component.create", json!({"name":"Author"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":"Author:1","axis":[1,0,0],"angle":"90 deg","translate":[-100,40,30]}));
|
||||
let author_second = run(&mut s, "occurrence.copy", json!({"component":author,"translate":[0,100,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":author,"occurrence":author_second}));
|
||||
run(&mut s, "parameters.add", json!({"name":"badge_depth","expression":"1mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[40.+pick_offset,0,0],"x_dir":[0,-1,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let placements = serde_json::to_value(&s.doc.occurrences).unwrap();
|
||||
let target = s
|
||||
.world_state()
|
||||
.bodies
|
||||
.iter()
|
||||
.find(|b| b.body.tessellate_with(0.001, true).unwrap().contains(solvecraft_geom::Vec3::new(40.5 + pick_offset, 0., 7.5)))
|
||||
.unwrap()
|
||||
.name
|
||||
.clone();
|
||||
run(
|
||||
&mut s,
|
||||
"solid.emboss",
|
||||
json!({"sketch":"Badge","profiles":"all","face":[40.+pick_offset,0,7.5],"depth":"badge_depth","targets":[target.clone()]}),
|
||||
);
|
||||
let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &s.doc.features.last().unwrap().kind else {
|
||||
panic!("missing selection");
|
||||
};
|
||||
assert_eq!(face.path, if pick_offset == 0. { s.doc.first_path(a) } else { vec![second] });
|
||||
assert_eq!(face.author_path, vec![author_second]);
|
||||
let state = s.model.state();
|
||||
let body = state.body("Sleeve").unwrap();
|
||||
assert_eq!(body.feature, source_feature);
|
||||
assert_eq!(s.doc.body_component("Sleeve", body.feature), a);
|
||||
assert_eq!(serde_json::to_value(&s.doc.occurrences).unwrap(), placements);
|
||||
let selected = s.doc.features.last().unwrap().face_names.first().unwrap();
|
||||
assert!(!selected.is_empty() && source_names.contains(selected), "{selected:?} not in {source_names:?}");
|
||||
assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02);
|
||||
let world = s.world_state();
|
||||
for x in [39.5, 119.5] {
|
||||
assert!(world.bodies.iter().any(|b| b.body.tessellate_with(0.001, true).unwrap().contains(solvecraft_geom::Vec3::new(x, 0., 7.5))));
|
||||
}
|
||||
let mut loaded = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Default::default() };
|
||||
loaded.refresh();
|
||||
assert!(loaded.model.results.iter().all(|r| r.error.is_none()), "{:?}", loaded.model.results);
|
||||
run(&mut loaded, "parameters.change", json!({"name":"badge_depth","expression":"2mm"}));
|
||||
assert!((exact_cylinder_volume(&loaded) - before - 44.).abs() < 0.02);
|
||||
assert_eq!(loaded.doc.body_component("Sleeve", loaded.model.state().body("Sleeve").unwrap().feature), a);
|
||||
assert_eq!(serde_json::to_value(&loaded.doc.occurrences).unwrap(), placements);
|
||||
let feature = loaded.doc.features.last().unwrap().clone();
|
||||
run(&mut loaded, "component.activate", json!({"component":"root"}));
|
||||
run(
|
||||
&mut loaded,
|
||||
"timeline.redefine",
|
||||
json!({"feature":feature.id,"command":"solid.emboss","params":{"sketch":"Badge","profiles":"all","face":[40.+pick_offset,0,7.5],"depth":"badge_depth","targets":[target.clone()]}}),
|
||||
);
|
||||
assert_eq!(loaded.doc.feature(feature.id).unwrap().kind, feature.kind);
|
||||
assert_eq!(loaded.doc.feature(feature.id).unwrap().face_names, feature.face_names);
|
||||
run(
|
||||
&mut loaded,
|
||||
"timeline.redefine",
|
||||
json!({"feature":feature.id,"command":"solid.emboss","params":{"sketch":"Badge","profiles":"all","face":[40.+pick_offset,0,7.5],"depth":"3mm","targets":[target]}}),
|
||||
);
|
||||
assert!((exact_cylinder_volume(&loaded) - before - 69.).abs() < 0.02);
|
||||
let mut broken = loaded.doc.as_ref().clone();
|
||||
let id = broken.features.last().unwrap().id;
|
||||
let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &mut broken.feature_mut(id).unwrap().kind else {
|
||||
panic!("missing selection");
|
||||
};
|
||||
face.path = vec![u64::MAX];
|
||||
let mut invalid = Session { doc: broken.into(), ..Default::default() };
|
||||
invalid.refresh();
|
||||
assert!(invalid.model.results.iter().find(|r| r.id == id).unwrap().error.as_ref().unwrap().contains("placement"));
|
||||
assert!((exact_cylinder_volume(&invalid) - before).abs() < 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_active_second_source_occurrence_authors_shared_geometry() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
let before = exact_cylinder_volume(&s);
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component":component,"translate":[80,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":component,"occurrence":second}));
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[80,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[80,10,7.5],"depth":1}));
|
||||
assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02);
|
||||
let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &s.doc.features.last().unwrap().kind else {
|
||||
panic!("missing selection");
|
||||
};
|
||||
assert_eq!(face.path, vec![second]);
|
||||
assert_eq!(face.author_path, vec![second]);
|
||||
let mut loaded = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Default::default() };
|
||||
loaded.refresh();
|
||||
assert!(loaded.model.results.iter().all(|r| r.error.is_none()), "{:?}", loaded.model.results);
|
||||
assert!((exact_cylinder_volume(&loaded) - before - 21.).abs() < 0.02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_pending_leaf_or_ancestor_positions_require_capture_atomically() {
|
||||
for ancestor in [false, true] {
|
||||
let mut s = Session::default();
|
||||
let parent = run(&mut s, "component.create", json!({"name":"Parent"}))["occurrence"].as_u64().unwrap();
|
||||
let leaf = run(&mut s, "component.create", json!({"name":"Source"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":if ancestor {parent}else{leaf},"translate":[5,0,0],"capture":false}));
|
||||
let before = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let volume = exact_cylinder_volume(&s);
|
||||
let error = s.execute("solid.emboss", &json!({"sketch":"Badge","profiles":"all","face":[5,10,7.5],"depth":1})).unwrap_err().to_string();
|
||||
assert!(error.contains("Capture Position"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(exact_cylinder_volume(&s), volume);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_pending_third_component_profile_requires_capture_atomically() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
let profile_occurrence = run(&mut s, "component.create", json!({"name":"ProfileOwner"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "component.activate", json!({"component":"root"}));
|
||||
run(&mut s, "component.create", json!({"name":"Author"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":profile_occurrence,"translate":[5,0,0],"capture":false}));
|
||||
let before = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let volume = exact_cylinder_volume(&s);
|
||||
let error = s
|
||||
.execute("solid.emboss", &json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]}))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("Capture Position"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(exact_cylinder_volume(&s), volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_body_offset_keeps_exact_profile_and_anchor_frames() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
let before = exact_cylinder_volume(&s);
|
||||
let holder = run(&mut s, "component.create", json!({"name":"Holder"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":"Holder:1","translate":[0,50,0]}));
|
||||
run(&mut s, "component.move_bodies", json!({"bodies":["Sleeve"],"component":holder}));
|
||||
assert!(s.doc.body_offsets.contains_key("Sleeve"));
|
||||
let offset = s.doc.body_offsets["Sleeve"];
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]}));
|
||||
assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02);
|
||||
assert_eq!(s.doc.body_offsets["Sleeve"], offset);
|
||||
assert_eq!(s.doc.body_component("Sleeve", s.model.state().body("Sleeve").unwrap().feature), holder);
|
||||
assert!(s.world_state().body("Sleeve").unwrap().body.tessellate_with(0.001, true).unwrap().contains(solvecraft_geom::Vec3::new(0., 10.5, 7.5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_face_align_remove() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -401,7 +401,7 @@ fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "too many face appearances"));
|
||||
}
|
||||
let name = st_names.iter().find(|(b, p, _)| b == body && p.dist(*pt) < 1e-12).and_then(|(_, _, n)| n.clone());
|
||||
a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone(), name });
|
||||
a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone(), name, strict_name: false });
|
||||
}
|
||||
}
|
||||
for c in &comps {
|
||||
@@ -414,11 +414,27 @@ fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if look.is_none() && !faces.is_empty() {
|
||||
let st = s.model.state();
|
||||
let doc = s.doc_mut();
|
||||
let picked: Vec<(String, Option<usize>)> =
|
||||
faces.iter().map(|(b, pt)| (b.clone(), st.body(b).and_then(|mb| solvecraft_doc::appearance::face_index_at(mb, *pt)))).collect();
|
||||
let picked: Vec<(String, Option<usize>, Option<String>)> = faces
|
||||
.iter()
|
||||
.map(|(b, pt)| {
|
||||
(
|
||||
b.clone(),
|
||||
st.body(b).and_then(|mb| solvecraft_doc::appearance::face_index_at(mb, *pt)),
|
||||
st_names.iter().find(|(name, p, _)| name == b && p.dist(*pt) < 1e-12).and_then(|(_, _, name)| name.clone()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
doc.appearances.faces.retain(|f| {
|
||||
let idx = st.body(&f.body).and_then(|mb| solvecraft_doc::appearance::face_index_at(mb, f.point));
|
||||
!picked.iter().any(|(b, i)| *b == f.body && i.is_some() && *i == idx)
|
||||
!picked.iter().any(|(b, i, name)| {
|
||||
*b == f.body
|
||||
&& (f
|
||||
.name
|
||||
.as_ref()
|
||||
.zip(name.as_ref())
|
||||
.is_some_and(|(a, b)| solvecraft_doc::naming::strip_piece(a) == solvecraft_doc::naming::strip_piece(b))
|
||||
|| (!f.strict_name && i.is_some() && *i == idx))
|
||||
})
|
||||
});
|
||||
}
|
||||
Ok(json!({
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
mod browser;
|
||||
mod clipboard;
|
||||
mod component;
|
||||
mod component_clone;
|
||||
mod component_clone_assembly;
|
||||
mod construct;
|
||||
mod delete;
|
||||
mod edit;
|
||||
|
||||
@@ -223,7 +223,12 @@ pub(super) fn edit<T>(
|
||||
let out = f(&mut sk, &mut doc)?;
|
||||
let (vals, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&vals, &mut sk)?;
|
||||
let rep = solve(&mut sk);
|
||||
sk.refresh_offsets()?;
|
||||
let mut rep = solve(&mut sk);
|
||||
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
|
||||
sk.refresh_offsets()?;
|
||||
rep = solve(&mut sk);
|
||||
}
|
||||
if strict && !rep.ok() {
|
||||
return Err(EngineError::Other(format!("that would over-constrain the sketch (conflicts with {})", rep.failing.join(", "))));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec2_list};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[path = "sketch_support.rs"]
|
||||
mod support;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.fillet", "Fillet", fillet)
|
||||
.at("SKETCH", "MODIFY")
|
||||
@@ -39,6 +42,9 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.key("O")
|
||||
.enabled(in_sketch)
|
||||
.params("curves: [ids], distance: number (positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain)"),
|
||||
CommandSpec::new("sketch.offset.break", "Break Offset Association", break_offset)
|
||||
.enabled(in_sketch)
|
||||
.params("curve: derived offset curve id (retains geometry and makes its points editable)"),
|
||||
CommandSpec::new("sketch.trim", "Trim", trim)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("trim")
|
||||
@@ -89,6 +95,9 @@ fn curve_arg(sk: &Sketch, p: &Value, cmd: &str) -> Result<usize> {
|
||||
if sk.is_linked_curve(c) {
|
||||
return Err(bad(cmd, format!("`{id}` is projected geometry: break its link first")));
|
||||
}
|
||||
if sk.freeform_offsets.iter().any(|offset| offset.output == id) {
|
||||
return Err(bad(cmd, "break the offset association before editing its output"));
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
@@ -365,7 +374,12 @@ fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn extend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.extend";
|
||||
let at = req_vec2(cmd, p, "at")?;
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
|
||||
let (values, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&values, sk)?;
|
||||
if !solvecraft_sketch::solve(sk).ok() {
|
||||
return Err(bad(cmd, "solve the existing sketch constraints before extending"));
|
||||
}
|
||||
let ci = curve_arg(sk, p, cmd)?;
|
||||
let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?;
|
||||
let (pa, pb) = match sk.curves.get(ci).map(|c| c.kind.clone()) {
|
||||
@@ -386,41 +400,62 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let (_, t1) = sh.range();
|
||||
let mut best: Option<(f64, Vec2, usize)> = None;
|
||||
let mut ambiguous = false;
|
||||
let mut budget = 1_000_000usize;
|
||||
let mut segment_count = 0usize;
|
||||
for (oi, _) in sk.curves.iter().enumerate() {
|
||||
if oi == ci {
|
||||
continue;
|
||||
}
|
||||
// Lines and circles exactly; free-form curves (splines, ellipses, conics) by the
|
||||
// pieces of their polyline.
|
||||
let pieces: Vec<Shape> = match sk.shape(oi) {
|
||||
Some(other) => vec![other],
|
||||
None => sk.polyline(oi).windows(2).map(|w| Shape::Line { a: w[0], b: w[1] }).collect(),
|
||||
};
|
||||
for q in pieces.iter().flat_map(|other| intersections(&unbounded, other).into_iter().filter(|q| on_bounded(other, *q).is_some())) {
|
||||
let t = sh.param(q);
|
||||
let gap = match sh {
|
||||
Shape::Line { a, b } => {
|
||||
let l = a.dist(b);
|
||||
if at_b { (t - 1.0) * l } else { -t * l }
|
||||
}
|
||||
Shape::Round { r, .. } => {
|
||||
if at_b {
|
||||
(t - t1) * r
|
||||
} else {
|
||||
(TAU - t) * r
|
||||
let pieces = sk.exact_segs(oi).unwrap_or_else(|| sk.segs(oi));
|
||||
segment_count += pieces.len();
|
||||
if segment_count > 16_384 {
|
||||
return Err(bad(cmd, "too many exact curve pieces to extend safely"));
|
||||
}
|
||||
for piece in &pieces {
|
||||
for q in support::hits(&unbounded, piece, &mut budget)? {
|
||||
let t = sh.param(q);
|
||||
let gap = match sh {
|
||||
Shape::Line { a, b } => {
|
||||
let l = a.dist(b);
|
||||
if at_b { (t - 1.0) * l } else { -t * l }
|
||||
}
|
||||
Shape::Round { r, .. } => {
|
||||
// A hit already inside the arc cannot lengthen either end. In
|
||||
// particular, extending the start must not turn a quarter into an eighth.
|
||||
if t <= t1 + TOL / r.max(1e-12) || t >= TAU - TOL / r.max(1e-12) {
|
||||
continue;
|
||||
}
|
||||
if at_b { (t - t1) * r } else { (TAU - t) * r }
|
||||
}
|
||||
};
|
||||
if gap > TOL * 10.0 {
|
||||
if best.is_none_or(|b| gap < b.0 - TOL) {
|
||||
best = Some((gap, q, oi));
|
||||
ambiguous = false;
|
||||
} else if best.is_some_and(|b| (gap - b.0).abs() <= TOL && (b.2 != oi || b.1.dist(q) > TOL)) {
|
||||
ambiguous = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
if gap > TOL * 10.0 && best.is_none_or(|b| gap < b.0) {
|
||||
best = Some((gap, q, oi));
|
||||
}
|
||||
}
|
||||
}
|
||||
if ambiguous {
|
||||
return Err(bad(cmd, "multiple curves stop the extension at the same distance"));
|
||||
}
|
||||
let (_, q, oi) = best.ok_or_else(|| bad(cmd, "nothing to extend to"))?;
|
||||
if let Some(pt) = sk.points.get_mut(end) {
|
||||
pt.pos = q;
|
||||
}
|
||||
pin(sk, end, oi)?;
|
||||
let before = sk.points.iter().map(|p| p.pos).collect::<Vec<_>>();
|
||||
let hold = solvecraft_sketch::Hold { points: (0..sk.points.len()).filter(|i| *i != end).collect(), radii: (0..sk.curves.len()).collect() };
|
||||
if !solvecraft_sketch::solve_holding(sk, &hold).ok()
|
||||
|| sk.point(end).is_none_or(|point| point.dist(q) > TOL)
|
||||
|| sk.points.iter().enumerate().any(|(i, point)| i != end && point.pos.dist(before[i]) > TOL)
|
||||
{
|
||||
return Err(bad(cmd, "the extension conflicts with constraints or cannot preserve its original support"));
|
||||
}
|
||||
Ok(sk.points.get(end).map(|x| x.id.clone()).unwrap_or_default())
|
||||
})?;
|
||||
Ok(json!({"moved": out, "sketch": info}))
|
||||
@@ -809,7 +844,14 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d0 = num(p, "distance").filter(|d| d.abs() > 1e-9).ok_or_else(|| bad(cmd, "`distance` must be a non-zero number"))?;
|
||||
let side = p.get("side").and_then(vec2);
|
||||
let use_chain = bool_(p, "chain").unwrap_or(true);
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
|
||||
let (values, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&values, sk)?;
|
||||
sk.refresh_offsets()?;
|
||||
if !solvecraft_sketch::solve(sk).ok() {
|
||||
return Err(bad(cmd, "solve existing constraints before offsetting"));
|
||||
}
|
||||
sk.refresh_offsets()?;
|
||||
let sel: Vec<usize> = ids.iter().map(|i| sk.curve_index(i).ok_or_else(|| bad(cmd, format!("unknown curve `{i}`")))).collect::<Result<_>>()?;
|
||||
let first = *sel.first().ok_or_else(|| bad(cmd, "no curves"))?;
|
||||
let mut made = Vec::new();
|
||||
@@ -855,6 +897,17 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => d0,
|
||||
};
|
||||
if order.iter().any(|(i, _)| sk.shape(*i).is_none()) {
|
||||
// A smooth free-form entity is offset from exact pieces. Junctions that
|
||||
// would require trimming/intersecting independent normal offsets are
|
||||
// refused rather than joined by a guessed average point.
|
||||
if order.len() != 1 {
|
||||
return Err(bad(cmd, "offset smooth free-form curves individually; mixed chain junctions are ambiguous"));
|
||||
}
|
||||
let (source, forward) = order[0];
|
||||
let output = sk.add_freeform_offset(source, forward, d)?;
|
||||
return Ok((ids_of(sk, &[output]), cons));
|
||||
}
|
||||
let pieces: Vec<Piece> =
|
||||
order.iter().map(|(c, f)| offset_piece(sk, *c, *f, d)).collect::<Option<_>>().ok_or_else(|| bad(cmd, "the offset collapses an arc"))?;
|
||||
let n = pieces.len();
|
||||
@@ -947,6 +1000,29 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info}))
|
||||
}
|
||||
|
||||
fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.offset.break";
|
||||
let (out, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
let output = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must name an offset output"))?;
|
||||
let record = sk
|
||||
.freeform_offsets
|
||||
.iter()
|
||||
.position(|record| record.output == output)
|
||||
.ok_or_else(|| bad(cmd, "that curve has no free-form offset association"))?;
|
||||
sk.refresh_offsets()?;
|
||||
let ci = sk.curve_index(output).ok_or_else(|| bad(cmd, "missing offset output"))?;
|
||||
let indices = sk.curves[ci].kind.point_ids();
|
||||
sk.freeform_offsets.remove(record);
|
||||
for i in indices {
|
||||
if let Some(point) = sk.points.get_mut(i) {
|
||||
point.fixed = false;
|
||||
}
|
||||
}
|
||||
Ok(output.to_string())
|
||||
})?;
|
||||
Ok(json!({"curve":out,"sketch":info}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Copies: mirror, patterns, move, scale
|
||||
|
||||
|
||||
@@ -183,6 +183,58 @@ fn trim_circle_makes_an_arc_and_a_profile() {
|
||||
assert!(close(a[0], std::f64::consts::PI * 50.0), "{a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_extend_ellipse_preserves_support_target_and_history() {
|
||||
for (y, expected_x) in [(3.0, 12.0), (5.0, 20.0)] {
|
||||
let mut s = new_sketch();
|
||||
let ellipse = ids(&run(&mut s, "sketch.ellipse", json!({"center":[20,0],"major":[30,0],"minor_radius":5}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entity":ellipse}));
|
||||
let line = ids(&run(&mut s, "sketch.line", json!({"points":[[0,y],[5,y]],"connect":false,"infer":false}))["curves"])[0].clone();
|
||||
let before = s.doc.to_json();
|
||||
let target = sketch(&s).exact_segs(0).unwrap();
|
||||
run(&mut s, "sketch.extend", json!({"curve":line,"at":[5,y]}));
|
||||
let sk = sketch(&s);
|
||||
let end = sk.point(sk.resolve_point(&format!("{line}.end")).unwrap()).unwrap();
|
||||
assert!(end.dist(Vec2::new(expected_x, y)) < 1e-7, "exact ellipse contact {end:?}");
|
||||
assert_eq!(sk.exact_segs(0).unwrap(), target);
|
||||
let persisted = s.doc.to_json();
|
||||
let reopened = solvecraft_doc::format::read(&persisted).unwrap();
|
||||
assert_eq!(reopened.to_json(), persisted);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), persisted);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_extend_arc_rejects_interior_hit_atomically() {
|
||||
let mut s = new_sketch();
|
||||
let arc = ids(&run(&mut s, "sketch.arc.center_point", json!({"center":[0,0],"start":[10,0],"end":[0,10],"connect":false}))["curves"])[0].clone();
|
||||
let x = 50.0_f64.sqrt();
|
||||
let cutter = ids(&run(&mut s, "sketch.line", json!({"points":[[x,5],[x,9]],"connect":false,"infer":false}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entity":cutter}));
|
||||
let before = s.doc.to_json();
|
||||
let history = s.undo.len();
|
||||
assert!(s.execute("sketch.extend", &json!({"curve":arc,"at":[10,0]})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_extend_refuses_ambiguous_stop_without_history_change() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,0],[5,0]],"connect":false,"infer":false}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[10,-5],[10,5]],"connect":false,"infer":false}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[8,-2],[12,2]],"connect":false,"infer":false}));
|
||||
let before = s.doc.to_json();
|
||||
let history = s.undo.len();
|
||||
let error = s.execute("sketch.extend", &json!({"curve":"l1","at":[5,0]})).unwrap_err();
|
||||
assert!(error.to_string().contains("multiple curves"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillet_and_chamfers() {
|
||||
let mut s = new_sketch();
|
||||
@@ -563,3 +615,101 @@ fn offset_free_form_curves() {
|
||||
let r = run(&mut s, "sketch.offset", json!({"curves": [e], "distance": 1, "side": [100, 20]}));
|
||||
assert_eq!(ids(&r["curves"]).len(), 1, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_offset_follows_dimension_drag_reopen_break_and_undo() {
|
||||
let mut s = new_sketch();
|
||||
let ellipse = ids(&run(&mut s, "sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5,"connect":false}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.dimension", json!({"entities":[ellipse],"type":"major","value":10}));
|
||||
let before = s.doc.to_json();
|
||||
let out = run(&mut s, "sketch.offset", json!({"curves":[ellipse],"distance":-1,"chain":false}));
|
||||
let output = out["curves"][0].as_str().unwrap().to_string();
|
||||
assert_eq!(sketch(&s).freeform_offsets.len(), 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
let result = run(&mut s, "parameters.change", json!({"name":"d1","expression":12}));
|
||||
assert_eq!(result["errors"], json!([]));
|
||||
let verify = |s: &Session| {
|
||||
let sk = sketch(s);
|
||||
let source = sk.curve_index(&ellipse).unwrap();
|
||||
let (c, m) = match sk.curves[source].kind {
|
||||
CurveKind::Ellipse { c, m, .. } => (sk.point(c).unwrap(), sk.point(m).unwrap()),
|
||||
_ => panic!("ellipse"),
|
||||
};
|
||||
let derived = sk.exact_segs(sk.curve_index(&output).unwrap()).unwrap();
|
||||
let want = m + (m - c).normalized().unwrap();
|
||||
assert!(derived[0].point_at(0.0).dist(want) < 1e-7, "updated offset {want:?}");
|
||||
};
|
||||
verify(&s);
|
||||
run(&mut s, "sketch.drag", json!({"entity":ellipse,"from":[0,0],"to":[4,3]}));
|
||||
verify(&s);
|
||||
let mut reopened = Session {
|
||||
doc: std::sync::Arc::new(solvecraft_doc::format::read(&s.doc.to_json()).unwrap()),
|
||||
active_sketch: s.active_sketch,
|
||||
..Session::default()
|
||||
};
|
||||
reopened.refresh();
|
||||
verify(&reopened);
|
||||
let attached = reopened.doc.to_json();
|
||||
run(&mut reopened, "sketch.offset.break", json!({"curve":output}));
|
||||
let sk = sketch(&reopened);
|
||||
assert!(sk.freeform_offsets.is_empty());
|
||||
assert!(sk.curves[sk.curve_index(&output).unwrap()].kind.point_ids().iter().all(|i| !sk.points[*i].fixed));
|
||||
run(&mut reopened, "edit.undo", json!({}));
|
||||
assert_eq!(reopened.doc.to_json(), attached);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_offset_delete_output_undo_reopen_and_refuse_dependent_source() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5}));
|
||||
let out = run(&mut s, "sketch.offset", json!({"curves":["e1"],"distance":-1,"chain":false}));
|
||||
let id = out["curves"][0].as_str().unwrap().to_string();
|
||||
let attached = s.doc.to_json();
|
||||
let history = s.undo.len();
|
||||
assert!(s.execute("sketch.delete", &json!({"entities":["e1"]})).is_err());
|
||||
assert_eq!(s.doc.to_json(), attached);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
run(&mut s, "sketch.delete", json!({"entities":[id]}));
|
||||
let sk = sketch(&s);
|
||||
assert!(sk.freeform_offsets.is_empty());
|
||||
assert!(sk.curve_index(&id).is_none());
|
||||
assert!(sk.curve_index("e1").is_some());
|
||||
let deleted = s.doc.to_json();
|
||||
let reopened = solvecraft_doc::format::read(&deleted).unwrap();
|
||||
assert_eq!(reopened.to_json(), deleted);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), attached);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), deleted);
|
||||
// The central removal path also handles a deleted fit node, which removes its curve.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let sk = sketch(&s);
|
||||
let node = sk.points[sk.curves[sk.curve_index(&id).unwrap()].kind.point_ids()[3]].id.clone();
|
||||
run(&mut s, "sketch.delete", json!({"entities":[node]}));
|
||||
assert!(sketch(&s).freeform_offsets.is_empty());
|
||||
assert!(sketch(&s).curve_index(&id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analytic_offset_partial_delete_releases_survivors_preserves_source_and_undo() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[20,30]}));
|
||||
let out = run(&mut s, "sketch.offset", json!({"curves":["l1"],"distance":-1}));
|
||||
let outputs = ids(&out["curves"]);
|
||||
assert_eq!(outputs.len(), 4);
|
||||
let before = s.doc.to_json();
|
||||
run(&mut s, "sketch.delete", json!({"entities":[outputs[0]]}));
|
||||
let sk = sketch(&s);
|
||||
assert!(sk.offsets.is_empty());
|
||||
for id in outputs.iter().skip(1) {
|
||||
let curve = &sk.curves[sk.curve_index(id).unwrap()];
|
||||
assert!(curve.kind.point_ids().iter().all(|i| !sk.points[*i].fixed));
|
||||
}
|
||||
assert!(sk.curve_index("l1").is_some());
|
||||
let saved = s.doc.to_json();
|
||||
assert_eq!(solvecraft_doc::format::read(&saved).unwrap().to_json(), saved);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Bounded roots on exact curve pieces against an infinite line or complete circle.
|
||||
//! Rational pieces are intersected in homogeneous coordinates, without sampled chords.
|
||||
use solvecraft_geom::{Seg2, Vec2};
|
||||
use solvecraft_sketch::{Shape, intersections};
|
||||
|
||||
use crate::{Result, params::bad};
|
||||
|
||||
fn choose(n: usize, k: usize) -> f64 {
|
||||
(0..k.min(n - k)).fold(1.0, |value, i| value * (n - i) as f64 / (i + 1) as f64)
|
||||
}
|
||||
fn product(a: &[f64], b: &[f64]) -> Vec<f64> {
|
||||
let (n, m) = (a.len() - 1, b.len() - 1);
|
||||
(0..=n + m)
|
||||
.map(|k| (k.saturating_sub(m)..=k.min(n)).map(|i| a[i] * b[k - i] * choose(n, i) * choose(m, k - i) / choose(n + m, k)).sum())
|
||||
.collect()
|
||||
}
|
||||
fn evaluate(c: &[f64], t: f64) -> f64 {
|
||||
let mut values = [0.0; 15];
|
||||
for (slot, value) in values.iter_mut().zip(c) {
|
||||
*slot = *value;
|
||||
}
|
||||
for n in (1..c.len()).rev() {
|
||||
for i in 0..n {
|
||||
values[i] = values[i] * (1.0 - t) + values[i + 1] * t;
|
||||
}
|
||||
}
|
||||
values[0]
|
||||
}
|
||||
|
||||
/// Derivative roots partition a polynomial into monotone intervals. Testing its critical
|
||||
/// points retains repeated/tangent roots that a sign-change-only search would miss.
|
||||
fn roots(c: &[f64], budget: &mut usize) -> Result<Vec<f64>> {
|
||||
if c.is_empty() || c.len() > 15 || c.iter().any(|v| !v.is_finite()) {
|
||||
return Err(bad("sketch.extend", "invalid intersection polynomial"));
|
||||
}
|
||||
let scale = c.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
|
||||
if scale < 1e-28 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let eps = scale * 2e-13;
|
||||
if c.len() == 1 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let d: Vec<f64> = c.windows(2).map(|w| (w[1] - w[0]) * (c.len() - 1) as f64).collect();
|
||||
let mut knots = roots(&d, budget)?;
|
||||
knots.extend([0.0, 1.0]);
|
||||
knots.sort_by(f64::total_cmp);
|
||||
knots.dedup_by(|a, b| (*a - *b).abs() < 1e-13);
|
||||
let mut found: Vec<f64> = knots.iter().copied().filter(|t| evaluate(c, *t).abs() <= eps).collect();
|
||||
for pair in knots.windows(2) {
|
||||
let (mut lo, mut hi) = (pair[0], pair[1]);
|
||||
let (mut fl, fh) = (evaluate(c, lo), evaluate(c, hi));
|
||||
if fl.abs() <= eps || fh.abs() <= eps || fl.signum() == fh.signum() {
|
||||
continue;
|
||||
}
|
||||
for _ in 0..64 {
|
||||
if *budget == 0 {
|
||||
return Err(bad("sketch.extend", "intersection work limit exceeded"));
|
||||
}
|
||||
*budget -= 1;
|
||||
let mid = (lo + hi) * 0.5;
|
||||
let f = evaluate(c, mid);
|
||||
if hi - lo < 2e-14 {
|
||||
break;
|
||||
}
|
||||
if f.signum() == fl.signum() {
|
||||
lo = mid;
|
||||
fl = f;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
found.push((lo + hi) * 0.5);
|
||||
}
|
||||
found.sort_by(f64::total_cmp);
|
||||
found.dedup_by(|a, b| (*a - *b).abs() < 1e-10);
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
pub(super) fn hits(support: &Shape, seg: &Seg2, budget: &mut usize) -> Result<Vec<Vec2>> {
|
||||
if let Seg2::Line { a, b } = *seg {
|
||||
let other = Shape::Line { a, b };
|
||||
if let Shape::Line { a: pa, b: pb } = *support
|
||||
&& (pb - pa).normalized().is_some_and(|d| d.cross(a - pa).abs() < 1e-9 && d.cross(b - pa).abs() < 1e-9)
|
||||
{
|
||||
return Err(bad("sketch.extend", "the continuation overlaps another curve"));
|
||||
}
|
||||
return Ok(intersections(support, &other)
|
||||
.into_iter()
|
||||
.filter(|p| {
|
||||
let d = b - a;
|
||||
let t = (*p - a).dot(d) / d.len2().max(1e-30);
|
||||
(-1e-10..=1.0 + 1e-10).contains(&t)
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
if let Seg2::Arc { center, radius, start, sweep } = *seg {
|
||||
let other = Shape::Round { c: center, r: radius, start, sweep };
|
||||
if let Shape::Round { c, r, .. } = *support
|
||||
&& c.dist(center) < 1e-9
|
||||
&& (r - radius).abs() < 1e-9
|
||||
{
|
||||
return Err(bad("sketch.extend", "the continuation overlaps another curve"));
|
||||
}
|
||||
return Ok(intersections(support, &other)
|
||||
.into_iter()
|
||||
.filter(|p| {
|
||||
let t = (((*p - center).angle() - start) * sweep.signum()).rem_euclid(std::f64::consts::TAU);
|
||||
t <= sweep.abs() + 1e-10
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
let (points, weights): (Vec<Vec2>, Vec<f64>) = match *seg {
|
||||
Seg2::Cubic { p0, p1, p2, p3 } => (vec![p0, p1, p2, p3], vec![1.0; 4]),
|
||||
Seg2::Conic { a, apex, b, w } if w.is_finite() && w > 0.0 => (vec![a, apex, b], vec![1.0, w, 1.0]),
|
||||
Seg2::Bezier { n, p } if (1..=7).contains(&n) => (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]),
|
||||
_ => return Err(bad("sketch.extend", "invalid exact curve piece")),
|
||||
};
|
||||
if points.iter().any(|p| !p.is_finite()) {
|
||||
return Err(bad("sketch.extend", "nonfinite intersection geometry"));
|
||||
}
|
||||
let weight_scale = weights.iter().copied().fold(1.0_f64, f64::max);
|
||||
let weights: Vec<f64> = weights.iter().map(|w| w / weight_scale).collect();
|
||||
let origin = match *support {
|
||||
Shape::Line { a, .. } => a,
|
||||
Shape::Round { c, .. } => c,
|
||||
};
|
||||
let scale = points.iter().map(|p| p.dist(origin)).fold(1.0_f64, f64::max);
|
||||
let x: Vec<f64> = points.iter().zip(&weights).map(|(p, w)| (p.x - origin.x) * w / scale).collect();
|
||||
let y: Vec<f64> = points.iter().zip(&weights).map(|(p, w)| (p.y - origin.y) * w / scale).collect();
|
||||
let c = match *support {
|
||||
Shape::Line { a, b } => {
|
||||
let d = (b - a).normalized().ok_or_else(|| bad("sketch.extend", "zero-length support"))?;
|
||||
x.iter().zip(&y).map(|(x, y)| d.x * y - d.y * x).collect::<Vec<_>>()
|
||||
}
|
||||
Shape::Round { r, .. } => {
|
||||
let (xx, yy, dd) = (product(&x, &x), product(&y, &y), product(&weights, &weights));
|
||||
xx.iter().zip(&yy).zip(&dd).map(|((x, y), d)| x + y - (r / scale).powi(2) * d).collect()
|
||||
}
|
||||
};
|
||||
if c.iter().all(|v| v.abs() < 1e-28) {
|
||||
return Err(bad("sketch.extend", "the continuation overlaps another curve"));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for t in roots(&c, budget)? {
|
||||
let p = seg.point_at(t);
|
||||
if !p.is_finite() || support.project(p).dist(p) > 1e-7 {
|
||||
return Err(bad("sketch.extend", "the intersection could not be resolved accurately"));
|
||||
}
|
||||
if !out.iter().any(|q: &Vec2| q.dist(p) < 1e-7) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_extend_negative_arc_target() {
|
||||
let support = Shape::Line { a: Vec2::new(-2.0, 0.5), b: Vec2::new(-1.5, 0.5) };
|
||||
let target = Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start: std::f64::consts::PI, sweep: -std::f64::consts::FRAC_PI_2 };
|
||||
let found = hits(&support, &target, &mut 1000).unwrap();
|
||||
assert_eq!(found.len(), 1);
|
||||
assert!(found[0].dist(Vec2::new(-0.75_f64.sqrt(), 0.5)) < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_extend_degree_seven_retains_tangent_and_crossing() {
|
||||
// Degree elevation of quadratic y=(2t-1)^2 retains its double root at t=1/2.
|
||||
let mut y = vec![1.0, -1.0, 1.0];
|
||||
while y.len() < 8 {
|
||||
let n = y.len();
|
||||
let mut elevated = vec![y[0]];
|
||||
for i in 1..n {
|
||||
let a = i as f64 / n as f64;
|
||||
elevated.push(a * y[i - 1] + (1.0 - a) * y[i]);
|
||||
}
|
||||
elevated.push(y[n - 1]);
|
||||
y = elevated;
|
||||
}
|
||||
let mut points = [Vec2::ZERO; 8];
|
||||
for (i, p) in points.iter_mut().enumerate() {
|
||||
*p = Vec2::new(10.0 + i as f64 * 20.0 / 7.0, y[i]);
|
||||
}
|
||||
let target = Seg2::Bezier { n: 7, p: points };
|
||||
let support = Shape::Line { a: Vec2::ZERO, b: Vec2::X };
|
||||
let found = hits(&support, &target, &mut 10000).unwrap();
|
||||
assert_eq!(found.len(), 1);
|
||||
assert!(found[0].dist(Vec2::new(20.0, 0.0)) < 1e-7);
|
||||
let crossing = Shape::Line { a: Vec2::new(0.0, 0.25), b: Vec2::new(1.0, 0.25) };
|
||||
let found = hits(&crossing, &target, &mut 10000).unwrap();
|
||||
assert_eq!(found.len(), 2);
|
||||
assert!(found[0].dist(Vec2::new(15.0, 0.25)) < 1e-7);
|
||||
assert!(found[1].dist(Vec2::new(25.0, 0.25)) < 1e-7);
|
||||
assert!(hits(&crossing, &target, &mut 0).is_err());
|
||||
let circle = Shape::Round { c: Vec2::new(20.0, -1.0), r: 1.0, start: 0.0, sweep: std::f64::consts::TAU };
|
||||
let found = hits(&circle, &target, &mut 10000).unwrap();
|
||||
assert_eq!(found.len(), 1, "degree fourteen tangent {found:?}");
|
||||
assert!(found[0].dist(Vec2::new(20.0, 0.0)) < 1e-7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_extend_rational_quarter_hits_analytic_eight_six_point() {
|
||||
let target =
|
||||
Seg2::Conic { a: Vec2::new(10.0, 0.0), apex: Vec2::new(10.0, 10.0), b: Vec2::new(0.0, 10.0), w: std::f64::consts::FRAC_1_SQRT_2 };
|
||||
let support = Shape::Line { a: Vec2::new(0.0, 6.0), b: Vec2::new(1.0, 6.0) };
|
||||
let found = hits(&support, &target, &mut 10000).unwrap();
|
||||
assert_eq!(found.len(), 1);
|
||||
assert!(found[0].dist(Vec2::new(8.0, 6.0)) < 1e-7);
|
||||
}
|
||||
}
|
||||
@@ -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)],
|
||||
@@ -333,7 +337,34 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
|
||||
// component is active. Body-relative picks still use the picked body's frame.
|
||||
let mut context = s.scratch();
|
||||
context.active_component = feature.component;
|
||||
let inner = to_local(&context, cmd, params.get("params")?)?;
|
||||
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);
|
||||
@@ -359,6 +390,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 => {
|
||||
|
||||
@@ -513,10 +513,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()));
|
||||
}
|
||||
@@ -534,7 +550,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,473 @@
|
||||
//! Exact radial embossing of orthogonal profiles on complete cylindrical walls.
|
||||
use crate::body::{Body, Solid, from_p3, p3};
|
||||
use crate::offset::{Surf, surf_of};
|
||||
use crate::{KernelError, Result, guard};
|
||||
use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
use truck_modeling::{self as mt, Transformed, builder};
|
||||
|
||||
fn fail(s: &str) -> KernelError {
|
||||
KernelError::Invalid(format!("cylinder emboss: {s}"))
|
||||
}
|
||||
fn angle(p: Vec3, o: Vec3, axis: Vec3, radial: Vec3, tangent: Vec3) -> f64 {
|
||||
let w = p - o;
|
||||
let w = w - axis * w.dot(axis);
|
||||
w.dot(tangent).atan2(w.dot(radial))
|
||||
}
|
||||
fn unwrap(a: f64, around: f64) -> f64 {
|
||||
around + (a - around + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI
|
||||
}
|
||||
|
||||
/// Certify a tessellated pick and project it onto the cylinder used by emboss.
|
||||
pub fn cylinder_emboss_anchor(body: &Body, face: usize, at: Vec3) -> Result<Vec3> {
|
||||
if !at.is_finite() || at.len() > 1e9 {
|
||||
return Err(fail("invalid selected face point"));
|
||||
}
|
||||
guard("cylinder emboss anchor", || {
|
||||
let f = body.solid.face_iter().nth(face).ok_or_else(|| fail("selected face"))?;
|
||||
let tol = (body.size() * 1e-8).max(1e-8);
|
||||
let Some(Surf::Cylinder { o, a, r, convex: true }) = surf_of(f, tol) else {
|
||||
return Err(fail("selected face is not an exterior cylinder"));
|
||||
};
|
||||
let w = at - o;
|
||||
let radial = w - a * w.dot(a);
|
||||
if (radial.len() - r).abs() > (body.size() * 1e-3).max(1e-3).min(r * 0.05) {
|
||||
return Err(fail("selected point is too far from cylinder"));
|
||||
}
|
||||
Ok(o + a * w.dot(a) + radial.normalized().ok_or_else(|| fail("radial point"))? * r)
|
||||
})
|
||||
}
|
||||
|
||||
/// Wrap sketch arc length around an exterior cylinder, retaining exact radial depth.
|
||||
/// Profiles must contain orthogonal lines and lie inside one uninterrupted wall face.
|
||||
pub fn emboss_cylinder(body: &Body, plane: &Plane, regions: &[Region2], depth: f64) -> Result<Body> {
|
||||
emboss_impl(body, None, plane, regions, depth)
|
||||
}
|
||||
|
||||
/// As [`emboss_cylinder`], requiring the saved selected point to identify that wall.
|
||||
pub fn emboss_cylinder_at(body: &Body, at: Vec3, plane: &Plane, regions: &[Region2], depth: f64) -> Result<Body> {
|
||||
if !at.is_finite() || at.len() > 1e9 {
|
||||
return Err(fail("invalid selected face point"));
|
||||
}
|
||||
emboss_impl(body, Some(at), plane, regions, depth)
|
||||
}
|
||||
|
||||
fn emboss_impl(body: &Body, anchor: Option<Vec3>, plane: &Plane, regions: &[Region2], depth: f64) -> Result<Body> {
|
||||
body.require_brep("cylinder emboss")?;
|
||||
if !depth.is_finite() || depth.abs() < 1e-6 || depth.abs() > 1e6 || regions.is_empty() || regions.len() > 64 {
|
||||
return Err(fail("needs finite nonzero depth and 1..64 profiles"));
|
||||
}
|
||||
if !plane.origin.is_finite()
|
||||
|| !plane.x.is_finite()
|
||||
|| !plane.y.is_finite()
|
||||
|| (plane.x.len() - 1.0).abs() > 1e-8
|
||||
|| (plane.y.len() - 1.0).abs() > 1e-8
|
||||
|| plane.x.dot(plane.y).abs() > 1e-8
|
||||
{
|
||||
return Err(fail("invalid sketch frame"));
|
||||
}
|
||||
guard("cylinder emboss", || {
|
||||
if body.solid.boundaries().len() != 1 || body.solid.face_iter().take(17).count() > 16 || body.solid.edge_iter().take(257).count() > 256 {
|
||||
return Err(fail("requires a complete cylinder or coaxial sleeve"));
|
||||
}
|
||||
let faces: Vec<_> = body.solid.face_iter().cloned().collect();
|
||||
let tol = (body.size() * 1e-8).max(1e-8);
|
||||
let surfaces: Vec<_> =
|
||||
faces.iter().map(|f| surf_of(f, tol).ok_or_else(|| fail("requires only cylindrical walls and planar endcaps"))).collect::<Result<_>>()?;
|
||||
let mut chosen = None;
|
||||
for (fi, s) in surfaces.iter().enumerate() {
|
||||
let Surf::Cylinder { o, a, r, convex: true } = *s else { continue };
|
||||
let w = plane.origin - o;
|
||||
let radial = w - a * w.dot(a);
|
||||
if (radial.len() - r).abs() > tol * 10.0 || plane.normal().dot(radial / r) < 1.0 - 1e-8 {
|
||||
continue;
|
||||
}
|
||||
let radial = radial.normalized().ok_or_else(|| fail("radial frame"))?;
|
||||
let tangent = a.cross(radial);
|
||||
let f = faces.get(fi).ok_or_else(|| fail("face"))?;
|
||||
if f.boundaries().len() != 1 || f.edge_iter().count() != 4 {
|
||||
continue;
|
||||
}
|
||||
let mut angular = None;
|
||||
for e in f.edge_iter() {
|
||||
if let Some(Some(mid)) = crate::offset::arc_mid(&e, tol * 10.0) {
|
||||
let am = angle(mid, o, a, radial, tangent);
|
||||
let aa = unwrap(angle(from_p3(e.front().point()), o, a, radial, tangent), am);
|
||||
let ab = unwrap(angle(from_p3(e.back().point()), o, a, radial, tangent), am);
|
||||
angular = Some((aa.min(ab), aa.max(ab)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some((alo, ahi)) = angular else { continue };
|
||||
if alo >= -tol / r || ahi <= tol / r {
|
||||
continue;
|
||||
}
|
||||
if chosen.is_some() {
|
||||
return Err(fail("ambiguous cylindrical face"));
|
||||
}
|
||||
chosen = Some((fi, o, a, r, radial, tangent, alo, ahi));
|
||||
}
|
||||
let (fi, o, axis, r, radial, tangent, alo, ahi) =
|
||||
chosen.ok_or_else(|| fail("sketch must be tangent to one complete exterior cylindrical wall, clear of its seam"))?;
|
||||
let mut caps = Vec::new();
|
||||
let mut inner: f64 = 0.0;
|
||||
for s in &surfaces {
|
||||
match *s {
|
||||
Surf::Plane { n, d } if n.dot(axis).abs() > 1.0 - 1e-8 => caps.push((d - n.dot(o)) / n.dot(axis)),
|
||||
Surf::Cylinder { o: co, a: ca, r: cr, convex }
|
||||
if ca.dot(axis).abs() > 1.0 - 1e-8 && (co - o - axis * (co - o).dot(axis)).len() < tol * 10.0 =>
|
||||
{
|
||||
if convex && (cr - r).abs() > tol * 10.0 {
|
||||
return Err(fail("interrupted or stepped cylinder"));
|
||||
}
|
||||
if !convex {
|
||||
inner = inner.max(cr);
|
||||
}
|
||||
}
|
||||
_ => return Err(fail("requires a complete cylinder or coaxial sleeve")),
|
||||
}
|
||||
}
|
||||
if caps.len() != 2 {
|
||||
return Err(fail("requires two planar endcaps"));
|
||||
}
|
||||
let lo = caps.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let hi = caps.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
let rr = r + depth;
|
||||
if rr <= inner + tol * 10.0 {
|
||||
return Err(fail("depth reaches the axis or inner wall"));
|
||||
}
|
||||
if let Some(at) = anchor {
|
||||
let h = (at - o).dot(axis);
|
||||
let v = at - o - axis * h;
|
||||
let theta = angle(at, o, axis, radial, tangent);
|
||||
if (v.len() - r).abs() > tol * 10.0 || theta <= alo + tol / r || theta >= ahi - tol / r || h <= lo + tol || h >= hi - tol {
|
||||
return Err(fail("saved selection no longer identifies the sketch's cylindrical face"));
|
||||
}
|
||||
}
|
||||
let z0 = (plane.origin - o).dot(axis);
|
||||
let map2 = |p: Vec2| Vec2::new(plane.dir_to_world(p).dot(tangent), z0 + plane.dir_to_world(p).dot(axis));
|
||||
let mut count = 0usize;
|
||||
let mut mapped = Vec::new();
|
||||
for reg in regions {
|
||||
let mut loops = Vec::new();
|
||||
for lp in std::iter::once(®.outer).chain(®.holes) {
|
||||
count = count.saturating_add(lp.segs.len());
|
||||
if count > 4096 || lp.segs.len() < 4 || !lp.is_closed(tol) {
|
||||
return Err(fail("profile geometry budget or open loop"));
|
||||
}
|
||||
let mut segs = Vec::new();
|
||||
for s in &lp.segs {
|
||||
let Seg2::Line { a, b } = *s else {
|
||||
return Err(fail("only orthogonal line profiles are supported on cylinders"));
|
||||
};
|
||||
if !a.is_finite() || !b.is_finite() || a.len() > 1e7 || b.len() > 1e7 {
|
||||
return Err(fail("nonfinite or excessive profile coordinates"));
|
||||
}
|
||||
let (a, b) = (map2(a), map2(b));
|
||||
let d = b - a;
|
||||
if d.len() < tol || (d.x.abs() > tol && d.y.abs() > tol) {
|
||||
return Err(fail("profile edges must follow the cylinder axis or circumference"));
|
||||
}
|
||||
for p in [a, b] {
|
||||
if p.x / r <= alo + tol / r || p.x / r >= ahi - tol / r || p.y <= lo + tol || p.y >= hi - tol {
|
||||
return Err(fail("profile crosses a cylinder seam or endcap boundary"));
|
||||
}
|
||||
}
|
||||
segs.push(Seg2::Line { a, b });
|
||||
}
|
||||
loops.push(Loop2 { segs });
|
||||
}
|
||||
let outer = loops.first().ok_or_else(|| fail("profile"))?.ccw();
|
||||
let holes = loops.iter().skip(1).map(|l| l.ccw().reversed()).collect();
|
||||
mapped.push(Region2 { outer, holes });
|
||||
}
|
||||
validate_profiles(&mapped, tol)?;
|
||||
let at = |p: Vec2, radius: f64| o + axis * p.y + (radial * (p.x / r).cos() + tangent * (p.x / r).sin()) * radius;
|
||||
let selected = faces.get(fi).ok_or_else(|| fail("face"))?;
|
||||
let mut base_patches = vec![selected.absolute_boundaries().clone()];
|
||||
let mut hole_patches = Vec::new();
|
||||
let mut footprints = Vec::new();
|
||||
let mut added = Vec::new();
|
||||
let k = rr / r;
|
||||
let coord = |v: Vec3| [v.x, v.y, v.z];
|
||||
let av = coord(axis);
|
||||
let ov = coord(o);
|
||||
let mut m = [[0.0; 4]; 4];
|
||||
for (j, col) in m.iter_mut().take(3).enumerate() {
|
||||
for (i, slot) in col.iter_mut().take(3).enumerate() {
|
||||
*slot = if i == j { k } else { 0.0 } + (1.0 - k) * av.get(i).copied().unwrap_or(0.0) * av.get(j).copied().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
for (i, oi) in ov.iter().enumerate() {
|
||||
let translation =
|
||||
*oi - (0..3).map(|j| m.get(j).and_then(|row| row.get(i)).copied().unwrap_or(0.0) * ov.get(j).copied().unwrap_or(0.0)).sum::<f64>();
|
||||
if let Some(slot) = m.get_mut(3).and_then(|row| row.get_mut(i)) {
|
||||
*slot = translation;
|
||||
}
|
||||
}
|
||||
if let Some(slot) = m.get_mut(3).and_then(|row| row.get_mut(3)) {
|
||||
*slot = 1.0;
|
||||
}
|
||||
let [c0, c1, c2, c3] = m;
|
||||
let [a, b, c, d] = c0;
|
||||
let [e, f, g, h] = c1;
|
||||
let [i, j, l, n] = c2;
|
||||
let [p, q, s, t] = c3;
|
||||
let mat = mt::Matrix4::new(a, b, c, d, e, f, g, h, i, j, l, n, p, q, s, t);
|
||||
let raised = selected.oriented_surface().transformed(mat);
|
||||
for reg in &mapped {
|
||||
let mut cap = Vec::new();
|
||||
for (li, lp) in std::iter::once(®.outer).chain(®.holes).enumerate() {
|
||||
let (base, top, walls) = wrapped_loop(lp, r, rr, &at)?;
|
||||
if li == 0 {
|
||||
footprints.push((lp, base.inverse()));
|
||||
} else {
|
||||
let index = base_patches.len();
|
||||
base_patches.push(vec![base.inverse()]);
|
||||
hole_patches.push((lp, index));
|
||||
}
|
||||
cap.push(top);
|
||||
added.extend(walls);
|
||||
}
|
||||
added.push(mt::Face::try_new(cap, raised.clone()).map_err(|e| fail(&e.to_string()))?);
|
||||
}
|
||||
// An island inside a profile hole cuts that hole's original-radius patch,
|
||||
// rather than adding another hole to the already-cut exterior patch.
|
||||
for (lp, wire) in footprints {
|
||||
let probe = lp.segs.first().ok_or_else(|| fail("empty profile"))?.start();
|
||||
let owner = hole_patches
|
||||
.iter()
|
||||
.filter(|(hole, _)| hole.contains(probe))
|
||||
.min_by(|(a, _), (b, _)| a.signed_area().abs().total_cmp(&b.signed_area().abs()))
|
||||
.map(|(_, index)| *index)
|
||||
.unwrap_or(0);
|
||||
base_patches.get_mut(owner).ok_or_else(|| fail("profile ownership"))?.push(wire);
|
||||
}
|
||||
let mut all: Vec<_> = faces.iter().enumerate().filter(|(i, _)| *i != fi).map(|(_, f)| f.clone()).collect();
|
||||
for patch in base_patches {
|
||||
all.push(mt::Face::try_new(patch, selected.oriented_surface()).map_err(|e| fail(&e.to_string()))?);
|
||||
}
|
||||
all.extend(added);
|
||||
let solid = Solid::try_new(vec![all.into()]).map_err(|e| fail(&e.to_string()))?;
|
||||
let result = Body::new(solid)?;
|
||||
if !result.validity().is_empty() {
|
||||
return Err(fail("rebuilt cylinder is not a closed manifold"));
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
fn wrapped_loop(lp: &Loop2, r: f64, rr: f64, at: &impl Fn(Vec2, f64) -> Vec3) -> Result<(mt::Wire, mt::Wire, Vec<mt::Face>)> {
|
||||
let base: Vec<_> = lp.segs.iter().map(|s| builder::vertex(p3(at(s.start(), r)))).collect();
|
||||
let top: Vec<_> = lp.segs.iter().map(|s| builder::vertex(p3(at(s.start(), rr)))).collect();
|
||||
let radial: Vec<_> = base.iter().zip(&top).map(|(a, b)| builder::line(a, b)).collect();
|
||||
let mut be = Vec::new();
|
||||
let mut te = Vec::new();
|
||||
let mut walls = Vec::new();
|
||||
for (i, s) in lp.segs.iter().enumerate() {
|
||||
let next = (i + 1) % lp.segs.len();
|
||||
let (Some(a), Some(b), Some(c), Some(d), Some(ra), Some(rb)) =
|
||||
(base.get(i), base.get(next), top.get(i), top.get(next), radial.get(i), radial.get(next))
|
||||
else {
|
||||
return Err(fail("loop topology"));
|
||||
};
|
||||
let circular = (s.end().x - s.start().x).abs() > 1e-10;
|
||||
let eb = if circular { builder::circle_arc(a, b, p3(at(s.mid(), r))) } else { builder::line(a, b) };
|
||||
let et = if circular { builder::circle_arc(c, d, p3(at(s.mid(), rr))) } else { builder::line(c, d) };
|
||||
let w: mt::Wire = vec![eb.clone(), rb.clone(), et.inverse(), ra.inverse()].into();
|
||||
walls.push(builder::try_attach_plane(&[w]).map_err(|e| fail(&e.to_string()))?);
|
||||
be.push(eb);
|
||||
te.push(et);
|
||||
}
|
||||
Ok((be.into(), te.into(), walls))
|
||||
}
|
||||
|
||||
fn validate_profiles(regions: &[Region2], tol: f64) -> Result<()> {
|
||||
let loops: Vec<_> = regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).collect();
|
||||
let edges: Vec<_> = loops.iter().enumerate().flat_map(|(l, lp)| lp.segs.iter().enumerate().map(move |(i, s)| (l, i, *s))).collect();
|
||||
for (k, (l, i, s)) in edges.iter().enumerate() {
|
||||
for (ll, j, t) in edges.iter().skip(k + 1) {
|
||||
let len = loops.get(*l).map(|lp| lp.segs.len()).unwrap_or(0);
|
||||
if l == ll && ((*i + 1) % len == *j || (*j + 1) % len == *i) {
|
||||
continue;
|
||||
}
|
||||
let (a, b, c, d) = (s.start(), s.end(), t.start(), t.end());
|
||||
let ab = b - a;
|
||||
let cd = d - c;
|
||||
let cross = ab.cross(cd);
|
||||
let intersects = if cross.abs() > tol * tol {
|
||||
let u = (c - a).cross(cd) / cross;
|
||||
let v = (c - a).cross(ab) / cross;
|
||||
(0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if intersects
|
||||
|| [a, b].iter().any(|p| t.point_at(t.closest_param(*p)).dist(*p) < tol)
|
||||
|| [c, d].iter().any(|p| s.point_at(s.closest_param(*p)).dist(*p) < tol)
|
||||
{
|
||||
return Err(fail("profile boundaries intersect or touch"));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, r) in regions.iter().enumerate() {
|
||||
if r.area() <= tol * tol {
|
||||
return Err(fail("profile has no area"));
|
||||
}
|
||||
for (h, lp) in r.holes.iter().enumerate() {
|
||||
if !r.outer.contains(lp.segs.first().ok_or_else(|| fail("empty hole"))?.start()) {
|
||||
return Err(fail("hole outside profile"));
|
||||
}
|
||||
for other in r.holes.iter().skip(h + 1) {
|
||||
if lp.contains(other.segs.first().ok_or_else(|| fail("empty hole"))?.start())
|
||||
|| other.contains(lp.segs.first().ok_or_else(|| fail("empty hole"))?.start())
|
||||
{
|
||||
return Err(fail("nested profile holes"));
|
||||
}
|
||||
}
|
||||
}
|
||||
for other in regions.iter().skip(i + 1) {
|
||||
if r.contains(other.outer.segs.first().ok_or_else(|| fail("empty profile"))?.start())
|
||||
|| other.contains(r.outer.segs.first().ok_or_else(|| fail("empty profile"))?.start())
|
||||
{
|
||||
return Err(fail("overlapping profiles"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn profile() -> Region2 {
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(-2., 5.), Vec2::new(2., 5.), Vec2::new(2., 10.), Vec2::new(-2., 10.)]), holes: vec![] }
|
||||
}
|
||||
fn plane() -> Plane {
|
||||
Plane::new(Vec3::Y * 10., -Vec3::X, Vec3::Z).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn wrapped_nested_island_uses_its_enclosing_hole_patch() {
|
||||
let body = crate::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap();
|
||||
let mut ring = profile();
|
||||
ring.holes.push(Loop2::polygon(&[Vec2::new(-1., 6.), Vec2::new(1., 6.), Vec2::new(1., 9.), Vec2::new(-1., 9.)]));
|
||||
let island =
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(-0.5, 6.5), Vec2::new(0.5, 6.5), Vec2::new(0.5, 8.5), Vec2::new(-0.5, 8.5)]), holes: vec![] };
|
||||
let before = body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
for depth in [1., -1.] {
|
||||
for regions in [vec![ring.clone(), island.clone()], vec![island.clone(), ring.clone()]] {
|
||||
let result = emboss_cylinder(&body, &plane(), ®ions, depth).unwrap();
|
||||
assert_eq!(result.shell_count(), 1);
|
||||
assert!(result.validity().is_empty());
|
||||
let after = result.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
let expected = 16. * (depth + depth * depth / 20.);
|
||||
assert!((after - before - expected).abs() < 0.02, "{} vs {expected}", after - before);
|
||||
let mesh = result.tessellate_with(0.001, true).unwrap();
|
||||
for (s, raised) in [(0., true), (0.75, false), (1.5, true)] {
|
||||
let radial = -Vec3::X * (s / 10.0_f64).sin() + Vec3::Y * (s / 10.0_f64).cos();
|
||||
let radius = if depth > 0. { 10.5 } else { 9.5 };
|
||||
assert_eq!(mesh.contains(radial * radius + Vec3::Z * 7.5), if depth > 0. { raised } else { !raised });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_wrapped_rectangle_is_connected_and_has_radial_depth() {
|
||||
let body = crate::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap();
|
||||
for depth in [1., -1.] {
|
||||
let result = emboss_cylinder(&body, &plane(), &[profile()], depth).unwrap();
|
||||
assert_eq!(result.shell_count(), 1);
|
||||
assert!(result.validity().is_empty());
|
||||
let before = body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
let after = result.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
let expected = if depth > 0. { 21. } else { -19. };
|
||||
assert!((after - before - expected).abs() < 0.02, "{} vs {expected}", after - before);
|
||||
let mesh = result.tessellate_with(0.001, true).unwrap();
|
||||
for s in [-1.5, 0., 1.5] {
|
||||
let radial = -Vec3::X * (s / 10.0_f64).sin() + Vec3::Y * (s / 10.0_f64).cos();
|
||||
assert_eq!(mesh.contains(radial * (if depth > 0. { 10.5 } else { 9.5 }) + Vec3::Z * 7.5), depth > 0.);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_holes_and_concave_profiles_preserve_material_on_rotated_axes() {
|
||||
let origin = Vec3::new(123.0, -456.0, 789.0);
|
||||
let axis = Vec3::X;
|
||||
let radial = Vec3::Y;
|
||||
let tangent = Vec3::Z;
|
||||
let body = crate::cylinder(origin, axis, 10.0, 20.0).unwrap();
|
||||
let plane = Plane::new(origin + radial * 10.0, tangent, axis).unwrap();
|
||||
let mut holed = profile();
|
||||
holed.holes.push(Loop2::polygon(&[Vec2::new(-1., 6.), Vec2::new(1., 6.), Vec2::new(1., 9.), Vec2::new(-1., 9.)]));
|
||||
let concave = Region2 {
|
||||
outer: Loop2::polygon(&[
|
||||
Vec2::new(-2., 5.),
|
||||
Vec2::new(2., 5.),
|
||||
Vec2::new(2., 7.),
|
||||
Vec2::new(0., 7.),
|
||||
Vec2::new(0., 10.),
|
||||
Vec2::new(-2., 10.),
|
||||
]),
|
||||
holes: vec![],
|
||||
};
|
||||
for profile in [holed, concave] {
|
||||
for depth in [1., -1.] {
|
||||
let result = emboss_cylinder_at(&body, origin + radial * 10.0 + axis * 7.5, &plane, std::slice::from_ref(&profile), depth).unwrap();
|
||||
assert_eq!(result.shell_count(), 1);
|
||||
let before = body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
let after = result.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
let want = profile.area().abs() * (depth + depth * depth / 20.0);
|
||||
assert!((after - before - want).abs() < 0.02, "{} vs {want}", after - before);
|
||||
let point = |s: f64, z: f64, r: f64| origin + axis * z + (radial * (s / 10.0).cos() + tangent * (s / 10.0).sin()) * r;
|
||||
let mesh = result.tessellate_with(0.001, true).unwrap();
|
||||
for (s, z) in [(-1.5, 7.5), (1.5, 7.5), (0.5, 6.5)] {
|
||||
let material = profile.contains(Vec2::new(s, z));
|
||||
assert_eq!(mesh.contains(point(s, z, if depth > 0. { 10.5 } else { 9.5 })), if depth > 0. { material } else { !material });
|
||||
}
|
||||
let imported = crate::step_import(&crate::step_export(&[&result], "wrapped cylinder").unwrap()).unwrap();
|
||||
assert!(imported.warnings.is_empty(), "{:?}", imported.warnings);
|
||||
assert_eq!(imported.bodies.len(), 1);
|
||||
let round = imported.bodies.first().unwrap();
|
||||
assert!(round.body.validity().is_empty());
|
||||
let roundmesh = round.body.tessellate_with(0.001, true).unwrap();
|
||||
assert_eq!(roundmesh.contains(point(-1.5, 7.5, if depth > 0. { 10.5 } else { 9.5 })), depth > 0.);
|
||||
let cut = crate::boolean(
|
||||
&round.body,
|
||||
&crate::box_solid(origin + Vec3::new(5., -20., -20.), origin + Vec3::new(10., 20., 20.)).unwrap(),
|
||||
crate::BoolOp::Cut,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(!cut.tessellate(0.01).unwrap().contains(point(-1.5, 7.5, 9.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sleeve_depth_and_hostile_profiles_refuse_changes_atomically() {
|
||||
let outer = crate::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap();
|
||||
let inner = crate::cylinder(-Vec3::Z, Vec3::Z, 8., 22.).unwrap();
|
||||
let sleeve = crate::boolean(&outer, &inner, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
let good = emboss_cylinder(&sleeve, &plane(), &[profile()], -1.).unwrap();
|
||||
assert_eq!(good.shell_count(), 1);
|
||||
let before = crate::step_export(&[&sleeve], "unchanged").unwrap();
|
||||
let mut cap = profile();
|
||||
cap.outer = Loop2::polygon(&[Vec2::new(-2., 19.), Vec2::new(2., 19.), Vec2::new(2., 21.), Vec2::new(-2., 21.)]);
|
||||
let circle = Region2 { outer: Loop2::circle(Vec2::new(0., 7.5), 1.), holes: vec![] };
|
||||
let mut crossing = profile();
|
||||
crossing.outer = Loop2::polygon(&[Vec2::new(-2., 5.), Vec2::new(2., 10.), Vec2::new(-2., 10.), Vec2::new(2., 5.)]);
|
||||
for (profiles, d) in [
|
||||
(vec![profile()], -2.),
|
||||
(vec![cap], 1.),
|
||||
(vec![circle], 1.),
|
||||
(vec![crossing], 1.),
|
||||
(vec![profile()], f64::NAN),
|
||||
(vec![profile(); 65], 1.),
|
||||
] {
|
||||
assert!(emboss_cylinder(&sleeve, &plane(), &profiles, d).is_err());
|
||||
}
|
||||
assert!(emboss_cylinder_at(&sleeve, -Vec3::Y * 10. + Vec3::Z * 7.5, &plane(), &[profile()], 1.).is_err());
|
||||
assert_eq!(crate::step_export(&[&sleeve], "unchanged").unwrap(), before);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ mod build;
|
||||
mod coplanar;
|
||||
mod curveblend;
|
||||
mod delete_face;
|
||||
mod emboss;
|
||||
mod freeform;
|
||||
pub mod fuzz;
|
||||
mod heal;
|
||||
@@ -54,6 +55,7 @@ pub use blend_variable::{fillet_chord, fillet_variable, fillet_variable_points};
|
||||
pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, Paint};
|
||||
pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
|
||||
pub use delete_face::delete_faces;
|
||||
pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at};
|
||||
pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh};
|
||||
pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step};
|
||||
pub use iges_out::iges_export_bodies;
|
||||
|
||||
+85
-15
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::Vec3;
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, Solid, from_p3, p3, v3};
|
||||
use crate::body::{Body, Solid, from_p3};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -530,16 +530,93 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
|
||||
m[0][0], m[0][1], m[0][2], m[0][3], m[1][0], m[1][1], m[1][2], m[1][3], m[2][0], m[2][1], m[2][2], m[2][3], m[3][0], m[3][1], m[3][2],
|
||||
m[3][3],
|
||||
);
|
||||
Body::new(mt::builder::transformed(&body.deep_copy(), mat))
|
||||
// An affine placement retains face order and appearance metadata.
|
||||
Body::new(placed_topology(&body.solid, mat)?).map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned()))
|
||||
})
|
||||
}
|
||||
|
||||
// A STEP periodic face may traverse a seam edge twice. Generic topology builders
|
||||
// reject that wire while cloning, even though placement preserves its topology.
|
||||
pub(crate) fn placed_topology(source: &Solid, matrix: mt::Matrix4) -> Result<Solid> {
|
||||
use mt::{Transform, Transformed};
|
||||
use std::collections::HashMap;
|
||||
let mut vertices = HashMap::new();
|
||||
let mut edges = HashMap::new();
|
||||
let mut shells = Vec::new();
|
||||
let mut face_count = 0usize;
|
||||
let mut edge_visits = 0usize;
|
||||
for shell in source.boundaries() {
|
||||
let mut faces = Vec::new();
|
||||
for face in shell {
|
||||
face_count = face_count.saturating_add(1);
|
||||
if face_count > 500_000 {
|
||||
return Err(KernelError::Invalid("placement exceeds face work limit".into()));
|
||||
}
|
||||
let mut wires = Vec::new();
|
||||
for wire in face.absolute_boundaries() {
|
||||
let mut mapped = Vec::new();
|
||||
for edge in wire {
|
||||
edge_visits = edge_visits.saturating_add(1);
|
||||
if edge_visits > 4_000_000 {
|
||||
return Err(KernelError::Invalid("placement exceeds edge work limit".into()));
|
||||
}
|
||||
if let std::collections::hash_map::Entry::Vacant(entry) = edges.entry(edge.id()) {
|
||||
for vertex in [edge.absolute_front(), edge.absolute_back()] {
|
||||
if let std::collections::hash_map::Entry::Vacant(entry) = vertices.entry(vertex.id()) {
|
||||
let point = matrix.transform_point(vertex.point());
|
||||
if !from_p3(point).is_finite() {
|
||||
return Err(KernelError::Invalid("placement produced nonfinite geometry".into()));
|
||||
}
|
||||
entry.insert(mt::Vertex::new(point));
|
||||
}
|
||||
}
|
||||
let front =
|
||||
vertices.get(&edge.absolute_front().id()).ok_or_else(|| KernelError::Failed("placement vertex missing".into()))?;
|
||||
let back = vertices.get(&edge.absolute_back().id()).ok_or_else(|| KernelError::Failed("placement vertex missing".into()))?;
|
||||
entry.insert(mt::Edge::new_unchecked(front, back, edge.curve().transformed(matrix)));
|
||||
}
|
||||
let copied = edges.get(&edge.id()).ok_or_else(|| KernelError::Failed("placement edge missing".into()))?;
|
||||
mapped.push(if edge.orientation() { copied.clone() } else { copied.inverse() });
|
||||
}
|
||||
wires.push(mt::Wire::from(mapped));
|
||||
}
|
||||
// Wire connectivity, seam sharing and orientation are copied exactly;
|
||||
// no new topology is introduced by this geometry-only operation.
|
||||
let mut copied = mt::Face::new_unchecked(wires, face.surface().transformed(matrix));
|
||||
if !face.orientation() {
|
||||
copied.invert();
|
||||
}
|
||||
faces.push(copied);
|
||||
}
|
||||
shells.push(mt::Shell::from(faces));
|
||||
}
|
||||
Ok(Solid::new_unchecked(shells))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn affine_placement_keeps_body_and_face_paint_attached() {
|
||||
let paint = crate::Paint { opacity: 0.4, faces: vec![crate::FacePaint { face: 0, color: [0.8, 0.2, 0.1], opacity: 0.3 }] };
|
||||
let body = crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap().with_color(Some([0.2, 0.6, 0.8])).with_paint(Some(paint.clone()));
|
||||
let source_faces = body.faces(0.01).unwrap();
|
||||
let transform = [[0.0, 2.0, 0.0, 0.0], [-1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.5, 0.0], [30.0, 40.0, 50.0, 1.0]];
|
||||
let placed = transform_matrix(&body, transform).unwrap();
|
||||
assert_eq!(placed.color(), body.color());
|
||||
assert_eq!(placed.paint(), Some(&paint));
|
||||
let faces = placed.faces(0.01).unwrap();
|
||||
for source in source_faces {
|
||||
let target = faces.iter().find(|f| f.index == source.index).unwrap();
|
||||
let p = source.centroid;
|
||||
assert!(target.centroid.dist(Vec3::new(30.0 - p.y, 40.0 + 2.0 * p.x, 50.0 + 0.5 * p.z)) < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rigid transform: rotate about `axis` through `origin` by `angle` radians, then translate.
|
||||
pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle: f64) -> Result<Body> {
|
||||
if !(translate.is_finite() && origin.is_finite() && angle.is_finite()) {
|
||||
return Err(KernelError::Invalid("non-finite transform".into()));
|
||||
}
|
||||
if body.is_mesh() {
|
||||
{
|
||||
// Same motion as a matrix: rotate about the line through `origin`, then translate.
|
||||
let ax = if angle.abs() > 1e-12 { axis.normalized().ok_or_else(|| KernelError::Invalid("rotation axis".into()))? } else { Vec3::Z };
|
||||
let (c, s) = (angle.cos(), angle.sin());
|
||||
@@ -556,19 +633,8 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle:
|
||||
);
|
||||
let tr = origin - ro + translate;
|
||||
let m = [[r[0][0], r[0][1], r[0][2], 0.0], [r[1][0], r[1][1], r[1][2], 0.0], [r[2][0], r[2][1], r[2][2], 0.0], [tr.x, tr.y, tr.z, 1.0]];
|
||||
return body.mesh_transformed(&m).ok_or_else(|| KernelError::Failed("mesh transform".into()));
|
||||
transform_matrix(body, m)
|
||||
}
|
||||
guard("transform", || {
|
||||
let mut s = body.deep_copy();
|
||||
if angle.abs() > 1e-12 {
|
||||
let ax = axis.normalized().ok_or_else(|| KernelError::Invalid("rotation axis".into()))?;
|
||||
s = mt::builder::rotated(&s, p3(origin), v3(ax), mt::Rad(angle));
|
||||
}
|
||||
if translate.len() > 0.0 {
|
||||
s = mt::builder::translated(&s, v3(translate));
|
||||
}
|
||||
Body::new(s)
|
||||
})
|
||||
}
|
||||
|
||||
/// Split a body with a plane: the parts on the positive and the negative side of the plane
|
||||
@@ -604,3 +670,7 @@ pub(crate) fn half_space(body: &Body, plane: &solvecraft_geom::Plane, positive:
|
||||
let half = crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))?;
|
||||
boolean(body, &half, BoolOp::Intersect)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "placement_tests.rs"]
|
||||
mod placement_tests;
|
||||
@@ -0,0 +1,95 @@
|
||||
use super::*;
|
||||
use crate::body::{p3, v3};
|
||||
use mt::builder;
|
||||
|
||||
fn assert_topology(source: &Body, placed: &Body, matrix: [[f64; 4]; 4]) {
|
||||
use std::collections::HashMap;
|
||||
let mut vertices = HashMap::new();
|
||||
let mut edges = HashMap::new();
|
||||
for (a, b) in source.solid.face_iter().zip(placed.solid.face_iter()) {
|
||||
assert_eq!(a.orientation(), b.orientation());
|
||||
let aw = a.absolute_boundaries();
|
||||
let bw = b.absolute_boundaries();
|
||||
assert_eq!(aw.len(), bw.len());
|
||||
for (a, b) in aw.iter().zip(bw.iter()) {
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (a, b) in a.edge_iter().zip(b.edge_iter()) {
|
||||
assert_eq!(a.orientation(), b.orientation());
|
||||
assert_ne!(a.id(), b.id());
|
||||
assert_eq!(*edges.entry(a.id()).or_insert(b.id()), b.id());
|
||||
for (a, b) in [(a.absolute_front(), b.absolute_front()), (a.absolute_back(), b.absolute_back())] {
|
||||
assert_ne!(a.id(), b.id());
|
||||
assert_eq!(*vertices.entry(a.id()).or_insert(b.id()), b.id());
|
||||
let p = from_p3(a.point());
|
||||
let expected = Vec3::new(
|
||||
matrix[0][0] * p.x + matrix[1][0] * p.y + matrix[2][0] * p.z + matrix[3][0],
|
||||
matrix[0][1] * p.x + matrix[1][1] * p.y + matrix[2][1] * p.z + matrix[3][1],
|
||||
matrix[0][2] * p.x + matrix[1][2] * p.y + matrix[2][2] * p.z + matrix[3][2],
|
||||
);
|
||||
assert!(from_p3(b.point()).dist(expected) < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(edges.len(), source.solid.edge_iter().map(|e| e.id()).collect::<std::collections::HashSet<_>>().len());
|
||||
}
|
||||
|
||||
fn periodic_cylinder() -> Body {
|
||||
let b0 = builder::vertex(p3(Vec3::new(10., 0., 0.)));
|
||||
let b1 = builder::vertex(p3(Vec3::new(-10., 0., 0.)));
|
||||
let t0 = builder::vertex(p3(Vec3::new(10., 0., 20.)));
|
||||
let t1 = builder::vertex(p3(Vec3::new(-10., 0., 20.)));
|
||||
let bottom: mt::Wire =
|
||||
vec![builder::circle_arc(&b0, &b1, p3(Vec3::new(0., 10., 0.))), builder::circle_arc(&b1, &b0, p3(Vec3::new(0., -10., 0.)))].into();
|
||||
let top: mt::Wire =
|
||||
vec![builder::circle_arc(&t0, &t1, p3(Vec3::new(0., 10., 20.))), builder::circle_arc(&t1, &t0, p3(Vec3::new(0., -10., 20.)))].into();
|
||||
let seam = builder::line(&b0, &t0);
|
||||
let mut boundary: Vec<_> = bottom.edge_iter().cloned().collect();
|
||||
boundary.push(seam.clone());
|
||||
boundary.extend(top.inverse().edge_iter().cloned());
|
||||
boundary.push(seam.inverse());
|
||||
let wall = mt::Face::new_unchecked(
|
||||
vec![boundary.into()],
|
||||
mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(
|
||||
seam.inverse().oriented_curve(),
|
||||
p3(Vec3::ZERO),
|
||||
v3(Vec3::Z),
|
||||
))),
|
||||
);
|
||||
let lower = builder::try_attach_plane(&[bottom.inverse()]).unwrap();
|
||||
let upper = builder::try_attach_plane(&[top]).unwrap();
|
||||
Body::new(Solid::new_unchecked(vec![vec![wall, lower, upper].into()])).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periodic_step_seam_survives_identity_rotation_and_translation() {
|
||||
let source = periodic_cylinder();
|
||||
let bytes = crate::step_export(&[&source], "placement regression").unwrap();
|
||||
let imported = crate::step_import(&bytes).unwrap();
|
||||
assert!(imported.warnings.is_empty(), "{:?}", imported.warnings);
|
||||
let body = &imported.bodies.first().unwrap().body;
|
||||
assert!(body.solid.face_iter().any(|f| f.absolute_boundaries().iter().any(|w| !w.is_simple())));
|
||||
let source_vertices: Vec<_> = body.solid.vertex_iter().map(|v| from_p3(v.point())).collect();
|
||||
let source_faces = body.solid.face_iter().count();
|
||||
let unchanged = crate::step_export(&[body], "source snapshot").unwrap();
|
||||
let before = body.tessellate_with(1e-3, true).unwrap().measure().volume;
|
||||
assert!((before - 2000. * std::f64::consts::PI).abs() < 1.);
|
||||
for matrix in [
|
||||
[[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., 0., 0., 1.]],
|
||||
[[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [10., 10., 0., 1.]],
|
||||
[[0., 1., 0., 0.], [-1., 0., 0., 0.], [0., 0., 1., 0.], [10., 10., 30., 1.]],
|
||||
] {
|
||||
let moved = transform_matrix(body, matrix).unwrap();
|
||||
assert_topology(body, &moved, matrix);
|
||||
assert_eq!(moved.solid.face_iter().count(), source_faces);
|
||||
assert_eq!(moved.solid.vertex_iter().count(), source_vertices.len());
|
||||
let after = moved.tessellate_with(1e-3, true).unwrap().measure().volume;
|
||||
assert!((after - before).abs() < 1e-5, "{after} vs {before}");
|
||||
let roundtrip = crate::step_import(&crate::step_export(&[&moved], "placement regression").unwrap()).unwrap();
|
||||
assert!(roundtrip.warnings.is_empty(), "{:?}", roundtrip.warnings);
|
||||
assert!((roundtrip.bodies.first().unwrap().body.tessellate_with(1e-3, true).unwrap().measure().volume - before).abs() < 1e-4);
|
||||
}
|
||||
let rigid = transform(body, Vec3::new(10., 10., 30.), Vec3::ZERO, Vec3::Z, std::f64::consts::FRAC_PI_2).unwrap();
|
||||
assert_topology(body, &rigid, [[0., 1., 0., 0.], [-1., 0., 0., 0.], [0., 0., 1., 0.], [10., 10., 30., 1.]]);
|
||||
assert_eq!(crate::step_export(&[body], "source snapshot").unwrap(), unchanged);
|
||||
}
|
||||
@@ -7,8 +7,8 @@ use crate::{KernelError, Result, guard};
|
||||
/// Intersection-curve edges (from booleans) as cubic B-splines through the exact curve: STEP
|
||||
/// readers get plain 3D geometry that lies on both faces, and the file does not repeat the two
|
||||
/// surfaces for every such edge.
|
||||
fn plain_curves(b: &Body) -> mt::Solid {
|
||||
let s = b.deep_copy();
|
||||
fn plain_curves(b: &Body) -> Result<mt::Solid> {
|
||||
let s = crate::ops::placed_topology(&b.solid, mt::Matrix4::from_scale(1.0))?;
|
||||
let size = b.size();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for e in s.edge_iter() {
|
||||
@@ -21,7 +21,7 @@ fn plain_curves(b: &Body) -> mt::Solid {
|
||||
e.set_curve(mt::Curve::BSplineCurve(c));
|
||||
}
|
||||
}
|
||||
s
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// A C¹ cubic B-spline through exact points of `c` spaced so that chords deviate at most `tol`
|
||||
@@ -71,7 +71,7 @@ pub(crate) fn truck_step(bodies: &[&Body], system: &str) -> Result<String> {
|
||||
b.require_brep("STEP export")?;
|
||||
}
|
||||
guard("step export", || {
|
||||
let cs: Vec<_> = bodies.iter().map(|b| plain_curves(b).compress()).collect();
|
||||
let cs: Vec<_> = bodies.iter().map(|b| plain_curves(b).map(|solid| solid.compress())).collect::<Result<_>>()?;
|
||||
let models: out::StepModels<_, _, _> = cs.iter().collect();
|
||||
let header = out::StepHeaderDescriptor { organization_system: system.to_owned(), ..Default::default() };
|
||||
Ok(out::CompleteStepDisplay::new(models, header).to_string())
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
//! Persistent free-form offsets with a whole-span normal-distance error certificate.
|
||||
//! The certificate bounds the second derivative of the exact normal offset and of
|
||||
//! the actual natural cubic interpolant. The endpoint interpolation theorem then
|
||||
//! bounds their distance everywhere, rather than just at selected samples.
|
||||
use crate::{CurveKind, Sketch, SketchError, fit_beziers};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::{Seg2, Vec2};
|
||||
|
||||
pub const OFFSET_TOLERANCE: f64 = 0.002;
|
||||
const MAX_PIECES: usize = 256;
|
||||
const MAX_ASSOCIATIONS: usize = 128;
|
||||
type Result<T> = std::result::Result<T, SketchError>;
|
||||
fn bad(message: &str) -> SketchError {
|
||||
SketchError::Invalid(message.into())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FreeformOffset {
|
||||
pub source: String,
|
||||
pub output: String,
|
||||
pub forward: bool,
|
||||
pub distance: f64,
|
||||
/// Uniform dyadic subdivision count per exact source piece; preserves point roles.
|
||||
pub divisions: usize,
|
||||
pub pieces: usize,
|
||||
pub closed: bool,
|
||||
pub tolerance: f64,
|
||||
pub certificate: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct I {
|
||||
lo: f64,
|
||||
hi: f64,
|
||||
}
|
||||
impl I {
|
||||
fn value(v: f64) -> Self {
|
||||
Self { lo: v, hi: v }
|
||||
}
|
||||
fn hull(values: impl Iterator<Item = f64>) -> Self {
|
||||
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
for v in values {
|
||||
lo = lo.min(v);
|
||||
hi = hi.max(v);
|
||||
}
|
||||
let padding = 64.0 * f64::EPSILON * (lo.abs().max(hi.abs()) + 1.0);
|
||||
Self { lo: lo - padding, hi: hi + padding }
|
||||
}
|
||||
fn add(self, b: Self) -> Self {
|
||||
Self { lo: self.lo + b.lo, hi: self.hi + b.hi }
|
||||
}
|
||||
fn neg(self) -> Self {
|
||||
Self { lo: -self.hi, hi: -self.lo }
|
||||
}
|
||||
fn sub(self, b: Self) -> Self {
|
||||
self.add(b.neg())
|
||||
}
|
||||
fn mul(self, b: Self) -> Self {
|
||||
Self::hull([self.lo * b.lo, self.lo * b.hi, self.hi * b.lo, self.hi * b.hi].into_iter())
|
||||
}
|
||||
fn scale(self, b: f64) -> Self {
|
||||
self.mul(Self::value(b))
|
||||
}
|
||||
fn div(self, b: Self) -> Result<Self> {
|
||||
if b.lo <= 0.0 || !b.hi.is_finite() {
|
||||
return Err(bad("uncertifiable rational offset denominator"));
|
||||
}
|
||||
Ok(self.mul(Self { lo: 1.0 / b.hi, hi: 1.0 / b.lo }))
|
||||
}
|
||||
fn abs_max(self) -> f64 {
|
||||
self.lo.abs().max(self.hi.abs())
|
||||
}
|
||||
fn abs_min(self) -> f64 {
|
||||
if self.lo <= 0.0 && self.hi >= 0.0 { 0.0 } else { self.lo.abs().min(self.hi.abs()) }
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct V {
|
||||
x: I,
|
||||
y: I,
|
||||
}
|
||||
impl V {
|
||||
fn hull(values: &[Vec2]) -> Self {
|
||||
Self { x: I::hull(values.iter().map(|v| v.x)), y: I::hull(values.iter().map(|v| v.y)) }
|
||||
}
|
||||
fn sub(self, b: Self) -> Self {
|
||||
Self { x: self.x.sub(b.x), y: self.y.sub(b.y) }
|
||||
}
|
||||
fn mul(self, b: I) -> Self {
|
||||
Self { x: self.x.mul(b), y: self.y.mul(b) }
|
||||
}
|
||||
fn div(self, b: I) -> Result<Self> {
|
||||
Ok(Self { x: self.x.div(b)?, y: self.y.div(b)? })
|
||||
}
|
||||
fn upper(self) -> f64 {
|
||||
self.x.abs_max().hypot(self.y.abs_max())
|
||||
}
|
||||
fn lower(self) -> f64 {
|
||||
self.x.abs_min().hypot(self.y.abs_min())
|
||||
}
|
||||
fn cross(self, b: Self) -> I {
|
||||
self.x.mul(b.y).sub(self.y.mul(b.x))
|
||||
}
|
||||
}
|
||||
|
||||
/// Homogeneous controls retain the original parameter during restriction. Re-normalising
|
||||
/// a conic with Seg2::split_at would change that parameter and invalidate the certificate.
|
||||
#[derive(Clone)]
|
||||
struct H {
|
||||
n: Vec<Vec2>,
|
||||
d: Vec<f64>,
|
||||
}
|
||||
impl H {
|
||||
fn normal_end(&self, at_end: bool, distance: f64) -> Result<Vec2> {
|
||||
let last = self.n.len() - 1;
|
||||
let (i, j) = if at_end { (last, last - 1) } else { (0, 1) };
|
||||
let position = self.n[i] / self.d[i];
|
||||
let sign = if at_end { -1.0 } else { 1.0 };
|
||||
let dn = (self.n[j] - self.n[i]) * (last as f64 * sign);
|
||||
let dd = (self.d[j] - self.d[i]) * (last as f64 * sign);
|
||||
let tangent = ((dn - position * dd) / self.d[i]).normalized().ok_or_else(|| bad("offset source has a stationary tangent"))?;
|
||||
Ok(position + tangent.perp() * distance)
|
||||
}
|
||||
fn from_seg(seg: &Seg2) -> Result<Self> {
|
||||
let (points, weights) = match *seg {
|
||||
Seg2::Line { a, b } => (vec![a, b], vec![1.0; 2]),
|
||||
Seg2::Cubic { p0, p1, p2, p3 } => (vec![p0, p1, p2, p3], vec![1.0; 4]),
|
||||
Seg2::Bezier { n, p } if (1..=7).contains(&n) => (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]),
|
||||
Seg2::Conic { a, apex, b, w } if w.is_finite() && w > 0.0 => (vec![a, apex, b], vec![1.0, w, 1.0]),
|
||||
_ => return Err(bad("unsupported exact free-form offset piece")),
|
||||
};
|
||||
if points.iter().any(|p| !p.is_finite()) {
|
||||
return Err(bad("nonfinite offset source"));
|
||||
}
|
||||
let scale = weights.iter().copied().fold(1.0_f64, f64::max);
|
||||
let d = weights.iter().map(|w| w / scale).collect::<Vec<_>>();
|
||||
Ok(Self { n: points.iter().zip(&d).map(|(p, w)| *p * *w).collect(), d })
|
||||
}
|
||||
fn split(&self, t: f64) -> (Self, Self) {
|
||||
let (mut n, mut d) = (self.n.clone(), self.d.clone());
|
||||
let (mut ln, mut rn, mut ld, mut rd) = (vec![n[0]], vec![n[n.len() - 1]], vec![d[0]], vec![d[d.len() - 1]]);
|
||||
for width in (1..n.len()).rev() {
|
||||
for i in 0..width {
|
||||
n[i] = n[i].lerp(n[i + 1], t);
|
||||
d[i] = d[i] * (1.0 - t) + d[i + 1] * t;
|
||||
}
|
||||
ln.push(n[0]);
|
||||
rn.push(n[width - 1]);
|
||||
ld.push(d[0]);
|
||||
rd.push(d[width - 1]);
|
||||
}
|
||||
rn.reverse();
|
||||
rd.reverse();
|
||||
(Self { n: ln, d: ld }, Self { n: rn, d: rd })
|
||||
}
|
||||
fn range(&self, a: f64, b: f64) -> Self {
|
||||
let left = if b < 1.0 { self.split(b).0 } else { self.clone() };
|
||||
if a > 0.0 { left.split(a / b).1 } else { left }
|
||||
}
|
||||
fn derivatives(&self) -> Result<[V; 4]> {
|
||||
let origin = self.n[0] / self.d[0];
|
||||
let mut n = self.n.iter().zip(&self.d).map(|(n, d)| *n - origin * *d).collect::<Vec<_>>();
|
||||
let mut d = self.d.clone();
|
||||
let mut nh = Vec::new();
|
||||
let mut dh = Vec::new();
|
||||
for _ in 0..4 {
|
||||
nh.push(V::hull(&n));
|
||||
dh.push(I::hull(d.iter().copied()));
|
||||
let degree = n.len().saturating_sub(1) as f64;
|
||||
n = if n.len() > 1 { n.windows(2).map(|p| (p[1] - p[0]) * degree).collect() } else { vec![Vec2::ZERO] };
|
||||
d = if d.len() > 1 { d.windows(2).map(|p| (p[1] - p[0]) * degree).collect() } else { vec![0.0] };
|
||||
}
|
||||
let r = nh[0].div(dh[0])?;
|
||||
let v = nh[1].sub(r.mul(dh[1])).div(dh[0])?;
|
||||
let a = nh[2].sub(v.mul(dh[1].scale(2.0))).sub(r.mul(dh[2])).div(dh[0])?;
|
||||
let j = nh[3].sub(a.mul(dh[1].scale(3.0))).sub(v.mul(dh[2].scale(3.0))).sub(r.mul(dh[3])).div(dh[0])?;
|
||||
Ok([r, v, a, j])
|
||||
}
|
||||
fn offset_second_bound(&self, distance: f64) -> Result<f64> {
|
||||
let [_, v, a, j] = self.derivatives()?;
|
||||
let speed = v.lower();
|
||||
if speed <= 1e-12 {
|
||||
return Err(bad("offset source has an unresolved stationary tangent"));
|
||||
}
|
||||
let curvature = v.cross(a).div(I { lo: speed.powi(3), hi: v.upper().powi(3) })?;
|
||||
if curvature.scale(distance).hi >= 1.0 - 1e-8 {
|
||||
return Err(bad("offset crosses a curvature cusp"));
|
||||
}
|
||||
let bound = a.upper() + distance.abs() * (2.0 * j.upper() / speed + 5.0 * (a.upper() / speed).powi(2));
|
||||
if !bound.is_finite() {
|
||||
return Err(bad("offset derivative bound overflow"));
|
||||
}
|
||||
Ok(bound)
|
||||
}
|
||||
}
|
||||
|
||||
/// Subdivision tightens the proof, not the stored geometry. Endpoint discrepancies
|
||||
/// are measured against the exact rational normal, then the second derivative
|
||||
/// interpolation bound certifies the full intervening interval.
|
||||
fn certify(h: &H, cubic: &[Vec2; 4], distance: f64, tolerance: f64, depth: u8, budget: &mut usize) -> Result<()> {
|
||||
if *budget == 0 {
|
||||
return Err(bad("offset certificate work limit exceeded"));
|
||||
}
|
||||
*budget -= 1;
|
||||
let endpoint_error = cubic[0].dist(h.normal_end(false, distance)?).max(cubic[3].dist(h.normal_end(true, distance)?));
|
||||
if endpoint_error + 1e-10 > tolerance {
|
||||
return Err(bad("offset spline exceeds its endpoint error certificate"));
|
||||
}
|
||||
let cubic_second = ((cubic[2] - cubic[1] * 2.0 + cubic[0]) * 6.0).len().max(((cubic[3] - cubic[2] * 2.0 + cubic[1]) * 6.0).len());
|
||||
if h.offset_second_bound(distance).is_ok_and(|second| endpoint_error + (second + cubic_second) / 8.0 * (1.0 + 1e-10) + 1e-10 <= tolerance) {
|
||||
return Ok(());
|
||||
}
|
||||
if depth >= 8 {
|
||||
return Err(bad("offset spline exceeds its whole-span error or regularity certificate"));
|
||||
}
|
||||
let (left, right) = h.split(0.5);
|
||||
let curve = Seg2::Cubic { p0: cubic[0], p1: cubic[1], p2: cubic[2], p3: cubic[3] };
|
||||
let (a, b) = curve.split_half();
|
||||
let controls = |s: Seg2| match s {
|
||||
Seg2::Cubic { p0, p1, p2, p3 } => Ok([p0, p1, p2, p3]),
|
||||
_ => Err(bad("invalid cubic certificate")),
|
||||
};
|
||||
certify(&left, &controls(a)?, distance, tolerance, depth + 1, budget)?;
|
||||
certify(&right, &controls(b)?, distance, tolerance, depth + 1, budget)
|
||||
}
|
||||
|
||||
fn normal_point(seg: &Seg2, t: f64, d: f64) -> Result<Vec2> {
|
||||
let normal = seg.derivative(t).normalized().ok_or_else(|| bad("offset source has a stationary tangent"))?.perp();
|
||||
let p = seg.point_at(t) + normal * d;
|
||||
if !p.is_finite() {
|
||||
return Err(bad("nonfinite offset point"));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn verify_simple(points: &[Vec2], cubics: &[[Vec2; 4]], closed: bool) -> Result<()> {
|
||||
// Each cubic's derivative controls must lie in the forward half-plane of its
|
||||
// chord. This proves local injectivity; separated convex hulls prove distant
|
||||
// spans cannot intersect. Near or ambiguous contacts are refused atomically.
|
||||
for c in cubics {
|
||||
let chord = c[3] - c[0];
|
||||
if c.windows(2).any(|p| (p[1] - p[0]).dot(chord) <= 1e-18) {
|
||||
return Err(bad("offset spline cannot be certified locally simple"));
|
||||
}
|
||||
}
|
||||
// Adjacent spans need their own proof: individual monotonicity does not
|
||||
// preclude them crossing each other away from their common endpoint.
|
||||
let separates_joint = |a: &[Vec2; 4], b: &[Vec2; 4]| {
|
||||
let joint = a[3];
|
||||
let direction = (a[3] - a[0]) + (b[3] - b[0]);
|
||||
joint.dist(b[0]) < 1e-7
|
||||
&& a[..3].iter().all(|p| (*p - joint).dot(direction) < -1e-18)
|
||||
&& b[1..].iter().all(|p| (*p - joint).dot(direction) > 1e-18)
|
||||
};
|
||||
for pair in cubics.windows(2) {
|
||||
if !separates_joint(&pair[0], &pair[1]) {
|
||||
return Err(bad("offset junction cannot be certified simple"));
|
||||
}
|
||||
}
|
||||
if closed && cubics.first().zip(cubics.last()).is_some_and(|(first, last)| !separates_joint(last, first)) {
|
||||
return Err(bad("offset seam cannot be certified simple"));
|
||||
}
|
||||
for i in 0..cubics.len() {
|
||||
for j in i + 2..cubics.len() {
|
||||
if closed && i == 0 && j + 1 == cubics.len() {
|
||||
continue;
|
||||
}
|
||||
let a = V::hull(&cubics[i]);
|
||||
let b = V::hull(&cubics[j]);
|
||||
if a.x.hi < b.x.lo || b.x.hi < a.x.lo || a.y.hi < b.y.lo || b.y.hi < a.y.lo {
|
||||
continue;
|
||||
}
|
||||
// A separating axis from either chord also separates both convex hulls.
|
||||
let separated = [points[i + 1] - points[i], points[j + 1] - points[j]].iter().any(|d| {
|
||||
let n = d.perp();
|
||||
let a = I::hull(cubics[i].iter().map(|p| p.dot(n)));
|
||||
let b = I::hull(cubics[j].iter().map(|p| p.dot(n)));
|
||||
a.hi < b.lo || b.hi < a.lo
|
||||
});
|
||||
if !separated {
|
||||
return Err(bad("offset self-intersection or ambiguous close contact"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn geometry(sk: &Sketch, source: usize, forward: bool, d: f64, divisions: usize, tolerance: f64) -> Result<(Vec<Vec2>, usize, bool)> {
|
||||
let mut pieces = sk.exact_segs(source).ok_or_else(|| bad("offset source changed shape"))?;
|
||||
if pieces.is_empty()
|
||||
|| pieces.len() > MAX_PIECES
|
||||
|| divisions == 0
|
||||
|| !divisions.is_power_of_two()
|
||||
|| divisions > 256
|
||||
|| pieces.len().saturating_mul(divisions) >= crate::MAX_SPLINE_POINTS
|
||||
|| !d.is_finite()
|
||||
|| d.abs() < 1e-9
|
||||
|| !tolerance.is_finite()
|
||||
|| tolerance <= 0.0
|
||||
|| tolerance > OFFSET_TOLERANCE
|
||||
{
|
||||
return Err(bad("invalid or excessive certified offset"));
|
||||
}
|
||||
if !forward {
|
||||
pieces.reverse();
|
||||
pieces = pieces.into_iter().map(|p| p.reversed()).collect();
|
||||
}
|
||||
let mut points = Vec::new();
|
||||
let mut restricted = Vec::new();
|
||||
for piece in &pieces {
|
||||
let homogeneous = H::from_seg(piece)?;
|
||||
let first = normal_point(piece, 0.0, d)?;
|
||||
if points.last().is_some_and(|p: &Vec2| p.dist(first) > 1e-7) {
|
||||
return Err(bad("free-form offset has a nonsmooth source junction"));
|
||||
}
|
||||
if points.is_empty() {
|
||||
points.push(first);
|
||||
}
|
||||
for i in 0..divisions {
|
||||
let (a, b) = (i as f64 / divisions as f64, (i + 1) as f64 / divisions as f64);
|
||||
restricted.push(homogeneous.range(a, b));
|
||||
let end = normal_point(piece, b, d)?;
|
||||
points.push(end);
|
||||
}
|
||||
}
|
||||
let closed = pieces.first().is_some_and(|a| pieces.last().is_some_and(|b| a.point_at(0.0).dist(b.point_at(1.0)) < 1e-7));
|
||||
if closed && points[0].dist(points[points.len() - 1]) > 1e-7 {
|
||||
return Err(bad("free-form closed offset has a nonsmooth seam"));
|
||||
}
|
||||
if closed {
|
||||
let first = points[0];
|
||||
if let Some(last) = points.last_mut() {
|
||||
*last = first;
|
||||
}
|
||||
}
|
||||
let cubics = fit_beziers(&points);
|
||||
let mut budget = 65_536;
|
||||
for (h, c) in restricted.iter().zip(&cubics) {
|
||||
certify(h, c, d, tolerance, 0, &mut budget)?;
|
||||
}
|
||||
verify_simple(&points, &cubics, closed)?;
|
||||
Ok((points, pieces.len(), closed))
|
||||
}
|
||||
|
||||
impl Sketch {
|
||||
/// Validate persisted association roles without solving or certifying geometry.
|
||||
pub fn validate_freeform_offsets(&self) -> Result<()> {
|
||||
if self.freeform_offsets.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.freeform_offsets.len() > MAX_ASSOCIATIONS {
|
||||
return Err(bad("too many associative free-form offsets"));
|
||||
}
|
||||
if self.points.len() > 100_000 || self.curves.len() > 100_000 {
|
||||
return Err(bad("offset sketch exceeds ownership validation limit"));
|
||||
}
|
||||
let mut owners = vec![0usize; self.points.len()];
|
||||
let mut references = 0usize;
|
||||
for curve in &self.curves {
|
||||
if matches!(&curve.kind, CurveKind::Spline {pts,..} if pts.len() > crate::MAX_SPLINE_POINTS) {
|
||||
return Err(bad("offset sketch contains an excessive spline"));
|
||||
}
|
||||
for index in curve.kind.point_ids() {
|
||||
references += 1;
|
||||
if references > 1_000_000 {
|
||||
return Err(bad("offset point ownership work limit exceeded"));
|
||||
}
|
||||
if let Some(count) = owners.get_mut(index) {
|
||||
*count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut outputs = std::collections::BTreeSet::new();
|
||||
for record in &self.freeform_offsets {
|
||||
if record.source.is_empty()
|
||||
|| record.source.len() > 512
|
||||
|| record.output.is_empty()
|
||||
|| record.output.len() > 512
|
||||
|| record.source == record.output
|
||||
|| record.certificate != 1
|
||||
|| record.pieces == 0
|
||||
|| record.pieces > MAX_PIECES
|
||||
|| record.divisions == 0
|
||||
|| !record.divisions.is_power_of_two()
|
||||
|| record.divisions > 256
|
||||
|| record.pieces.saturating_mul(record.divisions) >= crate::MAX_SPLINE_POINTS
|
||||
|| !record.distance.is_finite()
|
||||
|| record.distance.abs() < 1e-9
|
||||
|| !record.tolerance.is_finite()
|
||||
|| record.tolerance <= 0.0
|
||||
|| record.tolerance > OFFSET_TOLERANCE
|
||||
|| !outputs.insert(record.output.as_str())
|
||||
|| self.freeform_offsets.iter().any(|other| other.output == record.source && !outputs.contains(other.output.as_str()))
|
||||
{
|
||||
return Err(bad("invalid cyclic or unbounded offset association"));
|
||||
}
|
||||
let source = self.curve_index(&record.source).ok_or_else(|| bad("offset source vanished"))?;
|
||||
if !matches!(self.curves[source].kind, CurveKind::Ellipse { .. } | CurveKind::Conic { .. } | CurveKind::Spline { .. }) {
|
||||
return Err(bad("offset source changed shape"));
|
||||
}
|
||||
let output = self.curve_index(&record.output).ok_or_else(|| bad("offset output vanished"))?;
|
||||
match &self.curves[output].kind {
|
||||
CurveKind::Spline { pts, control: false, degree: 3 }
|
||||
if pts.len() == record.pieces * record.divisions + 1
|
||||
&& pts.iter().all(|i| *i < self.points.len())
|
||||
&& pts.iter().copied().collect::<std::collections::BTreeSet<_>>().len() == pts.len()
|
||||
&& pts
|
||||
.iter()
|
||||
.all(|i| self.points.get(*i).is_some_and(|point| point.fixed && point.link.is_none()) && owners.get(*i) == Some(&1)) => {}
|
||||
_ => return Err(bad("invalid offset output point roles")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn add_freeform_offset(&mut self, source: usize, forward: bool, distance: f64) -> Result<usize> {
|
||||
if self.freeform_offsets.len() >= MAX_ASSOCIATIONS {
|
||||
return Err(bad("too many associative free-form offsets"));
|
||||
}
|
||||
let source_id = self.curves.get(source).ok_or_else(|| bad("missing offset source"))?.id.clone();
|
||||
let source_pieces = self.exact_segs(source).ok_or_else(|| bad("offset source changed shape"))?.len();
|
||||
let mut result = None;
|
||||
let mut failure = None;
|
||||
for divisions in [1, 2, 4, 8, 16, 32, 64, 128, 256] {
|
||||
if source_pieces.saturating_mul(divisions) >= crate::MAX_SPLINE_POINTS {
|
||||
break;
|
||||
}
|
||||
match geometry(self, source, forward, distance, divisions, OFFSET_TOLERANCE) {
|
||||
Ok(g) => {
|
||||
result = Some((divisions, g));
|
||||
break;
|
||||
}
|
||||
Err(error) => {
|
||||
failure = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (divisions, (points, pieces, closed)) = result.ok_or_else(|| failure.unwrap_or_else(|| bad("offset subdivision limit exceeded")))?;
|
||||
let mut candidate = self.clone();
|
||||
let indices = points.iter().map(|p| candidate.add_point(*p, None)).collect::<Result<Vec<_>>>()?;
|
||||
for i in &indices {
|
||||
if let Some(p) = candidate.points.get_mut(*i) {
|
||||
p.fixed = true;
|
||||
}
|
||||
}
|
||||
let output = candidate.add_curve(CurveKind::Spline { pts: indices, control: false, degree: 3 }, None)?;
|
||||
candidate.freeform_offsets.push(FreeformOffset {
|
||||
source: source_id,
|
||||
output: candidate.curves[output].id.clone(),
|
||||
forward,
|
||||
distance,
|
||||
divisions,
|
||||
pieces,
|
||||
closed,
|
||||
tolerance: OFFSET_TOLERANCE,
|
||||
certificate: 1,
|
||||
});
|
||||
*self = candidate;
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_freeform_offsets(&mut self) -> Result<()> {
|
||||
self.validate_freeform_offsets()?;
|
||||
let mut candidate = self.clone();
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for (record_index, record) in self.freeform_offsets.clone().into_iter().enumerate() {
|
||||
if record.certificate != 1
|
||||
|| record.source == record.output
|
||||
|| !seen.insert(record.output.clone())
|
||||
|| candidate.freeform_offsets.iter().any(|later| later.output == record.source && !seen.contains(&later.output))
|
||||
{
|
||||
return Err(bad("invalid cyclic offset association"));
|
||||
}
|
||||
let source = candidate.curve_index(&record.source).ok_or_else(|| bad("offset source vanished"))?;
|
||||
let output = candidate.curve_index(&record.output).ok_or_else(|| bad("offset output vanished"))?;
|
||||
let source_pieces = candidate.exact_segs(source).ok_or_else(|| bad("offset source changed shape"))?.len();
|
||||
let mut divisions = record.divisions;
|
||||
if source_pieces != record.pieces {
|
||||
let lineage = candidate.freeform_offsets.iter().any(|parent| parent.output == record.source);
|
||||
if !lineage || source_pieces < record.pieces || source_pieces % record.pieces != 0 {
|
||||
return Err(bad("offset source topology changed outside its refinement lineage"));
|
||||
}
|
||||
let ratio = source_pieces / record.pieces;
|
||||
if !ratio.is_power_of_two() {
|
||||
return Err(bad("offset source refinement has incompatible point roles"));
|
||||
}
|
||||
divisions = (record.divisions / ratio).max(1);
|
||||
}
|
||||
let (points, pieces, closed) = loop {
|
||||
match geometry(&candidate, source, record.forward, record.distance, divisions, record.tolerance) {
|
||||
Ok(geometry) => break geometry,
|
||||
Err(error) => {
|
||||
if divisions >= 256 || source_pieces.saturating_mul(divisions.saturating_mul(2)) >= crate::MAX_SPLINE_POINTS {
|
||||
return Err(bad(&format!("offset refinement limit reached while certifying its chain: {error}")));
|
||||
}
|
||||
divisions *= 2;
|
||||
}
|
||||
}
|
||||
};
|
||||
if closed != record.closed {
|
||||
return Err(bad("offset source topology changed"));
|
||||
}
|
||||
let old_indices = match &candidate.curves[output].kind {
|
||||
CurveKind::Spline { pts, control: false, degree: 3 } if pts.len() == record.pieces * record.divisions + 1 => pts.clone(),
|
||||
_ => return Err(bad("offset output was edited; break its association first")),
|
||||
};
|
||||
// Refinement never reuses or removes an old node ID. Existing dyadic
|
||||
// roles remain at the same normalized source parameters; new nodes
|
||||
// append to the point table, so dimensions keep their point indices.
|
||||
let old_spans = record.pieces * record.divisions;
|
||||
let new_spans = pieces * divisions;
|
||||
if new_spans < old_spans || new_spans % old_spans != 0 || !(new_spans / old_spans).is_power_of_two() {
|
||||
return Err(bad("offset refinement would change existing normalized point roles"));
|
||||
}
|
||||
let factor = new_spans / old_spans;
|
||||
let mut indices = Vec::with_capacity(points.len());
|
||||
for (role, pos) in points.iter().enumerate() {
|
||||
let index = if role % factor == 0 {
|
||||
*old_indices.get(role / factor).ok_or_else(|| bad("offset node role vanished"))?
|
||||
} else {
|
||||
candidate.add_point(*pos, None)?
|
||||
};
|
||||
indices.push(index);
|
||||
}
|
||||
for (i, pos) in indices.iter().zip(points) {
|
||||
let p = candidate.points.get_mut(*i).ok_or_else(|| bad("offset output point vanished"))?;
|
||||
p.pos = pos;
|
||||
p.fixed = true;
|
||||
}
|
||||
candidate.curves[output].kind = CurveKind::Spline { pts: indices, control: false, degree: 3 };
|
||||
candidate.freeform_offsets[record_index].divisions = divisions;
|
||||
candidate.freeform_offsets[record_index].pieces = pieces;
|
||||
}
|
||||
*self = candidate;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ellipse() -> (Sketch, usize) {
|
||||
let mut sketch = Sketch::new();
|
||||
let center = sketch.add_point(Vec2::ZERO, None).unwrap();
|
||||
let major = sketch.add_point(Vec2::new(10.0, 0.0), None).unwrap();
|
||||
let source = sketch.add_curve(CurveKind::Ellipse { c: center, m: major, r: 5.0 }, None).unwrap();
|
||||
(sketch, source)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_offset_ellipse_refreshes_exact_normals_and_identity() {
|
||||
let (mut sk, source) = ellipse();
|
||||
let output = sk.add_freeform_offset(source, true, -1.0).unwrap();
|
||||
let record = sk.freeform_offsets[0].clone();
|
||||
let original_ids = sk.curves[output].kind.point_ids().iter().map(|i| sk.points[*i].id.clone()).collect::<Vec<_>>();
|
||||
let verify = |sk: &Sketch| {
|
||||
let record = &sk.freeform_offsets[0];
|
||||
let base = sk.exact_segs(source).unwrap();
|
||||
let curve = sk.exact_segs(output).unwrap();
|
||||
for (piece, source_piece) in base.iter().enumerate() {
|
||||
for span in 0..record.divisions {
|
||||
for t in [0.0, 0.13, 0.41, 0.79, 1.0] {
|
||||
let want = normal_point(source_piece, (span as f64 + t) / record.divisions as f64, -1.0).unwrap();
|
||||
let got = curve[piece * record.divisions + span].point_at(t);
|
||||
assert!(got.dist(want) <= OFFSET_TOLERANCE, "{got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
verify(&sk);
|
||||
// Independent Steiner area check for the convex ellipse expanded by 1mm.
|
||||
// Simpson integration evaluates the analytic ellipse speed, not offset samples.
|
||||
let n = 4096usize;
|
||||
let h = std::f64::consts::TAU / n as f64;
|
||||
let speed = |t: f64| (100.0 * t.sin().powi(2) + 25.0 * t.cos().powi(2)).sqrt();
|
||||
let perimeter = (0..=n)
|
||||
.map(|i| {
|
||||
speed(i as f64 * h)
|
||||
* if i == 0 || i == n {
|
||||
1.0
|
||||
} else if i % 2 == 0 {
|
||||
2.0
|
||||
} else {
|
||||
4.0
|
||||
}
|
||||
})
|
||||
.sum::<f64>()
|
||||
* h
|
||||
/ 3.0;
|
||||
let expected_area = 50.0 * std::f64::consts::PI + perimeter + std::f64::consts::PI;
|
||||
let actual_area = sk.exact_segs(output).unwrap().iter().map(Seg2::area_term).sum::<f64>();
|
||||
assert!((actual_area - expected_area).abs() < 0.15, "area {actual_area} vs {expected_area}");
|
||||
if let CurveKind::Ellipse { m, .. } = sk.curves[source].kind {
|
||||
sk.points[m].pos = Vec2::new(12.0, 0.0);
|
||||
}
|
||||
sk.refresh_offsets().unwrap();
|
||||
assert!(sk.freeform_offsets[0].divisions >= record.divisions);
|
||||
let factor = sk.freeform_offsets[0].divisions / record.divisions;
|
||||
assert_eq!(sk.curves[output].kind.point_ids().iter().step_by(factor).map(|i| sk.points[*i].id.clone()).collect::<Vec<_>>(), original_ids);
|
||||
verify(&sk);
|
||||
let first = sk.segs(output)[0].point_at(0.0);
|
||||
assert!(first.dist(Vec2::new(13.0, 0.0)) < 1e-10);
|
||||
let encoded = serde_json::to_string(&sk).unwrap();
|
||||
let mut reopened: Sketch = serde_json::from_str(&encoded).unwrap();
|
||||
reopened.refresh_offsets().unwrap();
|
||||
assert_eq!(reopened, sk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_offset_refuses_cusp_and_hostile_records_atomically() {
|
||||
let (mut sk, source) = ellipse();
|
||||
let before = sk.clone();
|
||||
// At (10,0), curvature radius is b²/a=2.5. An inward offset of 3
|
||||
// necessarily crosses the evolute and reverses its tangent orientation.
|
||||
assert!(sk.add_freeform_offset(source, true, 3.0).is_err());
|
||||
assert_eq!(sk, before);
|
||||
sk.add_freeform_offset(source, true, -1.0).unwrap();
|
||||
for divisions in [0, 3, usize::MAX] {
|
||||
let mut candidate = sk.clone();
|
||||
candidate.freeform_offsets[0].divisions = divisions;
|
||||
let before = candidate.clone();
|
||||
assert!(candidate.refresh_offsets().is_err());
|
||||
assert_eq!(candidate, before);
|
||||
}
|
||||
let mut cyclic = sk.clone();
|
||||
cyclic.freeform_offsets[0].source = cyclic.freeform_offsets[0].output.clone();
|
||||
let before = cyclic.clone();
|
||||
assert!(cyclic.refresh_offsets().is_err());
|
||||
assert_eq!(cyclic, before);
|
||||
let source_points = sk.curves[source].kind.point_ids();
|
||||
let output = sk.curve_index(&sk.freeform_offsets[0].output).unwrap();
|
||||
let mut aliased = sk.clone();
|
||||
if let CurveKind::Spline { pts, .. } = &mut aliased.curves[output].kind {
|
||||
pts[0] = source_points[0];
|
||||
}
|
||||
let before = aliased.clone();
|
||||
assert!(aliased.validate_freeform_offsets().is_err());
|
||||
assert!(aliased.refresh_offsets().is_err());
|
||||
assert_eq!(aliased, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_offset_chain_preserves_roles_during_upstream_refinement() {
|
||||
let mut sk = Sketch::new();
|
||||
let start = sk.add_point(Vec2::new(10.0, 0.0), None).unwrap();
|
||||
let apex = sk.add_point(Vec2::new(10.0, 5.0), None).unwrap();
|
||||
let end = sk.add_point(Vec2::new(0.0, 5.0), None).unwrap();
|
||||
let w = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let source = sk.add_curve(CurveKind::Conic { a: start, b: end, apex, rho: w / (1.0 + w) }, None).unwrap();
|
||||
let a = sk.add_freeform_offset(source, true, -1.0).unwrap();
|
||||
let b = sk.add_freeform_offset(a, true, -1.0).unwrap();
|
||||
let original = [a, b].map(|i| sk.curves[i].kind.point_ids().iter().map(|point| sk.points[*point].id.clone()).collect::<Vec<_>>());
|
||||
let records = sk.freeform_offsets.clone();
|
||||
for major in [12.0, 16.0, 20.0, 24.0] {
|
||||
sk.points[start].pos.x = major;
|
||||
sk.points[apex].pos.x = major;
|
||||
sk.refresh_offsets().unwrap();
|
||||
if sk.freeform_offsets[0].divisions > records[0].divisions {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(sk.freeform_offsets[0].divisions > records[0].divisions, "test must exercise real upstream refinement");
|
||||
for (record_index, output) in [a, b].into_iter().enumerate() {
|
||||
let old = &records[record_index];
|
||||
let current = &sk.freeform_offsets[record_index];
|
||||
let factor = current.pieces * current.divisions / (old.pieces * old.divisions);
|
||||
assert_eq!(
|
||||
sk.curves[output].kind.point_ids().iter().step_by(factor).map(|i| sk.points[*i].id.clone()).collect::<Vec<_>>(),
|
||||
original[record_index]
|
||||
);
|
||||
let source = sk.curve_index(¤t.source).unwrap();
|
||||
let source_pieces = sk.exact_segs(source).unwrap();
|
||||
let output_pieces = sk.exact_segs(output).unwrap();
|
||||
for (piece, source) in source_pieces.iter().enumerate() {
|
||||
for span in 0..current.divisions {
|
||||
let t = 0.371;
|
||||
let want = normal_point(source, (span as f64 + t) / current.divisions as f64, current.distance).unwrap();
|
||||
assert!(output_pieces[piece * current.divisions + span].point_at(t).dist(want) < OFFSET_TOLERANCE);
|
||||
}
|
||||
}
|
||||
}
|
||||
let saved = serde_json::to_string(&sk).unwrap();
|
||||
let mut reopened: Sketch = serde_json::from_str(&saved).unwrap();
|
||||
reopened.refresh_offsets().unwrap();
|
||||
assert_eq!(reopened, sk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_offset_closed_chain_refuses_natural_spline_seam_atomically() {
|
||||
let (mut sk, source) = ellipse();
|
||||
let first = sk.add_freeform_offset(source, true, -1.0).unwrap();
|
||||
let before = sk.clone();
|
||||
let error = sk.add_freeform_offset(first, true, -0.5).unwrap_err();
|
||||
assert!(error.to_string().contains("nonsmooth seam"), "{error}");
|
||||
assert_eq!(sk, before);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
mod curves;
|
||||
mod dims;
|
||||
mod freeform_offset;
|
||||
mod linalg;
|
||||
mod link;
|
||||
mod model;
|
||||
@@ -24,6 +25,7 @@ pub use curves::{
|
||||
MAX_SPLINE_POINTS, bspline_beziers, conic_point, ellipse_point, end_curvature, fit_beziers, spline_end_tangent, spline_point, spline_polyline,
|
||||
};
|
||||
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text};
|
||||
pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE};
|
||||
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
|
||||
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError, SketchView};
|
||||
pub use offset::OffsetChain;
|
||||
|
||||
@@ -492,6 +492,9 @@ pub struct Sketch {
|
||||
pub links: Vec<crate::link::Link>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub offsets: Vec<crate::offset::OffsetChain>,
|
||||
/// New certified normal offsets; legacy sampled output splines remain independent.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub freeform_offsets: Vec<crate::freeform_offset::FreeformOffset>,
|
||||
/// 3D curves (world coordinates) carried by the sketch.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub wires: Vec<crate::wire::Wire>,
|
||||
@@ -521,6 +524,7 @@ impl Sketch {
|
||||
constraints: Vec::new(),
|
||||
links: Vec::new(),
|
||||
offsets: Vec::new(),
|
||||
freeform_offsets: Vec::new(),
|
||||
wires: Vec::new(),
|
||||
counters: Default::default(),
|
||||
view: SketchView::default(),
|
||||
@@ -886,7 +890,7 @@ impl Sketch {
|
||||
|
||||
/// Is curve `c` held in place: fixed or projected (linked).
|
||||
pub fn curve_locked(&self, c: usize) -> bool {
|
||||
self.curves.get(c).is_some_and(|c| c.fixed || c.link.is_some())
|
||||
self.curves.get(c).is_some_and(|c| c.fixed || c.link.is_some() || self.freeform_offsets.iter().any(|offset| offset.output == c.id))
|
||||
}
|
||||
|
||||
/// Radius of a circle or arc.
|
||||
@@ -1060,6 +1064,7 @@ impl Sketch {
|
||||
|
||||
/// Remove curves only (points stay), remapping constraints.
|
||||
fn drop_curves(&mut self, which: &[usize]) {
|
||||
self.prune_offsets_for_removed_curves(which);
|
||||
let mut cmap = vec![None; self.curves.len()];
|
||||
let mut n = 0;
|
||||
for (i, slot) in cmap.iter_mut().enumerate() {
|
||||
|
||||
@@ -12,7 +12,51 @@ pub struct OffsetChain {
|
||||
}
|
||||
|
||||
impl Sketch {
|
||||
/// Removing an output removes its relationship. A partial analytic chain
|
||||
/// deletion releases the surviving outputs, while retained dependents keep
|
||||
/// their source references so the command's refresh refuses broken sources.
|
||||
pub(crate) fn prune_offsets_for_removed_curves(&mut self, which: &[usize]) {
|
||||
let removed = which.iter().filter_map(|i| self.curves.get(*i).map(|curve| curve.id.clone())).collect::<std::collections::BTreeSet<_>>();
|
||||
if removed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let released = self
|
||||
.offsets
|
||||
.iter()
|
||||
.filter(|offset| offset.outputs.iter().any(|id| removed.contains(id)))
|
||||
.flat_map(|offset| offset.outputs.iter().cloned())
|
||||
.chain(self.freeform_offsets.iter().filter(|offset| removed.contains(&offset.output)).map(|offset| offset.output.clone()))
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
self.offsets.retain(|offset| !offset.outputs.iter().any(|id| removed.contains(id)));
|
||||
self.freeform_offsets.retain(|offset| !removed.contains(&offset.output));
|
||||
let points = self
|
||||
.curves
|
||||
.iter()
|
||||
.filter(|curve| released.contains(&curve.id))
|
||||
.flat_map(|curve| curve.kind.point_ids())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
for index in points {
|
||||
if index != 0
|
||||
&& self.curves.iter().all(|curve| !curve.kind.uses(index) || released.contains(&curve.id))
|
||||
&& let Some(point) = self.points.get_mut(index)
|
||||
{
|
||||
point.fixed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_offsets(&mut self) -> Result<(), SketchError> {
|
||||
if self.offsets.is_empty() && self.freeform_offsets.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut candidate = self.clone();
|
||||
candidate.refresh_analytic_offsets()?;
|
||||
candidate.refresh_freeform_offsets()?;
|
||||
*self = candidate;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refresh_analytic_offsets(&mut self) -> Result<(), SketchError> {
|
||||
for offset in self.offsets.clone() {
|
||||
if offset.sources.len() != offset.outputs.len() || offset.sources.len() > 10_000 || !offset.distance.is_finite() {
|
||||
return Err(SketchError::Invalid("damaged offset chain".into()));
|
||||
|
||||
+137
-18
@@ -23,8 +23,26 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
|
||||
let (at, normal) = match first {
|
||||
Sel::Profile { sketch, index } => {
|
||||
let ss = st.sketch(*sketch)?;
|
||||
let plane = profile_plane(app, *sketch)?;
|
||||
let p = ss.profiles.get(*index)?;
|
||||
(ss.plane.to_world(p.region.interior_point()), Some(ss.plane.normal()))
|
||||
let centre = if matches!(d.kind, Kind::Extrude { .. }) {
|
||||
let regions: Vec<_> = d
|
||||
.inputs
|
||||
.first()?
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
if let Sel::Profile { sketch: id, index } = item { (*id == *sketch).then(|| ss.profiles.get(*index)).flatten() } else { None }
|
||||
})
|
||||
.map(|p| p.region.clone())
|
||||
.collect();
|
||||
let regions = solvecraft_engine::sketch::merge_regions(®ions);
|
||||
let points: Vec<_> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
|
||||
points.iter().fold(solvecraft_engine::geom::Vec2::default(), |sum, p| sum + *p) * (1.0 / points.len().max(1) as f64)
|
||||
} else {
|
||||
p.region.interior_point()
|
||||
};
|
||||
(plane.to_world(centre), Some(plane.normal()))
|
||||
}
|
||||
Sel::Face { body, index, point } => (*point, crate::dialogs::planar_face(&app.session, body, *index).map(|(_, n)| n)),
|
||||
Sel::Edge { body, index, point } => (*point, st.body(body).and_then(|b| edge_bisector(&b.mesh(), *index, *point))),
|
||||
@@ -43,13 +61,15 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(match d.kind {
|
||||
Kind::Extrude { .. }
|
||||
| Kind::Fillet { .. }
|
||||
| Kind::Section { .. }
|
||||
| Kind::Hole { .. }
|
||||
| Kind::OffsetPlane { .. }
|
||||
| Kind::OffsetFaces { .. } => (at, normal),
|
||||
Some(match &d.kind {
|
||||
Kind::Extrude { start, .. } => {
|
||||
let n = normal?;
|
||||
let plane = solvecraft_engine::geom::Plane::from_normal(at, n)?;
|
||||
let from = reference_height(app, d, "From Object", &plane, at).unwrap_or(0.0);
|
||||
let offset = if start.trim().is_empty() { 0.0 } else { app.session.doc.eval(start, ValueKind::Length).ok()? };
|
||||
(at + n * (from + offset), Some(n))
|
||||
}
|
||||
Kind::Fillet { .. } | Kind::Section { .. } | Kind::Hole { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal),
|
||||
// Shell thickness grows into the body.
|
||||
Kind::Shell { .. } => (at, normal.map(|n| -n)),
|
||||
Kind::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)),
|
||||
@@ -59,6 +79,25 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
|
||||
})
|
||||
}
|
||||
|
||||
fn profile_plane(app: &SolveApp, sketch: u64) -> Option<solvecraft_engine::geom::Plane> {
|
||||
let component = app.session.doc.feature(sketch)?.component;
|
||||
let state = app.session.model.state();
|
||||
let plane = state.sketch(sketch)?.plane;
|
||||
let frame = crate::dialogs::feature_frame(&app.session, component);
|
||||
solvecraft_engine::geom::Plane::new(
|
||||
solvecraft_engine::doc::apply_point(&frame, plane.origin),
|
||||
solvecraft_engine::doc::apply_vector(&frame, plane.x),
|
||||
solvecraft_engine::doc::apply_vector(&frame, plane.y),
|
||||
)
|
||||
}
|
||||
|
||||
fn reference_height(app: &SolveApp, d: &Dialog, label: &str, plane: &solvecraft_engine::geom::Plane, centre: Vec3) -> Option<f64> {
|
||||
let point = crate::dialogs::object_point(&d.inputs, label)?;
|
||||
let p = point.as_array()?;
|
||||
let p = Vec3::new(p.first()?.as_f64()?, p.get(1)?.as_f64()?, p.get(2)?.as_f64()?);
|
||||
Some(solvecraft_engine::doc::object_height(&app.session.world_state(), plane, centre, p))
|
||||
}
|
||||
|
||||
/// The outward direction between the two faces at an edge (where a fillet's radius arrow
|
||||
/// points).
|
||||
fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option<Vec3> {
|
||||
@@ -155,11 +194,40 @@ fn offset_plane_quad(proj: &Proj, c: Vec3, n: Vec3, h: f64) -> Option<Vec<Pos2>>
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, d: &mut Dialog, base: Vec3, bs: Pos2, dir: Option<Vec3>) {
|
||||
let t = Tokens::get();
|
||||
let sign = match d.kind {
|
||||
Kind::Extrude { direction: 1, .. } => -1.0,
|
||||
let to_span = dir.and_then(|n| {
|
||||
let plane = solvecraft_engine::geom::Plane::from_normal(base, n)?;
|
||||
reference_height(app, d, "To Object", &plane, base)
|
||||
});
|
||||
let all_span = if matches!(d.kind, Kind::Extrude { all: true, .. }) {
|
||||
dir.and_then(|n| {
|
||||
let state = app.session.world_state();
|
||||
let plane = match d.inputs.first()?.items.first()? {
|
||||
Sel::Profile { sketch, .. } => profile_plane(app, *sketch)?,
|
||||
_ => solvecraft_engine::geom::Plane::from_normal(base, n)?,
|
||||
};
|
||||
let targets: Vec<String> = d
|
||||
.extra
|
||||
.get("targets")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
|
||||
.unwrap_or_default();
|
||||
solvecraft_engine::doc::through_all_distance(&state, &plane, &targets).ok()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sign = match (to_span, &d.kind) {
|
||||
(Some(span), _) => {
|
||||
if span < 0.0 {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
(_, Kind::Extrude { direction: 1, .. }) => -1.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
let symmetric = matches!(d.kind, Kind::Extrude { direction: 2, .. });
|
||||
let symmetric = to_span.is_none() && matches!(d.kind, Kind::Extrude { direction: 2, .. });
|
||||
// Extrudes and moves go either way; radii and thicknesses stay positive.
|
||||
let signed =
|
||||
matches!(d.kind, Kind::Extrude { .. } | Kind::Move { .. } | Kind::Section { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. });
|
||||
@@ -170,7 +238,7 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
if let (Kind::Extrude { direction: crate::dialogs::TWO_SIDES, distance2, .. }, Some(n)) = (&mut d.kind, dir)
|
||||
&& let Some(l2) = app.session.doc.eval(distance2, ValueKind::Length).ok().filter(|v| v.is_finite())
|
||||
{
|
||||
drag_arrow(ui, painter, proj, base, bs, -n, l2, false, egui::Id::new("sc_manipulator2"), half_height, distance2);
|
||||
drag_arrow(ui, painter, proj, base, bs, -n, l2, true, egui::Id::new("sc_manipulator2"), half_height, distance2);
|
||||
}
|
||||
// An offset plane shows where it will be.
|
||||
if let (Kind::OffsetPlane { offset }, Some(n)) = (&d.kind, dir)
|
||||
@@ -184,16 +252,21 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
let mut dir = dir;
|
||||
// Extrude taper: a handle beside the arrow tip; dragging it sideways tilts the walls.
|
||||
let cam_right = app.cam.basis().0;
|
||||
if let (Kind::Extrude { direction: 0 | 1, distance, taper, .. }, Some(n)) = (&mut d.kind, dir) {
|
||||
if let (Kind::Extrude { direction: 0 | 1, distance, taper, taper_present, to_offset, .. }, Some(n)) = (&mut d.kind, dir) {
|
||||
let n = n * sign;
|
||||
if let Ok(l) = app.session.doc.eval(distance, ValueKind::Length)
|
||||
let span = to_span.map(|span| span.abs() + app.session.doc.eval(to_offset, ValueKind::Length).unwrap_or(0.0)).or(all_span);
|
||||
if let Ok(l) = span.map(Ok).unwrap_or_else(|| app.session.doc.eval(distance, ValueKind::Length))
|
||||
&& l.is_finite()
|
||||
&& l.abs() > 1e-9
|
||||
&& let Some(e) = (cam_right - n * cam_right.dot(n)).normalized()
|
||||
{
|
||||
let px = 2.0 * half_height / f64::from(proj.rect.height().max(1.0));
|
||||
let ang = app.session.doc.eval(taper, ValueKind::Angle).ok().filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let before = taper.clone();
|
||||
taper_handle(ui, painter, proj, base - e * (40.0 * px), n * l, -e, ang, taper);
|
||||
if *taper != before {
|
||||
*taper_present = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (Kind::Hole { depth, opts, .. }, Some(n)) = (&mut d.kind, dir) {
|
||||
@@ -207,7 +280,10 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
dir = None;
|
||||
}
|
||||
let Some((label, kind, value)) = d.primary() else { return };
|
||||
let len = app.session.doc.eval(value, kind).ok().filter(|v| v.is_finite());
|
||||
let len = all_span.or_else(|| {
|
||||
if to_span.is_some() && value.trim().is_empty() { Some(0.0) } else { app.session.doc.eval(value, kind).ok().filter(|v| v.is_finite()) }
|
||||
});
|
||||
let before = value.clone();
|
||||
let mut box_at = bs + vec2(16.0, -30.0);
|
||||
if let (Some(axis), Some(angle)) = (axis, len.filter(|_| kind == ValueKind::Angle))
|
||||
&& let Some(at) = rotator(ui, painter, proj, base, axis, angle, value)
|
||||
@@ -216,7 +292,17 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
}
|
||||
if let (Some(n), Some(l)) = (dir, len.filter(|_| kind == ValueKind::Length)) {
|
||||
let n = n * sign;
|
||||
if let Some(ts) = drag_arrow(ui, painter, proj, base, bs, n, l, signed, egui::Id::new("sc_manipulator"), half_height, value) {
|
||||
let baseline = to_span.map(f64::abs).unwrap_or(0.0);
|
||||
let mut shown_value = format_len(l + baseline);
|
||||
let original_shown = shown_value.clone();
|
||||
let dragged = if to_span.is_some() { &mut shown_value } else { &mut *value };
|
||||
if let Some(ts) = drag_arrow(ui, painter, proj, base, bs, n, l + baseline, signed, egui::Id::new("sc_manipulator"), half_height, dragged) {
|
||||
if to_span.is_some()
|
||||
&& shown_value != original_shown
|
||||
&& let Ok(v) = app.session.doc.eval(&shown_value, ValueKind::Length)
|
||||
{
|
||||
*value = format_len(v - baseline);
|
||||
}
|
||||
if symmetric && let Some(bt) = proj.to_screen(base - n * l) {
|
||||
arrow(painter, bs, bt, t.manipulator);
|
||||
}
|
||||
@@ -241,6 +327,11 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
});
|
||||
});
|
||||
});
|
||||
if *value != before
|
||||
&& let Kind::Extrude { all, .. } = &mut d.kind
|
||||
{
|
||||
*all = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// A draggable arrow from `base` along `n` showing `l` (at least a short stub); dragging writes
|
||||
@@ -338,11 +429,39 @@ fn taper_handle(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, foot: V
|
||||
}
|
||||
|
||||
/// The revolve axis (a point on it and its unit direction).
|
||||
fn revolve_axis(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Vec3)> {
|
||||
pub(crate) fn revolve_axis(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Vec3)> {
|
||||
if !matches!(d.kind, Kind::Revolve { .. }) {
|
||||
return None;
|
||||
}
|
||||
crate::dialogs::axis_of(app, d.inputs.get(1)?.items.first()?)
|
||||
if let Some(selected) = d.inputs.get(1)?.items.first() {
|
||||
if let Sel::Axis { name } = selected
|
||||
&& matches!(name.as_str(), "X" | "Y" | "Z")
|
||||
&& let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()?
|
||||
{
|
||||
let component = app.session.doc.feature(*sketch)?.component;
|
||||
let frame = crate::dialogs::feature_frame(&app.session, component);
|
||||
let dir = match name.as_str() {
|
||||
"X" => Vec3::X,
|
||||
"Y" => Vec3::Y,
|
||||
_ => Vec3::Z,
|
||||
};
|
||||
return Some((solvecraft_engine::doc::apply_point(&frame, Vec3::ZERO), solvecraft_engine::doc::apply_vector(&frame, dir)));
|
||||
}
|
||||
if let Sel::Axis { name } = selected
|
||||
&& matches!(name.as_str(), "x" | "y")
|
||||
&& let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()?
|
||||
{
|
||||
let plane = profile_plane(app, *sketch)?;
|
||||
return Some((plane.origin, if name == "x" { plane.x } else { plane.y }));
|
||||
}
|
||||
return crate::dialogs::axis_of(app, selected);
|
||||
}
|
||||
let Kind::Revolve { axis: Some(axis), .. } = &d.kind else { return None };
|
||||
let vector = |key: &str| -> Option<Vec3> {
|
||||
let p = axis.get(key)?.as_array()?;
|
||||
Some(Vec3::new(p.first()?.as_f64()?, p.get(1)?.as_f64()?, p.get(2)?.as_f64()?))
|
||||
};
|
||||
Some((vector("origin")?, vector("dir")?.normalized()?))
|
||||
}
|
||||
|
||||
/// The revolve angle rotator: an arc from the profile around the axis with a handle at its end
|
||||
|
||||
@@ -210,6 +210,18 @@ pub(super) fn show(ui: &mut Ui, app: &mut SolveApp, error: &mut Option<String>,
|
||||
if let Some(preview) = &editor.preview {
|
||||
ui.collapsing(format!("Preview: {}", preview["active"].as_str().unwrap_or("row")), |ui| {
|
||||
ui.label("Preview leaves the current design unchanged.");
|
||||
if let Some(styles) = preview["styles"].as_array() {
|
||||
for style in styles {
|
||||
ui.add(
|
||||
egui::Label::new(format!(
|
||||
"{}: {} (shared definition)",
|
||||
style["target"].as_str().unwrap_or("Appearance"),
|
||||
if style["dormant"].as_bool() == Some(true) { "dormant" } else { "resolved" }
|
||||
))
|
||||
.wrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(trace) = preview["trace"].as_array() {
|
||||
for entry in trace {
|
||||
ui.label(format!(
|
||||
|
||||
@@ -652,6 +652,9 @@ fn sketch_entity_items(app: &SolveApp) -> Vec<Item> {
|
||||
if !curves.is_empty() {
|
||||
v.push(cmd(app, "sketch.construction", "Normal / Construction").with(json!({ "curves": curves })));
|
||||
}
|
||||
if curves.len() == 1 && ss.sketch.freeform_offsets.iter().any(|offset| offset.output == curves[0]) {
|
||||
v.push(cmd(app, "sketch.offset.break", "Break Offset Association").with(json!({"curve":curves[0]})));
|
||||
}
|
||||
v.push(act("ui.fix", "Fix/Unfix", "c_fix").with(json!({ "entities": ents })));
|
||||
v.push(act("ui.delete", "Delete", "delete").key("Del"));
|
||||
v.push(cmd(app, "sketch.dimension", "Sketch Dimension").with(json!({ "entities": ents })).on(ents.len() <= 2));
|
||||
|
||||
+245
-43
@@ -40,6 +40,10 @@ pub enum Kind {
|
||||
distance2: String,
|
||||
/// Taper angle (empty or zero: none).
|
||||
taper: String,
|
||||
/// An explicitly authored zero taper is still a model parameter.
|
||||
taper_present: bool,
|
||||
/// Optional distance beyond the selected end object.
|
||||
to_offset: String,
|
||||
/// Start offset from the profile plane (empty: start on the profile plane).
|
||||
start: String,
|
||||
/// Extent type All (through everything) instead of a distance.
|
||||
@@ -48,6 +52,8 @@ pub enum Kind {
|
||||
Revolve {
|
||||
angle: String,
|
||||
operation: usize,
|
||||
/// An arbitrary stored line until the user deliberately picks a replacement.
|
||||
axis: Option<Value>,
|
||||
},
|
||||
Fillet {
|
||||
radius: String,
|
||||
@@ -262,6 +268,8 @@ pub struct Dialog {
|
||||
pub error: Option<String>,
|
||||
/// Editing an existing feature (its id, and the timeline marker to restore afterwards).
|
||||
pub editing: Option<(u64, Option<usize>)>,
|
||||
/// A user pick or clear makes the profile selector explicit, even if all are reselected.
|
||||
pub profile_selection_changed: bool,
|
||||
/// Give the on-canvas value box the keyboard (and select its text) on the next frame.
|
||||
pub focus: bool,
|
||||
/// Folded to its header (the "−" glyph).
|
||||
@@ -288,7 +296,17 @@ fn fits(a: Accept, s: &Sel) -> bool {
|
||||
|
||||
impl Dialog {
|
||||
pub(crate) fn new(kind: Kind, inputs: Vec<SelInput>) -> Dialog {
|
||||
Dialog { kind, inputs, active: 0, error: None, editing: None, focus: true, collapsed: false, extra: serde_json::Map::new() }
|
||||
Dialog {
|
||||
kind,
|
||||
inputs,
|
||||
active: 0,
|
||||
error: None,
|
||||
editing: None,
|
||||
profile_selection_changed: false,
|
||||
focus: true,
|
||||
collapsed: false,
|
||||
extra: serde_json::Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_command(app: &SolveApp, id: &str) -> Option<Dialog> {
|
||||
@@ -304,13 +322,15 @@ impl Dialog {
|
||||
auto_op: true,
|
||||
distance2: "10 mm".into(),
|
||||
taper: "0 deg".into(),
|
||||
taper_present: false,
|
||||
to_offset: String::new(),
|
||||
start: String::new(),
|
||||
all: false,
|
||||
},
|
||||
vec![SelInput::new("Profiles", PROFILES | PLANAR_FACES, true)],
|
||||
),
|
||||
"solid.revolve" => Dialog::new(
|
||||
Kind::Revolve { angle: "360 deg".into(), operation: 0 },
|
||||
Kind::Revolve { angle: "360 deg".into(), operation: 0, axis: None },
|
||||
vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Axis", AXES, false)],
|
||||
),
|
||||
"solid.fillet" => Dialog::new(fillet_kind("2 mm", false), vec![SelInput::new("Edges", EDGES | FACES, true)]),
|
||||
@@ -332,7 +352,10 @@ impl Dialog {
|
||||
Kind::Rib { web: id == "solid.web", thickness: "2 mm".into(), depth: String::new(), flip: false },
|
||||
vec![SelInput::new("Curves", CURVES, true)],
|
||||
),
|
||||
"solid.emboss" => Dialog::new(Kind::Emboss { depth: "1 mm".into(), deboss: false }, vec![SelInput::new("Profiles", PROFILES, true)]),
|
||||
"solid.emboss" => Dialog::new(
|
||||
Kind::Emboss { depth: "1 mm".into(), deboss: false },
|
||||
vec![SelInput::new("Profiles", PROFILES, true), SelInput::new("Target Face (optional)", FACES, false)],
|
||||
),
|
||||
"solid.replace_face" => Dialog::new(
|
||||
Kind::ReplaceFace,
|
||||
vec![SelInput::new("Faces", PLANAR_FACES, true), SelInput::new("Target", PLANES | PLANAR_FACES, false)],
|
||||
@@ -567,6 +590,7 @@ impl Dialog {
|
||||
/// The main value (shown on the canvas too): label, kind and the expression.
|
||||
pub fn primary(&mut self) -> Option<(&'static str, ValueKind, &mut String)> {
|
||||
Some(match &mut self.kind {
|
||||
Kind::Extrude { to_offset, .. } if to_object_on(&self.inputs) => ("To Offset", ValueKind::Length, to_offset),
|
||||
Kind::Extrude { distance, .. } => ("Distance", ValueKind::Length, distance),
|
||||
Kind::Revolve { angle, .. } => ("Angle", ValueKind::Angle, angle),
|
||||
Kind::Fillet { radius, chamfer, .. } => (if *chamfer { "Distance" } else { "Radius" }, ValueKind::Length, radius),
|
||||
@@ -633,6 +657,9 @@ impl Dialog {
|
||||
|
||||
/// A pick in the viewport (already accepted by the active input).
|
||||
pub fn pick(&mut self, s: &Session, sel: Sel) {
|
||||
if self.active == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) {
|
||||
self.profile_selection_changed = true;
|
||||
}
|
||||
let measure = matches!(self.kind, Kind::Measure { .. });
|
||||
let sel = if measure { crate::viewport::measurement_selection(s, &sel) } else { sel };
|
||||
let chain = matches!(self.kind, Kind::Fillet { chain: true, .. });
|
||||
@@ -673,6 +700,9 @@ impl Dialog {
|
||||
|
||||
/// Box selection result: replace (or add to) the active input.
|
||||
pub fn take_box(&mut self, s: &Session, sels: Vec<Sel>, add: bool) {
|
||||
if self.active == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) {
|
||||
self.profile_selection_changed = true;
|
||||
}
|
||||
let measure = matches!(self.kind, Kind::Measure { .. });
|
||||
let Some(inp) = self.inputs.get_mut(self.active) else { return };
|
||||
if !add {
|
||||
@@ -1055,6 +1085,9 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
|
||||
&& let Some(inp) = d.inputs.get_mut(i)
|
||||
{
|
||||
inp.items.clear();
|
||||
if i == 0 {
|
||||
d.profile_selection_changed = true;
|
||||
}
|
||||
d.active = i;
|
||||
}
|
||||
}
|
||||
@@ -1284,17 +1317,22 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
input_rows(&mut d, ui);
|
||||
match &mut d.kind {
|
||||
Kind::Sketch => {}
|
||||
Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, start, all } => {
|
||||
Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, taper_present, to_offset, start, all } => {
|
||||
row_label(ui, "Start");
|
||||
let mut st = usize::from(!start.is_empty());
|
||||
combo(ui, "ex_start", &["Profile Plane", "Offset"], &mut st);
|
||||
if st == 1 && start.is_empty() {
|
||||
*start = "5 mm".into();
|
||||
} else if st == 0 {
|
||||
start.clear();
|
||||
let old = if object_on(&d.inputs, FROM_OBJECT) { 2 } else { usize::from(!start.is_empty()) };
|
||||
let mut st = old;
|
||||
combo(ui, "ex_start", &["Profile Plane", "Offset", "From Object"], &mut st);
|
||||
if st != old {
|
||||
set_object(&mut d.inputs, &mut d.active, FROM_OBJECT, st == 2);
|
||||
if st == 1 && start.is_empty() {
|
||||
*start = "5 mm".into();
|
||||
}
|
||||
if st == 0 {
|
||||
start.clear();
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
if !start.is_empty() {
|
||||
if st != 0 {
|
||||
row_label(ui, "Offset");
|
||||
enter |= field(ui, start);
|
||||
ui.end_row();
|
||||
@@ -1304,8 +1342,18 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let order = [0usize, TWO_SIDES, 2];
|
||||
let mut pos = order.iter().position(|o| o == direction).unwrap_or(0);
|
||||
combo(ui, "ex_dir", &["One Side", "Two Sides", "Symmetric"], &mut pos);
|
||||
*direction = order.get(pos).copied().unwrap_or(0);
|
||||
if pos != order.iter().position(|o| o == direction).unwrap_or(0) {
|
||||
*direction = order.get(pos).copied().unwrap_or(0);
|
||||
}
|
||||
ui.end_row();
|
||||
if *direction < 2 {
|
||||
row_label(ui, "Reverse");
|
||||
let mut reverse = *direction == 1;
|
||||
if ui.checkbox(&mut reverse, "Reverse Direction").changed() {
|
||||
*direction = usize::from(reverse);
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
row_label(ui, "Extent Type");
|
||||
let cur = if to_object_on(&d.inputs) { 1 } else { usize::from(*all) * 2 };
|
||||
let mut ext = cur;
|
||||
@@ -1323,9 +1371,18 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
if ext == 1 {
|
||||
row_label(ui, "To Offset");
|
||||
enter |= field(ui, to_offset);
|
||||
ui.end_row();
|
||||
}
|
||||
if *direction < 2 {
|
||||
row_label(ui, "Taper Angle");
|
||||
let before = taper.clone();
|
||||
enter |= field(ui, taper);
|
||||
if *taper != before {
|
||||
*taper_present = !taper.trim().is_empty();
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
row_label(ui, "Operation");
|
||||
@@ -1337,7 +1394,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Revolve { angle, operation } => {
|
||||
Kind::Revolve { angle, operation, .. } => {
|
||||
row_label(ui, "Type");
|
||||
let cur = usize::from(to_object_on(&d.inputs));
|
||||
let mut ty = cur;
|
||||
@@ -1541,6 +1598,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
combo(ui, "emb_mode", &["Emboss", "Deboss"], &mut i);
|
||||
*deboss = i == 1;
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
ui.add(
|
||||
egui::Label::new("Pick a cylinder wall to wrap the profiles around it. Leave Target Face empty for planar embossing.").wrap(),
|
||||
);
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Align { flip } => {
|
||||
// Where on the picked face or edge to snap: as picked (a face turns to meet
|
||||
@@ -1873,7 +1935,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
app.dialog = Some(d);
|
||||
} else if let Some((_, marker)) = d.editing {
|
||||
// Editing done: put the timeline marker back where it was.
|
||||
let _ = app.run("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({})));
|
||||
let _ = roll_marker(app, marker);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1886,12 +1948,30 @@ pub fn cancel(app: &mut SolveApp) {
|
||||
if let Some(d) = app.dialog.take()
|
||||
&& let Some((_, marker)) = d.editing
|
||||
{
|
||||
let _ = app.run("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({})));
|
||||
let _ = roll_marker(app, marker);
|
||||
}
|
||||
}
|
||||
|
||||
/// Temporary rollback used by an editor is view state. Preserve both history stacks,
|
||||
/// including redo and a history already at the undo limit.
|
||||
pub(crate) fn roll_marker(app: &mut SolveApp, marker: Option<usize>) -> Result<(), String> {
|
||||
let undo = std::mem::take(&mut app.session.undo);
|
||||
let redo = std::mem::take(&mut app.session.redo);
|
||||
let selection = std::mem::take(&mut app.session.selection);
|
||||
let result = app.run("timeline.roll_to", marker.map(|m| json!({"position":m})).unwrap_or_else(|| json!({})));
|
||||
app.session.undo = undo;
|
||||
app.session.redo = redo;
|
||||
app.session.selection = selection;
|
||||
result.map(|_| ())
|
||||
}
|
||||
|
||||
/// The pick input an extent of To Object adds (a face, vertex or point to go to).
|
||||
const TO_OBJECT: &str = "To Object";
|
||||
const FROM_OBJECT: &str = "From Object";
|
||||
|
||||
fn object_on(inputs: &[SelInput], label: &str) -> bool {
|
||||
inputs.iter().any(|i| i.label == label)
|
||||
}
|
||||
|
||||
fn to_object_on(inputs: &[SelInput]) -> bool {
|
||||
inputs.iter().any(|i| i.label == TO_OBJECT)
|
||||
@@ -1899,18 +1979,26 @@ fn to_object_on(inputs: &[SelInput]) -> bool {
|
||||
|
||||
/// Add or remove the To Object input; the active input moves to it when it is added.
|
||||
fn set_to_object(inputs: &mut Vec<SelInput>, active: &mut usize, on: bool) {
|
||||
if on && !to_object_on(inputs) {
|
||||
inputs.push(SelInput::new(TO_OBJECT, FACES | selection::VERTICES, false));
|
||||
set_object(inputs, active, TO_OBJECT, on);
|
||||
}
|
||||
|
||||
fn set_object(inputs: &mut Vec<SelInput>, active: &mut usize, label: &'static str, on: bool) {
|
||||
if on && !object_on(inputs, label) {
|
||||
inputs.push(SelInput::new(label, FACES | selection::VERTICES, false));
|
||||
*active = inputs.len() - 1;
|
||||
} else if !on && to_object_on(inputs) {
|
||||
inputs.retain(|i| i.label != TO_OBJECT);
|
||||
} else if !on && object_on(inputs, label) {
|
||||
inputs.retain(|i| i.label != label);
|
||||
*active = (*active).min(inputs.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
|
||||
/// The picked To Object place.
|
||||
fn to_object_point(inputs: &[SelInput]) -> Option<Value> {
|
||||
inputs.iter().find(|i| i.label == TO_OBJECT)?.items.first().and_then(|x| match x {
|
||||
object_point(inputs, TO_OBJECT)
|
||||
}
|
||||
|
||||
pub(crate) fn object_point(inputs: &[SelInput], label: &str) -> Option<Value> {
|
||||
inputs.iter().find(|i| i.label == label)?.items.first().and_then(|x| match x {
|
||||
Sel::Face { point, .. } | Sel::Vertex { point, .. } | Sel::Edge { point, .. } => Some(pt(*point)),
|
||||
_ => None,
|
||||
})
|
||||
@@ -1918,9 +2006,13 @@ fn to_object_point(inputs: &[SelInput]) -> Option<Value> {
|
||||
|
||||
/// A To Object input filled from a stored place (editing).
|
||||
fn with_to_object(d: &mut Dialog, s: &Session, to: Option<Vec3>) {
|
||||
with_object(d, s, TO_OBJECT, to);
|
||||
}
|
||||
|
||||
fn with_object(d: &mut Dialog, s: &Session, label: &'static str, to: Option<Vec3>) {
|
||||
if let Some(p) = to {
|
||||
set_to_object(&mut d.inputs, &mut d.active, true);
|
||||
if let Some(inp) = d.inputs.iter_mut().find(|i| i.label == TO_OBJECT) {
|
||||
set_object(&mut d.inputs, &mut d.active, label, true);
|
||||
if let Some(inp) = d.inputs.iter_mut().find(|i| i.label == label) {
|
||||
inp.items = vec![face_sel(s, p).unwrap_or(Sel::Vertex { body: String::new(), point: p })];
|
||||
}
|
||||
}
|
||||
@@ -2086,17 +2178,58 @@ fn sels(d: &Dialog, i: usize) -> &[Sel] {
|
||||
}
|
||||
|
||||
fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> {
|
||||
for (cmd, params) in apply_commands(app, d)? {
|
||||
app.run(&cmd, params)?;
|
||||
let commands = apply_commands(app, d)?;
|
||||
if let Some((_, marker)) = d.editing {
|
||||
// Record the actual pre-edit timeline state in the command's undo snapshot.
|
||||
roll_marker(app, marker)?;
|
||||
}
|
||||
for (cmd, params) in commands {
|
||||
if let Err(error) = app.run(&cmd, params) {
|
||||
if let Some((id, _)) = d.editing {
|
||||
let position = app.session.doc.feature_index(id);
|
||||
let _ = roll_marker(app, position);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Placement used to edit a definition in the selected authoring occurrence.
|
||||
pub(crate) fn feature_frame(s: &Session, component: u64) -> solvecraft_engine::doc::Mat {
|
||||
if component == s.active_component { s.active_component_frame() } else { s.placed_document().component_transform(component) }
|
||||
}
|
||||
|
||||
/// The commands OK runs (an edit rebuilds its feature in place); the live preview runs the same.
|
||||
pub fn apply_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, String> {
|
||||
let cmds = dialog_commands(app, d)?;
|
||||
if let Some((id, _)) = d.editing {
|
||||
let (cmd, params) = cmds.into_iter().next().ok_or("nothing to apply")?;
|
||||
let (cmd, mut params) = cmds.into_iter().next().ok_or("nothing to apply")?;
|
||||
if let Some(feature) = app.session.doc.feature(id) {
|
||||
let original_all = match &feature.kind {
|
||||
FeatureKind::Extrude { sketch, profiles: ProfileSel::All, .. }
|
||||
| FeatureKind::Revolve { sketch, profiles: ProfileSel::All, .. }
|
||||
| FeatureKind::Emboss { sketch, profiles: ProfileSel::All, .. } => Some(*sketch),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(sketch) = original_all
|
||||
&& !d.profile_selection_changed
|
||||
&& let Some(ss) = app.session.world_state().sketch(sketch)
|
||||
{
|
||||
let mut selected: Vec<usize> = sels(d, 0)
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Sel::Profile { sketch: id, index } if *id == sketch => Some(*index),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
selected.sort_unstable();
|
||||
selected.dedup();
|
||||
if selected == (0..ss.profiles.len()).collect::<Vec<_>>() && sels(d, 0).len() == selected.len() {
|
||||
params["profiles"] = json!("all");
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(vec![("timeline.redefine".into(), json!({"feature": id, "command": cmd, "params": params}))]);
|
||||
}
|
||||
Ok(cmds)
|
||||
@@ -2111,6 +2244,31 @@ pub fn preview_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value
|
||||
Ok(cmds)
|
||||
}
|
||||
|
||||
/// A wrapped Emboss edit keeps its profile guide in the saved author occurrence.
|
||||
/// An invalid saved context must not draw the guide at a different occurrence.
|
||||
pub(crate) fn emboss_profile_plane(app: &SolveApp, sketch: u64) -> Result<Option<solvecraft_engine::geom::Plane>, String> {
|
||||
let Some(dialog) = app.dialog.as_ref().filter(|d| matches!(d.kind, Kind::Emboss { .. })) else { return Ok(None) };
|
||||
let Some((feature, _)) = dialog.editing else { return Ok(None) };
|
||||
let Some(feature) = app.session.doc.feature(feature) else { return Err("the edited feature is missing".into()) };
|
||||
let FeatureKind::Emboss { sketch: source, face: Some(face), .. } = &feature.kind else { return Ok(None) };
|
||||
if *source != sketch {
|
||||
return Ok(None);
|
||||
}
|
||||
let model = app.session.model.state();
|
||||
let sketch = model.sketch(sketch).ok_or("the Emboss sketch is missing")?;
|
||||
let body = model.body(&face.body).ok_or("the Emboss target body is missing")?;
|
||||
let owner = app.session.doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0);
|
||||
let plane = sketch.plane;
|
||||
let frame = face.sketch_frame(&app.session.doc, feature.component, owner, body.feature).map_err(|error| error.to_string())?;
|
||||
solvecraft_engine::geom::Plane::new(
|
||||
solvecraft_engine::doc::apply_point(&frame, plane.origin),
|
||||
solvecraft_engine::doc::apply_vector(&frame, plane.x),
|
||||
solvecraft_engine::doc::apply_vector(&frame, plane.y),
|
||||
)
|
||||
.map(Some)
|
||||
.ok_or_else(|| "the Emboss author frame is degenerate".into())
|
||||
}
|
||||
|
||||
/// The commands (id, parameters) a dialog's OK runs.
|
||||
fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, String> {
|
||||
let s = &app.session;
|
||||
@@ -2126,13 +2284,13 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
sels(d, i).iter().filter_map(|x| if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None }).collect()
|
||||
};
|
||||
let (cmd, params): (&str, Value) = match &d.kind {
|
||||
Kind::Extrude { distance, direction, operation, distance2, taper, start, all, .. } => {
|
||||
Kind::Extrude { distance, direction, operation, distance2, taper, taper_present, to_offset, start, all, .. } => {
|
||||
need(0, "profiles or a planar face")?;
|
||||
let mut common = json!({"distance": distance, "direction": DIRS.get(*direction).copied().unwrap_or("positive"), "operation": OPS.get(*operation).copied().unwrap_or("new")});
|
||||
let tapered = s.doc.eval(taper, ValueKind::Angle).map(|v| v.abs() > 1e-12).unwrap_or(!taper.trim().is_empty());
|
||||
if *direction == TWO_SIDES {
|
||||
common["distance2"] = json!(distance2);
|
||||
} else if *direction < 2 && tapered {
|
||||
} else if *direction < 2 && (*taper_present || tapered) {
|
||||
common["taper"] = json!(taper);
|
||||
}
|
||||
if !start.trim().is_empty() {
|
||||
@@ -2143,6 +2301,12 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
}
|
||||
if to_object_on(&d.inputs) {
|
||||
common["to"] = to_object_point(&d.inputs).ok_or("select the object to extrude to")?;
|
||||
if !to_offset.trim().is_empty() {
|
||||
common["to_offset"] = json!(to_offset);
|
||||
}
|
||||
}
|
||||
if object_on(&d.inputs, FROM_OBJECT) {
|
||||
common["from"] = object_point(&d.inputs, FROM_OBJECT).ok_or("select the object to extrude from")?;
|
||||
}
|
||||
let with = |extra: Value| -> Value {
|
||||
let mut p = common.clone();
|
||||
@@ -2165,21 +2329,18 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
Kind::Revolve { angle, operation } => {
|
||||
Kind::Revolve { angle, operation, axis: stored_axis } => {
|
||||
need(0, "profiles")?;
|
||||
need(1, "an axis")?;
|
||||
let axis = match sels(d, 1).first() {
|
||||
Some(Sel::Axis { name }) => json!(name),
|
||||
Some(Sel::SketchCurve { id }) => json!(id),
|
||||
_ => return Err("select an axis".into()),
|
||||
_ => stored_axis.clone().ok_or("select an axis")?,
|
||||
};
|
||||
let (sketch, idx) = profiles();
|
||||
let mut p =
|
||||
json!({"sketch": sketch, "profiles": idx, "axis": axis, "angle": angle, "operation": OPS.get(*operation).copied().unwrap_or("new")});
|
||||
if to_object_on(&d.inputs) {
|
||||
p["to"] = to_object_point(&d.inputs).ok_or("select the object to turn to")?;
|
||||
// A To Object turn has no second side.
|
||||
return Ok(vec![("solid.revolve".into(), p)]);
|
||||
}
|
||||
("solid.revolve", p)
|
||||
}
|
||||
@@ -2361,7 +2522,12 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
Kind::Emboss { depth, deboss } => {
|
||||
need(0, "profiles")?;
|
||||
let (sketch, idx) = profiles();
|
||||
("solid.emboss", json!({"sketch": sketch, "profiles": idx, "depth": depth, "mode": if *deboss { "deboss" } else { "emboss" }}))
|
||||
let mut p = json!({"sketch": sketch, "profiles": idx, "depth": depth, "mode": if *deboss { "deboss" } else { "emboss" }});
|
||||
if let Some(Sel::Face { body, point, .. }) = sels(d, 1).first() {
|
||||
p["face"] = pt(*point);
|
||||
p["targets"] = json!([body]);
|
||||
}
|
||||
("solid.emboss", p)
|
||||
}
|
||||
Kind::ReplaceFace => {
|
||||
need(0, "faces to move")?;
|
||||
@@ -2783,6 +2949,18 @@ pub(crate) fn face_sel(s: &Session, p0: Vec3) -> Option<Sel> {
|
||||
best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p0.len()).map(|x| x.1)
|
||||
}
|
||||
|
||||
fn emboss_face_sel(s: &Session, component: u64, face: &solvecraft_engine::doc::EmbossFace, persistent: &str) -> Option<Sel> {
|
||||
let source = s.model.state();
|
||||
let body = source.body(&face.body)?;
|
||||
let index = solvecraft_engine::doc::naming::face_names(body).iter().position(|name| name == persistent)?;
|
||||
let owner = s.doc.body_component(&face.body, body.feature);
|
||||
let world_name = s.doc.world_body_name(&source, &face.body, owner, &face.path);
|
||||
let author = solvecraft_engine::doc::EmbossFace::component_frame(&s.doc, component, &face.author_path).ok()?;
|
||||
let point = solvecraft_engine::doc::apply_point(&author, face.at);
|
||||
s.world_state().body(&world_name)?;
|
||||
Some(Sel::Face { body: world_name, index, point })
|
||||
}
|
||||
|
||||
pub(crate) fn plane_sel(s: &Session, pl: &PlaneRef) -> Option<Sel> {
|
||||
match pl {
|
||||
PlaneRef::Origin { name } | PlaneRef::Construction { name } => Some(Sel::Plane { name: name.clone() }),
|
||||
@@ -2840,16 +3018,21 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
auto_op: false,
|
||||
distance2: extent.distance2.clone().unwrap_or_else(|| "10 mm".into()),
|
||||
taper: extent.taper.clone().unwrap_or_else(|| "0 deg".into()),
|
||||
taper_present: extent.taper.is_some(),
|
||||
to_offset: extent.to_offset.clone().unwrap_or_default(),
|
||||
start: extent.start_offset.clone().unwrap_or_default(),
|
||||
all: extent.through_all,
|
||||
};
|
||||
with_to_object(&mut d, s, extent.to);
|
||||
// The dialog has no "flip": a flipped extrude is a negative distance.
|
||||
if let Kind::Extrude { direction, distance, .. } = &mut d.kind
|
||||
&& *direction == 1
|
||||
{
|
||||
*direction = 0;
|
||||
*distance = format!("-({distance})");
|
||||
// Extent references belong to the extrude's authoring frame, including free
|
||||
// points. They are not body-local picks (a reference may be on another body).
|
||||
let frame = feature_frame(s, f.component);
|
||||
for (label, point) in [(TO_OBJECT, extent.to), (FROM_OBJECT, extent.from)] {
|
||||
if let Some(point) = point {
|
||||
set_object(&mut d.inputs, &mut d.active, label, true);
|
||||
if let Some(input) = d.inputs.iter_mut().find(|i| i.label == label) {
|
||||
input.items = vec![Sel::Vertex { body: String::new(), point: solvecraft_engine::doc::apply_point(&frame, point) }];
|
||||
}
|
||||
}
|
||||
}
|
||||
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
||||
if let Some(inp) = d.inputs.first_mut() {
|
||||
@@ -2862,20 +3045,34 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
}
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets, angle2, symmetric, to } => {
|
||||
let mut d = start("solid.revolve")?;
|
||||
d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation) };
|
||||
let frame = feature_frame(s, f.component);
|
||||
let stored_axis = match axis {
|
||||
AxisRef::Line { origin, dir } => Some(json!({
|
||||
"origin": pt(solvecraft_engine::doc::apply_point(&frame, *origin)),
|
||||
"dir": pt(solvecraft_engine::doc::apply_vector(&frame, *dir))
|
||||
})),
|
||||
_ => None,
|
||||
};
|
||||
d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation), axis: stored_axis };
|
||||
if let Some(a2) = angle2 {
|
||||
d.extra.insert("angle2".into(), json!(a2));
|
||||
}
|
||||
if *symmetric {
|
||||
d.extra.insert("direction".into(), json!("symmetric"));
|
||||
}
|
||||
with_to_object(&mut d, s, *to);
|
||||
if let Some(point) = to {
|
||||
set_to_object(&mut d.inputs, &mut d.active, true);
|
||||
if let Some(input) = d.inputs.iter_mut().find(|i| i.label == TO_OBJECT) {
|
||||
input.items = vec![Sel::Vertex { body: String::new(), point: solvecraft_engine::doc::apply_point(&frame, *point) }];
|
||||
}
|
||||
}
|
||||
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
||||
if let Some(inp) = d.inputs.get_mut(0) {
|
||||
inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
|
||||
}
|
||||
let ax = match axis {
|
||||
AxisRef::World { axis } => Some(Sel::Axis { name: axis.to_ascii_uppercase() }),
|
||||
AxisRef::SketchAxis { axis } => Some(Sel::Axis { name: axis.to_ascii_lowercase() }),
|
||||
AxisRef::SketchLine { curve } => Some(Sel::SketchCurve { id: curve.clone() }),
|
||||
_ => None,
|
||||
};
|
||||
@@ -3289,7 +3486,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Emboss { sketch, profiles, depth, deboss, targets } => {
|
||||
FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face } => {
|
||||
let mut d = start("solid.emboss")?;
|
||||
d.kind = Kind::Emboss { depth: depth.clone(), deboss: *deboss };
|
||||
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
||||
@@ -3299,6 +3496,11 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
if !targets.is_empty() {
|
||||
d.extra.insert("targets".into(), json!(targets));
|
||||
}
|
||||
if let Some(face) = face
|
||||
&& let Some(input) = d.inputs.get_mut(1)
|
||||
{
|
||||
input.items = vec![emboss_face_sel(s, f.component, face, f.face_names.first()?)?];
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Rib { curves, thickness, depth, flip, web, .. } => {
|
||||
|
||||
@@ -118,17 +118,20 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
let faces = app.session.selection.iter().any(|s| matches!(s, Sel::Face { .. }))
|
||||
&& !app.session.selection.iter().any(|s| matches!(s, Sel::Body { .. }));
|
||||
let state = app.session.world_state();
|
||||
let body_look = |body: &solvecraft_engine::doc::ModelBody| {
|
||||
app.session.doc.model_body_look(body).or_else(|| {
|
||||
(body.body.paint().is_some() || body.imported.is_some() || body.body.color().is_some()).then(|| {
|
||||
let color =
|
||||
app.session.doc.body_color(body).map(|rgb| rgb.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).unwrap_or([196, 199, 204]);
|
||||
solvecraft_engine::doc::appearance::Look::custom(color, app.session.doc.model_body_opacity(body))
|
||||
})
|
||||
})
|
||||
};
|
||||
let existing = app.session.selection.iter().find_map(|sel| match sel {
|
||||
Sel::Body { name } => state.body(name).and_then(|b| app.session.doc.model_body_look(b)),
|
||||
Sel::Face { body, index, .. } => state.body(body).and_then(|b| {
|
||||
app.session
|
||||
.doc
|
||||
.face_colors(b)
|
||||
.into_iter()
|
||||
.find(|(i, _)| i == index)
|
||||
.map(|(_, look)| look)
|
||||
.or_else(|| app.session.doc.model_body_look(b))
|
||||
}),
|
||||
Sel::Body { name } => state.body(name).and_then(body_look),
|
||||
Sel::Face { body, index, .. } => state
|
||||
.body(body)
|
||||
.and_then(|b| app.session.doc.face_colors(b).into_iter().find(|(i, _)| i == index).map(|(_, look)| look).or_else(|| body_look(b))),
|
||||
_ => None,
|
||||
});
|
||||
(
|
||||
@@ -341,7 +344,8 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
|
||||
ui.end_row();
|
||||
row_label(ui, "Opacity");
|
||||
ui.spacing_mut().slider_width = 110.0;
|
||||
if ui.add(egui::Slider::new(&mut k.opacity, 0.0..=1.0).fixed_decimals(2)).changed() {
|
||||
// Decimal formatting must not round a saved look merely by drawing its editor.
|
||||
if ui.add(egui::Slider::new(&mut k.opacity, 0.0..=1.0).max_decimals_opt(None).custom_formatter(|value, _| format!("{value:.2}"))).changed() {
|
||||
k.remove_override = false;
|
||||
}
|
||||
ui.end_row();
|
||||
@@ -590,6 +594,23 @@ mod tests {
|
||||
assert_eq!(a.session.doc.appearances.bodies.get("Body1"), a.session.doc.appearances.bodies.get("Body2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_frames_and_unchanged_apply_keep_precise_opacity() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.app.run("appearance.assign", json!({"bodies":["Body1"],"color":"#abcdef","opacity":0.123456})).unwrap();
|
||||
for cancel in [true, false] {
|
||||
h.app.session.selection = vec![Sel::Body { name: "Body1".into() }];
|
||||
h.app.start("appearance.assign");
|
||||
h.frames(3);
|
||||
let Kind::Appearance(appearance) = &h.app.dialog.as_ref().unwrap().kind else { panic!("appearance dialog") };
|
||||
assert_eq!(appearance.opacity, 0.123456);
|
||||
h.step(&json!({"key":if cancel {"Escape"} else {"Enter"}})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
|
||||
assert_eq!(h.app.session.doc.appearances.bodies["Body1"].opacity, 0.123456);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immediate_apply_then_ok_has_one_undo_and_retains_precise_opacity() {
|
||||
let mut a = app();
|
||||
|
||||
@@ -819,13 +819,11 @@ pub fn world_preview(s: &Session, cmds: &[(String, Value)], colors: Colors) -> O
|
||||
if cmds.is_empty() || !(moves || solvecraft_engine::frames::placed(s)) {
|
||||
return None;
|
||||
}
|
||||
let mut scratch = s.scratch();
|
||||
for (id, p) in cmds {
|
||||
if let Err(e) = scratch.execute(id, p) {
|
||||
return Some(Err(e.to_string()));
|
||||
}
|
||||
}
|
||||
let (before, after) = (s.world_state(), scratch.world_state());
|
||||
let preview = match s.preview_visible_world(cmds) {
|
||||
Ok(preview) => preview,
|
||||
Err(error) => return Some(Err(error.to_string())),
|
||||
};
|
||||
let (before, after) = (preview.before, preview.after);
|
||||
// A moved component looks like itself (no new surface), with its old place as the ghost.
|
||||
let colors = Colors { added: colors.body, ..colors };
|
||||
let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::preview::build(&before, &after, colors)));
|
||||
|
||||
@@ -631,10 +631,22 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
"suppress" => format!("Suppress: {n}"),
|
||||
_ => n.to_string(),
|
||||
};
|
||||
let label = v["targets"]
|
||||
.as_array()
|
||||
.and_then(|targets| targets.iter().find(|target| target["key"].as_str() == Some(c)))
|
||||
.and_then(|target| target["label"].as_str())
|
||||
.filter(|_| matches!(kind, "component_appearance" | "face_appearance"))
|
||||
.map(str::to_string)
|
||||
.unwrap_or(label);
|
||||
ui.add_sized(vec2(156.0, 22.0), egui::Label::new(RichText::new(label).strong()).truncate()).on_hover_text(c);
|
||||
if ui.small_button("×").on_hover_text("Remove the column").clicked() {
|
||||
let key = if kind == "suppress" { "feature" } else { kind };
|
||||
run(app, error, "config.column", json!({ key: n, "delete": true }));
|
||||
run(
|
||||
app,
|
||||
error,
|
||||
"config.column",
|
||||
json!({ key: if kind == "face_appearance" {c.as_str()} else {n}, "delete": true }),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -661,7 +673,8 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
for (c, val) in tb.columns.iter().zip(vals) {
|
||||
ui.allocate_ui_with_layout(vec2(190.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
let (kind, _) = column_parts(c);
|
||||
let shown = if kind == "appearance" {
|
||||
let appearance = matches!(kind, "appearance" | "component_appearance" | "face_appearance");
|
||||
let shown = if appearance {
|
||||
solvecraft_engine::doc::config::appearance_cell(val)
|
||||
.ok()
|
||||
.flatten()
|
||||
@@ -671,11 +684,20 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
} else {
|
||||
val.clone()
|
||||
};
|
||||
let style = matches!(kind, "material" | "appearance");
|
||||
let style = kind == "material" || appearance;
|
||||
if style {
|
||||
let mut choice = None;
|
||||
ui.menu_button("Library", |ui| {
|
||||
if ui.button(if kind == "appearance" { "Follow material" } else { "Default material" }).clicked() {
|
||||
if ui
|
||||
.button(if kind == "appearance" {
|
||||
"Follow material"
|
||||
} else if appearance {
|
||||
"Follow inherited appearance"
|
||||
} else {
|
||||
"Default material"
|
||||
})
|
||||
.clicked()
|
||||
{
|
||||
choice = Some("Default".to_string());
|
||||
ui.close();
|
||||
}
|
||||
@@ -757,6 +779,17 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
}
|
||||
}
|
||||
}
|
||||
for component in &app.session.doc.components {
|
||||
if component.id != 0 && !tb.columns.contains(&format!("component_appearance:{}", component.id)) {
|
||||
options.push((format!("{} (component appearance)", component.name), json!({"component_appearance":component.id})));
|
||||
}
|
||||
}
|
||||
for selected in &app.session.selection {
|
||||
if let solvecraft_engine::Sel::Face { body, point, .. } = selected {
|
||||
options
|
||||
.push((format!("Selected face on {body} (shared appearance)"), json!({"face_appearance":{"body":body,"point":point}})));
|
||||
}
|
||||
}
|
||||
let mut pick: Option<Value> = None;
|
||||
ui.menu_button("Add Column", |ui| {
|
||||
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
|
||||
@@ -766,6 +799,43 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.menu_button("Face Appearance", |ui| {
|
||||
ui.label("Semantic faces share their appearance across instances.");
|
||||
let state = app.session.model.state();
|
||||
for body in &state.bodies {
|
||||
if body.body.is_mesh() {
|
||||
continue;
|
||||
}
|
||||
ui.menu_button(format!("Faces on {}", body.name), |ui| {
|
||||
let names = solvecraft_engine::doc::naming::face_names(body);
|
||||
if let Ok(faces) = body.body.faces((body.body.size() * 2e-3).max(1e-3)) {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for face in faces {
|
||||
let Some(name) = names.get(face.index) else { continue };
|
||||
let name = solvecraft_engine::doc::naming::strip_piece(name);
|
||||
if !seen.insert(name.to_string()) {
|
||||
continue;
|
||||
}
|
||||
let Some((id, role)) = name.strip_prefix('F').and_then(|name| name.split_once(':')) else { continue };
|
||||
let Ok(origin_feature) = id.parse() else { continue };
|
||||
let target = solvecraft_engine::doc::config::ConfigFaceTarget {
|
||||
body: body.name.clone(),
|
||||
origin_feature,
|
||||
name: name.into(),
|
||||
point: face.centroid,
|
||||
};
|
||||
if app.session.doc.certify_config_face(&target).is_err() {
|
||||
continue;
|
||||
}
|
||||
if ui.button(role).clicked() {
|
||||
pick = Some(json!({"face_appearance":{"body":body.name,"point":face.centroid}}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(p) = pick {
|
||||
@@ -779,6 +849,10 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
json!({ "features": [n] })
|
||||
} else if let Some(n) = p.get("material") {
|
||||
json!({ "materials": [n] })
|
||||
} else if let Some(n) = p.get("component_appearance") {
|
||||
json!({ "component_appearances": [n] })
|
||||
} else if let Some(n) = p.get("face_appearance") {
|
||||
json!({ "face_appearances": [n] })
|
||||
} else {
|
||||
json!({ "appearances": [p.get("appearance")] })
|
||||
},
|
||||
@@ -791,6 +865,20 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
});
|
||||
ui.end_row();
|
||||
configuration_rules_ui::show(ui, app, error, rule_editor);
|
||||
if let Some(styles) = v["style_status"].as_array() {
|
||||
for style in styles.iter().filter(|style| style["dormant"].as_bool() == Some(true)) {
|
||||
ui.add(
|
||||
egui::Label::new(format!(
|
||||
"{}: appearance dormant while its feature is suppressed",
|
||||
style["target"].as_str().unwrap_or("Face")
|
||||
))
|
||||
.wrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(message) = v["style_error"].as_str() {
|
||||
ui.add(egui::Label::new(RichText::new(message).color(t.error)).wrap());
|
||||
}
|
||||
if let Some(e) = error {
|
||||
ui.label("");
|
||||
ui.label(RichText::new(e.as_str()).color(t.error));
|
||||
@@ -1004,6 +1092,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_component_and_face_pickers_start_fresh_and_clear_inherited() {
|
||||
for (component, width) in [(true, 1400), (false, 900)] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.resize", json!({"width":width,"height":720}));
|
||||
if component {
|
||||
h.call("engine.execute", json!({"command":"component.create","params":{"name":"Part"}}));
|
||||
}
|
||||
h.run(&json!([
|
||||
{"call":"engine.execute","params":{"command":"solid.box","params":{"length":10,"width":10,"height":10,"body_name":"Block"}}},
|
||||
{"start":"config.table"}, {"widget":"Add Column"}
|
||||
]))
|
||||
.unwrap();
|
||||
if component {
|
||||
h.step(&json!({"widget":"Part (component appearance)"})).unwrap();
|
||||
assert!(h.widget("Component: Part").is_some());
|
||||
} else {
|
||||
h.run(&json!([{"widget":"Face Appearance"},{"widget":"Faces on Block"},{"widget":"+z"}])).unwrap();
|
||||
assert!(h.widget("Face: Block / +z").is_some());
|
||||
}
|
||||
assert_eq!(h.app.session.doc.configs.rows.len(), 1);
|
||||
h.run(&json!([{"widget":"Library"},{"widget":"ABS - Red"},{"widget":"Apply"}])).unwrap();
|
||||
if component {
|
||||
assert_eq!(h.app.session.doc.appearances.components.values().next().unwrap().name, "ABS - Red");
|
||||
} else {
|
||||
assert_eq!(h.app.session.doc.appearances.faces[0].look.name, "ABS - Red");
|
||||
assert!(h.app.session.doc.appearances.faces[0].strict_name);
|
||||
}
|
||||
h.run(&json!([{"widget":"Library"},{"widget":"Follow inherited appearance"},{"widget":"Apply"}])).unwrap();
|
||||
assert!(h.app.session.doc.appearances.components.is_empty());
|
||||
assert!(h.app.session.doc.appearances.faces.is_empty());
|
||||
crate::scenario::check(&mut h, &json!({"windows_fit":{"max_width":560}})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_style_picker_applies_then_inherits_material() {
|
||||
let steps: Value = serde_json::from_str(include_str!("../tests/scenarios/s80_configuration_styles.json")).unwrap();
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Exercise the actual feature editor, including frames rendered before OK.
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::doc::{Extent, FeatureKind};
|
||||
|
||||
fn make(params: Value) -> Harness {
|
||||
make_in(Harness::new(), params)
|
||||
}
|
||||
fn make_in(mut h: Harness, params: Value) -> Harness {
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Base"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let mut p = json!({"sketch":"Base","distance":"10 mm","operation":"new","name":"Depth","body_name":"Main"});
|
||||
p.as_object_mut().unwrap().extend(params.as_object().unwrap().clone());
|
||||
h.app.run("solid.extrude", p).unwrap();
|
||||
h.frames(4);
|
||||
h
|
||||
}
|
||||
fn extent(h: &Harness) -> Extent {
|
||||
let FeatureKind::Extrude { extent, .. } = &h.app.session.doc.find_feature("Depth").unwrap().kind else { panic!("extrude") };
|
||||
extent.clone()
|
||||
}
|
||||
fn open(h: &mut Harness) {
|
||||
h.step(&json!({"call":"ui.editFeature","params":{"feature":"Depth"}})).unwrap();
|
||||
h.frames(8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_all_profiles_includes_profiles_added_after_edit() {
|
||||
let mut h = make(json!({}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
let FeatureKind::Extrude { profiles, .. } = &h.app.session.doc.find_feature("Depth").unwrap().kind else { panic!("extrude") };
|
||||
assert!(matches!(profiles, solvecraft_engine::doc::ProfileSel::All));
|
||||
h.app.run("sketch.edit", json!({"sketch":"Base"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[20,0],"p1":[25,5]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let state = h.app.session.world_state();
|
||||
let volume: f64 = state.bodies.iter().map(|b| solvecraft_engine::doc::kernel::measure(&b.body).unwrap().volume).sum();
|
||||
assert!((volume - 1250.0).abs() < 1e-7, "{volume}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_actual_editor_preserves_all_extents_and_parameter_identities() {
|
||||
for p in [
|
||||
json!({"direction":"negative","distance":"10"}),
|
||||
json!({"direction":"negative","distance":"-10 mm"}),
|
||||
json!({"direction":"symmetric","distance":"5 mm"}),
|
||||
json!({"distance":"10 mm","distance2":"3 mm"}),
|
||||
json!({"from":[0,0,5],"start_offset":"0 mm"}),
|
||||
json!({"to":[0,0,10],"to_offset":"3 mm"}),
|
||||
json!({"to":[0,0,-10],"to_offset":"-2 mm"}),
|
||||
json!({"taper":"0 deg"}),
|
||||
] {
|
||||
let mut h = make(p.clone());
|
||||
let original = extent(&h);
|
||||
let names = h.app.session.doc.find_feature("Depth").unwrap().param_names.clone();
|
||||
let before = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{p}: {:?}", h.app.session.log);
|
||||
assert_eq!(extent(&h), original, "{p}");
|
||||
assert_eq!(h.app.session.doc.find_feature("Depth").unwrap().param_names, names, "{p}");
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), before, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_edit_keeps_downstream_distance_reference_positive() {
|
||||
let mut h = make(json!({"direction":"negative","distance":"10"}));
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":"d1","corner":[30,0,0],"body_name":"Dependent"})).unwrap();
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(h.app.session.doc.eval("d1", solvecraft_engine::doc::expr::Kind::Length).unwrap(), 10.0);
|
||||
assert_eq!(h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_after_canvas_edit_preserves_document_and_undo() {
|
||||
let mut h = make(json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"}));
|
||||
let original = extent(&h);
|
||||
let undo = h.app.session.undo.len();
|
||||
open(&mut h);
|
||||
assert_eq!(crate::canvas::anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().0.z, 5.0);
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"4 mm"})).unwrap();
|
||||
h.frames(8);
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert_eq!(extent(&h), original);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_offset_canvas_edit_changes_offset_and_one_undo_restores_extent() {
|
||||
let mut h = make(json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"}));
|
||||
let original = extent(&h);
|
||||
open(&mut h);
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"4 mm"})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(extent(&h).to_offset.as_deref(), Some("4 mm"));
|
||||
assert_eq!(extent(&h).distance, original.distance);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 19.0);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(extent(&h), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_owner_references_survive_actual_edit_with_another_component_active() {
|
||||
let mut h = Harness::new();
|
||||
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
|
||||
let mut h = make_in(h, json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"}));
|
||||
h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20]})).unwrap();
|
||||
let other = h.app.run("component.create", json!({"name":"Other","parent":0})).unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":other["occurrence"],"translate":[200,0,40]})).unwrap();
|
||||
let original = extent(&h);
|
||||
let active = h.app.session.active_component;
|
||||
open(&mut h);
|
||||
let base = crate::canvas::anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().0;
|
||||
assert_eq!(base.z, 25.0);
|
||||
assert!(base.x >= 100.0 && base.x <= 110.0);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(extent(&h), original);
|
||||
assert_eq!(h.app.session.active_component, active);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 37.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragged_to_object_handle_edits_offset_not_dormant_distance() {
|
||||
let mut h = make(json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"}));
|
||||
open(&mut h);
|
||||
let original = extent(&h);
|
||||
h.step(&json!({"drag":[{"handle":"arrow"},{"world":[5,5,21]}],"ctrl":true})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
|
||||
assert_eq!(extent(&h).distance, original.distance);
|
||||
let state = h.app.session.world_state();
|
||||
let z = state.body("Main").unwrap().mesh().bounds().max.z;
|
||||
assert!((z - 21.0).abs() < 0.1, "dragged end {z}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn referenced_offset_recomputes_after_edit_and_save_reopen() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("parameters.add", json!({"name":"clearance","expression":"2 mm"})).unwrap();
|
||||
let mut h = make_in(h, json!({"to":[0,0,15],"to_offset":"clearance"}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(extent(&h).to_offset.as_deref(), Some("clearance"));
|
||||
h.app.run("parameters.change", json!({"name":"clearance","expression":"6 mm"})).unwrap();
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 21.0);
|
||||
let path = h.dir.join("extent.solvecraft");
|
||||
h.app.run("file.save", json!({"path":path})).unwrap();
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
assert_eq!(extent(&h).to_offset.as_deref(), Some("clearance"));
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 21.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_object_start_can_be_explicitly_changed_to_profile_plane() {
|
||||
let mut h = make(json!({"from":[0,0,5]}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"click":{"handle":"dialog:Start"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Start:Profile Plane"}})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(extent(&h).from.is_none());
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().min.z, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_reverse_keeps_original_expression_and_parameter_sign() {
|
||||
let mut h = make(json!({"distance":"10"}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"Reverse Direction"})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(extent(&h).distance, "10");
|
||||
assert_eq!(extent(&h).direction, solvecraft_engine::doc::Direction::Negative);
|
||||
assert_eq!(h.app.session.doc.eval("d1", solvecraft_engine::doc::expr::Kind::Length).unwrap(), 10.0);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().min.z, -10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_edit_and_cancel_keep_full_history_and_existing_redo() {
|
||||
let mut h = make(json!({}));
|
||||
for i in 0..205 {
|
||||
h.app.run("timeline.rename", json!({"feature":1,"name":format!("Base{i}")})).unwrap();
|
||||
}
|
||||
h.app.run("solid.box", json!({"length":2,"width":2,"height":2,"corner":[30,0,0],"body_name":"RedoBody"})).unwrap();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
let undo = h.app.session.undo.len();
|
||||
let redo = h.app.session.redo.len();
|
||||
assert_eq!(undo, 199);
|
||||
assert_eq!(redo, 1);
|
||||
h.app.session.selection = vec![solvecraft_engine::Sel::Body { name: "Main".into() }];
|
||||
let selection = h.app.session.selection.clone();
|
||||
let original = serde_json::to_value(&*h.app.session.doc).unwrap();
|
||||
open(&mut h);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.session.redo.len(), redo);
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"missing_extent"})).unwrap();
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(h.app.dialog.is_some(), "failed edit must remain editable");
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&*h.app.session.doc).unwrap(), original);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.session.redo.len(), redo);
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
h.app.run("edit.redo", json!({})).unwrap();
|
||||
assert!(h.app.session.world_state().body("RedoBody").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tapered_from_and_signed_to_have_correct_start_volume_and_preview_parity() {
|
||||
for (params, lo, hi) in [
|
||||
(json!({"from":[0,0,5],"distance":"10 mm","taper":"atan(0.1)"}), 5.0, 15.0),
|
||||
(json!({"from":[0,0,5],"to":[0,0,15],"direction":"negative","taper":"atan(0.1)"}), 5.0, 15.0),
|
||||
(json!({"from":[0,0,5],"to":[0,0,-5],"direction":"negative","taper":"atan(0.1)"}), -5.0, 5.0),
|
||||
(json!({"from":[0,0,5],"to":[0,0,-5],"direction":"positive","taper":"atan(0.1)"}), -5.0, 5.0),
|
||||
] {
|
||||
let mut h = make(params.clone());
|
||||
let state = h.app.session.world_state();
|
||||
let body = state.body("Main").unwrap();
|
||||
let bounds = body.mesh().bounds();
|
||||
assert!((bounds.min.z - lo).abs() < 1e-8 && (bounds.max.z - hi).abs() < 1e-8, "{params}: {bounds:?}");
|
||||
let expected = 10.0 * (100.0 + 120.0 + 144.0) / 3.0;
|
||||
let volume = solvecraft_engine::doc::kernel::measure(&body.body).unwrap().volume;
|
||||
assert!((volume - expected).abs() < 1e-5, "{params}: volume{volume} expected{expected}");
|
||||
let original = extent(&h);
|
||||
open(&mut h);
|
||||
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let preview = h.app.session.preview(&commands).unwrap();
|
||||
assert_eq!(preview.after.body("Main").unwrap().mesh().bounds(), bounds);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(extent(&h), original);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), bounds);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_profiles_from_slanted_face_use_same_merged_centre_as_geometry() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length":30,"width":30,"height":2,"corner":[-10,-10,10],"body_name":"Reference"})).unwrap();
|
||||
h.app.run("solid.move", json!({"bodies":["Reference"],"axis":[0,1,0],"angle":"atan(0.1)","origin":[0,0,10]})).unwrap();
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Adjacent"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[2,10]})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[2,0],"p1":[10,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let angle = 0.1f64.atan();
|
||||
let point = [2.0 * angle.sin(), 0.0, 10.0 + 2.0 * angle.cos()];
|
||||
h.app.run("solid.extrude", json!({"sketch":"Adjacent","distance":10,"from":point,"name":"Depth","body_name":"Main","operation":"new"})).unwrap();
|
||||
let min_z = h.app.session.world_state().body("Main").unwrap().mesh().bounds().min.z;
|
||||
open(&mut h);
|
||||
let base = crate::canvas::anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().0;
|
||||
assert!((base.z - min_z).abs() < 1e-7, "displayed start{} geometry start{min_z}", base.z);
|
||||
// Averaging the two unmerged rectangle loops gives x=3.5; their internal
|
||||
// seam must not bias the reference ray used by the extent handle.
|
||||
let unmerged_height = 10.0 + 2.0 / angle.cos() - 3.5 * angle.tan();
|
||||
assert!((base.z - unmerged_height).abs() > 0.01, "fixture must distinguish the raw-profile centre");
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!((h.app.session.world_state().body("Main").unwrap().mesh().bounds().min.z - min_z).abs() < 1e-7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_second_occurrence_keeps_saved_from_and_to_points_exact() {
|
||||
let mut h = Harness::new();
|
||||
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
|
||||
let mut h = make_in(h, json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"}));
|
||||
let second = h.app.run("occurrence.copy", json!({"component":owner["component"],"translate":[100,0,20]})).unwrap()["occurrence"].clone();
|
||||
h.app.run("occurrence.move", json!({"occurrence":second,"axis":[1,0,0],"angle":"17 deg"})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":owner["component"],"occurrence":second,"path":[second]})).unwrap();
|
||||
let original = extent(&h);
|
||||
let before: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect();
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(extent(&h), original);
|
||||
let after: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect();
|
||||
assert_eq!(after, before);
|
||||
}
|
||||
@@ -54,6 +54,8 @@ mod status_tests {
|
||||
assert!(app.session.log.last().is_some_and(|l| l.starts_with("Redo: ") && !l.contains("edit.redo")), "{:?}", app.session.log.last());
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod extrude_edit_tests;
|
||||
mod file_load;
|
||||
pub mod palette;
|
||||
pub mod params_dialog;
|
||||
@@ -63,6 +65,8 @@ mod preselect_tests;
|
||||
pub mod preview;
|
||||
pub mod recovery_ui;
|
||||
pub mod ref_images;
|
||||
#[cfg(test)]
|
||||
mod revolve_edit_tests;
|
||||
pub mod scenario;
|
||||
pub mod selection;
|
||||
pub mod shortcut_box;
|
||||
@@ -548,13 +552,13 @@ impl SolveApp {
|
||||
let Some(idx) = self.session.doc.feature_index(id) else { return };
|
||||
let marker = self.session.doc.marker;
|
||||
self.tool = None;
|
||||
if self.run("timeline.roll_to", json!({ "position": idx })).is_err() {
|
||||
if dialogs::roll_marker(self, Some(idx)).is_err() {
|
||||
return;
|
||||
}
|
||||
match dialogs::for_feature(self, id, marker) {
|
||||
Some(d) => self.dialog = Some(d),
|
||||
None => {
|
||||
let _ = self.run("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({})));
|
||||
let _ = dialogs::roll_marker(self, marker);
|
||||
self.palette.text = format!("timeline.edit {{\"feature\": {id}, \"set\": {{}}}}");
|
||||
self.ui.palette_open = true;
|
||||
}
|
||||
|
||||
@@ -8,12 +8,187 @@ use solvecraft_engine::{Sel, Session};
|
||||
use crate::dialogs::{Dialog, Kind, apply_commands};
|
||||
use crate::{Services, SolveApp};
|
||||
|
||||
#[test]
|
||||
fn cylinder_emboss_target_face_and_edit_keep_the_anchored_operation() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap();
|
||||
let sketch = h.app.run("sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})).unwrap()["sketch"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.session.selection = vec![Sel::Profile { sketch, index: 0 }];
|
||||
h.app.start("solid.emboss");
|
||||
h.frames(2);
|
||||
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 1);
|
||||
let planar = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert!(planar[0].1.get("face").is_none());
|
||||
h.call("ui.dialogInput", json!({"index":1}));
|
||||
h.call("ui.view", json!({"view":"back"}));
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"world":[0,10,7.5]}})).unwrap();
|
||||
let dialog = h.app.dialog.as_ref().unwrap();
|
||||
assert!(matches!(dialog.inputs[1].items.first(), Some(Sel::Face { body, .. }) if body == "Sleeve"));
|
||||
let commands = apply_commands(&h.app, dialog).unwrap();
|
||||
assert_eq!(commands[0].1["targets"], json!(["Sleeve"]));
|
||||
assert!(commands[0].1.get("face").is_some());
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
|
||||
let feature = h.app.session.doc.features.last().unwrap().id;
|
||||
let before = serde_json::to_value(&h.app.session.doc.feature(feature).unwrap().kind).unwrap();
|
||||
h.app.edit_feature(feature);
|
||||
h.frames(2);
|
||||
let edited = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(edited[0].0, "timeline.redefine");
|
||||
assert_eq!(edited[0].1["params"]["targets"], json!(["Sleeve"]));
|
||||
assert!(edited[0].1["params"].get("face").is_some());
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.feature(feature).unwrap().kind).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_emboss_edit_retains_second_source_and_author_paths_and_all_profiles() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let source = h.app.run("component.create", json!({"name":"Source"})).unwrap()["component"].as_u64().unwrap();
|
||||
h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":"Source:1","axis":[0,0,1],"angle":"90 deg","translate":[50,0,0]})).unwrap();
|
||||
let source_second = h.app.run("occurrence.copy", json!({"component":source,"translate":[80,0,0]})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.app.run("component.activate", json!({"component":"root"})).unwrap();
|
||||
let author = h.app.run("component.create", json!({"name":"Author"})).unwrap()["component"].as_u64().unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":"Author:1","axis":[1,0,0],"angle":"90 deg","translate":[-100,40,30]})).unwrap();
|
||||
let author_second = h.app.run("occurrence.copy", json!({"component":author,"translate":[0,100,0]})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.app.run("component.activate", json!({"component":author,"occurrence":author_second})).unwrap();
|
||||
h.app.run("sketch.create", json!({"plane":{"origin":[120,0,0],"x_dir":[0,-1,0],"y_dir":[0,0,1]},"name":"Badge"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let target = h.app.session.occurrence_bodies(&[source_second], false).unwrap().into_iter().next().unwrap();
|
||||
let feature = h.app.run("solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[120,0,7.5],"depth":"1mm","targets":[target]})).unwrap()
|
||||
["feature"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let before = h.app.session.doc.feature(feature).unwrap().clone();
|
||||
let solvecraft_engine::doc::FeatureKind::Emboss { face: Some(face), .. } = &before.kind else { panic!("selected face") };
|
||||
assert_eq!(face.path, vec![source_second]);
|
||||
assert_eq!(face.author_path, vec![author_second]);
|
||||
h.app.run("component.activate", json!({"component":"root"})).unwrap();
|
||||
h.app.edit_feature(feature);
|
||||
h.frames(2);
|
||||
let dialog = h.app.dialog.as_ref().unwrap();
|
||||
let Sel::Face { body, point, .. } = &dialog.inputs[1].items[0] else { panic!("refilled face") };
|
||||
assert_eq!(body, &target);
|
||||
assert!(point.dist(Vec3::new(120.0, 0.0, 7.5)) < 1e-8);
|
||||
let commands = apply_commands(&h.app, dialog).unwrap();
|
||||
assert_eq!(commands[0].1["params"]["profiles"], json!("all"));
|
||||
let Sel::Profile { sketch, index } = dialog.inputs[0].items[0] else { panic!("refilled profile") };
|
||||
let plane = crate::dialogs::emboss_profile_plane(&h.app, sketch).unwrap().unwrap();
|
||||
assert!(plane.origin.dist(Vec3::new(120.0, 0.0, 0.0)) < 1e-8, "{plane:?}");
|
||||
let mut guide = crate::gpu::GpuScene::default();
|
||||
crate::viewport::profile_fill(&mut guide, &h.app, sketch, index, egui::Color32::BLUE, egui::Color32::BLUE);
|
||||
let (vertices, remainder) = guide.trans.as_chunks::<{ crate::gpu::TRI_SIZE }>();
|
||||
assert!(remainder.is_empty() && !vertices.is_empty());
|
||||
let lift = h.app.cam.back() * (h.app.cam.half_height() * 0.002);
|
||||
for vertex in vertices {
|
||||
let coordinate = |offset| f32::from_le_bytes(vertex[offset..offset + 4].try_into().unwrap()) as f64;
|
||||
let point = Vec3::new(coordinate(0), coordinate(4), coordinate(8)) - lift;
|
||||
assert!((point.x - 120.0).abs() < 1e-4 && point.y.abs() < 2.01 && (4.99..=10.01).contains(&point.z), "{point:?}");
|
||||
}
|
||||
let preview = h.app.session.preview_world(&commands).unwrap();
|
||||
let volume = preview.after.body(&target).unwrap().mesh().measure().volume;
|
||||
assert!(volume > preview.before.body(&target).unwrap().mesh().measure().volume + 10.0);
|
||||
let colors = crate::preview::Colors::from_tokens(&crate::theme::Tokens::get());
|
||||
let built = crate::preview::compute(&h.app.session, &commands, colors).unwrap();
|
||||
assert!(built.replaced.contains(&target) && !built.scene.tris.is_empty(), "{:?}", built.replaced);
|
||||
let source_first = h.app.session.doc.occurrence_of(source).unwrap().id;
|
||||
h.app.run("browser.visibility", json!({"paths":[[source_first]],"visible":false})).unwrap();
|
||||
let hidden = crate::preview::compute(&h.app.session, &commands, colors).unwrap();
|
||||
assert_eq!(hidden.replaced, vec![target.clone()]);
|
||||
let visible = h.app.session.preview_visible_world(&commands).unwrap();
|
||||
assert!(visible.before.body("Sleeve").is_none() && visible.after.body("Sleeve").is_none());
|
||||
assert!(visible.after.body(&target).is_some() && !hidden.scene.tris.is_empty());
|
||||
let mut shared = h.app.session.scratch();
|
||||
shared.execute("component.activate", &json!({"component":source,"occurrence":source_first})).unwrap();
|
||||
let new_body = shared
|
||||
.preview_visible_world(&[("solid.box".into(), json!({"body_name":"NewPreview","length":2,"width":2,"height":2,"operation":"new"}))])
|
||||
.unwrap();
|
||||
let added = new_body.after.bodies.iter().filter(|body| new_body.before.body(&body.name).is_none()).collect::<Vec<_>>();
|
||||
assert_eq!(added.len(), 1, "{:?}", added.iter().map(|body| &body.name).collect::<Vec<_>>());
|
||||
assert!(added[0].name.contains("Source:2"), "{}", added[0].name);
|
||||
let doc = h.app.session.doc.to_json();
|
||||
let history = (h.app.session.undo.len(), h.app.session.redo.len());
|
||||
let selection = h.app.session.selection.clone();
|
||||
let Kind::Emboss { depth, .. } = &mut h.app.dialog.as_mut().unwrap().kind else { panic!("Emboss editor") };
|
||||
*depth = "2 mm".into();
|
||||
let changed = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let changed_preview = h.app.session.preview_world(&changed).unwrap();
|
||||
assert!(changed_preview.after.body(&target).unwrap().mesh().measure().volume > volume + 10.0);
|
||||
let mut invalid = changed.clone();
|
||||
invalid[0].1["params"]["depth"] = json!("unknown_depth");
|
||||
assert!(h.app.session.preview_world(&invalid).is_err());
|
||||
assert_eq!(h.app.session.doc.to_json(), doc);
|
||||
assert_eq!((h.app.session.undo.len(), h.app.session.redo.len()), history);
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.dialog.is_none());
|
||||
assert_eq!(h.app.session.doc.feature(feature).unwrap().kind, before.kind);
|
||||
h.app.edit_feature(feature);
|
||||
h.frames(2);
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
|
||||
let after = h.app.session.doc.feature(feature).unwrap();
|
||||
assert_eq!(after.kind, before.kind);
|
||||
assert_eq!(after.face_names, before.face_names);
|
||||
assert_eq!(h.app.session.active_component, 0);
|
||||
}
|
||||
|
||||
fn app_with(script: serde_json::Value) -> SolveApp {
|
||||
let mut s = Session::default();
|
||||
s.run_script(&script).unwrap();
|
||||
SolveApp::new(s, Services::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_emboss_profile_guide_uses_target_or_unique_foreign_sketch_frame() {
|
||||
for target_owned in [true, false] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let source = if target_owned { h.app.run("component.create", json!({"name":"Source"})).unwrap()["component"].as_u64().unwrap() } else { 0 };
|
||||
h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap();
|
||||
let (profile_owner, expected, target, face) = if target_owned {
|
||||
h.app.run("occurrence.move", json!({"occurrence":"Source:1","axis":[0,0,1],"angle":"90 deg","translate":[50,0,0]})).unwrap();
|
||||
let second = h.app.run("occurrence.copy", json!({"component":source,"translate":[80,0,0]})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
let target = h.app.session.occurrence_bodies(&[second], false).unwrap().remove(0);
|
||||
(source, Vec3::new(120.0, 0.0, 0.0), target, json!([120, 0, 7.5]))
|
||||
} else {
|
||||
let profile = h.app.run("component.create", json!({"name":"Profile"})).unwrap()["component"].as_u64().unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":"Profile:1","axis":[0,0,1],"angle":"90 deg","translate":[50,20,0]})).unwrap();
|
||||
(profile, Vec3::new(0.0, 10.0, 0.0), "Sleeve".to_string(), json!([0, 10, 7.5]))
|
||||
};
|
||||
let plane = if target_owned {
|
||||
json!({"origin":[40,0,0],"x_dir":[0,-1,0],"y_dir":[0,0,1]})
|
||||
} else {
|
||||
json!({"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]})
|
||||
};
|
||||
let sketch = h.app.run("sketch.create", json!({"plane":plane,"name":"Badge"})).unwrap()["sketch"].as_u64().unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":"root"})).unwrap();
|
||||
let feature =
|
||||
h.app.run("solid.emboss", json!({"sketch":sketch,"profiles":"all","face":face,"depth":"1mm","targets":[target]})).unwrap()["feature"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
h.app.edit_feature(feature);
|
||||
h.frames(2);
|
||||
assert!(crate::dialogs::emboss_profile_plane(&h.app, sketch).unwrap().unwrap().origin.dist(expected) < 1e-8);
|
||||
if !target_owned {
|
||||
h.app.run("occurrence.copy", json!({"component":profile_owner,"translate":[100,0,0]})).unwrap();
|
||||
assert!(crate::dialogs::emboss_profile_plane(&h.app, sketch).unwrap_err().contains("ambiguous"));
|
||||
let mut guide = crate::gpu::GpuScene::default();
|
||||
crate::viewport::profile_fill(&mut guide, &h.app, sketch, 0, egui::Color32::BLUE, egui::Color32::BLUE);
|
||||
assert!(guide.trans.is_empty() && guide.lines.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn boxed() -> SolveApp {
|
||||
app_with(json!([{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}}]))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::doc::{AxisRef, FeatureKind};
|
||||
|
||||
fn make(h: Harness, axis: Value) -> Harness {
|
||||
make_params(h, axis, json!({}))
|
||||
}
|
||||
fn make_params(mut h: Harness, axis: Value, extra: Value) -> Harness {
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Profile"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[10,0],"p1":[15,5]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let mut params = json!({"sketch":"Profile","axis":axis,"angle":"90 deg","operation":"new","name":"Turn","body_name":"Main"});
|
||||
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
h.app.run("solid.revolve", params).unwrap();
|
||||
h.frames(4);
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_all_profiles_retains_future_geometry_but_reselection_is_explicit() {
|
||||
for reselect in [false, true] {
|
||||
let mut h = make(Harness::new(), json!("Y"));
|
||||
open(&mut h);
|
||||
if reselect {
|
||||
let selection = h.app.dialog.as_ref().unwrap().inputs[0].items[0].clone();
|
||||
let mut dialog = h.app.dialog.take().unwrap();
|
||||
dialog.active = 0;
|
||||
dialog.pick(&h.app.session, selection.clone());
|
||||
dialog.pick(&h.app.session, selection);
|
||||
h.app.dialog = Some(dialog);
|
||||
h.frames(4);
|
||||
}
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
let FeatureKind::Revolve { profiles, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(matches!(profiles, solvecraft_engine::doc::ProfileSel::All), !reselect);
|
||||
h.app.run("sketch.edit", json!({"sketch":"Profile"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[20,0],"p1":[25,5]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let state = h.app.session.world_state();
|
||||
let volume: f64 = state.bodies.iter().map(|b| solvecraft_engine::doc::kernel::measure(&b.body).unwrap().volume).sum();
|
||||
let expected = if reselect { 490.873852 } else { 1374.446786 };
|
||||
assert!((volume - expected).abs() < 0.3, "{volume} != {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_object_noop_preserves_cut_targets_and_untargeted_body() {
|
||||
let mut h = Harness::new();
|
||||
for name in ["A", "B"] {
|
||||
h.app.run("solid.box", json!({"length":20,"width":5,"height":20,"corner":[0,0,0],"body_name":name})).unwrap();
|
||||
}
|
||||
let mut h = make_params(h, json!("Y"), json!({"to":[0,2,12],"angle":"45 deg","operation":"cut","targets":["A"]}));
|
||||
let state = h.app.session.world_state();
|
||||
let before_a = solvecraft_engine::doc::kernel::measure(&state.body("A").unwrap().body).unwrap().volume;
|
||||
assert!((before_a - (2000.0 - 490.873852)).abs() < 0.2);
|
||||
let undo = h.app.session.undo.len();
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none());
|
||||
let state = h.app.session.world_state();
|
||||
assert!((solvecraft_engine::doc::kernel::measure(&state.body("B").unwrap().body).unwrap().volume - 2000.0).abs() < 1e-7);
|
||||
assert!((solvecraft_engine::doc::kernel::measure(&state.body("A").unwrap().body).unwrap().volume - before_a).abs() < 1e-7);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let FeatureKind::Revolve { targets, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(targets, &["A"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_object_noop_retains_dormant_secondary_angle_and_downstream_dimension() {
|
||||
let mut h = make_params(Harness::new(), json!("Y"), json!({"to":[0,2,12],"angle":"45 deg","angle2":"30 deg","direction":"symmetric"}));
|
||||
h.app.run("solid.box", json!({"length":2,"width":2,"height":"(d2 / (1 rad)) * 1 mm","corner":[30,0,0],"body_name":"Dependent"})).unwrap();
|
||||
let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
|
||||
let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone();
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
|
||||
assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names);
|
||||
assert!((h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z - std::f64::consts::PI / 6.0).abs() < 1e-8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_owner_to_point_remains_exact_on_noop_edit() {
|
||||
let mut h = Harness::new();
|
||||
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
|
||||
let mut h = make_params(h, json!({"origin":[0,0,0],"dir":[0,2,0]}), json!({"to":[0,2,12],"angle":"45 deg"}));
|
||||
h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20],"axis":[0,0,1],"angle":"17 deg"})).unwrap();
|
||||
h.app.run("component.create", json!({"name":"Other","parent":0})).unwrap();
|
||||
let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
|
||||
let bounds = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), bounds);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_active_occurrence_noop_preserves_definition_axis_target_and_instances() {
|
||||
let mut h = Harness::new();
|
||||
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
|
||||
let mut h = make_params(h, json!({"origin":[0,0,0],"dir":[0,2,0]}), json!({"to":[0,2,12],"angle":"45 deg"}));
|
||||
let second = h.app.run("occurrence.copy", json!({"component":owner["component"],"translate":[100,0,20]})).unwrap()["occurrence"].clone();
|
||||
h.app.run("occurrence.move", json!({"occurrence":second,"axis":[0,0,1],"angle":"17 deg"})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":owner["component"],"occurrence":second,"path":[second]})).unwrap();
|
||||
let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
|
||||
let frame = h.app.session.active_component_frame();
|
||||
let expected_origin = solvecraft_engine::doc::apply_point(&frame, solvecraft_engine::geom::Vec3::ZERO);
|
||||
let before: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect();
|
||||
open(&mut h);
|
||||
let (origin, _) = crate::canvas::revolve_axis(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert!((origin - expected_origin).len() < 1e-8);
|
||||
assert!(origin.len() > 50.0, "fixture must distinguish first and second placements");
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
|
||||
let after: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect();
|
||||
assert_eq!(after, before);
|
||||
assert_eq!(h.app.session.active_occurrence_path.as_ref(), Some(&vec![second.as_u64().unwrap()]));
|
||||
}
|
||||
fn axis(h: &Harness) -> Value {
|
||||
let FeatureKind::Revolve { axis, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
serde_json::to_value(axis).unwrap()
|
||||
}
|
||||
fn open(h: &mut Harness) {
|
||||
h.step(&json!({"call":"ui.editFeature","params":{"feature":"Turn"}})).unwrap();
|
||||
h.frames(8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_noop_editor_retains_all_revolve_axis_kinds_and_analytic_volume() {
|
||||
for axis_value in [json!("Y"), json!("y"), json!("l4"), json!({"origin":[0,0,0],"dir":[0,2,0]})] {
|
||||
let mut h = make(Harness::new(), axis_value.clone());
|
||||
let original = axis(&h);
|
||||
let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone();
|
||||
let state = h.app.session.world_state();
|
||||
let body = state.body("Main").unwrap();
|
||||
let bounds = body.mesh().bounds();
|
||||
let radii = if axis_value == json!("l4") { 25.0 } else { 225.0 - 100.0 };
|
||||
let expected = 0.5 * radii * 5.0 * std::f64::consts::FRAC_PI_2;
|
||||
let volume = solvecraft_engine::doc::kernel::measure(&body.body).unwrap().volume;
|
||||
assert!((volume - expected).abs() < 0.03, "{axis_value}: {volume}");
|
||||
open(&mut h);
|
||||
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let preview = h.app.session.preview(&commands).unwrap();
|
||||
assert_eq!(preview.after.body("Main").unwrap().mesh().bounds(), bounds);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{axis_value}: {:?}", h.app.session.log);
|
||||
assert_eq!(axis(&h), original);
|
||||
assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), bounds);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_angle_edit_preserves_axis_downstream_binding_and_one_undo() {
|
||||
let mut h = make(Harness::new(), json!({"origin":[0,0,0],"dir":[0,2,0]}));
|
||||
h.app.run("solid.box", json!({"length":2,"width":2,"height":"(d1 / (1 rad)) * 1 mm","corner":[30,0,0],"body_name":"Dependent"})).unwrap();
|
||||
let original = axis(&h);
|
||||
let undo = h.app.session.undo.len();
|
||||
open(&mut h);
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"180 deg"})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(axis(&h), original);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
assert!((h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z - std::f64::consts::PI).abs() < 1e-8);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(axis(&h), original);
|
||||
assert!((h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z - std::f64::consts::FRAC_PI_2).abs() < 1e-8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_owner_explicit_axis_is_lossless_with_another_component_active() {
|
||||
let mut h = Harness::new();
|
||||
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
|
||||
let mut h = make(h, json!({"origin":[0,0,0],"dir":[0,2,0]}));
|
||||
h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20],"axis":[0,0,1],"angle":"17 deg"})).unwrap();
|
||||
h.app.run("component.create", json!({"name":"Other","parent":0})).unwrap();
|
||||
let active = h.app.session.active_component;
|
||||
let original = axis(&h);
|
||||
let bounds = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
|
||||
open(&mut h);
|
||||
let (origin, direction) = crate::canvas::revolve_axis(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert!((origin.x - 100.0).abs() < 1e-8 && (origin.z - 20.0).abs() < 1e-8);
|
||||
let angle = 17.0f64.to_radians();
|
||||
assert!((direction.x + angle.sin()).abs() < 1e-8 && (direction.y - angle.cos()).abs() < 1e-8);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(axis(&h), original);
|
||||
assert_eq!(h.app.session.active_component, active);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), bounds);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_and_sketch_axis_handles_follow_authoring_occurrence() {
|
||||
for selected in [json!("Y"), json!("y")] {
|
||||
let mut h = Harness::new();
|
||||
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
|
||||
let mut h = make(h, selected);
|
||||
h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20],"axis":[0,0,1],"angle":"17 deg"})).unwrap();
|
||||
let original = axis(&h);
|
||||
open(&mut h);
|
||||
let (origin, direction) = crate::canvas::revolve_axis(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let angle = 17.0f64.to_radians();
|
||||
assert!((origin.x - 100.0).abs() < 1e-8 && (origin.z - 20.0).abs() < 1e-8);
|
||||
assert!((direction.x + angle.sin()).abs() < 1e-8 && (direction.y - angle.cos()).abs() < 1e-8);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(axis(&h), original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_axis_cancel_restores_original_document_and_history() {
|
||||
let mut h = make(Harness::new(), json!("y"));
|
||||
let original = serde_json::to_value(&*h.app.session.doc).unwrap();
|
||||
let undo = h.app.session.undo.len();
|
||||
open(&mut h);
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"180 deg"})).unwrap();
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&*h.app.session.doc).unwrap(), original);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert!(matches!(&h.app.session.doc.find_feature("Turn").unwrap().kind,FeatureKind::Revolve{axis:AxisRef::SketchAxis{axis},..} if axis=="y"));
|
||||
}
|
||||
@@ -1,6 +1,42 @@
|
||||
use super::*;
|
||||
use solvecraft_engine::sketch::CurveKind;
|
||||
|
||||
#[test]
|
||||
fn clicked_extend_endpoint_reaches_exact_ellipse_and_preserves_support() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.ui.show_origin = true;
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.ellipse", json!({"center":[20,0],"major":[30,0],"minor_radius":5})).unwrap();
|
||||
h.app.run("sketch.constraint.fix", json!({"entity":"e1"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0,3],[5,3]],"connect":false,"infer":false})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"start":"sketch.extend"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[5,3,0]}})).unwrap();
|
||||
let st = h.app.session.world_state();
|
||||
let ss = st.sketch(h.app.session.active_sketch.unwrap()).unwrap();
|
||||
let end = ss.sketch.point(ss.sketch.resolve_point("l1.end").unwrap()).unwrap();
|
||||
assert!(end.dist(Vec2::new(12.0, 3.0)) < 1e-7, "clicked endpoint must extend: {end:?}, {:?}", h.app.status);
|
||||
assert_eq!(ss.sketch.point(ss.sketch.resolve_point("l1.start").unwrap()), Some(Vec2::new(0.0, 3.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derived_offset_context_action_releases_geometry_and_undo_restores() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5})).unwrap();
|
||||
let out = h.app.run("sketch.offset", json!({"curves":["e1"],"distance":-1,"chain":false})).unwrap();
|
||||
let id = out["curves"][0].as_str().unwrap().to_string();
|
||||
h.app.session.selection = vec![solvecraft_engine::Sel::SketchCurve { id: id.clone() }];
|
||||
let items = crate::context_menu::items(&h.app, &crate::context_menu::Target::Viewport);
|
||||
let item = items.iter().find(|item| item.id == "sketch.offset.break").unwrap();
|
||||
assert_eq!(item.params, Some(json!({"curve":id})));
|
||||
crate::context_menu::run_item(&mut h.app, item, egui::Pos2::ZERO);
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
assert!(h.app.session.model.state().sketch(sid).unwrap().sketch.freeform_offsets.is_empty());
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.model.state().sketch(sid).unwrap().sketch.freeform_offsets.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicked_origin_corner_fillets_and_chamfers_with_origin_visible() {
|
||||
for command in ["sketch.fillet", "sketch.chamfer.equal_distance", "sketch.chamfer.distance_angle", "sketch.chamfer.two_distance"] {
|
||||
|
||||
@@ -647,7 +647,13 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
|
||||
}
|
||||
}
|
||||
Mode::CurveAt => {
|
||||
if let (Some((id, false)), Some((p, _))) = (sketch_entity(app, &hits), at) {
|
||||
let curve = active_sketch(app).and_then(|active| {
|
||||
hits.iter().find_map(|hit| match hit {
|
||||
Hit::SketchCurve { sketch, id, .. } if *sketch == active => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
if let (Some(id), Some((p, _))) = (curve, at) {
|
||||
let _ = app.run(cmd, json!({"curve": id, "at": xy(p)}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ use crate::selection::{BoxSel, origin_quads, origin_size, ray_quad};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{SolveApp, icons};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "viewport_appearance_tests.rs"]
|
||||
mod appearance_tests;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NavMode {
|
||||
Orbit,
|
||||
@@ -290,7 +294,7 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
let hidden_edges = app.ui.visual_style == 3;
|
||||
let hidden_col = c4(tk.text.gamma_multiply(0.35));
|
||||
// Appearances: the body's opacity, and faces with looks of their own.
|
||||
let opacity = s.doc.model_body_look(b).map_or(1.0, |l| l.opacity);
|
||||
let opacity = s.doc.model_body_opacity(b);
|
||||
let face_looks: std::collections::HashMap<usize, solvecraft_engine::doc::appearance::Look> =
|
||||
if shaded { s.doc.face_colors(b).into_iter().collect() } else { Default::default() };
|
||||
for (ti, t) in m.triangles.iter().enumerate().filter(|_| shaded) {
|
||||
@@ -1086,13 +1090,18 @@ fn hidden_face_outline(sc: &mut GpuScene, m: &solvecraft_engine::geom::Mesh, fac
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index: usize, fill: Color32, edge: Color32) {
|
||||
pub(crate) fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index: usize, fill: Color32, edge: Color32) {
|
||||
let st = app.session.world_state();
|
||||
let Some(ss) = st.sketch(sketch) else { return };
|
||||
let Some(p) = ss.profiles.get(index) else { return };
|
||||
let plane = match crate::dialogs::emboss_profile_plane(app, sketch) {
|
||||
Ok(Some(plane)) => plane,
|
||||
Ok(None) => ss.plane,
|
||||
Err(_) => return,
|
||||
};
|
||||
// Lift toward the eye so a profile on a face shows over it.
|
||||
let lift = app.cam.back() * (app.cam.half_height() * 0.002);
|
||||
let w = |q: solvecraft_engine::geom::Vec2| (ss.plane.to_world(q) + lift).to_f32();
|
||||
let w = |q: solvecraft_engine::geom::Vec2| (plane.to_world(q) + lift).to_f32();
|
||||
let col = c4(fill);
|
||||
// In the translucent pass (after the grid and edges) so it covers them.
|
||||
for [a, b, c] in p.region.triangulate(0.02) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
use super::build_scene;
|
||||
use crate::{Services, SolveApp};
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::{Session, doc, geom::Vec3, kernel};
|
||||
|
||||
#[test]
|
||||
fn placed_step_body_uses_imported_opacity_until_a_design_look_overrides_it() {
|
||||
let body = kernel::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0))
|
||||
.unwrap()
|
||||
.with_color(Some([0.2, 0.6, 0.8]))
|
||||
.with_paint(Some(kernel::Paint { opacity: 0.4, faces: Vec::new() }));
|
||||
let step =
|
||||
kernel::step_export_bodies(&[kernel::ExportBody { name: "Imported glass".into(), body: &body, color: body.color() }], &Default::default())
|
||||
.unwrap();
|
||||
let mut document = doc::Document::default();
|
||||
let component = document.add_component(Some("Imported part"), 0).unwrap();
|
||||
let feature = document.add_feature(doc::FeatureKind::Import { file: "glass.step".into(), step, components: Vec::new() }, None).unwrap();
|
||||
document.features.iter_mut().find(|f| f.id == feature).unwrap().component = component;
|
||||
let mut session = Session::new(document);
|
||||
let source = session.model.state().bodies[0].name.clone();
|
||||
assert!((session.doc.model_body_opacity(&session.model.state().bodies[0]) - 0.4).abs() < 1e-6);
|
||||
session.execute("occurrence.copy", &json!({"component":component,"translate":[30,0,0]})).unwrap();
|
||||
let mut app = SolveApp::new(session, Services::default());
|
||||
app.ui.ground_shadow = false;
|
||||
app.ui.show_origin = false;
|
||||
let placed = app.session.world_state().bodies.iter().find(|b| b.name != source).unwrap().name.clone();
|
||||
app.session.selection = vec![solvecraft_engine::Sel::Body { name: placed }];
|
||||
app.start("appearance.assign");
|
||||
let crate::dialogs::Kind::Appearance(appearance) = &app.dialog.as_ref().unwrap().kind else { panic!("appearance dialog") };
|
||||
assert!((appearance.opacity - 0.4).abs() < 1e-6);
|
||||
assert_eq!(appearance.color, [51, 153, 204]);
|
||||
crate::dialogs::cancel(&mut app);
|
||||
let scene = build_scene(&app);
|
||||
assert!(!scene.glass.is_empty());
|
||||
assert!(
|
||||
scene.tris.is_empty(),
|
||||
"opaque triangles {}, bodies {:?}",
|
||||
scene.tris.len(),
|
||||
app.session.world_state().bodies.iter().map(|b| (&b.name, b.body.paint(), &b.imported)).collect::<Vec<_>>()
|
||||
);
|
||||
let (vertices, remainder) = scene.glass.as_chunks::<{ crate::gpu::TRI_SIZE }>();
|
||||
assert!(remainder.is_empty());
|
||||
assert!(vertices.iter().all(|v| v[27] == 102));
|
||||
app.run("appearance.assign", json!({"bodies":[source],"color":"#336699","opacity":0.7})).unwrap();
|
||||
let scene = build_scene(&app);
|
||||
let (vertices, remainder) = scene.glass.as_chunks::<{ crate::gpu::TRI_SIZE }>();
|
||||
assert!(remainder.is_empty());
|
||||
assert!(vertices.iter().all(|v| v[27] == 179));
|
||||
app.run("appearance.assign", json!({"bodies":[source]})).unwrap();
|
||||
let scene = build_scene(&app);
|
||||
let (vertices, remainder) = scene.glass.as_chunks::<{ crate::gpu::TRI_SIZE }>();
|
||||
assert!(remainder.is_empty());
|
||||
assert!(vertices.iter().all(|v| v[27] == 102));
|
||||
}
|
||||
Reference in new issue
Block a user