A design can define its own physical materials (material.create, material.edit, material.delete): name, density and an optional look. They work everywhere built-in materials do: assignment, mass properties, colours and configuration cells. Stored in user_materials, an additive field under format 16. Un-ignores the #554 regression test. Fixes #532. Co-authored-by: jonaswre <jonas.wrede@beyondit.gmbh>
This commit is contained in:
1 parent
a2f31f683d
commit
14635e5d3a
13 files changed
+492
-37
No files matched your search
@@ -147,7 +147,7 @@ impl Document {
|
||||
.get(name)
|
||||
.cloned()
|
||||
.or_else(|| self.appearances.components.get(&self.model_body_component(body)).cloned())
|
||||
.or_else(|| self.materials.get(name).and_then(|material| material_look(material)))
|
||||
.or_else(|| self.materials.get(name).and_then(|material| self.material_look(material)))
|
||||
}
|
||||
/// Effective body opacity, including paint carried from an imported body.
|
||||
pub fn model_body_opacity(&self, body: &crate::ModelBody) -> f64 {
|
||||
@@ -168,7 +168,7 @@ impl Document {
|
||||
let c = self.body_component(name, feature);
|
||||
(c != 0).then(|| self.appearances.components.get(&c).cloned()).flatten()
|
||||
})
|
||||
.or_else(|| self.materials.get(name).and_then(|m| material_look(m)))
|
||||
.or_else(|| self.materials.get(name).and_then(|m| self.material_look(m)))
|
||||
}
|
||||
|
||||
/// Face appearances on a body: (point on the face, look).
|
||||
|
||||
@@ -91,17 +91,14 @@ pub fn appearance_cell(value: &str) -> Result<Option<crate::appearance::Look>> {
|
||||
Ok(Some(look))
|
||||
}
|
||||
|
||||
/// Parse a physical material cell, returning None for the default material.
|
||||
pub fn material_cell(value: &str) -> Result<Option<String>> {
|
||||
let name = crate::MATERIALS
|
||||
.iter()
|
||||
.find(|(n, _)| n.eq_ignore_ascii_case(value.trim()))
|
||||
.map(|(n, _)| *n)
|
||||
.ok_or_else(|| DocError::Invalid(format!("unknown configuration material `{value}`")))?;
|
||||
Ok((name != "Default").then(|| name.to_string()))
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Parse a physical material cell (a built-in material or one defined in this design),
|
||||
/// returning None for the default material.
|
||||
pub fn material_cell(&self, value: &str) -> Result<Option<String>> {
|
||||
let name = self.material_name(value).ok_or_else(|| DocError::Invalid(format!("unknown configuration material `{value}`")))?;
|
||||
Ok((name != "Default").then_some(name))
|
||||
}
|
||||
|
||||
/// Freeze design-unit-sensitive configuration literals when changing display units.
|
||||
/// Explicit units and nonliteral expressions retain their source text.
|
||||
pub fn preserve_configuration_units(&mut self, previous_unit: &str, kept_parameters: &[String]) {
|
||||
@@ -199,7 +196,7 @@ impl Document {
|
||||
let f = next.feature_mut(*id).ok_or_else(|| DocError::Unknown(format!("configuration `{row}`: feature {id} is gone")))?;
|
||||
f.suppressed = on;
|
||||
}
|
||||
Column::Material(body) => match material_cell(v)? {
|
||||
Column::Material(body) => match self.material_cell(v)? {
|
||||
Some(material) => {
|
||||
next.materials.insert(body.clone(), material);
|
||||
}
|
||||
@@ -274,7 +271,7 @@ impl Document {
|
||||
self.config_current(c)
|
||||
.is_some_and(|current| current.chars().filter(|c| !c.is_whitespace()).eq(expected.chars().filter(|c| !c.is_whitespace())))
|
||||
}
|
||||
Column::Material(body) => material_cell(v).is_ok_and(|m| self.materials.get(body) == m.as_ref()),
|
||||
Column::Material(body) => self.material_cell(v).is_ok_and(|m| self.materials.get(body) == m.as_ref()),
|
||||
Column::Appearance(body) => appearance_cell(v).is_ok_and(|look| self.appearances.bodies.get(body) == look.as_ref()),
|
||||
Column::ComponentAppearance(id) => appearance_cell(v).is_ok_and(|look| self.appearances.components.get(id) == look.as_ref()),
|
||||
Column::FaceAppearance(target) => appearance_cell(v).is_ok_and(|look| self.config_face_look(target) == look),
|
||||
|
||||
@@ -1746,6 +1746,9 @@ pub struct Document {
|
||||
/// Physical material per body (by body name); others use the default.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub materials: std::collections::BTreeMap<String, String>,
|
||||
/// Physical materials defined in this design (`material.create`), next to the built-in ones.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub user_materials: Vec<UserMaterial>,
|
||||
/// Appearance colour per body (by body name, sRGB); it overrides the material's look.
|
||||
#[serde(default, skip_serializing_if = "crate::appearance::Appearances::is_empty")]
|
||||
pub appearances: crate::appearance::Appearances,
|
||||
@@ -1858,6 +1861,7 @@ impl Document {
|
||||
body_components: Default::default(),
|
||||
body_offsets: Default::default(),
|
||||
materials: Default::default(),
|
||||
user_materials: Vec::new(),
|
||||
appearances: Default::default(),
|
||||
favorites: Default::default(),
|
||||
canvases: Vec::new(),
|
||||
@@ -1898,7 +1902,46 @@ impl Document {
|
||||
|
||||
/// Density of a body's material (g/cm³).
|
||||
pub fn density(&self, body: &str) -> f64 {
|
||||
self.materials.get(body).and_then(|m| material_density(m)).unwrap_or(DEFAULT_DENSITY)
|
||||
self.materials.get(body).and_then(|m| self.material_density(m)).unwrap_or(DEFAULT_DENSITY)
|
||||
}
|
||||
|
||||
/// A material defined in this design, by name (case-insensitive).
|
||||
pub fn user_material(&self, name: &str) -> Option<&UserMaterial> {
|
||||
let name = name.trim();
|
||||
self.user_materials.iter().find(|m| m.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// A physical material's name as the library or this design spells it (case-insensitive
|
||||
/// lookup): built-in materials first, then the design's own.
|
||||
pub fn material_name(&self, name: &str) -> Option<String> {
|
||||
MATERIALS
|
||||
.iter()
|
||||
.find(|(n, _)| n.eq_ignore_ascii_case(name.trim()))
|
||||
.map(|(n, _)| n.to_string())
|
||||
.or_else(|| self.user_material(name).map(|m| m.name.clone()))
|
||||
}
|
||||
|
||||
/// Density of a physical material (g/cm³): built-in or defined in this design. A design
|
||||
/// material with an unusable density (a damaged file) has none.
|
||||
pub fn material_density(&self, name: &str) -> Option<f64> {
|
||||
material_density(name).or_else(|| self.user_material(name).map(|m| m.density).filter(|d| valid_density(*d)))
|
||||
}
|
||||
|
||||
/// The look a physical material gives a body without an appearance of its own.
|
||||
pub fn material_look(&self, name: &str) -> Option<crate::appearance::Look> {
|
||||
crate::appearance::material_look(name).or_else(|| {
|
||||
let mut look = self.user_material(name)?.appearance.clone()?;
|
||||
// A damaged file's opacity is shown opaque rather than trusted.
|
||||
if !(look.opacity.is_finite() && (0.0..=1.0).contains(&look.opacity)) {
|
||||
look.opacity = 1.0;
|
||||
}
|
||||
Some(look)
|
||||
})
|
||||
}
|
||||
|
||||
/// Every physical material's name: the built-in library, then the design's own.
|
||||
pub fn material_names(&self) -> Vec<String> {
|
||||
MATERIALS.iter().map(|(n, _)| n.to_string()).chain(self.user_materials.iter().map(|m| m.name.clone())).collect()
|
||||
}
|
||||
|
||||
/// The component a body belongs to: where it was moved, else its feature's.
|
||||
@@ -2376,6 +2419,26 @@ pub fn material_color(name: &str) -> Option<[u8; 3]> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A physical material defined in a design (`material.create`): its name, its density and,
|
||||
/// optionally, the look it gives bodies without an appearance of their own.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UserMaterial {
|
||||
pub name: String,
|
||||
/// Density (g/cm³).
|
||||
pub density: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub appearance: Option<crate::appearance::Look>,
|
||||
}
|
||||
|
||||
/// Largest density a material may have (g/cm³); the densest elements are about 22.6.
|
||||
pub const MAX_DENSITY: f64 = 1000.0;
|
||||
|
||||
/// A density a material can have: finite, above zero and at most [`MAX_DENSITY`] g/cm³.
|
||||
pub fn valid_density(d: f64) -> bool {
|
||||
d.is_finite() && d > 0.0 && d <= MAX_DENSITY
|
||||
}
|
||||
|
||||
/// Density of a built-in physical material (g/cm³).
|
||||
pub fn material_density(name: &str) -> Option<f64> {
|
||||
MATERIALS.iter().find(|(n, _)| n.eq_ignore_ascii_case(name.trim())).map(|(_, d)| *d)
|
||||
}
|
||||
@@ -60,6 +60,9 @@
|
||||
//! `font` is in the built-in font, as it always was.
|
||||
//! A Move feature can also keep its source bodies and transform a copy (`copy: true`); a
|
||||
//! move without it saves as before.
|
||||
//! A design can also define its own physical materials (`user_materials`: name, density in
|
||||
//! g/cm³, optional look), which bodies (`materials`) and configuration material cells name
|
||||
//! like built-in ones; a design without them saves as before.
|
||||
//! A design can also keep what was hidden when it was saved (`visibility`: hidden bodies,
|
||||
//! occurrence placements, sketches and origin items; `browser.visibility`). A design without
|
||||
//! it shows everything, as it always did; a reader that ignores it loses only the hiding.
|
||||
|
||||
@@ -132,7 +132,6 @@ fn excessively_nested_assembly_refuses_partial_totals() {
|
||||
/// rubber, leather). A user-defined material with its density is assigned like a built-in one
|
||||
/// and sets the body's mass: a 10 mm walnut cube at 0.65 g/cm³ weighs 0.65 g.
|
||||
#[test]
|
||||
#[ignore = "fails until #532 is fixed"]
|
||||
fn user_defined_material_sets_the_body_mass() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Slat"}));
|
||||
@@ -143,3 +142,160 @@ fn user_defined_material_sets_the_body_mass() {
|
||||
close(&props["bodies"][0]["density_g_cm3"], 0.65);
|
||||
close(&props["total"]["mass_g"], 0.65);
|
||||
}
|
||||
|
||||
/// A design file path in a fresh temporary folder (removed by the caller).
|
||||
fn temp_design(tag: &str) -> (std::path::PathBuf, String) {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-user-material-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("design.solidcraft").to_string_lossy().to_string();
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
fn mass(s: &mut Session) -> Value {
|
||||
run(s, "component.properties", json!({"component": "root"}))["total"]["mass_g"].clone()
|
||||
}
|
||||
|
||||
/// #532: a design's own material is listed after the library, gives its bodies its look, and
|
||||
/// can be edited (density and name: the bodies follow) and deleted; each step is undoable.
|
||||
#[test]
|
||||
fn user_defined_material_is_edited_renamed_and_deleted() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Pad"}));
|
||||
assert!(s.execute("material.edit", &json!({"material": "Rubber", "density_g_cm3": 1.2})).is_err(), "nothing to edit yet");
|
||||
let created = run(&mut s, "material.create", json!({"name": "Rubber", "density_g_cm3": 1.5, "color": "#1c1c1c"}));
|
||||
assert_eq!(created["appearance"]["color"], "#1c1c1c", "{created}");
|
||||
let list = run(&mut s, "material.list", json!({}));
|
||||
let materials = list["materials"].as_array().unwrap();
|
||||
assert_eq!(materials.len(), solidcraft_doc::MATERIALS.len() + 1);
|
||||
let rubber = materials.last().unwrap();
|
||||
assert_eq!((rubber["name"].as_str(), rubber["user"].as_bool()), (Some("Rubber"), Some(true)));
|
||||
|
||||
// Assigned (in any case), it sets the mass and the body's colour.
|
||||
run(&mut s, "material.assign", json!({"bodies": ["Pad"], "material": "rubber"}));
|
||||
close(&mass(&mut s), 1.5);
|
||||
let pad = s.model.state().body("Pad").unwrap().clone();
|
||||
let grey = 28.0 / 255.0;
|
||||
assert_eq!(s.doc.body_color(&pad), Some([grey, grey, grey]));
|
||||
|
||||
// A new density changes the mass; a new name carries the body with it.
|
||||
run(&mut s, "material.edit", json!({"material": "Rubber", "density_g_cm3": 1.2, "name": "Foot Rubber"}));
|
||||
close(&mass(&mut s), 1.2);
|
||||
let props = run(&mut s, "component.properties", json!({"component": "root"}));
|
||||
assert_eq!(props["bodies"][0]["material"], "Foot Rubber");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
close(&mass(&mut s), 1.5);
|
||||
assert_eq!(s.doc.materials.get("Pad").map(String::as_str), Some("Rubber"));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
|
||||
// A material in use is not deleted unless its bodies get another one.
|
||||
let refused = s.execute("material.delete", &json!({"material": "Foot Rubber"})).unwrap_err().to_string();
|
||||
assert!(refused.contains("body `Pad`"), "{refused}");
|
||||
run(&mut s, "material.delete", json!({"material": "Foot Rubber", "replace": "Oak"}));
|
||||
close(&mass(&mut s), 0.75);
|
||||
assert!(s.doc.user_materials.is_empty());
|
||||
assert!(s.execute("material.assign", &json!({"bodies": ["Pad"], "material": "Foot Rubber"})).is_err());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
close(&mass(&mut s), 1.2);
|
||||
}
|
||||
|
||||
/// #532: names and densities are checked before anything changes.
|
||||
#[test]
|
||||
fn user_defined_material_refuses_bad_names_and_densities() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "material.create", json!({"name": "Walnut", "density_g_cm3": 0.65}));
|
||||
let doc = s.doc.clone();
|
||||
for p in [
|
||||
json!({"name": "Steel", "density_g_cm3": 1.0}),
|
||||
json!({"name": "walnut", "density_g_cm3": 1.0}),
|
||||
json!({"name": " ", "density_g_cm3": 1.0}),
|
||||
json!({"name": "x".repeat(129), "density_g_cm3": 1.0}),
|
||||
json!({"name": "Leather", "density_g_cm3": 0}),
|
||||
json!({"name": "Leather", "density_g_cm3": -1}),
|
||||
json!({"name": "Leather", "density_g_cm3": 1e9}),
|
||||
json!({"name": "Leather", "density_g_cm3": "heavy"}),
|
||||
json!({"name": "Leather"}),
|
||||
json!({"name": "Leather", "density_g_cm3": 0.9, "color": "#zzzzzz"}),
|
||||
json!({"name": "Leather", "density_g_cm3": 0.9, "appearance": "No Such Look"}),
|
||||
json!({"name": "Leather", "density_g_cm3": 0.9, "opacity": 0.5}),
|
||||
] {
|
||||
assert!(s.execute("material.create", &p).is_err(), "{p}");
|
||||
}
|
||||
for p in [
|
||||
json!({"material": "Steel", "density_g_cm3": 1.0}),
|
||||
json!({"material": "Walnut", "name": "Oak"}),
|
||||
json!({"material": "Walnut", "density_g_cm3": 0}),
|
||||
] {
|
||||
assert!(s.execute("material.edit", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(*s.doc, *doc);
|
||||
}
|
||||
|
||||
/// #532: a design's materials are saved with it: after reopening, the bodies keep their
|
||||
/// material, its density and its look.
|
||||
#[test]
|
||||
fn user_defined_material_survives_save_and_reopen() {
|
||||
let (dir, path) = temp_design("saved");
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Tab"}));
|
||||
run(&mut s, "material.create", json!({"name": "Leather", "density_g_cm3": 0.86, "appearance": "Rubber - Black"}));
|
||||
run(&mut s, "material.assign", json!({"bodies": ["Tab"], "material": "Leather"}));
|
||||
run(&mut s, "file.save", json!({"path": path}));
|
||||
assert!(s.doc.to_json().contains("\"solidcraft/16\""));
|
||||
|
||||
run(&mut s, "file.new", json!({}));
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
let props = run(&mut s, "component.properties", json!({"component": "root"}));
|
||||
assert_eq!(props["bodies"][0]["material"], "Leather", "{props}");
|
||||
close(&props["total"]["mass_g"], 0.86);
|
||||
assert_eq!(s.doc.material_look("Leather").map(|l| l.name), Some("Rubber - Black".to_string()));
|
||||
run(&mut s, "material.edit", json!({"material": "Leather", "density_g_cm3": 0.9}));
|
||||
close(&mass(&mut s), 0.9);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #532: designs from before user materials open unchanged: built-in assignments keep their
|
||||
/// densities and a design without its own materials saves no `user_materials`. A damaged
|
||||
/// file's unusable density falls back to the default instead of producing nonsense masses.
|
||||
#[test]
|
||||
fn designs_without_user_materials_open_unchanged() {
|
||||
let (dir, path) = temp_design("legacy");
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Plate"}));
|
||||
run(&mut s, "material.assign", json!({"bodies": ["Plate"], "material": "Steel"}));
|
||||
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
assert!(legacy.get("user_materials").is_none());
|
||||
legacy["format"] = json!("solidcraft/16");
|
||||
std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
|
||||
run(&mut s, "file.new", json!({}));
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
close(&mass(&mut s), 7.85);
|
||||
assert!(s.doc.user_materials.is_empty());
|
||||
assert!(s.execute("material.delete", &json!({"material": "Steel"})).is_err(), "no design materials: nothing to delete");
|
||||
|
||||
legacy["user_materials"] = json!([{"name": "Broken", "density": -3.0, "appearance": {"name": "Broken", "color": [1, 2, 3], "opacity": 9.0}}]);
|
||||
legacy["materials"] = json!({"Plate": "Broken"});
|
||||
std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
close(&mass(&mut s), solidcraft_doc::DEFAULT_DENSITY);
|
||||
assert_eq!(s.doc.material_look("Broken").map(|l| l.opacity), Some(1.0));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #532: configuration material cells take a design's own material and follow its rename.
|
||||
#[test]
|
||||
fn configurations_switch_to_a_user_defined_material() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Slat"}));
|
||||
run(&mut s, "material.create", json!({"name": "Walnut", "density_g_cm3": 0.65}));
|
||||
run(&mut s, "config.configure", json!({"materials": ["Slat"]}));
|
||||
run(&mut s, "config.row", json!({"name": "Walnut", "values": {"material:Slat": "walnut"}}));
|
||||
run(&mut s, "config.activate", json!({"row": "Walnut"}));
|
||||
close(&mass(&mut s), 0.65);
|
||||
run(&mut s, "material.edit", json!({"material": "Walnut", "name": "Black Walnut"}));
|
||||
run(&mut s, "config.activate", json!({"row": "Default"}));
|
||||
run(&mut s, "config.activate", json!({"row": "Walnut"}));
|
||||
assert_eq!(s.doc.materials.get("Slat").map(String::as_str), Some("Black Walnut"));
|
||||
run(&mut s, "material.delete", json!({"material": "Black Walnut", "replace": "Default"}));
|
||||
assert!(!s.doc.materials.contains_key("Slat"));
|
||||
}
|
||||
@@ -265,7 +265,7 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| bad(cmd, format!("`{k}` is not a column (config.column adds one)")))?;
|
||||
match s.doc.configs.columns.get(col) {
|
||||
Some(Column::Material(_)) => {
|
||||
text = solidcraft_doc::config::material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into());
|
||||
text = s.doc.material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into());
|
||||
}
|
||||
Some(Column::Appearance(_) | Column::ComponentAppearance(_) | Column::FaceAppearance(_)) => {
|
||||
text = match solidcraft_doc::config::appearance_cell(&text).map_err(|e| bad(cmd, e.to_string()))? {
|
||||
|
||||
@@ -22,7 +22,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("material.assign", "Physical Material", physical_material)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("material")
|
||||
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …; `material.list`)"),
|
||||
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …, or one made with material.create; `material.list`)"),
|
||||
CommandSpec::new("appearance.assign", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params(
|
||||
"bodies?: [names]; faces?: [[x,y,z] on faces] (body?: which body, default the nearest); components?: [names or ids, or occurrence names]; \
|
||||
appearance?: library name (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \
|
||||
@@ -34,7 +34,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("appearance.list", "List Appearances", appearance_list)
|
||||
.noundo()
|
||||
.params("body?: name → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
|
||||
CommandSpec::new("material.list", "List Materials", material_list).noundo().doc_only(),
|
||||
CommandSpec::new("material.list", "List Materials", material_list)
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("→ the built-in materials, then the design's own (user: true, with their look)"),
|
||||
CommandSpec::new("engine.commands", "List Commands", commands).noundo(),
|
||||
];
|
||||
|
||||
@@ -283,11 +286,7 @@ fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "material.assign";
|
||||
let bodies = string_list(p, "bodies");
|
||||
let material = str_(p, "material").ok_or_else(|| bad(cmd, "`material` must be a material name"))?;
|
||||
let canonical = solidcraft_doc::MATERIALS
|
||||
.iter()
|
||||
.find(|(n, _)| n.eq_ignore_ascii_case(material.trim()))
|
||||
.map(|(n, _)| n.to_string())
|
||||
.ok_or_else(|| bad(cmd, format!("unknown material `{material}` (see material.list)")))?;
|
||||
let canonical = s.doc.material_name(material).ok_or_else(|| bad(cmd, format!("unknown material `{material}` (see material.list)")))?;
|
||||
let st = s.model.state();
|
||||
if bodies.is_empty() || bodies.iter().any(|b| st.body(b).is_none()) {
|
||||
return Err(bad(cmd, "`bodies` must list existing bodies"));
|
||||
@@ -303,7 +302,7 @@ fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// A colour parameter: `"#rrggbb"` or `[r, g, b]` (0–255).
|
||||
fn color_param(v: &Value) -> Option<[u8; 3]> {
|
||||
pub(super) fn color_param(v: &Value) -> Option<[u8; 3]> {
|
||||
if let Some(t) = v.as_str() {
|
||||
let h = t.trim().trim_start_matches('#');
|
||||
if h.len() != 6 || !h.is_ascii() {
|
||||
@@ -455,7 +454,7 @@ fn dist_to(b: &solidcraft_doc::ModelBody, p: solidcraft_geom::Vec3) -> f64 {
|
||||
c.dist(p)
|
||||
}
|
||||
|
||||
fn look_json(l: &solidcraft_doc::appearance::Look) -> Value {
|
||||
pub(super) fn look_json(l: &solidcraft_doc::appearance::Look) -> Value {
|
||||
json!({"name": l.name, "color": l.hex(), "opacity": l.opacity})
|
||||
}
|
||||
|
||||
@@ -524,6 +523,10 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn material_list(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"materials": solidcraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d})).collect::<Vec<_>>()}))
|
||||
fn material_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let built_in = solidcraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d}));
|
||||
let user = s.doc.user_materials.iter().map(
|
||||
|m| json!({"name": m.name, "density_g_cm3": m.density, "appearance": s.doc.material_look(&m.name).as_ref().map(look_json), "user": true}),
|
||||
);
|
||||
Ok(json!({"materials": built_in.chain(user).collect::<Vec<_>>()}))
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Physical materials defined in a design (`material.create`, `material.edit`,
|
||||
//! `material.delete`): a name, a density and, optionally, a look. They are assigned like the
|
||||
//! built-in library's (`material.assign`), set masses (`component.properties`) and fill
|
||||
//! configuration material cells. The design keeps them, so they are saved and undone like
|
||||
//! features.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::appearance::{Look, library};
|
||||
use solidcraft_doc::config::Column;
|
||||
use solidcraft_doc::{MATERIALS, UserMaterial, valid_density};
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::inspect::{color_param, look_json};
|
||||
use crate::params::{bad, str_};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("material.create", "New Physical Material", create).icon("material").params(
|
||||
"name (not a built-in material's); density_g_cm3: (0, 1000]; appearance?: library name (appearance.library) | color?: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1): the look bodies of this material get without an appearance of their own. Assign it with material.assign",
|
||||
),
|
||||
CommandSpec::new("material.edit", "Edit Physical Material", edit).icon("material").enabled(has_user_materials).params(
|
||||
"material: a material defined in this design; name? (bodies and configuration cells follow); density_g_cm3?; appearance? | color?, opacity? (as material.create; color: null clears the look)",
|
||||
),
|
||||
CommandSpec::new("material.delete", "Delete Physical Material", delete).enabled(has_user_materials).params(
|
||||
"material: a material defined in this design; replace?: material for the bodies and configuration cells that use it (without it, a material in use is refused)",
|
||||
),
|
||||
];
|
||||
|
||||
/// Most materials a design defines.
|
||||
const MAX_USER_MATERIALS: usize = 1000;
|
||||
|
||||
fn has_user_materials(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.doc.user_materials.is_empty() { Err("the design defines no physical materials (material.create)".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// A new material name: 1–128 characters, not a built-in material's or another design
|
||||
/// material's (`except`: the one being renamed).
|
||||
fn new_name(s: &Session, cmd: &str, name: &str, except: Option<&str>) -> Result<String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name.chars().count() > 128 {
|
||||
return Err(bad(cmd, "`name` must have 1–128 characters"));
|
||||
}
|
||||
if MATERIALS.iter().any(|(n, _)| n.eq_ignore_ascii_case(name)) {
|
||||
return Err(bad(cmd, format!("`{name}` is a built-in material (material.list)")));
|
||||
}
|
||||
let taken = |m: &&UserMaterial| m.name.eq_ignore_ascii_case(name) && except.is_none_or(|e| !m.name.eq_ignore_ascii_case(e));
|
||||
if s.doc.user_materials.iter().any(|m| taken(&m)) {
|
||||
return Err(bad(cmd, format!("the design already has a material `{name}`")));
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
fn density(cmd: &str, p: &Value) -> Result<f64> {
|
||||
p.get("density_g_cm3")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|d| valid_density(*d))
|
||||
.ok_or_else(|| bad(cmd, "`density_g_cm3` must be a number above 0 and at most 1000"))
|
||||
}
|
||||
|
||||
/// The look `appearance` / `color` / `opacity` give: `None` when none is given, `Some(None)`
|
||||
/// to clear it (`color: null`).
|
||||
fn look(cmd: &str, p: &Value, material: &str) -> Result<Option<Option<Look>>> {
|
||||
let opacity = match p.get("opacity") {
|
||||
Some(o) => Some(o.as_f64().filter(|x| x.is_finite() && (0.0..=1.0).contains(x)).ok_or_else(|| bad(cmd, "`opacity` must be 0…1"))?),
|
||||
None => None,
|
||||
};
|
||||
let mut l = match (str_(p, "appearance"), p.get("color")) {
|
||||
(Some(_), Some(c)) if !c.is_null() => return Err(bad(cmd, "give `appearance` or `color`, not both")),
|
||||
(Some(n), _) => library(n).ok_or_else(|| bad(cmd, format!("no appearance `{n}` in the library (appearance.library)")))?,
|
||||
(None, Some(Value::Null)) => return Ok(Some(None)),
|
||||
(None, Some(c)) => {
|
||||
let mut l = Look::custom(color_param(c).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?, 1.0);
|
||||
l.name = material.to_string();
|
||||
l
|
||||
}
|
||||
(None, None) if opacity.is_some() => return Err(bad(cmd, "`opacity` needs `appearance` or `color`")),
|
||||
(None, None) => return Ok(None),
|
||||
};
|
||||
if let Some(o) = opacity {
|
||||
l.opacity = o;
|
||||
}
|
||||
Ok(Some(Some(l)))
|
||||
}
|
||||
|
||||
fn material_json(m: &UserMaterial) -> Value {
|
||||
json!({"name": m.name, "density_g_cm3": m.density, "appearance": m.appearance.as_ref().map(look_json), "user": true})
|
||||
}
|
||||
|
||||
/// The index of the design material `material` names.
|
||||
fn existing(s: &Session, cmd: &str, p: &Value) -> Result<usize> {
|
||||
let name = str_(p, "material").ok_or_else(|| bad(cmd, "`material` must be a material name"))?;
|
||||
s.doc
|
||||
.user_materials
|
||||
.iter()
|
||||
.position(|m| m.name.eq_ignore_ascii_case(name.trim()))
|
||||
.ok_or_else(|| bad(cmd, format!("the design defines no material `{name}` (built-in materials cannot be changed)")))
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "material.create";
|
||||
let name = new_name(s, cmd, str_(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?, None)?;
|
||||
if s.doc.user_materials.len() >= MAX_USER_MATERIALS {
|
||||
return Err(bad(cmd, format!("a design defines at most {MAX_USER_MATERIALS} materials")));
|
||||
}
|
||||
let material = UserMaterial { density: density(cmd, p)?, appearance: look(cmd, p, &name)?.flatten(), name };
|
||||
let out = material_json(&material);
|
||||
s.doc_mut().user_materials.push(material);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Which configuration columns hold materials.
|
||||
fn material_columns(s: &Session) -> Vec<bool> {
|
||||
s.doc.configs.columns.iter().map(|c| matches!(c, Column::Material(_))).collect()
|
||||
}
|
||||
|
||||
/// Where material `name` is used: bodies, and configuration rows with a cell naming it.
|
||||
fn uses(s: &Session, name: &str) -> (Vec<String>, Vec<String>) {
|
||||
let bodies = s.doc.materials.iter().filter(|(_, m)| m.eq_ignore_ascii_case(name)).map(|(b, _)| b.clone()).collect();
|
||||
let columns = material_columns(s);
|
||||
let rows = s
|
||||
.doc
|
||||
.configs
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|r| r.cells.iter().zip(&columns).any(|(cell, material)| *material && cell.trim().eq_ignore_ascii_case(name)))
|
||||
.map(|r| r.name.clone())
|
||||
.collect();
|
||||
(bodies, rows)
|
||||
}
|
||||
|
||||
/// Point every use of material `from` (body assignments, configuration cells) at `to`
|
||||
/// ("Default" removes the body assignments).
|
||||
fn move_uses(s: &mut Session, from: &str, to: &str) {
|
||||
let columns = material_columns(s);
|
||||
let d = s.doc_mut();
|
||||
let bodies: Vec<String> = d.materials.iter().filter(|(_, m)| m.eq_ignore_ascii_case(from)).map(|(b, _)| b.clone()).collect();
|
||||
for b in bodies {
|
||||
if to == "Default" {
|
||||
d.materials.remove(&b);
|
||||
} else {
|
||||
d.materials.insert(b, to.to_string());
|
||||
}
|
||||
}
|
||||
for row in &mut d.configs.rows {
|
||||
for (cell, material) in row.cells.iter_mut().zip(&columns) {
|
||||
if *material && cell.trim().eq_ignore_ascii_case(from) {
|
||||
*cell = to.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "material.edit";
|
||||
let i = existing(s, cmd, p)?;
|
||||
let old = s.doc.user_materials.get(i).cloned().ok_or_else(|| bad(cmd, "no such material"))?;
|
||||
let name = match str_(p, "name") {
|
||||
Some(n) => new_name(s, cmd, n, Some(&old.name))?,
|
||||
None => old.name.clone(),
|
||||
};
|
||||
let density = if p.get("density_g_cm3").is_some() { density(cmd, p)? } else { old.density };
|
||||
let appearance = match look(cmd, p, &name)? {
|
||||
Some(l) => l,
|
||||
// A colour named after the material keeps following its name.
|
||||
None => old.appearance.clone().map(|mut l| {
|
||||
if l.name == old.name {
|
||||
l.name = name.clone();
|
||||
}
|
||||
l
|
||||
}),
|
||||
};
|
||||
let material = UserMaterial { name: name.clone(), density, appearance };
|
||||
let mut out = material_json(&material);
|
||||
if let Some(m) = s.doc_mut().user_materials.get_mut(i) {
|
||||
*m = material;
|
||||
}
|
||||
if name != old.name {
|
||||
move_uses(s, &old.name, &name);
|
||||
}
|
||||
out["bodies"] = json!(uses(s, &name).0);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "material.delete";
|
||||
let i = existing(s, cmd, p)?;
|
||||
let name = s.doc.user_materials.get(i).map(|m| m.name.clone()).ok_or_else(|| bad(cmd, "no such material"))?;
|
||||
let (bodies, rows) = uses(s, &name);
|
||||
let replace = match str_(p, "replace") {
|
||||
Some(r) => {
|
||||
let r = s.doc.material_name(r).ok_or_else(|| bad(cmd, format!("unknown material `{r}` (see material.list)")))?;
|
||||
if r.eq_ignore_ascii_case(&name) {
|
||||
return Err(bad(cmd, "`replace` must be another material"));
|
||||
}
|
||||
Some(r)
|
||||
}
|
||||
None if bodies.is_empty() && rows.is_empty() => None,
|
||||
None => {
|
||||
let mut used: Vec<String> =
|
||||
bodies.iter().map(|b| format!("body `{b}`")).chain(rows.iter().map(|r| format!("configuration `{r}`"))).collect();
|
||||
if used.len() > 10 {
|
||||
let more = used.len() - 10;
|
||||
used.truncate(10);
|
||||
used.push(format!("{more} more"));
|
||||
}
|
||||
return Err(bad(cmd, format!("`{name}` is used by {}: give `replace` (a material for them)", used.join(", "))));
|
||||
}
|
||||
};
|
||||
if let Some(r) = &replace {
|
||||
move_uses(s, &name, r);
|
||||
}
|
||||
s.doc_mut().user_materials.remove(i);
|
||||
Ok(json!({"deleted": name, "replaced_by": replace, "bodies": bodies}))
|
||||
}
|
||||
@@ -26,6 +26,7 @@ mod config;
|
||||
mod file;
|
||||
mod inspect;
|
||||
pub(crate) mod joints;
|
||||
mod materials;
|
||||
mod measure_sel;
|
||||
mod mesh;
|
||||
pub(crate) mod motion;
|
||||
@@ -214,6 +215,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(parameters::COMMANDS.iter());
|
||||
v.extend(file::COMMANDS.iter());
|
||||
v.extend(inspect::COMMANDS.iter());
|
||||
v.extend(materials::COMMANDS.iter());
|
||||
v.extend(section::COMMANDS.iter());
|
||||
v.extend(diagnostics::COMMANDS.iter());
|
||||
v
|
||||
|
||||
@@ -1306,6 +1306,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
auto_operation(app, &mut d);
|
||||
update_measure(app, &mut d);
|
||||
update_fill_cells(app, &mut d);
|
||||
// Physical materials: the library's, then the design's own (material.create).
|
||||
let material_names = app.session.doc.material_names();
|
||||
let t = Tokens::get();
|
||||
let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect());
|
||||
let top = (vp.top() + crate::viewport::AXIS_VIEW_CLEARANCE).min((vp.bottom() - 360.0).max(vp.top()));
|
||||
@@ -1817,7 +1819,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
Kind::Material { index } => {
|
||||
let names: Vec<&str> = solidcraft_engine::doc::MATERIALS.iter().map(|(n, _)| *n).collect();
|
||||
let names: Vec<&str> = material_names.iter().map(String::as_str).collect();
|
||||
row_label(ui, "Material");
|
||||
combo(ui, "mat", &names, index);
|
||||
ui.end_row();
|
||||
@@ -2846,7 +2848,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
}
|
||||
Kind::Material { index } => {
|
||||
need(0, "bodies")?;
|
||||
let name = solidcraft_engine::doc::MATERIALS.get(*index).map(|(n, _)| *n).unwrap_or("Default");
|
||||
let name = s.doc.material_names().get(*index).cloned().unwrap_or_else(|| "Default".into());
|
||||
("material.assign", json!({"bodies": body_names(0), "material": name}))
|
||||
}
|
||||
Kind::Section { offset, flip } => {
|
||||
|
||||
@@ -343,6 +343,8 @@ fn joints_moving(app: &SolveApp) -> Vec<(usize, String, JointKind)> {
|
||||
/// The dialog's rows; true when Enter was pressed in a value.
|
||||
pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [SelInput]) -> bool {
|
||||
let t = Tokens::get();
|
||||
// Physical materials for the configuration table's Library: the library's, then the design's own.
|
||||
let material_names = app.session.doc.material_names();
|
||||
let mut enter = false;
|
||||
match k {
|
||||
Mo::ContactSet { name, check } => {
|
||||
@@ -702,8 +704,8 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
ui.close();
|
||||
}
|
||||
if kind == "material" {
|
||||
for (name, _) in solidcraft_engine::doc::MATERIALS.iter().filter(|(n, _)| *n != "Default") {
|
||||
if ui.button(*name).clicked() {
|
||||
for name in material_names.iter().filter(|n| *n != "Default") {
|
||||
if ui.button(name).clicked() {
|
||||
choice = Some(name.to_string());
|
||||
ui.close();
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ with `preview: true`. Unsupported or mismatched caches are ignored. Browser open
|
||||
still rebuilds synchronously.
|
||||
The repository's `docs/design-container.md` describes limits and encoding details.
|
||||
|
||||
Current designs use format 16, including the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
|
||||
Current designs use format 16, including physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
|
||||
links, persistent
|
||||
names from exact face history, exact planar shell-face anchors, persistent analytic offset boundary roles and
|
||||
association profile references, durable sketch face anchors, periodic fit splines and versioned
|
||||
|
||||
+17
-4
@@ -1,8 +1,8 @@
|
||||
# Features
|
||||
|
||||
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **314 commands.** Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
|
||||
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **322 commands.** Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
|
||||
|
||||
## Sketch (97)
|
||||
## Sketch (98)
|
||||
|
||||
| Command | Id | Toolbar |
|
||||
|---|---|---|
|
||||
@@ -41,6 +41,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
| Delete Sketch Entities | `sketch.delete` | – |
|
||||
| Drag Sketch Point | `sketch.move_point` | – |
|
||||
| Drag Sketch Geometry | `sketch.drag` | – |
|
||||
| End Sketch Drag | `sketch.drag_end` | – |
|
||||
| Move Dimension Text | `sketch.dimension_text` | – |
|
||||
| Sketch Palette Options | `sketch.options` | – |
|
||||
| Chain | `sketch.chain` | – |
|
||||
@@ -58,7 +59,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
| Control Point Spline (Degree 5) | `sketch.spline.control_point_5` | SKETCH › CREATE |
|
||||
| Conic Curve | `sketch.conic` | SKETCH › CREATE |
|
||||
| Blend Curve | `sketch.blend_curve` | SKETCH › MODIFY |
|
||||
| Centerline | `sketch.centerline` | – |
|
||||
| Centerline | `sketch.centerline` | SKETCH › CREATE |
|
||||
| Text | `sketch.text` | SKETCH › CREATE |
|
||||
| Edit Text | `sketch.edit_text` | – |
|
||||
| Curvature | `sketch.constraint.curvature` | SKETCH › CONSTRAINTS |
|
||||
@@ -364,6 +365,15 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
| List Contact Sets | `contact.list` | – |
|
||||
| Check Contact | `contact.check` | – |
|
||||
|
||||
## `diagnostics.*` (4)
|
||||
|
||||
| Command | Id | Toolbar |
|
||||
|---|---|---|
|
||||
| Crash Reports | `diagnostics.crash_reports` | – |
|
||||
| Crash Report | `diagnostics.crash_report` | – |
|
||||
| Delete Crash Reports | `diagnostics.crash_reports.clear` | – |
|
||||
| Crash Test | `diagnostics.crash_test` | – |
|
||||
|
||||
## `document.*` (2)
|
||||
|
||||
| Command | Id | Toolbar |
|
||||
@@ -398,12 +408,15 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
|---|---|---|
|
||||
| Delete Face | `face.delete` | SOLID › MODIFY |
|
||||
|
||||
## `material.*` (2)
|
||||
## `material.*` (5)
|
||||
|
||||
| Command | Id | Toolbar |
|
||||
|---|---|---|
|
||||
| Physical Material | `material.assign` | SOLID › MODIFY |
|
||||
| List Materials | `material.list` | – |
|
||||
| New Physical Material | `material.create` | – |
|
||||
| Edit Physical Material | `material.edit` | – |
|
||||
| Delete Physical Material | `material.delete` | – |
|
||||
|
||||
## `mesh.*` (1)
|
||||
|
||||
|
||||
Reference in new issue
Block a user