feat: strengthen sketches, feature editing, and independent assemblies

This commit is contained in:
bherbruck committed 2026-10-11 02:44:31 -04:00
1 parent 07932d0f42
commit 0398a813c8
65 files changed
+7082 -268

No files matched your search

+36 -1
View File
@@ -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));
}
+371
View File
@@ -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");
}
}
+46
View File
@@ -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),
})
}
}
+213
View File
@@ -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);
}
}
+38
View File
@@ -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 {
+191
View File
@@ -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));
}
}
+91 -7
View File
@@ -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
View File
@@ -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(())
+48 -1
View File
@@ -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, &regions, 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, &regions, a, b)?;
if st.bodies.is_empty() {
+290 -4
View File
@@ -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");
+3 -2
View File
@@ -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;
+12 -1
View File
@@ -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);
+3
View File
@@ -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));