mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-05 11:35:55 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 726ceea147 | |||
| cfffba411e |
+1
-1
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(§ion.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(§ion.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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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"))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user