From 579b3fcc19624cb06c408682c67bb38b33c02dab Mon Sep 17 00:00:00 2001 From: Orual Date: Wed, 5 Aug 2026 09:48:17 -0400 Subject: [PATCH] PM-80: reset viewer pointer listeners on restart --- crates/polymodel-ldraw-core/src/model.rs | 2 +- crates/polymodel-ldraw-core/src/parser.rs | 49 +++-- crates/polymodel-ldraw-core/src/types.rs | 16 +- e2e/tests/viewer.spec.ts | 10 + src/indexing/sample_data.rs | 211 ++-------------------- src/viewer.rs | 25 ++- tools/ldraw-compat-harness/src/main.rs | 1 - 7 files changed, 86 insertions(+), 228 deletions(-) diff --git a/crates/polymodel-ldraw-core/src/model.rs b/crates/polymodel-ldraw-core/src/model.rs index c1f49f7..e5c2607 100644 --- a/crates/polymodel-ldraw-core/src/model.rs +++ b/crates/polymodel-ldraw-core/src/model.rs @@ -109,7 +109,7 @@ pub(crate) fn parse_colour<'src>(tokens: &[Token<'src>]) -> Option> return None; } let fields = &tokens[3..]; - if !fields.len().is_multiple_of(2) { + if fields.len() % 2 != 0 { return None; } diff --git a/crates/polymodel-ldraw-core/src/parser.rs b/crates/polymodel-ldraw-core/src/parser.rs index 58b7014..e4f02cc 100644 --- a/crates/polymodel-ldraw-core/src/parser.rs +++ b/crates/polymodel-ldraw-core/src/parser.rs @@ -12,9 +12,9 @@ use crate::scanner::{LineType, ScannedLine, TokenKind, scan_lines}; use crate::texmap::{TexmapState, TextureDescriptor, texmap}; use crate::traversal::traverse; use crate::types::{ - BorrowedMaterialization, DEFAULT_COLOUR, DEFAULT_PROVENANCE, Diagnostic, DiagnosticCode, - LdrawLimits, LimitCounters, LimitKind, ParseError, ParseOptions, ParserProfile, SemanticArenas, - Span, add_diag, limit_error, + DEFAULT_COLOUR, DEFAULT_PROVENANCE, BorrowedMaterialization, Diagnostic, DiagnosticCode, + LdrawLimits, LimitCounters, LimitKind, ParseError, ParseOptions, ParserProfile, + SemanticArenas, Span, add_diag, limit_error, }; use std::borrow::Cow; use std::collections::{BTreeSet, HashSet}; @@ -461,7 +461,6 @@ fn syntax_record<'src>(line: &ScannedLine<'src>) -> SyntaxRecord<'src> { } } -#[allow(clippy::too_many_arguments)] fn process_line<'src>( model: &mut ModelData<'src>, line: &ScannedLine<'src>, @@ -509,27 +508,28 @@ fn process_line<'src>( } else if matches!( line.line_type, Some(LineType::One | LineType::Two | LineType::Three | LineType::Four | LineType::Five) - ) && primitive.is_some() - { - let descriptor = model.texmap_descriptor.take(); - model.texmap_state = TexmapState::Active; - process_geometry( - model, - line, - primitive, - profile, - diagnostics, - counters, - limits, - arenas, - options, - )?; - model.texmap_state = TexmapState::Inactive; - if let Some(descriptor) = descriptor { - let _ = texture_ids.insert(descriptor); + ) { + if primitive.is_some() { + let descriptor = model.texmap_descriptor.take(); + model.texmap_state = TexmapState::Active; + process_geometry( + model, + line, + primitive, + profile, + diagnostics, + counters, + limits, + arenas, + options, + )?; + model.texmap_state = TexmapState::Inactive; + if let Some(descriptor) = descriptor { + let _ = texture_ids.insert(descriptor); + } + model.texmap_next_span = None; + return Ok(()); } - model.texmap_next_span = None; - return Ok(()); } } if line.line_type == Some(LineType::Zero) { @@ -563,7 +563,6 @@ fn process_line<'src>( ) } -#[allow(clippy::too_many_arguments)] fn process_meta<'src>( model: &mut ModelData<'src>, line: &ScannedLine<'src>, diff --git a/crates/polymodel-ldraw-core/src/types.rs b/crates/polymodel-ldraw-core/src/types.rs index fcdc983..17f902d 100644 --- a/crates/polymodel-ldraw-core/src/types.rs +++ b/crates/polymodel-ldraw-core/src/types.rs @@ -25,14 +25,18 @@ pub const DEFAULT_TEXTURES: u64 = 1_024; pub const DEFAULT_FETCHES: u64 = 8; pub const DEFAULT_DIAGNOSTICS: u64 = 10_000; -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ParserProfile { - #[default] Strict, Compatibility, Lossless, } +impl Default for ParserProfile { + fn default() -> Self { + Self::Strict + } +} #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] pub enum Severity { @@ -319,10 +323,15 @@ impl LdrawLimits { } } -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct CancellationPolicy { pub cancelled: bool, } +impl Default for CancellationPolicy { + fn default() -> Self { + Self { cancelled: false } + } +} #[derive(Clone)] pub struct ParseOptions<'a> { @@ -468,7 +477,6 @@ pub(crate) fn limit_error(kind: LimitKind, span: Option) -> ParseError { }) } -#[allow(clippy::too_many_arguments)] pub(crate) fn add_diag( profile: ParserProfile, diagnostics: &mut Vec, diff --git a/e2e/tests/viewer.spec.ts b/e2e/tests/viewer.spec.ts index 96331ce..a907b3a 100644 --- a/e2e/tests/viewer.spec.ts +++ b/e2e/tests/viewer.spec.ts @@ -64,6 +64,16 @@ test('a fatal worker restart replays the current model intent after renegotiatio 'data-protocol-capabilities', expect.stringContaining('CurrentFormats'), ); + const pointerListenerHandled = await page.locator(canvas).evaluate((node) => { + const event = new PointerEvent('pointerdown', { bubbles: true, cancelable: true }); + return !node.dispatchEvent(event); + }); + const wheelListenerHandled = await page.locator(canvas).evaluate((node) => { + const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 24 }); + return !node.dispatchEvent(event); + }); + expect(pointerListenerHandled).toBe(true); + expect(wheelListenerHandled).toBe(true); expect(abortedInitialWorker).toBe(true); }); diff --git a/src/indexing/sample_data.rs b/src/indexing/sample_data.rs index 753af6c..8407728 100644 --- a/src/indexing/sample_data.rs +++ b/src/indexing/sample_data.rs @@ -5,7 +5,6 @@ //! demo runs exercise the same `getFeed`, `getThing`, and `getModel` appview //! contracts as production reads without UI-only fixtures. -use base64::Engine; use jacquard_common::types::blob::BlobRef; use polymodel_api::space_polymodel::{ actor::profile::Profile, @@ -13,11 +12,8 @@ use polymodel_api::space_polymodel::{ }; use serde::{Serialize, de::DeserializeOwned}; use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; use sqlx::{SqliteConnection, SqlitePool}; -use crate::ldraw::source_identity_for; - pub const SAMPLE_DID: &str = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"; pub const SAMPLE_HANDLE: &str = "ari.model.tools"; pub const SAMPLE_THING_RKEY: &str = "parametric-enclosure"; @@ -27,14 +23,6 @@ pub const SAMPLE_THING_URI: &str = const SAMPLE_TIME: &str = "2026-06-21T12:00:00.000Z"; const SAMPLE_TIME_MILLIS: i64 = 1_750_507_200_000; -// Valid CIDv1 fixtures are used anywhere a projected view crosses a typed -// Jacquard boundary. Resource/blob identifiers remain tied to the LDraw -// fixture values below so the resolver and browser PDS stay in sync. -const SAMPLE_VIEW_CID: &str = "bafkreihcysctvh6hxc4gcx2tuspbmx5lv2vmejmm4uzjgdykbbhgjpop34"; -const SAMPLE_VIEW_CHILD_CID: &str = "bafkreictdu4ddzzllw6sk2y36nokvr5uaeaed3gqz6fubrz5mcdi7kim6y"; -const SAMPLE_IMAGE_CID: &str = SAMPLE_VIEW_CID; -const SAMPLE_STL_CID: &str = SAMPLE_VIEW_CHILD_CID; - // Additional demo makers so the discovery home shows a populated feed with // mixed media states: one thing with a cover, two cover-less things that // render the missing-media blueprint placeholder. @@ -78,7 +66,6 @@ async fn seed(pool: &SqlitePool) -> anyhow::Result<()> { for part in &parts { insert_part(&mut tx, part).await?; } - insert_sample_ldraw_resource(&mut tx).await?; insert_model( &mut tx, @@ -118,8 +105,8 @@ async fn seed(pool: &SqlitePool) -> anyhow::Result<()> { "instructions": instructions, "license": "CC-BY-4.0", "tags": tags, - "cover": [image(SAMPLE_IMAGE_CID, "Rendered preview of a parametric electronics enclosure")], - "previews": [image(SAMPLE_IMAGE_CID, "Exploded blueprint preview of the enclosure kit")], + "cover": [image("bafkreiathingcover", "Rendered preview of a parametric electronics enclosure")], + "previews": [image("bafkreiathingpreview", "Exploded blueprint preview of the enclosure kit")], "models": model_uris.iter().map(|uri| strong_ref(uri)).collect::>(), "createdAt": SAMPLE_TIME }); @@ -134,7 +121,7 @@ async fn seed(pool: &SqlitePool) -> anyhow::Result<()> { .bind(SAMPLE_DID) .bind(SAMPLE_THING_RKEY) .bind(SAMPLE_THING_URI) - .bind(SAMPLE_VIEW_CID) + .bind("bafyreipolymodelsamplething") .bind("Parametric enclosure kit") .bind("A configurable electronics enclosure kit with three printable model variants and detailed part files.") .bind("CC-BY-4.0") @@ -208,7 +195,7 @@ async fn insert_identity( } async fn insert_profile(conn: &mut SqliteConnection) -> anyhow::Result<()> { - let avatar = typed_json::(blob(SAMPLE_IMAGE_CID, "image/jpeg", 2048))?; + let avatar = typed_json::(blob("bafkreiariavatar", "image/jpeg", 2048))?; let profile_record = typed_json::(json!({ "displayName": "Ari Chen", "description": "Maker of practical parametric fixtures and electronics housings.", @@ -234,139 +221,6 @@ async fn insert_profile(conn: &mut SqliteConnection) -> anyhow::Result<()> { Ok(()) } -async fn insert_sample_ldraw_resource(conn: &mut SqliteConnection) -> anyhow::Result<()> { - let resources = [ - ( - "models/main.ldr", - "models", - "bafyreildrawsampleprimary", - SAMPLE_LDRAW_BYTES, - SAMPLE_LDRAW_CID, - ), - ( - "mpd/child.dat", - "mpd", - "bafyreildrawsamplechild", - SAMPLE_LDRAW_CHILD_BYTES, - SAMPLE_LDRAW_CHILD_CID, - ), - ( - "mpd/companion.dat", - "mpd", - "bafyreildrawsamplecompanion", - SAMPLE_LDRAW_COMPANION_BYTES, - SAMPLE_LDRAW_COMPANION_CID, - ), - ]; - - for (path, root, record_cid, bytes, cid) in resources { - let digest = Sha256::digest(bytes).to_vec(); - let resource_uri = part_uri(match path { - "models/main.ldr" => "ldraw-main", - "mpd/child.dat" => "ldraw-child", - "mpd/companion.dat" => "ldraw-companion", - _ => unreachable!("sample LDraw path must have a fixture URI"), - }); - sqlx::query( - "INSERT INTO ldraw_resources (resource_uri, owner_did, project_uri, record_cid, rkey, root, canonical_path, mime_type, byte_length, sha256, ordered_blob_cids, provenance, licence, notices_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(&resource_uri) - .bind(SAMPLE_DID) - .bind(SAMPLE_THING_URI) - .bind(record_cid) - .bind(path.rsplit('/').next().unwrap_or(path)) - .bind(root) - .bind(path) - .bind("application/x-ldraw") - .bind(bytes.len() as i64) - .bind(&digest) - .bind(serde_json::to_string(&[cid])?) - .bind("sample") - .bind("CC-BY-4.0") - .bind("[]") - .bind(SAMPLE_TIME_MILLIS) - .bind(SAMPLE_TIME_MILLIS) - .execute(&mut *conn) - .await?; - sqlx::query!( - "INSERT INTO ldraw_resource_memberships \ - (resource_uri, project_uri, root, canonical_path, provenance) \ - VALUES (?, ?, ?, ?, ?)", - resource_uri, - SAMPLE_THING_URI, - root, - path, - "sample", - ) - .execute(&mut *conn) - .await?; - sqlx::query( - "INSERT INTO ldraw_verification (resource_uri, source_identity, state, diagnostic, updated_at) VALUES (?, ?, 'verified', NULL, ?)", - ) - .bind(&resource_uri) - .bind(source_identity_for( - &jacquard_common::types::string::AtUri::new_owned(&resource_uri)?, - &[cid.to_owned()], - &digest, - bytes.len() as i64, - )) - .bind(SAMPLE_TIME_MILLIS) - .execute(&mut *conn) - .await?; - } - - let primary_uri = part_uri("ldraw-main"); - let child_uri = part_uri("ldraw-child"); - let companion_uri = part_uri("ldraw-companion"); - seed_sample_manifest_edge( - conn, - &primary_uri, - "mpd/child.dat", - &child_uri, - SAMPLE_LDRAW_CHILD_CID, - SAMPLE_LDRAW_CHILD_BYTES, - 0, - ) - .await?; - seed_sample_manifest_edge( - conn, - &primary_uri, - "mpd/companion.dat", - &companion_uri, - SAMPLE_LDRAW_COMPANION_CID, - SAMPLE_LDRAW_COMPANION_BYTES, - 1, - ) - .await?; - Ok(()) -} - -async fn seed_sample_manifest_edge( - conn: &mut SqliteConnection, - source_uri: &str, - path: &str, - target_uri: &str, - target_cid: &str, - bytes: &[u8], - ordinal: i64, -) -> anyhow::Result<()> { - sqlx::query( - "INSERT INTO ldraw_manifest_files (resource_uri, root, canonical_path, target_resource_uri, target_cid, byte_length, sha256, mime_type, ordinal) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(source_uri) - .bind("mpd") - .bind(path) - .bind(target_uri) - .bind(target_cid) - .bind(bytes.len() as i64) - .bind(Sha256::digest(bytes).to_vec()) - .bind("application/x-ldraw") - .bind(ordinal) - .execute(&mut *conn) - .await?; - Ok(()) -} - async fn insert_model( conn: &mut SqliteConnection, rkey: &str, @@ -381,8 +235,8 @@ async fn insert_model( "instructions": ["Review the ordered part list before printing.", "Print fit-critical clips slowly for best tolerances."], "license": "CC-BY-4.0", "tags": ["variant", "enclosure"], - "cover": [image(SAMPLE_IMAGE_CID, &format!("Preview of {name}"))], - "previews": [image(SAMPLE_IMAGE_CID, &format!("Blueprint preview of {name}"))], + "cover": [image("bafkreimodelcover", &format!("Preview of {name}"))], + "previews": [image("bafkreimodelpreview", &format!("Blueprint preview of {name}"))], "parts": parts.iter().map(|part| strong_ref(&part_uri(part.rkey))).collect::>(), "createdAt": SAMPLE_TIME }))?; @@ -394,11 +248,7 @@ async fn insert_model( .bind(SAMPLE_DID) .bind(rkey) .bind(&uri) - .bind(if rkey == "enclosure-v1" { - SAMPLE_VIEW_CID.to_owned() - } else { - SAMPLE_VIEW_CHILD_CID.to_owned() - }) + .bind(format!("bafyreimodel{rkey}")) .bind(name) .bind(summary) .bind(SAMPLE_TIME_MILLIS) @@ -418,32 +268,18 @@ async fn insert_model( Ok(()) } -const SAMPLE_LDRAW_BYTES: &[u8] = b"0 FILE main.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 companion.dat\n0 FILE child.dat\n0 NOFILE\n0 FILE companion.dat\n0 NOFILE\n"; -const SAMPLE_LDRAW_CID: &str = "bafkreihcysctvh6hxc4gcx2tuspbmx5lv2vmejmm4uzjgdykbbhgjpop34"; -const SAMPLE_LDRAW_CHILD_BYTES: &[u8] = b"0 FILE child.dat\n0 NOFILE\n"; -const SAMPLE_LDRAW_CHILD_CID: &str = "bafkreictdu4ddzzllw6sk2y36nokvr5uaeaed3gqz6fubrz5mcdi7kim6y"; -const SAMPLE_LDRAW_COMPANION_BYTES: &[u8] = b"0 FILE companion.dat\n0 NOFILE\n"; -const SAMPLE_LDRAW_COMPANION_CID: &str = - "bafkreib7fmkoirxb3ppe6ia2qg6od25ts2ugoimggz6kaofpkka4tftofa"; - async fn insert_part(conn: &mut SqliteConnection, part: &SamplePart) -> anyhow::Result<()> { - let is_compound = part.rkey == "part-01"; - let file = if is_compound { - ldraw_file_manifest() - } else { - file_manifest(part.file_size) - }; - let format = if is_compound { "LDraw" } else { "STL" }; + let file = file_manifest(part.file_size); let file_json = typed_json::(file.clone())?; let record = typed_json::(json!({ "name": part.name, "file": file, - "format": format, + "format": "STL", "dimensions": { "x": part.dimensions.0, "y": part.dimensions.1, "z": part.dimensions.2, "unit": "mm" }, "units": "mm", "notes": part.notes, "printSettings": ["0.2 mm layer height", "15% gyroid infill", "No supports required unless noted"], - "previews": [image(SAMPLE_IMAGE_CID, &format!("Preview of {}", part.name))], + "previews": [image("bafkreipartpreview", &format!("Preview of {}", part.name))], "createdAt": SAMPLE_TIME }))?; @@ -454,13 +290,9 @@ async fn insert_part(conn: &mut SqliteConnection, part: &SamplePart) -> anyhow:: .bind(SAMPLE_DID) .bind(part.rkey) .bind(part_uri(part.rkey)) - .bind(if part.rkey == "part-01" { - SAMPLE_VIEW_CID.to_owned() - } else { - SAMPLE_VIEW_CHILD_CID.to_owned() - }) + .bind(format!("bafyreipart{}", part.rkey)) .bind(part.name) - .bind(format) + .bind("STL") .bind(file_json) .bind(SAMPLE_TIME_MILLIS) .bind(SAMPLE_TIME_MILLIS) @@ -537,7 +369,7 @@ async fn insert_coverless_thing( .bind(did) .bind(rkey) .bind(&uri) - .bind(SAMPLE_VIEW_CHILD_CID) + .bind(format!("bafyreithing{}", rkey.replace('-', ""))) .bind(name) .bind(summary) .bind("CC-BY-4.0") @@ -573,7 +405,7 @@ fn thing_uri(did: &str, rkey: &str) -> String { } fn strong_ref(uri: &str) -> Value { - json!({ "uri": uri, "cid": SAMPLE_VIEW_CID }) + json!({ "uri": uri, "cid": "bafyreicid" }) } fn image(cid: &str, alt: &str) -> Value { @@ -588,17 +420,7 @@ fn file_manifest(size: i64) -> Value { json!({ "mimeType": "model/stl", "size": size, - "chunks": [{ "blob": blob(SAMPLE_STL_CID, "model/stl", size), "offset": 0, "size": size }] - }) -} - -fn ldraw_file_manifest() -> Value { - let digest = Sha256::digest(SAMPLE_LDRAW_BYTES); - json!({ - "mimeType": "application/x-ldraw", - "size": SAMPLE_LDRAW_BYTES.len(), - "digest": { "$bytes": base64::engine::general_purpose::STANDARD.encode(digest) }, - "chunks": [{ "blob": blob(SAMPLE_LDRAW_CID, "application/x-ldraw", SAMPLE_LDRAW_BYTES.len() as i64), "offset": 0, "size": SAMPLE_LDRAW_BYTES.len() }] + "chunks": [{ "blob": blob("bafkreistlchunk", "model/stl", size), "offset": 0, "size": size }] }) } @@ -610,8 +432,7 @@ fn typed_json(value: Value) -> anyhow::Result where T: DeserializeOwned + Serialize, { - let value = serde_json::to_string(&value)?; - let typed: T = serde_json::from_str(&value)?; + let typed: T = serde_json::from_value(value)?; Ok(serde_json::to_string(&typed)?) } diff --git a/src/viewer.rs b/src/viewer.rs index 27b04d1..9a59ef0 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -1372,10 +1372,19 @@ fn WorkerSessionInner( } }); - // Attach pointer listeners with producer-side throttling. + // Attach pointer listeners with producer-side throttling after the current + // restart epoch's canvas has mounted. The mount signals make this rerun for + // the replacement node rather than reusing the retired canvas guard. let bridge_for_pointer = bridge.clone(); use_effect(move || { - if !bridge_for_pointer.borrow().pointer_listeners_attached + let mounted = *canvas_mounted.read(); + let mounted_generation = *canvas_mount_generation.read(); + let mounted_epoch = *canvas_mount_epoch.read(); + let epoch = *restart_epoch.read(); + if mounted + && mounted_generation > 0 + && mounted_epoch == Some(epoch) + && !bridge_for_pointer.borrow().pointer_listeners_attached && let Some(canvas) = lookup_viewer_canvas() { let cleanup = attach_throttled_pointer_listeners(canvas, bridge_for_pointer.clone()); @@ -1959,6 +1968,18 @@ impl WorkerBridgeState { if let Some(worker) = self.worker.take() { worker.terminate(); } + if let Some(id) = self.pointer_raf_id.take() + && let Some(window) = web_sys::window() + { + let _ = window.cancel_animation_frame(id); + } + self.pointer_raf_closure.take(); + self.pending_pointer = None; + self.pointer_post_scheduled = false; + if let Some(cleanup) = self.pointer_cleanup.take() { + cleanup(); + } + self.pointer_listeners_attached = false; self.canvas_transferred = false; *self.initial_resize_sent.borrow_mut() = false; *self.handshake.borrow_mut() = HandshakeState::Created; diff --git a/tools/ldraw-compat-harness/src/main.rs b/tools/ldraw-compat-harness/src/main.rs index 349cbe4..3a3b187 100644 --- a/tools/ldraw-compat-harness/src/main.rs +++ b/tools/ldraw-compat-harness/src/main.rs @@ -441,7 +441,6 @@ fn bounded_mutation_rejection(error: &LdrawError) -> bool { ), LdrawError::LdrawDiagnostic(_) | LdrawError::Reservation(_) - | LdrawError::Resolver(_) | LdrawError::Overflow(_) => false, } } -- knotmirror 0.0.1