Files
OneCAD/crates/ui-egui/src/render_workspace.rs
T

552 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::{Frame, 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 draft = &app.render_workspace.view.draft;
let size = (u64::from(draft.width), u64::from(draft.height));
if let Some((w, h)) = RenderLimits::default().fit(size.0, size.1, None)
&& (w, h) != size
{
ui.small(app.tr("render.image_too_large"));
if ui.button(format!("{} {w} × {h}", app.tr("render.image_use_largest"))).clicked() {
app.render_workspace.view.draft.width = u32::try_from(w).unwrap_or(1);
app.render_workspace.view.draft.height = u32::try_from(h).unwrap_or(1);
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);
}
}
}
/// The size the product preview draws at for a viewport of `full` physical pixels: the full
/// size when the viewport executor admits it, else the largest size at the same aspect it does.
/// Without an executor the request is left for resolution to report.
pub fn preview_size(executor: Option<&ExecutorDescriptor>, full: (u64, u64)) -> (u64, u64) {
executor.and_then(|e| e.fit(full.0, full.1)).unwrap_or(full)
}
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let rect = ui.max_rect();
app.viewport.rect = Some(rect);
crate::viewport::refresh_layout(app, ui.ctx().pixels_per_point());
let response = ui.interact(rect, ui.id().with("render_preview"), Sense::click_and_drag());
// The modelling viewport's camera input (preset, Orbit around, zoom to the cursor, middle
// double-click), with nothing to pick or select here.
crate::navigation::handle(app, ui, rect, &response, crate::navigation::NavCaps::RENDER);
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());
}
// A 4K or 5K viewport is past the preview's pixel budget: draw it smaller and filter it
// up into the viewport rather than refusing the frame.
let full = ((rect.width() * ppp).max(1.0) as u64, (rect.height() * ppp).max(1.0) as u64);
let (width, height) = preview_size(app.session.render_viewport(), full);
let mut request = RenderRequest {
backend: Some(setup.backend.clone()),
mode: setup.mode,
operation: RenderOperation::Viewport,
width,
height,
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(Frame {
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 }],
ssaa: if width < full.0 { width as f32 / full.0 as f32 } else { 1.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);
}
crate::viewport::axis_view(app, ui, rect);
}