diff --git a/.sqlx/query-0937d376f0b5774aee42d246559cd32ab68fa1662e796c3cf592285285551b7c.json b/.sqlx/query-0937d376f0b5774aee42d246559cd32ab68fa1662e796c3cf592285285551b7c.json deleted file mode 100644 index 5a176b0..0000000 --- a/.sqlx/query-0937d376f0b5774aee42d246559cd32ab68fa1662e796c3cf592285285551b7c.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO models (did, rkey, uri, cid, name, summary, created_at, indexed_at, record_json)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 9 - }, - "nullable": [] - }, - "hash": "0937d376f0b5774aee42d246559cd32ab68fa1662e796c3cf592285285551b7c" -} diff --git a/.sqlx/query-0e99368d1deb5983378e91966b91a8573669d421029964a768044f45d9ce0536.json b/.sqlx/query-0e99368d1deb5983378e91966b91a8573669d421029964a768044f45d9ce0536.json deleted file mode 100644 index f8a5c1d..0000000 --- a/.sqlx/query-0e99368d1deb5983378e91966b91a8573669d421029964a768044f45d9ce0536.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO content_stats (uri, like_count, save_count, tag_count) VALUES (?, ?, ?, 0)", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "0e99368d1deb5983378e91966b91a8573669d421029964a768044f45d9ce0536" -} diff --git a/.sqlx/query-111f07c8f230de39ff2c06967435622dd37ace1548fea17eb57fe1d55bcd73bd.json b/.sqlx/query-111f07c8f230de39ff2c06967435622dd37ace1548fea17eb57fe1d55bcd73bd.json deleted file mode 100644 index 2e73504..0000000 --- a/.sqlx/query-111f07c8f230de39ff2c06967435622dd37ace1548fea17eb57fe1d55bcd73bd.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO profiles (did, display_name, description, avatar_json, default_license,\n pronouns, printers_json, links_json, record_json, indexed_at)\n VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 8 - }, - "nullable": [] - }, - "hash": "111f07c8f230de39ff2c06967435622dd37ace1548fea17eb57fe1d55bcd73bd" -} diff --git a/.sqlx/query-78063f22dc67e546f0822eb5e1febe849023eb2d790b062342c6d853c9f7258c.json b/.sqlx/query-78063f22dc67e546f0822eb5e1febe849023eb2d790b062342c6d853c9f7258c.json deleted file mode 100644 index f8c1ed1..0000000 --- a/.sqlx/query-78063f22dc67e546f0822eb5e1febe849023eb2d790b062342c6d853c9f7258c.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO things (did, rkey, uri, cid, name, summary, license, tags_json,\n tags_text, instructions_text, cover_json, derived_from_uri, created_at, indexed_at, record_json)\n VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 10 - }, - "nullable": [] - }, - "hash": "78063f22dc67e546f0822eb5e1febe849023eb2d790b062342c6d853c9f7258c" -} diff --git a/.sqlx/query-82ad9d19691c9c2a7f68599d52302eff6a64c5e38cfb0255c21d4a55c6f99d8d.json b/.sqlx/query-82ad9d19691c9c2a7f68599d52302eff6a64c5e38cfb0255c21d4a55c6f99d8d.json deleted file mode 100644 index 1a19f3d..0000000 --- a/.sqlx/query-82ad9d19691c9c2a7f68599d52302eff6a64c5e38cfb0255c21d4a55c6f99d8d.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO content_stats (uri, like_count, save_count, tag_count) VALUES (?, 42, 12, 3)", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "82ad9d19691c9c2a7f68599d52302eff6a64c5e38cfb0255c21d4a55c6f99d8d" -} diff --git a/.sqlx/query-9a03057238bd06c8daf3ff1587d7e9817e2ca5fa7633c759b8620d25629518c3.json b/.sqlx/query-9a03057238bd06c8daf3ff1587d7e9817e2ca5fa7633c759b8620d25629518c3.json new file mode 100644 index 0000000..451eb77 --- /dev/null +++ b/.sqlx/query-9a03057238bd06c8daf3ff1587d7e9817e2ca5fa7633c759b8620d25629518c3.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT project_uri, canonical_path FROM ldraw_resources WHERE resource_uri = ?", + "describe": { + "columns": [ + { + "name": "project_uri", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_resources", + "name": "project_uri" + } + } + }, + { + "name": "canonical_path", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_resources", + "name": "canonical_path" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "9a03057238bd06c8daf3ff1587d7e9817e2ca5fa7633c759b8620d25629518c3" +} diff --git a/.sqlx/query-9d3ae9004dcf483e658e3da44d52bae0284416437e5e39fcef7f1536d820bad3.json b/.sqlx/query-9d3ae9004dcf483e658e3da44d52bae0284416437e5e39fcef7f1536d820bad3.json deleted file mode 100644 index c05c0d6..0000000 --- a/.sqlx/query-9d3ae9004dcf483e658e3da44d52bae0284416437e5e39fcef7f1536d820bad3.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO parts (did, rkey, uri, cid, name, format, file_json, created_at, indexed_at, record_json)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 10 - }, - "nullable": [] - }, - "hash": "9d3ae9004dcf483e658e3da44d52bae0284416437e5e39fcef7f1536d820bad3" -} diff --git a/.sqlx/query-b5def9780ec5f6802df046e8761999a329b310a341c35260ef101b9d40c901b8.json b/.sqlx/query-b5def9780ec5f6802df046e8761999a329b310a341c35260ef101b9d40c901b8.json deleted file mode 100644 index f4a6b4b..0000000 --- a/.sqlx/query-b5def9780ec5f6802df046e8761999a329b310a341c35260ef101b9d40c901b8.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO thing_models (thing_uri, model_uri, position) VALUES (?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "b5def9780ec5f6802df046e8761999a329b310a341c35260ef101b9d40c901b8" -} diff --git a/.sqlx/query-ccc3ede0dfc9b961386b10491195656b5589b0dd880e58444b8f6c0adf246668.json b/.sqlx/query-ccc3ede0dfc9b961386b10491195656b5589b0dd880e58444b8f6c0adf246668.json deleted file mode 100644 index 7ac1386..0000000 --- a/.sqlx/query-ccc3ede0dfc9b961386b10491195656b5589b0dd880e58444b8f6c0adf246668.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO identities (did, handle, updated_at) VALUES (?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "ccc3ede0dfc9b961386b10491195656b5589b0dd880e58444b8f6c0adf246668" -} diff --git a/.sqlx/query-dd588b2a5f3650d72db321af63c54ec94684eb9f59480ff6fe27f2885a3a90bf.json b/.sqlx/query-dd588b2a5f3650d72db321af63c54ec94684eb9f59480ff6fe27f2885a3a90bf.json deleted file mode 100644 index 8445622..0000000 --- a/.sqlx/query-dd588b2a5f3650d72db321af63c54ec94684eb9f59480ff6fe27f2885a3a90bf.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO things (did, rkey, uri, cid, name, summary, license, tags_json,\n tags_text, instructions_text, cover_json, derived_from_uri, created_at, indexed_at, record_json)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 14 - }, - "nullable": [] - }, - "hash": "dd588b2a5f3650d72db321af63c54ec94684eb9f59480ff6fe27f2885a3a90bf" -} diff --git a/.sqlx/query-e28ec474510b41426db6da080faa94e0dd662048bf8caf7ea75f6561ebd819b5.json b/.sqlx/query-e28ec474510b41426db6da080faa94e0dd662048bf8caf7ea75f6561ebd819b5.json deleted file mode 100644 index 59db16d..0000000 --- a/.sqlx/query-e28ec474510b41426db6da080faa94e0dd662048bf8caf7ea75f6561ebd819b5.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO model_parts (model_uri, part_uri, position) VALUES (?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "e28ec474510b41426db6da080faa94e0dd662048bf8caf7ea75f6561ebd819b5" -} diff --git a/.sqlx/query-fa70a2929db5194d48a34173e6f1292182911f65972d1fe58cac0d3e0c212e4c.json b/.sqlx/query-fa70a2929db5194d48a34173e6f1292182911f65972d1fe58cac0d3e0c212e4c.json deleted file mode 100644 index a0eb51e..0000000 --- a/.sqlx/query-fa70a2929db5194d48a34173e6f1292182911f65972d1fe58cac0d3e0c212e4c.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT COUNT(*) FROM things", - "describe": { - "columns": [ - { - "name": "COUNT(*)", - "ordinal": 0, - "type_info": "Integer", - "origin": "Expression" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - false - ] - }, - "hash": "fa70a2929db5194d48a34173e6f1292182911f65972d1fe58cac0d3e0c212e4c" -} diff --git a/crates/polymodel-api/lexicons/space_polymodel_library_getModelLoadBundle.json b/crates/polymodel-api/lexicons/space_polymodel_library_getModelLoadBundle.json new file mode 100644 index 0000000..aee87fe --- /dev/null +++ b/crates/polymodel-api/lexicons/space_polymodel_library_getModelLoadBundle.json @@ -0,0 +1,25 @@ +{ + "lexicon": 1, + "id": "space.polymodel.library.getModelLoadBundle", + "defs": { + "main": { + "type": "query", + "description": "Resolve a model Part URI into one bounded binary load bundle containing the primary resource and every indexed companion resource required by the format.", + "parameters": { + "type": "params", + "required": [ + "uri" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + } + } + }, + "output": { + "encoding": "*/*" + } + } + } +} \ No newline at end of file diff --git a/crates/polymodel-api/src/space_polymodel/library.rs b/crates/polymodel-api/src/space_polymodel/library.rs index 7727f44..653e618 100644 --- a/crates/polymodel-api/src/space_polymodel/library.rs +++ b/crates/polymodel-api/src/space_polymodel/library.rs @@ -10,6 +10,7 @@ pub mod delete_thing; pub mod get_author_things; pub mod get_feed; pub mod get_model; +pub mod get_model_load_bundle; pub mod get_part_file; pub mod get_thing; pub mod model; diff --git a/crates/polymodel-api/src/space_polymodel/library/get_model_load_bundle.rs b/crates/polymodel-api/src/space_polymodel/library/get_model_load_bundle.rs new file mode 100644 index 0000000..9d2456d --- /dev/null +++ b/crates/polymodel-api/src/space_polymodel/library/get_model_load_bundle.rs @@ -0,0 +1,195 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: space.polymodel.library.getModelLoadBundle +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::deps::bytes::Bytes; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::AtUri; +use jacquard_common::types::value::Data; +use jacquard_derive::IntoStatic; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct GetModelLoadBundle { + pub uri: AtUri, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase")] +pub struct GetModelLoadBundleOutput { + pub body: Bytes, +} + +/** Response marker for the `space.polymodel.library.getModelLoadBundle` query. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `GetModelLoadBundleOutput` for this endpoint.*/ +pub struct GetModelLoadBundleResponse; +impl jacquard_common::xrpc::XrpcResp for GetModelLoadBundleResponse { + const NSID: &'static str = "space.polymodel.library.getModelLoadBundle"; + const ENCODING: &'static str = "*/*"; + type Output = GetModelLoadBundleOutput; + type Err = jacquard_common::xrpc::GenericError; + fn encode_output( + output: &Self::Output, + ) -> Result, jacquard_common::xrpc::EncodeError> + where + Self::Output: Serialize, + { + Ok(output.body.to_vec()) + } + fn decode_output<'de, S>( + body: &'de [u8], + ) -> Result, jacquard_common::error::DecodeError> + where + S: BosStr + Deserialize<'de>, + Self::Output: Deserialize<'de>, + { + Ok(GetModelLoadBundleOutput { + body: jacquard_common::deps::bytes::Bytes::copy_from_slice(body), + }) + } +} + +impl jacquard_common::xrpc::XrpcRequest for GetModelLoadBundle { + const NSID: &'static str = "space.polymodel.library.getModelLoadBundle"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Response = GetModelLoadBundleResponse; +} + +/** Endpoint marker for the `space.polymodel.library.getModelLoadBundle` query. + +Path: `/xrpc/space.polymodel.library.getModelLoadBundle`. The request payload type is `GetModelLoadBundle`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ +pub struct GetModelLoadBundleRequest; +impl jacquard_common::xrpc::XrpcEndpoint for GetModelLoadBundleRequest { + const PATH: &'static str = "/xrpc/space.polymodel.library.getModelLoadBundle"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Request = GetModelLoadBundle; + type Response = GetModelLoadBundleResponse; +} + +pub mod get_model_load_bundle_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Uri; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Uri = Unset; + } + ///State transition - sets the `uri` field to Set + pub struct SetUri(PhantomData St>); + impl sealed::Sealed for SetUri {} + impl State for SetUri { + type Uri = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `uri` field + pub struct uri(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct GetModelLoadBundleBuilder< + St: get_model_load_bundle_state::State, + S: BosStr = DefaultStr, +> { + _state: PhantomData St>, + _fields: (Option>,), + _type: PhantomData S>, +} + +impl GetModelLoadBundle { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> GetModelLoadBundleBuilder< + get_model_load_bundle_state::Empty, + DefaultStr, + > { + GetModelLoadBundleBuilder::new() + } +} + +impl GetModelLoadBundle { + /// Create a new builder for this type + pub fn builder() -> GetModelLoadBundleBuilder< + get_model_load_bundle_state::Empty, + S, + > { + GetModelLoadBundleBuilder::builder() + } +} + +impl GetModelLoadBundleBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + GetModelLoadBundleBuilder { + _state: PhantomData, + _fields: (None,), + _type: PhantomData, + } + } +} + +impl GetModelLoadBundleBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + GetModelLoadBundleBuilder { + _state: PhantomData, + _fields: (None,), + _type: PhantomData, + } + } +} + +impl GetModelLoadBundleBuilder +where + St: get_model_load_bundle_state::State, + St::Uri: get_model_load_bundle_state::IsUnset, +{ + /// Set the `uri` field (required) + pub fn uri( + mut self, + value: impl Into>, + ) -> GetModelLoadBundleBuilder, S> { + self._fields.0 = Option::Some(value.into()); + GetModelLoadBundleBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl GetModelLoadBundleBuilder +where + St: get_model_load_bundle_state::State, + St::Uri: get_model_load_bundle_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> GetModelLoadBundle { + GetModelLoadBundle { + uri: self._fields.0.unwrap(), + } + } +} \ No newline at end of file diff --git a/crates/polymodel-renderer-protocol/src/lib.rs b/crates/polymodel-renderer-protocol/src/lib.rs index cca59f5..f817d03 100644 --- a/crates/polymodel-renderer-protocol/src/lib.rs +++ b/crates/polymodel-renderer-protocol/src/lib.rs @@ -22,7 +22,7 @@ pub use load_state::{ }; /// Current wire protocol version. The envelope is mandatory for every postcard message. -pub const PROTOCOL_VERSION: u16 = 1; +pub const PROTOCOL_VERSION: u16 = 2; /// Capability names negotiated before scene-dependent commands are accepted. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -425,38 +425,20 @@ pub fn verify_part_file(bytes: &[u8], size: i64, digest: Option<&[u8]>) -> Resul /// per-frame, never rendering from a single message. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum RendererCommand { - /// Begin an interactive load. The main thread allocates the identity and the worker - /// must not allocate or infer one. The worker discovers and fetches glTF companions. + /// Begin a load. The source determines how bytes are obtained; the output determines + /// whether the resulting candidate is installed or rendered once and discarded. StartLoad { namespace: RendererNamespace, identity: LoadIdentity, - format: MeshFormat, - primary_url: String, - expected_size: Option, - expected_digest: Option>, - }, - /// Begin a public, anonymous LDraw load from a complete content-bound plan. - /// The appview re-resolves canonical metadata for every request; the worker - /// never derives companion URLs from filenames. - StartCompoundLoad { - namespace: RendererNamespace, - identity: LoadIdentity, - plan: CompoundLoadPlan, + source: LoadSource, + output: LoadOutput, }, /// Cancel the current identity. Late results are rejected by the reducer. Cancel { namespace: RendererNamespace, identity: LoadIdentity, }, - /// Render a mesh URL to a PNG preview image. This is intentionally handled by - /// the worker so the Dioxus app does not depend on mesh parsing or `three-d`. - RenderPreviewImage { - request_id: u32, - format: MeshFormat, - primary_url: String, - width: u32, - height: u32, - }, + /// Resize the worker's `OffscreenCanvas` and viewport. `css_w`/`css_h` are CSS pixels, /// `dpr` is `window.devicePixelRatio`. The worker sets canvas pixel dimensions to /// `css_w * dpr` × `css_h * dpr`. @@ -479,76 +461,84 @@ pub enum RendererCommand { Dispose, /// Deliberately panic — used by e2e tests to verify crash surfacing. DebugCrash, - /// Render a compound LDraw plan to a temporary PNG preview. Verified bytes and - /// parsed scene data remain inside the worker; only the PNG crosses the bridge. - /// - /// Appended to preserve the discriminants of existing command variants. - StartCompoundPreview { +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum LoadSource { + Url { + format: MeshFormat, + primary_url: String, + expected_size: Option, + expected_digest: Option>, + }, + Bundle(ModelLoadBundle), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum LoadOutput { + Interactive, + Preview { request_id: u32, - namespace: RendererNamespace, - identity: LoadIdentity, - plan: CompoundLoadPlan, width: u32, height: u32, }, } -/// One opaque, same-origin resource route supplied by the public appview. -/// The route is stateless and content-bound; the worker never derives routes from -/// filenames or contacts a publisher directly. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorkerFetchDescriptor { +pub struct ModelResource { pub key: String, - pub route: String, pub path: String, pub root: String, pub byte_length: u64, pub sha256: Vec, pub mime_type: String, pub target_cids: Vec, + pub bytes: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CompoundLoadPlan { +pub struct ModelLoadBundle { pub version: u16, + pub format: MeshFormat, pub primary: String, - pub descriptors: Vec, + pub resources: Vec, } -pub const MAX_COMPOUND_DESCRIPTORS: usize = 16_384; -pub const MAX_COMPOUND_ENCODED_BYTES: usize = 8 * 1024 * 1024; -pub const MAX_COMPOUND_METADATA_BYTES: usize = 8 * 1024 * 1024; -pub const MAX_COMPOUND_TOTAL_BYTES: u64 = 128 * 1024 * 1024; -pub const MAX_COMPOUND_CIDS_PER_DESCRIPTOR: usize = 4_096; +pub const MAX_MODEL_RESOURCES: usize = 16_384; +pub const MAX_MODEL_BUNDLE_BYTES: usize = 136 * 1024 * 1024; +pub const MAX_MODEL_METADATA_BYTES: usize = 8 * 1024 * 1024; +pub const MAX_MODEL_RESOURCE_BYTES: u64 = 128 * 1024 * 1024; +pub const MAX_MODEL_CIDS_PER_RESOURCE: usize = 4_096; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CompoundPlanError { +pub enum ModelBundleError { Empty, - TooManyDescriptors, + TooManyResources, EncodedBytesExceeded, MetadataExceeded, TotalBytesExceeded, DuplicateKey, PrimaryMissing, - InvalidDescriptor, + InvalidResource, + SizeMismatch, + DigestMismatch, } -impl WorkerFetchDescriptor { - pub fn validate(&self) -> Result<(), &'static str> { - if self.key.is_empty() || self.key != self.path || self.route.is_empty() { - return Err("descriptor identity is incomplete"); - } - if !self.route.starts_with('/') - || self.route.starts_with("//") - || self.route.contains("://") +impl ModelResource { + pub fn validate(&self) -> Result<(), ModelBundleError> { + if self.key.is_empty() + || self.key != self.path + || self.sha256.len() != 32 + || self.mime_type.is_empty() + || self.target_cids.len() > MAX_MODEL_CIDS_PER_RESOURCE { - return Err("descriptor route is not a relative same-origin path"); + return Err(ModelBundleError::InvalidResource); } - if self.sha256.len() != 32 || self.mime_type.is_empty() || self.target_cids.is_empty() { - return Err("descriptor integrity metadata is incomplete"); + if self.byte_length != self.bytes.len() as u64 { + return Err(ModelBundleError::SizeMismatch); } - if self.target_cids.len() > MAX_COMPOUND_CIDS_PER_DESCRIPTOR { - return Err("descriptor has too many target CIDs"); + if sha2::Sha256::digest(&self.bytes)[..] != self.sha256[..] { + return Err(ModelBundleError::DigestMismatch); } Ok(()) } @@ -556,7 +546,6 @@ impl WorkerFetchDescriptor { fn metadata_bytes(&self) -> Option { self.key .len() - .checked_add(self.route.len())? .checked_add(self.path.len())? .checked_add(self.root.len())? .checked_add(self.mime_type.len())? @@ -569,54 +558,63 @@ impl WorkerFetchDescriptor { } } -impl CompoundLoadPlan { - pub fn validate(&self) -> Result<(), CompoundPlanError> { - if self.descriptors.is_empty() { - return Err(CompoundPlanError::Empty); +impl ModelLoadBundle { + pub fn validate(&self) -> Result<(), ModelBundleError> { + if self.resources.is_empty() { + return Err(ModelBundleError::Empty); } - if self.descriptors.len() > MAX_COMPOUND_DESCRIPTORS { - return Err(CompoundPlanError::TooManyDescriptors); + if self.resources.len() > MAX_MODEL_RESOURCES { + return Err(ModelBundleError::TooManyResources); } - let mut keys = HashSet::with_capacity(self.descriptors.len()); + let mut keys = HashSet::with_capacity(self.resources.len()); let mut metadata = self.primary.len(); let mut total_bytes = 0u64; - for descriptor in &self.descriptors { - descriptor - .validate() - .map_err(|_| CompoundPlanError::InvalidDescriptor)?; - if !keys.insert(&descriptor.key) { - return Err(CompoundPlanError::DuplicateKey); + for resource in &self.resources { + resource.validate()?; + if !keys.insert(&resource.key) { + return Err(ModelBundleError::DuplicateKey); } metadata = metadata .checked_add( - descriptor + resource .metadata_bytes() - .ok_or(CompoundPlanError::MetadataExceeded)?, + .ok_or(ModelBundleError::MetadataExceeded)?, ) - .ok_or(CompoundPlanError::MetadataExceeded)?; + .ok_or(ModelBundleError::MetadataExceeded)?; total_bytes = total_bytes - .checked_add(descriptor.byte_length) - .ok_or(CompoundPlanError::TotalBytesExceeded)?; - if metadata > MAX_COMPOUND_METADATA_BYTES { - return Err(CompoundPlanError::MetadataExceeded); + .checked_add(resource.byte_length) + .ok_or(ModelBundleError::TotalBytesExceeded)?; + if metadata > MAX_MODEL_METADATA_BYTES { + return Err(ModelBundleError::MetadataExceeded); } - if total_bytes > MAX_COMPOUND_TOTAL_BYTES { - return Err(CompoundPlanError::TotalBytesExceeded); + if total_bytes > MAX_MODEL_RESOURCE_BYTES { + return Err(ModelBundleError::TotalBytesExceeded); } } if !keys.contains(&self.primary) { - return Err(CompoundPlanError::PrimaryMissing); + return Err(ModelBundleError::PrimaryMissing); } Ok(()) } - pub fn encoded_size(&self) -> Result { + pub fn encode(&self) -> Result, ModelBundleError> { + self.validate()?; let encoded = - postcard::to_allocvec(self).map_err(|_| CompoundPlanError::EncodedBytesExceeded)?; - if encoded.len() > MAX_COMPOUND_ENCODED_BYTES { - return Err(CompoundPlanError::EncodedBytesExceeded); + postcard::to_allocvec(self).map_err(|_| ModelBundleError::EncodedBytesExceeded)?; + if encoded.len() > MAX_MODEL_BUNDLE_BYTES { + return Err(ModelBundleError::EncodedBytesExceeded); + } + Ok(encoded) + } + + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() > MAX_MODEL_BUNDLE_BYTES { + return Err(ModelBundleError::EncodedBytesExceeded); } - Ok(encoded.len()) + let bundle: Self = + postcard::from_bytes(bytes).map_err(|_| ModelBundleError::InvalidResource)?; + bundle.validate()?; + Ok(bundle) } } @@ -708,19 +706,25 @@ pub fn deserialize_event_envelope(bytes: &[u8]) -> Result Option { +pub fn required_capabilities(command: &RendererCommand) -> Vec { match command { - RendererCommand::StartCompoundLoad { .. } => Some(Capability::CompoundLdraw), - RendererCommand::StartCompoundPreview { .. } => Some(Capability::CompoundLdraw), - RendererCommand::RenderPreviewImage { .. } => Some(Capability::Preview), - RendererCommand::Cancel { .. } => Some(Capability::Cancellation), - RendererCommand::StartLoad { .. } - | RendererCommand::Resize { .. } + RendererCommand::StartLoad { source, output, .. } => { + let mut required = Vec::with_capacity(2); + if matches!(source, LoadSource::Bundle(_)) { + required.push(Capability::CompoundLdraw); + } + if matches!(output, LoadOutput::Preview { .. }) { + required.push(Capability::Preview); + } + required + } + RendererCommand::Cancel { .. } => vec![Capability::Cancellation], + RendererCommand::Resize { .. } | RendererCommand::SetPointerState { .. } | RendererCommand::PointerDown { .. } | RendererCommand::PointerUp { .. } | RendererCommand::Dispose - | RendererCommand::DebugCrash => None, + | RendererCommand::DebugCrash => Vec::new(), } } @@ -731,6 +735,7 @@ pub fn required_capability(command: &RendererCommand) -> Option { #[cfg(test)] mod tests { use super::*; + use sha2::Sha256; #[test] fn verify_part_file_matching_size_and_digest_is_ok() { @@ -845,94 +850,92 @@ mod tests { } } - fn compound_descriptor(key: &str, route: &str, byte: u8) -> WorkerFetchDescriptor { - WorkerFetchDescriptor { + fn model_resource(key: &str, byte: u8) -> ModelResource { + let bytes = vec![byte]; + ModelResource { key: key.into(), - route: route.into(), path: key.into(), root: "models".into(), - byte_length: 1, - sha256: vec![byte; 32], + byte_length: bytes.len() as u64, + sha256: Sha256::digest(&bytes).to_vec(), mime_type: "text/plain".into(), target_cids: vec![format!("cid-{byte}")], + bytes, } } - fn compound_plan() -> CompoundLoadPlan { - CompoundLoadPlan { + fn model_bundle() -> ModelLoadBundle { + ModelLoadBundle { version: PROTOCOL_VERSION, + format: MeshFormat::CompoundLdraw, primary: "models/main.ldr".into(), - descriptors: vec![ - compound_descriptor("models/main.ldr", "/app/ldraw/a", 1), - compound_descriptor("models/child.dat", "/app/ldraw/b", 2), + resources: vec![ + model_resource("models/main.ldr", 1), + model_resource("models/child.dat", 2), ], } } + fn url_source(format: MeshFormat, url: &str) -> LoadSource { + LoadSource::Url { + format, + primary_url: url.into(), + expected_size: None, + expected_digest: None, + } + } + #[test] fn all_commands_roundtrip() { let commands = vec![ RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 1), - format: MeshFormat::Stl, - primary_url: "/models/cube.stl".into(), - expected_size: Some(4096), - expected_digest: Some(vec![0u8; 32]), + source: url_source(MeshFormat::Stl, "/models/cube.stl"), + output: LoadOutput::Interactive, }, RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 2), - format: MeshFormat::Obj, - primary_url: "/models/cube.obj".into(), - expected_size: None, - expected_digest: None, + source: url_source(MeshFormat::Obj, "/models/cube.obj"), + output: LoadOutput::Interactive, }, RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(2, 1), - format: MeshFormat::Gltf, - primary_url: "/models/cube.glb".into(), - expected_size: Some(2048), - expected_digest: None, + source: url_source(MeshFormat::Gltf, "/models/cube.glb"), + output: LoadOutput::Interactive, }, RendererCommand::StartLoad { - namespace: RendererNamespace::Interactive, + namespace: RendererNamespace::Preview, identity: LoadIdentity::new(2, 2), - format: MeshFormat::Threemf, - primary_url: "/models/cube.3mf".into(), - expected_size: None, - expected_digest: Some(vec![1u8; 32]), + source: url_source(MeshFormat::Threemf, "/models/cube.3mf"), + output: LoadOutput::Preview { + request_id: 7, + width: 1024, + height: 1024, + }, }, - RendererCommand::StartCompoundLoad { + RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(3, 1), - plan: compound_plan(), + source: LoadSource::Bundle(model_bundle()), + output: LoadOutput::Interactive, }, - RendererCommand::StartCompoundLoad { + RendererCommand::StartLoad { namespace: RendererNamespace::Preview, identity: LoadIdentity::new(3, 2), - plan: compound_plan(), - }, - RendererCommand::StartCompoundPreview { - request_id: 8, - namespace: RendererNamespace::Preview, - identity: LoadIdentity::new(3, 3), - plan: compound_plan(), - width: 640, - height: 480, + source: LoadSource::Bundle(model_bundle()), + output: LoadOutput::Preview { + request_id: 8, + width: 640, + height: 480, + }, }, RendererCommand::Cancel { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(2, 2), }, - RendererCommand::RenderPreviewImage { - request_id: 7, - format: MeshFormat::Stl, - primary_url: "blob:http://localhost/preview".into(), - width: 1024, - height: 1024, - }, RendererCommand::Resize { css_w: 800.0, css_h: 600.0, @@ -959,37 +962,22 @@ mod tests { } #[test] - fn compound_preview_requires_compound_capability() { - let command = RendererCommand::StartCompoundPreview { - request_id: 91, + fn bundled_preview_requires_bundle_and_preview_capabilities() { + let command = RendererCommand::StartLoad { namespace: RendererNamespace::Preview, identity: LoadIdentity::new(7, 4), - plan: compound_plan(), - width: 320, - height: 240, + source: LoadSource::Bundle(model_bundle()), + output: LoadOutput::Preview { + request_id: 91, + width: 320, + height: 240, + }, }; assert_eq!( - required_capability(&command), - Some(Capability::CompoundLdraw) + required_capabilities(&command), + vec![Capability::CompoundLdraw, Capability::Preview] ); - let encoded = roundtrip_command(&command); - assert_eq!(encoded, command); - if let RendererCommand::StartCompoundPreview { - request_id, - namespace, - identity, - width, - height, - .. - } = encoded - { - assert_eq!(request_id, 91); - assert_eq!(namespace, RendererNamespace::Preview); - assert_eq!(identity, LoadIdentity::new(7, 4)); - assert_eq!((width, height), (320, 240)); - } else { - panic!("compound preview command changed during roundtrip"); - } + assert_eq!(roundtrip_command(&command), command); } #[test] @@ -1077,15 +1065,15 @@ mod tests { #[test] fn handshake_rejects_pre_negotiation_and_stale_sessions() { let hello = ProtocolHello { - min_version: 1, - max_version: 1, + min_version: PROTOCOL_VERSION, + max_version: PROTOCOL_VERSION, required: vec![Capability::CurrentFormats], optional: vec![Capability::Preview], }; let negotiated = negotiate(&hello, &hello, 44).unwrap(); let mut state = HandshakeState::Created; let command = CommandEnvelope { - version: 1, + version: PROTOCOL_VERSION, session_id: 44, payload: CommandPayload::Command(RendererCommand::Dispose), }; @@ -1163,32 +1151,7 @@ mod tests { payload: CommandPayload::Command(RendererCommand::Dispose), }; let bytes = serialize_command_envelope(&envelope).unwrap(); - assert_eq!(bytes, [1, 7, 2, 8]); - } - - #[test] - fn independent_golden_compound_preview_envelope_bytes_remain_stable() { - let envelope = CommandEnvelope { - version: PROTOCOL_VERSION, - session_id: 7, - payload: CommandPayload::Command(RendererCommand::StartCompoundPreview { - request_id: 8, - namespace: RendererNamespace::Preview, - identity: LoadIdentity::new(3, 3), - plan: CompoundLoadPlan { - version: PROTOCOL_VERSION, - primary: "p".into(), - descriptors: vec![], - }, - width: 640, - height: 480, - }), - }; - let bytes = serialize_command_envelope(&envelope).unwrap(); - assert_eq!( - bytes, - [1, 7, 2, 10, 8, 1, 3, 3, 1, 1, 112, 0, 128, 5, 224, 3] - ); + assert_eq!(bytes, [2, 7, 2, 6]); } #[test] @@ -1199,56 +1162,31 @@ mod tests { payload: EventPayload::Event(RendererEvent::ContextAcquired), }; let bytes = serialize_event_envelope(&envelope).unwrap(); - assert_eq!(bytes, [1, 7, 3, 0]); + assert_eq!(bytes, [2, 7, 3, 0]); } #[test] - fn descriptor_rejects_absolute_and_incomplete_routes() { - let mut descriptor = WorkerFetchDescriptor { - key: "models/main.ldr".into(), - route: "/app/route/opaque".into(), - path: "models/main.ldr".into(), - root: "models".into(), - byte_length: 1, - sha256: vec![0; 32], - mime_type: "text/plain".into(), - target_cids: vec!["cid".into()], - }; - assert!(descriptor.validate().is_ok()); - descriptor.route = "https://publisher.example/file".into(); - assert!(descriptor.validate().is_err()); + fn model_bundle_rejects_tampered_bytes_and_declared_size() { + let mut bundle = model_bundle(); + bundle.resources[0].bytes[0] ^= 0xff; + assert_eq!(bundle.validate(), Err(ModelBundleError::DigestMismatch)); + + let mut bundle = model_bundle(); + bundle.resources[0].byte_length += 1; + assert_eq!(bundle.validate(), Err(ModelBundleError::SizeMismatch)); } #[test] - fn compound_plan_bounds_cover_aggregate_bytes_and_metadata() { - let mut plan = compound_plan(); - plan.descriptors[0].byte_length = MAX_COMPOUND_TOTAL_BYTES; - assert_eq!(plan.validate(), Err(CompoundPlanError::TotalBytesExceeded)); - - let mut plan = compound_plan(); - plan.descriptors[0].target_cids = vec!["cid".repeat(MAX_COMPOUND_METADATA_BYTES)]; - assert_eq!(plan.validate(), Err(CompoundPlanError::MetadataExceeded)); - } + fn model_bundle_roundtrips_and_rejects_duplicate_keys() { + let bundle = model_bundle(); + assert_eq!( + ModelLoadBundle::decode(&bundle.encode().unwrap()), + Ok(bundle.clone()) + ); - #[test] - fn compound_plan_rejects_oversized_descriptor_lists_and_duplicate_keys() { - let descriptor = compound_descriptor("models/main.ldr", "/app/ldraw/a", 1); - let mut plan = CompoundLoadPlan { - version: PROTOCOL_VERSION, - primary: "models/main.ldr".into(), - descriptors: vec![descriptor.clone(), descriptor], - }; - assert_eq!(plan.validate(), Err(CompoundPlanError::DuplicateKey)); - plan.descriptors = (0..=MAX_COMPOUND_DESCRIPTORS) - .map(|index| { - compound_descriptor( - &format!("models/{index}.dat"), - &format!("/app/ldraw/{index}"), - 1, - ) - }) - .collect(); - assert_eq!(plan.validate(), Err(CompoundPlanError::TooManyDescriptors)); + let mut duplicate = bundle; + duplicate.resources.push(duplicate.resources[0].clone()); + assert_eq!(duplicate.validate(), Err(ModelBundleError::DuplicateKey)); } #[test] diff --git a/crates/polymodel-renderer-worker/src/worker.rs b/crates/polymodel-renderer-worker/src/worker.rs index d25eef7..80c42f4 100644 --- a/crates/polymodel-renderer-worker/src/worker.rs +++ b/crates/polymodel-renderer-worker/src/worker.rs @@ -94,7 +94,7 @@ struct PreviewTarget { struct VerifiedCompoundSource { primary: String, - descriptors: Vec<(polymodel_renderer_protocol::WorkerFetchDescriptor, Vec)>, + resources: Vec, } fn descriptor_root(root: &str) -> Result { @@ -107,31 +107,31 @@ fn descriptor_root(root: &str) -> Result { } } -fn validate_compound_descriptors( +fn validate_bundle_resources( primary: &str, - descriptors: &[polymodel_renderer_protocol::WorkerFetchDescriptor], + resources: &[polymodel_renderer_protocol::ModelResource], ) -> Result<(), String> { - let mut normalized = HashSet::with_capacity(descriptors.len()); + let mut normalized = HashSet::with_capacity(resources.len()); let mut primary_found = false; - for descriptor in descriptors { - descriptor + for resource in resources { + resource .validate() - .map_err(|error| format!("compound descriptor rejected: {error}"))?; - let path = NormalizedPath::new(&descriptor.path).map_err(|error| error.to_string())?; - let root = descriptor_root(&descriptor.root)?; + .map_err(|error| format!("model resource rejected: {error:?}"))?; + let path = NormalizedPath::new(&resource.path).map_err(|error| error.to_string())?; + let root = descriptor_root(&resource.root)?; let identity = format!("{root:?}:{}", path.as_str()); if !normalized.insert(identity) { return Err(format!( - "duplicate normalized descriptor identity: {}", - descriptor.path + "duplicate normalized resource identity: {}", + resource.path )); } - if descriptor.path == primary { + if resource.path == primary { primary_found = true; } } if !primary_found { - return Err("compound primary descriptor missing".to_owned()); + return Err("model bundle primary resource missing".to_owned()); } Ok(()) } @@ -148,25 +148,25 @@ fn parse_compound_source( String, > { let primary = source - .descriptors + .resources .iter() - .find(|(descriptor, _)| descriptor.path == source.primary) - .ok_or_else(|| "compound primary descriptor missing".to_string())?; - let primary_root = descriptor_root(&primary.0.root)?; + .find(|resource| resource.path == source.primary) + .ok_or_else(|| "model bundle primary resource missing".to_string())?; + let primary_root = descriptor_root(&primary.root)?; let mut materializations = Vec::new(); - for (descriptor, bytes) in &source.descriptors { - let path = NormalizedPath::new(&descriptor.path).map_err(|error| error.to_string())?; + for resource in &source.resources { + let path = NormalizedPath::new(&resource.path).map_err(|error| error.to_string())?; materializations.push(Materialization::new( path, - descriptor_root(&descriptor.root)?, - bytes.clone(), + descriptor_root(&resource.root)?, + resource.bytes.clone(), true, - Some(format!("worker:{}", descriptor.key)), + Some(format!("worker:{}", resource.key)), )); } let result = InProcessRustAdapter::parse(AdapterRequest { fixture_id: &source.primary, - bytes: &primary.1, + bytes: &primary.bytes, profile: ParserProfile::Compatibility, provenance_id: &source.primary, owner, @@ -249,7 +249,6 @@ struct WorkerState { canvas_reservation: Option, load_epoch: u32, scene_generation: u32, - preview_generation: u32, interactive_compound_abort: Option, preview_abort: Option, pending_events: Vec, @@ -278,7 +277,6 @@ impl WorkerState { canvas_reservation: None, load_epoch: 0, scene_generation: 0, - preview_generation: 0, interactive_compound_abort: None, preview_abort: None, pending_events: Vec::new(), @@ -1599,11 +1597,50 @@ async fn load_mesh_async( } // --------------------------------------------------------------------------- +fn start_compound_interactive( + state: &Rc>, + namespace: RendererNamespace, + identity: LoadIdentity, + bundle: polymodel_renderer_protocol::ModelLoadBundle, +) { + let mut st = state.borrow_mut(); + if st.disposed || namespace != RendererNamespace::Interactive { + return; + } + let previous_identity = st.load_reducer.current_identity(); + let result = st.load_reducer.reduce(LoadEvent::StartLoad { + namespace, + identity, + }); + for _token in result.disposed { + st.pending_scene = None; + } + if !matches!( + result.outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) { + return; + } + st.pending_scene = None; + st.needs_render = true; + if let Some(previous_identity) = previous_identity + && identity > previous_identity + { + abort_compound_slot(&mut st, namespace, previous_identity); + st.post_event(&RendererEvent::LoadCancelled { + namespace, + identity: previous_identity, + }); + } + drop(st); + handle_compound_load(state, namespace, identity, bundle); +} + fn handle_compound_load( state: &Rc>, namespace: RendererNamespace, identity: LoadIdentity, - plan: polymodel_renderer_protocol::CompoundLoadPlan, + bundle: polymodel_renderer_protocol::ModelLoadBundle, ) { let abort = AbortController::new().ok(); if let Some(abort) = &abort { @@ -1622,7 +1659,7 @@ fn handle_compound_load( } let state_clone = state.clone(); spawn_local(async move { - let result = load_compound_source(&state_clone, namespace, identity, plan, abort).await; + let result = load_compound_source(&state_clone, namespace, identity, bundle, abort).await; finish_compound_source(&state_clone, namespace, identity, result); }); } @@ -1632,7 +1669,7 @@ fn handle_compound_preview( request_id: u32, namespace: RendererNamespace, identity: LoadIdentity, - plan: polymodel_renderer_protocol::CompoundLoadPlan, + bundle: polymodel_renderer_protocol::ModelLoadBundle, width: u32, height: u32, ) { @@ -1686,7 +1723,7 @@ fn handle_compound_preview( let result = render_compound_preview_request( &state_clone, identity, - plan, + bundle, width.max(1), height.max(1), abort.clone(), @@ -1759,7 +1796,7 @@ fn finish_compound_preview( async fn render_compound_preview_request( state: &Rc>, identity: LoadIdentity, - plan: polymodel_renderer_protocol::CompoundLoadPlan, + bundle: polymodel_renderer_protocol::ModelLoadBundle, width: u32, height: u32, abort: Option, @@ -1768,7 +1805,7 @@ async fn render_compound_preview_request( state, RendererNamespace::Preview, identity, - plan, + bundle, abort.clone(), ) .await?; @@ -1854,58 +1891,45 @@ async fn load_compound_source( state: &Rc>, namespace: RendererNamespace, identity: LoadIdentity, - plan: polymodel_renderer_protocol::CompoundLoadPlan, + bundle: polymodel_renderer_protocol::ModelLoadBundle, abort: Option, ) -> Result { - if plan.version != polymodel_renderer_protocol::PROTOCOL_VERSION { - return Err("compound plan protocol version mismatch".into()); - } - plan.validate() - .map_err(|error| format!("compound plan rejected: {error:?}"))?; - plan.encoded_size() - .map_err(|error| format!("compound plan encoding rejected: {error:?}"))?; - validate_compound_descriptors(&plan.primary, &plan.descriptors)?; + if bundle.version != polymodel_renderer_protocol::PROTOCOL_VERSION { + return Err("model bundle protocol version mismatch".into()); + } + if bundle.format != MeshFormat::CompoundLdraw { + return Err("model bundle format is not LDraw".into()); + } + bundle + .validate() + .map_err(|error| format!("model bundle rejected: {error:?}"))?; + validate_bundle_resources(&bundle.primary, &bundle.resources)?; compound_is_current(state, namespace, identity, abort.as_ref())?; - let descriptors = plan.descriptors; - let owner = load_owner(namespace, identity); - let total_bytes = descriptors.iter().try_fold(0_u64, |total, descriptor| { + let total_bytes = bundle.resources.iter().try_fold(0_u64, |total, resource| { total - .checked_add(descriptor.byte_length) - .ok_or("compound plan byte bound overflow") + .checked_add(resource.byte_length) + .ok_or("model bundle byte bound overflow") })?; if total_bytes > MAX_UNKNOWN_FETCH_BYTES { - return Err("compound plan exceeds fetch bound".into()); + return Err("model bundle exceeds decoded source bound".into()); } let reservation = { let st = state.borrow(); st.ledger .composite( - owner, + load_owner(namespace, identity), &[ - (ResourceClass::NetworkInFlight, total_bytes), (ResourceClass::RetainedFetchedBytes, total_bytes), (ResourceClass::DecodedSourceData, total_bytes), (ResourceClass::WasmPages, WASM_FIXED_BYTES), ], ) - .map_err(|error| format!("compound load admission: {error}"))? + .map_err(|error| format!("model bundle admission: {error}"))? }; - let mut fetched = Vec::with_capacity(descriptors.len()); - for descriptor in descriptors { - compound_is_current(state, namespace, identity, abort.as_ref())?; - let bytes = fetch_descriptor_bytes( - &descriptor.route, - descriptor.byte_length, - &descriptor.sha256, - abort.as_ref(), - ) - .await?; - fetched.push((descriptor, bytes)); - } compound_is_current(state, namespace, identity, abort.as_ref())?; let source = VerifiedCompoundSource { - primary: plan.primary, - descriptors: fetched, + primary: bundle.primary, + resources: bundle.resources, }; drop(reservation); Ok(source) @@ -2155,41 +2179,17 @@ fn finish_compound_source( } } -async fn fetch_descriptor_bytes( - route: &str, - expected_length: u64, - expected_sha256: &[u8], - abort: Option<&AbortController>, -) -> Result, String> { - let bytes = fetch_bytes(route, abort, Some(expected_length)).await?; - let expected_size = i64::try_from(expected_length) - .map_err(|_| "compound descriptor byte length is too large".to_string())?; - polymodel_renderer_protocol::verify_part_file(&bytes, expected_size, Some(expected_sha256)) - .map_err(|error| format!("compound descriptor integrity check failed: {error}"))?; - Ok(bytes) -} - fn handle_render_preview_image( state: &Rc>, request_id: u32, + identity: LoadIdentity, format: MeshFormat, primary_url: String, width: u32, height: u32, ) { - let identity = { + let abort = { let mut st = state.borrow_mut(); - st.preview_generation = match next_generation(st.preview_generation) { - Ok(generation) => generation, - Err(message) => { - st.post_event(&RendererEvent::PreviewImageError { - request_id, - message: message.to_owned(), - }); - return; - } - }; - let identity = LoadIdentity::new(st.load_epoch, st.preview_generation); let previous = st.preview_reducer.current_identity(); let transition = st.preview_reducer.reduce(LoadEvent::StartLoad { namespace: RendererNamespace::Preview, @@ -2218,9 +2218,8 @@ fn handle_render_preview_image( identity: previous, }); } - (identity, abort) + abort }; - let (identity, abort) = identity; let state_clone = state.clone(); spawn_local(async move { let result = render_preview_request( @@ -2585,10 +2584,9 @@ fn handle_command(state: &Rc>, cmd: RendererCommand) { tracing::warn!("rejecting scene command before negotiated session"); return; }; - let capability_allowed = polymodel_renderer_protocol::required_capability(&cmd) - .is_none_or(|capability| capabilities.contains(&capability)) - && (!matches!(cmd, RendererCommand::StartCompoundPreview { .. }) - || capabilities.contains(&Capability::Preview)); + let capability_allowed = polymodel_renderer_protocol::required_capabilities(&cmd) + .iter() + .all(|capability| capabilities.contains(capability)); if !capability_allowed { tracing::warn!("renderer command rejected by negotiated capabilities"); return; @@ -2598,92 +2596,63 @@ fn handle_command(state: &Rc>, cmd: RendererCommand) { RendererCommand::StartLoad { namespace, identity, - format, - primary_url, - expected_size, - expected_digest, + source, + output, } => { drop(st); - handle_load_mesh( - state, - namespace, - identity, - format, - primary_url, - expected_size, - expected_digest, - ); - } - RendererCommand::StartCompoundPreview { - request_id, - namespace, - identity, - plan, - width, - height, - } => { - drop(st); - handle_compound_preview(state, request_id, namespace, identity, plan, width, height); - } - RendererCommand::StartCompoundLoad { - namespace, - identity, - plan, - } => { - if st.disposed { - return; - } - if namespace == RendererNamespace::Preview { - st.post_event(&RendererEvent::LoadFailed { + match (source, output) { + ( + polymodel_renderer_protocol::LoadSource::Url { + format, + primary_url, + expected_size, + expected_digest, + }, + polymodel_renderer_protocol::LoadOutput::Interactive, + ) => handle_load_mesh( + state, namespace, identity, - code: LoadErrorCode::Install, - detail: "compound Preview requires StartCompoundPreview".to_owned(), - }); - return; - } - let reducer = match namespace { - RendererNamespace::Interactive => &mut st.load_reducer, - RendererNamespace::Preview => &mut st.preview_reducer, - }; - let previous_identity = reducer.current_identity(); - let result = reducer.reduce(LoadEvent::StartLoad { - namespace, - identity, - }); - if namespace == RendererNamespace::Interactive { - for _token in result.disposed { - st.pending_scene = None; - st.renderer.as_mut().map(|renderer| renderer.model = None); - } - if !matches!( - result.outcome, - polymodel_renderer_protocol::TransitionOutcome::Committed - ) { - return; - } - if let Some(renderer) = st.renderer.as_mut() { - renderer.model = None; - } - st.pending_scene = None; - st.needs_render = true; - } else if !matches!( - result.outcome, - polymodel_renderer_protocol::TransitionOutcome::Committed - ) { - return; - } - if let Some(previous_identity) = previous_identity - && identity > previous_identity - { - abort_compound_slot(&mut st, namespace, previous_identity); - st.post_event(&RendererEvent::LoadCancelled { - namespace, - identity: previous_identity, - }); + format, + primary_url, + expected_size, + expected_digest, + ), + ( + polymodel_renderer_protocol::LoadSource::Url { + format, + primary_url, + .. + }, + polymodel_renderer_protocol::LoadOutput::Preview { + request_id, + width, + height, + }, + ) => handle_render_preview_image( + state, + request_id, + identity, + format, + primary_url, + width, + height, + ), + ( + polymodel_renderer_protocol::LoadSource::Bundle(bundle), + polymodel_renderer_protocol::LoadOutput::Interactive, + ) => start_compound_interactive(state, namespace, identity, bundle), + ( + polymodel_renderer_protocol::LoadSource::Bundle(bundle), + polymodel_renderer_protocol::LoadOutput::Preview { + request_id, + width, + height, + }, + ) => handle_compound_preview( + state, request_id, namespace, identity, bundle, width, height, + ), } - drop(st); - handle_compound_load(state, namespace, identity, plan); } RendererCommand::Cancel { namespace, @@ -2715,28 +2684,12 @@ fn handle_command(state: &Rc>, cmd: RendererCommand) { result.outcome, polymodel_renderer_protocol::TransitionOutcome::Committed ) { - if namespace == RendererNamespace::Interactive { - if let Some(renderer) = st.renderer.as_mut() { - renderer.model = None; - } - st.needs_render = true; - } st.post_event(&RendererEvent::LoadCancelled { namespace, identity, }); } } - RendererCommand::RenderPreviewImage { - request_id, - format, - primary_url, - width, - height, - } => { - drop(st); - handle_render_preview_image(state, request_id, format, primary_url, width, height); - } RendererCommand::Resize { css_w, css_h, dpr } => { if let Some(slot) = st.preview_abort.take() { slot.controller.abort(); diff --git a/e2e/tests/viewer.spec.ts b/e2e/tests/viewer.spec.ts index 273cd96..4ac2002 100644 --- a/e2e/tests/viewer.spec.ts +++ b/e2e/tests/viewer.spec.ts @@ -97,41 +97,39 @@ test('direct viewer routes select deterministic STL and 3MF assets', async ({ pa await expect(page.getByRole('button', { name: 'Body F forearm 2x v9' })).toBeVisible(); }); -test('logged-out Thing detail acquires the compound MPD through the public plan and resource routes', async ({ page }) => { - const resourceResponses = []; - page.on('response', (response) => { - if (response.url().includes('/app/ldraw/resources/')) resourceResponses.push(response); +test('logged-out Thing detail acquires the complete model bundle in one public XRPC request', async ({ page }) => { + const bundleRequests = []; + const resourceRequests = []; + page.on('request', (request) => { + const url = new URL(request.url()); + if ( + url.pathname === '/xrpc/space.polymodel.library.getModelLoadBundle' + && request.method() === 'GET' + ) { + bundleRequests.push(request); + } + if (url.pathname.startsWith('/app/ldraw/resources/')) resourceRequests.push(request); }); - const planResponsePromise = page.waitForResponse((response) => - response.url().endsWith('/app/ldraw/compound-resources') && response.request().method() === 'POST', + const bundleResponsePromise = page.waitForResponse((response) => + response.url().includes('/xrpc/space.polymodel.library.getModelLoadBundle') + && response.request().method() === 'GET', ); await page.goto(sampleThingPath); - const planResponse = await planResponsePromise; - expect(planResponse.status()).toBe(200); - const plan = await planResponse.json(); - expect(plan.primary).toBe('models/main.ldr'); - expect(plan.descriptors.map((descriptor) => descriptor.key)).toEqual([ - 'models/main.ldr', - 'models/child.dat', - 'models/companion.dat', - ]); - expect(planResponse.request().postDataJSON()).toEqual({ - project: 'at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/space.polymodel.library.thing/parametric-enclosure', - path: 'models/main.ldr', - }); - - await expect.poll(() => resourceResponses.length).toBe(3); - for (const response of resourceResponses) expect(response.status()).toBe(200); - for (const descriptor of plan.descriptors) { - const first = await page.request.get(new URL(descriptor.route, page.url()).toString()); - const second = await page.request.get(new URL(descriptor.route, page.url()).toString()); - expect(first.status()).toBe(200); - expect(second.status()).toBe(200); - expect(await first.body()).toEqual(await second.body()); - } + const bundleResponse = await bundleResponsePromise; + expect(bundleResponse.status()).toBe(200); + expect(bundleResponse.headers()['content-type']).toContain('application/vnd.polymodel.load-bundle'); + expect((await bundleResponse.body()).byteLength).toBeGreaterThan(0); + expect(new URL(bundleResponse.url()).searchParams.get('uri')).toBe( + 'at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/space.polymodel.library.part/part-01', + ); await expect(page.locator(boundary)).toHaveAttribute('data-compound-boundary', 'true'); + expect(bundleRequests).toHaveLength(1); + expect(new URL(bundleRequests[0].url()).searchParams.get('uri')).toBe( + 'at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/space.polymodel.library.part/part-01', + ); + expect(resourceRequests).toHaveLength(0); }); test('model switching keeps one WebGL context and updates renderer session metadata', async ({ page }) => { diff --git a/lexicons/library/getModelLoadBundle.json b/lexicons/library/getModelLoadBundle.json new file mode 100644 index 0000000..19c3096 --- /dev/null +++ b/lexicons/library/getModelLoadBundle.json @@ -0,0 +1,20 @@ +{ + "lexicon": 1, + "id": "space.polymodel.library.getModelLoadBundle", + "defs": { + "main": { + "type": "query", + "description": "Resolve a model Part URI into one bounded binary load bundle containing the primary resource and every indexed companion resource required by the format.", + "parameters": { + "type": "params", + "required": ["uri"], + "properties": { + "uri": { "type": "string", "format": "at-uri" } + } + }, + "output": { + "encoding": "*/*" + } + } + } +} diff --git a/src/appview/download_tests.rs b/src/appview/download_tests.rs index cf5764e..87d6350 100644 --- a/src/appview/download_tests.rs +++ b/src/appview/download_tests.rs @@ -240,7 +240,7 @@ async fn get_part_file_later_blob_failure_truncates_committed_stream() { } #[tokio::test] -async fn anonymous_compound_surface_is_present_without_legacy_routes_or_route_rows() { +async fn anonymous_model_bundle_surface_is_present_without_legacy_routes_or_route_rows() { let state = state().await; let pool = state.pool.clone(); let app = crate::appview::router().with_state(state); @@ -250,10 +250,10 @@ async fn anonymous_compound_surface_is_present_without_legacy_routes_or_route_ro .oneshot( Request::builder() .method(Method::POST) - .uri("/app/ldraw/compound-resources") + .uri("/app/model-load-bundle") .header("content-type", "application/json") .body(Body::from( - r#"{"project":"at://did:plc:x/space.polymodel.library.thing/main","path":"models/main.ldr"}"#, + r#"{"uri":"at://did:plc:x/space.polymodel.library.part/main"}"#, )) .unwrap(), ) diff --git a/src/appview/ldraw.rs b/src/appview/ldraw.rs index 43b9fc4..23c472d 100644 --- a/src/appview/ldraw.rs +++ b/src/appview/ldraw.rs @@ -6,15 +6,19 @@ use base64::Engine as _; use futures::TryStreamExt; use futures::stream; use jacquard::identity::resolver::IdentityResolver; +use jacquard_axum::ExtractXrpc; use jacquard_common::types::string::{AtUri, Cid, Did}; use jacquard_common::xrpc::XrpcExt; use polymodel_api::com_atproto::sync::get_blob::GetBlob; +use polymodel_api::space_polymodel::library::get_model_load_bundle::GetModelLoadBundleRequest; use polymodel_ldraw_core::extract_references; use polymodel_renderer_protocol::{ - CompoundLoadPlan, MAX_COMPOUND_DESCRIPTORS, MAX_COMPOUND_METADATA_BYTES, - MAX_COMPOUND_TOTAL_BYTES, PROTOCOL_VERSION, WorkerFetchDescriptor, + MAX_MODEL_METADATA_BYTES, MAX_MODEL_RESOURCE_BYTES, MAX_MODEL_RESOURCES, MeshFormat, + ModelLoadBundle, ModelResource, PROTOCOL_VERSION, }; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; +#[cfg(test)] +use serde::Serialize; use sha2::{Digest, Sha256}; use std::collections::{HashSet, VecDeque}; @@ -25,7 +29,7 @@ use crate::ldraw::{CanonicalPath, LdrawResourceResolver, RootId}; const ROUTE_PREFIX: &str = "/app/ldraw/resources/"; const ROUTE_VERSION: u8 = 1; -const MAX_MANIFEST_EDGES: usize = MAX_COMPOUND_DESCRIPTORS * 8; +const MAX_MANIFEST_EDGES: usize = MAX_MODEL_RESOURCES * 8; #[derive(Debug)] struct ManifestEntry { @@ -39,13 +43,12 @@ struct ManifestEntry { } #[derive(Debug, Deserialize)] -pub(super) struct CompoundRequest { - pub project: AtUri, - pub path: String, +pub(super) struct ModelBundleRequest { + pub uri: AtUri, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct RouteBinding { +pub(super) struct RouteBinding { version: u8, project: AtUri, publisher: Did, @@ -57,6 +60,7 @@ struct RouteBinding { sha256: Vec, } +#[cfg(test)] #[derive(Debug, Serialize)] struct RoutePayload<'a> { v: u8, @@ -83,12 +87,30 @@ struct RoutePayloadOwned { sha256: Vec, } -pub(super) async fn compound_resources( +pub(super) async fn get_model_load_bundle( State(state): State, - axum::Json(request): axum::Json, -) -> AppResult> { - let path = CanonicalPath::parse(&request.path) - .map_err(|diagnostic| invalid_request(format!("invalid LDraw path: {diagnostic:?}")))?; + ExtractXrpc(request): ExtractXrpc, +) -> AppResult { + build_model_load_bundle(state, request.uri).await +} + +pub(super) async fn app_model_load_bundle( + State(state): State, + axum::Json(request): axum::Json, +) -> AppResult { + build_model_load_bundle(state, request.uri).await +} + +async fn build_model_load_bundle(state: AppState, uri: AtUri) -> AppResult { + let row = db(sqlx::query!( + "SELECT project_uri, canonical_path FROM ldraw_resources WHERE resource_uri = ?", + uri.as_ref(), + ) + .fetch_optional(&state.pool) + .await)? + .ok_or_else(not_found)?; + let project = AtUri::new_owned(row.project_uri).map_err(|_| not_found())?; + let path = CanonicalPath::parse(&row.canonical_path).map_err(|_| not_found())?; let resolver = SqliteResolver::new(state.pool.clone()); let mut resources = Vec::new(); let mut seen_bindings = HashSet::new(); @@ -98,20 +120,20 @@ pub(super) async fn compound_resources( while let Some(current_path) = queue.pop_front() { let current = resolver - .resolve(&request.project, ¤t_path) + .resolve(&project, ¤t_path) .await .map_err(resolve_error)?; - let current_binding = binding_for(&request.project, ¤t)?; + let current_binding = binding_for(&project, ¤t)?; if !seen_bindings.insert(current_binding.clone()) { continue; } - if seen_bindings.len() > MAX_COMPOUND_DESCRIPTORS { + if seen_bindings.len() > MAX_MODEL_RESOURCES { return Err(invalid_request( "compound resource descriptor bound exceeded", )); } let projected_length = current_binding.byte_length; - let remaining_budget = MAX_COMPOUND_TOTAL_BYTES + let remaining_budget = MAX_MODEL_RESOURCE_BYTES .checked_sub(total_bytes) .ok_or_else(|| invalid_request("compound byte bound overflow"))?; if projected_length > remaining_budget { @@ -158,7 +180,7 @@ pub(super) async fn compound_resources( } let child_path = core_reference_path(¤t.path, reference).ok_or_else(not_found)?; let child = resolver - .resolve(&request.project, &child_path) + .resolve(&project, &child_path) .await .map_err(resolve_error)?; validate_manifest_child(¤t, &child, &manifest)?; @@ -177,73 +199,79 @@ pub(super) async fn compound_resources( return Err(invalid_request("compound manifest edge bound exceeded")); } let child = resolver - .resolve(&request.project, &entry.path) + .resolve(&project, &entry.path) .await .map_err(resolve_error)?; push_first_seen(&mut children, &mut referenced_paths, child.path); } } - resources.push(current); + resources.push((current, bytes)); for child_path in children { queue.push_back(child_path); } } - let mut descriptors = Vec::with_capacity(resources.len()); + let mut bundled = Vec::with_capacity(resources.len()); let mut seen_keys = HashSet::new(); let mut metadata_bytes = path.as_str().len(); - for resource in resources { - let binding = binding_for(&request.project, &resource)?; - let route = encode_binding(&binding)?; + for (resource, bytes) in resources { + let binding = binding_for(&project, &resource)?; let key = resource.path.to_string(); if !seen_keys.insert(key.clone()) { continue; } - let descriptor = WorkerFetchDescriptor { + let bundled_resource = ModelResource { key: key.clone(), - route, path: key, root: resource.root.as_str().to_owned(), byte_length: binding.byte_length, sha256: binding.sha256, mime_type: resource.mime_type, target_cids: binding.target_cids, + bytes, }; - descriptor + bundled_resource .validate() - .map_err(|error| invalid_request(format!("invalid compound descriptor: {error}")))?; - let descriptor_metadata = descriptor + .map_err(|error| invalid_request(format!("invalid model resource: {error:?}")))?; + let resource_metadata = bundled_resource .key .len() - .checked_add(descriptor.route.len()) - .and_then(|size| size.checked_add(descriptor.path.len())) - .and_then(|size| size.checked_add(descriptor.root.len())) - .and_then(|size| size.checked_add(descriptor.mime_type.len())) + .checked_add(bundled_resource.path.len()) + .and_then(|size| size.checked_add(bundled_resource.root.len())) + .and_then(|size| size.checked_add(bundled_resource.mime_type.len())) .and_then(|size| { - descriptor + bundled_resource .target_cids .iter() .try_fold(size, |total, cid| total.checked_add(cid.len())) }) - .ok_or_else(|| invalid_request("compound metadata bound overflow"))?; + .ok_or_else(|| invalid_request("model bundle metadata bound overflow"))?; metadata_bytes = metadata_bytes - .checked_add(descriptor_metadata) - .ok_or_else(|| invalid_request("compound metadata bound overflow"))?; - descriptors.push(descriptor); + .checked_add(resource_metadata) + .ok_or_else(|| invalid_request("model bundle metadata bound overflow"))?; + bundled.push(bundled_resource); } - if metadata_bytes > MAX_COMPOUND_METADATA_BYTES { - return Err(invalid_request("compound plan bounds exceeded")); + if metadata_bytes > MAX_MODEL_METADATA_BYTES { + return Err(invalid_request("model bundle metadata bound exceeded")); } - let plan = CompoundLoadPlan { + let bundle = ModelLoadBundle { version: PROTOCOL_VERSION, + format: MeshFormat::CompoundLdraw, primary: path.to_string(), - descriptors, + resources: bundled, }; - plan.validate() - .map_err(|error| invalid_request(format!("invalid compound plan: {error:?}")))?; - plan.encoded_size() - .map_err(|error| invalid_request(format!("compound plan encoding failed: {error:?}")))?; - Ok(axum::Json(plan)) + let encoded = bundle + .encode() + .map_err(|error| invalid_request(format!("model bundle encoding failed: {error:?}")))?; + Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + "application/vnd.polymodel.load-bundle", + ) + .header(header::CONTENT_LENGTH, encoded.len()) + .body(Body::from(encoded)) + .map_err(|error| internal(format!("model bundle response build failed: {error}"))) } pub(super) async fn fetch_resource( @@ -270,7 +298,7 @@ pub(super) async fn fetch_resource( .map_err(|error| internal(format!("LDraw response build failed: {error}"))) } -fn binding_for(project: &AtUri, resource: &ResolvedResource) -> AppResult { +pub(super) fn binding_for(project: &AtUri, resource: &ResolvedResource) -> AppResult { let publisher = resource.source.resource_uri.authority().convert(); let publisher = match publisher { jacquard_common::types::string::AtIdentifier::Did(did) => did, @@ -296,7 +324,8 @@ fn binding_for(project: &AtUri, resource: &ResolvedResource) -> AppResult AppResult { +#[cfg(test)] +pub(super) fn encode_binding(binding: &RouteBinding) -> AppResult { let payload = RoutePayload { v: binding.version, project: &binding.project, @@ -499,7 +528,7 @@ async fn fetch_verified_bytes( .to_owned(); let expected_length = u64::try_from(resource.byte_length) .map_err(|_| invalid_request("negative LDraw resource length"))?; - if expected_length != max_bytes || expected_length > MAX_COMPOUND_TOTAL_BYTES { + if expected_length != max_bytes || expected_length > MAX_MODEL_RESOURCE_BYTES { return Err(invalid_request("LDraw byte admission failed")); } let capacity = usize::try_from(expected_length) diff --git a/src/appview/mod.rs b/src/appview/mod.rs index 623fa36..800c422 100644 --- a/src/appview/mod.rs +++ b/src/appview/mod.rs @@ -48,7 +48,8 @@ use polymodel_api::space_polymodel::{ }, library::{ delete_thing::DeleteThingRequest, get_author_things::GetAuthorThingsRequest, - get_feed::GetFeedRequest, get_model::GetModelRequest, get_part_file::GetPartFileRequest, + get_feed::GetFeedRequest, get_model::GetModelRequest, + get_model_load_bundle::GetModelLoadBundleRequest, get_part_file::GetPartFileRequest, get_thing::GetThingRequest, publish_thing::PublishThingRequest, search_things::SearchThingsRequest, stage_file::StageFileRequest, update_thing::UpdateThingRequest, @@ -124,9 +125,12 @@ pub fn router() -> Router { axum::routing::get(actor::get_session), ) .merge(GetPartFileRequest::into_router(downloads::get_part_file)) + .merge(GetModelLoadBundleRequest::into_router( + ldraw::get_model_load_bundle, + )) .route( - "/app/ldraw/compound-resources", - axum::routing::post(ldraw::compound_resources), + "/app/model-load-bundle", + axum::routing::post(ldraw::app_model_load_bundle), ) .route( "/app/ldraw/resources/{binding}", diff --git a/src/appview/read_tests.rs b/src/appview/read_tests.rs index 9e758c1..19a693e 100644 --- a/src/appview/read_tests.rs +++ b/src/appview/read_tests.rs @@ -603,37 +603,35 @@ async fn ordinary_projection_feeds_compound_resolver_and_route() { .clone() .oneshot( Request::builder() - .method(Method::POST) - .uri("/app/ldraw/compound-resources") - .header("content-type", "application/json") - .body(Body::from( - json!({ "project": project, "path": "models/main.ldr" }).to_string(), + .method(Method::GET) + .uri(format!( + "/xrpc/space.polymodel.library.getModelLoadBundle?uri={}", + urlencoding::encode(&part) )) + .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); - let plan: serde_json::Value = serde_json::from_slice( - &axum::body::to_bytes(response.into_body(), 1 << 20) - .await - .unwrap(), + let bundle_bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .unwrap(); + let bundle = polymodel_renderer_protocol::ModelLoadBundle::decode(&bundle_bytes).unwrap(); + assert_eq!(bundle.primary, "models/main.ldr"); + assert_eq!(bundle.resources.len(), 1); + let bundled = &bundle.resources[0]; + assert_eq!(bundled.key, "models/main.ldr"); + assert_eq!(bundled.target_cids[0], resource.target.blob_cids[0]); + assert_eq!(bundled.bytes, bytes); + + let route = crate::appview::ldraw::encode_binding( + &crate::appview::ldraw::binding_for(&project_uri, &resource).unwrap(), ) .unwrap(); - assert_eq!(plan["primary"], "models/main.ldr"); - assert_eq!(plan["descriptors"].as_array().unwrap().len(), 1); - let descriptor = &plan["descriptors"][0]; - assert_eq!(descriptor["key"], "models/main.ldr"); - assert_eq!(descriptor["target_cids"][0], resource.target.blob_cids[0]); - let fetched = app .clone() - .oneshot( - Request::builder() - .uri(descriptor["route"].as_str().unwrap()) - .body(Body::empty()) - .unwrap(), - ) + .oneshot(Request::builder().uri(&route).body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(fetched.status(), StatusCode::OK); @@ -648,7 +646,6 @@ async fn ordinary_projection_feeds_compound_resolver_and_route() { bytes.as_slice() ); - let route = descriptor["route"].as_str().unwrap().to_owned(); let route_variant = |mutate: &dyn Fn(&mut serde_json::Value)| { let encoded = route.strip_prefix("/app/ldraw/resources/").unwrap(); let (payload, _) = encoded.rsplit_once('.').unwrap(); @@ -837,16 +834,14 @@ async fn anonymous_compound_route_traverses_mpd_cycles_and_manifest_only_compani let request = || { Request::builder() .method(Method::POST) - .uri("/app/ldraw/compound-resources") + .uri("/app/model-load-bundle") .header("content-type", "application/json") - .body(Body::from( - json!({"project": project, "path": "models/root.ldr"}).to_string(), - )) + .body(Body::from(json!({"uri": root_uri}).to_string())) .unwrap() }; let first = app.clone().oneshot(request()).await.unwrap(); assert_eq!(first.status(), StatusCode::OK); - let first: serde_json::Value = serde_json::from_slice( + let first = polymodel_renderer_protocol::ModelLoadBundle::decode( &axum::body::to_bytes(first.into_body(), 1 << 20) .await .unwrap(), @@ -854,18 +849,17 @@ async fn anonymous_compound_route_traverses_mpd_cycles_and_manifest_only_compani .unwrap(); let second = app.oneshot(request()).await.unwrap(); assert_eq!(second.status(), StatusCode::OK); - let second: serde_json::Value = serde_json::from_slice( + let second = polymodel_renderer_protocol::ModelLoadBundle::decode( &axum::body::to_bytes(second.into_body(), 1 << 20) .await .unwrap(), ) .unwrap(); assert_eq!(first, second); - let keys = first["descriptors"] - .as_array() - .unwrap() + let keys = first + .resources .iter() - .map(|descriptor| descriptor["key"].as_str().unwrap()) + .map(|resource| resource.key.as_str()) .collect::>(); assert_eq!( keys, diff --git a/src/client.rs b/src/client.rs index 1525065..37e8a20 100644 --- a/src/client.rs +++ b/src/client.rs @@ -335,6 +335,27 @@ impl PolymodelClient { Self::decode(&response) } + /// Send an app request and return the successful response body unchanged. + #[cfg(all(target_family = "wasm", target_os = "unknown"))] + pub async fn app_bytes( + &self, + method: http::Method, + path: &str, + body: Option<&B>, + ) -> Result, RawError> + where + B: serde::Serialize, + { + let (content_type, bytes) = match body { + Some(value) => ( + Some("application/json"), + serde_json::to_vec(value).map_err(|e| RawError::Build(e.to_string()))?, + ), + None => (None, Vec::new()), + }; + self.send_raw(method, path, content_type, &[], bytes).await + } + /// POST raw bytes (a file/image upload) and decode the JSON response. pub async fn app_post_bytes( &self, diff --git a/src/publish.rs b/src/publish.rs index a0230e3..13ea841 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -2462,10 +2462,12 @@ async fn generate_preview( #[cfg(all(target_family = "wasm", target_os = "unknown"))] mod renderer_preview { use polymodel_renderer_protocol::{ - Capability, CommandEnvelope, CommandPayload, EventPayload, HandshakeState, MeshFormat, - ProtocolHello, RendererCommand, RendererEvent, deserialize_event_envelope, - serialize_command_envelope, + Capability, CommandEnvelope, CommandPayload, EventPayload, HandshakeState, LoadIdentity, + LoadOutput, LoadSource, MeshFormat, ModelLoadBundle, ModelResource, ProtocolHello, + RendererCommand, RendererEvent, RendererNamespace, deserialize_event_envelope, + required_capabilities, serialize_command_envelope, }; + use sha2::{Digest, Sha256}; use std::cell::RefCell; use std::rc::Rc; use wasm_bindgen::JsCast; @@ -2487,17 +2489,56 @@ mod renderer_preview { return Err(error); } }; - let result = - request_preview_png(MeshFormat::Gltf, glb_url.clone(), width, height).await; + let result = request_preview_png( + LoadSource::Url { + format: MeshFormat::Gltf, + primary_url: glb_url.clone(), + expected_size: None, + expected_digest: None, + }, + width, + height, + ) + .await; let _ = web_sys::Url::revoke_object_url(&glb_url); let _ = web_sys::Url::revoke_object_url(&step_url); return result; } + if is_ldraw_filename(filename) { + let byte_length = bytes.len() as u64; + let bundle = ModelLoadBundle { + version: polymodel_renderer_protocol::PROTOCOL_VERSION, + format: MeshFormat::CompoundLdraw, + primary: filename.to_owned(), + resources: vec![ModelResource { + key: filename.to_owned(), + path: filename.to_owned(), + root: "models".to_owned(), + byte_length, + sha256: Sha256::digest(&bytes).to_vec(), + mime_type: "text/plain".to_owned(), + target_cids: Vec::new(), + bytes, + }], + }; + return request_preview_png(LoadSource::Bundle(bundle), width, height).await; + } + let format = MeshFormat::from_extension(filename) .ok_or_else(|| "unsupported preview format".to_string())?; let blob_url = blob_url_for_bytes(bytes, mime_from_format(format))?; - let result = request_preview_png(format, blob_url.clone(), width, height).await; + let result = request_preview_png( + LoadSource::Url { + format, + primary_url: blob_url.clone(), + expected_size: None, + expected_digest: None, + }, + width, + height, + ) + .await; let _ = web_sys::Url::revoke_object_url(&blob_url); result } @@ -2531,6 +2572,13 @@ mod renderer_preview { .unwrap_or(false) } + fn is_ldraw_filename(filename: &str) -> bool { + filename + .rsplit_once('.') + .map(|(_, ext)| matches!(ext.to_ascii_lowercase().as_str(), "ldr" | "mpd")) + .unwrap_or(false) + } + async fn convert_step_to_glb_url(step_url: String) -> Result { let opts = web_sys::WorkerOptions::new(); opts.set_type(web_sys::WorkerType::Module); @@ -2633,8 +2681,7 @@ mod renderer_preview { } async fn request_preview_png( - format: MeshFormat, - primary_url: String, + source: LoadSource, width: u32, height: u32, ) -> Result, String> { @@ -2643,18 +2690,21 @@ mod renderer_preview { let worker = web_sys::Worker::new_with_options("/renderer_worker_loader.js", &opts) .map_err(|e| format!("preview worker: {e:?}"))?; let request_id = js_sys::Math::random().to_bits() as u32; - let command = RendererCommand::RenderPreviewImage { - request_id, - format, - primary_url, - width, - height, + let command = RendererCommand::StartLoad { + namespace: RendererNamespace::Preview, + identity: LoadIdentity::new(1, request_id.max(1)), + source, + output: LoadOutput::Preview { + request_id, + width, + height, + }, }; let local_hello = ProtocolHello { min_version: polymodel_renderer_protocol::PROTOCOL_VERSION, max_version: polymodel_renderer_protocol::PROTOCOL_VERSION, required: vec![Capability::CurrentFormats, Capability::Cancellation], - optional: vec![Capability::Preview], + optional: vec![Capability::Preview, Capability::CompoundLdraw], }; let handshake = Rc::new(RefCell::new(HandshakeState::Created)); let worker_for_message = worker.clone(); @@ -2716,10 +2766,15 @@ mod renderer_preview { Ok(Some(event)) => event, Ok(None) => { if let Some(protocol) = negotiated { - if !protocol.capabilities.contains(&Capability::Preview) { + if let Some(capability) = required_capabilities(&command) + .into_iter() + .find(|capability| !protocol.capabilities.contains(capability)) + { let _ = reject_message.call1( &JsValue::NULL, - &JsValue::from_str("preview capability was not negotiated"), + &JsValue::from_str(&format!( + "preview capability was not negotiated: {capability:?}" + )), ); return; } diff --git a/src/thing_detail.rs b/src/thing_detail.rs index 39c0062..639f7bb 100644 --- a/src/thing_detail.rs +++ b/src/thing_detail.rs @@ -19,8 +19,6 @@ use crate::client::PolymodelClient; use crate::session::SessionIdentity; use crate::viewer::{AssetViewer, ViewerFallbackImage, ViewerMeshSource, ViewerSourceFormat}; -const COMPOUND_PRIMARY_PATH: &str = "models/main.ldr"; - #[derive(Clone, Debug, PartialEq, Eq)] enum DetailState { Loading, @@ -84,26 +82,16 @@ fn part_download_href(uri: &AtUri) -> Option { )) } -fn viewer_source_for_part(part: &PartDetail, compound_project: &AtUri) -> Option { +fn viewer_source_for_part(part: &PartDetail) -> Option { let url = part_download_href(&part.view.uri)?; let name = clean_text(Some(part.view.name.as_ref())) .unwrap_or("Untitled part") .to_string(); let format = part_viewer_format(part, &url, &name)?; - let compound_path = if matches!( - format, - ViewerSourceFormat::Renderer(MeshFormat::CompoundLdraw) - ) { - COMPOUND_PRIMARY_PATH.to_string() - } else { - String::new() - }; Some(ViewerMeshSource { key: part.view.uri.as_str().to_string(), name, url, - compound_project: compound_project.as_str().to_string(), - compound_path, format, expected_size: Some(part.view.file.size), expected_digest: part.view.file.digest.as_deref().map(|b| b.to_vec()), @@ -500,7 +488,7 @@ fn DetailPopulated( let viewer_source = selected_model .as_ref() .and_then(|selected| selected.parts.first()) - .and_then(|part| viewer_source_for_part(part, &thing.uri)); + .and_then(viewer_source_for_part); let instructions = thing_record.instructions; rsx! { diff --git a/src/viewer.rs b/src/viewer.rs index 96c1b2f..0dc4116 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -16,12 +16,10 @@ use crate::client::PolymodelClient; use dioxus::prelude::*; #[cfg(target_arch = "wasm32")] -use polymodel_renderer_protocol::LoadIdentity; -#[cfg(target_arch = "wasm32")] use polymodel_renderer_protocol::{ - Capability, CommandEnvelope, CommandPayload, CompoundLoadPlan, EventPayload, HandshakeState, - LoadEvent, LoadReducer, ProtocolHello, RendererNamespace, deserialize_event_envelope, - serialize_command_envelope, + Capability, CommandEnvelope, CommandPayload, EventPayload, HandshakeState, LoadEvent, + LoadIdentity, LoadOutput, LoadReducer, LoadSource, ModelLoadBundle, ProtocolHello, + RendererNamespace, deserialize_event_envelope, serialize_command_envelope, }; use polymodel_renderer_protocol::{ KeyModifiers, LoadErrorCode, MeshFormat, MeshStats, RendererCommand, RendererEvent, @@ -62,10 +60,6 @@ pub struct ViewerMeshSource { pub key: String, pub name: String, pub url: String, - /// Thing URI used as the anonymous compound-plan project identity. - pub compound_project: String, - /// Canonical LDraw path selected by the ordinary part mapping. - pub compound_path: String, pub format: ViewerSourceFormat, pub expected_size: Option, pub expected_digest: Option>, @@ -75,15 +69,13 @@ pub struct ViewerMeshSource { #[derive(Clone, Debug, PartialEq, Eq)] struct CompoundLogicalIntent { key: String, - project: String, - path: String, } #[cfg(target_arch = "wasm32")] #[derive(Clone, Debug, PartialEq, Eq)] struct PendingCompoundLoad { logical: CompoundLogicalIntent, - plan: CompoundLoadPlan, + bundle: ModelLoadBundle, acquisition_epoch: u32, } @@ -505,11 +497,7 @@ pub fn AssetViewer( clear_step_render_source(render_source, active_step_object_url); render_source.set(None); #[cfg(target_arch = "wasm32")] - compound_intent.set(Some(CompoundLogicalIntent { - key: source.key, - project: source.compound_project, - path: source.compound_path, - })); + compound_intent.set(Some(CompoundLogicalIntent { key: source.key })); compound_load.set(None); } ViewerSourceFormat::Renderer(format) => { @@ -579,9 +567,8 @@ pub fn AssetViewer( #[cfg(target_arch = "wasm32")] #[derive(serde::Serialize)] -struct CompoundPlanRequest<'a> { - project: &'a str, - path: &'a str, +struct ModelBundleRequest<'a> { + uri: &'a str, } #[cfg(target_arch = "wasm32")] @@ -595,40 +582,30 @@ fn start_compound_plan_load( mut status: Signal, ) { wasm_bindgen_futures::spawn_local(async move { + let path = format!( + "/xrpc/space.polymodel.library.getModelLoadBundle?uri={}", + urlencoding::encode(&logical.key) + ); let result = client - .app_json::<_, CompoundLoadPlan>( - http::Method::POST, - "/app/ldraw/compound-resources", - Some(&CompoundPlanRequest { - project: &logical.project, - path: &logical.path, - }), - ) - .await; + .app_bytes::>(http::Method::GET, &path, None) + .await + .and_then(|bytes| { + ModelLoadBundle::decode(&bytes) + .map_err(|error| crate::client::RawError::Decode(format!("{error:?}"))) + }); let current = *restart_epoch.read() == expected_restart_epoch && expected_intent.read().as_ref() == Some(&logical); if !current { return; } match result { - Ok(plan) => { - if let Err(error) = plan - .validate() - .and_then(|_| plan.encoded_size().map(|_| ())) - { - status.set(ViewerStatus::MeshError(format!( - "compound plan rejected: {error:?}" - ))); - return; - } - pending.set(Some(PendingCompoundLoad { - logical, - plan, - acquisition_epoch: expected_restart_epoch, - })); - } + Ok(bundle) => pending.set(Some(PendingCompoundLoad { + logical, + bundle, + acquisition_epoch: expected_restart_epoch, + })), Err(error) => status.set(ViewerStatus::MeshError(format!( - "compound plan acquisition failed: {error}" + "model bundle acquisition failed: {error}" ))), } }); @@ -1917,10 +1894,11 @@ impl WorkerBridgeState { pending.logical.key.clone(), identity.scene_generation as u64, )); - self.send_command(&RendererCommand::StartCompoundLoad { + self.send_command(&RendererCommand::StartLoad { namespace, identity, - plan: pending.plan, + source: LoadSource::Bundle(pending.bundle), + output: LoadOutput::Interactive, }); } @@ -1945,10 +1923,13 @@ impl WorkerBridgeState { self.send_command(&RendererCommand::StartLoad { namespace, identity, - format: source.format, - primary_url: source.primary_url, - expected_size: source.expected_size, - expected_digest: source.expected_digest, + source: LoadSource::Url { + format: source.format, + primary_url: source.primary_url, + expected_size: source.expected_size, + expected_digest: source.expected_digest, + }, + output: LoadOutput::Interactive, }); } @@ -2367,8 +2348,8 @@ fn mark_canvas_mounted( mod tests { use super::*; use polymodel_renderer_protocol::{ - KeyModifiers, LoadEvent, LoadIdentity, LoadReducer, MeshStats, RendererCommand, - RendererEvent, RendererNamespace, + KeyModifiers, LoadEvent, LoadIdentity, LoadOutput, LoadReducer, LoadSource, MeshStats, + RendererCommand, RendererEvent, RendererNamespace, }; #[test] @@ -2524,10 +2505,13 @@ mod tests { let start = RendererCommand::StartLoad { namespace: RendererNamespace::Interactive, identity: LoadIdentity::new(1, 1), - format: MeshFormat::Stl, - primary_url: "/models/cube.stl".into(), - expected_size: None, - expected_digest: None, + source: LoadSource::Url { + format: MeshFormat::Stl, + primary_url: "/models/cube.stl".into(), + expected_size: None, + expected_digest: None, + }, + output: LoadOutput::Interactive, }; let resize = RendererCommand::Resize { css_w: 100.0,