mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 10:35:58 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a077906234 | |||
| b2733d607a |
@@ -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::*;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user