Render: integrated workspace, preview image jobs and opaque PBR (#1046)

Lands the Render workspace (R3), bounded preview-image jobs with cancellation and atomic PNG export (R4a), and opaque PBR looks as one change.

- Render workspace: an overlay-free product preview on the installed GPU device. Returning to Design restores its camera. render.scene and render.settings.apply are added.
- Image jobs: render.preflight/start/status/cancel/export run bounded CPU preview jobs. Export captures the job, overwrite choice and document, and checks the caller's file scope before writing.
- Opaque PBR: looks can carry metal fraction, roughness and dielectric IOR. The product viewport and preview PNG use them; geometry and mass are unchanged. The v1 catalog export refuses PBR rather than dropping it.
- Render setups and PBR looks are saved under design format 21. Labels are in en/de/ja/ko/zh/ru.

Supersedes #706 and #800. Refs #620, #673, #756, #1004.

Contributed by @0xlillith.

Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com>
This commit is contained in:
selune authored and GitHub committed 2026-10-14 19:24:36 -04:00
1 parent e4ef6cfe29
commit a261aaa841
70 files changed
+5860 -101

No files matched your search

+17 -3
View File
@@ -329,8 +329,15 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
Target::ToolbarCommand { id } => crate::toolbar_custom::command_items(app, id),
Target::Toolbar => crate::toolbar_custom::toolbar_items(app),
Target::Workspace => {
let mut v = vec![act("ui.workspace", "Design", "box").key("current")];
for w in ["Render", "Animation", "Simulation", "Manufacture", "Drawing"] {
let rendering = crate::render_workspace::active(app);
let mut v = vec![
act("ui.workspace.design", "Design", "box").with(json!({"workspace":"design"})).key(if rendering { "" } else { "current" }),
act("ui.workspace.render", "Render", "camera")
.with(json!({"workspace":"render"}))
.key(if rendering { "current" } else { "" })
.on(app.session.active_sketch.is_none() && app.tool.is_none() && app.dialog.is_none() && !app.file_load.busy()),
];
for w in ["Animation", "Simulation", "Manufacture", "Drawing"] {
v.push(act("ui.workspace", w, "").key("coming later").on(false));
}
v
@@ -975,7 +982,13 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
"ui.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.toolbarCustomize" | "ui.shortcutPin" => {
crate::toolbar_custom::run(app, &item.id, &p)
}
"ui.workspace" => {}
"ui.workspace" | "ui.workspace.design" | "ui.workspace.render" => {
if let Some(workspace) = p.get("workspace").and_then(Value::as_str)
&& let Err(e) = crate::render_workspace::enter(app, workspace == "render")
{
app.set_status(e, true);
}
}
"ui.findJoint" => {
if let Some(id) = id_of(&p, "joint") {
crate::dialogs_assembly::find_joint(app, id);
@@ -1043,6 +1056,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
{
k.color = look.color;
k.opacity = look.opacity;
k.pbr = look.pbr;
k.look = Some(look.name.clone());
}
k.component = Some(name);
+18
View File
@@ -185,10 +185,22 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
}
fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> Outcome {
crate::render_workspace::sync_backend(app);
let s = |k: &str| p.get(k).and_then(Value::as_str);
let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
match method {
"ui.workspace" => {
if let Some(workspace) = s("workspace") {
if !matches!(workspace, "design" | "render") {
return err("workspace must be design or render");
}
if let Err(e) = crate::render_workspace::enter(app, workspace == "render") {
return err(e);
}
}
ok(json!({"workspace":if crate::render_workspace::active(app) {"render"} else {"design"}}))
}
"engine.execute" | "command" => {
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
wrap(execute(app, id, p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}))))
@@ -230,6 +242,7 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
"frame_ms": app.frame_ms,
"file_busy": app.file_load.busy(),
"file_picker_busy": app.file_picker_busy(),
"file_dialog_open": app.file_picker_busy(),
"build_ms": app.viewport.build_ms,
"preview": {"active": app.preview.active, "busy": app.preview.busy, "error": app.preview.error, "ms": app.preview.ms, "replaced": app.preview.replaced},
"home": app.home.open,
@@ -283,6 +296,11 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
}
match (serde_json::from_value::<crate::UiState>(cur), serde_json::from_value::<solidcraft_engine::Visibility>(vis)) {
(Ok(u), Ok(v)) => {
if (u.tab == "RENDER") != crate::render_workspace::active(app)
&& let Err(e) = crate::render_workspace::enter(app, u.tab == "RENDER")
{
return err(e);
}
app.ui = u;
app.session.visibility = v;
ok(serde_json::to_value(&app.ui).unwrap_or_default())
+89 -7
View File
@@ -6,6 +6,7 @@
use egui::{Color32, RichText, Sense, Stroke, vec2};
use serde_json::{Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::doc::pbr_appearance::PbrParameters;
use crate::SolveApp;
use crate::dialogs::{Kind, combo, row_label};
@@ -20,11 +21,12 @@ pub struct Look {
pub category: String,
pub color: [u8; 3],
pub opacity: f64,
pub pbr: Option<PbrParameters>,
}
/// Built-in looks, used until the engine offers a library (`appearance.library`).
fn builtin() -> Vec<Look> {
let l = |cat: &str, name: &str, c: [u8; 3], o: f64| Look { name: name.into(), category: cat.into(), color: c, opacity: o };
let l = |cat: &str, name: &str, c: [u8; 3], o: f64| Look { name: name.into(), category: cat.into(), color: c, opacity: o, pbr: None };
vec![
l("Metal", "Aluminum - Satin", [196, 199, 204], 1.0),
l("Metal", "Steel - Brushed", [160, 164, 170], 1.0),
@@ -64,6 +66,7 @@ pub fn library(app: &mut SolveApp) -> Vec<Look> {
category: x.get("category").and_then(Value::as_str).unwrap_or("Library").to_string(),
color: parse_color(x.get("color")?)?,
opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0),
pbr: x.get("pbr").and_then(|p| serde_json::from_value(p.clone()).ok()),
})
})
.collect();
@@ -98,6 +101,7 @@ pub struct Ap {
pub faces: bool,
pub color: [u8; 3],
pub opacity: f64,
pub pbr: Option<PbrParameters>,
/// The look picked last (its name), if the colour came from one.
pub look: Option<String>,
/// Library category shown (0: all).
@@ -141,6 +145,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
faces,
color: existing.as_ref().map_or([196, 199, 204], |l| l.color),
opacity: existing.as_ref().map_or(1.0, |l| l.opacity),
pbr: existing.as_ref().and_then(|l| l.pbr),
look: existing.map(|l| l.name),
category: 0,
search: String::new(),
@@ -200,11 +205,15 @@ pub fn commands(k: &Ap, inputs: &[SelInput]) -> Vec<(String, Value)> {
}
pub(super) fn selected_params(k: &Ap, items: &[Sel]) -> Option<Value> {
if let Some(component) = &k.component {
let mut p = if let Some(component) = &k.component {
Some(json!({"components": [component], "color": hex(k.color), "opacity": k.opacity, "appearance": k.look}))
} else {
apply_params(items, k.color, k.opacity, k.look.as_deref())
}?;
if let Some(pbr) = k.pbr {
p["pbr"] = json!(pbr);
}
Some(p)
}
/// Removing an override restores the inherited component or material appearance.
@@ -214,6 +223,7 @@ fn clear_params(k: &Ap, items: &[Sel]) -> Option<Value> {
obj.remove("appearance");
obj.remove("color");
obj.remove("opacity");
obj.remove("pbr");
}
Some(p)
}
@@ -244,6 +254,7 @@ fn in_design(app: &mut SolveApp) -> Vec<(Look, usize)> {
category: "In This Design".into(),
color: parse_color(x.get("color")?)?,
opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0),
pbr: x.get("pbr").and_then(|p| serde_json::from_value(p.clone()).ok()),
},
count(x, "bodies") + count(x, "faces") + count(x, "components"),
))
@@ -252,7 +263,7 @@ fn in_design(app: &mut SolveApp) -> Vec<(Look, usize)> {
}
let mut out: Vec<(Look, usize)> = Vec::new();
for l in app.session.doc.appearances.bodies.values() {
let look = Look { name: l.name.clone(), category: "In This Design".into(), color: l.color, opacity: l.opacity };
let look = Look { name: l.name.clone(), category: "In This Design".into(), color: l.color, opacity: l.opacity, pbr: l.pbr };
match out.iter_mut().find(|(x, _)| *x == look) {
Some(e) => e.1 += 1,
None => out.push((look, 1)),
@@ -347,10 +358,49 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
row_label(ui, "Opacity");
ui.spacing_mut().slider_width = 110.0;
// Decimal formatting must not round a saved look merely by drawing its editor.
if ui.add(egui::Slider::new(&mut k.opacity, 0.0..=1.0).max_decimals_opt(None).custom_formatter(|value, _| format!("{value:.2}"))).changed() {
if ui
.add_enabled(
k.pbr.is_none(),
egui::Slider::new(&mut k.opacity, 0.0..=1.0).max_decimals_opt(None).custom_formatter(|value, _| format!("{value:.2}")),
)
.changed()
{
k.remove_override = false;
}
ui.end_row();
row_label(ui, app.tr("appearance.surface_response"));
let mut enabled = k.pbr.is_some();
let toggle = ui.checkbox(&mut enabled, app.tr("appearance.pbr_opaque")).on_hover_text(app.tr("appearance.pbr_help"));
crate::scenario::publish_handle("appearance:pbr", toggle.rect.center());
if toggle.changed() {
k.pbr = enabled.then(PbrParameters::default);
if enabled {
k.opacity = 1.0;
}
k.remove_override = false;
}
ui.end_row();
if let Some(parameters) = k.pbr {
let mut metallic = parameters.metallic();
let mut roughness = parameters.roughness();
let mut ior = parameters.dielectric_ior();
for (label, handle, value, range) in [
("appearance.metallic", "appearance:metallic", &mut metallic, 0.0..=1.0),
("appearance.roughness", "appearance:roughness", &mut roughness, 0.045..=1.0),
("appearance.dielectric_ior", "appearance:ior", &mut ior, 1.0..=3.0),
] {
row_label(ui, app.tr(label));
let response = ui.add(egui::DragValue::new(value).range(range).speed(0.01));
crate::scenario::publish_handle(handle, response.rect.center());
if response.changed() {
k.remove_override = false;
}
ui.end_row();
}
if let Ok(parameters) = PbrParameters::new(metallic, roughness, ior) {
k.pbr = Some(parameters);
}
}
row_label(ui, "");
let picked: Vec<Sel> = inputs.first().map(|i| i.items.clone()).unwrap_or_default();
ui.horizontal(|ui| {
@@ -396,7 +446,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
egui::Id::new(("ap_used", *i)),
look.color,
look.opacity,
k.color == look.color && k.opacity == look.opacity,
k.color == look.color && k.opacity == look.opacity && k.pbr == look.pbr,
&format!("{} · {n} item(s)", look.name),
look.clone(),
)
@@ -404,6 +454,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
{
k.color = look.color;
k.opacity = look.opacity;
k.pbr = look.pbr;
k.look = Some(look.name.clone());
k.remove_override = false;
}
@@ -435,12 +486,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
ui.label(RichText::new(crate::i18n::tr_label("No matching appearances")).color(t.text_dim));
}
swatch_rows(ui, &shown, |ui, (i, l)| {
let picked_look = k.look.as_deref() == Some(l.name.as_str());
let picked_look = k.look.as_deref() == Some(l.name.as_str()) && k.pbr == l.pbr;
if swatch(ui, egui::Id::new(("ap_lib", *i)), l.color, l.opacity, picked_look, &format!("{} (drag onto a body or face)", l.name), (*l).clone())
.clicked()
{
k.color = l.color;
k.opacity = l.opacity;
k.pbr = l.pbr;
k.look = Some(l.name.clone());
k.remove_override = false;
}
@@ -452,9 +504,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
let targets = drop_target(app, k.faces);
k.color = look.color;
k.opacity = look.opacity;
k.pbr = look.pbr;
k.look = Some(look.name.clone());
k.remove_override = false;
if let Some(p) = apply_params(&targets, look.color, look.opacity, k.look.as_deref()) {
if let Some(mut p) = apply_params(&targets, look.color, look.opacity, k.look.as_deref()) {
if let Some(pbr) = look.pbr {
p["pbr"] = json!(pbr);
}
k.note = Some(match app.run("appearance.assign", p.clone()) {
Ok(_) => {
k.applied = Some(p);
@@ -500,6 +556,32 @@ mod tests {
SolveApp::new(s, Services::default())
}
#[test]
fn clicked_pbr_editor_commits_once_and_reopens_without_changing_mass() {
let mut h = crate::scenario::Harness::new();
h.call("ui.resize", json!({"width":1600,"height":1400}));
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
h.app.session.selection = vec![Sel::Body { name: "Body1".into() }];
let mass = h.app.session.execute("component.properties", &json!({})).unwrap();
let before = h.app.session.undo.len();
h.app.start("appearance.assign");
h.frames(3);
h.step(&json!({"click":{"handle":"appearance:pbr"}})).unwrap();
h.step(&json!({"widget":"Apply to selection"})).unwrap();
assert!(h.app.session.doc.appearances.bodies["Body1"].pbr.is_some());
assert_eq!(h.app.session.undo.len(), before + 1);
h.step(&json!({"widget":"OK"})).unwrap();
assert_eq!(h.app.session.undo.len(), before + 1);
assert_eq!(h.app.session.execute("component.properties", &json!({})).unwrap(), mass);
h.app.session.selection = vec![Sel::Body { name: "Body1".into() }];
h.app.start("appearance.assign");
let Kind::Appearance(k) = &h.app.dialog.as_ref().unwrap().kind else { panic!("appearance editor") };
assert_eq!(k.pbr, Some(PbrParameters::default()));
assert_eq!(k.opacity, 1.0);
let params = selected_params(k, &h.app.dialog.as_ref().unwrap().inputs[0].items).unwrap();
assert_eq!(params["pbr"]["roughness"], 0.5);
}
#[test]
fn colours_parse_both_ways() {
assert_eq!(parse_color(&json!("#0a1B2c")), Some([10, 27, 44]));
+56 -29
View File
@@ -7,9 +7,12 @@ use serde_json::json;
use solidcraft_engine::io::catalog::Catalog;
#[cfg(not(target_arch = "wasm32"))]
use solidcraft_engine::io::catalog::Definition;
use std::sync::Arc;
#[derive(Clone, Debug, Default)]
pub struct LocalLibrary {
/// A delayed picker may update only the editor that requested it.
token: Arc<()>,
pub search: String,
pub path: String,
pub catalog: Option<Catalog>,
@@ -22,15 +25,55 @@ fn display_order(mut catalog: Catalog) -> Catalog {
catalog
}
/// Runs `f` on the open Appearance dialog's state, once a file picker has answered.
fn with_state(app: &mut SolveApp, f: impl FnOnce(&mut SolveApp, &mut Ap)) {
let Some(mut dialog) = app.dialog.take() else { return };
if let crate::dialogs::Kind::Appearance(k) = &mut dialog.kind {
f(app, k);
}
if app.dialog.is_none() {
app.dialog = Some(dialog);
}
fn editor<'a>(app: &'a mut SolveApp, token: &Arc<()>) -> Option<&'a mut Ap> {
let crate::dialogs::Kind::Appearance(k) = &mut app.dialog.as_mut()?.kind else { return None };
Arc::ptr_eq(&k.local.token, token).then_some(k)
}
fn save_library(app: &mut SolveApp, token: Arc<()>, overwrite: bool) {
let origin = app.session.doc.clone();
app.pick_save("my-library.solidcraft-library.json", &["json"], move |app, path| {
let Some(path) = path else { return };
if !Arc::ptr_eq(&origin, &app.session.doc) {
app.set_status("The library export document changed while choosing its destination", true);
return;
}
match app.run("library.export", json!({"path":path,"overwrite":overwrite})) {
Ok(result) => {
let parsed = result.get("catalog").cloned().and_then(|v| serde_json::from_value::<Catalog>(v).ok()).map(display_order);
let count = parsed.as_ref().map_or(0, |c| c.entries.len());
if let Some(k) = editor(app, &token) {
k.local.path = path;
k.local.catalog = parsed;
k.local.selected = None;
k.local.search.clear();
k.note = Some(format!("Saved {count} reusable definitions. The design is unchanged."));
}
}
Err(error) => {
if let Some(k) = editor(app, &token) {
k.note = Some(error);
}
}
}
});
}
#[cfg(not(target_arch = "wasm32"))]
fn open_library(app: &mut SolveApp, token: Arc<()>) {
app.pick_open(move |app, path| {
let Some(path) = path else { return };
let Some(k) = editor(app, &token) else { return };
let mut local = k.local.clone();
local.path = path;
local.catalog = None;
local.selected = None;
let note = load(app, &mut local).unwrap_or_else(|error| error);
if let Some(k) = editor(app, &token) {
k.local = local;
k.note = Some(note);
}
});
}
/// Exports the design's library to `path` and shows it as the local library.
@@ -74,17 +117,10 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelInpu
let save = ui.button(crate::i18n::tr_label("Export design library..."));
crate::scenario::publish_handle("library:export", save.rect.center());
if save.clicked() {
let overwrite = k.local.overwrite;
if app.services.pick_save.is_some() {
// The picker answers in a later frame (#1088); the export runs then.
app.pick_save("my-library.solidcraft-library.json", &["json"], move |app, path| {
if let Some(path) = path {
with_state(app, |app, k| export(app, k, path, overwrite));
}
});
save_library(app, k.local.token.clone(), k.local.overwrite);
} else if !k.local.path.is_empty() {
let path = k.local.path.clone();
export(app, k, path, overwrite);
export(app, k, k.local.path.clone(), k.local.overwrite);
}
}
ui.label(
@@ -106,17 +142,7 @@ fn import_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelI
}
ui.horizontal(|ui| {
if ui.button(crate::i18n::tr_label("Open library...")).clicked() {
// The picker answers in a later frame (#1088); the library loads then.
app.pick_open(|app, path| {
if let Some(path) = path {
with_state(app, |app, k| {
k.local.path = path;
k.local.catalog = None;
k.local.selected = None;
k.note = Some(load(app, &mut k.local).unwrap_or_else(|error| error));
});
}
});
open_library(app, k.local.token.clone());
}
let load_button = ui.add_enabled(!k.local.path.is_empty(), egui::Button::new(crate::i18n::tr_label("Load library")));
crate::scenario::publish_handle("library:load", load_button.rect.center());
@@ -194,6 +220,7 @@ fn import_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelI
Ok(_) => {
if let Definition::Appearance(look) = entry.definition {
k.color = look.color;
k.pbr = None;
k.opacity = look.opacity;
k.look = Some(look.name.clone());
k.remove_override = false;
+126 -1
View File
@@ -12,7 +12,13 @@ fn catalog_clicked_load_apply_export_and_ok_keep_one_design_undo_step() {
source.execute("appearance.assign", &json!({"bodies":["Body1"],"appearance":"Warm oak colour","color":[180,120,70],"opacity":0.8})).unwrap();
source.execute("library.export", &json!({"path":path})).unwrap();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
h.app.run("appearance.assign", json!({"bodies":["Body1"],"color":[20,20,20]})).unwrap();
h.app
.run(
"appearance.assign",
json!({"bodies":["Body1"],"color":[20,20,20],
"pbr":{"metallic":1.0,"roughness":0.3,"dielectric_ior":1.5}}),
)
.unwrap();
h.app.session.mark_saved();
h.app.session.selection = vec![Sel::Body { name: "Body1".into() }];
h.app.start("appearance.assign");
@@ -27,6 +33,7 @@ fn catalog_clicked_load_apply_export_and_ok_keep_one_design_undo_step() {
h.step(&json!({"click":{"handle":"library:entry:0"}})).unwrap();
h.step(&json!({"widget":"Apply library appearance"})).unwrap();
assert_eq!(h.app.session.doc.appearances.bodies["Body1"].name, "Warm oak colour");
assert!(h.app.session.doc.appearances.bodies["Body1"].pbr.is_none());
assert_eq!(h.app.session.undo.len(), undo + 1);
let revision = h.app.session.revision;
h.step(&json!({"widget":"Export design library..."})).unwrap();
@@ -39,3 +46,121 @@ fn catalog_clicked_load_apply_export_and_ok_keep_one_design_undo_step() {
assert!(h.app.dialog.is_none());
assert_eq!(h.app.session.undo.len(), undo + 1);
}
fn appearance_fixture() -> Result<(Harness, Arc<()>), String> {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10}))?;
h.app.run("appearance.assign", json!({"bodies":["Body1"],"appearance":"Warm oak colour","color":[180,120,70]}))?;
h.app.session.mark_saved();
let token = start_appearance(&mut h)?;
Ok((h, token))
}
fn start_appearance(h: &mut Harness) -> Result<Arc<()>, String> {
h.app.start("appearance.assign");
match h.app.dialog.as_ref().map(|dialog| &dialog.kind) {
Some(Kind::Appearance(k)) => Ok(k.local.token.clone()),
_ => Err("appearance dialog missing".into()),
}
}
#[test]
fn async_library_export_holds_its_document_until_the_answer() {
use crate::picker_test_support::{Slot, save};
let (mut h, token) = appearance_fixture().unwrap();
let original = h.app.session.doc.clone();
let path = h.dir.join("delayed-library.json");
let ctx = egui::Context::default();
let slot: Slot = Default::default();
save(&mut h.app, &slot);
save_library(&mut h.app, token, false);
crate::file_picker::tick(&mut h.app, &ctx);
assert!(!path.exists());
crate::documents::new_design(&mut h.app);
let other = h.app.session.doc.clone();
*slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned()));
crate::file_picker::tick(&mut h.app, &ctx);
assert_eq!(h.app.docs.active, 0, "upstream holds tab changes while a picker is pending");
assert!(Arc::ptr_eq(&other, &h.app.session.doc));
let catalog = Catalog::read(&path.to_string_lossy()).unwrap();
assert_eq!(catalog.entries.len(), 1);
assert_eq!(catalog.entries[0].definition.name(), "Warm oak colour");
crate::documents::switch(&mut h.app, 0);
assert!(Arc::ptr_eq(&original, &h.app.session.doc));
assert!(!h.app.session.is_dirty());
}
#[test]
fn async_library_export_captures_overwrite_consent_and_cancel_writes_nothing() {
use crate::picker_test_support::{Slot, save};
let (mut h, token) = appearance_fixture().unwrap();
let path = h.dir.join("existing-library.json");
std::fs::write(&path, b"keep this file").unwrap();
let original = h.app.session.doc.clone();
let ctx = egui::Context::default();
let slot: Slot = Default::default();
save(&mut h.app, &slot);
save_library(&mut h.app, token.clone(), false);
crate::file_picker::tick(&mut h.app, &ctx);
editor(&mut h.app, &token).unwrap().local.overwrite = true;
*slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned()));
crate::file_picker::tick(&mut h.app, &ctx);
assert_eq!(std::fs::read(&path).unwrap(), b"keep this file");
assert!(matches!(&h.app.status, Some((_, _, true))));
save_library(&mut h.app, token, false);
crate::file_picker::tick(&mut h.app, &ctx);
*slot.borrow_mut() = Some(None);
crate::file_picker::tick(&mut h.app, &ctx);
assert!(!h.app.file_picker_busy());
assert_eq!(std::fs::read(&path).unwrap(), b"keep this file");
assert!(Arc::ptr_eq(&original, &h.app.session.doc));
}
#[test]
fn async_library_open_does_not_replace_a_new_appearance_editor() {
use crate::picker_test_support::{Slot, save};
let (mut h, token) = appearance_fixture().unwrap();
let path = h.dir.join("source-library.json");
h.app.run("library.export", json!({"path":path})).unwrap();
let original = h.app.session.doc.clone();
let ctx = egui::Context::default();
let slot: Slot = Default::default();
save(&mut h.app, &slot);
// Prepare a different editor before the picker holds user commands, then model
// an internal host replacing it while the old asynchronous answer is pending.
let original_editor = h.app.dialog.take();
let newer = start_appearance(&mut h).unwrap();
editor(&mut h.app, &newer).unwrap().local.path = "new editor draft".into();
let replacement_editor = h.app.dialog.take();
h.app.dialog = original_editor;
crate::picker_test_support::open(&mut h.app, &slot);
open_library(&mut h.app, token);
crate::file_picker::tick(&mut h.app, &ctx);
h.app.dialog = replacement_editor;
*slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned()));
crate::file_picker::tick(&mut h.app, &ctx);
let k = editor(&mut h.app, &newer).unwrap();
assert_eq!(k.local.path, "new editor draft");
assert!(k.local.catalog.is_none());
assert!(Arc::ptr_eq(&original, &h.app.session.doc));
}
#[test]
fn delayed_library_export_refuses_an_internally_changed_design_without_overwriting() {
use crate::picker_test_support::{Slot, save};
let (mut h, token) = appearance_fixture().unwrap();
let path = h.dir.join("keep.json");
std::fs::write(&path, b"keep library").unwrap();
let slot: Slot = Default::default();
save(&mut h.app, &slot);
save_library(&mut h.app, token, true);
h.app.session.execute("appearance.assign", &json!({"bodies":["Body1"],"color":[1,2,3]})).unwrap();
let changed = h.app.session.doc.clone();
let history = (h.app.session.undo.len(), h.app.session.revision);
*slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned()));
crate::file_picker::tick(&mut h.app, &egui::Context::default());
assert_eq!(std::fs::read(path).unwrap(), b"keep library");
assert!(Arc::ptr_eq(&changed, &h.app.session.doc));
assert_eq!((h.app.session.undo.len(), h.app.session.revision), history);
assert!(matches!(&h.app.status, Some((_, _, true))));
}
+9
View File
@@ -27,6 +27,7 @@ pub struct DocSlot {
/// The per-design part of the UI state (what is hidden, shown or locked).
#[derive(Clone, Debug, Default)]
struct DocView {
render: crate::render_workspace::ViewState,
sketch_choices: crate::sketch_families::Choices,
hidden_profiles: Vec<u64>,
shown_dims: Vec<u64>,
@@ -64,6 +65,7 @@ impl Documents {
fn take_view(app: &mut SolveApp) -> DocView {
let u = &mut app.ui;
DocView {
render: std::mem::take(&mut app.render_workspace.view),
sketch_choices: std::mem::take(&mut app.sketch_choices),
hidden_profiles: std::mem::take(&mut u.hidden_profiles),
shown_dims: std::mem::take(&mut u.shown_dims),
@@ -72,6 +74,9 @@ fn take_view(app: &mut SolveApp) -> DocView {
}
fn put_view(app: &mut SolveApp, v: DocView) {
app.render_workspace.view = v.render;
crate::render_workspace::invalidate_scene(app);
crate::render_workspace::sync_backend(app);
app.sketch_choices = v.sketch_choices;
let u = &mut app.ui;
u.hidden_profiles = v.hidden_profiles;
@@ -81,6 +86,9 @@ fn put_view(app: &mut SolveApp, v: DocView) {
/// Put `slot` into the app and return what was there.
fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
if crate::render_workspace::active(app) {
let _ = crate::render_workspace::enter(app, false);
}
// Whatever was going on in the design leaves with it.
crate::sketch_text_move::cancel_drag(app);
crate::dialogs::cancel(app);
@@ -162,6 +170,7 @@ pub fn adopt(app: &mut SolveApp, session: Session) {
app.menu.props = None;
crate::sketch_attachment_ui::clear(app);
app.session.replace_design(session);
crate::render_workspace::reset_document(app);
app.sketch_choices = crate::sketch_families::Choices::default();
app.session.invalidate_view(rev);
app.cam = Camera::default();
+79 -5
View File
@@ -11,6 +11,9 @@ use egui_wgpu::wgpu::util::DeviceExt;
/// Triangle vertex: position (3 × f32), normal (3 × f32), sRGB colour (4 × u8).
pub const TRI_SIZE: usize = 28;
#[path = "pbr_gpu.rs"]
mod pbr;
pub const PBR_SIZE: usize = pbr::VERTEX_SIZE;
/// Line instance: a, b (3 × f32 each), colour (4 × u8), width in pixels (f32).
pub const LINE_SIZE: usize = 32;
/// The shader's `U`: the view-projection matrix, 9 `vec4`s, the 8 analysis colours and their
@@ -31,6 +34,8 @@ pub struct PlannedBody {
pub struct GpuScene {
/// Opaque triangles. A zero normal draws the colour flat (unlit).
pub tris: Vec<u8>,
/// Opaque product PBR vertices; linear base color and explicit optical parameters.
pub pbr: Vec<u8>,
/// Translucent triangles (alpha from the colour), drawn after the opaque ones without
/// writing depth.
pub trans: Vec<u8>,
@@ -99,6 +104,13 @@ pub struct BlockBytes {
}
impl GpuScene {
pub fn pbr_tri(&mut self, p: [f32; 3], n: [f32; 3], surface: solidcraft_engine::render::surface::OpaqueSurface) {
let base = surface.base_color().to_f32();
let parameters = [surface.metallic() as f32, surface.roughness() as f32, surface.dielectric_ior() as f32];
for value in p.into_iter().chain(n).chain(base).chain(parameters) {
self.pbr.extend_from_slice(&value.to_le_bytes());
}
}
pub fn tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) {
for v in p.iter().chain(&n) {
self.tris.extend_from_slice(&v.to_le_bytes());
@@ -176,6 +188,8 @@ const IMG_SIZE: usize = 24;
/// Per-frame parameters.
pub struct ViewportCallback {
/// Product preview uses its own batches, retaining the Design viewport's uploads.
pub product: bool,
pub key: u64,
pub slot: SceneSlot,
pub sketch_key: u64,
@@ -265,6 +279,7 @@ struct Batches {
blocks: std::collections::HashMap<u64, BlockGpu>,
key: Option<u64>,
tris: Option<Batch>,
pbr: Option<Batch>,
trans: Option<Batch>,
glass: Option<Batch>,
/// The glass triangles as built, and the view direction they were last sorted for.
@@ -315,6 +330,7 @@ impl Batches {
}
}
self.tris = joined(blocks.iter().map(|b| &b.tris), upload(device, "sc_tris", &sc.tris, TRI_SIZE));
self.pbr = upload(device, "sc_pbr", &sc.pbr, PBR_SIZE);
self.trans = joined(blocks.iter().map(|b| &b.trans), upload(device, "sc_trans", &sc.trans, TRI_SIZE));
self.glass = upload_with(device, "sc_glass", &sc.glass, TRI_SIZE, wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST);
self.glass_cpu = sc.glass;
@@ -401,6 +417,7 @@ struct Resources {
tri_cut: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
/// Highlight triangles over the model (depth test ≤, no depth write).
tri_hl: wgpu::RenderPipeline,
pbr: wgpu::RenderPipeline,
trans: wgpu::RenderPipeline,
/// Glass: far sides (front faces culled), then near sides (back faces culled).
glass_far: wgpu::RenderPipeline,
@@ -415,6 +432,8 @@ struct Resources {
bind: wgpu::BindGroup,
linear_out: bool,
model: Batches,
product: Batches,
empty: Batches,
sketch: Batches,
highlight: Batches,
preview: Batches,
@@ -471,6 +490,11 @@ fn tri_layout() -> wgpu::VertexBufferLayout<'static> {
/// A planned body's vertices as the GPU evaluation writes them: the position, then the normal
/// at [`solidcraft_gpu::NORMAL_OFFSET`] (each padded to four floats).
fn pbr_layout() -> wgpu::VertexBufferLayout<'static> {
const A: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x3, 3 => Float32x3];
wgpu::VertexBufferLayout { array_stride: PBR_SIZE as u64, step_mode: wgpu::VertexStepMode::Vertex, attributes: &A }
}
fn planned_layout() -> wgpu::VertexBufferLayout<'static> {
const A: [wgpu::VertexAttribute; 2] = [
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x3, offset: 0, shader_location: 0 },
@@ -502,8 +526,9 @@ pub struct GpuTarget {
impl Resources {
fn new(device: &wgpu::Device, t: GpuTarget, tess: Option<solidcraft_gpu::TessGpu>) -> Self {
let source = format!("{SHADER}\n{}", pbr::SOURCE);
let module =
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("sc_viewport"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("sc_viewport"), source: wgpu::ShaderSource::Wgsl(source.into()) });
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("sc_viewport"),
entries: &[wgpu::BindGroupLayoutEntry {
@@ -730,6 +755,7 @@ impl Resources {
access_bind,
access_version: 0,
tri_hl: pipeline("sc_tris_hl", "vs_tri", "fs_tri", tri_layout(), highlight_depth(t.depth), alpha),
pbr: pipeline("sc_pbr", "vs_pbr", "fs_pbr", pbr_layout(), depth(wgpu::CompareFunction::Less, true), None),
trans: pipeline("sc_trans", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
glass_far: glass("sc_glass_far", wgpu::Face::Front),
glass_near: glass("sc_glass_near", wgpu::Face::Back),
@@ -742,6 +768,8 @@ impl Resources {
bind,
linear_out: t.format.is_srgb(),
model: Batches::default(),
product: Batches::default(),
empty: Batches::default(),
sketch: Batches::default(),
highlight: Batches::default(),
preview: Batches::default(),
@@ -875,6 +903,12 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
resources: &mut egui_wgpu::CallbackResources,
) -> Vec<wgpu::CommandBuffer> {
let Some(res) = resources.get_mut::<Resources>() else { return Vec::new() };
if self.product {
res.product.take(device, self.key, &self.slot, None);
res.product.sort_glass(queue, self.back);
queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, self.size_px[0], self.size_px[1], res.linear_out));
return Vec::new();
}
res.model.take(device, self.key, &self.slot, res.tess.as_ref().map(|t| (t, &mut *encoder)));
res.sketch.take(device, self.sketch_key, &self.sketch_slot, None);
res.highlight.take(device, self.hl_key, &self.hl_slot, None);
@@ -965,7 +999,8 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
}
}
};
let (m, sk, h, pv) = (&res.model, &res.sketch, &res.highlight, &res.preview);
let (m, sk, h, pv) =
if self.product { (&res.product, &res.empty, &res.empty, &res.empty) } else { (&res.model, &res.sketch, &res.highlight, &res.preview) };
lines(pass, &res.overlay, &m.under);
lines(pass, &res.overlay, &sk.under);
pass.set_bind_group(1, &res.access_bind, &[]);
@@ -995,6 +1030,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
planned(pass, &res.tri_planned);
}
}
tris(pass, &res.pbr, &m.pbr);
tris(pass, &res.tri_hl, &h.tris);
lines(pass, &res.line, &m.lines);
lines(pass, &res.line, &sk.lines);
@@ -1002,7 +1038,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
lines(pass, &res.line_hidden, &h.hidden);
lines(pass, &res.line, &pv.lines);
lines(pass, &res.line, &h.lines);
for (id, q) in &res.quads {
for (id, q) in res.quads.iter().filter(|_| !self.product) {
if let Some((_, bind)) = res.textures.get(id) {
pass.set_pipeline(&res.image);
pass.set_bind_group(1, bind, &[]);
@@ -1066,14 +1102,52 @@ pub(crate) fn render_offscreen(
analysis: [f32; 8],
colors: [f32; 36],
) -> Option<Vec<u8>> {
offscreen(scene, highlight, view_proj, back, eye, size, Offscreen { tess, product: false, srgb: false, analysis, analysis_colors: colors })
}
#[cfg(all(test, not(target_arch = "wasm32")))]
pub(crate) fn render_product_offscreen(
scene: GpuScene,
view_proj: [[f32; 4]; 4],
back: [f32; 3],
eye: [f32; 4],
size: u32,
srgb: bool,
) -> Option<Vec<u8>> {
offscreen(scene, None, view_proj, back, eye, size, Offscreen { tess: None, product: true, srgb, analysis: [0.0; 8], analysis_colors: [0.0; 36] })
}
#[cfg(all(test, not(target_arch = "wasm32")))]
struct Offscreen<'a> {
tess: Option<&'a str>,
product: bool,
srgb: bool,
analysis: [f32; 8],
analysis_colors: [f32; 36],
}
#[cfg(all(test, not(target_arch = "wasm32")))]
fn offscreen(
scene: GpuScene,
highlight: Option<GpuScene>,
view_proj: [[f32; 4]; 4],
back: [f32; 3],
eye: [f32; 4],
size: u32,
options: Offscreen<'_>,
) -> Option<Vec<u8>> {
let Offscreen { tess, product, srgb, analysis, analysis_colors } = options;
let (adapter, device, queue) = offscreen_device()?;
let (format, depth) = (wgpu::TextureFormat::Rgba8Unorm, wgpu::TextureFormat::Depth24PlusStencil8);
eprintln!("offscreen adapter: {:?}", adapter.get_info().backend);
let format = if srgb { wgpu::TextureFormat::Rgba8UnormSrgb } else { wgpu::TextureFormat::Rgba8Unorm };
let depth = wgpu::TextureFormat::Depth24PlusStencil8;
let compute = tess.and_then(|shader| tessellation(&adapter, &device, &queue, shader));
let target = GpuTarget { format, depth: Some(depth), samples: 1, tess: compute.is_some() };
let mut resources = egui_wgpu::CallbackResources::default();
resources.insert(Resources::new(&device, target, compute));
let none = || Arc::new(Mutex::new(None));
let cb = ViewportCallback {
product,
key: 1,
slot: Arc::new(Mutex::new(Some(scene))),
sketch_key: 0,
@@ -1089,7 +1163,7 @@ pub(crate) fn render_offscreen(
cap: [0.0; 4],
images: Vec::new(),
analysis,
analysis_colors: colors,
analysis_colors,
access: None,
outline: false,
eye,
+65 -6
View File
@@ -98,6 +98,7 @@ pub mod preview;
mod primitive_units_tests;
pub mod recovery_ui;
pub mod ref_images;
pub mod render_workspace;
#[cfg(test)]
mod revolve_edit_tests;
#[cfg(test)]
@@ -217,7 +218,7 @@ impl Default for UiState {
}
}
/// Host services (file pickers) injected by the app so this crate stays portable.
/// Host services injected by the app so this crate stays portable.
#[derive(Default)]
pub struct Services {
pub pick_open: Option<Box<dyn Fn() -> FilePickerFuture>>,
@@ -247,6 +248,7 @@ pub struct SolveApp {
pub services: Services,
file_picker: Option<file_picker::Pending>,
pub viewport: viewport::ViewportState,
pub render_workspace: render_workspace::State,
pub tool: Option<tools::Tool>,
pub dialog: Option<dialogs::Dialog>,
/// The Change Parameters window, when open.
@@ -331,7 +333,16 @@ pub struct SolveApp {
}
impl SolveApp {
pub fn new(session: Session, services: Services) -> Self {
pub fn new(mut session: Session, services: Services) -> Self {
#[cfg(test)]
let image_host = Some(solidcraft_engine::render::job::CpuHost::cooperative());
#[cfg(not(test))]
let image_host = solidcraft_engine::render::job::CpuHost::for_platform().map_err(|e| log::error!("render image host: {e}")).ok();
if let Err(e) = session.set_render_job_host(image_host.clone()) {
log::error!("render image host: {e}");
}
let mut render_workspace = render_workspace::State::default();
render_workspace.image_host = image_host;
SolveApp {
session,
ui: UiState::default(),
@@ -343,6 +354,7 @@ impl SolveApp {
services,
file_picker: None,
viewport: viewport::ViewportState::default(),
render_workspace,
tool: None,
dialog: None,
params: None,
@@ -408,6 +420,19 @@ impl SolveApp {
/// stencil buffer bits and MSAA sample count). Without it the viewport renders on the CPU.
pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32) {
self.viewport.gpu = Some(gpu::install(rs, depth_bits, stencil_bits, samples, !self.cpu_meshing));
render_workspace::invalidate_scene(self);
self.render_workspace.host = match render_workspace::host_executor(
rs.device.limits().max_texture_dimension_2d,
self.viewport.gpu.is_some_and(|g| g.depth.is_some()),
samples.max(1),
) {
Ok(executor) => Some(executor),
Err(error) => {
self.set_status(error, true);
None
}
};
render_workspace::sync_backend(self);
self.msaa_supported = rs.msaa_supported.clone();
let info = rs.adapter.get_info();
self.help.gpu = Some(format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type));
@@ -541,6 +566,7 @@ impl SolveApp {
let label = language.label(command_label(id));
self.session.echo(language.format("ui.command_done", &[("command", label)]));
if matches!(id, "file.new" | "doc.open" | "doc.recover") {
render_workspace::reset_document(self);
self.sketch_choices = sketch_families::Choices::default();
dim_view::cancel();
self.menu.props = None;
@@ -882,6 +908,15 @@ impl SolveApp {
}
pub fn fit_view(&mut self) {
if render_workspace::active(self) {
self.cancel_view_animation();
match solidcraft_engine::product_scene::extract(&self.session) {
Ok(scene) if !scene.bounds.is_empty() => self.cam.fit(&scene.bounds),
Err(e) => self.set_status(e.to_string(), true),
_ => {}
}
return;
}
self.cam_anim = None;
self.viewport.orbit_snap = None;
self.viewport.orbit_free = None;
@@ -960,6 +995,13 @@ impl SolveApp {
/// Animated view changes for the axis view and navigation bar.
pub fn animate_view(&mut self, v: &str) {
if render_workspace::active(self) && matches!(v, "fit" | "home") {
if v == "home" {
self.cam.set_view(StandardView::Iso);
}
self.fit_view();
return;
}
self.pending_fit = None;
let mut to = self.cam;
match v {
@@ -993,7 +1035,11 @@ impl SolveApp {
/// change, or at once (control requests, so a screenshot right after shows it).
pub fn follow_view_request(&mut self, animate: bool) {
let Some(mut to) = self.session.camera.take() else { return };
to.up = self.cam.up;
if render_workspace::active(self) {
self.ui.up_axis = to.up;
} else {
to.up = self.cam.up;
}
if animate {
self.animate_to(to);
} else if to.is_valid() {
@@ -1033,6 +1079,8 @@ impl SolveApp {
/// Per-frame logic before layout.
pub fn logic(&mut self, ctx: &egui::Context) {
file_picker::tick(self, ctx);
render_workspace::sync_backend(self);
render_workspace::tick(self, ctx);
file_load::tick(self, ctx);
self.esc_handled = false;
prefs::apply(self, ctx);
@@ -1143,8 +1191,9 @@ impl SolveApp {
}
}
toolbar::toolbar(self, ui);
let left_timeline = self.ui.show_timeline && self.preferences.timeline_left;
if self.ui.show_timeline && !left_timeline {
let show_timeline = self.ui.show_timeline && !render_workspace::active(self);
let left_timeline = show_timeline && self.preferences.timeline_left;
if show_timeline && !left_timeline {
timeline::timeline(self, ui);
}
palette::palette_bar(self, ui);
@@ -1157,9 +1206,16 @@ impl SolveApp {
} else if left_timeline {
timeline_left::left_panel(self, ui);
}
if render_workspace::active(self) {
render_workspace::panel(self, ui);
}
preview::update(self, ui.ctx());
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| {
viewport::show(self, ui);
if render_workspace::active(self) {
render_workspace::show(self, ui);
} else {
viewport::show(self, ui);
}
});
dialogs::show(self, ui.ctx());
issue_report::CadInput::block(self, ui, report_input);
@@ -1303,3 +1359,6 @@ pub fn now_ms() -> f64 {
use web_time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
}
#[cfg(test)]
mod picker_test_support;
+3 -2
View File
@@ -660,11 +660,12 @@ fn toolbar_pins_removes_reorders_and_keeps_its_layout() {
}
#[test]
fn the_workspace_menu_lists_design_and_greys_the_rest() {
fn the_workspace_menu_lists_design_and_render_and_greys_future_workspaces() {
let app = sample_app();
let items = context_menu::items(&app, &Target::Workspace);
assert!(find(&items, "Design").enabled);
assert!(!find(&items, "Render").enabled && find(&items, "Render").shortcut == "coming later");
assert!(find(&items, "Render").enabled);
assert!(!find(&items, "Animation").enabled);
assert!(!items.iter().any(|i| i.label.contains("Generative")));
}
+78
View File
@@ -0,0 +1,78 @@
//! Original GPU translation of the L1 opaque surface and fixed studio reference.
//! Appended to the existing viewport shader so camera/output conventions are shared.
pub const VERTEX_SIZE: usize = 48;
pub const SOURCE: &str = r#"
struct PbrOut {
@builtin(position) pos: vec4<f32>,
@location(0) n: vec3<f32>,
@location(1) base: vec3<f32>,
@location(2) wp: vec3<f32>,
@location(3) parameters: vec3<f32>,
};
fn pbr_unit(v: vec3<f32>) -> vec3<f32> {
let scale = max(max(abs(v.x), abs(v.y)), abs(v.z));
if (scale == 0.0) { return vec3<f32>(0.0, 0.0, 1.0); }
let s = v / scale;
return s / length(s);
}
fn pbr_encode(c: vec3<f32>) -> vec3<f32> {
return select(c * 12.92, 1.055 * pow(c, vec3<f32>(1.0 / 2.4)) - vec3<f32>(0.055), c > vec3<f32>(0.0031308));
}
@vertex
fn vs_pbr(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) base: vec3<f32>, @location(3) parameters: vec3<f32>) -> PbrOut {
var o: PbrOut;
o.pos = u.vp * vec4<f32>(p, 1.0);
o.n = n;
o.base = base;
o.wp = p;
o.parameters = parameters;
return o;
}
fn pbr_fresnel(f0: vec3<f32>, cosine: f32) -> vec3<f32> {
let t = 1.0 - cosine;
return f0 + (vec3<f32>(1.0) - f0) * (t * t * t * t * t);
}
fn pbr_brdf(base: vec3<f32>, parameters: vec3<f32>, n: vec3<f32>, v: vec3<f32>, l: vec3<f32>) -> vec3<f32> {
let nv = clamp(dot(n, v), -1.0, 1.0);
let nl = clamp(dot(n, l), -1.0, 1.0);
if (nv <= 0.0 || nl <= 0.0) { return vec3<f32>(0.0); }
let h = pbr_unit(v + l);
let nh = clamp(dot(n, h), 0.0, 1.0);
let vh = clamp(dot(v, h), 0.0, 1.0);
let alpha = parameters.y * parameters.y;
let a2 = alpha * alpha;
let denominator = (1.0 - nh * nh) + nh * nh * a2;
let distribution = a2 / (3.141592653589793 * denominator * denominator);
let gv = 2.0 / (nv + sqrt(a2 + (1.0 - a2) * nv * nv));
let gl = 2.0 / (nl + sqrt(a2 + (1.0 - a2) * nl * nl));
let ratio = (parameters.z - 1.0) / (parameters.z + 1.0);
let f0 = mix(vec3<f32>(ratio * ratio), base, parameters.x);
let specular = pbr_fresnel(f0, vh) * (distribution * 0.25 * gv * gl);
let diffuse = base * ((1.0 - parameters.x) / 3.141592653589793);
return specular + (vec3<f32>(1.0) - pbr_fresnel(f0, nv)) * (vec3<f32>(1.0) - pbr_fresnel(f0, nl)) * diffuse;
}
@fragment
fn fs_pbr(i: PbrOut) -> @location(0) vec4<f32> {
let n = pbr_unit(i.n);
var view = u.back.xyz;
if (u.eye.w > 0.5) { view = u.eye.xyz - i.wp; }
let v = pbr_unit(view);
let lights = array<vec4<f32>, 3>(vec4<f32>(0.6, 0.0, 0.8, 3.0), vec4<f32>(-0.8, 0.0, 0.6, 1.0), vec4<f32>(0.0, -0.6, 0.8, 1.5));
var radiance = vec3<f32>(0.0);
for (var k = 0u; k < 3u; k += 1u) {
let light = lights[k];
radiance += pbr_brdf(i.base, i.parameters, n, v, light.xyz) * (light.w * max(dot(n, light.xyz), 0.0));
}
let mapped = radiance / (vec3<f32>(1.0) + radiance);
if (u.back.w > 0.5) { return vec4<f32>(mapped, 1.0); }
return vec4<f32>(pbr_encode(mapped), 1.0);
}
"#;
+25
View File
@@ -0,0 +1,25 @@
//! Controlled host futures shared by Render and library picker regressions.
use crate::{SolveApp, file_picker::FilePickerFuture};
use std::{
cell::RefCell,
future::Future,
pin::Pin,
rc::Rc,
task::{Context, Poll},
};
pub type Slot = Rc<RefCell<Option<Option<String>>>>;
struct Manual(Slot);
impl Future for Manual {
type Output = Option<String>;
fn poll(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Self::Output> {
self.0.borrow_mut().take().map_or(Poll::Pending, Poll::Ready)
}
}
pub fn save(app: &mut SolveApp, slot: &Slot) {
let slot = slot.clone();
app.services.pick_save = Some(Box::new(move |_, _| Box::pin(Manual(slot.clone())) as FilePickerFuture));
}
pub fn open(app: &mut SolveApp, slot: &Slot) {
let slot = slot.clone();
app.services.pick_open = Some(Box::new(move || Box::pin(Manual(slot.clone())) as FilePickerFuture));
}
+535
View File
@@ -0,0 +1,535 @@
//! Integrated product preview. Shares the installed device/pipelines, not Design's scene policy.
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex};
use egui::{Color32, Sense};
use serde_json::json;
use solidcraft_engine::doc::render_settings::RenderSetup;
use solidcraft_engine::render::{
Availability, Camera, ExecutorDescriptor, ExecutorId, ProductScene, RenderLimits, RenderMode, RenderOperation, RenderProfile, RenderRequest,
ScratchLayout,
};
use crate::gpu::{GpuScene, SceneSlot, ViewportCallback};
use crate::{SolveApp, theme::Tokens};
const MAX_GEOMETRY_BYTES: usize = 128 * 1024 * 1024;
#[cfg(test)]
#[path = "render_workspace_tests.rs"]
mod tests;
pub struct State {
pub host: Option<ExecutorDescriptor>,
pub image_host: Option<solidcraft_engine::render::job::CpuHost>,
pub view: ViewState,
revision: u64,
design_camera: Option<(Camera, bool)>,
slot: SceneSlot,
key: Option<u64>,
scene_signature: Option<(u64, usize, u64)>,
scene: Option<ProductScene>,
}
/// Small per-document editor state; GPU uploads and host capabilities stay outside tabs.
#[derive(Clone, Debug)]
pub struct ViewState {
pub name: String,
draft: RenderSetup,
dirty: bool,
job: Option<solidcraft_engine::render::job::JobId>,
completed: Option<solidcraft_engine::render::job::JobId>,
transparent: bool,
overwrite: bool,
}
impl Default for ViewState {
fn default() -> Self {
Self { name: "Studio".into(), draft: RenderSetup::default(), dirty: false, job: None, completed: None, transparent: false, overwrite: false }
}
}
impl Default for State {
fn default() -> Self {
Self {
host: None,
image_host: None,
view: Default::default(),
revision: u64::MAX,
design_camera: None,
slot: Arc::new(Mutex::new(None)),
key: None,
scene_signature: None,
scene: None,
}
}
}
pub fn active(app: &SolveApp) -> bool {
app.ui.tab == "RENDER"
}
pub fn host_executor(max_dimension: u32, depth: bool, samples: u32) -> Result<ExecutorDescriptor, String> {
if !matches!(samples, 1 | 2 | 4 | 8 | 16) {
return Err("unsupported GPU sample count".into());
}
Ok(ExecutorDescriptor {
id: ExecutorId::new("gpu-viewport").map_err(|e| e.to_string())?,
availability: if depth && max_dimension > 0 {
Availability::Available
} else {
Availability::Unavailable { reason: "product preview requires an installed device with a depth buffer".into() }
},
profiles: vec![RenderProfile {
mode: RenderMode::Preview,
operation: RenderOperation::Viewport,
formats: Vec::new(),
features: vec![solidcraft_engine::render::RenderFeature::Pbr],
}],
limits: RenderLimits { max_dimension: u64::from(max_dimension.clamp(1, 4096)), ..Default::default() },
scratch: ScratchLayout {
sample_scale: 1,
sample_bytes_per_pixel: 8 * u64::from(samples),
output_bytes_per_pixel: 8,
encoding_bytes_per_pixel: 0,
},
})
}
pub fn sync_backend(app: &mut SolveApp) {
if app.session.render_job_host().is_none()
&& let Some(host) = &app.render_workspace.image_host
&& let Err(e) = app.session.set_render_job_host(Some(host.clone()))
{
app.set_status(e.to_string(), true);
}
if app.session.render_viewport() == app.render_workspace.host.as_ref() {
return;
}
if let Err(e) = app.session.set_render_viewport(app.render_workspace.host.clone()) {
app.set_status(e.to_string(), true);
}
}
/// Cooperative work belongs to the frame loop, never to a status/control request. Keep
/// scheduling frames even after leaving Render so a browser job can finish in Design.
pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
if let Some(host) = &app.render_workspace.image_host {
match host.pump(1024) {
Ok(true) => ctx.request_repaint(),
Ok(false) => {}
Err(e) => app.set_status(e.to_string(), true),
}
}
}
pub fn reset_document(app: &mut SolveApp) {
let host = app.render_workspace.host.clone();
let image_host = app.render_workspace.image_host.clone();
app.render_workspace = State { host, image_host, ..Default::default() };
if active(app) {
app.ui.tab = "SOLID".into();
}
sync_backend(app);
}
pub fn invalidate_scene(app: &mut SolveApp) {
app.render_workspace.revision = u64::MAX;
app.render_workspace.scene_signature = None;
app.render_workspace.scene = None;
app.render_workspace.key = None;
}
pub fn enter(app: &mut SolveApp, render: bool) -> Result<(), String> {
if render {
if app.session.active_sketch.is_some() || app.tool.is_some() || app.dialog.is_some() || app.file_load.busy() {
return Err("finish the current modelling operation before entering Render".into());
}
app.run("render.scene", json!({}))?;
if !active(app) {
app.render_workspace.design_camera = Some((app.cam, app.ui.perspective));
app.ui.tab = "RENDER".into();
let scene = solidcraft_engine::product_scene::extract(&app.session).map_err(|e| e.to_string())?;
if !scene.bounds.is_empty() {
app.cam.fit(&scene.bounds);
}
}
} else {
if let Some((camera, perspective)) = app.render_workspace.design_camera.take() {
app.cam = camera;
app.ui.up_axis = camera.up;
app.ui.perspective = perspective;
}
app.ui.tab = "SOLID".into();
}
app.cancel_view_animation();
Ok(())
}
pub fn panel(app: &mut SolveApp, ui: &mut egui::Ui) {
if app.render_workspace.revision != app.session.revision && !app.render_workspace.view.dirty {
app.render_workspace.view.draft = app.session.doc.render.setups.get(&app.render_workspace.view.name).cloned().unwrap_or_default();
app.render_workspace.revision = app.session.revision;
}
egui::Panel::right("sc_render_settings")
.exact_size(285.0)
.resizable(false)
.frame(egui::Frame::NONE.fill(Tokens::get().panel).inner_margin(10))
.show(ui, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
ui.heading(app.tr("cmd.render_settings_get"));
ui.label(app.tr("render.preview"));
ui.small(app.tr("render.opaque_preview_help"));
ui.separator();
let names: Vec<_> = app.session.doc.render.setups.keys().cloned().collect();
let old_name = app.render_workspace.view.name.clone();
egui::ComboBox::from_id_salt("render_setups").selected_text(&old_name).show_ui(ui, |ui| {
for name in names {
ui.selectable_value(&mut app.render_workspace.view.name, name.clone(), name);
}
});
ui.label(app.tr("render.setup_name"));
ui.add(egui::TextEdit::singleline(&mut app.render_workspace.view.name).char_limit(128));
if app.render_workspace.view.name != old_name {
app.render_workspace.view.draft = app.session.doc.render.setups.get(&app.render_workspace.view.name).cloned().unwrap_or_default();
app.render_workspace.view.dirty = false;
}
ui.separator();
ui.label(app.tr("render.background"));
if ui.color_edit_button_srgb(&mut app.render_workspace.view.draft.background).changed() {
app.render_workspace.view.dirty = true;
}
let mut perspective = app.cam.fov > 0.0;
if ui.checkbox(&mut perspective, app.tr("render.perspective")).changed() {
app.cam.fov = if perspective { std::f64::consts::FRAC_PI_4 } else { 0.0 };
app.render_workspace.view.dirty = true;
}
if ui.button(app.tr("render.fit")).clicked() {
match solidcraft_engine::product_scene::extract(&app.session) {
Ok(scene) if !scene.bounds.is_empty() => app.cam.fit(&scene.bounds),
Err(e) => app.set_status(e.to_string(), true),
_ => {}
}
}
if ui.button(app.tr("render.restore_camera")).clicked() {
let name = app.render_workspace.view.name.clone();
if app.run("render.settings.apply", json!({"name":name})).is_ok() {
app.follow_view_request(false);
}
}
if ui.button(app.tr("render.save_setup")).clicked() {
let name = app.render_workspace.view.name.clone();
let setup = RenderSetup { camera: app.cam, ..app.render_workspace.view.draft.clone() };
if app.run("render.settings.set", json!({"name":name,"set":setup})).is_ok() {
app.render_workspace.view.dirty = false;
}
}
if app.render_workspace.view.dirty {
ui.label(app.tr("render.unsaved"));
}
if ui.button(app.tr("render.reload_settings")).clicked() {
app.render_workspace.view.dirty = false;
app.render_workspace.revision = u64::MAX;
}
if app.render_workspace.view.draft.backend.as_str() != "builtin" || app.render_workspace.view.draft.mode != RenderMode::Preview {
ui.label(app.tr("render.unavailable_preview"));
if ui.button(app.tr("render.use_preview")).clicked() {
app.render_workspace.view.draft.backend = solidcraft_engine::render::builtin_backend().id;
app.render_workspace.view.draft.mode = RenderMode::Preview;
app.render_workspace.view.dirty = true;
}
}
ui.separator();
image_controls(app, ui);
ui.separator();
ui.small(app.tr("render.navigation_help"));
if ui.button(app.tr("render.return_design")).clicked()
&& let Err(e) = enter(app, false)
{
app.set_status(e, true);
}
});
});
}
fn image_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.heading(app.tr("render.image_title"));
ui.small(app.tr("render.opaque_image_summary"));
ui.horizontal(|ui| {
ui.label(app.tr("render.image_width"));
if ui.add(egui::DragValue::new(&mut app.render_workspace.view.draft.width).range(1..=4096)).changed() {
app.render_workspace.view.dirty = true;
}
ui.label(app.tr("render.image_height"));
if ui.add(egui::DragValue::new(&mut app.render_workspace.view.draft.height).range(1..=4096)).changed() {
app.render_workspace.view.dirty = true;
}
});
let transparent_label = app.tr("render.image_transparent").to_string();
ui.checkbox(&mut app.render_workspace.view.transparent, transparent_label);
if ui.button(app.tr("render.image_check")).clicked() {
let settings = RenderSetup { camera: app.cam, ..app.render_workspace.view.draft.clone() };
if app.run("render.preflight", json!({"settings":settings,"transparent_background":app.render_workspace.view.transparent})).is_ok() {
app.set_status(app.tr("render.image_supported").to_string(), false);
}
}
let mut busy = false;
if let Some(job) = app.render_workspace.view.job {
match app.session.execute("render.status", &json!({"job":job})) {
Ok(status) => {
let phase = status["progress"]["phase"].as_str().unwrap_or("unknown");
busy = matches!(phase, "queued" | "preparing" | "running" | "encoding" | "cancelling");
ui.label(match phase {
"queued" => app.tr("render.image_queued"),
"preparing" => app.tr("render.image_preparing"),
"running" => app.tr("render.image_running"),
"encoding" => app.tr("render.image_encoding"),
"cancelling" => app.tr("render.image_cancelling"),
"cancelled" => app.tr("render.image_cancelled"),
"completed" => app.tr("render.image_completed"),
"failed" => app.tr("render.image_failed"),
_ => app.tr("render.image_unknown"),
});
if phase == "completed" {
app.render_workspace.view.completed = Some(job);
}
if let Some(message) = status["error"]["message"].as_str() {
ui.label(message);
if status["error"]["code"] == "limit_exceeded" {
ui.small(app.tr("render.image_reduce"));
}
}
if busy {
let done = status["progress"]["done"].as_u64().unwrap_or(0);
let total = status["progress"]["total"].as_u64().unwrap_or(1).max(1);
ui.add(egui::ProgressBar::new((done as f64 / total as f64).clamp(0.0, 1.0) as f32));
ui.ctx().request_repaint();
if ui.button(app.tr("render.image_cancel")).clicked() {
let _ = app.run("render.cancel", json!({"job":job}));
}
}
if let Some(revision) = status["scene_generation"].as_u64()
&& revision != app.session.revision
{
ui.small(app.tr("render.image_stale"));
}
}
Err(e) => {
ui.label(e.to_string());
}
}
}
if ui.add_enabled(!busy, egui::Button::new(app.tr("render.image_start"))).clicked() {
let settings = RenderSetup { camera: app.cam, ..app.render_workspace.view.draft.clone() };
if let Ok(reply) = app.run("render.start", json!({"settings":settings,"transparent_background":app.render_workspace.view.transparent})) {
app.render_workspace.view.job = serde_json::from_value(reply["job"].clone()).ok();
ui.ctx().request_repaint();
}
}
let overwrite_label = app.tr("render.image_overwrite").to_string();
ui.checkbox(&mut app.render_workspace.view.overwrite, overwrite_label);
if ui.add_enabled(app.render_workspace.view.completed.is_some(), egui::Button::new(app.tr("render.image_export"))).clicked()
&& let Some(job) = app.render_workspace.view.completed
{
export_image(app, job, app.render_workspace.view.overwrite);
}
}
/// Keep the selected image and overwrite consent fixed while the upstream picker holds model input.
fn export_image(app: &mut SolveApp, job: solidcraft_engine::render::job::JobId, overwrite: bool) {
let origin = app.session.doc.clone();
app.pick_save("render.png", &["png"], move |app, path| {
let Some(path) = path else { return };
if !Arc::ptr_eq(&origin, &app.session.doc) {
app.set_status("The render export document changed while choosing its destination", true);
return;
}
let _ = app.run("render.export", json!({"job":job,"path":path,"overwrite":overwrite}));
});
}
/// Convert only product surfaces; geometry budgets are admitted before vertex allocation.
pub fn gpu_scene(scene: &ProductScene) -> Result<(u64, GpuScene), String> {
let bytes = scene
.meshes
.iter()
.try_fold(0usize, |n, m| m.mesh.triangles.len().checked_mul(3 * crate::gpu::PBR_SIZE).and_then(|x| n.checked_add(x)))
.ok_or("product geometry size overflow")?;
if bytes > MAX_GEOMETRY_BYTES {
return Err("product preview exceeds the 128 MiB geometry upload budget".into());
}
let mut hash = std::collections::hash_map::DefaultHasher::new();
let mut gpu = GpuScene::default();
for body in &scene.meshes {
if body.faces.len() > 262_144 {
return Err("product preview exceeds the face-appearance admission cap".into());
}
if !body.opacity.is_finite()
|| !(0.0..=1.0).contains(&body.opacity)
|| body.faces.iter().any(|f| !f.opacity.is_finite() || !(0.0..=1.0).contains(&f.opacity))
{
return Err("invalid product appearance opacity".into());
}
(Arc::as_ptr(&body.mesh) as usize).hash(&mut hash);
body.name.hash(&mut hash);
body.color.hash(&mut hash);
body.opacity.to_bits().hash(&mut hash);
hash_surface(body.surface, &mut hash);
let looks: std::collections::BTreeMap<_, _> = body.faces.iter().map(|l| (l.face, l)).collect();
for face in &body.faces {
face.face.hash(&mut hash);
face.color.hash(&mut hash);
face.opacity.to_bits().hash(&mut hash);
hash_surface(face.surface, &mut hash);
}
for (index, triangle) in body.mesh.triangles.iter().enumerate() {
let face = body.mesh.tri_face.get(index).and_then(|f| looks.get(f));
let (color, opacity, surface) = face.map_or((body.color, body.opacity, body.surface), |l| (l.color, l.opacity, l.surface));
if surface.is_some() && opacity != 1.0 {
return Err("opaque PBR requires opacity 1".into());
}
if opacity <= 0.0 {
continue;
}
let vertices = triangle.map(|i| {
let p = body.mesh.positions.get(i as usize)?.to_f32();
let n = *body.mesh.normals.get(i as usize)?;
let n = if surface.is_some() { solidcraft_engine::render::surface::unit_direction(n).ok()? } else { n }.to_f32();
p.into_iter().chain(n).all(f32::is_finite).then_some((p, n))
});
let [Some(a), Some(b), Some(c)] = vertices else { return Err("invalid product triangle".into()) };
let rgba = [color[0], color[1], color[2], (opacity * 255.0).round() as u8];
let (buffer, stride) = if surface.is_some() {
(&mut gpu.pbr, crate::gpu::PBR_SIZE)
} else if opacity < 0.999 {
(&mut gpu.glass, crate::gpu::TRI_SIZE)
} else {
(&mut gpu.tris, crate::gpu::TRI_SIZE)
};
buffer.try_reserve(3 * stride).map_err(|_| "product preview vertex allocation failed".to_string())?;
for (p, n) in [a, b, c] {
if let Some(surface) = surface {
if n.iter().all(|v| *v == 0.0) {
return Err("PBR requires nonzero surface normals".into());
}
gpu.pbr_tri(p, n, surface);
} else if opacity < 0.999 {
gpu.glass_tri(p, n, rgba);
} else {
gpu.tri(p, n, rgba);
}
}
}
}
Ok((hash.finish(), gpu))
}
fn hash_surface(surface: Option<solidcraft_engine::render::surface::OpaqueSurface>, hash: &mut impl Hasher) {
surface.is_some().hash(hash);
if let Some(s) = surface {
let c = s.base_color();
for value in [c.x, c.y, c.z, s.metallic(), s.roughness(), s.dielectric_ior()] {
value.to_bits().hash(hash);
}
}
}
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let rect = ui.max_rect();
app.viewport.rect = Some(rect);
let response = ui.allocate_rect(rect, Sense::click_and_drag());
let delta = ui.input(|i| i.pointer.delta());
if response.dragged_by(egui::PointerButton::Primary) {
app.cam.orbit(f64::from(delta.x), f64::from(delta.y));
}
if response.dragged_by(egui::PointerButton::Middle) {
app.cam.pan(f64::from(delta.x), f64::from(delta.y), f64::from(rect.height()));
}
if response.hovered() {
let scroll = ui.input(|i| i.smooth_scroll_delta.y);
if scroll != 0.0 {
app.cam.zoom_at((-f64::from(scroll) * 0.0015 * crate::prefs::zoom_sign(app)).exp(), None);
}
}
let bg = app.render_workspace.view.draft.background;
ui.painter().rect_filled(rect, 0.0, Color32::from_rgb(bg[0], bg[1], bg[2]));
let result = (|| -> Result<(), String> {
if app.viewport.gpu.is_none() {
return Err("GPU product preview is unavailable on this host. Saved setups remain available.".into());
}
let ppp = ui.ctx().pixels_per_point();
let setup = &app.render_workspace.view.draft;
if setup.exposure_ev != 0.0 || setup.environment_rotation != 0.0 || setup.environment_intensity != 1.0 {
return Err("fixed studio preview requires exposure 0, environment rotation 0 and intensity 1".into());
}
let mut request = RenderRequest {
backend: Some(setup.backend.clone()),
mode: setup.mode,
operation: RenderOperation::Viewport,
width: (rect.width() * ppp).max(1.0) as u64,
height: (rect.height() * ppp).max(1.0) as u64,
format: None,
features: Vec::new(),
policy: Default::default(),
};
solidcraft_engine::render::resolve_request(&[app.session.render_backend()], &request).map_err(|e| e.to_string())?;
if app.session.model.is_stale() {
return Err("finish the modelling gesture before previewing".into());
}
let mut visibility = std::collections::hash_map::DefaultHasher::new();
app.session.visibility.hidden_bodies.hash(&mut visibility);
app.session.visibility.hidden_occurrences.hash(&mut visibility);
let signature = (app.session.revision, Arc::as_ptr(&app.session.world_state()) as usize, visibility.finish());
if app.render_workspace.scene_signature != Some(signature) {
let scene = solidcraft_engine::product_scene::extract(&app.session).map_err(|e| e.to_string())?;
let (key, gpu) = gpu_scene(&scene)?;
if app.render_workspace.key != Some(key) {
if let Ok(mut slot) = app.render_workspace.slot.lock() {
*slot = Some(gpu);
}
app.render_workspace.key = Some(key);
}
app.render_workspace.scene = Some(scene);
app.render_workspace.scene_signature = Some(signature);
}
let scene = app.render_workspace.scene.as_ref().ok_or("no product scene")?;
request.features = scene.required_features();
solidcraft_engine::render::resolve_request(&[app.session.render_backend()], &request).map_err(|e| e.to_string())?;
let key = app.render_workspace.key.ok_or("no product geometry")?;
if scene.bounds.is_empty() {
return Err("Create a visible solid in Design to preview it here.".into());
}
let camera = app.cam;
let radius = scene.bounds.diagonal().max(10.0) + scene.bounds.center().dist(camera.target);
let eye = camera.eye();
let slot = app.render_workspace.slot.clone();
let callback = ViewportCallback {
product: true,
key,
slot: slot.clone(),
sketch_key: 0,
sketch_slot: slot.clone(),
hl_key: 0,
hl_slot: slot.clone(),
pv_key: 0,
pv_slot: slot,
view_proj: camera.view_proj(f64::from(rect.width() / rect.height().max(1.0)), radius).to_f32(),
back: camera.back().to_f32(),
size_px: [rect.width() * ppp, rect.height() * ppp],
clip: [0.0; 4],
cap: [0.0; 4],
images: Vec::new(),
analysis: [0.0; 8],
analysis_colors: [0.0; 36],
access: None,
outline: false,
eye: [eye.x as f32, eye.y as f32, eye.z as f32, if camera.fov > 0.0 { 1.0 } else { 0.0 }],
};
ui.painter().add(egui_wgpu::Callback::new_paint_callback(rect, callback));
Ok(())
})();
if let Err(error) = result {
ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, error, egui::FontId::proportional(14.0), Tokens::get().text);
}
}
@@ -0,0 +1,472 @@
use super::*;
use crate::scenario::Harness;
use solidcraft_engine::Session;
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn opaque_pbr_product_gpu_and_cpu_share_color_and_material_semantics() {
use solidcraft_engine::render::{
StandardView,
job::{CancelToken, ImageRequest, ImageTask, TaskPoll},
};
let mut s = Session::default();
s.execute("solid.sphere", &json!({"radius":10,"body_name":"Original sphere"})).unwrap();
const SIZE: u32 = 256;
let mut previous = None;
for (metallic, roughness) in [(0.0, 0.2), (0.0, 0.65), (1.0, 0.2), (1.0, 0.65)] {
s.execute(
"appearance.assign",
&json!({"bodies":["Original sphere"],"color":"#b47846",
"pbr":{"metallic":metallic,"roughness":roughness,"dielectric_ior":1.5}}),
)
.unwrap();
let scene = solidcraft_engine::product_scene::extract(&s).unwrap();
let (key, gpu) = gpu_scene(&scene).unwrap();
assert!(!gpu.pbr.is_empty() && gpu.tris.is_empty());
assert_ne!(previous, Some(key));
previous = Some(key);
let mut camera = Camera { fov: 0.0, ..Default::default() };
camera.set_view(StandardView::Iso);
camera.fit(&scene.bounds);
let radius = scene.bounds.diagonal().max(10.0) + scene.bounds.center().dist(camera.target);
let eye = camera.eye().to_f32();
let mut task = ImageTask::new(
scene,
ImageRequest { camera, width: SIZE, height: SIZE, background: [255; 3], transparent_background: false },
CancelToken::default(),
)
.unwrap();
let mut png = None;
for _ in 0..100_000 {
if let TaskPoll::Complete(bytes) = task.step(4096).unwrap() {
png = Some(bytes);
break;
}
}
let cpu = image::load_from_memory(&png.expect("bounded original fixture completes")).unwrap().to_rgba8();
assert_eq!(cpu.dimensions(), (SIZE, SIZE));
let cpu = cpu.into_raw();
for srgb in [false, true] {
let Some(gpu) = crate::gpu::render_product_offscreen(
gpu.clone(),
camera.view_proj(1.0, radius).to_f32(),
camera.back().to_f32(),
[eye[0], eye[1], eye[2], 0.0],
SIZE,
srgb,
) else {
eprintln!("skipping: no GPU adapter");
return;
};
let at = |pixels: &[u8], x: usize, y: usize| {
let i = (y * SIZE as usize + x) * 4;
[pixels[i], pixels[i + 1], pixels[i + 2]]
};
let mut core = 0usize;
let mut error = 0usize;
let mut outliers = 0usize;
for y in 2..SIZE as usize - 2 {
for x in 2..SIZE as usize - 2 {
// Exclude antialiased silhouettes: CPU uses 2x supersampling, GPU uses 1x.
if (y - 1..=y + 1).any(|ny| (x - 1..=x + 1).any(|nx| at(&cpu, nx, ny) == [255; 3] || at(&gpu, nx, ny) == [255; 3])) {
continue;
}
let delta = at(&cpu, x, y).into_iter().zip(at(&gpu, x, y)).map(|(a, b)| usize::from(a.abs_diff(b))).collect::<Vec<_>>();
error += delta.iter().sum::<usize>();
outliers += usize::from(delta.iter().any(|d| *d > 12));
core += 1;
}
}
assert!(core > 1000);
let mean = error as f64 / (3 * core) as f64;
eprintln!("PBR m={metallic} r={roughness} srgb={srgb}: core={core}, mean={mean}, outliers={outliers}");
assert!(mean < 2.0 && outliers * 100 < core * 2, "CPU/GPU color or material interpretation differs");
}
}
}
#[test]
fn control_render_export_respects_upstream_file_scope() {
let mut h = Harness::new();
let root = h.dir.join("allowed");
let outside = h.dir.join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let roots = json!([root.to_string_lossy()]);
let response = h.call("ui.workspace", json!({"workspace":"render","allow_paths":roots}));
assert_eq!(response["ok"], true, "{response}");
let response = h.call("engine.execute", json!({"command":"render.start","params":{"settings":{"width":16,"height":16}},"allow_paths":roots}));
assert_eq!(response["ok"], true, "{response}");
let job = response["result"]["job"].clone();
for _ in 0..50 {
h.frames(1);
}
assert_eq!(h.app.session.execute("render.status", &json!({"job":job})).unwrap()["progress"]["phase"], "completed");
for path in [outside.join("denied.png"), root.join("../outside/traversal.png")] {
for overwrite in [false, true] {
let response = h.call(
"engine.execute",
json!({"command":"render.export","params":{"job":job,"path":path,"overwrite":overwrite},"allow_paths":roots}),
);
assert_eq!(response["ok"], false, "{response}");
assert!(response.to_string().contains("outside the folders this agent may use"), "{response}");
}
}
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 0);
let path = root.join("allowed.png");
let response = h.call("engine.execute", json!({"command":"render.export","params":{"job":job,"path":path},"allow_paths":roots}));
assert_eq!(response["ok"], true, "{response}");
assert!(path.is_file());
let original = std::fs::read(&path).unwrap();
let response = h.call("engine.execute", json!({"command":"render.export","params":{"job":job,"path":path,"overwrite":true},"allow_paths":roots}));
assert_eq!(response["ok"], true, "{response}");
assert_eq!(std::fs::read(&path).unwrap(), original);
}
#[test]
fn adopting_into_blank_document_resets_render_draft_and_preserves_hosts() {
let mut h = Harness::new();
h.app.render_workspace.host = Some(host_executor(2048, true, 4).unwrap());
sync_backend(&mut h.app);
h.app.render_workspace.view.name = "Old draft".into();
enter(&mut h.app, true).unwrap();
let mut incoming = Session::default();
incoming.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
crate::documents::adopt(&mut h.app, incoming);
assert!(!active(&h.app));
assert_eq!(h.app.render_workspace.view.name, "Studio");
assert_eq!(h.app.session.render_backend().executors.len(), 3);
assert!(h.app.session.render_job_host().is_some());
assert_eq!(h.app.session.model.state().bodies.len(), 1);
}
#[test]
fn clicked_image_controls_complete_cancel_and_export_without_dirtying_the_design() {
for language in ["en", "de", "ko", "ru"] {
let mut h = Harness::new();
h.app.preferences.language = language.into();
h.frames(1);
let check = h.app.tr("render.image_check").to_string();
let start = h.app.tr("render.image_start").to_string();
let export = h.app.tr("render.image_export").to_string();
let cancel = h.app.tr("render.image_cancel").to_string();
h.call("ui.resize", json!({"width":1600,"height":1400}));
enter(&mut h.app, true).unwrap();
h.app.render_workspace.view.draft.width = 16;
h.app.render_workspace.view.draft.height = 16;
h.app.render_workspace.view.dirty = true;
h.frames(3);
let doc = h.app.session.doc.clone();
let undo = h.app.session.undo.len();
h.step(&json!({"widget":check})).unwrap();
h.step(&json!({"widget":start})).unwrap();
for _ in 0..50 {
h.frames(1);
if h.app.render_workspace.view.completed.is_some() {
break;
}
}
let good = h.app.render_workspace.view.completed.unwrap();
h.step(&json!({"widget":export})).unwrap();
assert!(h.dir.join("render.png").is_file());
h.app.render_workspace.view.draft.width = 1024;
h.app.render_workspace.view.draft.height = 768;
h.step(&json!({"widget":start})).unwrap();
h.step(&json!({"widget":cancel})).unwrap();
h.frames(5);
assert_eq!(h.app.render_workspace.view.completed, Some(good));
assert_eq!(
h.app.session.execute("render.status", &json!({"job":h.app.render_workspace.view.job})).unwrap()["progress"]["phase"],
"cancelled"
);
assert!(Arc::ptr_eq(&doc, &h.app.session.doc));
assert_eq!(h.app.session.undo.len(), undo);
assert!(!h.app.session.is_dirty());
}
}
#[test]
fn saved_camera_survives_frames_and_product_fit_ignores_selection() {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length":10,"width":20,"height":5,"body_name":"Product"})).unwrap();
h.app.run("solid.box", json!({"corner":[100,0,0],"length":10,"width":20,"height":5,"body_name":"Other"})).unwrap();
let camera = Camera { up: solidcraft_engine::render::UpAxis::Y, roll: 0.2, ..Default::default() };
h.app.run("render.settings.set", json!({"name":"Studio","set":{"camera":camera,"background":[21,43,65]}})).unwrap();
h.frames(3);
let design = h.app.cam;
enter(&mut h.app, true).unwrap();
h.frames(3);
h.step(&json!({"widget":"Restore Saved Camera"})).unwrap();
h.frames(3);
assert_eq!(h.app.cam, camera);
assert_eq!(h.app.render_workspace.view.draft.background, [21, 43, 65]);
h.app.session.selection = vec![solidcraft_engine::Sel::Body { name: "Product".into() }];
let bounds = solidcraft_engine::product_scene::extract(&h.app.session).unwrap().bounds;
let mut expected = camera;
expected.fit(&bounds);
h.app.animate_view("fit");
h.frames(3);
assert_eq!(h.app.cam, expected);
enter(&mut h.app, false).unwrap();
assert_eq!(h.app.cam, design);
}
#[test]
fn clicked_render_workspace_saves_one_setup_and_restores_design_view() {
for language in ["en", "de", "ko", "ru"] {
let mut h = Harness::new();
h.app.preferences.language = language.into();
h.app.run("solid.box", json!({"length":10,"width":20,"height":5})).unwrap();
h.app.session.mark_saved();
h.frames(3);
let camera = h.app.cam;
let flags = (h.app.ui.show_grid, h.app.ui.show_origin, h.app.ui.visual_style, h.app.ui.perspective);
let undo = h.app.session.undo.len();
let at = crate::scenario::rect_of("workspace_switcher").unwrap().center();
h.step(&json!({"click":[at.x,at.y]})).unwrap();
h.step(&json!({"click":{"handle":"menu:ui.workspace.render"}})).unwrap();
assert!(active(&h.app));
let heading = h.app.tr("cmd.render_settings_get").to_string();
let perspective = h.app.tr("render.perspective").to_string();
let save = h.app.tr("render.save_setup").to_string();
let back = h.app.tr("render.return_design").to_string();
assert!(h.widget(&heading).is_some(), "{language}");
h.step(&json!({"widget":perspective})).unwrap();
assert!(!h.app.session.is_dirty(), "preview edits are staged");
h.step(&json!({"widget":save})).unwrap();
assert_eq!(h.app.session.undo.len(), undo + 1);
assert_eq!(h.app.session.doc.render.setups["Studio"].camera, h.app.cam);
h.step(&json!({"widget":back})).unwrap();
assert!(!active(&h.app));
assert_eq!(h.app.cam, camera);
assert_eq!(flags, (h.app.ui.show_grid, h.app.ui.show_origin, h.app.ui.visual_style, h.app.ui.perspective));
h.app.run("edit.undo", json!({})).unwrap();
assert!(!h.app.session.is_dirty());
}
}
#[test]
fn render_drafts_and_capabilities_are_isolated_across_document_switches() {
let mut h = Harness::new();
h.app.render_workspace.host = Some(host_executor(2048, true, 4).unwrap());
sync_backend(&mut h.app);
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
h.app.render_workspace.view.name = "First draft".into();
h.app.render_workspace.view.draft.background = [20, 40, 60];
h.app.render_workspace.view.dirty = true;
enter(&mut h.app, true).unwrap();
crate::documents::open_tab(&mut h.app, Session::default()).unwrap();
assert!(!active(&h.app));
assert_eq!(h.app.render_workspace.view.name, "Studio");
assert_eq!(h.app.session.render_backend().executors.len(), 3);
crate::documents::switch(&mut h.app, 0);
assert_eq!(h.app.render_workspace.view.name, "First draft");
assert_eq!(h.app.render_workspace.view.draft.background, [20, 40, 60]);
assert!(h.app.render_workspace.view.dirty);
assert_eq!(h.app.session.render_backend().executors.len(), 3);
h.app.run("file.new", json!({})).unwrap();
assert_eq!(h.app.render_workspace.view.name, "Studio");
assert_eq!(h.app.session.render_backend().executors.len(), 3);
}
#[test]
fn product_gpu_buffers_preserve_opacity_and_never_contain_overlays() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Glass"})).unwrap();
s.execute("appearance.assign", &json!({"bodies":["Glass"],"color":"#336699","opacity":0.4})).unwrap();
let scene = solidcraft_engine::product_scene::extract(&s).unwrap();
let (key, gpu) = gpu_scene(&scene).unwrap();
assert!(gpu.tris.is_empty());
assert!(!gpu.glass.is_empty());
assert!(gpu.glass.as_chunks::<{ crate::gpu::TRI_SIZE }>().0.iter().all(|v| v[24..] == [51, 102, 153, 102]));
assert!(gpu.lines.is_empty() && gpu.overlay.is_empty() && gpu.under.is_empty() && gpu.hidden.is_empty() && gpu.ghost.is_empty());
s.selection = vec![solidcraft_engine::Sel::Body { name: "Glass".into() }];
s.section = Some((solidcraft_engine::geom::Vec3::ZERO, solidcraft_engine::geom::Vec3::X));
let after = solidcraft_engine::product_scene::extract(&s).unwrap();
assert_eq!(gpu_scene(&after).unwrap().0, key);
s.execute("appearance.assign", &json!({"bodies":["Glass"],"color":"#336699","opacity":0.0})).unwrap();
let invisible = solidcraft_engine::product_scene::extract(&s).unwrap();
assert_eq!(invisible.meshes[0].opacity, 0.0);
let (_, empty) = gpu_scene(&invisible).unwrap();
assert!(empty.tris.is_empty() && empty.glass.is_empty());
}
#[test]
fn host_profiles_advertise_only_installed_opaque_pbr_not_hdri_refraction_or_final() {
use solidcraft_engine::render::*;
let host = host_executor(512, true, 4).unwrap();
assert_eq!(host.limits.max_dimension, 512);
assert_eq!(host.profiles.len(), 1);
assert_eq!(host.profiles[0].features, vec![RenderFeature::Pbr]);
assert!(host.profiles[0].formats.is_empty());
let mut s = Session::default();
s.set_render_viewport(Some(host)).unwrap();
let mut request = RenderRequest {
backend: None,
mode: RenderMode::Preview,
operation: RenderOperation::Viewport,
width: 512,
height: 512,
format: None,
features: Vec::new(),
policy: Default::default(),
};
assert_eq!(resolve_request(&[s.render_backend()], &request).unwrap().executor.as_str(), "gpu-viewport");
request.features = vec![RenderFeature::Pbr];
assert!(resolve_request(&[s.render_backend()], &request).is_ok());
for unsupported in [RenderFeature::Hdri, RenderFeature::Refraction, RenderFeature::Progressive] {
request.features = vec![unsupported];
assert!(resolve_request(&[s.render_backend()], &request).is_err());
}
request.features.clear();
request.width = 513;
assert!(resolve_request(&[s.render_backend()], &request).is_err());
request.width = 512;
request.mode = RenderMode::Final;
assert!(resolve_request(&[s.render_backend()], &request).is_err());
assert!(host_executor(1024, false, 1).unwrap().availability != Availability::Available);
assert!(host_executor(1024, true, 0).is_err());
}
fn completed_preview(h: &mut Harness, background: [u8; 3]) -> solidcraft_engine::render::job::JobId {
let reply = h.app.session.execute("render.start", &json!({"settings":{"width":16,"height":16,"background":background}})).unwrap();
let job = serde_json::from_value(reply["job"].clone()).unwrap();
let host = h.app.session.render_job_host().unwrap().clone();
for _ in 0..100 {
host.pump(16384).unwrap();
if h.app.session.execute("render.status", &json!({"job":job})).unwrap()["progress"]["phase"] == "completed" {
return job;
}
}
panic!("small cooperative image did not complete within the work budget");
}
fn async_export_fixture() -> (Harness, solidcraft_engine::render::job::JobId) {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length":10,"width":20,"height":5})).unwrap();
h.app.session.mark_saved();
let job = completed_preview(&mut h, [200, 210, 220]);
(h, job)
}
#[test]
fn delayed_image_export_holds_its_document_without_changing_history() {
use crate::picker_test_support::{Slot, save};
let (mut h, job) = async_export_fixture();
let path = h.dir.join("delayed.png");
let doc = h.app.session.doc.clone();
let undo = h.app.session.undo.len();
let ctx = egui::Context::default();
let slot: Slot = Default::default();
save(&mut h.app, &slot);
export_image(&mut h.app, job, false);
crate::file_picker::tick(&mut h.app, &ctx);
assert!(h.app.file_picker_busy() && !path.exists());
assert_eq!(h.call("ui.inspect", json!({}))["result"]["file_dialog_open"], true);
crate::documents::new_design(&mut h.app);
let other = h.app.session.doc.clone();
*slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned()));
crate::file_picker::tick(&mut h.app, &ctx);
assert_eq!(h.app.docs.active, 0, "upstream holds tab changes while a picker is pending");
assert_eq!(h.call("ui.inspect", json!({}))["result"]["file_dialog_open"], false);
assert!(Arc::ptr_eq(&other, &h.app.session.doc));
assert_eq!(&std::fs::read(&path).unwrap()[..8], b"\x89PNG\r\n\x1a\n");
crate::documents::switch(&mut h.app, 0);
assert!(Arc::ptr_eq(&doc, &h.app.session.doc));
assert_eq!(h.app.session.undo.len(), undo);
assert!(!h.app.session.is_dirty());
}
#[test]
fn delayed_image_export_cancel_and_overwrite_choice_preserve_existing_files() {
use crate::picker_test_support::{Slot, save};
let (mut h, job) = async_export_fixture();
let path = h.dir.join("existing.png");
std::fs::write(&path, b"existing contents").unwrap();
let doc = h.app.session.doc.clone();
let ctx = egui::Context::default();
let slot: Slot = Default::default();
save(&mut h.app, &slot);
export_image(&mut h.app, job, false);
crate::file_picker::tick(&mut h.app, &ctx);
*slot.borrow_mut() = Some(None);
crate::file_picker::tick(&mut h.app, &ctx);
assert!(!h.app.file_picker_busy());
assert_eq!(std::fs::read(&path).unwrap(), b"existing contents");
export_image(&mut h.app, job, false);
crate::file_picker::tick(&mut h.app, &ctx);
h.app.render_workspace.view.overwrite = true;
*slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned()));
crate::file_picker::tick(&mut h.app, &ctx);
assert_eq!(std::fs::read(&path).unwrap(), b"existing contents", "a later checkbox change is not overwrite consent for the pending export");
assert!(matches!(&h.app.status, Some((_, _, true))));
assert!(Arc::ptr_eq(&doc, &h.app.session.doc));
assert_eq!(h.app.session.execute("render.status", &json!({"job":job})).unwrap()["progress"]["phase"], "completed");
}
#[test]
fn delayed_image_export_never_substitutes_a_newer_retained_image() {
use crate::picker_test_support::{Slot, save};
let (mut h, job) = async_export_fixture();
let path = h.dir.join("old-image.png");
let ctx = egui::Context::default();
let slot: Slot = Default::default();
save(&mut h.app, &slot);
export_image(&mut h.app, job, false);
crate::file_picker::tick(&mut h.app, &ctx);
let newer = completed_preview(&mut h, [20, 30, 40]);
h.app.render_workspace.view.completed = Some(newer);
*slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned()));
crate::file_picker::tick(&mut h.app, &ctx);
assert!(!path.exists(), "the evicted requested image must fail instead of exporting the newer image");
assert!(matches!(&h.app.status, Some((_, _, true))));
assert_eq!(h.app.render_workspace.view.completed, Some(newer));
let newer_path = h.dir.join("new-image.png");
h.app.run("render.export", json!({"job":newer,"path":newer_path})).unwrap();
assert!(newer_path.is_file());
}
#[test]
fn delayed_image_export_holds_close_and_new_document_until_cancel() {
use crate::picker_test_support::{Slot, save};
let (mut h, job) = async_export_fixture();
let path = h.dir.join("closed.png");
let ctx = egui::Context::default();
let slot: Slot = Default::default();
save(&mut h.app, &slot);
export_image(&mut h.app, job, false);
crate::file_picker::tick(&mut h.app, &ctx);
crate::documents::new_design(&mut h.app);
crate::documents::close(&mut h.app, 0, true);
let other = h.app.session.doc.clone();
assert_eq!(h.app.docs.count(), 1);
assert!(h.app.file_picker_busy());
*slot.borrow_mut() = Some(None);
crate::file_picker::tick(&mut h.app, &ctx);
assert!(!path.exists() && !h.app.file_picker_busy());
assert!(Arc::ptr_eq(&other, &h.app.session.doc));
}
#[test]
fn delayed_image_export_refuses_an_internally_replaced_document_even_with_overwrite() {
use crate::picker_test_support::{Slot, save};
for overwrite in [false, true] {
let (mut h, job) = async_export_fixture();
let path = h.dir.join("keep.png");
std::fs::write(&path, b"keep this file").unwrap();
let slot: Slot = Default::default();
save(&mut h.app, &slot);
export_image(&mut h.app, job, overwrite);
assert!(h.app.run("appearance.assign", json!({"bodies":["Body1"],"color":[1,2,3]})).is_err());
// Internal host/session changes are outside the input hold; never export them by accident.
h.app.session.execute("appearance.assign", &json!({"bodies":["Body1"],"color":[1,2,3]})).unwrap();
let changed = h.app.session.doc.clone();
let history = (h.app.session.undo.len(), h.app.session.revision);
*slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned()));
crate::file_picker::tick(&mut h.app, &egui::Context::default());
assert_eq!(std::fs::read(path).unwrap(), b"keep this file");
assert!(Arc::ptr_eq(&changed, &h.app.session.doc));
assert_eq!((h.app.session.undo.len(), h.app.session.revision), history);
assert!(matches!(&h.app.status, Some((_, _, true))));
}
}
+7 -2
View File
@@ -57,6 +57,7 @@ pub fn panels(tab: &str) -> &'static [&'static str] {
"MESH" => &["CREATE", "PREPARE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
"SHEET METAL" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
"PLASTIC" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
"RENDER" => &["SCENE"],
_ => &["MAKE", "ADD-INS", "UTILITY", "INSPECT", "SELECT"],
}
}
@@ -505,7 +506,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
painter.text(
pos2(ws.center().x - 5.0, ws.top() + 52.0),
Align2::CENTER_CENTER,
app.tr_label(crate::toolbar_custom::WORKSPACE),
app.tr_label(if crate::render_workspace::active(app) { "RENDER" } else { crate::toolbar_custom::WORKSPACE }),
FontId::proportional(12.0),
t.text,
);
@@ -521,7 +522,11 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
if sketching {
tabs.push("SKETCH");
}
tabs.extend(TABS.iter());
if crate::render_workspace::active(app) {
tabs.push("RENDER");
} else {
tabs.extend(TABS.iter());
}
for tab in tabs {
let display_tab = app.tr_label(tab);
let text_width = painter.layout_no_wrap(display_tab.to_string(), FontId::proportional(12.0), t.text).size().x;
+1
View File
@@ -2258,6 +2258,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
let ppp = ui.ctx().pixels_per_point();
let cb = ViewportCallback {
product: false,
key: app.viewport.key,
slot: app.viewport.slot.clone(),
sketch_key: app.viewport.sketch_key,