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>
198 lines
7.3 KiB
Rust
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())
|
|
}
|