Files
OneCAD/crates/doc/src/format_offsets.rs
T
Karl Von RichterandKarl-VonRichter 1ec33fcdc7 Extrude: a symmetric distance is the total thickness (#1260)
A symmetric solid extrude of 10 mm is now 10 mm thick, 5 mm each side of the sketch plane, and switching from One Side to Symmetric keeps the thickness entered. Under format 21 the extent records its convention; designs saved without it keep the per-side reading, so their geometry and parameters don't change. Fixes #1236.

Co-authored-by: Karl-VonRichter <[email protected]>
2026-10-15 01:19:55 -04:00

456 lines
22 KiB
Rust

//! Pre-allocation bounds for versioned analytic offset roles and boundary references.
use std::collections::BTreeSet;
use serde_json::Value;
use crate::{DocError, Document, FeatureKind, ProfileSel, Result};
fn invalid() -> DocError {
DocError::Invalid("damaged design: invalid analytic offset topology or boundary reference".into())
}
fn id(value: &Value) -> Result<&str> {
value.as_str().filter(|id| !id.is_empty() && id.len() <= 512 && !id.chars().any(char::is_control)).ok_or_else(invalid)
}
fn exact_keys(value: &Value, keys: &[&str]) -> Result<()> {
let object = value.as_object().ok_or_else(invalid)?;
if object.len() != keys.len() || object.keys().any(|key| !keys.contains(&key.as_str())) {
return Err(invalid());
}
Ok(())
}
/// Validate a complete-source profile reference before copying or decoding it.
pub fn whole_profile_selection_from_value(value: &Value) -> Result<ProfileSel> {
validate_whole_reference(value)?;
serde_json::from_value(value.clone()).map_err(|_| invalid())
}
fn validate_whole_reference(value: &Value) -> Result<()> {
exact_keys(value, &["type", "profiles"])?;
if value.get("type").and_then(Value::as_str) != Some("whole_boundaries") {
return Err(invalid());
}
let profiles = value.get("profiles").and_then(Value::as_array).ok_or_else(invalid)?;
if profiles.is_empty() || profiles.len() > 128 {
return Err(invalid());
}
let mut total = 0usize;
let mut identities = BTreeSet::new();
for profile in profiles {
exact_keys(profile, &["outer", "holes"])?;
let mut boundary = |value: &Value| -> Result<Vec<String>> {
let values = value.as_array().ok_or_else(invalid)?;
if values.is_empty() || values.len() > 512 {
return Err(invalid());
}
total += values.len();
if total > 20_000 {
return Err(invalid());
}
let mut previous = None;
// Bound each raw ID before any owned allocation. Require canonical order.
for value in values {
let current = id(value)?;
if previous.is_some_and(|previous| previous >= current) {
return Err(invalid());
}
previous = Some(current);
}
values.iter().map(|value| Ok(id(value)?.to_owned())).collect()
};
let outer = boundary(profile.get("outer").ok_or_else(invalid)?)?;
let holes = profile.get("holes").and_then(Value::as_array).ok_or_else(invalid)?;
if holes.len() > 128 {
return Err(invalid());
}
let mut sorted_holes = Vec::with_capacity(holes.len());
let mut all: BTreeSet<_> = outer.iter().cloned().collect();
for hole in holes {
let hole = boundary(hole)?;
if sorted_holes.last().is_some_and(|previous| previous >= &hole) || hole.iter().any(|curve| !all.insert(curve.clone())) {
return Err(invalid());
}
sorted_holes.push(hole);
}
if !identities.insert((outer, sorted_holes)) {
return Err(invalid());
}
}
Ok(())
}
pub(crate) fn validate_raw(value: &Value) -> Result<()> {
let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) };
let mut total_roles = 0usize;
for feature in features {
if feature.get("type").and_then(Value::as_str) != Some("sketch") {
continue;
}
let Some(offsets) = feature.get("sketch").and_then(|sketch| sketch.get("offsets")) else { continue };
let offsets = offsets.as_array().ok_or_else(invalid)?;
let mut groups = BTreeSet::new();
let mut outputs = BTreeSet::new();
let mut point_owners = std::collections::BTreeMap::new();
let mut sketch_roles = 0usize;
for offset in offsets {
let Some(topology) = offset.get("topology").filter(|topology| !topology.is_null()) else { continue };
if groups.len() >= 128 {
return Err(invalid());
}
exact_keys(topology, &["version", "id", "roles"])?;
let group = id(topology.get("id").ok_or_else(invalid)?)?;
if !groups.insert(group)
|| topology.get("version").and_then(Value::as_u64) != Some(1)
|| offset.get("closed").and_then(Value::as_bool) != Some(true)
{
return Err(invalid());
}
let distance = offset.get("distance").and_then(Value::as_f64).ok_or_else(invalid)?;
if !distance.is_finite() || distance.abs() <= 1e-9 || distance.abs() >= 1e9 {
return Err(invalid());
}
let sources = offset.get("sources").and_then(Value::as_array).ok_or_else(invalid)?;
let roles = topology.get("roles").and_then(Value::as_array).ok_or_else(invalid)?;
let active = offset.get("outputs").and_then(Value::as_array).ok_or_else(invalid)?;
if roles.is_empty() || roles.len() > 512 || roles.len() != sources.len() || active.len() > roles.len() {
return Err(invalid());
}
total_roles = total_roles.saturating_add(roles.len());
sketch_roles = sketch_roles.saturating_add(roles.len());
if total_roles > 1_000_000 || sketch_roles > 20_000 {
return Err(invalid());
}
let mut active_index = 0;
let mut source_ids = BTreeSet::new();
for (source, role) in sources.iter().zip(roles) {
let source = source.as_array().ok_or_else(invalid)?;
if source.len() != 2 || source.get(1).and_then(Value::as_bool).is_none() {
return Err(invalid());
}
let source = id(source.first().ok_or_else(invalid)?)?;
if !source_ids.insert(source) {
return Err(invalid());
}
let role_object = role.as_object().ok_or_else(invalid)?;
if role_object.keys().any(|key| !matches!(key.as_str(), "output" | "kind" | "start" | "end" | "retired" | "flags")) {
return Err(invalid());
}
if let Some(flags) = role.get("flags") {
exact_keys(flags, &["construction", "centerline", "reversed", "fixed"])?;
if flags.as_object().is_none_or(|flags| flags.values().any(|flag| flag.as_bool().is_none())) {
return Err(invalid());
}
}
let output = id(role.get("output").ok_or_else(invalid)?)?;
if source == output || !outputs.insert(output) {
return Err(invalid());
}
if role.get("start") == role.get("end") {
return Err(invalid());
}
for key in ["start", "end"] {
let point = id(role.get(key).ok_or_else(invalid)?)?;
if point == output || point == source || point_owners.insert(point, group).is_some_and(|owner| owner != group) {
return Err(invalid());
}
}
let kind = role.get("kind").and_then(Value::as_str).ok_or_else(invalid)?;
let retired = role.get("retired").and_then(Value::as_bool).ok_or_else(invalid)?;
if !matches!(kind, "line" | "arc") || (retired && kind != "arc") {
return Err(invalid());
}
if !retired {
if active.get(active_index).map(id).transpose()?.is_none_or(|id| id != output) {
return Err(invalid());
}
active_index += 1;
}
}
if active_index != active.len() || active_index < 3 {
return Err(invalid());
}
}
if groups.iter().any(|group| outputs.contains(group) || point_owners.contains_key(group))
|| outputs.iter().any(|output| point_owners.contains_key(output))
{
return Err(invalid());
}
}
fn references(value: &Value) -> Result<()> {
if value.get("type").and_then(Value::as_str) == Some("whole_boundaries") {
return validate_whole_reference(value);
}
if value.get("type").and_then(Value::as_str) == Some("offset_boundaries") {
exact_keys(value, &["type", "associations"])?;
let ids = value.get("associations").and_then(Value::as_array).ok_or_else(invalid)?;
if ids.is_empty() || ids.len() > 128 {
return Err(invalid());
}
let mut seen = BTreeSet::new();
for value in ids {
if !seen.insert(id(value)?) {
return Err(invalid());
}
}
}
Ok(())
}
// Only feature profile fields can contain this variant. Do not impose a new
// document-wide node limit on unrelated legacy sketches or imported payloads.
for feature in features {
if let Some(selection) = feature.get("profiles") {
references(selection)?;
}
if let Some(sections) = feature.get("sections").and_then(Value::as_array) {
if sections.len() > 10_000 {
return Err(invalid());
}
for section in sections {
if let Some(selection) = section.get("profiles") {
references(selection)?;
}
}
}
}
Ok(())
}
pub(crate) fn validate_typed(document: &Document) -> Result<()> {
let mut sketches = std::collections::BTreeMap::new();
for feature in &document.features {
if let FeatureKind::Sketch { sketch, .. } = &feature.kind {
sketch.validate_offset_topologies()?;
sketches.insert(feature.id, sketch);
}
for (sketch, selection) in feature.kind.profile_sels() {
if let ProfileSel::OffsetBoundaries { associations } = selection {
// A deleted association or source sketch is a repairable model error,
// just like an ordinary missing curve reference. Keep that design loadable.
let Some(sketch) = sketches.get(&sketch) else { continue };
for association in associations {
let count =
sketch.offsets.iter().filter_map(|offset| offset.topology.as_ref()).filter(|topology| &topology.id == association).count();
if count > 1 {
return Err(invalid());
}
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic)]
use super::*;
use serde_json::json;
fn fixture() -> Value {
json!({"features":[{"type":"sketch","sketch":{"offsets":[{
"sources":[["s0",true],["s1",true],["s2",true],["s3",true]],
"outputs":["l0","l1","l2"],"closed":true,"distance":1.4,
"topology":{"version":1,"id":"o1","roles":[
{"output":"l0","kind":"line","start":"p0","end":"p1","retired":false},
{"output":"a0","kind":"arc","start":"p1","end":"p2","retired":true},
{"output":"l1","kind":"line","start":"p2","end":"p3","retired":false},
{"output":"l2","kind":"line","start":"p3","end":"p0","retired":false}
]}
}]}}]})
}
#[test]
fn whole_boundaries_raw_identity_is_bounded_canonical_and_complete() {
let good = json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s1"]]}]});
assert!(matches!(whole_profile_selection_from_value(&good).unwrap(), ProfileSel::WholeBoundaries { profiles } if profiles.len() == 1));
let malformed = [
json!({"type":"whole_boundaries","profiles":[]}),
json!({"type":"whole_boundaries","profiles":[{"outer":[],"holes":[]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s1","s1"],"holes":[]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s2","s1"],"holes":[]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s1"],"holes":[["s1"]]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s1"],["s1"]]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s3"],["s1"]]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[],"extra":true}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[]},{"outer":["s2"],"holes":[]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["x".repeat(513)],"holes":[]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["a\nb"],"holes":[]}]}),
];
for value in malformed {
assert!(whole_profile_selection_from_value(&value).is_err(), "accepted malformed reference {value}");
assert!(validate_raw(&json!({"features":[{"type":"extrude","profiles":value}]})).is_err());
}
let many = (0..129).map(|index| json!({"outer":[format!("c{index:03}")],"holes":[]})).collect::<Vec<_>>();
assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":&many[..128]})).is_ok());
assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":many})).is_err());
let oversized = (0..513).map(|index| format!("c{index:03}")).collect::<Vec<_>>();
assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":[{"outer":oversized,"holes":[]}]})).is_err());
let too_many_holes = (0..129).map(|index| vec![format!("hole{index:03}")]).collect::<Vec<_>>();
assert!(
whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":[{"outer":["outer"],"holes":too_many_holes}]})).is_err()
);
let aggregate = (0..40)
.map(|profile| {
let outer = (0..512).map(|curve| format!("profile{profile:03}curve{curve:03}")).collect::<Vec<_>>();
json!({"outer":outer,"holes":[]})
})
.collect::<Vec<_>>();
assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":aggregate})).is_err());
}
#[test]
fn raw_offset_roles_reject_aliases_order_damage_and_hostile_shapes() {
let good = fixture();
assert!(validate_raw(&good).is_ok());
let path = "/features/0/sketch/offsets/0";
let mut flags = good.clone();
flags.pointer_mut(&format!("{path}/topology/roles/1")).unwrap()["flags"] =
json!({"construction":true,"centerline":false,"reversed":true,"fixed":false});
assert!(validate_raw(&flags).is_ok());
*flags.pointer_mut(&format!("{path}/topology/roles/1/flags/construction")).unwrap() = json!(1);
assert!(validate_raw(&flags).is_err());
for (suffix, replacement) in [
("/topology/version", json!(2)),
("/topology/id", json!("x".repeat(513))),
("/closed", json!(false)),
("/distance", json!(1e9)),
("/outputs", json!(["l1", "l0", "l2"])),
("/outputs", json!(["l0", "a0", "l1", "l2"])),
("/topology/roles/1/kind", json!("line")),
("/topology/roles/1/output", json!("l0")),
("/topology/roles/1/start", json!("a0")),
("/topology/roles/0/kind", json!("guess")),
("/sources/0/1", json!(0)),
("/sources/0/0", json!("l0")),
("/topology/roles", json!(vec![json!({}); 513])),
] {
let mut damaged = good.clone();
*damaged.pointer_mut(&format!("{path}{suffix}")).unwrap() = replacement;
assert!(validate_raw(&damaged).is_err(), "accepted {suffix}");
}
let mut extra = good.clone();
extra.pointer_mut(&format!("{path}/topology")).unwrap()["fallback"] = json!([]);
assert!(validate_raw(&extra).is_err());
let mut excessive = good.clone();
let chain = excessive.pointer(path).unwrap().clone();
let groups = (0..129)
.map(|index| {
let mut chain = chain.clone();
chain["topology"]["id"] = json!(format!("o{index}"));
for source in chain["sources"].as_array_mut().unwrap() {
source[0] = json!(format!("g{index}-{}", source[0].as_str().unwrap()));
}
for output in chain["outputs"].as_array_mut().unwrap() {
*output = json!(format!("g{index}-{}", output.as_str().unwrap()));
}
for role in chain["topology"]["roles"].as_array_mut().unwrap() {
for key in ["output", "start", "end"] {
role[key] = json!(format!("g{index}-{}", role[key].as_str().unwrap()));
}
}
chain
})
.collect::<Vec<_>>();
excessive["features"][0]["sketch"]["offsets"] = json!(&groups[..128]);
assert!(validate_raw(&excessive).is_ok(), "the permitted group boundary must be accepted");
excessive["features"][0]["sketch"]["offsets"] = json!(groups);
assert!(validate_raw(&excessive).is_err());
}
#[test]
fn raw_boundary_references_are_bounded_and_future_versions_refuse_first() {
let mut good = fixture();
good["features"].as_array_mut().unwrap().push(json!({"type":"extrude","profiles":{"type":"offset_boundaries","associations":["o1"]}}));
assert!(validate_raw(&good).is_ok());
for associations in [json!([]), json!(["o1", "o1"]), json!(["x".repeat(513)]), json!(vec!["o"; 129]), json!([0])] {
let mut damaged = good.clone();
damaged["features"][1]["profiles"]["associations"] = associations;
assert!(validate_raw(&damaged).is_err());
damaged["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
assert!(crate::format::read(&damaged.to_string()).unwrap_err().to_string().contains("newer SolidCraft"));
}
// Absent metadata preserves the historical one-to-one chain representation.
let mut legacy = fixture();
legacy["features"][0]["sketch"]["offsets"][0].as_object_mut().unwrap().remove("topology");
legacy["features"][0]["sketch"]["offsets"][0]["closed"] = json!(false);
assert!(validate_raw(&legacy).is_ok());
}
#[test]
fn formats_one_through_eleven_keep_legacy_offset_geometry_and_ids() {
let mut sketch = solidcraft_sketch::Sketch::new();
let center = sketch.add_point(solidcraft_geom::Vec2::new(4., 7.), Some("center")).unwrap();
sketch.add_circle(solidcraft_geom::Vec2::new(4., 7.), 10., Some(center), Some("source")).unwrap();
sketch.add_circle(solidcraft_geom::Vec2::new(4., 7.), 9., Some(center), Some("offset")).unwrap();
sketch.offsets.push(solidcraft_sketch::OffsetChain {
sources: vec![("source".into(), true)],
outputs: vec!["offset".into()],
distance: 1.,
closed: true,
topology: None,
});
let mut document = Document::new("legacy offset circles");
let sketch_id =
document.add_feature(FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, Some("Circles")).unwrap();
let original: Value = serde_json::from_str(&document.to_json()).unwrap();
for version in 1..=11 {
let mut old = original.clone();
old["format"] = json!(crate::format::format_string(version));
let upgraded = crate::format::read(&old.to_string()).unwrap();
let saved: Value = serde_json::from_str(&upgraded.to_json()).unwrap();
assert_eq!(saved["features"][0]["sketch"]["offsets"], original["features"][0]["sketch"]["offsets"]);
assert_eq!(saved["features"][0]["sketch"]["curves"], original["features"][0]["sketch"]["curves"]);
assert_eq!(saved["features"][0]["sketch"]["points"], original["features"][0]["sketch"]["points"]);
let mut model = crate::Model::new();
model.evaluate(&upgraded);
assert!(model.results.iter().all(|result| result.error.is_none()));
let state = model.state();
let solved = state.sketch(sketch_id).unwrap();
let radius = solved.sketch.curves.iter().find(|curve| curve.id == "offset").map(|curve| &curve.kind);
assert!(matches!(radius,Some(solidcraft_sketch::CurveKind::Circle {r,..}) if *r ==9.));
}
}
#[test]
fn missing_boundary_or_defining_sketch_reopens_as_a_model_error_without_retarget() {
for missing_sketch in [false, true] {
let mut sketch = solidcraft_sketch::Sketch::new();
sketch.add_circle(solidcraft_geom::Vec2::new(0., 0.), 10., None, Some("unrelated-circle")).unwrap();
let mut document = Document::new("repairable lost boundary");
let id = document.add_feature(FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
document
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: if missing_sketch { 999_999 } else { id },
profiles: ProfileSel::OffsetBoundaries { associations: vec!["deleted-o1".into()] },
extent: crate::Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
distance: "2".into(),
direction: crate::Direction::Positive,
distance2: None,
start_offset: None,
through_all: false,
taper: None,
to: None,
to_offset: None,
from: None,
},
operation: crate::Operation::NewBody,
targets: vec![],
},
None,
)
.unwrap();
let reopened = crate::format::read(&document.to_json()).unwrap();
assert_eq!(reopened, document);
let mut model = crate::Model::new();
model.evaluate(&reopened);
assert!(model.results.last().unwrap().error.is_some());
assert!(model.state().bodies.is_empty(), "lost boundary must not select the remaining ordinary circle");
}
}
}