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>
This commit is contained in:
selune authored and GitHub committed 2026-10-14 19:24:36 -04:00
1 parent e4ef6cfe29
commit a261aaa841
70 files changed
+5860 -101

No files matched your search

+516
View File
@@ -0,0 +1,516 @@
//! Caller-owned CPU execution host. One worker, two bounded queued inputs; browser pumping
//! uses the same incremental task without threads. Shared ownership never includes CAD state.
use std::collections::VecDeque;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Condvar;
use std::sync::{
Arc, Mutex, Weak,
atomic::{AtomicBool, AtomicU64, Ordering},
};
use serde::Serialize;
use super::{
Admission, AlphaConvention, CancelToken, ColourEncoding, ErrorCode, FrameInfo, ImageRequest, ImageTask, JobError, JobId, Phase, Progress, Result,
TaskPoll, admit,
};
use crate::{
Availability, ExecutorDescriptor, ExecutorId, ProductScene, RenderFormat, RenderLimits, RenderMode, RenderOperation, RenderProfile, ScratchLayout,
};
const MEMORY_LIMIT: u64 = 512 * 1024 * 1024;
const QUEUE_LIMIT: usize = 2;
#[cfg(test)]
#[path = "host_tests.rs"]
mod tests;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum HostKind {
NativeWorker,
Cooperative,
}
struct Budget {
used: AtomicU64,
}
struct Reservation {
budget: Arc<Budget>,
bytes: u64,
}
impl Budget {
fn reserve(self: &Arc<Self>, bytes: u64) -> Result<Reservation> {
let mut used = self.used.load(Ordering::Acquire);
loop {
let next = used
.checked_add(bytes)
.filter(|n| *n <= MEMORY_LIMIT)
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "CPU image host's 512 MiB admission pool is full"))?;
match self.used.compare_exchange_weak(used, next, Ordering::AcqRel, Ordering::Acquire) {
Ok(_) => break,
Err(current) => used = current,
}
}
Ok(Reservation { budget: self.clone(), bytes })
}
}
impl Reservation {
fn shrink(&mut self, bytes: u64) -> Result<()> {
if bytes > self.bytes {
return Err(JobError::new(ErrorCode::LimitExceeded, "completed PNG exceeds its reservation"));
}
self.budget.used.fetch_sub(self.bytes - bytes, Ordering::AcqRel);
self.bytes = bytes;
Ok(())
}
}
impl Drop for Reservation {
fn drop(&mut self) {
self.budget.used.fetch_sub(self.bytes, Ordering::AcqRel);
}
}
/// Borrow encoded bytes through the frame, so retaining the data also retains its admission lease.
pub struct RenderFrame {
pub info: FrameInfo,
png: Vec<u8>,
_reservation: Reservation,
}
impl RenderFrame {
pub fn png(&self) -> &[u8] {
&self.png
}
}
#[derive(Clone, Debug, Serialize)]
pub struct JobStatus {
pub job: JobId,
pub scene_generation: u64,
pub request: ImageRequest,
pub resolved: crate::ResolvedRequest,
pub progress: Progress,
pub error: Option<JobError>,
pub frame: Option<FrameInfo>,
}
struct Data {
progress: Progress,
error: Option<JobError>,
frame: Option<Arc<RenderFrame>>,
}
struct Record {
id: JobId,
scene_generation: u64,
request: ImageRequest,
resolved: crate::ResolvedRequest,
cancel: CancelToken,
data: Mutex<Data>,
#[cfg(not(target_arch = "wasm32"))]
done: Condvar,
}
impl Record {
fn notify(&self) {
#[cfg(not(target_arch = "wasm32"))]
self.done.notify_all();
}
}
#[derive(Clone)]
pub struct JobHandle(Arc<Record>);
impl JobHandle {
pub fn id(&self) -> JobId {
self.0.id
}
pub fn status(&self) -> Result<JobStatus> {
let data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?;
Ok(JobStatus {
job: self.0.id,
scene_generation: self.0.scene_generation,
request: self.0.request.clone(),
resolved: self.0.resolved.clone(),
progress: data.progress,
error: data.error.clone(),
frame: data.frame.as_ref().map(|f| f.info.clone()),
})
}
pub fn cancel(&self) -> Result<JobStatus> {
{
let mut data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?;
if !data.progress.phase.terminal() {
self.0.cancel.cancel();
data.progress.phase = if data.progress.phase == Phase::Queued { Phase::Cancelled } else { Phase::Cancelling };
}
}
self.0.notify();
self.status()
}
/// Native application helper; engine status commands remain non-blocking.
#[cfg(not(target_arch = "wasm32"))]
pub fn wait_timeout(&self, timeout: std::time::Duration) -> Result<JobStatus> {
let data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?;
let (data, timed) = self
.0
.done
.wait_timeout_while(data, timeout, |d| !d.progress.phase.terminal())
.map_err(|_| JobError::new(ErrorCode::Internal, "render completion lock poisoned"))?;
if timed.timed_out() && !data.progress.phase.terminal() {
return Err(JobError::new(ErrorCode::TimedOut, "render wait timed out"));
}
drop(data);
self.status()
}
pub fn frame(&self) -> Result<Option<Arc<RenderFrame>>> {
let data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render result lock poisoned"))?;
let frame = data.frame.clone();
if frame.as_ref().is_some_and(|f| f.info.job != self.0.id || f.info.scene_generation != self.0.scene_generation) {
return Err(JobError::new(ErrorCode::WrongOwner, "render frame identity does not match its job"));
}
Ok(frame)
}
}
struct Work {
scene: ProductScene,
handle: JobHandle,
reservation: Reservation,
}
struct Running {
task: ImageTask,
handle: JobHandle,
reservation: Option<Reservation>,
}
fn terminal(handle: &JobHandle, error: JobError) {
if let Ok(mut data) = handle.0.data.lock() {
if data.progress.phase == Phase::Completed {
return;
}
if handle.0.cancel.is_cancelled() || error.code == ErrorCode::Cancelled {
data.progress.phase = Phase::Cancelled;
data.error = None;
} else {
data.progress.phase = Phase::Failed;
data.error = Some(error);
}
data.frame = None;
}
handle.0.notify();
}
impl Running {
fn begin(work: Work) -> Option<Self> {
if work.handle.0.cancel.is_cancelled() {
terminal(&work.handle, JobError::new(ErrorCode::Cancelled, "render cancelled"));
return None;
}
if let Ok(mut data) = work.handle.0.data.lock()
&& !work.handle.0.cancel.is_cancelled()
{
data.progress.phase = Phase::Preparing;
}
let task = ImageTask::new(work.scene, work.handle.0.request.clone(), work.handle.0.cancel.clone());
match task {
Ok(task) => Some(Self { task, handle: work.handle, reservation: Some(work.reservation) }),
Err(e) => {
terminal(&work.handle, e);
None
}
}
}
fn advance(&mut self, units: usize) -> Result<bool> {
let result = self.task.step(units)?;
let mut data = self.handle.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?;
// The final publication check is under the same lock as cancellation.
if self.handle.0.cancel.is_cancelled() {
data.progress.phase = Phase::Cancelled;
data.frame = None;
self.handle.0.notify();
return Ok(false);
}
match result {
TaskPoll::Pending(progress) => {
data.progress = progress;
Ok(true)
}
TaskPoll::Complete(png) => {
let mut reservation = self.reservation.take().ok_or_else(|| JobError::new(ErrorCode::Internal, "missing render reservation"))?;
reservation.shrink(png.capacity() as u64 + 1024)?;
let info = FrameInfo {
job: self.handle.0.id,
scene_generation: self.handle.0.scene_generation,
backend: self.handle.0.resolved.backend.clone(),
executor: self.handle.0.resolved.executor.clone(),
width: self.handle.0.request.width,
height: self.handle.0.request.height,
encoding: ColourEncoding::Srgb,
alpha: AlphaConvention::Straight,
};
data.frame = Some(Arc::new(RenderFrame { info, png, _reservation: reservation }));
data.progress = Progress { phase: Phase::Completed, done: 1, total: 1 };
self.handle.0.notify();
Ok(false)
}
}
}
fn guarded_step(&mut self, units: usize) -> bool {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.advance(units)));
match result {
Ok(Ok(more)) => more,
Ok(Err(e)) => {
terminal(&self.handle, e);
false
}
Err(_) => {
terminal(&self.handle, JobError::new(ErrorCode::Internal, "CPU image host encountered an internal error"));
false
}
}
}
}
struct Shared {
queue: Mutex<VecDeque<Work>>,
active: Mutex<Option<Weak<Record>>>,
stop: AtomicBool,
budget: Arc<Budget>,
#[cfg(not(target_arch = "wasm32"))]
wake: Condvar,
}
struct Owner {
shared: Arc<Shared>,
kind: HostKind,
running: Mutex<Option<Running>>,
#[cfg(not(target_arch = "wasm32"))]
_worker: Option<std::thread::JoinHandle<()>>,
}
impl Drop for Owner {
fn drop(&mut self) {
self.shared.stop.store(true, Ordering::Release);
if let Ok(active) = self.shared.active.lock()
&& let Some(record) = active.as_ref().and_then(Weak::upgrade)
{
let _ = JobHandle(record).cancel();
}
if let Ok(mut queue) = self.shared.queue.lock() {
for work in queue.iter() {
let _ = work.handle.cancel();
}
queue.clear();
}
if let Ok(mut running) = self.running.lock()
&& let Some(work) = running.take()
{
let _ = work.handle.cancel();
terminal(&work.handle, JobError::new(ErrorCode::Cancelled, "render host closed"));
}
#[cfg(not(target_arch = "wasm32"))]
self.shared.wake.notify_all();
// Detaching cannot stall the UI; the bounded worker observes stop/cancel at its next unit.
}
}
#[derive(Clone)]
pub struct CpuHost(Arc<Owner>);
impl CpuHost {
fn shared() -> Arc<Shared> {
Arc::new(Shared {
queue: Mutex::new(VecDeque::new()),
active: Mutex::new(None),
stop: AtomicBool::new(false),
budget: Arc::new(Budget { used: AtomicU64::new(0) }),
#[cfg(not(target_arch = "wasm32"))]
wake: Condvar::new(),
})
}
pub fn cooperative() -> Self {
Self(Arc::new(Owner {
shared: Self::shared(),
kind: HostKind::Cooperative,
running: Mutex::new(None),
#[cfg(not(target_arch = "wasm32"))]
_worker: None,
}))
}
pub fn for_platform() -> Result<Self> {
#[cfg(target_arch = "wasm32")]
{
Ok(Self::cooperative())
}
#[cfg(not(target_arch = "wasm32"))]
{
let shared = Self::shared();
let worker_shared = shared.clone();
let worker = std::thread::Builder::new()
.name("solidcraft-render".into())
.spawn(move || native_worker(worker_shared))
.map_err(|e| JobError::new(ErrorCode::HostUnavailable, format!("could not start CPU image worker: {e}")))?;
Ok(Self(Arc::new(Owner { shared, kind: HostKind::NativeWorker, running: Mutex::new(None), _worker: Some(worker) })))
}
}
pub fn kind(&self) -> HostKind {
self.0.kind
}
pub fn same_host(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.0, &other.0)
}
pub fn is_available(&self) -> bool {
!self.0.shared.stop.load(Ordering::Acquire)
}
pub fn admitted_bytes(&self) -> u64 {
self.0.shared.budget.used.load(Ordering::Acquire)
}
pub fn descriptor(&self) -> Result<ExecutorDescriptor> {
Ok(ExecutorDescriptor {
id: ExecutorId::new("cpu-product").map_err(|e| JobError::new(ErrorCode::Internal, e.to_string()))?,
availability: if self.0.shared.stop.load(Ordering::Acquire) {
Availability::Unavailable { reason: "CPU image host has stopped".into() }
} else {
Availability::Available
},
profiles: vec![RenderProfile {
mode: RenderMode::Preview,
operation: RenderOperation::Image,
formats: vec![RenderFormat::Png],
features: vec![crate::RenderFeature::Pbr],
}],
limits: RenderLimits::default(),
scratch: ScratchLayout { sample_scale: 2, sample_bytes_per_pixel: 8, output_bytes_per_pixel: 8, encoding_bytes_per_pixel: 8 },
})
}
pub fn submit(&self, id: JobId, scene_generation: u64, scene: ProductScene, request: ImageRequest) -> Result<JobHandle> {
if id.session == 0 || id.serial == 0 || id.generation == 0 {
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid render job identity"));
}
let Admission { estimated_total_bytes, .. } = admit(&scene, &request)?;
let mut backend = crate::builtin_backend();
backend.executors.push(self.descriptor()?);
let resolved = crate::resolve_request(
&[backend],
&crate::RenderRequest {
backend: None,
mode: RenderMode::Preview,
operation: RenderOperation::Image,
width: u64::from(request.width),
height: u64::from(request.height),
format: Some(RenderFormat::Png),
features: scene.required_features(),
policy: Default::default(),
},
)
.map_err(|e| JobError::new(ErrorCode::HostUnavailable, e.to_string()))?;
if resolved.executor.as_str() != "cpu-product" {
return Err(JobError::new(ErrorCode::HostUnavailable, "CPU product executor is unavailable"));
}
let mut queue = self.0.shared.queue.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "render queue lock poisoned"))?;
if self.0.shared.stop.load(Ordering::Acquire) {
return Err(JobError::new(ErrorCode::HostUnavailable, "CPU image host has stopped"));
}
queue.retain(|work| !work.handle.0.cancel.is_cancelled());
if queue.len() >= QUEUE_LIMIT {
return Err(JobError::new(ErrorCode::QueueFull, "CPU image queue is full"));
}
let reservation = self.0.shared.budget.reserve(estimated_total_bytes)?;
let handle = JobHandle(Arc::new(Record {
id,
scene_generation,
request,
resolved,
cancel: CancelToken::default(),
data: Mutex::new(Data { progress: Progress { phase: Phase::Queued, done: 0, total: 1 }, error: None, frame: None }),
#[cfg(not(target_arch = "wasm32"))]
done: Condvar::new(),
}));
queue.push_back(Work { scene, handle: handle.clone(), reservation });
drop(queue);
#[cfg(not(target_arch = "wasm32"))]
self.0.shared.wake.notify_one();
Ok(handle)
}
/// Cooperative callers pump a small quantum independently of polling a job's status.
/// Native jobs run even when the UI is idle or a headless caller is not polling.
pub fn pump(&self, units: usize) -> Result<bool> {
if self.0.kind != HostKind::Cooperative {
return Ok(false);
}
let mut running = self.0.running.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "cooperative host lock poisoned"))?;
if running.is_none() {
let work = self.0.shared.queue.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "render queue lock poisoned"))?.pop_front();
if let Some(work) = work {
*running = Running::begin(work);
}
}
if let Some(work) = running.as_mut() {
if let Ok(mut active) = self.0.shared.active.lock() {
*active = Some(Arc::downgrade(&work.handle.0));
}
if !work.guarded_step(units) {
*running = None;
if let Ok(mut active) = self.0.shared.active.lock() {
*active = None;
}
}
}
Ok(running.is_some()
|| !self.0.shared.queue.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "render queue lock poisoned"))?.is_empty())
}
}
#[cfg(not(target_arch = "wasm32"))]
fn native_worker(shared: Arc<Shared>) {
loop {
let mut queue = match shared.queue.lock() {
Ok(q) => q,
Err(_) => {
shared.stop.store(true, Ordering::Release);
return;
}
};
while queue.is_empty() && !shared.stop.load(Ordering::Acquire) {
queue = match shared.wake.wait(queue) {
Ok(q) => q,
Err(_) => {
shared.stop.store(true, Ordering::Release);
return;
}
};
}
if shared.stop.load(Ordering::Acquire) {
return;
}
let work = queue.pop_front();
drop(queue);
let Some(mut running) = work.and_then(Running::begin) else { continue };
if let Ok(mut active) = shared.active.lock() {
*active = Some(Arc::downgrade(&running.handle.0));
}
loop {
if shared.stop.load(Ordering::Acquire) {
let _ = running.handle.cancel();
}
if !running.guarded_step(4096) {
break;
}
}
if let Ok(mut active) = shared.active.lock() {
*active = None;
}
}
}
+131
View File
@@ -0,0 +1,131 @@
use super::*;
use solidcraft_geom::{Aabb3, Mesh};
fn request() -> ImageRequest {
ImageRequest { camera: Default::default(), width: 16, height: 16, background: [40, 80, 120], transparent_background: false }
}
fn empty() -> ProductScene {
ProductScene { meshes: Vec::new(), bounds: Aabb3::EMPTY }
}
fn id(owner: u64, serial: u64) -> JobId {
JobId { session: owner, serial, generation: serial }
}
fn drain(host: &CpuHost) {
for _ in 0..10_000 {
if !host.pump(4096).unwrap() {
return;
}
}
panic!("small cooperative jobs did not drain");
}
#[test]
fn queue_is_bounded_and_cancelled_entries_release_their_reservations() {
let host = CpuHost::cooperative();
let a = host.submit(id(10, 1), 100, empty(), request()).unwrap();
let b = host.submit(id(20, 1), 200, empty(), request()).unwrap();
let before = host.admitted_bytes();
assert_eq!(host.submit(id(30, 1), 300, empty(), request()).err().unwrap().code, ErrorCode::QueueFull);
assert_eq!(host.admitted_bytes(), before);
assert_eq!(a.cancel().unwrap().progress.phase, Phase::Cancelled);
let c = host.submit(id(30, 1), 300, empty(), request()).unwrap();
drain(&host);
assert_eq!(a.status().unwrap().progress.phase, Phase::Cancelled);
for (handle, owner, generation) in [(&b, 20, 200), (&c, 30, 300)] {
let frame = handle.frame().unwrap().unwrap();
assert_eq!(frame.info.job, id(owner, 1));
assert_eq!(frame.info.scene_generation, generation);
assert_eq!(frame.info.executor.as_str(), "cpu-product");
assert_eq!(image::load_from_memory(frame.png()).unwrap().width(), 16);
}
assert!(host.admitted_bytes() > 0 && host.admitted_bytes() < before);
let frame = b.frame().unwrap().unwrap();
drop(b);
drop(c);
assert!(host.admitted_bytes() > 0, "an externally retained frame retains its lease");
drop(frame);
assert_eq!(host.admitted_bytes(), 0);
}
#[test]
fn cancellation_while_encoding_cannot_publish_a_late_frame() {
let host = CpuHost::cooperative();
let job = host.submit(id(1, 1), 8, empty(), request()).unwrap();
for _ in 0..10_000 {
host.pump(64).unwrap();
let status = job.status().unwrap();
if status.progress.phase == Phase::Encoding && status.progress.done > 0 {
break;
}
}
assert_eq!(job.status().unwrap().progress.phase, Phase::Encoding);
assert_eq!(job.cancel().unwrap().progress.phase, Phase::Cancelling);
drain(&host);
assert_eq!(job.status().unwrap().progress.phase, Phase::Cancelled);
assert!(job.frame().unwrap().is_none());
assert_eq!(host.admitted_bytes(), 0);
}
#[test]
fn malformed_preparation_fails_the_job_without_stopping_the_host() {
let host = CpuHost::cooperative();
let scene = ProductScene {
bounds: Aabb3::EMPTY,
meshes: vec![crate::ProductMesh {
name: "Broken".into(),
mesh: Arc::new(Mesh { triangles: vec![[99, 99, 99]], ..Default::default() }),
color: [0; 3],
opacity: 1.0,
surface: None,
faces: Vec::new(),
}],
};
let broken = host.submit(id(1, 1), 1, scene, request()).unwrap();
drain(&host);
let status = broken.status().unwrap();
assert_eq!(status.progress.phase, Phase::Failed);
assert_eq!(status.error.unwrap().code, ErrorCode::InvalidScene);
assert!(broken.frame().unwrap().is_none());
assert!(host.is_available());
let healthy = host.submit(id(1, 2), 1, empty(), request()).unwrap();
drain(&host);
assert_eq!(healthy.status().unwrap().progress.phase, Phase::Completed);
}
#[test]
fn closing_the_host_cancels_active_and_queued_work() {
let host = CpuHost::cooperative();
let budget = host.0.shared.budget.clone();
let active = host.submit(id(1, 1), 1, empty(), request()).unwrap();
let queued = host.submit(id(2, 1), 1, empty(), request()).unwrap();
host.pump(1).unwrap();
drop(host);
assert_eq!(active.status().unwrap().progress.phase, Phase::Cancelled);
assert_eq!(queued.status().unwrap().progress.phase, Phase::Cancelled);
assert_eq!(budget.used.load(Ordering::Acquire), 0);
}
#[test]
fn a_foreign_frame_is_rejected_even_if_a_broken_host_puts_it_in_the_result_slot() {
let host = CpuHost::cooperative();
let job = host.submit(id(1, 1), 1, empty(), request()).unwrap();
drain(&host);
{
let mut data = job.0.data.lock().unwrap();
let frame = Arc::get_mut(data.frame.as_mut().unwrap()).unwrap();
frame.info.job.session = 2;
}
assert_eq!(job.frame().err().unwrap().code, ErrorCode::WrongOwner);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn native_job_finishes_without_any_poll_or_ui_pump() {
let host = CpuHost::for_platform().unwrap();
assert_eq!(host.kind(), HostKind::NativeWorker);
let job = host.submit(id(1, 1), 12, empty(), request()).unwrap();
// A generous deadlock watchdog, not a performance assertion.
let status = job.wait_timeout(std::time::Duration::from_secs(60)).unwrap();
assert_eq!(status.progress.phase, Phase::Completed);
assert_eq!(job.frame().unwrap().unwrap().info.scene_generation, 12);
}
+700
View File
@@ -0,0 +1,700 @@
//! Incremental product rasterization. Every phase yields between bounded work units.
use std::collections::BTreeMap;
use std::io::Write;
use std::sync::{Arc, Mutex};
use serde::Serialize;
use solidcraft_geom::Vec3;
use super::{CancelToken, ErrorCode, ImageRequest, JobError, Phase, Progress, Result};
use crate::{FaceAppearance, Mat4, ProductScene, RenderFormat, RenderMode, RenderOperation, RenderRequest};
#[cfg(test)]
#[path = "image_tests.rs"]
mod tests;
pub const MAX_TRIANGLES: u64 = 262_144;
pub const MAX_GEOMETRY_BYTES: u64 = 128 * 1024 * 1024;
pub const MAX_JOB_BYTES: u64 = 384 * 1024 * 1024;
pub const MAX_PIXEL_TESTS: u64 = 128_000_000;
const MAX_STEP: usize = 16_384;
const SS: usize = 2;
#[derive(Clone, Copy, Debug, Serialize)]
pub struct Admission {
pub triangles: u64,
pub face_appearances: u64,
pub geometry_bytes: u64,
pub pixel_scratch_bytes: u64,
pub preparation_bytes: u64,
pub estimated_total_bytes: u64,
pub encoded_limit: u64,
}
fn limit(message: &str) -> JobError {
JobError::new(ErrorCode::LimitExceeded, message)
}
fn invalid(message: &str) -> JobError {
JobError::new(ErrorCode::InvalidScene, message)
}
fn add_bytes(sum: &mut u64, count: usize, size: usize) -> Result<()> {
let bytes = (count as u64).checked_mul(size as u64).ok_or_else(|| limit("geometry size overflow"))?;
*sum = sum.checked_add(bytes).ok_or_else(|| limit("geometry size overflow"))?;
if *sum > MAX_GEOMETRY_BYTES {
return Err(limit("retained product geometry exceeds 128 MiB"));
}
Ok(())
}
/// Conservative admission before allocating pixels or projected-work lists. Source Arcs are
/// counted even when another owner already holds them. Allocator and codec internals are estimates.
pub fn admit(scene: &ProductScene, request: &ImageRequest) -> Result<Admission> {
if !request.camera.is_valid() {
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid image camera"));
}
if scene.meshes.len() > 16_384 {
return Err(limit("too many product bodies"));
}
let resolved = crate::resolve_request(
&[crate::builtin_backend()],
&RenderRequest {
backend: None,
mode: RenderMode::Preview,
operation: RenderOperation::Image,
width: u64::from(request.width),
height: u64::from(request.height),
format: Some(RenderFormat::Png),
features: Vec::new(),
policy: Default::default(),
},
)
.map_err(|e| JobError::new(ErrorCode::InvalidRequest, e.to_string()))?;
let (mut triangles, mut faces, mut geometry, mut polylines) = (0u64, 0u64, 0u64, 0u64);
add_bytes(&mut geometry, scene.meshes.capacity(), std::mem::size_of::<crate::ProductMesh>())?;
for body in &scene.meshes {
let m = &body.mesh;
triangles = triangles.checked_add(m.triangles.len() as u64).ok_or_else(|| limit("triangle count overflow"))?;
faces = faces.checked_add(body.faces.len() as u64).ok_or_else(|| limit("appearance count overflow"))?;
polylines = polylines.checked_add(m.edges.len() as u64 + m.edge_faces.len() as u64).ok_or_else(|| limit("edge count overflow"))?;
if triangles > MAX_TRIANGLES || faces > MAX_TRIANGLES || polylines > MAX_TRIANGLES {
return Err(limit("product image exceeds the 262144 triangle/appearance/polyline admission cap"));
}
add_bytes(&mut geometry, m.positions.capacity(), std::mem::size_of::<Vec3>())?;
add_bytes(&mut geometry, m.normals.capacity(), std::mem::size_of::<Vec3>())?;
add_bytes(&mut geometry, m.triangles.capacity(), std::mem::size_of::<[u32; 3]>())?;
add_bytes(&mut geometry, m.tri_face.capacity(), std::mem::size_of::<u32>())?;
add_bytes(&mut geometry, body.faces.capacity(), std::mem::size_of::<FaceAppearance>())?;
add_bytes(&mut geometry, body.name.capacity(), 1)?;
add_bytes(&mut geometry, m.edges.capacity(), std::mem::size_of::<Vec<Vec3>>())?;
add_bytes(&mut geometry, m.edge_faces.capacity(), std::mem::size_of::<Vec<u32>>())?;
for edge in &m.edges {
add_bytes(&mut geometry, edge.capacity(), std::mem::size_of::<Vec3>())?;
}
for edge in &m.edge_faces {
add_bytes(&mut geometry, edge.capacity(), std::mem::size_of::<u32>())?;
}
for count in [m.seams.capacity(), m.smooth_edges.capacity(), m.sharp_edges.capacity()] {
add_bytes(&mut geometry, count, 1)?;
}
if body.surface.is_some() && body.opacity != 1.0 {
return Err(invalid("opaque PBR requires opacity 1"));
}
if !body.opacity.is_finite() || !(0.0..=1.0).contains(&body.opacity) {
return Err(invalid("invalid body opacity"));
}
}
// Three bounded 16-byte lists, a conservative map-node allowance, and codec overhead.
let preparation = triangles
.checked_mul(48)
.and_then(|n| faces.checked_mul(160).and_then(|f| n.checked_add(f)))
.and_then(|n| n.checked_add(scene.meshes.len() as u64 * 128 + 262_144))
.ok_or_else(|| limit("work list size overflow"))?;
let total = geometry
.checked_add(preparation)
.and_then(|n| n.checked_add(resolved.estimated_scratch_bytes))
.ok_or_else(|| limit("image memory overflow"))?;
if total > MAX_JOB_BYTES {
return Err(limit("product job exceeds its 384 MiB admission estimate"));
}
let encoded_limit = u64::from(request.width) * u64::from(request.height) * 8 + 65_536;
Ok(Admission {
triangles,
face_appearances: faces,
geometry_bytes: geometry,
pixel_scratch_bytes: resolved.estimated_scratch_bytes,
preparation_bytes: preparation,
estimated_total_bytes: total,
encoded_limit,
})
}
fn reserve<T>(count: usize) -> Result<Vec<T>> {
let mut v = Vec::new();
v.try_reserve_exact(count).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "could not reserve render work memory"))?;
Ok(v)
}
#[derive(Clone, Copy, Default)]
struct Draw {
body: u32,
triangle: u32,
depth: f64,
}
impl Draw {
fn compare(&self, other: &Self) -> std::cmp::Ordering {
self.depth.total_cmp(&other.depth).then_with(|| (self.body, self.triangle).cmp(&(other.body, other.triangle)))
}
}
struct Triangle {
points: [[f64; 3]; 3],
world: [Vec3; 3],
normals: [Vec3; 3],
inv_w: [f64; 3],
colour: [f64; 3],
opacity: f64,
surface: Option<crate::surface::OpaqueSurface>,
centre_depth: f64,
area: f64,
bounds: [usize; 4],
x: usize,
y: usize,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Stage {
Looks,
Prepare,
Sort,
Clear,
Opaque,
Glass,
Downsample,
Encode,
Done,
}
struct BufferWriter {
bytes: Arc<Mutex<Vec<u8>>>,
limit: usize,
cancel: CancelToken,
}
impl Write for BufferWriter {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
if self.cancel.is_cancelled() {
return Err(std::io::Error::other("render cancelled"));
}
let mut out = self.bytes.lock().map_err(|_| std::io::Error::other("PNG output lock poisoned"))?;
if out.len().checked_add(bytes.len()).is_none_or(|n| n > self.limit) {
return Err(std::io::Error::other("PNG output exceeds its byte budget"));
}
out.try_reserve_exact(bytes.len()).map_err(|_| std::io::Error::other("PNG allocation failed"))?;
out.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
if self.cancel.is_cancelled() { Err(std::io::Error::other("render cancelled")) } else { Ok(()) }
}
}
pub enum TaskPoll {
Pending(Progress),
Complete(Vec<u8>),
}
/// CPU product renderer with cooperative preparation, sorting, raster and PNG encoding.
/// Transparency sorts triangle centres and blends in linear space; it is not refraction.
pub struct ImageTask {
scene: ProductScene,
request: ImageRequest,
admission: Admission,
cancel: CancelToken,
stage: Stage,
vp: Mat4,
back: Vec3,
sw: usize,
sh: usize,
looks: Vec<BTreeMap<u32, FaceAppearance>>,
body: usize,
item: usize,
prepare_done: u64,
opaque: Vec<Draw>,
glass: Vec<Draw>,
scratch: Vec<Draw>,
sort_width: usize,
sort_block: usize,
sort_left: usize,
sort_right: usize,
sort_mid: usize,
sort_end: usize,
sort_dst: usize,
pixels: Vec<[u8; 4]>,
depth: Vec<f32>,
pixel_tests: u64,
pixel_done: u64,
draw: usize,
triangle: Option<Triangle>,
output: Vec<u8>,
encoded: Arc<Mutex<Vec<u8>>>,
encoder: Option<png::StreamWriter<'static, BufferWriter>>,
row: usize,
}
impl ImageTask {
pub fn new(scene: ProductScene, request: ImageRequest, cancel: CancelToken) -> Result<Self> {
cancel.check()?;
let admission = admit(&scene, &request)?;
let (sw, sh) = (request.width as usize * SS, request.height as usize * SS);
let radius = scene.bounds.diagonal().max(10.0) + scene.bounds.center().dist(request.camera.target);
if !radius.is_finite() {
return Err(invalid("invalid product bounds"));
}
let vp = request.camera.view_proj(sw as f64 / sh as f64, radius);
let back = request.camera.back();
let looks = reserve(scene.meshes.len())?;
let (opaque, glass, scratch) =
(reserve(admission.triangles as usize)?, reserve(admission.triangles as usize)?, reserve(admission.triangles as usize)?);
let (pixels, depth, output) = (reserve(sw * sh)?, reserve(sw * sh)?, reserve(request.width as usize * request.height as usize * 4)?);
Ok(Self {
scene,
request,
admission,
cancel,
stage: Stage::Looks,
vp,
back,
sw,
sh,
looks,
body: 0,
item: 0,
prepare_done: 0,
opaque,
glass,
scratch,
sort_width: 1,
sort_block: 0,
sort_left: 0,
sort_right: 0,
sort_mid: 0,
sort_end: 0,
sort_dst: 0,
pixels,
depth,
pixel_tests: 0,
pixel_done: 0,
draw: 0,
triangle: None,
output,
encoded: Arc::new(Mutex::new(Vec::new())),
encoder: None,
row: 0,
})
}
pub fn progress(&self) -> Progress {
match self.stage {
Stage::Looks | Stage::Prepare | Stage::Sort => Progress {
phase: Phase::Preparing,
done: self.prepare_done,
total: self.admission.face_appearances + self.admission.triangles + self.scene.meshes.len() as u64 + self.glass.len() as u64 * 19,
},
Stage::Clear | Stage::Opaque | Stage::Glass | Stage::Downsample => Progress {
phase: Phase::Running,
done: self.pixel_done,
total: self.pixel_tests + (self.sw * self.sh) as u64 + u64::from(self.request.width) * u64::from(self.request.height),
},
Stage::Encode => Progress { phase: Phase::Encoding, done: self.row as u64, total: u64::from(self.request.height) },
Stage::Done => Progress { phase: Phase::Completed, done: 1, total: 1 },
}
}
/// At most 16384 simple units; encoding additionally yields after at most four rows.
pub fn step(&mut self, units: usize) -> Result<TaskPoll> {
let mut rows = 0;
for _ in 0..units.clamp(1, MAX_STEP) {
self.cancel.check()?;
match self.stage {
Stage::Looks => self.look_step()?,
Stage::Prepare => self.prepare_step()?,
Stage::Sort => self.sort_step()?,
Stage::Clear => self.clear_step(),
Stage::Opaque | Stage::Glass => self.draw_step()?,
Stage::Downsample => self.downsample_step()?,
Stage::Encode => {
if let Some(png) = self.encode_step()? {
return Ok(TaskPoll::Complete(png));
}
rows += 1;
if rows >= 4 {
break;
}
}
Stage::Done => return Err(JobError::new(ErrorCode::NotCompleted, "render result already consumed")),
}
}
Ok(TaskPoll::Pending(self.progress()))
}
fn look_step(&mut self) -> Result<()> {
let Some(body) = self.scene.meshes.get(self.body) else {
self.body = 0;
self.item = 0;
self.stage = Stage::Prepare;
return Ok(());
};
if self.looks.len() == self.body {
self.looks.push(BTreeMap::new());
}
if let Some(face) = body.faces.get(self.item) {
if !face.opacity.is_finite() || !(0.0..=1.0).contains(&face.opacity) {
return Err(invalid("invalid face opacity"));
}
let looks = self.looks.get_mut(self.body).ok_or_else(|| invalid("missing body appearances"))?;
// Match the document policy: the last assignment of a face wins.
looks.insert(face.face, face.clone());
self.item += 1;
} else {
self.body += 1;
self.item = 0;
}
self.prepare_done += 1;
Ok(())
}
fn project(&self, draw: Draw) -> Result<Option<Triangle>> {
let body = self.scene.meshes.get(draw.body as usize).ok_or_else(|| invalid("missing product body"))?;
let mesh = &body.mesh;
let indices = mesh.triangles.get(draw.triangle as usize).ok_or_else(|| invalid("missing product triangle"))?;
let vertices = indices.map(|i| mesh.positions.get(i as usize).copied());
let normals = indices.map(|i| mesh.normals.get(i as usize).copied());
let ([Some(a), Some(b), Some(c)], [Some(na), Some(nb), Some(nc)]) = (vertices, normals) else {
return Err(invalid("invalid triangle index or missing normal"));
};
let world = [a, b, c];
let normals = [na, nb, nc];
if world.iter().chain(normals.iter()).any(|v| !v.is_finite()) {
return Err(invalid("non-finite product triangle"));
}
let look = self.looks.get(draw.body as usize).and_then(|looks| mesh.tri_face.get(draw.triangle as usize).and_then(|face| looks.get(face)));
let (colour, opacity, surface) = look.map_or((body.color, body.opacity, body.surface), |look| (look.color, look.opacity, look.surface));
if surface.is_some() && opacity != 1.0 {
return Err(invalid("opaque PBR requires opacity 1"));
}
let normals = if surface.is_some() {
[crate::surface::unit_direction(na), crate::surface::unit_direction(nb), crate::surface::unit_direction(nc)]
.map(|n| n.map_err(|e| invalid(&e.to_string())))
} else {
[Ok(normals[0]), Ok(normals[1]), Ok(normals[2])]
};
let [na, nb, nc] = normals;
let normals = [na?, nb?, nc?];
if opacity <= 0.0 {
return Ok(None);
}
let clip = world.map(|p| self.vp.apply(p));
if clip.iter().flatten().any(|v| !v.is_finite()) {
return Err(invalid("non-finite product projection"));
}
if clip.iter().any(|p| p[3] <= 1e-9) {
return Ok(None);
}
let inv_w = clip.map(|p| 1.0 / p[3]);
let points = clip.map(|p| [(p[0] / p[3] + 1.0) * 0.5 * self.sw as f64, (1.0 - p[1] / p[3]) * 0.5 * self.sh as f64, p[2] / p[3]]);
if points.iter().flatten().any(|v| !v.is_finite()) || points.iter().any(|p| !(p[2] as f32).is_finite()) {
return Err(invalid("product projection exceeds pixel precision"));
}
let [a, b, c] = points;
let (x0, x1) = (a[0].min(b[0]).min(c[0]).floor().max(0.0), a[0].max(b[0]).max(c[0]).ceil().min(self.sw as f64 - 1.0));
let (y0, y1) = (a[1].min(b[1]).min(c[1]).floor().max(0.0), a[1].max(b[1]).max(c[1]).ceil().min(self.sh as f64 - 1.0));
let area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
if !(x0 <= x1 && y0 <= y1) || area.abs() < 1e-12 {
return Ok(None);
}
if !area.is_finite() {
return Err(invalid("invalid projected area"));
}
let centre_depth = (world[0] / 3.0 + world[1] / 3.0 + world[2] / 3.0 - self.request.camera.target).dot(self.back);
if !centre_depth.is_finite() {
return Err(invalid("invalid triangle depth"));
}
Ok(Some(Triangle {
points,
world,
normals,
inv_w,
colour: colour.map(decode),
opacity,
surface,
centre_depth,
area,
bounds: [x0 as usize, x1 as usize, y0 as usize, y1 as usize],
x: x0 as usize,
y: y0 as usize,
}))
}
fn prepare_step(&mut self) -> Result<()> {
let Some(body) = self.scene.meshes.get(self.body) else {
self.stage = if self.glass.len() > 1 { Stage::Sort } else { Stage::Clear };
self.sort_block = 0;
self.init_merge();
return Ok(());
};
if self.item >= body.mesh.triangles.len() {
self.body += 1;
self.item = 0;
return Ok(());
}
let mut draw = Draw { body: self.body as u32, triangle: self.item as u32, depth: 0.0 };
if let Some(triangle) = self.project(draw)? {
let [x0, x1, y0, y1] = triangle.bounds;
let tests = ((x1 - x0 + 1) as u64).checked_mul((y1 - y0 + 1) as u64).ok_or_else(|| limit("pixel work overflow"))?;
self.pixel_tests = self.pixel_tests.checked_add(tests).ok_or_else(|| limit("pixel work overflow"))?;
if self.pixel_tests > MAX_PIXEL_TESTS {
return Err(limit("projected product exceeds 128000000 pixel tests"));
}
draw.depth = triangle.centre_depth;
if triangle.opacity >= 1.0 {
self.opaque.push(draw);
} else {
self.glass.push(draw);
self.scratch.push(draw);
}
}
self.item += 1;
self.prepare_done += 1;
Ok(())
}
fn init_merge(&mut self) {
let n = self.glass.len();
self.sort_mid = (self.sort_block + self.sort_width).min(n);
self.sort_end = (self.sort_block + 2 * self.sort_width).min(n);
self.sort_left = self.sort_block;
self.sort_right = self.sort_mid;
self.sort_dst = self.sort_block;
}
fn sort_step(&mut self) -> Result<()> {
if self.sort_dst >= self.sort_end {
self.sort_block += 2 * self.sort_width;
if self.sort_block >= self.glass.len() {
std::mem::swap(&mut self.glass, &mut self.scratch);
self.sort_width *= 2;
self.sort_block = 0;
if self.sort_width >= self.glass.len() {
self.stage = Stage::Clear;
return Ok(());
}
}
self.init_merge();
}
let left = self.glass.get(self.sort_left).filter(|_| self.sort_left < self.sort_mid);
let right = self.glass.get(self.sort_right).filter(|_| self.sort_right < self.sort_end);
let take_left = match (left, right) {
(Some(a), Some(b)) => a.compare(b).is_le(),
(Some(_), None) => true,
_ => false,
};
let source = if take_left {
let i = self.sort_left;
self.sort_left += 1;
i
} else {
let i = self.sort_right;
self.sort_right += 1;
i
};
let draw = self.glass.get(source).copied().ok_or_else(|| invalid("invalid transparent sort source"))?;
*self.scratch.get_mut(self.sort_dst).ok_or_else(|| invalid("invalid transparent sort destination"))? = draw;
self.sort_dst += 1;
self.prepare_done += 1;
Ok(())
}
fn clear_step(&mut self) {
if self.pixels.len() >= self.sw * self.sh {
self.stage = Stage::Opaque;
self.draw = 0;
return;
}
let [r, g, b] = self.request.background;
self.pixels.push(if self.request.transparent_background { [0; 4] } else { [r, g, b, 255] });
self.depth.push(f32::INFINITY);
self.pixel_done += 1;
}
fn draw_step(&mut self) -> Result<()> {
if self.triangle.is_none() {
let list = if self.stage == Stage::Opaque { &self.opaque } else { &self.glass };
let Some(draw) = list.get(self.draw).copied() else {
self.stage = if self.stage == Stage::Opaque { Stage::Glass } else { Stage::Downsample };
self.draw = 0;
return Ok(());
};
self.triangle = self.project(draw)?;
if self.triangle.is_none() {
self.draw += 1;
}
return Ok(());
}
let triangle = self.triangle.as_mut().ok_or_else(|| invalid("missing raster triangle"))?;
let eye = (self.request.camera.fov > 0.0).then(|| self.request.camera.eye());
raster_pixel(triangle, self.sw, self.back, eye, &mut self.pixels, &mut self.depth)?;
triangle.x += 1;
if triangle.x > triangle.bounds[1] {
triangle.x = triangle.bounds[0];
triangle.y += 1;
}
if triangle.y > triangle.bounds[3] {
self.triangle = None;
self.draw += 1;
}
self.pixel_done += 1;
Ok(())
}
fn downsample_step(&mut self) -> Result<()> {
let width = self.request.width as usize;
let index = self.output.len() / 4;
if index >= width * self.request.height as usize {
let writer = BufferWriter { bytes: self.encoded.clone(), limit: self.admission.encoded_limit as usize, cancel: self.cancel.clone() };
let mut encoder = png::Encoder::new(writer, self.request.width, self.request.height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
self.encoder = Some(
encoder.write_header().and_then(|w| w.into_stream_writer()).map_err(|e| JobError::new(ErrorCode::EncodingFailed, e.to_string()))?,
);
self.stage = Stage::Encode;
// Pixels and prepared geometry are no longer needed during PNG compression.
self.pixels = Vec::new();
self.depth = Vec::new();
self.opaque = Vec::new();
self.glass = Vec::new();
self.scratch = Vec::new();
self.looks = Vec::new();
self.scene.meshes.clear();
return Ok(());
}
let (x, y) = (index % width, index / width);
let (mut colour, mut alpha) = ([0.0; 3], 0.0);
for dy in 0..SS {
for dx in 0..SS {
let p = self.pixels.get((y * SS + dy) * self.sw + x * SS + dx).ok_or_else(|| invalid("missing downsample pixel"))?;
let a = f64::from(p[3]) / 255.0;
for (sum, channel) in colour.iter_mut().zip(p.iter().take(3)) {
*sum += decode(*channel) * a;
}
alpha += a;
}
}
let rgb = colour.map(|v| if alpha > 1e-12 { encode(v / alpha) } else { 0 });
self.output.extend_from_slice(&[rgb[0], rgb[1], rgb[2], (alpha * 255.0 / (SS * SS) as f64).round() as u8]);
self.pixel_done += 1;
Ok(())
}
fn encode_step(&mut self) -> Result<Option<Vec<u8>>> {
if self.row < self.request.height as usize {
let length = self.request.width as usize * 4;
let start = self.row * length;
let row = self.output.get(start..start + length).ok_or_else(|| invalid("missing PNG row"))?;
self.encoder
.as_mut()
.ok_or_else(|| invalid("missing PNG writer"))?
.write_all(row)
.map_err(|e| JobError::new(ErrorCode::EncodingFailed, e.to_string()))?;
self.row += 1;
return Ok(None);
}
self.encoder
.take()
.ok_or_else(|| invalid("missing PNG writer"))?
.finish()
.map_err(|e| JobError::new(ErrorCode::EncodingFailed, e.to_string()))?;
self.cancel.check()?;
let mut bytes = self.encoded.lock().map_err(|_| JobError::new(ErrorCode::Internal, "PNG output lock poisoned"))?;
let png = std::mem::take(&mut *bytes);
if !png.starts_with(b"\x89PNG\r\n\x1a\n") {
return Err(JobError::new(ErrorCode::EncodingFailed, "invalid PNG output"));
}
self.output = Vec::new();
self.stage = Stage::Done;
Ok(Some(png))
}
}
fn raster_pixel(t: &Triangle, width: usize, back: Vec3, eye: Option<Vec3>, pixels: &mut [[u8; 4]], depth: &mut [f32]) -> Result<()> {
let [a, b, c] = t.points;
let (x, y) = (t.x as f64 + 0.5, t.y as f64 + 0.5);
let w0 = ((b[0] - x) * (c[1] - y) - (b[1] - y) * (c[0] - x)) / t.area;
let w1 = ((c[0] - x) * (a[1] - y) - (c[1] - y) * (a[0] - x)) / t.area;
let w2 = 1.0 - w0 - w1;
// A shared triangle edge belongs to exactly one raster triangle, including glass.
for (w, mut p, mut q) in [(w0, b, c), (w1, c, a), (w2, a, b)] {
if w < -1e-9 {
return Ok(());
}
if w.abs() <= 1e-9 {
if t.area < 0.0 {
std::mem::swap(&mut p, &mut q);
}
let (dx, dy) = (q[0] - p[0], q[1] - p[1]);
if !(dy < 0.0 || (dy == 0.0 && dx > 0.0)) {
return Ok(());
}
}
}
let z = (w0 * a[2] + w1 * b[2] + w2 * c[2]) as f32;
let index = t.y * width + t.x;
let old_depth = depth.get_mut(index).ok_or_else(|| invalid("missing pixel depth"))?;
if z > *old_depth {
return Ok(());
}
let weights = [w0 * t.inv_w[0], w1 * t.inv_w[1], w2 * t.inv_w[2]];
let sum: f64 = weights.iter().sum();
if !sum.is_finite() || sum <= 1e-12 {
return Ok(());
}
let n = t.normals.iter().zip(weights).fold(Vec3::ZERO, |v, (n, w)| v + *n * (w / sum)).normalized().unwrap_or(Vec3::Z);
let colour = if let Some(surface) = t.surface {
let world = t.world.iter().zip(weights).fold(Vec3::ZERO, |p, (v, w)| p + *v * (w / sum));
let view = eye.map_or(back, |eye| eye - world);
let radiance = crate::studio::shade(surface, n, view).map_err(|e| invalid(&e.to_string()))?;
let mapped = crate::studio::tone_map(radiance).map_err(|e| invalid(&e.to_string()))?;
[mapped.x, mapped.y, mapped.z]
} else {
crate::raster::shade(n, back, t.colour)
};
if colour.iter().any(|v| !v.is_finite()) {
return Err(invalid("non-finite shaded colour"));
}
let pixel = pixels.get_mut(index).ok_or_else(|| invalid("missing colour pixel"))?;
if t.opacity >= 1.0 {
*old_depth = z;
*pixel = [encode(colour[0]), encode(colour[1]), encode(colour[2]), 255];
} else {
let old_alpha = f64::from(pixel[3]) / 255.0;
let alpha = t.opacity + old_alpha * (1.0 - t.opacity);
for (out, src) in pixel.iter_mut().take(3).zip(colour) {
*out = encode((src * t.opacity + decode(*out) * old_alpha * (1.0 - t.opacity)) / alpha);
}
pixel[3] = (alpha * 255.0).round() as u8;
}
Ok(())
}
fn decode(v: u8) -> f64 {
let s = f64::from(v) / 255.0;
if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }
}
fn encode(v: f64) -> u8 {
let l = v.clamp(0.0, 1.0);
let s = if l <= 0.0031308 { l * 12.92 } else { 1.055 * l.powf(1.0 / 2.4) - 0.055 };
(s * 255.0).round() as u8
}
+147
View File
@@ -0,0 +1,147 @@
use super::*;
use crate::{Camera, ProductMesh, StandardView};
use solidcraft_geom::{Aabb3, Mesh};
fn request() -> ImageRequest {
let mut camera = Camera { distance: 50.0, ..Default::default() };
camera.set_view(StandardView::Front);
ImageRequest { camera, width: 64, height: 64, background: [20, 40, 60], transparent_background: true }
}
fn square(name: &str, colour: [u8; 3], opacity: f64, offset: Vec3) -> ProductMesh {
let mesh = Mesh {
positions: vec![
Vec3::new(-10.0, 0.0, -10.0) + offset,
Vec3::new(10.0, 0.0, -10.0) + offset,
Vec3::new(10.0, 0.0, 10.0) + offset,
Vec3::new(-10.0, 0.0, 10.0) + offset,
],
normals: vec![Vec3::new(0.0, -1.0, 0.0); 4],
triangles: vec![[0, 1, 2], [0, 2, 3]],
tri_face: vec![0, 1],
..Default::default()
};
ProductMesh { name: name.into(), mesh: Arc::new(mesh), color: colour, opacity, surface: None, faces: Vec::new() }
}
fn scene(meshes: Vec<ProductMesh>) -> ProductScene {
let bounds = meshes.iter().fold(Aabb3::EMPTY, |b, m| b.union(&m.mesh.bounds()));
ProductScene { meshes, bounds }
}
fn finish(mut task: ImageTask) -> Vec<u8> {
for _ in 0..100_000 {
match task.step(4096).unwrap() {
TaskPoll::Complete(png) => return png,
TaskPoll::Pending(p) => assert!(p.done <= p.total, "{p:?}"),
}
}
panic!("bounded fixture did not finish");
}
#[test]
fn transparent_png_has_straight_srgb_colour_without_a_shared_edge_seam() {
let scene = scene(vec![square("Glass", [220, 40, 20], 0.5, Vec3::ZERO)]);
let png = finish(ImageTask::new(scene, request(), CancelToken::default()).unwrap());
let image = image::load_from_memory(&png).unwrap().to_rgba8();
assert_eq!(image.get_pixel(0, 0).0, [0; 4]);
for (x, y) in [(32, 32), (31, 31), (30, 32), (32, 30)] {
let p = image.get_pixel(x, y).0;
assert!((i16::from(p[3]) - 128).abs() <= 1, "a shared diagonal must not double-blend: {p:?}");
assert!(p[0] > 180 && p[0] > p[1] + 60, "RGB is straight, not multiplied by 0.5: {p:?}");
}
let reader = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap();
assert_eq!(reader.info().srgb, Some(png::SrgbRenderingIntent::Perceptual));
}
#[test]
fn glass_is_sorted_back_to_front_independently_of_body_order() {
let request = request();
let far = square("Far", [240, 20, 20], 0.5, request.camera.back() * -5.0);
let near = square("Near", [20, 20, 240], 0.5, request.camera.back() * 5.0);
let a = finish(ImageTask::new(scene(vec![far.clone(), near.clone()]), request.clone(), CancelToken::default()).unwrap());
let b = finish(ImageTask::new(scene(vec![near, far]), request, CancelToken::default()).unwrap());
assert_eq!(a, b);
let image = image::load_from_memory(&a).unwrap().to_rgba8();
let p = image.get_pixel(32, 32).0;
assert!((i16::from(p[3]) - 192).abs() <= 1, "two half-transparent layers: {p:?}");
assert!(p[2] > p[0] + 30, "the near blue layer contributes more: {p:?}");
}
#[test]
fn cancellation_is_observed_during_preparation_sort_pixels_and_encoding() {
for stage in [Stage::Looks, Stage::Prepare, Stage::Sort, Stage::Clear, Stage::Glass, Stage::Downsample, Stage::Encode] {
let cancel = CancelToken::default();
let mut task = ImageTask::new(scene(vec![square("Glass", [40, 180, 100], 0.4, Vec3::ZERO)]), request(), cancel.clone()).unwrap();
let mut reached = false;
for _ in 0..100_000 {
if task.stage == stage {
reached = true;
break;
}
assert!(matches!(task.step(1).unwrap(), TaskPoll::Pending(_)));
}
assert!(reached);
cancel.cancel();
assert_eq!(task.step(4096).err().unwrap().code, ErrorCode::Cancelled);
}
}
#[test]
fn encoding_yields_between_rows_and_completed_result_is_consumed_once() {
let mut task = ImageTask::new(scene(Vec::new()), request(), CancelToken::default()).unwrap();
while task.stage != Stage::Encode {
assert!(matches!(task.step(64).unwrap(), TaskPoll::Pending(_)));
}
let before = task.row;
assert!(matches!(task.step(usize::MAX).unwrap(), TaskPoll::Pending(_)));
assert!(task.row - before <= 4);
loop {
if let TaskPoll::Complete(bytes) = task.step(4096).unwrap() {
assert_eq!(image::load_from_memory(&bytes).unwrap().width(), 64);
break;
}
}
assert_eq!(task.step(1).err().unwrap().code, ErrorCode::NotCompleted);
}
#[test]
fn invalid_sizes_indices_opacity_and_large_work_lists_fail_without_a_frame() {
let body = square("Product", [100, 120, 140], 1.0, Vec3::ZERO);
let mut bad_request = request();
bad_request.width = u32::MAX;
assert!(ImageTask::new(scene(vec![body.clone()]), bad_request, CancelToken::default()).is_err());
for bad in [0, 1] {
let mut body = body.clone();
if bad == 0 {
Arc::make_mut(&mut body.mesh).triangles[0][0] = u32::MAX;
} else {
body.faces.push(FaceAppearance { face: 0, color: [0; 3], opacity: f64::NAN, surface: None });
}
let mut task = ImageTask::new(scene(vec![body]), request(), CancelToken::default()).unwrap();
let error = loop {
if let Err(error) = task.step(64) {
break error;
}
};
assert_eq!(error.code, ErrorCode::InvalidScene);
}
let mut body = body;
Arc::make_mut(&mut body.mesh).triangles.resize(MAX_TRIANGLES as usize + 1, [0, 1, 2]);
assert_eq!(ImageTask::new(scene(vec![body]), request(), CancelToken::default()).err().unwrap().code, ErrorCode::LimitExceeded);
assert_eq!(std::mem::size_of::<Draw>(), 16, "admission accounts for each of the three lists");
}
#[test]
fn png_output_budget_and_cancellation_fail_without_partial_publication() {
let bytes = Arc::new(Mutex::new(Vec::new()));
let cancel = CancelToken::default();
let mut writer = BufferWriter { bytes: bytes.clone(), limit: 4, cancel: cancel.clone() };
writer.write_all(b"PNG").unwrap();
assert!(writer.write_all(b"too large").is_err());
assert_eq!(*bytes.lock().unwrap(), b"PNG");
cancel.cancel();
assert!(writer.write_all(b"x").is_err());
assert!(writer.flush().is_err());
assert_eq!(*bytes.lock().unwrap(), b"PNG");
}
+140
View File
@@ -0,0 +1,140 @@
//! CPU product-image work and its device-independent, session-scoped protocol.
//! Hosts own execution; documents and history never contain these values.
use std::fmt;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use serde::{Deserialize, Serialize};
use crate::{BackendId, Camera, ExecutorId};
mod host;
mod image;
pub use host::{CpuHost, HostKind, JobHandle, JobStatus, RenderFrame};
pub use image::{Admission, ImageTask, TaskPoll, admit};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct JobId {
pub session: u64,
pub serial: u64,
pub generation: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
Queued,
Preparing,
Running,
Encoding,
Cancelling,
Cancelled,
Completed,
Failed,
}
impl Phase {
pub fn terminal(self) -> bool {
matches!(self, Self::Cancelled | Self::Completed | Self::Failed)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct Progress {
pub phase: Phase,
pub done: u64,
pub total: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
InvalidRequest,
LimitExceeded,
AllocationFailed,
HostUnavailable,
QueueFull,
TimedOut,
Cancelled,
InvalidScene,
EncodingFailed,
WrongOwner,
UnknownJob,
NotCompleted,
Internal,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct JobError {
pub code: ErrorCode,
pub message: String,
}
impl JobError {
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
Self { code, message: message.into() }
}
}
impl fmt::Display for JobError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}: {}", self.code, self.message)
}
}
impl std::error::Error for JobError {}
pub type Result<T> = std::result::Result<T, JobError>;
#[derive(Clone, Default)]
pub struct CancelToken(Arc<AtomicBool>);
impl CancelToken {
pub fn cancel(&self) {
self.0.store(true, Ordering::Release);
}
pub fn is_cancelled(&self) -> bool {
self.0.load(Ordering::Acquire)
}
pub fn check(&self) -> Result<()> {
if self.is_cancelled() { Err(JobError::new(ErrorCode::Cancelled, "render cancelled")) } else { Ok(()) }
}
}
/// Fully resolved input; no mutable session, topology graph or GPU resources.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ImageRequest {
pub camera: Camera,
pub width: u32,
pub height: u32,
pub background: [u8; 3],
pub transparent_background: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ColourEncoding {
Srgb,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AlphaConvention {
Straight,
}
#[derive(Clone, Debug, Serialize)]
pub struct FrameInfo {
pub job: JobId,
pub scene_generation: u64,
pub backend: BackendId,
pub executor: ExecutorId,
pub width: u32,
pub height: u32,
pub encoding: ColourEncoding,
pub alpha: AlphaConvention,
}
+4
View File
@@ -7,8 +7,11 @@
mod backend;
mod camera;
mod curve_display;
pub mod job;
pub mod lighting;
mod product;
mod raster;
pub mod studio;
pub mod surface;
pub mod texture_input;
@@ -19,4 +22,5 @@ pub use backend::{
};
pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out};
pub use curve_display::{PixelCurveView, SampledPath, SamplingBudget, SamplingQuality, sample_segment, sample_segment_unclipped};
pub use product::{FaceAppearance, ProductMesh, ProductScene};
pub use raster::{Canvas, Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent};
+42
View File
@@ -0,0 +1,42 @@
//! Renderer-independent product geometry. No modelling overlays or device resources.
use std::sync::Arc;
use solidcraft_geom::{Aabb3, Mesh};
#[derive(Clone, Debug, PartialEq)]
pub struct FaceAppearance {
/// The existing mesh's B-rep face index, resolved by the document's appearance policy.
pub face: u32,
pub color: [u8; 3],
pub opacity: f64,
pub surface: Option<crate::surface::OpaqueSurface>,
}
#[derive(Clone, Debug)]
pub struct ProductMesh {
/// Existing computed body name, including occurrence identity where applicable.
pub name: String,
/// Established world-space mesh. Adapters must not apply occurrence transforms again.
pub mesh: Arc<Mesh>,
pub color: [u8; 3],
pub opacity: f64,
pub surface: Option<crate::surface::OpaqueSurface>,
pub faces: Vec<FaceAppearance>,
}
#[derive(Clone, Debug)]
pub struct ProductScene {
pub meshes: Vec<ProductMesh>,
pub bounds: Aabb3,
}
impl ProductScene {
pub fn required_features(&self) -> Vec<crate::RenderFeature> {
if self.meshes.iter().any(|m| m.surface.is_some() || m.faces.iter().any(|f| f.surface.is_some())) {
vec![crate::RenderFeature::Pbr]
} else {
Vec::new()
}
}
}
+1 -1
View File
@@ -79,7 +79,7 @@ impl Canvas {
}
}
fn shade(n: Vec3, view_dir: Vec3, base: [f64; 3]) -> [f64; 3] {
pub(crate) fn shade(n: Vec3, view_dir: Vec3, base: [f64; 3]) -> [f64; 3] {
let n = if n.dot(view_dir) < 0.0 { -n } else { n };
let key = (view_dir * 0.8 + Vec3::new(-0.3, 0.2, 0.6)).normalized().unwrap_or(view_dir);
let diff = n.dot(key).max(0.0);
+51
View File
@@ -0,0 +1,51 @@
//! Original fixed direct-light studio used by opaque product preview consumers.
//! Relative linear radiance, not a calibrated illuminance or an HDRI environment.
use solidcraft_geom::Vec3;
use crate::surface::{OpaqueSurface, SurfaceError, unit_direction};
/// Fixed, neutral directional lights in the project's world frame (Z up).
/// Directions point towards each light and are analytically unit length.
pub const LIGHTS: [(Vec3, f64); 3] = [(Vec3::new(0.6, 0.0, 0.8), 3.0), (Vec3::new(-0.8, 0.0, 0.6), 1.0), (Vec3::new(0.0, -0.6, 0.8), 1.5)];
/// Sum BRDF * incident radiance * light cosine, without display transfer.
/// There are no cast shadows, indirect bounces, ambient term or emission.
pub fn shade(surface: OpaqueSurface, normal: Vec3, view: Vec3) -> Result<Vec3, SurfaceError> {
let n = unit_direction(normal)?;
let mut radiance = Vec3::ZERO;
for (light, intensity) in LIGHTS {
radiance += surface.brdf(n, view, light)? * (intensity * n.dot(light).max(0.0));
}
Ok(radiance)
}
/// Per-channel Reinhard preview tone map, still in linear sRGB.
/// Consumers encode exactly once after applying this to nonnegative finite radiance.
pub fn tone_map(radiance: Vec3) -> Result<Vec3, SurfaceError> {
if !radiance.is_finite() || radiance.x < 0.0 || radiance.y < 0.0 || radiance.z < 0.0 {
return Err(SurfaceError::Radiance);
}
let map = |c: f64| c / (1.0 + c);
Ok(Vec3::new(map(radiance.x), map(radiance.y), map(radiance.z)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
#[test]
fn direct_white_conductor_matches_closed_form_and_is_tone_mapped_once() {
let s = OpaqueSurface::new(Vec3::new(1.0, 1.0, 1.0), 1.0, 1.0, 1.5).unwrap();
let c = shade(s, Vec3::Z, Vec3::Z).unwrap();
let expected = (4.5 * 0.8 / 1.8 + 0.6 / 1.6) / (2.0 * PI);
assert!((c - Vec3::new(expected, expected, expected)).len() < 1e-14);
let mapped = tone_map(c).unwrap();
assert!((mapped.x - expected / (1.0 + expected)).abs() < 1e-14);
assert_eq!(tone_map(Vec3::ZERO).unwrap(), Vec3::ZERO);
assert!(tone_map(Vec3::new(f64::NAN, 0.0, 0.0)).is_err());
assert!(tone_map(Vec3::new(-1.0, 0.0, 0.0)).is_err());
assert!(shade(s, Vec3::ZERO, Vec3::Z).is_err());
}
}
+8 -5
View File
@@ -25,6 +25,7 @@ pub enum SurfaceError {
Roughness,
DielectricIor,
Direction,
Radiance,
}
impl std::fmt::Display for SurfaceError {
@@ -35,6 +36,7 @@ impl std::fmt::Display for SurfaceError {
Self::Roughness => "perceptual roughness must be finite and in [0.045, 1]",
Self::DielectricIor => "dielectric IOR must be finite and in [1, 3]",
Self::Direction => "surface directions must be finite and nonzero",
Self::Radiance => "radiance must be finite and nonnegative",
})
}
}
@@ -81,15 +83,15 @@ impl OpaqueSurface {
/// No incident radiance, cosine weight, exposure or display transfer is included.
/// Back-facing/tangent directions return zero; malformed directions are errors.
pub fn brdf(self, normal: Vec3, view: Vec3, light: Vec3) -> Result<Vec3, SurfaceError> {
let n = unit(normal)?;
let v = unit(view)?;
let l = unit(light)?;
let n = unit_direction(normal)?;
let v = unit_direction(view)?;
let l = unit_direction(light)?;
let nv = n.dot(v).clamp(-1.0, 1.0);
let nl = n.dot(l).clamp(-1.0, 1.0);
if nv <= 0.0 || nl <= 0.0 {
return Ok(Vec3::ZERO);
}
let h = unit(v + l)?;
let h = unit_direction(v + l)?;
let nh = n.dot(h).clamp(0.0, 1.0);
let vh = v.dot(h).clamp(0.0, 1.0);
let alpha = self.roughness * self.roughness;
@@ -122,7 +124,8 @@ pub fn srgb8_to_linear(channel: u8) -> f64 {
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
}
fn unit(v: Vec3) -> Result<Vec3, SurfaceError> {
/// Normalize finite directions without overflow or an arbitrary magnitude threshold.
pub fn unit_direction(v: Vec3) -> Result<Vec3, SurfaceError> {
if !v.is_finite() {
return Err(SurfaceError::Direction);
}