Colours in exports from materials and appearances; Appearance command

- Bodies export (STEP, 3MF) in the colour they are shown in: an appearance,
  else the physical material's look, else the colour the body was imported
  with (Document::body_color / painted). Physical materials used to export
  no colour at all.
- AppearanceCommand (Modify): a colour per body ("#rrggbb" or [r, g, b];
  null follows the material again), kept in the design and across renames.
- Material looks move to solvecraft-doc (material_color); the viewport draws
  appearance, material or imported colour, as exported.
- Engine test: brass and red ABS bodies export, re-import with their RGB, and
  keep it through open and export again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 08:11:28 +00:00
1 parent 578b35e88a
commit ae4570ed39
8 files changed
+200 -86

No files matched your search

+3
View File
@@ -219,6 +219,9 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(m) = d.materials.remove(&old) {
d.materials.insert(name.to_string(), m);
}
if let Some(c) = d.appearances.remove(&old) {
d.appearances.insert(name.to_string(), c);
}
if let Some(c) = d.body_components.remove(&old) {
d.body_components.insert(name.to_string(), c);
}
+3 -2
View File
@@ -162,9 +162,10 @@ fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Resu
let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone());
// A design with components exports as a STEP assembly; otherwise bodies as placed.
let bytes = if format == Format::Step && bodies.is_empty() && !s.doc.occurrences.is_empty() {
solvecraft_io::step_assembly(&s.doc, &s.model.state(), &name)?
solvecraft_io::step_assembly(&s.doc, &s.doc.painted(&s.model.state()), &name)?
} else {
solvecraft_io::export(&s.world_state(), bodies, format, &name)?
// Bodies carry the colour they are shown in (appearance, material or imported).
solvecraft_io::export(&s.doc.painted(&s.world_state()), bodies, format, &name)?
};
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")}))
+39
View File
@@ -23,6 +23,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("material")
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …; `material.list`)"),
CommandSpec::new("AppearanceCommand", "Appearance", appearance)
.at("SOLID", "MODIFY")
.icon("appearance")
.params("bodies: [names]; color: \"#rrggbb\" or [r, g, b] (0–255), or null to follow the material"),
CommandSpec::new("material.list", "List Materials", material_list).noundo(),
CommandSpec::new("engine.commands", "List Commands", commands).noundo(),
];
@@ -271,6 +275,41 @@ fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"bodies": bodies, "material": canonical}))
}
/// A colour parameter: `"#rrggbb"` or `[r, g, b]` (0–255).
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() {
return None;
}
let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok());
return Some([byte(0)?, byte(2)?, byte(4)?]);
}
let a = v.as_array().filter(|a| a.len() == 3)?;
let c = |i: usize| a.get(i).and_then(Value::as_f64).filter(|x| x.is_finite() && (0.0..=255.0).contains(x)).map(|x| x.round() as u8);
Some([c(0)?, c(1)?, c(2)?])
}
fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "AppearanceCommand";
let bodies = string_list(p, "bodies");
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"));
}
let color = match p.get("color") {
None | Some(Value::Null) => None,
Some(v) => Some(color_param(v).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?),
};
for b in &bodies {
match color {
Some(c) => s.doc_mut().appearances.insert(b.clone(), c),
None => s.doc_mut().appearances.remove(b),
};
}
Ok(json!({"bodies": bodies, "color": color.map(|c| format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]))}))
}
fn material_list(_s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"materials": solvecraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d})).collect::<Vec<_>>()}))
}