mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 18:45:55 +00:00
Merge origin/main (3c806394b) into smlib (sync 5)
Conflict: crates/ui-egui/src/render_workspace_tests.rs takes main's reflection-editor flow (#8000) and reads the world through open_meshing::session_world, as smlib's side did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdcgEtwSrdwZxqTQp5MfnU
This commit is contained in:
@@ -304,6 +304,28 @@ pub fn value_in_unit(v: Value, unit: &str) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The number a reply reports for a value: millimetres for a length, degrees for an angle,
|
||||
/// as-is for a unit-less or compound value. One meaning of `value` in every reply (#10747).
|
||||
pub fn reported_value(v: Value) -> f64 {
|
||||
match v.kind() {
|
||||
Some(Kind::Angle) => v.v.to_degrees(),
|
||||
_ => v.v,
|
||||
}
|
||||
}
|
||||
|
||||
/// The number a person would read for `v`: the shortest decimal that reads back after
|
||||
/// rounding off the last few bits (12 significant digits), so `30 deg` stored as
|
||||
/// 29.999999999999996 reports 30 and `0.1 + 0.2` reports 0.3. Only what is *reported*
|
||||
/// is rounded; stored values and evaluation keep full precision (#10747).
|
||||
pub fn round_reported(v: f64) -> f64 {
|
||||
if !v.is_finite() {
|
||||
return v;
|
||||
}
|
||||
// `:.11e` keeps 12 significant digits; parsing back gives the f64 nearest that decimal,
|
||||
// which serializes as the shortest form that reads back to it.
|
||||
format!("{v:.11e}").parse().unwrap_or(v)
|
||||
}
|
||||
|
||||
fn unit(name: &str) -> Option<Value> {
|
||||
UNITS.iter().find(|(n, _, _)| *n == name).map(|(_, k, s)| match k {
|
||||
Kind::Angle => Value::angle(*s),
|
||||
@@ -1637,4 +1659,36 @@ mod tests {
|
||||
defs[2].expr = "bad +".into();
|
||||
assert!(eval_params(&defs, 1.0).1.contains_key("c"));
|
||||
}
|
||||
|
||||
/// #10747: reported numbers are the shortest decimals that read back after rounding off
|
||||
/// the last few bits (12 significant digits); stored values keep full precision.
|
||||
#[test]
|
||||
fn round_reported_reads_as_a_person_would() {
|
||||
for (noisy, read) in [
|
||||
(30f64.to_radians().to_degrees(), 30.0),
|
||||
(0.1 + 0.2, 0.3),
|
||||
(3.0 * 25.4, 76.2),
|
||||
(1.2 * 25.4, 30.48),
|
||||
(29.999999999999996, 30.0),
|
||||
(0.30000000000000004, 0.3),
|
||||
(-0.1 - 0.2, -0.3),
|
||||
] {
|
||||
assert_eq!(round_reported(noisy), read, "{noisy}");
|
||||
}
|
||||
// A number with real digits of its own keeps them up to 12 significant digits.
|
||||
assert_eq!(round_reported(1.234567890123), 1.23456789012);
|
||||
assert_eq!(round_reported(1.0000000000001), 1.0);
|
||||
// Zero and non-finite values pass through.
|
||||
assert_eq!(round_reported(0.0), 0.0);
|
||||
assert!(round_reported(f64::INFINITY).is_infinite());
|
||||
assert!(round_reported(f64::NAN).is_nan());
|
||||
}
|
||||
|
||||
/// #10747: `value` means millimetres and degrees in every reply.
|
||||
#[test]
|
||||
fn reported_value_is_mm_and_degrees() {
|
||||
assert_eq!(reported_value(Value::length(50.8)), 50.8);
|
||||
assert_eq!(round_reported(reported_value(Value::angle(30f64.to_radians()))), 30.0);
|
||||
assert_eq!(reported_value(Value::num(4.0)), 4.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,11 @@ pub struct ParamRow {
|
||||
pub name: String,
|
||||
pub expression: String,
|
||||
pub unit: String,
|
||||
/// Always millimetres for a length, degrees for an angle, whatever the parameter's own
|
||||
/// unit — the same meaning `value` has in every reply (#10747).
|
||||
pub value: Option<f64>,
|
||||
/// The value in the parameter's own `unit`, as the Parameters window shows it.
|
||||
pub display_value: Option<f64>,
|
||||
pub error: Option<String>,
|
||||
pub comment: String,
|
||||
/// "user", "sketch" (a dimension) or "feature".
|
||||
@@ -367,14 +371,20 @@ impl Document {
|
||||
}
|
||||
}
|
||||
let used_by = |n: &str| -> Vec<String> { users.get(n).cloned().unwrap_or_default() };
|
||||
let value = |n: &str, k: Kind| vals.get(n).and_then(|v| v.to_kind(k).ok()).map(|x| if k == Kind::Angle { x.to_degrees() } else { x });
|
||||
let value = |n: &str, k: Kind| {
|
||||
vals.get(n).and_then(|v| v.to_kind(k).ok()).map(|x| if k == Kind::Angle { x.to_degrees() } else { x }).map(expr::round_reported)
|
||||
};
|
||||
// `value` is mm / degrees in every row (#10747); the parameter's own unit goes to
|
||||
// `display_value`. Both carry the number a person would read, not the float noise.
|
||||
let unified = |n: &str| vals.get(n).map(|v| expr::round_reported(expr::reported_value(*v)));
|
||||
let mut rows = Vec::new();
|
||||
for p in &self.params {
|
||||
rows.push(ParamRow {
|
||||
name: p.name.clone(),
|
||||
expression: p.expr.clone(),
|
||||
unit: p.unit.clone(),
|
||||
value: self.param_display_value(&vals, &p.name),
|
||||
value: unified(&p.name),
|
||||
display_value: self.param_display_value(&vals, &p.name).map(expr::round_reported),
|
||||
error: errs.get(&p.name).cloned(),
|
||||
comment: p.comment.clone(),
|
||||
source: if p.model { "sketch" } else { "user" },
|
||||
@@ -396,6 +406,8 @@ impl Document {
|
||||
expression: e,
|
||||
unit: unit_of(k).into(),
|
||||
value: value(name, k),
|
||||
// A feature input's own unit is mm / deg, so its display value is its value.
|
||||
display_value: value(name, k),
|
||||
error: errs.get(name).cloned(),
|
||||
comment: self.param_comments.get(name).cloned().unwrap_or_default(),
|
||||
source: "feature",
|
||||
@@ -415,6 +427,7 @@ impl Document {
|
||||
expression: p.text.clone(),
|
||||
unit: crate::text_params::TEXT_UNIT.into(),
|
||||
value: None,
|
||||
display_value: None,
|
||||
error: None,
|
||||
comment: p.comment.clone(),
|
||||
source: "user",
|
||||
@@ -460,7 +473,8 @@ impl Document {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// User parameters as CSV (`name,unit,expression,value,comment`).
|
||||
/// User parameters as CSV (`name,unit,expression,value,comment`). The `value` column is the
|
||||
/// number in the `unit` column, rounded for reading (#10747); import reads `expression`.
|
||||
pub fn params_csv(&self) -> String {
|
||||
let q = |s: &str| if s.contains([',', '"', '\r', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() };
|
||||
let mut out = String::from("name,unit,expression,value,comment,favorite\n");
|
||||
@@ -470,7 +484,7 @@ impl Document {
|
||||
q(&r.name),
|
||||
q(&r.unit),
|
||||
q(&r.expression),
|
||||
r.value.map(|v| format!("{v}")).unwrap_or_default(),
|
||||
r.display_value.or(r.value).map(|v| format!("{v}")).unwrap_or_default(),
|
||||
q(&r.comment),
|
||||
r.favorite
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_doc::expr::{self, Kind};
|
||||
use solidcraft_doc::joints::cam::{self, Cam, CamProfile, Follower};
|
||||
use solidcraft_doc::joints::{self, Coupling, Joint, JointExprs, JointKind, JointOrigin, MotionLink, NamedOrigin, Size, Snap, XRef};
|
||||
use solidcraft_geom::{Aabb3, Vec3};
|
||||
@@ -232,7 +232,8 @@ fn sol_json(sol: &joints::Solution, p: &Value) -> Value {
|
||||
}
|
||||
|
||||
/// A joint's values as `joint.list` gives them: degrees for angles, mm for distances, each with
|
||||
/// its name and limits.
|
||||
/// its name and limits. The numbers are rounded for reading (#10747); the joint keeps full
|
||||
/// precision.
|
||||
pub(crate) fn named_values(j: &Joint, q: &[f64]) -> Vec<Value> {
|
||||
j.kind
|
||||
.dofs()
|
||||
@@ -240,8 +241,8 @@ pub(crate) fn named_values(j: &Joint, q: &[f64]) -> Vec<Value> {
|
||||
.enumerate()
|
||||
.map(|(i, n)| {
|
||||
let (v, angle) = (q.get(i).copied().unwrap_or(0.0), j.kind.is_angle(i));
|
||||
json!({"name": n, "value": if angle { v.to_degrees() } else { v }, "unit": if angle { "deg" } else { "mm" },
|
||||
"limits": j.limits.get(i).copied().flatten().map(|(a, b)| if angle { [a.to_degrees(), b.to_degrees()] } else { [a, b] })})
|
||||
json!({"name": n, "value": expr::round_reported(if angle { v.to_degrees() } else { v }), "unit": if angle { "deg" } else { "mm" },
|
||||
"limits": j.limits.get(i).copied().flatten().map(|(a, b)| if angle { [expr::round_reported(a.to_degrees()), expr::round_reported(b.to_degrees())] } else { [expr::round_reported(a), expr::round_reported(b)] })})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1103,7 +1104,7 @@ fn held_values(s: &Session, j: &Joint, requested: &[f64], driven: &[usize], r: &
|
||||
continue;
|
||||
}
|
||||
let angle = j.kind.is_angle(i);
|
||||
let shown = |v: f64| if angle { v.to_degrees() } else { v };
|
||||
let shown = |v: f64| expr::round_reported(if angle { v.to_degrees() } else { v });
|
||||
let name = names.get(i).copied().unwrap_or("value");
|
||||
let why = if let Some(l) = s.doc.assembly.links.iter().find(|l| l.b == j.id && l.ib == i) {
|
||||
let coupling = match &l.coupling {
|
||||
|
||||
@@ -2586,3 +2586,21 @@ fn driving_one_cylindrical_coordinate_preserves_the_other_expression_and_reports
|
||||
assert!(s.execute("joint.drive", &json!({"joint":joint,"index":bad,"value":0})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// #10747: driving a joint to 30 reports 30, not 29.999999999999996; the joint keeps
|
||||
/// full precision.
|
||||
#[test]
|
||||
fn driving_an_angle_reports_the_number_a_person_would_read() {
|
||||
let mut s = two_cubes();
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "revolute", "a": {"occurrence": "A", "point": [5.0, 5.0, 10.0]}, "b": {"occurrence": "B", "point": [25.0, 5.0, 10.0]}, "name": "Turn"}),
|
||||
);
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Turn", "value": 30}));
|
||||
assert_eq!(r["values"][0]["value"], json!(30.0), "{r}");
|
||||
assert_eq!(r["requested"][0]["value"], json!(30.0), "{r}");
|
||||
// Only what is reported is rounded: the stored value keeps full precision.
|
||||
let j = s.doc.assembly.joints.iter().find(|j| j.name == "Turn").unwrap();
|
||||
assert_ne!(j.values[0].to_degrees(), 30.0, "the stored angle keeps its bits");
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
activation; driven parameters and restrictions wait for a configuration edit or activation)",
|
||||
),
|
||||
CommandSpec::new("parameters.list", "List Parameters", list).icon("list").noundo().doc_only().params(
|
||||
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \
|
||||
source, feature/input, comment, favorite, uses, used_by, error) and cycles",
|
||||
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value (mm/deg in every \
|
||||
reply), display_value (in its own unit), source, feature/input, comment, favorite, uses, used_by, error) and cycles",
|
||||
),
|
||||
CommandSpec::new("parameters.add", "Add User Parameter", add)
|
||||
.icon("new")
|
||||
@@ -177,8 +177,8 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(t) = s.doc.text_param(&name) {
|
||||
out["text"] = json!(t.text);
|
||||
}
|
||||
out["value"] = json!(vals.get(&name).map(|v| v.v));
|
||||
out["display_value"] = json!(s.doc.param_display_value(&vals, &name));
|
||||
out["value"] = json!(vals.get(&name).map(|v| expr::round_reported(expr::reported_value(*v))));
|
||||
out["display_value"] = json!(s.doc.param_display_value(&vals, &name).map(expr::round_reported));
|
||||
out["recomputed"] = json!(s.model.last_recomputed);
|
||||
out["restored"] = json!(s.model.last_restored);
|
||||
out["errors"] = json!(failing(s));
|
||||
@@ -280,7 +280,8 @@ fn change_many(s: &mut Session, changes: &Value, solved: &crate::SketchValues) -
|
||||
}
|
||||
s.settle_value_edit(solved)?;
|
||||
let (vals, _) = s.doc.param_values();
|
||||
let values: serde_json::Map<String, Value> = list.iter().map(|(n, _)| (n.clone(), json!(vals.get(n).map(|v| v.v)))).collect();
|
||||
let values: serde_json::Map<String, Value> =
|
||||
list.iter().map(|(n, _)| (n.clone(), json!(vals.get(n).map(|v| expr::round_reported(expr::reported_value(*v)))))).collect();
|
||||
let mut out = json!({"changed": values, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored, "errors": failing(s)});
|
||||
if !switched.is_empty() {
|
||||
out["switched"] = json!(switched);
|
||||
@@ -343,7 +344,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({"name": name, "unit": solidcraft_doc::text_params::TEXT_UNIT, "text": t.text, "value": null}));
|
||||
}
|
||||
Ok(
|
||||
json!({"name": name, "unit": s.doc.param(&name).map(|x| x.unit.clone()), "value": vals.get(&name).map(|v| v.v), "display_value": s.doc.param_display_value(&vals, &name)}),
|
||||
json!({"name": name, "unit": s.doc.param(&name).map(|x| x.unit.clone()), "value": vals.get(&name).map(|v| expr::round_reported(expr::reported_value(*v))), "display_value": s.doc.param_display_value(&vals, &name).map(expr::round_reported)}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -426,7 +427,9 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.param_rows()
|
||||
.into_iter()
|
||||
.filter(|r| r.source == "user")
|
||||
.map(|r| json!({"name": r.name, "expression": r.expression, "unit": r.unit, "value": r.value, "comment": r.comment, "favorite": r.favorite}))
|
||||
// The `value` field keeps the number in the parameter's own unit, as the CSV
|
||||
// column does, rounded for reading (#10747).
|
||||
.map(|r| json!({"name": r.name, "expression": r.expression, "unit": r.unit, "value": r.display_value.or(r.value), "comment": r.comment, "favorite": r.favorite}))
|
||||
.collect();
|
||||
serde_json::to_string_pretty(&rows).map_err(|e| bad(cmd, e.to_string()))?
|
||||
}
|
||||
@@ -501,7 +504,7 @@ fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, Kind::Angle) => v.to_degrees(),
|
||||
(None, _) => v,
|
||||
};
|
||||
json!({"ok": true, "value": v, "kind": k, "display_value": shown, "references": refs})
|
||||
json!({"ok": true, "value": expr::round_reported(v), "kind": k, "display_value": expr::round_reported(shown), "references": refs})
|
||||
}
|
||||
Err(err) => json!({"ok": false, "error": err.to_string().trim_start_matches("expression: ").to_string(), "references": refs}),
|
||||
};
|
||||
@@ -526,7 +529,8 @@ fn complete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// parameters, then the others.
|
||||
rows.sort_by_key(|r| (!starts(&r.name), !r.favorite, r.source != "user"));
|
||||
for r in rows.into_iter().take(200) {
|
||||
let v = r.value.map(|v| format!("{v:.4} {}", r.unit).trim().to_string()).unwrap_or_else(|| "error".into());
|
||||
// The completion shows the parameter's own unit, so it reads `display_value`.
|
||||
let v = r.display_value.or(r.value).map(|v| format!("{v:.4} {}", r.unit).trim().to_string()).unwrap_or_else(|| "error".into());
|
||||
let mut detail = format!("{} = {v}", r.expression);
|
||||
if let (Some(f), Some(i)) = (&r.feature, r.input) {
|
||||
detail.push_str(&format!(" ({f} {i})"));
|
||||
|
||||
@@ -60,7 +60,9 @@ fn width_follows_an_expression() {
|
||||
run(&mut s, "parameters.change", json!({"name": "len", "expression": "5", "unit": "in"}));
|
||||
assert!(rel(volume(&mut s), 40.0 * 63.5 * 10.0) < 1e-9);
|
||||
let l = run(&mut s, "parameters.list", json!({}));
|
||||
assert_eq!(row(&l, "len")["value"], 5.0, "{l}");
|
||||
// `value` is mm in every reply; the own-unit number is `display_value` (#10747).
|
||||
assert_eq!(row(&l, "len")["value"], 127.0, "{l}");
|
||||
assert_eq!(row(&l, "len")["display_value"], 5.0, "{l}");
|
||||
assert_eq!(row(&l, "len")["unit"], "in");
|
||||
// A failing parameter fails the features using it, with its error.
|
||||
let e = err(&mut s, "parameters.change", json!({"name": "len", "expression": "5 deg"}));
|
||||
@@ -389,7 +391,8 @@ fn text_parameter_imports_accept_mixed_case_units() {
|
||||
assert_eq!(label["comment"], "quoted label");
|
||||
assert!(label["value"].is_null());
|
||||
assert_eq!(row(&list, "width")["unit"], "in");
|
||||
assert_eq!(row(&list, "width")["value"], 1.0);
|
||||
assert_eq!(row(&list, "width")["value"], 25.4);
|
||||
assert_eq!(row(&list, "width")["display_value"], 1.0);
|
||||
assert_eq!(s.doc.param_values().0["width"].v, 25.4);
|
||||
}
|
||||
// Export still uses the canonical marker and round-trips the unquoted text.
|
||||
@@ -933,8 +936,8 @@ fn a_parameter_added_without_a_unit_takes_the_design_unit() {
|
||||
let r = run(s, "parameters.add", p);
|
||||
(r["unit"].as_str().unwrap_or("?").to_string(), r["value"].as_f64().unwrap_or(f64::NAN), r["display_value"].as_f64().unwrap_or(f64::NAN))
|
||||
};
|
||||
let close = |got: (String, f64, f64), unit: &str, mm: f64, shown: f64| {
|
||||
assert!(got.0 == unit && rel(got.1, mm) < 1e-9 && rel(got.2, shown) < 1e-9, "{got:?}, expected {unit} {mm} mm ({shown})");
|
||||
let close = |got: (String, f64, f64), unit: &str, val: f64, shown: f64| {
|
||||
assert!(got.0 == unit && rel(got.1, val) < 1e-9 && rel(got.2, shown) < 1e-9, "{got:?}, expected {unit} {val} ({shown})");
|
||||
};
|
||||
// A millimetre design is as it was.
|
||||
let mut s = Session::default();
|
||||
@@ -951,8 +954,8 @@ fn a_parameter_added_without_a_unit_takes_the_design_unit() {
|
||||
// One that refers to another parameter: its bare number is an inch too.
|
||||
close(added(&mut s, json!({"name": "more", "expression": "v + 1"})), "in", 533.4, 21.0);
|
||||
close(added(&mut s, json!({"name": "half", "expression": "v / 2"})), "in", 254.0, 10.0);
|
||||
// An angle stays in degrees, a unit-less count stays unit-less.
|
||||
close(added(&mut s, json!({"name": "turn", "expression": "30 deg"})), "deg", 30f64.to_radians(), 30.0);
|
||||
// An angle reports degrees like every other reply, a unit-less count stays unit-less.
|
||||
close(added(&mut s, json!({"name": "turn", "expression": "30 deg"})), "deg", 30.0, 30.0);
|
||||
close(added(&mut s, json!({"name": "n", "expression": "4", "unit": ""})), "", 4.0, 4.0);
|
||||
close(added(&mut s, json!({"name": "twice", "expression": "n * 2"})), "", 8.0, 8.0);
|
||||
// parameters.change and parameters.import make a new parameter the same way.
|
||||
@@ -1195,3 +1198,51 @@ fn a_configuration_row_that_shrinks_a_dimensioned_line_to_a_point_is_refused() {
|
||||
run(&mut s, "config.activate", json!({"row": "Default"}));
|
||||
assert!((far_x(&s, feature) - 30.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// #10747: `value` means millimetres (and degrees) in every reply — add, change and list —
|
||||
/// with the parameter's own unit in `display_value`. Reported numbers carry no float noise;
|
||||
/// stored values keep full precision.
|
||||
#[test]
|
||||
fn value_means_the_same_unit_in_every_reply() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "document.units", json!({"units": "in"}));
|
||||
let a = run(&mut s, "parameters.add", json!({"name": "L", "expression": "2"}));
|
||||
assert_eq!((a["value"].as_f64(), a["display_value"].as_f64()), (Some(50.8), Some(2.0)), "{a}");
|
||||
// The change reply is rounded too: 3 in is 76.2, not 76.19999999999999.
|
||||
let c = run(&mut s, "parameters.change", json!({"name": "L", "expression": "3"}));
|
||||
assert_eq!((c["value"].as_f64(), c["display_value"].as_f64()), (Some(76.2), Some(3.0)), "{c}");
|
||||
run(&mut s, "parameters.add", json!({"name": "d1", "expression": "L"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "d2", "expression": "1 in"}));
|
||||
let l = run(&mut s, "parameters.list", json!({}));
|
||||
// Every row's `value` is mm, whatever the parameter's own unit (#10747).
|
||||
for (name, mm) in [("L", 76.2), ("d1", 76.2), ("d2", 25.4)] {
|
||||
assert_eq!(row(&l, name)["value"], mm, "{name}: {l}");
|
||||
}
|
||||
assert_eq!(row(&l, "L")["display_value"], 3.0, "{l}");
|
||||
assert_eq!(row(&l, "d2")["display_value"], 1.0, "{l}");
|
||||
// An angle reports degrees like every other reply, without the noise.
|
||||
let a = run(&mut s, "parameters.add", json!({"name": "ang", "expression": "30 deg"}));
|
||||
assert_eq!((a["value"].as_f64(), a["display_value"].as_f64()), (Some(30.0), Some(30.0)), "{a}");
|
||||
// Only what is reported is rounded: the stored value keeps full precision — the raw
|
||||
// own-unit conversion still says 29.999999999999996.
|
||||
let (vals, _) = s.doc.param_values();
|
||||
assert_eq!(s.doc.param_display_value(&vals, "ang"), Some(29.999999999999996), "the stored angle keeps its bits");
|
||||
}
|
||||
|
||||
/// #10747: exports carry the numbers a person would read, not float noise. The `value`
|
||||
/// column keeps the number in the parameter's own unit, as it always did.
|
||||
#[test]
|
||||
fn exports_round_reported_numbers() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "ang", "expression": "30 deg"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "w", "expression": "1.2 in"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "s", "expression": "0.1 + 0.2"}));
|
||||
let csv = run(&mut s, "parameters.export", json!({"format": "csv"}))["text"].as_str().unwrap_or_default().to_string();
|
||||
assert!(csv.contains("ang,deg,30 deg,30,,false"), "{csv}");
|
||||
assert!(csv.contains("w,mm,1.2 in,30.48,,false"), "{csv}");
|
||||
assert!(csv.contains("s,mm,0.1 + 0.2,0.3,,false"), "{csv}");
|
||||
let json_text = run(&mut s, "parameters.export", json!({"format": "json"}))["text"].as_str().unwrap_or_default().to_string();
|
||||
let rows: Vec<Value> = serde_json::from_str(&json_text).unwrap_or_default();
|
||||
let vals: Vec<f64> = rows.iter().filter_map(|r| r["value"].as_f64()).collect();
|
||||
assert_eq!(vals, vec![30.0, 30.48, 0.3], "{json_text}");
|
||||
}
|
||||
|
||||
+121
-12
@@ -7,7 +7,9 @@ use solidcraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Point
|
||||
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, expr, num, req_expr, req_vec2, str_, string_list, vec2, vec3};
|
||||
use crate::{EngineError, Result, Session, SketchEntry};
|
||||
use crate::schema::sketch::{CancelResult, EditParams, EditResult, ExitParams, FinishResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session, SketchEntry, Snapshot};
|
||||
|
||||
#[path = "sketch_dimensioned.rs"]
|
||||
mod dimensioned;
|
||||
@@ -33,18 +35,28 @@ macro_rules! model_operand_doc {
|
||||
};
|
||||
}
|
||||
|
||||
const EDIT_CONTRACT: TypedContract<EditParams> = TypedContract::new();
|
||||
const EXIT_CONTRACT: TypedContract<ExitParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sketch")
|
||||
.params("plane: XY|XZ|YZ (sketch x, y along world +X, +Y on XY; +X, -Z on XZ; +Y, +Z on YZ) | construction plane name or id | {origin?: [x,y,z] numbers in mm, default [0,0,0] (not an expression), x_dir, y_dir} (in world coordinates unless frame says otherwise; a named plane is the active component's own) | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source (a flat face of a sheet metal body attaches to its panel instead, in the flat pattern's coordinates), fixed:true explicitly freezes the selected plane; offset?: expr, flip?: bool | \"x\" | \"y\" (not on a face: sketch the plane from its other side, so the normal and the default extrude direction are reversed; a half turn about the plane's own x or y axis, true picks the one keeping the sketch's y axis upward; offset still moves along the unflipped normal), frame?: world (default) | component (which coordinates an explicit plane is in; the reply's `frame` says which, with a `note` when the active component is moved or turned), project_edges?: bool, activate_part?: bool (default false; on a face of a body of another component than the active one: activate that component at the picked placement first, as component.activate would, and make the sketch there; it stays active afterwards and the reply's `activated` names it), name?"),
|
||||
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).noundo().icon("sketch").params("sketch: id or name"),
|
||||
CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
|
||||
CommandSpec::new("sketch.cancel", "Cancel Sketch", cancel_sketch)
|
||||
CommandSpec::new("sketch.edit", "Edit Sketch", crate::checked!("sketch.edit", edit_sketch))
|
||||
.noundo().icon("sketch")
|
||||
.contract(&EDIT_CONTRACT).output(output_schema_for::<EditResult>)
|
||||
.params("sketch: id or name; restore_marker?: nonnegative timeline position or null (end). When provided, temporarily roll to this sketch and restore that position on Finish or Cancel; Finish groups the edit into one Undo step. Omit to leave the timeline in place"),
|
||||
CommandSpec::new("sketch.finish", "Finish Sketch", crate::checked!("sketch.finish", finish_sketch))
|
||||
.at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo()
|
||||
.contract(&EXIT_CONTRACT).output(output_schema_for::<FinishResult>)
|
||||
.params("restore_marker?: bool (default true); false transfers the temporary marker directly to another editor without rebuilding the intervening timeline"),
|
||||
CommandSpec::new("sketch.cancel", "Cancel Sketch", crate::checked!("sketch.cancel", cancel_sketch))
|
||||
.at("SKETCH", "FINISH SKETCH")
|
||||
.icon("sketch_cancel")
|
||||
.enabled(in_sketch)
|
||||
.params("Leaves the sketch without keeping what changed since it was created or opened for editing: the design goes back to how it was then (a new sketch is removed), as one step that Undo brings back. Refused when the undo history no longer leads back to that point (use Undo instead)"),
|
||||
.contract(&EXIT_CONTRACT).output(output_schema_for::<CancelResult>)
|
||||
.params("Leaves the sketch without keeping what changed since it was created or opened for editing: the design goes back to how it was then (a new sketch is removed), as one step that Undo brings back. Refused when the undo history no longer leads back to that point (use Undo instead). restore_marker?: bool (default true); false transfers the temporary marker to another editor"),
|
||||
CommandSpec::new("sketch.line", "Line", draw_line)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("line")
|
||||
@@ -946,6 +958,16 @@ pub(super) fn create_sketch_with_plane(s: &mut Session, p: &Value, plane: PlaneR
|
||||
}
|
||||
|
||||
fn edit_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let restore_marker = p
|
||||
.get("restore_marker")
|
||||
.map(|v| {
|
||||
serde_json::from_value::<Option<usize>>(v.clone())
|
||||
.map_err(|_| bad("sketch.edit", "`restore_marker` must be a nonnegative timeline position or null"))
|
||||
})
|
||||
.transpose()?;
|
||||
if restore_marker.flatten().is_some_and(|m| m > s.doc.features.len()) {
|
||||
return Err(bad("sketch.edit", "`restore_marker` is past the end of the timeline"));
|
||||
}
|
||||
let key = match p.get("sketch") {
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::String(x)) => x.clone(),
|
||||
@@ -967,7 +989,16 @@ fn edit_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.active_occurrence_path = (!path.is_empty()).then_some(path);
|
||||
}
|
||||
if s.active_sketch != Some(id) || s.sketch_entry.as_ref().is_none_or(|entry| entry.sketch != id) {
|
||||
s.sketch_entry = Some(SketchEntry { sketch: id, ..entry_point(s) });
|
||||
s.sketch_entry = Some(SketchEntry { sketch: id, restore_marker, ..entry_point(s) });
|
||||
if restore_marker.is_some()
|
||||
&& let Some(index) = s.doc.feature_index(id)
|
||||
{
|
||||
let marker = (index + 1 < s.doc.features.len()).then_some(index + 1);
|
||||
if s.doc.marker != marker {
|
||||
s.doc_mut().marker = marker;
|
||||
}
|
||||
s.selection.clear();
|
||||
}
|
||||
}
|
||||
s.active_sketch = Some(id);
|
||||
s.revision += 1;
|
||||
@@ -976,15 +1007,32 @@ fn edit_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// The design and undo history as they are now, where a sketch is entered (its id set after).
|
||||
fn entry_point(s: &Session) -> SketchEntry {
|
||||
SketchEntry { sketch: 0, doc: s.doc.clone(), undo_len: s.undo.len(), undo_top: s.undo.last().map(|snap| snap.doc.clone()) }
|
||||
SketchEntry {
|
||||
sketch: 0,
|
||||
doc: s.doc.clone(),
|
||||
undo_len: s.undo.len(),
|
||||
undo_top: s.undo.last().map(|snap| snap.doc.clone()),
|
||||
restore_marker: None,
|
||||
pending_moves: s.pending_moves.clone(),
|
||||
visibility: s.visibility.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_on_exit(cmd: &str, p: &Value) -> Result<bool> {
|
||||
match p.get("restore_marker") {
|
||||
None => Ok(true),
|
||||
Some(Value::Bool(restore)) => Ok(*restore),
|
||||
_ => Err(bad(cmd, "`restore_marker` must be a boolean")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel Sketch (#3838): the design as it was when the open sketch was entered. The entry is
|
||||
/// used only while the undo history still runs through it: the same steps before it, and every
|
||||
/// step since taken in this sketch. Otherwise the steps since can't be told apart from others,
|
||||
/// and Undo is the way back.
|
||||
fn cancel_sketch(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn cancel_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.cancel";
|
||||
let restore = restore_on_exit(cmd, p)?;
|
||||
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
|
||||
let entry = s.sketch_entry.clone().filter(|entry| entry.sketch == id);
|
||||
let current = entry.filter(|entry| {
|
||||
@@ -1004,23 +1052,84 @@ fn cancel_sketch(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "the undo history no longer leads back to where this sketch was opened: use Undo to go back"));
|
||||
};
|
||||
let created = entry.doc.feature(id).is_none();
|
||||
let marker = if restore { entry.restore_marker.unwrap_or(entry.doc.marker) } else { s.doc.marker };
|
||||
// As Undo does: the session evaluates the design it is given.
|
||||
// Undo of Cancel reopens this edit, so retain its return position for a later Finish.
|
||||
s.sketch_entry = entry.restore_marker.map(|_| entry.clone());
|
||||
s.doc = entry.doc;
|
||||
let marker = marker.filter(|m| *m < s.doc.features.len());
|
||||
if s.doc.marker != marker {
|
||||
s.doc_mut().marker = marker;
|
||||
}
|
||||
s.active_sketch = None;
|
||||
s.sketch_entry = None;
|
||||
s.selection.clear();
|
||||
s.revision += 1;
|
||||
Ok(json!({"sketch": id, "removed": created}))
|
||||
}
|
||||
|
||||
fn finish_sketch(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn finish_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let restore = restore_on_exit("sketch.finish", p)?;
|
||||
let id = s.active_sketch.take();
|
||||
s.sketch_entry = None;
|
||||
s.revision += 1;
|
||||
let profiles = id.and_then(|i| s.model.state().sketch(i).map(|ss| ss.profiles.len())).unwrap_or(0);
|
||||
if let Some(entry) = s.sketch_entry.take().filter(|entry| Some(entry.sketch) == id)
|
||||
&& let Some(marker) = entry.restore_marker
|
||||
{
|
||||
finish_edit_history(s, &entry, marker);
|
||||
if restore {
|
||||
let marker = marker.filter(|m| *m < s.doc.features.len());
|
||||
if s.doc.marker != marker {
|
||||
s.doc_mut().marker = marker;
|
||||
}
|
||||
}
|
||||
s.selection.clear();
|
||||
}
|
||||
s.revision += 1;
|
||||
Ok(json!({"sketch": id, "profiles": profiles}))
|
||||
}
|
||||
|
||||
/// Fold only the steps still belonging to this edit. Its snapshots describe the complete
|
||||
/// design, never the temporary marker. The anchor may have moved when the undo limit trimmed
|
||||
/// older steps; if it is gone, only a stack entirely from this sketch can be folded safely.
|
||||
fn finish_edit_history(s: &mut Session, entry: &SketchEntry, marker: Option<usize>) {
|
||||
use std::sync::Arc;
|
||||
let start = match &entry.undo_top {
|
||||
Some(top) => s.undo.iter().rposition(|snap| Arc::ptr_eq(&snap.doc, top)).map(|i| i + 1),
|
||||
None => Some(0),
|
||||
}
|
||||
.or_else(|| (s.undo.len() == crate::MAX_UNDO && s.undo.iter().all(|snap| snap.active_sketch == Some(entry.sketch))).then_some(0));
|
||||
let Some(start) =
|
||||
start.filter(|&start| s.undo.get(start..).is_some_and(|steps| steps.iter().all(|snap| snap.active_sketch == Some(entry.sketch))))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut before = (*entry.doc).clone();
|
||||
before.marker = marker.filter(|m| *m < before.features.len());
|
||||
let mut after = (*s.doc).clone();
|
||||
after.marker = marker.filter(|m| *m < after.features.len());
|
||||
let changed = before != after || entry.pending_moves != s.pending_moves || entry.visibility != s.visibility;
|
||||
s.undo.truncate(start);
|
||||
if changed {
|
||||
s.undo.push(Snapshot {
|
||||
label: "Edit Sketch".into(),
|
||||
doc: Arc::new(before),
|
||||
active_sketch: None,
|
||||
pending_moves: entry.pending_moves.clone(),
|
||||
visibility: entry.visibility.clone(),
|
||||
});
|
||||
if s.undo.len() > crate::MAX_UNDO {
|
||||
s.undo.remove(0);
|
||||
}
|
||||
}
|
||||
// Redo of a step undone while editing must not reopen a finished temporary rollback.
|
||||
for snap in &mut s.redo {
|
||||
if snap.active_sketch == Some(entry.sketch) {
|
||||
let doc = Arc::make_mut(&mut snap.doc);
|
||||
doc.marker = marker.filter(|m| *m < doc.features.len());
|
||||
snap.active_sketch = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Drawing
|
||||
|
||||
|
||||
@@ -1809,6 +1809,55 @@ fn budget_limited_cursor_progress_requests_a_frame_with_the_same_target() {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Cancel Sketch (#3838)
|
||||
|
||||
fn sketch_below_a_solid() -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
let id = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5}));
|
||||
(s, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finishing_a_temporary_sketch_edit_at_the_undo_limit_keeps_the_original_design() {
|
||||
let (mut s, id) = sketch_below_a_solid();
|
||||
for i in 0..crate::MAX_UNDO {
|
||||
run(&mut s, "browser.visibility", json!({"items": ["Body1"], "visible": i % 2 != 0}));
|
||||
}
|
||||
assert_eq!(s.undo.len(), crate::MAX_UNDO);
|
||||
let before = s.doc.to_json();
|
||||
run(&mut s, "sketch.edit", json!({"sketch": id, "restore_marker": null}));
|
||||
for y in [20, 30] {
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, y], [10, y]]}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert!(s.undo.len() <= crate::MAX_UNDO);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.active_sketch, None);
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.marker, None);
|
||||
assert_eq!(s.doc.sketch(id).unwrap().curves.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undoing_cancel_then_finishing_restores_the_temporary_sketch_marker() {
|
||||
let (mut s, id) = sketch_below_a_solid();
|
||||
run(&mut s, "sketch.edit", json!({"sketch": id, "restore_marker": null}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 20], [10, 20]]}));
|
||||
run(&mut s, "sketch.cancel", json!({}));
|
||||
assert_eq!(s.doc.marker, None);
|
||||
assert_eq!(s.doc.sketch(id).unwrap().curves.len(), 4);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.active_sketch, Some(id));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(s.doc.marker, None);
|
||||
assert_eq!(s.active_sketch, None);
|
||||
assert_eq!(s.doc.sketch(id).unwrap().curves.len(), 5);
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_removes_a_new_sketch_with_its_dimensions_and_undo_brings_it_back() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -302,6 +302,10 @@ pub(crate) struct SketchEntry {
|
||||
pub(crate) doc: Arc<Document>,
|
||||
pub(crate) undo_len: usize,
|
||||
pub(crate) undo_top: Option<Arc<Document>>,
|
||||
/// A temporary editor rollback, restored by the engine even without a window.
|
||||
pub(crate) restore_marker: Option<Option<usize>>,
|
||||
pub(crate) pending_moves: std::collections::BTreeMap<u64, solidcraft_doc::Mat>,
|
||||
pub(crate) visibility: Visibility,
|
||||
}
|
||||
|
||||
/// A copy of a session that evaluates commands ahead of it ([`Session::ahead`]).
|
||||
@@ -1043,6 +1047,12 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The marker an active temporary sketch edit returns to. `Some(None)` means the end of
|
||||
/// the timeline; `None` means this sketch was opened without a temporary rollback.
|
||||
pub fn sketch_restore_marker(&self) -> Option<Option<usize>> {
|
||||
self.sketch_entry.as_ref().filter(|entry| Some(entry.sketch) == self.active_sketch).and_then(|entry| entry.restore_marker)
|
||||
}
|
||||
|
||||
/// Mutable document (copy-on-write).
|
||||
pub fn doc_mut(&mut self) -> &mut Document {
|
||||
self.forget_document_caches();
|
||||
@@ -2141,6 +2151,7 @@ impl Session {
|
||||
let mut copy = self.scratch();
|
||||
copy.undo.clone_from(&self.undo);
|
||||
copy.redo.clone_from(&self.redo);
|
||||
copy.sketch_entry.clone_from(&self.sketch_entry);
|
||||
copy.model.keep(Some(Arc::default()));
|
||||
Ahead(copy)
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ pub mod render_lights;
|
||||
pub mod render_ui;
|
||||
pub mod section;
|
||||
pub mod sheet;
|
||||
pub mod sketch;
|
||||
pub mod sketch_constraints;
|
||||
pub mod sketch_create;
|
||||
pub mod sketch_equation;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Contracts and result types for sketch editing and completion in `cmd/sketch.rs`.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::types::{IdOrName, present};
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct EditParams {
|
||||
pub sketch: IdOrName,
|
||||
/// When present, temporarily roll to this sketch and restore this position when the edit
|
||||
/// ends. Null means the end of the timeline; omission leaves the timeline in place.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<usize>")]
|
||||
pub restore_marker: Option<Option<usize>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ExitParams {
|
||||
/// Restore the temporary editor marker (default true). False transfers it directly to
|
||||
/// another editor without rebuilding the intermediate timeline.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool")]
|
||||
pub restore_marker: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct EditResult {
|
||||
pub sketch: u64,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct FinishResult {
|
||||
pub sketch: Option<u64>,
|
||||
pub profiles: usize,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CancelResult {
|
||||
pub sketch: u64,
|
||||
pub removed: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
@@ -1131,3 +1131,38 @@ fn plane_names_match_check_plane_name() {
|
||||
}
|
||||
assert!(parse::<PlaneName>(json!(5)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_edit_contracts_validate_markers_before_changing_the_session() {
|
||||
use crate::schema::sketch::{CancelResult, EditParams, EditResult, ExitParams, FinishResult};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
assert_keys(input_schema_for::<EditParams>(), &["restore_marker", "sketch"]);
|
||||
assert_keys(input_schema_for::<ExitParams>(), &["restore_marker"]);
|
||||
let mut s = crate::Session::default();
|
||||
let run = |s: &mut crate::Session, id: &str, p: serde_json::Value| s.execute(id, &p).unwrap();
|
||||
let id = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5}));
|
||||
s.param_check = ParamCheck::Strict;
|
||||
let before = s.doc.to_json();
|
||||
for marker in [json!(-1), json!(3), json!(1.5), json!(true), json!([])] {
|
||||
assert!(s.execute("sketch.edit", &json!({"sketch": id, "restore_marker": marker})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.active_sketch, None);
|
||||
}
|
||||
for exit in ["sketch.finish", "sketch.cancel"] {
|
||||
let r = run(&mut s, "sketch.edit", json!({"sketch": id, "restore_marker": null}));
|
||||
let schema = output_schema_for::<EditResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r));
|
||||
for params in [json!({"restore_marker": "yes"}), json!({"unknown": true})] {
|
||||
assert!(s.execute(exit, ¶ms).is_err());
|
||||
assert_eq!(s.active_sketch, Some(id));
|
||||
assert_eq!(s.doc.marker, Some(1));
|
||||
}
|
||||
let r = run(&mut s, exit, json!({}));
|
||||
let schema = if exit == "sketch.finish" { output_schema_for::<FinishResult>() } else { output_schema_for::<CancelResult>() };
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +237,6 @@ sketch.auto_project
|
||||
sketch.blend_curve
|
||||
sketch.break
|
||||
sketch.break_link
|
||||
sketch.cancel
|
||||
sketch.centerline
|
||||
sketch.chain
|
||||
sketch.chamfer.distance_angle
|
||||
@@ -273,13 +272,11 @@ sketch.dimension_text
|
||||
sketch.drag
|
||||
sketch.drag_end
|
||||
sketch.draw.dimensioned
|
||||
sketch.edit
|
||||
sketch.edit_text
|
||||
sketch.ellipse
|
||||
sketch.export_dxf
|
||||
sketch.extend
|
||||
sketch.fillet
|
||||
sketch.finish
|
||||
sketch.finish_auto_constrain
|
||||
sketch.fit_curves_to_section
|
||||
sketch.glyphs
|
||||
|
||||
@@ -133,6 +133,19 @@ fn replaced_by_a_later_one(id: &str) -> bool {
|
||||
}
|
||||
|
||||
impl SolveApp {
|
||||
/// A direct exit or editor switch supersedes any queued sketch exit. A late worker must
|
||||
/// never finish a different sketch that was opened while it computed.
|
||||
pub(crate) fn take_sketch_exit(&mut self) -> Option<String> {
|
||||
let is_exit = |id: &str| matches!(id, "sketch.finish" | "sketch.cancel");
|
||||
let pending = self.deferred.as_ref().filter(|job| is_exit(&job.command.0)).map(|job| job.command.0.clone());
|
||||
if pending.is_some() {
|
||||
self.deferred = None;
|
||||
}
|
||||
let queued = self.deferred_queue.iter().find(|(id, _)| is_exit(id)).map(|(id, _)| id.clone());
|
||||
self.deferred_queue.retain(|(id, _)| !is_exit(id));
|
||||
pending.or(queued)
|
||||
}
|
||||
|
||||
/// Run a command that may recompute much of the history, off the window thread when it takes
|
||||
/// a while (see the module notes). `Ok(Null)` when it is left computing or waiting its turn:
|
||||
/// its outcome then shows as any command's does (an error in the status line).
|
||||
|
||||
@@ -41,7 +41,6 @@ pub struct DocSlot {
|
||||
pub(crate) autosave: Option<solidcraft_engine::recovery::Autosaver>,
|
||||
pub(crate) big_operation: bool,
|
||||
cam: Camera,
|
||||
pre_sketch_marker: Option<Option<usize>>,
|
||||
pub(crate) pending_feature_edit: Option<crate::feature_edit::PendingFeatureEdit>,
|
||||
pre_sketch_cam: Option<crate::PreSketchView>,
|
||||
pre_sketch_component: Option<crate::PreSketchComponent>,
|
||||
@@ -135,7 +134,6 @@ impl DocSlot {
|
||||
autosave: None,
|
||||
big_operation: false,
|
||||
cam: Camera::default(),
|
||||
pre_sketch_marker: None,
|
||||
pending_feature_edit: None,
|
||||
pre_sketch_cam: None,
|
||||
pre_sketch_component: None,
|
||||
@@ -241,7 +239,6 @@ fn swap_in(app: &mut SolveApp, mut slot: DocSlot) -> DocSlot {
|
||||
autosave: app.autosave.as_mut().and_then(|a| std::mem::replace(&mut a.active, slot.autosave)),
|
||||
big_operation: std::mem::replace(&mut app.big_operation, slot.big_operation),
|
||||
cam: std::mem::replace(&mut app.cam, slot.cam),
|
||||
pre_sketch_marker: std::mem::replace(&mut app.pre_sketch_marker, slot.pre_sketch_marker),
|
||||
pending_feature_edit: std::mem::replace(&mut app.pending_feature_edit, slot.pending_feature_edit),
|
||||
pre_sketch_cam: std::mem::replace(&mut app.pre_sketch_cam, slot.pre_sketch_cam),
|
||||
pre_sketch_component: std::mem::replace(&mut app.pre_sketch_component, slot.pre_sketch_component),
|
||||
|
||||
@@ -316,6 +316,47 @@ fn switching_sketches_keeps_the_original_marker_with_background_rebuilds() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finishing_a_sketch_in_the_background_restores_the_design_and_its_undo_snapshot() {
|
||||
let (mut h, sketches) = sketches_with_later_bodies();
|
||||
let before = h.app.session.doc.to_json();
|
||||
h.app.edit_sketch(sketches[0]);
|
||||
h.app.run("sketch.line", json!({"points": [[0, 2], [10, 2]]})).unwrap();
|
||||
h.app.finish_sketch();
|
||||
assert!(h.app.deferred.is_some());
|
||||
until_settled(&mut h);
|
||||
assert_eq!(marker(&h), None);
|
||||
assert_eq!(h.app.session.active_sketch, None);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.active_sketch, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pending_sketch_exit_cannot_close_the_next_editor() {
|
||||
for cancel in [false, true] {
|
||||
let (mut h, sketches) = sketches_with_later_bodies();
|
||||
let curves = h.app.session.doc.sketch(sketches[0]).unwrap().curves.len();
|
||||
h.app.edit_sketch(sketches[0]);
|
||||
h.app.run("sketch.line", json!({"points": [[0, 2], [10, 2]]})).unwrap();
|
||||
if cancel {
|
||||
h.app.cancel_sketch();
|
||||
} else {
|
||||
h.app.finish_sketch();
|
||||
}
|
||||
assert!(h.app.deferred.is_some());
|
||||
h.app.edit_sketch(sketches[1]);
|
||||
until_settled(&mut h);
|
||||
assert_eq!(h.app.session.active_sketch, Some(sketches[1]));
|
||||
assert_eq!(marker(&h), Some(3));
|
||||
assert_eq!(h.app.session.doc.sketch(sketches[0]).unwrap().curves.len(), curves + usize::from(!cancel));
|
||||
h.app.finish_sketch();
|
||||
until_settled(&mut h);
|
||||
assert_eq!(h.app.session.active_sketch, None);
|
||||
assert_eq!(marker(&h), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_from_a_sketch_to_a_feature_keeps_the_original_marker_with_background_rebuilds() {
|
||||
let (mut h, sketches) = sketches_with_later_bodies();
|
||||
|
||||
+34
-42
@@ -522,9 +522,6 @@ pub struct SolveApp {
|
||||
pub cam_anim: Option<CameraAnim>,
|
||||
/// Seconds since start, as of this frame.
|
||||
pub now: f64,
|
||||
/// The history marker before Edit Sketch rolled the timeline to the sketch; Finish Sketch
|
||||
/// restores it.
|
||||
pub pre_sketch_marker: Option<Option<usize>>,
|
||||
/// The views around opening the open sketch. Leaving it goes back to the earlier one as the
|
||||
/// "When a sketch ends" preference says (#7038).
|
||||
pub pre_sketch_cam: Option<PreSketchView>,
|
||||
@@ -793,7 +790,6 @@ impl SolveApp {
|
||||
cam: Camera::default(),
|
||||
cam_anim: None,
|
||||
now: 0.0,
|
||||
pre_sketch_marker: None,
|
||||
pre_sketch_cam: None,
|
||||
pre_extrude_cam: None,
|
||||
pre_sketch_component: None,
|
||||
@@ -1205,14 +1201,12 @@ impl SolveApp {
|
||||
// Render focus already applies its semantic selection after clearing object picks.
|
||||
// A tab switch brings in the other design's own Render selection.
|
||||
if done.is_ok() && !matches!(id, "view.render_select" | "view.next_tab" | "view.previous_tab") && selection_changed {
|
||||
if self.session.selection.is_empty() && render_workspace::active(self) {
|
||||
render_workspace::selection_editor(self, &render_workspace::RenderSelection::Nothing);
|
||||
}
|
||||
self.render_workspace.view.selection = if self.session.selection.is_empty() {
|
||||
render_workspace::RenderSelection::Nothing
|
||||
let selection = render_workspace::object_selection(self);
|
||||
if render_workspace::active(self) {
|
||||
render_workspace::selection_editor(self, &selection);
|
||||
} else {
|
||||
render_workspace::RenderSelection::Object(self.session.selection.clone())
|
||||
};
|
||||
self.render_workspace.view.selection = selection;
|
||||
}
|
||||
}
|
||||
if let Ok(v) = &done {
|
||||
if matches!(id, "render.lights.solo" | "render.lights.mute") {
|
||||
@@ -1236,6 +1230,7 @@ impl SolveApp {
|
||||
};
|
||||
if let Ok(v) = &r {
|
||||
match id {
|
||||
"sketch.finish" | "sketch.cancel" => self.sketch_closed(v.get("sketch").and_then(Value::as_u64)),
|
||||
"view.fit" | "view.home" => self.frame_view(id == "view.home"),
|
||||
"view.tool" => {
|
||||
self.viewport.nav = match v.get("tool").and_then(Value::as_str) {
|
||||
@@ -1288,23 +1283,16 @@ impl SolveApp {
|
||||
let selected = match v.get("kind").and_then(Value::as_str) {
|
||||
Some("environment") => RenderSelection::Environment,
|
||||
Some("wear") => RenderSelection::Wear,
|
||||
Some("lighting") => {
|
||||
render_workspace::select_lighting(self);
|
||||
RenderSelection::Lighting
|
||||
}
|
||||
Some("lighting") => RenderSelection::Lighting,
|
||||
Some("output") => RenderSelection::Output,
|
||||
Some("sampling") => RenderSelection::Sampling,
|
||||
Some("effects") => RenderSelection::Effects,
|
||||
Some("check") => RenderSelection::Check,
|
||||
Some("light") => {
|
||||
render_workspace::select_lighting(self);
|
||||
RenderSelection::Light(v.get("id").and_then(Value::as_str).unwrap_or_default().into())
|
||||
}
|
||||
Some("light") => RenderSelection::Light(v.get("id").and_then(Value::as_str).unwrap_or_default().into()),
|
||||
Some("shot") => RenderSelection::Shot(v.get("id").and_then(Value::as_str).unwrap_or_default().into()),
|
||||
_ => RenderSelection::Nothing,
|
||||
};
|
||||
render_workspace::selection_editor(self, &selected);
|
||||
self.render_workspace.view.selection = selected;
|
||||
}
|
||||
if id == "view.layout" {
|
||||
let layout =
|
||||
@@ -2168,22 +2156,19 @@ impl SolveApp {
|
||||
if self.session.active_sketch == Some(id) {
|
||||
return;
|
||||
}
|
||||
let Some(idx) = self.session.doc.feature_index(id) else { return };
|
||||
if self.session.doc.feature_index(id).is_none() {
|
||||
return;
|
||||
}
|
||||
let marker = self.finish_sketch_for_edit();
|
||||
let before = self.settled_cam();
|
||||
let context = (self.session.active_component, self.session.active_occurrence_path.clone());
|
||||
self.tool = None;
|
||||
self.dialog = None;
|
||||
activate(self);
|
||||
let rolled = self.session.doc.marker != Some(idx + 1) && self.run("timeline.roll_to", json!({ "position": idx + 1 })).is_ok();
|
||||
if self.run("sketch.edit", json!({ "sketch": id })).is_err() {
|
||||
if rolled || marker != self.session.doc.marker {
|
||||
self.restore_marker(marker);
|
||||
}
|
||||
if self.run("sketch.edit", json!({ "sketch": id, "restore_marker": marker })).is_err() {
|
||||
self.restore_component(context.0, context.1);
|
||||
return;
|
||||
}
|
||||
self.pre_sketch_marker = (rolled || marker != self.session.doc.marker).then_some(marker);
|
||||
// `sketch.edit` itself activates the component a face-attached sketch was drawn in.
|
||||
let opened_in = self.session.active_component;
|
||||
let moved = (opened_in, &self.session.active_occurrence_path) != (context.0, &context.1);
|
||||
@@ -2252,9 +2237,11 @@ impl SolveApp {
|
||||
.pending_feature_edit
|
||||
.take()
|
||||
.map(|pending| pending.marker)
|
||||
.unwrap_or_else(|| self.pre_sketch_marker.take().unwrap_or(self.session.doc.marker));
|
||||
.unwrap_or_else(|| self.session.sketch_restore_marker().unwrap_or(self.session.doc.marker));
|
||||
if self.session.active_sketch.is_some() {
|
||||
self.finish_sketch();
|
||||
let command = self.take_sketch_exit().unwrap_or_else(|| "sketch.finish".into());
|
||||
self.prepare_sketch_exit();
|
||||
let _ = self.run(&command, json!({"restore_marker": false}));
|
||||
}
|
||||
marker
|
||||
}
|
||||
@@ -2273,19 +2260,30 @@ impl SolveApp {
|
||||
}
|
||||
|
||||
fn leave_sketch(&mut self, command: &str) {
|
||||
self.prepare_sketch_exit();
|
||||
if self.session.sketch_restore_marker().is_some() {
|
||||
// Finishing now restores the marker in the engine: evaluate the complete
|
||||
// command ahead so rebuilding later features stays off the window thread.
|
||||
let _ = self.run_heavy(command, json!({}));
|
||||
} else {
|
||||
let _ = self.run(command, json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_sketch_exit(&mut self) {
|
||||
sketch_text_move::cancel_drag(self);
|
||||
if sketch_text::editing(self) || self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::TextMove(_))) {
|
||||
dialogs::cancel(self);
|
||||
}
|
||||
dim_view::cancel();
|
||||
self.tool = None;
|
||||
let open = self.session.active_sketch;
|
||||
if self.run(command, json!({})).is_err() && command != "sketch.finish" {
|
||||
return;
|
||||
}
|
||||
if let Some(marker) = self.pre_sketch_marker.take() {
|
||||
self.restore_marker(marker);
|
||||
}
|
||||
}
|
||||
|
||||
/// Successful UI and programmatic exits share the same window cleanup.
|
||||
fn sketch_closed(&mut self, open: Option<u64>) {
|
||||
self.take_sketch_exit();
|
||||
self.tool = None;
|
||||
dim_view::cancel();
|
||||
// Unless another component was activated meanwhile: that one stays.
|
||||
if let Some(pre) = self.pre_sketch_component.take().filter(|pre| Some(pre.sketch) == open && pre.opened_in == self.session.active_component) {
|
||||
self.restore_component(pre.component, pre.path);
|
||||
@@ -2298,12 +2296,6 @@ impl SolveApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_marker(&mut self, marker: Option<usize>) {
|
||||
// Rolling forward past the features after the sketch recomputes them (an edited sketch
|
||||
// under a text cut): off the window thread when it takes a while.
|
||||
let _ = self.run_heavy("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({})));
|
||||
}
|
||||
|
||||
/// Visibility in the editor's scene: hidden face-profile sources are temporarily shown
|
||||
/// without changing the session's visibility or either history stack.
|
||||
pub(crate) fn body_visible(&self, name: &str) -> bool {
|
||||
|
||||
@@ -151,7 +151,9 @@ fn rows(app: &mut SolveApp) -> Vec<Row> {
|
||||
Row {
|
||||
expression: s(r, "expression"),
|
||||
unit: s(r, "unit"),
|
||||
value: r.get("value").and_then(Value::as_f64),
|
||||
// The dialog shows the parameter's own unit, so it reads `display_value`
|
||||
// (`value` is mm / degrees in every reply, #10747).
|
||||
value: r.get("display_value").and_then(Value::as_f64).or_else(|| r.get("value").and_then(Value::as_f64)),
|
||||
error: r.get("error").and_then(Value::as_str).map(str::to_string),
|
||||
comment: s(r, "comment"),
|
||||
favorite: r.get("favorite").and_then(Value::as_bool).unwrap_or(false),
|
||||
|
||||
@@ -221,7 +221,7 @@ fn ended(response: &egui::Response, ui: &egui::Ui) -> bool {
|
||||
|| response.changed() && !response.has_focus() && !ui.input(|i| i.pointer.any_down()) && !ui.ctx().egui_wants_keyboard_input()
|
||||
}
|
||||
|
||||
pub(super) fn scope_label(app: &SolveApp) -> &str {
|
||||
pub(in crate::render_workspace) fn scope_label(app: &SolveApp) -> &str {
|
||||
app.tr(match app.session.selection.last() {
|
||||
Some(Sel::Occurrence { .. }) => "render.pick_component",
|
||||
Some(Sel::Face { .. }) => "render.pick_face",
|
||||
@@ -254,7 +254,7 @@ pub(super) fn reset(app: &mut SolveApp) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn remove(app: &mut SolveApp) {
|
||||
pub(in crate::render_workspace) fn remove(app: &mut SolveApp) {
|
||||
let view = &app.render_workspace.view.inspector.selection_appearance;
|
||||
let selected = targets(app, view.inspected.unwrap_or(mode_step(view.mode)));
|
||||
if let Some(look) = view.draft.clone() {
|
||||
@@ -265,7 +265,7 @@ pub(super) fn remove(app: &mut SolveApp) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn footer(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
pub(in crate::render_workspace) fn footer(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
let enabled = !app.session.selection.is_empty();
|
||||
if ui_kit::icon_label_action(ui, app.tr("render.inspector_save_library"), Some("plus"), enabled, false)
|
||||
@@ -283,7 +283,7 @@ pub(super) fn footer(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
|
||||
/// Returns false only when the explicit-target fallback should be shown.
|
||||
pub(super) fn controls(app: &mut SolveApp, ui: &mut egui::Ui) -> bool {
|
||||
pub(in crate::render_workspace) fn controls(app: &mut SolveApp, ui: &mut egui::Ui) -> bool {
|
||||
if app.session.selection.is_empty() {
|
||||
app.render_workspace.view.inspector.selection_appearance = View::default();
|
||||
return false;
|
||||
|
||||
@@ -561,61 +561,6 @@ pub(crate) fn selection_bar(app: &mut SolveApp, ui: &mut egui::Ui, free: egui::R
|
||||
}
|
||||
|
||||
/// Existing appearance commands exposed alongside the shared inheritance widget.
|
||||
pub(crate) fn selection_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let Some(sel) = app.session.selection.last().cloned() else { return };
|
||||
let Ok(target) = chain_target(app, &sel) else { return };
|
||||
if let Ok(chain) = app.session.execute("appearance.chain", &json!({"target":target})) {
|
||||
let steps = chain["steps"].as_array().map(Vec::as_slice).unwrap_or_default();
|
||||
let looks =
|
||||
std::array::from_fn(|index| steps.get(index).and_then(|s| s.get("look")).filter(|l| !l.is_null()).map(super::library::tile_sphere));
|
||||
let active = match chain["active"].as_str() {
|
||||
Some("face") => ui_kit::LookStep::Face,
|
||||
Some("body") => ui_kit::LookStep::Body,
|
||||
Some("component") => ui_kit::LookStep::Component,
|
||||
_ => ui_kit::LookStep::Material,
|
||||
};
|
||||
if ui_kit::look_chain(
|
||||
ui,
|
||||
"render:look_chain",
|
||||
[app.tr("render.pick_face"), app.tr("render.pick_body"), app.tr("render.pick_component"), app.tr("render.look_material")],
|
||||
looks,
|
||||
active,
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
app.start("appearance.assign");
|
||||
}
|
||||
}
|
||||
ui_kit::dropdown_menu(ui, "render_selection_actions", app.tr("cmd.appearance_assign"), |ui| {
|
||||
for (id, label) in
|
||||
[("appearance.assign", "cmd.appearance_assign"), ("appearance.replace", "render.replace_all"), ("appearance.copy", "cmd.appearance_copy")]
|
||||
{
|
||||
if let Some(command) = solidcraft_engine::find_command(id) {
|
||||
let info = command.info(&app.session);
|
||||
if ui_kit::command_menu_button(
|
||||
ui,
|
||||
id,
|
||||
app.tr(label),
|
||||
command.icon,
|
||||
&crate::keymap::display_for(app, id).unwrap_or_default(),
|
||||
info.enabled,
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
app.start(id);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
if ui_kit::command_menu_button(ui, "render_remove_override", app.tr("render.remove_override"), "delete", "", true).clicked() {
|
||||
if let Ok(targets) = targets(app, &app.session.selection) {
|
||||
let _ = app.run("appearance.assign", targets);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn replacement_dialog(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(pending) = app.render_workspace.view.assign.replacement.clone() else { return };
|
||||
if pending.revision != app.session.revision || !pending.document.ptr_eq(&std::sync::Arc::downgrade(&app.session.doc)) {
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! One floating editor for the selected Render item.
|
||||
use super::{RenderSelection, environment, inspector, library, light, setups, shot, wear};
|
||||
use crate::{SolveApp, ui_kit};
|
||||
use inspector::selection_appearance;
|
||||
use serde_json::json;
|
||||
|
||||
pub(crate) fn panel(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
// Reviews belong to the workspace, including when their originating editor closes.
|
||||
inspector::confirmation(app, ui.ctx());
|
||||
library::review::dialog(app, ui.ctx());
|
||||
library::review::confirmation(app, ui.ctx());
|
||||
if app.render_workspace.view.inspector.hidden || app.render_workspace.view.selection != RenderSelection::Environment {
|
||||
environment::stage_confirmation(app, ui.ctx());
|
||||
}
|
||||
if app.render_workspace.view.inspector.hidden {
|
||||
crate::shell::register_panel(app, "render:inspector", None, None);
|
||||
return;
|
||||
}
|
||||
setups::refresh_dirty(app);
|
||||
setups::sync_revision(app);
|
||||
let selection = app.render_workspace.view.selection.clone();
|
||||
let kind = kind(&selection);
|
||||
let appearance = matches!(selection, RenderSelection::Object(_));
|
||||
let (title, icon) = title(app, &selection);
|
||||
let scope = selection_appearance::scope_label(app).to_owned();
|
||||
let bounds = crate::shell::panel_bounds(app, ui.available_rect_before_wrap());
|
||||
let mut side = app.preferences.render_inspector_side.clone();
|
||||
let mut width = app.preferences.render_inspector_width;
|
||||
let action = std::cell::Cell::new(None);
|
||||
let content = ui_kit::Panel::workspace_with_header(
|
||||
ui,
|
||||
ui_kit::WorkspaceOptions {
|
||||
id: "render:inspector",
|
||||
title: &title,
|
||||
icon,
|
||||
bounds,
|
||||
fill_height: true,
|
||||
compact: crate::render_ui::compact(app, bounds.width()),
|
||||
editor: appearance,
|
||||
enabled: true,
|
||||
header_actions: None,
|
||||
},
|
||||
&mut side,
|
||||
&mut width,
|
||||
false,
|
||||
|ui, options| {
|
||||
if appearance {
|
||||
action.set(ui_kit::inspector_header(ui, options.icon, options.title, &scope));
|
||||
} else {
|
||||
ui_kit::panel::header(ui, options.icon, options.title, || action.set(Some(ui_kit::InspectorAction::Close)));
|
||||
}
|
||||
},
|
||||
|ui| {
|
||||
if appearance {
|
||||
ui_kit::Panel::editor_footer(ui, "render_appearance_footer", |ui| selection_appearance::footer(app, ui));
|
||||
} else if selection == RenderSelection::Output {
|
||||
ui_kit::Panel::pinned_footer(ui, "render_output_footer", 1, |ui| {
|
||||
output_actions(app, ui);
|
||||
crate::scenario::publish_rect("render:output-footer", ui.min_rect());
|
||||
});
|
||||
}
|
||||
egui::ScrollArea::vertical()
|
||||
.max_width(ui.available_width())
|
||||
.id_salt(("render_inspector", kind, app.docs.active))
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
let before = setups::draft(app).ok();
|
||||
let rollback = app.render_workspace.view.clone();
|
||||
let response = ui.scope(|ui| match &selection {
|
||||
RenderSelection::Nothing => inspector::scene_controls(app, ui),
|
||||
RenderSelection::Object(_) => {
|
||||
selection_appearance::controls(app, ui);
|
||||
}
|
||||
RenderSelection::Wear => wear::controls(app, ui),
|
||||
RenderSelection::Environment => environment::editor(app, ui),
|
||||
RenderSelection::Lighting => light::lighting(app, ui),
|
||||
RenderSelection::Light(id) => light::editor(app, ui, id),
|
||||
RenderSelection::Shot(name) => shot::editor(app, ui, name),
|
||||
RenderSelection::Output => inspector::output_editor(app, ui),
|
||||
RenderSelection::Sampling => inspector::sampling_editor(app, ui),
|
||||
RenderSelection::Effects => super::effects::output_controls(app, ui),
|
||||
RenderSelection::Check => inspector::check_editor(app, ui),
|
||||
});
|
||||
if matches!(selection, RenderSelection::Output | RenderSelection::Sampling | RenderSelection::Effects | RenderSelection::Wear)
|
||||
&& let Some(before) = before
|
||||
&& let Err(error) = setups::persist_editor(app, &before)
|
||||
{
|
||||
// Refuse the numerical edit without undoing transient editor navigation.
|
||||
let inspector = std::mem::take(&mut app.render_workspace.view.inspector);
|
||||
app.render_workspace.view = rollback;
|
||||
app.render_workspace.view.inspector = inspector;
|
||||
app.set_status(error, true);
|
||||
}
|
||||
crate::scenario::publish_rect(&format!("render:editor:{kind}"), response.response.rect);
|
||||
});
|
||||
},
|
||||
);
|
||||
match action.get() {
|
||||
Some(ui_kit::InspectorAction::Close) => {
|
||||
let _ = app.run("view.render_inspector", json!({"visible":false}));
|
||||
}
|
||||
Some(ui_kit::InspectorAction::Remove) => selection_appearance::remove(app),
|
||||
None => {}
|
||||
}
|
||||
app.preferences.render_inspector_width = width;
|
||||
let requested = content
|
||||
.as_ref()
|
||||
.and_then(|c| c.inner.1)
|
||||
.map(str::to_string)
|
||||
.or_else(|| (app.preferences.render_inspector_side != side).then(|| side.clone()));
|
||||
if let Some(side) = requested {
|
||||
let _ = app.run("view.render_dock", json!({"panel":"inspector","side":side}));
|
||||
}
|
||||
let rect = content.map(|content| content.response.rect);
|
||||
crate::shell::register_panel(app, "render:inspector", rect, Some(if side == "left" { ui_kit::Edge::Left } else { ui_kit::Edge::Right }));
|
||||
if let Some(rect) = rect {
|
||||
crate::scenario::publish_rect("render:inspector", rect);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn output_actions(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (id, label, target) in [
|
||||
("render.start", app.tr("cmd.render_start").to_owned(), "render:output:render"),
|
||||
(
|
||||
"render.batch",
|
||||
crate::i18n::format("render.batch_all_shots", &[("count", &app.session.doc.render.setups.len().to_string())]),
|
||||
"render:output:all-shots",
|
||||
),
|
||||
] {
|
||||
let ready = super::ribbon::action_ready(app, id);
|
||||
let response = ui_kit::icon_label_action(ui, &label, Some("play"), ready.is_ok(), id == "render.start");
|
||||
let response = if let Err(reason) = ready { response.on_disabled_hover_text(reason) } else { response };
|
||||
crate::scenario::publish_target(target, response.rect);
|
||||
if response.clicked() {
|
||||
app.start(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn kind(selection: &RenderSelection) -> &'static str {
|
||||
match selection {
|
||||
RenderSelection::Nothing => "scene",
|
||||
RenderSelection::Object(_) => "appearance",
|
||||
RenderSelection::Wear => "wear",
|
||||
RenderSelection::Environment => "environment",
|
||||
RenderSelection::Lighting => "lighting",
|
||||
RenderSelection::Light(_) => "light",
|
||||
RenderSelection::Shot(_) => "shot",
|
||||
RenderSelection::Output => "output",
|
||||
RenderSelection::Sampling => "sampling",
|
||||
RenderSelection::Effects => "effects",
|
||||
RenderSelection::Check => "check",
|
||||
}
|
||||
}
|
||||
|
||||
fn title(app: &SolveApp, selection: &RenderSelection) -> (String, &'static str) {
|
||||
let (key, icon) = match selection {
|
||||
RenderSelection::Nothing => ("render.page_scene", "render_scene"),
|
||||
RenderSelection::Object(items) => {
|
||||
let name = items
|
||||
.last()
|
||||
.and_then(|item| match item {
|
||||
solidcraft_engine::Sel::Body { name } | solidcraft_engine::Sel::Face { body: name, .. } => Some(name.clone()),
|
||||
solidcraft_engine::Sel::Occurrence { path } => app.session.occurrence_placement(path).ok().map(|p| p.name),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| app.tr("render.page_appearance").to_owned());
|
||||
return (name, "body");
|
||||
}
|
||||
RenderSelection::Shot(name) => return (name.clone(), "camera"),
|
||||
RenderSelection::Light(id) => {
|
||||
let name = light::rows(app)
|
||||
.ok()
|
||||
.and_then(|rows| rows.into_iter().find(|row| &row.id == id))
|
||||
.map(|row| light::name(app, &row))
|
||||
.unwrap_or_else(|| app.tr("render.tree_lights").to_owned());
|
||||
return (name, "render_scene");
|
||||
}
|
||||
RenderSelection::Wear => ("render.wear_title", "appearance"),
|
||||
RenderSelection::Environment => ("render.page_environment", "environment_map"),
|
||||
RenderSelection::Lighting => ("render.page_lighting", "render_scene"),
|
||||
RenderSelection::Output => ("render.page_output", "export"),
|
||||
RenderSelection::Sampling => ("render.children.sampling", "settings"),
|
||||
RenderSelection::Effects => ("render.page_effects", "camera"),
|
||||
RenderSelection::Check => ("render.children.check", "inspect"),
|
||||
};
|
||||
(app.tr(key).to_owned(), icon)
|
||||
}
|
||||
@@ -105,13 +105,13 @@ fn section(app: &mut SolveApp, ui: &mut egui::Ui, key: &str) -> bool {
|
||||
crate::scenario::publish_target(&id, rect);
|
||||
}
|
||||
if row.actions.iter().any(|r| r.clicked()) {
|
||||
// The existing Shots manager owns naming and saving. This navigation never saves.
|
||||
// Shot naming and saving use the existing Save shot prompt.
|
||||
if key == "lights" {
|
||||
if let Err(error) = super::light::edit(app, "render.lights.add", None, Some(json!({"kind":"area"}))) {
|
||||
app.set_status(error, true);
|
||||
}
|
||||
} else {
|
||||
app.start("render.settings.get");
|
||||
app.start("render.settings.set");
|
||||
}
|
||||
}
|
||||
if row.clicked && key == "lights" {
|
||||
@@ -200,7 +200,6 @@ pub(crate) fn sections(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
if row.clicked {
|
||||
select(app, "environment", None);
|
||||
app.render_workspace.view.inspector.page = inspector::Page::Environment;
|
||||
}
|
||||
if section(app, ui, "lights") {
|
||||
for (index, light) in super::light::rows(app).unwrap_or_default().iter().enumerate() {
|
||||
@@ -243,7 +242,6 @@ pub(crate) fn sections(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
if row.clicked {
|
||||
select(app, "light", Some(id));
|
||||
app.render_workspace.view.inspector.page = inspector::Page::Lighting;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,10 @@ mod hdr;
|
||||
mod effects;
|
||||
#[path = "render_workspace_environment.rs"]
|
||||
mod environment;
|
||||
#[path = "render_workspace_inspector.rs"]
|
||||
#[path = "render_workspace_output.rs"]
|
||||
mod inspector;
|
||||
#[path = "render_ui/inspector.rs"]
|
||||
mod inspector_panel;
|
||||
|
||||
#[path = "render_workspace_library.rs"]
|
||||
mod library;
|
||||
@@ -304,14 +306,6 @@ impl Stage {
|
||||
Self::Output => "render.stage_output",
|
||||
}
|
||||
}
|
||||
fn page(self) -> inspector::Page {
|
||||
match self {
|
||||
Self::Look => inspector::Page::Appearance,
|
||||
Self::Light => inspector::Page::Lighting,
|
||||
Self::Frame => inspector::Page::Camera,
|
||||
Self::Output => inspector::Page::Output,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser focus is transient per-document view state, never part of a saved design.
|
||||
@@ -332,18 +326,18 @@ pub enum RenderSelection {
|
||||
Check,
|
||||
}
|
||||
|
||||
/// Selection-driven homes introduced by E1/E7; remaining editors retain their adapters.
|
||||
/// Focus a Render editor without changing the shared modelling selection.
|
||||
pub(crate) fn selection_editor(app: &mut SolveApp, selection: &RenderSelection) {
|
||||
app.render_workspace.view.inspector.page = match selection {
|
||||
RenderSelection::Nothing => inspector::Page::Scene,
|
||||
RenderSelection::Shot(_) => inspector::Page::Camera,
|
||||
RenderSelection::Output => inspector::Page::Output,
|
||||
_ => return,
|
||||
};
|
||||
app.render_workspace.view.selection = selection.clone();
|
||||
app.render_workspace.view.inspector.hidden = false;
|
||||
}
|
||||
|
||||
pub(crate) fn select_lighting(app: &mut SolveApp) {
|
||||
app.render_workspace.view.inspector.page = inspector::Page::Lighting;
|
||||
pub(crate) fn object_selection(app: &SolveApp) -> RenderSelection {
|
||||
if app.session.selection.is_empty() { RenderSelection::Nothing } else { RenderSelection::Object(app.session.selection.clone()) }
|
||||
}
|
||||
pub(crate) fn current_shot_selection(app: &SolveApp) -> RenderSelection {
|
||||
let name = &app.render_workspace.view.name;
|
||||
if app.session.doc.render.setups.contains_key(name) { RenderSelection::Shot(name.clone()) } else { RenderSelection::Nothing }
|
||||
}
|
||||
|
||||
/// Small per-document editor state; GPU uploads and host capabilities stay outside tabs.
|
||||
@@ -363,6 +357,7 @@ pub struct ViewState {
|
||||
draft: RenderSetup,
|
||||
dirty: bool,
|
||||
clean_setup: Option<RenderSetup>,
|
||||
clean_name: String,
|
||||
job: Option<solidcraft_engine::render::job::JobId>,
|
||||
completed: Option<solidcraft_engine::render::job::JobId>,
|
||||
retained: Vec<solidcraft_engine::render_jobs::RetainedResult>,
|
||||
@@ -431,6 +426,7 @@ impl Default for ViewState {
|
||||
draft: RenderSetup::default(),
|
||||
dirty: false,
|
||||
clean_setup: None,
|
||||
clean_name: "Studio".into(),
|
||||
job: None,
|
||||
completed: None,
|
||||
retained: Vec::new(),
|
||||
@@ -699,9 +695,7 @@ pub fn enter(app: &mut SolveApp, render: bool) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
setups::refresh_dirty(app);
|
||||
if app.render_workspace.revision != app.session.revision && !app.render_workspace.view.dirty {
|
||||
setups::load(app);
|
||||
}
|
||||
setups::sync_revision(app);
|
||||
// Fit once the setup in effect is known: the fit frames that image's aspect.
|
||||
if entering {
|
||||
let scene = solidcraft_engine::product_scene::extract(&app.session).map_err(|e| e.to_string())?;
|
||||
@@ -742,7 +736,6 @@ pub(crate) fn select_stage(app: &mut SolveApp, key: &str) {
|
||||
};
|
||||
app.render_workspace.view.stage = stage;
|
||||
app.render_workspace.view.inspector.hidden = false;
|
||||
app.render_workspace.view.inspector.page = stage.page();
|
||||
let sections: &[&str] = match stage {
|
||||
Stage::Look => &["objects"],
|
||||
Stage::Light => &["lights", "environment"],
|
||||
@@ -815,7 +808,7 @@ pub fn panel(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if crate::render_ui::focused(app) {
|
||||
return;
|
||||
}
|
||||
inspector::panel(app, ui);
|
||||
inspector_panel::panel(app, ui);
|
||||
}
|
||||
|
||||
/// Interactive stage-panel review. Direct engine JSON calls remain dialog-free.
|
||||
@@ -1149,6 +1142,7 @@ fn image_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
completed_image(app, ui);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn job_controls(app: &mut SolveApp, ui: &mut egui::Ui, compact: bool) {
|
||||
let params = image_params(app);
|
||||
if !compact {
|
||||
@@ -2397,16 +2391,5 @@ pub(crate) fn comparison_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
compare::controls(app, ui);
|
||||
}
|
||||
pub(crate) fn start_interactive(app: &mut SolveApp, id: &str) -> bool {
|
||||
let handled = assign::start(app, id) || ribbon::start(app, id);
|
||||
// Opening Appearance Advanced must still reach its atmosphere controls after leaving Wear.
|
||||
if handled
|
||||
&& id == "render.aging.describe"
|
||||
&& app.render_workspace.view.inspector.page == inspector::Page::Appearance
|
||||
&& app.render_workspace.view.selection == RenderSelection::Wear
|
||||
{
|
||||
let _ = app.run("view.render_select", json!({"kind":"nothing"}));
|
||||
// Clearing semantic focus routes to Scene; retain the explicit Advanced destination.
|
||||
app.render_workspace.view.inspector.page = inspector::Page::Appearance;
|
||||
}
|
||||
handled
|
||||
assign::start(app, id) || ribbon::start(app, id)
|
||||
}
|
||||
|
||||
@@ -258,6 +258,25 @@ pub(super) fn revalidate(app: &mut SolveApp, name: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn refresh_render_revision(app: &mut SolveApp) {
|
||||
let mut sources = super::setups::SourceCache::default();
|
||||
let eligible: Vec<String> = app
|
||||
.render_workspace
|
||||
.view
|
||||
.cpu
|
||||
.bindings
|
||||
.iter()
|
||||
.filter(|(name, binding)| sources.same_model_source(app, &binding.document) && target(app, name).as_ref() == Some(&binding.target))
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
for name in eligible {
|
||||
if let Some(binding) = app.render_workspace.view.cpu.bindings.get_mut(&name) {
|
||||
binding.revision = app.session.revision;
|
||||
binding.document = std::sync::Arc::downgrade(&app.session.doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A removed, hidden or ambiguous body never silently becomes a different target.
|
||||
pub(super) fn set_reflection(app: &mut SolveApp, name: &str, mode: Option<ReflectionMode>) -> Result<(), String> {
|
||||
let selected = if mode.is_some() { Some(target(app, name).ok_or_else(|| app.tr("render.cpu_body_missing").to_string())?) } else { None };
|
||||
|
||||
@@ -74,18 +74,6 @@ pub(super) fn layer_actions(app: &SolveApp, ui: &mut egui::Ui, prefix: &str, sel
|
||||
(movement, remove)
|
||||
}
|
||||
|
||||
pub(super) fn aging_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
super::wear::controls(app, ui);
|
||||
if app.render_workspace.view.method != ImageMethod::GpuRaytrace {
|
||||
return;
|
||||
}
|
||||
let title = app.tr("render.aging_airborne").to_string();
|
||||
ui_kit::section(ui, "render_aging_atmosphere", &title, false, |ui| {
|
||||
note(app, ui, "render.aging_airborne_help");
|
||||
super::environment::atmosphere(app, ui);
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn presentation_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui_kit::banner(ui, ui_kit::AssistantTone::Neutral, |ui| note(app, ui, "render.children.effects_banner"));
|
||||
let enabled = app.render_workspace.view.method == ImageMethod::GpuRaytrace;
|
||||
|
||||
@@ -55,8 +55,7 @@ fn ring_target(app: &SolveApp) -> bool {
|
||||
let view = &app.render_workspace.view;
|
||||
super::active(app)
|
||||
&& !view.result
|
||||
&& (view.selection == super::RenderSelection::Environment
|
||||
|| matches!(view.inspector.page, super::inspector::Page::Environment | super::inspector::Page::Background))
|
||||
&& view.selection == super::RenderSelection::Environment
|
||||
&& environment_values(app).is_some_and(|(_, _, _, rotates)| rotates)
|
||||
&& !app.session.model.is_stale()
|
||||
}
|
||||
@@ -268,12 +267,10 @@ pub(super) fn editor(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
|
||||
pub(super) fn open_sources(app: &mut SolveApp) {
|
||||
let page = app.render_workspace.view.inspector.page;
|
||||
super::library::open_category(app, "hdr");
|
||||
if let Err(error) = app.run("view.render_stage", json!({"stage":Stage::Light.key()})) {
|
||||
app.set_status(error, true);
|
||||
}
|
||||
app.render_workspace.view.inspector.page = page;
|
||||
}
|
||||
|
||||
fn note(app: &SolveApp, ui: &mut egui::Ui, key: &str) {
|
||||
|
||||
@@ -122,9 +122,7 @@ pub(super) fn pick(app: &mut SolveApp, target: Target) {
|
||||
}
|
||||
if let Err(error) = inspect(app, target, path) {
|
||||
app.set_status(error, true);
|
||||
} else if app.render_workspace.view.selection == super::RenderSelection::Environment
|
||||
|| matches!(app.render_workspace.view.inspector.page, super::inspector::Page::Environment | super::inspector::Page::Background)
|
||||
{
|
||||
} else if app.render_workspace.view.selection == super::RenderSelection::Environment {
|
||||
super::environment::persist(app);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ fn fixture() -> Harness {
|
||||
h.app.run("solid.box", json!({"length":20,"width":15,"height":10,"body_name":"Subject"})).unwrap();
|
||||
super::super::enter(&mut h.app, true).unwrap();
|
||||
h.app.run("view.render_stage", json!({"stage":"output"})).unwrap();
|
||||
h.app.run("view.render_select", json!({"kind":"output"})).unwrap();
|
||||
h.frames(3);
|
||||
h
|
||||
}
|
||||
@@ -135,7 +136,7 @@ fn render_workspace_scene_overview_selection_order_links_and_readiness_are_read_
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.frames(3);
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, Page::Scene);
|
||||
assert_eq!(selected_editor(&h.app), Editor::Scene);
|
||||
ordered_sections(&["render.scene_stages", "render.scene_ready", "render.scene_reminders"], &h.app);
|
||||
let mut bottom = f32::NEG_INFINITY;
|
||||
for id in ["look", "light", "frame", "output"] {
|
||||
@@ -149,7 +150,7 @@ fn render_workspace_scene_overview_selection_order_links_and_readiness_are_read_
|
||||
assert!(crate::scenario::rect_of("dialog:note:render.scene_batch_reason").is_some());
|
||||
h.step(&json!({"click":{"handle":"render:scene:frame"}})).unwrap();
|
||||
assert!(matches!(h.app.render_workspace.view.selection, super::super::RenderSelection::Shot(_)));
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, Page::Camera);
|
||||
assert_eq!(selected_editor(&h.app), Editor::Camera);
|
||||
h.app.run("view.render_select", json!({"kind":"nothing"})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"handle":"render:scene:output"}})).unwrap();
|
||||
@@ -157,7 +158,7 @@ fn render_workspace_scene_overview_selection_order_links_and_readiness_are_read_
|
||||
h.app.run("view.render_select", json!({"kind":"nothing"})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"handle":"render:scene:open-check"}})).unwrap();
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, Page::Diagnostics);
|
||||
assert_eq!(selected_editor(&h.app), Editor::Check);
|
||||
assert_eq!(h.app.render_workspace.view.selection, super::super::RenderSelection::Check);
|
||||
assert!(crate::scenario::rect_of("render:check").is_some());
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
@@ -174,7 +175,7 @@ fn render_workspace_shot_e7_order_disabled_reasons_output_link_and_confirm_remov
|
||||
h.app.run("view.render_select", json!({"kind":"shot","id":name})).unwrap();
|
||||
let before = h.app.session.doc.to_json();
|
||||
h.frames(3);
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, Page::Camera);
|
||||
assert_eq!(selected_editor(&h.app), Editor::Camera);
|
||||
assert!(crate::scenario::rect_of("render:job-footer").is_none(), "Shot owns its actions");
|
||||
assert!(crate::scenario::rect_of("dialog:editor_footer").is_none(), "Appearance footer stays in Appearance");
|
||||
let inspector = crate::scenario::rect_of("render:inspector").unwrap();
|
||||
@@ -198,7 +199,7 @@ fn render_workspace_shot_e7_order_disabled_reasons_output_link_and_confirm_remov
|
||||
assert_eq!(h.app.session.doc.to_json(), before, "cancelling removal keeps the Shot");
|
||||
h.step(&json!({"click":{"handle":"render:shot:output"}})).unwrap();
|
||||
assert_eq!(h.app.render_workspace.view.selection, super::super::RenderSelection::Output);
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, Page::Output);
|
||||
assert_eq!(selected_editor(&h.app), Editor::Output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -253,55 +254,71 @@ fn render_workspace_shot_lens_saves_only_lens_and_orthographic_keeps_faded_contr
|
||||
assert_eq!(h.app.session.doc.render.setups[name], before, "opening must not change saved intent or method");
|
||||
}
|
||||
#[test]
|
||||
fn native_inspector_keeps_stage_actions_on_screen_and_output_has_no_pinned_footer() {
|
||||
fn inspector_dispatch_has_one_editor_per_selection_independent_of_every_stage() {
|
||||
use super::super::{RenderSelection, Stage};
|
||||
for dark in [false, true] {
|
||||
for layout in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] {
|
||||
let mut h = fixture();
|
||||
h.app.preferences.theme = if dark { "dark" } else { "light" }.into();
|
||||
h.app.ui.layout = layout;
|
||||
h.call("ui.resize", json!({"width":1280,"height":900}));
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, Page::Output);
|
||||
h.call("ui.resize", json!({"width":1440,"height":1000}));
|
||||
super::super::select_image_method(&mut h.app, ImageMethod::CpuRaytrace);
|
||||
super::super::light::edit(&mut h.app, "render.lights.add", None, Some(json!({"kind":"point"}))).unwrap();
|
||||
let light = super::super::light::rows(&h.app).unwrap().last().unwrap().id.clone();
|
||||
let shot = h.app.render_workspace.view.name.clone();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let revision = h.app.session.revision;
|
||||
let history = (h.app.session.undo.len(), h.app.session.redo.len());
|
||||
for page in Page::ALL {
|
||||
h.app.render_workspace.view.inspector.page = page;
|
||||
h.frames(3);
|
||||
let inspector = crate::scenario::rect_of("render:inspector").unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.revision, revision);
|
||||
assert_eq!((h.app.session.undo.len(), h.app.session.redo.len()), history);
|
||||
assert!(h.app.render_workspace.view.job.is_none());
|
||||
assert!(h.app.render_workspace.view.completed.is_none());
|
||||
if page == Page::Appearance {
|
||||
let footer = crate::scenario::rect_of("dialog:editor_footer").unwrap();
|
||||
assert!(inspector.contains_rect(footer), "Appearance footer outside inspector: {inspector:?} {footer:?}");
|
||||
assert!(crate::scenario::rect_of("render:job-footer").is_none());
|
||||
continue;
|
||||
}
|
||||
if matches!(page, Page::Output | Page::Effects | Page::Diagnostics | Page::Sampling) {
|
||||
assert!(crate::scenario::rect_of("render:job-footer").is_none());
|
||||
let bar = crate::scenario::rect_of("render:stage_bar").unwrap();
|
||||
for name in ["render:stage:render.start", "render:stage:render.cancel"] {
|
||||
let action = crate::scenario::rect_of(name).unwrap();
|
||||
assert!(bar.contains_rect(action), "{name} outside stage bar: {bar:?} {action:?}");
|
||||
}
|
||||
} else if page == Page::Scene {
|
||||
let stages = crate::scenario::rect_of(&format!("dialog:section:{}", h.app.tr("render.scene_stages"))).unwrap();
|
||||
assert!(inspector.contains_rect(stages), "Scene stages outside inspector: {inspector:?} {stages:?}");
|
||||
assert!(crate::scenario::rect_of("render:job-footer").is_none());
|
||||
let undo = (h.app.session.undo.len(), h.app.session.redo.len());
|
||||
for (selection, kind) in [
|
||||
(RenderSelection::Nothing, "scene"),
|
||||
(RenderSelection::Object(vec![solidcraft_engine::Sel::Body { name: "Subject".into() }]), "appearance"),
|
||||
(RenderSelection::Environment, "environment"),
|
||||
(RenderSelection::Lighting, "lighting"),
|
||||
(RenderSelection::Light(light.clone()), "light"),
|
||||
(RenderSelection::Shot(shot.clone()), "shot"),
|
||||
(RenderSelection::Output, "output"),
|
||||
(RenderSelection::Sampling, "sampling"),
|
||||
(RenderSelection::Effects, "effects"),
|
||||
(RenderSelection::Check, "check"),
|
||||
(RenderSelection::Wear, "wear"),
|
||||
] {
|
||||
if let RenderSelection::Object(_) = &selection {
|
||||
h.app.run("select.set", json!({"items":[solidcraft_engine::Sel::Body { name: "Subject".into() }]})).unwrap();
|
||||
} else {
|
||||
let footer = crate::scenario::rect_of("render:job-footer").unwrap();
|
||||
assert!(inspector.contains_rect(footer), "footer outside inspector at {page:?}: {inspector:?} {footer:?}");
|
||||
for name in ["render:check", "render:start", "render:cancel", "render:export"] {
|
||||
let target = crate::scenario::rect_of(name).unwrap();
|
||||
assert!(footer.contains_rect(target), "{name} clipped at {page:?}");
|
||||
let mut params = json!({"kind":if kind == "scene" { "nothing" } else { kind }});
|
||||
if let RenderSelection::Light(id) | RenderSelection::Shot(id) = &selection {
|
||||
params["id"] = json!(id);
|
||||
}
|
||||
h.app.run("view.render_select", params).unwrap();
|
||||
}
|
||||
assert_eq!((h.app.session.undo.len(), h.app.session.redo.len()), undo);
|
||||
let history = (h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len());
|
||||
for stage in Stage::ALL {
|
||||
h.app.run("view.render_stage", json!({"stage":stage.key()})).unwrap();
|
||||
h.frames(3);
|
||||
assert_eq!(h.app.render_workspace.view.selection, selection);
|
||||
let editors = crate::scenario::rects("render:editor:");
|
||||
assert_eq!(editors.len(), 1, "{kind}/{stage:?}: {editors:?}");
|
||||
assert!(crate::scenario::rect_of(&format!("render:editor:{kind}")).is_some());
|
||||
assert!(crate::scenario::rect_of("render:job-footer").is_none());
|
||||
assert!(crate::scenario::rect_of("render:setups").is_none());
|
||||
assert!(crate::scenario::rect_of("render:setup-name").is_none());
|
||||
assert_eq!(crate::scenario::rect_of("render:output-footer").is_some(), kind == "output");
|
||||
assert_eq!(crate::scenario::rect_of("dialog:editor_footer").is_some(), kind == "appearance");
|
||||
assert!(h.widget(h.app.tr("render.studio_title")).is_none());
|
||||
assert_eq!(h.app.render_workspace.view.method, ImageMethod::CpuRaytrace);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!((h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len()), history);
|
||||
assert!(h.app.render_workspace.view.job.is_none());
|
||||
assert!(h.app.render_workspace.view.completed.is_none());
|
||||
}
|
||||
if !dark && layout == crate::shell::LayoutMode::Floating {
|
||||
appearance_inspector_capture(&mut h, &format!("dispatch-{kind}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_navigation_is_local_and_never_switches_executor_or_overwrites_drafts() {
|
||||
let mut h = fixture();
|
||||
@@ -310,7 +327,7 @@ fn stage_navigation_is_local_and_never_switches_executor_or_overwrites_drafts()
|
||||
h.app.render_workspace.view.draft.width = 777;
|
||||
for stage in super::super::Stage::ALL {
|
||||
h.step(&json!({"click":{"handle":format!("render:stage:{}",stage.key())}})).unwrap();
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, stage.page());
|
||||
assert_eq!(h.app.render_workspace.view.selection, super::super::RenderSelection::Output);
|
||||
assert_eq!(h.app.render_workspace.view.method, ImageMethod::Raster);
|
||||
assert_eq!(h.app.render_workspace.view.samples, 37);
|
||||
assert_eq!(h.app.render_workspace.view.draft.width, 777);
|
||||
@@ -319,32 +336,83 @@ fn stage_navigation_is_local_and_never_switches_executor_or_overwrites_drafts()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_shot_switch_keep_discard_and_save_are_explicit_and_lossless() {
|
||||
fn numerical_editor_saves_before_switch_and_keeps_navigation_camera_explicit() {
|
||||
let mut h = fixture();
|
||||
h.app.render_workspace.view.name = "A".into();
|
||||
setups::save(&mut h.app).unwrap();
|
||||
h.app.run("render.settings.set", json!({"name":"B","set":{"width":321,"job":{"method":"raster"}}})).unwrap();
|
||||
let before = setups::draft(&h.app).unwrap();
|
||||
let camera = before.camera;
|
||||
h.app.render_workspace.view.draft.width = 123;
|
||||
setups::refresh_dirty(&mut h.app);
|
||||
h.app.cam.yaw += 0.25;
|
||||
setups::persist_editor(&mut h.app, &before).unwrap();
|
||||
assert_eq!(h.app.session.doc.render.setups["A"].width, 123);
|
||||
assert_eq!(h.app.session.doc.render.setups["A"].camera, camera);
|
||||
request_action(&mut h.app, PendingAction::Switch("B".into()));
|
||||
h.frames(3);
|
||||
h.step(&json!({"widget":h.app.tr("render.keep_editing")})).unwrap();
|
||||
assert!(confirmation_open(&h.app), "persisting width must not silently discard the camera draft");
|
||||
assert_eq!(h.app.render_workspace.view.name, "A");
|
||||
assert_eq!(h.app.render_workspace.view.draft.width, 123);
|
||||
request_action(&mut h.app, PendingAction::Switch("B".into()));
|
||||
h.frames(3);
|
||||
h.step(&json!({"widget":h.app.tr("render.discard_continue")})).unwrap();
|
||||
assert_eq!(h.app.render_workspace.view.name, "B");
|
||||
assert_eq!(h.app.render_workspace.view.selection, super::super::RenderSelection::Shot("B".into()));
|
||||
assert_eq!(h.app.render_workspace.view.draft.width, 321);
|
||||
assert_ne!(h.app.session.doc.render.setups["A"].width, 123);
|
||||
h.app.render_workspace.view.draft.width = 456;
|
||||
setups::refresh_dirty(&mut h.app);
|
||||
request_action(&mut h.app, PendingAction::Switch("A".into()));
|
||||
h.frames(3);
|
||||
h.step(&json!({"widget":h.app.tr("render.save_continue")})).unwrap();
|
||||
assert_eq!(h.app.session.doc.render.setups["B"].width, 456);
|
||||
assert_eq!(h.app.render_workspace.view.name, "A");
|
||||
assert_eq!(h.app.session.doc.render.setups["A"].width, 123);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shot_switch_confirms_transient_inputs_and_current_shot_focus_preserves_them() {
|
||||
use solidcraft_engine::render::job::ReflectionMode;
|
||||
for kind in ["glass", "maps", "cpu", "camera"] {
|
||||
let mut h = fixture();
|
||||
h.app.render_workspace.view.name = "A".into();
|
||||
super::super::select_image_method(&mut h.app, if kind == "cpu" { ImageMethod::CpuRaytrace } else { ImageMethod::GpuRaytrace });
|
||||
setups::save(&mut h.app).unwrap();
|
||||
h.app.run("render.settings.set", json!({"name":"B","set":{"width":321,"job":{"method":"raster"}}})).unwrap();
|
||||
setups::load(&mut h.app);
|
||||
if kind == "cpu" {
|
||||
super::super::cpu::set_reflection(&mut h.app, "Subject", Some(ReflectionMode::Mirror)).unwrap();
|
||||
} else if kind == "camera" {
|
||||
h.app.cam.yaw += 0.25;
|
||||
} else {
|
||||
let target = materials::read_targets(&mut h.app).unwrap().remove(0);
|
||||
let mut binding = materials::Binding::new(target, &h.app);
|
||||
binding.glass_enabled = kind == "glass";
|
||||
binding.maps_enabled = kind == "maps";
|
||||
h.app.render_workspace.view.materials.enabled = true;
|
||||
h.app.render_workspace.view.materials.bindings.push(binding);
|
||||
}
|
||||
let camera = h.app.cam;
|
||||
let runtime = format!("{:?}", h.app.render_workspace.view.materials);
|
||||
let reflections = h.app.render_workspace.view.raytrace.reflections.clone();
|
||||
let doc = h.app.session.doc.clone();
|
||||
let history = (h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len());
|
||||
switch_shot(&mut h.app, "A".into());
|
||||
assert!(!confirmation_open(&h.app), "focusing the current shot must not reload {kind}");
|
||||
assert_eq!(h.app.render_workspace.view.selection, super::super::RenderSelection::Shot("A".into()));
|
||||
assert_eq!(format!("{:?}", h.app.render_workspace.view.materials), runtime);
|
||||
assert_eq!(h.app.render_workspace.view.raytrace.reflections, reflections);
|
||||
assert_eq!(h.app.cam, camera);
|
||||
switch_shot(&mut h.app, "B".into());
|
||||
h.frames(3);
|
||||
assert!(confirmation_open(&h.app), "unsaved {kind} must be reviewed");
|
||||
assert_eq!(h.app.render_workspace.view.name, "A");
|
||||
h.step(&json!({"widget":h.app.tr("render.keep_editing")})).unwrap();
|
||||
assert!(!confirmation_open(&h.app));
|
||||
assert!(Arc::ptr_eq(&doc, &h.app.session.doc));
|
||||
assert_eq!((h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len()), history);
|
||||
assert_eq!(format!("{:?}", h.app.render_workspace.view.materials), runtime);
|
||||
assert_eq!(h.app.render_workspace.view.raytrace.reflections, reflections);
|
||||
assert_eq!(h.app.cam, camera);
|
||||
switch_shot(&mut h.app, "B".into());
|
||||
h.frames(3);
|
||||
h.step(&json!({"widget":h.app.tr("render.discard_continue")})).unwrap();
|
||||
assert_eq!(h.app.render_workspace.view.name, "B");
|
||||
assert_eq!(h.app.render_workspace.view.draft.width, 321);
|
||||
assert!(Arc::ptr_eq(&doc, &h.app.session.doc));
|
||||
assert_eq!((h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len()), history);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_confirmation_cannot_remove_a_changed_shot() {
|
||||
let mut h = fixture();
|
||||
@@ -711,7 +779,7 @@ fn surface_target_dropdown_keeps_the_chosen_body_through_review() {
|
||||
fn unsupported_output_is_visible_and_never_fakes_export_or_clamps_intent() {
|
||||
let mut h = fixture();
|
||||
super::super::select_image_method(&mut h.app, ImageMethod::GpuRaytrace);
|
||||
h.app.render_workspace.view.inspector.page = Page::Output;
|
||||
select_editor(&mut h.app, Editor::Output);
|
||||
h.app.render_workspace.view.draft.width = 7000;
|
||||
h.app.render_workspace.view.draft.height = 6000;
|
||||
h.app.render_workspace.view.samples = 37;
|
||||
@@ -770,7 +838,7 @@ fn actual_output_ratio_is_local_and_orientation_saves_once_preserving_other_capt
|
||||
h.app.render_workspace.view.draft.width = 1920;
|
||||
h.app.render_workspace.view.draft.height = 1080;
|
||||
h.app.render_workspace.view.samples = 37;
|
||||
h.app.render_workspace.view.inspector.page = Page::Output;
|
||||
select_editor(&mut h.app, Editor::Output);
|
||||
setups::save(&mut h.app).unwrap();
|
||||
let saved = h.app.session.doc.clone();
|
||||
let undo = h.app.session.undo.len();
|
||||
@@ -791,7 +859,7 @@ fn actual_output_ratio_is_local_and_orientation_saves_once_preserving_other_capt
|
||||
expected.height = 1920;
|
||||
assert_eq!(h.app.session.doc.render.setups[&h.app.render_workspace.view.name], expected);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
assert!(h.app.render_workspace.view.dirty);
|
||||
assert!(!h.app.render_workspace.view.dirty, "the numeric edit is already saved");
|
||||
setups::load(&mut h.app);
|
||||
h.frames(3);
|
||||
assert_eq!(h.app.render_workspace.view.inspector.resolution.anchor, Some([1080, 1920]));
|
||||
@@ -800,7 +868,7 @@ fn actual_output_ratio_is_local_and_orientation_saves_once_preserving_other_capt
|
||||
#[test]
|
||||
fn contextual_scope_notes_wrap_with_native_tokens_instead_of_clipping() {
|
||||
let mut h = fixture();
|
||||
h.app.render_workspace.view.inspector.page = Page::Scene;
|
||||
select_editor(&mut h.app, Editor::Scene);
|
||||
h.frames(3);
|
||||
let rect = crate::scenario::rect_of("dialog:note:render.scene_preserves_design").unwrap();
|
||||
let inspector = crate::scenario::rect_of("render:inspector").unwrap();
|
||||
@@ -909,39 +977,40 @@ fn ribbon_navigation_reuses_inspector_and_preserves_exact_unsaved_intent() {
|
||||
let doc = h.app.session.doc.to_json();
|
||||
let history = (h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len());
|
||||
for (id, page) in [
|
||||
("render.preset.resolve", Page::Scene),
|
||||
("render.settings.get", Page::Scene),
|
||||
("render.library.appearance.apply", Page::Appearance),
|
||||
("render.library.surface.bind", Page::Appearance),
|
||||
("render.library.studio.prepare", Page::Lighting),
|
||||
("render.library.studio.apply", Page::Lighting),
|
||||
("render.library.environment.apply", Page::Environment),
|
||||
("render.stage.backdrop", Page::Background),
|
||||
("render.quality.prepare", Page::Output),
|
||||
("render.status", Page::Diagnostics),
|
||||
("render.camera.get", Page::Camera),
|
||||
("render.camera.set", Page::Camera),
|
||||
("render.preset.apply", Page::Scene),
|
||||
("render.shot.duplicate", Page::Scene),
|
||||
("render.shot.rename", Page::Scene),
|
||||
("render.shot.remove", Page::Scene),
|
||||
("render.optics.bind", Page::Appearance),
|
||||
("render.optics.unbind", Page::Appearance),
|
||||
("render.library.surface.prepare", Page::Appearance),
|
||||
("render.library.surface.unbind", Page::Appearance),
|
||||
("render.library.physical.prepare", Page::Appearance),
|
||||
("render.stage.ground", Page::Background),
|
||||
("render.stage.clear", Page::Background),
|
||||
("render.hdr.inspect", Page::Environment),
|
||||
("render.environment.set", Page::Environment),
|
||||
("render.settings.capture_hdr", Page::Environment),
|
||||
("render.aging.describe", Page::Appearance),
|
||||
("render.effects.describe", Page::Effects),
|
||||
("render.backends", Page::Diagnostics),
|
||||
("render.preset.resolve", Editor::Scene),
|
||||
("render.settings.get", Editor::Camera),
|
||||
("render.library.appearance.apply", Editor::Appearance),
|
||||
("render.library.surface.bind", Editor::Appearance),
|
||||
("render.library.studio.prepare", Editor::Lighting),
|
||||
("render.library.studio.apply", Editor::Lighting),
|
||||
("render.library.environment.apply", Editor::Environment),
|
||||
("render.stage.backdrop", Editor::Environment),
|
||||
("render.quality.prepare", Editor::Sampling),
|
||||
("render.status", Editor::Check),
|
||||
("render.camera.get", Editor::Camera),
|
||||
("render.camera.set", Editor::Camera),
|
||||
("render.preset.apply", Editor::Scene),
|
||||
("render.shot.duplicate", Editor::Camera),
|
||||
("render.shot.rename", Editor::Camera),
|
||||
("render.shot.remove", Editor::Camera),
|
||||
("render.optics.bind", Editor::Appearance),
|
||||
("render.optics.unbind", Editor::Appearance),
|
||||
("render.library.surface.prepare", Editor::Appearance),
|
||||
("render.library.surface.unbind", Editor::Appearance),
|
||||
("render.library.physical.prepare", Editor::Appearance),
|
||||
("render.stage.ground", Editor::Environment),
|
||||
("render.stage.clear", Editor::Environment),
|
||||
("render.hdr.inspect", Editor::Environment),
|
||||
("render.environment.set", Editor::Environment),
|
||||
("render.settings.capture_hdr", Editor::Environment),
|
||||
("render.aging.describe", Editor::Wear),
|
||||
("render.effects.describe", Editor::Effects),
|
||||
("render.backends", Editor::Check),
|
||||
] {
|
||||
h.app.start(id);
|
||||
h.frames(3);
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, page, "{id}");
|
||||
let expected = if matches!(page, Editor::Appearance | Editor::Camera) { Editor::Scene } else { page };
|
||||
assert_eq!(selected_editor(&h.app), expected, "{id}");
|
||||
assert_eq!(h.app.render_workspace.view.samples, 37);
|
||||
assert_eq!(h.app.render_workspace.view.draft.width, 777);
|
||||
assert_eq!(h.app.session.doc.to_json(), doc, "navigation may not execute a parameterless apply");
|
||||
@@ -1146,7 +1215,7 @@ fn aging_tab_adds_reorders_disables_and_persists_layers_without_engineering_edit
|
||||
let before = h.app.session.doc.to_json();
|
||||
h.app.start("render.aging.describe");
|
||||
h.frames(3);
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, Page::Appearance);
|
||||
assert_eq!(selected_editor(&h.app), Editor::Wear);
|
||||
assert!(!crate::icon_svg::resolve(solidcraft_engine::find_command("render.aging.describe").unwrap().icon).placeholder);
|
||||
for code in [1, 3] {
|
||||
h.step(&json!({"click":{"handle":"render:aging-add"}})).unwrap();
|
||||
@@ -1162,7 +1231,8 @@ fn aging_tab_adds_reorders_disables_and_persists_layers_without_engineering_edit
|
||||
h.step(&json!({"click":{"handle":"render:aging-enabled"}})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(!h.app.render_workspace.view.studio.aging.get(0).unwrap().enabled);
|
||||
assert_eq!(h.app.session.doc.to_json(), before, "editing layers only edits the render draft");
|
||||
assert_ne!(h.app.session.doc.to_json(), before, "Wear saves its stack through render.settings.set");
|
||||
assert_eq!(setups::snapshot(&h.app).unwrap().job, h.app.session.doc.render.setups[&h.app.render_workspace.view.name].job);
|
||||
let targets = super::super::materials::read_targets(&mut h.app).unwrap();
|
||||
let identity = targets.iter().find(|target| target.name == "Subject").unwrap().stable_target.clone().unwrap();
|
||||
h.app.render_workspace.view.studio.aging.get_mut(0).unwrap().target = Some(identity.clone());
|
||||
@@ -1192,7 +1262,7 @@ fn wear_selection_precedes_output_page_and_appearance_selection_chrome() {
|
||||
let context = format!(
|
||||
"layout={:?}, page={:?}, title={title:?}, header={header:?}, pixels_per_point={}",
|
||||
h.app.ui.layout,
|
||||
h.app.render_workspace.view.inspector.page,
|
||||
selected_editor(&h.app),
|
||||
h.ctx.pixels_per_point(),
|
||||
);
|
||||
// egui stores Role::Label text in value(), not label(). Each pass supplies the
|
||||
@@ -1223,36 +1293,31 @@ fn wear_selection_precedes_output_page_and_appearance_selection_chrome() {
|
||||
let mut h = fixture();
|
||||
h.app.ui.layout = layout;
|
||||
h.app.session.selection = vec![solidcraft_engine::Sel::Body { name: "Subject".into() }];
|
||||
{
|
||||
let selection = super::super::object_selection(&h.app);
|
||||
super::super::selection_editor(&mut h.app, &selection);
|
||||
}
|
||||
let before = h.app.session.doc.to_json();
|
||||
let history = (h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len());
|
||||
h.app.run("view.render_select", json!({"kind":"wear"})).unwrap();
|
||||
assert!(h.app.session.selection.is_empty(), "Render focus clears object picks");
|
||||
let wear_history = (history.0 + 1, history.1, history.2);
|
||||
assert_eq!((h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len()), wear_history);
|
||||
// Exercise stale object picks alongside Wear; normal focus commands clear them.
|
||||
h.app.session.selection = vec![solidcraft_engine::Sel::Body { name: "Subject".into() }];
|
||||
for page in [Page::Output, Page::Appearance] {
|
||||
h.app.render_workspace.view.inspector.page = page;
|
||||
for stage in super::super::Stage::ALL {
|
||||
h.app.run("view.render_stage", json!({"stage":stage.key()})).unwrap();
|
||||
h.frames(3);
|
||||
assert_header(&h, h.app.tr("render.wear_title"));
|
||||
assert!(crate::scenario::rect_of("render:wear:controls").is_some());
|
||||
assert!(crate::scenario::rect_of("render:appearance:close").is_none());
|
||||
assert!(crate::scenario::rect_of("dialog:editor_footer").is_none());
|
||||
assert!(crate::scenario::rect_of("render:page:aging").is_none());
|
||||
let inspector = crate::scenario::rect_of("render:inspector").unwrap();
|
||||
let footer = crate::scenario::rect_of("render:job-footer").unwrap();
|
||||
assert!(inspector.contains_rect(footer));
|
||||
for name in ["render:check", "render:start", "render:cancel", "render:export"] {
|
||||
assert!(footer.contains_rect(crate::scenario::rect_of(name).unwrap()), "{name} outside Wear footer");
|
||||
}
|
||||
assert!(crate::scenario::rect_of("render:job-footer").is_none());
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!((h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len()), wear_history);
|
||||
}
|
||||
h.app.run("view.render_select", json!({"kind":"output"})).unwrap();
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
let output_history = (history.0 + 2, history.1, history.2);
|
||||
let output_history = wear_history;
|
||||
assert_eq!((h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len()), output_history);
|
||||
h.app.render_workspace.view.inspector.page = Page::Appearance;
|
||||
h.frames(3);
|
||||
assert_header(&h, h.app.tr("render.page_output"));
|
||||
assert!(crate::scenario::rect_of("render:wear:controls").is_none());
|
||||
@@ -1260,9 +1325,12 @@ fn wear_selection_precedes_output_page_and_appearance_selection_chrome() {
|
||||
assert!(crate::scenario::rect_of("render:appearance:close").is_none());
|
||||
h.app.run("view.render_select", json!({"kind":"nothing"})).unwrap();
|
||||
// Clearing semantic focus opens Scene; choose Appearance to test its header recovery.
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, Page::Scene);
|
||||
h.app.render_workspace.view.inspector.page = Page::Appearance;
|
||||
assert_eq!(selected_editor(&h.app), Editor::Scene);
|
||||
h.app.session.selection = vec![solidcraft_engine::Sel::Body { name: "Subject".into() }];
|
||||
{
|
||||
let selection = super::super::object_selection(&h.app);
|
||||
super::super::selection_editor(&mut h.app, &selection);
|
||||
}
|
||||
h.frames(3);
|
||||
assert_header(&h, "Subject");
|
||||
let inspector = crate::scenario::rect_of("render:inspector").unwrap();
|
||||
@@ -1317,10 +1385,9 @@ fn wear_selection_shows_disabled_controls_with_reason_and_never_changes_method()
|
||||
assert!(!h.app.render_workspace.view.studio.aging.get(0).unwrap().enabled);
|
||||
h.app.start("render.aging.describe");
|
||||
h.frames(3);
|
||||
assert_eq!(h.app.render_workspace.view.selection, super::super::RenderSelection::Nothing);
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, Page::Appearance);
|
||||
assert!(crate::scenario::rect_of("render:wear:controls").is_some(), "Appearance Advanced mounts the same editor");
|
||||
assert!(h.shapes.iter().any(|s| text_color(&s.shape, h.app.tr("render.aging_airborne")).is_some()));
|
||||
assert_eq!(h.app.render_workspace.view.selection, super::super::RenderSelection::Wear);
|
||||
assert!(crate::scenario::rect_of("render:wear:controls").is_some());
|
||||
assert!(h.shapes.iter().all(|s| text_color(&s.shape, h.app.tr("render.aging_airborne")).is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1348,8 +1415,14 @@ fn wear_editor_bounds_stale_selection_and_hostile_layer_values_never_panic() {
|
||||
h.frames(3);
|
||||
assert_eq!(h.app.render_workspace.view.inspector.effects.selected_aging, 0);
|
||||
}
|
||||
let model = h.app.session.doc.features.clone();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"click":{"handle":"render:aging-remove"}})).unwrap();
|
||||
assert!(h.app.render_workspace.view.studio.aging.is_empty());
|
||||
let saved = &h.app.session.doc.render.setups[&h.app.render_workspace.view.name];
|
||||
assert!(matches!(&saved.job, Some(solidcraft_engine::doc::render_settings::RenderJob::GpuPath { options, .. }) if options.aging.is_empty()));
|
||||
assert_eq!(h.app.session.doc.features, model);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1, "removing an invalid layer saves one corrective command");
|
||||
h.frames(3);
|
||||
assert!(crate::scenario::rect_of("render:aging-enabled").is_none());
|
||||
}
|
||||
@@ -1362,7 +1435,7 @@ fn output_effects_retains_ordered_looks_and_photographic_intent_across_save_and_
|
||||
let before = h.app.session.doc.to_json();
|
||||
h.app.start("render.effects.describe");
|
||||
h.frames(24);
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, Page::Effects);
|
||||
assert_eq!(selected_editor(&h.app), Editor::Effects);
|
||||
assert_eq!(h.app.session.doc.to_json(), before, "opening Effects is read-only");
|
||||
let undo = h.app.session.undo.len();
|
||||
|
||||
@@ -1404,7 +1477,7 @@ fn backgrounds_ribbon_exposes_camera_and_physical_options_without_mutating_draft
|
||||
let revision = h.app.session.revision;
|
||||
h.app.start("render.stage.backdrop");
|
||||
h.frames(3);
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, Page::Background);
|
||||
assert_eq!(selected_editor(&h.app), Editor::Environment);
|
||||
assert_eq!(setups::snapshot(&h.app).unwrap(), draft);
|
||||
assert_eq!(h.app.session.doc.to_json(), doc);
|
||||
assert_eq!(h.app.session.revision, revision);
|
||||
@@ -1442,7 +1515,7 @@ fn clearing_physical_stage_preserves_unsaved_camera_background_and_output_intent
|
||||
let materials = h.app.session.doc.materials.clone();
|
||||
h.app.start("render.stage.clear");
|
||||
h.frames(4);
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, Page::Background);
|
||||
assert_eq!(selected_editor(&h.app), Editor::Environment);
|
||||
h.step(&json!({"click":{"handle":"render:stage-clear"}})).unwrap();
|
||||
h.frames(3);
|
||||
assert_eq!(h.app.render_workspace.view.draft.stage.len(), 1, "removal needs consent");
|
||||
@@ -1497,22 +1570,21 @@ fn camera_draft_uses_render_command_and_keeps_saved_design_unchanged() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shots_strip_uses_the_existing_dirty_shot_confirmation() {
|
||||
fn shots_strip_switch_keeps_numeric_edits_saved_and_opens_the_selected_shot() {
|
||||
let mut h = fixture();
|
||||
h.app.render_workspace.view.name = "A".into();
|
||||
setups::save(&mut h.app).unwrap();
|
||||
h.app.run("render.settings.set", json!({"name":"B","set":{"width":321,"job":{"method":"raster"}}})).unwrap();
|
||||
let before = setups::draft(&h.app).unwrap();
|
||||
h.app.render_workspace.view.draft.width = 123;
|
||||
setups::persist_editor(&mut h.app, &before).unwrap();
|
||||
h.frames(3);
|
||||
h.step(&json!({"click":{"handle":"render:shots:setup:B"}})).unwrap();
|
||||
assert!(confirmation_open(&h.app));
|
||||
assert_eq!(h.app.render_workspace.view.name, "A");
|
||||
h.step(&json!({"widget":h.app.tr("render.keep_editing")})).unwrap();
|
||||
assert_eq!(h.app.render_workspace.view.draft.width, 123);
|
||||
h.step(&json!({"click":{"handle":"render:shots:setup:B"}})).unwrap();
|
||||
h.step(&json!({"widget":h.app.tr("render.discard_continue")})).unwrap();
|
||||
assert!(!confirmation_open(&h.app));
|
||||
assert_eq!(h.app.render_workspace.view.name, "B");
|
||||
assert_eq!(h.app.render_workspace.view.draft.width, 321);
|
||||
assert_eq!(h.app.session.doc.render.setups["A"].width, 123);
|
||||
assert_eq!(h.app.render_workspace.view.selection, super::super::RenderSelection::Shot("B".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1745,39 +1817,22 @@ fn shots_strip_render_submits_the_current_job_request() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_shot_menus_keep_target_and_draft_through_confirmation() {
|
||||
fn browser_shot_remove_keeps_confirmation_and_rejects_stale_actions() {
|
||||
let mut h = fixture();
|
||||
h.app.render_workspace.view.name = "A".into();
|
||||
setups::save(&mut h.app).unwrap();
|
||||
h.app.run("render.settings.set", json!({"name":"B","set":{"width":321,"job":{"method":"raster"}}})).unwrap();
|
||||
h.app.render_workspace.view.draft.width = 123;
|
||||
h.frames(3);
|
||||
h.step(&json!({"click":{"handle":"render:tree:shot:B"},"button":"right"})).unwrap();
|
||||
h.step(&json!({"widget":h.app.tr("render.shot_rename")})).unwrap();
|
||||
assert!(confirmation_open(&h.app));
|
||||
assert_eq!(h.app.render_workspace.view.name, "A");
|
||||
h.step(&json!({"widget":h.app.tr("render.keep_editing")})).unwrap();
|
||||
assert_eq!(h.app.render_workspace.view.draft.width, 123);
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, Page::Output);
|
||||
shot_row_action(&mut h.app, "B".into(), "render.shot.rename");
|
||||
h.frames(3);
|
||||
h.step(&json!({"widget":h.app.tr("render.discard_continue")})).unwrap();
|
||||
assert_eq!(h.app.render_workspace.view.name, "B");
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, Page::Scene);
|
||||
assert_eq!(h.app.render_workspace.view.draft.width, 321);
|
||||
h.app.render_workspace.view.draft.width = 456;
|
||||
shot_row_action(&mut h.app, "A".into(), "render.shot.remove");
|
||||
let before = h.app.session.doc.to_json();
|
||||
shot_row_action(&mut h.app, "B".into(), "render.shot.remove");
|
||||
h.frames(3);
|
||||
assert!(confirmation_open(&h.app));
|
||||
h.step(&json!({"click":{"handle":"dialog:cancel"}})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
shot_row_action(&mut h.app, "B".into(), "render.shot.remove");
|
||||
h.frames(3);
|
||||
h.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap();
|
||||
assert!(
|
||||
!h.app.session.doc.render.setups.contains_key("A"),
|
||||
"pending={:?}, status={:?}",
|
||||
h.app.render_workspace.view.inspector.pending,
|
||||
h.app.status
|
||||
);
|
||||
assert_eq!(h.app.render_workspace.view.name, "B");
|
||||
assert_eq!(h.app.render_workspace.view.draft.width, 456, "removing another shot preserves this draft");
|
||||
assert!(!h.app.session.doc.render.setups.contains_key("B"));
|
||||
assert_eq!(h.app.render_workspace.view.name, "A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1800,6 +1855,10 @@ fn appearance_inspector_targets_and_chain_follow_body_face_and_component() {
|
||||
.as_u64()
|
||||
.unwrap() as usize;
|
||||
h.app.session.selection = vec![Sel::Face { body: "Subject".into(), index: face, point: Vec3::new(10.0, 7.0, 10.0) }];
|
||||
{
|
||||
let selection = super::super::object_selection(&h.app);
|
||||
super::super::selection_editor(&mut h.app, &selection);
|
||||
}
|
||||
let before = h.app.session.doc.to_json();
|
||||
for (step, present) in [
|
||||
(ui_kit::LookStep::Face, [true, true, true, true]),
|
||||
@@ -1828,6 +1887,10 @@ fn appearance_inspector_all_users_replaces_exact_look_once_without_changing_mass
|
||||
h.app.run("material.assign", json!({"bodies":["Subject","Other"],"material":"Steel"})).unwrap();
|
||||
h.app.run("appearance.assign", json!({"bodies":["Subject","Other"],"appearance":"ABS - Red"})).unwrap();
|
||||
h.app.session.selection = vec![Sel::Body { name: "Subject".into() }];
|
||||
{
|
||||
let selection = super::super::object_selection(&h.app);
|
||||
super::super::selection_editor(&mut h.app, &selection);
|
||||
}
|
||||
let target = targets(&h.app, ui_kit::LookStep::Body);
|
||||
let from = chains(&mut h.app, &target).unwrap()[0][1].clone().unwrap();
|
||||
assert_eq!(users(&h.app, &from), 2);
|
||||
@@ -1844,9 +1907,13 @@ fn appearance_inspector_all_users_replaces_exact_look_once_without_changing_mass
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(users(&h.app, &from), 2);
|
||||
h.app.session.selection = vec![Sel::Body { name: "Subject".into() }];
|
||||
{
|
||||
let selection = super::super::object_selection(&h.app);
|
||||
super::super::selection_editor(&mut h.app, &selection);
|
||||
}
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"handle":"render:stage:look"}})).unwrap();
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, Page::Appearance);
|
||||
assert_eq!(selected_editor(&h.app), Editor::Appearance);
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"click":{"handle":format!("dialog:{}:{}", h.app.tr("render.inspector_applies_to"), crate::i18n::format("render.inspector_all_users", &[("count", "2")]))}}))
|
||||
.unwrap();
|
||||
@@ -1878,13 +1945,17 @@ fn appearance_inspector_finish_presets_mixed_set_all_and_clear_use_existing_assi
|
||||
h.app.run("appearance.assign", json!({"bodies":["Subject"],"appearance":"ABS - Red"})).unwrap();
|
||||
h.app.run("appearance.assign", json!({"bodies":["Other"],"appearance":"ABS - Blue"})).unwrap();
|
||||
h.app.session.selection = vec![Sel::Body { name: "Subject".into() }, Sel::Body { name: "Other".into() }];
|
||||
{
|
||||
let selection = super::super::object_selection(&h.app);
|
||||
super::super::selection_editor(&mut h.app, &selection);
|
||||
}
|
||||
let target = targets(&h.app, ui_kit::LookStep::Body);
|
||||
let looks: Vec<_> = chains(&mut h.app, &target).unwrap().into_iter().map(|c| c[1].clone().unwrap()).collect();
|
||||
assert!(mixed(&looks, |l| l.color));
|
||||
assert!(!mixed(&looks, |l| l.opacity));
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"handle":"render:stage:look"}})).unwrap();
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, Page::Appearance);
|
||||
assert_eq!(selected_editor(&h.app), Editor::Appearance);
|
||||
assert!(h.widget(h.app.tr("render.tree_mixed")).is_some());
|
||||
appearance_inspector_capture(&mut h, "mixed");
|
||||
let mass = h.app.run("component.properties", json!({})).unwrap();
|
||||
@@ -1918,9 +1989,13 @@ fn appearance_inspector_real_controls_switch_targets_inspect_chain_and_edit_fini
|
||||
.as_u64()
|
||||
.unwrap() as usize;
|
||||
h.app.session.selection = vec![Sel::Face { body: "Subject".into(), index: face, point: Vec3::new(10.0, 7.0, 10.0) }];
|
||||
{
|
||||
let selection = super::super::object_selection(&h.app);
|
||||
super::super::selection_editor(&mut h.app, &selection);
|
||||
}
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"handle":"render:stage:look"}})).unwrap();
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, Page::Appearance);
|
||||
assert_eq!(selected_editor(&h.app), Editor::Appearance);
|
||||
let before = h.app.session.doc.to_json();
|
||||
h.step(&json!({"click":{"handle":"render:appearance:chain-menu"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"render:appearance:chain:body"}})).unwrap();
|
||||
@@ -1948,9 +2023,9 @@ fn appearance_inspector_real_controls_switch_targets_inspect_chain_and_edit_fini
|
||||
h.step(&json!({"click":{"handle":format!("dialog::{}",h.app.tr("render.pick_body"))}})).unwrap();
|
||||
assert_eq!(h.app.session.undo.len(), edited_undo);
|
||||
appearance_inspector_capture(&mut h, "body");
|
||||
h.app.session.selection.clear();
|
||||
h.app.run("select.clear", json!({})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(h.widget(h.app.tr("render.base_appearance")).is_some());
|
||||
assert_eq!(h.app.render_workspace.view.selection, super::super::RenderSelection::Nothing);
|
||||
appearance_inspector_capture(&mut h, "explicit-target");
|
||||
}
|
||||
|
||||
@@ -1963,9 +2038,13 @@ fn appearance_inspector_mixed_fields_are_inert_until_edited_and_accept_the_first
|
||||
h.app.run("appearance.assign", json!({"bodies":[name],"color":"#123456","opacity":opacity})).unwrap();
|
||||
}
|
||||
h.app.session.selection = vec![Sel::Body { name: "Subject".into() }, Sel::Body { name: "Other".into() }];
|
||||
{
|
||||
let selection = super::super::object_selection(&h.app);
|
||||
super::super::selection_editor(&mut h.app, &selection);
|
||||
}
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"handle":"render:stage:look"}})).unwrap();
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, Page::Appearance);
|
||||
assert_eq!(selected_editor(&h.app), Editor::Appearance);
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
let field = format!("dialog:scalar:{}", h.app.tr("render.inspector_opacity"));
|
||||
@@ -2002,9 +2081,13 @@ fn appearance_inspector_optical_colour_and_opacity_edits_commit_once_with_shared
|
||||
)
|
||||
.unwrap();
|
||||
h.app.session.selection = vec![Sel::Body { name: "Subject".into() }];
|
||||
{
|
||||
let selection = super::super::object_selection(&h.app);
|
||||
super::super::selection_editor(&mut h.app, &selection);
|
||||
}
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"handle":"render:stage:look"}})).unwrap();
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, Page::Appearance);
|
||||
assert_eq!(selected_editor(&h.app), Editor::Appearance);
|
||||
let mass = h.app.run("component.properties", json!({})).unwrap();
|
||||
for (key, text) in [("appearance.metallic", "2"), ("appearance.roughness", "0"), ("render.inspector_opacity", "0.25")] {
|
||||
let undo = h.app.session.undo.len();
|
||||
@@ -2044,6 +2127,10 @@ fn appearance_inspector_hex_edits_validate_and_commit_once() {
|
||||
let mut h = fixture();
|
||||
h.app.run("appearance.assign", json!({"bodies":["Subject"],"appearance":"ABS - Red"})).unwrap();
|
||||
h.app.session.selection = vec![Sel::Body { name: "Subject".into() }];
|
||||
{
|
||||
let selection = super::super::object_selection(&h.app);
|
||||
super::super::selection_editor(&mut h.app, &selection);
|
||||
}
|
||||
h.app.run("view.render_stage", json!({"stage":"look"})).unwrap();
|
||||
h.frames(3);
|
||||
let before = h.app.session.doc.to_json();
|
||||
@@ -2098,6 +2185,10 @@ fn appearance_inspector_shared_picker_preserves_exact_opacity_and_commits_alpha(
|
||||
let mut h = fixture();
|
||||
h.app.run("appearance.assign", json!({"bodies":["Subject"],"color":"#abcdef","opacity":0.123456,"appearance":"Tint"})).unwrap();
|
||||
h.app.session.selection = vec![Sel::Body { name: "Subject".into() }];
|
||||
{
|
||||
let selection = super::super::object_selection(&h.app);
|
||||
super::super::selection_editor(&mut h.app, &selection);
|
||||
}
|
||||
h.app.run("view.render_stage", json!({"stage":"look"})).unwrap();
|
||||
h.frames(3);
|
||||
let mass = h.app.run("component.properties", json!({})).unwrap();
|
||||
@@ -2148,6 +2239,10 @@ fn appearance_inspector_shared_picker_pbr_rejects_nonopaque_alpha() {
|
||||
.run("appearance.assign", json!({"bodies":["Subject"],"color":"#123456", "pbr":{"metallic":0.2,"roughness":0.4,"dielectric_ior":1.6}}))
|
||||
.unwrap();
|
||||
h.app.session.selection = vec![Sel::Body { name: "Subject".into() }];
|
||||
{
|
||||
let selection = super::super::object_selection(&h.app);
|
||||
super::super::selection_editor(&mut h.app, &selection);
|
||||
}
|
||||
h.app.run("view.render_stage", json!({"stage":"look"})).unwrap();
|
||||
h.frames(3);
|
||||
let before = h.app.session.doc.to_json();
|
||||
@@ -2179,6 +2274,10 @@ fn appearance_inspector_shared_picker_has_no_outer_hex_editor_and_keeps_mixed_va
|
||||
h.app.run("appearance.assign", json!({"bodies":[body],"color":color,"opacity":0.123456})).unwrap();
|
||||
}
|
||||
h.app.session.selection = vec![Sel::Body { name: "Subject".into() }, Sel::Body { name: "Other".into() }];
|
||||
{
|
||||
let selection = super::super::object_selection(&h.app);
|
||||
super::super::selection_editor(&mut h.app, &selection);
|
||||
}
|
||||
h.app.run("view.render_stage", json!({"stage":"look"})).unwrap();
|
||||
h.frames(3);
|
||||
let outer = format!("dialog:color_hex:{}", h.app.tr("render.inspector_colour"));
|
||||
@@ -2222,10 +2321,14 @@ fn appearance_inspector_component_and_face_overrides_remove_to_material_from_rea
|
||||
.as_u64()
|
||||
.unwrap() as usize;
|
||||
h.app.session.selection = vec![Sel::Face { body: "Subject".into(), index: face, point: Vec3::new(10.0, 7.0, 10.0) }];
|
||||
{
|
||||
let selection = super::super::object_selection(&h.app);
|
||||
super::super::selection_editor(&mut h.app, &selection);
|
||||
}
|
||||
super::super::enter(&mut h.app, true).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"handle":"render:stage:look"}})).unwrap();
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, Page::Appearance);
|
||||
assert_eq!(selected_editor(&h.app), Editor::Appearance);
|
||||
let mass = h.app.run("component.properties", json!({})).unwrap();
|
||||
for (step, name) in [("face", "Copper"), ("body", "ABS - Red"), ("component", "ABS - Blue"), ("material", "Steel")] {
|
||||
let before = h.app.session.doc.to_json();
|
||||
@@ -2260,15 +2363,22 @@ fn appearance_inspector_capture(h: &mut Harness, name: &str) {
|
||||
h.app.preferences.theme = if dark { "dark" } else { "light" }.into();
|
||||
h.app.ui.dark = dark;
|
||||
h.app.preferences.render_inspector_width = 305.0;
|
||||
let ctx = egui::Context::default();
|
||||
crate::fonts::setup_bundled_fonts(&ctx);
|
||||
// Texture handles belong to the Harness context. Keep that context and
|
||||
// reupload cached artwork so this CPU renderer has the complete images,
|
||||
// including textures uploaded before evidence recording began.
|
||||
let ctx = h.ctx.clone();
|
||||
crate::icons::clear_texture_cache(&ctx);
|
||||
super::super::library::clear_preview_texture(&mut h.app);
|
||||
let thumbnails = std::mem::take(&mut h.app.render_workspace.thumbnails);
|
||||
let mut textures = std::collections::HashMap::new();
|
||||
textures.insert(egui::TextureId::default(), ctx.fonts(|fonts| fonts.image()));
|
||||
let start = ctx.input(|input| input.time);
|
||||
let mut last = None;
|
||||
for frame in 0..10 {
|
||||
let mut output = ctx.run_ui(
|
||||
egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))),
|
||||
time: Some(frame as f64 / 10.0),
|
||||
time: Some(start + (frame + 1) as f64 / 10.0),
|
||||
focused: true,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -2287,6 +2397,7 @@ fn appearance_inspector_capture(h: &mut Harness, name: &str) {
|
||||
crop.save(std::path::Path::new(&dir).join(format!("{name}-{}-305.png", if dark { "dark" } else { "light" }))).unwrap();
|
||||
let image = ui_kit::raster_ui(&ctx, output.shapes, output.pixels_per_point, ctx.content_rect(), &textures);
|
||||
image.save(std::path::Path::new(&dir).join(format!("{name}-{}.png", if dark { "dark" } else { "light" }))).unwrap();
|
||||
h.app.render_workspace.thumbnails = thumbnails;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2299,6 +2410,10 @@ fn appearance_inspector_approved_layout_is_305_wide_and_preserves_stage_bounds()
|
||||
h.app.run("material.assign", json!({"bodies":["Boss","Subject"],"material":"Steel"})).unwrap();
|
||||
h.app.run("select.filter", json!({"mode":"body"})).unwrap();
|
||||
h.app.session.selection = vec![Sel::Body { name: "Boss".into() }];
|
||||
{
|
||||
let selection = super::super::object_selection(&h.app);
|
||||
super::super::selection_editor(&mut h.app, &selection);
|
||||
}
|
||||
h.app.preferences.render_inspector_width = 305.0;
|
||||
h.app.run("view.render_stage", json!({"stage":"look"})).unwrap();
|
||||
h.frames(3);
|
||||
@@ -2335,6 +2450,10 @@ fn appearance_editor_e2_sections_are_ordered_collapsed_and_expand_for_each_metho
|
||||
h.call("ui.resize", json!({"width":1440,"height":4000}));
|
||||
h.app.preferences.render_inspector_width = 308.0;
|
||||
h.app.session.selection = vec![Sel::Body { name: "Subject".into() }];
|
||||
{
|
||||
let selection = super::super::object_selection(&h.app);
|
||||
super::super::selection_editor(&mut h.app, &selection);
|
||||
}
|
||||
h.app.run("view.render_stage", json!({"stage":"look"})).unwrap();
|
||||
let titles = [
|
||||
"render.inspector_edit",
|
||||
@@ -2401,6 +2520,10 @@ fn appearance_editor_e2_sections_are_ordered_collapsed_and_expand_for_each_metho
|
||||
[Sel::Face { body: "Subject".into(), index: 0, point: solidcraft_engine::geom::Vec3::ZERO }, Sel::Body { name: "Subject".into() }]
|
||||
{
|
||||
h.app.session.selection = vec![selection];
|
||||
{
|
||||
let selection = super::super::object_selection(&h.app);
|
||||
super::super::selection_editor(&mut h.app, &selection);
|
||||
}
|
||||
if matches!(h.app.session.selection.first(), Some(Sel::Body { .. })) {
|
||||
h.app.ui.pick_components = true;
|
||||
}
|
||||
@@ -2425,13 +2548,13 @@ fn output_children_select_their_editor_with_all_sections_in_design_order() {
|
||||
(
|
||||
"sampling",
|
||||
RenderSelection::Sampling,
|
||||
Page::Sampling,
|
||||
Editor::Sampling,
|
||||
vec!["render.output_quality", "render.children.path", "render.children.adaptive", "render.children.glass", "render.children.cpu"],
|
||||
),
|
||||
(
|
||||
"effects",
|
||||
RenderSelection::Effects,
|
||||
Page::Effects,
|
||||
Editor::Effects,
|
||||
vec![
|
||||
"render.children.tone",
|
||||
"render.children.looks",
|
||||
@@ -2443,13 +2566,13 @@ fn output_children_select_their_editor_with_all_sections_in_design_order() {
|
||||
(
|
||||
"check",
|
||||
RenderSelection::Check,
|
||||
Page::Diagnostics,
|
||||
Editor::Check,
|
||||
vec!["render.children.check", "render.children.request", "render.children.renderers", "render.children.under_hood"],
|
||||
),
|
||||
(
|
||||
"output",
|
||||
RenderSelection::Output,
|
||||
Page::Output,
|
||||
Editor::Output,
|
||||
vec!["render.children.renderer", "render.output_image", "render.output_destination", "render.children.render"],
|
||||
),
|
||||
] {
|
||||
@@ -2468,7 +2591,7 @@ fn output_children_select_their_editor_with_all_sections_in_design_order() {
|
||||
h.step(&json!({"click":{"handle":handle}})).unwrap();
|
||||
h.frames(3);
|
||||
assert_eq!(h.app.render_workspace.view.selection, selection);
|
||||
assert_eq!(selected_page(&h.app), page);
|
||||
assert_eq!(selected_editor(&h.app), page);
|
||||
let mut bottom = 0.0;
|
||||
for key in sections {
|
||||
let rect = crate::scenario::rect_of(&format!("dialog:section:{}", h.app.tr(key)))
|
||||
@@ -2501,6 +2624,10 @@ fn appearance_editor_reset_restores_each_picked_saved_look() {
|
||||
let mut h = fixture();
|
||||
h.app.run("appearance.assign", json!({"bodies":["Subject"],"appearance":"ABS - Red"})).unwrap();
|
||||
h.app.session.selection = vec![Sel::Body { name: "Subject".into() }];
|
||||
{
|
||||
let selection = super::super::object_selection(&h.app);
|
||||
super::super::selection_editor(&mut h.app, &selection);
|
||||
}
|
||||
h.app.run("view.render_stage", json!({"stage":"look"})).unwrap();
|
||||
h.frames(3);
|
||||
let saved = h.app.session.doc.appearances.bodies["Subject"].clone();
|
||||
@@ -2530,6 +2657,10 @@ fn appearance_editor_saved_optics_removal_confirms_and_preserves_other_drafts()
|
||||
.unwrap();
|
||||
setups::load(&mut h.app);
|
||||
h.app.session.selection = vec![Sel::Body { name: "Subject".into() }];
|
||||
{
|
||||
let selection = super::super::object_selection(&h.app);
|
||||
super::super::selection_editor(&mut h.app, &selection);
|
||||
}
|
||||
h.app.run("view.render_stage", json!({"stage":"look"})).unwrap();
|
||||
h.frames(3);
|
||||
h.step(&json!({"click":{"handle":format!("dialog:section:{}",h.app.tr("render.appearance_editor.saved"))}})).unwrap();
|
||||
@@ -2569,6 +2700,10 @@ fn appearance_editor_save_optics_keeps_other_temporary_bindings() {
|
||||
h.app.render_workspace.view.materials.bindings.push(binding);
|
||||
h.app.render_workspace.view.materials.enabled = true;
|
||||
h.app.session.selection = vec![Sel::Body { name: "Subject".into() }];
|
||||
{
|
||||
let selection = super::super::object_selection(&h.app);
|
||||
super::super::selection_editor(&mut h.app, &selection);
|
||||
}
|
||||
h.app.run("view.render_stage", json!({"stage":"look"})).unwrap();
|
||||
h.frames(3);
|
||||
h.step(&json!({"click":{"handle":format!("dialog:section:{}",h.app.tr("render.appearance_editor.glass"))}})).unwrap();
|
||||
@@ -2642,6 +2777,7 @@ fn output_children_hostile_drafts_are_readable_without_normalizing_or_saving() {
|
||||
#[test]
|
||||
fn output_children_check_diagnostics_are_collapsed_and_copy_the_current_request() {
|
||||
let mut h = fixture();
|
||||
setups::save(&mut h.app).unwrap();
|
||||
h.call("ui.resize", json!({"width":1440,"height":3000}));
|
||||
h.app.session.set_render_job_host(Some(solidcraft_engine::render::job::CpuHost::cooperative())).unwrap();
|
||||
h.app.pending_fit = None;
|
||||
@@ -2688,7 +2824,7 @@ fn output_children_check_diagnostics_are_collapsed_and_copy_the_current_request(
|
||||
record_check(&mut h.app, &request, &json!({"error":"Exposure exceeds the CPU limit"}));
|
||||
h.frames(3);
|
||||
h.step(&json!({"click":{"handle":"render:check:go-to:0"}})).unwrap();
|
||||
assert_eq!(selected_page(&h.app), Page::Camera);
|
||||
assert_eq!(selected_editor(&h.app), Editor::Camera);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2700,7 +2836,7 @@ fn output_children_focus_survives_clearing_an_object_pick() {
|
||||
("effects", super::super::RenderSelection::Effects),
|
||||
("check", super::super::RenderSelection::Check),
|
||||
] {
|
||||
h.app.run("select.set", json!({"bodies":["Subject"]})).unwrap();
|
||||
h.app.run("select.set", json!({"items":[solidcraft_engine::Sel::Body { name: "Subject".into() }]})).unwrap();
|
||||
h.app.run("view.render_select", json!({"kind":kind})).unwrap();
|
||||
assert_eq!(h.app.render_workspace.view.selection, selection);
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
|
||||
@@ -66,6 +66,11 @@ struct SourcePreview {
|
||||
sha256: String,
|
||||
texture: egui::TextureHandle,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn clear_preview_texture(app: &mut SolveApp) {
|
||||
app.render_workspace.view.library.preview = None;
|
||||
}
|
||||
impl std::fmt::Debug for SourcePreview {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("SourcePreview").field("id", &self.id).field("sha256", &self.sha256).finish()
|
||||
@@ -306,9 +311,8 @@ pub(super) fn open_category(app: &mut SolveApp, kind: &str) {
|
||||
"quality" | "capabilities" => "output",
|
||||
_ => "look",
|
||||
};
|
||||
let page = app.render_workspace.view.inspector.page;
|
||||
let _ = app.run("view.render_stage", json!({"stage":stage}));
|
||||
app.render_workspace.view.inspector.page = page;
|
||||
|
||||
switch_kind(&mut app.render_workspace.view.library, kind);
|
||||
let view = &mut app.render_workspace.view.library;
|
||||
view.collection = 0;
|
||||
@@ -343,14 +347,6 @@ pub(super) fn choose(app: &mut SolveApp, view: &mut View, kind: &str, id: &str)
|
||||
view.labels.insert(key, display);
|
||||
view.labels.retain(|key, _| app.preferences.render_library_favorites.contains(key) || app.preferences.render_library_recent.contains(key));
|
||||
view.statuses.retain(|key, _| app.preferences.render_library_favorites.contains(key) || app.preferences.render_library_recent.contains(key));
|
||||
app.render_workspace.view.inspector.page = match kind {
|
||||
"native_scenes" | "scenes" | "selene_shots" => super::inspector::Page::Scene,
|
||||
"hdr" | "environments" => super::inspector::Page::Environment,
|
||||
"rigs" | "lighting" => super::inspector::Page::Lighting,
|
||||
"quality" => super::inspector::Page::Output,
|
||||
"capabilities" => super::inspector::Page::Diagnostics,
|
||||
_ => super::inspector::Page::Appearance,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1272,12 +1268,6 @@ pub(super) fn inspect_kinds(app: &mut SolveApp, ui: &mut egui::Ui, kinds: &[&str
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn inspect_appearance(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if app.render_workspace.view.library.kind == "appearances" {
|
||||
inspect(app, ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// Interactive command activation can review the specification selected by S5.
|
||||
pub(super) fn review_selected_quality(app: &mut SolveApp) -> Result<(), String> {
|
||||
let id = app.render_workspace.view.library.selected.clone().filter(|_| app.render_workspace.view.library.kind == "quality");
|
||||
@@ -1696,19 +1686,17 @@ mod studio_browser_tests {
|
||||
h.step(&json!({"click":{"handle":format!("render:library:{id}")}})).unwrap();
|
||||
h.frames(3);
|
||||
assert_eq!(h.app.render_workspace.view.library.selected.as_deref(), Some(id.as_str()));
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, super::super::inspector::Page::Appearance);
|
||||
assert_eq!(super::super::inspector::selected_editor(&h.app), super::super::inspector::Editor::Scene);
|
||||
assert_eq!(h.app.session.doc.to_json(), before, "browsing and selection must not apply appearances");
|
||||
// The selection-following editor takes precedence; library inspection is
|
||||
// available under Advanced when no model target is selected.
|
||||
// Browsing keeps Scene selected until an object is picked.
|
||||
h.app.run("select.clear", json!({})).unwrap();
|
||||
h.frames(3);
|
||||
// Clearing model selection opens Scene overview; inspect the resource again.
|
||||
h.step(&json!({"click":{"handle":format!("render:library:{id}")}})).unwrap();
|
||||
h.frames(3);
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, super::super::inspector::Page::Appearance);
|
||||
h.step(&json!({"click":{"handle":format!("dialog:section:{}", h.app.tr("render.inspector_advanced"))}})).unwrap();
|
||||
h.frames(3);
|
||||
h.step(&json!({"widget":h.app.tr("render.library_favorite")})).unwrap();
|
||||
assert_eq!(super::super::inspector::selected_editor(&h.app), super::super::inspector::Editor::Scene);
|
||||
let look = h.app.session.doc.appearances.bodies.get("Sample").unwrap().clone();
|
||||
h.app.run("appearance.favorite", json!({"look":look,"favorite":true})).unwrap();
|
||||
assert!(h.app.preferences.render_library_favorites.contains(&("appearances".into(), id.clone())));
|
||||
h.step(&json!({"click":{"handle":"render:library-list"}})).unwrap();
|
||||
h.frames(3);
|
||||
@@ -1719,15 +1707,15 @@ mod studio_browser_tests {
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
let catalog = h.app.run("appearance.library", json!({})).unwrap();
|
||||
let blue = catalog["appearances"].as_array().unwrap().iter().find(|entry| entry["name"] == "ABS - Blue").unwrap();
|
||||
let mut view = std::mem::take(&mut h.app.render_workspace.view.library);
|
||||
choose(&mut h.app, &mut view, "appearances", &appearance_id(blue)).unwrap();
|
||||
refresh_targets(&mut h.app, &mut view).unwrap();
|
||||
view.target = view.targets.first().cloned();
|
||||
h.app.render_workspace.view.library = view;
|
||||
h.app.run("select.set", json!({"items":[solidcraft_engine::Sel::Body { name: "Sample".into() }]})).unwrap();
|
||||
h.app.render_workspace.view.library.collection = 0;
|
||||
h.app.render_workspace.view.library.loaded = false;
|
||||
h.frames(3);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
h.step(&json!({"widget":h.app.tr("render.library_apply_appearance")})).unwrap();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"click":{"handle":format!("render:library:{}", appearance_id(blue))}})).unwrap();
|
||||
assert_eq!(h.app.session.doc.appearances.bodies.get("Sample").unwrap().name, "ABS - Blue");
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
if let Some(dir) = std::env::var_os("SOLIDCRAFT_LIBRARY_EVIDENCE") {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
h.app.run("appearance.assign", json!({"bodies":["Sample"],"appearance":"Aluminum - Anodized Blue"})).unwrap();
|
||||
@@ -2033,9 +2021,9 @@ mod studio_browser_tests {
|
||||
assert!(h.app.session.undo.is_empty());
|
||||
choose(&mut h.app, &mut view, "surfaces", "SURF-048").unwrap();
|
||||
assert_eq!(h.app.preferences.render_library_recent.len(), 32);
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, super::super::inspector::Page::Appearance);
|
||||
assert_eq!(super::super::inspector::selected_editor(&h.app), super::super::inspector::Editor::Scene);
|
||||
choose(&mut h.app, &mut view, "native_scenes", "NATIVE-STUDIO-002").unwrap();
|
||||
assert_eq!(h.app.render_workspace.view.inspector.page, super::super::inspector::Page::Scene);
|
||||
assert_eq!(super::super::inspector::selected_editor(&h.app), super::super::inspector::Editor::Scene);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ use solidcraft_engine::{
|
||||
},
|
||||
};
|
||||
|
||||
use crate::{SolveApp, theme::Tokens, ui_kit};
|
||||
#[cfg(test)]
|
||||
use crate::theme::Tokens;
|
||||
use crate::{SolveApp, ui_kit};
|
||||
|
||||
const MAX_BINDINGS: usize = 128;
|
||||
|
||||
@@ -164,7 +166,9 @@ pub(super) struct View {
|
||||
pub enabled: bool,
|
||||
pub bindings: Vec<Binding>,
|
||||
targets: Vec<Target>,
|
||||
#[cfg(test)]
|
||||
target: Option<usize>,
|
||||
#[cfg(test)]
|
||||
target_search: String,
|
||||
selected: Option<usize>,
|
||||
target_revision: Option<u64>,
|
||||
@@ -208,6 +212,30 @@ pub(super) fn revalidate(app: &mut SolveApp, binding: &mut Binding) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render settings may change document identity without changing the reviewed source.
|
||||
/// A changed model, material, placement or target still needs explicit revalidation.
|
||||
pub(super) fn refresh_render_revision(app: &mut SolveApp) {
|
||||
if app.render_workspace.view.materials.bindings.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(targets) = read_targets(app) else { return };
|
||||
let mut sources = super::setups::SourceCache::default();
|
||||
let eligible: Vec<bool> = app
|
||||
.render_workspace
|
||||
.view
|
||||
.materials
|
||||
.bindings
|
||||
.iter()
|
||||
.map(|binding| sources.same_model_source(app, &binding.document) && targets.contains(&binding.target))
|
||||
.collect();
|
||||
for (binding, eligible) in app.render_workspace.view.materials.bindings.iter_mut().zip(eligible) {
|
||||
if eligible {
|
||||
binding.revision = app.session.revision;
|
||||
binding.document = Arc::downgrade(&app.session.doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called for every Check and Start through the one shared image request builder.
|
||||
/// No files are read until the engine captures the actual request.
|
||||
pub(super) fn params(app: &SolveApp, params: &mut Value) -> Result<(), String> {
|
||||
@@ -315,6 +343,7 @@ fn vector(app: &SolveApp, ui: &mut egui::Ui, key: &str, values: &mut [f64; 3], r
|
||||
ui_kit::row(ui, app.tr(key), |ui| ui_kit::vector3_field(ui, values, range, suffix))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let title = app.tr("render.material_title").to_string();
|
||||
ui_kit::section(ui, "render_materials", &title, false, |ui| {
|
||||
|
||||
+110
-623
@@ -1,5 +1,5 @@
|
||||
//! Contextual native Render UI. One workspace, existing command authority and shared controls.
|
||||
//! Page navigation is per document; unsupported backend capabilities never emit dummy parameters.
|
||||
//! Output, sampling, checks and shared Render editor adapters.
|
||||
//! Unsupported backend capabilities remain visible with a reason.
|
||||
use super::{ImageMethod, cpu, library, materials, setups, studio};
|
||||
use crate::{SolveApp, ui_kit};
|
||||
use serde_json::{Value, json};
|
||||
@@ -7,100 +7,16 @@ use solidcraft_engine::doc::Document;
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
#[path = "render_ui/appearance.rs"]
|
||||
mod selection_appearance;
|
||||
pub(super) mod selection_appearance;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) enum Page {
|
||||
#[default]
|
||||
Scene,
|
||||
Appearance,
|
||||
Effects,
|
||||
Lighting,
|
||||
Environment,
|
||||
Background,
|
||||
Camera,
|
||||
Output,
|
||||
Sampling,
|
||||
Diagnostics,
|
||||
#[cfg(test)]
|
||||
All,
|
||||
}
|
||||
impl Page {
|
||||
#[cfg(test)]
|
||||
const ALL: [Self; 11] = [
|
||||
Self::Scene,
|
||||
Self::Appearance,
|
||||
Self::Lighting,
|
||||
Self::Environment,
|
||||
Self::Background,
|
||||
Self::Camera,
|
||||
Self::Effects,
|
||||
Self::Output,
|
||||
Self::Sampling,
|
||||
Self::Diagnostics,
|
||||
Self::All,
|
||||
];
|
||||
pub(super) fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Scene => "render.page_scene",
|
||||
Self::Appearance => "render.page_appearance",
|
||||
Self::Effects => "render.page_effects",
|
||||
Self::Lighting => "render.page_lighting",
|
||||
Self::Environment => "render.page_environment",
|
||||
Self::Background => "render.page_background",
|
||||
Self::Camera => "render.page_camera",
|
||||
Self::Output => "render.page_output",
|
||||
Self::Sampling => "render.children.sampling",
|
||||
Self::Diagnostics => "render.page_diagnostics",
|
||||
#[cfg(test)]
|
||||
Self::All => "render.page_all",
|
||||
}
|
||||
}
|
||||
fn handle(self) -> &'static str {
|
||||
match self {
|
||||
Self::Scene => "scene",
|
||||
Self::Appearance => "appearance",
|
||||
Self::Effects => "effects",
|
||||
Self::Lighting => "lighting",
|
||||
Self::Environment => "environment",
|
||||
Self::Background => "background",
|
||||
Self::Camera => "camera",
|
||||
Self::Output => "output",
|
||||
Self::Sampling => "sampling",
|
||||
Self::Diagnostics => "diagnostics",
|
||||
#[cfg(test)]
|
||||
Self::All => "all",
|
||||
}
|
||||
}
|
||||
fn icon(self) -> &'static str {
|
||||
match self {
|
||||
Self::Scene => "render_scene",
|
||||
Self::Appearance => "appearance",
|
||||
Self::Effects => "camera",
|
||||
Self::Lighting => "settings",
|
||||
Self::Environment => "environment_map",
|
||||
Self::Background => "render_scene",
|
||||
Self::Camera => "camera",
|
||||
Self::Output => "export",
|
||||
Self::Sampling => "settings",
|
||||
Self::Diagnostics => "inspect",
|
||||
#[cfg(test)]
|
||||
Self::All => "list",
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct View {
|
||||
pub effects: super::effects::View,
|
||||
pub hidden: bool,
|
||||
pub page: Page,
|
||||
reveal: Option<&'static str>,
|
||||
pending: Option<Pending>,
|
||||
#[cfg(test)]
|
||||
shot_name: String,
|
||||
pub checked: Option<Review>,
|
||||
scene_info: Option<Review>,
|
||||
appearance: Appearance,
|
||||
selection_appearance: selection_appearance::View,
|
||||
pub(super) resolution: Resolution,
|
||||
}
|
||||
@@ -166,7 +82,6 @@ struct Pending {
|
||||
enum PendingAction {
|
||||
Switch(String),
|
||||
ManageShot { name: String, command: String },
|
||||
Reload,
|
||||
Remove(String),
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -181,256 +96,11 @@ impl Review {
|
||||
self.revision == app.session.revision && Weak::ptr_eq(&self.document, &Arc::downgrade(&app.session.doc))
|
||||
}
|
||||
}
|
||||
impl Default for View {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
effects: Default::default(),
|
||||
hidden: false,
|
||||
page: Page::Scene,
|
||||
reveal: None,
|
||||
pending: None,
|
||||
#[cfg(test)]
|
||||
shot_name: String::new(),
|
||||
checked: None,
|
||||
scene_info: None,
|
||||
appearance: Appearance::default(),
|
||||
selection_appearance: Default::default(),
|
||||
resolution: Resolution::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn record_check(app: &mut SolveApp, request: &Value, reply: &Value) {
|
||||
app.render_workspace.view.inspector.checked =
|
||||
Some(Review { request: request.clone(), reply: reply.clone(), revision: app.session.revision, document: Arc::downgrade(&app.session.doc) });
|
||||
}
|
||||
|
||||
pub(super) fn panel(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if app.render_workspace.view.inspector.hidden {
|
||||
crate::shell::register_panel(app, "render:inspector", None, None);
|
||||
return;
|
||||
}
|
||||
setups::refresh_dirty(app);
|
||||
if app.render_workspace.revision != app.session.revision && !app.render_workspace.view.dirty {
|
||||
setups::load(app);
|
||||
}
|
||||
let bounds = crate::shell::panel_bounds(app, ui.available_rect_before_wrap());
|
||||
let mut side = app.preferences.render_inspector_side.clone();
|
||||
let mut width = app.preferences.render_inspector_width;
|
||||
let wear = app.render_workspace.view.selection == super::RenderSelection::Wear;
|
||||
let page = selected_page(app);
|
||||
let output = !wear && matches!(page, Page::Output | Page::Sampling | Page::Effects | Page::Diagnostics);
|
||||
let appearance_editor = !wear && page == Page::Appearance;
|
||||
let standalone = !wear && (page == Page::Scene || (page == Page::Camera && super::shot::selected(app).is_some()));
|
||||
let selection_appearance = appearance_editor && !app.session.selection.is_empty();
|
||||
let title = if wear {
|
||||
app.tr("render.wear_title").to_string()
|
||||
} else if selection_appearance {
|
||||
app.session
|
||||
.selection
|
||||
.last()
|
||||
.and_then(|item| match item {
|
||||
solidcraft_engine::Sel::Body { name } | solidcraft_engine::Sel::Face { body: name, .. } => Some(name.clone()),
|
||||
solidcraft_engine::Sel::Occurrence { path } => app.session.occurrence_placement(path).ok().map(|placement| placement.name),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| app.tr("render.inspector").to_owned())
|
||||
} else if standalone {
|
||||
super::shot::selected(app).filter(|_| page == Page::Camera).map(str::to_owned).unwrap_or_else(|| app.tr("render.page_scene").to_string())
|
||||
} else {
|
||||
app.tr(if page == Page::Diagnostics {
|
||||
"render.children.check"
|
||||
} else if output {
|
||||
page.key()
|
||||
} else {
|
||||
"render.studio_title"
|
||||
})
|
||||
.to_string()
|
||||
};
|
||||
let icon = if wear {
|
||||
"appearance"
|
||||
} else if selection_appearance {
|
||||
"body"
|
||||
} else {
|
||||
page.icon()
|
||||
};
|
||||
let scope = selection_appearance::scope_label(app).to_owned();
|
||||
let header_action = std::cell::Cell::new(None);
|
||||
let content = ui_kit::Panel::workspace_with_header(
|
||||
ui,
|
||||
ui_kit::WorkspaceOptions {
|
||||
id: "render:inspector",
|
||||
title: &title,
|
||||
icon,
|
||||
bounds,
|
||||
fill_height: true,
|
||||
compact: crate::render_ui::compact(app, bounds.width()),
|
||||
editor: appearance_editor,
|
||||
enabled: true,
|
||||
header_actions: None,
|
||||
},
|
||||
&mut side,
|
||||
&mut width,
|
||||
false,
|
||||
|ui, options| {
|
||||
if appearance_editor {
|
||||
header_action.set(ui_kit::inspector_header(ui, options.icon, options.title, &scope));
|
||||
} else {
|
||||
ui_kit::panel::header(ui, options.icon, options.title, || header_action.set(Some(ui_kit::InspectorAction::Close)));
|
||||
}
|
||||
},
|
||||
|ui| {
|
||||
if output {
|
||||
ui_kit::Panel::pinned_footer(ui, "render_output_footer", 1, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (id, label, target) in [
|
||||
("render.start", app.tr("cmd.render_start").to_owned(), "render:output:render"),
|
||||
(
|
||||
"render.batch",
|
||||
crate::i18n::format("render.batch_all_shots", &[("count", &app.session.doc.render.setups.len().to_string())]),
|
||||
"render:output:all-shots",
|
||||
),
|
||||
] {
|
||||
let ready = super::ribbon::action_ready(app, id);
|
||||
let response = ui_kit::icon_label_action(ui, &label, Some("play"), ready.is_ok(), id == "render.start");
|
||||
let response = if let Err(reason) = ready { response.on_disabled_hover_text(reason) } else { response };
|
||||
crate::scenario::publish_target(target, response.rect);
|
||||
if response.clicked() {
|
||||
app.start(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::scenario::publish_rect("render:output-footer", ui.min_rect());
|
||||
});
|
||||
}
|
||||
if !appearance_editor && !output && !standalone {
|
||||
header(app, ui);
|
||||
ui_kit::Panel::pinned_footer(ui, "render_job_footer", 4, |ui| {
|
||||
ui.separator();
|
||||
super::job_controls(app, ui, true);
|
||||
if ui_kit::button(ui, app.tr("render.return_design"), ui_kit::Button::Normal).clicked()
|
||||
&& let Err(error) = super::enter(app, false)
|
||||
{
|
||||
app.set_status(error, true);
|
||||
}
|
||||
crate::scenario::publish_rect("render:job-footer", ui.min_rect());
|
||||
});
|
||||
}
|
||||
if appearance_editor {
|
||||
ui_kit::Panel::editor_footer(ui, "render_appearance_footer", |ui| selection_appearance::footer(app, ui));
|
||||
}
|
||||
egui::ScrollArea::vertical()
|
||||
.max_width(ui.available_width())
|
||||
.id_salt(("render_inspector", wear, page.handle(), app.docs.active))
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
if wear {
|
||||
super::wear::controls(app, ui);
|
||||
setups::refresh_dirty(app);
|
||||
return;
|
||||
}
|
||||
if !appearance_editor && !output && !standalone {
|
||||
ui_kit::text(ui, app.tr(page.key()), ui_kit::Text::Heading(page.key()));
|
||||
}
|
||||
match page {
|
||||
Page::Scene => scene_controls(app, ui),
|
||||
Page::Appearance => appearance_controls(app, ui),
|
||||
Page::Effects => super::effects::output_controls(app, ui),
|
||||
Page::Lighting => lighting_controls(app, ui),
|
||||
Page::Environment | Page::Background => super::environment::editor(app, ui),
|
||||
Page::Camera => {
|
||||
if let Some(shot) = super::shot::selected(app).map(str::to_owned) {
|
||||
super::shot::editor(app, ui, &shot);
|
||||
} else {
|
||||
camera_controls(app, ui);
|
||||
}
|
||||
}
|
||||
Page::Output => output_editor(app, ui),
|
||||
Page::Sampling => sampling_editor(app, ui),
|
||||
Page::Diagnostics => check_editor(app, ui),
|
||||
#[cfg(test)]
|
||||
Page::All => {
|
||||
ui_kit::text(ui, app.tr("render.all_controls_help"), ui_kit::Text::Note("render.all_controls_help"));
|
||||
shot_controls(app, ui);
|
||||
camera_actions(app, ui);
|
||||
let title = app.tr("render.page_camera").to_string();
|
||||
ui_kit::section(ui, "render_all_camera", &title, false, |ui| camera_controls(app, ui));
|
||||
let title = app.tr("render.page_aging").to_string();
|
||||
ui_kit::section(ui, "render_all_aging", &title, false, |ui| super::effects::aging_controls(app, ui));
|
||||
super::image_edit_controls(app, ui);
|
||||
}
|
||||
}
|
||||
setups::refresh_dirty(app);
|
||||
});
|
||||
},
|
||||
);
|
||||
match header_action.get() {
|
||||
Some(ui_kit::InspectorAction::Close) => {
|
||||
let _ = app.run("view.render_inspector", json!({"visible":false}));
|
||||
}
|
||||
Some(ui_kit::InspectorAction::Remove) => selection_appearance::remove(app),
|
||||
None => {}
|
||||
}
|
||||
app.preferences.render_inspector_width = width;
|
||||
let requested = content
|
||||
.as_ref()
|
||||
.and_then(|c| c.inner.1)
|
||||
.map(str::to_string)
|
||||
.or_else(|| (app.preferences.render_inspector_side != side).then(|| side.clone()));
|
||||
if let Some(side) = requested {
|
||||
let _ = app.run("view.render_dock", serde_json::json!({"panel":"inspector","side":side}));
|
||||
}
|
||||
let rect = content.map(|c| c.response.rect);
|
||||
crate::shell::register_panel(app, "render:inspector", rect, Some(if side == "left" { ui_kit::Edge::Left } else { ui_kit::Edge::Right }));
|
||||
if let Some(rect) = rect {
|
||||
crate::scenario::publish_rect("render:inspector", rect);
|
||||
}
|
||||
confirmation(app, ui.ctx());
|
||||
library::review::dialog(app, ui.ctx());
|
||||
library::review::confirmation(app, ui.ctx());
|
||||
}
|
||||
fn header(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let current = app.render_workspace.view.name.clone();
|
||||
let names: Vec<_> = app.session.doc.render.setups.keys().cloned().collect();
|
||||
let mut chosen = None;
|
||||
let menu = ui_kit::dropdown_menu(ui, "render_setups", ¤t, |ui| {
|
||||
for name in names {
|
||||
let r = ui_kit::list_picker(ui, &name, &name, name == current);
|
||||
crate::scenario::publish_handle(&format!("render:setup:{name}"), r.rect.center());
|
||||
if r.clicked() {
|
||||
chosen = Some(name);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::scenario::publish_target("render:setups", menu.rect);
|
||||
if let Some(name) = chosen {
|
||||
switch_shot(app, name);
|
||||
}
|
||||
let field = ui_kit::bounded_cell_editor(ui, ui.id().with("render_setup_name"), &mut app.render_workspace.view.name, "", false, 128);
|
||||
crate::scenario::publish_target("render:setup-name", field.rect);
|
||||
if field.changed() {
|
||||
app.render_workspace.view.dirty = true;
|
||||
}
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if ui_kit::button(ui, app.tr("render.save_setup"), ui_kit::Button::Enabled(setups::snapshot(app).is_ok())).clicked()
|
||||
&& let Err(error) = setups::save(app)
|
||||
{
|
||||
app.set_status(error, true);
|
||||
}
|
||||
if ui_kit::button(ui, app.tr("render.reload_settings"), ui_kit::Button::Normal).clicked() {
|
||||
request_action(app, PendingAction::Reload);
|
||||
}
|
||||
});
|
||||
let status = if app.render_workspace.view.dirty { app.tr("render.unsaved") } else { app.tr("render.studio_saved") };
|
||||
ui.add_sized([ui.available_width(), ui.text_style_height(&egui::TextStyle::Body)], egui::Label::new(status).truncate());
|
||||
{
|
||||
let labels = [app.tr("render.result_preview").to_string(), app.tr("render.result_title").to_string()];
|
||||
let mut value = usize::from(app.render_workspace.view.result);
|
||||
if ui_kit::segmented(ui, &mut value, &labels.iter().map(String::as_str).collect::<Vec<_>>(), Default::default()) {
|
||||
let _ = app.run("render.result.view", json!({"view":if value == 1 {"image"} else {"model"}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn switch_shot(app: &mut SolveApp, name: String) {
|
||||
setups::refresh_dirty(app);
|
||||
request_action(app, PendingAction::Switch(name));
|
||||
@@ -446,7 +116,20 @@ pub(super) fn shot_row_action(app: &mut SolveApp, name: String, command: &str) {
|
||||
}
|
||||
|
||||
fn request_action(app: &mut SolveApp, action: PendingAction) {
|
||||
if app.render_workspace.view.dirty || matches!(&action, PendingAction::Remove(_)) {
|
||||
// Focusing the current shot is navigation, never a request to reload its draft.
|
||||
if let PendingAction::Switch(name) = &action
|
||||
&& *name == app.render_workspace.view.name
|
||||
{
|
||||
super::selection_editor(app, &super::RenderSelection::Shot(name.clone()));
|
||||
return;
|
||||
}
|
||||
setups::refresh_dirty(app);
|
||||
let replaces_draft = match &action {
|
||||
PendingAction::Switch(_) => true,
|
||||
PendingAction::ManageShot { name, .. } => *name != app.render_workspace.view.name,
|
||||
PendingAction::Remove(_) => false,
|
||||
};
|
||||
if (replaces_draft && app.render_workspace.view.dirty) || matches!(&action, PendingAction::Remove(_)) {
|
||||
app.render_workspace.view.inspector.pending =
|
||||
Some(Pending { action, document: Arc::downgrade(&app.session.doc), revision: app.session.revision });
|
||||
} else if let Err(error) = perform_action(app, action) {
|
||||
@@ -456,16 +139,18 @@ fn request_action(app: &mut SolveApp, action: PendingAction) {
|
||||
fn perform_action(app: &mut SolveApp, action: PendingAction) -> Result<(), String> {
|
||||
match action {
|
||||
PendingAction::Switch(name) => {
|
||||
app.render_workspace.view.name = name;
|
||||
app.render_workspace.view.name = name.clone();
|
||||
setups::load(app);
|
||||
super::selection_editor(app, &super::RenderSelection::Shot(name));
|
||||
}
|
||||
PendingAction::ManageShot { name, command } => {
|
||||
app.render_workspace.view.name = name;
|
||||
setups::load(app);
|
||||
if name != app.render_workspace.view.name {
|
||||
app.render_workspace.view.name = name;
|
||||
setups::load(app);
|
||||
}
|
||||
// The existing manager owns name validation and command execution.
|
||||
app.start(&command);
|
||||
}
|
||||
PendingAction::Reload => setups::load(app),
|
||||
PendingAction::Remove(name) => {
|
||||
app.run("render.shot.remove", json!({"name":name,"expected_revision":app.session.revision}))?;
|
||||
if name == app.render_workspace.view.name {
|
||||
@@ -479,7 +164,7 @@ fn perform_action(app: &mut SolveApp, action: PendingAction) -> Result<(), Strin
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn confirmation(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
pub(super) fn confirmation(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
super::shots::confirmation(app, ctx);
|
||||
materials::confirmation(app, ctx);
|
||||
super::light::confirmation(app, ctx);
|
||||
@@ -500,7 +185,7 @@ fn confirmation(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
.actions(&primary, &keep, (!removing).then_some(discard.as_str()))
|
||||
.selected(ui_kit::Action::Cancel)
|
||||
.show(ctx, removing || setups::snapshot(app).is_ok(), |ui| {
|
||||
ui.label(&message);
|
||||
ui_kit::text(ui, &message, ui_kit::Text::Inline);
|
||||
});
|
||||
if closed || answer == ui_kit::Action::Cancel {
|
||||
app.render_workspace.view.inspector.pending = None;
|
||||
@@ -526,7 +211,7 @@ fn browse_button(app: &mut SolveApp, ui: &mut egui::Ui, kind: &str, key: &str) {
|
||||
library::open_category(app, kind);
|
||||
}
|
||||
}
|
||||
fn scene_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
pub(super) fn scene_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui_kit::text(ui, app.tr("render.scene_preserves_design"), ui_kit::Text::Note("render.scene_preserves_design"));
|
||||
let label = app.tr("render.children.method").to_string();
|
||||
ui_kit::row(ui, &label, |ui| super::method_dropdown(app, ui));
|
||||
@@ -631,75 +316,6 @@ fn scene_stage(app: &mut SolveApp, stage: super::Stage) {
|
||||
super::Stage::Output => json!({"kind":"output"}),
|
||||
};
|
||||
let _ = app.run("view.render_select", params);
|
||||
app.render_workspace.view.inspector.page = stage.page();
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn shot_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let title = app.tr("render.shot_manager").to_string();
|
||||
reveal_section(app, ui, "render_shot_manager");
|
||||
ui_kit::section(ui, "render_shot_manager", &title, false, |ui| {
|
||||
ui_kit::text(ui, app.tr("render.shot_saved_only"), ui_kit::Text::Note("render.shot_saved_only"));
|
||||
let label = app.tr("render.shot_new_name").to_string();
|
||||
ui_kit::bounded_cell_editor(ui, ui.id().with("render_shot_new_name"), &mut app.render_workspace.view.inspector.shot_name, &label, false, 128);
|
||||
for (command, key) in [("render.shot.duplicate", "render.shot_duplicate"), ("render.shot.rename", "render.shot_rename")] {
|
||||
let enabled = !app.render_workspace.view.dirty
|
||||
&& app.session.doc.render.setups.contains_key(&app.render_workspace.view.name)
|
||||
&& !app.render_workspace.view.inspector.shot_name.trim().is_empty();
|
||||
if ui_kit::button(ui, app.tr(key), ui_kit::Button::Enabled(enabled)).clicked() {
|
||||
let name = app.render_workspace.view.inspector.shot_name.clone();
|
||||
match app.run(command, json!({"name":app.render_workspace.view.name,"new_name":name,"expected_revision":app.session.revision})) {
|
||||
Ok(_) => {
|
||||
app.render_workspace.view.name = name;
|
||||
setups::load(app);
|
||||
}
|
||||
Err(e) => app.set_status(e, true),
|
||||
}
|
||||
}
|
||||
}
|
||||
if ui_kit::button(
|
||||
ui,
|
||||
app.tr("render.shot_remove"),
|
||||
ui_kit::Button::Enabled(app.session.doc.render.setups.contains_key(&app.render_workspace.view.name)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
request_action(app, PendingAction::Remove(app.render_workspace.view.name.clone()));
|
||||
}
|
||||
});
|
||||
}
|
||||
fn appearance_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
super::assign::selection_controls(app, ui);
|
||||
if selection_appearance::controls(app, ui) {
|
||||
return;
|
||||
}
|
||||
library::inspect_appearance(app, ui);
|
||||
ui_kit::text(ui, app.tr("render.appearance_physics_separate"), ui_kit::Text::Note("render.appearance_physics_separate"));
|
||||
base_appearance(app, ui);
|
||||
materials::controls(app, ui);
|
||||
let title = app.tr("render.inspector_advanced").to_owned();
|
||||
reveal_section(app, ui, "render:appearance:advanced");
|
||||
ui_kit::section(ui, "render:appearance:advanced", &title, false, |ui| advanced_appearance_controls(app, ui));
|
||||
}
|
||||
fn advanced_appearance_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
browse_button(app, ui, "surfaces", "render.browse_surfaces");
|
||||
browse_button(app, ui, "physical_profiles", "render.browse_physical");
|
||||
library::inspect_kinds(app, ui, &["surfaces", "materials", "selene_appearances", "physical_profiles"]);
|
||||
super::effects::aging_controls(app, ui);
|
||||
}
|
||||
fn lighting_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if let super::RenderSelection::Light(id) = app.render_workspace.view.selection.clone() {
|
||||
super::light::editor(app, ui, &id);
|
||||
return;
|
||||
}
|
||||
super::light::lighting(app, ui);
|
||||
}
|
||||
fn vector(ui: &mut egui::Ui, label: &str, value: &mut [f64; 3], range: std::ops::RangeInclusive<f64>, speed: f64) {
|
||||
ui.label(label);
|
||||
ui.horizontal(|ui| {
|
||||
for component in value {
|
||||
ui_kit::number_field(ui, component, range.clone(), speed, "");
|
||||
}
|
||||
});
|
||||
}
|
||||
pub(super) fn apply_camera_draft(app: &mut SolveApp, camera: solidcraft_engine::render::Camera) -> Result<(), String> {
|
||||
app.run("render.camera.set", json!({"camera":camera}))?;
|
||||
@@ -708,93 +324,78 @@ pub(super) fn apply_camera_draft(app: &mut SolveApp, camera: solidcraft_engine::
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn camera_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if super::shots::camera_modified(app, &app.render_workspace.view.name) {
|
||||
ui_kit::banner(ui, ui_kit::AssistantTone::Warning, |ui| {
|
||||
ui_kit::text(ui, app.tr("render.shots_camera_changed"), ui_kit::Text::Note("render.shots_camera_changed"));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let update = ui_kit::primary_button(ui, app.tr("render.shots_update"));
|
||||
crate::scenario::publish_target("render:shots:update", update.rect);
|
||||
if update.clicked()
|
||||
&& let Err(error) = super::shots::update_camera(app)
|
||||
{
|
||||
app.set_status(error, true);
|
||||
}
|
||||
let revert = ui_kit::button(ui, app.tr("render.shots_revert"), ui_kit::Button::Normal);
|
||||
crate::scenario::publish_target("render:shots:revert", revert.rect);
|
||||
if revert.clicked()
|
||||
&& let Err(error) = restore_camera(app)
|
||||
{
|
||||
app.set_status(error, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
ui_kit::text(ui, app.tr("render.camera_exact_help"), ui_kit::Text::Note("render.camera_exact_help"));
|
||||
let before = app.cam;
|
||||
let mut camera = before;
|
||||
let mut perspective = camera.fov > 0.0;
|
||||
if ui_kit::checkbox(ui, app.tr("render.perspective"), &mut perspective).changed() {
|
||||
// On at the Field of view preference's angle (#5364); a saved setup keeps its own.
|
||||
camera.fov = if perspective { crate::prefs::perspective_fov(&app.preferences) } else { 0.0 };
|
||||
}
|
||||
if perspective {
|
||||
let mut fov = camera.fov.to_degrees();
|
||||
let label = app.tr("render.camera_fov").to_string();
|
||||
if ui_kit::row(ui, &label, |ui| ui_kit::number_field(ui, &mut fov, 1.0..=150.0, 0.1, " deg")).changed() {
|
||||
camera.fov = fov.to_radians();
|
||||
#[cfg(test)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Editor {
|
||||
Scene,
|
||||
Appearance,
|
||||
Environment,
|
||||
Wear,
|
||||
Lighting,
|
||||
Light,
|
||||
Camera,
|
||||
Output,
|
||||
Sampling,
|
||||
Effects,
|
||||
Check,
|
||||
}
|
||||
|
||||
/// Existing fixtures navigate the same selection model as Browser clicks.
|
||||
#[cfg(test)]
|
||||
pub(super) fn select_editor(app: &mut SolveApp, editor: Editor) {
|
||||
use super::RenderSelection;
|
||||
let selection = match editor {
|
||||
Editor::Appearance => {
|
||||
if app.session.selection.is_empty()
|
||||
&& let Some(group) = super::scene::groups(app).first()
|
||||
{
|
||||
app.run(
|
||||
"select.set",
|
||||
json!({"items":group.objects.iter().map(|name| solidcraft_engine::Sel::Body { name: name.clone() }).collect::<Vec<_>>()}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
super::object_selection(app)
|
||||
}
|
||||
}
|
||||
let label = app.tr("render.camera_distance").to_string();
|
||||
ui_kit::row(ui, &label, |ui| ui_kit::number_field(ui, &mut camera.distance, 0.000001..=1e9, 1.0, " mm"));
|
||||
let mut target = [camera.target.x, camera.target.y, camera.target.z];
|
||||
vector(ui, app.tr("render.camera_target"), &mut target, -1e9..=1e9, 1.0);
|
||||
camera.target = solidcraft_engine::geom::Vec3::new(target[0], target[1], target[2]);
|
||||
for (key, value) in [("render.camera_yaw", &mut camera.yaw), ("render.camera_pitch", &mut camera.pitch), ("render.camera_roll", &mut camera.roll)]
|
||||
{
|
||||
let mut degrees = value.to_degrees();
|
||||
let label = app.tr(key).to_string();
|
||||
if ui_kit::row(ui, &label, |ui| ui_kit::number_field(ui, &mut degrees, -360000.0..=360000.0, 0.5, " deg")).changed() {
|
||||
*value = degrees.to_radians();
|
||||
}
|
||||
}
|
||||
if camera != before
|
||||
&& camera.is_valid()
|
||||
&& let Err(error) = apply_camera_draft(app, camera)
|
||||
{
|
||||
app.set_status(error, true);
|
||||
}
|
||||
camera_actions(app, ui);
|
||||
if app.render_workspace.view.method == ImageMethod::GpuRaytrace {
|
||||
studio::lens_controls(app, ui);
|
||||
}
|
||||
editor_section(app, ui, "render_shot_exposure", "render.image_exposure", |app, ui| {
|
||||
let enabled = app.render_workspace.view.method != ImageMethod::Raster;
|
||||
if !enabled {
|
||||
ui_kit::text(ui, app.tr("render.method_limit_raster"), ui_kit::Text::Note("render.method_limit_raster"));
|
||||
}
|
||||
let label = app.tr("render.image_exposure").to_string();
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
ui_kit::row(ui, &label, |ui| ui_kit::number_field(ui, &mut app.render_workspace.view.draft.exposure_ev, -32.0..=32.0, 0.1, " EV"));
|
||||
});
|
||||
});
|
||||
if app.render_workspace.view.result {
|
||||
if ui_kit::button(ui, app.tr("render.result_fit"), ui_kit::Button::Normal).clicked() {
|
||||
super::fit_result(app);
|
||||
}
|
||||
if ui_kit::button(ui, crate::i18n::tr("render.result_actual_size"), ui_kit::Button::Enabled(!super::reduced_display(app))).clicked() {
|
||||
app.render_workspace.view.result_scale = super::ResultScale::Actual;
|
||||
Editor::Camera => super::current_shot_selection(app),
|
||||
_ => {
|
||||
let kind = match editor {
|
||||
Editor::Environment => "environment",
|
||||
Editor::Wear => "wear",
|
||||
Editor::Lighting => "lighting",
|
||||
Editor::Output => "output",
|
||||
Editor::Sampling => "sampling",
|
||||
Editor::Effects => "effects",
|
||||
Editor::Check => "check",
|
||||
Editor::Scene => "nothing",
|
||||
Editor::Light | Editor::Appearance | Editor::Camera => panic!("use the exact object, light or shot selection"),
|
||||
};
|
||||
app.run("view.render_select", json!({"kind":kind})).unwrap();
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let RenderSelection::Shot(name) = &selection {
|
||||
app.run("view.render_select", json!({"kind":"shot","id":name})).unwrap();
|
||||
} else {
|
||||
super::selection_editor(app, &selection);
|
||||
}
|
||||
}
|
||||
/// The selection owns the editor independently of the active stage.
|
||||
fn selected_page(app: &SolveApp) -> Page {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn selected_editor(app: &SolveApp) -> Editor {
|
||||
use super::RenderSelection;
|
||||
match app.render_workspace.view.selection {
|
||||
super::RenderSelection::Output => Page::Output,
|
||||
super::RenderSelection::Sampling => Page::Sampling,
|
||||
super::RenderSelection::Effects => Page::Effects,
|
||||
super::RenderSelection::Check => Page::Diagnostics,
|
||||
_ => app.render_workspace.view.inspector.page,
|
||||
RenderSelection::Nothing => Editor::Scene,
|
||||
RenderSelection::Object(_) => Editor::Appearance,
|
||||
RenderSelection::Environment => Editor::Environment,
|
||||
RenderSelection::Wear => Editor::Wear,
|
||||
RenderSelection::Lighting => Editor::Lighting,
|
||||
RenderSelection::Light(_) => Editor::Light,
|
||||
RenderSelection::Shot(_) => Editor::Camera,
|
||||
RenderSelection::Output => Editor::Output,
|
||||
RenderSelection::Sampling => Editor::Sampling,
|
||||
RenderSelection::Effects => Editor::Effects,
|
||||
RenderSelection::Check => Editor::Check,
|
||||
}
|
||||
}
|
||||
fn editor_section(app: &mut SolveApp, ui: &mut egui::Ui, id: &'static str, key: &'static str, draw: fn(&mut SolveApp, &mut egui::Ui)) {
|
||||
@@ -802,7 +403,7 @@ fn editor_section(app: &mut SolveApp, ui: &mut egui::Ui, id: &'static str, key:
|
||||
let title = app.tr(key).to_string();
|
||||
ui_kit::section(ui, id, &title, true, |ui| draw(app, ui));
|
||||
}
|
||||
fn output_editor(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
pub(super) fn output_editor(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let summary = check_summary(app);
|
||||
let notes = ui_kit::button(ui, &summary, ui_kit::Button::Normal);
|
||||
crate::scenario::publish_target("render:output:check", notes.rect);
|
||||
@@ -1002,7 +603,7 @@ fn render_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let _ = app.run("render.result.view", json!({"view":"image"}));
|
||||
}
|
||||
}
|
||||
fn sampling_editor(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
pub(super) fn sampling_editor(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
editor_section(app, ui, "render_sampling_quality", "render.output_quality", |app, ui| {
|
||||
quality_choices(app, ui);
|
||||
ui_kit::text(ui, app.tr("render.output_quality_waiting"), ui_kit::Text::Note("render.output_quality_waiting"));
|
||||
@@ -1063,7 +664,7 @@ fn cpu_sampling(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
*value = reflections;
|
||||
}
|
||||
}
|
||||
fn check_editor(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
pub(super) fn check_editor(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
editor_section(app, ui, "render_check_check", "render.children.check", check_controls);
|
||||
editor_section(app, ui, "render_check_request", "render.children.request", request_controls);
|
||||
editor_section(app, ui, "render_check_renderers", "render.children.renderers", renderer_status);
|
||||
@@ -1112,12 +713,15 @@ fn check_note_link(app: &mut SolveApp, ui: &mut egui::Ui, index: usize, line: &C
|
||||
let response = ui_kit::button(ui, &label, ui_kit::Button::Normal);
|
||||
crate::scenario::publish_target(&format!("render:check:go-to:{index}"), response.rect);
|
||||
if response.clicked() {
|
||||
let _ = app.run("view.render_select", json!({"kind":kind}));
|
||||
if kind == "nothing" {
|
||||
app.render_workspace.view.inspector.page = if key == "render.page_camera" { Page::Camera } else { Page::Appearance };
|
||||
}
|
||||
if kind == "environment" {
|
||||
app.render_workspace.view.inspector.page = Page::Environment;
|
||||
if key == "render.page_camera" {
|
||||
let selection = super::current_shot_selection(app);
|
||||
if let super::RenderSelection::Shot(name) = selection {
|
||||
let _ = app.run("view.render_select", json!({"kind":"shot","id":name}));
|
||||
}
|
||||
} else if kind == "nothing" {
|
||||
super::selection_editor(app, &super::object_selection(app));
|
||||
} else {
|
||||
let _ = app.run("view.render_select", json!({"kind":kind}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1546,7 +1150,9 @@ fn diagnostics(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if let Some(check) = &app.render_workspace.view.inspector.checked {
|
||||
json_details(app, ui, "render.diag_preflight_details", &check.reply);
|
||||
}
|
||||
cpu::checked_status(app, ui, super::image_params(app).as_ref().ok());
|
||||
if let Some(job) = job {
|
||||
cpu::captured_status(app, ui, &job);
|
||||
json_details(app, ui, "render.diag_job_details", &job);
|
||||
}
|
||||
for key in
|
||||
@@ -1571,107 +1177,6 @@ fn json_details(app: &SolveApp, ui: &mut egui::Ui, key: &str, value: &Value) {
|
||||
ui_kit::read_only_text(ui, &text);
|
||||
});
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
struct Appearance {
|
||||
query: String,
|
||||
items: Vec<Value>,
|
||||
selected: Option<String>,
|
||||
document: Option<Weak<Document>>,
|
||||
revision: Option<u64>,
|
||||
color: [u8; 3],
|
||||
metallic: f64,
|
||||
roughness: f64,
|
||||
ior: f64,
|
||||
opaque: bool,
|
||||
}
|
||||
impl Default for Appearance {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
query: String::new(),
|
||||
items: Vec::new(),
|
||||
selected: None,
|
||||
document: None,
|
||||
revision: None,
|
||||
color: [160, 170, 180],
|
||||
metallic: 0.0,
|
||||
roughness: 0.5,
|
||||
ior: 1.5,
|
||||
opaque: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn base_appearance(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let title = app.tr("render.base_appearance").to_string();
|
||||
ui_kit::section(ui, "render_base_appearance", &title, false, |ui| {
|
||||
let mut view = std::mem::take(&mut app.render_workspace.view.inspector.appearance);
|
||||
ui_kit::bounded_cell_editor(ui, ui.id().with("render_base_target_search"), &mut view.query, app.tr("render.material_filter"), false, 128);
|
||||
if ui_kit::button(ui, app.tr("render.material_refresh"), ui_kit::Button::Normal).clicked() {
|
||||
match app.run("render.library.targets", json!({"query":view.query,"limit":128})) {
|
||||
Ok(reply) => {
|
||||
view.items = reply["items"].as_array().cloned().unwrap_or_default();
|
||||
view.selected = None;
|
||||
view.document = Some(Arc::downgrade(&app.session.doc));
|
||||
view.revision = Some(app.session.revision);
|
||||
}
|
||||
Err(error) => app.set_status(error, true),
|
||||
}
|
||||
}
|
||||
let label = view.selected.clone().unwrap_or_else(|| app.tr("render.material_choose_body").into());
|
||||
ui_kit::dropdown_menu(ui, "render_base_target", &label, |ui| {
|
||||
for item in &view.items {
|
||||
let Some(name) = item["name"].as_str() else { continue };
|
||||
if ui_kit::list_picker(ui, &format!("base:{name}"), name, view.selected.as_deref() == Some(name)).clicked() {
|
||||
view.selected = Some(name.into());
|
||||
view.color = serde_json::from_value(item["color"].clone()).unwrap_or([160, 170, 180]);
|
||||
view.opaque = item["opacity"].as_f64() == Some(1.0);
|
||||
view.metallic = item["pbr"]["metallic"].as_f64().unwrap_or(0.0);
|
||||
view.roughness = item["pbr"]["roughness"].as_f64().unwrap_or(0.5);
|
||||
view.ior = item["pbr"]["dielectric_ior"].as_f64().unwrap_or(1.5);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
if view.selected.is_some() {
|
||||
ui.label(app.tr("render.base_color"));
|
||||
ui_kit::color_field(ui, &mut view.color);
|
||||
for (key, value, range) in [
|
||||
("render.base_metallic", &mut view.metallic, 0.0..=1.0),
|
||||
("render.material_roughness", &mut view.roughness, 0.045..=1.0),
|
||||
("render.material_ior", &mut view.ior, 1.0..=3.0),
|
||||
] {
|
||||
let label = app.tr(key).to_string();
|
||||
ui_kit::row(ui, &label, |ui| ui_kit::number_field(ui, value, range, 0.01, ""));
|
||||
}
|
||||
let current = view.revision == Some(app.session.revision)
|
||||
&& view.document.as_ref().is_some_and(|d| Weak::ptr_eq(d, &Arc::downgrade(&app.session.doc)))
|
||||
&& !app.session.model.is_stale();
|
||||
if !current {
|
||||
ui_kit::text(ui, app.tr("render.material_refresh_needed"), ui_kit::Text::Note("render.material_refresh_needed"));
|
||||
}
|
||||
if !view.opaque {
|
||||
ui_kit::text(ui, app.tr("render.base_opaque_only"), ui_kit::Text::Note("render.base_opaque_only"));
|
||||
}
|
||||
if ui_kit::button(ui, app.tr("render.base_apply"), ui_kit::Button::Enabled(current && view.opaque)).clicked()
|
||||
&& let Some(name) = &view.selected
|
||||
{
|
||||
match app.run(
|
||||
"appearance.assign",
|
||||
json!({"bodies":[name],"color":view.color,"opacity":1.0,
|
||||
"pbr":{"metallic":view.metallic,"roughness":view.roughness,"dielectric_ior":view.ior}}),
|
||||
) {
|
||||
Ok(_) => {
|
||||
view.revision = Some(app.session.revision);
|
||||
view.document = Some(Arc::downgrade(&app.session.doc));
|
||||
}
|
||||
Err(error) => app.set_status(error, true),
|
||||
}
|
||||
}
|
||||
ui_kit::text(ui, app.tr("render.base_apply_help"), ui_kit::Text::Note("render.base_apply_help"));
|
||||
}
|
||||
app.render_workspace.view.inspector.appearance = view;
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "render_workspace_inspector_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1692,21 +1197,6 @@ pub(super) fn restore_camera(app: &mut SolveApp) -> Result<(), String> {
|
||||
app.follow_view_request(false);
|
||||
Ok(())
|
||||
}
|
||||
fn camera_actions(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if ui_kit::button(ui, app.tr("render.fit"), ui_kit::Button::Normal).clicked()
|
||||
&& let Err(error) = fit_subject(app)
|
||||
{
|
||||
app.set_status(error, true);
|
||||
}
|
||||
if ui_kit::button(ui, app.tr("render.restore_camera"), ui_kit::Button::Normal).clicked()
|
||||
&& let Err(error) = restore_camera(app)
|
||||
{
|
||||
app.set_status(error, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn confirmation_open(app: &SolveApp) -> bool {
|
||||
app.render_workspace.view.inspector.pending.is_some()
|
||||
|| app.render_workspace.view.save_shot_name.is_some()
|
||||
@@ -1727,17 +1217,14 @@ fn reveal_section(app: &mut SolveApp, ui: &mut egui::Ui, section: &'static str)
|
||||
}
|
||||
|
||||
fn gpu_status(app: &SolveApp, ui: &mut egui::Ui) {
|
||||
if app.render_workspace.view.method != ImageMethod::GpuRaytrace {
|
||||
return;
|
||||
}
|
||||
match &app.render_workspace.path_host {
|
||||
super::PathHost::Preparing(_, started) => super::setup_progress(ui, app, *started),
|
||||
super::PathHost::Failed(error) => {
|
||||
ui_kit::text(ui, &format!("{}: {error}", app.tr("render.gpu_path_failed")), ui_kit::Text::Error("render.gpu_path_failed"));
|
||||
}
|
||||
super::PathHost::NotStarted => {
|
||||
super::PathHost::NotStarted if app.render_workspace.view.method == ImageMethod::GpuRaytrace => {
|
||||
ui_kit::text(ui, app.tr("render.gpu_unavailable"), ui_kit::Text::Note("render.gpu_unavailable"));
|
||||
}
|
||||
super::PathHost::Ready(_) => {}
|
||||
super::PathHost::NotStarted | super::PathHost::Ready(_) => {}
|
||||
}
|
||||
}
|
||||
@@ -220,17 +220,31 @@ pub(super) fn presentation(app: &SolveApp) -> solidcraft_engine::render::job::pr
|
||||
}
|
||||
}
|
||||
|
||||
fn focus(app: &mut SolveApp, page: inspector::Page, category: Option<&str>) {
|
||||
app.render_workspace.view.inspector.hidden = false;
|
||||
let kind = match page {
|
||||
inspector::Page::Sampling => "sampling",
|
||||
inspector::Page::Effects => "effects",
|
||||
inspector::Page::Diagnostics => "check",
|
||||
inspector::Page::Output => "output",
|
||||
_ => "nothing",
|
||||
};
|
||||
let _ = app.run("view.render_select", json!({"kind":kind}));
|
||||
app.render_workspace.view.inspector.page = page;
|
||||
fn focus(app: &mut SolveApp, selection: super::RenderSelection, category: Option<&str>) {
|
||||
use super::RenderSelection;
|
||||
if matches!(selection, RenderSelection::Object(_)) {
|
||||
// Object shortcuts keep the shared modelling selection.
|
||||
super::selection_editor(app, &selection);
|
||||
} else {
|
||||
let kind = match &selection {
|
||||
RenderSelection::Nothing => "nothing",
|
||||
RenderSelection::Environment => "environment",
|
||||
RenderSelection::Wear => "wear",
|
||||
RenderSelection::Lighting => "lighting",
|
||||
RenderSelection::Light(_) => "light",
|
||||
RenderSelection::Shot(_) => "shot",
|
||||
RenderSelection::Output => "output",
|
||||
RenderSelection::Sampling => "sampling",
|
||||
RenderSelection::Effects => "effects",
|
||||
RenderSelection::Check => "check",
|
||||
RenderSelection::Object(_) => return,
|
||||
};
|
||||
let mut params = json!({"kind":kind});
|
||||
if let RenderSelection::Shot(id) | RenderSelection::Light(id) = &selection {
|
||||
params["id"] = json!(id);
|
||||
}
|
||||
let _ = app.run("view.render_select", params);
|
||||
}
|
||||
if let Some(category) = category {
|
||||
library::open_category(app, category);
|
||||
}
|
||||
@@ -248,7 +262,7 @@ pub(super) fn start(app: &mut SolveApp, id: &str) -> bool {
|
||||
return true;
|
||||
}
|
||||
// Entering from Design never starts an expensive render with hidden defaults.
|
||||
focus(app, inspector::Page::Output, None);
|
||||
focus(app, super::RenderSelection::Output, None);
|
||||
if is_job_action(id) {
|
||||
return true;
|
||||
}
|
||||
@@ -262,11 +276,12 @@ pub(super) fn start(app: &mut SolveApp, id: &str) -> bool {
|
||||
|
||||
fn navigate(app: &mut SolveApp, id: &str) -> Result<(), String> {
|
||||
action_ready(app, id)?;
|
||||
use inspector::Page;
|
||||
use super::RenderSelection;
|
||||
let object = super::object_selection(app);
|
||||
let shot = super::current_shot_selection(app);
|
||||
match id {
|
||||
"render.aging.describe" => {
|
||||
focus(app, Page::Appearance, None);
|
||||
inspector::reveal(app, "render:appearance:advanced");
|
||||
let _ = app.run("view.render_select", json!({"kind":"wear"}));
|
||||
}
|
||||
"render.compare" => super::compare::start_default(app)?,
|
||||
"render.result.close" => {
|
||||
@@ -275,35 +290,34 @@ fn navigate(app: &mut SolveApp, id: &str) -> Result<(), String> {
|
||||
"render.result.view" => {
|
||||
app.run(id, json!({"view":"image"}))?;
|
||||
}
|
||||
"render.effects.describe" => focus(app, Page::Effects, None),
|
||||
"render.preset.resolve" | "render.preset.apply" => focus(app, Page::Scene, Some("native_scenes")),
|
||||
"render.effects.describe" => focus(app, RenderSelection::Effects, None),
|
||||
"render.preset.resolve" | "render.preset.apply" => focus(app, RenderSelection::Nothing, Some("native_scenes")),
|
||||
"render.settings.get" | "render.shot.duplicate" | "render.shot.rename" | "render.shot.remove" => {
|
||||
focus(app, Page::Scene, None);
|
||||
inspector::reveal(app, "render_shot_manager");
|
||||
focus(app, shot, None);
|
||||
}
|
||||
"render.settings.set" => app.render_workspace.view.save_shot_name = Some(String::new()),
|
||||
"render.library.appearance.apply" | "render.optics.bind" | "render.optics.unbind" => focus(app, Page::Appearance, Some("surfaces")),
|
||||
"render.library.appearance.apply" | "render.optics.bind" | "render.optics.unbind" => focus(app, object, Some("surfaces")),
|
||||
"render.library.surface.bind" | "render.library.surface.prepare" | "render.library.surface.unbind" => {
|
||||
focus(app, Page::Appearance, Some("surfaces"));
|
||||
focus(app, object, Some("surfaces"));
|
||||
}
|
||||
"render.library.physical.prepare" => focus(app, Page::Appearance, Some("physical_profiles")),
|
||||
"render.library.studio.prepare" => focus(app, Page::Lighting, Some("rigs")),
|
||||
"render.library.studio.apply" => focus(app, Page::Lighting, None),
|
||||
"render.library.environment.apply" => focus(app, Page::Environment, Some("hdr")),
|
||||
"render.environment.set" | "render.hdr.inspect" | "render.settings.capture_hdr" => focus(app, Page::Environment, None),
|
||||
"render.stage.backdrop" => focus(app, Page::Background, None),
|
||||
"render.library.physical.prepare" => focus(app, object, Some("physical_profiles")),
|
||||
"render.library.studio.prepare" => focus(app, RenderSelection::Lighting, Some("rigs")),
|
||||
"render.library.studio.apply" => focus(app, RenderSelection::Lighting, None),
|
||||
"render.library.environment.apply" => focus(app, RenderSelection::Environment, Some("hdr")),
|
||||
"render.environment.set" | "render.hdr.inspect" | "render.settings.capture_hdr" => focus(app, RenderSelection::Environment, None),
|
||||
"render.stage.backdrop" => focus(app, RenderSelection::Environment, None),
|
||||
"render.stage.ground" | "render.stage.clear" => {
|
||||
focus(app, Page::Background, None);
|
||||
focus(app, RenderSelection::Environment, None);
|
||||
inspector::reveal(app, "render_ground_details");
|
||||
}
|
||||
"render.settings.apply" => inspector::restore_camera(app)?,
|
||||
"render.camera.fit" => inspector::fit_subject(app)?,
|
||||
"render.camera.get" | "render.camera.set" => focus(app, Page::Camera, None),
|
||||
"render.camera.get" | "render.camera.set" => focus(app, shot, None),
|
||||
"render.quality.prepare" => {
|
||||
focus(app, Page::Output, Some("quality"));
|
||||
focus(app, RenderSelection::Sampling, Some("quality"));
|
||||
library::review_selected_quality(app)?;
|
||||
}
|
||||
"render.status" | "render.backends" => focus(app, Page::Diagnostics, None),
|
||||
"render.status" | "render.backends" => focus(app, RenderSelection::Check, None),
|
||||
_ => return Err(crate::i18n::tr("render.error_interactive_command").into()),
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -50,7 +50,7 @@ pub(super) fn snapshot(app: &SolveApp) -> Result<RenderSetup, String> {
|
||||
|
||||
// Compare editable numerical intent before validation, so an incompatible legacy value
|
||||
// or a lens temporarily paired with an orthographic camera cannot disable dirty tracking.
|
||||
fn draft(app: &SolveApp) -> Result<RenderSetup, String> {
|
||||
pub(super) fn draft(app: &SolveApp) -> Result<RenderSetup, String> {
|
||||
let view = &app.render_workspace.view;
|
||||
let mut setup = RenderSetup { camera: app.cam, ..view.draft.clone() };
|
||||
setup.job = Some(match view.method {
|
||||
@@ -140,10 +140,66 @@ fn draft(app: &SolveApp) -> Result<RenderSetup, String> {
|
||||
Ok(setup)
|
||||
}
|
||||
|
||||
/// Save only fields a numerical editor changed. Navigation keeps its explicit
|
||||
/// Update shot/Revert flow, and temporary optical bindings remain local.
|
||||
pub(super) fn persist_editor(app: &mut SolveApp, before: &RenderSetup) -> Result<(), String> {
|
||||
let after = draft(app)?;
|
||||
// A corrective edit (for example removing an invalid wear layer) must not
|
||||
// require the hostile previous draft to be serialisable. The new value and
|
||||
// the saved command are still validated normally.
|
||||
let old = serde_json::to_value(before).ok();
|
||||
let new = serde_json::to_value(&after).map_err(|error| error.to_string())?;
|
||||
let saved = app.session.doc.render.setups.get(&app.render_workspace.view.name);
|
||||
let saved = saved.and_then(|setup| serde_json::to_value(setup).ok());
|
||||
let mut patch = serde_json::Map::new();
|
||||
if let Some(fields) = new.as_object() {
|
||||
for (key, value) in fields {
|
||||
if key != "camera"
|
||||
&& old.as_ref().and_then(|old| old.get(key)) != Some(value)
|
||||
&& saved.as_ref().and_then(|saved| saved.get(key)) != Some(value)
|
||||
{
|
||||
patch.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if !patch.is_empty() {
|
||||
after.validate().map_err(|error| error.to_string())?;
|
||||
let name = app.render_workspace.view.name.clone();
|
||||
app.run("render.settings.set", serde_json::json!({"name":name,"set":patch}))?;
|
||||
app.render_workspace.revision = app.session.revision;
|
||||
let camera = app.render_workspace.view.clean_setup.as_ref().map_or(after.camera, |clean| clean.camera);
|
||||
app.render_workspace.view.clean_setup = Some(RenderSetup { camera, ..after.clone() });
|
||||
app.render_workspace.view.clean_name = app.render_workspace.view.name.clone();
|
||||
materials::refresh_render_revision(app);
|
||||
cpu::refresh_render_revision(app);
|
||||
}
|
||||
if let Some(saved) = app.session.doc.render.setups.get(&app.render_workspace.view.name) {
|
||||
let mut saved = saved.clone();
|
||||
saved.camera = after.camera;
|
||||
if saved == after {
|
||||
let camera = app.render_workspace.view.clean_setup.as_ref().map_or(after.camera, |clean| clean.camera);
|
||||
app.render_workspace.view.clean_setup = Some(RenderSetup { camera, ..after });
|
||||
app.render_workspace.view.clean_name = app.render_workspace.view.name.clone();
|
||||
app.render_workspace.revision = app.session.revision;
|
||||
}
|
||||
}
|
||||
refresh_dirty(app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A setup switch/reload replaces its numeric editor draft, never a completed image or camera.
|
||||
pub(super) fn load(app: &mut SolveApp) {
|
||||
let setup = app.session.doc.render.setups.get(&app.render_workspace.view.name).cloned().unwrap_or_default();
|
||||
let view = &mut app.render_workspace.view;
|
||||
load_values(&mut app.render_workspace.view, setup);
|
||||
app.render_workspace.view.dirty = false;
|
||||
app.render_workspace.revision = app.session.revision;
|
||||
// Keep the explicit Restore Saved Camera action: loading options does not navigate.
|
||||
app.render_workspace.view.clean_setup = draft(app).ok();
|
||||
app.render_workspace.view.clean_name = app.render_workspace.view.name.clone();
|
||||
super::light::loaded(app);
|
||||
}
|
||||
|
||||
fn load_values(view: &mut super::ViewState, setup: RenderSetup) {
|
||||
view.method = ImageMethod::Raster;
|
||||
view.transparent = false;
|
||||
view.raytrace = RaytraceOptions::default();
|
||||
@@ -230,18 +286,193 @@ pub(super) fn load(app: &mut SolveApp) {
|
||||
None => {}
|
||||
}
|
||||
view.draft = setup;
|
||||
view.dirty = false;
|
||||
}
|
||||
|
||||
/// Compare the complete authoring source, excluding only saved Render settings.
|
||||
/// Stable body labels alone do not prove that geometry or its material is unchanged.
|
||||
pub(super) fn same_model_source(app: &SolveApp, document: &std::sync::Weak<solidcraft_engine::doc::Document>) -> bool {
|
||||
if app.session.model.is_stale() {
|
||||
return false;
|
||||
}
|
||||
let Some(source) = document.upgrade() else { return false };
|
||||
// Exhaustive by design: adding a Document field must update this comparison.
|
||||
// Match ergonomics bind references, including vectors and point-cloud data.
|
||||
macro_rules! compare_authoring_fields {
|
||||
($($field:ident),* $(,)?) => {{
|
||||
let solidcraft_engine::doc::Document { render: _, $($field),* } = source.as_ref();
|
||||
true $(&& $field == &app.session.doc.$field)*
|
||||
}};
|
||||
}
|
||||
compare_authoring_fields!(
|
||||
format,
|
||||
format_pending,
|
||||
name,
|
||||
units,
|
||||
metadata,
|
||||
params,
|
||||
text_params,
|
||||
features,
|
||||
marker,
|
||||
next_id,
|
||||
reserved_body_names,
|
||||
components,
|
||||
next_component_color_index,
|
||||
occurrences,
|
||||
assembly,
|
||||
sheet,
|
||||
plastic,
|
||||
parts,
|
||||
links,
|
||||
configs,
|
||||
favorites,
|
||||
canvases,
|
||||
point_clouds,
|
||||
param_comments,
|
||||
materials,
|
||||
print_faces,
|
||||
user_materials,
|
||||
appearances,
|
||||
body_components,
|
||||
body_offsets,
|
||||
browser_groups,
|
||||
timeline_groups,
|
||||
browser_order,
|
||||
named_views,
|
||||
visibility,
|
||||
)
|
||||
}
|
||||
|
||||
/// A single refresh's source comparisons. Targets are still checked per binding.
|
||||
#[derive(Default)]
|
||||
pub(super) struct SourceCache(Vec<(std::sync::Weak<solidcraft_engine::doc::Document>, bool)>);
|
||||
|
||||
impl SourceCache {
|
||||
pub(super) fn same_model_source(&mut self, app: &SolveApp, document: &std::sync::Weak<solidcraft_engine::doc::Document>) -> bool {
|
||||
if let Some((_, same)) = self.0.iter().find(|(source, _)| std::sync::Weak::ptr_eq(source, document)) {
|
||||
return *same && !app.session.model.is_stale() && document.upgrade().is_some();
|
||||
}
|
||||
let same = same_model_source(app, document);
|
||||
self.0.push((document.clone(), same));
|
||||
same
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronize saved edits/Undo without replacing local camera or runtime inputs.
|
||||
pub(super) fn sync_revision(app: &mut SolveApp) {
|
||||
if app.render_workspace.revision == app.session.revision {
|
||||
return;
|
||||
}
|
||||
// A new shot name can intentionally carry the unsaved draft of its source.
|
||||
// Its missing saved entry is not an Undo of that source's numerical fields.
|
||||
if app.render_workspace.view.clean_name != app.render_workspace.view.name {
|
||||
if !app.render_workspace.view.dirty {
|
||||
load(app);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(clean) = app.render_workspace.view.clean_setup.clone() else {
|
||||
if !app.render_workspace.view.dirty {
|
||||
load(app);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let saved = app.session.doc.render.setups.get(&app.render_workspace.view.name).cloned().unwrap_or_default();
|
||||
let mut previous = super::ViewState::default();
|
||||
load_values(&mut previous, clean.clone());
|
||||
let mut updated = super::ViewState::default();
|
||||
load_values(&mut updated, saved);
|
||||
// Construct the new clean numerical baseline independently of local drafts.
|
||||
let current = std::mem::replace(&mut app.render_workspace.view, updated);
|
||||
let mut next_clean = draft(app).ok();
|
||||
if let Some(next) = &mut next_clean {
|
||||
next.camera = clean.camera;
|
||||
}
|
||||
let updated = std::mem::replace(&mut app.render_workspace.view, current);
|
||||
let view = &mut app.render_workspace.view;
|
||||
macro_rules! sync {
|
||||
($($head:ident $(.$tail:ident)*),* $(,)?) => {$(
|
||||
if previous.$head$(.$tail)* != updated.$head$(.$tail)* && view.$head$(.$tail)* == previous.$head$(.$tail)* {
|
||||
view.$head$(.$tail)* = updated.$head$(.$tail)*;
|
||||
}
|
||||
)*};
|
||||
}
|
||||
// Only persisted fields participate. Reflections, optical bindings, file selectors,
|
||||
// editor selection, results and host/job state stay in their existing owners.
|
||||
sync!(
|
||||
method,
|
||||
transparent,
|
||||
samples,
|
||||
max_depth,
|
||||
seed,
|
||||
draft.backend,
|
||||
draft.mode,
|
||||
draft.width,
|
||||
draft.height,
|
||||
draft.format,
|
||||
draft.exposure_ev,
|
||||
draft.environment_rotation,
|
||||
draft.environment_intensity,
|
||||
draft.background,
|
||||
draft.job,
|
||||
draft.surfaces,
|
||||
draft.optics,
|
||||
draft.stage,
|
||||
raytrace.budget,
|
||||
raytrace.primary_samples,
|
||||
raytrace.progressive,
|
||||
raytrace.preset,
|
||||
raytrace.lights,
|
||||
raytrace.punctual_lights,
|
||||
raytrace.area_lights,
|
||||
raytrace.area_samples,
|
||||
raytrace.seed,
|
||||
raytrace.shadows,
|
||||
cpu.environment,
|
||||
cpu.color,
|
||||
cpu.intensity,
|
||||
cpu.rotation_rad,
|
||||
cpu.samples,
|
||||
studio.aging,
|
||||
studio.nested_media,
|
||||
studio.volume,
|
||||
studio.adaptive,
|
||||
studio.transmission_samples,
|
||||
studio.finishing,
|
||||
studio.backdrop_enabled,
|
||||
studio.backdrop,
|
||||
studio.linear_exr,
|
||||
studio.tone_map,
|
||||
studio.denoise,
|
||||
studio.lens_enabled,
|
||||
studio.lens,
|
||||
studio.lighting_enabled,
|
||||
studio.ambient,
|
||||
studio.sun_enabled,
|
||||
studio.sun,
|
||||
studio.area_lights,
|
||||
studio.environment,
|
||||
studio.gradient,
|
||||
studio.rotation_rad,
|
||||
studio.intensity,
|
||||
studio.assume_linear_srgb
|
||||
);
|
||||
// File HDR selectors are runtime inputs; embedded selectors follow saved Undo.
|
||||
if view.cpu.hdr == previous.cpu.hdr {
|
||||
view.cpu.hdr = updated.cpu.hdr;
|
||||
}
|
||||
if view.studio.hdr == previous.studio.hdr {
|
||||
view.studio.hdr = updated.studio.hdr;
|
||||
}
|
||||
view.clean_setup = next_clean;
|
||||
app.render_workspace.revision = app.session.revision;
|
||||
// Keep the existing explicit Restore Saved Camera action: loading options does not navigate.
|
||||
app.render_workspace.view.clean_setup = draft(app).ok();
|
||||
super::light::loaded(app);
|
||||
materials::refresh_render_revision(app);
|
||||
cpu::refresh_render_revision(app);
|
||||
refresh_dirty(app);
|
||||
}
|
||||
|
||||
pub(super) fn refresh_dirty(app: &mut SolveApp) {
|
||||
if let Some(clean) = &app.render_workspace.view.clean_setup
|
||||
&& (has_runtime_inputs(app) || draft(app).as_ref() != Ok(clean))
|
||||
{
|
||||
app.render_workspace.view.dirty = true;
|
||||
if let Some(clean) = &app.render_workspace.view.clean_setup {
|
||||
app.render_workspace.view.dirty = has_runtime_inputs(app) || draft(app).as_ref() != Ok(clean);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,6 +515,7 @@ pub(super) fn save(app: &mut SolveApp) -> Result<(), String> {
|
||||
}
|
||||
app.render_workspace.view.draft = setup.clone();
|
||||
app.render_workspace.view.clean_setup = Some(setup);
|
||||
app.render_workspace.view.clean_name = app.render_workspace.view.name.clone();
|
||||
app.render_workspace.view.dirty = false;
|
||||
app.render_workspace.revision = app.session.revision;
|
||||
Ok(())
|
||||
|
||||
@@ -266,7 +266,6 @@ pub(super) fn editor(app: &mut SolveApp, ui: &mut egui::Ui, shot: &str) {
|
||||
crate::scenario::publish_target("render:shot:output", response.rect);
|
||||
if response.clicked() {
|
||||
let _ = app.run("view.render_select", json!({"kind":"output"}));
|
||||
app.render_workspace.view.inspector.page = inspector::Page::Output;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -451,9 +450,7 @@ pub(super) fn frame_rect(aspect: f64, viewport: Rect) -> Option<Rect> {
|
||||
}
|
||||
|
||||
pub(crate) fn overlay_visible(app: &SolveApp) -> bool {
|
||||
!app.render_workspace.view.result
|
||||
&& (app.render_workspace.view.stage == Stage::Frame
|
||||
|| (app.render_workspace.view.inspector.page == inspector::Page::Camera && selected(app).is_some()))
|
||||
!app.render_workspace.view.result && (app.render_workspace.view.stage == Stage::Frame || selected(app).is_some())
|
||||
}
|
||||
|
||||
pub(crate) fn preview_rect(app: &SolveApp, viewport: Rect) -> Option<Rect> {
|
||||
|
||||
@@ -9,13 +9,6 @@ pub(super) fn camera_modified(app: &SolveApp, name: &str) -> bool {
|
||||
app.session.doc.render.setups.get(name).is_some_and(|setup| setup.camera != app.cam)
|
||||
}
|
||||
|
||||
/// Update just the saved camera, preserving all the shot's other saved intent.
|
||||
pub(super) fn update_camera(app: &mut SolveApp) -> Result<(), String> {
|
||||
let name = app.render_workspace.view.name.clone();
|
||||
app.run("render.settings.set", json!({"name":name,"set":{"camera":app.cam}}))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn contents(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
super::thumbnails::refresh(app, ui.ctx());
|
||||
let status = app.render_workspace.view.job.and_then(|job| app.session.render_job_handle(job).ok()).and_then(|handle| handle.status().ok());
|
||||
|
||||
@@ -164,6 +164,7 @@ pub(super) fn tone_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn lens_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let lens = app.tr("render.studio_lens").to_string();
|
||||
ui_kit::checkbox(ui, &lens, &mut app.render_workspace.view.studio.lens_enabled);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -257,7 +257,7 @@ fn design_rows(app: &mut SolveApp, query: &str) -> Vec<DesignRow> {
|
||||
.iter()
|
||||
.take(64)
|
||||
.find_map(|name| values.get(name.as_str()))
|
||||
.map(|p| p.value.filter(|v| v.is_finite()).map_or_else(|| bounded(&p.expression), |v| format!("{v} {}", p.unit)))
|
||||
.map(|p| p.display_value.or(p.value).filter(|v| v.is_finite()).map_or_else(|| bounded(&p.expression), |v| format!("{v} {}", p.unit)))
|
||||
.unwrap_or_default();
|
||||
rows.push(DesignRow {
|
||||
label: bounded(&feature.name),
|
||||
@@ -268,7 +268,7 @@ fn design_rows(app: &mut SolveApp, query: &str) -> Vec<DesignRow> {
|
||||
}
|
||||
let icon = solidcraft_engine::find_command("parameters.change").map_or("", |c| c.icon);
|
||||
for p in params.iter().filter(|p| p.source == "user").take(10_000) {
|
||||
let value = p.value.filter(|v| v.is_finite()).map_or_else(|| bounded(&p.expression), |v| format!("{v} {}", p.unit));
|
||||
let value = p.display_value.or(p.value).filter(|v| v.is_finite()).map_or_else(|| bounded(&p.expression), |v| format!("{v} {}", p.unit));
|
||||
rows.push(DesignRow {
|
||||
label: bounded(&p.name),
|
||||
detail: format!("{} · {value}", crate::i18n::tr("search.parameter")),
|
||||
|
||||
@@ -8,6 +8,86 @@ fn first_sketch(app: &SolveApp) -> u64 {
|
||||
app.session.doc.features.iter().find(|f| matches!(f.kind, FeatureKind::Sketch { .. })).map(|f| f.id).unwrap()
|
||||
}
|
||||
|
||||
fn plate_and_cylinder() -> crate::scenario::Harness {
|
||||
use serde_json::json;
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
for (command, params) in [
|
||||
("sketch.create", json!({"plane": "XY", "name": "Sketch1"})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})),
|
||||
("sketch.finish", json!({})),
|
||||
("solid.extrude", json!({"name": "Plate", "distance": 10})),
|
||||
("solid.cylinder", json!({"name": "Cyl", "base": [60, 15, 0], "radius": 8, "height": 20, "operation": "new"})),
|
||||
] {
|
||||
h.app.run(command, params).unwrap();
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finishing_an_edited_sketch_undoes_the_edit_with_the_complete_design_visible() {
|
||||
use serde_json::json;
|
||||
for engine_finish in [false, true] {
|
||||
let mut h = plate_and_cylinder();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let id = first_sketch(&h.app);
|
||||
h.app.edit_sketch(id);
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [20, 20]})).unwrap();
|
||||
if engine_finish {
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
} else {
|
||||
h.app.finish_sketch();
|
||||
}
|
||||
assert_eq!(h.app.session.doc.marker, None);
|
||||
let edited = h.app.session.doc.to_json();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.marker, None, "Undo must not expose the temporary sketch rollback");
|
||||
assert_eq!(h.app.session.active_sketch, None);
|
||||
assert_eq!(h.app.session.doc.to_json(), before, "one Undo takes back the sketch edit");
|
||||
assert_eq!(h.app.session.model.state().bodies.len(), 2);
|
||||
let volume = h.app.run("inspect.measure", json!({"bodies": ["Body1"]})).unwrap();
|
||||
assert!((volume["total"]["volume_mm3"].as_f64().unwrap() - 12000.0).abs() < 1e-6);
|
||||
h.app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), edited);
|
||||
assert_eq!(h.app.session.active_sketch, None);
|
||||
assert_eq!(h.app.session.model.state().bodies.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_finish_restores_the_marker_from_a_ui_sketch_edit() {
|
||||
use serde_json::json;
|
||||
for marker in [None, Some(0), Some(2)] {
|
||||
let mut h = plate_and_cylinder();
|
||||
h.app.run("timeline.roll_to", json!({"position": marker})).unwrap();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let bodies = h.app.session.model.state().bodies.len();
|
||||
let id = first_sketch(&h.app);
|
||||
h.app.edit_sketch(id);
|
||||
// Deliberately bypass the window's completion callback: the engine owns restoration.
|
||||
h.app.session.execute("sketch.finish", &json!({})).unwrap();
|
||||
assert_eq!(h.app.session.active_sketch, None);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.model.state().bodies.len(), bodies);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_sketch_edit_keeps_undo_and_redo() {
|
||||
use serde_json::json;
|
||||
let mut h = plate_and_cylinder();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let history = (h.app.session.undo.len(), h.app.session.redo.len());
|
||||
let id = first_sketch(&h.app);
|
||||
h.app.edit_sketch(id);
|
||||
h.app.finish_sketch();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!((h.app.session.undo.len(), h.app.session.redo.len()), history);
|
||||
h.app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.model.state().bodies.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_sketch_rolls_back_and_finish_restores() {
|
||||
let mut app = sample_app();
|
||||
@@ -456,9 +536,10 @@ mod exit_view_preference {
|
||||
#[test]
|
||||
fn a_sketch_opened_by_a_command_leaves_the_view_alone() {
|
||||
let (mut h, _) = sketching(SketchExitView::Previous);
|
||||
// Finished by the command, so the views saved for this sketch stay behind.
|
||||
// A programmatic finish clears this sketch's saved view too. A later sketch opened
|
||||
// entirely by commands must not inherit that view.
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
assert!(h.app.pre_sketch_cam.is_some());
|
||||
assert!(h.app.pre_sketch_cam.is_none());
|
||||
h.app.run("sketch.create", json!({"plane": "XZ"})).unwrap();
|
||||
h.app.cam.orbit(60.0, -40.0);
|
||||
h.app.cam_anim = None;
|
||||
|
||||
@@ -336,7 +336,7 @@ fn a_feature_started_while_the_marker_rolls_back_waits_for_it() {
|
||||
h.app.edit_feature(sketch);
|
||||
h.app.apply_off_thread = Some(std::time::Duration::ZERO);
|
||||
h.app.start("solid.extrude");
|
||||
assert!(h.app.session.active_sketch.is_none());
|
||||
assert_eq!(h.app.session.active_sketch, Some(sketch), "Finish and the marker restore commit together after evaluation");
|
||||
assert!(h.app.deferred.is_some(), "the marker is not rolling back off the window thread");
|
||||
assert!(h.app.dialog.is_none(), "Extrude opened before the marker was back");
|
||||
for _ in 0..500 {
|
||||
@@ -347,6 +347,7 @@ fn a_feature_started_while_the_marker_rolls_back_waits_for_it() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
assert_eq!(h.app.session.doc.marker, None);
|
||||
assert_eq!(h.app.session.active_sketch, None);
|
||||
let items = h.app.dialog.as_ref().and_then(|d| d.inputs.first()).map(|i| i.items.clone()).unwrap_or_default();
|
||||
assert_eq!(items, vec![Sel::Profile { sketch, index: 0 }], "Extrude did not open once the marker was back");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user