Compare commits

...

2 Commits

Author SHA1 Message Date
lillith a077906234 feat(render): add bounded CPU Interactive request lifecycle 2026-10-29 20:18:50 +01:00
lillith b2733d607a claim: #8936 2026-10-29 18:16:13 +01:00
4 changed files with 585 additions and 0 deletions
+252
View File
@@ -1,6 +1,258 @@
use super::*;
use solidcraft_geom::{Aabb3, Mesh};
mod interactive {
use super::*;
use crate::job::{RaytraceOptions, interactive::*};
use std::time::Duration;
fn cpu() -> ImageRequest {
ImageRequest { integrator: Integrator::Raytrace, raytrace: Some(RaytraceOptions::default()), ..request() }
}
fn revision(visual_revision: u64) -> VisualRevision {
VisualRevision { scene_generation: 19, visual_revision }
}
fn ms(value: u64) -> Duration {
Duration::from_millis(value)
}
#[test]
fn preview_fits_landscape_portrait_square_and_small_outputs_without_mutating_final_intent() {
for (width, height, expected) in
[(3840, 2160, (1024, 576)), (2160, 3840, (576, 1024)), (4096, 4096, (1024, 1024)), (81, 53, (81, 53)), (u32::MAX, 1, (1024, 1))]
{
let mut source = cpu();
source.width = width;
source.height = height;
source.camera.roll = 0.37;
source.background = [19, 31, 97];
source.studio_weights = Some([0.1, 0.2, 0.9]);
source.raytrace = Some(RaytraceOptions { primary_samples: 16, exposure_ev: -2.0, shadows: false, ..Default::default() });
let before = serde_json::to_value(&source).unwrap();
let preview = cpu_request(&source).unwrap();
assert_eq!((preview.width, preview.height), expected);
assert_eq!(preview.camera, source.camera);
assert_eq!(preview.background, source.background);
assert_eq!(preview.studio_weights, source.studio_weights);
let options = preview.raytrace.unwrap();
assert_eq!(options.progressive, Some(crate::job::CpuProgressiveOptions { samples_per_pixel: 16, seed: 0 }));
assert_eq!(options.primary_samples, 1);
assert_eq!(options.exposure_ev, -2.0);
assert!(!options.shadows);
assert_eq!(serde_json::to_value(source).unwrap(), before);
}
}
#[test]
fn preview_refuses_incompatible_transport_and_invalid_input_without_erasing_it() {
let mut source = cpu();
source.raytrace.as_mut().unwrap().area_lights = Some(vec![crate::job::RectangleLightOptions {
center_mm: [0.0, 0.0, 10.0],
edge_u_mm: [2.0, 0.0, 0.0],
edge_v_mm: [0.0, 2.0, 0.0],
color: [1.0; 3],
intensity: 2.0,
}]);
let before = serde_json::to_value(&source).unwrap();
assert!(cpu_request(&source).is_err());
assert_eq!(serde_json::to_value(&source).unwrap(), before);
for source in [
ImageRequest { transparent_background: true, ..cpu() },
ImageRequest { width: 0, ..cpu() },
request(),
ImageRequest { raytrace: Some(RaytraceOptions { primary_samples: 3, ..Default::default() }), ..cpu() },
] {
assert!(cpu_request(&source).is_err());
}
}
#[test]
fn explicit_enable_settles_edits_and_keeps_only_one_capture_even_without_cad_revision_changes() {
let mut interactive = Interactive::new(9001);
interactive.edit(revision(1), ms(0)).unwrap();
assert_eq!(interactive.state().unwrap(), State::Off);
assert!(interactive.capture(ms(1000)).unwrap().is_none());
interactive.enable(revision(1), ms(1000)).unwrap();
assert!(interactive.capture(ms(1199)).unwrap().is_none());
interactive.edit(revision(2), ms(1199)).unwrap();
assert!(interactive.capture(ms(1398)).unwrap().is_none());
let b = interactive.capture(ms(1399)).unwrap().unwrap();
assert_eq!(b.revision(), revision(2));
assert!(interactive.capture(ms(9999)).unwrap().is_none());
interactive.edit(revision(3), ms(1400)).unwrap();
// Delayed failure of B cannot poison C.
interactive.capture_failed(b, JobError::new(ErrorCode::InvalidScene, "old capture"));
assert_eq!(interactive.state().unwrap(), State::Updating);
let c = interactive.capture(ms(1600)).unwrap().unwrap();
interactive.capture_failed(c, JobError::new(ErrorCode::InvalidScene, "current capture"));
assert_eq!(interactive.state().unwrap(), State::Error);
assert_eq!(interactive.error().unwrap().message, "current capture");
assert!(interactive.capture(ms(9999)).unwrap().is_none(), "no automatic failure retry loop");
interactive.resume(ms(2000)).unwrap();
assert!(interactive.capture(ms(2200)).unwrap().is_some());
}
#[test]
fn pause_edits_final_priority_workspace_departure_and_clock_regression_do_not_start_jobs() {
let mut interactive = Interactive::new(9002);
interactive.enable(revision(1), ms(1000)).unwrap();
assert!(interactive.capture(ms(999)).unwrap().is_none());
interactive.pause().unwrap();
interactive.edit(revision(2), ms(2000)).unwrap();
assert_eq!(interactive.state().unwrap(), State::Paused);
assert!(interactive.capture(ms(3000)).unwrap().is_none());
interactive.set_final_active(true, ms(3000)).unwrap();
interactive.resume(ms(3000)).unwrap();
assert!(interactive.capture(ms(4000)).unwrap().is_none());
interactive.set_final_active(false, ms(4000)).unwrap();
assert!(interactive.capture(ms(4199)).unwrap().is_none());
assert_eq!(interactive.capture(ms(4200)).unwrap().unwrap().revision(), revision(2));
interactive.disable().unwrap();
assert_eq!(interactive.state().unwrap(), State::Off);
assert!(interactive.capture(ms(9999)).unwrap().is_none());
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
fn host() -> CpuHost {
// Real native admission/worker steps, explicitly pumped like the existing
// checkpoint tests. No test clock sleeps, mock renderer or OS race.
ImageHost(Arc::new(Owner {
executor: None,
shared: CpuHost::shared(MEMORY_LIMIT),
kind: HostKind::NativeWorker,
running: Mutex::new(None),
_worker: None,
}))
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn superseded_jobs_are_cancelled_and_foreign_jobs_remain_untouched() {
let host = host();
let mut interactive = Interactive::new(9003);
interactive.enable(revision(1), ms(0)).unwrap();
let a = interactive.capture(ms(200)).unwrap().unwrap();
let first = host.submit(id(9003, 1), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
assert!(interactive.attach(a, first.clone()).unwrap());
interactive.edit(revision(2), ms(201)).unwrap();
assert_eq!(first.status().unwrap().progress.phase, Phase::Cancelled);
assert!(interactive.current().is_none());
let b = interactive.capture(ms(401)).unwrap().unwrap();
let late = host.submit(id(9003, 2), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
assert!(!interactive.attach(a, late.clone()).unwrap());
assert_eq!(late.status().unwrap().progress.phase, Phase::Cancelled);
// Drain cancelled queue entries before admitting another real job.
while let Some(work) = host.0.shared.queue.lock().unwrap().pop_front() {
drop(work);
}
let foreign = host.submit(id(9999, 1), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
assert!(!interactive.attach(b, foreign.clone()).unwrap());
assert_eq!(foreign.status().unwrap().progress.phase, Phase::Queued);
assert_eq!(interactive.state().unwrap(), State::Error);
interactive.disable().unwrap();
drop(interactive);
let work = host.0.shared.queue.lock().unwrap().pop_front().unwrap();
let mut running = Running::begin(work).unwrap();
for _ in 0..100_000 {
if !running.advance(16).unwrap() {
break;
}
}
assert_eq!(foreign.status().unwrap().progress.phase, Phase::Completed, "the foreign job can still finish after our controller is closed");
assert!(foreign.frame().unwrap().is_some());
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn progressive_checkpoints_reach_target_and_final_suspension_releases_owned_work() {
let host = host();
let mut interactive = Interactive::new(9004);
interactive.enable(revision(1), ms(0)).unwrap();
let ticket = interactive.capture(ms(200)).unwrap().unwrap();
let job = host.submit(id(9004, 1), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
assert!(interactive.attach(ticket, job.clone()).unwrap());
let work = host.0.shared.queue.lock().unwrap().pop_front().unwrap();
let mut running = Running::begin(work).unwrap();
let mut seen = Vec::new();
for _ in 0..100_000 {
let more = running.advance(16).unwrap();
if let Some(preview) = interactive.current().unwrap().preview().unwrap() {
let spp = preview.info.samples_per_pixel;
if seen.last() != Some(&spp) {
seen.push(spp);
}
}
if !more {
break;
}
}
assert_eq!(seen, [1, 2, 4, 8]);
assert_eq!(interactive.state().unwrap(), State::TargetReached);
assert!(job.frame().unwrap().is_some());
assert!(!interactive.attach(ticket, job.clone()).unwrap(), "duplicate acknowledgement cannot restart or cancel the accepted job");
interactive.set_final_active(true, ms(300)).unwrap();
assert_eq!(interactive.state().unwrap(), State::Paused);
assert!(interactive.current().is_none());
assert!(interactive.capture(ms(9999)).unwrap().is_none());
drop(running);
drop(job);
assert_eq!(host.admitted_bytes(), 0);
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn same_scene_stale_serial_generation_and_delayed_success_after_failure_are_rejected() {
for mismatch in 0..3 {
let host = host();
let mut interactive = Interactive::new(9005);
interactive.enable(revision(1), ms(0)).unwrap();
let ticket = interactive.capture(ms(200)).unwrap().unwrap();
let mut job_id = ticket.job_id();
let mut scene_generation = ticket.revision().scene_generation;
match mismatch {
0 => job_id.serial += 1,
1 => job_id.generation += 1,
_ => scene_generation += 1,
}
let job = host.submit(job_id, scene_generation, empty(), cpu_request(&cpu()).unwrap()).unwrap();
assert!(!interactive.attach(ticket, job.clone()).unwrap());
assert_eq!(job.status().unwrap().progress.phase, Phase::Cancelled);
assert_eq!(interactive.state().unwrap(), State::Error);
}
let host = host();
let mut interactive = Interactive::new(9006);
interactive.enable(revision(1), ms(0)).unwrap();
let ticket = interactive.capture(ms(200)).unwrap().unwrap();
interactive.capture_failed(ticket, JobError::new(ErrorCode::InvalidScene, "capture failed"));
let late = host.submit(ticket.job_id(), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
assert!(!interactive.attach(ticket, late.clone()).unwrap());
assert_eq!(late.status().unwrap().progress.phase, Phase::Cancelled);
assert!(interactive.current().is_none());
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn delayed_ticket_cannot_cancel_the_current_handle_but_still_cancels_its_obsolete_job() {
let host = host();
let mut interactive = Interactive::new(9007);
interactive.enable(revision(1), ms(0)).unwrap();
let old = interactive.capture(ms(200)).unwrap().unwrap();
interactive.edit(revision(2), ms(201)).unwrap();
let current = interactive.capture(ms(401)).unwrap().unwrap();
let job = host.submit(current.job_id(), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
assert!(interactive.attach(current, job.clone()).unwrap());
assert!(!interactive.attach(old, job.clone()).unwrap());
assert_eq!(job.status().unwrap().progress.phase, Phase::Queued);
let obsolete = host.submit(old.job_id(), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
assert!(!interactive.attach(old, obsolete.clone()).unwrap());
assert_eq!(obsolete.status().unwrap().progress.phase, Phase::Cancelled);
assert_eq!(job.status().unwrap().progress.phase, Phase::Queued);
assert_eq!(interactive.current().unwrap().id(), current.job_id());
drop(interactive);
assert_eq!(job.status().unwrap().progress.phase, Phase::Cancelled);
}
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
mod progressive_checkpoints {
use super::*;
+276
View File
@@ -0,0 +1,276 @@
//! Transient CPU Interactive scheduling. The UI adapter owns visual revisions;
//! this controller owns cancellation, not document settings or final-image export.
use std::time::Duration;
use super::{CpuProgressiveOptions, ErrorCode, ImageRequest, Integrator, JobError, JobHandle, JobId, Phase, Result};
const SETTLE: Duration = Duration::from_millis(200);
const MAX_EDGE: u32 = 1024;
/// Prepare a separate low-resolution request. Never edits the final request,
/// changes renderer, removes optics/lights, or promises geometry admission.
pub fn cpu_request(final_request: &ImageRequest) -> Result<ImageRequest> {
if final_request.integrator != Integrator::Raytrace || final_request.transparent_background {
return Err(JobError::new(ErrorCode::InvalidRequest, "Interactive CPU requires opaque ray tracing"));
}
let (width, height) = (final_request.width, final_request.height);
if width == 0 || height == 0 {
return Err(JobError::new(ErrorCode::InvalidRequest, "Interactive output dimensions must be positive"));
}
if let Some(options) = &final_request.raytrace {
options.validate()?;
}
let edge = width.max(height);
let fit = |side: u32| {
if edge <= MAX_EDGE { side } else { ((u64::from(side) * u64::from(MAX_EDGE) + u64::from(edge) / 2) / u64::from(edge)).max(1) as u32 }
};
let mut request = final_request.clone();
request.width = fit(width);
request.height = fit(height);
let options = request.raytrace.get_or_insert_default();
options.primary_samples = 1;
// These are preview sampling choices only. Incompatible transport remains an
// explicit error, rather than silently removing the user's requested effects.
options.progressive = Some(CpuProgressiveOptions::default());
if let Some(reflections) = &mut options.reflections {
reflections.samples = 1;
}
options.validate()?;
options.budget.validate_image(request.width, request.height, options.progressive)?;
Ok(request)
}
/// Caller increments visual_revision for every camera/draft/appearance/light/HDR
/// or output edit, even if scene_generation did not change. Never use only a CAD
/// revision, a body name, or the selected setup name as the visual identity.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct VisualRevision {
pub scene_generation: u64,
pub visual_revision: u64,
}
/// Issued only after the edit group settles. Private fields prevent fabricating
/// a ticket for a newer request from a delayed capture.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CaptureTicket {
session: u64,
serial: u64,
revision: VisualRevision,
}
impl CaptureTicket {
pub fn revision(self) -> VisualRevision {
self.revision
}
/// Submit this identity to the preview host, outside the final-job registry.
pub fn job_id(self) -> JobId {
JobId { session: self.session, serial: self.serial, generation: self.serial }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum State {
Off,
Updating,
Refining,
Paused,
TargetReached,
Error,
}
/// One controller per document session. No serialization, clocks, threads, scene
/// extraction or new renderer here. Supply monotonic elapsed time from the host;
/// this keeps both browser integration and deterministic event tests possible.
pub struct Interactive {
session: u64,
serial: u64,
revision: Option<VisualRevision>,
changed_at: Duration,
enabled: bool,
paused: bool,
final_active: bool,
ticket: Option<CaptureTicket>,
active: Option<JobHandle>,
error: Option<JobError>,
}
impl Interactive {
pub fn new(session: u64) -> Self {
Self {
session,
serial: 0,
revision: None,
changed_at: Duration::ZERO,
enabled: false,
paused: false,
final_active: false,
ticket: None,
active: None,
error: None,
}
}
fn cancel(&mut self) -> Result<()> {
// Invalidate first, including when a poisoned host refuses cancellation.
self.ticket = None;
if let Some(job) = self.active.take() {
job.cancel()?;
}
Ok(())
}
/// Explicit user activation. Merely entering Render must not call this.
pub fn enable(&mut self, revision: VisualRevision, now: Duration) -> Result<()> {
self.cancel()?;
self.enabled = true;
self.paused = false;
self.error = None;
self.revision = Some(revision);
self.changed_at = now;
Ok(())
}
/// Workspace/document departure uses disable; manually started final jobs
/// are deliberately outside this controller and are never cancelled here.
pub fn disable(&mut self) -> Result<()> {
self.enabled = false;
self.paused = false;
self.error = None;
self.cancel()
}
pub fn edit(&mut self, revision: VisualRevision, now: Duration) -> Result<()> {
if self.revision != Some(revision) {
self.revision = Some(revision);
self.changed_at = now;
self.error = None;
self.cancel()?;
}
Ok(())
}
/// A view may retain an identified immutable PNG before pausing, but must
/// label it paused/stale. It is never a current or exportable final image.
pub fn pause(&mut self) -> Result<()> {
self.paused = true;
self.cancel()
}
pub fn resume(&mut self, now: Duration) -> Result<()> {
self.cancel()?;
self.paused = false;
self.error = None;
self.changed_at = now;
Ok(())
}
pub fn set_final_active(&mut self, active: bool, now: Duration) -> Result<()> {
if self.final_active != active {
self.final_active = active;
self.changed_at = now;
self.cancel()?;
}
Ok(())
}
/// At most one unacknowledged capture; no queue of obsolete requests.
pub fn capture(&mut self, now: Duration) -> Result<Option<CaptureTicket>> {
if !self.enabled
|| self.paused
|| self.final_active
|| self.ticket.is_some()
|| self.error.is_some()
|| now.checked_sub(self.changed_at).is_none_or(|elapsed| elapsed < SETTLE)
{
return Ok(None);
}
let Some(revision) = self.revision else { return Ok(None) };
self.serial = self.serial.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "Interactive capture identity exhausted"))?;
let ticket = CaptureTicket { session: self.session, serial: self.serial, revision };
self.ticket = Some(ticket);
Ok(Some(ticket))
}
/// Attach an admitted preview-only job after immutable capture. Delayed jobs
/// are cancelled, including a previous ticket for the same visual revision.
/// Use a separate engine job session/registry from final-image export.
pub fn attach(&mut self, ticket: CaptureTicket, job: JobHandle) -> Result<bool> {
// A misrouted callback must never cancel another document's work or a
// manual final job. Only our dedicated preview session is ours to stop.
if job.id().session != self.session {
self.capture_failed(ticket, JobError::new(ErrorCode::WrongOwner, "Interactive job belongs to another session"));
return Ok(false);
}
if self.ticket != Some(ticket) || ticket.session != self.session {
if self.active.as_ref().is_none_or(|active| active.id() != job.id()) {
job.cancel()?;
}
return Ok(false);
}
let status = job.status()?;
if status.job != ticket.job_id() || status.scene_generation != ticket.revision.scene_generation {
job.cancel()?;
self.capture_failed(ticket, JobError::new(ErrorCode::WrongOwner, "Interactive job capture identity mismatch"));
return Ok(false);
}
if status.request.integrator != Integrator::Raytrace || status.request.raytrace.as_ref().is_none_or(|options| options.progressive.is_none()) {
job.cancel()?;
self.capture_failed(ticket, JobError::new(ErrorCode::InvalidRequest, "Interactive requires a CPU progressive job"));
return Ok(false);
}
// A ticket is acknowledged exactly once, even if the same capture is
// delivered twice while an earlier attached job is still running.
if self.active.is_some() {
if self.active.as_ref().is_none_or(|active| active.id() != job.id()) {
job.cancel()?;
}
return Ok(false);
}
self.active = Some(job);
Ok(true)
}
pub fn capture_failed(&mut self, ticket: CaptureTicket, error: JobError) {
if self.ticket == Some(ticket) && self.active.is_none() {
self.ticket = None;
self.error = Some(error);
}
}
/// Access only the current capture. The adapter reads checkpoint/final bytes
/// through this handle, displaying both as Interactive/provisional content.
pub fn current(&self) -> Option<&JobHandle> {
self.active.as_ref()
}
pub fn error(&self) -> Option<&JobError> {
self.error.as_ref()
}
pub fn state(&self) -> Result<State> {
if !self.enabled {
return Ok(State::Off);
}
if self.paused || self.final_active {
return Ok(State::Paused);
}
if self.error.is_some() {
return Ok(State::Error);
}
if let Some(job) = &self.active {
return Ok(match job.status()?.progress.phase {
Phase::Completed => State::TargetReached,
Phase::Cancelled | Phase::Failed => State::Error,
_ => State::Refining,
});
}
Ok(State::Updating)
}
}
impl Drop for Interactive {
fn drop(&mut self) {
let _ = self.cancel();
}
}
+1
View File
@@ -19,6 +19,7 @@ pub(crate) mod encoding;
pub mod finishing;
mod host;
pub(crate) mod image;
pub mod interactive;
pub mod led_strip;
mod lens;
#[cfg(test)]
+56
View File
@@ -0,0 +1,56 @@
# CPU Interactive core
First slice of #8936 under #6201. `job::interactive` prepares bounded CPU
progressive requests and coordinates their lifetime. **It is not yet connected
to the workspace UI.** Entering Render does not start a job. No default,
document format, final export, panel or gizmo is changed by this slice.
`cpu_request` clones an immutable final request. It preserves the camera,
background, shadows, exposure, lighting, resources and transport. Only preview
dimensions and camera/reflection sampling change: maximum 1024 pixels along the
longest edge, rounded to the nearest pixel without enlargement, 16 SPP, seed 0,
one primary and one selected reflection attempt per camera sample. The original
final request stays untouched. Integer aspect rounding is at most half a pixel
(or one pixel for an extreme aspect). Transparent output, another integrator or
unsupported progressive transport returns an error instead of dropping effects.
The existing profile and host still enforce geometry, traversal and memory limits.
## Adapter contract
- Allocate a distinct transient job session for each document's Interactive
controller. Never recreate a controller with the same session while callbacks
from its previous instance can arrive. Do not register its jobs in the engine's
final/export registry. Even its 16-SPP completed image is provisional content.
- Increment `VisualRevision.visual_revision` whenever camera, draft, material,
visibility, lighting, HDR/resource, method or output intent changes. Include
changes that leave the document revision unchanged. Capture this counter and
`scene_generation` atomically with the full request and scene. This core does
not infer that identity from a setup name or a partial hash.
- Explicit activation calls `enable`; edits call `edit`. Supply monotonic elapsed
`Duration` values. `capture` issues one ticket after 200 ms without a newer edit.
Do not evaluate geometry or read files every frame just to compare identity.
- Capture through the existing authoritative engine boundary, prepare the CPU
request, then admit/submit through the installed CPU host using
`CaptureTicket::job_id()`. Pass the resulting handle to `attach`
with the original ticket. Delayed jobs are cancelled, including a wrong serial
or generation in our dedicated preview session. A foreign session is rejected
without cancelling its work, even if its handle arrived through a misrouted
callback. Final jobs must keep their distinct engine session.
Report a failed capture through `capture_failed`; failure waits for an edit or
explicit resume, rather than retrying every frame.
- Read only `current()` to publish checkpoint pixels. The existing host provides
immutable previews, sample counts, sequence and final target bytes. Their job
and revision must remain attached to the displayed image. A copied handle or
retained texture is not permission to display it as current after an edit.
- Pause may retain an immutable checkpoint in the adapter, explicitly labelled
paused and stale after edits. It cancels work. Resume uses the latest revision.
Leave Render or change documents with `disable`; Drop also cancels owned work.
- Before a manual final starts, call `set_final_active(true)`. This cancels only
Interactive work and prevents new capture tickets. Call it with false after the
final finishes; an enabled, unpaused controller then groups the latest edits
again. No method here cancels a final job or exports an image.
The UI adapter, registered activation/pause commands, current-request identity
capture, retained paused texture and visual acceptance remain a following slice
coordinated with #8000/#7375. This is not a claim that those integration gates or
the installed application's shadow defect are fixed.