Files
solidcraft-upstream/crates/engine/src/render_jobs.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

198 lines
7.3 KiB
Rust

//! Per-document, transient render ownership. Captured scenes remain immutable when CAD edits
//! continue; callers get the captured revision and must never present it as a newer scene.
use solidcraft_render::{
ExecutorDescriptor,
job::{CpuHost, ErrorCode, JobError, JobHandle, JobId, JobStatus, Phase, RenderFrame},
};
use std::collections::VecDeque;
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
static NEXT_OWNER: AtomicU64 = AtomicU64::new(1);
fn owner() -> u64 {
let mut n = NEXT_OWNER.load(Ordering::Relaxed);
loop {
let Some(next) = n.checked_add(1) else { return 0 };
match NEXT_OWNER.compare_exchange_weak(n, next, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => return n,
Err(current) => n = current,
}
}
}
pub(crate) struct Jobs {
owner: u64,
serial: u64,
generation: u64,
pub(crate) host: Option<CpuHost>,
pub(crate) descriptor: Option<ExecutorDescriptor>,
live: Vec<JobHandle>,
history: VecDeque<JobStatus>,
last_good: Option<JobHandle>,
}
impl Default for Jobs {
fn default() -> Self {
Self { owner: owner(), serial: 0, generation: 0, host: None, descriptor: None, live: Vec::new(), history: VecDeque::new(), last_good: None }
}
}
impl Drop for Jobs {
fn drop(&mut self) {
for job in &self.live {
let _ = job.cancel();
}
}
}
impl Jobs {
pub(crate) fn scratch(&self) -> Self {
let mut jobs = Self::default();
jobs.host = self.host.clone();
jobs.descriptor = self.descriptor.clone();
jobs
}
pub(crate) fn collect(&mut self) -> Result<(), JobError> {
let mut i = 0;
while i < self.live.len() {
let status = self.live.get(i).ok_or_else(|| JobError::new(ErrorCode::Internal, "missing live job"))?.status()?;
if !status.progress.phase.terminal() {
i += 1;
continue;
}
let handle = self.live.remove(i);
if status.progress.phase == Phase::Completed && self.last_good.as_ref().is_none_or(|old| old.id().serial < handle.id().serial) {
self.last_good = Some(handle);
}
self.history.push_back(status);
while self.history.len() > 4 {
self.history.pop_front();
}
}
Ok(())
}
pub(crate) fn start(
&mut self,
revision: u64,
scene: solidcraft_render::ProductScene,
request: solidcraft_render::job::ImageRequest,
replace: bool,
) -> Result<JobStatus, JobError> {
self.collect()?;
let host = self.host.as_ref().ok_or_else(|| JobError::new(ErrorCode::HostUnavailable, "no image host is installed"))?.clone();
if replace {
for job in self.live.drain(..) {
let mut status = job.cancel()?;
// Replaced handles are deliberately abandoned: keep a cancellation tombstone,
// not a permanently "cancelling" snapshot. The token already prevents any
// publication; the host can finish releasing its buffers asynchronously.
if !status.progress.phase.terminal() {
status.progress.phase = Phase::Cancelled;
}
self.history.push_back(status);
}
while self.history.len() > 4 {
self.history.pop_front();
}
}
if self.live.len() >= 2 {
return Err(JobError::new(ErrorCode::QueueFull, "this document already has an active and pending render"));
}
if self.owner == 0 {
return Err(JobError::new(ErrorCode::LimitExceeded, "render session identity exhausted"));
}
let serial = self.serial.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "render job identity exhausted"))?;
let generation = self.generation.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "render generation exhausted"))?;
let handle = host.submit(JobId { session: self.owner, serial, generation }, revision, scene, request)?;
let status = handle.status()?;
self.serial = serial;
self.generation = generation;
self.live.push(handle);
Ok(status)
}
fn check_owner(&self, id: JobId) -> Result<(), JobError> {
if id.session != self.owner {
return Err(JobError::new(ErrorCode::WrongOwner, "render job belongs to another document session"));
}
Ok(())
}
pub(crate) fn handle(&self, id: JobId) -> Result<JobHandle, JobError> {
self.check_owner(id)?;
self.live
.iter()
.chain(self.last_good.iter())
.find(|j| j.id() == id)
.cloned()
.ok_or_else(|| JobError::new(ErrorCode::UnknownJob, "render job is no longer retained"))
}
pub(crate) fn status(&mut self, id: JobId) -> Result<JobStatus, JobError> {
self.check_owner(id)?;
self.collect()?;
if let Ok(handle) = self.handle(id) {
return handle.status();
}
self.history.iter().find(|s| s.job == id).cloned().ok_or_else(|| JobError::new(ErrorCode::UnknownJob, "render job is no longer retained"))
}
pub(crate) fn frame(&mut self, id: JobId) -> Result<Arc<RenderFrame>, JobError> {
self.collect()?;
self.handle(id)?.frame()?.ok_or_else(|| JobError::new(ErrorCode::NotCompleted, "render has no completed image"))
}
}
impl crate::Session {
/// A host is installed by the application, never by document data or a command.
pub fn set_render_job_host(&mut self, host: Option<CpuHost>) -> crate::Result<()> {
if self.render_jobs.host.as_ref().zip(host.as_ref()).is_some_and(|(a, b)| a.same_host(b)) {
return Ok(());
}
let descriptor = host.as_ref().map(CpuHost::descriptor).transpose().map_err(job_error)?;
let mut backend = self.render_backend();
backend.executors.retain(|e| self.render_jobs.descriptor.as_ref().is_none_or(|old| old.id != e.id));
if let Some(d) = &descriptor {
backend.executors.push(d.clone());
}
solidcraft_render::resolve_request(
&[backend],
&solidcraft_render::RenderRequest {
backend: None,
mode: solidcraft_render::RenderMode::Preview,
operation: solidcraft_render::RenderOperation::Image,
width: 1,
height: 1,
format: Some(solidcraft_render::RenderFormat::Png),
features: Vec::new(),
policy: Default::default(),
},
)
.map_err(|e| crate::EngineError::Other(e.to_string()))?;
let mut jobs = Jobs::default();
jobs.host = host;
jobs.descriptor = descriptor;
self.render_jobs = jobs;
Ok(())
}
pub fn render_job_host(&self) -> Option<&CpuHost> {
self.render_jobs.host.as_ref()
}
/// Explicit native CLI waiting is separate from non-blocking status commands.
pub fn render_job_handle(&self, id: JobId) -> crate::Result<JobHandle> {
self.render_jobs.handle(id).map_err(job_error)
}
}
pub(crate) fn job_error(error: JobError) -> crate::EngineError {
crate::EngineError::Other(error.to_string())
}