diff --git a/.polytoken/hooks.json b/.polytoken/hooks.json index 4a15669..8630d21 100644 --- a/.polytoken/hooks.json +++ b/.polytoken/hooks.json @@ -4,11 +4,7 @@ "event": "session_start", "handler": { "bash": ".polytoken/hooks/workflow-context.sh" } }, - { - "name": "workflow-pre-model-context", - "event": "pre_model_turn", - "handler": { "bash": ".polytoken/hooks/workflow-context.sh" } - }, + { "name": "workflow-post-compaction-context", "event": "post_compaction", diff --git a/Cargo.lock b/Cargo.lock index a95a68a..cf0354b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6791,6 +6791,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "smol_str", "thiserror 2.0.18", "web-time", ] @@ -6829,6 +6830,7 @@ dependencies = [ "postcard", "serde", "sha2 0.10.9", + "smol_str", "thiserror 2.0.18", ] @@ -6844,6 +6846,7 @@ dependencies = [ "polymodel-mesh", "polymodel-renderer-ledger", "polymodel-renderer-protocol", + "smol_str", "spinning_top", "talc", "three-d", @@ -6854,6 +6857,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-bindgen-test", "web-sys", + "web-time", "winit", ] diff --git a/Cargo.toml b/Cargo.toml index 63b1fd4..b6fcfcc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ jacquard-common = { path = "../jacquard/crates/jacquard-common" } jacquard-derive = { path = "../jacquard/crates/jacquard-derive" } jacquard-lexicon = { path = "../jacquard/crates/jacquard-lexicon", default-features = false } serde = { version = "1.0", features = ["derive"] } +smol_str = { version = "0.3", features = ["serde"] } thiserror = "2.0" miette = "7.6" postcard = "1" diff --git a/crates/polymodel-ldraw-core/Cargo.toml b/crates/polymodel-ldraw-core/Cargo.toml index d45c713..138a88e 100644 --- a/crates/polymodel-ldraw-core/Cargo.toml +++ b/crates/polymodel-ldraw-core/Cargo.toml @@ -9,6 +9,7 @@ description = "Pure Rust/WASM LDraw syntax and semantic core." base64 = { version = "0.22" } polymodel-renderer-ledger = { path = "../polymodel-renderer-ledger" } serde = { workspace = true } +smol_str = { workspace = true } miette = { workspace = true } sha2 = "0.10" thiserror = { workspace = true } diff --git a/crates/polymodel-ldraw-core/src/cache.rs b/crates/polymodel-ldraw-core/src/cache.rs index 53cbad1..e21a977 100644 --- a/crates/polymodel-ldraw-core/src/cache.rs +++ b/crates/polymodel-ldraw-core/src/cache.rs @@ -2,10 +2,11 @@ use crate::types::{Diagnostic, DiagnosticCode, ParseError, RootId, Severity}; use crate::util::hex; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use smol_str::SmolStr; use std::fmt; #[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)] -pub struct NormalizedPath(String); +pub struct NormalizedPath(SmolStr); impl NormalizedPath { pub fn new(path: &str) -> Result { let normalized_separators = path.replace('\\', "/"); @@ -56,7 +57,7 @@ impl NormalizedPath { span: None, })); } - Ok(Self(parts.join("/"))) + Ok(Self(parts.join("/").into())) } pub fn as_str(&self) -> &str { &self.0 diff --git a/crates/polymodel-ldraw-core/src/geom.rs b/crates/polymodel-ldraw-core/src/geom.rs index 954e19e..153fa36 100644 --- a/crates/polymodel-ldraw-core/src/geom.rs +++ b/crates/polymodel-ldraw-core/src/geom.rs @@ -1,10 +1,11 @@ -use crate::bfc::BfcState; +use crate::bfc::{BfcFrame, BfcState}; +use crate::cache::CacheKey; use crate::model::{Include, ModelData}; use crate::scanner::{LineType, ScannedLine, Token}; use crate::texmap::TexmapAssociation; use crate::types::{ Diagnostic, DiagnosticCode, LdrawLimits, LimitCounters, ParseError, ParseOptions, - ParserProfile, SemanticArenas, add_diag, + ParserProfile, SemanticArenas, Span, add_diag, }; use serde::{Deserialize, Deserializer, Serialize}; use std::borrow::Cow; @@ -141,6 +142,46 @@ impl Transform { matrix: self.matrix.multiply(child.matrix)?, }) } + + pub fn apply_bounds(&self, bounds: Bounds3) -> Option { + let center = Point3::new( + (bounds.min.x + bounds.max.x) * 0.5, + (bounds.min.y + bounds.max.y) * 0.5, + (bounds.min.z + bounds.max.z) * 0.5, + ); + let extent = Point3::new( + (bounds.max.x - bounds.min.x) * 0.5, + (bounds.max.y - bounds.min.y) * 0.5, + (bounds.max.z - bounds.min.z) * 0.5, + ); + let center = self.apply(center)?; + let transformed_extent = |row: Point3| { + finite_sum([ + finite_product(row.x.abs(), extent.x)?, + finite_product(row.y.abs(), extent.y)?, + finite_product(row.z.abs(), extent.z)?, + ]) + }; + let extent = Point3::new( + transformed_extent(self.matrix.row_x)?, + transformed_extent(self.matrix.row_y)?, + transformed_extent(self.matrix.row_z)?, + ); + let min = Point3::new( + center.x - extent.x, + center.y - extent.y, + center.z - extent.z, + ); + let max = Point3::new( + center.x + extent.x, + center.y + extent.y, + center.z + extent.z, + ); + [min.x, min.y, min.z, max.x, max.y, max.z] + .into_iter() + .all(f64::is_finite) + .then_some(Bounds3 { min, max }) + } } #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] @@ -249,11 +290,15 @@ impl From for ColourCode { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct GeometryRecord { + pub primitive_id: u32, + pub source: CacheKey, + pub span: Span, pub line_type: LineType, pub colour: ColourCode, /// Type-5 vertices are ordered as endpoint1, endpoint2, control1, control2. pub vertices: Vec, - #[serde(default)] + pub bfc: BfcFrame, + pub inverted: bool, pub texmap: Option, } @@ -419,10 +464,20 @@ pub(crate) fn process_geometry<'src>( arenas.charge_geometry_record(options)?; let texmap = TexmapAssociation::from_state(model.texmap_state, model.texmap_descriptor.as_ref()); + let primitive_id = u32::try_from(model.geometry.len()) + .map_err(|_| ParseError::Overflow("geometry primitive identity"))?; + let bfc = model.bfc; + let inverted = bfc.invert_next; + model.bfc.invert_next = false; model.geometry.push(GeometryRecord { + primitive_id, + source: model.key.clone(), + span: line.span, line_type: typ, colour, vertices: points, + bfc, + inverted, texmap: texmap.clone(), }); if let Some(association) = texmap { @@ -495,3 +550,49 @@ pub(crate) fn process_geometry<'src>( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn affine_bounds_match_transformed_corner_envelope() { + let bounds = Bounds3 { + min: Point3::new(-2.0, -3.0, -5.0), + max: Point3::new(7.0, 11.0, 13.0), + }; + let transform = Transform { + translation: Point3::new(17.0, -19.0, 23.0), + matrix: Matrix3 { + row_x: Point3::new(-1.5, 0.25, 0.75), + row_y: Point3::new(0.5, 2.0, -0.125), + row_z: Point3::new(-0.375, 0.625, 1.25), + }, + }; + let actual = transform.apply_bounds(bounds).unwrap(); + let mut expected: Option = None; + for x in [bounds.min.x, bounds.max.x] { + for y in [bounds.min.y, bounds.max.y] { + for z in [bounds.min.z, bounds.max.z] { + let point = transform.apply(Point3::new(x, y, z)).unwrap(); + if let Some(bounds) = expected.as_mut() { + bounds.add(point); + } else { + expected = Some(Bounds3::from_point(point)); + } + } + } + } + let expected = expected.unwrap(); + for (actual, expected) in [ + (actual.min.x, expected.min.x), + (actual.min.y, expected.min.y), + (actual.min.z, expected.min.z), + (actual.max.x, expected.max.x), + (actual.max.y, expected.max.y), + (actual.max.z, expected.max.z), + ] { + assert!((actual - expected).abs() <= 1.0e-12); + } + } +} diff --git a/crates/polymodel-ldraw-core/src/lib.rs b/crates/polymodel-ldraw-core/src/lib.rs index 4f4b77d..92c4c08 100644 --- a/crates/polymodel-ldraw-core/src/lib.rs +++ b/crates/polymodel-ldraw-core/src/lib.rs @@ -237,16 +237,10 @@ mod tests { assert_eq!(endpoints[1], Point3::new(1.0, 0.0, 0.0)); assert_eq!(controls[0], Point3::new(0.0, 1.0, 0.0)); assert_eq!(controls[1], Point3::new(2.0, 0.0, 0.0)); - assert!( - GeometryRecord { - line_type: LineType::Two, - colour: ColourCode::from(16_u16), - vertices: Vec::new(), - texmap: None, - } - .conditional_endpoints() - .is_none() - ); + let mut ordinary_line = geometry.clone(); + ordinary_line.line_type = LineType::Two; + ordinary_line.vertices.clear(); + assert!(ordinary_line.conditional_endpoints().is_none()); } #[test] fn bfc_state_machine_certify_then_nocertify() { diff --git a/crates/polymodel-ldraw-core/src/model.rs b/crates/polymodel-ldraw-core/src/model.rs index 75a5c24..6214d41 100644 --- a/crates/polymodel-ldraw-core/src/model.rs +++ b/crates/polymodel-ldraw-core/src/model.rs @@ -6,6 +6,7 @@ use crate::scanner::{LineType, Token, TokenKind}; use crate::texmap::{TexmapAssociation, TexmapEvent, TexmapState, TextureDescriptor}; use crate::types::{Diagnostic, LimitCounters, SCHEMA_VERSION, SemanticArenas, Span}; use serde::{Deserialize, Serialize}; +use smol_str::SmolStr; use std::borrow::Cow; use std::collections::BTreeMap; use std::fmt; @@ -637,13 +638,17 @@ impl<'src> SemanticRecord<'src> { } } } +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct ModelId(pub u32); + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct InstanceRecord { - pub instance_id: String, + pub model: ModelId, + pub instance_id: SmolStr, #[serde(default)] - pub parent_chain: Vec, - pub source_model: String, - pub target_model: String, + pub parent: Option, + pub source_model: SmolStr, + pub target_model: SmolStr, #[serde(default)] pub source_span: Option, #[serde(default)] @@ -666,8 +671,8 @@ pub struct InstanceRecord { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct SceneRecord { - pub model_id: String, - pub instance_ids: Vec, + pub model_id: SmolStr, + pub instance_ids: Vec, #[serde(default)] pub instances: Vec, pub triangles: u64, @@ -868,12 +873,13 @@ impl LdMeshSemantic { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ModelSummary { - pub id: String, - pub path: String, + pub model: ModelId, + pub id: SmolStr, + pub path: SmolStr, pub cache_key: CacheKey, pub depth: u64, pub include_spans: Vec, - pub includes: Vec, + pub includes: Vec, pub triangles: u64, pub quads: u64, pub lines: u64, diff --git a/crates/polymodel-ldraw-core/src/parser.rs b/crates/polymodel-ldraw-core/src/parser.rs index 51f57d3..0cc7661 100644 --- a/crates/polymodel-ldraw-core/src/parser.rs +++ b/crates/polymodel-ldraw-core/src/parser.rs @@ -35,13 +35,16 @@ pub(crate) struct ParseMetrics { pub(crate) bytes_hashed: u64, pub(crate) bytes_cloned: u64, pub(crate) bytes_read: u64, + pub(crate) traversal_bounds_ns: u128, + pub(crate) traversal_instances_ns: u128, + pub(crate) traversal_children_ns: u128, } impl ParseMetrics { fn report(&self, started: &Instant, phase: &str) { if std::env::var_os("POLYMODEL_LDRAW_TIMING").is_some() { eprintln!( - "[ldraw-metrics] phase={phase} elapsed_ms={} unique_materializations_scanned={} include_edges={} selection_lookups={} selection_hits={} unique_models_parsed={} model_parse_cache_hits={} subtree_occurrences_traversed={} geometry_records_emitted={} geometry_records_merged={} bytes_hashed={} bytes_cloned={} bytes_read={}", + "[ldraw-metrics] phase={phase} elapsed_ms={} unique_materializations_scanned={} include_edges={} selection_lookups={} selection_hits={} unique_models_parsed={} model_parse_cache_hits={} subtree_occurrences_traversed={} geometry_records_emitted={} geometry_records_merged={} bytes_hashed={} bytes_cloned={} bytes_read={} traversal_bounds_ms={} traversal_instances_ms={} traversal_children_ms={}", started.elapsed().as_millis(), self.unique_materializations_scanned, self.include_edges, @@ -55,6 +58,9 @@ impl ParseMetrics { self.bytes_hashed, self.bytes_cloned, self.bytes_read, + self.traversal_bounds_ns / 1_000_000, + self.traversal_instances_ns / 1_000_000, + self.traversal_children_ns / 1_000_000, ); } } @@ -269,6 +275,9 @@ impl LdrawParser { resolved_root: materialization.root, content_hash: materialization.content_hash.clone(), }; + for geometry in &mut model.geometry { + geometry.source = model.key.clone(); + } models.push(model); } metrics.report(&started, "parsed-materializations"); diff --git a/crates/polymodel-ldraw-core/src/traversal.rs b/crates/polymodel-ldraw-core/src/traversal.rs index 8d73872..c5b88ef 100644 --- a/crates/polymodel-ldraw-core/src/traversal.rs +++ b/crates/polymodel-ldraw-core/src/traversal.rs @@ -1,6 +1,6 @@ use crate::cache::CacheKey; -use crate::geom::{Bounds3, Point3, Transform}; -use crate::model::{InstanceRecord, LdMeshSemantic, ModelData, ModelSummary, SceneRecord}; +use crate::geom::{Bounds3, Transform}; +use crate::model::{InstanceRecord, LdMeshSemantic, ModelData, ModelId, ModelSummary, SceneRecord}; use crate::types::{ Diagnostic, DiagnosticCode, LimitCounters, LimitKind, ParseError, ParseOptions, SemanticArenas, add_diag, limit_error, @@ -134,7 +134,7 @@ pub(crate) fn traverse( if models.is_empty() { return Ok(( SceneRecord { - model_id: String::new(), + model_id: smol_str::SmolStr::new_static(""), instance_ids: Vec::new(), instances: Vec::new(), triangles: 0, @@ -156,10 +156,10 @@ pub(crate) fn traverse( depth: u64, reflected: bool, colour: crate::ColourCode, - parent_chain: Vec, + parent_instance: Option, }, Exit { - key: CacheKey, + index: usize, }, } @@ -188,24 +188,58 @@ pub(crate) fn traverse( for (index, model) in models.iter().enumerate() { by_path.entry(model.path.clone()).or_default().push(index); } - let mut ids = HashMap::::new(); + let mut resolved_includes = Vec::with_capacity(models.len()); + for model in models { + let mut targets = Vec::with_capacity(model.includes.len()); + for include in &model.includes { + targets.push(target_index( + models, + model, + include, + selection_index.as_ref(), + metrics, + &by_key, + &by_path, + )?); + } + resolved_includes.push(targets); + } + let mut ids = HashMap::::new(); + let mut definition_ids = HashMap::::new(); + let mut next_definition_id = 1_u32; + for model in models { + if !definition_ids.contains_key(&model.key) { + definition_ids.insert(model.key.clone(), ModelId(next_definition_id)); + next_definition_id = next_definition_id + .checked_add(1) + .ok_or(ParseError::Overflow("model definition id"))?; + } + } let mut next_model_id = 1_u64; let mut next_instance_id = 1_u64; - let mut visiting = HashSet::::new(); + let mut visiting = HashSet::::new(); let mut stack = VecDeque::::new(); + let mut child_scratch = Vec::::new(); let mut summaries = Vec::new(); - let mut summarized = HashSet::::new(); + let mut summarized = HashSet::::new(); let mut instance_ids = Vec::new(); - let mut instances = Vec::new(); + let mut instances = Vec::::new(); let mut scene_bounds: Option = None; let mut scene_triangles = 0u64; let mut scene_quads = 0u64; let mut scene_lines = 0u64; let mut scene_conditional_lines = 0u64; let mut scene_reflection = false; + let mut charged_instances = 0_u64; + let timing = std::env::var_os("POLYMODEL_LDRAW_TIMING").is_some(); - let root_key = models[0].key.clone(); - ids.insert(root_key.clone(), format!("model-{next_model_id:08}")); + let root_definition_id = *definition_ids + .get(&models[0].key) + .ok_or(ParseError::Overflow("model definition id"))?; + ids.insert( + root_definition_id, + smol_str::format_smolstr!("model-{next_model_id:08}"), + ); next_model_id = next_model_id .checked_add(1) .ok_or(ParseError::Overflow("model id"))?; @@ -215,7 +249,7 @@ pub(crate) fn traverse( depth: 0, reflected: false, colour: models[0].colour, - parent_chain: Vec::new(), + parent_instance: None, }); while let Some(visit) = stack.pop_back() { @@ -223,8 +257,8 @@ pub(crate) fn traverse( return Err(ParseError::Cancelled); } match visit { - Visit::Exit { key } => { - visiting.remove(&key); + Visit::Exit { index } => { + visiting.remove(&index); } Visit::Enter { index, @@ -232,22 +266,25 @@ pub(crate) fn traverse( depth, reflected, colour, - parent_chain, + parent_instance, } => { let model = &models[index]; metrics.subtree_occurrences_traversed += 1; - let key = model.key.clone(); - if !visiting.insert(key.clone()) { + let key = &model.key; + if !visiting.insert(index) { continue; } - let model_id = if let Some(model_id) = ids.get(&key) { + let definition_id = *definition_ids + .get(key) + .ok_or(ParseError::Overflow("model definition id"))?; + let model_id = if let Some(model_id) = ids.get(&definition_id) { model_id.clone() } else { - let model_id = format!("model-{next_model_id:08}"); + let model_id = smol_str::format_smolstr!("model-{next_model_id:08}"); next_model_id = next_model_id .checked_add(1) .ok_or(ParseError::Overflow("model id"))?; - ids.insert(key.clone(), model_id.clone()); + ids.insert(definition_id, model_id.clone()); model_id }; let model_triangles = model @@ -269,7 +306,7 @@ pub(crate) fn traverse( counters .add(LimitKind::Lines, model_lines, &options.limits) .map_err(|name| limit_error(name, None))?; - if summarized.insert(key.clone()) { + if summarized.insert(definition_id) { let semantic = semantic_export(model)?; let step_ends = model .step_ends @@ -280,15 +317,16 @@ pub(crate) fn traverse( }) .collect::, _>>()?; summaries.push(ModelSummary { + model: definition_id, id: model_id, - path: model.path.to_string(), + path: model.path.as_str().into(), cache_key: model.key.clone(), depth, include_spans: model.includes.iter().map(|include| include.span).collect(), includes: model .includes .iter() - .map(|include| include.name.as_ref().to_owned()) + .map(|include| include.name.as_ref().into()) .collect(), triangles: model.triangles, quads: model.quads, @@ -301,28 +339,21 @@ pub(crate) fn traverse( }); } + let bounds_started = timing.then(web_time::Instant::now); if let Some(model_bounds) = model.bounds { - let corners = [ - Point3::new(model_bounds.min.x, model_bounds.min.y, model_bounds.min.z), - Point3::new(model_bounds.min.x, model_bounds.min.y, model_bounds.max.z), - Point3::new(model_bounds.min.x, model_bounds.max.y, model_bounds.min.z), - Point3::new(model_bounds.min.x, model_bounds.max.y, model_bounds.max.z), - Point3::new(model_bounds.max.x, model_bounds.min.y, model_bounds.min.z), - Point3::new(model_bounds.max.x, model_bounds.min.y, model_bounds.max.z), - Point3::new(model_bounds.max.x, model_bounds.max.y, model_bounds.min.z), - Point3::new(model_bounds.max.x, model_bounds.max.y, model_bounds.max.z), - ]; - for corner in corners { - let transformed = transform - .apply(corner) - .ok_or(ParseError::Overflow("scene transform arithmetic"))?; - if let Some(bounds) = scene_bounds.as_mut() { - bounds.add(transformed); - } else { - scene_bounds = Some(Bounds3::from_point(transformed)); - } + let transformed = transform + .apply_bounds(model_bounds) + .ok_or(ParseError::Overflow("scene transform arithmetic"))?; + if let Some(bounds) = scene_bounds.as_mut() { + bounds.add(transformed.min); + bounds.add(transformed.max); + } else { + scene_bounds = Some(transformed); } } + if let Some(started) = bounds_started { + metrics.traversal_bounds_ns += started.elapsed().as_nanos(); + } scene_triangles = scene_triangles .checked_add(model.triangles) .ok_or(ParseError::Overflow("scene triangles"))?; @@ -336,8 +367,9 @@ pub(crate) fn traverse( .checked_add(model.conditional_lines) .ok_or(ParseError::Overflow("scene conditional_lines"))?; scene_reflection |= reflected; - let mut children = Vec::new(); - for include in &model.includes { + let children_started = timing.then(web_time::Instant::now); + debug_assert!(child_scratch.is_empty()); + for (include_index, include) in model.includes.iter().enumerate() { metrics.include_edges += 1; if options.cancellation.checkpoint() { return Err(ParseError::Cancelled); @@ -345,17 +377,10 @@ pub(crate) fn traverse( counters .add(LimitKind::Instances, 1, &options.limits) .map_err(|name| limit_error(name, Some(include.span)))?; - arenas.charge_instance(options)?; - let Some(target) = target_index( - models, - model, - include, - selection_index.as_ref(), - metrics, - &by_key, - &by_path, - )? - else { + charged_instances = charged_instances + .checked_add(1) + .ok_or(ParseError::Overflow("instance semantic reservation"))?; + let Some(target) = resolved_includes[index][include_index] else { add_diag( options.profile, diagnostics, @@ -369,7 +394,7 @@ pub(crate) fn traverse( continue; }; let child = &models[target]; - if visiting.contains(&child.key) { + if visiting.contains(&target) { add_diag( options.profile, diagnostics, @@ -398,8 +423,7 @@ pub(crate) fn traverse( )?; continue; } - let child_name = child.path.to_string(); - let instance_id = format!("instance-{next_instance_id:08}"); + let instance_id = smol_str::format_smolstr!("instance-{next_instance_id:08}"); next_instance_id = next_instance_id .checked_add(1) .ok_or(ParseError::Overflow("instance id"))?; @@ -412,12 +436,23 @@ pub(crate) fn traverse( } else { include.colour }; + let instance_started = timing.then(web_time::Instant::now); + let parent = parent_instance + .map(|parent| { + u32::try_from(parent) + .map_err(|_| ParseError::Overflow("parent instance index")) + }) + .transpose()?; instance_ids.push(instance_id.clone()); + let instance_index = instances.len(); instances.push(InstanceRecord { + model: *definition_ids + .get(&child.key) + .ok_or(ParseError::Overflow("model definition id"))?, instance_id: instance_id.clone(), - parent_chain: parent_chain.clone(), - source_model: model.path.to_string(), - target_model: child_name.clone(), + parent, + source_model: model.path.as_str().into(), + target_model: child.path.as_str().into(), source_span: Some(include.span), target_key: Some(child.key.clone()), transform: composed_transform, @@ -432,29 +467,34 @@ pub(crate) fn traverse( .map(|descriptor| descriptor.pngfile.to_string()), texmap: include.texmap_association.clone(), }); - let mut child_parent_chain = parent_chain.clone(); - child_parent_chain.push(instance_id); - children.push(Visit::Enter { + child_scratch.push(Visit::Enter { index: target, transform: composed_transform, depth: child_depth, reflected: inverted, colour: child_colour, - parent_chain: child_parent_chain, + parent_instance: Some(instance_index), }); + if let Some(started) = instance_started { + metrics.traversal_instances_ns += started.elapsed().as_nanos(); + } + } + if let Some(started) = children_started { + metrics.traversal_children_ns += started.elapsed().as_nanos(); } - stack.push_back(Visit::Exit { key: key.clone() }); - for child in children.into_iter().rev() { + stack.push_back(Visit::Exit { index }); + while let Some(child) = child_scratch.pop() { stack.push_back(child); } } } } + arenas.charge_instances(options, charged_instances)?; let scene = SceneRecord { model_id: ids - .get(&models[0].key) + .get(&root_definition_id) .cloned() - .unwrap_or_else(|| models[0].path.to_string()), + .unwrap_or_else(|| models[0].path.as_str().into()), instance_ids, instances, triangles: scene_triangles, diff --git a/crates/polymodel-ldraw-core/src/types.rs b/crates/polymodel-ldraw-core/src/types.rs index 5c85103..e189cfe 100644 --- a/crates/polymodel-ldraw-core/src/types.rs +++ b/crates/polymodel-ldraw-core/src/types.rs @@ -666,8 +666,19 @@ impl SemanticArenas { self.child(options, 256) } - pub(crate) fn charge_instance(&mut self, options: &ParseOptions<'_>) -> Result<(), ParseError> { - self.child(options, 128) + pub(crate) fn charge_instances( + &mut self, + options: &ParseOptions<'_>, + count: u64, + ) -> Result<(), ParseError> { + let bytes = count + .checked_mul(128) + .ok_or(ParseError::Overflow("instance semantic reservation"))?; + if bytes == 0 { + Ok(()) + } else { + self.child(options, bytes) + } } pub(crate) fn charge_geometry_record( diff --git a/crates/polymodel-ldraw-testkit/src/schema.rs b/crates/polymodel-ldraw-testkit/src/schema.rs index 86b2af1..0639166 100644 --- a/crates/polymodel-ldraw-testkit/src/schema.rs +++ b/crates/polymodel-ldraw-testkit/src/schema.rs @@ -1,6 +1,6 @@ pub use polymodel_ldraw_core::{ - CanonicalRecord, Diagnostic, SceneRecord, SemanticRecord, Span, SyntaxRecord, TexmapEvent, - TexmapKind, + CanonicalRecord, Diagnostic, LdMeshSemantic, SceneRecord, SemanticRecord, Span, SyntaxRecord, + TexmapEvent, TexmapKind, }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -70,6 +70,90 @@ pub struct OracleMesh { pub bf_triangle_count: usize, } +/// Compare the LDParse mesh projection with the candidate's authoritative +/// semantic mesh. Oracle coordinates are f32 because that is the pinned C++ +/// projection; candidate f64 values must round to the same finite f32 bits. +pub fn compare_mesh_semantic( + model_name: &str, + expected: &OracleMesh, + expected_step_offsets: &[usize], + actual: &LdMeshSemantic, +) -> Result<(), String> { + fn rounded(value: f64) -> Option { + if value.is_finite() { + let value = value as f32; + value.is_finite().then_some(value) + } else { + None + } + } + fn position_matches(actual: &[f64; 3], expected: &OraclePosition) -> bool { + [rounded(actual[0]), rounded(actual[1]), rounded(actual[2])] + .into_iter() + .zip([expected.0, expected.1, expected.2]) + .all(|(actual, expected)| match (actual, expected) { + (Some(actual), Some(expected)) => actual.to_bits() == expected.to_bits(), + (None, None) => true, + _ => false, + }) + } + if actual.positions.len() != expected.vertex_count + || actual.positions.len() != expected.positions.len() + { + return Err(format!( + "{model_name} positions length mismatch: oracle_count={} oracle_values={} candidate={}", + expected.vertex_count, + expected.positions.len(), + actual.positions.len() + )); + } + for (index, (actual, expected)) in actual.positions.iter().zip(&expected.positions).enumerate() + { + if !position_matches(actual, expected) { + return Err(format!( + "{model_name} position mismatch at {index}: oracle={expected:?} candidate={actual:?}" + )); + } + } + let actual_normals = actual.normals.as_deref().unwrap_or(&[]); + if actual_normals.len() != expected.normal_count + || actual_normals.len() != expected.normals.len() + { + return Err(format!( + "{model_name} normals length mismatch: oracle_count={} oracle_values={} candidate={}", + expected.normal_count, + expected.normals.len(), + actual_normals.len() + )); + } + for (index, (actual, expected)) in actual_normals.iter().zip(&expected.normals).enumerate() { + if !position_matches(actual, expected) { + return Err(format!( + "{model_name} normal mismatch at {index}: oracle={expected:?} candidate={actual:?}" + )); + } + } + if actual.indices != expected.indices { + return Err(format!( + "{model_name} forward indices mismatch: oracle={:?} candidate={:?}", + expected.indices, actual.indices + )); + } + if actual.bf_indices != expected.bf_indices { + return Err(format!( + "{model_name} reverse indices mismatch: oracle={:?} candidate={:?}", + expected.bf_indices, actual.bf_indices + )); + } + if actual.step_offsets != expected_step_offsets { + return Err(format!( + "{model_name} step offsets mismatch: oracle={expected_step_offsets:?} candidate={:?}", + actual.step_offsets + )); + } + Ok(()) +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct OracleTransform { diff --git a/crates/polymodel-renderer-ledger/src/lib.rs b/crates/polymodel-renderer-ledger/src/lib.rs index 3a15c6f..74ed495 100644 --- a/crates/polymodel-renderer-ledger/src/lib.rs +++ b/crates/polymodel-renderer-ledger/src/lib.rs @@ -502,10 +502,10 @@ impl Reservation { } let mut state = lock(&self.inner.state); if let Some(entry) = state.reservations.remove(&self.id) { - if let Some(parent_id) = entry.parent { - if let Some(parent) = state.reservations.get_mut(&parent_id) { - parent.child_bytes = parent.child_bytes.saturating_sub(entry.bytes); - } + if let Some(parent_id) = entry.parent + && let Some(parent) = state.reservations.get_mut(&parent_id) + { + parent.child_bytes = parent.child_bytes.saturating_sub(entry.bytes); } state.class_totals[entry.class as usize] -= entry.bytes; state.domain_totals[entry.class.domain() as usize] -= entry.bytes; diff --git a/crates/polymodel-renderer-protocol/Cargo.toml b/crates/polymodel-renderer-protocol/Cargo.toml index d770e6c..265142a 100644 --- a/crates/polymodel-renderer-protocol/Cargo.toml +++ b/crates/polymodel-renderer-protocol/Cargo.toml @@ -10,6 +10,7 @@ default = [] [dependencies] serde = { workspace = true } +smol_str = { workspace = true } postcard = { workspace = true, features = ["alloc"] } sha2 = { version = "0.10", default-features = false } thiserror = { workspace = true } diff --git a/crates/polymodel-renderer-protocol/src/scene.rs b/crates/polymodel-renderer-protocol/src/scene.rs index a6f70d9..afd7d76 100644 --- a/crates/polymodel-renderer-protocol/src/scene.rs +++ b/crates/polymodel-renderer-protocol/src/scene.rs @@ -4,6 +4,7 @@ //! occurrence transforms remain intact until the renderer uploads geometry. use serde::{Deserialize, Serialize}; +use smol_str::SmolStr; macro_rules! id_type { ($name:ident) => { @@ -45,9 +46,9 @@ impl SceneBounds { if point.iter().any(|value| !value.is_finite()) { return None; } - for axis in 0..3 { - bounds.min[axis] = bounds.min[axis].min(point[axis]); - bounds.max[axis] = bounds.max[axis].max(point[axis]); + for (axis, value) in point.into_iter().enumerate() { + bounds.min[axis] = bounds.min[axis].min(value); + bounds.max[axis] = bounds.max[axis].max(value); } } Some(bounds) @@ -63,6 +64,35 @@ pub struct SceneStats { pub occurrences: u64, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SceneTexmapProjectionMode { + Planar, + Cylindrical, + Spherical, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SceneTexmapAssociation { + pub mode: SceneTexmapProjectionMode, + pub parameters: Vec, + pub pngfile: SmolStr, + pub glossmap: Option, + pub fallback: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ScenePrimitiveRange { + pub point_start: u32, + pub point_count: u32, + pub topology: SmolStr, + pub bfc_state: SmolStr, + pub bfc_cull: bool, + pub bfc_ccw: bool, + pub bfc_inverted: bool, + pub texmap: Option, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SceneGeometry { pub id: GeometryId, @@ -72,6 +102,7 @@ pub struct SceneGeometry { pub quads: Vec<[u32; 4]>, pub lines: Vec<[u32; 2]>, pub conditional_lines: Vec<[u32; 4]>, + pub primitives: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -79,38 +110,39 @@ pub struct SceneMaterial { pub id: MaterialId, pub colour: u32, pub edge_colour: Option, - pub finish: Option, - pub local_mapping: Option, + pub finish: Option, + pub local_mapping: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SceneTexture { pub id: TextureId, pub provenance: ProvenanceId, - pub uri: String, + pub uri: SmolStr, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SceneOccurrence { pub id: OccurrenceId, - pub canonical_occurrence_id: String, - pub parent_chain: Vec, + pub canonical_occurrence_id: SmolStr, #[serde(default)] - pub logical_part_occurrence_id: Option, + pub parent_occurrence_id: Option, #[serde(default)] - pub logical_part_path: Option, + pub logical_part_occurrence_id: Option, + #[serde(default)] + pub logical_part_path: Option, pub node: NodeId, - pub definition_id: String, - pub canonical_path: String, - pub source_class: String, - pub source: String, - pub target: String, - pub source_span: Option<[u32; 3]>, + pub definition_id: SmolStr, + pub canonical_path: SmolStr, + pub source_class: SmolStr, + pub source: SmolStr, + pub target: SmolStr, + pub source_span: Option<[u32; 4]>, pub direct_colour: u32, pub effective_colour: u32, pub primitive_colour: u32, pub material: Option, - pub bfc_state: String, + pub bfc_state: SmolStr, pub bfc_cull: bool, pub bfc_ccw: bool, pub bfc_inverted: bool, @@ -120,21 +152,21 @@ pub struct SceneOccurrence { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SceneProvenance { pub id: ProvenanceId, - pub source: String, - pub span: Option<[u32; 3]>, + pub source: SmolStr, + pub span: Option<[u32; 4]>, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SceneDiagnostic { pub id: DiagnosticId, - pub message: String, + pub message: SmolStr, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Scene { pub id: SceneId, pub units: Units, - pub source: String, + pub source: SmolStr, pub provenance: Vec, pub diagnostics: Vec, pub root: NodeId, @@ -147,30 +179,52 @@ pub struct Scene { } impl Scene { - pub fn recompute_stats(&mut self) { - self.stats = SceneStats { - points: self - .geometries - .iter() - .map(|geometry| geometry.points.len() as u64) - .sum(), - triangles: self - .geometries - .iter() - .map(|geometry| geometry.triangles.len() as u64 + geometry.quads.len() as u64) - .sum(), - lines: self - .geometries - .iter() - .map(|geometry| geometry.lines.len() as u64) - .sum(), - conditional_lines: self - .geometries - .iter() - .map(|geometry| geometry.conditional_lines.len() as u64) - .sum(), - occurrences: self.occurrences.len() as u64, + pub fn recompute_stats(&mut self) -> Result<(), &'static str> { + use std::collections::HashMap; + + let mut geometries = HashMap::with_capacity(self.geometries.len()); + for geometry in &self.geometries { + if geometries.insert(geometry.id.0, geometry).is_some() { + return Err("duplicate geometry id"); + } + } + let mut stats = SceneStats { + occurrences: u64::try_from(self.occurrences.len()) + .map_err(|_| "occurrence count overflow")?, + ..SceneStats::default() }; + for occurrence in &self.occurrences { + let geometry = geometries + .get(&occurrence.node.0) + .ok_or("occurrence references unknown geometry")?; + let points = + u64::try_from(geometry.points.len()).map_err(|_| "point count overflow")?; + let triangles = + u64::try_from(geometry.triangles.len()).map_err(|_| "triangle count overflow")?; + let quads = u64::try_from(geometry.quads.len()).map_err(|_| "quad count overflow")?; + let lines = u64::try_from(geometry.lines.len()).map_err(|_| "line count overflow")?; + let conditional_lines = u64::try_from(geometry.conditional_lines.len()) + .map_err(|_| "conditional-line count overflow")?; + stats.points = stats + .points + .checked_add(points) + .ok_or("point count overflow")?; + stats.triangles = stats + .triangles + .checked_add(triangles) + .and_then(|count| count.checked_add(quads.checked_mul(2)?)) + .ok_or("triangle count overflow")?; + stats.lines = stats + .lines + .checked_add(lines) + .ok_or("line count overflow")?; + stats.conditional_lines = stats + .conditional_lines + .checked_add(conditional_lines) + .ok_or("conditional-line count overflow")?; + } + self.stats = stats; + Ok(()) } } @@ -202,14 +256,42 @@ mod tests { quads: Vec::new(), lines: Vec::new(), conditional_lines: Vec::new(), + primitives: Vec::new(), }], materials: Vec::new(), textures: Vec::new(), - occurrences: Vec::new(), + occurrences: vec![SceneOccurrence { + id: OccurrenceId(1), + canonical_occurrence_id: "root".into(), + parent_occurrence_id: None, + logical_part_occurrence_id: None, + logical_part_path: None, + node: NodeId(1), + definition_id: "root".into(), + canonical_path: "omr-10030-1.mpd".into(), + source_class: "project".into(), + source: "omr-10030-1.mpd".into(), + target: "omr-10030-1.mpd".into(), + source_span: None, + direct_colour: 16, + effective_colour: 16, + primitive_colour: 16, + material: None, + bfc_state: "certified".into(), + bfc_cull: true, + bfc_ccw: true, + bfc_inverted: false, + transform: [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + }], bounds: SceneBounds::from_points([[0.0, 0.0, 0.0], [1.0, 1.0, 0.0]]), stats: SceneStats::default(), }; - scene.recompute_stats(); + scene.recompute_stats().unwrap(); let bytes = postcard::to_allocvec(&scene).unwrap(); assert_eq!(postcard::from_bytes::(&bytes).unwrap(), scene); assert_eq!(scene.stats.triangles, 1); diff --git a/crates/polymodel-renderer-worker/Cargo.toml b/crates/polymodel-renderer-worker/Cargo.toml index f33e4ce..42c0290 100644 --- a/crates/polymodel-renderer-worker/Cargo.toml +++ b/crates/polymodel-renderer-worker/Cargo.toml @@ -25,10 +25,12 @@ polymodel-ldraw-core = { path = "../polymodel-ldraw-core" } polymodel-mesh = { path = "../polymodel-mesh" } polymodel-renderer-ledger = { path = "../polymodel-renderer-ledger" } polymodel-renderer-protocol = { path = "../polymodel-renderer-protocol" } +smol_str = { workspace = true } three-d = { version = "0.19.0", default-features = false } three-d-asset = { version = "0.10", default-features = false, features = ["gltf", "obj", "data-url", "png"] } wasm-bindgen = { workspace = true } wasm-bindgen-futures = "0.4" +web-time = { workspace = true } web-sys = { version = "0.3", features = [ "DedicatedWorkerGlobalScope", "WorkerGlobalScope", diff --git a/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs b/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs index e47a156..54c46c2 100644 --- a/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs +++ b/crates/polymodel-renderer-worker/src/bin/ldraw_preview.rs @@ -9,16 +9,16 @@ use std::{ use polymodel_renderer_protocol::scene::Scene; #[cfg(not(target_arch = "wasm32"))] use polymodel_renderer_worker::{ - NativeLights, NativeRenderOptions, NativeStage, NativeTerminal, NativeTrace, - build_scene_model_parts_for_view, camera_space_clip_planes, logical_part_selection, - occurrence_gpu_ranges, occurrence_selection_bounds, prepare_preview_scene, + NativeLights, NativeRenderOptions, NativeStage, NativeTerminal, NativeTrace, PickRay, + RenderStyle, ScenePicker, build_edge_model_for_view, build_scene_model_parts_for_style, + camera_space_clip_planes, logical_part_selection, prepare_preview_scene, render_scene_window_configured, scene_camera_fit, yaw_camera_position, }; #[cfg(not(target_arch = "wasm32"))] use three_d::{ BoundingBox, Camera, ClearState, ColorMaterial, Gm, InnerSpace, InstancedMesh, OrbitControl, - PhysicalMaterial, SurfaceSettings, WindowedContext, pick, + PhysicalMaterial, SurfaceSettings, WindowedContext, }; #[cfg(not(target_arch = "wasm32"))] @@ -105,31 +105,31 @@ impl PickerInput { #[cfg(not(target_arch = "wasm32"))] fn main() { - if env::args().any(|arg| arg == "--help") { + let args = env::args().skip(1).collect::>(); + if args.iter().any(|arg| arg == "--help") { println!( - "ldraw_preview [INPUT] [--output PATH] [--width N] [--height N] [--library-root PATH] [--proof-ms N] [--readback-prefix PATH]" + "ldraw_preview [INPUT] [--output PATH] [--width N] [--height N] [--library-root PATH] [--proof-ms N] [--readback-prefix PATH] [--yaw-degrees F32] [--edges]" ); println!("Defaults to the pinned omr-10030-1.mpd corpus at 1280x720."); return; } - let args = env::args().skip(1).collect::>(); - let input = positional_input(&args) - .unwrap_or_else(|| PathBuf::from("crates/polymodel-ldraw-testkit/corpus/omr-10030-1.mpd")); - let output = option_path(&args, "--output").or_else(|| { - if args.is_empty() { - Some(PathBuf::from("target/ldraw-preview/omr-10030-1.png")) - } else { - None + let ParsedOptions { + input, + output, + width, + height, + proof_ms, + readback_prefix, + yaw_degrees, + edges, + library_root, + } = match parse_options(&args) { + Ok(options) => options, + Err(error) => { + eprintln!("ldraw_preview: {error}"); + std::process::exit(2); } - }); - let width = option_u32(&args, "--width").unwrap_or(1280); - let height = option_u32(&args, "--height").unwrap_or(720); - let proof_ms = option_u32(&args, "--proof-ms"); - let readback_prefix = option_path(&args, "--readback-prefix"); - let yaw_degrees = option_f32(&args, "--yaw-degrees").unwrap_or(0.0); - let library_root = option_path(&args, "--library-root") - .or_else(|| env::var_os("LDRAWDIR").map(PathBuf::from)) - .expect("--library-root or LDRAWDIR is required for official parts"); + }; let trace = NativeTrace::default(); trace.record(NativeStage::ProcessStarted); @@ -141,7 +141,12 @@ fn main() { NativeRenderOptions { width, height, - yaw_degrees: option_f32(&args, "--yaw-degrees").unwrap_or(0.0), + yaw_degrees, + style: if edges { + RenderStyle::Edges + } else { + RenderStyle::Faces + }, }, ) .expect("native three-d preview"); @@ -156,45 +161,22 @@ fn main() { return; } - run_visible_preview( + if let Err(error) = run_visible_preview( input, library_root, - width, - height, - proof_ms, - readback_prefix, - yaw_degrees, + VisiblePreviewOptions { + width, + height, + proof_ms, + readback_prefix, + yaw_degrees, + edges, + }, trace, - ); -} - -#[cfg(all(test, not(target_arch = "wasm32")))] -fn camera_pick_ray(camera: &Camera, x: f64, y: f64) -> Option { - let viewport = camera.viewport(); - if viewport.width == 0 || viewport.height == 0 { - return None; + ) { + eprintln!("ldraw_preview: {error}"); + std::process::exit(1); } - let ndc_x = 2.0 * (x - viewport.x as f64) / viewport.width as f64 - 1.0; - let ndc_y = 1.0 - 2.0 * (y - viewport.y as f64) / viewport.height as f64; - let inverse = (camera.projection() * camera.view()).invert()?; - let unproject = |z| { - let point = inverse * Vector4::new(ndc_x as f32, ndc_y as f32, z, 1.0); - (point.w.abs() > f32::EPSILON).then(|| { - [ - (point.x / point.w) as f64, - (point.y / point.w) as f64, - (point.z / point.w) as f64, - ] - }) - }; - let near = unproject(-1.0)?; - let far = unproject(1.0)?; - let delta = [far[0] - near[0], far[1] - near[1], far[2] - near[2]]; - let length = (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt(); - (length > f64::EPSILON).then_some(PickRay { - origin: near, - direction: [delta[0] / length, delta[1] / length, delta[2] / length], - }) } #[cfg(not(target_arch = "wasm32"))] @@ -219,16 +201,30 @@ fn print_camera_diagnostic( } #[cfg(not(target_arch = "wasm32"))] -fn run_visible_preview( - input: PathBuf, - library_root: PathBuf, +struct VisiblePreviewOptions { width: u32, height: u32, proof_ms: Option, readback_prefix: Option, yaw_degrees: f32, + edges: bool, +} + +#[cfg(not(target_arch = "wasm32"))] +fn run_visible_preview( + input: PathBuf, + library_root: PathBuf, + options: VisiblePreviewOptions, trace: NativeTrace, -) { +) -> Result<(), String> { + let VisiblePreviewOptions { + width, + height, + proof_ms, + readback_prefix, + yaw_degrees, + edges, + } = options; let event_loop = EventLoopBuilder::::with_user_event().build(); let window = WindowBuilder::new() .with_title("Polymodel LDraw Preview") @@ -256,7 +252,7 @@ fn run_visible_preview( let message = format!("native window context: {error}"); trace.finish(NativeTerminal::Failure(message.clone())); eprintln!("[ldraw-preview] preparation-failed error={message}"); - return; + return Err(message); } }; let mut frame_generator = three_d::FrameInputGenerator::from_winit_window(&window); @@ -280,21 +276,20 @@ fn run_visible_preview( let mut last_view: Option<[f64; 3]> = None; let mut selection_bounds: Option> = None; let mut scene: Option = None; - let mut pick_lookup = std::collections::HashMap::new(); - let mut pick_ranges = Vec::new(); + let mut scene_picker: Option = None; let mut scene_bounds = None; let mut camera = None; let mut control = None; let mut cursor = None; let mut picker_input = PickerInput::default(); + let mut orbit_dragging = false; let mut rendered = false; let mut redraw_requested = false; let mut frame_number = 0_u32; let mut resized = false; - let readback_prefix = readback_prefix.map(|path| { + let readback_prefix = readback_prefix.inspect(|path| { fs::create_dir_all(path.parent().unwrap_or_else(|| Path::new("."))) .expect("readback directory"); - path }); let proof_deadline = proof_ms.map(|milliseconds| { std::time::Instant::now() + std::time::Duration::from_millis(milliseconds as u64) @@ -309,12 +304,23 @@ fn run_visible_preview( None => { loop_trace.finish(NativeTerminal::Failure("scene has no bounds".into())); *control_flow = ControlFlow::Exit; - return; + std::process::exit(1); } }; loop_trace.record(NativeStage::NativeModelBuildStarted); - match build_scene_model_parts_for_view(&context, &prepared_scene, [0.0, 0.0, 1.0]) { - Ok((static_instanced, conditional_instanced)) => { + match build_scene_model_parts_for_style( + &context, + &prepared_scene, + [0.0, 0.0, 1.0], + if edges { RenderStyle::Edges } else { RenderStyle::Faces }, + ) { + Ok((static_instanced, conditional_instanced, picker)) => { + eprintln!( + "[ldraw-preview] edge_style={} gpu-objects static={} conditional={}", + if edges { "on" } else { "off" }, + static_instanced.len(), + conditional_instanced.as_ref().map_or(0, Vec::len), + ); loop_trace.record(NativeStage::NativeModelBuildComplete); let fit = scene_camera_fit(bounds, width, height); let position = yaw_camera_position(fit.center, fit.position, yaw_degrees); @@ -346,25 +352,11 @@ fn run_visible_preview( )); scene_bounds = Some(bounds); static_model = Some(static_instanced); - conditional_model = Some(conditional_instanced); - last_view = Some([0.0, 0.0, 1.0]); + conditional_model = conditional_instanced; + last_view = edges.then_some([0.0, 0.0, 1.0]); loop_trace.record(NativeStage::PickIndexBuildStarted); - match occurrence_gpu_ranges(&prepared_scene) { - Ok(ranges) => { - loop_trace.record(NativeStage::PickIndexBuildComplete); - pick_lookup = ranges - .iter() - .enumerate() - .map(|(index, range)| ((range.batch as u32, range.instance as u32), index)) - .collect(); - pick_ranges = ranges; - } - Err(error) => { - loop_trace.finish(NativeTerminal::Failure(format!("pick index: {error}"))); - *control_flow = ControlFlow::Exit; - return; - } - } + scene_picker = Some(picker); + loop_trace.record(NativeStage::PickIndexBuildComplete); eprintln!("[ldraw-preview] controls orbit=left-drag select=Shift+left-click camera-diagnostic=D"); scene = Some(prepared_scene); loop_trace.record(NativeStage::SceneInstalled); @@ -375,6 +367,7 @@ fn run_visible_preview( Err(error) => { loop_trace.finish(NativeTerminal::Failure(error)); *control_flow = ControlFlow::Exit; + std::process::exit(1); } } } @@ -382,6 +375,7 @@ fn run_visible_preview( loop_trace.finish(NativeTerminal::Failure(error.clone())); eprintln!("[ldraw-preview] preparation-failed error={error}"); *control_flow = ControlFlow::Exit; + std::process::exit(1); } Event::RedrawRequested(_) => { redraw_requested = false; @@ -400,18 +394,29 @@ fn run_visible_preview( } if let (Some(active_scene), Some(active_camera)) = (scene.as_ref(), camera.as_ref()) { let direction = active_camera.position() - active_camera.target(); - let view = [ - f64::from(direction.x), - f64::from(direction.y), - f64::from(direction.z), - ]; - let changed = last_view.is_none_or(|previous| { - let dot = previous[0] * view[0] + previous[1] * view[1] + previous[2] * view[2]; - dot < 0.9995 - }); - if changed { - if let Ok(model) = build_scene_model_parts_for_view(&context, active_scene, view) { - conditional_model = Some(model.1); + let length = f64::from(direction.magnitude()); + if length > f64::EPSILON { + let view = [ + f64::from(direction.x) / length, + f64::from(direction.y) / length, + f64::from(direction.z) / length, + ]; + let changed = last_view.is_none_or(|previous| { + let dot = previous[0] * view[0] + + previous[1] * view[1] + + previous[2] * view[2]; + dot < 0.9995 + }); + if edges + && changed + && !orbit_dragging + && let Ok(model) = build_edge_model_for_view( + &context, + active_scene, + view, + ) + { + conditional_model = Some(model); last_view = Some(view); } } @@ -423,7 +428,7 @@ fn run_visible_preview( loop_trace.record(NativeStage::BaseRenderStarted); } let light_refs = lights.as_refs(); - screen.render(&mut *camera, static_model, &light_refs); + screen.render(&mut *camera, &mut *static_model, &light_refs); if let Some(conditional_model) = conditional_model.as_mut() { screen.render(&mut *camera, conditional_model, &light_refs); } @@ -473,13 +478,17 @@ fn run_visible_preview( if let Err(error) = context.swap_buffers() { loop_trace.finish(NativeTerminal::Failure(format!("swap buffers: {error}"))); *control_flow = ControlFlow::Exit; - } else if rendered && frame_number == 1 { - loop_trace.record(NativeStage::FirstSwapComplete); - } else if static_model.is_some() - && proof_deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) - { - loop_trace.finish(NativeTerminal::Success); - *control_flow = ControlFlow::Exit; + } else { + if rendered && frame_number == 1 { + loop_trace.record(NativeStage::FirstSwapComplete); + } + if static_model.is_some() + && proof_deadline + .is_some_and(|deadline| std::time::Instant::now() >= deadline) + { + loop_trace.finish(NativeTerminal::Success); + *control_flow = ControlFlow::Exit; + } } } Event::WindowEvent { event, .. } => { @@ -498,13 +507,25 @@ fn run_visible_preview( state, button: MouseButton::Left, .. - } => picker_input.left_mouse(*state), + } => { + let action = picker_input.left_mouse(*state); + orbit_dragging = action == LeftMouseAction::ForwardToOrbit + && *state == ElementState::Pressed; + action + } WindowEvent::CursorMoved { .. } => picker_input.cursor_moved(), _ => LeftMouseAction::ForwardToOrbit, }; if left_mouse_action == LeftMouseAction::ForwardToOrbit { frame_generator.handle_winit_window_event(&event); - if !redraw_requested { + let affects_render = matches!( + event, + WindowEvent::Resized(_) + | WindowEvent::ScaleFactorChanged { .. } + | WindowEvent::MouseWheel { .. } + | WindowEvent::MouseInput { .. } + ) || orbit_dragging && matches!(event, WindowEvent::CursorMoved { .. }); + if affects_render && !redraw_requested { window.request_redraw(); redraw_requested = true; } @@ -547,30 +568,30 @@ fn run_visible_preview( ), ); let started = std::time::Instant::now(); - let result = (|| -> Result, String> { + let result = (|| { let (x, y) = cursor.ok_or("missing-cursor")?; let active_camera = camera.as_ref().ok_or("missing-camera")?; - let active_model = static_model.as_ref().ok_or("missing-model")?; + let picker = scene_picker.as_ref().ok_or("missing-picker")?; let viewport = active_camera.viewport(); - let gpu_y = viewport.y as f64 + viewport.height as f64 - y; - let hit = pick( - &context, - active_camera, - (x as f32, gpu_y as f32), - active_model.iter().map(|object| &object.geometry), - three_d::Cull::Back, - ) - .map_err(|error| format!("gpu-pick: {error}"))?; - hit.map(|hit| { - pick_lookup - .get(&(hit.geometry_id, hit.instance_id)) - .copied() - .ok_or_else(|| format!("unmapped-gpu-hit batch={} instance={}", hit.geometry_id, hit.instance_id)) - }) - .transpose() + let pixel = three_d::vec2( + x as f32, + viewport.y as f32 + viewport.height as f32 - y as f32, + ); + let origin = active_camera.position_at_pixel(pixel); + let direction = active_camera.view_direction_at_pixel(pixel); + picker + .pick(PickRay { + origin: [f64::from(origin.x), f64::from(origin.y), f64::from(origin.z)], + direction: [ + f64::from(direction.x), + f64::from(direction.y), + f64::from(direction.z), + ], + }) })(); match result { - Ok(Some(occurrence_index)) => { + Ok(Some(hit)) => { + let occurrence_index = hit.occurrence_index; let active_scene = scene.as_ref().expect("pick requires installed scene"); let occurrence = &active_scene.occurrences[occurrence_index]; let Ok((selected_id, selected_path, subtree)) = @@ -579,10 +600,16 @@ fn run_visible_preview( eprintln!("[ldraw-preview] selection-error error=invalid-selection-ancestry"); return; }; - let range = pick_ranges[occurrence_index]; + let Some(range) = hit.gpu else { + eprintln!("[ldraw-preview] selection-hit-without-gpu occurrence={} path={}", occurrence.canonical_occurrence_id, occurrence.canonical_path); + return; + }; let pick_ms = started.elapsed().as_secs_f64() * 1000.0; let highlight_started = std::time::Instant::now(); - let Ok(bounds) = occurrence_selection_bounds(active_scene, &subtree) else { + let Some(bounds) = scene_picker + .as_ref() + .and_then(|picker| picker.logical_part_bounds(selected_id.as_str())) + else { eprintln!("[ldraw-preview] selection-error error=selection-bounds total_selection_ms={:.3}", started.elapsed().as_secs_f64() * 1000.0); return; }; @@ -616,9 +643,9 @@ fn run_visible_preview( - transform[1][1] * transform[2][0]); let reflection = determinant < 0.0; eprintln!( - "[ldraw-preview] selection raw_hit_occurrence={} raw_hit_definition={} raw_hit_path={} raw_hit_class={} raw_hit_parents={:?} raw_hit_source={} raw_hit_target={} raw_hit_span={:?} selected_part_occurrence={} selected_part_path={} selected_part_rendered_occurrences={} colour_direct={} colour_effective={} colour_primitive={} bfc_state={} cull={} winding={} inverted={} reflection={} transform={:?} scene_occurrence={} gpu_geometry={} gpu_batch={} gpu_instance={} vertex_range={}..{} index_range={}..{} pick_ms={:.3} highlight_ms={:.3} total_selection_ms={:.3}", + "[ldraw-preview] selection raw_hit_occurrence={} raw_hit_definition={} raw_hit_path={} raw_hit_class={} raw_hit_parent={:?} raw_hit_source={} raw_hit_target={} raw_hit_span={:?} selected_part_occurrence={} selected_part_path={} selected_part_rendered_occurrences={} colour_direct={} colour_effective={} colour_primitive={} bfc_state={} cull={} winding={} inverted={} reflection={} transform={:?} scene_occurrence={} gpu_geometry={} gpu_batch={} gpu_instance={} vertex_range={}..{} index_range={}..{} pick_ms={:.3} highlight_ms={:.3} total_selection_ms={:.3}", occurrence.canonical_occurrence_id, occurrence.definition_id, occurrence.canonical_path, - occurrence.source_class, occurrence.parent_chain, occurrence.source, occurrence.target, + occurrence.source_class, occurrence.parent_occurrence_id, occurrence.source, occurrence.target, occurrence.source_span, selected_id, selected_path, subtree.len(), occurrence.direct_colour, occurrence.effective_colour, occurrence.primitive_colour, occurrence.bfc_state, occurrence.bfc_cull, @@ -642,14 +669,103 @@ fn run_visible_preview( _ => {} } } - Event::LoopDestroyed => { - if loop_trace.snapshot().terminal.is_none() { - loop_trace.finish(NativeTerminal::Success); - } + Event::LoopDestroyed if loop_trace.snapshot().terminal.is_none() => { + loop_trace.finish(NativeTerminal::Failure("preview exited before rendering".into())); } _ => {} } }); + #[allow(unreachable_code)] + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +struct ParsedOptions { + input: PathBuf, + output: Option, + width: u32, + height: u32, + proof_ms: Option, + readback_prefix: Option, + yaw_degrees: f32, + edges: bool, + library_root: PathBuf, +} + +#[cfg(not(target_arch = "wasm32"))] +fn parse_options(args: &[String]) -> Result { + let input = positional_input(args) + .unwrap_or_else(|| PathBuf::from("crates/polymodel-ldraw-testkit/corpus/omr-10030-1.mpd")); + let optional_u32 = |name: &str| -> Result, String> { + option_path(args, name) + .map(|path| { + path.to_str() + .ok_or_else(|| format!("{name} must be valid UTF-8"))? + .parse::() + .map_err(|_| format!("{name} must be a non-negative integer")) + }) + .transpose() + }; + let width = optional_u32("--width")?.unwrap_or(1280); + let height = optional_u32("--height")?.unwrap_or(720); + if width == 0 || height == 0 { + return Err("--width and --height must be greater than zero".into()); + } + let proof_ms = optional_u32("--proof-ms")?; + let yaw_degrees = option_path(args, "--yaw-degrees") + .map(|path| { + path.to_str() + .ok_or_else(|| "--yaw-degrees must be valid UTF-8".to_owned())? + .parse::() + .map_err(|_| "--yaw-degrees must be a finite number".to_owned()) + }) + .transpose()? + .unwrap_or(0.0); + if !yaw_degrees.is_finite() { + return Err("--yaw-degrees must be finite".into()); + } + let known = [ + "--output", + "--width", + "--height", + "--library-root", + "--proof-ms", + "--readback-prefix", + "--yaw-degrees", + "--edges", + ]; + for arg in args.iter().filter(|arg| arg.starts_with('-')) { + if !known.contains(&arg.as_str()) { + return Err(format!("unknown option {arg}")); + } + } + for name in [ + "--output", + "--width", + "--height", + "--library-root", + "--proof-ms", + "--readback-prefix", + "--yaw-degrees", + ] { + if args.iter().any(|arg| arg == name) && option_path(args, name).is_none() { + return Err(format!("{name} requires a value")); + } + } + let library_root = option_path(args, "--library-root") + .or_else(|| env::var_os("LDRAWDIR").map(PathBuf::from)) + .ok_or_else(|| "--library-root or LDRAWDIR is required for official parts".to_owned())?; + Ok(ParsedOptions { + input, + output: option_path(args, "--output"), + width, + height, + proof_ms, + readback_prefix: option_path(args, "--readback-prefix"), + yaw_degrees, + edges: args.iter().any(|arg| arg == "--edges"), + library_root, + }) } #[cfg(not(target_arch = "wasm32"))] @@ -684,16 +800,6 @@ fn option_path(args: &[String], name: &str) -> Option { .map(|pair| PathBuf::from(&pair[1])) } -#[cfg(not(target_arch = "wasm32"))] -fn option_u32(args: &[String], name: &str) -> Option { - option_path(args, name).and_then(|path| path.to_string_lossy().parse().ok()) -} - -#[cfg(not(target_arch = "wasm32"))] -fn option_f32(args: &[String], name: &str) -> Option { - option_path(args, name).and_then(|path| path.to_string_lossy().parse().ok()) -} - #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; @@ -708,6 +814,54 @@ mod tests { } } + #[test] + fn output_mode_requires_an_explicit_output_argument() { + assert_eq!(option_path(&[], "--output"), None); + assert_eq!( + option_path( + &["--output".to_owned(), "target/preview.png".to_owned()], + "--output", + ), + Some(PathBuf::from("target/preview.png")) + ); + } + + #[test] + fn strict_numeric_options_reject_malformed_and_missing_values() { + let malformed = vec![ + "--width".into(), + "wide".into(), + "--library-root".into(), + "/tmp/lib".into(), + ]; + assert!(parse_options(&malformed).unwrap_err().contains("--width")); + let missing = vec![ + "--height".into(), + "--library-root".into(), + "/tmp/lib".into(), + ]; + assert!(parse_options(&missing).unwrap_err().contains("--height")); + let zero = vec![ + "--width".into(), + "0".into(), + "--library-root".into(), + "/tmp/lib".into(), + ]; + assert!( + parse_options(&zero) + .unwrap_err() + .contains("greater than zero") + ); + } + + #[test] + fn render_style_defaults_to_faces_and_edges_is_explicit() { + let args = vec!["--library-root".into(), "/tmp/lib".into(), "--edges".into()]; + let options = parse_options(&args).expect("valid options"); + assert!(options.edges); + assert_eq!(RenderStyle::default(), RenderStyle::Faces); + } + #[test] fn stale_modifier_event_still_recognizes_shift_click_from_keyboard_sequence() { let mut input = PickerInput::default(); diff --git a/crates/polymodel-renderer-worker/src/lib.rs b/crates/polymodel-renderer-worker/src/lib.rs index 9b3da36..b7a5d35 100644 --- a/crates/polymodel-renderer-worker/src/lib.rs +++ b/crates/polymodel-renderer-worker/src/lib.rs @@ -5,20 +5,21 @@ use polymodel_ldraw_core::{LdMeshSemantic, ModelSummary, OwnedParseResult}; use polymodel_ldraw_core::{Materialization, NormalizedPath, RootId}; use polymodel_renderer_protocol::scene::{ GeometryId, MaterialId, NodeId, OccurrenceId, ProvenanceId, Scene, SceneBounds, SceneGeometry, - SceneId, SceneMaterial, SceneOccurrence, SceneStats, + SceneId, SceneMaterial, SceneOccurrence, ScenePrimitiveRange, SceneStats, + SceneTexmapAssociation, SceneTexmapProjectionMode, }; use polymodel_renderer_protocol::{LengthUnit, Units}; +use smol_str::SmolStr; #[cfg(not(target_arch = "wasm32"))] use std::sync::{Arc, Mutex}; #[cfg(not(target_arch = "wasm32"))] -use std::time::Instant; -#[cfg(not(target_arch = "wasm32"))] use three_d::{AmbientLight, Camera, DirectionalLight, OrbitControl}; use three_d::{ CpuMaterial, CpuMesh, Cull, Gm, Indices, InstancedMesh, Instances, Mat4, PhysicalMaterial, Positions, RenderStates, Srgba, }; +use web_time::Instant; #[cfg(not(target_arch = "wasm32"))] #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -66,24 +67,13 @@ pub struct NativeStageRecord { } #[cfg(not(target_arch = "wasm32"))] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub struct NativeTraceSnapshot { pub stages: Vec, pub records: Vec, pub terminal: Option, } -#[cfg(not(target_arch = "wasm32"))] -impl Default for NativeTraceSnapshot { - fn default() -> Self { - Self { - stages: Vec::new(), - records: Vec::new(), - terminal: None, - } - } -} - #[cfg(not(target_arch = "wasm32"))] #[derive(Clone)] pub struct NativeTrace { @@ -175,7 +165,27 @@ fn adapt_ldraw_assembly( use polymodel_ldraw_core::LineType; use std::collections::HashMap; - let mut node_by_key = HashMap::new(); + #[derive(Clone)] + struct DefinitionMetadata { + definition_id: smol_str::SmolStr, + canonical_path: smol_str::SmolStr, + source_class: smol_str::SmolStr, + } + + #[derive(Clone, Copy, PartialEq, Eq)] + struct PrimitiveBatchKey { + colour: u32, + bfc_state: polymodel_ldraw_core::BfcState, + bfc_cull: bool, + bfc_ccw: bool, + reverse_winding: bool, + } + + let diagnostics_enabled = std::env::var_os("POLYMODEL_LDRAW_ADAPTER_DIAGNOSTICS").is_some() + || std::env::var_os("POLYMODEL_LDRAW_SCENE_DIAGNOSTICS").is_some(); + let mut node_by_key = diagnostics_enabled.then(HashMap::new); + let mut nodes_by_model = HashMap::with_capacity(models.len()); + let mut metadata_by_model = HashMap::with_capacity(models.len()); if std::env::var_os("POLYMODEL_LDRAW_ADAPTER_DIAGNOSTICS").is_some() { let definitions_with_semantic_triangles = models .iter() @@ -226,18 +236,24 @@ fn adapt_ldraw_assembly( source: model.path.clone(), span: None, }); - let mut batches = Vec::<(u32, SceneGeometry)>::new(); + let mut batches = Vec::<(PrimitiveBatchKey, SceneGeometry)>::new(); for primitive in &model.geometry { - let colour = u32::from(primitive.colour); - let batch = if let Some((_, geometry)) = batches - .iter_mut() - .find(|(batch_colour, _)| *batch_colour == colour) + let key = PrimitiveBatchKey { + colour: u32::from(primitive.colour), + bfc_state: primitive.bfc.state, + bfc_cull: primitive.bfc.clipping, + bfc_ccw: matches!(primitive.bfc.winding, polymodel_ldraw_core::Winding::Ccw), + reverse_winding: primitive.inverted + ^ !matches!(primitive.bfc.winding, polymodel_ldraw_core::Winding::Ccw), + }; + let batch = if let Some((_, geometry)) = + batches.iter_mut().find(|(batch_key, _)| *batch_key == key) { geometry } else { let id = GeometryId((geometries.len() + batches.len() + 1) as u64); batches.push(( - colour, + key, SceneGeometry { id, provenance: provenance_id, @@ -246,11 +262,43 @@ fn adapt_ldraw_assembly( quads: Vec::new(), lines: Vec::new(), conditional_lines: Vec::new(), + primitives: Vec::new(), }, )); &mut batches.last_mut().expect("inserted geometry batch").1 }; let base = u32::try_from(batch.points.len()).map_err(|_| "geometry index overflow")?; + let point_count = u32::try_from(primitive.vertices.len()) + .map_err(|_| "primitive point count overflow")?; + batch.primitives.push(ScenePrimitiveRange { + point_start: base, + point_count, + topology: smol_str::format_smolstr!("{:?}", primitive.line_type), + bfc_state: smol_str::format_smolstr!("{:?}", primitive.bfc.state), + bfc_cull: primitive.bfc.clipping, + bfc_ccw: matches!(primitive.bfc.winding, polymodel_ldraw_core::Winding::Ccw), + bfc_inverted: primitive.inverted, + texmap: primitive + .texmap + .as_ref() + .map(|texmap| SceneTexmapAssociation { + mode: match texmap.mode { + polymodel_ldraw_core::TexmapProjectionMode::Planar => { + SceneTexmapProjectionMode::Planar + } + polymodel_ldraw_core::TexmapProjectionMode::Cylindrical => { + SceneTexmapProjectionMode::Cylindrical + } + polymodel_ldraw_core::TexmapProjectionMode::Spherical => { + SceneTexmapProjectionMode::Spherical + } + }, + parameters: texmap.parameters.iter().map(Into::into).collect(), + pngfile: texmap.pngfile.as_str().into(), + glossmap: texmap.glossmap.as_deref().map(Into::into), + fallback: texmap.fallback, + }), + }); for point in primitive.vertices.iter().copied() { finite_f32(point.x)?; finite_f32(point.y)?; @@ -279,14 +327,39 @@ fn adapt_ldraw_assembly( } let nodes = batches .into_iter() - .map(|(colour, geometry)| { + .map(|(key, geometry)| { let node = NodeId(geometry.id.0); geometries.push(geometry); - (node, colour) + (node, key) }) .collect::>(); - node_by_key.insert(model.cache_key.clone(), nodes); + metadata_by_model.insert( + model.model, + DefinitionMetadata { + definition_id: smol_str::format_smolstr!("{}", model.cache_key.content_hash), + canonical_path: model.cache_key.canonical_path.as_str().into(), + source_class: smol_str::format_smolstr!("{:?}", model.cache_key.resolved_root), + }, + ); + if let Some(node_by_key) = node_by_key.as_mut() { + node_by_key.insert(model.cache_key.clone(), nodes.clone()); + } + nodes_by_model.insert(model.model, nodes); } + let edge_colour_for = |colour: u32| { + parsed + .and_then(|parsed| { + parsed + .result + .colours + .get(polymodel_ldraw_core::ColourCode(colour)) + }) + .and_then(|colour| match &colour.edge { + polymodel_ldraw_core::ColourEdge::Code(code) => Some(u32::from(*code)), + polymodel_ldraw_core::ColourEdge::Data(_) => None, + }) + .unwrap_or(0) + }; let mut material_for = |colour: u32| { *materials_by_colour.entry(colour).or_insert_with(|| { let id = MaterialId((materials.len() + 1) as u64); @@ -303,7 +376,9 @@ fn adapt_ldraw_assembly( let instances = parsed_scene .map(|scene| scene.instances.as_slice()) .unwrap_or(&[]); - if std::env::var_os("POLYMODEL_LDRAW_ADAPTER_DIAGNOSTICS").is_some() { + if let Some(node_by_key) = node_by_key.as_ref() + && std::env::var_os("POLYMODEL_LDRAW_ADAPTER_DIAGNOSTICS").is_some() + { let mut exact_hits = 0usize; let mut exact_misses = 0usize; for (index, occurrence) in instances.iter().enumerate().take(1024) { @@ -337,128 +412,137 @@ fn adapt_ldraw_assembly( [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0], ]; - let root_batches = node_by_key - .get(&models[0].cache_key) + let root_batches = nodes_by_model + .get(&models[0].model) .ok_or("root model has no geometry batches")?; + let root_metadata = metadata_by_model + .get(&models[0].model) + .ok_or("root model has no definition metadata")?; let mut occurrences = Vec::new(); - for &(node, primitive_colour) in root_batches { - if primitive_colour == 24 { - continue; - } - let effective_colour = if primitive_colour == 16 { - u32::from(polymodel_ldraw_core::DEFAULT_COLOUR) - } else { - primitive_colour + for (node, primitive) in root_batches { + let inherited_colour = u32::from(polymodel_ldraw_core::DEFAULT_COLOUR); + let effective_colour = match primitive.colour { + 16 => inherited_colour, + 24 => edge_colour_for(inherited_colour), + colour => colour, }; occurrences.push(SceneOccurrence { id: OccurrenceId((occurrences.len() + 1) as u64), - canonical_occurrence_id: "root".to_owned(), - parent_chain: Vec::new(), + canonical_occurrence_id: "root".into(), + parent_occurrence_id: None, logical_part_occurrence_id: None, logical_part_path: None, - node, - definition_id: models[0].cache_key.content_hash.to_string(), - canonical_path: models[0].cache_key.canonical_path.to_string(), - source_class: format!("{:?}", models[0].cache_key.resolved_root), + node: *node, + definition_id: root_metadata.definition_id.clone(), + canonical_path: root_metadata.canonical_path.clone(), + source_class: root_metadata.source_class.clone(), source: models[0].path.clone(), target: models[0].path.clone(), source_span: None, direct_colour: u32::from(polymodel_ldraw_core::DEFAULT_COLOUR), effective_colour, - primitive_colour, + primitive_colour: primitive.colour, material: Some(material_for(effective_colour)), - bfc_state: format!("{:?}", models[0].bfc), - bfc_cull: matches!(models[0].bfc, polymodel_ldraw_core::BfcState::Certified), - bfc_ccw: true, - bfc_inverted: false, + bfc_state: smol_str::format_smolstr!("{:?}", primitive.bfc_state), + bfc_cull: primitive.bfc_cull, + bfc_ccw: primitive.bfc_ccw, + bfc_inverted: primitive.reverse_winding, transform: identity, }); } - let instance_paths = instances - .iter() - .filter_map(|instance| { - instance.target_key.as_ref().map(|key| { - ( - instance.instance_id.as_str(), - key.canonical_path.to_string(), - ) - }) - }) - .collect::>(); let meaningful_part = |path: &str| path.starts_with("parts/") && !path.starts_with("parts/s/"); + let mut logical_parts = + Vec::>::with_capacity(instances.len()); for occurrence in instances { - let logical_part = occurrence - .parent_chain - .iter() - .enumerate() - .rev() - .filter(|(depth, _)| *depth > 0) - .filter_map(|(_, id)| instance_paths.get(id.as_str()).map(|path| (id, path))) - .find(|(_, path)| meaningful_part(path)); + let inherited_logical_part = occurrence + .parent + .and_then(|parent| logical_parts.get(parent as usize)) + .cloned() + .flatten(); let target_key = occurrence .target_key .as_ref() .ok_or("renderable occurrence missing target identity")?; - let batches = node_by_key - .get(target_key) - .ok_or("renderable occurrence target identity was not materialized")?; + let batches = nodes_by_model + .get(&occurrence.model) + .ok_or("occurrence model was not materialized")?; + let metadata = metadata_by_model + .get(&occurrence.model) + .ok_or("occurrence model has no definition metadata")?; + let logical_part = inherited_logical_part.or_else(|| { + meaningful_part(target_key.canonical_path.as_str()).then(|| { + ( + occurrence.instance_id.clone(), + target_key.canonical_path.as_str().into(), + ) + }) + }); + logical_parts.push(logical_part.clone()); + let parent_occurrence_id = occurrence + .parent + .and_then(|parent| instances.get(parent as usize)) + .map(|parent| parent.instance_id.clone()); let inherited_colour = occurrence.colour.map(u32::from).unwrap_or(16); let transform = world_transform(occurrence.transform)?; - for &(node, primitive_colour) in batches { - if primitive_colour == 24 { - continue; - } - let effective_colour = if primitive_colour == 16 { - inherited_colour - } else { - primitive_colour + for (node, primitive) in batches { + let effective_colour = match primitive.colour { + 16 => inherited_colour, + 24 => edge_colour_for(inherited_colour), + colour => colour, }; - let bfc = occurrence.bfc.unwrap_or_default(); occurrences.push(SceneOccurrence { id: OccurrenceId((occurrences.len() + 1) as u64), canonical_occurrence_id: occurrence.instance_id.clone(), - parent_chain: occurrence.parent_chain.clone(), - logical_part_occurrence_id: logical_part.map(|(id, _)| id.clone()).or_else(|| { - meaningful_part(&target_key.canonical_path.to_string()) - .then(|| occurrence.instance_id.clone()) - }), - logical_part_path: logical_part.map(|(_, path)| (*path).clone()).or_else(|| { - meaningful_part(&target_key.canonical_path.to_string()) - .then(|| target_key.canonical_path.to_string()) - }), - node, - definition_id: target_key.content_hash.to_string(), - canonical_path: target_key.canonical_path.to_string(), - source_class: format!("{:?}", target_key.resolved_root), + parent_occurrence_id: parent_occurrence_id.clone(), + logical_part_occurrence_id: logical_part.as_ref().map(|(id, _)| id.clone()), + logical_part_path: logical_part.as_ref().map(|(_, path)| path.clone()), + node: *node, + definition_id: metadata.definition_id.clone(), + canonical_path: metadata.canonical_path.clone(), + source_class: metadata.source_class.clone(), source: occurrence.source_model.clone(), target: occurrence.target_model.clone(), source_span: occurrence .source_span - .map(|span| [span.start, span.end, span.line]), + .map(|span| [span.start, span.end, span.line, span.column]), direct_colour: occurrence.direct_colour.map(u32::from).unwrap_or(16), effective_colour, - primitive_colour, + primitive_colour: primitive.colour, material: Some(material_for(effective_colour)), - bfc_state: format!("{:?}", bfc.state), - bfc_cull: bfc.clipping, - bfc_ccw: matches!(bfc.winding, polymodel_ldraw_core::Winding::Ccw), - bfc_inverted: occurrence.inverted, + bfc_state: smol_str::format_smolstr!("{:?}", primitive.bfc_state), + bfc_cull: primitive.bfc_cull, + bfc_ccw: primitive.bfc_ccw, + bfc_inverted: occurrence.inverted ^ primitive.reverse_winding, transform, }); } } - drop(material_for); - let mut bounds_points = Vec::new(); - for occurrence in &occurrences { - let geometry = geometries - .get(occurrence.node.0.saturating_sub(1) as usize) - .ok_or("occurrence node is outside geometry table")?; - for point in &geometry.points { - bounds_points.push(apply_scene_transform(occurrence.transform, *point)?); - } - } - let bounds = SceneBounds::from_points(bounds_points); - if std::env::var_os("POLYMODEL_LDRAW_SCENE_DIAGNOSTICS").is_some() { + let geometry_indices = geometries + .iter() + .enumerate() + .map(|(index, geometry)| (geometry.id, index)) + .collect::>(); + let bounds = parsed_scene + .and_then(|scene| scene.bounds) + .map(|bounds| SceneBounds { + min: [bounds.min.x, bounds.min.z, -bounds.max.y], + max: [bounds.max.x, bounds.max.z, -bounds.min.y], + }) + .or_else(|| { + let mut bounds_points = Vec::new(); + for occurrence in &occurrences { + let geometry = geometry_indices + .get(&GeometryId(occurrence.node.0)) + .and_then(|index| geometries.get(*index))?; + for point in &geometry.points { + bounds_points.push(apply_scene_transform(occurrence.transform, *point).ok()?); + } + } + SceneBounds::from_points(bounds_points) + }); + if let Some(node_by_key) = node_by_key.as_ref() + && std::env::var_os("POLYMODEL_LDRAW_SCENE_DIAGNOSTICS").is_some() + { #[derive(Debug)] struct OccurrenceBoundsDiagnostic<'a> { max_extent: f64, @@ -506,7 +590,10 @@ fn adapt_ldraw_assembly( continue; }; for &(node, _) in batches { - let Some(geometry) = geometries.get(node.0.saturating_sub(1) as usize) else { + let Some(geometry) = geometry_indices + .get(&GeometryId(node.0)) + .and_then(|index| geometries.get(*index)) + else { continue; }; for point in &geometry.points { @@ -612,7 +699,7 @@ fn adapt_ldraw_assembly( id: polymodel_renderer_protocol::scene::DiagnosticId( (index + 1) as u64, ), - message: diagnostic.message.clone(), + message: diagnostic.message.as_str().into(), } }) .collect() @@ -626,12 +713,7 @@ fn adapt_ldraw_assembly( bounds, stats: SceneStats::default(), }; - scene.recompute_stats(); - if let Some(parsed_scene) = parsed_scene { - scene.stats.triangles = parsed_scene.triangles + parsed_scene.quads * 2; - scene.stats.lines = parsed_scene.lines; - scene.stats.conditional_lines = parsed_scene.conditional_lines; - } + scene.recompute_stats()?; Ok(scene) } @@ -654,10 +736,7 @@ fn world_transform( }; let translation = apply(origin)?; let axes = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; - let columns = axes - .map(|axis| apply(axis)) - .into_iter() - .collect::, _>>()?; + let columns = axes.map(apply).into_iter().collect::, _>>()?; Ok([ [ columns[0][0] - translation[0], @@ -700,12 +779,76 @@ fn apply_scene_transform( } } -fn transform_reflection(transform: [[f64; 4]; 4]) -> bool { - let determinant = transform[0][0] - * (transform[1][1] * transform[2][2] - transform[1][2] * transform[2][1]) - - transform[0][1] * (transform[1][0] * transform[2][2] - transform[1][2] * transform[2][0]) - + transform[0][2] * (transform[1][0] * transform[2][1] - transform[1][1] * transform[2][0]); - determinant < 0.0 +#[derive(Debug, Clone, Copy, PartialEq)] +struct InverseSceneTransform { + linear: [[f64; 3]; 3], + translation: [f64; 3], +} + +impl InverseSceneTransform { + fn new(transform: [[f64; 4]; 4]) -> Option { + let a = transform[0][0]; + let b = transform[1][0]; + let c = transform[2][0]; + let d = transform[0][1]; + let e = transform[1][1]; + let f = transform[2][1]; + let g = transform[0][2]; + let h = transform[1][2]; + let i = transform[2][2]; + let determinant = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g); + if !determinant.is_finite() || determinant.abs() <= f64::EPSILON { + return None; + } + let reciprocal = determinant.recip(); + let linear = [ + [ + (e * i - f * h) * reciprocal, + (c * h - b * i) * reciprocal, + (b * f - c * e) * reciprocal, + ], + [ + (f * g - d * i) * reciprocal, + (a * i - c * g) * reciprocal, + (c * d - a * f) * reciprocal, + ], + [ + (d * h - e * g) * reciprocal, + (b * g - a * h) * reciprocal, + (a * e - b * d) * reciprocal, + ], + ]; + let world_translation = [transform[3][0], transform[3][1], transform[3][2]]; + let translation = linear.map(|row| { + -(row[0] * world_translation[0] + + row[1] * world_translation[1] + + row[2] * world_translation[2]) + }); + linear + .iter() + .flatten() + .chain(translation.iter()) + .all(|value| value.is_finite()) + .then_some(Self { + linear, + translation, + }) + } + + fn ray(self, ray: PickRay) -> PickRay { + let apply = |value: [f64; 3]| { + self.linear + .map(|row| row[0] * value[0] + row[1] * value[1] + row[2] * value[2]) + }; + let mut origin = apply(ray.origin); + for (value, translation) in origin.iter_mut().zip(self.translation) { + *value += translation; + } + PickRay { + origin, + direction: apply(ray.direction), + } + } } /// Convert a world-space camera direction to the local space of one occurrence. @@ -753,25 +896,25 @@ pub fn conditional_line_view_for_transform( .then_some(result) } -fn scene_transform_to_mat4(matrix: [[f64; 4]; 4]) -> Mat4 { - Mat4::new( - matrix[0][0] as f32, - matrix[0][1] as f32, - matrix[0][2] as f32, - matrix[0][3] as f32, - matrix[1][0] as f32, - matrix[1][1] as f32, - matrix[1][2] as f32, - matrix[1][3] as f32, - matrix[2][0] as f32, - matrix[2][1] as f32, - matrix[2][2] as f32, - matrix[2][3] as f32, - matrix[3][0] as f32, - matrix[3][1] as f32, - matrix[3][2] as f32, - matrix[3][3] as f32, - ) +fn scene_transform_to_mat4(matrix: [[f64; 4]; 4]) -> Result { + Ok(Mat4::new( + finite_f32(matrix[0][0])?, + finite_f32(matrix[0][1])?, + finite_f32(matrix[0][2])?, + finite_f32(matrix[0][3])?, + finite_f32(matrix[1][0])?, + finite_f32(matrix[1][1])?, + finite_f32(matrix[1][2])?, + finite_f32(matrix[1][3])?, + finite_f32(matrix[2][0])?, + finite_f32(matrix[2][1])?, + finite_f32(matrix[2][2])?, + finite_f32(matrix[2][3])?, + finite_f32(matrix[3][0])?, + finite_f32(matrix[3][1])?, + finite_f32(matrix[3][2])?, + finite_f32(matrix[3][3])?, + )) } #[derive(Clone, Copy, Debug, PartialEq)] @@ -905,7 +1048,14 @@ pub fn prepare_preview_scene( .ok_or_else(|| format!("preview input has no UTF-8 file name: {}", input.display()))?; let path = NormalizedPath::new(logical_name) .map_err(|error| format!("preview input path: {error}"))?; + let started = Instant::now(); let closure = official_materializations(library_root, &path, &bytes)?; + eprintln!( + "[ldraw-preview] preparation-stage=closure elapsed_ms={} materializations={} selections={}", + started.elapsed().as_millis(), + closure.materializations.len(), + closure.target_selections.len(), + ); let mut materializations = closure.materializations; let target_selections = closure.target_selections; materializations.insert( @@ -1243,12 +1393,25 @@ pub fn native_png_bytes(width: u32, height: u32, pixels: &[u8]) -> Vec { output } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RenderStyle { + Faces, + Edges, +} + +impl Default for RenderStyle { + fn default() -> Self { + Self::Faces + } +} + #[cfg(not(target_arch = "wasm32"))] #[derive(Clone, Copy, Debug)] pub struct NativeRenderOptions { pub width: u32, pub height: u32, pub yaw_degrees: f32, + pub style: RenderStyle, } #[cfg(not(target_arch = "wasm32"))] @@ -1258,6 +1421,7 @@ impl Default for NativeRenderOptions { width: 1280, height: 720, yaw_degrees: 0.0, + style: RenderStyle::default(), } } } @@ -1317,7 +1481,27 @@ pub fn scene_oriented_mesh_topology_for_view( reflection: bool, view: [f64; 3], ) -> Result { - let mut topology = scene_mesh_topology_for_view(geometry, view)?; + scene_oriented_mesh_topology_for_view_kind( + geometry, + bfc_cull, + bfc_ccw, + bfc_inverted, + reflection, + view, + SceneTopologyKind::Complete, + ) +} + +fn scene_oriented_mesh_topology_for_view_kind( + geometry: &SceneGeometry, + bfc_cull: bool, + bfc_ccw: bool, + bfc_inverted: bool, + reflection: bool, + view: [f64; 3], + kind: SceneTopologyKind, +) -> Result { + let mut topology = scene_mesh_topology_for_view_kind(geometry, view, kind)?; // `bfc_inverted` is the authoritative effective winding bit produced by // traversal. It already includes accumulated transform reflection; applying // `reflection` here would cancel that parity for reflected occurrences. @@ -1339,15 +1523,24 @@ pub fn scene_mesh_topology_for_view( geometry: &SceneGeometry, view: [f64; 3], ) -> Result { - scene_mesh_topology_for_view_kind(geometry, view, true) + scene_mesh_topology_for_view_kind(geometry, view, SceneTopologyKind::Complete) +} + +#[derive(Clone, Copy)] +enum SceneTopologyKind { + Faces, + Complete, } fn scene_mesh_topology_for_view_kind( geometry: &SceneGeometry, view: [f64; 3], - include_conditional: bool, + kind: SceneTopologyKind, ) -> Result { - let mut positions = geometry + let include_faces = matches!(kind, SceneTopologyKind::Faces | SceneTopologyKind::Complete); + let include_explicit = matches!(kind, SceneTopologyKind::Complete); + let include_conditional = matches!(kind, SceneTopologyKind::Complete); + let source_positions = geometry .points .iter() .map(|point| { @@ -1362,30 +1555,43 @@ fn scene_mesh_topology_for_view_kind( } }) .collect::, _>>()?; + let mut positions = if include_faces { + source_positions.clone() + } else { + Vec::new() + }; let mut indices = Vec::with_capacity( - geometry.triangles.len() * 3 - + geometry.quads.len() * 6 - + (geometry.lines.len() + geometry.conditional_lines.len()) * 6, + usize::from(include_faces) * (geometry.triangles.len() * 3 + geometry.quads.len() * 6) + + usize::from(include_explicit) * geometry.lines.len() * 6 + + usize::from(include_conditional) * geometry.conditional_lines.len() * 6, ); - for triangle in &geometry.triangles { - indices.extend_from_slice(triangle); - } - for quad in &geometry.quads { - indices.extend_from_slice(&[quad[0], quad[1], quad[2], quad[0], quad[2], quad[3]]); + if include_faces { + for triangle in &geometry.triangles { + indices.extend_from_slice(triangle); + } + for quad in &geometry.quads { + indices.extend_from_slice(&[quad[0], quad[1], quad[2], quad[0], quad[2], quad[3]]); + } } let mut add_line = |line: [u32; 2]| -> Result<(), String> { - let start = positions.get(line[0] as usize).copied().ok_or_else(|| { - format!( - "geometry {} line start is outside its position domain", - geometry.id.0 - ) - })?; - let end = positions.get(line[1] as usize).copied().ok_or_else(|| { - format!( - "geometry {} line end is outside its position domain", - geometry.id.0 - ) - })?; + let start = source_positions + .get(line[0] as usize) + .copied() + .ok_or_else(|| { + format!( + "geometry {} line start is outside its position domain", + geometry.id.0 + ) + })?; + let end = source_positions + .get(line[1] as usize) + .copied() + .ok_or_else(|| { + format!( + "geometry {} line end is outside its position domain", + geometry.id.0 + ) + })?; let direction = [ f64::from(end[0] - start[0]), f64::from(end[1] - start[1]), @@ -1458,8 +1664,10 @@ fn scene_mesh_topology_for_view_kind( indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); Ok(()) }; - for line in geometry.lines.iter().copied() { - add_line(line)?; + if include_explicit { + for line in geometry.lines.iter().copied() { + add_line(line)?; + } } if include_conditional { for conditional in &geometry.conditional_lines { @@ -1524,7 +1732,9 @@ pub struct ScenePickHit { pub occurrence_index: usize, pub triangle_index: usize, pub distance: f64, - pub gpu: OccurrenceGpuRange, + /// GPU range when this occurrence was uploaded as face geometry. + /// Edge-only and empty-face occurrences are intentionally `None`. + pub gpu: Option, } fn ray_triangle_distance(ray: PickRay, a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> Option { @@ -1559,266 +1769,738 @@ fn ray_triangle_distance(ray: PickRay, a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> (distance > 1.0e-9 && distance.is_finite()).then_some(distance) } -pub fn occurrence_gpu_ranges(scene: &Scene) -> Result, String> { - let batches = scene_instance_batches(scene)?; - let default_material = scene - .materials - .first() - .map(|value| value.id) - .unwrap_or(MaterialId(1)); - let batch_indices = batches - .iter() - .enumerate() - .map(|(index, batch)| { - ( - ( - batch.node, - batch.material, - batch.bfc_cull, - batch.bfc_ccw, - batch.bfc_inverted, - ), - index, - ) - }) - .collect::>(); - let mut next_instances = vec![0_usize; batches.len()]; - let mut topology_ranges = vec![None; scene.geometries.len()]; - let mut ranges = Vec::with_capacity(scene.occurrences.len()); - for occurrence in &scene.occurrences { - let material = occurrence.material.unwrap_or(default_material); - let batch = batch_indices - .get(&( - occurrence.node, - material, - occurrence.bfc_cull, - occurrence.bfc_ccw, - occurrence.bfc_inverted, - )) - .copied() - .ok_or("occurrence has no native GPU batch")?; - let instance = next_instances[batch]; - next_instances[batch] += 1; - let geometry_index = occurrence.node.0.saturating_sub(1) as usize; - let geometry = scene - .geometries - .get(geometry_index) - .ok_or("occurrence geometry is outside the scene table")?; - let (vertex_count, index_count) = match topology_ranges[geometry_index] { - Some(counts) => counts, - None => { - let topology = scene_mesh_topology(geometry)?; - let counts = ( - u32::try_from(topology.positions.len()).map_err(|_| "vertex range overflow")?, - u32::try_from(topology.indices.len()).map_err(|_| "index range overflow")?, - ); - topology_ranges[geometry_index] = Some(counts); - counts - } - }; - ranges.push(OccurrenceGpuRange { - batch, - instance, - geometry: geometry.id, - vertex_start: 0, - vertex_count, - index_start: 0, - index_count, - }); - } - Ok(ranges) +#[derive(Debug, Clone, PartialEq)] +struct SceneIndex { + geometries: std::collections::HashMap, + materials: std::collections::HashMap, } -pub fn pick_scene(scene: &Scene, ray: PickRay) -> Result, String> { - let ranges = occurrence_gpu_ranges(scene)?; - let mut nearest: Option = None; - for (occurrence_index, occurrence) in scene.occurrences.iter().enumerate() { - let geometry = scene - .geometries - .get(occurrence.node.0.saturating_sub(1) as usize) - .ok_or("occurrence geometry is outside the scene table")?; - let topology = scene_mesh_topology(geometry)?; - for (triangle_index, indices) in topology.indices.chunks_exact(3).enumerate() { - let point = |index: u32| { - apply_scene_transform(occurrence.transform, geometry.points[index as usize]) - }; - let distance = ray_triangle_distance( - ray, - point(indices[0])?, - point(indices[1])?, - point(indices[2])?, - ); - if let Some(distance) = distance - && nearest.as_ref().is_none_or(|hit| distance < hit.distance) +impl SceneIndex { + fn new(scene: &Scene) -> Result { + let mut geometries = std::collections::HashMap::with_capacity(scene.geometries.len()); + for (index, geometry) in scene.geometries.iter().enumerate() { + if geometry.id.0 == 0 { + return Err("geometry id zero is invalid".to_owned()); + } + if geometries.insert(geometry.id, index).is_some() { + return Err(format!("duplicate geometry id {}", geometry.id.0)); + } + } + let mut materials = std::collections::HashMap::with_capacity(scene.materials.len()); + for (index, material) in scene.materials.iter().enumerate() { + if material.id.0 == 0 { + return Err("material id zero is invalid".to_owned()); + } + if materials.insert(material.id, index).is_some() { + return Err(format!("duplicate material id {}", material.id.0)); + } + } + let index = Self { + geometries, + materials, + }; + index.geometry(scene, scene.root)?; + for occurrence in &scene.occurrences { + index.geometry(scene, occurrence.node)?; + if let Some(material) = occurrence.material { + index.material(scene, material)?; + } + if occurrence + .transform + .iter() + .flatten() + .any(|value| !value.is_finite()) { - nearest = Some(ScenePickHit { - occurrence_index, - triangle_index, - distance, - gpu: ranges[occurrence_index], - }); + return Err(format!( + "occurrence {} has a non-finite transform", + occurrence.id.0 + )); } } + Ok(index) + } + + fn geometry<'a>(&self, scene: &'a Scene, node: NodeId) -> Result<&'a SceneGeometry, String> { + let id = GeometryId(node.0); + self.geometries + .get(&id) + .and_then(|index| scene.geometries.get(*index)) + .ok_or_else(|| format!("unknown geometry id {}", node.0)) + } + + fn geometry_index(&self, node: NodeId) -> Result { + self.geometries + .get(&GeometryId(node.0)) + .copied() + .ok_or_else(|| format!("unknown geometry id {}", node.0)) + } + + fn material<'a>(&self, scene: &'a Scene, id: MaterialId) -> Result<&'a SceneMaterial, String> { + self.materials + .get(&id) + .and_then(|index| scene.materials.get(*index)) + .ok_or_else(|| format!("unknown material id {}", id.0)) } - Ok(nearest) } -pub fn occurrence_selection_bounds( - scene: &Scene, - occurrence_indices: &[usize], -) -> Result { - let mut points = Vec::new(); - for index in occurrence_indices { - let occurrence = scene - .occurrences - .get(*index) - .ok_or("selected occurrence is outside the scene table")?; - let geometry = scene - .geometries - .get(occurrence.node.0.saturating_sub(1) as usize) - .ok_or("selected occurrence geometry is outside the scene table")?; - for point in &geometry.points { - points.push(apply_scene_transform(occurrence.transform, *point)?); +pub fn occurrence_gpu_ranges(scene: &Scene) -> Result>, String> { + Ok(scene_render_plan(scene)?.occurrence_ranges) +} + +fn finalized_upload_batches( + index: &SceneIndex, + batches: &[SceneInstanceBatch], + topologies: &[SceneMeshTopology], +) -> Result<(Vec, Vec>), String> { + let mut upload_batches = Vec::new(); + let mut planned_to_upload = vec![None; batches.len()]; + for (planned_index, batch) in batches.iter().enumerate() { + let geometry_index = index.geometry_index(batch.node)?; + let topology = topologies + .get(geometry_index) + .ok_or("render plan is missing geometry topology")?; + if topology.indices.is_empty() { + continue; } + planned_to_upload[planned_index] = Some(upload_batches.len()); + upload_batches.push(batch.clone()); } - SceneBounds::from_points(points) - .ok_or_else(|| "selected subtree has no finite bounds".to_owned()) + Ok((upload_batches, planned_to_upload)) } -pub fn logical_part_selection( +fn occurrence_gpu_ranges_from_batches( scene: &Scene, - raw_index: usize, -) -> Result<(String, String, Vec), String> { - let raw = scene - .occurrences - .get(raw_index) - .ok_or("raw occurrence is outside the scene table")?; - let selected = raw - .logical_part_occurrence_id - .as_ref() - .zip(raw.logical_part_path.as_ref()) - .map(|(id, path)| (id.clone(), path.clone())) - .unwrap_or_else(|| { - ( - raw.canonical_occurrence_id.clone(), - raw.canonical_path.clone(), - ) - }); - let subtree = scene - .occurrences - .iter() - .enumerate() - .filter_map(|(index, occurrence)| { - (occurrence.canonical_occurrence_id == selected.0 - || occurrence.parent_chain.iter().any(|id| id == &selected.0)) - .then_some(index) - }) - .collect(); - Ok((selected.0, selected.1, subtree)) -} + index: &SceneIndex, + batches: &[SceneInstanceBatch], + topologies: &[SceneMeshTopology], +) -> Result>, String> { + let mut ranges = vec![None; scene.occurrences.len()]; -#[cfg(not(target_arch = "wasm32"))] -pub fn build_scene_occurrence_models( - context: &three_d::Context, - scene: &Scene, - occurrence_indices: &[usize], -) -> Result>, String> { - let mut selected = scene.clone(); - selected.occurrences = occurrence_indices - .iter() - .map(|index| { - scene - .occurrences - .get(*index) - .cloned() - .ok_or_else(|| "selected occurrence is outside the scene table".to_owned()) - }) - .collect::>()?; - build_scene_model(context, &selected) + for (upload_batch_index, batch) in batches.iter().enumerate() { + let geometry_index = index.geometry_index(batch.node)?; + let topology = topologies + .get(geometry_index) + .ok_or("render plan is missing geometry topology")?; + let vertex_count = + u32::try_from(topology.positions.len()).map_err(|_| "vertex range overflow")?; + let index_count = + u32::try_from(topology.indices.len()).map_err(|_| "index range overflow")?; + for (instance_index, instance) in batch.instances.iter().enumerate() { + let range = ranges + .get_mut(instance.occurrence_index) + .ok_or("batch references an unknown occurrence")?; + if range.is_some() { + return Err("occurrence appears in multiple native GPU batches".to_owned()); + } + *range = Some(OccurrenceGpuRange { + batch: upload_batch_index, + instance: instance_index, + geometry: GeometryId(batch.node.0), + vertex_start: 0, + vertex_count, + index_start: 0, + index_count, + }); + } + } + + Ok(ranges) } -#[cfg(not(target_arch = "wasm32"))] -pub fn build_scene_occurrence_model( - context: &three_d::Context, - scene: &Scene, - occurrence_index: usize, -) -> Result>, String> { - build_scene_occurrence_models(context, scene, &[occurrence_index]) +#[derive(Debug, Clone, Copy, PartialEq)] +struct PickBounds { + min: [f64; 3], + max: [f64; 3], } -#[derive(Debug, Clone, PartialEq)] -pub struct SceneInstanceBatch { - pub node: NodeId, - pub material: MaterialId, - pub bfc_cull: bool, +impl PickBounds { + fn transformed(self, transform: [[f64; 4]; 4]) -> Option { + let center = [ + (self.min[0] + self.max[0]) * 0.5, + (self.min[1] + self.max[1]) * 0.5, + (self.min[2] + self.max[2]) * 0.5, + ]; + let half = [ + (self.max[0] - self.min[0]) * 0.5, + (self.max[1] - self.min[1]) * 0.5, + (self.max[2] - self.min[2]) * 0.5, + ]; + let center = apply_scene_transform(transform, center).ok()?; + let extent = [0, 1, 2].map(|axis| { + transform[0][axis].abs() * half[0] + + transform[1][axis].abs() * half[1] + + transform[2][axis].abs() * half[2] + }); + center + .iter() + .chain(extent.iter()) + .all(|value| value.is_finite()) + .then_some(Self { + min: [ + center[0] - extent[0], + center[1] - extent[1], + center[2] - extent[2], + ], + max: [ + center[0] + extent[0], + center[1] + extent[1], + center[2] + extent[2], + ], + }) + } + + fn from_points(points: impl IntoIterator) -> Option { + let mut bounds = Self { + min: [f64::INFINITY; 3], + max: [f64::NEG_INFINITY; 3], + }; + let mut any = false; + for point in points { + if point.iter().any(|value| !value.is_finite()) { + return None; + } + any = true; + for axis in 0..3 { + bounds.min[axis] = bounds.min[axis].min(point[axis]); + bounds.max[axis] = bounds.max[axis].max(point[axis]); + } + } + any.then_some(bounds) + } + + fn union(self, other: Self) -> Self { + let mut result = self; + for axis in 0..3 { + result.min[axis] = result.min[axis].min(other.min[axis]); + result.max[axis] = result.max[axis].max(other.max[axis]); + } + result + } + + fn intersects(self, ray: PickRay, maximum: f64) -> bool { + let mut near = 0.0_f64; + let mut far = maximum; + for axis in 0..3 { + if ray.direction[axis].abs() <= 1.0e-15 { + if ray.origin[axis] < self.min[axis] || ray.origin[axis] > self.max[axis] { + return false; + } + continue; + } + let inverse = ray.direction[axis].recip(); + let mut axis_near = (self.min[axis] - ray.origin[axis]) * inverse; + let mut axis_far = (self.max[axis] - ray.origin[axis]) * inverse; + if axis_near > axis_far { + std::mem::swap(&mut axis_near, &mut axis_far); + } + near = near.max(axis_near); + far = far.min(axis_far); + if near > far { + return false; + } + } + far >= 0.0 + } +} + +#[derive(Debug, Clone, PartialEq)] +struct PickOccurrence { + occurrence_index: usize, + geometry_index: usize, + transform: [[f64; 4]; 4], + inverse: Option, + bounds: PickBounds, +} + +#[derive(Debug, Clone)] +struct PickBvhNode { + bounds: PickBounds, + children: Option<[usize; 2]>, + range: std::ops::Range, +} + +#[derive(Debug, Clone)] +struct PickBvh { + nodes: Vec, + indices: Vec, +} + +impl PickBvh { + const LEAF_SIZE: usize = 16; + + fn new(item_bounds: &[PickBounds]) -> Option { + if item_bounds.is_empty() { + return None; + } + let mut bvh = Self { + nodes: Vec::with_capacity(item_bounds.len() / Self::LEAF_SIZE * 2), + indices: (0..item_bounds.len()).collect(), + }; + bvh.build_node(0..item_bounds.len(), item_bounds); + Some(bvh) + } + + fn build_node(&mut self, range: std::ops::Range, item_bounds: &[PickBounds]) -> usize { + let bounds = self.indices[range.clone()] + .iter() + .map(|index| item_bounds[*index]) + .reduce(PickBounds::union) + .expect("BVH nodes are never empty"); + let node_index = self.nodes.len(); + self.nodes.push(PickBvhNode { + bounds, + children: None, + range: range.clone(), + }); + if range.len() <= Self::LEAF_SIZE { + return node_index; + } + let extents = [ + bounds.max[0] - bounds.min[0], + bounds.max[1] - bounds.min[1], + bounds.max[2] - bounds.min[2], + ]; + let axis = (0..3) + .max_by(|a, b| extents[*a].total_cmp(&extents[*b])) + .expect("three axes"); + let middle = range.start + range.len() / 2; + self.indices[range.clone()].select_nth_unstable_by(middle - range.start, |a, b| { + let center = |index: usize| { + let bounds = item_bounds[index]; + bounds.min[axis] + bounds.max[axis] + }; + center(*a).total_cmp(¢er(*b)) + }); + let left = self.build_node(range.start..middle, item_bounds); + let right = self.build_node(middle..range.end, item_bounds); + self.nodes[node_index].children = Some([left, right]); + node_index + } + + fn candidates(&self, ray: PickRay, maximum: f64, output: &mut Vec) { + let mut stack = vec![0]; + while let Some(node_index) = stack.pop() { + let node = &self.nodes[node_index]; + if !node.bounds.intersects(ray, maximum) { + continue; + } + if let Some([left, right]) = node.children { + stack.push(right); + stack.push(left); + } else { + output.extend_from_slice(&self.indices[node.range.clone()]); + } + } + } +} + +#[derive(Debug, Clone)] +pub struct ScenePicker { + topologies: Vec, + occurrences: Vec, + logical_parts: Vec>, + logical_bounds: Vec, + ranges: Vec>, + logical_part_bounds: std::collections::HashMap, + bvh: Option, +} + +impl ScenePicker { + pub fn new(scene: &Scene, plan: SceneRenderPlan) -> Result { + if plan.pick_occurrences.len() != scene.occurrences.len() { + return Err("render plan pick occurrences are incomplete".to_owned()); + } + let bvh = PickBvh::new(&plan.logical_bounds); + Ok(Self { + topologies: plan.topologies, + occurrences: plan.pick_occurrences, + logical_parts: plan.logical_parts, + logical_bounds: plan.logical_bounds, + ranges: plan.occurrence_ranges, + logical_part_bounds: plan.logical_part_bounds, + bvh, + }) + } + + pub fn logical_part_bounds(&self, occurrence_id: &str) -> Option { + self.logical_part_bounds.get(occurrence_id).copied() + } + + pub fn pick(&self, ray: PickRay) -> Result, String> { + let mut candidate_parts = Vec::new(); + if let Some(bvh) = &self.bvh { + bvh.candidates(ray, f64::INFINITY, &mut candidate_parts); + } + let mut nearest: Option = None; + for part_index in candidate_parts { + let maximum = nearest.as_ref().map_or(f64::INFINITY, |hit| hit.distance); + if !self.logical_bounds[part_index].intersects(ray, maximum) { + continue; + } + for candidate in &self.logical_parts[part_index] { + let occurrence = &self.occurrences[*candidate]; + let maximum = nearest.as_ref().map_or(f64::INFINITY, |hit| hit.distance); + if !occurrence.bounds.intersects(ray, maximum) { + continue; + } + let topology = &self.topologies[occurrence.geometry_index]; + let local_ray = occurrence.inverse.map(|inverse| inverse.ray(ray)); + for (triangle_index, indices) in topology.indices.chunks_exact(3).enumerate() { + let distance = if let Some(local_ray) = local_ray { + let point = |index: u32| { + let point = topology.positions[index as usize]; + [ + f64::from(point[0]), + f64::from(point[1]), + f64::from(point[2]), + ] + }; + ray_triangle_distance( + local_ray, + point(indices[0]), + point(indices[1]), + point(indices[2]), + ) + } else { + let point = |index: u32| { + let point = topology.positions[index as usize]; + apply_scene_transform( + occurrence.transform, + [ + f64::from(point[0]), + f64::from(point[1]), + f64::from(point[2]), + ], + ) + }; + ray_triangle_distance( + ray, + point(indices[0])?, + point(indices[1])?, + point(indices[2])?, + ) + }; + if let Some(distance) = distance + && nearest.as_ref().is_none_or(|hit| distance < hit.distance) + { + nearest = Some(ScenePickHit { + occurrence_index: occurrence.occurrence_index, + triangle_index, + distance, + gpu: self.ranges[occurrence.occurrence_index], + }); + } + } + } + } + Ok(nearest) + } +} + +pub fn pick_scene(scene: &Scene, ray: PickRay) -> Result, String> { + let plan = scene_render_plan(scene)?; + ScenePicker::new(scene, plan)?.pick(ray) +} + +pub fn occurrence_selection_bounds( + scene: &Scene, + occurrence_indices: &[usize], +) -> Result { + let index = SceneIndex::new(scene)?; + let mut points = Vec::new(); + for occurrence_index in occurrence_indices { + let occurrence = scene + .occurrences + .get(*occurrence_index) + .ok_or("selected occurrence is outside the scene table")?; + let geometry = index.geometry(scene, occurrence.node)?; + for point in &geometry.points { + points.push(apply_scene_transform(occurrence.transform, *point)?); + } + } + SceneBounds::from_points(points) + .ok_or_else(|| "selected subtree has no finite bounds".to_owned()) +} + +pub fn logical_part_selection( + scene: &Scene, + raw_index: usize, +) -> Result<(smol_str::SmolStr, smol_str::SmolStr, Vec), String> { + let raw = scene + .occurrences + .get(raw_index) + .ok_or("raw occurrence is outside the scene table")?; + let selected = raw + .logical_part_occurrence_id + .as_ref() + .zip(raw.logical_part_path.as_ref()) + .map(|(id, path)| (id.clone(), path.clone())) + .unwrap_or_else(|| { + ( + raw.canonical_occurrence_id.clone(), + raw.canonical_path.clone(), + ) + }); + let subtree = scene + .occurrences + .iter() + .enumerate() + .filter_map(|(index, occurrence)| { + (occurrence.canonical_occurrence_id == selected.0 + || occurrence.logical_part_occurrence_id.as_ref() == Some(&selected.0)) + .then_some(index) + }) + .collect(); + Ok((selected.0, selected.1, subtree)) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn build_scene_occurrence_models( + context: &three_d::Context, + scene: &Scene, + occurrence_indices: &[usize], +) -> Result>, String> { + let mut selected = scene.clone(); + selected.occurrences = occurrence_indices + .iter() + .map(|index| { + scene + .occurrences + .get(*index) + .cloned() + .ok_or_else(|| "selected occurrence is outside the scene table".to_owned()) + }) + .collect::>()?; + build_scene_model(context, &selected) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn build_scene_occurrence_model( + context: &three_d::Context, + scene: &Scene, + occurrence_index: usize, +) -> Result>, String> { + build_scene_occurrence_models(context, scene, &[occurrence_index]) +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SceneBatchInstance { + pub occurrence_index: usize, + pub transform: [[f64; 4]; 4], +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SceneInstanceBatch { + pub node: NodeId, + pub material: MaterialId, + pub bfc_cull: bool, pub bfc_ccw: bool, pub bfc_inverted: bool, pub reflection: bool, - pub transforms: Vec<[[f64; 4]; 4]>, + pub instances: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SceneRenderPlan { + /// Planned batches retain all semantic occurrences for picking and diagnostics. + pub batches: Vec, + /// Finalized post-filter upload plan consumed by GPU construction. + pub upload_batches: Vec, + /// Mapping from planned batch index to actual upload-object index. + pub planned_to_upload: Vec>, + pub occurrence_ranges: Vec>, + pub topologies: Vec, + pick_occurrences: Vec, + logical_parts: Vec>, + logical_bounds: Vec, + logical_part_bounds: std::collections::HashMap, + index: SceneIndex, +} + +pub fn scene_render_plan(scene: &Scene) -> Result { + #[cfg(not(target_arch = "wasm32"))] + let diagnostics = std::env::var_os("POLYMODEL_LDRAW_PLAN_DIAGNOSTICS").is_some(); + #[cfg(target_arch = "wasm32")] + let diagnostics = false; + let started = Instant::now(); + let index = SceneIndex::new(scene)?; + let indexed = started.elapsed(); + let topologies = scene + .geometries + .iter() + .map(|geometry| { + scene_mesh_topology_for_view_kind(geometry, [0.0, 0.0, 1.0], SceneTopologyKind::Faces) + }) + .collect::, _>>()?; + let topology_complete = started.elapsed(); + let local_bounds = topologies + .iter() + .enumerate() + .map(|(geometry_index, topology)| { + PickBounds::from_points(topology.positions.iter().map(|point| { + [ + f64::from(point[0]), + f64::from(point[1]), + f64::from(point[2]), + ] + })) + .ok_or_else(|| { + format!( + "geometry {} has no finite render bounds", + scene.geometries[geometry_index].id.0 + ) + }) + }) + .collect::, _>>()?; + let local_bounds_complete = started.elapsed(); + let mut pick_occurrences = Vec::with_capacity(scene.occurrences.len()); + let mut logical_part_indices = std::collections::HashMap::::new(); + let mut logical_parts = Vec::>::new(); + let mut logical_bounds = Vec::::new(); + let mut logical_part_bounds = std::collections::HashMap::::new(); + let batches = scene_instance_batches_for_mode( + scene, + &index, + &local_bounds, + &mut pick_occurrences, + &mut logical_part_indices, + &mut logical_parts, + &mut logical_bounds, + &mut logical_part_bounds, + false, + )?; + let batches_complete = started.elapsed(); + let (upload_batches, planned_to_upload) = + finalized_upload_batches(&index, &batches, &topologies)?; + let occurrence_ranges = + occurrence_gpu_ranges_from_batches(scene, &index, &upload_batches, &topologies)?; + let ranges_complete = started.elapsed(); + if diagnostics { + eprintln!( + "[ldraw-preview] plan-detail index_ms={} topology_ms={} local_bounds_ms={} batches_ms={} ranges_ms={}", + indexed.as_millis(), + (topology_complete - indexed).as_millis(), + (local_bounds_complete - topology_complete).as_millis(), + (batches_complete - local_bounds_complete).as_millis(), + (ranges_complete - batches_complete).as_millis(), + ); + } + Ok(SceneRenderPlan { + batches, + upload_batches, + planned_to_upload, + occurrence_ranges, + topologies, + pick_occurrences, + logical_parts, + logical_bounds, + logical_part_bounds, + index, + }) } pub fn scene_instance_batches(scene: &Scene) -> Result, String> { + Ok(scene_render_plan(scene)?.batches) +} + +fn scene_instance_batches_for_mode( + scene: &Scene, + index: &SceneIndex, + local_bounds: &[PickBounds], + pick_occurrences: &mut Vec, + logical_part_indices: &mut std::collections::HashMap, + logical_parts: &mut Vec>, + logical_bounds: &mut Vec, + logical_part_bounds: &mut std::collections::HashMap, + separate_conditional_occurrences: bool, +) -> Result, String> { + #[derive(Clone, Copy, PartialEq, Eq, Hash)] + struct BatchKey { + node: NodeId, + material: MaterialId, + bfc_cull: bool, + reverse_winding: bool, + conditional_occurrence: Option, + } + let default_material = scene .materials .first() .map(|material| material.id) .unwrap_or(MaterialId(1)); let mut batches = Vec::::new(); - for occurrence in &scene.occurrences { - let geometry_index = occurrence - .node - .0 - .checked_sub(1) - .ok_or("occurrence node zero is invalid")? as usize; - if geometry_index >= scene.geometries.len() { - return Err(format!( - "occurrence node {} is outside the geometry table", - occurrence.node.0 - )); + let mut batch_indices = std::collections::HashMap::::new(); + for (occurrence_index, occurrence) in scene.occurrences.iter().enumerate() { + let geometry_index = index.geometry_index(occurrence.node)?; + let geometry = &scene.geometries[geometry_index]; + let local = local_bounds[geometry_index]; + let bounds = local + .transformed(occurrence.transform) + .ok_or("occurrence has no finite render bounds")?; + pick_occurrences.push(PickOccurrence { + occurrence_index, + geometry_index, + transform: occurrence.transform, + inverse: InverseSceneTransform::new(occurrence.transform), + bounds, + }); + let logical_part = occurrence + .logical_part_occurrence_id + .as_ref() + .unwrap_or(&occurrence.canonical_occurrence_id); + if let Some(group_index) = logical_part_indices.get(logical_part).copied() { + logical_parts[group_index].push(occurrence_index); + logical_bounds[group_index] = logical_bounds[group_index].union(bounds); + } else { + let group_index = logical_parts.len(); + logical_part_indices.insert(logical_part.clone(), group_index); + logical_parts.push(vec![occurrence_index]); + logical_bounds.push(bounds); } + logical_part_bounds + .entry(logical_part.clone()) + .and_modify(|aggregate| { + for axis in 0..3 { + aggregate.min[axis] = aggregate.min[axis].min(bounds.min[axis]); + aggregate.max[axis] = aggregate.max[axis].max(bounds.max[axis]); + } + }) + .or_insert(SceneBounds { + min: bounds.min, + max: bounds.max, + }); let material = occurrence.material.unwrap_or(default_material); - let has_conditional = scene - .geometries - .get(geometry_index) - .is_some_and(|geometry| !geometry.conditional_lines.is_empty()); - if let Some(batch) = batches.iter_mut().find(|batch| { - !has_conditional - && batch.node == occurrence.node - && batch.material == material - && batch.bfc_cull == occurrence.bfc_cull - && batch.bfc_ccw == occurrence.bfc_ccw - && batch.bfc_inverted == occurrence.bfc_inverted - && batch.reflection == transform_reflection(occurrence.transform) - }) { - batch.transforms.push(occurrence.transform); + index.material(scene, material)?; + let reverse_winding = occurrence.bfc_inverted ^ !occurrence.bfc_ccw; + let key = BatchKey { + node: occurrence.node, + material, + bfc_cull: occurrence.bfc_cull, + reverse_winding, + conditional_occurrence: (separate_conditional_occurrences + && !geometry.conditional_lines.is_empty()) + .then_some(occurrence_index), + }; + let instance = SceneBatchInstance { + occurrence_index, + transform: occurrence.transform, + }; + if let Some(index) = batch_indices.get(&key).copied() { + batches[index].instances.push(instance); } else { + let index = batches.len(); batches.push(SceneInstanceBatch { node: occurrence.node, material, bfc_cull: occurrence.bfc_cull, bfc_ccw: occurrence.bfc_ccw, - bfc_inverted: occurrence.bfc_inverted, - reflection: transform_reflection(occurrence.transform), - transforms: vec![occurrence.transform], + bfc_inverted: reverse_winding, + reflection: false, + instances: vec![instance], }); + batch_indices.insert(key, index); } } - if !scene.geometries.is_empty() && !batches.iter().any(|batch| batch.node == scene.root) { - batches.push(SceneInstanceBatch { - node: scene.root, - material: default_material, - bfc_cull: false, - bfc_ccw: true, - bfc_inverted: false, - reflection: false, - transforms: vec![[ - [1.0, 0.0, 0.0, 0.0], - [0.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.0], - [0.0, 0.0, 0.0, 1.0], - ]], - }); - } Ok(batches) } @@ -1876,170 +2558,224 @@ fn ldraw_colour(code: u32) -> Srgba { Srgba::new_opaque(r, g, b) } +pub type NativeSceneModel = Vec>; + pub fn build_scene_model( context: &three_d::Context, scene: &Scene, -) -> Result>, String> { +) -> Result { build_scene_model_for_view(context, scene, [0.0, 0.0, 1.0]) } -pub fn build_scene_model_parts_for_view( +pub fn build_scene_model_for_view( + context: &three_d::Context, + scene: &Scene, + _view: [f64; 3], +) -> Result { + let plan = scene_render_plan(scene)?; + build_scene_model_from_batches( + context, + scene, + &plan.index, + &plan.upload_batches, + &plan.topologies, + ) +} + +pub fn build_scene_model_parts_for_style( context: &three_d::Context, scene: &Scene, view: [f64; 3], -) -> Result< - ( - Vec>, - Vec>, - ), - String, -> { - let static_model = build_scene_model_for_view_kind(context, scene, view, false)?; - let conditional_model = build_scene_model_for_view_kind(context, scene, view, true)?; - Ok((static_model, conditional_model)) + style: RenderStyle, +) -> Result<(NativeSceneModel, Option, ScenePicker), String> { + let started = Instant::now(); + let plan = scene_render_plan(scene)?; + eprintln!( + "[ldraw-preview] native-build-stage=plan elapsed_ms={}", + started.elapsed().as_millis() + ); + let static_model = build_scene_model_from_batches( + context, + scene, + &plan.index, + &plan.upload_batches, + &plan.topologies, + )?; + eprintln!( + "[ldraw-preview] native-build-stage=static elapsed_ms={}", + started.elapsed().as_millis() + ); + let edge_model = (style == RenderStyle::Edges) + .then(|| build_edge_model_with_index(context, scene, view, &plan.index)) + .transpose()?; + eprintln!( + "[ldraw-preview] native-build-stage=edges elapsed_ms={}", + started.elapsed().as_millis() + ); + let picker = ScenePicker::new(scene, plan)?; + eprintln!( + "[ldraw-preview] native-build-stage=picker elapsed_ms={}", + started.elapsed().as_millis() + ); + Ok((static_model, edge_model, picker)) +} + +pub fn build_scene_faces_and_picker( + context: &three_d::Context, + scene: &Scene, +) -> Result<(NativeSceneModel, ScenePicker), String> { + let started = Instant::now(); + let plan = scene_render_plan(scene)?; + eprintln!( + "[ldraw-preview] native-build-stage=plan elapsed_ms={}", + started.elapsed().as_millis() + ); + let static_model = build_scene_model_from_batches( + context, + scene, + &plan.index, + &plan.upload_batches, + &plan.topologies, + )?; + eprintln!( + "[ldraw-preview] native-build-stage=static elapsed_ms={}", + started.elapsed().as_millis() + ); + let picker = ScenePicker::new(scene, plan)?; + eprintln!( + "[ldraw-preview] native-build-stage=picker elapsed_ms={}", + started.elapsed().as_millis() + ); + Ok((static_model, picker)) } -pub fn build_conditional_model_for_view( +pub fn build_edge_model_for_view( context: &three_d::Context, scene: &Scene, view: [f64; 3], -) -> Result>, String> { - build_scene_model_for_view_kind(context, scene, view, true) +) -> Result { + let index = SceneIndex::new(scene)?; + build_edge_model_with_index(context, scene, view, &index) } -fn build_scene_model_for_view_kind( +fn build_edge_model_with_index( context: &three_d::Context, scene: &Scene, view: [f64; 3], - conditional_only: bool, -) -> Result>, String> { - let batches = scene_instance_batches(scene)?; - let mut model = Vec::new(); - for batch in batches { - let geometry_index = batch - .node - .0 - .checked_sub(1) - .ok_or("native batch node zero is invalid")? as usize; - let geometry = scene.geometries.get(geometry_index).ok_or_else(|| { - format!( - "native batch node {} is outside the geometry table", - batch.node.0 - ) - })?; - let has_conditional = !geometry.conditional_lines.is_empty(); - if conditional_only && !has_conditional { - continue; - } - let conditional_view = if conditional_only { - batch - .transforms - .first() - .and_then(|transform| conditional_line_view_for_transform(*transform, view)) - .unwrap_or(view) - } else { - view - }; - let transformations = batch - .transforms - .into_iter() - .map(scene_transform_to_mat4) - .collect::>(); - if transformations.is_empty() { + index: &SceneIndex, +) -> Result { + let default_material = scene + .materials + .first() + .map(|material| material.id) + .unwrap_or(MaterialId(1)); + let mut meshes = std::collections::HashMap::::new(); + + for occurrence in &scene.occurrences { + let geometry = index.geometry(scene, occurrence.node)?; + if geometry.lines.is_empty() && geometry.conditional_lines.is_empty() { continue; } - let colour = scene - .materials - .iter() - .find(|material| material.id == batch.material) - .map(|material| material.colour) - .unwrap_or(16); - let mut material = PhysicalMaterial::new_opaque( - context, - &CpuMaterial { - albedo: ldraw_colour(colour), - roughness: 0.72, - metallic: 0.0, - ..Default::default() - }, - ); - material.render_states = RenderStates { - cull: if batch.bfc_cull { - Cull::Back - } else { - Cull::None - }, - ..Default::default() - }; + let local_view = + conditional_line_view_for_transform(occurrence.transform, view).unwrap_or(view); let topology = - scene_mesh_topology_for_view_kind(geometry, conditional_view, conditional_only)?; + scene_mesh_topology_for_view_kind(geometry, local_view, SceneTopologyKind::Complete)?; if topology.indices.is_empty() { continue; } - let mut cpu_mesh = CpuMesh { - positions: Positions::F32( - topology - .positions - .iter() - .map(|point| three_d::vec3(point[0], point[1], point[2])) - .collect(), - ), - indices: Indices::U32(topology.indices), - ..Default::default() - }; - cpu_mesh.compute_normals(); - let object = Gm::new( - InstancedMesh::new( + let material = occurrence.material.unwrap_or(default_material); + index.material(scene, material)?; + let mesh = meshes.entry(material).or_insert_with(|| SceneMeshTopology { + positions: Vec::new(), + indices: Vec::new(), + }); + let base = u32::try_from(mesh.positions.len()) + .map_err(|_| "conditional vertex range overflow".to_owned())?; + for position in topology.positions { + let world = apply_scene_transform( + occurrence.transform, + [ + f64::from(position[0]), + f64::from(position[1]), + f64::from(position[2]), + ], + )?; + mesh.positions.push([ + finite_f32(world[0]).map_err(str::to_owned)?, + finite_f32(world[1]).map_err(str::to_owned)?, + finite_f32(world[2]).map_err(str::to_owned)?, + ]); + } + mesh.indices.extend( + topology + .indices + .into_iter() + .map(|index| index.checked_add(base).ok_or("conditional index overflow")) + .collect::, _>>()?, + ); + } + + meshes + .into_iter() + .map(|(material_id, topology)| { + let colour = index.material(scene, material_id)?.colour; + let mut material = PhysicalMaterial::new_opaque( context, - &Instances { - transformations, + &CpuMaterial { + albedo: ldraw_colour(colour), + roughness: 0.72, + metallic: 0.0, ..Default::default() }, - &cpu_mesh, - ), - material, - ); - model.push(object); - } - if model.is_empty() { - return Err("scene contains no renderable instanced geometry".to_owned()); - } - Ok(model) + ); + material.render_states = RenderStates { + cull: Cull::None, + ..Default::default() + }; + let mut cpu_mesh = CpuMesh { + positions: Positions::F32( + topology + .positions + .into_iter() + .map(|point| three_d::vec3(point[0], point[1], point[2])) + .collect(), + ), + indices: Indices::U32(topology.indices), + ..Default::default() + }; + cpu_mesh.compute_normals(); + Ok(Gm::new( + InstancedMesh::new( + context, + &Instances { + transformations: vec![three_d::Mat4::from_scale(1.0)], + ..Default::default() + }, + &cpu_mesh, + ), + material, + )) + }) + .collect() } -pub fn build_scene_model_for_view( +fn build_scene_model_from_batches( context: &three_d::Context, scene: &Scene, - view: [f64; 3], -) -> Result>, String> { - let batches = scene_instance_batches(scene)?; + index: &SceneIndex, + batches: &[SceneInstanceBatch], + topologies: &[SceneMeshTopology], +) -> Result { let model = batches - .into_iter() + .iter() .map(|batch| { - let geometry_index = batch - .node - .0 - .checked_sub(1) - .ok_or("native batch node zero is invalid")? - as usize; - let geometry = scene.geometries.get(geometry_index).ok_or_else(|| { - format!( - "native batch node {} is outside the geometry table", - batch.node.0 - ) - })?; - let reflection = batch.reflection; + let geometry = index.geometry(scene, batch.node)?; let transformations = batch - .transforms - .into_iter() - .map(scene_transform_to_mat4) - .collect::>(); - let colour = scene - .materials + .instances .iter() - .find(|material| material.id == batch.material) - .map(|material| material.colour) - .unwrap_or(16); + .map(|instance| scene_transform_to_mat4(instance.transform).map_err(str::to_owned)) + .collect::, _>>()?; + let colour = index.material(scene, batch.material)?.colour; let mut material = PhysicalMaterial::new_opaque( context, &CpuMaterial { @@ -2060,17 +2796,19 @@ pub fn build_scene_model_for_view( if transformations.is_empty() { return Ok(None); } - let topology = scene_oriented_mesh_topology_for_view( - geometry, - batch.bfc_cull, - batch.bfc_ccw, - batch.bfc_inverted, - reflection, - view, - )?; + let geometry_index = index.geometry_index(batch.node)?; + let topology = topologies + .get(geometry_index) + .ok_or("render plan is missing geometry topology")?; if topology.indices.is_empty() { return Ok(None); } + let mut indices = topology.indices.clone(); + if batch.bfc_inverted ^ !batch.bfc_ccw { + for triangle in indices.chunks_exact_mut(3) { + triangle.swap(0, 2); + } + } let mut cpu_mesh = CpuMesh { positions: Positions::F32( topology @@ -2079,7 +2817,7 @@ pub fn build_scene_model_for_view( .map(|point| three_d::vec3(point[0], point[1], point[2])) .collect(), ), - indices: Indices::U32(topology.indices), + indices: Indices::U32(indices), ..Default::default() }; cpu_mesh.compute_normals(); @@ -2200,14 +2938,23 @@ pub fn render_scene_window_options_traced( window.render_loop(move |mut frame_input| { let result = catch_unwind(AssertUnwindSafe(|| { callback_trace.record(NativeStage::FrameCallbackEntered); - camera.set_viewport(frame_input.viewport); + let headless = output_path.is_some(); + let viewport = if headless { + three_d::Viewport::new_at_origo(options.width, options.height) + } else { + frame_input.viewport + }; + camera.set_viewport(viewport); control.handle_events(&mut camera, &mut frame_input.events); - let target = frame_input.screen(); + let target = if headless { + three_d::RenderTarget::screen(&context, options.width, options.height) + } else { + frame_input.screen() + }; let screen = target.clear(ClearState::color_and_depth(0.78, 0.80, 0.84, 1.0, 1.0)); let light_refs = lights.as_refs(); screen.render(&camera, &model, &light_refs); callback_trace.record(NativeStage::DrawCompleted); - let headless = output_path.is_some(); if frame_input.first_frame || headless { let pixels = screen.read_color::<[u8; 4]>(); callback_trace.record(NativeStage::ReadbackCompleted); @@ -2219,8 +2966,8 @@ pub fn render_scene_window_options_traced( std::fs::write( path, native_png_bytes( - frame_input.viewport.width, - frame_input.viewport.height, + viewport.width, + viewport.height, &pixels.into_iter().flatten().collect::>(), ), ) @@ -2308,6 +3055,7 @@ mod tests { quads, lines: Vec::new(), conditional_lines: Vec::new(), + primitives: Vec::new(), } } @@ -2320,9 +3068,9 @@ mod tests { ); let occurrence = |id, z| SceneOccurrence { id: OccurrenceId(id), - canonical_occurrence_id: format!("root/{id}"), - parent_chain: vec!["root".into()], - logical_part_occurrence_id: Some(format!("root/{id}")), + canonical_occurrence_id: smol_str::format_smolstr!("root/{id}"), + parent_occurrence_id: Some("root".into()), + logical_part_occurrence_id: Some(smol_str::format_smolstr!("root/{id}")), logical_part_path: Some("parts/triangle.dat".into()), node: NodeId(1), definition_id: "triangle.dat".into(), @@ -2330,7 +3078,7 @@ mod tests { source_class: "part".into(), source: "root.ldr".into(), target: "triangle.dat".into(), - source_span: Some([id as u32, 0, 1]), + source_span: Some([id as u32, 0, 1, 1]), direct_colour: 4, effective_colour: 4, primitive_colour: 4, @@ -2374,24 +3122,19 @@ mod tests { let mut scene = picker_scene(); scene.occurrences[0].canonical_occurrence_id = "leaf-disc".into(); scene.occurrences[0].canonical_path = "p/4-4disc.dat".into(); - scene.occurrences[0].parent_chain = vec![ - "root".into(), - "logical-part-with-no-geometry".into(), - "primitive-stud-with-no-geometry".into(), - ]; + scene.occurrences[0].parent_occurrence_id = Some("primitive-stud-with-no-geometry".into()); scene.occurrences[0].logical_part_occurrence_id = Some("logical-part-with-no-geometry".into()); scene.occurrences[0].logical_part_path = Some("parts/3021.dat".into()); scene.occurrences[1].canonical_occurrence_id = "sibling-leaf".into(); scene.occurrences[1].canonical_path = "parts/s/3021s01.dat".into(); - scene.occurrences[1].parent_chain = - vec!["root".into(), "logical-part-with-no-geometry".into()]; + scene.occurrences[1].parent_occurrence_id = Some("logical-part-with-no-geometry".into()); scene.occurrences[1].logical_part_occurrence_id = Some("logical-part-with-no-geometry".into()); scene.occurrences[1].logical_part_path = Some("parts/3021.dat".into()); let mut unrelated = scene.occurrences[1].clone(); unrelated.canonical_occurrence_id = "other-part".into(); - unrelated.parent_chain = vec!["root".into()]; + unrelated.parent_occurrence_id = Some("root".into()); unrelated.logical_part_occurrence_id = Some("other-part".into()); unrelated.logical_part_path = Some("parts/3001.dat".into()); scene.occurrences.push(unrelated); @@ -2410,16 +3153,187 @@ mod tests { ); } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn empty_face_batch_does_not_shift_later_gpu_ranges() { + let mut scene = picker_scene(); + scene.geometries.insert( + 0, + SceneGeometry { + id: GeometryId(99), + provenance: ProvenanceId(1), + points: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + triangles: Vec::new(), + quads: Vec::new(), + lines: vec![[0, 1]], + conditional_lines: Vec::new(), + primitives: Vec::new(), + }, + ); + scene.occurrences.insert( + 0, + SceneOccurrence { + id: OccurrenceId(99), + canonical_occurrence_id: "edge-only".into(), + parent_occurrence_id: None, + logical_part_occurrence_id: None, + logical_part_path: None, + node: NodeId(99), + definition_id: "edge-only".into(), + canonical_path: "edge-only.ldr".into(), + source_class: "CurrentMpd".into(), + source: "edge-only.ldr".into(), + target: "edge-only.ldr".into(), + source_span: None, + direct_colour: 16, + effective_colour: 16, + primitive_colour: 16, + material: Some(MaterialId(1)), + bfc_state: "Uncertified".into(), + bfc_cull: false, + bfc_ccw: true, + bfc_inverted: false, + transform: [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + }, + ); + let plan = scene_render_plan(&scene).expect("render plan"); + assert_eq!(plan.planned_to_upload[0], None); + assert_eq!(plan.planned_to_upload[1], Some(0)); + assert_eq!(plan.occurrence_ranges[0], None); + assert_eq!(plan.occurrence_ranges[1].unwrap().batch, 0); + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn occurrence_gpu_ranges_follow_production_batch_instances_and_topology() { let ranges = occurrence_gpu_ranges(&picker_scene()).expect("ranges"); assert_eq!(ranges.len(), 2); - assert_eq!((ranges[0].batch, ranges[0].instance), (0, 0)); - assert_eq!((ranges[1].batch, ranges[1].instance), (0, 1)); - assert_eq!(ranges[0].geometry, GeometryId(1)); - assert_eq!((ranges[0].vertex_start, ranges[0].vertex_count), (0, 3)); - assert_eq!((ranges[0].index_start, ranges[0].index_count), (0, 3)); + assert_eq!( + (ranges[0].unwrap().batch, ranges[0].unwrap().instance), + (0, 0) + ); + assert_eq!( + (ranges[1].unwrap().batch, ranges[1].unwrap().instance), + (0, 1) + ); + assert_eq!(ranges[0].unwrap().geometry, GeometryId(1)); + assert_eq!( + ( + ranges[0].unwrap().vertex_start, + ranges[0].unwrap().vertex_count + ), + (0, 3) + ); + assert_eq!( + ( + ranges[0].unwrap().index_start, + ranges[0].unwrap().index_count + ), + (0, 3) + ); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn scene_consumers_resolve_sparse_opaque_ids_and_reject_invalid_references() { + let mut scene = picker_scene(); + scene.geometries[0].id = GeometryId(41); + scene.root = NodeId(41); + for occurrence in &mut scene.occurrences { + occurrence.node = NodeId(41); + occurrence.material = Some(MaterialId(73)); + } + scene.materials[0].id = MaterialId(73); + + let ranges = occurrence_gpu_ranges(&scene).expect("sparse ids"); + assert_eq!(ranges[0].unwrap().geometry, GeometryId(41)); + assert!( + pick_scene( + &scene, + PickRay { + origin: [0.0, 0.0, 0.0], + direction: [0.0, 0.0, 1.0], + } + ) + .expect("sparse-id pick") + .is_some() + ); + + let mut duplicate = scene.clone(); + duplicate.geometries.push(duplicate.geometries[0].clone()); + assert!( + occurrence_gpu_ranges(&duplicate) + .unwrap_err() + .contains("duplicate geometry id") + ); + + let mut dangling_geometry = scene.clone(); + dangling_geometry.occurrences[0].node = NodeId(99); + assert!( + occurrence_gpu_ranges(&dangling_geometry) + .unwrap_err() + .contains("unknown geometry id 99") + ); + + let mut dangling_material = scene; + dangling_material.occurrences[0].material = Some(MaterialId(99)); + assert!( + occurrence_gpu_ranges(&dangling_material) + .unwrap_err() + .contains("unknown material id 99") + ); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn affine_bounds_match_transformed_corners() { + let local = PickBounds { + min: [-2.0, -3.0, -5.0], + max: [7.0, 11.0, 13.0], + }; + let transform = [ + [0.0, -2.0, 0.5, 0.0], + [-3.0, 0.0, 0.25, 0.0], + [0.75, 0.5, -4.0, 0.0], + [17.0, -19.0, 23.0, 1.0], + ]; + let corners = (0..8) + .map(|corner| { + apply_scene_transform( + transform, + [ + if corner & 1 == 0 { + local.min[0] + } else { + local.max[0] + }, + if corner & 2 == 0 { + local.min[1] + } else { + local.max[1] + }, + if corner & 4 == 0 { + local.min[2] + } else { + local.max[2] + }, + ], + ) + .expect("finite corner") + }) + .collect::>(); + let expected = PickBounds::from_points(corners).expect("corner bounds"); + let actual = local.transformed(transform).expect("affine bounds"); + + for axis in 0..3 { + assert!((actual.min[axis] - expected.min[axis]).abs() < 1.0e-12); + assert!((actual.max[axis] - expected.max[axis]).abs() < 1.0e-12); + } } #[cfg(not(target_arch = "wasm32"))] @@ -2437,7 +3351,8 @@ mod tests { assert_eq!(hit.occurrence_index, 1); assert_eq!(hit.triangle_index, 0); assert!((hit.distance - 2.0).abs() < 1.0e-12); - assert_eq!((hit.gpu.batch, hit.gpu.instance), (0, 1)); + let gpu = hit.gpu.expect("face occurrence has a GPU range"); + assert_eq!((gpu.batch, gpu.instance), (0, 1)); } #[cfg(not(target_arch = "wasm32"))] @@ -2448,7 +3363,7 @@ mod tests { scene.occurrences[0].transform = [ [2.0, 0.0, 0.0, 0.0], [0.0, 2.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 3.0, 0.0], [4.0, -3.0, 7.0, 1.0], ]; let hit = pick_scene( @@ -2598,6 +3513,21 @@ mod tests { assert!(visible.positions.len() > geometry.points.len()); } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn render_plan_uses_faces_without_edge_ribbons() { + let mut scene = picker_scene(); + scene.geometries[0].lines = vec![[0, 1]]; + scene.geometries[0].conditional_lines = vec![[0, 1, 2, 1]]; + + let complete = scene_mesh_topology(&scene.geometries[0]).expect("complete topology"); + let plan = scene_render_plan(&scene).expect("render plan"); + + assert!(complete.triangle_count() > plan.topologies[0].triangle_count()); + assert_eq!(plan.topologies[0].triangle_count(), 1); + assert_eq!(plan.topologies[0].indices, [0, 1, 2]); + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn native_topology_rejects_malformed_conditional_line_indices() { @@ -2685,7 +3615,7 @@ mod tests { assert_eq!(world_points[2], [6.0, 34.0, -22.0]); assert_eq!(world_points[3], [7.0, 35.0, -22.0]); - let gpu = scene_transform_to_mat4(scene); + let gpu = scene_transform_to_mat4(scene).expect("representable GPU transform"); for (source, expected) in source_points.into_iter().zip(world_points) { let world_source = ldraw_to_world([source.x, source.y, source.z]); let cpu = apply_scene_transform(scene, world_source).expect("scene CPU transform"); @@ -2701,15 +3631,31 @@ mod tests { } } + #[test] + fn gpu_transform_rejects_finite_f64_outside_f32_range() { + let mut transform = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + transform[0][3] = f32::MAX as f64 * 2.0; + + assert_eq!( + scene_transform_to_mat4(transform), + Err("semantic value cannot be represented as finite f32") + ); + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn native_camera_fit_targets_bounds_center_and_contains_projected_corners() { let bounds = SceneBounds { - min: [-2654.399_419_125, -742.736, -456.829_925], + min: [-2_654.399_419_125, -742.736, -456.829_925], max: [-187.804_052_188, 742.725, 686.390_413], }; let fit = scene_camera_fit(bounds, 1280, 720); - assert!((fit.center.x - -1421.101_7).abs() < 1.0e-3); + assert!((fit.center.x - -1_421.101_7).abs() < 1.0e-3); assert!((fit.center.y - -0.005_5).abs() < 1.0e-3); assert!((fit.center.z - 114.780_24).abs() < 1.0e-3); assert!(fit.near > 0.0); @@ -2767,9 +3713,10 @@ mod tests { polymodel_ldraw_core::RootId::OfficialLibrary, b"child-a", ); - let child = ModelSummary { + let mut child = ModelSummary { + model: polymodel_ldraw_core::ModelId(579), id: "child".into(), - path: child_path.to_string(), + path: child_path.as_str().into(), cache_key: child_key.clone(), depth: 1, include_spans: Vec::new(), @@ -2787,6 +3734,14 @@ mod tests { step_offsets: Vec::new(), }, geometry: vec![GeometryRecord { + primitive_id: 0, + source: child_key.clone(), + span: polymodel_ldraw_core::Span { + start: 0, + end: 31, + line: 1, + column: 1, + }, line_type: LineType::Three, colour: polymodel_ldraw_core::ColourCode(4), vertices: vec![ @@ -2794,17 +3749,25 @@ mod tests { polymodel_ldraw_core::Point3::new(1.0, 0.0, 0.0), polymodel_ldraw_core::Point3::new(0.0, 1.0, 0.0), ], + bfc: polymodel_ldraw_core::BfcFrame { + state: polymodel_ldraw_core::BfcState::Certified, + clipping: true, + winding: polymodel_ldraw_core::Winding::Ccw, + invert_next: false, + }, + inverted: false, texmap: None, }], bfc: polymodel_ldraw_core::BfcState::Certified, }; let root = ModelSummary { + model: polymodel_ldraw_core::ModelId(1), id: "root".into(), path: "root.ldr".into(), cache_key: root_key, depth: 0, include_spans: Vec::new(), - includes: vec![child_path.to_string()], + includes: vec![child_path.as_str().into()], triangles: 0, quads: 0, lines: 0, @@ -2820,6 +3783,19 @@ mod tests { geometry: Vec::new(), bfc: polymodel_ldraw_core::BfcState::Unknown, }; + let mut clockwise = child.geometry[0].clone(); + clockwise.primitive_id = 1; + clockwise.bfc.clipping = false; + clockwise.bfc.winding = polymodel_ldraw_core::Winding::Cw; + clockwise.inverted = true; + let mut edge = child.geometry[0].clone(); + edge.primitive_id = 2; + edge.line_type = LineType::Two; + edge.colour = polymodel_ldraw_core::ColourCode(24); + edge.vertices.truncate(2); + child.geometry.extend([clockwise, edge]); + child.triangles = 2; + child.lines = 1; let transform = polymodel_ldraw_core::Transform { translation: polymodel_ldraw_core::Point3::new(10.0, 20.0, 30.0), matrix: polymodel_ldraw_core::Matrix3 { @@ -2832,8 +3808,9 @@ mod tests { model_id: "root".into(), instance_ids: vec!["instance-00000001".into()], instances: vec![polymodel_ldraw_core::InstanceRecord { + model: polymodel_ldraw_core::ModelId(579), instance_id: "instance-00000001".into(), - parent_chain: vec!["root".into()], + parent: None, source_model: "root.ldr".into(), target_model: child.path.clone(), source_span: None, @@ -2864,7 +3841,43 @@ mod tests { assert_eq!(scene.occurrences[0].transform[3], [10.0, 30.0, -20.0, 1.0]); assert_eq!(scene.bounds.unwrap().min, [9.0, 30.0, -21.0]); assert_eq!(scene.bounds.unwrap().max, [10.0, 30.0, -20.0]); - assert_eq!(scene.stats.triangles, 1); + assert_eq!(scene.occurrences.len(), 3); + assert!(scene.geometries.iter().any(|geometry| { + geometry + .primitives + .iter() + .any(|primitive| primitive.bfc_cull && primitive.bfc_ccw && !primitive.bfc_inverted) + })); + assert!(scene.geometries.iter().any(|geometry| { + geometry.primitives.iter().any(|primitive| { + !primitive.bfc_cull && !primitive.bfc_ccw && primitive.bfc_inverted + }) + })); + assert!( + scene + .occurrences + .iter() + .any(|occurrence| occurrence.bfc_ccw) + ); + assert!( + scene + .occurrences + .iter() + .any(|occurrence| !occurrence.bfc_ccw) + ); + let edge_occurrence = scene + .occurrences + .iter() + .find(|occurrence| occurrence.primitive_colour == 24) + .expect("colour-24 edge occurrence"); + let edge_geometry = scene + .geometries + .iter() + .find(|geometry| geometry.id.0 == edge_occurrence.node.0) + .expect("edge geometry"); + assert_eq!(edge_geometry.lines.len(), 1); + assert_eq!(scene.stats.triangles, 2); + assert_eq!(scene.stats.lines, 1); } #[test] @@ -2889,10 +3902,13 @@ mod tests { polymodel_ldraw_core::RootId::OfficialLibrary, b"b", ); - let child = |id: &str, key: polymodel_ldraw_core::CacheKey| ModelSummary { + let child = |model: polymodel_ldraw_core::ModelId, + id: &str, + key: polymodel_ldraw_core::CacheKey| ModelSummary { + model, id: id.into(), - path: shared_path.to_string(), - cache_key: key, + path: shared_path.as_str().into(), + cache_key: key.clone(), depth: 1, include_spans: Vec::new(), includes: Vec::new(), @@ -2909,6 +3925,14 @@ mod tests { step_offsets: Vec::new(), }, geometry: vec![GeometryRecord { + primitive_id: 0, + source: key, + span: polymodel_ldraw_core::Span { + start: 0, + end: 31, + line: 1, + column: 1, + }, line_type: LineType::Three, colour: polymodel_ldraw_core::ColourCode(4), vertices: vec![ @@ -2916,11 +3940,19 @@ mod tests { polymodel_ldraw_core::Point3::new(1.0, 0.0, 0.0), polymodel_ldraw_core::Point3::new(0.0, 1.0, 0.0), ], + bfc: polymodel_ldraw_core::BfcFrame { + state: polymodel_ldraw_core::BfcState::Certified, + clipping: true, + winding: polymodel_ldraw_core::Winding::Ccw, + invert_next: false, + }, + inverted: false, texmap: None, }], bfc: polymodel_ldraw_core::BfcState::Certified, }; let root = ModelSummary { + model: polymodel_ldraw_core::ModelId(1), id: "root".into(), path: "root.ldr".into(), cache_key: root_key, @@ -2943,12 +3975,15 @@ mod tests { bfc: polymodel_ldraw_core::BfcState::Unknown, }; let transform = polymodel_ldraw_core::Transform::default(); - let instance = - |id: &str, key: polymodel_ldraw_core::CacheKey| polymodel_ldraw_core::InstanceRecord { + let instance = |model: polymodel_ldraw_core::ModelId, + id: &str, + key: polymodel_ldraw_core::CacheKey| { + polymodel_ldraw_core::InstanceRecord { + model, instance_id: id.into(), - parent_chain: vec!["root".into()], + parent: None, source_model: "root.ldr".into(), - target_model: shared_path.to_string(), + target_model: shared_path.as_str().into(), source_span: None, target_key: Some(key), transform, @@ -2959,13 +3994,14 @@ mod tests { texmap_state: None, texmap_reference: None, texmap: None, - }; + } + }; let parsed_scene = polymodel_ldraw_core::SceneRecord { model_id: "root".into(), instance_ids: vec!["a".into(), "b".into()], instances: vec![ - instance("a", child_a_key.clone()), - instance("b", child_b_key.clone()), + instance(polymodel_ldraw_core::ModelId(2), "a", child_a_key.clone()), + instance(polymodel_ldraw_core::ModelId(3), "b", child_b_key.clone()), ], triangles: 2, quads: 0, @@ -2975,7 +4011,11 @@ mod tests { reflection: false, }; let scene = adapt_ldraw_assembly( - &[root, child("a", child_a_key), child("b", child_b_key)], + &[ + root, + child(polymodel_ldraw_core::ModelId(2), "a", child_a_key), + child(polymodel_ldraw_core::ModelId(3), "b", child_b_key), + ], Some(&parsed_scene), None, ) diff --git a/crates/polymodel-renderer-worker/src/worker.rs b/crates/polymodel-renderer-worker/src/worker.rs index 0dfe31a..7bd716a 100644 --- a/crates/polymodel-renderer-worker/src/worker.rs +++ b/crates/polymodel-renderer-worker/src/worker.rs @@ -34,7 +34,7 @@ use polymodel_renderer_protocol::{ ResourceToken, deserialize_command_envelope, serialize_event_envelope, }; use polymodel_renderer_worker::adapt_ldraw_parse; -use three_d_asset::{Geometry, Texture2D, TextureData}; +use three_d_asset::{Geometry, InnerSpace as _, Texture2D, TextureData}; // --------------------------------------------------------------------------- // Allocator @@ -189,6 +189,12 @@ const GPU_TEXTURE_OVERHEAD_BYTES: u64 = 256; const SCENE_FIXED_BYTES: u64 = 16 * 1024; const WASM_FIXED_BYTES: u64 = 2 * 65_536; +fn next_generation(current: u32) -> Result { + current + .checked_add(1) + .ok_or("renderer generation exhausted; restart the worker") +} + fn owner(load_epoch: u32, scene_generation: u32) -> ReservationOwner { ReservationOwner::interactive( WORKER_ID, @@ -229,6 +235,7 @@ struct WorkerState { load_reducer: LoadReducer, preview_reducer: LoadReducer, raf_id: Option, + raf_retry_count: u8, raf_closure: Option>, ledger: ReservationLedger, canvas_reservation: Option, @@ -257,6 +264,7 @@ impl WorkerState { load_reducer: LoadReducer::new(RendererNamespace::Interactive), preview_reducer: LoadReducer::new(RendererNamespace::Preview), raf_id: None, + raf_retry_count: 0, raf_closure: None, ledger: ReservationLedger::new(), canvas_reservation: None, @@ -702,6 +710,16 @@ mod tests { wasm_bindgen_test_configure!(run_in_browser); + #[wasm_bindgen_test] + fn generation_allocation_never_reuses_saturated_identity() { + assert_eq!(next_generation(0), Ok(1)); + assert_eq!(next_generation(u32::MAX - 1), Ok(u32::MAX)); + assert_eq!( + next_generation(u32::MAX), + Err("renderer generation exhausted; restart the worker") + ); + } + fn textured_box() -> ModelMesh { let primary = include_bytes!("../../../public/models/box_textured.gltf"); let companions = vec![ @@ -998,14 +1016,28 @@ fn start_frame_loop(state: &Rc>) { } fn schedule_next_frame(st: &mut WorkerState, state: Rc>) { + const MAX_RAF_RETRIES: u8 = 8; if st.raf_id.is_some() || st.disposed { return; } let scope = st.scope.clone(); let closure_ref = st.raf_closure.as_ref().expect("raf closure exists"); match scope.request_animation_frame(closure_ref.as_ref().unchecked_ref()) { - Ok(id) => st.raf_id = Some(id), + Ok(id) => { + st.raf_retry_count = 0; + st.raf_id = Some(id); + } Err(_) => { + st.raf_id = None; + if st.raf_retry_count >= MAX_RAF_RETRIES { + st.post_event(&RendererEvent::Error { + fatal: true, + message: "requestAnimationFrame scheduling failed repeatedly".into(), + }); + st.disposed = true; + return; + } + st.raf_retry_count = st.raf_retry_count.saturating_add(1); let state_for_timeout = state.clone(); let timeout_closure = Closure::wrap(Box::new(move || { let st = state_for_timeout.borrow(); @@ -1014,13 +1046,7 @@ fn schedule_next_frame(st: &mut WorkerState, state: Rc>) { } drop(st); let mut st = state_for_timeout.borrow_mut(); - let scope = st.scope.clone(); - if let Some(closure) = st.raf_closure.as_ref() { - if let Ok(id) = scope.request_animation_frame(closure.as_ref().unchecked_ref()) - { - st.raf_id = Some(id); - } - } + schedule_next_frame(&mut st, state_for_timeout.clone()); }) as Box); let scope = st.scope.clone(); let _ = scope.set_timeout_with_callback_and_timeout_and_arguments_0( @@ -1083,19 +1109,23 @@ fn consume_input_and_render(st: &mut WorkerState) { renderer .control .handle_events(&mut renderer.camera, &mut events); - if let Some(scene) = renderer.ldraw_scene.as_ref() { - let direction = renderer.camera.position() - renderer.camera.target(); + } + + if !button_down && let Some(scene) = renderer.ldraw_scene.as_ref() { + let direction = renderer.camera.position() - renderer.camera.target(); + let length = f64::from(direction.magnitude()); + if length > f64::EPSILON { let view = [ - f64::from(direction.x), - f64::from(direction.y), - f64::from(direction.z), + f64::from(direction.x) / length, + f64::from(direction.y) / length, + f64::from(direction.z) / length, ]; let changed = renderer.ldraw_last_view.is_none_or(|previous| { let dot = previous[0] * view[0] + previous[1] * view[1] + previous[2] * view[2]; dot < 0.9995 }); if changed { - match polymodel_renderer_worker::build_conditional_model_for_view( + match polymodel_renderer_worker::build_edge_model_for_view( &renderer.context, scene, view, @@ -1306,7 +1336,28 @@ fn handle_load_mesh( return; } }; - st.scene_generation = st.scene_generation.saturating_add(1); + st.scene_generation = match next_generation(st.scene_generation) { + Ok(generation) => generation, + Err(detail) => { + let error = LoadError { + code: LoadErrorCode::Install, + detail: detail.to_owned(), + }; + st.load_reducer.reduce(LoadEvent::InstallFailed { + namespace, + identity, + error, + candidate: None, + }); + st.post_event(&RendererEvent::LoadFailed { + namespace, + identity, + code: LoadErrorCode::Install, + detail: detail.to_owned(), + }); + return; + } + }; let scene_owner = owner(st.load_epoch, st.scene_generation); let (scene_reservations, texture_reservations) = match admit_scene(&st.ledger, scene_owner, &model) { @@ -1608,13 +1659,47 @@ async fn load_compound_source( Ok(source) } +fn fail_compound_install( + state: &mut WorkerState, + namespace: RendererNamespace, + identity: LoadIdentity, + detail: String, +) { + let error = LoadError { + code: LoadErrorCode::Install, + detail: detail.clone(), + }; + let reducer = match namespace { + RendererNamespace::Interactive => &mut state.load_reducer, + RendererNamespace::Preview => &mut state.preview_reducer, + }; + let transition = reducer.reduce(LoadEvent::InstallFailed { + namespace, + identity, + error, + candidate: None, + }); + if matches!( + transition.outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) { + state.post_event(&RendererEvent::LoadFailed { + namespace, + identity, + code: LoadErrorCode::Install, + detail, + }); + } +} + fn finish_compound_source( state: &Rc>, namespace: RendererNamespace, identity: LoadIdentity, result: Result, ) { - if let Ok(mut st) = state.try_borrow_mut() { + { + let mut st = state.borrow_mut(); let slot = match namespace { RendererNamespace::Interactive => &mut st.interactive_compound_abort, RendererNamespace::Preview => &mut st.preview_abort, @@ -1624,7 +1709,11 @@ fn finish_compound_source( } match result { Ok(source) => { - if compound_is_current(state, namespace, identity, None).is_err() { + let reducer = match namespace { + RendererNamespace::Interactive => &st.load_reducer, + RendererNamespace::Preview => &st.preview_reducer, + }; + if st.disposed || reducer.current_identity() != Some(identity) { return; } if namespace == RendererNamespace::Preview { @@ -1684,15 +1773,34 @@ fn finish_compound_source( return; } }; + let reducer = match namespace { + RendererNamespace::Interactive => &mut st.load_reducer, + RendererNamespace::Preview => &mut st.preview_reducer, + }; + let loaded = reducer.reduce(LoadEvent::LoadSucceeded { + namespace, + identity, + candidate: None, + }); + if !matches!( + loaded.outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) { + return; + } + st.post_event(&RendererEvent::LoadSucceeded { + namespace, + identity, + }); let bounds = match scene.bounds { Some(bounds) => bounds, None => { - st.post_event(&RendererEvent::LoadFailed { + fail_compound_install( + &mut st, namespace, identity, - code: LoadErrorCode::Install, - detail: "LDraw scene has no bounds".to_owned(), - }); + "LDraw scene has no bounds".to_owned(), + ); return; } }; @@ -1702,29 +1810,25 @@ fn finish_compound_source( let context = match st.renderer.as_ref() { Some(renderer) => renderer.context.clone(), None => { - st.post_event(&RendererEvent::LoadFailed { + fail_compound_install( + &mut st, namespace, identity, - code: LoadErrorCode::Install, - detail: "renderer context is not attached".to_owned(), - }); + "renderer context is not attached".to_owned(), + ); return; } }; let scene_ref = std::rc::Rc::new(scene.clone()); - let models = match polymodel_renderer_worker::build_scene_model_parts_for_view( + let models = match polymodel_renderer_worker::build_scene_model_parts_for_style( &context, &scene_ref, [0.0, 0.0, 1.0], + polymodel_renderer_worker::RenderStyle::Faces, ) { - Ok(models) => models, + Ok((static_model, edge_model, _picker)) => (static_model, edge_model), Err(detail) => { - st.post_event(&RendererEvent::LoadFailed { - namespace, - identity, - code: LoadErrorCode::Install, - detail, - }); + fail_compound_install(&mut st, namespace, identity, detail); return; } }; @@ -1737,21 +1841,6 @@ fn finish_compound_source( } else { (None, None) }; - let reducer = match namespace { - RendererNamespace::Interactive => &mut st.load_reducer, - RendererNamespace::Preview => &mut st.preview_reducer, - }; - let loaded = reducer.reduce(LoadEvent::LoadSucceeded { - namespace, - identity, - candidate: None, - }); - if !matches!( - loaded.outcome, - polymodel_renderer_protocol::TransitionOutcome::Committed - ) { - return; - } if let Some((static_model, conditional_model, scene)) = candidate_models.map(|((static_model, conditional_model), scene)| { (static_model, conditional_model, scene) @@ -1788,7 +1877,7 @@ fn finish_compound_source( renderer.scene_bounds = Some(bounds); renderer.model = None; renderer.ldraw_static_model = Some(static_model); - renderer.ldraw_conditional_model = Some(conditional_model); + renderer.ldraw_conditional_model = conditional_model; renderer.ldraw_last_view = Some([0.0, 0.0, 1.0]); renderer.ldraw_scene = Some(scene); st.needs_render = true; @@ -1853,7 +1942,16 @@ fn handle_render_preview_image( ) { let identity = { let mut st = state.borrow_mut(); - st.preview_generation = st.preview_generation.saturating_add(1); + 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 { diff --git a/crates/polymodel-renderer-worker/tests/ldraw_preview.rs b/crates/polymodel-renderer-worker/tests/ldraw_preview.rs index 2829026..27c6a72 100644 --- a/crates/polymodel-renderer-worker/tests/ldraw_preview.rs +++ b/crates/polymodel-renderer-worker/tests/ldraw_preview.rs @@ -124,7 +124,7 @@ fn isolated_plate() -> IsolatedPlate { .collect::>(); assert_eq!(plate_batches.len(), 1, "3021 body must have one GPU batch"); assert_eq!( - plate_batches[0].transforms.len(), + plate_batches[0].instances.len(), 1, "isolated root body uploads once" ); @@ -443,8 +443,35 @@ fn pinned_scene() -> polymodel_renderer_protocol::scene::Scene { 375, "recovered shell surface definitions stay present" ); - assert_eq!(scene.stats.occurrences, 214_903); - assert_eq!(scene.stats.triangles, 4_116_537); + assert!(!result.result.scene.instances.is_empty()); + assert_eq!( + result.result.scene.instance_ids.len(), + result.result.scene.instances.len() + ); + assert_eq!( + scene.stats.occurrences, + u64::try_from(scene.occurrences.len()).expect("occurrence count") + ); + let geometry_by_id = scene + .geometries + .iter() + .map(|geometry| (geometry.id.0, geometry)) + .collect::>(); + let emitted_triangles = scene + .occurrences + .iter() + .try_fold(0_u64, |total, occurrence| { + let geometry = geometry_by_id + .get(&occurrence.node.0) + .expect("occurrence geometry"); + let triangles = u64::try_from(geometry.triangles.len()).expect("triangle count"); + let quads = u64::try_from(geometry.quads.len()).expect("quad count"); + total + .checked_add(triangles) + .and_then(|count| count.checked_add(quads.checked_mul(2)?)) + }) + .expect("emitted triangle count"); + assert_eq!(scene.stats.triangles, emitted_triangles); let renderable = scene .geometries .iter() @@ -769,6 +796,7 @@ fn identity_and_reflected_batches_preserve_outward_normal_and_material() { quads: Vec::new(), lines: Vec::new(), conditional_lines: Vec::new(), + primitives: Vec::new(), }; let normal_z = |topology: &polymodel_renderer_worker::SceneMeshTopology| { let [a, b, c] = topology.indices[..3] else { diff --git a/justfile b/justfile index 837afb9..5921de5 100644 --- a/justfile +++ b/justfile @@ -321,7 +321,7 @@ _build-web-release: _build-renderer-worker _build-opencascade-step _verify-renderer-split: _build-web @echo "==> AC.1: checking app dependency tree for forbidden crates..." - @if cargo tree -p polymodel --target wasm32-unknown-unknown --features web --edges normal 2>/dev/null | grep -qE '(^| )three-d v|three-d-asset v|stl_io v|polymodel-mesh v'; then echo "FAIL: forbidden crate found in app dependency tree"; exit 1; else echo "PASS: no forbidden crates"; fi + @if cargo tree -p polymodel --target wasm32-unknown-unknown --features web --edges normal 2>/dev/null | grep -qE '(^| )three-d v|three-d-asset v|stl_io v|polymodel-mesh v|polymodel-ldraw-core v'; then echo "FAIL: forbidden crate found in app dependency tree"; exit 1; else echo "PASS: no forbidden crates"; fi @echo "==> AC.1b: checking app Cargo.toml for forbidden deps..." @if grep -qE '^\s*(three-d|three-d-asset|stl_io|polymodel-mesh)\b' Cargo.toml; then echo "FAIL: forbidden dep found in Cargo.toml"; exit 1; else echo "PASS: no forbidden deps in Cargo.toml"; fi @echo "==> AC.2: checking app wasm bundle size (debug build, debuginfo-dominated)..." diff --git a/src/appview/state.rs b/src/appview/state.rs index f67083e..5d148e9 100644 --- a/src/appview/state.rs +++ b/src/appview/state.rs @@ -64,6 +64,7 @@ impl AppState { ) } + #[cfg(test)] pub(crate) fn new_with_resolver( pool: SqlitePool, bootstrap: OAuthBootstrap, diff --git a/src/viewer.rs b/src/viewer.rs index 74173ed..96c1b2f 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -1418,6 +1418,7 @@ struct WorkerBridgeState { post_canvas_queue: Rc>>, pending_resize: Option, compound_started_key: Option, + active_compound_identity: Rc>>, current_intent: Option, compound_plan_refresh_epoch: Option, last_started_source_key: Option, @@ -1511,6 +1512,7 @@ impl WorkerBridgeState { post_canvas_queue: Rc::new(RefCell::new(Vec::new())), pending_resize: None, compound_started_key: None, + active_compound_identity: Rc::new(RefCell::new(None)), current_intent: None, compound_plan_refresh_epoch: None, last_started_source_key: None, @@ -1540,7 +1542,7 @@ impl WorkerBridgeState { worker_session_id, worker_capabilities, cancelled_loads, - compound_boundary: _, + compound_boundary, canvas_mounted, canvas_mount_epoch, canvas_mount_requested, @@ -1595,6 +1597,8 @@ impl WorkerBridgeState { let mut worker_session_id_msg = worker_session_id; let mut worker_capabilities_msg = worker_capabilities; let mut cancelled_loads_msg = cancelled_loads; + let mut compound_boundary_msg = compound_boundary; + let active_compound_identity_msg = self.active_compound_identity.clone(); let reducer_msg = self.load_reducer.clone(); let current_session_msg = self.current_session.clone(); let handshake_msg = self.handshake.clone(); @@ -1755,6 +1759,9 @@ impl WorkerBridgeState { && let Some(session) = current_session_msg.borrow().clone() { stats_msg.set(Some(stats)); + compound_boundary_msg.set( + active_compound_identity_msg.borrow().as_ref() == Some(&identity), + ); status_msg.set(ViewerStatus::Ready(session)); } } @@ -1904,6 +1911,7 @@ impl WorkerBridgeState { return; } self.compound_started_key = Some(pending.logical.key.clone()); + *self.active_compound_identity.borrow_mut() = Some(identity); self.current_intent = Some(pending.logical.clone()); *self.current_session.borrow_mut() = Some(SessionKey::new( pending.logical.key.clone(), @@ -1917,6 +1925,7 @@ impl WorkerBridgeState { } fn start_load(&mut self, source: RenderSource) { + *self.active_compound_identity.borrow_mut() = None; let identity = self.allocate_identity(); let namespace = RendererNamespace::Interactive; let result = self.load_reducer.borrow_mut().reduce(LoadEvent::StartLoad { @@ -1966,6 +1975,7 @@ impl WorkerBridgeState { self.pending_resize = None; self.post_canvas_queue.borrow_mut().clear(); self.compound_started_key = None; + *self.active_compound_identity.borrow_mut() = None; self.compound_plan_refresh_epoch = None; self.last_started_source_key = None; self.restart_epoch = self.restart_epoch.saturating_add(1); diff --git a/tools/ldraw-compat-harness/Cargo.lock b/tools/ldraw-compat-harness/Cargo.lock index f8fd922..613929c 100644 --- a/tools/ldraw-compat-harness/Cargo.lock +++ b/tools/ldraw-compat-harness/Cargo.lock @@ -17,12 +17,40 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borsh" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -52,6 +80,30 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -68,6 +120,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "ldraw-compat-harness" version = "0.1.0" @@ -115,6 +178,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "polymodel-ldraw-core" version = "0.1.0" @@ -124,7 +199,9 @@ dependencies = [ "polymodel-renderer-ledger", "serde", "sha2", + "smol_str", "thiserror", + "web-time", ] [[package]] @@ -160,6 +237,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "serde" version = "1.0.229" @@ -214,6 +297,22 @@ dependencies = [ "digest", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + [[package]] name = "syn" version = "2.0.119" @@ -280,6 +379,61 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/tools/ldraw-compat-harness/src/main.rs b/tools/ldraw-compat-harness/src/main.rs index ea35e35..3b36b62 100644 --- a/tools/ldraw-compat-harness/src/main.rs +++ b/tools/ldraw-compat-harness/src/main.rs @@ -819,10 +819,6 @@ fn candidate_observation( "oracle_expected.tokenized_models", "oracle_expected.tokenized_geometry", "oracle_expected.resolved_geometry.triangle_positions", - "oracle_expected.models[*].mesh.positions", - "oracle_expected.models[*].mesh.normals", - "oracle_expected.models[*].mesh.indices", - "oracle_expected.models[*].mesh.bf_indices", ], })) } @@ -959,6 +955,13 @@ fn compare_candidate_to_oracle( fixture.id, model_index, oracle_model.name, oracle_model.step_ends, candidate_model.step_ends )); } + polymodel_ldraw_testkit::compare_mesh_semantic( + &oracle_model.name, + &oracle_model.mesh, + &oracle_model.step_ends, + &candidate_model.semantic, + ) + .map_err(|error| format!("{} model[{}] semantic mesh: {error}", fixture.id, model_index))?; let expected_certification = polymodel_ldraw_testkit::normalize_certification(&oracle_model.certify) .ok_or_else(|| format!("{} invalid oracle certification at model[{}]", fixture.id, model_index))?; let candidate_certification = match candidate_model.bfc {