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

+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;