1 commits, squashed; the originals stay reachable from tag archive/merge-main-develop.
- 9a7958857 jyviko: doc, engine, ui: placing the model returns its error; placements are checked where they are set
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
1656 lines
71 KiB
Rust
1656 lines
71 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::job::{ImageHost, Integrator, LightingPreset, PreviewInfo, RaytraceOptions};
|
||
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};
|
||
|
||
// Vertex payload only, not an OS/GPU peak-memory promise. Native viewport
|
||
// uploads are already chunked by the installed device's buffer limit.
|
||
const MAX_GEOMETRY_BYTES: usize =
|
||
if cfg!(all(not(target_arch = "wasm32"), target_pointer_width = "64")) { 512 * 1024 * 1024 } else { 128 * 1024 * 1024 };
|
||
|
||
#[path = "render_workspace_ribbon.rs"]
|
||
mod ribbon;
|
||
|
||
#[path = "render_workspace_cpu.rs"]
|
||
mod cpu;
|
||
|
||
#[path = "render_workspace_hdr.rs"]
|
||
mod hdr;
|
||
|
||
#[path = "render_workspace_effects.rs"]
|
||
mod effects;
|
||
#[path = "render_workspace_inspector.rs"]
|
||
mod inspector;
|
||
|
||
#[path = "render_workspace_library.rs"]
|
||
mod library;
|
||
|
||
#[path = "render_workspace_materials.rs"]
|
||
mod materials;
|
||
|
||
#[path = "render_workspace_setups.rs"]
|
||
mod setups;
|
||
|
||
#[path = "render_workspace_studio.rs"]
|
||
mod studio;
|
||
#[path = "render_workspace_transport.rs"]
|
||
mod transport;
|
||
|
||
#[cfg(test)]
|
||
#[path = "render_workspace_tests.rs"]
|
||
mod tests;
|
||
|
||
type PathFactory = Box<dyn FnOnce() -> Result<ImageHost, String> + Send>;
|
||
|
||
pub struct State {
|
||
pending_setup: Option<PathFactory>,
|
||
pub host: Option<ExecutorDescriptor>,
|
||
pub path_host: PathHost,
|
||
/// How long the last path tracer setup took, in seconds, per viewport adapter
|
||
/// ([`adapter_key`]); kept in the preferences to estimate the next one.
|
||
pub setup_seconds: std::collections::BTreeMap<String, f64>,
|
||
/// The viewport adapter the current setup runs on.
|
||
pub setup_key: Option<String>,
|
||
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>,
|
||
scene_error: Option<String>,
|
||
preview_upload_bytes: Option<usize>,
|
||
image: Option<(solidcraft_engine::render::job::JobId, Result<egui::TextureHandle, String>)>,
|
||
image_info: Option<solidcraft_engine::render::job::FrameInfo>,
|
||
preview_image: Option<PreviewImage>,
|
||
}
|
||
|
||
/// Provisional pixels are never an export source and never replace the retained final image.
|
||
struct PreviewImage {
|
||
info: PreviewInfo,
|
||
image: Result<egui::TextureHandle, String>,
|
||
}
|
||
|
||
struct DisplayImage<'a> {
|
||
info: &'a solidcraft_engine::render::job::FrameInfo,
|
||
image: &'a Result<egui::TextureHandle, String>,
|
||
preview: Option<&'a PreviewInfo>,
|
||
}
|
||
|
||
/// Application-wide setup, retained across document changes. The worker owns a
|
||
/// cloned adapter and initializes the job device; the UI receives the finished host.
|
||
#[derive(Default)]
|
||
pub enum PathHost {
|
||
#[default]
|
||
NotStarted,
|
||
/// The worker's answer, and when it started: shader compilation can take minutes.
|
||
Preparing(std::sync::mpsc::Receiver<Result<ImageHost, String>>, std::time::Instant),
|
||
Ready(ImageHost),
|
||
Failed(String),
|
||
}
|
||
|
||
impl PathHost {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub(crate) fn start(&mut self, create: impl FnOnce() -> Result<ImageHost, String> + Send + 'static) {
|
||
if !matches!(self, Self::NotStarted) {
|
||
return;
|
||
}
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
// Dropping the handle detaches: neither startup nor shutdown joins a driver
|
||
// that may spend minutes compiling or warming up its pipelines.
|
||
*self = match std::thread::Builder::new().name("gpu-path-setup".into()).spawn(move || {
|
||
let result =
|
||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(create)).unwrap_or_else(|_| Err("GPU path tracer setup panicked".into()));
|
||
let _ = tx.send(result);
|
||
}) {
|
||
Ok(_) => Self::Preparing(rx, std::time::Instant::now()),
|
||
Err(e) => Self::failed(format!("could not start setup worker: {e}")),
|
||
};
|
||
}
|
||
|
||
fn failed(message: String) -> Self {
|
||
log::warn!("progressive render unavailable: {message}");
|
||
Self::Failed(message)
|
||
}
|
||
|
||
/// Take the worker's answer if it has come; returns how long a successful setup took.
|
||
fn poll(&mut self) -> Option<std::time::Duration> {
|
||
let Self::Preparing(rx, started) = self else { return None };
|
||
let took = started.elapsed();
|
||
*self = match rx.try_recv() {
|
||
Ok(Ok(host)) => Self::Ready(host),
|
||
Ok(Err(error)) => Self::failed(error),
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => return None,
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => Self::failed("setup worker stopped without returning a host".into()),
|
||
};
|
||
matches!(self, Self::Ready(_)).then_some(took)
|
||
}
|
||
}
|
||
|
||
/// The samples slider's top end: 2^12 = 4096 samples per pixel.
|
||
const MAX_SAMPLES_EXPONENT: u32 = 12;
|
||
|
||
/// Setup times kept per adapter, at most.
|
||
const MAX_SETUP_TIMES: usize = 16;
|
||
|
||
/// The key [`State::setup_seconds`] files an adapter's setup time under: a driver update
|
||
/// usually empties the driver's shader cache, so the driver is part of it.
|
||
pub fn adapter_key(info: &egui_wgpu::wgpu::AdapterInfo) -> String {
|
||
format!("{} | {:?} | {} {}", info.name.trim(), info.backend, info.driver.trim(), info.driver_info.trim())
|
||
}
|
||
|
||
/// Remember how long the setup on `key` took, for the next start's estimate.
|
||
fn record_setup(state: &mut State, took: std::time::Duration) {
|
||
let Some(key) = state.setup_key.clone() else { return };
|
||
if state.setup_seconds.len() >= MAX_SETUP_TIMES && !state.setup_seconds.contains_key(&key) {
|
||
return;
|
||
}
|
||
state.setup_seconds.insert(key, took.as_secs_f64());
|
||
}
|
||
|
||
/// Setup times from the preferences: finite, non-negative and at most a day each.
|
||
pub fn load_setup_seconds(value: &serde_json::Value) -> std::collections::BTreeMap<String, f64> {
|
||
value
|
||
.as_object()
|
||
.into_iter()
|
||
.flatten()
|
||
.filter_map(|(k, v)| Some((k.clone(), v.as_f64().filter(|s| s.is_finite() && (0.0..=86_400.0).contains(s))?)))
|
||
.take(MAX_SETUP_TIMES)
|
||
.collect()
|
||
}
|
||
|
||
/// Minutes and seconds, as `1:05`.
|
||
fn mmss(d: std::time::Duration) -> String {
|
||
let s = d.as_secs();
|
||
format!("{}:{:02}", s / 60, s % 60)
|
||
}
|
||
|
||
/// The path tracer's setup as it goes: a progress bar estimated from the last setup on this
|
||
/// adapter, else a spinner with the time so far. Drivers report no progress of their own.
|
||
fn setup_progress(ui: &mut egui::Ui, app: &SolveApp, started: std::time::Instant) {
|
||
let elapsed = started.elapsed();
|
||
let expected = app.render_workspace.setup_key.as_ref().and_then(|k| app.render_workspace.setup_seconds.get(k)).copied();
|
||
ui.label(app.tr("render.gpu_path_preparing"));
|
||
match expected.map(|s| std::time::Duration::from_secs_f64(s.max(1.0))) {
|
||
Some(expected) if elapsed < expected.mul_f64(1.5) => {
|
||
let fraction = (elapsed.as_secs_f32() / expected.as_secs_f32()).min(0.99);
|
||
crate::ui_kit::progress(ui, "gpu_path_setup", fraction);
|
||
ui.label(format!("{} / ~{}", mmss(elapsed), mmss(expected)));
|
||
// The bar moves with time, so keep drawing while it is shown.
|
||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(250));
|
||
}
|
||
_ => {
|
||
ui.horizontal(|ui| {
|
||
ui.spinner();
|
||
ui.label(mmss(elapsed));
|
||
});
|
||
if expected.is_none() {
|
||
ui.small(app.tr("render.gpu_path_first_setup"));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
clean_setup: Option<RenderSetup>,
|
||
job: Option<solidcraft_engine::render::job::JobId>,
|
||
completed: Option<solidcraft_engine::render::job::JobId>,
|
||
transparent: bool,
|
||
method: ImageMethod,
|
||
raytrace: RaytraceOptions,
|
||
samples: u32,
|
||
max_depth: u32,
|
||
seed: u32,
|
||
studio: studio::View,
|
||
materials: materials::View,
|
||
library: library::View,
|
||
inspector: inspector::View,
|
||
|
||
cpu: cpu::View,
|
||
overwrite: bool,
|
||
result: bool,
|
||
result_camera: Camera,
|
||
result_scale: ResultScale,
|
||
result_job: Option<solidcraft_engine::render::job::JobId>,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||
enum ResultScale {
|
||
#[default]
|
||
Fit,
|
||
Actual,
|
||
Free,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||
enum ImageMethod {
|
||
#[default]
|
||
Raster,
|
||
CpuRaytrace,
|
||
GpuRaytrace,
|
||
}
|
||
|
||
impl ImageMethod {
|
||
fn label(self) -> &'static str {
|
||
match self {
|
||
Self::Raster => "render.method_raster",
|
||
Self::CpuRaytrace => "render.method_cpu",
|
||
Self::GpuRaytrace => "render.method_gpu",
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for ViewState {
|
||
fn default() -> Self {
|
||
Self {
|
||
name: "Studio".into(),
|
||
draft: RenderSetup::default(),
|
||
dirty: false,
|
||
clean_setup: None,
|
||
job: None,
|
||
completed: None,
|
||
transparent: false,
|
||
method: ImageMethod::Raster,
|
||
raytrace: RaytraceOptions::default(),
|
||
samples: 64,
|
||
max_depth: 8,
|
||
seed: 0,
|
||
studio: studio::View::default(),
|
||
materials: materials::View::default(),
|
||
library: library::View::default(),
|
||
inspector: inspector::View::default(),
|
||
|
||
cpu: cpu::View::default(),
|
||
overwrite: false,
|
||
result: false,
|
||
result_camera: Camera { yaw: 0.0, pitch: std::f64::consts::FRAC_PI_2, ..Default::default() },
|
||
result_scale: ResultScale::Fit,
|
||
result_job: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for State {
|
||
fn default() -> Self {
|
||
Self {
|
||
host: None,
|
||
path_host: PathHost::default(),
|
||
pending_setup: None,
|
||
setup_seconds: Default::default(),
|
||
setup_key: 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,
|
||
scene_error: None,
|
||
preview_upload_bytes: None,
|
||
image: None,
|
||
image_info: None,
|
||
preview_image: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl State {
|
||
/// Retain the selected viewport adapter, but do not open another device or
|
||
/// compile shaders until Render is used. A later viewport installation (for
|
||
/// example safe-mode fallback) replaces only a not-yet-started factory.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub(crate) fn defer_path_host(&mut self, key: String, create: impl FnOnce() -> Result<ImageHost, String> + Send + 'static) {
|
||
if matches!(self.path_host, PathHost::NotStarted) {
|
||
self.setup_key = Some(key);
|
||
self.pending_setup = Some(Box::new(create));
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn start_deferred_path_host(&mut self) {
|
||
if matches!(self.path_host, PathHost::NotStarted)
|
||
&& let Some(create) = self.pending_setup.take()
|
||
{
|
||
self.path_host.start(create);
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn active(app: &SolveApp) -> bool {
|
||
app.ui.tab == "RENDER"
|
||
}
|
||
|
||
pub(crate) fn result_active(app: &SolveApp) -> bool {
|
||
active(app) && app.render_workspace.view.result
|
||
}
|
||
|
||
pub(crate) fn fit_result(app: &mut SolveApp) {
|
||
app.render_workspace.view.result_scale = ResultScale::Fit;
|
||
}
|
||
|
||
/// Read-only identities for automation and diagnostics. Does not prepare a
|
||
/// request, open a device, evaluate geometry, change the selected result or pump
|
||
/// a job. Clients must use render.status for the exact identity returned here.
|
||
pub(crate) fn inspect_state(app: &SolveApp) -> serde_json::Value {
|
||
let view = &app.render_workspace.view;
|
||
let method = match view.method {
|
||
ImageMethod::Raster => "raster",
|
||
ImageMethod::CpuRaytrace => "cpu_raytrace",
|
||
ImageMethod::GpuRaytrace => "gpu_path",
|
||
};
|
||
json!({
|
||
"active": active(app), "setup": view.name, "draft_dirty": view.dirty,
|
||
"method": method, "job": view.job, "completed": view.completed,
|
||
"showing_result": view.result, "export_format": if view.studio.export_exr {"exr"} else {"png"},
|
||
})
|
||
}
|
||
|
||
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) {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
if active(app) {
|
||
app.render_workspace.start_deferred_path_host();
|
||
}
|
||
if let Some(took) = app.render_workspace.path_host.poll() {
|
||
record_setup(&mut app.render_workspace, took);
|
||
}
|
||
if let PathHost::Ready(host) = &app.render_workspace.path_host
|
||
&& let Err(e) = app.session.register_render_job_host(host.clone())
|
||
{
|
||
app.render_workspace.path_host = PathHost::failed(e.to_string());
|
||
app.set_status(e.to_string(), true);
|
||
}
|
||
|
||
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 matches!(app.render_workspace.path_host, PathHost::Preparing(..)) {
|
||
// Poll even in Design or an idle window, without continuously redrawing.
|
||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||
}
|
||
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) {
|
||
app.ui.ribbon_context = None;
|
||
let host = app.render_workspace.host.clone();
|
||
let image_host = app.render_workspace.image_host.clone();
|
||
let path_host = std::mem::take(&mut app.render_workspace.path_host);
|
||
let setup_seconds = std::mem::take(&mut app.render_workspace.setup_seconds);
|
||
let setup_key = app.render_workspace.setup_key.take();
|
||
let pending_setup = app.render_workspace.pending_setup.take();
|
||
app.render_workspace = State { host, image_host, path_host, setup_seconds, setup_key, pending_setup, ..Default::default() };
|
||
if active(app) {
|
||
app.ui.tab = "MODEL".into();
|
||
}
|
||
sync_backend(app);
|
||
}
|
||
|
||
pub(crate) fn invalidate_images(app: &mut SolveApp) {
|
||
app.render_workspace.image = None;
|
||
app.render_workspace.image_info = None;
|
||
app.render_workspace.preview_image = None;
|
||
}
|
||
|
||
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.scene_error = None;
|
||
app.render_workspace.preview_upload_bytes = None;
|
||
app.render_workspace.key = None;
|
||
if let Ok(mut slot) = app.render_workspace.slot.lock() {
|
||
*slot = 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);
|
||
}
|
||
}
|
||
setups::refresh_dirty(app);
|
||
if app.render_workspace.revision != app.session.revision && !app.render_workspace.view.dirty {
|
||
setups::load(app);
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
app.render_workspace.start_deferred_path_host();
|
||
} 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 = "MODEL".into();
|
||
}
|
||
app.cancel_view_animation();
|
||
Ok(())
|
||
}
|
||
|
||
pub fn panel(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||
inspector::panel(app, ui);
|
||
}
|
||
|
||
/// Local Browser-card content, never a new workspace or an alternative model tree.
|
||
pub(crate) fn browser_mode(app: &mut SolveApp, ui: &mut egui::Ui) -> bool {
|
||
if !active(app) {
|
||
return false;
|
||
}
|
||
let labels = [app.tr("render.browser_model").to_string(), app.tr("render.browser_library").to_string()];
|
||
let mut selected = usize::from(app.render_workspace.view.inspector.library_open);
|
||
let top = ui.cursor().min;
|
||
let width = ui.available_width();
|
||
crate::ui_kit::segmented(ui, &mut selected, &labels.iter().map(String::as_str).collect::<Vec<_>>(), Default::default());
|
||
for (i, name) in ["render:browser-model", "render:browser-library"].iter().enumerate() {
|
||
crate::scenario::publish_target(
|
||
name,
|
||
egui::Rect::from_min_size(top + egui::vec2(width * i as f32 / 2.0, 0.0), egui::vec2(width / 2.0, crate::ui_kit::row_height())),
|
||
);
|
||
}
|
||
app.render_workspace.view.inspector.library_open = selected == 1;
|
||
if selected == 1 {
|
||
crate::ui_kit::scope(ui, |ui| library::browse(app, ui));
|
||
true
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
|
||
fn image_host(app: &SolveApp, method: ImageMethod) -> Result<&ImageHost, String> {
|
||
if method == ImageMethod::GpuRaytrace {
|
||
let PathHost::Ready(host) = &app.render_workspace.path_host else {
|
||
return Err(app.tr("render.gpu_unavailable").to_string());
|
||
};
|
||
let descriptor = host.descriptor().map_err(|error| error.to_string())?;
|
||
if !host.is_available()
|
||
|| !descriptor.profiles.iter().any(|profile| profile.features.contains(&solidcraft_engine::render::RenderFeature::Progressive))
|
||
{
|
||
return Err(app.tr("render.gpu_unavailable").to_string());
|
||
}
|
||
return Ok(host);
|
||
}
|
||
let host = app.session.render_job_host().ok_or_else(|| app.tr("render.image_host_missing").to_string())?;
|
||
host.validate_integrator(if method == ImageMethod::CpuRaytrace { Integrator::Raytrace } else { Integrator::Raster })
|
||
.map_err(|error| error.to_string())?;
|
||
Ok(host)
|
||
}
|
||
|
||
/// Check and Start use exactly this request, rebuilt at each click. The result camera is
|
||
/// temporarily installed only inside shared navigation; app.cam remains the product camera.
|
||
fn image_params(app: &SolveApp) -> Result<serde_json::Value, String> {
|
||
let view = &app.render_workspace.view;
|
||
image_host(app, view.method)?;
|
||
if app.session.model.is_stale() {
|
||
return Err(app.tr("render.image_scene_stale").to_string());
|
||
}
|
||
image_request(app)
|
||
}
|
||
|
||
/// Pure request construction is shared with offline setup editing; host checks stay above.
|
||
fn image_request(app: &SolveApp) -> Result<serde_json::Value, String> {
|
||
cpu::validate_progressive_image(app)?;
|
||
if !setups::has_runtime_inputs(app) {
|
||
return Ok(json!({"settings":setups::snapshot(app)?}));
|
||
}
|
||
let view = &app.render_workspace.view;
|
||
// Temporary bindings and unsaved HDR captures use the runtime API. Embedded HDRs
|
||
// still resolve by their document resource ID when a temporary binding is present.
|
||
if !view.draft.surfaces.is_empty() {
|
||
return Err(app.tr("render.saved_maps_runtime_conflict").to_string());
|
||
}
|
||
let settings = RenderSetup { camera: app.cam, job: None, ..view.draft.clone() };
|
||
let mut params = json!({"settings":settings,"transparent_background":view.transparent});
|
||
match view.method {
|
||
ImageMethod::Raster => {}
|
||
ImageMethod::CpuRaytrace => {
|
||
if view.transparent {
|
||
return Err(app.tr("render.cpu_opaque_required").to_string());
|
||
}
|
||
cpu::params(app, &mut params)?;
|
||
}
|
||
ImageMethod::GpuRaytrace => {
|
||
params["progressive"] = json!(true);
|
||
params["samples"] = json!(view.samples);
|
||
params["max_depth"] = json!(view.max_depth);
|
||
params["seed"] = json!(view.seed);
|
||
studio::params(app, &mut params)?;
|
||
}
|
||
}
|
||
materials::params(app, &mut params)?;
|
||
Ok(params)
|
||
}
|
||
|
||
fn select_image_method(app: &mut SolveApp, method: ImageMethod) {
|
||
app.render_workspace.view.method = method;
|
||
// Raster has no exposure control. Selecting it explicitly selects its zero-EV response.
|
||
if method == ImageMethod::Raster {
|
||
app.render_workspace.view.draft.exposure_ev = 0.0;
|
||
}
|
||
app.render_workspace.view.dirty = true;
|
||
}
|
||
|
||
fn method_selector(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||
ui.label(app.tr("render.image_method"));
|
||
let current = app.render_workspace.view.method;
|
||
let label = app.tr(current.label()).to_string();
|
||
let response = crate::ui_kit::dropdown_menu(ui, "render_image_method", &label, |ui| {
|
||
for (method, handle) in [
|
||
(ImageMethod::Raster, "render:method-raster"),
|
||
(ImageMethod::CpuRaytrace, "render:method-cpu"),
|
||
(ImageMethod::GpuRaytrace, "render:method-gpu"),
|
||
] {
|
||
let capability = image_host(app, method).map(|_| ());
|
||
let label = app.tr(method.label()).to_string();
|
||
let response = crate::ui_kit::list_picker(ui, handle, &label, method == current);
|
||
crate::scenario::publish_handle(handle, response.rect.center());
|
||
if response.clicked() {
|
||
select_image_method(app, method);
|
||
ui.close();
|
||
}
|
||
if let Err(reason) = capability {
|
||
response.on_hover_text(reason);
|
||
}
|
||
}
|
||
});
|
||
crate::scenario::publish_handle("render:method", response.rect.center());
|
||
if image_host(app, app.render_workspace.view.method).is_err() {
|
||
if app.render_workspace.view.inspector.page == inspector::Page::All {
|
||
ui.small(app.tr("render.setup_offline_help"));
|
||
} else {
|
||
crate::ui_kit::text(ui, app.tr("render.setup_offline_help"), crate::ui_kit::Text::Note("render.setup_offline_help"));
|
||
}
|
||
}
|
||
if app.render_workspace.view.method != ImageMethod::Raster {
|
||
if app.render_workspace.view.inspector.page == inspector::Page::All {
|
||
ui.small(app.tr("render.preview_image_settings_help"));
|
||
} else {
|
||
crate::ui_kit::text(ui, app.tr("render.preview_image_settings_help"), crate::ui_kit::Text::Note("render.preview_image_settings_help"));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn cpu_sampling_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||
let progressive = cpu::progressive_controls(app, ui);
|
||
if !progressive {
|
||
ui.label(app.tr("render.cpu_primary_samples"));
|
||
let samples = app.render_workspace.view.raytrace.primary_samples;
|
||
let aa = crate::ui_kit::dropdown_menu(ui, "render_cpu_samples", &format!("{samples}×"), |ui| {
|
||
for count in [1, 4, 16] {
|
||
let handle = format!("render:aa-{count}");
|
||
let response = crate::ui_kit::list_picker(ui, &handle, &format!("{count}×"), count == samples);
|
||
crate::scenario::publish_handle(&handle, response.rect.center());
|
||
if response.clicked() {
|
||
app.render_workspace.view.raytrace.primary_samples = count;
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
crate::scenario::publish_handle("render:aa", aa.rect.center());
|
||
ui.small(app.tr("render.cpu_aa_help"));
|
||
}
|
||
}
|
||
|
||
fn cpu_lighting_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||
ui.label(app.tr("render.cpu_lighting"));
|
||
let presets = [
|
||
(LightingPreset::Neutral, "render.cpu_neutral"),
|
||
(LightingPreset::WarmCool, "render.cpu_warm_cool"),
|
||
(LightingPreset::KeyRim, "render.cpu_key_rim"),
|
||
];
|
||
let selected = app.render_workspace.view.raytrace.preset.unwrap_or(LightingPreset::Neutral);
|
||
let label = app
|
||
.tr(match selected {
|
||
LightingPreset::Neutral => "render.cpu_neutral",
|
||
LightingPreset::WarmCool => "render.cpu_warm_cool",
|
||
LightingPreset::KeyRim => "render.cpu_key_rim",
|
||
})
|
||
.to_string();
|
||
crate::ui_kit::dropdown_menu(ui, "render_cpu_lighting", &label, |ui| {
|
||
for (preset, key) in presets {
|
||
if crate::ui_kit::list_picker(ui, key, app.tr(key), preset == selected).clicked() {
|
||
app.render_workspace.view.raytrace.preset = Some(preset);
|
||
app.render_workspace.view.raytrace.lights = None;
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
let shadows = app.tr("render.cpu_shadows").to_string();
|
||
crate::ui_kit::checkbox(ui, &shadows, &mut app.render_workspace.view.raytrace.shadows);
|
||
}
|
||
|
||
fn image_method_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||
method_selector(app, ui);
|
||
if app.render_workspace.view.method != ImageMethod::CpuRaytrace {
|
||
return;
|
||
}
|
||
cpu_sampling_controls(app, ui);
|
||
cpu_lighting_controls(app, ui);
|
||
ui.label(app.tr("render.image_exposure"));
|
||
let exposure_help = app.tr("render.cpu_exposure_help").to_string();
|
||
crate::ui_kit::number_field(ui, &mut app.render_workspace.view.draft.exposure_ev, -32.0..=32.0, 0.1, " EV").on_hover_text(exposure_help);
|
||
cpu::controls(app, ui);
|
||
}
|
||
|
||
fn image_edit_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||
ui.heading(app.tr("render.image_title"));
|
||
let preview = app.tr("render.result_preview").to_string();
|
||
let result = app.tr("render.result_title").to_string();
|
||
let mut selected = usize::from(app.render_workspace.view.result);
|
||
if crate::ui_kit::segmented(ui, &mut selected, &[&preview, &result], crate::ui_kit::Segments { compact: true, ..Default::default() }) {
|
||
app.render_workspace.view.result = selected == 1;
|
||
app.cancel_view_animation();
|
||
app.viewport.key_hold = None;
|
||
app.viewport.orbit_free = None;
|
||
}
|
||
if app.render_workspace.view.result {
|
||
ui.horizontal(|ui| {
|
||
if crate::ui_kit::button(ui, app.tr("render.result_fit"), crate::ui_kit::Button::Normal).clicked() {
|
||
fit_result(app);
|
||
}
|
||
if crate::ui_kit::button(ui, "100%", crate::ui_kit::Button::Enabled(!reduced_display(app)))
|
||
.on_hover_text(app.tr("render.result_actual_help"))
|
||
.clicked()
|
||
{
|
||
app.render_workspace.view.result_scale = ResultScale::Actual;
|
||
}
|
||
});
|
||
ui.small(app.tr("render.result_navigation"));
|
||
}
|
||
image_method_controls(app, ui);
|
||
ui.small(app.tr(match app.render_workspace.view.method {
|
||
ImageMethod::Raster => "render.opaque_image_summary",
|
||
ImageMethod::CpuRaytrace => "render.cpu_summary",
|
||
ImageMethod::GpuRaytrace => "render.progressive_image_summary",
|
||
}));
|
||
// The field's hard range never silently clamps a draft when its budget changes.
|
||
let side = match app.render_workspace.view.method {
|
||
ImageMethod::CpuRaytrace => 16384,
|
||
ImageMethod::GpuRaytrace => 8192,
|
||
ImageMethod::Raster => 4096,
|
||
};
|
||
ui.horizontal(|ui| {
|
||
ui.label(app.tr("render.image_width"));
|
||
if crate::ui_kit::number_field(ui, &mut app.render_workspace.view.draft.width, 1..=side, 1.0, "").changed() {
|
||
app.render_workspace.view.dirty = true;
|
||
}
|
||
ui.label(app.tr("render.image_height"));
|
||
if crate::ui_kit::number_field(ui, &mut app.render_workspace.view.draft.height, 1..=side, 1.0, "").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)) = cpu::image_limits(app).fit(size.0, size.1, None)
|
||
&& (w, h) != size
|
||
{
|
||
let limit = if app.render_workspace.view.method == ImageMethod::CpuRaytrace && app.render_workspace.view.raytrace.progressive.is_some() {
|
||
"render.cpu_budget_resize"
|
||
} else {
|
||
"render.image_too_large"
|
||
};
|
||
ui.small(app.tr(limit));
|
||
if crate::ui_kit::button(ui, format!("{} {w} × {h}", app.tr("render.image_use_largest")), crate::ui_kit::Button::Normal).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;
|
||
}
|
||
}
|
||
match &app.render_workspace.path_host {
|
||
PathHost::Preparing(_, started) => setup_progress(ui, app, *started),
|
||
PathHost::Failed(error) => {
|
||
ui.add(egui::Label::new(format!("{}: {error}", app.tr("render.gpu_path_failed"))).wrap());
|
||
}
|
||
_ => {}
|
||
}
|
||
if app.render_workspace.view.method == ImageMethod::GpuRaytrace {
|
||
ui.label(app.tr("render.image_samples"));
|
||
// Powers of two from 1 to 4096; requests from elsewhere keep any count.
|
||
let samples = &mut app.render_workspace.view.samples;
|
||
let mut exponent = (*samples).max(1).ilog2().min(MAX_SAMPLES_EXPONENT);
|
||
let slider = crate::ui_kit::Slider { integer: true, format: |e| format!("{}", 1u32 << (e as u32).min(31)), ..Default::default() };
|
||
if crate::ui_kit::slider(ui, &mut exponent, 0..=MAX_SAMPLES_EXPONENT, slider).changed() {
|
||
*samples = 1 << exponent.min(MAX_SAMPLES_EXPONENT);
|
||
}
|
||
let title = app.tr("render.image_advanced").to_string();
|
||
crate::ui_kit::section(ui, "render_gpu_advanced", &title, false, |ui| {
|
||
ui.label(app.tr("render.gpu_depth"));
|
||
crate::ui_kit::number_field(ui, &mut app.render_workspace.view.max_depth, 1..=16, 1.0, "");
|
||
ui.label(app.tr("render.gpu_seed"));
|
||
crate::ui_kit::number_field(ui, &mut app.render_workspace.view.seed, 0..=u32::MAX, 1.0, "");
|
||
ui.label(app.tr("render.image_exposure"));
|
||
crate::ui_kit::number_field(ui, &mut app.render_workspace.view.draft.exposure_ev, -32.0..=32.0, 0.1, " EV");
|
||
ui.small(app.tr("render.gpu_options_help"));
|
||
studio::controls(app, ui);
|
||
});
|
||
}
|
||
library::controls(app, ui);
|
||
materials::controls(app, ui);
|
||
let transparent_label = app.tr("render.image_transparent").to_string();
|
||
crate::ui_kit::checkbox(ui, &transparent_label, &mut app.render_workspace.view.transparent);
|
||
}
|
||
|
||
/// Dense all-controls composition, also useful to standalone/offscreen hosts.
|
||
#[cfg(test)]
|
||
fn image_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||
image_edit_controls(app, ui);
|
||
job_controls(app, ui, false);
|
||
completed_image(app, ui);
|
||
}
|
||
|
||
fn job_controls(app: &mut SolveApp, ui: &mut egui::Ui, compact: bool) {
|
||
let params = image_params(app);
|
||
if !compact {
|
||
if let Err(error) = ¶ms {
|
||
ui.add(egui::Label::new(egui::RichText::new(error).color(Tokens::get().warning)).wrap());
|
||
}
|
||
cpu::checked_status(app, ui, params.as_ref().ok());
|
||
}
|
||
let mut busy = false;
|
||
let mut state = None;
|
||
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");
|
||
if phase == "completed" {
|
||
app.render_workspace.view.completed = Some(job);
|
||
}
|
||
state = Some(status);
|
||
}
|
||
Err(error) => {
|
||
if !compact {
|
||
ui.add(egui::Label::new(egui::RichText::new(error.to_string()).small()).wrap());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
ui.horizontal_wrapped(|ui| {
|
||
let check = crate::ui_kit::button(
|
||
ui,
|
||
app.tr("cmd.render_preflight"),
|
||
crate::ui_kit::Button::Enabled(ribbon::action_ready(app, "render.preflight").is_ok()),
|
||
);
|
||
crate::scenario::publish_target("render:check", check.rect);
|
||
if check.clicked()
|
||
&& let Err(error) = ribbon::perform_job(app, "render.preflight")
|
||
{
|
||
app.set_status(error, true);
|
||
}
|
||
let start = crate::ui_kit::button(
|
||
ui,
|
||
app.tr("render.image_start"),
|
||
crate::ui_kit::Button::Enabled(ribbon::action_ready(app, "render.start").is_ok()),
|
||
);
|
||
crate::scenario::publish_target("render:start", start.rect);
|
||
if start.clicked() {
|
||
if let Err(error) = ribbon::perform_job(app, "render.start") {
|
||
app.set_status(error, true);
|
||
}
|
||
ui.ctx().request_repaint();
|
||
}
|
||
});
|
||
let message = state.as_ref().map_or_else(
|
||
|| app.tr(if params.is_ok() { "render.studio_ready" } else { "render.studio_needs_review" }).to_string(),
|
||
|status| {
|
||
app.tr(match status["progress"]["phase"].as_str().unwrap_or("") {
|
||
"queued" => "render.image_queued",
|
||
"preparing" => "render.image_preparing",
|
||
"running" => "render.image_running",
|
||
"encoding" => "render.image_encoding",
|
||
"cancelling" => "render.image_cancelling",
|
||
"cancelled" => "render.image_cancelled",
|
||
"completed" => "render.image_completed",
|
||
"failed" => "render.image_failed",
|
||
_ => "render.image_unknown",
|
||
})
|
||
.to_string()
|
||
},
|
||
);
|
||
let response = ui.add_sized([ui.available_width(), ui.text_style_height(&egui::TextStyle::Body)], egui::Label::new(message).truncate());
|
||
crate::scenario::publish_target("render:job-summary", response.rect);
|
||
if let Err(error) = ¶ms {
|
||
response.on_hover_text(error);
|
||
}
|
||
if let Some(status) = &state {
|
||
if !compact {
|
||
cpu::captured_status(app, ui, status);
|
||
if let Some(message) = status["error"]["message"].as_str() {
|
||
ui.add(egui::Label::new(egui::RichText::new(message).small()).wrap());
|
||
}
|
||
}
|
||
if busy {
|
||
let done = status["progress"]["done"].as_u64().unwrap_or(0);
|
||
let total = status["progress"]["total"].as_u64().unwrap_or(1).max(1);
|
||
crate::ui_kit::progress(ui, "render", (done as f64 / total as f64).clamp(0.0, 1.0) as f32);
|
||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
|
||
}
|
||
}
|
||
ui.horizontal_wrapped(|ui| {
|
||
let cancel = crate::ui_kit::button(
|
||
ui,
|
||
app.tr("cmd.render_cancel"),
|
||
crate::ui_kit::Button::Enabled(ribbon::action_ready(app, "render.cancel").is_ok()),
|
||
);
|
||
crate::scenario::publish_target("render:cancel", cancel.rect);
|
||
if cancel.clicked()
|
||
&& let Err(error) = ribbon::perform_job(app, "render.cancel")
|
||
{
|
||
app.set_status(error, true);
|
||
}
|
||
let export = crate::ui_kit::button(
|
||
ui,
|
||
app.tr("render.image_export"),
|
||
crate::ui_kit::Button::Enabled(ribbon::action_ready(app, "render.export").is_ok()),
|
||
);
|
||
crate::scenario::publish_target("render:export", export.rect);
|
||
if export.clicked()
|
||
&& let Err(error) = ribbon::perform_job(app, "render.export")
|
||
{
|
||
app.set_status(error, true);
|
||
}
|
||
});
|
||
if !compact {
|
||
let label = app.tr("render.image_overwrite").to_string();
|
||
crate::ui_kit::checkbox(ui, &label, &mut app.render_workspace.view.overwrite);
|
||
}
|
||
}
|
||
|
||
/// Decode each published sequence once. Provisional textures stay separate from the last
|
||
/// final image, so cancellation or a failed pass cannot change the export target.
|
||
fn completed_image(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||
cache_render_images(app, ui.ctx());
|
||
if let Some(display) = displayed_image(app) {
|
||
if display.preview.is_some() {
|
||
ui.small(app.tr("render.result_provisional"));
|
||
}
|
||
match display.image {
|
||
Ok(texture) => {
|
||
if ui
|
||
.add(egui::Image::new(texture).max_size(egui::vec2(ui.available_width(), 240.0)).shrink_to_fit().sense(Sense::click()))
|
||
.on_hover_text(app.tr("render.result_open"))
|
||
.clicked()
|
||
{
|
||
app.render_workspace.view.result = true;
|
||
app.cancel_view_animation();
|
||
app.viewport.key_hold = None;
|
||
app.viewport.orbit_free = None;
|
||
}
|
||
}
|
||
Err(error) => {
|
||
ui.add(egui::Label::new(error).wrap());
|
||
}
|
||
}
|
||
} else if let Some((_, Err(error))) = &app.render_workspace.image {
|
||
ui.add(egui::Label::new(error).wrap());
|
||
}
|
||
}
|
||
|
||
fn cache_completed_image(app: &mut SolveApp, ctx: &egui::Context) {
|
||
let Some(job) = app.render_workspace.view.completed else {
|
||
app.render_workspace.image = None;
|
||
app.render_workspace.image_info = None;
|
||
return;
|
||
};
|
||
if app.render_workspace.image.as_ref().is_none_or(|(old, _)| *old != job) {
|
||
app.render_workspace.image_info = None;
|
||
let result = (|| -> Result<egui::TextureHandle, String> {
|
||
let handle = app.session.render_job_handle(job).map_err(|e| e.to_string())?;
|
||
let frame = handle.frame().map_err(|e| e.to_string())?.ok_or("render has no completed image")?;
|
||
let captured = handle.status().map_err(|e| e.to_string())?;
|
||
let budget = if frame.info.executor.as_str() == "gpu-path" {
|
||
solidcraft_engine::render::job::GPU_PATH_LIMITS
|
||
} else {
|
||
captured_image_limits(&captured.request)
|
||
};
|
||
let image = decode_render_image(frame.png(), frame.info.width, frame.info.height, budget, ctx.input(|i| i.max_texture_side))?;
|
||
app.session.execute("render.result.pin", &json!({"job":job})).map_err(|e| e.to_string())?;
|
||
app.render_workspace.image_info = Some(frame.info.clone());
|
||
Ok(ctx.load_texture("sc_render_image", image, egui::TextureOptions::LINEAR))
|
||
})();
|
||
// Cache errors too, so a bad frame cannot repeatedly allocate or decode on repaint.
|
||
app.render_workspace.image = Some((job, result));
|
||
}
|
||
}
|
||
|
||
fn displayed_image(app: &SolveApp) -> Option<DisplayImage<'_>> {
|
||
let state = &app.render_workspace;
|
||
if let Some(preview) = &state.preview_image
|
||
&& state.view.job == Some(preview.info.frame.job)
|
||
{
|
||
return Some(DisplayImage { info: &preview.info.frame, image: &preview.image, preview: Some(&preview.info) });
|
||
}
|
||
Some(DisplayImage { info: state.image_info.as_ref()?, image: &state.image.as_ref()?.1, preview: None })
|
||
}
|
||
|
||
fn cache_preview_frame(app: &mut SolveApp, ctx: &egui::Context, info: &PreviewInfo, png: &[u8]) {
|
||
if app.render_workspace.view.job != Some(info.frame.job) {
|
||
return;
|
||
}
|
||
if app
|
||
.render_workspace
|
||
.preview_image
|
||
.as_ref()
|
||
.is_some_and(|old| old.info.frame.job == info.frame.job && old.info.frame_sequence >= info.frame_sequence)
|
||
{
|
||
return;
|
||
}
|
||
let result = (|| -> Result<egui::TextureHandle, String> {
|
||
if info.frame_sequence == 0 || !(1..=4096).contains(&info.samples_per_pixel) {
|
||
return Err("invalid provisional render identity or sample count".into());
|
||
}
|
||
let handle = app.session.render_job_handle(info.frame.job).map_err(|e| e.to_string())?;
|
||
let captured = handle.status().map_err(|e| e.to_string())?;
|
||
if (info.frame.width, info.frame.height) != (captured.request.width, captured.request.height) {
|
||
return Err("checkpoint dimensions differ from the captured request".into());
|
||
}
|
||
let options = solidcraft_engine::render::job::CpuProgressiveOptions { samples_per_pixel: info.samples_per_pixel, seed: 0 };
|
||
options.validate_image_with_budget(info.frame.width, info.frame.height, captured.request.cpu_budget()).map_err(|error| error.to_string())?;
|
||
let image = decode_render_image(
|
||
png,
|
||
info.frame.width,
|
||
info.frame.height,
|
||
captured_image_limits(&captured.request),
|
||
ctx.input(|i| i.max_texture_side),
|
||
)?;
|
||
Ok(ctx.load_texture("sc_render_preview", image, egui::TextureOptions::LINEAR))
|
||
})();
|
||
// Cache errors by identity too: a malformed publication cannot decode on every repaint.
|
||
app.render_workspace.preview_image = Some(PreviewImage { info: info.clone(), image: result });
|
||
}
|
||
|
||
fn cache_preview_image(app: &mut SolveApp, ctx: &egui::Context) {
|
||
if let Some(job) = app.render_workspace.view.job
|
||
&& let Ok(handle) = app.session.render_job_handle(job)
|
||
{
|
||
let preview = handle.preview().ok().flatten();
|
||
// Read the final after the checkpoint: completion may occur after the panel's
|
||
// earlier status query. Promote the real final before dropping its preview,
|
||
// rather than briefly falling back to an older job and losing result framing.
|
||
if handle.frame().ok().flatten().is_some() {
|
||
app.render_workspace.view.completed = Some(job);
|
||
} else if let Some(preview) = preview {
|
||
cache_preview_frame(app, ctx, &preview.info, preview.png());
|
||
return;
|
||
}
|
||
}
|
||
app.render_workspace.preview_image = None;
|
||
}
|
||
|
||
fn remember_displayed_job(app: &mut SolveApp, preserve_view: bool) {
|
||
let Some(job) = displayed_image(app).map(|display| display.info.job) else { return };
|
||
if app.render_workspace.view.result_job != Some(job) {
|
||
app.render_workspace.view.result_job = Some(job);
|
||
if !preserve_view {
|
||
app.render_workspace.view.result_scale = ResultScale::Fit;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn cache_render_images(app: &mut SolveApp, ctx: &egui::Context) {
|
||
let preserve_view = app.render_workspace.preview_image.as_ref().is_some_and(|preview| {
|
||
app.render_workspace.view.result_job == Some(preview.info.frame.job) && app.render_workspace.view.job == Some(preview.info.frame.job)
|
||
});
|
||
cache_preview_image(app, ctx);
|
||
cache_completed_image(app, ctx);
|
||
// Checkpoint updates and preview→final/cancel transitions preserve shared pan/zoom.
|
||
remember_displayed_job(app, preserve_view);
|
||
}
|
||
|
||
fn captured_image_limits(request: &solidcraft_engine::render::job::ImageRequest) -> RenderLimits {
|
||
if request.integrator == Integrator::Raytrace {
|
||
request.cpu_budget().image_limits(request.raytrace.as_ref().is_some_and(|o| o.progressive.is_some()))
|
||
} else {
|
||
RenderLimits::default()
|
||
}
|
||
}
|
||
|
||
/// This is a display cache only: the authoritative PNG/frame and export remain
|
||
/// full resolution. Avoid asking a viewport device for an unsupported texture.
|
||
fn display_dimensions(width: u32, height: u32, max_texture_side: usize) -> Result<(u32, u32), String> {
|
||
if width == 0 || height == 0 || max_texture_side == 0 {
|
||
return Err("invalid image display dimensions".into());
|
||
}
|
||
let max_side = max_texture_side.min(8192) as u64;
|
||
let display = RenderLimits { max_dimension: max_side, max_pixels: 16_777_216, max_scratch_bytes: 512 * 1024 * 1024 };
|
||
let (w, h) = display.fit(u64::from(width), u64::from(height), None).ok_or("image display cannot fit this device")?;
|
||
Ok((u32::try_from(w).map_err(|_| "display width overflow")?, u32::try_from(h).map_err(|_| "display height overflow")?))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
fn decode_image(png: &[u8], width: u32, height: u32) -> Result<egui::ColorImage, String> {
|
||
decode_render_image(png, width, height, RenderLimits::default(), 8192)
|
||
}
|
||
|
||
fn decode_render_image(png: &[u8], width: u32, height: u32, budget: RenderLimits, max_texture_side: usize) -> Result<egui::ColorImage, String> {
|
||
let size = (u64::from(width), u64::from(height));
|
||
if budget.fit(size.0, size.1, None) != Some(size) {
|
||
return Err("render image exceeds its captured resource profile".into());
|
||
}
|
||
let display_size = display_dimensions(width, height, max_texture_side)?;
|
||
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(png), image::ImageFormat::Png);
|
||
let mut limits = image::Limits::default();
|
||
limits.max_image_width = Some(width);
|
||
limits.max_image_height = Some(height);
|
||
limits.max_alloc = Some(size.0.checked_mul(size.1).and_then(|n| n.checked_mul(16)).ok_or("image decode budget overflow")?);
|
||
reader.limits(limits);
|
||
let image = reader.decode().map_err(|e| e.to_string())?.to_rgba8();
|
||
if image.dimensions() != (width, height) {
|
||
return Err("render image dimensions do not match its job".into());
|
||
}
|
||
let image = if display_size != (width, height) {
|
||
image::imageops::resize(&image, display_size.0, display_size.1, image::imageops::FilterType::Triangle)
|
||
} else {
|
||
image
|
||
};
|
||
Ok(egui::ColorImage::from_rgba_unmultiplied([display_size.0 as usize, display_size.1 as usize], image.as_raw()))
|
||
}
|
||
|
||
fn reduced_display(app: &SolveApp) -> bool {
|
||
displayed_image(app).is_some_and(|display| {
|
||
display.image.as_ref().is_ok_and(|texture| texture.size() != [display.info.width as usize, display.info.height as usize])
|
||
})
|
||
}
|
||
|
||
/// The image has its own per-document orthographic camera. Shared navigation can temporarily
|
||
/// drive it without changing the product camera, saved setup, or a pending Design view.
|
||
pub(crate) fn with_result_camera(app: &mut SolveApp, navigate: impl FnOnce(&mut SolveApp)) {
|
||
let preview = std::mem::replace(&mut app.cam, app.render_workspace.view.result_camera);
|
||
let before = app.cam;
|
||
let draft = app.render_workspace.view.draft.clone();
|
||
let dirty = app.render_workspace.view.dirty;
|
||
let perspective = std::mem::replace(&mut app.ui.perspective, false);
|
||
let up = app.ui.up_axis;
|
||
let animation = app.cam_anim.take();
|
||
let pending = app.pending_fit.take();
|
||
let request = app.session.camera.take();
|
||
let free = app.viewport.orbit_free.take();
|
||
let spin = std::mem::replace(&mut app.viewport.spin, false);
|
||
navigate(app);
|
||
app.render_workspace.view.result_camera = std::mem::replace(&mut app.cam, preview);
|
||
if app.render_workspace.view.result_camera != before {
|
||
app.render_workspace.view.result_scale = ResultScale::Free;
|
||
}
|
||
app.render_workspace.view.draft = draft;
|
||
app.render_workspace.view.dirty = dirty;
|
||
app.ui.perspective = perspective;
|
||
app.ui.up_axis = up;
|
||
app.cam_anim = animation;
|
||
app.pending_fit = pending;
|
||
app.session.camera = request;
|
||
app.viewport.orbit_free = free;
|
||
app.viewport.spin = spin;
|
||
}
|
||
|
||
fn result_camera(size: [u32; 2], rect: egui::Rect, pixels_per_point: f32, scale: ResultScale) -> Camera {
|
||
let width = f64::from(size[0].max(1));
|
||
let height = f64::from(size[1].max(1));
|
||
let scale = if scale == ResultScale::Actual {
|
||
1.0 / f64::from(pixels_per_point.max(0.1))
|
||
} else {
|
||
(f64::from((rect.width() - 32.0).max(1.0)) / width).min(f64::from((rect.height() - 32.0).max(1.0)) / height)
|
||
};
|
||
Camera {
|
||
yaw: 0.0,
|
||
pitch: std::f64::consts::FRAC_PI_2,
|
||
distance: (f64::from(rect.height().max(1.0)) / (0.8 * scale)).clamp(1e-3, 1e7),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
fn result_rect(camera: Camera, size: [u32; 2], canvas: egui::Rect) -> egui::Rect {
|
||
let scale = f64::from(canvas.height().max(1.0)) / (2.0 * camera.half_height());
|
||
let center = canvas.center() + egui::vec2((-camera.target.x * scale) as f32, (camera.target.y * scale) as f32);
|
||
egui::Rect::from_center_size(center, egui::vec2((f64::from(size[0]) * scale) as f32, (f64::from(size[1]) * scale) as f32))
|
||
}
|
||
|
||
fn show_result(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||
cache_render_images(app, ui.ctx());
|
||
let t = Tokens::get();
|
||
ui.painter().rect_filled(ui.max_rect(), 0.0, t.viewport_bottom);
|
||
let Some((info, preview)) = displayed_image(app).map(|display| (display.info.clone(), display.preview.cloned())) else {
|
||
let message = match &app.render_workspace.image {
|
||
Some((_, Err(error))) => error.clone(),
|
||
_ => app.tr("render.result_empty").to_string(),
|
||
};
|
||
ui.centered_and_justified(|ui| {
|
||
ui.add(egui::Label::new(egui::RichText::new(message).size(t.font_heading).color(t.text)).wrap());
|
||
});
|
||
app.viewport.rect = None;
|
||
return;
|
||
};
|
||
egui::Frame::NONE.fill(t.panel).inner_margin(t.space_8).show(ui, |ui| {
|
||
if let Some(preview) = &preview {
|
||
ui.colored_label(
|
||
t.warning,
|
||
format!(
|
||
"{} · {} {} · {} {}",
|
||
app.tr("render.result_provisional"),
|
||
preview.samples_per_pixel,
|
||
app.tr("render.cpu_progressive_spp"),
|
||
app.tr("render.result_sequence"),
|
||
preview.frame_sequence
|
||
),
|
||
);
|
||
ui.small(app.tr("render.result_provisional_help"));
|
||
}
|
||
if let Some(display) = displayed_image(app)
|
||
&& let Err(error) = display.image
|
||
{
|
||
ui.colored_label(t.warning, error);
|
||
}
|
||
ui.horizontal_wrapped(|ui| {
|
||
ui.strong(format!("{} × {} px · PNG · {}", info.width, info.height, info.executor.as_str()));
|
||
ui.label(format!(
|
||
"{} {}:{}:{} · {} {}",
|
||
app.tr("render.result_job"),
|
||
info.job.session,
|
||
info.job.serial,
|
||
info.job.generation,
|
||
app.tr("render.result_capture"),
|
||
info.scene_generation
|
||
));
|
||
});
|
||
if reduced_display(app) {
|
||
ui.colored_label(t.warning, app.tr("render.result_reduced_display"));
|
||
}
|
||
let stale = info.scene_generation != app.session.revision;
|
||
ui.colored_label(if stale { t.warning } else { t.text_dim }, app.tr(if stale { "render.image_stale" } else { "render.result_current" }));
|
||
if app.render_workspace.view.job.is_some_and(|job| job != info.job) {
|
||
ui.small(app.tr("render.result_retained"));
|
||
}
|
||
});
|
||
let rect = ui.available_rect_before_wrap();
|
||
if rect.width() < 1.0 || rect.height() < 1.0 {
|
||
return;
|
||
}
|
||
let response = ui.allocate_rect(rect, Sense::click_and_drag());
|
||
app.viewport.rect = Some(rect);
|
||
crate::viewport::refresh_layout(app, ui.ctx().pixels_per_point());
|
||
let size = [info.width, info.height];
|
||
let scale = app.render_workspace.view.result_scale;
|
||
if scale != ResultScale::Free {
|
||
app.render_workspace.view.result_camera = result_camera(size, rect, ui.ctx().pixels_per_point(), scale);
|
||
}
|
||
with_result_camera(app, |app| {
|
||
crate::navigation::handle(app, ui, rect, &response, crate::navigation::NavCaps { orbit: false, pick: false });
|
||
});
|
||
let after = app.render_workspace.view.result_scale;
|
||
if after != scale && after != ResultScale::Free {
|
||
app.render_workspace.view.result_camera = result_camera(size, rect, ui.ctx().pixels_per_point(), after);
|
||
}
|
||
let camera = app.render_workspace.view.result_camera;
|
||
let mut image_rect = result_rect(camera, size, rect);
|
||
if after == ResultScale::Actual {
|
||
let dpi = ui.ctx().pixels_per_point();
|
||
let min = egui::pos2((image_rect.min.x * dpi).round() / dpi, (image_rect.min.y * dpi).round() / dpi);
|
||
image_rect = image_rect.translate(min - image_rect.min);
|
||
}
|
||
let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect()));
|
||
let visible = image_rect.intersect(rect);
|
||
if visible.is_positive() {
|
||
// Bound checker work by the visible canvas, even for an extremely magnified image.
|
||
painter.rect_filled(visible, 0.0, t.panel);
|
||
let cell = 24.0f32.max(visible.width().max(visible.height()) / 128.0);
|
||
for row in 0..(visible.height() / cell).ceil() as u32 {
|
||
for column in 0..(visible.width() / cell).ceil() as u32 {
|
||
if (row + column) % 2 == 0 {
|
||
let square = egui::Rect::from_min_size(visible.min + egui::vec2(column as f32 * cell, row as f32 * cell), egui::vec2(cell, cell));
|
||
painter.rect_filled(square.intersect(visible), 0.0, t.control_fill);
|
||
}
|
||
}
|
||
}
|
||
if let Some(display) = displayed_image(app)
|
||
&& let Ok(texture) = display.image
|
||
{
|
||
painter.image(texture.id(), image_rect, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), Color32::WHITE);
|
||
}
|
||
painter.rect_stroke(image_rect, 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Outside);
|
||
}
|
||
let percent = f64::from(rect.height() * ui.ctx().pixels_per_point()) / (2.0 * camera.half_height()) * 100.0;
|
||
let label = painter.layout_no_wrap(format!("{percent:.1}%"), egui::FontId::proportional(t.font_label), t.text);
|
||
let badge = egui::Rect::from_min_size(
|
||
rect.left_bottom() + egui::vec2(t.space_8, -label.size().y - t.space_8 * 2.0),
|
||
label.size() + egui::vec2(t.space_8 * 2.0, t.space_8),
|
||
);
|
||
painter.rect_filled(badge, t.control_radius, t.panel);
|
||
painter.galley(badge.min + egui::vec2(t.space_8, t.space_8 * 0.5), label, t.text);
|
||
}
|
||
|
||
impl State {
|
||
fn prepare_preview(&mut self, signature: (u64, usize, u64), capture: impl FnOnce() -> Result<ProductScene, String>) -> Result<(), String> {
|
||
if self.scene_signature != Some(signature) {
|
||
let prepared = capture().and_then(|scene| gpu_scene(&scene).map(|(key, gpu)| (scene, key, gpu)));
|
||
self.scene_signature = Some(signature);
|
||
match prepared {
|
||
Ok((scene, key, gpu)) => {
|
||
let bytes = gpu.pbr.len() + gpu.tris.len() + gpu.glass.len();
|
||
if self.key != Some(key) {
|
||
let uploaded = match self.slot.lock() {
|
||
Ok(mut slot) => {
|
||
*slot = Some(gpu);
|
||
true
|
||
}
|
||
Err(_) => false,
|
||
};
|
||
if !uploaded {
|
||
let error = "product preview upload slot unavailable".to_string();
|
||
self.scene = None;
|
||
self.key = None;
|
||
self.preview_upload_bytes = None;
|
||
self.scene_error = Some(error.clone());
|
||
return Err(error);
|
||
}
|
||
self.key = Some(key);
|
||
}
|
||
self.scene = Some(scene);
|
||
self.scene_error = None;
|
||
self.preview_upload_bytes = Some(bytes);
|
||
}
|
||
Err(error) => {
|
||
self.scene = None;
|
||
self.key = None;
|
||
self.preview_upload_bytes = None;
|
||
if let Ok(mut slot) = self.slot.lock() {
|
||
*slot = None;
|
||
}
|
||
self.scene_error = Some(error);
|
||
}
|
||
}
|
||
}
|
||
match &self.scene_error {
|
||
Some(error) => Err(error.clone()),
|
||
None => Ok(()),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Use the shared kit's wrapped, themed error surface, not text painted directly
|
||
/// on the user-selected background (which may have the opposite theme contrast).
|
||
fn preview_error(app: &mut SolveApp, ui: &mut egui::Ui, rect: egui::Rect, error: &str) {
|
||
let t = Tokens::get();
|
||
let bounded = rect.shrink(t.space_12);
|
||
ui.scope_builder(egui::UiBuilder::new().max_rect(bounded), |ui| {
|
||
ui.set_max_width(bounded.width().min(t.form_card_width * 2.0));
|
||
crate::ui_kit::Panel::feature_frame(ui.ctx(), crate::shell::LayoutMode::Floating, crate::frame::Side::Left).inner_margin(t.space_8).show(
|
||
ui,
|
||
|ui| {
|
||
crate::ui_kit::text(ui, error, crate::ui_kit::Text::Error("render.preview_failure"));
|
||
crate::ui_kit::text(ui, app.tr("render.preview_unavailable_help"), crate::ui_kit::Text::Note("render.preview_unavailable_help"));
|
||
let retry = crate::ui_kit::button(ui, app.tr("render.preview_retry"), crate::ui_kit::Button::Normal);
|
||
crate::scenario::publish_target("render:preview-retry", retry.rect);
|
||
if retry.clicked() {
|
||
invalidate_scene(app);
|
||
ui.ctx().request_repaint();
|
||
}
|
||
},
|
||
);
|
||
});
|
||
}
|
||
|
||
/// 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}));
|
||
});
|
||
}
|
||
|
||
/// Captures format and completed identity before the asynchronous picker opens.
|
||
fn export_linear_image(app: &mut SolveApp, job: solidcraft_engine::render::job::JobId, overwrite: bool) {
|
||
let origin = app.session.doc.clone();
|
||
app.pick_save("render.exr", &["exr"], 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,"format":"exr"}));
|
||
});
|
||
}
|
||
|
||
/// An exact expanded vertex payload, counted before allocating its buffers.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||
struct PreviewUploadPlan {
|
||
pbr: usize,
|
||
tris: usize,
|
||
glass: usize,
|
||
source_triangles: usize,
|
||
}
|
||
impl PreviewUploadPlan {
|
||
fn bytes(self) -> Result<usize, String> {
|
||
self.pbr.checked_add(self.tris).and_then(|n| n.checked_add(self.glass)).ok_or_else(|| "product geometry size overflow".into())
|
||
}
|
||
fn add(&mut self, pbr: bool, glass: bool, triangles: usize, limit: usize) -> Result<(), String> {
|
||
let stride = if pbr { crate::gpu::PBR_SIZE } else { crate::gpu::TRI_SIZE };
|
||
let bytes = triangles.checked_mul(3).and_then(|n| n.checked_mul(stride)).ok_or("product geometry size overflow")?;
|
||
let count = if pbr {
|
||
&mut self.pbr
|
||
} else if glass {
|
||
&mut self.glass
|
||
} else {
|
||
&mut self.tris
|
||
};
|
||
*count = count.checked_add(bytes).ok_or("product geometry size overflow")?;
|
||
if self.bytes()? > limit {
|
||
return Err(format!("product preview exceeds its {} MiB vertex payload envelope; geometry was not simplified", limit / (1024 * 1024)));
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
fn preview_upload_plan(scene: &ProductScene, limit: usize) -> Result<PreviewUploadPlan, String> {
|
||
if scene.meshes.len() > solidcraft_engine::render::job::MAX_CPU_BODIES {
|
||
return Err("product preview exceeds its captured-body bound".into());
|
||
}
|
||
let mut plan = PreviewUploadPlan::default();
|
||
for body in &scene.meshes {
|
||
plan.source_triangles = plan.source_triangles.checked_add(body.mesh.triangles.len()).ok_or("product triangle count overflow")?;
|
||
if plan.source_triangles > solidcraft_engine::render::job::MAX_CPU_TRIANGLES {
|
||
return Err("product preview exceeds its eight-million-triangle source bound".into());
|
||
}
|
||
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(|face| !face.opacity.is_finite() || !(0.0..=1.0).contains(&face.opacity))
|
||
{
|
||
return Err("invalid product appearance opacity".into());
|
||
}
|
||
let looks: std::collections::BTreeMap<_, _> = body.faces.iter().map(|face| (face.face, face)).collect();
|
||
if looks.len() != body.faces.len() {
|
||
return Err("duplicate product face appearance".into());
|
||
}
|
||
for index in 0..body.mesh.triangles.len() {
|
||
let face = body.mesh.tri_face.get(index).and_then(|id| looks.get(id));
|
||
let (opacity, pbr) = face.map_or((body.opacity, body.surface.is_some() && body.transmission.is_none()), |face| {
|
||
(face.opacity, face.surface.is_some() && face.transmission.is_none())
|
||
});
|
||
if pbr && opacity != 1.0 {
|
||
return Err("opaque PBR requires opacity 1".into());
|
||
}
|
||
if opacity > 0.0 {
|
||
plan.add(pbr, opacity < 0.999, 1, limit)?;
|
||
}
|
||
}
|
||
}
|
||
Ok(plan)
|
||
}
|
||
|
||
/// Convert all captured surfaces, without decimation. The finite, exact payload
|
||
/// plan is admitted first; each output vector is reserved once rather than grown
|
||
/// once per triangle. Device uploads retain their existing chunked buffer path.
|
||
pub fn gpu_scene(scene: &ProductScene) -> Result<(u64, GpuScene), String> {
|
||
let plan = preview_upload_plan(scene, MAX_GEOMETRY_BYTES)?;
|
||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||
let mut gpu = GpuScene::default();
|
||
for (buffer, bytes) in [(&mut gpu.pbr, plan.pbr), (&mut gpu.tris, plan.tris), (&mut gpu.glass, plan.glass)] {
|
||
buffer.try_reserve_exact(bytes).map_err(|_| "product preview vertex allocation failed".to_string())?;
|
||
}
|
||
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);
|
||
body.transmission.is_some().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);
|
||
face.transmission.is_some().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.filter(|_| body.transmission.is_none())), |l| {
|
||
(l.color, l.opacity, l.surface.filter(|_| l.transmission.is_none()))
|
||
});
|
||
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 index = gpu.color(rgba);
|
||
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, index);
|
||
} else {
|
||
gpu.tri(p, n, index);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (gpu.pbr.len(), gpu.tris.len(), gpu.glass.len()) != (plan.pbr, plan.tris, plan.glass) {
|
||
return Err("product preview packing differs from its admitted vertex payload".into());
|
||
}
|
||
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) {
|
||
if app.render_workspace.view.result {
|
||
show_result(app, ui);
|
||
return;
|
||
}
|
||
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 (app.render_workspace.view.method == ImageMethod::Raster && 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(&crate::open_meshing::session_world(&app.session)) as usize, visibility.finish());
|
||
app.render_workspace
|
||
.prepare_preview(signature, || solidcraft_engine::product_scene::extract(&app.session).map_err(|error| error.to_string()))?;
|
||
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,
|
||
pv_hide: false,
|
||
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,
|
||
// Product rendering has its own lighting; AO is a Design viewport setting.
|
||
ao_radius: 0.0,
|
||
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 },
|
||
hover_tint: [0.0; 3],
|
||
selection_tint: [0.0; 3],
|
||
});
|
||
ui.painter().add(egui_wgpu::Callback::new_paint_callback(rect, callback));
|
||
Ok(())
|
||
})();
|
||
if let Err(error) = result {
|
||
preview_error(app, ui, rect, &error);
|
||
}
|
||
crate::viewport::axis_view(app, ui, rect);
|
||
}
|
||
|
||
pub(crate) fn interactive_command_info(app: &SolveApp, command: &solidcraft_engine::CommandSpec) -> solidcraft_engine::CommandInfo {
|
||
ribbon::command_info(app, command)
|
||
}
|
||
pub(crate) fn start_interactive(app: &mut SolveApp, id: &str) -> bool {
|
||
ribbon::start(app, id)
|
||
}
|