mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-06 20:02:16 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a077906234 | |||
| b2733d607a |
@@ -1,6 +1,258 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use solidcraft_geom::{Aabb3, Mesh};
|
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")))]
|
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||||
mod progressive_checkpoints {
|
mod progressive_checkpoints {
|
||||||
use super::*;
|
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;
|
pub mod finishing;
|
||||||
mod host;
|
mod host;
|
||||||
pub(crate) mod image;
|
pub(crate) mod image;
|
||||||
|
pub mod interactive;
|
||||||
pub mod led_strip;
|
pub mod led_strip;
|
||||||
mod lens;
|
mod lens;
|
||||||
#[cfg(test)]
|
#[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