feat: preserve modeling intent across sketches, assemblies, and fabrication

Persist typed sketch dimensions and bounded ordered configuration rules in format 8. Use exact occurrence paths and source identity for editing, movement, appearance, and mass. Preserve sheet material regions through cutting, folding, and exact DXF export; retain nested cavities and material islands.

Validation: cargo xtask ci passed, including 368 engine tests, 178 UI tests, 68 UI scenarios, 136 kernel tests, and the 10-crate wasm check. Actual preview workflows and legacy format-7 origin-corner reopening were inspected.
This commit is contained in:
bherbruck committed 2026-10-11 01:28:01 -04:00
1 parent d71bdb78ec
commit 07932d0f42
65 files changed
+6145 -484

No files matched your search

+6 -2
View File
@@ -130,6 +130,10 @@ pub fn material_look(material: &str) -> Option<Look> {
}
impl Document {
/// Shared definition appearance for a computed occurrence body.
pub fn model_body_look(&self, body: &crate::ModelBody) -> Option<Look> {
self.body_look(body.definition_name(), body.feature)
}
/// 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> {
@@ -167,8 +171,8 @@ impl Document {
None => Vec::new(),
},
};
for f in self.appearances.faces.iter().filter(|f| f.body == b.name) {
let (p, look) = (f.point, f.look.clone());
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());
// 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);
+43 -6
View File
@@ -385,24 +385,61 @@ impl Document {
/// further instance), its component, the occurrence path, and the transform from the frame
/// its geometry is in to the world.
pub fn body_frames(&self, st: &crate::ModelState) -> Vec<(String, u64, Vec<u64>, Mat)> {
self.body_frames_with_source(st).into_iter().map(|(world, _, component, path, matrix)| (world, component, path, matrix)).collect()
}
/// Body frames with explicit definition names; callers must not infer them from labels.
pub fn body_frames_with_source(&self, st: &crate::ModelState) -> Vec<(String, String, u64, Vec<u64>, Mat)> {
let mut out = Vec::new();
for (comp, path, m) in self.placements() {
let primary = path == self.first_path(comp);
let label = || -> String {
path.iter().filter_map(|id| self.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect::<Vec<_>>().join("/")
};
for b in st.bodies.iter().filter(|b| self.body_component(&b.name, b.feature) == comp) {
let name = if primary { b.name.clone() } else { format!("{} ({})", b.name, label()) };
let name = self.world_body_name(st, &b.name, comp, &path);
let w = match self.body_offsets.get(&b.name) {
Some(o) => mat_mul(&m, o),
None => m,
};
out.push((name, comp, path.clone(), w));
out.push((name, b.definition_name().to_string(), comp, path.clone(), w));
}
}
out
}
/// Computed placement name. Source names retain priority; ambiguous labels use full IDs.
/// Resolves label ambiguity with bounded dynamic counts instead of expanding all placements.
pub fn world_body_name(&self, st: &crate::ModelState, source: &str, component: u64, path: &[u64]) -> String {
if path == self.first_path(component) {
return source.to_string();
}
let names: Vec<&str> = path.iter().filter_map(|id| self.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect();
let label = names.join("/");
let candidate = format!("{source} ({label})");
let mut counts = std::collections::BTreeMap::from([(0, 1usize)]);
for name in &names {
let mut next = std::collections::BTreeMap::<u64, usize>::new();
for o in self.occurrences.iter().filter(|o| o.name == *name) {
if let Some(count) = counts.get(&o.parent) {
let n = next.entry(o.component).or_default();
*n = (*n + count).min(2);
}
}
counts = next;
}
let ambiguous = counts.get(&component).copied().unwrap_or(0) > 1;
// Slash and reserved-marker names could alias concatenated labels or fallback keys.
let reserved =
self.occurrences.iter().any(|o| o.name.contains('/') || o.name.contains("@occ[")) || st.bodies.iter().any(|b| b.name.contains("@occ["));
if !ambiguous && !reserved && st.body(&candidate).is_none() {
return candidate;
}
let ids = path.iter().map(u64::to_string).collect::<Vec<_>>().join("/");
let encoded = serde_json::to_string(source).unwrap_or_else(|_| source.to_string());
let mut key = format!("{encoded}@occ[{ids}]");
while st.body(&key).is_some() {
key.push('#');
}
key
}
/// The occurrence path of a component's first placement.
pub fn first_path(&self, component: u64) -> Vec<u64> {
let mut path = Vec::new();
+135 -15
View File
@@ -6,6 +6,10 @@ use serde::{Deserialize, Serialize};
use crate::{DocError, Document, Result};
#[path = "config_rules.rs"]
mod rules;
pub use rules::*;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Column {
@@ -32,14 +36,24 @@ pub struct ConfigTable {
pub rows: Vec<ConfigRow>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rules: Vec<ConfigRule>,
}
pub const MAX_COLUMNS: usize = 500;
pub const MAX_ROWS: usize = 2000;
pub fn suppression_cell(value: &str) -> Result<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" => Ok(true),
"false" | "0" | "no" => Ok(false),
_ => Err(DocError::Invalid("suppression must be true or false".into())),
}
}
impl ConfigTable {
pub fn is_empty(&self) -> bool {
self.columns.is_empty() && self.rows.is_empty()
self.columns.is_empty() && self.rows.is_empty() && self.rules.is_empty()
}
}
@@ -75,10 +89,46 @@ pub fn material_cell(value: &str) -> Result<Option<String>> {
}
impl Document {
/// Freeze design-unit-sensitive configuration literals when changing display units.
/// Explicit units and nonliteral expressions retain their source text.
pub fn preserve_configuration_units(&mut self, previous_unit: &str, kept_parameters: &[String]) {
let feature_lengths: Vec<String> = self
.features
.iter()
.flat_map(|feature| {
feature
.param_names
.iter()
.zip(feature.kind.inputs())
.filter(|(_, (_, _, kind))| *kind == crate::expr::Kind::Length)
.map(|(name, _)| name.clone())
})
.collect();
let lengths: std::collections::BTreeSet<String> = feature_lengths.into_iter().chain(kept_parameters.iter().cloned()).collect();
for row in &mut self.configs.rows {
for (column, cell) in self.configs.columns.iter().zip(&mut row.cells) {
if matches!(column,Column::Param(name) if lengths.contains(name)) && cell.trim().parse::<f64>().is_ok() {
*cell = format!("{} {previous_unit}", cell.trim());
}
}
}
for rule in &mut self.configs.rules {
rule.preserve_length_units(&lengths, previous_unit);
}
}
/// A column's current value in the design.
pub fn config_current(&self, c: &Column) -> Option<String> {
match c {
Column::Param(n) => self.all_param_exprs().into_iter().find(|(m, _, _)| m == n).map(|(_, e, _)| e),
Column::Param(n) => self.all_param_exprs().into_iter().find(|(m, _, _)| m == n).map(|(_, e, kind)| {
// Feature literals are stored in base units, unlike newly typed bare
// row cells. Capture their unit explicitly before editing in an inch design.
if self.param(n).is_none() && kind == crate::expr::Kind::Length && self.units != "mm" && e.trim().parse::<f64>().is_ok() {
format!("{} mm", e.trim())
} else {
e
}
}),
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()),
@@ -97,27 +147,34 @@ impl Document {
/// Set the design to a row's values (all or nothing).
pub fn apply_configuration(&mut self, row: &str) -> Result<()> {
self.apply_configuration_with_trace(row).map(|_| ())
}
pub fn apply_configuration_with_trace(&mut self, row: &str) -> Result<Vec<RuleTrace>> {
let r = self.configs.rows.iter().find(|r| r.name == row).cloned().ok_or_else(|| DocError::Unknown(format!("configuration `{row}`")))?;
if self.configs.columns.len() > MAX_COLUMNS || self.configs.rows.len() > MAX_ROWS || r.cells.len() != self.configs.columns.len() {
return Err(DocError::Invalid("configuration table exceeds limits or has incomplete rows".into()));
}
let mut next = self.clone();
let changes: Vec<(String, String)> = self
.configs
.columns
.iter()
.zip(&r.cells)
.filter_map(|(c, v)| match c {
Column::Param(name) => Some((name.clone(), v.clone())),
_ => None,
})
.collect();
next.change_params_atomic(&changes).map_err(|e| DocError::Invalid(format!("configuration `{row}`: {e}")))?;
for (c, v) in self.configs.columns.iter().zip(&r.cells) {
if v.len() > 4096 {
return Err(DocError::Invalid("configuration value too long".into()));
}
match c {
Column::Param(n) => {
if next.config_current(c).as_deref() != Some(v.as_str()) {
next.change_param(n, v, None, None).map_err(|e| DocError::Invalid(format!("configuration `{row}`, parameter `{n}`: {e}")))?;
}
}
Column::Param(_) => {}
Column::Suppress(id) => {
let on = match v.trim() {
"true" | "1" | "yes" => true,
"false" | "0" | "no" => false,
o => return Err(DocError::Invalid(format!("configuration `{row}`: suppression must be true or false, not `{o}`"))),
};
let on = suppression_cell(v)?;
let f = next.feature_mut(*id).ok_or_else(|| DocError::Unknown(format!("configuration `{row}`: feature {id} is gone")))?;
f.suppressed = on;
}
@@ -140,8 +197,9 @@ impl Document {
}
}
next.configs.active = Some(row.to_string());
let trace = next.evaluate_configuration_rules()?;
*self = next;
Ok(())
Ok(trace)
}
/// The active row's values all hold in the design.
@@ -150,9 +208,24 @@ impl Document {
if r.cells.len() != self.configs.columns.len() {
return false;
}
if !self.configs.rules.is_empty() {
let mut expected = self.clone();
if expected.apply_configuration(&r.name).is_err() {
return false;
}
return self.configs.columns.iter().all(|column| self.config_current(column) == expected.config_current(column));
}
self.configs.columns.iter().zip(&r.cells).all(|(c, v)| match c {
Column::Suppress(_) => self.config_current(c).as_deref() == Some(v.trim()),
Column::Param(_) => self.config_current(c).is_some_and(|x| x.replace(' ', "") == v.replace(' ', "")),
Column::Suppress(id) => suppression_cell(v).is_ok_and(|on| self.feature(*id).is_some_and(|f| f.suppressed == on)),
Column::Param(name) => {
let kind = self
.features
.iter()
.find_map(|f| f.param_names.iter().position(|n| n == name).and_then(|k| f.kind.inputs().get(k).map(|input| input.2)));
let expected = kind.map_or_else(|| v.trim().to_string(), |kind| self.with_design_unit(v, kind));
self.config_current(c)
.is_some_and(|current| current.chars().filter(|c| !c.is_whitespace()).eq(expected.chars().filter(|c| !c.is_whitespace())))
}
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()),
})
@@ -163,6 +236,53 @@ impl Document {
mod tests {
use super::*;
#[test]
fn rows_use_final_graph_and_missing_targets_never_reappear() {
let mut d = Document::new("A");
d.set_param("a", "1 mm", None, None).unwrap();
d.set_param("b", "a", None, None).unwrap();
d.configs.columns = vec![Column::Param("a".into()), Column::Param("b".into())];
d.configs.rows.push(ConfigRow { name: "Reordered".into(), cells: vec!["b".into(), "2 mm".into()] });
d.apply_configuration("Reordered").unwrap();
assert!(d.configuration_matches());
assert_eq!(d.param_values().0["a"].v, 2.0);
d.configs.rows.push(ConfigRow { name: "Cycle".into(), cells: vec!["b".into(), "a".into()] });
let before = d.clone();
assert!(d.apply_configuration("Cycle").is_err());
assert_eq!(d, before);
d.params.retain(|p| p.name != "a");
let before = d.clone();
assert!(d.apply_configuration("Reordered").is_err());
assert_eq!(d, before);
}
#[test]
fn bulk_changes_reject_new_errors_in_unchanged_dependents() {
let mut d = Document::new("A");
d.set_param("length", "2 mm", None, None).unwrap();
d.set_param("twice", "length * 2", None, None).unwrap();
let before = d.clone();
assert!(d.change_params_atomic(&[("length".into(), "1 / 0".into())]).is_err());
assert_eq!(d, before);
assert!(d.change_params_atomic(&[("length".into(), "3 mm".into()), ("length".into(), "4 mm".into())]).is_err());
assert_eq!(d, before);
}
#[test]
fn unchanged_erroneous_configured_parameter_is_a_noop_but_changed_error_rejects() {
let mut d = Document::new("Existing broken design");
d.set_param("width", "10 mm", None, None).unwrap();
d.params[0].expr = "1/".into();
d.configs.columns = vec![Column::Param("width".into())];
d.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["1/".into()] });
d.apply_configuration("Default").unwrap();
assert!(d.configuration_matches());
d.configs.rows.push(ConfigRow { name: "Different error".into(), cells: vec!["missing + 1".into()] });
let before = d.clone();
assert!(d.apply_configuration("Different error").is_err());
assert_eq!(d, before);
}
#[test]
fn rows_switch_parameters() {
let mut d = Document::new("A");
+646
View File
@@ -0,0 +1,646 @@
//! Bounded typed configuration rules. The saved row remains the editable source;
//! ordered rules derive an effective design without overwriting its cells.
use super::Column;
use crate::{DocError, Document, Result, expr};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
pub const MAX_RULES: usize = 128;
const MAX_NODES: usize = 64;
const MAX_DEPTH: usize = 8;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigRule {
pub id: String,
pub name: String,
#[serde(default = "enabled")]
pub enabled: bool,
/// Unit context for feature-length expressions, fixed when authoring a rule.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub length_unit: Option<String>,
pub condition: RuleCondition,
pub effect: RuleEffect,
}
fn enabled() -> bool {
true
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum RuleCondition {
Always,
All { conditions: Vec<RuleCondition> },
Any { conditions: Vec<RuleCondition> },
Not { condition: Box<RuleCondition> },
Parameter { parameter: String, operator: RuleOperator, expression: String },
Suppression { feature: u64, value: bool },
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuleOperator {
Eq,
Ne,
Lt,
Le,
Gt,
Ge,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum RuleEffect {
DriveParameter { parameter: String, expression: String },
DriveSuppression { feature: u64, value: bool },
ParameterRange { parameter: String, minimum: String, maximum: String },
AllowParameterValues { parameter: String, expressions: Vec<String> },
}
#[derive(Clone, Debug, Serialize)]
pub struct RuleTrace {
pub id: String,
pub name: String,
pub matched: bool,
pub effect: RuleEffect,
}
/// Check saved rule structure before recursive serde decoding. Absence is backward
/// compatible; malformed present data is refused. Runtime evaluation checks it again.
pub fn validate_rules_json(document: &Value) -> Result<()> {
let Some(rules) = document.get("configs").and_then(|configs| configs.get("rules")) else { return Ok(()) };
let rules = rules.as_array().ok_or_else(|| invalid("rules must be an array"))?;
if rules.len() > MAX_RULES {
return Err(invalid("too many rules"));
}
for rule in rules {
let rule = rule.as_object().ok_or_else(|| invalid("rule must be an object"))?;
for (key, limit) in [("id", 64), ("name", 128)] {
let value = rule.get(key).and_then(Value::as_str).ok_or_else(|| invalid("rule id and name are required"))?;
if value.trim().is_empty() || value.chars().count() > limit {
return Err(invalid("rule id or name is empty or too long"));
}
}
if let Some(unit) = rule.get("length_unit") {
let unit =
unit.as_str().filter(|unit| matches!(*unit, "mm" | "cm" | "m" | "in" | "ft")).ok_or_else(|| invalid("invalid rule length unit"))?;
let _ = unit;
}
let mut pending = vec![(rule.get("condition").ok_or_else(|| invalid("condition required"))?, 1usize)];
let mut count = 0;
while let Some((condition, depth)) = pending.pop() {
count += 1;
if depth > MAX_DEPTH || count > MAX_NODES {
return Err(invalid("condition nesting or node limit exceeded"));
}
let kind = condition.get("type").and_then(Value::as_str).ok_or_else(|| invalid("condition type required"))?;
match kind {
"all" | "any" => {
let children = condition.get("conditions").and_then(Value::as_array).ok_or_else(|| invalid("conditions must be an array"))?;
if children.is_empty() || children.len() > MAX_NODES {
return Err(invalid("condition group is empty or too large"));
}
pending.extend(children.iter().map(|child| (child, depth + 1)));
}
"not" => pending.push((condition.get("condition").ok_or_else(|| invalid("nested condition required"))?, depth + 1)),
"parameter" => {
check_parameter(condition.get("parameter"))?;
check_expression(condition.get("expression"))?;
}
"always" | "suppression" => {}
_ => return Err(invalid("unknown condition type")),
}
}
let effect = rule.get("effect").ok_or_else(|| invalid("effect required"))?;
if effect.get("type").and_then(Value::as_str) != Some("drive_suppression") {
check_parameter(effect.get("parameter"))?;
}
match effect.get("type").and_then(Value::as_str) {
Some("drive_parameter") => check_expression(effect.get("expression"))?,
Some("parameter_range") => {
check_expression(effect.get("minimum"))?;
check_expression(effect.get("maximum"))?;
}
Some("allow_parameter_values") => {
let expressions = effect.get("expressions").and_then(Value::as_array).ok_or_else(|| invalid("allowed values must be an array"))?;
if expressions.is_empty() || expressions.len() > 128 {
return Err(invalid("allowed values are empty or too many"));
}
for expression in expressions {
check_expression(Some(expression))?;
}
}
Some("drive_suppression") => {}
_ => return Err(invalid("unknown effect type")),
}
}
Ok(())
}
fn check_expression(value: Option<&Value>) -> Result<()> {
let text = value.and_then(Value::as_str).ok_or_else(|| invalid("expression must be text"))?;
if text.trim().is_empty() || text.len() > 4096 {
return Err(invalid("expression is empty or too long"));
}
Ok(())
}
fn check_parameter(value: Option<&Value>) -> Result<()> {
let name = value.and_then(Value::as_str).ok_or_else(|| invalid("parameter name required"))?;
if name.is_empty() || name.len() > 64 || Document::param_name_problem(name).is_some() {
return Err(invalid("invalid parameter name"));
}
Ok(())
}
fn invalid(message: &str) -> DocError {
DocError::Invalid(format!("configuration rules: {message}"))
}
fn operands(
doc: &Document,
values: &BTreeMap<String, expr::Value>,
parameter: &str,
expression: &str,
length_unit: Option<&str>,
) -> Result<(f64, f64)> {
let lhs = values.get(parameter).copied().ok_or_else(|| invalid(&format!("parameter `{parameter}` is missing or invalid")))?;
let unit = doc
.param(parameter)
.map(|p| p.unit.as_str())
.or_else(|| lhs.kind().map(|kind| if kind == expr::Kind::Length { length_unit.unwrap_or(doc.units.as_str()) } else { kind.default_unit() }))
.unwrap_or("");
let defaults = expr::Defaults::for_unit(unit, expr::unit_info(&doc.units).map(|(_, scale)| scale).unwrap_or(1.0));
let rhs = expr::eval_with_defaults(
expression,
&|name| values.get(name).copied().ok_or_else(|| invalid(&format!("unknown parameter `{name}`"))),
defaults,
)?;
let kind = lhs.kind().ok_or_else(|| invalid("comparison requires length, angle or unitless values"))?;
let right = rhs.to_kind_in(kind, defaults)?;
if !lhs.v.is_finite() || !right.is_finite() {
return Err(invalid("comparison requires finite values"));
}
Ok((lhs.v, right))
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
}
fn validate_drive_graph(graph: &BTreeMap<String, Vec<String>>, parameter: &str, expression: &str) -> Result<()> {
let mut pending = expr::references(expression);
let mut visited = BTreeSet::new();
while let Some(name) = pending.pop() {
if name == parameter {
return Err(invalid(&format!("drive for `{parameter}` creates a parameter cycle")));
}
if visited.insert(name.clone())
&& let Some(references) = graph.get(&name)
{
pending.extend(references.iter().cloned());
}
}
Ok(())
}
fn condition(doc: &Document, values: &BTreeMap<String, expr::Value>, value: &RuleCondition, length_unit: Option<&str>) -> Result<bool> {
match value {
RuleCondition::Always => Ok(true),
RuleCondition::All { conditions } => {
for c in conditions {
if !condition(doc, values, c, length_unit)? {
return Ok(false);
}
}
Ok(true)
}
RuleCondition::Any { conditions } => {
for c in conditions {
if condition(doc, values, c, length_unit)? {
return Ok(true);
}
}
Ok(false)
}
RuleCondition::Not { condition: c } => Ok(!condition(doc, values, c, length_unit)?),
RuleCondition::Suppression { feature, value } => {
Ok(doc.feature(*feature).ok_or_else(|| invalid("condition feature is gone"))?.suppressed == *value)
}
RuleCondition::Parameter { parameter, operator, expression } => {
let (a, b) = operands(doc, values, parameter, expression, length_unit)?;
Ok(match operator {
RuleOperator::Eq => close(a, b),
RuleOperator::Ne => !close(a, b),
RuleOperator::Lt => a < b,
RuleOperator::Le => a <= b || close(a, b),
RuleOperator::Gt => a > b,
RuleOperator::Ge => a >= b || close(a, b),
})
}
}
}
impl Document {
/// Apply ordered drives, then enforce all matched restrictions on the final state.
/// Running on a scratch document makes a failing rule fully atomic for callers.
pub(super) fn evaluate_configuration_rules(&mut self) -> Result<Vec<RuleTrace>> {
validate_rules_json(&serde_json::json!({"configs":{"rules":self.configs.rules}}))?;
let mut ids = BTreeSet::new();
let mut trace = Vec::new();
let mut restrictions = Vec::new();
let (mut values, _) = self.param_values();
let parameter_names: BTreeSet<String> = self.all_param_exprs().into_iter().map(|(name, _, _)| name).collect();
let mut dependency_graph: BTreeMap<String, Vec<String>> =
self.all_param_exprs().into_iter().map(|(name, expression, _)| (name, expr::references(&expression))).collect();
for rule in self.configs.rules.clone() {
if !ids.insert(rule.id.clone()) {
return Err(invalid("duplicate rule id"));
}
if let Some(name) = rule.parameter_references().iter().find(|name| !parameter_names.contains(*name)) {
return Err(invalid(&format!("rule `{}` references missing parameter `{name}`", rule.name)));
}
// Targets must remain configured, including disabled rules, so dormant rules
// cannot silently become invalid and surprise a subsequent activation.
let target = match &rule.effect {
RuleEffect::DriveParameter { parameter, .. }
| RuleEffect::ParameterRange { parameter, .. }
| RuleEffect::AllowParameterValues { parameter, .. } => Column::Param(parameter.clone()),
RuleEffect::DriveSuppression { feature, .. } => Column::Suppress(*feature),
};
if !self.configs.columns.contains(&target) {
return Err(invalid(&format!("rule `{}` target is not configured", rule.name)));
}
let matched = rule.enabled
&& condition(self, &values, &rule.condition, rule.length_unit.as_deref())
.map_err(|e| invalid(&format!("rule `{}`: {e}", rule.name)))?;
trace.push(RuleTrace { id: rule.id, name: rule.name.clone(), matched, effect: rule.effect.clone() });
if !matched {
continue;
}
match rule.effect {
RuleEffect::DriveParameter { parameter, mut expression } => {
validate_drive_graph(&dependency_graph, &parameter, &expression)?;
dependency_graph.insert(parameter.clone(), expr::references(&expression));
if self.param(&parameter).is_none()
&& rule.length_unit.as_deref().is_some_and(|unit| unit != self.units)
&& values.get(&parameter).is_some_and(|value| value.kind() == Some(expr::Kind::Length))
{
let (_, value) = operands(self, &values, &parameter, &expression, rule.length_unit.as_deref())?;
expression = format!("{value} mm");
}
self.change_params_atomic(&[(parameter, expression)]).map_err(|e| invalid(&format!("rule `{}`: {e}", rule.name)))?;
values = self.param_values().0;
}
RuleEffect::DriveSuppression { feature, value } => {
self.feature_mut(feature).ok_or_else(|| invalid("rule feature is gone"))?.suppressed = value
}
effect => restrictions.push((rule.name, effect, rule.length_unit)),
}
}
for (name, effect, length_unit) in restrictions {
let valid = match effect {
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
let (value, min) = operands(self, &values, &parameter, &minimum, length_unit.as_deref())?;
let (_, max) = operands(self, &values, &parameter, &maximum, length_unit.as_deref())?;
min <= max && (value >= min || close(value, min)) && (value <= max || close(value, max))
}
RuleEffect::AllowParameterValues { parameter, expressions } => {
let mut valid = false;
for expression in expressions {
let (a, b) = operands(self, &values, &parameter, &expression, length_unit.as_deref())?;
valid |= close(a, b);
}
valid
}
_ => true,
};
if !valid {
return Err(invalid(&format!("rule `{name}` restricts the final value")));
}
}
Ok(trace)
}
}
impl ConfigRule {
pub(super) fn preserve_length_units(&mut self, parameters: &BTreeSet<String>, unit: &str) {
if self.length_unit.is_none() {
self.length_unit = Some(unit.to_string());
}
fn annotate(parameter: &str, expression: &mut String, parameters: &BTreeSet<String>, unit: &str) {
if parameters.contains(parameter) && expression.trim().parse::<f64>().is_ok() {
*expression = format!("{} {unit}", expression.trim());
}
}
fn walk(condition: &mut RuleCondition, parameters: &BTreeSet<String>, unit: &str) {
match condition {
RuleCondition::Parameter { parameter, expression, .. } => annotate(parameter, expression, parameters, unit),
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
for condition in conditions {
walk(condition, parameters, unit);
}
}
RuleCondition::Not { condition } => walk(condition, parameters, unit),
_ => {}
}
}
walk(&mut self.condition, parameters, unit);
match &mut self.effect {
RuleEffect::DriveParameter { parameter, expression } => annotate(parameter, expression, parameters, unit),
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
annotate(parameter, minimum, parameters, unit);
annotate(parameter, maximum, parameters, unit);
}
RuleEffect::AllowParameterValues { parameter, expressions } => {
for expression in expressions {
annotate(parameter, expression, parameters, unit);
}
}
_ => {}
}
}
/// Parameter references include targets and condition/effect expressions.
pub fn parameter_references(&self) -> Vec<String> {
let mut copy = self.clone();
let mut references = Vec::new();
copy.visit_parameters(&mut |text, expression| {
if expression {
references.extend(expr::references(text));
} else {
references.push(text.clone());
}
});
references
}
pub fn rename_parameter(&mut self, old: &str, new: &str) {
self.visit_parameters(&mut |text, expression| {
if expression {
*text = expr::rename_reference(text, old, new);
} else if text == old {
*text = new.to_string();
}
});
}
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 {
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
for c in conditions {
walk(c, visit);
}
}
RuleCondition::Not { condition } => walk(condition, visit),
RuleCondition::Parameter { parameter, expression, .. } => {
visit(parameter, false);
visit(expression, true);
}
_ => {}
}
}
walk(&mut self.condition, visit);
match &mut self.effect {
RuleEffect::DriveParameter { parameter, expression } => {
visit(parameter, false);
visit(expression, true);
}
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
visit(parameter, false);
visit(minimum, true);
visit(maximum, true);
}
RuleEffect::AllowParameterValues { parameter, expressions } => {
visit(parameter, false);
for e in expressions {
visit(e, true);
}
}
_ => {}
}
}
pub fn references_features(&self, ids: &[u64]) -> bool {
fn condition(c: &RuleCondition, ids: &[u64]) -> bool {
match c {
RuleCondition::Suppression { feature, .. } => ids.contains(feature),
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => conditions.iter().any(|c| condition(c, ids)),
RuleCondition::Not { condition: c } => condition(c, ids),
_ => false,
}
}
condition(&self.condition, ids) || matches!(&self.effect,RuleEffect::DriveSuppression {feature,..} if ids.contains(feature))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ConfigRow;
fn design() -> Document {
let mut doc = Document::new("Rules");
doc.set_param("width", "10 mm", None, None).unwrap();
doc.set_param("depth", "5 mm", None, None).unwrap();
doc.configs.columns = vec![Column::Param("width".into()), Column::Param("depth".into())];
doc.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
doc
}
fn rule(id: &str, condition: RuleCondition, effect: RuleEffect) -> ConfigRule {
ConfigRule { id: id.into(), name: id.into(), enabled: true, length_unit: None, condition, effect }
}
#[test]
fn ordered_rules_derive_values_without_overwriting_rows() {
let mut doc = design();
doc.configs.rules = vec![
rule("first", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "width".into(), expression: "1 in".into() }),
rule(
"second",
RuleCondition::Parameter { parameter: "width".into(), operator: RuleOperator::Gt, expression: "20 mm".into() },
RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width / 2".into() },
),
];
let source = doc.configs.rows.clone();
let trace = doc.apply_configuration_with_trace("Default").unwrap();
assert!(trace.iter().all(|entry| entry.matched));
assert!((doc.param_values().0["depth"].v - 12.7).abs() < 1e-9);
assert_eq!(doc.configs.rows, source);
assert!(doc.configuration_matches());
doc.configs.rules.swap(0, 1);
let trace = doc.apply_configuration_with_trace("Default").unwrap();
assert!(!trace[0].matched);
assert_eq!(doc.param_values().0["depth"].v, 5.0);
}
#[test]
fn restrictions_intersect_and_fail_atomically_with_named_rule() {
let mut doc = design();
doc.configs.rules = vec![
rule(
"range",
RuleCondition::Always,
RuleEffect::ParameterRange { parameter: "width".into(), minimum: "5 mm".into(), maximum: "1 in".into() },
),
rule(
"choices",
RuleCondition::Always,
RuleEffect::AllowParameterValues { parameter: "width".into(), expressions: vec!["1 cm".into(), "20 mm".into()] },
),
];
doc.apply_configuration("Default").unwrap();
doc.configs.rows[0].cells[0] = "15 mm".into();
let before = doc.clone();
assert!(doc.apply_configuration("Default").unwrap_err().to_string().contains("choices"));
assert_eq!(doc, before);
doc.configs.rules[0].effect = RuleEffect::ParameterRange { parameter: "width".into(), minimum: "1 deg".into(), maximum: "90 deg".into() };
assert!(doc.apply_configuration("Default").is_err());
}
#[test]
fn rule_references_follow_rename_and_prevent_delete() {
let mut doc = design();
doc.configs.rules.push(rule(
"derive",
RuleCondition::Parameter { parameter: "width".into(), operator: RuleOperator::Ge, expression: "depth".into() },
RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width / 2".into() },
));
assert!(doc.remove_param("width").is_err());
doc.rename_param("width", "span").unwrap();
let refs = doc.configs.rules[0].parameter_references();
assert!(refs.contains(&"span".into()));
assert!(!refs.contains(&"width".into()));
doc.apply_configuration("Default").unwrap();
assert_eq!(doc.param_values().0["depth"].v, 5.0);
}
#[test]
fn angle_and_unitless_comparisons_keep_their_dimensions() {
let mut doc = design();
doc.set_param("angle", "45 deg", Some("deg"), None).unwrap();
doc.set_param("count", "1", Some(""), None).unwrap();
doc.configs.columns.push(Column::Param("count".into()));
doc.configs.rows[0].cells.push("1".into());
doc.configs.rules.push(rule(
"angle-drive",
RuleCondition::Parameter { parameter: "angle".into(), operator: RuleOperator::Gt, expression: "1".into() },
RuleEffect::DriveParameter { parameter: "count".into(), expression: "2".into() },
));
let trace = doc.apply_configuration_with_trace("Default").unwrap();
assert!(trace[0].matched);
assert_eq!(doc.param_values().0["count"].v, 2.0);
doc.configs.rules[0].condition =
RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Eq, expression: "1 deg".into() };
let before = doc.clone();
assert!(doc.apply_configuration("Default").is_err());
assert_eq!(doc, before);
}
#[test]
fn saved_rule_budgets_are_checked_before_deserialization() {
let valid = serde_json::to_value(rule(
"r",
RuleCondition::Always,
RuleEffect::DriveParameter { parameter: "width".into(), expression: "2 mm".into() },
))
.unwrap();
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[valid.clone()]}})).is_ok());
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":null}})).is_err());
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":vec![valid.clone();129]}})).is_err());
let mut condition = serde_json::json!({"type":"always"});
for _ in 0..8 {
condition = serde_json::json!({"type":"not","condition":condition});
}
let mut deep = valid.clone();
deep["condition"] = condition;
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[deep]}})).is_err());
let mut wide = valid.clone();
wide["condition"] = serde_json::json!({"type":"all","conditions":vec![serde_json::json!({"type":"always"});64]});
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[wide]}})).is_err());
let mut long = valid.clone();
long["effect"]["expression"] = serde_json::json!("a".repeat(4097));
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[long]}})).is_err());
let mut malformed = valid;
malformed["condition"] = serde_json::json!({"type":"any","conditions":{}});
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[malformed]}})).is_err());
}
#[test]
fn compound_conditions_short_circuit_guarded_numeric_expressions() {
let mut doc = design();
doc.set_param("count", "0", Some(""), None).unwrap();
let guard = RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Gt, expression: "0".into() };
let invalid_when_zero = RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Gt, expression: "1 / count".into() };
doc.configs.rules = vec![rule(
"guarded",
RuleCondition::All { conditions: vec![guard, invalid_when_zero.clone()] },
RuleEffect::DriveParameter { parameter: "width".into(), expression: "20 mm".into() },
)];
assert!(!doc.apply_configuration_with_trace("Default").unwrap()[0].matched);
assert_eq!(doc.param_values().0["width"].v, 10.0);
doc.configs.rules[0].condition = RuleCondition::Any {
conditions: vec![
RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Eq, expression: "0".into() },
invalid_when_zero,
],
};
assert!(doc.apply_configuration_with_trace("Default").unwrap()[0].matched);
assert_eq!(doc.param_values().0["width"].v, 20.0);
}
#[test]
fn saved_documents_reject_malformed_deep_and_oversized_rules_before_typed_decode() {
let valid = serde_json::to_value(rule(
"r",
RuleCondition::Always,
RuleEffect::DriveParameter { parameter: "width".into(), expression: "2 mm".into() },
))
.unwrap();
let mut deep = valid.clone();
let mut condition = serde_json::json!({"type":"always"});
for _ in 0..8 {
condition = serde_json::json!({"type":"not","condition":condition});
}
deep["condition"] = condition;
let mut wide = valid.clone();
wide["condition"] = serde_json::json!({"type":"all","conditions":vec![serde_json::json!({"type":"always"});64]});
let mut invalid_context = valid.clone();
invalid_context["length_unit"] = serde_json::json!("mile".repeat(1000));
for invalid in [
Value::Null,
serde_json::json!([deep]),
serde_json::json!([wide]),
serde_json::json!(vec![valid; 129]),
serde_json::json!([invalid_context]),
] {
let mut saved = serde_json::to_value(design()).unwrap();
saved["configs"]["rules"] = invalid;
let error = crate::format::read(&saved.to_string()).unwrap_err().to_string();
assert!(error.contains("configuration rules"), "saved boundary must report bounded rule validation: {error}");
}
}
#[test]
fn cross_unit_effective_literals_keep_declared_drive_cycle_checks() {
let mut doc = Document::new("Cross-unit drive graph");
doc.add_feature(
crate::FeatureKind::Box {
corner: ["0".into(), "0".into(), "0".into()],
length: "10 mm".into(),
width: "5 mm".into(),
height: "10 mm".into(),
operation: crate::Operation::NewBody,
},
None,
)
.unwrap();
let width = doc.features[0].param_names[0].clone();
let depth = doc.features[0].param_names[1].clone();
doc.rename_param(&width, "width").unwrap();
doc.rename_param(&depth, "depth").unwrap();
doc.configs.columns = vec![Column::Param("width".into()), Column::Param("depth".into())];
doc.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
doc.units = "in".into();
let mut first =
rule("first", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "width".into(), expression: "depth + 1".into() });
first.length_unit = Some("mm".into());
let mut second =
rule("second", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width + 1".into() });
second.length_unit = Some("mm".into());
doc.configs.rules = vec![first];
doc.apply_configuration("Default").unwrap();
assert_eq!(doc.config_current(&Column::Param("width".into())).as_deref(), Some("6 mm"));
doc.configs.rules.push(second);
let before = doc.clone();
assert!(doc.apply_configuration("Default").is_err());
assert_eq!(doc, before);
}
}
+32 -3
View File
@@ -1586,7 +1586,7 @@ impl Document {
/// else its component's, else its material's look, else the colour it was imported with.
/// (Faces may have their own: `face_looks`.)
pub fn body_color(&self, b: &crate::ModelBody) -> Option<[f32; 3]> {
self.body_look(&b.name, b.feature).map(|l| l.rgb()).or_else(|| b.body.color()).or_else(|| b.imported.as_ref().and_then(|i| i.color))
self.model_body_look(b).map(|l| l.rgb()).or_else(|| b.body.color()).or_else(|| b.imported.as_ref().and_then(|i| i.color))
}
/// The model with each body carrying the colour, opacity and face colours it is shown with
@@ -1595,7 +1595,7 @@ impl Document {
let mut st = state.clone();
for b in &mut st.bodies {
let c = self.body_color(b);
let look = self.body_look(&b.name, b.feature);
let look = self.model_body_look(b);
let faces: Vec<solvecraft_kernel::FacePaint> = self
.face_colors(b)
.into_iter()
@@ -1739,9 +1739,22 @@ impl Document {
let idx = self.feature_index(id).ok_or_else(|| DocError::Unknown(format!("feature {id}")))?;
let mut gone = vec![id];
gone.extend(self.features.iter().filter(|f| f.kind.input_sketches().contains(&id)).map(|f| f.id));
let gone_params: Vec<String> = self.features.iter().filter(|f| gone.contains(&f.id)).flat_map(|f| f.param_names.clone()).collect();
self.configs
.rules
.retain(|rule| !rule.references_features(&gone) && !rule.parameter_references().iter().any(|name| gone_params.contains(name)));
self.features.retain(|f| !gone.contains(&f.id));
// Configuration columns of deleted features go with them.
let keep: Vec<bool> = self.configs.columns.iter().map(|c| !matches!(c, crate::config::Column::Suppress(id) if gone.contains(id))).collect();
let keep: Vec<bool> = self
.configs
.columns
.iter()
.map(|c| match c {
crate::config::Column::Suppress(id) => !gone.contains(id),
crate::config::Column::Param(name) => !gone_params.contains(name),
_ => true,
})
.collect();
if keep.contains(&false) {
let mut k = keep.iter();
self.configs.columns.retain(|_| k.next().copied().unwrap_or(true));
@@ -1768,6 +1781,19 @@ impl Document {
fn referenced_names(&self) -> Vec<String> {
let mut used: Vec<String> = Vec::new();
for rule in &self.configs.rules {
used.extend(rule.parameter_references());
}
for (index, column) in self.configs.columns.iter().enumerate() {
if let crate::config::Column::Param(name) = column {
used.push(name.clone());
for row in &self.configs.rows {
if let Some(value) = row.cells.get(index) {
used.extend(expr::references(value));
}
}
}
}
for f in &self.features {
for e in f.kind.expressions() {
used.extend(expr::references(e));
@@ -1960,6 +1986,9 @@ impl Document {
c.kind.set_value(v);
}
}
for c in &sk.constraints {
sk.validate(&c.kind)?;
}
Ok(())
}
}
+51 -8
View File
@@ -16,6 +16,10 @@ use crate::{AxisRef, DocError, Document, Feature, FeatureKind, HoleKind, Operati
#[derive(Clone, Debug)]
pub struct ModelBody {
pub name: String,
/// Definition identity for a computed placement; never parsed from its display name.
pub source_name: Option<String>,
/// Definition geometry coordinates to this computed body's frame.
pub source_transform: crate::Mat,
pub body: Body,
/// Feature that created it.
pub feature: u64,
@@ -35,7 +39,21 @@ pub struct ModelBody {
impl ModelBody {
pub fn new(name: String, body: Body, feature: u64) -> Self {
ModelBody { name, body, feature, mesh: Arc::new(OnceLock::new()), key: 0, names: Default::default(), component: 0, imported: None }
ModelBody {
name,
source_name: None,
source_transform: crate::IDENTITY,
body,
feature,
mesh: Arc::new(OnceLock::new()),
key: 0,
names: Default::default(),
component: 0,
imported: None,
}
}
pub fn definition_name(&self) -> &str {
self.source_name.as_deref().unwrap_or(&self.name)
}
/// Display mesh (chord tolerance 1/1000 of the body size), computed once.
pub fn mesh(&self) -> Arc<Mesh> {
@@ -807,6 +825,8 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation
}
Operation::Cut | Operation::Intersect => {
let bop = if op == Operation::Cut { BoolOp::Cut } else { BoolOp::Intersect };
let affected: std::collections::BTreeSet<String> =
target_idx.iter().filter_map(|i| state.bodies.get(*i).map(|b| b.name.clone())).collect();
let mut remove = Vec::new();
for &ti in &target_idx {
let Some(mut acc) = state.bodies.get(ti).map(|b| Some(b.body.clone())) else { continue };
@@ -834,6 +854,10 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation
// A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)").
let mut split: Vec<ModelBody> = Vec::new();
for mb in std::mem::take(&mut state.bodies) {
if !affected.contains(&mb.name) || state.sheets.iter().any(|s| s.body == mb.name) {
split.push(mb);
continue;
}
let pieces = mb.body.lumps().unwrap_or_else(|_| vec![mb.body.clone()]);
if pieces.len() < 2 {
split.push(mb);
@@ -2370,11 +2394,21 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
if *operation == Operation::Cut && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() {
let handled = more::sheet_cut(f, st, &tools, targets)?;
if !handled.is_empty() {
let effective = if targets.is_empty() { &f.participants } else { targets };
let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*");
let rest: Vec<String> = st
.bodies
.iter()
.filter(|b| {
!handled.contains(&b.name)
&& (all
|| if effective.is_empty() {
b.component == f.component || b.feature == f.id
} else {
effective.contains(&b.name)
})
})
.map(|b| b.name.clone())
.filter(|n| !handled.contains(n) && (targets.is_empty() || targets.contains(n)))
.collect();
if rest.is_empty() {
return Ok(());
@@ -2873,14 +2907,19 @@ fn unique_body_name(st: &ModelState, base: &str) -> String {
/// face finds the face where it is shown and projects its edges there. The model itself when
/// every placement agrees (the usual case).
pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> {
state_in_frame_at(doc, st, comp, doc.component_transform(comp))
}
/// As `state_in_frame`, using the exact active placement for its shared source geometry.
pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, frame: Mat) -> std::borrow::Cow<'a, ModelState> {
use std::borrow::Cow;
if doc.occurrences.is_empty() && doc.body_offsets.is_empty() {
return Cow::Borrowed(st);
}
let Some(inv) = crate::mat_inverse(&doc.component_transform(comp)) else { return Cow::Borrowed(st) };
let Some(inv) = crate::mat_inverse(&frame) else { return Cow::Borrowed(st) };
let comp_of_feature = |id: u64| doc.feature(id).map(|f| f.component).unwrap_or(0);
let rel = |c: u64, body: Option<&str>| {
let m = crate::mat_mul(&inv, &doc.component_transform(c));
let m = crate::mat_mul(&inv, &if c == comp { frame } else { doc.component_transform(c) });
match body.and_then(|b| doc.body_offsets.get(b)) {
Some(o) => crate::mat_mul(&m, o),
None => m,
@@ -2900,6 +2939,9 @@ pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std:
let names = crate::naming::face_names(b);
let mut nb = ModelBody::new(b.name.clone(), t, b.feature);
nb.component = b.component;
nb.source_name = b.source_name.clone();
nb.source_transform = crate::mat_mul(m, &b.source_transform);
nb.imported = b.imported.clone();
nb.names = crate::naming::NameCell::known(names);
*b = nb;
}
@@ -2938,17 +2980,18 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
let mut out = ModelState { body_counter: st.body_counter, ..Default::default() };
for (comp, path, m) in doc.placements() {
let primary = path == first_path(comp);
let label = || -> String {
path.iter().filter_map(|id| doc.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect::<Vec<_>>().join("/")
};
let ident = crate::is_identity(&m);
for b in st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == comp) {
let name = if primary { b.name.clone() } else { format!("{} ({})", b.name, label()) };
let name = doc.world_body_name(st, &b.name, comp, &path);
// A body moved in from another component keeps its place (its offset).
let bm = doc.body_offsets.get(&b.name).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
let body =
if crate::is_identity(&bm) { b.body.clone() } else { kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone()) };
let mut placed = ModelBody::new(name, body, b.feature);
placed.source_name = Some(b.definition_name().to_string());
placed.source_transform = crate::mat_mul(&bm, &b.source_transform);
placed.component = comp;
placed.imported = b.imported.clone();
// Placement preserves topology: face ownership remains that of the
// source model, including modifiers on component instances.
placed.names = crate::naming::NameCell::known(crate::naming::face_names(b));
+13 -22
View File
@@ -497,17 +497,22 @@ pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], target
}
}
let mut handled = Vec::new();
let mut overlap_work = crate::sheet::FlatWork::new();
let targets = if targets.is_empty() { &f.participants } else { targets };
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
for i in 0..st.sheets.len() {
let Some(sh) = st.sheets.get(i).cloned() else { continue };
if !targets.is_empty() && !targets.contains(&sh.body) {
let eligible = all
|| if targets.is_empty() {
st.bodies.iter().any(|b| b.name == sh.body && (b.component == f.component || b.feature == f.id))
} else {
targets.contains(&sh.body)
};
if !eligible {
continue;
}
let candidates: Vec<(Option<usize>, Region2)> = if sh.flat {
if sh.frame.normal().cross(n).len() > 1e-6 {
Vec::new()
} else {
vec![(None, Region2 { outer: Loop2::polygon(&sh.outline()?.into_iter().next().unwrap_or_default()), holes: Vec::new() })]
}
if sh.frame.normal().cross(n).len() > 1e-6 { Vec::new() } else { sh.flat_regions()?.into_iter().map(|region| (None, region)).collect() }
} else {
sh.parallel_panels(&ss.plane)
};
@@ -520,11 +525,9 @@ pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], target
if z0.min(z1) < span.0 - 1e-6 || z0.max(z1) > span.1 + 1e-6 {
continue;
}
let outline = if sh.flat { sh.outline()?.into_iter().flatten().collect::<Vec<_>>() } else { panel.outer.polyline(1e-3) };
for r in &regions {
let Some(flat) = sh.region_to_flat(pi, &ss.plane, r) else { continue };
let fp = flat.outer.polyline(1e-3);
let overlaps = fp.iter().any(|p| point_in_poly(&outline, *p)) || outline.iter().any(|p| point_in_poly(&fp, *p));
let flat = sh.region_to_flat(pi, &ss.plane, r)?;
let overlaps = crate::sheet::flat_regions_overlap(&panel, &flat, &mut overlap_work)?;
if overlaps && !added.iter().any(|x: &Region2| x == &flat) {
added.push(flat);
}
@@ -542,18 +545,6 @@ pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], target
Ok(handled)
}
fn point_in_poly(poly: &[Vec2], p: Vec2) -> bool {
let mut inside = false;
let m = poly.len();
for i in 0..m {
let (Some(&a), Some(&b)) = (poly.get(i), poly.get((i + m - 1) % m)) else { continue };
if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x {
inside = !inside;
}
}
inside
}
/// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance
/// `level` from `at` along `n`: the edges used by one of them only, chained.
fn face_loops(mesh: &solvecraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Vec<Vec2>> {
+63 -5
View File
@@ -23,13 +23,15 @@
//! - 7: configuration tables can include body material and appearance columns. Version 6
//! rows need no transformation; the version stamp lets older readers request an update
//! 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.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 7;
pub const FORMAT: u32 = 8;
pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}")
@@ -42,7 +44,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; 6] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(())];
const JSON_STEPS: [JsonStep; 7] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| 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,
@@ -133,6 +135,7 @@ pub fn read(s: &str) -> Result<Document> {
)));
}
check_numbers(&v, 0)?;
crate::config::validate_rules_json(&v)?;
for step in JSON_STEPS.iter().skip(version as usize - 1).take((FORMAT - version) as usize) {
step(&mut v)?;
}
@@ -242,7 +245,7 @@ mod tests {
let upgraded = read(&legacy.to_string()).unwrap();
assert_eq!(upgraded, d, "the identity migration preserves the complete design");
assert_eq!(serde_json::to_value(&upgraded.configs).unwrap(), original_table);
assert_eq!(upgraded.format, "solvecraft/7");
assert_eq!(upgraded.format, format_string(FORMAT));
assert!(upgraded.configuration_matches());
legacy.as_object_mut().unwrap().remove("configs");
assert!(read(&legacy.to_string()).unwrap().configs.is_empty());
@@ -251,7 +254,7 @@ mod tests {
}
#[test]
fn version_seven_styles_roundtrip_and_newer_versions_are_refused_before_columns() {
fn version_seven_styles_upgrade_and_newer_versions_are_refused_before_columns() {
use crate::config::{Column, ConfigRow};
let mut d = Document::new("configured styles");
d.configs.columns = vec![Column::Material("Body".into()), Column::Appearance("Body".into())];
@@ -261,17 +264,72 @@ mod tests {
}];
d.apply_configuration("Blue steel").unwrap();
let mut value: Value = serde_json::from_str(&d.to_json()).unwrap();
assert_eq!(value["format"], "solvecraft/7");
value["format"] = Value::from("solvecraft/7");
let original_table = value["configs"].clone();
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!(roundtrip.configuration_matches());
value["format"] = Value::from(format_string(FORMAT + 1));
value["configs"]["columns"] = serde_json::json!([{"unknown_future_column":"Body"}]);
value["configs"]["rules"] = serde_json::json!({"unknown_future_rule": true});
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("newer SolveCraft") && error.contains("update SolveCraft"), "{error}");
assert!(!error.contains("unknown variant"), "format refusal precedes decoding new variants");
}
#[test]
fn version_seven_origin_sketch_keeps_geometry_dimension_ids_and_expressions() {
use solvecraft_geom::Vec2;
use solvecraft_sketch::{ConstraintKind, Sketch};
let mut d = Document::new("legacy origin dimensions");
d.set_param("reach", "25 mm", Some("mm"), None).unwrap();
let dim = d.new_model_param("reach", "mm");
let mut sketch = Sketch::new();
let end = sketch.add_point(Vec2::new(25.0, 0.0), Some("tip")).unwrap();
let line = sketch.add_line_pts(0, end, Some("edge")).unwrap();
sketch.add_constraint(ConstraintKind::Horizontal { l: line }, None).unwrap();
sketch.add_constraint(ConstraintKind::Length { l: line, value: 25.0 }, Some(dim)).unwrap();
d.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, Some("Dimensioned")).unwrap();
let mut legacy: Value = serde_json::from_str(&d.to_json()).unwrap();
legacy["format"] = Value::from("solvecraft/7");
let features = legacy["features"].clone();
let params = legacy["params"].clone();
let upgraded = read(&legacy.to_string()).unwrap();
assert_eq!(upgraded, d);
let upgraded_json: Value = serde_json::from_str(&upgraded.to_json()).unwrap();
assert_eq!(upgraded_json["features"], features);
assert_eq!(upgraded_json["params"], params);
assert_eq!(read(&upgraded.to_json()).unwrap(), upgraded);
}
#[test]
fn version_eight_persistent_angle_sweep_and_slot_dimensions_roundtrip() {
use solvecraft_geom::Vec2;
use solvecraft_sketch::{ConstraintKind, Sketch};
let mut d = Document::new("persistent typed dimensions");
let heading = d.new_model_param("45 deg", "deg");
let sweep = d.new_model_param("180 deg", "deg");
let overall = d.new_model_param("10 mm", "mm");
let mut sketch = Sketch::new();
let end = sketch.add_point(Vec2::new(2.0, 2.0), None).unwrap();
let line = sketch.add_line_pts(0, end, None).unwrap();
let a = sketch.add_arc(Vec2::ZERO, Vec2::new(0.0, 2.0), Vec2::new(0.0, -2.0), [None; 3], None).unwrap();
let b = sketch.add_arc(Vec2::new(6.0, 0.0), Vec2::new(6.0, -2.0), Vec2::new(6.0, 2.0), [None; 3], None).unwrap();
sketch.add_constraint(ConstraintKind::LineAngle { l: line, value: std::f64::consts::FRAC_PI_4 }, Some(heading)).unwrap();
sketch.add_constraint(ConstraintKind::ArcSweep { c: a, value: std::f64::consts::PI }, Some(sweep)).unwrap();
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!(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");
let error = read(&saved.to_string()).unwrap_err().to_string();
assert!(error.contains("newer SolveCraft") && !error.contains("unknown variant"), "{error}");
}
#[test]
fn bulk_input_repair_preserves_existing_names_and_reuses_surplus_names() {
let mut d = Document::new("input repair");
+1 -1
View File
@@ -29,7 +29,7 @@ pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_iden
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, world_state,
state_in_frame, state_in_frame_at, world_state,
};
pub use params::{ParamRow, ParamUser, cycles};
pub use solvecraft_kernel as kernel;
+74
View File
@@ -502,6 +502,53 @@ impl Document {
Ok(())
}
/// Change existing expressions against their final dependency graph, independent of
/// column order. Unlike `change_param`, this never creates a missing parameter.
pub fn change_params_atomic(&mut self, changes: &[(String, String)]) -> Result<()> {
if changes.len() > crate::config::MAX_COLUMNS {
return Err(DocError::Invalid("too many parameter changes".into()));
}
let mut names = BTreeSet::new();
let mut changed = BTreeSet::new();
let (_, before_errors) = self.param_values();
let mut next = self.clone();
for (name, expression) in changes {
if !names.insert(name.clone()) {
return Err(DocError::Invalid(format!("duplicate parameter `{name}`")));
}
if expression.len() > 4096 || expression.trim().is_empty() {
return Err(DocError::Expr("expression is empty or too long".into()));
}
if let Some(p) = next.params.iter_mut().find(|p| p.name == *name) {
if p.expr != expression.trim() {
changed.insert(name.clone());
}
p.expr = expression.trim().to_string();
continue;
}
let hit = next.features.iter().enumerate().find_map(|(i, f)| f.param_names.iter().position(|n| n == name).map(|k| (i, k)));
let Some((i, k)) = hit else { return Err(DocError::Unknown(format!("parameter `{name}`"))) };
let kind = next.features.get(i).and_then(|f| f.kind.inputs().get(k).map(|x| x.2)).unwrap_or(Kind::Unitless);
let normalized = next.with_design_unit(expression, kind);
let Some(feature) = next.features.get_mut(i) else { return Err(DocError::Unknown(format!("parameter `{name}`"))) };
let Some((_, target, _)) = feature.kind.inputs_mut().into_iter().nth(k) else {
return Err(DocError::Unknown(format!("parameter `{name}`")));
};
if *target != normalized {
changed.insert(name.clone());
}
*target = normalized;
}
let (_, errors) = next.param_values();
for (name, error) in errors {
if changed.contains(&name) || !before_errors.contains_key(&name) {
return Err(DocError::Expr(format!("parameter `{name}`: {error}")));
}
}
*self = next;
Ok(())
}
/// The parameters table (user, sketch dimensions, feature inputs).
pub fn param_rows(&self) -> Vec<ParamRow> {
let (vals, errs) = self.param_values();
@@ -639,6 +686,8 @@ pub enum ParamUser {
Feature { feature: u64, name: String, input: String },
/// A sketch dimension (`constraint` is its id).
Dimension { feature: u64, name: String, constraint: String },
/// A configured column or an expression saved in a configuration row.
Configuration { row: Option<String>, column: String },
}
impl ParamUser {
@@ -647,6 +696,10 @@ impl ParamUser {
ParamUser::Parameter { name } => format!("parameter {name}"),
ParamUser::Feature { name, input, .. } => format!("{name} ({input})"),
ParamUser::Dimension { name, constraint, .. } => format!("{name} dimension {constraint}"),
ParamUser::Configuration { row, column } => match row {
Some(row) => format!("configuration `{row}` ({column})"),
None => format!("configuration column {column}"),
},
}
}
}
@@ -716,6 +769,24 @@ impl Document {
/// expressions refer to it, and the sketch dimensions it drives.
pub fn param_users(&self, name: &str) -> Vec<ParamUser> {
let mut out = Vec::new();
for rule in &self.configs.rules {
if rule.parameter_references().iter().any(|reference| reference == name) {
out.push(ParamUser::Configuration { row: None, column: format!("rule `{}`", rule.name) });
}
}
for (index, column) in self.configs.columns.iter().enumerate() {
if let crate::config::Column::Param(target) = column {
let key = self.config_key(column);
if target == name {
out.push(ParamUser::Configuration { row: None, column: key.clone() });
}
for row in &self.configs.rows {
if row.cells.get(index).is_some_and(|value| expr::references(value).iter().any(|reference| reference == name)) {
out.push(ParamUser::Configuration { row: Some(row.name.clone()), column: key.clone() });
}
}
}
}
for p in &self.params {
if p.name != name && expr::references(&p.expr).iter().any(|r| r == name) {
out.push(ParamUser::Parameter { name: p.name.clone() });
@@ -828,6 +899,9 @@ impl Document {
}
}
// Configuration cells refer to parameters too.
for rule in &mut self.configs.rules {
rule.rename_parameter(old, new);
}
for r in &mut self.configs.rows {
for (column, cell) in self.configs.columns.iter().zip(&mut r.cells) {
if matches!(column, crate::config::Column::Param(_)) {
+394 -124
View File
@@ -16,6 +16,118 @@ use solvecraft_kernel::{self as kernel, Body, EdgeSpec, FaceSpec, SurfSpec};
use crate::expr::{self, Kind, Value};
use crate::{DocError, Document, IDENTITY, Mat, Result, apply_point, apply_vector, mat_inverse, mat_mul};
// Bound the aggregate geometry, including sampled curves and intermediate clipping pieces.
// These are work limits, never geometry truncation: a larger input returns a useful error.
const MAX_FLAT_POINTS: usize = 16_384;
const MAX_FLAT_PIECES: usize = 4096;
const MAX_FLAT_WORK: usize = 500_000_000;
fn flat_budget_error() -> DocError {
DocError::Invalid("sheet metal flat geometry exceeds the bounded processing budget".into())
}
pub(crate) struct FlatWork(usize);
impl FlatWork {
pub(crate) fn new() -> Self {
Self(MAX_FLAT_WORK)
}
fn charge(&mut self, work: usize) -> Result<()> {
self.0 = self.0.checked_sub(work).ok_or_else(flat_budget_error)?;
Ok(())
}
}
fn flat_polyline(lp: &Loop2) -> Result<Vec<Vec2>> {
if lp.segs.len() > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
let mut points = Vec::new();
for seg in &lp.segs {
let p = seg.polyline(1e-3);
let add = p.len().saturating_sub(1);
if points.len().saturating_add(add) > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
points.extend(p.iter().take(add));
}
Ok(points)
}
fn flat_points(polys: &[Vec<Vec2>]) -> Result<usize> {
let n = polys.iter().try_fold(0usize, |n, p| n.checked_add(p.len()).ok_or_else(flat_budget_error))?;
if n > MAX_FLAT_POINTS || polys.len() > MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
Ok(n)
}
fn planar_region(region: &Region2) -> Result<Region2> {
if region.holes.len() > MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
region_complexity(std::slice::from_ref(region))?;
let convert = |lp: &Loop2| -> Result<Loop2> {
if lp.segs.iter().all(|s| matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) {
flat_polyline(lp)?;
Ok(lp.clone())
} else {
// Keep free-form profiles through controlled 0.001 mm chord sampling.
Ok(Loop2::polygon(&flat_polyline(lp)?))
}
};
Ok(Region2 {
outer: convert(&region.outer)?.ccw(),
holes: region.holes.iter().map(|h| convert(h).map(|l| l.ccw().reversed())).collect::<Result<_>>()?,
})
}
fn region_complexity(regions: &[Region2]) -> Result<(usize, usize)> {
if regions.len() > MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
let (mut edges, mut points) = (0usize, 0usize);
for lp in regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)) {
edges = edges.saturating_add(lp.segs.len());
if edges > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
points = points.saturating_add(flat_polyline(lp)?.len());
if points > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
}
Ok((edges, points))
}
fn material_boolean(a: &[Region2], b: &[Region2], op: kernel::BoolOp, work: &mut FlatWork) -> Result<Vec<Region2>> {
let (ae, ap) = region_complexity(a)?;
let (be, bp) = region_complexity(b)?;
let edges = ae.saturating_add(be);
work.charge(edges.saturating_mul(edges).saturating_add(edges.saturating_mul(ap.saturating_add(bp))).saturating_mul(8))?;
let regions = kernel::planar_regions_boolean(a, b, op, 1e-7)?;
region_complexity(&regions)?;
Ok(regions)
}
// Merge in balanced rounds so hundreds of disjoint holes or adjoining panels do not
// repeatedly arrange a growing accumulated boundary against just one small region.
fn material_union(regions: Vec<Region2>, work: &mut FlatWork) -> Result<Vec<Region2>> {
region_complexity(&regions)?;
let mut groups: Vec<Vec<Region2>> = regions.into_iter().map(|r| vec![r]).collect();
while groups.len() > 1 {
let mut next = Vec::new();
let mut iter = groups.into_iter();
while let Some(a) = iter.next() {
match iter.next() {
Some(b) => next.push(material_boolean(&a, &b, kernel::BoolOp::Union, work)?),
None => next.push(a),
}
}
groups = next;
}
Ok(groups.pop().unwrap_or_default())
}
/// A sheet metal rule. Values are expressions; `Thickness` inside them is the rule's thickness.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SheetRule {
@@ -424,65 +536,70 @@ impl SheetBody {
}
}
/// The pieces of the sheet as cells: base and flange panels and bend zones, each less the
/// cut-outs over it (cut-outs inside a panel stay exact holes; ones crossing a piece's edge
/// must be convex polygons).
fn cells(&self) -> Result<Vec<Cell>> {
let mut pieces: Vec<(Piece, Vec<Vec2>)> =
self.panels().into_iter().map(|(pi, r)| (Piece::Panel(pi), dedup(&r.outer.ccw().polyline(1e-3)))).collect();
for (i, f) in self.flanges.iter().enumerate() {
pieces.push((Piece::Zone(i), dedup(&self.zone(f).outer.ccw().polyline(1e-3))));
/// The pieces of the sheet as cells: base and flange panels and bend zones, each less
/// the combined cut-out material. Planar clipping retains holes and disconnected islands.
fn cells_with_work(&self, work: &mut FlatWork) -> Result<Vec<Cell>> {
if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES || self.holes.len() > MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
let raw = self
.flanges
.iter()
.fold(self.base.outer.segs.len(), |n, f| n.saturating_add(f.shape.as_ref().map_or(4, Vec::len)))
.saturating_add(self.holes.iter().fold(0usize, |n, h| n.saturating_add(h.outer.segs.len())));
if raw > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
let mut pieces: Vec<(Piece, Region2)> = self.panels().into_iter().map(|(pi, r)| (Piece::Panel(pi), r)).collect();
for (i, f) in self.flanges.iter().enumerate() {
pieces.push((Piece::Zone(i), self.zone(f)));
}
let mut cuts = Vec::new();
let mut cut_points = 0usize;
for hole in &self.holes {
let region = planar_region(hole)?;
cut_points = cut_points.saturating_add(region_complexity(std::slice::from_ref(&region))?.1);
if cut_points > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
cuts.push(region);
}
let cuts = material_union(cuts, work)?;
let mut out = Vec::new();
for (piece, poly) in pieces {
let mut inner: Vec<Vec<Seg2>> = Vec::new();
let mut crossing: Vec<Vec<Vec2>> = Vec::new();
for h in &self.holes {
let hp = dedup(&h.outer.ccw().polyline(1e-3));
if matches!(piece, Piece::Panel(_)) && hp.iter().all(|p| point_in(&poly, *p)) {
inner.push(h.outer.ccw().reversed().segs.clone());
} else if hp.iter().any(|p| point_in(&poly, *p)) || poly.iter().any(|p| point_in(&hp, *p)) || polys_cross(&poly, &hp) {
if !is_convex(&hp) {
return Err(DocError::Invalid(
"not supported yet: cut-outs over a panel's edge or a bend that are not convex polygons".into(),
));
}
crossing.push(hp);
let mut points = 0usize;
for (piece, region) in pieces {
let panel = planar_region(&region)?;
let material = if cuts.is_empty() { vec![panel] } else { material_boolean(&[panel], &cuts, kernel::BoolOp::Cut, work)? };
for region in material {
points = points.saturating_add(region_complexity(std::slice::from_ref(&region))?.1);
if points > MAX_FLAT_POINTS || out.len() >= MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
}
if crossing.is_empty() {
let outer = Loop2::polygon(&poly).segs;
out.push(Cell {
piece,
outer: if matches!(piece, Piece::Panel(None)) { self.base.outer.ccw().segs.clone() } else { outer },
holes: inner,
});
continue;
}
let mut parts = vec![poly];
for h in &crossing {
parts = parts.iter().flat_map(|p| convex_diff(p, h)).collect();
}
let loops = merge_loops(&parts);
let (outers, holes): (Vec<Vec<Vec2>>, Vec<Vec<Vec2>>) = loops.into_iter().partition(|l| signed_area(l) > 0.0);
for o in outers {
let mut hs: Vec<Vec<Seg2>> =
holes.iter().filter(|h| h.first().is_some_and(|p| point_in(&o, *p))).map(|h| Loop2::polygon(h).segs).collect();
hs.extend(inner.iter().filter(|h| h.first().is_some_and(|s| point_in(&o, s.start()))).cloned());
out.push(Cell { piece, outer: Loop2::polygon(&o).segs, holes: hs });
out.push(Cell { piece, outer: region.outer.segs, holes: region.holes.into_iter().map(|l| l.segs).collect() });
}
}
Ok(out)
}
/// Faces of the solid made by `cells` between heights `z0` and `z1`.
fn faces(&self, cells: &[Cell], z0: f64, z1: f64) -> Result<Vec<FaceSpec>> {
fn faces(&self, cells: &[Cell], z0: f64, z1: f64, work: &mut FlatWork) -> Result<Vec<FaceSpec>> {
// Split straight edges where other cells' corners touch them, so neighbours share edges.
let edges = cells
.iter()
.fold(0usize, |n, c| n.saturating_add(c.outer.len()).saturating_add(c.holes.iter().fold(0usize, |m, h| m.saturating_add(h.len()))));
if edges > MAX_FLAT_POINTS || cells.len() > MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
work.charge(edges.saturating_mul(edges).saturating_mul(4))?;
let corners: Vec<Vec2> = cells.iter().flat_map(|c| c.outer.iter().chain(c.holes.iter().flatten()).map(Seg2::start)).collect();
let split_loop = |segs: &[Seg2]| -> Vec<Seg2> {
let mut split_edges = 0usize;
let mut split_loop = |segs: &[Seg2]| -> Result<Vec<Seg2>> {
let mut v = Vec::new();
for e in segs {
let Seg2::Line { a, b } = *e else {
split_edges += 1;
if split_edges > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
v.push(*e);
continue;
};
@@ -499,18 +616,31 @@ impl SheetBody {
ts.dedup_by(|x, y| (*x - *y).abs() < 1e-12);
let mut prev = a;
for t in ts {
split_edges += 1;
if split_edges > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
let q = a + d * t;
v.push(Seg2::Line { a: prev, b: q });
prev = q;
}
split_edges += 1;
if split_edges > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
v.push(Seg2::Line { a: prev, b });
}
v
Ok(v)
};
let cells: Vec<Cell> = cells
.iter()
.map(|c| Cell { piece: c.piece, outer: split_loop(&c.outer), holes: c.holes.iter().map(|h| split_loop(h)).collect() })
.collect();
let mut split_cells = Vec::new();
for c in cells {
let outer = split_loop(&c.outer)?;
let holes = c.holes.iter().map(|h| split_loop(h)).collect::<Result<_>>()?;
split_cells.push(Cell { piece: c.piece, outer, holes });
}
// Reserve the complete adjacency scan before visiting any wall.
work.charge(split_edges.saturating_mul(split_edges).saturating_mul(2))?;
let cells = split_cells;
let near = |p: Vec2, q: Vec2| p.dist(q) < 1e-6;
// Shared with a neighbour: the same flat edge the other way, landing in the same place
// (flat neighbours across a corner of two bends do not).
@@ -602,40 +732,78 @@ impl SheetBody {
if self.flat {
return self.flat_solid();
}
Ok(kernel::sew(&self.faces(&self.cells()?, 0.0, self.t)?)?)
let mut work = FlatWork::new();
let cells = self.cells_with_work(&mut work)?;
Ok(kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?)
}
/// The flat pattern as one extrusion (outline with its cut-outs).
pub fn flat_solid(&self) -> Result<Body> {
let loops = self.outline()?;
let mut loops: Vec<Loop2> = loops.into_iter().map(|l| Loop2::polygon(&l)).collect();
loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs()));
let mut it = loops.into_iter();
let outer = it.next().ok_or_else(|| DocError::Invalid("empty flat pattern".into()))?.ccw();
let mut holes: Vec<Loop2> = it.map(|l| l.ccw().reversed()).collect();
let op = outer.polyline(1e-3);
for h in &self.holes {
let hp = h.outer.polyline(1e-3);
if hp.iter().all(|p| point_in(&op, *p)) {
holes.push(h.outer.ccw().reversed());
} else {
return Err(DocError::Invalid("not supported yet: cut-outs over the flat pattern's edge".into()));
}
}
let regions = self.flat_regions()?;
let pl = plane_of(&self.frame_mat(), Vec3::ZERO, Vec3::X, Vec3::Y)?;
kernel::extrude(&pl, &[Region2 { outer, holes }], 0.0, self.t)?.into_iter().next().ok_or_else(|| DocError::Invalid("flat pattern".into()))
Ok(kernel::extrude_planar_regions(&pl, &regions, 0.0, self.t)?)
}
/// Actual flat material boundaries after cut-outs. The same panel and cut regions build
/// the folded solid; planar unions remove panel/bend seams and turn edge-crossing cuts
/// into notches. Lines and circular arcs remain exact through clipping and merging.
pub fn flat_regions(&self) -> Result<Vec<Region2>> {
let mut work = FlatWork::new();
// Merge structural pieces before cutting: clipping each panel first introduces
// coincident notch endpoints at seams, while a single material cut has no seams.
if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES || self.holes.len() > MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
let raw = self.flanges.iter().fold(self.base.outer.segs.len(), |n, f| n.saturating_add(f.shape.as_ref().map_or(4, Vec::len)));
if raw > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
let mut regions = Vec::new();
let mut panel_points = 0usize;
for (_, r) in self.panels() {
let r = planar_region(&r)?;
panel_points = panel_points.saturating_add(region_complexity(std::slice::from_ref(&r))?.1);
if panel_points > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
regions.push(r);
}
for f in &self.flanges {
regions.push(planar_region(&self.zone(f))?);
region_complexity(&regions)?;
}
let regions = material_union(regions, &mut work)?;
let mut cuts = Vec::new();
let mut cut_points = 0usize;
for hole in &self.holes {
let region = planar_region(hole)?;
cut_points = cut_points.saturating_add(region_complexity(std::slice::from_ref(&region))?.1);
if cut_points > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
cuts.push(region);
}
let cuts = material_union(cuts, &mut work)?;
let regions = if cuts.is_empty() { regions } else { material_boolean(&regions, &cuts, kernel::BoolOp::Cut, &mut work)? };
if regions.is_empty() {
return Err(DocError::Invalid("empty flat pattern".into()));
}
Ok(regions)
}
/// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out.
pub fn outline(&self) -> Result<Vec<Vec<Vec2>>> {
let mut polys: Vec<Vec<Vec2>> = vec![dedup(&self.base.outer.ccw().polyline(1e-3))];
for f in &self.flanges {
polys.push(dedup(&self.zone(f).outer.ccw().polyline(1e-3)));
if f.leg > 1e-9 {
polys.push(dedup(&self.panel(f).outer.ccw().polyline(1e-3)));
}
if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
let loops = merge_loops(&polys);
let mut polys: Vec<Vec<Vec2>> = vec![dedup(&flat_polyline(&self.base.outer.ccw())?)];
for f in &self.flanges {
polys.push(dedup(&flat_polyline(&self.zone(f).outer.ccw())?));
if f.leg > 1e-9 {
polys.push(dedup(&flat_polyline(&self.panel(f).outer.ccw())?));
}
flat_points(&polys)?;
}
let loops = merge_loops(&polys, &mut FlatWork::new())?;
if loops.is_empty() {
return Err(DocError::Invalid("empty flat pattern".into()));
}
@@ -670,13 +838,13 @@ impl SheetBody {
/// the sewn (faces, edges, vertices).
pub fn round_hole_correction(&self) -> (i64, i64, i64) {
let (mut df, mut de, mut dv) = (0i64, 0i64, 0i64);
for h in &self.holes {
let Ok(regions) = self.flat_regions() else { return (0, 0, 0) };
for h in regions.iter().flat_map(|r| &r.holes) {
let arcs: Vec<(Vec2, f64)> =
h.outer.segs.iter().filter_map(|s| if let Seg2::Arc { center, radius, .. } = s { Some((*center, *radius)) } else { None }).collect();
let n = h.outer.segs.len() as i64;
let full = !arcs.is_empty()
&& arcs.len() == h.outer.segs.len()
&& arcs.iter().all(|(c, r)| c.dist(arcs[0].0) < 1e-9 && (r - arcs[0].1).abs() < 1e-9);
h.segs.iter().filter_map(|s| if let Seg2::Arc { center, radius, .. } = s { Some((*center, *radius)) } else { None }).collect();
let n = h.segs.len() as i64;
let full =
!arcs.is_empty() && arcs.len() == h.segs.len() && arcs.iter().all(|(c, r)| c.dist(arcs[0].0) < 1e-9 && (r - arcs[0].1).abs() < 1e-9);
if full && n > 1 {
df += 1 - n;
de += 2 - 3 * n;
@@ -688,7 +856,7 @@ impl SheetBody {
/// Bounding size of the flat pattern (x, y in the flat frame).
pub fn flat_size(&self) -> Result<(f64, f64)> {
let loops = self.outline()?;
let loops: Vec<Vec<Vec2>> = self.flat_regions()?.iter().map(|r| r.outer.polyline(1e-3)).collect();
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
for p in loops.iter().flatten() {
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
@@ -1114,8 +1282,9 @@ impl SheetBody {
}
/// Map a region in a world plane to flat coordinates through a panel's placement.
pub fn region_to_flat(&self, panel: Option<usize>, plane: &Plane, r: &Region2) -> Option<Region2> {
let inv = mat_inverse(&self.world(panel))?;
pub fn region_to_flat(&self, panel: Option<usize>, plane: &Plane, r: &Region2) -> Result<Region2> {
region_complexity(std::slice::from_ref(r))?;
let inv = mat_inverse(&self.world(panel)).ok_or_else(|| DocError::Invalid("invalid sheet panel frame".into()))?;
let map = |p: Vec2| {
let q = apply_point(&inv, plane.to_world(p));
Vec2::new(q.x, q.y)
@@ -1138,16 +1307,18 @@ impl SheetBody {
other => Seg2::Line { a: map(other.start()), b: map(other.end()) },
}
};
let curved_other = r.outer.segs.iter().any(|x| !matches!(x, Seg2::Line { .. } | Seg2::Arc { .. }));
let mut lp = if curved_other {
Loop2::polygon(&r.outer.polyline(1e-3).into_iter().map(map).collect::<Vec<_>>())
} else {
Loop2 { segs: r.outer.segs.iter().map(seg).collect() }
let map_loop = |lp: &Loop2| -> Result<Loop2> {
let curved_other = lp.segs.iter().any(|x| !matches!(x, Seg2::Line { .. } | Seg2::Arc { .. }));
if curved_other {
Ok(Loop2::polygon(&flat_polyline(lp)?.into_iter().map(map).collect::<Vec<_>>()))
} else {
Ok(Loop2 { segs: lp.segs.iter().map(seg).collect() })
}
};
if lp.signed_area() < 0.0 {
lp = lp.reversed();
}
Some(Region2 { outer: lp, holes: Vec::new() })
Ok(Region2 {
outer: map_loop(&r.outer)?.ccw(),
holes: r.holes.iter().map(|h| map_loop(h).map(|l| l.ccw().reversed())).collect::<Result<_>>()?,
})
}
/// Panels whose plane is parallel to `plane` (world): (panel, region).
@@ -1178,7 +1349,9 @@ fn point_in(poly: &[Vec2], p: Vec2) -> bool {
/// The boundary of a union of counter-clockwise polygons that meet edge to edge: edges are
/// split where corners touch them, edges running both ways cancel, the rest chain into loops
/// (outer loops counter-clockwise, holes clockwise; straight runs merged).
fn merge_loops(polys: &[Vec<Vec2>]) -> Vec<Vec<Vec2>> {
fn merge_loops(polys: &[Vec<Vec2>], work: &mut FlatWork) -> Result<Vec<Vec<Vec2>>> {
let n = flat_points(polys)?;
work.charge(n.saturating_mul(n).saturating_mul(2))?;
let mut segs: Vec<(Vec2, Vec2)> = Vec::new();
for p in polys {
let m = p.len();
@@ -1208,9 +1381,13 @@ fn merge_loops(polys: &[Vec<Vec2>]) -> Vec<Vec<Vec2>> {
ts.sort_by(|x, y| x.total_cmp(y));
ts.dedup_by(|x, y| (*x - *y).abs() < 1e-12);
for w in ts.windows(2) {
if split.len() >= MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
split.push((*a + d * w[0], *a + d * w[1]));
}
}
work.charge(split.len().saturating_mul(split.len()).saturating_mul(2))?;
let same = |p: Vec2, q: Vec2| p.dist(q) < 1e-6;
let keep: Vec<(Vec2, Vec2)> = split
.iter()
@@ -1248,7 +1425,7 @@ fn merge_loops(polys: &[Vec<Vec2>]) -> Vec<Vec<Vec2>> {
loops.push(clean);
}
}
loops
Ok(loops)
}
fn signed_area(p: &[Vec2]) -> f64 {
@@ -1256,27 +1433,6 @@ fn signed_area(p: &[Vec2]) -> f64 {
(0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::<f64>() / 2.0
}
/// A convex polygon minus a convex polygon (both counter-clockwise), as convex pieces.
fn convex_diff(a: &[Vec2], h: &[Vec2]) -> Vec<Vec<Vec2>> {
let mut out = Vec::new();
let mut rest = a.to_vec();
let m = h.len();
for i in 0..m {
let (Some(&p), Some(&q)) = (h.get(i), h.get((i + 1) % m)) else { continue };
let Some(d) = (q - p).normalized() else { continue };
let left = Vec2::new(-d.y, d.x);
let outside = dedup(&clip(&rest, p, left * -1.0));
if outside.len() >= 3 && signed_area(&outside).abs() > 1e-12 {
out.push(outside);
}
rest = dedup(&clip(&rest, p, left));
if rest.len() < 3 {
break;
}
}
out
}
/// Do two polygons' edges cross?
fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool {
let seg = |p: &[Vec2], i: usize| -> Option<(Vec2, Vec2)> { Some((*p.get(i)?, *p.get((i + 1) % p.len())?)) };
@@ -1291,25 +1447,24 @@ fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool {
})
}
fn is_convex(p: &[Vec2]) -> bool {
let m = p.len();
(0..m).all(|i| match (p.get(i), p.get((i + 1) % m), p.get((i + 2) % m)) {
(Some(a), Some(b), Some(c)) => (*b - *a).cross(*c - *b) >= -1e-9,
_ => true,
})
/// Bounded overlap predicate, including crossing-only cuts whose vertices lie outside a panel.
pub(crate) fn flat_regions_overlap(a: &Region2, b: &Region2, work: &mut FlatWork) -> Result<bool> {
let (a, b) = (planar_region(a)?, planar_region(b)?);
Ok(!material_boolean(&[a], &[b], kernel::BoolOp::Intersect, work)?.is_empty())
}
/// A sheet from a closed profile (base flange): the region in `plane`, thickness along its normal.
pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region: Region2) -> SheetBody {
let holes = region.holes.into_iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect();
SheetBody {
body: body.into(),
rule: rule.into(),
t: rv.thickness,
k: rv.k_factor,
frame: plane,
base: region,
base: Region2 { outer: region.outer, holes: Vec::new() },
flanges: Vec::new(),
holes: Vec::new(),
holes,
flat: false,
gap: rv.gap,
base_curves: Vec::new(),
@@ -1510,4 +1665,119 @@ mod tests {
s.flat = true;
assert!(s.solid().is_ok());
}
#[test]
fn disconnected_material_keeps_each_hole_with_its_own_region() {
let mut s = plate(100.0, 60.0);
s.holes.push(rect(Vec2::new(45.0, -5.0), Vec2::new(55.0, -5.0), Vec2::new(55.0, 65.0), Vec2::new(45.0, 65.0)));
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 30.0), 5.0), holes: vec![] });
let regions = s.flat_regions().unwrap();
assert_eq!(regions.len(), 2);
let left = regions.iter().find(|r| r.outer.contains(Vec2::new(10.0, 10.0))).unwrap();
let right = regions.iter().find(|r| r.outer.contains(Vec2::new(90.0, 10.0))).unwrap();
assert_eq!(left.holes.len(), 1);
assert!(left.holes[0].segs.iter().all(|s| matches!(s, Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9)));
assert!(right.holes.is_empty());
let area: f64 = regions.iter().map(Region2::area).sum();
assert!((area - (5400.0 - std::f64::consts::PI * 25.0)).abs() < 1e-6);
assert!((vol(&s.flat_solid().unwrap()) - area * 2.5).abs() < 0.1);
}
#[test]
fn ring_cut_retains_exact_central_material_island() {
let mut s = plate(100.0, 60.0);
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(50.0, 30.0), 10.0), holes: vec![Loop2::circle(Vec2::new(50.0, 30.0), 5.0)] });
let regions = s.flat_regions().unwrap();
assert_eq!(regions.len(), 2);
let island = regions.iter().find(|r| r.outer.contains(Vec2::new(50.0, 30.0))).unwrap();
assert!(island.holes.is_empty());
assert!(island.outer.segs.iter().all(|s| matches!(s, Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9)));
let area: f64 = regions.iter().map(Region2::area).sum();
assert!((area - (6000.0 - std::f64::consts::PI * 75.0)).abs() < 1e-6);
assert!((vol(&s.flat_solid().unwrap()) - area * 2.5).abs() < 0.1);
}
#[test]
fn freeform_profile_uses_bounded_sampling_with_exact_round_holes() {
let mut s = plate(100.0, 60.0);
s.base.outer.segs = vec![
Seg2::Line { a: Vec2::ZERO, b: Vec2::new(100.0, 0.0) },
Seg2::Line { a: Vec2::new(100.0, 0.0), b: Vec2::new(100.0, 60.0) },
Seg2::Cubic { p0: Vec2::new(100.0, 60.0), p1: Vec2::new(200.0 / 3.0, 80.0), p2: Vec2::new(100.0 / 3.0, 80.0), p3: Vec2::new(0.0, 60.0) },
Seg2::Line { a: Vec2::new(0.0, 60.0), b: Vec2::ZERO },
];
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(50.0, 30.0), 3.0), holes: vec![] });
let regions = s.flat_regions().unwrap();
assert!(regions[0].outer.segs.iter().all(|s| matches!(s, Seg2::Line { .. })));
assert!(regions[0].holes[0].segs.iter().all(|s| matches!(s, Seg2::Arc { .. })));
// The cubic has x=100(1-t), y=60+60t(1-t), hence an exact blank area of 7000.
let expected = (7000.0 - std::f64::consts::PI * 9.0) * 2.5;
assert!((vol(&s.solid().unwrap()) - expected).abs() < 0.5);
assert!((vol(&s.flat_solid().unwrap()) - expected).abs() < 0.5);
}
#[test]
fn flat_geometry_budgets_reject_aggregate_and_intermediate_work() {
let mut s = plate(100.0, 60.0);
// Each arc is individually legal, but their combined sampled points exceed the cap.
s.base.outer.segs = vec![Seg2::Arc { center: Vec2::ZERO, radius: 1000.0, start: 0.0, sweep: std::f64::consts::TAU }; 8];
assert!(s.flat_regions().unwrap_err().to_string().contains("budget"));
let mut s = plate(100.0, 60.0);
s.holes = vec![Region2 { outer: Loop2::circle(Vec2::new(20.0, 20.0), 1.0), holes: vec![] }; MAX_FLAT_PIECES + 1];
assert!(s.flat_regions().unwrap_err().to_string().contains("budget"));
let p = vec![vec![Vec2::ZERO; MAX_FLAT_POINTS]];
assert!(merge_loops(&p, &mut FlatWork::new()).unwrap_err().to_string().contains("budget"));
// Exhausting a clipping/merge allowance must report an error before work begins.
assert!(merge_loops(&[vec![Vec2::ZERO, Vec2::X, Vec2::Y]], &mut FlatWork(0)).is_err());
}
#[test]
fn overlapping_round_cutouts_union_with_exact_arc_boundaries() {
let mut s = plate(100.0, 60.0);
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 30.0), 5.0), holes: vec![] });
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(22.0, 30.0), 5.0), holes: vec![] });
let regions = s.flat_regions().unwrap();
assert_eq!(regions[0].holes.len(), 1);
assert!(regions[0].holes[0].segs.iter().all(|s| matches!(s, Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9)));
let intersection = 50.0 * (0.2f64).acos() - (96.0f64).sqrt();
let area = 6000.0 - (50.0 * std::f64::consts::PI - intersection);
assert!((regions[0].area() - area).abs() < 1e-6);
assert!((vol(&s.solid().unwrap()) - area * 2.5).abs() < 0.1);
}
#[test]
fn nested_and_repeated_round_cutouts_keep_only_the_largest_exact_boundary() {
for reverse in [false, true] {
let mut s = plate(100.0, 60.0);
let mut radii = vec![3.0, 5.0, 5.0];
if reverse {
radii.reverse();
}
for radius in radii {
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 30.0), radius), holes: vec![] });
}
let regions = s.flat_regions().unwrap();
assert_eq!(regions[0].holes.len(), 1);
assert!((regions[0].area() - (6000.0 - std::f64::consts::PI * 25.0)).abs() < 1e-6);
let volume = vol(&s.solid().unwrap());
// Body measure tessellates curved faces; the exact region area above is the
// stronger analytic check, while this follows the existing sheet measure budget.
assert!((volume - (6000.0 - std::f64::consts::PI * 25.0) * 2.5).abs() < 0.1, "{volume}");
assert_eq!(s.round_hole_correction(), (-1, -4, -2));
}
}
#[test]
fn sewing_budget_rejects_edge_splitting_growth_before_allocating_faces() {
let s = plate(100.0, 60.0);
let cells: Vec<Cell> = (0..250)
.map(|i| Cell {
piece: Piece::Panel(None),
outer: Loop2::polygon(&[Vec2::new(i as f64, 0.0), Vec2::new(250.0, 0.0), Vec2::new(i as f64, 1.0)]).segs,
holes: vec![],
})
.collect();
assert!(s.faces(&cells, 0.0, 2.5, &mut FlatWork::new()).unwrap_err().to_string().contains("budget"));
assert!(s.faces(&cells[..1], 0.0, 2.5, &mut FlatWork(0)).unwrap_err().to_string().contains("budget"));
}
}