Design format: a change is one new file, never an edit to FORMAT (#1563)

A PR that saves something new adds crates/doc/src/format/pending/<name>.rs (a note, and an upgrade step if older designs must be rewritten). The build writes the next format number while changes are pending and lists them in format_pending; scripts/fold-format-pending.sh fixes them as v<N>/ at release time. Formats 23 and 24 move into v23/ and v24/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-15 21:13:38 -04:00
1 parent 067f2642c2
commit 9a07932f20
16 files changed
+494 -33

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
# Files many pull requests add to are kept so that additions don't conflict: one file per change
# or one sorted line per item (CONTRIBUTING.md, "Lists many changes add to"). Fast, no build.
name: Lists
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
format-changes:
name: Design format changes merge cleanly
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: bash scripts/test-format-pending.sh
+7
View File
@@ -123,6 +123,13 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
keep monitoring it while the task is active and make its pending status clear at handoff;
do not claim it merged without checking.
## Design file format
A change that saves something new in a design adds ONE file, `crates/doc/src/format/pending/<name>.rs`
(a `//!` note, and `pub fn step` if older designs must be rewritten). Never edit `FORMAT`,
`JSON_STEPS`, the history in `format.rs`, another change's file or a `v<N>/` folder: the build
works out the number (the next one while anything is pending), and the release folds pending
changes into `v<N>/` with `scripts/fold-format-pending.sh`. See CONTRIBUTING.md.
## Application releases
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
+7
View File
@@ -96,6 +96,13 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
## Design file format
A change that saves something new in a design adds ONE file, `crates/doc/src/format/pending/<name>.rs`
(a `//!` note, and `pub fn step` if older designs must be rewritten). Never edit `FORMAT`,
`JSON_STEPS`, the history in `format.rs`, another change's file or a `v<N>/` folder: the build
works out the number (the next one while anything is pending), and the release folds pending
changes into `v<N>/` with `scripts/fold-format-pending.sh`. See CONTRIBUTING.md.
## Application releases
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
+25
View File
@@ -199,6 +199,31 @@ the current results, and CI runs `cargo xtask oracle` on every pull request. A c
because of a known bug names its issue in `expected.json` (`"known_failure": "#123"`); never
change an expected value to match SolidCraft's output.
## Changing the design file format
A design file says which format it is in (`"format": "solidcraft/N"`). If your change saves
something new in a design (a field, a feature type, a new kind of name), or makes older designs
mean something else, it needs a format change. **Add one file, and edit nothing shared:**
1. Create `crates/doc/src/format/pending/<what-it-is>.rs` (lowercase, dashes). Start it with a
`//!` note: what is saved, what older designs get without it, and that the last release asks
for an update instead of meeting it.
2. If older designs must be rewritten as they open, put `pub fn step(v: &mut Value) ->
crate::Result<()>` in the same file. Pending steps run in file-name order, after the older
formats' steps.
3. Don't touch `FORMAT`, `JSON_STEPS`, the history in `crates/doc/src/format.rs`, another
change's file, or any `v<N>/` folder: those are fixed once released.
That's all. The build counts the files: while any are pending it writes the next format number,
which no release reads, so a release refuses a design that has your change instead of losing it.
A file also lists the pending changes it has (`format_pending`), so a later development build
runs only the steps that file is missing. Two pull requests that each add a change merge without
a conflict. `crates/doc/src/format/pending/README.md` has an example.
**At release time** the maintainer runs `scripts/fold-format-pending.sh`, which moves the pending
files into `crates/doc/src/format/v<N>/` and fixes that number for good (see
[docs/releasing.md](docs/releasing.md)). Never add a file to a `v<N>/` folder afterwards.
## Sending a change
1. Work is tracked in [GitHub issues](https://github.com/bherbruck/solidcraft/issues), labelled
+62
View File
@@ -0,0 +1,62 @@
//! Lists the design-format changes in `src/format/`: one file per change, in `v<N>/` once a
//! release fixed its number, else in `pending/` (see `src/format/pending/README.md`). The list is
//! generated so that a change adds a file and never edits a line another change also edits.
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
/// The last format whose upgrade steps are written out in `format.rs` (`JSON_STEPS`).
const HAND_WRITTEN: u32 = 22;
fn main() {
let root = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("src/format");
println!("cargo:rerun-if-changed={}", root.display());
let mut folded: Vec<(u32, PathBuf)> = std::fs::read_dir(&root)
.unwrap_or_else(|e| panic!("{}: {e}", root.display()))
.filter_map(|entry| {
let path = entry.ok()?.path();
let version = path.file_name()?.to_str()?.strip_prefix('v')?.parse().ok()?;
path.is_dir().then_some((version, path))
})
.collect();
folded.sort();
for (i, (version, _)) in folded.iter().enumerate() {
let expected = HAND_WRITTEN + 1 + i as u32;
assert_eq!(*version, expected, "src/format/: expected v{expected}/ next, found v{version}/ (versions are contiguous)");
}
let mut out = String::new();
let mut mods = String::new();
out.push_str("/// The changes of each format after the hand-written ones, oldest first.\nconst FOLDED: &[(u32, &[Part])] = &[\n");
for (version, dir) in &folded {
let _ = writeln!(out, " ({version}, &[");
parts(dir, &format!("v{version}"), &mut out, &mut mods);
out.push_str(" ]),\n");
}
out.push_str("];\n\n/// The changes no release has a number for yet (`src/format/pending/`).\nconst PENDING: &[Part] = &[\n");
parts(&root.join("pending"), "pending", &mut out, &mut mods);
out.push_str("];\n");
out.push_str(&mods);
let dest = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("format_parts.rs");
std::fs::write(dest, out).unwrap();
}
/// The `Part`s of the change files in `dir`, in file-name order, and a module for each.
fn parts(dir: &Path, prefix: &str, out: &mut String, mods: &mut String) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut files: Vec<PathBuf> = entries.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x == "rs")).collect();
files.sort();
for path in files {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default().to_string();
assert!(
!name.is_empty() && name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'),
"{}: name a format change file with lowercase letters, digits and dashes",
path.display()
);
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.trim_start().starts_with("//!"), "{}: start a format change with a `//!` note", path.display());
let module = format!("{prefix}_{}", name.replace('-', "_"));
let step = if text.contains("fn step(") { format!("Some({module}::step)") } else { "None".into() };
let _ = writeln!(out, " Part {{ name: {name:?}, step: {step} }},");
let _ = writeln!(mods, "#[path = {:?}]\nmod {module};", path.display().to_string());
}
}
+5
View File
@@ -2133,6 +2133,10 @@ pub struct Document {
pub render: std::sync::Arc<crate::render_settings::RenderSettings>,
#[serde(default = "default_format")]
pub format: String,
/// The changes of `format` this design has while they are pending (no release has fixed
/// that number yet; `crate::format::pending_names`). Empty, and not saved, otherwise.
#[serde(default = "crate::format::pending_names", skip_serializing_if = "Vec::is_empty")]
pub format_pending: Vec<String>,
pub name: String,
#[serde(default = "default_units")]
pub units: String,
@@ -2350,6 +2354,7 @@ impl Document {
Document {
render: Default::default(),
format: default_format(),
format_pending: crate::format::pending_names(),
name: name.into(),
units: default_units(),
metadata: Default::default(),
+232 -26
View File
@@ -222,29 +222,111 @@
//! parameters); the source's `at`, `height` and `angle` then hold the numbers they last came
//! to. A text without `exprs` is set as it always was; 0.12.0 asks for an update instead of
//! dropping the expressions and keeping the text at its last numbers.
//! - 23: a motion link can carry a `coupling`: a gear pair (`ra`, `rb`, `internal`), a rack and
//! pinion (`radius`) or a screw (`pitch`), each size a number in mm, an expression or a picked
//! round face, and `reverse`. The link's `ratio` is worked out from it whenever the design
//! changes and saved as well, so a link reads the same without it. Version 22 designs need no
//! transformation: a link without `coupling` is a plain motion link, as it always was; 0.13.0
//! (which reads up to 22) asks for an update instead of keeping a gear's ratio when its radius
//! changes.
//! Also under 23, an Import's `file` can name a FreeCAD `.FCStd` or Rhino `.3dm` file (#1435,
//! read through cadmpeg); its `step` is still the STEP text the file was restated as, so the
//! feature's fields are unchanged and version 22 designs need no transformation; 0.13.x asks
//! for an update before it meets these imports.
//! - 24: an Edge Flange feature can choose its own corner relief (`corner_relief_shape`: notch,
//! round, rectangular or tear, and `corner_relief`, the size across), for the corners its
//! flanges make. Version 23 designs need no transformation: a feature without them follows the
//! sheet metal rule, as it always did, and saves as before; 0.13.0 (which reads up to 22) asks
//! for an update instead of silently building the rule's relief where a design asks for another.
//!
//! Later formats are one file per change, so changes made side by side never edit the same line
//! (`src/format/pending/README.md`):
//! - `src/format/v<N>/`: the changes format N added, one file each, its `//!` note saying what is
//! saved and what older designs get, and a `step` when older designs are rewritten as they open
//! (23: motion link couplings, FreeCAD and Rhino imports; 24: an Edge Flange's own corner relief).
//! - `src/format/pending/`: changes no release has fixed a number for yet. While there are any,
//! this build writes the next number (which no release reads), with the names of these changes
//! in `format_pending`, so a later build applies just the steps a file is missing. At release
//! time `scripts/fold-format-pending.sh` moves them into the next `v<N>/`.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 24;
/// The format this build writes: the last folded one, or the next while changes are pending.
pub const FORMAT: u32 = FORMATS.current();
/// The last format whose upgrade step is written out in [`JSON_STEPS`]; later formats are made
/// of [`Part`]s (`src/format/`, listed by `build.rs`).
const HAND_WRITTEN: u32 = 22;
/// One change to the file format: a file in `src/format/v<N>/` or `src/format/pending/`.
pub(crate) struct Part {
/// The file's name, which a file written while the change was pending lists.
pub name: &'static str,
/// How a design from before the change is rewritten as it opens, if it is.
pub step: Option<JsonStep>,
}
include!(concat!(env!("OUT_DIR"), "/format_parts.rs"));
/// Every format this build knows and how to upgrade a design through them.
const FORMATS: Formats<'static> = Formats { hand: &JSON_STEPS, folded: FOLDED, pending: PENDING };
/// The names of the pending changes this build writes into a design (`format_pending`).
pub fn pending_names() -> Vec<String> {
FORMATS.pending.iter().map(|p| p.name.to_string()).collect()
}
/// The upgrade path: the hand-written steps up to [`HAND_WRITTEN`], the folded formats after it
/// (each a list of changes) and the pending changes, which make the next format.
struct Formats<'a> {
hand: &'a [JsonStep],
folded: &'a [(u32, &'a [Part])],
pending: &'a [Part],
}
impl Formats<'_> {
const fn current(&self) -> u32 {
self.hand.len() as u32 + 1 + self.folded.len() as u32 + if self.pending.is_empty() { 0 } else { 1 }
}
/// The changes format `version` is made of, if it is made of changes.
fn parts(&self, version: u32) -> Option<&[Part]> {
let after = version.checked_sub(self.hand.len() as u32 + 2)? as usize;
match self.folded.get(after) {
Some((_, parts)) => Some(parts),
None if after == self.folded.len() && !self.pending.is_empty() => Some(self.pending),
None => None,
}
}
/// Bring a design of format `version` up to [`Formats::current`]. `applied` is the file's
/// `format_pending`: the changes of its format it already has, when it was written while they
/// were pending; the others' steps run first. A pending change this build doesn't have is a
/// newer build's, and the design is refused.
fn upgrade(&self, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
if version == 0 || version > self.current() {
return Err(DocError::Invalid(format!("not a SolidCraft design (format {version})")));
}
// A list beside a format that isn't made of changes means nothing (a tool or a test set
// an older number on a newer file): it is ignored, like any field an upgrade drops.
if let (Some(applied), Some(parts)) = (applied, self.parts(version)) {
let pending = version == self.current() && !self.pending.is_empty();
if let Some(unknown) = applied.iter().find(|name| pending && !parts.iter().any(|p| p.name == name.as_str())) {
return Err(DocError::Invalid(format!(
"this design was saved by a newer SolidCraft (file format {version} with `{unknown}`, which this version doesn't know): update SolidCraft to open it"
)));
}
for part in parts.iter().filter(|p| !applied.iter().any(|name| name == p.name)) {
part.step.map_or(Ok(()), |step| step(v))?;
}
}
for from in version..self.current() {
match self.hand.get(from as usize - 1) {
Some(step) => step(v)?,
None => {
for part in self.parts(from + 1).unwrap_or_default() {
part.step.map_or(Ok(()), |step| step(v))?;
}
}
}
}
Ok(())
}
}
/// Take a file's `format_pending` (the pending changes it has) out of its JSON.
fn take_pending(v: &mut Value) -> Result<Option<Vec<String>>> {
let Some(list) = v.as_object_mut().and_then(|o| o.remove("format_pending")) else { return Ok(None) };
let invalid = || DocError::Invalid("damaged design: `format_pending` is not a short list of names".into());
let list = list.as_array().filter(|l| l.len() <= 1000).ok_or_else(invalid)?;
list.iter().map(|n| n.as_str().filter(|n| n.len() <= 128).map(str::to_string).ok_or_else(invalid)).collect::<Result<_>>().map(Some)
}
pub fn format_string(n: u32) -> String {
format!("solidcraft/{n}")
@@ -259,9 +341,10 @@ pub fn version_of(s: &str) -> Option<u32> {
s.strip_prefix("solidcraft/").or_else(|| s.strip_prefix(LEGACY_FORMAT_PREFIX))?.parse().ok()
}
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
/// JSON-level upgrades up to [`HAND_WRITTEN`]: entry `i` turns format `i + 1` into `i + 2`.
/// Never add to it: a new format is a file in `src/format/pending/`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 23] = [
const JSON_STEPS: [JsonStep; HAND_WRITTEN as usize - 1] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
@@ -283,8 +366,6 @@ const JSON_STEPS: [JsonStep; 23] = [
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
];
/// Stored face and edge names were worked out from geometry before format 14; they get the
@@ -475,6 +556,7 @@ pub fn read(s: &str) -> Result<Document> {
Some(Value::String(f)) => version_of(f).ok_or_else(|| DocError::Invalid(format!("not a SolidCraft design (format `{f}`)")))?,
Some(_) => return Err(DocError::Invalid("not a SolidCraft design (format is not a string)".into())),
};
let applied = take_pending(&mut v)?;
if version == 0 {
return Err(DocError::Invalid("not a SolidCraft design (format 0)".into()));
}
@@ -494,9 +576,7 @@ pub fn read(s: &str) -> Result<Document> {
crate::format_offsets::validate_raw(&v)?;
let legacy_wires = validate_wires(&v, version)?;
validate_body_transfers(&v)?;
for step in JSON_STEPS.iter().skip(version as usize - 1).take((FORMAT - version) as usize) {
step(&mut v)?;
}
FORMATS.upgrade(&mut v, version, applied.as_deref())?;
let mut d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
let mut legacy_by_feature = std::collections::BTreeMap::<usize, Vec<usize>>::new();
for (feature, wire) in legacy_wires {
@@ -595,6 +675,7 @@ pub fn read(s: &str) -> Result<Document> {
let floor = largest.checked_add(1).ok_or_else(|| DocError::Invalid("damaged design: the id space is exhausted".into()))?;
d.next_id = d.next_id.max(floor).max(1);
d.format = format_string(FORMAT);
d.format_pending = pending_names();
Ok(d)
}
@@ -1017,11 +1098,136 @@ fn normalise(d: &mut Document) -> Result<()> {
mod tests {
use super::*;
/// A pending change that records that its step ran, in `trace`.
fn traced(name: &'static str) -> Part {
fn step_a(v: &mut Value) -> Result<()> {
trace(v, "a")
}
fn step_b(v: &mut Value) -> Result<()> {
trace(v, "b")
}
fn trace(v: &mut Value, s: &str) -> Result<()> {
let o = v.as_object_mut().ok_or_else(|| DocError::Invalid("no object".into()))?;
o.entry("trace").or_insert_with(|| Value::Array(Vec::new())).as_array_mut().into_iter().for_each(|t| t.push(Value::from(s)));
Ok(())
}
Part { name, step: Some(if name.starts_with('a') { step_a } else { step_b }) }
}
fn trace_of(v: &Value) -> Vec<&str> {
v["trace"].as_array().into_iter().flatten().filter_map(Value::as_str).collect()
}
/// Formats after the hand-written ones follow on from them, one folder each, and the build
/// writes the next number exactly while changes are pending, so the last release (which
/// reads up to 22) refuses what a development build saves.
#[test]
fn formats_follow_on_and_pending_changes_take_the_next_number() {
assert_eq!(JSON_STEPS.len() as u32, HAND_WRITTEN - 1);
for (i, (version, parts)) in FOLDED.iter().enumerate() {
assert_eq!(*version, HAND_WRITTEN + 1 + i as u32);
assert!(!parts.is_empty(), "format {version} has no changes");
}
let last_folded = HAND_WRITTEN + FOLDED.len() as u32;
assert_eq!(FORMAT, last_folded + u32::from(!PENDING.is_empty()));
const { assert!(FORMAT >= 24, "designs saved as 23 and 24 by nightlies still open") };
assert_eq!(pending_names(), PENDING.iter().map(|p| p.name).collect::<Vec<_>>());
assert_eq!(Document::new("A").format_pending, pending_names());
}
/// The build lists every change file there is, in file-name order: a change is a file, so
/// two branches that each add one merge cleanly and the merged build has both.
#[test]
fn every_change_file_is_listed_in_name_order() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/format");
let names = |dir: &str| -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(root.join(dir))
.map(|d| d.filter_map(|e| e.ok()?.file_name().to_str()?.strip_suffix(".rs").map(str::to_string)).collect())
.unwrap_or_default();
names.sort();
names
};
assert_eq!(names("pending"), pending_names());
for (version, parts) in FOLDED {
assert_eq!(names(&format!("v{version}")), parts.iter().map(|p| p.name).collect::<Vec<_>>());
}
}
/// Two changes pending side by side both run, in name order, after every older format's
/// steps: an old design upgrades through all of them, and one from the last folded format
/// gets just the pending ones.
#[test]
fn pending_changes_apply_in_order_after_older_formats() {
let pending = [traced("a-first"), traced("b-second")];
let formats = Formats { hand: &JSON_STEPS, folded: FOLDED, pending: &pending };
let next = HAND_WRITTEN + FOLDED.len() as u32 + 1;
assert_eq!(formats.current(), next);
for version in [1, HAND_WRITTEN, next - 1] {
let mut v = serde_json::json!({"features": [], "assembly": {"joints": [{"flip": false}]}});
formats.upgrade(&mut v, version, None).unwrap();
assert_eq!(trace_of(&v), ["a", "b"], "from format {version}");
// The hand-written steps ran too: format 3's joints were flipped on the way up.
assert_eq!(v["assembly"]["joints"][0]["flip"], Value::Bool(version < 4), "from format {version}");
}
// A design of the pending number written before `b-second` existed gets just that step.
let mut v = serde_json::json!({});
formats.upgrade(&mut v, next, Some(&["a-first".to_string()])).unwrap();
assert_eq!(trace_of(&v), ["b"]);
// One that has both, or that this build wrote, needs nothing.
let mut v = serde_json::json!({});
formats.upgrade(&mut v, next, Some(&["a-first".into(), "b-second".into()])).unwrap();
assert_eq!(trace_of(&v), Vec::<&str>::new());
// A change this build doesn't know is a newer build's: refused, not guessed at.
let err = formats.upgrade(&mut serde_json::json!({}), next, Some(&["c-later".into()])).unwrap_err();
assert!(err.to_string().contains("newer SolidCraft"), "{err}");
// A list on a format that isn't made of changes means nothing.
let mut v = serde_json::json!({});
formats.upgrade(&mut v, HAND_WRITTEN, Some(&["a-first".into()])).unwrap();
assert_eq!(trace_of(&v), ["a", "b"]);
}
/// Folding the pending changes into a number (the release script) keeps every design
/// opening the same: a file saved while they were pending gets the steps it missed.
#[test]
fn folded_changes_upgrade_like_pending_ones() {
let parts = [traced("a-first"), traced("b-second")];
let mut folded: Vec<(u32, &[Part])> = FOLDED.to_vec();
let next = HAND_WRITTEN + folded.len() as u32 + 1;
folded.push((next, &parts));
let formats = Formats { hand: &JSON_STEPS, folded: &folded, pending: &[] };
assert_eq!(formats.current(), next);
let mut old = serde_json::json!({});
formats.upgrade(&mut old, HAND_WRITTEN, None).unwrap();
assert_eq!(trace_of(&old), ["a", "b"]);
let mut nightly = serde_json::json!({});
formats.upgrade(&mut nightly, next, Some(&["a-first".into()])).unwrap();
assert_eq!(trace_of(&nightly), ["b"]);
let mut released = serde_json::json!({});
formats.upgrade(&mut released, next, None).unwrap();
assert_eq!(trace_of(&released), Vec::<&str>::new());
}
/// `format_pending` is read and checked: a damaged list is refused, a list of changes this
/// build doesn't have is a newer build's, and saving writes this build's own list.
#[test]
fn format_pending_in_a_file_is_checked() {
let mut file: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
assert_eq!(file.get("format_pending").is_some(), !PENDING.is_empty());
for damaged in [serde_json::json!("x"), serde_json::json!([1]), serde_json::json!(["x".repeat(200)])] {
file["format_pending"] = damaged;
assert!(read(&file.to_string()).is_err());
}
file["format_pending"] = serde_json::json!(["not-a-change-anyone-made"]);
assert_eq!(read(&file.to_string()).is_err(), !PENDING.is_empty(), "only a pending format's list names changes");
file["format_pending"] = Value::from(pending_names());
let opened = read(&file.to_string()).unwrap();
assert_eq!(opened.format_pending, pending_names());
}
/// 24 added the corner relief of an edge flange: a no-op step carries a 23 design up, a flange
/// without a relief of its own saves as before, and one with it is saved under 24 and kept.
#[test]
fn version_twenty_three_designs_open_and_an_edge_flange_relief_is_saved_in_twenty_four() {
assert_eq!(JSON_STEPS.len(), FORMAT as usize - 1, "one upgrade step for each format");
let flange = |shape: Option<&str>, size: Option<&str>| crate::FeatureKind::SheetFlange {
edges: vec![crate::FlangeEdge::at(solidcraft_geom::Vec3::ZERO)],
height: "10 mm".into(),
+27
View File
@@ -0,0 +1,27 @@
# Pending design-format changes
One file per change to what a design file saves that no release has shipped yet. Adding a file
here is the whole format bump: never edit `FORMAT`, `JSON_STEPS` or another change's file.
- Name it after the change, lowercase with dashes: `placed-plane-origins.rs`.
- Start it with a `//!` note: what is saved, what an older design gets without it, and what
the last release does with a design that has it (it refuses the newer format number).
- If older designs must be rewritten as they open, add the step to the same file:
```rust
//! …the note…
use serde_json::Value;
pub fn step(v: &mut Value) -> crate::Result<()> {
// rewrite the design's JSON here
Ok(())
}
```
Steps run in file-name order, after the steps of every older format.
While this folder has a change in it, the build writes the next format number (the last
folded one + 1, which no release reads), and lists the changes a file has in its
`format_pending` field, so a later build applies only the steps that file is missing. At release
time `scripts/fold-format-pending.sh` moves these files into `../v<N>/`, which fixes that number
for good. See `crates/doc/src/format.rs` and CONTRIBUTING.md.
@@ -0,0 +1,4 @@
//! An Import's `file` can name a FreeCAD `.FCStd` or Rhino `.3dm` file (#1435, read through
//! cadmpeg); its `step` is still the STEP text the file was restated as, so the feature's fields
//! are unchanged and version 22 designs need no transformation; 0.13.x asks for an update before
//! it meets these imports.
@@ -0,0 +1,7 @@
//! A motion link can carry a `coupling`: a gear pair (`ra`, `rb`, `internal`), a rack and pinion
//! (`radius`) or a screw (`pitch`), each size a number in mm, an expression or a picked round
//! face, and `reverse`. The link's `ratio` is worked out from it whenever the design changes and
//! saved as well, so a link reads the same without it. Version 22 designs need no
//! transformation: a link without `coupling` is a plain motion link, as it always was; 0.13.0
//! (which reads up to 22) asks for an update instead of keeping a gear's ratio when its radius
//! changes.
@@ -0,0 +1,5 @@
//! An Edge Flange feature can choose its own corner relief (`corner_relief_shape`: notch, round,
//! rectangular or tear, and `corner_relief`, the size across), for the corners its flanges make.
//! Version 23 designs need no transformation: a feature without them follows the sheet metal
//! rule, as it always did, and saves as before; 0.13.0 (which reads up to 22) asks for an update
//! instead of silently building the rule's relief where a design asks for another.
+6 -5
View File
@@ -86,11 +86,12 @@ is available. Missing or incompatible pictures use the usual thumbnail generatio
The default `cache: false` omits the portable thumbnail too.
The repository's `docs/design-container.md` describes limits and encoding details.
Current designs use format 24, including the corner relief of an edge flange, the gear, rack and pinion and screw couplings of motion links, design properties, named render setups, opaque PBR looks, thin extrudes, sheet metal perforation fields, associative editable sketch patterns, editable associative offset-distance dimensions, the style of sketch text (bold, italic, alignment, spacing and the curve it follows), face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, the expressions behind a sketch text's height, place and angle, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
links, persistent
names from exact face history, exact planar shell-face anchors, persistent analytic offset boundary roles and
association profile references, durable sketch face anchors, periodic fit splines and versioned
revolve conventions. It also retains recorded body transfers, explicit 3D curve coordinate
Designs are saved in the newest format this version of SolidCraft knows (`"format": "solidcraft/N"`
in the file). It opens designs of every older format and upgrades them as they open; a design
from a newer version asks you to update instead of being guessed at. Development (nightly) builds
save a format number no release reads yet.
Upgrading keeps what a design meant. Designs keep recorded body transfers, explicit 3D curve coordinate
spaces and certified face bindings, alongside semantic configuration styles, associative
free-form offsets and cylindrical emboss selections. Older ownership maps, geometry and
expressions retain their saved meaning. Legacy 3D curves with retained associations receive
+1 -1
View File
@@ -7,7 +7,7 @@ self-contained input/output format. `io::write_design` keeps its JSON contract;
`io::write_design_container` is the fallible container writer.
Packages have `container.json` with `{"format":"solidcraft-container/1"}` and
`document.json` with the versioned parametric document (currently `solidcraft/24`).
`document.json` with the versioned parametric document (`solidcraft/N`, the format in `crates/doc/src/format.rs`).
The independent versions let document migrations evolve without changing blob encodings.
Existing serialized document fields are retained automatically, without a second copy
of the document schema or a deep clone of embedded payloads.
+6 -1
View File
@@ -71,7 +71,12 @@ deployment. The stable Flatpak repository is not automatically updated with nigh
`Cargo.lock` (it changes only our own crates, never dependency versions, and needs no
network). Inspect the diff: only `version = ` lines of workspace crates should change.
SolidCraft does not currently implement `cargo xtask version set`.
3. Run `cargo xtask ci` and the packaging checks in `.github/workflows/packaging-lint.yml`.
3. Fix the design format number: run `scripts/fold-format-pending.sh`. It moves the pending
format changes (`crates/doc/src/format/pending/*.rs`) into the next `crates/doc/src/format/v<N>/`
folder, so the release writes and reads that number for good, and does nothing when nothing
is pending. Commit the move with the version files. (CONTRIBUTING.md, "Changing the design
file format".)
4. Run `cargo xtask ci` and the packaging checks in `.github/workflows/packaging-lint.yml`.
Commit the version files through the normal review flow and land them on `main`.
Do not use `[skip ci]`. Record the exact commit SHA to release. If it changes, revalidate it.
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Release step: give the pending design-format changes their number for good.
#
# Moves every change in crates/doc/src/format/pending/ into crates/doc/src/format/v<N>/, where N
# is the number development builds have been writing for them (the last folded format + 1).
# Commit the result with the release's version bump. Nothing to do when nothing is pending.
# See crates/doc/src/format/pending/README.md.
set -euo pipefail
root=$(git rev-parse --show-toplevel)
dir="$root/crates/doc/src/format"
last=$(sed -n 's/^const HAND_WRITTEN: u32 = \([0-9]*\);/\1/p' "$root/crates/doc/build.rs")
[ -n "$last" ] || { echo "HAND_WRITTEN not found in crates/doc/build.rs" >&2; exit 1; }
for d in "$dir"/v*/; do
[ -d "$d" ] || continue
n=${d%/}
n=${n##*/v}
if [ "$n" -gt "$last" ]; then last=$n; fi
done
shopt -s nullglob
pending=("$dir"/pending/*.rs)
if [ ${#pending[@]} -eq 0 ]; then
echo "No pending format changes: this release writes format $last."
exit 0
fi
next=$((last + 1))
mkdir -p "$dir/v$next"
git -C "$root" mv "${pending[@]}" "$dir/v$next/"
echo "Format $next is now fixed, with ${#pending[@]} change(s):"
for f in "${pending[@]}"; do echo " ${f##*/}"; done
echo "Commit crates/doc/src/format/ with the version bump."
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Checks the design-format change files (crates/doc/src/format/): two branches that each add a
# pending change merge without a conflict, and the release fold moves both into the next number.
# The order they apply in and the upgrades themselves are tested in crates/doc/src/format.rs.
set -euo pipefail
root=$(git rev-parse --show-toplevel)
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
repo="$tmp/repo"
mkdir -p "$repo/crates/doc/src" "$repo/scripts"
cp -r "$root/crates/doc/src/format" "$repo/crates/doc/src/"
cp "$root/crates/doc/build.rs" "$repo/crates/doc/"
cp "$root/scripts/fold-format-pending.sh" "$repo/scripts/"
rm -f "$repo"/crates/doc/src/format/pending/*.rs # start from a release: nothing pending
git() { command git -C "$repo" -c user.name=test -c user.email=test@example.com "$@"; }
git init -q -b main
git add -A
git commit -qm base
change() {
git switch -q -c "$1" main
printf '//! %s\n' "$1" > "$repo/crates/doc/src/format/pending/$1.rs"
git add -A
git commit -qm "$1"
}
change b-second
change a-first
git switch -q main
if ! tree=$(git merge-tree --write-tree a-first b-second); then
echo "FAIL: two pending format changes conflict" >&2
exit 1
fi
git merge -q --no-edit a-first b-second >/dev/null
listed=$(git ls-tree --name-only HEAD crates/doc/src/format/pending/ | grep '\.rs$' | sed 's#.*/##' | tr '\n' ' ')
[ "$listed" = "a-first.rs b-second.rs " ] || { echo "FAIL: merged pending changes are: $listed" >&2; exit 1; }
[ "$tree" = "$(git rev-parse HEAD^{tree})" ] || { echo "FAIL: merge-tree and merge disagree" >&2; exit 1; }
before=$(ls -d "$repo"/crates/doc/src/format/v*/ 2>/dev/null | wc -l)
(cd "$repo" && bash scripts/fold-format-pending.sh) > "$tmp/fold.log"
after=$(ls -d "$repo"/crates/doc/src/format/v*/ | sort -V | tail -1)
[ "$(ls -d "$repo"/crates/doc/src/format/v*/ | wc -l)" -eq $((before + 1)) ] || { echo "FAIL: the fold made no new version" >&2; exit 1; }
[ "$(ls "$after")" = "$(printf 'a-first.rs\nb-second.rs')" ] || { echo "FAIL: the fold moved $(ls "$after")" >&2; exit 1; }
[ -z "$(ls "$repo/crates/doc/src/format/pending/" | grep '\.rs$' || true)" ] || { echo "FAIL: pending changes left after the fold" >&2; exit 1; }
(cd "$repo" && bash scripts/fold-format-pending.sh) | grep -q "No pending format changes" || { echo "FAIL: a second fold did something" >&2; exit 1; }
echo "ok: pending format changes merge cleanly and fold into $(basename "$after")"