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