Lands the Render workspace (R3), bounded preview-image jobs with cancellation and atomic PNG export (R4a), and opaque PBR looks as one change. - Render workspace: an overlay-free product preview on the installed GPU device. Returning to Design restores its camera. render.scene and render.settings.apply are added. - Image jobs: render.preflight/start/status/cancel/export run bounded CPU preview jobs. Export captures the job, overwrite choice and document, and checks the caller's file scope before writing. - Opaque PBR: looks can carry metal fraction, roughness and dielectric IOR. The product viewport and preview PNG use them; geometry and mass are unchanged. The v1 catalog export refuses PBR rather than dropping it. - Render setups and PBR looks are saved under design format 21. Labels are in en/de/ja/ko/zh/ru. Supersedes #706 and #800. Refs #620, #673, #756, #1004. Contributed by @0xlillith. Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com>
253 lines
11 KiB
Rust
253 lines
11 KiB
Rust
use super::*;
|
|
use solidcraft_io::catalog::{Definition, Entry, FORMAT, ScalarLook, ScalarMaterial};
|
|
use std::sync::{
|
|
Arc,
|
|
atomic::{AtomicU64, Ordering},
|
|
};
|
|
|
|
struct File(String);
|
|
impl File {
|
|
fn new() -> Self {
|
|
static NEXT: AtomicU64 = AtomicU64::new(0);
|
|
Self(
|
|
solidcraft_io::vfs::temp_dir()
|
|
.join(format!("solidcraft-catalog-{}-{}.json", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)))
|
|
.to_string_lossy()
|
|
.into_owned(),
|
|
)
|
|
}
|
|
fn write(&self, catalog: &Catalog) {
|
|
std::fs::write(&self.0, catalog.encode().unwrap()).unwrap();
|
|
}
|
|
}
|
|
impl Drop for File {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_file(&self.0);
|
|
}
|
|
}
|
|
fn catalog() -> Catalog {
|
|
Catalog {
|
|
format: FORMAT.into(),
|
|
id: "user.test".into(),
|
|
name: "Original scalar test library".into(),
|
|
entries: vec![
|
|
Entry::new(
|
|
"user.test",
|
|
Definition::Appearance(ScalarLook { name: "Warm oak colour".into(), color: [180, 120, 70], opacity: 0.8 }),
|
|
Provenance::default(),
|
|
)
|
|
.unwrap(),
|
|
Entry::new(
|
|
"user.test",
|
|
Definition::Material(ScalarMaterial {
|
|
name: "Timber density".into(),
|
|
density_g_cm3: 0.7,
|
|
appearance: Some(ScalarLook { name: "Timber tint".into(), color: [180, 120, 70], opacity: 1.0 }),
|
|
}),
|
|
Provenance::default(),
|
|
)
|
|
.unwrap(),
|
|
],
|
|
}
|
|
}
|
|
fn session() -> Session {
|
|
let mut s = Session::default();
|
|
s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
|
|
s.mark_saved();
|
|
s
|
|
}
|
|
fn apply_entry(s: &mut Session, file: &File, entry: &Entry, targets: Value) -> Result<Value> {
|
|
s.execute("library.apply", &json!({"path":file.0,"entry":entry.id,"revision":entry.revision,"targets":targets}))
|
|
}
|
|
|
|
#[test]
|
|
fn catalog_export_obeys_file_scope_in_new_file_and_overwrite_modes() {
|
|
let file = File::new();
|
|
let base = std::path::Path::new(&file.0).with_extension("scope");
|
|
std::fs::create_dir(&base).unwrap();
|
|
let root = base.join("allowed");
|
|
let outside = base.join("outside");
|
|
std::fs::create_dir(&root).unwrap();
|
|
std::fs::create_dir(&outside).unwrap();
|
|
let sentinel = outside.join("existing.json");
|
|
std::fs::write(&sentinel, b"previous contents").unwrap();
|
|
let mut s = session();
|
|
let before = s.doc.clone();
|
|
let undo = s.undo.len();
|
|
solidcraft_io::vfs::scoped(Some(solidcraft_io::vfs::PathScope::new([root.clone()])), || {
|
|
for path in [outside.join("new.json"), root.join("../outside/traversal.json"), sentinel.clone()] {
|
|
for overwrite in [false, true] {
|
|
let error = s.execute("library.export", &json!({"path":path,"overwrite":overwrite})).unwrap_err();
|
|
assert!(error.to_string().contains("outside the folders this agent may use"), "{error}");
|
|
}
|
|
}
|
|
for overwrite in [false, true] {
|
|
s.execute("library.export", &json!({"path":root.join("allowed.json"),"overwrite":overwrite})).unwrap();
|
|
}
|
|
});
|
|
assert_eq!(std::fs::read(&sentinel).unwrap(), b"previous contents");
|
|
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1);
|
|
assert!(root.join("allowed.json").is_file());
|
|
assert!(Arc::ptr_eq(&before, &s.doc));
|
|
assert_eq!(s.undo.len(), undo);
|
|
assert!(!s.is_dirty());
|
|
std::fs::remove_dir_all(&base).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn catalog_appearance_copy_keeps_mass_and_survives_undo_and_cold_reopen_without_file() {
|
|
let file = File::new();
|
|
let catalog = catalog();
|
|
file.write(&catalog);
|
|
let mut s = session();
|
|
let original_format = s.doc.format.clone();
|
|
let before = s.execute("component.properties", &json!({})).unwrap()["total"]["mass_g"].as_f64().unwrap();
|
|
let undo = s.undo.len();
|
|
apply_entry(&mut s, &file, &catalog.entries[0], json!({"bodies":["Body1"]})).unwrap();
|
|
assert_eq!(s.undo.len(), undo + 1);
|
|
assert_eq!(s.doc.appearances.bodies["Body1"].name, "Warm oak colour");
|
|
assert_eq!(s.execute("component.properties", &json!({})).unwrap()["total"]["mass_g"].as_f64().unwrap(), before);
|
|
let saved = solidcraft_io::write_design(&s.doc);
|
|
std::fs::remove_file(&file.0).unwrap();
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert!(s.doc.appearances.bodies.is_empty());
|
|
s.execute("edit.redo", &json!({})).unwrap();
|
|
let reopened = Session::new(solidcraft_io::read_design(&saved).unwrap());
|
|
assert_eq!(reopened.doc.appearances, s.doc.appearances);
|
|
assert_eq!(reopened.doc.format, original_format, "scalar library adds no document schema");
|
|
}
|
|
|
|
#[test]
|
|
fn catalog_physical_copy_preserves_exact_look_label_and_is_one_transaction() {
|
|
let file = File::new();
|
|
let catalog = catalog();
|
|
file.write(&catalog);
|
|
let mut s = session();
|
|
let undo = s.undo.len();
|
|
apply_entry(&mut s, &file, &catalog.entries[1], json!({"bodies":["Body1"]})).unwrap();
|
|
assert_eq!(s.undo.len(), undo + 1);
|
|
assert_eq!(s.doc.user_materials[0].appearance.as_ref().unwrap().name, "Timber tint");
|
|
let mass = s.execute("component.properties", &json!({})).unwrap()["total"]["mass_g"].as_f64().unwrap();
|
|
assert!((mass - 0.7).abs() < 1e-8);
|
|
let saved = solidcraft_io::write_design(&s.doc);
|
|
std::fs::remove_file(&file.0).unwrap();
|
|
let mut reopened = Session::new(solidcraft_io::read_design(&saved).unwrap());
|
|
let actual = reopened.execute("component.properties", &json!({})).unwrap()["total"]["mass_g"].as_f64().unwrap();
|
|
assert!((actual - 0.7).abs() < 1e-8);
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert!(s.doc.user_materials.is_empty());
|
|
assert!(s.doc.materials.is_empty());
|
|
s.execute("edit.redo", &json!({})).unwrap();
|
|
assert_eq!(s.doc.user_materials, reopened.doc.user_materials);
|
|
}
|
|
|
|
#[test]
|
|
fn catalog_conflicts_stale_revisions_and_invalid_targets_are_atomic() {
|
|
let file = File::new();
|
|
let catalog = catalog();
|
|
file.write(&catalog);
|
|
let mut s = session();
|
|
s.execute("material.create", &json!({"name":"Timber density","density_g_cm3":1.2})).unwrap();
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
let redo = s.redo.len();
|
|
let doc = s.doc.clone();
|
|
let undo = s.undo.len();
|
|
let revision = s.revision;
|
|
assert!(apply_entry(&mut s, &file, &catalog.entries[1], json!({"bodies":["missing"]})).is_err());
|
|
assert_eq!(*s.doc, *doc);
|
|
assert_eq!((s.undo.len(), s.redo.len(), s.revision), (undo, redo, revision));
|
|
s.execute("edit.redo", &json!({})).unwrap();
|
|
let doc = s.doc.clone();
|
|
let history = (s.undo.len(), s.redo.len(), s.revision);
|
|
assert!(apply_entry(&mut s, &file, &catalog.entries[1], json!({"bodies":["Body1"]})).is_err());
|
|
assert_eq!(*s.doc, *doc);
|
|
assert_eq!((s.undo.len(), s.redo.len(), s.revision), history);
|
|
let mut changed = catalog.clone();
|
|
if let Definition::Appearance(look) = &mut changed.entries[0].definition {
|
|
look.color = [1, 2, 3];
|
|
}
|
|
changed.entries[0].revision = changed.entries[0].content_revision().unwrap();
|
|
file.write(&changed);
|
|
assert!(apply_entry(&mut s, &file, &catalog.entries[0], json!({"bodies":["Body1"]})).is_err());
|
|
assert_eq!(*s.doc, *doc);
|
|
assert_eq!((s.undo.len(), s.redo.len(), s.revision), history);
|
|
}
|
|
|
|
#[test]
|
|
fn catalog_explicit_material_rename_and_identical_definition_reuse() {
|
|
let file = File::new();
|
|
let catalog = catalog();
|
|
file.write(&catalog);
|
|
let entry = &catalog.entries[1];
|
|
let mut s = session();
|
|
s.execute("material.create", &json!({"name":"Timber density","density_g_cm3":1.2})).unwrap();
|
|
let params = json!({"path":file.0,"entry":entry.id,"revision":entry.revision,"name":"Imported timber"});
|
|
s.execute("library.apply", ¶ms).unwrap();
|
|
assert_eq!(s.doc.user_materials.len(), 2);
|
|
let undo = s.undo.len();
|
|
let revision = s.revision;
|
|
let again = s.execute("library.apply", ¶ms).unwrap();
|
|
assert_eq!(again["result"]["reused"], true);
|
|
assert_eq!((s.undo.len(), s.revision), (undo, revision));
|
|
assert_eq!(s.doc.user_materials[0].density, 1.2);
|
|
}
|
|
|
|
#[test]
|
|
fn catalog_inspection_export_and_no_clobber_do_not_touch_design_or_history() {
|
|
let file = File::new();
|
|
let mut s = session();
|
|
s.execute("appearance.assign", &json!({"bodies":["Body1"],"appearance":"Warm oak colour","color":[180,120,70],"opacity":0.8})).unwrap();
|
|
s.mark_saved();
|
|
let doc = s.doc.clone();
|
|
let undo = s.undo.len();
|
|
let revision = s.revision;
|
|
let result = s.execute("library.export", &json!({"path":file.0})).unwrap();
|
|
assert_eq!(result["entries"], 1);
|
|
assert!(Arc::ptr_eq(&doc, &s.doc));
|
|
assert_eq!((s.undo.len(), s.revision), (undo, revision));
|
|
assert!(!s.is_dirty());
|
|
let before = std::fs::read(&file.0).unwrap();
|
|
s.execute("library.inspect", &json!({"path":file.0})).unwrap();
|
|
assert!(s.execute("library.export", &json!({"path":file.0})).is_err());
|
|
assert_eq!(before, std::fs::read(&file.0).unwrap());
|
|
assert!(Arc::ptr_eq(&doc, &s.doc));
|
|
assert_eq!((s.undo.len(), s.revision), (undo, revision));
|
|
s.execute("library.export", &json!({"path":file.0,"overwrite":true})).unwrap();
|
|
assert_eq!(before, std::fs::read(&file.0).unwrap());
|
|
for command in ["library.inspect", "library.export", "library.apply"] {
|
|
assert!(s.execute(command, &json!({"path":file.0,"unknown":true})).is_err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn lossy_scalar_export_never_creates_or_replaces_a_file_or_changes_history() {
|
|
let mut s = session();
|
|
s.execute(
|
|
"appearance.assign",
|
|
&json!({"bodies":["Body1"],"color":[180,120,70],
|
|
"pbr":{"metallic":1.0,"roughness":0.3,"dielectric_ior":1.5}}),
|
|
)
|
|
.unwrap();
|
|
s.mark_saved();
|
|
let doc = s.doc.clone();
|
|
let history = (s.undo.len(), s.redo.len(), s.revision);
|
|
for existing in [false, true] {
|
|
let file = File::new();
|
|
if existing {
|
|
std::fs::write(&file.0, b"preserve existing library").unwrap();
|
|
}
|
|
for overwrite in [false, true] {
|
|
let error = s.execute("library.export", &json!({"path":file.0,"overwrite":overwrite})).unwrap_err();
|
|
assert!(error.to_string().contains("cannot preserve PBR"));
|
|
if existing {
|
|
assert_eq!(std::fs::read(&file.0).unwrap(), b"preserve existing library");
|
|
} else {
|
|
assert!(!std::path::Path::new(&file.0).exists());
|
|
}
|
|
assert!(Arc::ptr_eq(&doc, &s.doc));
|
|
assert_eq!((s.undo.len(), s.redo.len(), s.revision), history);
|
|
assert!(!s.is_dirty());
|
|
}
|
|
}
|
|
}
|