Files
OneCAD/crates/doc/src/config_styles.rs
T
Irakli KourtisandClaude Opus 5.5 49956c2797 Sketch on a cylinder primitive's caps; format 15 (#460)
* Sketch on a cylinder primitive's caps (#420)

A solid.cylinder's caps carried no construction certificate, so a sketch
on its top or bottom was refused unless fixed:true (#420's crossed
cylinders, built from primitives).

- kernel: FaceSource::CylinderCap { producer, high, plane };
  Body::with_cylinder_certificate turns a cylinder's two extrude caps into
  certified caps. kernel::cylinder's output is unchanged for its other
  callers.
- doc: FaceCertificate::CylinderCap { high }, named F<id>:top / F<id>:bottom
  as the cylinder's face names already are; New Body and Join cylinders
  are certified, shells carry it as their planar source.
- format 15: designs can store the new certificate kind; version 14
  designs need no change, and older builds ask for an update instead of
  meeting an unknown kind.

Tests: top and bottom attachments follow a height and base edit and a
reopen; crossed joined cylinders keep both caps attachable; a shelled
cylinder's floor keeps its cap identity; malformed certificate JSON is
refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDDPmNdRoBgkHcLeUDKVfb

* Format 15 also covers sketch_axis links (#448)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDDPmNdRoBgkHcLeUDKVfb

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:32:07 -04:00

353 lines
21 KiB
Rust

//! Stable configuration style targets. Display indices and placement names are never saved.
use super::Column;
use crate::{DocError, Document, FeatureKind, ModelState, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use solidcraft_geom::Vec3;
pub(crate) fn shell_plane_family(planes: impl IntoIterator<Item = Option<solidcraft_geom::Plane>>) -> bool {
let mut planes = planes.into_iter();
let Some(Some(first)) = planes.next() else { return false };
first.origin.is_finite() && first.x.is_finite() && first.y.is_finite() && planes.all(|plane| plane == Some(first))
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigFaceTarget {
pub body: String,
pub origin_feature: u64,
pub name: String,
/// Definition-frame point for diagnostics and rendering; never a resolver fallback.
pub point: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub certificate: Option<super::FaceCertificate>,
}
impl ConfigFaceTarget {
pub fn key(&self) -> String {
// Length-prefix encoding keeps punctuation in body/name unambiguous.
format!("{}:{}{}", self.body.len(), self.body, self.name)
}
pub fn matches(&self, face: &crate::appearance::FaceLook) -> bool {
face.body == self.body && face.name.as_ref().is_some_and(|name| crate::naming::strip_piece(name) == self.name)
}
pub fn validate(&self) -> Result<()> {
if let Some(certificate) = &self.certificate {
certificate.validate()?;
}
if self.body.is_empty()
|| self.body.len() > 128
|| self.name.is_empty()
|| self.name.len() > 512
|| self.origin_feature == 0
|| [self.point.x, self.point.y, self.point.z].iter().any(|v| !v.is_finite() || v.abs() > 1e9)
{
return Err(DocError::Invalid("invalid configuration face target".into()));
}
Ok(())
}
}
pub fn validate_face_target_json(target: &Value) -> Result<()> {
let fields = target.as_object().ok_or_else(|| DocError::Invalid("face target must be an object".into()))?;
if !(4..=5).contains(&fields.len())
|| fields.keys().any(|key| !["body", "origin_feature", "name", "point", "certificate"].contains(&key.as_str()))
|| fields.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128)
|| fields.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512)
|| fields.get("origin_feature").and_then(Value::as_u64).is_none_or(|id| id == 0)
|| fields.get("point").and_then(Value::as_array).is_none_or(|point| {
point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9))
})
{
return Err(DocError::Invalid("invalid or oversized configuration face target".into()));
}
if let Some(certificate) = fields.get("certificate") {
super::validate_certificate_json(certificate)?;
}
Ok(())
}
/// Budget malformed style nodes before decoding their typed representation.
pub fn validate_styles_json(value: &Value) -> Result<()> {
if let Some(columns) = value.get("configs").and_then(|v| v.get("columns")).and_then(Value::as_array) {
if columns.len() > super::MAX_COLUMNS {
return Err(DocError::Invalid("too many configuration columns".into()));
}
for column in columns {
if let Some(target) = column.get("face_appearance") {
validate_face_target_json(target)?;
}
if let Some(id) = column.get("component_appearance")
&& id.as_u64().is_none_or(|id| id == 0)
{
return Err(DocError::Invalid("configuration component ID must be nonzero".into()));
}
}
}
if let Some(faces) = value.get("appearances").and_then(|value| value.get("faces")).and_then(Value::as_array) {
for face in faces {
if let Some(certificate) = face.get("certificate") {
super::validate_certificate_json(certificate)?;
if !certificate.is_null()
&& (face.get("strict_name").and_then(Value::as_bool) != Some(true)
|| face.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128)
|| face.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512)
|| face.get("point").and_then(Value::as_array).is_none_or(|point| {
point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9))
}))
{
return Err(DocError::Invalid("invalid certified face appearance".into()));
}
}
}
}
Ok(())
}
impl Document {
pub(crate) fn flat_certificate_producer_matches(&self, producer: u64, certificate: &super::FaceCertificate) -> bool {
use super::FaceCertificate;
let Some(feature) = self.feature(producer) else { return false };
match (certificate, &feature.kind) {
(FaceCertificate::Box { .. }, FeatureKind::Box { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => true,
(FaceCertificate::CylinderCap { .. }, FeatureKind::Cylinder { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => true,
(FaceCertificate::ExtrudeCap { sketch, .. }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => sketch == current && extent.taper.is_none(),
(FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
sketch == current && extent.taper.is_none()
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solidcraft_sketch::CurveKind::Line { .. } | solidcraft_sketch::CurveKind::Circle { .. } | solidcraft_sketch::CurveKind::Arc { .. }))))
}
(FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => true,
_ => false,
}
}
/// Certify named generator lineage; generic index-derived naming is unsupported.
pub fn certify_config_face(&self, target: &ConfigFaceTarget) -> Result<()> {
target.validate()?;
let prefix = format!("F{}:", target.origin_feature);
let role = target.name.strip_prefix(&prefix).ok_or_else(|| DocError::Invalid("face lineage does not match its feature".into()))?;
if crate::naming::strip_piece(&target.name) != target.name {
return Err(DocError::Invalid("configure a face lineage, not a numbered split piece".into()));
}
let feature = self.feature(target.origin_feature).ok_or_else(|| DocError::Unknown("face origin feature is gone".into()))?;
if let Some(certificate) = &target.certificate {
let valid = match (certificate, &feature.kind) {
(super::FaceCertificate::Box { axis, high }, FeatureKind::Box { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
["x", "y", "z"].get(*axis as usize).is_some_and(|axis| role == format!("{}{axis}", if *high { "+" } else { "-" }))
}
(super::FaceCertificate::CylinderCap { high }, FeatureKind::Cylinder { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
role == super::cylinder_cap_role(*high)
}
(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
sketch == producer_sketch && extent.taper.is_none()
&& role == format!("cap:{}:{}", if *high { "high" } else { "low" }, super::cap_key(outer, holes))
}
(super::FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
sketch == producer_sketch && extent.taper.is_none() && role == format!("side:{curve}")
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solidcraft_sketch::CurveKind::Line { .. } | solidcraft_sketch::CurveKind::Circle { .. } | solidcraft_sketch::CurveKind::Arc { .. }))))
}
(super::FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => role.strip_prefix("face:").is_some_and(|index| index.parse::<usize>().is_ok()),
(super::FaceCertificate::ShellPlanar { source_producer, source, inner }, FeatureKind::Shell { .. }) => {
*source_producer != target.origin_feature
&& self.flat_certificate_producer_matches(*source_producer, source)
&& super::shell_certificate_role(source, *inner).is_ok_and(|expected| role == expected)
}
_ => false,
};
return if valid { Ok(()) } else { Err(DocError::Invalid("face certificate no longer matches its producer".into())) };
}
let valid = match &feature.kind {
FeatureKind::Box { .. } => ["+x", "-x", "+y", "-y", "+z", "-z"].contains(&role),
FeatureKind::Cylinder { .. } => ["top", "bottom", "side"].contains(&role),
// Curved side lineage currently uses centroid/nearest-curve heuristics;
// certify caps only until the naming layer supplies proven curve provenance.
FeatureKind::Extrude { extent, .. } => {
extent.from.is_none()
&& extent.start_offset.as_deref().is_none_or(|value| {
crate::expr::is_literal(value)
&& crate::expr::eval_with(value, &|name| Err(DocError::Unknown(name.into())))
.ok()
.and_then(|value| value.to_kind(crate::expr::Kind::Length).ok())
.is_some_and(|value| value.abs() < 1e-12)
})
&& extent.distance2.is_none()
&& ["start", "top", "bottom"].contains(&role)
}
FeatureKind::Revolve { angle2, symmetric, .. } => angle2.is_none() && !symmetric && ["start", "end"].contains(&role),
_ => false,
};
if !valid {
return Err(DocError::Invalid("this face has no supported stable semantic lineage".into()));
}
Ok(())
}
/// Verify the current named pieces describe one surface, not ambiguous generated roles.
pub fn configuration_face_indices(&self, body: &crate::ModelBody, target: &ConfigFaceTarget) -> Result<Vec<usize>> {
self.certify_config_face(target)?;
if body.body.is_mesh() || body.definition_name() != target.body {
return Err(DocError::Invalid("configured face definition does not match".into()));
}
if let Some(super::FaceCertificate::ShellPlanar { source_producer, source, inner }) = &target.certificate {
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, current)| {
matches!(current, Some(solidcraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), source: current_source, inner: current_inner, .. }) if *producer == target.origin_feature && inner == current_inner && super::flat_source_matches(source, current_source, *source_producer)).then_some(index)
}).collect();
// A shell can split one original support into several pieces. All pieces
// must retain the same canonical frame, not merely lie on a common plane.
if !shell_plane_family(indices.iter().map(|index| body.body.face_plane(*index))) {
return Err(DocError::Unknown("shell planar face certificate is missing or ambiguous".into()));
}
return Ok(indices);
}
if matches!(
target.certificate,
Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::CylinderCap { .. } | super::FaceCertificate::ExtrudeCap { .. })
) {
let indices: Vec<_> = body
.body
.face_sources()
.into_iter()
.flatten()
.enumerate()
.filter_map(|(index, source)| {
let matched = match (&target.certificate, source) {
(
Some(super::FaceCertificate::Box { axis, high }),
Some(solidcraft_kernel::FaceSource::Box { producer: Some(producer), axis: current_axis, high: current_high, .. }),
) => *producer == target.origin_feature && axis == current_axis && high == current_high,
(
Some(super::FaceCertificate::CylinderCap { high }),
Some(solidcraft_kernel::FaceSource::CylinderCap { producer, high: current_high, .. }),
) => *producer == target.origin_feature && high == current_high,
(
Some(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }),
Some(solidcraft_kernel::FaceSource::CertifiedExtrudeCap {
producer,
sketch: current_sketch,
high: current_high,
outer: current_outer,
holes: current_holes,
..
}),
) => {
*producer == target.origin_feature
&& sketch == current_sketch
&& high == current_high
&& outer == current_outer
&& holes == current_holes
}
_ => false,
};
matched.then_some(index)
})
.collect();
if indices.len() != 1 {
return Err(DocError::Unknown("attached planar face certificate is missing or ambiguous".into()));
}
return Ok(indices);
}
if let Some(super::FaceCertificate::ExtrudeSide { sketch, curve }) = &target.certificate {
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
matches!(source, Some(solidcraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch: current_sketch, curve: current_curve, .. }) if *producer == target.origin_feature && current_sketch == sketch && current_curve == curve).then_some(index)
}).collect();
if indices.is_empty() {
return Err(DocError::Unknown("constructed side certificate no longer exists".into()));
}
return Ok(indices);
}
if let Some(super::FaceCertificate::Imported { payload_sha256, item, face }) = &target.certificate {
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
matches!(source, Some(solidcraft_kernel::FaceSource::Imported { item: current_item, face: current_face, payload_sha256: Some(digest), producer: Some(producer) }) if current_item == item && current_face == face && digest == payload_sha256 && *producer == target.origin_feature).then_some(index)
}).collect();
if indices.is_empty() {
return Err(DocError::Unknown("immutable import face certificate no longer exists".into()));
}
return Ok(indices);
}
let names = crate::naming::face_names(body);
let indices: Vec<usize> =
names.iter().enumerate().filter(|(_, name)| crate::naming::strip_piece(name) == target.name).map(|(index, _)| index).collect();
if indices.is_empty() {
return Err(DocError::Unknown(format!("configured face `{}` on `{}` no longer exists", target.name, target.body)));
}
if let Some(certificate) = &target.certificate {
for index in &indices {
if self.configuration_face_certificate(body, *index)?.as_ref() != Some(certificate) {
return Err(DocError::Invalid("face pieces do not share the exact certified source".into()));
}
}
return Ok(indices);
}
if target.name.ends_with(":side") {
if indices.len() != 1 {
return Err(DocError::Invalid("split curved face lineage is not yet certified".into()));
}
return Ok(indices);
}
// Caps and box faces must share an oriented plane. This rejects generic cap-role
// collisions while permitting the coplanar pieces of a split supported face.
let mesh = body.mesh();
let mut plane: Option<(Vec3, Vec3)> = None;
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
if indices.binary_search(&(*face as usize)).is_err() {
continue;
}
let Some([a, b, c]) = mesh.tri(triangle) else { continue };
let Some(normal) = (b - a).cross(c - a).normalized() else { continue };
if let Some((point, base)) = plane {
let magnitude = [point.x, point.y, point.z, a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z]
.iter()
.fold(body.body.size(), |scale, value| scale.max(value.abs()));
let tolerance = (magnitude * f64::EPSILON * 128.0).max(1e-7);
if normal.dot(base) < 1.0 - 1e-6 || [a, b, c].iter().any(|vertex| (*vertex - point).dot(base).abs() > tolerance) {
return Err(DocError::Invalid("face lineage names more than one oriented surface".into()));
}
} else {
plane = Some((a, normal));
}
}
if plane.is_none() {
return Err(DocError::Invalid("configured face has no valid surface".into()));
}
Ok(indices)
}
pub fn config_face_look(&self, target: &ConfigFaceTarget) -> Option<crate::appearance::Look> {
self.appearances.faces.iter().rev().find(|face| target.matches(face)).map(|face| face.look.clone())
}
/// Validate against the final rebuilt definition state. Missing names never pick nearby faces.
pub fn configuration_style_status(&self, state: &ModelState) -> Result<Vec<serde_json::Value>> {
let mut result = Vec::new();
for column in &self.configs.columns {
match column {
Column::ComponentAppearance(id) => {
let component =
self.components.iter().find(|c| c.id == *id).ok_or_else(|| DocError::Unknown(format!("component {id} is gone")))?;
result.push(serde_json::json!({"key":self.config_key(column),"target":component.name,"shared":true,"dormant":false}));
}
Column::FaceAppearance(target) => {
self.certify_config_face(target)?;
let dormant = match state.body(&target.body) {
None if self.feature(target.origin_feature).is_some_and(|feature| feature.suppressed) => true,
None => return Err(DocError::Unknown(format!("configured face body `{}` is gone", target.body))),
Some(body) => {
self.configuration_face_indices(body, target)?;
false
}
};
result.push(serde_json::json!({"key":self.config_key(column),"target":format!("{} / {}",target.body,target.name),"shared":true,"dormant":dormant}));
}
_ => {}
}
}
Ok(result)
}
/// Maintain columns and row cell alignment when component definitions are deleted.
pub fn remove_configuration_components(&mut self, ids: &[u64]) {
let keep: Vec<bool> = self.configs.columns.iter().map(|c| !matches!(c,Column::ComponentAppearance(id) if ids.contains(id))).collect();
let mut mask = keep.iter();
self.configs.columns.retain(|_| mask.next().copied().unwrap_or(false));
for row in &mut self.configs.rows {
let mut mask = keep.iter();
row.cells.retain(|_| mask.next().copied().unwrap_or(false));
}
self.appearances.components.retain(|id, _| !ids.contains(id));
}
}