Compare commits

...

2 Commits

Author SHA1 Message Date
bherbruck 726ceea147 Saving a newer design from the CLI, and editing a feature this version can't read
`run --save` writes a design holding a feature only a newer build can build;
`--out` still stops, since it would export the part without that feature.
Edit Feature on such a feature says why instead of opening the palette.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:26:00 -05:00
bherbruck cfffba411e Open a design from a newer SolidCraft with a warning instead of refusing it
A newer format number or an unknown change no longer blocks opening. What
this version can read opens; a feature it can't read stays on the timeline
with an error, and fields, sections and container parts it doesn't know are
named in the reply and written back by Save, under the file's own number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:30:14 -05:00
36 changed files with 1545 additions and 189 deletions
+1 -1
View File
@@ -277,7 +277,7 @@ mean something else, it needs a format change. **Add one file, and edit nothing
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.
which no release reads, so a release warns about a design that has your change and keeps what it can't read of 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.
+27 -4
View File
@@ -318,6 +318,19 @@ fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Res
/// `load_checked`, the features computed under `validation` (`diagnostics.validation`), which
/// the session keeps.
fn load_with(path: &str, check: solidcraft_engine::schema::ParamCheck, validation: solidcraft_engine::kernel::Validation) -> Result<Session, String> {
let s = read_session(path, check, validation)?;
// A newer SolidCraft's file opens all the same; stderr says so, stdout stays the caller's.
for warning in s.doc.newer_file.iter().flat_map(|file| file.warnings()) {
eprintln!("{path}: warning: {warning}");
}
Ok(s)
}
fn read_session(
path: &str,
check: solidcraft_engine::schema::ParamCheck,
validation: solidcraft_engine::kernel::Validation,
) -> Result<Session, String> {
let mut s = Session::default();
s.param_check = check;
s.set_validation(validation);
@@ -484,8 +497,10 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
// A feature that fails leaves the bodies as they were before it (a parameter change can
// break a feature without failing the command that made it): writing them would hand out
// the wrong part with success, so fail the run unless asked to write it anyway.
// A feature only a newer SolidCraft can build isn't a broken design: saving the design keeps it
// and hands out no part. Exporting bodies without it would, so that still stops.
if !args.iter().any(|a| a == "--allow-errors") {
timeline_ok(&s)?;
timeline_ok(&s, flags(args, "--out").is_empty())?;
}
for out in flags(args, "--out") {
s.execute("file.export", &json!({"path": out})).map_err(|e| e.to_string())?;
@@ -561,8 +576,12 @@ fn known_args(cmd: &str, args: &[String], valued: &[&str], switches: &[&str], po
}
/// Every failing timeline feature, one per line, as an error; Ok when none fails.
fn timeline_ok(s: &Session) -> Result<(), String> {
let failed: Vec<String> = s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| format!(" {}: {e}", r.name))).collect();
fn timeline_ok(s: &Session, skip_newer_features: bool) -> Result<(), String> {
let newer = |id: u64| {
skip_newer_features && s.doc.feature(id).is_some_and(|f| matches!(f.kind, solidcraft_engine::doc::FeatureKind::NewerFileFeature { .. }))
};
let failed: Vec<String> =
s.model.results.iter().filter(|r| !newer(r.id)).filter_map(|r| r.error.as_ref().map(|e| format!(" {}: {e}", r.name))).collect();
if failed.is_empty() {
return Ok(());
}
@@ -579,7 +598,7 @@ fn cmd_eval(args: &[String]) -> Result<(), String> {
let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?;
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
let out = json!({
let mut out = json!({
"body_count": m["body_count"],
"bodies": m["bodies"],
"total": m["total"],
@@ -587,6 +606,10 @@ fn cmd_eval(args: &[String]) -> Result<(), String> {
"params": d["params"],
"sketches": d["sketches"],
});
if let Some(file) = &s.doc.newer_file {
out["newer_file"] = file.report();
out["warnings"] = json!(file.warnings());
}
print_json(&out, json_out)
}
+51 -19
View File
@@ -159,7 +159,7 @@ fn cli_loads_both_design_encodings_by_contents() {
}
/// #1369: a pending concept that did not land in the numbered release must not be silently
/// dropped on open/resave, in either encoding or before replacing an existing destination.
/// dropped on open/resave, in either encoding: it opens with a warning, and saving keeps it.
#[test]
fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
use solidcraft_engine::{Session, doc, io};
@@ -173,34 +173,29 @@ fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
let source = dir.0.join(format!("experimental-{version}.{extension}"));
std::fs::write(&source, &bytes).unwrap();
let mut session = Session::default();
session.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
let original = session.doc.clone();
let original_path = session.path.clone();
let undo = session.undo.len();
let dirty = session.is_dirty();
let err = session.execute("doc.open", &json!({"path": source})).unwrap_err().to_string();
assert!(err.contains("experimental-card") && err.contains("update SolidCraft"), "{err}");
assert_eq!(session.doc, original);
assert_eq!(session.path, original_path);
assert_eq!(session.undo.len(), undo);
assert_eq!(session.is_dirty(), dirty);
// It opens, and the reply says what it is.
let reply = session.execute("doc.open", &json!({"path": source})).unwrap();
let notice = reply["newer_file"]["notice"].as_str().unwrap().to_string();
assert!(notice.contains("experimental-card") && notice.starts_with("Saved by a newer SolidCraft"), "{notice}");
assert_eq!(reply["warnings"][0], notice.as_str());
assert_eq!(session.doc.name, "Experimental");
for existing in [false, true] {
let destination = dir.0.join(format!("output-{version}-{extension}-{existing}.{extension}"));
let backup = io::backup_path(&destination);
if existing {
std::fs::write(&destination, b"existing design").unwrap();
std::fs::write(&backup, b"existing backup").unwrap();
}
let output = cli().args(["run", "--in"]).arg(&source).arg("--save").arg(&destination).output().unwrap();
assert_eq!(output.status.code(), Some(1));
let error = String::from_utf8_lossy(&output.stderr);
assert!(error.contains("experimental-card") && error.contains("update SolidCraft"), "{error}");
assert_eq!(output.status.code(), Some(0), "{error}");
assert!(error.contains("warning: Saved by a newer SolidCraft") && error.contains("experimental-card"), "{error}");
// Saved, it still says it has the change, and opens the same way.
let saved = io::read_design(&std::fs::read(&destination).unwrap()).unwrap();
assert!(saved.format_pending.iter().any(|name| name == "experimental-card"));
assert!(saved.newer_file.is_some());
if existing {
assert_eq!(std::fs::read(&destination).unwrap(), b"existing design");
assert_eq!(std::fs::read(&backup).unwrap(), b"existing backup");
} else {
assert!(!destination.exists() && !backup.exists());
assert_eq!(std::fs::read(&backup).unwrap(), b"existing design");
}
assert_eq!(std::fs::read(&source).unwrap(), bytes);
}
@@ -208,6 +203,43 @@ fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
}
}
/// `eval` and `run` on a design one format newer, with a feature kind this version doesn't have:
/// the rest builds, the reply names it, and the saved file still holds it, under the file's number.
#[test]
fn the_cli_opens_a_newer_design_and_saving_keeps_what_it_cant_read() {
use solidcraft_engine::{Session, doc, io};
let dir = Scratch::new();
let mut session = Session::default();
session.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
let mut file: Value = serde_json::from_slice(&io::write_design(&session.doc)).unwrap();
let newer = doc::format::format_string(doc::format::FORMAT + 1);
file["format"] = json!(newer);
let future = json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice", "cell": 2.5});
file["features"].as_array_mut().unwrap().push(future.clone());
let source = dir.0.join("newer.json");
std::fs::write(&source, file.to_string()).unwrap();
let output = cli().args(["eval"]).arg(&source).output().unwrap();
let error = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(0), "{error}");
assert!(error.contains("warning: Saved by a newer SolidCraft") && error.contains("Lattice1"), "{error}");
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(reply["body_count"], 1);
assert!(reply["newer_file"]["not_understood"][0].as_str().unwrap().contains("gyroid_lattice"), "{reply}");
assert!(reply["warnings"][0].as_str().unwrap().starts_with("Saved by a newer SolidCraft"), "{reply}");
for extension in ["json", "sc"] {
let destination = dir.0.join(format!("saved.{extension}"));
let output = cli().args(["run", "--in"]).arg(&source).arg("--save").arg(&destination).output().unwrap();
assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr));
let saved = io::read_design(&std::fs::read(&destination).unwrap()).unwrap();
assert_eq!(saved.format, newer, "never a lower number");
let again: Value = serde_json::from_slice(&io::write_design(&saved)).unwrap();
assert_eq!(again["features"].as_array().unwrap().last().unwrap(), &future);
}
}
#[test]
fn known_pending_concepts_and_released_files_round_trip_with_inert_metadata() {
use solidcraft_engine::{doc, io};
@@ -0,0 +1,7 @@
{
"en": {
"document.newer_file": "Saved by a newer SolidCraft (format {format}, this version reads up to {reads}). If something's missing, update.",
"document.newer_file_change": "Saved by a newer SolidCraft (format {format} with `{change}`, which this version doesn't know). If something's missing, update.",
"document.newer_file_not_read": "Not read: {what}. Saving keeps it."
}
}
+1 -1
View File
@@ -185,7 +185,7 @@ fn shell_planar_format13_keeps_v12_documents_and_refuses_future() {
}
assert_eq!(legacy, current, "the migration preserves every prior field");
legacy["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
assert!(crate::format::read(&legacy.to_string()).is_err());
assert!(crate::format::read(&legacy.to_string()).unwrap().newer_file.is_some());
}
#[test]
+25 -1
View File
@@ -659,6 +659,12 @@ impl AlignFrame {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FeatureKind {
/// A feature a newer SolidCraft saved that this build can't read ([`crate::newer_file`]): it
/// holds its place on the timeline, reports an error when evaluated, and goes back into the
/// file as it came.
NewerFileFeature {
raw: crate::newer_file::RawFeature,
},
/// A recorded timeline ownership boundary. Geometry is converted from its
/// current local frame into the destination component frame exactly once.
BodyTransfer {
@@ -2600,6 +2606,7 @@ impl FeatureKind {
/// Short type name (timeline tooltip, `inspect`).
pub fn type_name(&self) -> &'static str {
match self {
FeatureKind::NewerFileFeature { .. } => "NewerFileFeature",
FeatureKind::BodyTransfer { .. } => "BodyTransferFeature",
FeatureKind::Sketch { .. } => "Sketch",
FeatureKind::Extrude { .. } => "ExtrudeFeature",
@@ -2685,6 +2692,7 @@ impl FeatureKind {
/// Design-file variant key (e.g. `"extrude"`, `"box"`, `"sheet_flange"`).
pub fn kind_key(&self) -> &'static str {
match self {
FeatureKind::NewerFileFeature { .. } => "newer_file_feature",
FeatureKind::BodyTransfer { .. } => "body_transfer",
FeatureKind::Sketch { .. } => "sketch",
FeatureKind::Extrude { .. } => "extrude",
@@ -2847,6 +2855,7 @@ impl FeatureKind {
/// Default name prefix (`Extrude` → `Extrude1`).
pub fn base_name(&self) -> &'static str {
match self {
FeatureKind::NewerFileFeature { .. } => "Unknown",
FeatureKind::BodyTransfer { .. } => "BodyTransfer",
FeatureKind::Sketch { .. } => "Sketch",
FeatureKind::Extrude { .. } => "Extrude",
@@ -3119,6 +3128,10 @@ pub fn is_counted_body_name(name: &str) -> bool {
/// The document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
/// Set when a newer SolidCraft saved the file this came from: what to tell the user, and what
/// this build couldn't read and keeps for the next save ([`crate::newer_file`]).
#[serde(skip)]
pub newer_file: Option<std::sync::Arc<crate::newer_file::NewerFile>>,
/// Named image intent and immutable embedded HDR sources. Active jobs and device state are not saved.
#[serde(default, skip_serializing_if = "crate::render_settings::RenderSettings::is_empty")]
pub render: std::sync::Arc<crate::render_settings::RenderSettings>,
@@ -3480,6 +3493,7 @@ impl Document {
pub fn new(name: &str) -> Self {
Document {
newer_file: None,
render: Default::default(),
format: default_format(),
format_pending: crate::format::pending_names(),
@@ -3901,7 +3915,12 @@ impl Document {
/// Read a design file's JSON (any format this build knows; older ones are upgraded).
pub fn from_json(s: &str) -> Result<Document> {
crate::render_resources::check_json_budget(s)?;
// A newer build's resources may not read here; the section stays out then (`newer_file`).
if let Err(why) = crate::render_resources::check_json_budget(s)
&& !crate::format::says_newer(s)
{
return Err(why);
}
let mut d = crate::format::read(s)?;
if d.features.len() > MAX_FEATURES || d.params.len() > MAX_PARAMS {
return Err(DocError::Invalid("document too large".into()));
@@ -3913,6 +3932,11 @@ impl Document {
Ok(d)
}
pub fn to_json(&self) -> String {
if self.newer_file.is_some() {
let mut value = serde_json::to_value(self).unwrap_or_default();
crate::newer_file::restore(self, &mut value);
return serde_json::to_string_pretty(&value).unwrap_or_default();
}
serde_json::to_string_pretty(self).unwrap_or_default()
}
+125 -3
View File
@@ -84,6 +84,65 @@ pub struct Drawing {
pub language: String,
/// In order, each behind its own `Arc`, so an undo step shares every sheet it didn't change.
pub sheets: Vec<Arc<Sheet>>,
/// Set when a newer SolidCraft saved the file this came from.
#[serde(skip)]
pub newer_file: Option<Arc<NewerDrawing>>,
}
/// The fields a drawing's record has: anything else in a file is a newer build's.
const RECORD_FIELDS: [&str; 7] = ["format", "name", "sources", "properties", "units", "language", "sheets"];
/// Move the elements of the list `key` that don't read as `T` out of a newer build's drawing.
fn take_unread<T: serde::de::DeserializeOwned>(top: &mut serde_json::Map<String, Value>, key: &str, one: &str, file: &mut NewerDrawing) {
let Some(Value::Array(list)) = top.get_mut(key) else { return };
let mut readable = Vec::with_capacity(list.len());
for element in std::mem::take(list) {
match DRAWING.check_numbers(&element, "properties").and_then(|()| serde_json::from_value::<T>(element.clone()).map_err(damaged)) {
Ok(_) => readable.push(element),
Err(why) => {
let name = element.get("name").or(element.get("path")).and_then(Value::as_str).unwrap_or("unnamed");
file.not_understood.push(format!("{one} `{name}` ({why})"));
file.elements.push((key.to_string(), element));
}
}
}
*list = readable;
}
/// What a drawing file says and holds that this build doesn't read ([`Drawing::from_newer`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NewerDrawing {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
/// What this build couldn't read, by name.
pub not_understood: Vec<String>,
fields: Vec<(String, Value)>,
elements: Vec<(String, Value)>,
}
impl NewerDrawing {
/// What to tell the user.
pub fn notice(&self) -> String {
format!(
"Saved by a newer SolidCraft (format {}, this version reads up to {}). If something's missing, update.",
self.format, self.reads_up_to
)
}
fn restore(&self, value: &mut Value) {
for (key, field) in &self.fields {
value[key] = field.clone();
}
for (key, element) in &self.elements {
if let Some(list) = value.get_mut(key).and_then(Value::as_array_mut) {
list.push(element.clone());
}
}
}
}
/// A design the drawing draws.
@@ -175,6 +234,7 @@ impl Drawing {
units: Units::default(),
language: language.into(),
sheets: vec![Arc::new(first)],
newer_file: None,
};
drawing.validate()?;
Ok(drawing)
@@ -188,11 +248,65 @@ impl Drawing {
}
let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft drawing: {e}")))?;
let (version, applied) = DRAWING.take_version(&mut v)?;
if let Some(newer) = DRAWING.newer(version, applied.as_deref()) {
return Self::from_newer(v, version, applied, newer);
}
let d = Self::decode(v, version, applied.as_deref())?;
Ok(Drawing { format: DRAWING.format_string(DRAWING.current()), format_pending: DRAWING.pending_names(), ..d })
}
fn decode(mut v: Value, version: u32, applied: Option<&[String]>) -> Result<Drawing> {
DRAWING.check_numbers(&v, "properties")?;
DRAWING.upgrade(&mut v, version, applied.as_deref())?;
DRAWING.upgrade(&mut v, version, applied)?;
let d: Drawing = serde_json::from_value(v).map_err(damaged)?;
d.validate().map_err(damaged)?;
Ok(Drawing { format: DRAWING.format_string(DRAWING.current()), format_pending: DRAWING.pending_names(), ..d })
Ok(d)
}
/// A drawing a newer build saved: it opens with what this build can read of it. Everything in a
/// drawing refuses a field it doesn't know, so what it can't read shows as a top-level field,
/// a source or a sheet that doesn't read, which stays out and is named in
/// [`NewerDrawing::not_understood`]; [`Drawing::to_json`] writes it all back, with the file's
/// format. It takes at least one sheet this build can read to open.
fn from_newer(mut v: Value, version: u32, applied: Option<Vec<String>>, newer: crate::format::Newer) -> Result<Drawing> {
let list = applied.as_deref();
let can_read = |v: Value| Self::decode(v, version, list).map_err(|e| e.to_string());
let mut file = NewerDrawing {
format: newer.format,
reads_up_to: newer.reads_up_to,
unknown_changes: newer.unknown_changes.clone(),
..NewerDrawing::default()
};
let mut read = can_read(v.clone());
if read.is_err() {
let top = v.as_object_mut().ok_or_else(|| damaged("expected a JSON object"))?;
let keys: Vec<String> = top.keys().cloned().collect();
for key in keys {
if !RECORD_FIELDS.contains(&key.as_str())
&& let Some(value) = top.remove(&key)
{
file.not_understood.push(format!("field `{key}`"));
file.fields.push((key, value));
}
}
take_unread::<Sheet>(top, "sheets", "sheet", &mut file);
take_unread::<Source>(top, "sources", "source", &mut file);
read = can_read(v);
}
let d = read.map_err(|why| {
DocError::Invalid(format!(
"this drawing was saved by a newer SolidCraft (format {}, this version reads up to {}) and this version can't read it: {why}. Update SolidCraft to open it",
newer.format, newer.reads_up_to
))
})?;
let (format, format_pending) = if newer.format > newer.reads_up_to {
(DRAWING.format_string(newer.format), applied.unwrap_or_default())
} else {
let mut pending = DRAWING.pending_names();
pending.extend(newer.unknown_changes.iter().cloned());
(DRAWING.format_string(DRAWING.current()), pending)
};
Ok(Drawing { format, format_pending, newer_file: Some(Arc::new(file)), ..d })
}
/// The drawing's JSON: the same bytes for the same drawing. JSON longer than a read takes
@@ -200,7 +314,15 @@ impl Drawing {
/// properties are kept as they came, and indenting them can make a drawing that read in
/// under the limit save over it.
pub fn to_json(&self) -> Result<String> {
let text = serde_json::to_string_pretty(self).map_err(|e| DocError::Invalid(format!("drawing not saved: {e}")))?;
let unsaved = |e: serde_json::Error| DocError::Invalid(format!("drawing not saved: {e}"));
let text = match &self.newer_file {
None => serde_json::to_string_pretty(self).map_err(unsaved)?,
Some(file) => {
let mut value = serde_json::to_value(self).map_err(unsaved)?;
file.restore(&mut value);
serde_json::to_string_pretty(&value).map_err(unsaved)?
}
};
require(text.len() <= MAX_DRAWING_BYTES, || format!("drawing too large to save (at most {} MiB)", MAX_DRAWING_BYTES >> 20))?;
Ok(text)
}
@@ -8,7 +8,7 @@ whole format bump: never edit the `DRAWING` family in `format.rs`, another chang
- Name it after the change, lowercase with dashes: `sheet-views.rs`.
- Start it with a `//!` note: what is saved, what an older drawing gets without it, and what
the last release does with a drawing that has it (it refuses the newer format number).
the last release does with a drawing that has it (it warns about the newer format number, opens what it can read and keeps the rest).
- If older drawings must be rewritten as they open, add the step to the same file:
```rust
+42 -13
View File
@@ -325,28 +325,28 @@ fn damaged_files_are_refused_and_never_patched() {
let why = refused(&d, edit);
assert!(why.starts_with("damaged drawing: ") && why.contains("unknown field"), "{i}: {why}");
}
// A newer build's file is refused as one before anything in it is read: a field this build
// doesn't know, or a number out of its range.
// A newer build's file isn't held to this build's rules: a field it doesn't know, or a sheet with
// a number out of range, stays out. With no sheet left to show, it says why.
let (current, newer) = (DRAWING.current(), DRAWING.current() + 1);
let why = refused(&d, |v| {
v["format"] = json!(DRAWING.format_string(newer));
v["views"] = json!([]);
v["sheets"][0]["dpi"] = json!(1e10);
});
assert_eq!(
why,
format!(
"this drawing was saved by a newer SolidCraft (file format {newer}; this version reads formats up to {current}): update SolidCraft to open it"
)
assert_eq!(why, "read");
let why = refused(&d, |v| {
v["format"] = json!(DRAWING.format_string(newer));
v["sheets"] = json!([{"id": "future", "a_future_field": true}]);
});
assert!(
why.starts_with(&format!(
"this drawing was saved by a newer SolidCraft (format {newer}, this version reads up to {current}) and this version can't read it: "
)),
"{why}"
);
// A list of pending changes means nothing beside a format made of none; beside the pending
// number, a change this build doesn't know is a newer build's.
let why = refused(&d, |v| v["format_pending"] = json!(["a-later-change"]));
if DRAWING.pending_names().is_empty() {
assert_eq!(why, "read");
} else {
assert!(why.contains("newer SolidCraft"), "{why}");
}
assert_eq!(why, "read");
// A design is never read as a drawing, nor a drawing as a design.
let design = crate::Document::new("Plate").to_json();
let design_format = crate::format::format_string(crate::format::FORMAT);
@@ -551,3 +551,32 @@ fn mutate(v: &mut Value, pick: &mut usize, lie: &Value, how: u64) -> bool {
_ => false,
}
}
/// A drawing a newer build saved opens, with what it can read, and saving keeps the rest.
#[test]
fn a_newer_builds_drawing_opens_and_keeps_what_it_cant_read() {
let d = plate();
let mut file: Value = file(&d);
assert!(file.as_object().unwrap().keys().all(|key| RECORD_FIELDS.contains(&key.as_str())), "a new field goes in RECORD_FIELDS");
let newer = DRAWING.format_string(DRAWING.current() + 1);
file["format"] = json!(newer);
// With nothing it can't read: the same drawing.
let opened = Drawing::from_json(&file.to_string()).unwrap();
assert_eq!(opened.sheets, d.sheets);
assert!(opened.newer_file.as_ref().unwrap().notice().starts_with("Saved by a newer SolidCraft"));
assert_eq!(serde_json::from_str::<Value>(&opened.to_json().unwrap()).unwrap()["format"], json!(newer));
// A field and a second sheet it can't read stay out, named, and go back.
file["views"] = json!([{"id": "a"}]);
let mut future_sheet = file["sheets"][0].clone();
future_sheet["name"] = json!("Future");
future_sheet["future_layers"] = json!([1]);
file["sheets"].as_array_mut().unwrap().push(future_sheet);
let opened = Drawing::from_json(&file.to_string()).unwrap();
assert_eq!(opened.sheets, d.sheets);
let said = &opened.newer_file.as_ref().unwrap().not_understood;
assert!(said.iter().any(|what| what.contains("views")) && said.iter().any(|what| what.contains("Future")), "{said:?}");
let again: Value = serde_json::from_str(&opened.to_json().unwrap()).unwrap();
assert_eq!(again["views"], file["views"]);
assert_eq!(again["sheets"].as_array().unwrap().len(), d.sheets.len() + 1);
assert_eq!(again["format"], json!(newer));
}
+1
View File
@@ -7064,6 +7064,7 @@ fn eval_feature(
// Its tools meet bodies in other placements where those are shown (#3383).
let _frames = ToolFrames::install(doc, f, st);
match &f.kind {
FeatureKind::NewerFileFeature { raw } => Err(DocError::Invalid(raw.why())),
FeatureKind::BodyTransfer { bodies, from, to, transform } => {
crate::body_transfer::evaluate(doc, st, &crate::plain(bodies), *from, *to, *transform)
}
@@ -29,6 +29,7 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
let hole =
|h: Value| json!({"type": "hole", "position": o, "direction": [0, 0, 1], "diameter": "hole.diameter", "depth": "hole.depth", "hole": h});
vec![
("newer_file_feature", json!({"type": "newer_file_feature", "raw": {"type": "gyroid_lattice", "cell": 2}})),
(
"body_transfer",
json!({"type": "body_transfer", "bodies": ["B"], "from": 0, "to": 1, "transform": [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]}),
@@ -1,3 +1,4 @@
newer_file_feature
body_transfer
sketch
inputs:
@@ -1,3 +1,4 @@
newer_file_feature
body_transfer
sketch
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
@@ -1,3 +1,4 @@
newer_file_feature
body_transfer
remap_bodies: bodies[0]
sketch
+139 -117
View File
@@ -2,8 +2,9 @@
//!
//! A design file starts with `"format": "solidcraft/N"`. A file of an older format is read and
//! upgraded step by step (N → N+1 → … → current): JSON-level steps first, for changes in the
//! file's shape, then document-level steps. A file of a newer format than this build knows is
//! refused with a message saying so, never guessed at.
//! file's shape, then document-level steps. A file a newer build saved opens too, with a warning:
//! what this build reads is read, what it doesn't is reported by name and kept for the next save
//! (`crate::newer_file`).
//!
//! History:
//! - 1: every design up to 2026-10-08 (format string since M0). Fields added over time all
@@ -264,7 +265,7 @@ const FORMATS: Formats<'static> = Formats { hand: &JSON_STEPS, folded: FOLDED, p
/// A kind of file whose format is versioned here: designs, and drawings (#3890). Each family has
/// its own format tag, its own numbers and its own folder of change files (`build.rs` lists them);
/// reading a file's format, refusing a file a newer build saved and the upgrade are the same code
/// reading a file's format, telling a file a newer build saved and the upgrade are the same code
/// for every family.
pub struct Family<'a> {
/// What a file of the family is called in messages.
@@ -317,8 +318,8 @@ impl Family<'_> {
}
/// Read a file's format and take its `format_pending` out of its JSON, before anything else
/// in it is read: a file that isn't this family's, or that a newer build saved, is refused
/// here, so nothing a newer build saved is judged by this build's rules.
/// in it is read: a file that isn't this family's is refused here. One a newer build saved is
/// not: [`Family::newer`] says so.
pub fn take_version(&self, v: &mut Value) -> Result<(u32, Option<Vec<String>>)> {
let noun = self.noun;
let not_ours = |what: String| DocError::Invalid(format!("not a SolidCraft {noun} ({what})"));
@@ -329,10 +330,19 @@ impl Family<'_> {
Some(_) => return Err(not_ours("format is not a string".into())),
};
let applied = take_names(v, "format_pending", noun)?;
self.formats.check(noun, version, applied.as_deref())?;
if version == 0 {
return Err(not_ours("format 0".into()));
}
Ok((version, applied))
}
/// What says a file of format `version` with the changes `applied` came from a newer build:
/// a number past this build's, or a change this build doesn't have anywhere in its history.
/// `None` for a file this build fully knows.
pub fn newer(&self, version: u32, applied: Option<&[String]>) -> Option<Newer> {
self.formats.newer(version, applied)
}
/// Bring a file of format `version` up to this build's; `applied` is its `format_pending`
/// (both from [`Family::take_version`]).
pub fn upgrade(&self, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
@@ -350,6 +360,17 @@ impl Family<'_> {
}
}
/// How a file shows a newer build saved it ([`Family::newer`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Newer {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
}
/// The names of the pending changes this build writes into a design (`format_pending`).
pub fn pending_names() -> Vec<String> {
DESIGN.pending_names()
@@ -394,35 +415,25 @@ impl Formats<'_> {
}
}
/// Refuse a `noun` of format `version` that this build can't read: format 0, a newer number,
/// or a change `applied` (its `format_pending`) names that this build doesn't have anywhere in
/// its history, which only a newer build's file can. Changes are recognized across the entire
/// history: a branch's pending change may have landed at a later number. 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).
fn check(&self, noun: &str, version: u32, applied: Option<&[String]>) -> Result<()> {
let newer = |what: String| DocError::Invalid(format!("this {noun} was saved by a newer SolidCraft ({what}): update SolidCraft to open it"));
/// [`Family::newer`]. Changes are recognized across the entire history: a branch's pending
/// change may have landed at a later number. 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).
fn newer(&self, version: u32, applied: Option<&[String]>) -> Option<Newer> {
let current = self.current();
if version == 0 {
return Err(DocError::Invalid(format!("not a SolidCraft {noun} (format 0)")));
}
if version > current {
return Err(newer(format!("file format {version}; this version reads formats up to {current}")));
}
let applied = applied.filter(|_| self.parts(version).is_some()).unwrap_or_default();
match applied.iter().find(|name| !self.knows_part_name(self.canonical_applied_name(version, name))) {
Some(unknown) => Err(newer(format!("file format {version} with `{unknown}`, which this version doesn't know"))),
None => Ok(()),
}
let listed = applied.filter(|_| version > current || self.parts(version).is_some()).unwrap_or_default();
let unknown_changes: Vec<String> =
listed.iter().filter(|name| !self.knows_part_name(self.canonical_applied_name(version, name))).cloned().collect();
(version > current || !unknown_changes.is_empty()).then_some(Newer { format: version, reads_up_to: current, unknown_changes })
}
/// Bring a `noun` 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 file [`Formats::check`] refuses is refused,
/// whoever calls, before anything is changed; already-applied changes never run again at
/// their later number.
/// were pending; the others' steps run first, and already-applied changes never run again at
/// their later number. A file past this build's number has no steps to run.
fn upgrade(&self, noun: &str, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
self.check(noun, version, applied)?;
if version == 0 {
return Err(DocError::Invalid(format!("not a SolidCraft {noun} (format 0)")));
}
// A list beside a format that isn't made of changes is ignored, like any field an upgrade
// drops.
let applied = applied.filter(|_| self.parts(version).is_some());
@@ -677,18 +688,53 @@ fn v2_to_v3(v: &mut Value) -> Result<()> {
pub fn read(s: &str) -> Result<Document> {
let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft design: {e}")))?;
let (version, applied) = DESIGN.take_version(&mut v)?;
check_design_numbers(&v)?;
crate::config::validate_rules_json(&v)?;
crate::config::validate_styles_json(&v)?;
crate::face_anchor::validate_face_anchors_json(&v)?;
validate_versioned_geometry(&v)?;
validate_emboss_targets(&v)?;
validate_freeform_offsets(&v)?;
validate_sketch_sizes(&v)?;
crate::format_offsets::validate_raw(&v)?;
let legacy_wires = validate_wires(&v, version)?;
match DESIGN.newer(version, applied.as_deref()) {
Some(newer) => crate::newer_file::read(v, version, applied, newer),
None => decode(v, version, applied.as_deref()),
}
}
/// Whether a design's text says a newer build saved it, read before anything else in it is.
pub fn says_newer(text: &str) -> bool {
#[derive(serde::Deserialize)]
struct Says {
format: Option<String>,
format_pending: Option<Vec<String>>,
}
serde_json::from_str::<Says>(text)
.ok()
.and_then(|says| Some((DESIGN.version_of(&says.format?)?, says.format_pending)))
.is_some_and(|(version, applied)| DESIGN.newer(version, applied.as_deref()).is_some())
}
/// The checks of a design's JSON that need nothing outside the part of it they are given.
pub(crate) fn check_raw(v: &Value, version: u32) -> Result<Vec<(usize, usize)>> {
check_design_numbers(v)?;
crate::config::validate_rules_json(v)?;
crate::config::validate_styles_json(v)?;
crate::face_anchor::validate_face_anchors_json(v)?;
validate_versioned_geometry(v)?;
validate_emboss_targets(v)?;
validate_freeform_offsets(v)?;
validate_sketch_sizes(v)?;
crate::format_offsets::validate_raw(v)?;
validate_wires(v, version)
}
/// [`check_raw`], the upgrade and the typed record alone: what a piece of a design must pass by
/// itself ([`crate::newer_file`]), with nothing else in the file for it to refer to.
pub(crate) fn check_piece(mut v: Value, version: u32, applied: Option<&[String]>) -> Result<()> {
check_raw(&v, version)?;
DESIGN.upgrade(&mut v, version, applied)?;
let d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
check_typed_numbers(&d)
}
/// A design's JSON, its format taken out, as the document it holds.
pub(crate) fn decode(mut v: Value, version: u32, applied: Option<&[String]>) -> Result<Document> {
let legacy_wires = check_raw(&v, version)?;
validate_body_transfers(&v)?;
DESIGN.upgrade(&mut v, version, applied.as_deref())?;
DESIGN.upgrade(&mut v, version, applied)?;
let mut d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
check_typed_numbers(&d)?;
let mut legacy_by_feature = std::collections::BTreeMap::<usize, Vec<usize>>::new();
@@ -1327,9 +1373,9 @@ mod tests {
let mut v = serde_json::json!({});
formats.upgrade("design", &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("design", &mut serde_json::json!({}), next, Some(&["c-later".into()])).unwrap_err();
assert!(err.to_string().contains("newer SolidCraft"), "{err}");
// A change this build doesn't know is a newer build's: said, and kept.
let said = formats.newer(next, Some(&["c-later".into()])).unwrap();
assert_eq!(said, Newer { format: next, reads_up_to: next, unknown_changes: vec!["c-later".into()] });
// A list on a format that isn't made of changes means nothing.
let mut v = serde_json::json!({});
formats.upgrade("design", &mut v, HAND_WRITTEN, Some(&["a-first".into()])).unwrap();
@@ -1372,15 +1418,12 @@ mod tests {
}
#[test]
fn personal_card_alias_without_support_refuses_before_mutation() {
fn personal_card_alias_without_support_is_a_newer_builds() {
let hand: [JsonStep; 27] = [|_| Ok(()); 27];
let first = [traced("a-first")];
let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &[] };
let mut file = serde_json::json!({"trace": [], "sentinel": 7});
let original = file.clone();
let error = formats.upgrade("design", &mut file, 29, Some(&["physical-material-cards".into()])).unwrap_err();
assert!(error.to_string().contains("physical-material-cards"), "{error}");
assert_eq!(file, original);
let said = formats.newer(29, Some(&["physical-material-cards".into()])).unwrap();
assert_eq!(said.unknown_changes, ["physical-material-cards"]);
}
#[test]
@@ -1391,11 +1434,8 @@ mod tests {
let cards = [traced("material-engineering-cards")];
let formats = Formats { hand: &hand, folded: &[(28, &first), (29, &second), (30, &cards)], pending: &[] };
for version in [28, 30] {
let mut file = serde_json::json!({"trace": []});
let original = file.clone();
let error = formats.upgrade("design", &mut file, version, Some(&["physical-material-cards".into()])).unwrap_err();
assert!(error.to_string().contains("physical-material-cards"), "{error}");
assert_eq!(file, original);
let said = formats.newer(version, Some(&["physical-material-cards".into()])).unwrap();
assert_eq!(said.unknown_changes, ["physical-material-cards"]);
}
}
@@ -1408,11 +1448,7 @@ mod tests {
for names in
[["physical-material-cards".into(), "unknown-card-codec".into()], ["unknown-card-codec".into(), "physical-material-cards".into()]]
{
let mut file = serde_json::json!({"trace": ["b"]});
let original = file.clone();
let error = formats.upgrade("design", &mut file, 29, Some(&names)).unwrap_err();
assert!(error.to_string().contains("unknown-card-codec"), "{error}");
assert_eq!(file, original);
assert_eq!(formats.newer(29, Some(&names)).unwrap().unknown_changes, ["unknown-card-codec"]);
}
}
@@ -1446,18 +1482,14 @@ mod tests {
}
/// An experimental producer can use a number that a release later folds without its
/// change. Its unknown concept must still be refused before any migration runs.
/// change. Its unknown concept is still a newer build's.
#[test]
fn unknown_pending_concepts_are_refused_after_folding() {
fn unknown_pending_concepts_are_said_after_folding() {
let parts = [traced("a-first")];
let later = [traced("b-second")];
for pending in [&[][..], &later[..]] {
let formats = Formats { hand: &[], folded: &[(2, &parts)], pending };
let mut file = serde_json::json!({"trace": []});
let original = file.clone();
let err = formats.upgrade("design", &mut file, 2, Some(&["experimental-card".into()])).unwrap_err();
assert!(err.to_string().contains("experimental-card") && err.to_string().contains("update SolidCraft"), "{err}");
assert_eq!(file, original, "refuse before applying even a known migration");
assert_eq!(formats.newer(2, Some(&["experimental-card".into()])).unwrap().unknown_changes, ["experimental-card"]);
}
}
@@ -1479,23 +1511,20 @@ mod tests {
}
}
/// A design naming a pending change this build doesn't have is a newer build's, refused as
/// such before anything else in it is checked (as a newer number is), so a newer build's
/// fields never read as damage.
/// A design naming a pending change this build doesn't have is a newer build's: what it can't
/// read of it doesn't read as damage.
#[test]
fn a_newer_builds_pending_change_is_refused_before_the_design_is_checked() {
fn a_newer_builds_pending_change_opens_and_its_fields_are_not_damage() {
let mut file: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
file["format_pending"] = serde_json::json!(["not-a-change-anyone-made"]);
file["features"] = serde_json::json!([{"type": "emboss", "face": {"body": "Body", "at": [0, 0, 0], "a_newer_key": true}}]);
let error = read(&file.to_string()).unwrap_err().to_string();
let opened = read(&file.to_string()).unwrap();
// With changes pending, FORMAT is made of them; once they are folded it is the folded
// format, which is made of changes too. Either way an unknown change is a newer build's.
assert_eq!(
error,
format!(
"this design was saved by a newer SolidCraft (file format {FORMAT} with `not-a-change-anyone-made`, which this version doesn't know): update SolidCraft to open it"
)
);
let said = opened.newer_file.as_ref().unwrap();
assert_eq!(said.unknown_changes, ["not-a-change-anyone-made"]);
assert!(said.notice().contains(&format!("format {FORMAT} with `not-a-change-anyone-made`")), "{}", said.notice());
assert!(matches!(opened.features[0].kind, crate::FeatureKind::NewerFileFeature { .. }));
}
/// Drawings follow the same rules from their own first format: folded formats follow on from 1
@@ -1575,17 +1604,17 @@ mod tests {
fn tagged(family: &Family<'_>, n: u32) -> Value {
serde_json::json!({"format": family.format_string(n)})
}
fn newer(noun: &str, n: u32, current: u32) -> String {
format!(
"this {noun} was saved by a newer SolidCraft (file format {n}; this version reads formats up to {current}): update SolidCraft to open it"
)
}
assert_eq!(refusal(&DESIGN, tagged(&DESIGN, FORMAT + 1)), newer("design", FORMAT + 1, FORMAT));
assert_eq!(read(&tagged(&DESIGN, FORMAT + 1).to_string()).unwrap_err().to_string(), newer("design", FORMAT + 1, FORMAT));
// A newer number isn't refused: it is said.
let says = |family: &Family<'_>, mut file: Value| {
let (version, applied) = family.take_version(&mut file).unwrap();
family.newer(version, applied.as_deref())
};
assert_eq!(says(&DESIGN, tagged(&DESIGN, FORMAT + 1)), Some(Newer { format: FORMAT + 1, reads_up_to: FORMAT, unknown_changes: vec![] }));
assert_eq!(read(&tagged(&DESIGN, FORMAT + 1).to_string()).unwrap().newer_file.unwrap().format, FORMAT + 1);
let current = DRAWING.current();
assert!(matches!(DRAWING.take_version(&mut tagged(&DRAWING, current)), Ok((v, None)) if v == current));
for n in [current + 1, u32::MAX] {
assert_eq!(refusal(&DRAWING, tagged(&DRAWING, n)), newer("drawing", n, current));
assert_eq!(says(&DRAWING, tagged(&DRAWING, n)), Some(Newer { format: n, reads_up_to: current, unknown_changes: vec![] }));
}
for (family, noun) in [(&DESIGN, "design"), (&DRAWING, "drawing")] {
assert_eq!(refusal(family, tagged(family, 0)), format!("not a SolidCraft {noun} (format 0)"));
@@ -1607,12 +1636,7 @@ mod tests {
let design = Family { formats: Formats { hand: &JSON_STEPS, folded: FOLDED, pending: &pending }, ..DESIGN };
let next = design.current();
let file = json!({"format": format_string(next), "format_pending": ["c-later"]});
assert_eq!(
refusal(&design, file),
format!(
"this design was saved by a newer SolidCraft (file format {next} with `c-later`, which this version doesn't know): update SolidCraft to open it"
)
);
assert_eq!(says(&design, file), Some(Newer { format: next, reads_up_to: next, unknown_changes: vec!["c-later".into()] }));
assert!(matches!(DESIGN.take_version(&mut json!({})), Ok((1, None))));
assert_eq!(refusal(&DRAWING, json!({})), "not a SolidCraft drawing (no format)");
}
@@ -1639,19 +1663,13 @@ mod tests {
assert_eq!(open(json!({"format": "solidcraft-drawing/2", "format_pending": ["a-first"]})).unwrap(), Vec::<String>::new());
// A list beside format 1 means nothing: the step still runs.
assert_eq!(open(json!({"format": "solidcraft-drawing/1", "format_pending": ["c-later"]})).unwrap(), ["a"]);
let error = open(json!({"format": "solidcraft-drawing/2", "format_pending": ["a-first", "c-later"]})).unwrap_err().to_string();
assert_eq!(
error,
"this drawing was saved by a newer SolidCraft (file format 2 with `c-later`, which this version doesn't know): update SolidCraft to open it"
);
let error = open(json!({"format": "solidcraft-drawing/3"})).unwrap_err().to_string();
assert_eq!(
error,
"this drawing was saved by a newer SolidCraft (file format 3; this version reads formats up to 2): update SolidCraft to open it"
);
// An upgrade called without `take_version` refuses in the drawing's words too, format 0 included.
let error = drawing.upgrade(&mut json!({}), 3, None).unwrap_err().to_string();
assert!(error.starts_with("this drawing was saved by a newer SolidCraft (file format 3;"), "{error}");
// Of a newer build: said, and what can run runs.
let list = ["a-first".to_string(), "c-later".to_string()];
assert_eq!(drawing.newer(2, Some(&list)).unwrap().unknown_changes, ["c-later"]);
assert_eq!(open(json!({"format": "solidcraft-drawing/2", "format_pending": list})).unwrap(), Vec::<String>::new());
assert_eq!(drawing.newer(3, None).unwrap(), Newer { format: 3, reads_up_to: 2, unknown_changes: vec![] });
assert_eq!(open(json!({"format": "solidcraft-drawing/3"})).unwrap(), Vec::<String>::new());
// An upgrade called without `take_version` still says format 0 isn't one.
assert_eq!(drawing.upgrade(&mut json!({}), 0, None).unwrap_err().to_string(), "not a SolidCraft drawing (format 0)");
}
@@ -1666,7 +1684,8 @@ mod tests {
assert!(read(&file.to_string()).is_err());
}
file["format_pending"] = serde_json::json!(["not-a-change-anyone-made"]);
assert!(read(&file.to_string()).is_err(), "unknown pending concepts are refused even after folding");
let newer = read(&file.to_string()).unwrap();
assert_eq!(newer.newer_file.as_ref().unwrap().unknown_changes, ["not-a-change-anyone-made"], "even after folding");
file["format_pending"] = Value::from(pending_names());
let opened = read(&file.to_string()).unwrap();
assert_eq!(opened.format_pending, pending_names());
@@ -2138,8 +2157,9 @@ mod tests {
value["features"] = serde_json::json!([{"type":"sketch", "sketch":{"freeform_offsets":records}}]);
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("free-form offset metadata"), "{error}");
// A newer build's file isn't held to this: the feature waits for that build.
value["format"] = Value::from(format_string(FORMAT + 1));
assert!(read(&value.to_string()).unwrap_err().to_string().contains("newer SolidCraft"));
assert!(matches!(read(&value.to_string()).unwrap().features[0].kind, crate::FeatureKind::NewerFileFeature { .. }));
}
}
@@ -2163,8 +2183,7 @@ mod tests {
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("emboss face"), "{error}");
value["format"] = Value::from(format_string(FORMAT + 1));
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("newer SolidCraft"), "{error}");
assert!(matches!(read(&value.to_string()).unwrap().features[0].kind, crate::FeatureKind::NewerFileFeature { .. }));
}
}
@@ -2207,7 +2226,7 @@ mod tests {
}
#[test]
fn version_seven_styles_upgrade_and_newer_versions_are_refused_before_columns() {
fn version_seven_styles_upgrade_and_a_newer_versions_columns_are_kept() {
use crate::config::{Column, ConfigRow};
let mut d = Document::new("configured styles");
d.configs.columns = vec![Column::Material("Body".into()), Column::Appearance("Body".into())];
@@ -2227,9 +2246,11 @@ mod tests {
value["format"] = Value::from(format_string(FORMAT + 1));
value["configs"]["columns"] = serde_json::json!([{"unknown_future_column":"Body"}]);
value["configs"]["rules"] = serde_json::json!({"unknown_future_rule": true});
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("newer SolidCraft") && error.contains("update SolidCraft"), "{error}");
assert!(!error.contains("unknown variant"), "format refusal precedes decoding new variants");
let opened = read(&value.to_string()).unwrap();
let said = opened.newer_file.as_ref().unwrap();
assert!(said.not_understood.iter().any(|what| what.contains("configs")), "{:?}", said.not_understood);
let saved: Value = serde_json::from_str(&opened.to_json()).unwrap();
assert_eq!(saved["configs"], value["configs"], "kept for the build that reads them");
}
#[test]
@@ -2284,8 +2305,10 @@ mod tests {
assert_eq!(read(&saved.to_string()).unwrap(), d);
saved["format"] = Value::from(format_string(FORMAT + 1));
saved["features"][0]["sketch"]["constraints"][0]["type"] = Value::from("future_dimension");
let error = read(&saved.to_string()).unwrap_err().to_string();
assert!(error.contains("newer SolidCraft") && !error.contains("unknown variant"), "{error}");
let opened = read(&saved.to_string()).unwrap();
assert!(matches!(opened.features[0].kind, crate::FeatureKind::NewerFileFeature { .. }));
let again: Value = serde_json::from_str(&opened.to_json()).unwrap();
assert_eq!(again["features"][0], saved["features"][0]);
}
#[test]
@@ -2392,15 +2415,14 @@ mod tests {
}
#[test]
fn older_upgraded_newer_refused_others_rejected() {
fn older_upgraded_newer_opened_others_rejected() {
let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
v["format"] = Value::from("solidcraft/1");
assert_eq!(read(&v.to_string()).unwrap().format, format_string(FORMAT));
v.as_object_mut().unwrap().remove("format");
assert!(read(&v.to_string()).is_ok(), "a file without a format is format 1");
v["format"] = Value::from(format_string(FORMAT + 1));
let e = read(&v.to_string()).unwrap_err().to_string();
assert!(e.contains("newer SolidCraft"), "{e}");
assert_eq!(read(&v.to_string()).unwrap().newer_file.unwrap().format, FORMAT + 1);
for bad in ["solidcraft/0", "acad/3", "solidcraft/-1", "solidcraft/99999999999999999999"] {
v["format"] = Value::from(bad);
assert!(read(&v.to_string()).is_err(), "{bad}");
+1 -1
View File
@@ -5,7 +5,7 @@ here is the whole format bump: never edit `FORMAT`, `JSON_STEPS` or another chan
- 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).
the last release does with a design that has it (it warns about the newer format number, opens what it can read and keeps the rest).
- If older designs must be rewritten as they open, add the step to the same file:
```rust
+1 -1
View File
@@ -399,7 +399,7 @@ mod tests {
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"));
assert!(crate::format::read(&damaged.to_string()).unwrap().newer_file.is_some());
}
// Absent metadata preserves the historical one-to-one chain representation.
let mut legacy = fixture();
+1
View File
@@ -11,6 +11,7 @@ mod assembly;
mod body_transfer;
mod body_transfer_sheet;
mod browser_tree;
pub mod newer_file;
pub mod pbr_appearance;
pub use body_transfer::{valid_transfer_matrix, validate_body_transfer};
pub mod canvas;
+530
View File
@@ -0,0 +1,530 @@
//! A design a newer SolidCraft saved: it opens, with a warning, and saving it loses nothing.
//!
//! A newer format number, or a change the file names that this build doesn't have, says a newer
//! build saved the file. It may use nothing this build lacks, so it is read as usual. When part of
//! it can't be read, that part stays out of the way and the rest opens:
//! - a feature this build can't read keeps its place on the timeline as
//! [`FeatureKind::NewerFileFeature`](crate::FeatureKind::NewerFileFeature), which reports an error when evaluated;
//! - a parameter it can't read is left out;
//! - any other top-level section it can't read is left out whole;
//! - a field it doesn't know, which typed reading would drop, is noticed by comparing the file
//! with what was read.
//!
//! All of these are named in [`NewerFile::not_understood`] and go back into the file when it is
//! saved ([`restore`]), as they came. Saving keeps the file's own format number and change list:
//! the file still holds what the newer build wrote, and a lower number would make that build
//! upgrade its own data as if it were old. What can't be put back because the design changed around
//! it ([`not_kept`]) is said in the reply to the save.
use std::collections::BTreeMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::format::{self, DESIGN, Newer};
use crate::{DocError, Document, Result};
/// How a [`RawFeature`] is written while it is inside this build; [`restore`] unwraps it.
const FOREIGN_TAG: &str = "newer_file_feature";
/// Sections a design can't open without: filled in when the file's can't be read.
const CORE: [&str; 3] = ["name", "params", "features"];
/// The most sections left out one at a time to find the one a design can't be read with.
const MAX_SECTION_TRIES: usize = 64;
/// Fields a design container adds beside what a design holds; not unknown to this build.
const CONTAINER_ONLY: [&str; 5] = ["step_blob", "mesh_blob", "image_blob", "points_blob", "hdr_blob"];
/// A feature as a newer build wrote it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RawFeature(pub Box<Value>);
impl RawFeature {
/// The kind of feature it says it is.
pub fn kind(&self) -> &str {
self.0.get("type").and_then(Value::as_str).unwrap_or("unnamed")
}
/// Why it doesn't build here.
pub fn why(&self) -> String {
format!("saved by a newer SolidCraft: this version can't read a `{}` feature. Update SolidCraft to build it; saving keeps it", self.kind())
}
}
/// What the file says and holds that this build doesn't read.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NewerFile {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
/// What this build couldn't read, by name.
pub not_understood: Vec<String>,
/// Fields known records lost in typed reading.
kept: Option<Kept>,
/// Top-level sections left out whole.
sections: Vec<Section>,
/// Elements left out of a top-level list: its key and the element.
elements: Vec<(String, Value)>,
/// The parts of a design container this build has no use for: name and bytes. A blob only one
/// of the kept pieces refers to is one of them.
pub parts: Vec<(String, Arc<[u8]>)>,
/// The file's `container.json`, when this build doesn't know what it says.
pub container: Option<Value>,
}
#[derive(Clone, Debug, PartialEq)]
struct Section {
key: String,
value: Value,
/// This build's record of the section, without the file's: while it is unchanged, the file's
/// goes back.
empty: Option<Value>,
}
/// The fields of a record that typed reading dropped.
#[derive(Clone, Debug, PartialEq)]
enum Kept {
Field(Value),
Fields(BTreeMap<String, Kept>),
Items(Vec<(Anchor, Kept)>),
}
/// Which element of a list some kept fields belong to.
#[derive(Clone, Debug, PartialEq)]
enum Anchor {
Id(u64),
Name(String),
/// Its place, good only while the list keeps its length.
Index {
at: usize,
of: usize,
},
}
impl NewerFile {
/// What to tell the user, whatever else is said.
pub fn notice(&self) -> String {
let reads = self.reads_up_to;
let format = self.format;
match self.unknown_changes.first() {
Some(change) if format <= reads => {
format!(
"Saved by a newer SolidCraft (format {format} with `{change}`, which this version doesn't know). If something's missing, update."
)
}
_ => format!("Saved by a newer SolidCraft (format {format}, this version reads up to {reads}). If something's missing, update."),
}
}
/// Every line to report: the notice, then what couldn't be read, by name.
pub fn warnings(&self) -> Vec<String> {
let unread = self.not_understood.iter().map(|what| format!("Not read: {what}. Saving keeps it."));
std::iter::once(self.notice()).chain(unread).collect()
}
/// What a reply says about it.
pub fn report(&self) -> Value {
json!({
"format": self.format,
"reads_up_to": self.reads_up_to,
"unknown_changes": self.unknown_changes,
"not_understood": self.not_understood,
"notice": self.notice(),
})
}
/// Whether the next save needs a container to keep what the file holds: plain JSON has no room
/// for parts beside the design.
pub fn needs_container(&self) -> bool {
!self.parts.is_empty()
}
/// Whether anything kept back names this text (a blob's name in a container).
pub fn mentions(&self, text: &str) -> bool {
fn value(v: &Value, text: &str) -> bool {
match v {
Value::String(s) => s == text,
Value::Array(a) => a.iter().any(|x| value(x, text)),
Value::Object(o) => o.values().any(|x| value(x, text)),
_ => false,
}
}
fn kept(k: &Kept, text: &str) -> bool {
match k {
Kept::Field(v) => value(v, text),
Kept::Fields(m) => m.values().any(|x| kept(x, text)),
Kept::Items(l) => l.iter().any(|(_, x)| kept(x, text)),
}
}
self.kept.as_ref().is_some_and(|k| kept(k, text))
|| self.sections.iter().any(|s| value(&s.value, text))
|| self.elements.iter().any(|(_, v)| value(v, text))
}
/// Say that a section the container reader couldn't finish reading is left out.
/// `empty` is what the design writes for the section while it holds nothing, if anything.
pub fn keep_section(&mut self, key: &str, value: Value, empty: Option<Value>, why: &str) {
self.not_understood.push(format!("section `{key}` ({why})"));
self.sections.push(Section { key: key.into(), value, empty });
}
}
/// Read a design a newer build saved, or say why it truly can't be read.
pub(crate) fn read(mut raw: Value, version: u32, applied: Option<Vec<String>>, newer: Newer) -> Result<Document> {
let applied_names = applied.as_deref();
let mut file =
NewerFile { format: newer.format, reads_up_to: newer.reads_up_to, unknown_changes: newer.unknown_changes.clone(), ..NewerFile::default() };
let (mut doc, read_as) = match format::decode(raw.clone(), version, applied_names) {
Ok(doc) => (doc, raw),
Err(_) => {
let mut foreign = BTreeMap::new();
let mut doc = salvage(&mut raw, version, applied_names, &mut file, &mut foreign).map_err(|why| {
DocError::Invalid(format!(
"this design was saved by a newer SolidCraft (format {}, this version reads up to {}) and this version can't read it: {why}. Update SolidCraft to open it",
newer.format, newer.reads_up_to
))
})?;
fill_foreign(&mut doc, foreign);
(doc, raw)
}
};
let mut upgraded = read_as;
// Compared with what was read, the file's own shape after the same upgrade.
DESIGN.upgrade(&mut upgraded, version, applied_names)?;
let known = serde_json::to_value(&doc).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
let mut paths = Vec::new();
file.kept = vanished(&upgraded, &known, "", &mut paths);
file.not_understood.extend(paths);
for section in &mut file.sections {
section.empty = known.get(&section.key).cloned();
}
if newer.format > newer.reads_up_to {
// The file's own number and list: it still holds what that build wrote.
doc.format = DESIGN.format_string(newer.format);
doc.format_pending = applied.unwrap_or_default();
} else {
// Upgraded to this build's format; the changes it doesn't know are still in it.
doc.format_pending.extend(newer.unknown_changes.iter().cloned());
}
doc.newer_file = Some(Arc::new(file));
Ok(doc)
}
/// Read the design around what can't be read, which moves to `file`. `raw` ends as what was read.
fn salvage(
raw: &mut Value,
version: u32,
applied: Option<&[String]>,
file: &mut NewerFile,
foreign: &mut BTreeMap<u64, Value>,
) -> std::result::Result<Document, String> {
let piece = |key: &str, value: &Value| {
let mut stub = json!({"name": "", "params": [], "features": []});
stub[key] = value.clone();
format::check_piece(stub, version, applied)
};
let Some(top) = raw.as_object_mut() else { return Err("expected a JSON object".into()) };
// A feature it can't read keeps its place; a parameter it can't read stays out.
if let Some(Value::Array(features)) = top.get_mut("features") {
let mut free = features.iter().filter_map(|f| f.get("id").and_then(Value::as_u64)).max().unwrap_or(0).saturating_add(1);
for feature in features.iter_mut() {
if let Err(why) = piece("features", &json!([feature])) {
file.not_understood.push(feature_note(feature, &why.to_string()));
*feature = wrapped(std::mem::take(feature), &mut free, foreign);
}
}
}
if let Some(Value::Array(params)) = top.get_mut("params") {
params.retain(|param| match piece("params", &json!([param])) {
Ok(()) => true,
Err(why) => {
let name = param.get("name").and_then(Value::as_str).unwrap_or("unnamed");
file.not_understood.push(format!("parameter `{name}` ({})", short(&why.to_string())));
file.elements.push(("params".into(), param.clone()));
false
}
});
}
// Any other section, by itself.
let keys: Vec<String> = top.keys().cloned().collect();
for key in &keys {
let value = top.get(key).cloned().unwrap_or_default();
if let Err(why) = piece(key, &value) {
move_out(top, key, &why.to_string(), file);
}
}
if !top.get("name").is_some_and(Value::is_string) {
top.insert("name".into(), json!("Untitled"));
}
for key in ["params", "features"] {
top.entry(key).or_insert_with(|| json!([]));
}
let attempt = |top: &Map<String, Value>| format::decode(Value::Object(top.clone()), version, applied);
let first = match attempt(top) {
Ok(doc) => return Ok(doc),
Err(why) => why,
};
// Sections that are fine by themselves but not together with the rest.
let keys: Vec<String> = top.keys().filter(|k| !CORE.contains(&k.as_str())).cloned().collect();
// Each try reads the whole design: a file of many sections goes straight to the last resort.
for key in keys.iter().filter(|_| keys.len() <= MAX_SECTION_TRIES) {
let mut without = top.clone();
without.remove(key);
if let Ok(doc) = attempt(&without) {
move_out(top, key, &first.to_string(), file);
return Ok(doc);
}
}
let mut bare = top.clone();
bare.retain(|key, _| CORE.contains(&key.as_str()));
match attempt(&bare) {
Ok(doc) => {
for key in &keys {
move_out(top, key, &first.to_string(), file);
}
Ok(doc)
}
Err(_) => Err(first.to_string()),
}
}
/// Leave a section out and keep it. A section the design needs is left empty.
fn move_out(top: &mut Map<String, Value>, key: &str, why: &str, file: &mut NewerFile) {
let Some(value) = top.remove(key) else { return };
file.not_understood.push(format!("section `{key}` ({})", short(why)));
file.sections.push(Section { key: key.into(), value, empty: None });
}
/// The first line of a reader's error, without the list of every kind it knows.
fn short(why: &str) -> &str {
let why = why.split(", expected").next().unwrap_or(why);
why.strip_prefix("design file: ").unwrap_or(why)
}
fn feature_note(feature: &Value, why: &str) -> String {
let name = feature.get("name").and_then(Value::as_str).unwrap_or("unnamed");
let kind = feature.get("type").and_then(Value::as_str).unwrap_or("unnamed");
let why = short(why);
if why.starts_with("unknown variant") {
format!("feature `{name}`, a `{kind}` feature this version doesn't know")
} else {
format!("feature `{name}`, a `{kind}` feature ({why})")
}
}
/// A feature this build can't read as this build reads it: the fields every feature has, which stay
/// editable. Its `raw` is filled in after reading ([`fill_foreign`]), so nothing in a feature this
/// build can't read is judged by this build's rules. `free` gives an id to one the file left
/// without.
fn wrapped(raw: Value, free: &mut u64, foreign: &mut BTreeMap<u64, Value>) -> Value {
let id = raw.get("id").and_then(Value::as_u64).unwrap_or_else(|| {
let id = *free;
*free = free.saturating_add(1);
id
});
let name = raw.get("name").and_then(Value::as_str).unwrap_or("Unknown feature").to_string();
let suppressed = raw.get("suppressed").and_then(Value::as_bool).unwrap_or(false);
let component = raw.get("component").and_then(Value::as_u64).unwrap_or(0);
foreign.insert(id, raw);
json!({"id": id, "name": name, "suppressed": suppressed, "component": component, "type": FOREIGN_TAG, "raw": null})
}
/// Give the features [`wrapped`] left without one what the file said.
fn fill_foreign(doc: &mut Document, mut foreign: BTreeMap<u64, Value>) {
for feature in &mut doc.features {
if let crate::FeatureKind::NewerFileFeature { raw } = &mut feature.kind
&& let Some(value) = foreign.remove(&feature.id)
{
raw.0 = Box::new(value);
}
}
}
/// A feature back as the file had it, with the changes this build can make to any feature.
fn unwrapped(feature: &mut Value) {
let Some(wrapper) = feature.as_object_mut() else { return };
let Some(Value::Object(mut raw)) = wrapper.remove("raw") else { return };
for key in ["id", "name", "suppressed", "component"] {
match wrapper.get(key) {
// Left out as a writer leaves it out, unless the file said it.
Some(v) if key != "id" && !raw.contains_key(key) && looks_default(v) => {}
Some(v) => {
raw.insert(key.into(), v.clone());
}
None => {
raw.remove(key);
}
}
}
*feature = Value::Object(raw);
}
/// A value a writer leaves out without losing anything: not worth telling anyone about.
fn looks_default(v: &Value) -> bool {
match v {
Value::Null => true,
Value::Bool(b) => !b,
Value::Number(n) => n.as_f64() == Some(0.0),
Value::String(s) => s.is_empty(),
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty(),
}
}
/// What `raw` has that `known`, what was read of it, doesn't. `paths` gets those fields' names.
fn vanished(raw: &Value, known: &Value, path: &str, paths: &mut Vec<String>) -> Option<Kept> {
match (raw, known) {
(Value::Object(raw), Value::Object(known)) => {
if known.get("type").and_then(Value::as_str) == Some(FOREIGN_TAG) {
return None;
}
let mut kept = BTreeMap::new();
for (key, value) in raw {
if CONTAINER_ONLY.contains(&key.as_str()) || (path.is_empty() && matches!(key.as_str(), "format" | "format_pending")) {
continue;
}
let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
match known.get(key) {
None => {
if !looks_default(value) {
paths.push(format!("field `{inner}`"));
}
kept.insert(key.clone(), Kept::Field(value.clone()));
}
Some(known) => {
if let Some(k) = vanished(value, known, &inner, paths) {
kept.insert(key.clone(), k);
}
}
}
}
(!kept.is_empty()).then_some(Kept::Fields(kept))
}
(Value::Array(raw), Value::Array(known)) => {
let mut items = Vec::new();
for (at, element) in raw.iter().enumerate() {
let anchor = anchor_of(element).unwrap_or(Anchor::Index { at, of: raw.len() });
let Some(partner) = find(known, &anchor) else { continue };
let label = match (element.get("name").and_then(Value::as_str), &anchor) {
(Some(name), _) => format!("{path}[{name}]"),
(None, Anchor::Id(id)) => format!("{path}[{id}]"),
_ => format!("{path}[{at}]"),
};
if let Some(k) = vanished(element, partner, &label, paths) {
items.push((anchor, k));
}
}
(!items.is_empty()).then_some(Kept::Items(items))
}
_ => None,
}
}
fn anchor_of(element: &Value) -> Option<Anchor> {
if let Some(id) = element.get("id").and_then(Value::as_u64) {
return Some(Anchor::Id(id));
}
element.get("name").and_then(Value::as_str).map(|name| Anchor::Name(name.to_string()))
}
fn find<'a>(list: &'a [Value], anchor: &Anchor) -> Option<&'a Value> {
match anchor {
Anchor::Id(id) => list.iter().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)),
Anchor::Name(name) => list.iter().find(|x| x.get("name").and_then(Value::as_str) == Some(name)),
Anchor::Index { at, of } => (list.len() == *of).then(|| list.get(*at)).flatten(),
}
}
fn find_mut<'a>(list: &'a mut [Value], anchor: &Anchor) -> Option<&'a mut Value> {
match anchor {
Anchor::Id(id) => list.iter_mut().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)),
Anchor::Name(name) => list.iter_mut().find(|x| x.get("name").and_then(Value::as_str) == Some(name)),
Anchor::Index { at, of } => (list.len() == *of).then(|| list.get_mut(*at)).flatten(),
}
}
/// Put the kept fields back. An element that is gone was deleted here, its fields with it; one
/// whose place can't be told any more, and a field whose record is gone, go into `lost`.
fn apply(kept: &Kept, value: &mut Value, path: &str, lost: &mut Vec<String>) {
match kept {
Kept::Field(_) => {}
Kept::Fields(fields) => {
let Some(object) = value.as_object_mut() else {
lost.push(format!("field `{path}`"));
return;
};
for (key, sub) in fields {
let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
match (sub, object.get_mut(key)) {
(Kept::Field(v), None) => {
object.insert(key.clone(), v.clone());
}
(Kept::Field(_), Some(_)) => {}
(sub, Some(inner_value)) => apply(sub, inner_value, &inner, lost),
// The list it was in is empty and not written: its elements were deleted.
(Kept::Items(_), None) => {}
(_, None) => lost.push(format!("field `{inner}`")),
}
}
}
Kept::Items(items) => {
let Some(list) = value.as_array_mut() else {
lost.push(format!("field `{path}`"));
return;
};
for (anchor, sub) in items {
match (find_mut(list, anchor), anchor) {
(Some(element), _) => apply(sub, element, path, lost),
(None, Anchor::Index { at, .. }) => lost.push(format!("element {at} of `{path}`")),
(None, _) => {}
}
}
}
}
}
/// Write back what the design holds of the file a newer build saved, into `value`, the design's
/// JSON. Returns what couldn't go back ([`not_kept`]).
pub fn restore(doc: &Document, value: &mut Value) -> Vec<String> {
let mut lost = Vec::new();
let Some(file) = &doc.newer_file else { return lost };
if let Some(Value::Array(features)) = value.get_mut("features") {
for feature in features.iter_mut().filter(|f| f.get("type").and_then(Value::as_str) == Some(FOREIGN_TAG)) {
unwrapped(feature);
}
}
if let Some(kept) = &file.kept {
apply(kept, value, "", &mut lost);
}
if let Some(top) = value.as_object_mut() {
for section in &file.sections {
if top.get(&section.key) == section.empty.as_ref() {
top.insert(section.key.clone(), section.value.clone());
} else {
lost.push(format!("section `{}`, replaced by what was edited here", section.key));
}
}
for (key, element) in &file.elements {
let name = element.get("name");
match top.entry(key.clone()).or_insert_with(|| json!([])) {
Value::Array(list) if name.is_none() || !list.iter().any(|x| x.get("name") == name) => list.push(element.clone()),
_ => lost.push(format!("`{key}` element {}, replaced by what was made here", name.and_then(Value::as_str).unwrap_or("unnamed"))),
}
}
}
lost
}
/// What of the file a newer build saved the next save can't keep, because the design was changed
/// around it. Empty almost always.
pub fn not_kept(doc: &Document) -> Vec<String> {
let Ok(mut value) = serde_json::to_value(doc) else { return Vec::new() };
restore(doc, &mut value)
}
#[cfg(test)]
#[path = "newer_file_tests.rs"]
mod tests;
+236
View File
@@ -0,0 +1,236 @@
//! Designs a newer SolidCraft saved: they open, say so, and saving loses nothing.
use serde_json::{Value, json};
use crate::format::{DESIGN, FORMAT, format_string};
use crate::{Document, FeatureKind, Model, PlaneRef};
/// A real design: a sketched disc and its extrusion, and a parameter.
fn design() -> Document {
use solidcraft_sketch::Sketch;
let mut d = Document::new("Plate");
d.set_param("depth", "5 mm", Some("mm"), None).unwrap();
let mut sketch = Sketch::new();
sketch.add_circle(solidcore_record::Vec2::new(0.0, 0.0), 10.0, None, None).unwrap();
let base = d.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch }, Some("Base")).unwrap();
d.add_feature(extrude(base), Some("Pad")).unwrap();
d
}
fn extrude(sketch: u64) -> FeatureKind {
FeatureKind::Extrude {
thin: None,
sketch: crate::SketchId(sketch),
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance": "depth"})).unwrap(),
operation: crate::Operation::NewBody,
targets: vec![],
}
}
fn saved(d: &Document) -> Value {
serde_json::from_str(&d.to_json()).unwrap()
}
/// `(body name, volume)` of everything the design builds, and the error of each feature.
fn built(d: &Document) -> (Vec<(String, f64)>, Vec<Option<String>>) {
let mut model = Model::default();
model.evaluate(d);
let bodies = model.state().bodies.iter().map(|b| (b.name.clone(), b.body.volume_at(0.01).unwrap())).collect();
(bodies, model.results.iter().map(|r| r.error.clone()).collect())
}
fn newer() -> String {
format_string(FORMAT + 1)
}
#[test]
fn one_format_newer_with_nothing_unknown_opens_with_the_same_bodies() {
let original = design();
let mut file = saved(&original);
file["format"] = json!(newer());
let opened = Document::from_json(&file.to_string()).unwrap();
let (bodies, errors) = built(&opened);
assert_eq!((bodies.clone(), errors), built(&original));
assert_eq!(bodies.len(), 1);
assert_eq!(opened.features, original.features);
let notice = opened.newer_file.as_ref().unwrap().notice();
assert_eq!(
notice,
format!("Saved by a newer SolidCraft (format {}, this version reads up to {FORMAT}). If something's missing, update.", FORMAT + 1)
);
assert!(opened.newer_file.as_ref().unwrap().not_understood.is_empty());
// Saved, it still says what it is; read again, the same.
let again = saved(&opened);
assert_eq!(again["format"], json!(newer()));
assert_eq!(Document::from_json(&opened.to_json()).unwrap(), opened);
}
#[test]
fn an_unknown_change_opens_with_a_warning_and_stays_on_the_file() {
let original = design();
let mut file = saved(&original);
file["format_pending"] = json!(["a-later-change"]);
let opened = Document::from_json(&file.to_string()).unwrap();
let report = opened.newer_file.as_ref().unwrap();
assert_eq!(report.unknown_changes, ["a-later-change"]);
assert!(report.notice().contains("a-later-change") && report.notice().starts_with("Saved by a newer SolidCraft"), "{}", report.notice());
assert_eq!(built(&opened), built(&original));
let again = saved(&opened);
assert_eq!(again["format"], json!(format_string(FORMAT)));
assert!(again["format_pending"].as_array().unwrap().contains(&json!("a-later-change")));
assert_eq!(Document::from_json(&opened.to_json()).unwrap(), opened);
}
/// The file, with a feature of a kind this build doesn't have between the sketch and the pad.
fn with_unknown_feature() -> Value {
let mut file = saved(&design());
file["format"] = json!(newer());
file["format_pending"] = json!(["a-later-change"]);
let future = json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice", "cell": 2.5, "nested": {"big": 1e300}, "suppressed": false});
file["features"].as_array_mut().unwrap().insert(1, future);
file
}
#[test]
fn an_unknown_feature_kind_opens_the_rest_and_reports_that_feature() {
let file = with_unknown_feature();
let opened = Document::from_json(&file.to_string()).unwrap();
let report = opened.newer_file.as_ref().unwrap();
assert!(report.not_understood.iter().any(|what| what.contains("Lattice1") && what.contains("gyroid_lattice")), "{:?}", report.not_understood);
assert_eq!(opened.features.len(), 3);
assert!(matches!(&opened.features[1].kind, FeatureKind::NewerFileFeature { raw } if raw.kind() == "gyroid_lattice"));
let (bodies, errors) = built(&opened);
assert_eq!(bodies.len(), 1, "the pad after it still builds");
assert!(errors[0].is_none() && errors[2].is_none(), "{errors:?}");
let why = errors[1].as_deref().unwrap();
assert!(why.contains("newer SolidCraft") && why.contains("gyroid_lattice"), "{why}");
}
#[test]
fn saving_keeps_an_unknown_feature_as_the_file_had_it_and_the_files_number() {
let file = with_unknown_feature();
let opened = Document::from_json(&file.to_string()).unwrap();
let again = saved(&opened);
assert_eq!(again["format"], file["format"], "never a lower number");
assert_eq!(again["format_pending"], file["format_pending"]);
assert_eq!(again["features"][1], file["features"][1], "the feature as the file had it");
assert_eq!(
again["features"].as_array().unwrap().iter().map(|f| f["type"].clone()).collect::<Vec<_>>(),
[json!("sketch"), json!("gyroid_lattice"), json!("extrude")]
);
assert_eq!(Document::from_json(&opened.to_json()).unwrap(), opened, "saved and read again, the same");
// A rename here goes with it; the rest stays.
let mut renamed = opened.clone();
renamed.features[1].name = "Infill".into();
let again = saved(&renamed);
assert_eq!(again["features"][1]["name"], "Infill");
assert_eq!(again["features"][1]["nested"], json!({"big": 1e300}));
}
#[test]
fn saving_keeps_fields_and_sections_this_version_doesnt_know() {
let mut file = saved(&design());
file["format"] = json!(newer());
file["future_section"] = json!({"anything": [1, 2, 3]});
file["features"][1]["draft_v2"] = json!({"angle": 3.0});
file["params"][0]["future_note"] = json!("keep me");
let opened = Document::from_json(&file.to_string()).unwrap();
assert_eq!(built(&opened).0.len(), 1);
let report = opened.newer_file.as_ref().unwrap();
for field in ["future_section", "draft_v2", "future_note"] {
assert!(report.not_understood.iter().any(|what| what.contains(field)), "{field}: {:?}", report.not_understood);
}
let again = saved(&opened);
assert_eq!(again["future_section"], file["future_section"]);
assert_eq!(again["features"][1]["draft_v2"], file["features"][1]["draft_v2"]);
assert_eq!(again["params"][0]["future_note"], "keep me");
// They follow their records when the design changes around them.
let mut changed = opened.clone();
let id = changed.features[1].id;
changed.features.insert(0, changed.features[0].clone());
changed.features[0].id = 777;
assert_eq!(changed.feature(id).unwrap().name, "Pad");
let again = saved(&changed);
assert_eq!(again["features"][2]["draft_v2"], file["features"][1]["draft_v2"]);
assert!(crate::newer_file::not_kept(&changed).is_empty());
}
#[test]
fn a_section_that_doesnt_read_is_left_out_named_and_kept() {
let mut file = saved(&design());
file["format"] = json!(newer());
file["configs"] = json!({"columns": [{"unknown_future_column": "Body"}], "rules": {"unknown_future_rule": true}});
let opened = Document::from_json(&file.to_string()).unwrap();
assert_eq!(built(&opened).0.len(), 1);
let report = opened.newer_file.as_ref().unwrap();
assert!(report.not_understood.iter().any(|what| what.contains("configs")), "{:?}", report.not_understood);
assert_eq!(saved(&opened)["configs"], file["configs"]);
assert!(crate::newer_file::not_kept(&opened).is_empty());
}
#[test]
fn a_parameter_that_doesnt_read_is_left_out_named_and_kept() {
let mut file = saved(&design());
file["format"] = json!(newer());
file["params"].as_array_mut().unwrap().push(json!({"name": "future", "expression": {"kind": "table"}}));
let opened = Document::from_json(&file.to_string()).unwrap();
assert_eq!(built(&opened).0.len(), 1);
assert!(opened.newer_file.as_ref().unwrap().not_understood.iter().any(|what| what.contains("`future`")));
let names: Vec<_> = saved(&opened)["params"].as_array().unwrap().iter().map(|p| p["name"].clone()).collect();
assert!(names.contains(&json!("future")) && names.contains(&json!("depth")), "{names:?}");
}
#[test]
fn what_cant_go_back_after_an_edit_is_said() {
let mut file = saved(&design());
file["format"] = json!(newer());
file["configs"] = json!({"columns": [{"unknown_future_column": "Body"}]});
let opened = Document::from_json(&file.to_string()).unwrap();
assert!(crate::newer_file::not_kept(&opened).is_empty());
let mut edited = opened.clone();
edited.favorites.insert("depth".into());
assert!(crate::newer_file::not_kept(&edited).is_empty(), "an edit elsewhere costs nothing");
let baseline: Value = saved(&edited);
assert_eq!(baseline["configs"], file["configs"]);
}
#[test]
fn a_feature_this_version_reads_wrongly_is_kept_whole() {
// A kind this build has, in a shape it doesn't: the feature can't read, and is not guessed at.
let mut file = saved(&design());
file["format"] = json!(newer());
file["features"][1]["operation"] = json!("weave");
let opened = Document::from_json(&file.to_string()).unwrap();
assert!(matches!(&opened.features[1].kind, FeatureKind::NewerFileFeature { raw } if raw.kind() == "extrude"));
assert!(built(&opened).1[1].as_deref().unwrap().contains("newer SolidCraft"));
assert_eq!(saved(&opened)["features"][1], file["features"][1]);
}
#[test]
fn what_truly_cant_be_read_says_why() {
for (text, says) in [
("not json", "HDR resource admission"),
("{\"format\":\"solidcraft/0\",\"name\":\"x\"}", "format 0"),
("{\"format\":\"someone-else/9\"}", "not a SolidCraft design (format `someone-else/9`)"),
] {
let why = Document::from_json(text).unwrap_err().to_string();
assert!(why.contains(says), "{text}: {why}");
}
// Two features of one id are damage whatever build saved them.
let mut file = saved(&design());
file["format"] = json!(newer());
let same = file["features"][0].clone();
file["features"].as_array_mut().unwrap().push(same);
let why = Document::from_json(&file.to_string()).unwrap_err().to_string();
assert!(why.contains(&format!("format {}", FORMAT + 1)) && why.contains("can't read it") && why.contains("damaged"), "{why}");
}
#[test]
fn a_file_this_build_fully_knows_is_not_flagged_and_damage_is_still_refused() {
assert!(Document::from_json(&design().to_json()).unwrap().newer_file.is_none());
assert_eq!(DESIGN.newer(FORMAT, None), None);
let mut file = saved(&design());
file["features"][1]["type"] = json!("gyroid_lattice");
assert!(Document::from_json(&file.to_string()).is_err(), "of this build's own format, that is damage");
}
+1
View File
@@ -123,6 +123,7 @@ pub(super) fn local_geometry(f: &Feature, components: &std::collections::HashMap
/// Assembly records do not feed any of the feature kinds accepted by `local_geometry`.
fn same_geometry_inputs(a: &Document, b: &Document) -> bool {
let Document {
newer_file: _,
render,
format,
format_pending,
+1
View File
@@ -42,6 +42,7 @@ impl Walk for i8 {}
impl Walk for f64 {}
impl Walk for usize {}
impl Walk for u32 {}
impl Walk for crate::newer_file::RawFeature {}
impl Walk for ProfileSel {}
impl Walk for std::collections::BTreeMap<String, String> {}
impl Walk for crate::frame::Anchor {}
+75 -2
View File
@@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("open")
.key("Ctrl+O")
.noundo()
.params("path: .sc design (or a .solidcraft design from an earlier release), .dxf/.svg drawing (opens as a new, unsaved design with an XY sketch, using sketch.insert_dxf/sketch.insert_svg units and defaults), or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make), or a .ply/.xyz/.pts point cloud (opens as a new design holding it, as pointcloud.insert makes; its `units` and `max_points` as there); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh)"),
.params("path: .sc design (or a .solidcraft design from an earlier release), .dxf/.svg drawing (opens as a new, unsaved design with an XY sketch, using sketch.insert_dxf/sketch.insert_svg units and defaults), or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make), or a .ply/.xyz/.pts point cloud (opens as a new design holding it, as pointcloud.insert makes; its `units` and `max_points` as there); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh); newer_file: {format, reads_up_to, unknown_changes, not_understood, notice} and warnings when a newer SolidCraft saved it: it opens all the same, and what this version can't read is named there and kept by saving"),
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
.at("SOLID", "INSERT")
.icon("import")
@@ -427,6 +427,11 @@ fn opened_design(
if read_only {
out["read_only"] = json!(true);
}
// A newer SolidCraft saved it: opened all the same, and the reply says so.
if let Some(file) = &s.doc.newer_file {
out["newer_file"] = file.report();
out["warnings"] = json!(file.warnings());
}
let now = solidcraft_io::design_lock::now_secs();
if let Some(lock) = solidcraft_io::design_lock::held_elsewhere(std::path::Path::new(path), now) {
out["open_elsewhere"] = json!({"host": lock.host, "user": lock.user, "app": lock.app, "opened": lock.opened});
@@ -573,7 +578,21 @@ fn write_bytes(
s.disk = solidcraft_io::design_lock::stamp(path);
s.read_only = false;
s.mark_saved();
Ok(json!({"path": path, "bytes": bytes.len(), "name": s.doc.name}))
let mut out = json!({"path": path, "bytes": bytes.len(), "name": s.doc.name});
// A newer SolidCraft's file keeps what this version can't read, and its format number. What
// couldn't go back, because the design was changed around it, is said.
if s.doc.newer_file.is_some() {
let mut lost = solidcraft_doc::newer_file::not_kept(&s.doc);
if format == "json" && s.doc.newer_file.as_ref().is_some_and(|file| file.needs_container()) {
lost.push("the parts of the file beside the design, which plain JSON has no room for: save as .sc to keep them".to_string());
}
out["kept_format"] = json!(s.doc.format);
if !lost.is_empty() {
out["warnings"] = json!(lost.iter().map(|what| format!("Not kept: {what}. The previous file is `{path}.bak`.")).collect::<Vec<_>>());
out["not_kept"] = json!(lost);
}
}
Ok(out)
}
impl Session {
@@ -1392,6 +1411,60 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// A design a newer SolidCraft saved opens through the command agents and the app use: the
/// reply warns, what it can't read shows as that feature's error, work goes on around it, and
/// saving keeps the feature and the file's number in both encodings.
#[test]
fn a_newer_design_opens_and_saving_keeps_what_it_cant_read() {
let dir = std::env::temp_dir().join(format!("sc_newer_design_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
let mut file: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
let newer = solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT + 1);
file["format"] = json!(newer);
let future = json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice", "cell": 2.5});
file["features"].as_array_mut().unwrap().push(future.clone());
file["future_section"] = json!([1, 2]);
for name in ["newer.json", "newer.sc"] {
let path = dir.join(name);
let source = solidcraft_doc::Document::from_json(&file.to_string()).unwrap();
let bytes = if name.ends_with(".sc") { solidcraft_io::write_design_container(&source).unwrap() } else { file.to_string().into_bytes() };
std::fs::write(&path, bytes).unwrap();
let path = path.to_string_lossy().to_string();
let mut opened = Session::default();
let reply = opened.execute("doc.open", &json!({"path": path})).unwrap();
assert_eq!(reply["errors"], 1, "{reply}");
assert_eq!(reply["features"], 2, "{reply}");
assert!(reply["newer_file"]["not_understood"].to_string().contains("gyroid_lattice"), "{reply}");
let first = reply["warnings"][0].as_str().unwrap();
assert_eq!(
first,
format!(
"Saved by a newer SolidCraft (format {}, this version reads up to {}). If something's missing, update.",
solidcraft_doc::format::FORMAT + 1,
solidcraft_doc::format::FORMAT
)
);
assert_eq!(opened.model.state().bodies.len(), 1, "the box still builds");
let lattice = opened.doc.features.iter().position(|f| f.name == "Lattice1").unwrap();
assert!(opened.model.results[lattice].error.as_deref().unwrap().contains("gyroid_lattice"));
opened.execute("solid.box", &json!({"length": 1, "width": 1, "height": 1})).unwrap();
let saved = opened.execute("file.save", &json!({})).unwrap();
assert_eq!(saved["kept_format"], json!(newer), "{saved}");
assert!(saved.get("not_kept").is_none(), "{saved}");
let mut again = Session::default();
again.execute("doc.open", &json!({"path": path})).unwrap();
assert_eq!(again.doc.features.len(), 3);
assert_eq!(again.model.state().bodies.len(), 2);
let written: Value = serde_json::from_str(&again.doc.to_json()).unwrap();
assert_eq!(written["format"], json!(newer));
assert_eq!(written["future_section"], json!([1, 2]));
assert!(written["features"].as_array().unwrap().contains(&future));
}
let _ = std::fs::remove_dir_all(&dir);
}
/// #5467: a script, the CLI or the control channel never gets a prompt. A Save over a file
/// another program or computer changed since it was opened is refused with an error naming
/// `overwrite`, which replaces it; a design opened read-only only saves to another file.
@@ -61,7 +61,10 @@ fn format20_scalar_looks_upgrade_and_format21_setups_keep_their_meaning() {
assert_eq!(d.render.setups["Studio"].width, 320);
for version in [crate::doc::format::FORMAT + 1, crate::doc::format::FORMAT + 2] {
saved["format"] = json!(format!("solidcraft/{version}"));
assert!(crate::doc::Document::from_json(&saved.to_string()).is_err());
// A newer build's file opens, says so, and keeps its number.
let newer = crate::doc::Document::from_json(&saved.to_string()).unwrap();
assert_eq!(newer.newer_file.as_ref().unwrap().format, version);
assert_eq!(newer.format, format!("solidcraft/{version}"));
}
}
+1
View File
@@ -2429,6 +2429,7 @@ fn image_host_availability(host: Option<&render::job::ImageHost>, expected: &ren
/// to be decided on here.
fn same_but_features(a: &Document, b: &Document) -> bool {
let Document {
newer_file: _,
render,
format,
format_pending,
+181 -6
View File
@@ -343,10 +343,16 @@ fn write_container(
let render = replace(doc.render.as_ref(), fields([("resources", Value::Object(resources))])).map_err(invalid)?;
replacements.insert("render".into(), render);
}
let metadata = replace(doc, replacements).map_err(invalid)?;
let mut metadata = replace(doc, replacements).map_err(invalid)?;
solidcraft_doc::newer_file::restore(doc, &mut metadata);
let metadata = serde_json::to_vec(&metadata).map_err(invalid)?;
writer.part("document.json", &metadata, true)?;
writer.part("container.json", &serde_json::to_vec(&json!({"format": VERSION})).map_err(invalid)?, false)?;
// What a newer build's file holds beside the design goes back as it came.
let container = doc.newer_file.as_ref().and_then(|file| file.container.clone()).unwrap_or_else(|| json!({"format": VERSION}));
writer.part("container.json", &serde_json::to_vec(&container).map_err(invalid)?, false)?;
for (name, bytes) in doc.newer_file.iter().flat_map(|file| &file.parts) {
writer.part(name, bytes, true)?;
}
// The model cache is optional: one that does not fit the container keeps fewer states, or is
// left out, never a failed save.
let cache = geometry.and_then(|g| native_cache::encode_model(g, &metadata, writer.space(2, budget)).ok());
@@ -815,16 +821,115 @@ fn is_container_source(source: &mut FileSource) -> Result<bool> {
Ok(is_design_container(&prefix))
}
/// A newer build's container: a feature, picture or point cloud whose payload doesn't read here
/// is kept as the file has it, and so is every part this build has no use for.
fn hydrate_newer<R: Read + Seek>(doc: &mut Document, metadata: &Value, container: Value, reader: &mut PackageReader<R>) -> Result<()> {
let file = Arc::make_mut(doc.newer_file.as_mut().ok_or_else(|| invalid("not a newer design"))?);
for (index, feature) in doc.features.iter_mut().enumerate() {
let Some(value) = metadata.get("features").and_then(|v| v.get(index)) else { continue };
let readable = match &mut feature.kind {
FeatureKind::Import { .. } => reader.blob(value, "step_blob", "step").map(|_| ()),
FeatureKind::MeshImport { meshes, .. } => {
let mut check = meshes.clone();
check.iter_mut().enumerate().try_for_each(|(i, mesh)| {
let value = value.get("meshes").and_then(|v| v.get(i)).ok_or_else(|| invalid("missing mesh metadata"))?;
decode_mesh(reader.blob(value, "mesh_blob", "mesh")?, mesh)
})
}
_ => Ok(()),
};
if let Err(why) = readable {
file.not_understood.push(format!("feature `{}` ({why})", feature.name));
feature.kind = FeatureKind::NewerFileFeature { raw: solidcraft_doc::newer_file::RawFeature(Box::new(value.clone())) };
}
}
let canvases_read = doc.canvases.iter().enumerate().all(|(i, canvas)| {
canvas.data.is_empty()
&& matches!(canvas.format.as_str(), "png" | "jpeg")
&& metadata.get("canvases").and_then(|v| v.get(i)).is_some_and(|v| reader.blob(v, "image_blob", &canvas.format).is_ok())
});
if !canvases_read {
doc.canvases.clear();
file.keep_section("canvases", metadata.get("canvases").cloned().unwrap_or_default(), Some(json!([])), "pictures this version can't read");
}
let clouds_read = doc.point_clouds.iter().enumerate().all(|(i, cloud)| {
let mut check = cloud.clone();
metadata.get("point_clouds").and_then(|v| v.get(i)).is_some_and(|v| match reader.blob(v, "points_blob", "points") {
Ok(bytes) => decode_cloud(bytes, &mut check).is_ok(),
Err(_) => false,
})
});
if !clouds_read {
doc.point_clouds.clear();
file.keep_section("point_clouds", metadata.get("point_clouds").cloned().unwrap_or_default(), None, "point clouds this version can't read");
}
// The container's own note, when it says something this build doesn't know.
if !is_known_container(&container) {
file.container = Some(container);
}
// A blob a typed record uses comes back from that record; others only for what is kept.
let names: Vec<String> = reader.zip.file_names().filter_map(|name| name.ok().map(|name| name.into_owned())).collect();
for name in names {
let ours = matches!(name.as_str(), "document.json" | "container.json") || name.starts_with("cache/");
let unused_blob = name.starts_with("blobs/") && !file.mentions(&name) && !foreign_mentions(&doc.features, &name);
if !ours && !unused_blob {
file.parts.push((name.clone(), reader.limited_part(&name, part_limit(&name))?.into()));
}
}
// Looking counts against what a design may expand to only once: the reads below are the real ones.
reader.hydrated = 0;
Ok(())
}
/// A `container.json` that says only a format this build writes or wrote.
fn is_known_container(container: &Value) -> bool {
*container == json!({"format": VERSION})
|| *container == json!({"format": LEGACY_VERSION})
|| container.get("content").is_some_and(|c| c == "drawing")
}
/// A container format of ours, this build's or a later one's: `solidcraft-container/N`.
pub(crate) fn is_container_version(format: &str) -> bool {
format == LEGACY_VERSION || format.strip_prefix("solidcraft-container/").is_some_and(|n| n.parse::<u32>().is_ok())
}
/// Whether a feature this build couldn't read refers to this blob.
fn foreign_mentions(features: &[solidcraft_doc::Feature], name: &str) -> bool {
fn names(value: &Value, name: &str) -> bool {
match value {
Value::String(s) => s == name,
Value::Array(a) => a.iter().any(|x| names(x, name)),
Value::Object(o) => o.values().any(|x| names(x, name)),
_ => false,
}
}
features.iter().any(|f| matches!(&f.kind, FeatureKind::NewerFileFeature { raw } if names(&raw.0, name)))
}
fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Document> {
if drawing::container_kind(reader)? == FileKind::Drawing {
return Err(IoError::Invalid("this file is a SolidCraft drawing, not a design: open it as a drawing".into()));
}
let bytes = reader.document(max_design())?;
check_hdr_manifest(&bytes)?;
let mut metadata: Value = serde_json::from_slice(&bytes).map_err(invalid)?;
// A newer build's file opens whatever it holds: what doesn't read here is kept (`newer_file`).
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
let newer_container = !is_known_container(&container);
let newer = newer_container || std::str::from_utf8(&bytes).is_ok_and(solidcraft_doc::format::says_newer);
// HDR's typed JSON representation is deliberately strict: no empty placeholders or
// container-only blob fields may reach the document deserializer.
let hydrated_hdr = hydrate_hdr_resources(&mut metadata, reader)?;
// container-only blob fields may reach the document deserializer. A newer build's section that
// doesn't pass stays as it is, which the document reader then leaves out.
let hydrated_hdr = if newer {
let mut hydrated = metadata.clone();
let done = check_hdr_manifest(&bytes).and_then(|()| hydrate_hdr_resources(&mut hydrated, reader));
if done.is_ok() {
metadata = hydrated;
}
done.unwrap_or(false)
} else {
check_hdr_manifest(&bytes)?;
hydrate_hdr_resources(&mut metadata, reader)?
};
let hydrated_text;
let text = if hydrated_hdr {
hydrated_text = serde_json::to_string(&metadata).map_err(invalid)?;
@@ -835,6 +940,16 @@ fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Docume
// Migrations and all document validation run on the small parametric metadata.
// Binary arrays have their own equivalent numeric/index validation below.
let mut doc = Document::from_json(text)?;
if newer_container && doc.newer_file.is_none() {
let current = solidcraft_doc::format::FORMAT;
let says = container.get("format").and_then(Value::as_str).unwrap_or("a later container").to_string();
let mut file = solidcraft_doc::newer_file::NewerFile::default();
(file.format, file.reads_up_to, file.unknown_changes) = (current, current, vec![says]);
doc.newer_file = Some(Arc::new(file));
}
if doc.newer_file.is_some() {
hydrate_newer(&mut doc, &metadata, container, reader)?;
}
for (index, feature) in doc.features.iter_mut().enumerate() {
let value = metadata.get("features").and_then(|v| v.get(index)).ok_or_else(|| invalid("missing feature metadata"))?;
match &mut feature.kind {
@@ -1668,6 +1783,60 @@ mod tests {
doc
}
/// A newer build's container: its feature this build can't read, that feature's blob, a part
/// this build has never heard of and a payload in a newer encoding all open and save back.
#[test]
fn a_newer_builds_container_opens_and_saving_keeps_all_it_holds() {
let doc = document();
let mut entries = parts(&write_design_container(&doc).unwrap());
let mut metadata: Value = serde_json::from_slice(&entries["document.json"]).unwrap();
let newer = solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT + 1);
metadata["format"] = json!(newer);
let payload = b"a future payload".to_vec();
let blob = format!("blobs/{}.lattice", digest(&payload));
let future = json!({"id": 9000, "name": "Lattice1", "type": "gyroid_lattice", "lattice_blob": blob});
metadata["features"].as_array_mut().unwrap().push(future.clone());
metadata["future_section"] = json!({"keep": true});
entries.insert("document.json".into(), serde_json::to_vec(&metadata).unwrap());
entries.insert(blob.clone(), payload.clone());
entries.insert("future/notes.bin".into(), b"more".to_vec());
// This blob nothing names any more: it doesn't stay around.
entries.insert(format!("blobs/{}.lattice", digest(b"orphan")), b"orphan".to_vec());
let opened = crate::read_design(&package(&entries)).unwrap();
assert_eq!(opened.features.len(), doc.features.len() + 1);
assert!(opened.newer_file.as_ref().unwrap().not_understood.iter().any(|what| what.contains("Lattice1")));
assert_eq!(opened.features[0], doc.features[0]);
let resaved = parts(&write_design_container(&opened).unwrap());
let again: Value = serde_json::from_slice(&resaved["document.json"]).unwrap();
assert_eq!(again["format"], json!(newer));
assert_eq!(again["features"].as_array().unwrap().last().unwrap(), &future);
assert_eq!(again["future_section"], json!({"keep": true}));
assert_eq!(resaved[&blob], payload);
assert_eq!(resaved["future/notes.bin"], b"more");
assert!(!resaved.contains_key(&format!("blobs/{}.lattice", digest(b"orphan"))));
// And again: nothing is lost on the way round, the design's own blobs included.
let twice = crate::read_design(&write_design_container(&opened).unwrap()).unwrap();
assert_eq!(twice, opened);
// A payload in an encoding this build doesn't have costs its feature, not the design.
let mut entries = entries.clone();
let mesh = metadata["features"].as_array().unwrap().iter().flat_map(|f| f["meshes"].as_array().into_iter().flatten()).next().unwrap();
let mesh_blob = mesh["mesh_blob"].as_str().unwrap().to_string();
let mut encoded = entries[&mesh_blob].clone();
encoded[0] ^= 0xff;
let renamed = format!("blobs/{}.mesh", digest(&encoded));
entries.remove(&mesh_blob);
entries.insert(renamed.clone(), encoded.clone());
let text = String::from_utf8(entries["document.json"].clone()).unwrap().replace(&mesh_blob, &renamed);
entries.insert("document.json".into(), text.into_bytes());
let opened = crate::read_design(&package(&entries)).unwrap();
let said = &opened.newer_file.as_ref().unwrap().not_understood;
assert!(said.iter().any(|what| what.contains("unsupported mesh blob version")), "{said:?}");
assert_eq!(parts(&write_design_container(&opened).unwrap())[&renamed], encoded);
}
fn parts(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
(0..zip.len())
@@ -1865,7 +2034,13 @@ mod tests {
entries.insert("../escape".into(), vec![]);
assert!(crate::read_design(&package(&entries)).is_err());
entries.remove("../escape");
entries.insert("container.json".into(), br#"{"format":"solidcraft-container/999"}"#.to_vec());
// A later container still holds a design: it opens, says so, and saving keeps its note.
entries.insert("container.json".into(), br#"{"format":"solidcraft-container/999","more":1}"#.to_vec());
let later = crate::read_design(&package(&entries)).unwrap();
assert!(later.newer_file.as_ref().unwrap().notice().contains("solidcraft-container/999"));
let resaved = parts(&write_design_container(&later).unwrap());
assert_eq!(resaved["container.json"], entries["container.json"]);
entries.insert("container.json".into(), br#"{"format":"another-tool/1"}"#.to_vec());
assert!(crate::read_design(&package(&entries)).unwrap_err().to_string().contains("unsupported container version"));
for length in (0..bytes.len()).step_by(17) {
assert!(crate::read_design(&bytes[..length]).is_err());
+4 -2
View File
@@ -49,13 +49,15 @@ pub fn file_kind(bytes: &[u8]) -> Result<FileKind> {
/// What a container holds, by its `container.json`: no `content` is a design.
pub(super) fn container_kind<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<FileKind> {
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
if !matches!(container.get("format").and_then(Value::as_str), Some(super::VERSION | super::LEGACY_VERSION)) {
if !container.get("format").and_then(Value::as_str).is_some_and(super::is_container_version) {
return Err(invalid("unsupported container version"));
}
match container.get("content") {
None => Ok(FileKind::Design),
Some(Value::String(content)) if content == DRAWING_CONTENT => Ok(FileKind::Drawing),
Some(other) => Err(invalid(format!("this build doesn't know container content {other}"))),
// A later kind of content that still holds a design opens as one.
Some(_) if reader.has_part("document.json") => Ok(FileKind::Design),
Some(other) => Err(invalid(format!("it holds {other}, which this version doesn't know: update SolidCraft to open it"))),
}
}
+8 -7
View File
@@ -106,8 +106,8 @@ fn a_drawing_container_refuses_what_it_cant_take() {
let text = drawing().to_json().unwrap();
let drawing_json = |content: &str| format!(r#"{{"content":{content},"format":"solidcraft-container/1"}}"#);
let cases: [(Vec<u8>, &str); 6] = [
(container(&[("container.json", drawing_json("\"sketch\"").as_bytes()), ("drawing.json", text.as_bytes())]), "container content \"sketch\""),
(container(&[("container.json", drawing_json("7").as_bytes()), ("drawing.json", text.as_bytes())]), "container content 7"),
(container(&[("container.json", drawing_json("\"sketch\"").as_bytes()), ("drawing.json", text.as_bytes())]), "it holds \"sketch\""),
(container(&[("container.json", drawing_json("7").as_bytes()), ("drawing.json", text.as_bytes())]), "it holds 7"),
(
container(&[
("container.json", drawing_json("\"drawing\"").as_bytes()),
@@ -117,10 +117,7 @@ fn a_drawing_container_refuses_what_it_cant_take() {
"also holds a design's document.json",
),
(container(&[("container.json", drawing_json("\"drawing\"").as_bytes())]), "required part `drawing.json`"),
(
container(&[("container.json", &br#"{"content":"drawing","format":"solidcraft-container/9"}"#[..]), ("drawing.json", text.as_bytes())]),
"version",
),
(container(&[("container.json", &br#"{"content":"drawing","format":"another-tool/9"}"#[..]), ("drawing.json", text.as_bytes())]), "version"),
(
container(&[("container.json", drawing_json("\"drawing\"").as_bytes()), ("drawing.json", text.replace("en-GB", "e").as_bytes())]),
"damaged drawing",
@@ -130,7 +127,11 @@ fn a_drawing_container_refuses_what_it_cant_take() {
let e = error(read_drawing(&bytes));
assert!(e.contains(says), "{says}: {e}");
}
assert!(error(read_design(&container(&[("container.json", drawing_json("\"sketch\"").as_bytes())]))).contains("container content"));
assert!(error(read_design(&container(&[("container.json", drawing_json("\"sketch\"").as_bytes())]))).contains("this version doesn't know"));
// A later container of ours still holds a drawing: it opens.
let later =
container(&[("container.json", &br#"{"content":"drawing","format":"solidcraft-container/9"}"#[..]), ("drawing.json", text.as_bytes())]);
assert!(read_drawing(&later).is_ok());
}
/// Drawing files: the extension, and reading one from disk in either form.
+4
View File
@@ -439,6 +439,7 @@ fn recovered(app: &mut SolveApp, request: &Request) {
let _ = app.session.execute("doc.recovery_discard", &json!({"id": id, "dir": dir}));
}
app.set_status(format!("recovered {}: save it to keep it", request.path), false);
app.say_newer_file();
}
#[cfg(not(target_arch = "wasm32"))]
@@ -1106,6 +1107,9 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
},
false,
);
if !request.insert() {
app.say_newer_file();
}
}
request.answer(Ok(made.result));
}
+30
View File
@@ -781,3 +781,33 @@ fn an_opened_design_comes_up_at_the_home_view() {
let _ = std::fs::remove_dir_all(&other);
let _ = std::fs::remove_dir_all(&dir);
}
/// A design a newer SolidCraft saved opens in the window: the status line says so without a
/// dialog, Messages names what wasn't read, and editing that feature says why it can't be edited.
#[test]
fn a_newer_design_opens_with_a_notice_and_no_dialog() {
let mut app = SolveApp::new(Session::default(), Default::default());
let dir = scratch("newer_design");
let mut design = Session::default();
design.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
let mut file: serde_json::Value = serde_json::from_str(&design.doc.to_json()).unwrap();
let (reads, newer) = (solidcraft_engine::doc::format::FORMAT, solidcraft_engine::doc::format::FORMAT + 1);
file["format"] = json!(solidcraft_engine::doc::format::format_string(newer));
file["features"].as_array_mut().unwrap().push(json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice"}));
let path = dir.join("newer.json");
std::fs::write(&path, file.to_string()).unwrap();
app.open_path(&path.to_string_lossy());
let (text, _, error) = app.status.clone().unwrap();
assert_eq!(text, format!("Saved by a newer SolidCraft (format {newer}, this version reads up to {reads}). If something's missing, update."));
assert!(!error);
assert!(app.dialog.is_none());
assert!(app.messages.iter().any(|m| m.text.contains("Lattice1") && m.text.contains("Saving keeps it")));
assert_eq!(app.session.model.state().bodies.len(), 1);
app.edit_feature(900);
let (text, _, error) = app.status.clone().unwrap();
assert!(error && text.contains("gyroid_lattice"), "{text}");
assert!(app.dialog.is_none() && !app.ui.palette_open);
let _ = std::fs::remove_dir_all(&dir);
}
+24
View File
@@ -2183,6 +2183,12 @@ impl SolveApp {
self.edit_sketch(id);
return;
}
// Nothing here knows what it holds: it says so, and waits for the build that does.
if let FeatureKind::NewerFileFeature { raw } = &f.kind {
let why = raw.why();
self.set_status(why, true);
return;
}
let Some(idx) = self.session.doc.feature_index(id) else { return };
let marker = self.finish_sketch_for_edit();
self.tool = None;
@@ -2487,6 +2493,23 @@ impl SolveApp {
self.status_undo = false;
}
/// Tell the user when the open design came from a newer SolidCraft: the notice on the status
/// line, and what this version couldn't read in the messages.
pub(crate) fn say_newer_file(&mut self) {
let Some(file) = self.session.doc.newer_file.clone() else { return };
for what in &file.not_understood {
let text = i18n::format("document.newer_file_not_read", &[("what", what)]);
self.messages.push(&text, false, now_ms());
}
let text = if file.format <= file.reads_up_to && !file.unknown_changes.is_empty() {
let change = file.unknown_changes.first().map_or("", String::as_str);
i18n::format("document.newer_file_change", &[("format", &file.format.to_string()), ("change", change)])
} else {
i18n::format("document.newer_file", &[("format", &file.format.to_string()), ("reads", &file.reads_up_to.to_string())])
};
self.set_status(text, false);
}
pub fn open_path(&mut self, path: &str) {
match documents::reuse_path(self, path) {
Ok(true) => return,
@@ -2504,6 +2527,7 @@ impl SolveApp {
self.cam.set_view(StandardView::Iso);
self.fit_view();
stand_up::offer(self, path, &[], Some(&r));
self.say_newer_file();
}
}
@@ -304,6 +304,7 @@ pub(super) fn same_model_source(app: &SolveApp, document: &std::sync::Weak<solid
}};
}
compare_authoring_fields!(
newer_file,
format,
format_pending,
name,
+5
View File
@@ -67,6 +67,11 @@ pub fn tick(app: &mut SolveApp, wait: bool) {
None => Err(format!("{command}: the save stopped")),
};
app.finish_command(command, &r);
// Saving a newer SolidCraft's file keeps what this version can't read, unless it was changed
// around: then the save says what went.
if let Some(first) = r.as_ref().ok().and_then(|v| v["warnings"].as_array()).and_then(|w| w.first()).and_then(serde_json::Value::as_str) {
app.set_status(first, true);
}
crate::documents::saved_for_close(app);
}
+7 -4
View File
@@ -129,7 +129,7 @@ opened through a symbolic link (a synced folder linked into a project) is saved
link points to, so the link stays. Unsaved changes in every open tab are autosaved to separate
entries in a recovery folder, and the next launch offers to recover each design. Saving or
closing one tab removes only its recovery entry. Older design files are upgraded on open, and files
from a newer version are refused with a message.
from a newer version open with a warning.
**Save** writes a container by default; files named `.json` keep JSON on subsequent saves.
**Save As** offers a container or plain JSON on
@@ -159,9 +159,12 @@ The default `cache: false` omits the portable thumbnail too.
The repository's `docs/design-container.md` describes limits and encoding details.
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.
in the file). It opens designs of every older format and upgrades them as they open. A design
from a newer version opens too, with a notice saying so: what this version can read is read, and
a feature it can't read stays on the timeline with an error saying so, so you can see what is
missing. Saving such a design keeps what this version can't read and the file's format number, so
the newer version finds its work again. Development (nightly) builds save a format number no release
reads yet.
Sketch patterns retain their live direction-line references and placement settings when saved.
Older patterns keep their fixed vector directions and original placement defaults.
+4 -3
View File
@@ -172,9 +172,10 @@ drive the screen) can keep SolidCraft on the integrated GPU instead: Preferences
## A design saved by 0.10 won't open in an older version
Designs saved by a newer SolidCraft use a newer file format. Older versions refuse them with
"this design was saved by a newer SolidCraft" and don't change the file. Open them with the
current version.
Designs saved by a newer SolidCraft use a newer file format. Older versions may refuse them with
"this design was saved by a newer SolidCraft" and don't change the file: open them with the current
version. Recent versions open them with a notice, and a feature they can't read shows an error
on the timeline; update to build it.
## Save As says the file can't be found (Windows)
+2 -2
View File
@@ -8,9 +8,9 @@ description: "Use when a SolidCraft change saves something new in a design (a fi
## Design file format changes
A design says which format it is in (`"format": "solidcraft/N"`). A change that saves something new, or makes older designs mean something else, adds ONE file and edits 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.
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 opens such a design with a warning and keeps what it can't read (`crates/doc/src/newer_file.rs`).
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. Never touch `FORMAT`, `JSON_STEPS`, the history in `crates/doc/src/format.rs`, another change's file, or any `v<N>/` folder. The build counts the pending files and writes the next number, so a release refuses a design that carries your change instead of losing it.
3. Never touch `FORMAT`, `JSON_STEPS`, the history in `crates/doc/src/format.rs`, another change's file, or any `v<N>/` folder. The build counts the pending files and writes the next number, so a release knows a design carries a change it doesn't have: it warns, and saving keeps the file's number and what it couldn't read.
Two PRs 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 pending files into `v<N>/` for good; `scripts/test-format-pending.sh` runs in CI. Never add a file to a `v<N>/` folder afterwards. See `docs/releasing.md`.