feat: strengthen modeling, configuration, and assembly workflows
Preserve linked sketch identities through deterministic face projection and resizing. Fix origin-corner picking, persistent inline navigation, appearance editing, configuration styles, component properties, contact sweeps, sheet exports, analytic STEP offsets, and bounded rolling-ball fit accuracy. Validated with full cargo xtask ci, native workflow screenshots, repeated enclosure replays, and analytic geometry regressions.
This commit is contained in:
1 parent
22672585c9
commit
d71bdb78ec
54 files changed
+4179
-316
No files matched your search
@@ -1,6 +1,6 @@
|
||||
//! Configurations: a table whose rows are variants of the design. Each column is a parameter
|
||||
//! (its expression per row) or a feature (suppressed or not per row); activating a row sets
|
||||
//! those values in the design, which then rebuilds as usual.
|
||||
//! expression, feature suppression, body material or body appearance. Activating a row sets
|
||||
//! those values in the design atomically, which then rebuilds as usual.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -13,12 +13,16 @@ pub enum Column {
|
||||
Param(String),
|
||||
/// A feature's suppression (by feature id).
|
||||
Suppress(u64),
|
||||
/// A body's physical material (library name, or Default to clear).
|
||||
Material(String),
|
||||
/// A body's appearance (library name, serialized Look, or empty to clear).
|
||||
Appearance(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ConfigRow {
|
||||
pub name: String,
|
||||
/// One cell per column: an expression, or "true"/"false" for suppression.
|
||||
/// One cell per column: expression, suppression, material name or appearance look.
|
||||
pub cells: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -39,28 +43,69 @@ impl ConfigTable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an appearance cell. Empty/Default follows the body's material; named library looks
|
||||
/// and JSON Look objects support both convenient editing and lossless custom colours.
|
||||
pub fn appearance_cell(value: &str) -> Result<Option<crate::appearance::Look>> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.eq_ignore_ascii_case("default") {
|
||||
return Ok(None);
|
||||
}
|
||||
if value.len() > 4096 {
|
||||
return Err(DocError::Invalid("appearance value too long".into()));
|
||||
}
|
||||
if let Some(look) = crate::appearance::library(value) {
|
||||
return Ok(Some(look));
|
||||
}
|
||||
let look: crate::appearance::Look =
|
||||
serde_json::from_str(value).map_err(|_| DocError::Invalid(format!("unknown configuration appearance `{value}`")))?;
|
||||
if look.name.chars().count() > 128 || !look.opacity.is_finite() || !(0.0..=1.0).contains(&look.opacity) {
|
||||
return Err(DocError::Invalid("appearance requires a name up to 128 characters and opacity in 0..1".into()));
|
||||
}
|
||||
Ok(Some(look))
|
||||
}
|
||||
|
||||
/// Parse a physical material cell, returning None for the default material.
|
||||
pub fn material_cell(value: &str) -> Result<Option<String>> {
|
||||
let name = crate::MATERIALS
|
||||
.iter()
|
||||
.find(|(n, _)| n.eq_ignore_ascii_case(value.trim()))
|
||||
.map(|(n, _)| *n)
|
||||
.ok_or_else(|| DocError::Invalid(format!("unknown configuration material `{value}`")))?;
|
||||
Ok((name != "Default").then(|| name.to_string()))
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// 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::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()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A column's display key: `param:<name>` or `suppress:<feature name>`.
|
||||
/// A column's display key, prefixed by its kind to distinguish targets with equal names.
|
||||
pub fn config_key(&self, c: &Column) -> String {
|
||||
match c {
|
||||
Column::Param(n) => format!("param:{n}"),
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the design to a row's values (all or nothing).
|
||||
pub fn apply_configuration(&mut self, row: &str) -> Result<()> {
|
||||
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();
|
||||
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()) {
|
||||
@@ -76,6 +121,22 @@ impl Document {
|
||||
let f = next.feature_mut(*id).ok_or_else(|| DocError::Unknown(format!("configuration `{row}`: feature {id} is gone")))?;
|
||||
f.suppressed = on;
|
||||
}
|
||||
Column::Material(body) => match material_cell(v)? {
|
||||
Some(material) => {
|
||||
next.materials.insert(body.clone(), material);
|
||||
}
|
||||
None => {
|
||||
next.materials.remove(body);
|
||||
}
|
||||
},
|
||||
Column::Appearance(body) => match appearance_cell(v)? {
|
||||
Some(look) => {
|
||||
next.appearances.bodies.insert(body.clone(), look);
|
||||
}
|
||||
None => {
|
||||
next.appearances.bodies.remove(body);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
next.configs.active = Some(row.to_string());
|
||||
@@ -86,9 +147,14 @@ impl Document {
|
||||
/// The active row's values all hold in the design.
|
||||
pub fn configuration_matches(&self) -> bool {
|
||||
let Some(r) = self.configs.active.as_ref().and_then(|a| self.configs.rows.iter().find(|r| r.name == *a)) else { return false };
|
||||
if r.cells.len() != self.configs.columns.len() {
|
||||
return false;
|
||||
}
|
||||
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::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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -114,4 +180,21 @@ mod tests {
|
||||
assert!(d.apply_configuration("Bad").is_err());
|
||||
assert_eq!(d, before, "all or nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_saved_appearance_rolls_back_material_and_parameters() {
|
||||
let mut d = Document::new("A");
|
||||
d.set_param("width", "10 mm", None, None).unwrap();
|
||||
d.configs.columns = vec![Column::Param("width".into()), Column::Material("Body".into()), Column::Appearance("Body".into())];
|
||||
d.configs.rows.push(ConfigRow {
|
||||
name: "Broken".into(),
|
||||
cells: vec!["20 mm".into(), "Steel".into(), r#"{"name":"Invalid","color":[1,2,3],"opacity":2}"#.into()],
|
||||
});
|
||||
let before = d.clone();
|
||||
assert!(d.apply_configuration("Broken").is_err());
|
||||
assert_eq!(d, before);
|
||||
assert!(appearance_cell(r#"{"name":"Invalid","color":[1,2,3],"opacity":1e999}"#).is_err());
|
||||
assert!(appearance_cell(&"a".repeat(4097)).is_err());
|
||||
assert!(appearance_cell("default").unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,16 @@
|
||||
//! "don't mate". Older joints keep their placement: their `flip` is inverted.
|
||||
//! - 6: XZ origin planes use sketch y = -Z and normal +Y. Older XZ references become
|
||||
//! explicit planes retaining their previous axes, including offset-plane expressions.
|
||||
//! - 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.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The format this build writes.
|
||||
pub const FORMAT: u32 = 6;
|
||||
pub const FORMAT: u32 = 7;
|
||||
|
||||
pub fn format_string(n: u32) -> String {
|
||||
format!("solvecraft/{n}")
|
||||
@@ -39,7 +42,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; 5] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6];
|
||||
const JSON_STEPS: [JsonStep; 6] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| 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,
|
||||
@@ -210,6 +213,65 @@ fn normalise(d: &mut Document) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_six_configuration_rows_upgrade_without_changing_values_or_features() {
|
||||
use crate::config::{Column, ConfigRow};
|
||||
let mut d = Document::new("legacy configurations");
|
||||
d.set_param("width", "10 mm", None, None).unwrap();
|
||||
let feature = d
|
||||
.add_feature(
|
||||
crate::FeatureKind::Box {
|
||||
corner: ["0".into(), "0".into(), "0".into()],
|
||||
length: "width".into(),
|
||||
width: "20".into(),
|
||||
height: "5".into(),
|
||||
operation: crate::Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
d.configs.columns = vec![Column::Param("width".into()), Column::Suppress(feature)];
|
||||
d.configs.rows = vec![
|
||||
ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "false".into()] },
|
||||
ConfigRow { name: "Alternate".into(), cells: vec!["width / 2 + 5 mm".into(), "yes".into()] },
|
||||
];
|
||||
d.configs.active = Some("Default".into());
|
||||
let mut legacy: Value = serde_json::from_str(&d.to_json()).unwrap();
|
||||
legacy["format"] = Value::from("solvecraft/6");
|
||||
let original_table = legacy["configs"].clone();
|
||||
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!(upgraded.configuration_matches());
|
||||
legacy.as_object_mut().unwrap().remove("configs");
|
||||
assert!(read(&legacy.to_string()).unwrap().configs.is_empty());
|
||||
legacy["configs"] = serde_json::json!({"columns":[],"rows":[]});
|
||||
assert!(read(&legacy.to_string()).unwrap().configs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_seven_styles_roundtrip_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())];
|
||||
d.configs.rows = vec![ConfigRow {
|
||||
name: "Blue steel".into(),
|
||||
cells: vec!["Steel".into(), r#"{"name":"Custom blue","color":[40,90,186],"opacity":0.4}"#.into()],
|
||||
}];
|
||||
d.apply_configuration("Blue steel").unwrap();
|
||||
let mut value: Value = serde_json::from_str(&d.to_json()).unwrap();
|
||||
assert_eq!(value["format"], "solvecraft/7");
|
||||
let roundtrip = read(&value.to_string()).unwrap();
|
||||
assert_eq!(roundtrip, d);
|
||||
assert!(roundtrip.configuration_matches());
|
||||
value["format"] = Value::from(format_string(FORMAT + 1));
|
||||
value["configs"]["columns"] = serde_json::json!([{"unknown_future_column":"Body"}]);
|
||||
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 bulk_input_repair_preserves_existing_names_and_reuses_surplus_names() {
|
||||
let mut d = Document::new("input repair");
|
||||
|
||||
@@ -829,8 +829,10 @@ impl Document {
|
||||
}
|
||||
// Configuration cells refer to parameters too.
|
||||
for r in &mut self.configs.rows {
|
||||
for cell in &mut r.cells {
|
||||
*cell = expr::rename_reference(cell, old, new);
|
||||
for (column, cell) in self.configs.columns.iter().zip(&mut r.cells) {
|
||||
if matches!(column, crate::config::Column::Param(_)) {
|
||||
*cell = expr::rename_reference(cell, old, new);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(c) = self.param_comments.remove(old) {
|
||||
|
||||
@@ -20,6 +20,9 @@ const AXIS_HALF: f64 = 100.0;
|
||||
/// Points in one fitted chain at most (longer chains are thinned).
|
||||
const MAX_CHAIN: usize = 4000;
|
||||
|
||||
#[path = "project_order.rs"]
|
||||
mod order;
|
||||
|
||||
/// A resolved link: its geometry in sketch coordinates and the (possibly moved) reference.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Resolved {
|
||||
@@ -133,7 +136,7 @@ fn resolve_uncached(
|
||||
LinkKind::Intersect => section(plane, &mesh, Some(f)),
|
||||
_ => {
|
||||
let mut g = Vec::new();
|
||||
for e in mesh.face_edges(f) {
|
||||
for e in order::face_edges(&mesh, f, plane)? {
|
||||
if mesh.seams.get(e).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Stable face projection order: outer boundary first, then holes by geometry.
|
||||
//! B-rep edge indices depend on face enumeration and must not choose sketch curve IDs.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use solvecraft_geom::{Mesh, Plane, Vec3};
|
||||
|
||||
use crate::{DocError, Result};
|
||||
|
||||
type Key = (i64, i64, i64);
|
||||
|
||||
pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<usize>> {
|
||||
let bounds = mesh.bounds();
|
||||
let origin = bounds.min;
|
||||
let local_origin = plane.to_local(origin);
|
||||
let tol = (bounds.diagonal() * 1e-8).max(1e-9);
|
||||
if !tol.is_finite() {
|
||||
return Err(DocError::Invalid("face projection has invalid bounds".into()));
|
||||
}
|
||||
let key = |p: Vec3| -> Key {
|
||||
let p = p - origin;
|
||||
((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64)
|
||||
};
|
||||
let mut edges: Vec<usize> = mesh.face_edges(face).into_iter().filter(|e| !mesh.seams.get(*e).copied().unwrap_or(false)).collect();
|
||||
if edges.iter().any(|e| !mesh.edges.get(*e).is_some_and(|p| p.len() >= 2 && p.iter().all(|p| p.is_finite()))) {
|
||||
return Err(DocError::Invalid("face projection has an invalid boundary edge".into()));
|
||||
}
|
||||
if edges.len() > solvecraft_sketch::MAX_LINK_ENTITIES {
|
||||
return Err(DocError::Invalid("face projection exceeds the boundary edge limit".into()));
|
||||
}
|
||||
let geometry_key = |e: usize| {
|
||||
let pts = mesh.edges.get(e).map(Vec::as_slice).unwrap_or_default();
|
||||
let lo = pts.iter().copied().fold(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::min);
|
||||
let hi = pts.iter().copied().fold(Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), Vec3::max);
|
||||
(key(lo), key(hi))
|
||||
};
|
||||
edges.sort_by_key(|e| geometry_key(*e));
|
||||
let mut at: BTreeMap<Key, Vec<usize>> = BTreeMap::new();
|
||||
for e in &edges {
|
||||
if let Some(pts) = mesh.edges.get(*e) {
|
||||
for point in [pts.first(), pts.last()].into_iter().flatten() {
|
||||
at.entry(key(*point)).or_default().push(*e);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut used = vec![false; mesh.edges.len()];
|
||||
let mut boundaries = Vec::new();
|
||||
for start_edge in &edges {
|
||||
if used.get(*start_edge).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Some(pts) = mesh.edges.get(*start_edge) else { continue };
|
||||
let (Some(a), Some(b)) = (pts.first(), pts.last()) else { continue };
|
||||
let start = if key(*a) <= key(*b) { *a } else { *b };
|
||||
let mut current = start;
|
||||
let mut edge = *start_edge;
|
||||
let mut boundary = Vec::new();
|
||||
let mut twice_area = 0.0;
|
||||
for _ in 0..edges.len() {
|
||||
let Some(pts) = mesh.edges.get(edge) else { break };
|
||||
let (Some(a), Some(b)) = (pts.first(), pts.last()) else { break };
|
||||
let forward = current.dist(*a) <= current.dist(*b);
|
||||
for pair in pts.windows(2) {
|
||||
let (Some(a), Some(b)) = (pair.first(), pair.get(1)) else { continue };
|
||||
let (a, b) = (plane.to_local(*a) - local_origin, plane.to_local(*b) - local_origin);
|
||||
twice_area += if forward { a.cross(b) } else { b.cross(a) };
|
||||
}
|
||||
current = if forward { *b } else { *a };
|
||||
if let Some(slot) = used.get_mut(edge) {
|
||||
*slot = true;
|
||||
}
|
||||
boundary.push(edge);
|
||||
if current.dist(start) <= tol {
|
||||
break;
|
||||
}
|
||||
// Neighbouring quantization cells cover points on either side of a rounding
|
||||
// boundary. Connectivity is checked in world space, not in the projection.
|
||||
let (x, y, z) = key(current);
|
||||
let mut candidates = Vec::new();
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
for dz in -1..=1 {
|
||||
let cell = (x.saturating_add(dx), y.saturating_add(dy), z.saturating_add(dz));
|
||||
if let Some(list) = at.get(&cell) {
|
||||
candidates.extend(list.iter().copied().filter(|e| {
|
||||
!used.get(*e).copied().unwrap_or(true)
|
||||
&& mesh.edges.get(*e).is_some_and(|p| {
|
||||
p.first().is_some_and(|a| a.dist(current) <= tol) || p.last().is_some_and(|b| b.dist(current) <= tol)
|
||||
})
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.sort_by_key(|e| geometry_key(*e));
|
||||
candidates.dedup();
|
||||
if candidates.len() > 1 {
|
||||
return Err(DocError::Invalid("face projection has a branched boundary".into()));
|
||||
}
|
||||
let Some(next) = candidates.first() else { break };
|
||||
edge = *next;
|
||||
}
|
||||
boundary.sort_by_key(|e| geometry_key(*e));
|
||||
let closed = current.dist(start) <= tol;
|
||||
if !closed || !twice_area.is_finite() {
|
||||
return Err(DocError::Invalid("face projection has an open or invalid boundary".into()));
|
||||
}
|
||||
// Equal-sized holes use their geometric key, not round-off from summing the
|
||||
// same area at different positions or walking their edges in reverse.
|
||||
let area = (twice_area.abs() / (tol * bounds.diagonal().max(tol))).round() as i64;
|
||||
let first = boundary.first().copied().map(geometry_key);
|
||||
boundaries.push((area, first, boundary));
|
||||
}
|
||||
boundaries.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
|
||||
Ok(boundaries.into_iter().flat_map(|(_, _, edges)| edges).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rectangles() -> Mesh {
|
||||
let mut mesh = Mesh::default();
|
||||
for (x, y) in [(40.0, 30.0), (38.0, 28.0), (4.0, 2.0)] {
|
||||
let pts = [Vec3::new(-x, -y, 0.0), Vec3::new(x, -y, 0.0), Vec3::new(x, y, 0.0), Vec3::new(-x, y, 0.0)];
|
||||
mesh.positions.extend(pts);
|
||||
for i in 0..4 {
|
||||
mesh.edges.push(vec![pts[i], pts[(i + 1) % 4]]);
|
||||
mesh.edge_faces.push(vec![7]);
|
||||
}
|
||||
}
|
||||
mesh
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundaries_and_edge_identity_are_independent_of_mesh_enumeration() {
|
||||
let baseline = rectangles();
|
||||
let expected: Vec<_> =
|
||||
face_edges(&baseline, 7, &Plane::XY).unwrap().into_iter().map(|e| super::super::fit_world(&Plane::XY, &baseline.edges[e])).collect();
|
||||
for shift in 0..baseline.edges.len() {
|
||||
for reverse in [false, true] {
|
||||
let mut mesh = baseline.clone();
|
||||
mesh.edges = (0..mesh.edges.len()).map(|i| baseline.edges[(i * 5) % baseline.edges.len()].clone()).collect();
|
||||
mesh.edges.rotate_left(shift);
|
||||
if reverse {
|
||||
mesh.edges.reverse();
|
||||
for pts in &mut mesh.edges {
|
||||
pts.reverse();
|
||||
}
|
||||
}
|
||||
let actual: Vec<_> = face_edges(&mesh, 7, &Plane::XY)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let mut pts = mesh.edges[e].clone();
|
||||
// Lines' direction does not change their geometry.
|
||||
if pts.first().unwrap().x > pts.last().unwrap().x || pts.first().unwrap().y > pts.last().unwrap().y {
|
||||
pts.reverse();
|
||||
}
|
||||
super::super::fit_world(&Plane::XY, &pts)
|
||||
})
|
||||
.collect();
|
||||
let normalized: Vec<_> = expected
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let solvecraft_sketch::LinkGeom::Line(a, b) = g[0] else { panic!("line") };
|
||||
if a.x > b.x || a.y > b.y { vec![solvecraft_sketch::LinkGeom::Line(b, a)] } else { g.clone() }
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(actual, normalized);
|
||||
let ordered = face_edges(&mesh, 7, &Plane::XY).unwrap();
|
||||
for (loop_index, chunk) in ordered.chunks(4).enumerate() {
|
||||
let want = [40.0, 38.0, 4.0][loop_index];
|
||||
assert!(chunk.iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x.abs() <= want));
|
||||
assert!(chunk.iter().flat_map(|e| &mesh.edges[*e]).any(|p| p.x.abs() == want));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_boundary_arc_sweeps_survive_individual_edge_reversal() {
|
||||
use solvecraft_geom::{Seg2, Vec2};
|
||||
use solvecraft_sketch::LinkGeom;
|
||||
let mut mesh = Mesh::default();
|
||||
for (extent, radius) in [(10.0, 3.0), (8.0, 1.0)] {
|
||||
for k in 0..4 {
|
||||
let angle = k as f64 * std::f64::consts::FRAC_PI_2;
|
||||
let rotate = |p: Vec2| (0..k).fold(p, |p, _| p.perp());
|
||||
let centre = rotate(Vec2::new(extent - radius, extent - radius));
|
||||
let arc = Seg2::Arc { center: centre, radius, start: angle, sweep: std::f64::consts::FRAC_PI_2 };
|
||||
let line = Seg2::Line { a: arc.point_at(1.0), b: rotate(Vec2::new(-(extent - radius), extent)) };
|
||||
for seg in [arc, line] {
|
||||
let pts: Vec<_> = (0..=16).map(|i| Plane::XY.to_world(seg.point_at(i as f64 / 16.0))).collect();
|
||||
mesh.positions.extend(pts.iter().copied());
|
||||
mesh.edges.push(pts);
|
||||
mesh.edge_faces.push(vec![2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let normalized = |mesh: &Mesh| -> Vec<Vec<LinkGeom>> {
|
||||
face_edges(mesh, 2, &Plane::XY)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
super::super::fit_world(&Plane::XY, &mesh.edges[e])
|
||||
.into_iter()
|
||||
.map(|g| match g {
|
||||
LinkGeom::Line(a, b) if (a.x, a.y) > (b.x, b.y) => LinkGeom::Line(b, a),
|
||||
g => g,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let expected = normalized(&mesh);
|
||||
for shift in 0..mesh.edges.len() {
|
||||
let mut permuted = mesh.clone();
|
||||
permuted.edges = (0..mesh.edges.len()).map(|i| mesh.edges[(i * 5 + shift) % mesh.edges.len()].clone()).collect();
|
||||
for (i, points) in permuted.edges.iter_mut().enumerate() {
|
||||
if i % 2 == 0 {
|
||||
points.reverse();
|
||||
}
|
||||
}
|
||||
let actual = normalized(&permuted);
|
||||
for (a, b) in actual.iter().zip(&expected) {
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (a, b) in a.iter().zip(b) {
|
||||
match (a, b) {
|
||||
(LinkGeom::Line(a, b), LinkGeom::Line(c, d)) => assert!(a.dist(*c) < 1e-9 && b.dist(*d) < 1e-9),
|
||||
(LinkGeom::Arc { c, a, b }, LinkGeom::Arc { c: cc, a: aa, b: bb }) => {
|
||||
assert!(c.dist(*cc) < 1e-9 && a.dist(*aa) < 1e-9 && b.dist(*bb) < 1e-9);
|
||||
}
|
||||
_ => panic!("boundary changed kind"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_and_branched_boundaries_are_reported_without_partial_geometry() {
|
||||
let mut open = rectangles();
|
||||
open.edges.pop();
|
||||
open.edge_faces.pop();
|
||||
assert!(face_edges(&open, 7, &Plane::XY).unwrap_err().to_string().contains("open"));
|
||||
let mut branched = rectangles();
|
||||
branched.edges.push(vec![Vec3::new(-40.0, -30.0, 0.0), Vec3::ZERO]);
|
||||
branched.edge_faces.push(vec![7]);
|
||||
assert!(face_edges(&branched, 7, &Plane::XY).is_err());
|
||||
let mut invalid = rectangles();
|
||||
invalid.edges[0].push(Vec3::new(f64::NAN, 0.0, 0.0));
|
||||
assert!(face_edges(&invalid, 7, &Plane::XY).unwrap_err().to_string().contains("invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_area_holes_use_geometric_order_after_translation() {
|
||||
let mut baseline = rectangles();
|
||||
baseline.edges.truncate(4);
|
||||
baseline.edge_faces.truncate(4);
|
||||
for x in [-12.0, 12.0] {
|
||||
let pts = [Vec3::new(x - 2.0, -2.0, 0.0), Vec3::new(x + 2.0, -2.0, 0.0), Vec3::new(x + 2.0, 2.0, 0.0), Vec3::new(x - 2.0, 2.0, 0.0)];
|
||||
for i in 0..4 {
|
||||
baseline.edges.push(vec![pts[i], pts[(i + 1) % 4]]);
|
||||
baseline.edge_faces.push(vec![7]);
|
||||
}
|
||||
}
|
||||
let shift = Vec3::new(12345.6789, -23456.7891, 987.0);
|
||||
for p in &mut baseline.positions {
|
||||
*p += shift;
|
||||
}
|
||||
for points in &mut baseline.edges {
|
||||
for p in points {
|
||||
*p += shift;
|
||||
}
|
||||
}
|
||||
for offset in 0..baseline.edges.len() {
|
||||
let mut mesh = baseline.clone();
|
||||
mesh.edges = (0..mesh.edges.len()).map(|i| baseline.edges[(i * 5 + offset) % baseline.edges.len()].clone()).collect();
|
||||
for (i, points) in mesh.edges.iter_mut().enumerate() {
|
||||
if i % 2 == 0 {
|
||||
points.reverse();
|
||||
}
|
||||
}
|
||||
let order = face_edges(&mesh, 7, &Plane::XY).unwrap();
|
||||
assert!(order[4..8].iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x < shift.x));
|
||||
assert!(order[8..12].iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x > shift.x));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user