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>
This commit is contained in:
Irakli KourtisandClaude Opus 5.5 authored and GitHub committed 2026-10-12 21:32:07 -04:00
1 parent b24e478bc1
commit 49956c2797
10 files changed
+247 -23

No files matched your search

+76 -6
View File
@@ -151,11 +151,34 @@ pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solidcraft_k
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum FaceCertificate {
Box { axis: u8, high: bool },
ExtrudeCap { sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>> },
ExtrudeSide { sketch: u64, curve: String },
Imported { payload_sha256: String, item: u64, face: u64 },
ShellPlanar { source_producer: u64, source: Box<FaceCertificate>, inner: bool },
Box {
axis: u8,
high: bool,
},
/// A cylinder primitive's cap: `high` is its `top` (the far end), else its `bottom`.
CylinderCap {
high: bool,
},
ExtrudeCap {
sketch: u64,
high: bool,
outer: Vec<String>,
holes: Vec<Vec<String>>,
},
ExtrudeSide {
sketch: u64,
curve: String,
},
Imported {
payload_sha256: String,
item: u64,
face: u64,
},
ShellPlanar {
source_producer: u64,
source: Box<FaceCertificate>,
inner: bool,
},
}
impl FaceCertificate {
@@ -177,6 +200,7 @@ impl FaceCertificate {
pub fn validate(&self) -> Result<()> {
let valid = match self {
Self::Box { axis, .. } => *axis <= 2,
Self::CylinderCap { .. } => true,
Self::ExtrudeCap { sketch, outer, holes, .. } => *sketch != 0 && valid_signature(outer, holes),
Self::ExtrudeSide { sketch, curve } => *sketch != 0 && !curve.is_empty() && curve.len() <= 128,
Self::Imported { payload_sha256, item, face } => {
@@ -206,6 +230,11 @@ pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()>
&& fields.get("axis").and_then(serde_json::Value::as_u64).is_some_and(|axis| axis <= 2)
&& fields.get("high").and_then(serde_json::Value::as_bool).is_some()
}
Some("cylinder_cap") => {
fields.len() == 2
&& fields.keys().all(|key| ["kind", "high"].contains(&key.as_str()))
&& fields.get("high").and_then(serde_json::Value::as_bool).is_some()
}
Some("extrude_cap") => {
fields.len() == 5
&& fields.keys().all(|key| ["kind", "sketch", "high", "outer", "holes"].contains(&key.as_str()))
@@ -252,6 +281,7 @@ pub(crate) fn shell_certificate_role(source: &FaceCertificate, inner: bool) -> R
let axis = ["x", "y", "z"].get(*axis as usize).ok_or_else(|| DocError::Invalid("invalid shell source axis".into()))?;
format!("box:{}{axis}", if *high { "+" } else { "-" })
}
FaceCertificate::CylinderCap { high } => format!("cylinder:{}", cylinder_cap_role(*high)),
FaceCertificate::ExtrudeSide { curve, .. } => format!("side:{curve}"),
FaceCertificate::ExtrudeCap { high, outer, holes, .. } => format!("cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes)),
FaceCertificate::Imported { payload_sha256, item, face } => format!("import:{payload_sha256}:{item}:{face}"),
@@ -260,10 +290,16 @@ pub(crate) fn shell_certificate_role(source: &FaceCertificate, inner: bool) -> R
Ok(format!("shell:{}:{role}", if inner { "inner" } else { "outer" }))
}
/// A cylinder cap's role, as the cylinder's face names call it.
pub(crate) fn cylinder_cap_role(high: bool) -> &'static str {
if high { "top" } else { "bottom" }
}
pub(crate) fn flat_source_certificate(source: &solidcraft_kernel::FaceSource) -> Option<(u64, FaceCertificate)> {
use solidcraft_kernel::FaceSource;
match source {
FaceSource::Box { producer: Some(producer), axis, high, .. } => Some((*producer, FaceCertificate::Box { axis: *axis, high: *high })),
FaceSource::CylinderCap { producer, high, .. } => Some((*producer, FaceCertificate::CylinderCap { high: *high })),
FaceSource::CertifiedExtrudeSide { producer, sketch, curve, .. } => {
Some((*producer, FaceCertificate::ExtrudeSide { sketch: *sketch, curve: curve.clone() }))
}
@@ -283,6 +319,9 @@ pub(crate) fn flat_source_matches(certificate: &FaceCertificate, source: &solidc
(FaceCertificate::Box { axis, high }, FaceSource::Box { producer: Some(current), axis: current_axis, high: current_high, .. }) => {
producer == *current && axis == current_axis && high == current_high
}
(FaceCertificate::CylinderCap { high }, FaceSource::CylinderCap { producer: current, high: current_high, .. }) => {
producer == *current && high == current_high
}
(
FaceCertificate::ExtrudeSide { sketch, curve },
FaceSource::CertifiedExtrudeSide { producer: current, sketch: current_sketch, curve: current_curve, .. },
@@ -411,7 +450,8 @@ impl Document {
| Some(solidcraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. })
| Some(solidcraft_kernel::FaceSource::CertifiedExtrudeCap { producer, .. })
| Some(solidcraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), .. })
| Some(solidcraft_kernel::FaceSource::Box { producer: Some(producer), .. }) => *producer,
| Some(solidcraft_kernel::FaceSource::Box { producer: Some(producer), .. })
| Some(solidcraft_kernel::FaceSource::CylinderCap { producer, .. }) => *producer,
_ => name
.strip_prefix('F')
.and_then(|name| name.split_once(':'))
@@ -481,6 +521,11 @@ impl Document {
target.name = format!("F{producer}:{}{axis_name}", if *high { "+" } else { "-" });
target.certificate = Some(FaceCertificate::Box { axis: *axis, high: *high });
}
Some(solidcraft_kernel::FaceSource::CylinderCap { producer, high, .. }) => {
target.origin_feature = *producer;
target.name = format!("F{producer}:{}", cylinder_cap_role(*high));
target.certificate = Some(FaceCertificate::CylinderCap { high: *high });
}
Some(solidcraft_kernel::FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, .. }) => {
target.origin_feature = *producer;
target.name = format!("F{producer}:cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes));
@@ -512,6 +557,31 @@ mod tests {
SolvedSketch { feature: 1, name: "sources".into(), plane: solidcraft_geom::Plane::XY, sketch, report, profiles: Vec::new() }
}
#[test]
fn cylinder_cap_certificate_json_is_exact() {
for high in [true, false] {
let valid = json!({"kind":"cylinder_cap","high":high});
assert!(validate_certificate_json(&valid).is_ok());
let certificate: FaceCertificate = serde_json::from_value(valid.clone()).unwrap();
assert_eq!(certificate, FaceCertificate::CylinderCap { high });
assert_eq!(serde_json::to_value(&certificate).unwrap(), valid);
}
let shelled = json!({"kind":"shell_planar","source_producer":3,"source":{"kind":"cylinder_cap","high":false},"inner":true});
assert!(validate_certificate_json(&shelled).is_ok());
let certificate: FaceCertificate = serde_json::from_value(shelled).unwrap();
assert_eq!(shell_certificate_role(&FaceCertificate::CylinderCap { high: false }, true).unwrap(), "shell:inner:cylinder:bottom");
assert!(certificate.validate().is_ok());
for bad in [
json!({"kind":"cylinder_cap"}),
json!({"kind":"cylinder_cap","high":1}),
json!({"kind":"cylinder_cap","high":null}),
json!({"kind":"cylinder_cap","high":true,"axis":2}),
json!({"kind":"cylinder_cap","high":true,"extra":[[[]]]}),
] {
assert!(validate_certificate_json(&bad).is_err(), "{bad}");
}
}
#[test]
fn prepared_sources_keep_exact_identity_and_reject_ambiguity() {
use solidcraft_geom::Vec2;
+12 -1
View File
@@ -109,6 +109,7 @@ impl Document {
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()
@@ -132,6 +133,9 @@ impl Document {
(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))
@@ -192,7 +196,10 @@ impl Document {
}
return Ok(indices);
}
if matches!(target.certificate, Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::ExtrudeCap { .. })) {
if matches!(
target.certificate,
Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::CylinderCap { .. } | super::FaceCertificate::ExtrudeCap { .. })
) {
let indices: Vec<_> = body
.body
.face_sources()
@@ -205,6 +212,10 @@ impl Document {
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 {
+1
View File
@@ -3643,6 +3643,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
let b = kernel::cylinder(point(vals, base)?, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?;
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_cylinder_certificate(f.id)? } else { b };
apply_traced(st, f, vec![crate::lineage::cylinder_tool(b)], *operation, &[])
}
FeatureKind::Sphere { center, radius, operation } => {
+7 -2
View File
@@ -46,13 +46,17 @@
//! legacy mark (`?F2:top#2`) and are found again as the build that saved them found them.
//! Pieces a feature splits a body into are named after their faces; features saved before
//! keep the kernel's shell order (`shell_piece_order`), which their stored body names mean.
//! - 15: sketch face anchors and face bindings can name a cylinder primitive's cap
//! (`cylinder_cap`), and sketches can link lines to their own axes (`sketch_axis`). Version 14
//! designs need no transformation; the version stamp lets older readers request an update
//! instead of encountering an unknown certificate or link kind.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 14;
pub const FORMAT: u32 = 15;
pub fn format_string(n: u32) -> String {
format!("solidcraft/{n}")
@@ -69,7 +73,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; 13] = [
const JSON_STEPS: [JsonStep; 14] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
@@ -83,6 +87,7 @@ const JSON_STEPS: [JsonStep; 13] = [
|_| Ok(()),
|_| Ok(()),
v13_to_v14,
|_| Ok(()),
];
/// Stored face and edge names were worked out from geometry before format 14; they get the
+1
View File
@@ -324,6 +324,7 @@ fn certified_planar_construction(body: &crate::ModelBody, index: usize) -> bool
Some(
solidcraft_kernel::FaceSource::ShellPlanar { producer: Some(_), .. }
| solidcraft_kernel::FaceSource::Box { producer: Some(_), .. }
| solidcraft_kernel::FaceSource::CylinderCap { .. }
| solidcraft_kernel::FaceSource::CertifiedExtrudeSide { plane: Some(_), .. }
| solidcraft_kernel::FaceSource::CertifiedExtrudeCap { .. }
)