Skip to main content

spatialrust_web/
wasm.rs

1use js_sys::{ArrayBuffer, Uint8Array};
2use wasm_bindgen::{prelude::*, JsCast};
3use wasm_bindgen_futures::JsFuture;
4use web_sys::{AbortController, Request, RequestInit, RequestMode, Response};
5
6use spatialrust_math::Vec3;
7use spatialrust_viewer::{ViewerState, ViewportSize};
8use spatialrust_viz::{Camera, Projection};
9
10use crate::{BrowserInput, ByteRange, RangeBudget, RangeCache, RangePlanner, WebViewerState};
11
12fn js_error(error: impl core::fmt::Display) -> JsValue {
13    JsValue::from_str(&error.to_string())
14}
15
16/// Browser-visible cooperative cancellation backed by `AbortController`.
17#[wasm_bindgen]
18pub struct BrowserRangeAbort {
19    controller: AbortController,
20}
21
22#[wasm_bindgen]
23impl BrowserRangeAbort {
24    /// Creates a live cancellation handle.
25    #[wasm_bindgen(constructor)]
26    pub fn new() -> Result<BrowserRangeAbort, JsValue> {
27        Ok(Self { controller: AbortController::new()? })
28    }
29
30    /// Aborts an in-progress fetch. Repeated calls are harmless.
31    pub fn abort(&self) {
32        self.controller.abort();
33    }
34
35    /// Whether cancellation was requested.
36    #[wasm_bindgen(getter)]
37    pub fn aborted(&self) -> bool {
38        self.controller.signal().aborted()
39    }
40}
41
42/// Executes one strictly bounded browser HTTP Range fetch.
43///
44/// The server must return `206`, an exact `Content-Length`, and a matching
45/// response length. No bytes are copied into WASM until those headers pass.
46#[wasm_bindgen]
47pub async fn bounded_fetch_range(
48    url: String,
49    start: u64,
50    end_exclusive: u64,
51    max_response_bytes: u64,
52    cancellation: &BrowserRangeAbort,
53) -> Result<Uint8Array, JsValue> {
54    let range = ByteRange::try_new(start, end_exclusive).map_err(js_error)?;
55    if max_response_bytes == 0 || range.len() > max_response_bytes {
56        return Err(js_error("requested range exceeds max_response_bytes"));
57    }
58    if cancellation.aborted() {
59        return Err(js_error("range fetch cancelled before request"));
60    }
61    let options = RequestInit::new();
62    options.set_method("GET");
63    options.set_mode(RequestMode::Cors);
64    options.set_signal(Some(&cancellation.controller.signal()));
65    let request = Request::new_with_str_and_init(&url, &options)?;
66    request.headers().set("Range", &range.http_header())?;
67    let window = web_sys::window().ok_or_else(|| js_error("browser Window is unavailable"))?;
68    let response =
69        JsFuture::from(window.fetch_with_request(&request)).await?.dyn_into::<Response>()?;
70    if response.status() != 206 {
71        return Err(js_error(format!(
72            "range server returned status {}, expected 206",
73            response.status()
74        )));
75    }
76    let length_header = response
77        .headers()
78        .get("Content-Length")?
79        .ok_or_else(|| js_error("range response omitted Content-Length"))?;
80    let declared: u64 = length_header.parse().map_err(|_| js_error("invalid Content-Length"))?;
81    if declared != range.len() || declared > max_response_bytes {
82        return Err(js_error(format!(
83            "range Content-Length {declared} does not match requested {}",
84            range.len()
85        )));
86    }
87    let buffer = JsFuture::from(response.array_buffer()?).await?.dyn_into::<ArrayBuffer>()?;
88    let bytes = Uint8Array::new(&buffer);
89    if u64::from(bytes.length()) != declared {
90        return Err(js_error("range body length differs from Content-Length"));
91    }
92    Ok(bytes)
93}
94
95/// WASM-facing portable viewer and bounded remote-range cache.
96#[wasm_bindgen]
97pub struct BrowserViewer {
98    state: WebViewerState,
99    planner: RangePlanner,
100    cache: RangeCache,
101}
102
103#[wasm_bindgen]
104impl BrowserViewer {
105    /// Creates a deterministic empty viewer for browser smoke tests and simple embeds.
106    pub fn new_default(
107        width: u32,
108        height: u32,
109        range_budget_json: &str,
110    ) -> Result<BrowserViewer, JsValue> {
111        let camera = Camera::try_new(
112            Vec3::new(0.0, 0.0, 5.0),
113            Vec3::new(0.0, 0.0, 0.0),
114            Vec3::new(0.0, 1.0, 0.0),
115            Projection::Perspective { vertical_fov_radians: 1.0, near: 0.1, far: 100.0 },
116        )
117        .map_err(js_error)?;
118        let viewer =
119            ViewerState::try_new(camera, ViewportSize::try_new(width, height).map_err(js_error)?)
120                .map_err(js_error)?;
121        let state = WebViewerState::try_new(viewer).map_err(js_error)?;
122        let budget: RangeBudget = serde_json::from_str(range_budget_json).map_err(js_error)?;
123        Ok(Self {
124            state,
125            planner: RangePlanner::try_new(budget).map_err(js_error)?,
126            cache: RangeCache::try_new(budget).map_err(js_error)?,
127        })
128    }
129
130    /// Creates a browser viewer from strict state and range-budget JSON.
131    #[wasm_bindgen(constructor)]
132    pub fn new(state_json: &str, range_budget_json: &str) -> Result<BrowserViewer, JsValue> {
133        let state = WebViewerState::from_json(state_json).map_err(js_error)?;
134        let budget: RangeBudget = serde_json::from_str(range_budget_json).map_err(js_error)?;
135        Ok(Self {
136            state,
137            planner: RangePlanner::try_new(budget).map_err(js_error)?,
138            cache: RangeCache::try_new(budget).map_err(js_error)?,
139        })
140    }
141
142    /// Returns strict portable viewer-state JSON.
143    pub fn state_json(&self) -> Result<String, JsValue> {
144        self.state.to_json().map_err(js_error)
145    }
146
147    /// Applies one strict [`BrowserInput`] JSON object.
148    pub fn apply_input_json(&mut self, input_json: &str) -> Result<(), JsValue> {
149        let input: BrowserInput = serde_json::from_str(input_json).map_err(js_error)?;
150        self.state.apply(input).map_err(js_error)
151    }
152
153    /// Plans sorted/deduplicated bounded cache misses from a JSON range array.
154    pub fn plan_ranges_json(
155        &mut self,
156        ranges_json: &str,
157        cancelled: bool,
158    ) -> Result<String, JsValue> {
159        let ranges: Vec<ByteRange> = serde_json::from_str(ranges_json).map_err(js_error)?;
160        let plan = self.planner.plan(ranges, &self.cache, cancelled).map_err(js_error)?;
161        serde_json::to_string(&plan).map_err(js_error)
162    }
163
164    /// Explicitly copies a fetched JS byte array into the bounded WASM cache.
165    ///
166    /// The returned JSON receipt reports the exact copied and evicted bytes.
167    pub fn admit_range(
168        &mut self,
169        start: u64,
170        end_exclusive: u64,
171        bytes: &Uint8Array,
172    ) -> Result<String, JsValue> {
173        let range = ByteRange::try_new(start, end_exclusive).map_err(js_error)?;
174        let mut owned = vec![0_u8; bytes.length() as usize];
175        bytes.copy_to(&mut owned);
176        let receipt = self.cache.admit(range, owned).map_err(js_error)?;
177        serde_json::to_string(&receipt).map_err(js_error)
178    }
179
180    /// Explicitly copies an exact cached range back into a JS byte array.
181    pub fn cached_range(
182        &mut self,
183        start: u64,
184        end_exclusive: u64,
185    ) -> Result<Option<Uint8Array>, JsValue> {
186        let range = ByteRange::try_new(start, end_exclusive).map_err(js_error)?;
187        Ok(self.cache.get(range).map(Uint8Array::from))
188    }
189}