Files
OneCAD/crates/ui-egui/src/render_workspace.rs
T
selune a261aaa841 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>
2026-10-14 19:24:36 -04:00

536 lines
24 KiB
Rust

//! 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);
}
}