//! Shared renderer scene boundary for the WASM worker and native preview. #[cfg(not(target_arch = "wasm32"))] use polymodel_ldraw_core::Materialization; use polymodel_ldraw_core::{ ModelSummary, NormalizedPath, OwnedParseResult, RootId, SemanticEvent, SemanticSink, TraversalContext, }; use polymodel_renderer_protocol::scene::{ GeometryId, MaterialId, NodeId, OccurrenceId, ProvenanceId, Scene, SceneBounds, SceneGeometry, SceneId, SceneMaterial, SceneOccurrence, ScenePrimitiveRange, SceneStats, SceneTexmapAssociation, SceneTexmapProjectionMode, SceneTexture, TextureId, }; use polymodel_renderer_protocol::{LengthUnit, ModelResource, Units}; use smol_str::SmolStr; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; #[cfg(not(target_arch = "wasm32"))] use std::sync::Mutex; #[cfg(not(target_arch = "wasm32"))] use three_d::{AmbientLight, Camera, DirectionalLight, OrbitControl}; use three_d::{ AxisAlignedBoundingBox, Blend, BlendEquationType, BlendMultiplierType, CpuMaterial, CpuMesh, Cull, Effect, EffectMaterialId, Geometry, GeometryId as ThreeGeometryId, Gm, Indices, InstanceBuffer, InstancedMesh, Instances, Light, Mat4, Material, MaterialType, Matrix, PhysicalMaterial, Positions, Program, RenderStates, Srgba, VertexBuffer, Viewer, WriteMask, }; #[cfg(not(target_arch = "wasm32"))] use three_d::{InnerSpace, Object}; use three_d_asset::{Texture2D, TextureData}; use web_time::Instant; #[cfg(not(target_arch = "wasm32"))] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum NativeStage { ProcessStarted, WindowCreated, PreparationStarted, PreparationComplete, PreparationFailed, NativeModelBuildStarted, NativeModelBuildComplete, PickIndexBuildStarted, PickIndexBuildComplete, SceneInstalled, FirstRedrawRequested, BaseRenderStarted, BaseRenderComplete, FirstSwapComplete, Rendered, Started, LibraryResolved, OmrParsed, SceneAdapted, ContextCreated, GpuResourcesCreated, FrameCallbackEntered, DrawCompleted, ReadbackCompleted, OutputWritten, } #[cfg(not(target_arch = "wasm32"))] #[derive(Clone, Debug, Eq, PartialEq)] pub enum NativeTerminal { Success, Failure(String), Timeout(String), } #[cfg(not(target_arch = "wasm32"))] #[derive(Clone, Debug)] pub struct NativeStageRecord { pub stage: NativeStage, pub elapsed_ms: u128, } #[cfg(not(target_arch = "wasm32"))] #[derive(Clone, Debug, Default)] pub struct NativeTraceSnapshot { pub stages: Vec, pub records: Vec, pub terminal: Option, } #[cfg(not(target_arch = "wasm32"))] #[derive(Clone)] pub struct NativeTrace { state: Arc>, started: Arc, } #[cfg(not(target_arch = "wasm32"))] impl Default for NativeTrace { fn default() -> Self { Self { state: Arc::new(Mutex::new(NativeTraceSnapshot::default())), started: Arc::new(Instant::now()), } } } #[cfg(not(target_arch = "wasm32"))] impl NativeTrace { pub fn record(&self, stage: NativeStage) { let elapsed_ms = self.started.elapsed().as_millis(); let mut state = self.state.lock().expect("native trace poisoned"); state.stages.push(stage); state.records.push(NativeStageRecord { stage, elapsed_ms }); tracing::debug!("[ldraw-preview] stage={stage:?} elapsed_ms={elapsed_ms}"); } pub fn finish(&self, terminal: NativeTerminal) { self.state.lock().expect("native trace poisoned").terminal = Some(terminal); } pub fn snapshot(&self) -> NativeTraceSnapshot { self.state.lock().expect("native trace poisoned").clone() } pub fn timeout(&self, detail: impl Into) { let snapshot = self.snapshot(); let last = snapshot .stages .last() .map(|stage| format!("{stage:?}")) .unwrap_or_else(|| "".into()); let display = std::env::var("DISPLAY").unwrap_or_else(|_| "".into()); let vendor = std::env::var("POLYMODEL_GL_VENDOR").unwrap_or_else(|_| "".into()); let renderer = std::env::var("POLYMODEL_GL_RENDERER").unwrap_or_else(|_| "".into()); let version = std::env::var("POLYMODEL_GL_VERSION").unwrap_or_else(|_| "".into()); self.finish(NativeTerminal::Timeout(format!( "{}; last_stage={last}; DISPLAY={display}; gl_vendor={vendor}; gl_renderer={renderer}; gl_version={version}", detail.into() ))); } } pub const LDRAW_TO_WORLD: [[f64; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, -1.0, 0.0]]; pub fn ldraw_to_world(point: [f64; 3]) -> [f64; 3] { [point[0], point[2], -point[1]] } pub fn basis_determinant() -> f64 { 1.0 } #[derive(Debug, PartialEq, Eq, thiserror::Error)] pub enum RendererError { #[error("missing geometry {0:?}")] MissingGeometry(GeometryId), #[error("missing material {0:?}")] MissingMaterial(MaterialId), #[error("missing texture {0:?}")] MissingTexture(TextureId), #[error("missing node {0:?}")] MissingNode(NodeId), #[error("duplicate geometry {0:?}")] DuplicateGeometry(GeometryId), #[error("index overflow: {context}")] IndexOverflow { context: &'static str }, #[error("invalid range: {context} (index {index}, length {len})")] InvalidRange { context: &'static str, index: usize, len: usize, }, #[error("non-finite transform or coordinate")] NonFiniteTransform, #[error("GPU/resource failure: {0}")] GpuResource(String), #[error("texture decode failure: {0}")] TextureDecode(String), #[error("parse/adaptation failure: {0}")] ParseAdaptation(&'static str), #[error("parse/adaptation failure: {0}")] ParseMessage(String), #[error("I/O failure: {0}")] Io(String), #[error("renderer generation exhausted; restart the worker")] GenerationExhausted, #[error("renderer request cancelled")] Cancelled, #[error("external runtime failure: {0}")] External(String), } impl RendererError { fn parse(message: &'static str) -> Self { Self::ParseAdaptation(message) } pub fn external_message(&self) -> Option<&str> { match self { Self::External(message) => Some(message), _ => None, } } } fn finite_f32(value: f64) -> Result { if !value.is_finite() || value < f32::MIN as f64 || value > f32::MAX as f64 { return Err(RendererError::NonFiniteTransform); } Ok(value as f32) } #[derive(Debug)] pub enum ParseAndAdaptError { Parse(polymodel_ldraw_core::ParseError), Adapt(RendererError), } pub fn parse_and_adapt_ldraw( request: polymodel_ldraw_core::AdapterRequest<'_>, limits: polymodel_ldraw_core::LdrawLimits, resources: Option<&[ModelResource]>, ) -> Result { let mut sink = AdapterSink::new(resources); let result = polymodel_ldraw_core::InProcessRustAdapter::parse_with_limits_and_sink( request, limits, &mut sink, ) .map_err(ParseAndAdaptError::Parse)?; sink.take_error().map_err(ParseAndAdaptError::Adapt)?; let models = result.models.clone(); adapt_ldraw_assembly_with_sink( &models, Some(&result.result.scene), Some(&result), resources, Some(sink), ) .map_err(ParseAndAdaptError::Adapt) } #[cfg(not(target_arch = "wasm32"))] fn texture_resource_root(root: RootId) -> &'static str { match root { RootId::CurrentMpd => "mpd", RootId::UploadedManifest => "manifest", RootId::UploadedLdraw => "models", RootId::OfficialLibrary => "official-parts", } } fn texture_resource_for<'a>( resources: &'a [ModelResource], root: RootId, path: &NormalizedPath, ) -> Option<&'a ModelResource> { let root_matches = |label: &str| match root { RootId::CurrentMpd => label == "mpd", RootId::UploadedManifest => label == "manifest", RootId::UploadedLdraw => matches!(label, "models" | "parts" | "p" | "external"), RootId::OfficialLibrary => matches!(label, "official-parts" | "official-p"), }; let authored = path.as_str(); let stripped = authored .split_once('/') .filter(|(prefix, _)| prefix.eq_ignore_ascii_case("textures")) .map(|(_, rest)| rest) .unwrap_or(authored); let prefixed = format!("textures/{stripped}"); for candidate in [authored, prefixed.as_str(), stripped] { if let Some(resource) = resources .iter() .find(|resource| root_matches(&resource.root) && resource.path == candidate) { return Some(resource); } } None } fn adapt_ldraw_parse_result( result: &OwnedParseResult, resources: Option<&[polymodel_renderer_protocol::ModelResource]>, sink: AdapterSink<'_>, ) -> Result { let started = Instant::now(); let models = result.models.as_slice(); if models.is_empty() { return Err(RendererError::parse("parse emitted no root model")); } let scene = adapt_ldraw_assembly_with_sink( models, Some(&result.result.scene), Some(result), resources, Some(sink), )?; if std::env::var_os("POLYMODEL_LDRAW_TIMING").is_some() { eprintln!( "[ldraw-metrics] phase=adapt-ldraw elapsed_ms={} geometries={} materials={} textures={} occurrences={} points={} triangles={} lines={} conditional_lines={}", started.elapsed().as_millis(), scene.geometries.len(), scene.materials.len(), scene.textures.len(), scene.stats.occurrences, scene.stats.points, scene.stats.triangles, scene.stats.lines, scene.stats.conditional_lines, ); } Ok(scene) } #[derive(Clone, Hash, PartialEq, Eq)] struct PrimitiveBatchKey { colour: u32, bfc_state: polymodel_ldraw_core::BfcState, bfc_cull: bool, bfc_ccw: bool, reverse_winding: bool, texmap: Option, } fn append_items(slot: &mut Vec, items: impl IntoIterator) { slot.extend(items); } struct SceneGeometryBuilder { id: GeometryId, provenance: ProvenanceId, points: Vec<[f64; 3]>, uvs: Vec<[f32; 2]>, triangles: Vec<[u32; 3]>, quads: Vec<[u32; 4]>, lines: Vec<[u32; 2]>, conditional_lines: Vec<[u32; 4]>, primitives: Vec, } impl SceneGeometryBuilder { fn into_scene_geometry(self) -> SceneGeometry { SceneGeometry { id: self.id, provenance: self.provenance, points: self.points.into_boxed_slice(), uvs: self.uvs.into_boxed_slice(), triangles: self.triangles.into_boxed_slice(), quads: self.quads.into_boxed_slice(), lines: self.lines.into_boxed_slice(), conditional_lines: self.conditional_lines.into_boxed_slice(), primitives: self.primitives.into_boxed_slice(), } } } struct AdapterSink<'a> { geometries: Vec, provenance: Vec, nodes_by_model: HashMap>, batch_indices: HashMap<(ProvenanceId, u64), Vec>, resources: Option<&'a [ModelResource]>, current_model: Option, current_provenance: Option, error: Option, } impl<'a> AdapterSink<'a> { pub fn new(resources: Option<&'a [ModelResource]>) -> Self { Self { geometries: Vec::new(), provenance: Vec::new(), nodes_by_model: HashMap::new(), batch_indices: HashMap::new(), resources, current_model: None, current_provenance: None, error: None, } } fn take_error(&mut self) -> Result<(), RendererError> { self.error.take().map_or(Ok(()), Err) } fn texmap_available(&self, root: RootId, texmap: &SceneTexmapAssociation) -> bool { let Some(resources) = self.resources else { return false; }; let valid = |reference: &str| { let path = NormalizedPath::new(reference).ok()?; let resource = texture_resource_for(resources, root, &path)?; resource.validate().ok().map(|_| ()) }; valid(texmap.pngfile.as_str()).is_some() } fn emit_geometry( &mut self, colour: polymodel_ldraw_core::ColourCode, vertices: &[polymodel_ldraw_core::Point3], line_type: polymodel_ldraw_core::LineType, bfc: polymodel_ldraw_core::BfcFrame, texmap_source: Option<&polymodel_ldraw_core::TexmapAssociation>, inverted: bool, ) -> Result<(), RendererError> { use polymodel_ldraw_core::LineType; use std::hash::{Hash, Hasher}; let model = self .current_model .as_ref() .ok_or_else(|| RendererError::parse("geometry emitted before model boundary"))?; let provenance = self .current_provenance .ok_or_else(|| RendererError::parse("geometry emitted without provenance"))?; let texmap = texmap_source.and_then(|texmap| { let 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 } }; let association = SceneTexmapAssociation { mode, 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, }; self.texmap_available(model.resolved_root, &association) .then_some(association) }); if texmap_source.is_some_and(|source| source.fallback && texmap.is_some()) { return Ok(()); } for point in vertices { finite_f32(point.x)?; finite_f32(point.y)?; finite_f32(point.z)?; } let key = PrimitiveBatchKey { colour: u32::from(colour), bfc_state: bfc.state, bfc_cull: bfc.clipping, bfc_ccw: matches!(bfc.winding, polymodel_ldraw_core::Winding::Ccw), reverse_winding: inverted ^ !matches!(bfc.winding, polymodel_ldraw_core::Winding::Ccw), texmap, }; let mut hasher = std::collections::hash_map::DefaultHasher::new(); key.hash(&mut hasher); let key_hash = hasher.finish(); let batch_key = (provenance, key_hash); let batch_index = self.batch_indices.get(&batch_key).and_then(|indices| { indices.iter().copied().find(|index| { self.geometries[*index].provenance == provenance && self.nodes_by_model.get(model).is_some_and(|nodes| { nodes.iter().any(|(node, candidate)| { *node == NodeId(self.geometries[*index].id.0) && candidate == &key }) }) }) }); let batch_index = if let Some(index) = batch_index { index } else { let id = GeometryId((self.geometries.len() + 1) as u64); let index = self.geometries.len(); self.geometries.push(SceneGeometryBuilder { id, provenance, points: Vec::new(), uvs: Vec::new(), triangles: Vec::new(), quads: Vec::new(), lines: Vec::new(), conditional_lines: Vec::new(), primitives: Vec::new(), }); self.nodes_by_model .entry(model.clone()) .or_default() .push((NodeId(id.0), key.clone())); self.batch_indices.entry(batch_key).or_default().push(index); index }; let batch = &mut self.geometries[batch_index]; let base = u32::try_from(batch.points.len()).map_err(|_| RendererError::IndexOverflow { context: "geometry index", })?; let point_count = u32::try_from(vertices.len()).map_err(|_| RendererError::IndexOverflow { context: "primitive point count", })?; append_items( &mut batch.primitives, [ScenePrimitiveRange { point_start: base, point_count, topology: smol_str::format_smolstr!("{:?}", line_type), bfc_state: smol_str::format_smolstr!("{:?}", bfc.state), bfc_cull: bfc.clipping, bfc_ccw: matches!(bfc.winding, polymodel_ldraw_core::Winding::Ccw), bfc_inverted: inverted, texmap: self.nodes_by_model[model] .iter() .find(|(node, _)| *node == NodeId(batch.id.0)) .map(|(_, key)| key.texmap.clone()) .flatten(), }], ); append_items( &mut batch.points, vertices .iter() .map(|point| ldraw_to_world([point.x, point.y, point.z])), ); if let Some(texmap) = self.nodes_by_model[model] .iter() .find(|(node, _)| *node == NodeId(batch.id.0)) .and_then(|(_, key)| key.texmap.as_ref()) { let mut parameters = texmap .parameters .iter() .map(|value| { value .parse::() .map_err(|_| RendererError::parse("invalid TEXMAP parameter")) }) .collect::, _>>()?; let expected = match texmap.mode { SceneTexmapProjectionMode::Planar => 9, SceneTexmapProjectionMode::Cylindrical => 10, SceneTexmapProjectionMode::Spherical => 11, }; if parameters.len() != expected { return Err(RendererError::parse("invalid TEXMAP parameter count")); } for offset in (0..9).step_by(3) { let point = [ parameters[offset], parameters[offset + 1], parameters[offset + 2], ]; parameters[offset..offset + 3].copy_from_slice(&ldraw_to_world(point)); } let generated = texmap_uvs( texmap.mode, ¶meters, &batch.points[batch.points.len() - vertices.len()..], ) .map_err(|_| RendererError::parse("invalid TEXMAP UV projection"))?; append_items(&mut batch.uvs, generated); } match line_type { LineType::Two if vertices.len() == 2 => { append_items(&mut batch.lines, [[base, base + 1]]) } LineType::Three if vertices.len() == 3 => { append_items(&mut batch.triangles, [[base, base + 1, base + 2]]) } LineType::Four if vertices.len() == 4 => { append_items(&mut batch.quads, [[base, base + 1, base + 2, base + 3]]) } LineType::Five if vertices.len() == 4 => append_items( &mut batch.conditional_lines, [[base, base + 1, base + 2, base + 3]], ), _ => {} } Ok(()) } } impl SemanticSink for AdapterSink<'_> { fn on_event(&mut self, event: &SemanticEvent<'_>, _context: &TraversalContext) { let result = match event { SemanticEvent::ModelBoundary { name, root } => { let provenance = ProvenanceId((self.provenance.len() + 1) as u64); self.provenance .push(polymodel_renderer_protocol::scene::SceneProvenance { id: provenance, source: name.clone(), span: None, }); self.current_model = Some(root.clone()); self.current_provenance = Some(provenance); Ok(()) } SemanticEvent::GeometryEmitted { colour, vertices, line_type, bfc, texmap, inverted, .. } => self.emit_geometry(*colour, vertices, *line_type, *bfc, *texmap, *inverted), _ => Ok(()), }; if let Err(error) = result { self.error.get_or_insert(error); } } } fn adapt_ldraw_assembly_with_sink( models: &[ModelSummary], parsed_scene: Option<&polymodel_ldraw_core::SceneRecord>, parsed: Option<&OwnedParseResult>, resources: Option<&[polymodel_renderer_protocol::ModelResource]>, sink: Option>, ) -> Result { use std::collections::HashMap; let event_models = models; let mut adapter_sink = sink.unwrap_or_else(|| AdapterSink::new(resources)); adapter_sink.take_error()?; 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 nodes_by_model = event_models .iter() .map(|model| { ( model.model, adapter_sink .nodes_by_model .get(&model.cache_key) .cloned() .unwrap_or_default(), ) }) .collect::>(); let path_by_model = event_models .iter() .map(|model| (model.model, SmolStr::from(model.path.as_str()))) .collect::>(); let definition_id_by_model = event_models .iter() .map(|model| { ( model.model, SmolStr::from(model.cache_key.content_hash.to_string()), ) }) .collect::>(); let canonical_path_by_model = event_models .iter() .map(|model| { ( model.model, SmolStr::from(model.cache_key.canonical_path.as_str()), ) }) .collect::>(); let source_class_by_model = event_models .iter() .map(|model| { ( model.model, SmolStr::from(format!("{:?}", model.cache_key.resolved_root)), ) }) .collect::>(); let source_by_path = event_models .iter() .map(|model| (model.path.clone(), path_by_model[&model.model].clone())) .collect::>(); if std::env::var_os("POLYMODEL_LDRAW_ADAPTER_DIAGNOSTICS").is_some() { let definitions_with_semantic_triangles = nodes_by_model .values() .filter(|nodes| !nodes.is_empty()) .count(); let definitions_with_geometry_triangles = definitions_with_semantic_triangles; tracing::debug!( "[ldraw-adapter] definitions={} semantic_triangle_defs={} geometry_triangle_defs={} root_key={:?}", event_models.len(), definitions_with_semantic_triangles, definitions_with_geometry_triangles, models.first().map(|model| &model.cache_key), ); for (index, model) in models.iter().take(8).enumerate() { tracing::debug!( "[ldraw-adapter] def[{index}] path={} key={:?} semantic_triangles={} geometry_records={} geometry_triangles={}", model.path, model.cache_key, nodes_by_model .get(&model.model) .map(|nodes| nodes.len()) .unwrap_or(0), nodes_by_model .get(&model.model) .map(|nodes| nodes.len()) .unwrap_or(0), nodes_by_model .get(&model.model) .map(|nodes| nodes.len()) .unwrap_or(0), ); } } let geometries = adapter_sink .geometries .into_iter() .map(SceneGeometryBuilder::into_scene_geometry) .collect::>(); let provenance = adapter_sink.provenance; let mut uv_cache = HashMap::<(GeometryId, u64, u64), Arc<[[f32; 2]]>>::new(); let mut uv_cache_hits = 0_u64; let mut uv_cache_misses = 0_u64; let mut texture_ids = HashMap::<(RootId, String, String), TextureId>::new(); let mut textures = Vec::::new(); let mut texture_for = |root: RootId, reference: &str, role: &str| -> Result { let path = NormalizedPath::new(reference).map_err(|_| { RendererError::ParseAdaptation("TEXMAP texture reference is not a valid canonical path") })?; let key = (root, path.as_str().to_owned(), role.to_owned()); if let Some(id) = texture_ids.get(&key) { return Ok(*id); } let resource = resources .and_then(|resources| texture_resource_for(resources, root, &path)) .ok_or_else(|| RendererError::MissingTexture(TextureId((textures.len() + 1) as u64)))?; resource.validate().map_err(|_| { RendererError::TextureDecode("TEXMAP texture resource failed verification".into()) })?; let id = TextureId((textures.len() + 1) as u64); textures.push(SceneTexture { id, provenance: ProvenanceId(1), uri: reference.into(), resource_key: Some(resource.key.as_str().into()), sha256: Some(resource.sha256.clone()), byte_length: Some(resource.byte_length), role: Some(role.into()), }); texture_ids.insert(key, id); Ok(id) }; let texmap_available = |root: RootId, texmap: &SceneTexmapAssociation| { let Some(resources) = resources else { return false; }; let valid = |reference: &str| { let path = NormalizedPath::new(reference).ok()?; let resource = texture_resource_for(resources, root, &path)?; resource.validate().ok().map(|_| ()) }; valid(texmap.pngfile.as_str()).is_some() }; let mut material_ids = HashMap::< ( u32, Option<[u8; 3]>, u8, Option, Option, ), MaterialId, >::new(); let mut materials = Vec::new(); for model in event_models { if let Some(node_by_key) = node_by_key.as_mut() { node_by_key.insert( model.cache_key.clone(), nodes_by_model .get(&model.model) .cloned() .unwrap_or_default(), ); } } 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 colour_data = |colour: u32| { parsed.and_then(|parsed| { parsed .result .colours .get(polymodel_ldraw_core::ColourCode(colour)) .map(|colour| colour.data) }) }; let mut material_for = |root: RootId, colour: u32, texmap: Option<&SceneTexmapAssociation>| { let textures = &mut texture_for; let colour_data = colour_data(colour); let colour_rgb = colour_data.map(|data| [data.r, data.g, data.b]); let alpha = colour_data.and_then(|data| data.alpha).unwrap_or(u8::MAX); let albedo = resources .is_some() .then_some(texmap) .flatten() .filter(|texmap| texmap_available(root, texmap)) .map(|texmap| textures(root, texmap.pngfile.as_str(), "albedo")) .transpose()?; let roughness = resources .is_some() .then_some(texmap) .flatten() .and_then(|texmap| texmap.glossmap.as_deref()) .and_then(|reference| textures(root, reference, "metallic_roughness").ok()); let key = (colour, colour_rgb, alpha, albedo, roughness); Ok::(*material_ids.entry(key).or_insert_with(|| { let id = MaterialId((materials.len() + 1) as u64); materials.push(SceneMaterial { id, colour, colour_rgb, alpha, edge_colour: None, finish: None, local_mapping: None, albedo_texture: albedo, metallic_roughness_texture: roughness, }); id })) }; let instances = parsed_scene .map(|scene| scene.instances.as_slice()) .unwrap_or(&[]); 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) { let hit = occurrence .target_key .as_ref() .and_then(|key| node_by_key.get(key)) .is_some(); if hit { exact_hits += 1; } else { exact_misses += 1; } if index < 12 { tracing::debug!( "[ldraw-adapter] edge[{index}] source={} target={} target_key={:?} exact_hit={hit}", occurrence.source_model, occurrence.target_model, occurrence.target_key, ); } } tracing::debug!( "[ldraw-adapter] edges_sampled={} exact_target_hits={} exact_target_misses={}", instances.len().min(1024), exact_hits, exact_misses, ); } let identity = [ [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 geometry_indices = geometries .iter() .enumerate() .map(|(index, geometry)| (geometry.id, index)) .collect::>(); let root_batches = nodes_by_model .get(&event_models[0].model) .ok_or_else(|| RendererError::ParseAdaptation("root model has no geometry batches"))?; let mut occurrences = Vec::new(); let mut stats = SceneStats::default(); 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, }; let geometry = geometries .get( *geometry_indices .get(&GeometryId(node.0)) .ok_or(RendererError::MissingGeometry(GeometryId(node.0)))?, ) .ok_or(RendererError::MissingGeometry(GeometryId(node.0)))?; accumulate_scene_stats(&mut stats, geometry)?; occurrences.push(SceneOccurrence { id: OccurrenceId((occurrences.len() + 1) as u64), canonical_occurrence_id: "root".into(), parent_occurrence_id: None, logical_part_occurrence_id: None, logical_part_path: None, node: *node, uvs: None, definition_id: definition_id_by_model[&event_models[0].model].clone(), canonical_path: canonical_path_by_model[&event_models[0].model].clone(), source_class: source_class_by_model[&event_models[0].model].clone(), source: path_by_model[&event_models[0].model].clone(), target: path_by_model[&event_models[0].model].clone(), source_span: None, direct_colour: u32::from(polymodel_ldraw_core::DEFAULT_COLOUR), effective_colour, primitive_colour: primitive.colour, material: Some(material_for( event_models[0].cache_key.resolved_root, effective_colour, primitive.texmap.as_ref(), )?), 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 meaningful_part = |path: &str| path.starts_with("parts/") && !path.starts_with("parts/s/"); let mut logical_parts = Vec::>::with_capacity(instances.len()); for (_instance_index, occurrence) in instances.iter().enumerate() { 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_else(|| { RendererError::ParseAdaptation("renderable occurrence missing target identity") })?; let batches = nodes_by_model.get(&occurrence.model).ok_or_else(|| { RendererError::ParseAdaptation("occurrence model was not materialized") })?; 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 occurrence_transform = occurrence.transform; let transform = world_transform(occurrence_transform)?; for (node, primitive) in batches { let (render_node, occurrence_uvs) = if let Some(texmap) = primitive.texmap.as_ref() { let source_index = geometry_indices .get(&GeometryId(node.0)) .copied() .ok_or_else(|| RendererError::MissingGeometry(GeometryId(node.0)))?; use std::hash::{Hash, Hasher}; let mut transform_hasher = std::collections::hash_map::DefaultHasher::new(); for value in transform.into_iter().flatten() { value.to_bits().hash(&mut transform_hasher); } let transform_hash = transform_hasher.finish(); let mut texmap_hasher = std::collections::hash_map::DefaultHasher::new(); match texmap.mode { SceneTexmapProjectionMode::Planar => 0_u8.hash(&mut texmap_hasher), SceneTexmapProjectionMode::Cylindrical => 1_u8.hash(&mut texmap_hasher), SceneTexmapProjectionMode::Spherical => 2_u8.hash(&mut texmap_hasher), } texmap.parameters.hash(&mut texmap_hasher); texmap.pngfile.hash(&mut texmap_hasher); texmap.glossmap.hash(&mut texmap_hasher); texmap.fallback.hash(&mut texmap_hasher); let texmap_hash = texmap_hasher.finish(); let cache_key = (GeometryId(node.0), transform_hash, texmap_hash); let occurrence_uvs = if let Some(uvs) = uv_cache.get(&cache_key) { uv_cache_hits += 1; uvs.clone() } else { uv_cache_misses += 1; let uvs: Arc<[[f32; 2]]> = Arc::from( occurrence_texmap_uvs(texmap, transform, &geometries[source_index].points)? .into_boxed_slice(), ); uv_cache.insert(cache_key, uvs.clone()); uvs }; (*node, Some(occurrence_uvs)) } else { (*node, None) }; let effective_colour = match primitive.colour { 16 => inherited_colour, 24 => edge_colour_for(inherited_colour), colour => colour, }; let geometry = geometries .get( *geometry_indices .get(&GeometryId(render_node.0)) .ok_or(RendererError::MissingGeometry(GeometryId(render_node.0)))?, ) .ok_or(RendererError::MissingGeometry(GeometryId(render_node.0)))?; accumulate_scene_stats(&mut stats, geometry)?; occurrences.push(SceneOccurrence { id: OccurrenceId((occurrences.len() + 1) as u64), canonical_occurrence_id: occurrence.instance_id.clone(), 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: render_node, uvs: occurrence_uvs, definition_id: definition_id_by_model[&occurrence.model].clone(), canonical_path: canonical_path_by_model[&occurrence.model].clone(), source_class: source_class_by_model[&occurrence.model].clone(), source: source_by_path .get(&occurrence.source_model) .cloned() .ok_or_else(|| { RendererError::ParseAdaptation( "occurrence source path was not materialized", ) })?, target: path_by_model .get(&occurrence.model) .cloned() .ok_or_else(|| { RendererError::ParseAdaptation( "occurrence target model was not materialized", ) })?, source_span: occurrence .source_span .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, material: Some(material_for( target_key.resolved_root, effective_colour, primitive.texmap.as_ref(), )?), 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, }); } } let bounds = compute_scene_bounds(parsed_scene, &occurrences, &geometries, &geometry_indices); 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, center_distance: f64, index: usize, source: &'a str, target: &'a str, target_key: &'a polymodel_ldraw_core::CacheKey, min: [f64; 3], max: [f64; 3], } let aggregate_center = bounds.map(|value| { [ (value.min[0] + value.max[0]) * 0.5, (value.min[1] + value.max[1]) * 0.5, (value.min[2] + value.max[2]) * 0.5, ] }); if let Some(value) = bounds { tracing::debug!( "[ldraw-scene] aggregate_bounds min={:?} max={:?} center={:?} extents={:?}", value.min, value.max, aggregate_center, [ value.max[0] - value.min[0], value.max[1] - value.min[1], value.max[2] - value.min[2], ], ); } let mut distribution = Vec::new(); for (index, occurrence) in instances.iter().enumerate() { let Some(target_key) = occurrence.target_key.as_ref() else { continue; }; let Some(batches) = node_by_key.get(target_key) else { continue; }; let mut min = [f64::INFINITY; 3]; let mut max = [f64::NEG_INFINITY; 3]; let mut point_count = 0usize; let Ok(transform) = world_transform(occurrence.transform) else { continue; }; for &(node, _) in batches { let Some(geometry) = geometry_indices .get(&GeometryId(node.0)) .and_then(|index| geometries.get(*index)) else { continue; }; for point in &geometry.points { let Ok(point) = apply_scene_transform(transform, *point) else { continue; }; point_count += 1; for axis in 0..3 { min[axis] = min[axis].min(point[axis]); max[axis] = max[axis].max(point[axis]); } } } if point_count == 0 { continue; } let center = [ (min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5, (min[2] + max[2]) * 0.5, ]; let max_extent = (0..3) .map(|axis| max[axis] - min[axis]) .fold(0.0_f64, f64::max); let center_distance = aggregate_center .map(|aggregate| { (0..3) .map(|axis| (center[axis] - aggregate[axis]).powi(2)) .sum::() .sqrt() }) .unwrap_or(0.0); distribution.push(OccurrenceBoundsDiagnostic { max_extent, center_distance, index, source: &occurrence.source_model, target: &occurrence.target_model, target_key, min, max, }); } distribution.sort_by(|left, right| { right .center_distance .partial_cmp(&left.center_distance) .unwrap_or(std::cmp::Ordering::Equal) }); tracing::debug!( "[ldraw-scene] occurrence_bounds count={} ranked_by=center_distance", distribution.len() ); for diagnostic in distribution.iter().take(8) { tracing::debug!( "[ldraw-scene] outlier index={} center_distance={:.3} max_extent={:.3} min={:?} max={:?} source={} target={} target_key={:?}", diagnostic.index, diagnostic.center_distance, diagnostic.max_extent, diagnostic.min, diagnostic.max, diagnostic.source, diagnostic.target, diagnostic.target_key, ); } distribution.sort_by(|left, right| { right .max_extent .partial_cmp(&left.max_extent) .unwrap_or(std::cmp::Ordering::Equal) }); for diagnostic in distribution.iter().take(8) { tracing::debug!( "[ldraw-scene] largest index={} center_distance={:.3} max_extent={:.3} min={:?} max={:?} source={} target={} target_key={:?}", diagnostic.index, diagnostic.center_distance, diagnostic.max_extent, diagnostic.min, diagnostic.max, diagnostic.source, diagnostic.target, diagnostic.target_key, ); } } if std::env::var_os("POLYMODEL_LDRAW_TIMING").is_some() { let total = uv_cache_hits + uv_cache_misses; let hit_rate = if total == 0 { 1.0 } else { uv_cache_hits as f64 / total as f64 }; eprintln!( "[ldraw-metrics] phase=uv-cache uv_cache_hits={} uv_cache_misses={} hits={} misses={} hit_rate={hit_rate:.6} entries={}", uv_cache_hits, uv_cache_misses, uv_cache_hits, uv_cache_misses, uv_cache.len(), ); } let root = models .first() .ok_or_else(|| RendererError::parse("parse emitted no root model"))?; let source = root.path.clone(); let occurrence_count = u64::try_from(occurrences.len()) .map_err(|_| RendererError::ParseAdaptation("scene occurrence count overflow"))?; let scene = Scene { id: SceneId(1), units: Units::declared(LengthUnit::LdrawUnit), source, provenance: provenance.into_boxed_slice(), diagnostics: parsed .map(|result| { result .result .diagnostics .iter() .enumerate() .map(|(index, diagnostic)| { polymodel_renderer_protocol::scene::SceneDiagnostic { id: polymodel_renderer_protocol::scene::DiagnosticId( (index + 1) as u64, ), message: diagnostic.message.as_str().into(), } }) .collect::>() }) .unwrap_or_default() .into_boxed_slice(), root: NodeId(1), geometries: geometries.into_boxed_slice(), materials: materials.into_boxed_slice(), textures: textures.into_boxed_slice(), occurrences: occurrences.into_boxed_slice(), bounds, stats: SceneStats { occurrences: occurrence_count, ..stats }, }; Ok(scene) } fn compute_scene_bounds( parsed_scene: Option<&polymodel_ldraw_core::SceneRecord>, occurrences: &[SceneOccurrence], geometries: &[SceneGeometry], geometry_indices: &HashMap, ) -> Option { 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) }) } fn world_transform( transform: polymodel_ldraw_core::Transform, ) -> Result<[[f64; 4]; 4], RendererError> { let origin = ldraw_to_world([0.0, 0.0, 0.0]); let apply = |point: [f64; 3]| -> Result<[f64; 3], RendererError> { let source = [point[0], -point[2], point[1]]; let transformed = transform .apply(polymodel_ldraw_core::Point3::new( source[0], source[1], source[2], )) .ok_or(RendererError::NonFiniteTransform)?; Ok(ldraw_to_world([ transformed.x, transformed.y, transformed.z, ])) }; 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(apply).into_iter().collect::, _>>()?; Ok([ [ columns[0][0] - translation[0], columns[0][1] - translation[1], columns[0][2] - translation[2], 0.0, ], [ columns[1][0] - translation[0], columns[1][1] - translation[1], columns[1][2] - translation[2], 0.0, ], [ columns[2][0] - translation[0], columns[2][1] - translation[1], columns[2][2] - translation[2], 0.0, ], [translation[0], translation[1], translation[2], 1.0], ]) } fn occurrence_texmap_uvs( texmap: &SceneTexmapAssociation, transform: [[f64; 4]; 4], points: &[[f64; 3]], ) -> Result, RendererError> { let mut parameters = texmap .parameters .iter() .map(|value| { value .parse::() .map_err(|_| RendererError::parse("invalid TEXMAP parameter")) }) .collect::, _>>()?; let control_points = 9; let expected = match texmap.mode { SceneTexmapProjectionMode::Planar => 9, SceneTexmapProjectionMode::Cylindrical => 10, SceneTexmapProjectionMode::Spherical => 11, }; if parameters.len() != expected { return Err(RendererError::parse("invalid TEXMAP parameter count")); } for offset in (0..control_points).step_by(3) { let source = [ parameters[offset], parameters[offset + 1], parameters[offset + 2], ]; let transformed = apply_scene_transform(transform, ldraw_to_world(source))?; parameters[offset..offset + 3].copy_from_slice(&transformed); } let transformed_points = points .iter() .copied() .map(|point| apply_scene_transform(transform, point)) .collect::, _>>()?; texmap_uvs(texmap.mode, ¶meters, &transformed_points) .map_err(|_| RendererError::parse("invalid TEXMAP UV projection")) } fn scene_transform_reflection(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); (determinant.is_finite() && determinant.abs() > f64::EPSILON).then_some(determinant < 0.0) } fn apply_scene_transform( transform: [[f64; 4]; 4], point: [f64; 3], ) -> Result<[f64; 3], RendererError> { let values = [point[0], point[1], point[2], 1.0]; let output = (0..3) .map(|row| { (0..4) .map(|column| transform[column][row] * values[column]) .sum::() }) .collect::>(); if output.iter().all(|value| value.is_finite()) { Ok([output[0], output[1], output[2]]) } else { Err(RendererError::NonFiniteTransform) } } #[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. /// Conditional-line classification is defined in the part's projected plane, so /// translation is intentionally ignored and only the affine linear component is inverted. pub fn conditional_line_view_for_transform( transform: [[f64; 4]; 4], world_view: [f64; 3], ) -> Option<[f64; 3]> { 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 inverse = [ [e * i - f * h, c * h - b * i, b * f - c * e], [f * g - d * i, a * i - c * g, c * d - a * f], [d * h - e * g, b * g - a * h, a * e - b * d], ]; let result = [ (inverse[0][0] * world_view[0] + inverse[0][1] * world_view[1] + inverse[0][2] * world_view[2]) / determinant, (inverse[1][0] * world_view[0] + inverse[1][1] * world_view[1] + inverse[1][2] * world_view[2]) / determinant, (inverse[2][0] * world_view[0] + inverse[2][1] * world_view[1] + inverse[2][2] * world_view[2]) / determinant, ]; result .iter() .all(|value| value.is_finite()) .then_some(result) } 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)] pub struct NativeCameraFit { pub center: three_d::Vec3, pub position: three_d::Vec3, pub near: f32, pub far: f32, pub control_min: f32, pub control_max: f32, } pub const CAMERA_CASE_SCHEMA: &str = "pm-camera-case-v1"; #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct CameraCase { pub case_id: String, pub schema_version: String, pub yaw_degrees: f64, pub pitch_degrees: f64, pub roll_degrees: f64, pub target: [f64; 3], pub distance: Option, pub fov_degrees: f64, pub width: u32, pub height: u32, pub fit_bounds: bool, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum CameraCaseError { UnknownSchema(String), EmptyCaseId, ZeroViewport, NonFinite(String), InvalidDistance, InvalidFov, } impl std::fmt::Display for CameraCaseError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(formatter, "{self:?}") } } impl std::error::Error for CameraCaseError {} impl CameraCase { pub fn validate(&self) -> Result<(), CameraCaseError> { if self.schema_version != CAMERA_CASE_SCHEMA { return Err(CameraCaseError::UnknownSchema(self.schema_version.clone())); } if self.case_id.is_empty() { return Err(CameraCaseError::EmptyCaseId); } if self.width == 0 || self.height == 0 { return Err(CameraCaseError::ZeroViewport); } for (name, value) in [ ("yaw_degrees", self.yaw_degrees), ("pitch_degrees", self.pitch_degrees), ("roll_degrees", self.roll_degrees), ("fov_degrees", self.fov_degrees), ] { if !value.is_finite() { return Err(CameraCaseError::NonFinite(name.to_owned())); } } for (index, value) in self.target.into_iter().enumerate() { if !value.is_finite() { return Err(CameraCaseError::NonFinite(format!("target[{index}]"))); } } if let Some(distance) = self.distance && (!distance.is_finite() || distance <= 0.0) { return Err(CameraCaseError::InvalidDistance); } if !self.fov_degrees.is_finite() || !(0.0 < self.fov_degrees && self.fov_degrees < 180.0) { return Err(CameraCaseError::InvalidFov); } Ok(()) } pub fn default_for(case_id: impl Into) -> Self { Self { case_id: case_id.into(), schema_version: CAMERA_CASE_SCHEMA.to_owned(), yaw_degrees: 0.0, pitch_degrees: 0.0, roll_degrees: 0.0, target: [0.0, 0.0, 0.0], distance: None, fov_degrees: 45.0, width: 1280, height: 720, fit_bounds: true, } } pub fn encoded_json(&self) -> Result { self.validate()?; serde_json::to_string(self).map_err(|_| CameraCaseError::NonFinite("serialization".into())) } #[cfg(not(target_arch = "wasm32"))] pub fn native_pose(&self, fit: NativeCameraFit) -> Result { self.validate()?; use three_d::InnerSpace as _; let target = three_d::vec3( self.target[0] as f32, self.target[1] as f32, self.target[2] as f32, ); let target = if self.fit_bounds { fit.center } else { target }; let distance = self .distance .map(|value| value as f32) .unwrap_or_else(|| (fit.position - fit.center).magnitude()); let mut view = (fit.center - fit.position).normalize(); let world_up = three_d::vec3(0.0, 0.0, 1.0); let rotate = |vector: three_d::Vec3, axis: three_d::Vec3, degrees: f64| { let radians = (degrees as f32).to_radians(); let (sin, cos) = radians.sin_cos(); vector * cos + axis.cross(vector) * sin + axis * axis.dot(vector) * (1.0 - cos) }; view = rotate(view, world_up, self.yaw_degrees); let right = view.cross(world_up).normalize(); view = rotate(view, right, self.pitch_degrees); let up = rotate(world_up, view, self.roll_degrees).normalize(); Ok(NativeCameraPose { target, position: target - view * distance, up, near: fit.near, far: fit.far, fov_degrees: self.fov_degrees as f32, }) } } #[cfg(not(target_arch = "wasm32"))] #[derive(Clone, Copy, Debug, PartialEq)] pub struct NativeCameraPose { pub target: three_d::Vec3, pub position: three_d::Vec3, pub up: three_d::Vec3, pub near: f32, pub far: f32, pub fov_degrees: f32, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct SceneVariantBinding { pub variant_id: String, pub occurrence_id: String, pub definition_id: String, pub source_span: Option<[u32; 4]>, pub occurrence_index: usize, pub determinant: i8, pub topology: String, pub material_id: u32, } impl SceneVariantBinding { pub fn for_case(case_id: &str) -> Result { let reflected = case_id.starts_with("reflected_"); let variant_id = if reflected { "reflected" } else { "identity" }; Ok(Self { variant_id: variant_id.to_owned(), occurrence_id: if reflected { "fixture-reflected" } else { "fixture-identity" } .into(), definition_id: "pm90-lighting-fixture-v1".into(), source_span: Some([1, 1, 1, 1]), occurrence_index: usize::from(reflected), determinant: if reflected { -1 } else { 1 }, topology: "triangles".into(), material_id: 4, }) } } pub fn named_camera_case(case_id: &str) -> Result { let mut case = CameraCase::default_for(case_id); case.width = 2560; case.height = 1440; match case_id { "identity_front" | "reflected_front" => {} "identity_back" | "reflected_back" => case.yaw_degrees = 180.0, "orbit_left" => case.yaw_degrees = -35.0, "close_range" => { case.distance = Some(0.35); case.fit_bounds = false; } "top_down" => case.pitch_degrees = -90.0, "top" => case.pitch_degrees = -90.0, "bottom" => case.pitch_degrees = 90.0, other => return Err(CameraCaseError::UnknownSchema(other.to_owned())), } case.validate()?; Ok(case) } #[derive(Clone, Debug, Eq, PartialEq)] pub struct NativeCaptureMetadata { pub case_id: String, pub schema_version: String, pub variant: SceneVariantBinding, pub fixture_hash: String, pub requested_width: u32, pub requested_height: u32, pub actual_width: u32, pub actual_height: u32, pub native_path: String, } pub const LIGHTING_DIAGNOSTICS_ENV: &str = "POLYMODEL_RENDERER_LIGHTING_DIAGNOSTICS"; pub fn lighting_diagnostics_enabled() -> bool { std::env::var(LIGHTING_DIAGNOSTICS_ENV).as_deref() == Ok("1") } pub fn emit_lighting_diagnostic(metadata: &NativeCaptureMetadata, scene: &Scene) { if lighting_diagnostics_enabled() { tracing::debug!( "[ldraw-diagnostic] case={} schema={} fixture={} path={} variant={} occurrence={} source_span={:?} determinant={} material_id={} scene_triangles={}", metadata.case_id, metadata.schema_version, metadata.fixture_hash, metadata.native_path, metadata.variant.variant_id, metadata.variant.occurrence_id, metadata.variant.source_span, metadata.variant.determinant, metadata.variant.material_id, scene.stats.triangles, ); } } pub const PM90_FIXTURE: &[u8] = b"0 PM-90 lighting fixture v1\n0 BFC CERTIFY CCW\n3 4 0 0 0 1 0 0 0 1 0\n3 14 0 0 1 1 0 1 0 1 1\n0 BFC CERTIFY CW\n0 BFC INVERTNEXT\n3 4 0 0 -1 1 0 -1 0 1 -1\n"; pub fn pm90_fixture_hash() -> String { use sha2::{Digest, Sha256}; format!("{:x}", Sha256::digest(PM90_FIXTURE)) } pub const CAMERA_DEPTH_RATIO_LIMIT: f64 = 4096.0; const CAMERA_DEPTH_MARGIN_FRACTION: f64 = 0.01; const CAMERA_DEPTH_ROUNDING_FLOOR: f64 = 0.00034526698; #[derive(Clone, Copy, Debug, PartialEq)] pub struct CameraDepthInterval { pub min_depth: f64, pub max_depth: f64, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CameraDepthError { InvalidBounds, InvalidCamera, InvalidInterval, DepthRangeExceedsPrecision, F32Conversion, } impl std::fmt::Display for CameraDepthError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(formatter, "{self:?}") } } impl std::error::Error for CameraDepthError {} fn finite_ordered_bounds(bounds: SceneBounds) -> Result<([f64; 3], [f64; 3]), CameraDepthError> { for axis in 0..3 { if !bounds.min[axis].is_finite() || !bounds.max[axis].is_finite() || bounds.min[axis] > bounds.max[axis] { return Err(CameraDepthError::InvalidBounds); } } Ok((bounds.min, bounds.max)) } /// Return the signed f64 camera-space interval for all eight bounds corners. pub fn camera_space_depth_interval( bounds: SceneBounds, position: three_d::Vec3, target: three_d::Vec3, ) -> Result { let (min, max) = finite_ordered_bounds(bounds)?; let position = [ f64::from(position.x), f64::from(position.y), f64::from(position.z), ]; let target = [ f64::from(target.x), f64::from(target.y), f64::from(target.z), ]; if position .iter() .chain(target.iter()) .any(|value| !value.is_finite()) { return Err(CameraDepthError::InvalidCamera); } let mut forward = [ target[0] - position[0], target[1] - position[1], target[2] - position[2], ]; let length = (forward[0] * forward[0] + forward[1] * forward[1] + forward[2] * forward[2]).sqrt(); if !length.is_finite() || length <= f64::EPSILON { return Err(CameraDepthError::InvalidCamera); } for component in &mut forward { *component /= length; } let mut interval = CameraDepthInterval { min_depth: f64::INFINITY, max_depth: f64::NEG_INFINITY, }; for x in [min[0], max[0]] { for y in [min[1], max[1]] { for z in [min[2], max[2]] { let offset = [x - position[0], y - position[1], z - position[2]]; let depth = offset[0] * forward[0] + offset[1] * forward[1] + offset[2] * forward[2]; interval.min_depth = interval.min_depth.min(depth); interval.max_depth = interval.max_depth.max(depth); } } } if interval.min_depth.is_finite() && interval.max_depth.is_finite() && interval.min_depth <= interval.max_depth { Ok(interval) } else { Err(CameraDepthError::InvalidInterval) } } fn scene_radius_f64(bounds: SceneBounds) -> Result { let (min, max) = finite_ordered_bounds(bounds)?; let half = [ (max[0] - min[0]) * 0.5, (max[1] - min[1]) * 0.5, (max[2] - min[2]) * 0.5, ]; let radius = (half[0] * half[0] + half[1] * half[1] + half[2] * half[2]).sqrt(); radius .is_finite() .then_some(radius) .ok_or(CameraDepthError::InvalidBounds) } fn precision_aware_clip_planes( interval: CameraDepthInterval, camera_target_distance: f64, scene_radius: f64, ) -> Result<(f32, f32), CameraDepthError> { if !camera_target_distance.is_finite() || camera_target_distance <= 0.0 || !scene_radius.is_finite() || scene_radius < 0.0 { return Err(CameraDepthError::InvalidCamera); } let min_depth = interval.min_depth; let max_depth = interval.max_depth; if !min_depth.is_finite() || !max_depth.is_finite() || min_depth > max_depth || max_depth <= 0.0 { return Err(CameraDepthError::InvalidInterval); } let span = max_depth - min_depth; let scale = camera_target_distance .max(scene_radius) .max(max_depth) .max(1.0) .min(f64::from(f32::MAX)); let margin = (span * CAMERA_DEPTH_MARGIN_FRACTION).max(scale * f64::from(f32::EPSILON)); let near_floor = scale * f64::from(f32::EPSILON); let mut near = if min_depth > 0.0 { (min_depth - margin).max(near_floor).min(min_depth) } else { scale * CAMERA_DEPTH_ROUNDING_FLOOR }; let far = (max_depth + margin).max(near * (1.0 + f64::from(f32::EPSILON))); near = near.max(far / CAMERA_DEPTH_RATIO_LIMIT); let near_f32 = finite_f32(near).map_err(|_| CameraDepthError::F32Conversion)?; let far_f32 = finite_f32(far).map_err(|_| CameraDepthError::F32Conversion)?; let max_f32 = finite_f32(max_depth).map_err(|_| CameraDepthError::F32Conversion)?; if near_f32 <= 0.0 || far_f32 <= near_f32 || far_f32 < max_f32 { return Err(CameraDepthError::F32Conversion); } Ok((near_f32, far_f32)) } pub fn scene_camera_fit( bounds: SceneBounds, width: u32, height: u32, ) -> Result { use three_d::InnerSpace as _; if width == 0 || height == 0 { return Err(CameraDepthError::InvalidBounds); } let (min, max) = finite_ordered_bounds(bounds)?; let center = three_d::vec3( finite_f32((min[0] + max[0]) * 0.5).map_err(|_| CameraDepthError::F32Conversion)?, finite_f32((min[1] + max[1]) * 0.5).map_err(|_| CameraDepthError::F32Conversion)?, finite_f32((min[2] + max[2]) * 0.5).map_err(|_| CameraDepthError::F32Conversion)?, ); let half = three_d::vec3( finite_f32((max[0] - min[0]) * 0.5).map_err(|_| CameraDepthError::F32Conversion)?, finite_f32((max[1] - min[1]) * 0.5).map_err(|_| CameraDepthError::F32Conversion)?, finite_f32((max[2] - min[2]) * 0.5).map_err(|_| CameraDepthError::F32Conversion)?, ); let view = three_d::vec3(1.8, -2.4, 1.5).normalize(); let right = view.cross(three_d::vec3(0.0, 0.0, 1.0)).normalize(); let up = right.cross(view).normalize(); let projected_half = |axis: three_d::Vec3| axis.x.abs() * half.x + axis.y.abs() * half.y + axis.z.abs() * half.z; let depth = projected_half(view); let vertical = projected_half(up); let horizontal = projected_half(right); let tan_half_fov = (45.0_f32.to_radians() * 0.5).tan(); let aspect = width as f32 / height as f32; let distance = depth + (vertical / tan_half_fov).max(horizontal / (tan_half_fov * aspect)) * 1.08; if !distance.is_finite() || distance <= 0.0 { return Err(CameraDepthError::InvalidCamera); } let position = center + view * distance; let projection = camera_space_clip_planes(bounds, position, center)?; Ok(NativeCameraFit { center, position, near: projection.near, far: projection.far, control_min: finite_f32( (scene_radius_f64(bounds)? * CAMERA_DEPTH_ROUNDING_FLOOR).max(f64::from(f32::EPSILON)), ) .map_err(|_| CameraDepthError::F32Conversion)? .min(distance), control_max: projection.far, }) } #[cfg(not(target_arch = "wasm32"))] pub fn yaw_camera_position( center: three_d::Vec3, position: three_d::Vec3, degrees: f32, ) -> three_d::Vec3 { let offset = position - center; let radians = degrees.to_radians(); let (sin, cos) = radians.sin_cos(); center + three_d::vec3( offset.x * cos - offset.y * sin, offset.x * sin + offset.y * cos, offset.z, ) } #[derive(Clone, Copy, Debug, PartialEq)] pub struct CameraClipPlanes { pub near: f32, pub far: f32, pub min_depth: f32, pub max_depth: f32, } pub fn camera_clip_planes( distance: f32, scene_radius: f32, ) -> Result<(f32, f32), CameraDepthError> { if !distance.is_finite() || distance <= 0.0 || !scene_radius.is_finite() || scene_radius < 0.0 { return Err(CameraDepthError::InvalidCamera); } let min_depth = distance as f64 - scene_radius as f64; let max_depth = distance as f64 + scene_radius as f64; let projection = precision_aware_clip_planes( CameraDepthInterval { min_depth, max_depth, }, f64::from(distance), f64::from(scene_radius), )?; Ok(projection) } pub fn camera_space_clip_planes( bounds: SceneBounds, position: three_d::Vec3, target: three_d::Vec3, ) -> Result { use three_d::InnerSpace as _; let interval = camera_space_depth_interval(bounds, position, target)?; let (near, far) = precision_aware_clip_planes( interval, f64::from((target - position).magnitude()), scene_radius_f64(bounds)?, )?; Ok(CameraClipPlanes { near, far, min_depth: finite_f32(interval.min_depth).map_err(|_| CameraDepthError::F32Conversion)?, max_depth: finite_f32(interval.max_depth).map_err(|_| CameraDepthError::F32Conversion)?, }) } pub fn scene_radius(bounds: SceneBounds) -> Result { let (min, max) = finite_ordered_bounds(bounds)?; let radius = ((max[0] - min[0]).mul_add(0.5, 0.0).powi(2) + (max[1] - min[1]).mul_add(0.5, 0.0).powi(2) + (max[2] - min[2]).mul_add(0.5, 0.0).powi(2)) .sqrt(); finite_f32(radius).map_err(|_| CameraDepthError::F32Conversion) } #[cfg(not(target_arch = "wasm32"))] fn official_texture_resources( closure: &polymodel_ldraw_core::ResourceClosure, ) -> Result, RendererError> { closure.validate().map_err(|error| { RendererError::External(format!("official resource closure rejected: {error}")) })?; let mut resources = Vec::new(); for dependency in closure.texture_dependencies() { let Some(resolved) = closure.resolved.iter().find(|resource| { resource.root == dependency.root && resource.path == dependency.path && resource.mime_type.starts_with("image/") }) else { continue; }; let resource = ModelResource { key: dependency.path.as_str().to_owned(), path: dependency.path.as_str().to_owned(), root: texture_resource_root(dependency.root).to_owned(), byte_length: resolved.byte_length, sha256: resolved.sha256.clone().try_into().map_err(|_| { RendererError::External( "official resource SHA-256 digest has invalid length".into(), ) })?, mime_type: resolved.mime_type.to_owned(), target_cids: Box::default(), bytes: resolved.bytes.clone().into_boxed_slice(), }; resource.validate().map_err(|error| { RendererError::External(format!("official TEXMAP resource rejected: {error:?}")) })?; if !resources.iter().any(|existing: &ModelResource| { existing.root == resource.root && existing.path == resource.path }) { resources.push(resource); } } Ok(resources) } #[cfg(not(target_arch = "wasm32"))] pub fn prepare_preview_scene( input: &std::path::Path, library_root: &std::path::Path, ) -> Result { use polymodel_ldraw_core::{ AdapterRequest, CancellationPolicy, InProcessRustAdapter, Materialization, NormalizedPath, ParserProfile, RootId, }; let bytes = std::fs::read(input) .map_err(|error| RendererError::Io(format!("read {}: {error}", input.display())))?; let logical_name = input .file_name() .and_then(std::ffi::OsStr::to_str) .ok_or_else(|| { RendererError::External(format!( "preview input has no UTF-8 file name: {}", input.display() )) })?; let path = NormalizedPath::new(logical_name) .map_err(|error| RendererError::External(format!("preview input path: {error}")))?; let started = Instant::now(); let closure = official_materializations(library_root, &path, &bytes)?; tracing::debug!( "[ldraw-preview] preparation-stage=closure elapsed_ms={} materializations={} selections={}", started.elapsed().as_millis(), closure.materializations.len(), closure.target_selections.len(), ); let resources = official_texture_resources(&closure.resource_closure)?; let mut materializations = closure.materializations; let target_selections = closure.target_selections; materializations.retain(|materialization| { !closure .resource_closure .texture_dependencies() .any(|dependency| { materialization.root == dependency.root && materialization.path == dependency.path }) }); materializations.insert( 0, Materialization::from_key( polymodel_ldraw_core::CacheKey::new(path.clone(), RootId::CurrentMpd, &bytes), bytes.clone(), true, Some("pinned-omr".into()), ), ); let mut sink = AdapterSink::new(Some(&resources)); let result = InProcessRustAdapter::parse_with_limits_and_sink( AdapterRequest { bytes: &bytes, profile: ParserProfile::Compatibility, provenance_id: logical_name, root_name: logical_name, root: RootId::CurrentMpd, materializations: &materializations, target_selections: &target_selections, cancellation: CancellationPolicy::default(), }, preview_limits(), &mut sink, ) .map_err(|error| RendererError::ParseMessage(format!("pinned OMR parse: {error}")))?; sink.take_error()?; let scene = adapt_ldraw_parse_result(&result, Some(&resources), sink)?; if scene.stats.triangles == 0 || scene.stats.occurrences == 0 || scene.bounds.is_none() { return Err(RendererError::ParseMessage(format!( "preview root scene incomplete: occurrences={} triangles={} bounds={}", scene.stats.occurrences, scene.stats.triangles, scene.bounds.is_some() ))); } Ok(scene) } #[cfg(not(target_arch = "wasm32"))] pub fn preview_limits() -> polymodel_ldraw_core::LdrawLimits { polymodel_ldraw_core::LdrawLimits { resource_bytes: 256 * 1024 * 1024, line_bytes: 1024 * 1024, files: 8_192, include_depth: 128, commands: 2_000_000, instances: 500_000, triangles: 1_000_000_000_000, lines: 1_000_000_000, textures: 1_024, fetches: 8, diagnostics: 20_000, } } #[cfg(not(target_arch = "wasm32"))] #[derive(Clone, Debug)] pub struct OfficialClosure { pub materializations: Vec, pub target_selections: Vec, pub resource_closure: polymodel_ldraw_core::ResourceClosure, } #[cfg(not(target_arch = "wasm32"))] pub fn official_materializations( library_root: &std::path::Path, root_path: &NormalizedPath, root_bytes: &[u8], ) -> Result { use polymodel_ldraw_core::CacheKey; use std::collections::{HashMap, HashSet, VecDeque}; const MAX_FILES: usize = 4096; const MAX_EDGES: usize = 16_384; const MAX_BYTES: usize = 256 * 1024 * 1024; const MAX_DEPTH: usize = 128; let canonical_root = library_root.canonicalize().map_err(|error| { RendererError::Io(format!("canonicalize official LDraw library: {error}")) })?; if !canonical_root.is_dir() { return Err(RendererError::External(format!( "official LDraw library is not a directory: {}", library_root.display() ))); } #[derive(Clone)] struct Pending { path: NormalizedPath, bytes: Vec, key: CacheKey, depth: usize, } let root = if root_path.as_str().starts_with("parts/") || root_path.as_str().starts_with("p/") { RootId::OfficialLibrary } else { RootId::CurrentMpd }; let root_key = CacheKey::new(root_path.clone(), root, root_bytes); let mut queue = VecDeque::from([Pending { path: root_path.clone(), bytes: root_bytes.to_vec(), key: root_key, depth: 0, }]); let mut seen = HashSet::::new(); let mut materialization_cache = HashMap::<(RootId, NormalizedPath), (CacheKey, Vec)>::new(); let mut resolution_cache = HashMap::<(String, String), Option<(String, std::path::PathBuf, String)>>::new(); let mut materializations = Vec::new(); let mut target_selections = Vec::new(); let mut resource_closure = polymodel_ldraw_core::ResourceClosure { include_references: Vec::new(), dependencies: Vec::new(), resolved: Vec::new(), needs_ldconfig: false, }; let mut total_bytes = root_bytes.len(); while let Some(current) = queue.pop_front() { if current.depth > MAX_DEPTH { return Err(RendererError::External(format!( "official closure depth exceeded {MAX_DEPTH}" ))); } let inspection = polymodel_ldraw_core::inspect_compound_source( ¤t.bytes, ¤t.path, current.key.resolved_root, ) .map_err(|error| { RendererError::ParseMessage(format!("inspect {}: {error}", current.path.as_str())) })?; let mut inspection = inspection; let source_keys = std::mem::take(&mut inspection.source_keys); let has_virtual_files = inspection.has_virtual_files; let inspection_closure = polymodel_ldraw_core::ResourceClosure::from_inspection( &mut inspection, ¤t.path, current.key.resolved_root, ); let polymodel_ldraw_core::ResourceClosure { include_references: references, dependencies, needs_ldconfig, .. } = inspection_closure; resource_closure.dependencies.extend(dependencies); resource_closure.needs_ldconfig |= needs_ldconfig; for reference in references { if reference.target_virtual_file { continue; } let resolved = polymodel_ldraw_core::resolve_reference_path(¤t.path, &reference) .map_err(|error| { RendererError::ParseMessage(format!("resolve {}: {error}", reference.target)) })?; let unqualified_cache_key = ( "unqualified".to_owned(), reference.target.to_ascii_lowercase(), ); let unqualified_official = if !reference.target.contains('/') && matches!(resolved.root, polymodel_ldraw_core::ReferenceRoot::Models) { if let Some(cached) = resolution_cache.get(&unqualified_cache_key) { cached.clone().map(|(prefix, _canonical, relative)| { ( NormalizedPath::new(&format!("{prefix}/{relative}")) .expect("cached official path remains normalized"), prefix == "parts", ) }) } else { let resolved = ["parts", "p"].iter().find_map(|prefix| { let relative = reference.target.clone(); let candidate = library_root.join(prefix).join(&relative); candidate.canonicalize().ok().and_then(|canonical| { canonical .starts_with(&canonical_root) .then(|| ((*prefix).to_owned(), canonical, relative)) }) }); resolution_cache.insert(unqualified_cache_key, resolved.clone()); resolved.map(|(prefix, _canonical, relative)| { ( NormalizedPath::new(&format!("{prefix}/{relative}")) .expect("resolved official path remains normalized"), prefix == "parts", ) }) } } else { None }; let (path, selected_root) = if let Some((path, is_parts)) = unqualified_official { (path, if is_parts { "parts" } else { "p" }) } else { if !matches!( resolved.root, polymodel_ldraw_core::ReferenceRoot::Parts | polymodel_ldraw_core::ReferenceRoot::P ) { continue; } let path = resolved.path; let selected_root = if path.as_str().starts_with("parts/") { "parts" } else { "p" }; (path, selected_root) }; let source = if has_virtual_files { source_keys .iter() .find(|(name, _)| name == &reference.file_name.to_ascii_lowercase()) .map(|(_, key)| key.clone()) .ok_or_else(|| { RendererError::External(format!( "source section missing: {}", reference.file_name )) })? } else { current.key.clone() }; let relative = path .as_str() .strip_prefix("parts/") .or_else(|| path.as_str().strip_prefix("p/")) .ok_or_else(|| { RendererError::External(format!( "official path outside library roots: {}", path.as_str() )) })?; let fallback_root = if selected_root == "parts" { "p" } else { "parts" }; let authored_target = reference.target.trim().replace('\\', "/"); let authored_subpart = authored_target .split_once('/') .and_then(|(prefix, suffix)| { (prefix.eq_ignore_ascii_case("s") && !suffix.is_empty()).then_some(suffix) }); let mut canonical_path = None; let mut candidates = if selected_root == "parts" { if let Some(authored_suffix) = authored_subpart { vec![ ("parts", format!("s/{authored_suffix}")), ("p", authored_suffix.to_owned()), ("parts", authored_suffix.to_owned()), ] } else { vec![ (selected_root, relative.to_owned()), (fallback_root, relative.to_owned()), ] } } else { vec![ (selected_root, relative.to_owned()), (fallback_root, relative.to_owned()), ] }; if selected_root == "parts" && authored_subpart.is_none() { if !authored_target.contains('/') { candidates.extend([ ("p", authored_target.clone()), ("parts", authored_target.clone()), ]); } candidates.push(("parts", format!("s/{relative}"))); if !relative.starts_with("8/") && !relative.starts_with("48/") { candidates.extend([ ("parts", format!("8/{relative}")), ("parts", format!("48/{relative}")), ]); } } else if selected_root != "parts" && !relative.starts_with("8/") && !relative.starts_with("48/") { candidates.extend([ ("p", format!("8/{relative}")), ("p", format!("48/{relative}")), ]); } let resolution_key = ( format!("{selected_root}:{}", authored_target.to_ascii_lowercase()), relative.to_owned(), ); if let Some(cached) = resolution_cache.get(&resolution_key) { canonical_path = cached.clone(); } else { for (prefix, relative_candidate) in candidates.drain(..) { let candidate = library_root.join(prefix).join(&relative_candidate); if let Ok(path) = candidate.canonicalize() { if !path.starts_with(&canonical_root) { return Err(RendererError::External(format!( "official path escapes library: {}", candidate.display() ))); } canonical_path = Some((prefix.to_owned(), path, relative_candidate)); break; } } resolution_cache.insert(resolution_key, canonical_path.clone()); } let (actual_root, canonical_path, relative_candidate) = canonical_path.ok_or_else(|| { RendererError::External(format!( "official part is missing from parts/ and p/: {}", path.as_str() )) })?; let logical_path = NormalizedPath::new(&format!("{actual_root}/{relative_candidate}")) .map_err(|error| { RendererError::External(format!("normalize official part path: {error}")) })?; let cache_key = (RootId::OfficialLibrary, logical_path.clone()); let (target, child_bytes) = if let Some((target, bytes)) = materialization_cache.get(&cache_key) { (target.clone(), bytes.clone()) } else { let child_bytes = std::fs::read(&canonical_path).map_err(|error| { RendererError::Io(format!("read {}: {error}", canonical_path.display())) })?; let target = CacheKey::new(logical_path.clone(), RootId::OfficialLibrary, &child_bytes); materialization_cache.insert(cache_key, (target.clone(), child_bytes.clone())); (target, child_bytes) }; target_selections.push(polymodel_ldraw_core::TargetSelection { source, span: reference.span, target: target.clone(), }); if seen.insert(target.clone()) { if materializations.len() >= MAX_FILES { return Err(RendererError::External(format!( "official closure file limit exceeded {MAX_FILES}" ))); } total_bytes = total_bytes .checked_add(child_bytes.len()) .ok_or(RendererError::External( "official closure byte count overflow".to_owned(), ))?; if total_bytes > MAX_BYTES { return Err(RendererError::External(format!( "official closure byte limit exceeded {MAX_BYTES}" ))); } materializations.push(Materialization::new( logical_path.clone(), RootId::OfficialLibrary, child_bytes.clone(), true, Some("official-ldraw-library".into()), )); queue.push_back(Pending { path: logical_path, bytes: child_bytes, key: target, depth: current.depth + 1, }); } if target_selections.len() > MAX_EDGES { return Err(RendererError::External(format!( "official closure edge limit exceeded {MAX_EDGES}" ))); } } } // Resolve auxiliary resources through the same typed closure consumed by // server bundling. Keep texture bytes in the official closure so native // preview can pass the exact admitted assets to scene adaptation. let texture_dependencies = resource_closure .texture_dependencies() .cloned() .collect::>(); for dependency in texture_dependencies { let candidate = library_root.join(dependency.path.as_str()); if !candidate.is_file() { if dependency.required { return Err(RendererError::External(format!( "required TEXMAP resource is missing: {} (source {})", dependency.path, dependency.source ))); } continue; } if materializations.iter().any(|materialization| { materialization.root == dependency.root && materialization.path == dependency.path }) { continue; } let bytes = std::fs::read(&candidate).map_err(|error| { RendererError::Io(format!( "read TEXMAP resource {}: {error}", candidate.display() )) })?; let sha256 = polymodel_ldraw_core::Sha256Hash::digest(&bytes) .as_bytes() .to_vec(); resource_closure .resolved .push(polymodel_ldraw_core::ResolvedResource { path: dependency.path.clone(), root: dependency.root, sha256, byte_length: bytes.len() as u64, bytes: bytes.clone(), mime_type: "image/png", required: dependency.required, fallback_available: dependency.fallback_available, }); materializations.push(Materialization::new( dependency.path.clone(), dependency.root, bytes, true, Some("official-ldraw-library".into()), )); } // Seed the colour table from LDConfig.ldr at the library root when the // closure requests the optional official-library colour seed. if resource_closure.needs_ldconfig { let ldconfig_path = library_root.join("LDConfig.ldr"); if let Ok(ldconfig_bytes) = std::fs::read(&ldconfig_path) { if let Ok(ldconfig_normalized) = NormalizedPath::new("LDConfig.ldr") { let ldconfig_key = CacheKey::new( ldconfig_normalized.clone(), RootId::OfficialLibrary, &ldconfig_bytes, ); if seen.insert(ldconfig_key) { materializations.insert( 0, Materialization::new( ldconfig_normalized, RootId::OfficialLibrary, ldconfig_bytes, true, Some("official-ldraw-library".into()), ), ); } } } } resource_closure.validate().map_err(|error| { RendererError::External(format!("official resource closure rejected: {error}")) })?; Ok(OfficialClosure { materializations, target_selections, resource_closure, }) } #[cfg(not(target_arch = "wasm32"))] pub fn native_png_bytes(width: u32, height: u32, pixels: &[u8]) -> Vec { try_native_png_bytes(width, height, pixels).expect("valid native PNG data") } #[cfg(not(target_arch = "wasm32"))] pub fn try_native_png_bytes( width: u32, height: u32, pixels: &[u8], ) -> Result, RendererError> { let mut output = Vec::new(); let mut encoder = png::Encoder::new(std::io::Cursor::new(&mut output), width, height); encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); let mut writer = encoder .write_header() .map_err(|error| RendererError::External(format!("encode PNG header: {error}")))?; writer .write_image_data(pixels) .map_err(|error| RendererError::External(format!("encode PNG pixels: {error}")))?; drop(writer); Ok(output) } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum RenderStyle { #[default] Faces, Edges, } #[cfg(not(target_arch = "wasm32"))] #[derive(Clone, Debug)] pub struct NativeRenderOptions { pub width: u32, pub height: u32, pub yaw_degrees: f32, pub style: RenderStyle, pub camera_case: Option, pub variant: Option, } #[cfg(not(target_arch = "wasm32"))] impl Default for NativeRenderOptions { fn default() -> Self { Self { width: 1280, height: 720, yaw_degrees: 0.0, style: RenderStyle::default(), camera_case: None, variant: None, } } } #[cfg(not(target_arch = "wasm32"))] pub fn render_scene_window( scene: &Scene, output: Option<&std::path::Path>, ) -> Result<(), RendererError> { render_scene_window_configured(scene, output, NativeRenderOptions::default()) } #[cfg(not(target_arch = "wasm32"))] pub fn render_scene_window_configured( scene: &Scene, output: Option<&std::path::Path>, options: NativeRenderOptions, ) -> Result<(), RendererError> { render_scene_window_options_traced(scene, output, NativeTrace::default(), options) } #[derive(Debug, Clone, PartialEq)] pub struct SceneMeshTopology { pub positions: Box<[[f32; 3]]>, /// Optional texture coordinates aligned one-to-one with `positions`. pub uvs: Box<[[f32; 2]]>, pub indices: Vec, } impl SceneMeshTopology { pub fn triangle_count(&self) -> usize { self.indices.len() / 3 } pub fn max_index(&self) -> Option { self.indices.iter().copied().max() } } pub fn scene_oriented_mesh_topology( geometry: &SceneGeometry, bfc_cull: bool, bfc_ccw: bool, bfc_inverted: bool, reflection: bool, ) -> Result { scene_oriented_mesh_topology_for_view( geometry, bfc_cull, bfc_ccw, bfc_inverted, reflection, [0.0, 0.0, 1.0], ) } fn scene_oriented_mesh_topology_for_view( geometry: &SceneGeometry, bfc_cull: bool, bfc_ccw: bool, bfc_inverted: bool, reflection: bool, view: [f64; 3], ) -> Result { 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. let reverse = bfc_inverted ^ !bfc_ccw; if reverse { for triangle in topology.indices.chunks_exact_mut(3) { triangle.swap(0, 2); } } let _ = (bfc_cull, reflection); Ok(topology) } pub fn scene_mesh_topology(geometry: &SceneGeometry) -> Result { scene_mesh_topology_for_view(geometry, [0.0, 0.0, 1.0]) } pub fn scene_mesh_topology_for_view( geometry: &SceneGeometry, view: [f64; 3], ) -> Result { scene_mesh_topology_for_view_kind(geometry, view, SceneTopologyKind::Complete) } #[derive(Clone, Copy)] enum SceneTopologyKind { Faces, #[allow(dead_code)] Edges, Complete, } fn scene_mesh_topology_for_view_kind( geometry: &SceneGeometry, view: [f64; 3], kind: SceneTopologyKind, ) -> Result { let include_faces = matches!(kind, SceneTopologyKind::Faces | SceneTopologyKind::Complete); let include_explicit = matches!(kind, SceneTopologyKind::Edges | SceneTopologyKind::Complete); let include_conditional = matches!(kind, SceneTopologyKind::Edges | SceneTopologyKind::Complete); let source_positions = geometry .points .iter() .map(|point| { let converted = [point[0] as f32, point[1] as f32, point[2] as f32]; if converted.iter().all(|coordinate| coordinate.is_finite()) { Ok(converted) } else { Err(RendererError::NonFiniteTransform) } }) .collect::, _>>()?; let mut positions = if include_faces { source_positions.clone() } else { Vec::new() }; let uvs: Vec<[f32; 2]> = if include_faces { if geometry.uvs.is_empty() { Vec::new() } else if geometry.uvs.len() != geometry.points.len() || geometry .uvs .iter() .flatten() .any(|value| !value.is_finite()) { return Err(RendererError::InvalidRange { context: "geometry UV alignment", index: geometry.uvs.len(), len: geometry.points.len(), }); } else { geometry.uvs.to_vec() } } else { Vec::new() }; let mut indices = Vec::with_capacity( 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, ); 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<(), RendererError> { let start = source_positions .get(line[0] as usize) .copied() .ok_or_else(|| RendererError::InvalidRange { context: "geometry line start", index: line[0] as usize, len: source_positions.len(), })?; let end = source_positions .get(line[1] as usize) .copied() .ok_or_else(|| RendererError::InvalidRange { context: "geometry line end", index: line[1] as usize, len: source_positions.len(), })?; let direction = [ f64::from(end[0] - start[0]), f64::from(end[1] - start[1]), f64::from(end[2] - start[2]), ]; let length = (direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2]) .sqrt(); if !length.is_finite() || length <= f64::EPSILON { return Ok(()); } let mut side = [ direction[1] * view[2] - direction[2] * view[1], direction[2] * view[0] - direction[0] * view[2], direction[0] * view[1] - direction[1] * view[0], ]; let side_length = (side[0] * side[0] + side[1] * side[1] + side[2] * side[2]).sqrt(); if !side_length.is_finite() || side_length <= f64::EPSILON { // Pick the least-parallel cardinal axis, then cross it with the // line. This handles view-parallel Y edges as well as arbitrary // finite camera directions without a zero-length fallback. let axis = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] .into_iter() .min_by(|left, right| { let left_dot = (direction[0] * left[0] + direction[1] * left[1] + direction[2] * left[2]) .abs(); let right_dot = (direction[0] * right[0] + direction[1] * right[1] + direction[2] * right[2]) .abs(); left_dot .partial_cmp(&right_dot) .unwrap_or(std::cmp::Ordering::Equal) }) .unwrap_or([1.0, 0.0, 0.0]); side = [ direction[1] * axis[2] - direction[2] * axis[1], direction[2] * axis[0] - direction[0] * axis[2], direction[0] * axis[1] - direction[1] * axis[0], ]; } let side_length = (side[0] * side[0] + side[1] * side[1] + side[2] * side[2]).sqrt(); if !side_length.is_finite() || side_length <= f64::EPSILON { return Ok(()); } let radius = 0.02_f64; let offset = [ (side[0] / side_length * radius) as f32, (side[1] / side_length * radius) as f32, (side[2] / side_length * radius) as f32, ]; let base = u32::try_from(positions.len()).map_err(|_| RendererError::IndexOverflow { context: "line vertex range", })?; positions.extend([ [ start[0] - offset[0], start[1] - offset[1], start[2] - offset[2], ], [ start[0] + offset[0], start[1] + offset[1], start[2] + offset[2], ], [end[0] + offset[0], end[1] + offset[1], end[2] + offset[2]], [end[0] - offset[0], end[1] - offset[1], end[2] - offset[2]], ]); indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); Ok(()) }; if include_explicit { for line in geometry.lines.iter().copied() { add_line(line)?; } } if include_conditional { for conditional in &geometry.conditional_lines { let points = conditional .iter() .map(|index| { geometry .points .get(*index as usize) .copied() .ok_or_else(|| RendererError::InvalidRange { context: "geometry conditional-line index", index: *index as usize, len: geometry.points.len(), }) }) .collect::, _>>()?; let endpoints = [points[0], points[1]]; let controls = [points[2], points[3]]; if conditional_line_visible(endpoints, controls, view) { add_line([conditional[0], conditional[1]])?; } } } if indices.len() % 3 != 0 { return Err(RendererError::InvalidRange { context: "geometry triangle index stream", index: indices.len(), len: indices.len() + (3 - indices.len() % 3) % 3, }); } if let Some(index) = indices .iter() .copied() .find(|index| *index as usize >= positions.len()) { return Err(RendererError::InvalidRange { context: "geometry index", index: index as usize, len: positions.len(), }); } Ok(SceneMeshTopology { positions: positions.into_boxed_slice(), uvs: uvs.into_boxed_slice(), indices, }) } #[derive(Debug, Clone, Copy, PartialEq)] pub struct PickRay { pub origin: [f64; 3], pub direction: [f64; 3], } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct OccurrenceGpuRange { pub batch: usize, pub instance: usize, pub geometry: GeometryId, pub vertex_start: u32, pub vertex_count: u32, pub index_start: u32, pub index_count: u32, } #[derive(Debug, Clone, Copy, PartialEq)] pub struct ScenePickHit { pub occurrence_index: usize, pub triangle_index: usize, pub distance: f64, /// 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 { let sub = |x: [f64; 3], y: [f64; 3]| [x[0] - y[0], x[1] - y[1], x[2] - y[2]]; let cross = |x: [f64; 3], y: [f64; 3]| { [ x[1] * y[2] - x[2] * y[1], x[2] * y[0] - x[0] * y[2], x[0] * y[1] - x[1] * y[0], ] }; let dot = |x: [f64; 3], y: [f64; 3]| x[0] * y[0] + x[1] * y[1] + x[2] * y[2]; let edge1 = sub(b, a); let edge2 = sub(c, a); let p = cross(ray.direction, edge2); let determinant = dot(edge1, p); if determinant.abs() <= 1.0e-12 { return None; } let inverse = determinant.recip(); let from_a = sub(ray.origin, a); let u = dot(from_a, p) * inverse; if !(0.0..=1.0).contains(&u) { return None; } let q = cross(from_a, edge1); let v = dot(ray.direction, q) * inverse; if v < 0.0 || u + v > 1.0 { return None; } let distance = dot(edge2, q) * inverse; (distance > 1.0e-9 && distance.is_finite()).then_some(distance) } #[derive(Debug, Clone, PartialEq)] struct SceneIndex { geometries: std::collections::HashMap, materials: std::collections::HashMap, } 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(RendererError::ParseAdaptation( "geometry id zero is invalid", )); } if geometries.insert(geometry.id, index).is_some() { return Err(RendererError::DuplicateGeometry(geometry.id)); } } 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(RendererError::ParseAdaptation( "material id zero is invalid", )); } if materials.insert(material.id, index).is_some() { return Err(RendererError::ParseAdaptation("duplicate material id")); } } 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()) { return Err(RendererError::NonFiniteTransform); } } Ok(index) } fn geometry<'a>( &self, scene: &'a Scene, node: NodeId, ) -> Result<&'a SceneGeometry, RendererError> { let id = GeometryId(node.0); self.geometries .get(&id) .and_then(|index| scene.geometries.get(*index)) .ok_or(RendererError::MissingGeometry(id)) } fn geometry_index(&self, node: NodeId) -> Result { self.geometries .get(&GeometryId(node.0)) .copied() .ok_or(RendererError::MissingGeometry(GeometryId(node.0))) } fn material<'a>( &self, scene: &'a Scene, id: MaterialId, ) -> Result<&'a SceneMaterial, RendererError> { self.materials .get(&id) .and_then(|index| scene.materials.get(*index)) .ok_or(RendererError::MissingMaterial(id)) } } pub fn occurrence_gpu_ranges( scene: &Scene, ) -> Result>, RendererError> { Ok(scene_render_plan(scene)?.occurrence_ranges.to_vec()) } fn finalized_upload_batches( index: &SceneIndex, batches: &[SceneInstanceBatch], topologies: &[SceneMeshTopology], ) -> Result<(Vec, Vec>), RendererError> { 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(RendererError::InvalidRange { context: "render plan geometry topology", index: geometry_index, len: topologies.len(), })?; if topology.indices.is_empty() { continue; } planned_to_upload[planned_index] = Some(upload_batches.len()); upload_batches.push(planned_index); } Ok((upload_batches, planned_to_upload)) } fn occurrence_gpu_ranges_from_batches( scene: &Scene, index: &SceneIndex, batches: &[SceneInstanceBatch], upload_batch_indices: &[usize], topologies: &[SceneMeshTopology], ) -> Result>, RendererError> { let mut ranges = vec![None; scene.occurrences.len()]; for (upload_batch_index, planned_batch_index) in upload_batch_indices.iter().copied().enumerate() { let batch = batches .get(planned_batch_index) .ok_or(RendererError::InvalidRange { context: "upload batch index", index: planned_batch_index, len: batches.len(), })?; let geometry_index = index.geometry_index(batch.node)?; let topology = topologies .get(geometry_index) .ok_or(RendererError::InvalidRange { context: "render plan geometry topology", index: geometry_index, len: topologies.len(), })?; let vertex_count = u32::try_from(topology.positions.len()).map_err(|_| RendererError::IndexOverflow { context: "vertex range", })?; let index_count = u32::try_from(topology.indices.len()).map_err(|_| RendererError::IndexOverflow { context: "index range", })?; for (instance_index, instance) in batch.instances.iter().enumerate() { let range = ranges .get_mut(instance.occurrence_index) .ok_or(RendererError::InvalidRange { context: "batch occurrence", index: instance.occurrence_index, len: scene.occurrences.len(), })?; if range.is_some() { return Err(RendererError::ParseAdaptation( "occurrence appears in multiple native GPU batches", )); } *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) } #[derive(Debug, Clone, Copy, PartialEq)] struct PickBounds { min: [f64; 3], max: [f64; 3], } 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, coordinate) in point.into_iter().enumerate() { bounds.min[axis] = bounds.min[axis].min(coordinate); bounds.max[axis] = bounds.max[axis].max(coordinate); } } 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 } } pub type PlannedConditionalEdge = ([[f32; 3]; 2], [[f32; 3]; 2]); #[derive(Debug, Clone, PartialEq)] pub struct PlannedEdgeSource { pub explicit: Box<[[[f32; 3]; 2]]>, pub conditional: Box<[PlannedConditionalEdge]>, } #[derive(Debug, Clone, Copy, PartialEq)] pub struct PlannedOccurrence { pub occurrence_index: usize, pub geometry_index: usize, pub material: Option, pub logical_part_index: u32, pub bounds: Option, } #[derive(Debug, Clone, PartialEq)] pub struct SelectionIdentity { pub canonical_occurrence_id: SmolStr, pub canonical_path: SmolStr, pub logical_part_occurrence_id: SmolStr, pub logical_part_path: SmolStr, pub occurrence_indices: Vec, pub transforms: Vec<[[f64; 4]; 4]>, pub bounds: Option, } #[derive(Debug, Clone, Copy, PartialEq)] pub enum SelectionFallback { BoundingBox(SceneBounds), } #[derive(Debug, Clone, Copy, PartialEq)] pub enum SelectionPointerAction { ForwardToOrbit, Select { x: f64, y: f64 }, ClearSelection, WithholdFromOrbit, } #[derive(Debug, Clone, Copy, Default, PartialEq)] pub struct SelectionInteraction { shift: bool, shift_on_down: bool, down: Option<(f64, f64)>, dragged: bool, pending_select: Option<(f64, f64)>, } impl SelectionInteraction { pub const DRAG_THRESHOLD: f64 = 4.0; pub fn set_modifiers(&mut self, shift: bool) { self.shift = shift; } pub fn pointer_down(&mut self, x: f64, y: f64) -> SelectionPointerAction { self.down = Some((x, y)); self.shift_on_down = self.shift; self.dragged = false; if self.shift_on_down { SelectionPointerAction::WithholdFromOrbit } else { SelectionPointerAction::ForwardToOrbit } } pub fn pointer_move(&mut self, x: f64, y: f64) -> SelectionPointerAction { let Some((start_x, start_y)) = self.down else { return SelectionPointerAction::ForwardToOrbit; }; if (x - start_x).hypot(y - start_y) >= Self::DRAG_THRESHOLD { self.dragged = true; SelectionPointerAction::ForwardToOrbit } else { SelectionPointerAction::WithholdFromOrbit } } pub fn pointer_up(&mut self, x: f64, y: f64) -> SelectionPointerAction { let action = match self.down.take() { None => SelectionPointerAction::ForwardToOrbit, Some(_) if self.dragged => SelectionPointerAction::ForwardToOrbit, Some((start_x, start_y)) if self.shift_on_down => SelectionPointerAction::Select { x: (start_x + x) * 0.5, y: (start_y + y) * 0.5, }, Some(_) => SelectionPointerAction::ForwardToOrbit, }; if let SelectionPointerAction::Select { x, y } = action { self.pending_select = Some((x, y)); } self.dragged = false; self.shift_on_down = false; action } pub fn has_pending_select(&self) -> bool { self.pending_select.is_some() } pub fn take_pending_select(&mut self) -> Option<(f64, f64)> { self.pending_select.take() } pub fn cancel(&mut self) { *self = Self::default(); } pub fn dispose(&mut self) { self.cancel(); } pub fn has_pending_click(&self) -> bool { self.down.is_some() } } #[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 { scene_occurrences: Arc<[SceneOccurrence]>, topologies: Arc<[SceneMeshTopology]>, edge_sources: Arc<[PlannedEdgeSource]>, occurrences: Arc<[PlannedOccurrence]>, logical_parts: Arc<[Box<[usize]>]>, logical_bounds: Arc<[PickBounds]>, ranges: Arc<[Option]>, logical_part_bounds: Arc<[SceneBounds]>, logical_part_indices: std::collections::HashMap, bvh: Option, } impl ScenePicker { pub fn new(scene: &Scene, plan: SceneRenderPlan) -> Result { Self::from_plan(scene, &plan) } fn from_plan(scene: &Scene, plan: &SceneRenderPlan) -> Result { if plan.planned_occurrences.len() != scene.occurrences.len() { return Err(RendererError::InvalidRange { context: "render plan pick occurrences", index: plan.planned_occurrences.len(), len: scene.occurrences.len(), }); } let bvh = PickBvh::new(&plan.logical_bounds); Ok(Self { scene_occurrences: scene.occurrences.clone().into(), topologies: plan.topologies.clone(), edge_sources: plan.edge_sources.clone(), occurrences: plan.planned_occurrences.clone(), logical_parts: plan.logical_parts.clone(), logical_bounds: plan.logical_bounds.clone(), ranges: plan.occurrence_ranges.clone(), logical_part_bounds: plan.logical_part_bounds.clone(), logical_part_indices: plan.logical_part_indices.clone(), bvh, }) } pub fn logical_part_bounds(&self, occurrence_id: &str) -> Option { self.logical_part_indices .get(occurrence_id) .and_then(|index| self.logical_part_bounds.get(*index as usize)) .copied() } pub fn selection(&self, raw_index: usize) -> Result { let raw = self .occurrences .get(raw_index) .ok_or_else(|| RendererError::InvalidRange { context: "raw occurrence", index: raw_index, len: self.occurrences.len(), })?; let scene_occurrence = self.scene_occurrences.get(raw.occurrence_index).ok_or( RendererError::InvalidRange { context: "planned occurrence source", index: raw.occurrence_index, len: self.scene_occurrences.len(), }, )?; let group_index = raw.logical_part_index; let occurrence_indices = self.logical_parts[group_index as usize].clone(); let transforms = occurrence_indices .iter() .map(|index| self.occurrences[*index].occurrence_index) .map(|index| self.scene_occurrences[index].transform) .collect(); Ok(SelectionIdentity { canonical_occurrence_id: scene_occurrence.canonical_occurrence_id.clone(), canonical_path: scene_occurrence.canonical_path.as_str().into(), logical_part_occurrence_id: scene_occurrence .logical_part_occurrence_id .clone() .unwrap_or_else(|| scene_occurrence.canonical_occurrence_id.clone()), logical_part_path: scene_occurrence .logical_part_path .clone() .unwrap_or_else(|| scene_occurrence.canonical_path.clone()), occurrence_indices: occurrence_indices.to_vec(), transforms, bounds: self.logical_part_bounds.get(group_index as usize).copied(), }) } pub fn edge_sources(&self) -> &Arc<[PlannedEdgeSource]> { &self.edge_sources } pub fn planned_occurrences(&self) -> &Arc<[PlannedOccurrence]> { &self.occurrences } pub fn pick(&self, ray: PickRay) -> Result, RendererError> { 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 planned = &self.occurrences[*candidate]; let occurrence = self.scene_occurrences.get(planned.occurrence_index).ok_or( RendererError::InvalidRange { context: "planned occurrence source", index: planned.occurrence_index, len: self.scene_occurrences.len(), }, )?; let maximum = nearest.as_ref().map_or(f64::INFINITY, |hit| hit.distance); let bounds = planned.bounds.map(|bounds| PickBounds { min: bounds.min, max: bounds.max, }); if !bounds.is_some_and(|bounds| bounds.intersects(ray, maximum)) { continue; } let topology = &self.topologies[planned.geometry_index]; let local_ray = InverseSceneTransform::new(occurrence.transform) .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: planned.occurrence_index, triangle_index, distance, gpu: self.ranges[planned.occurrence_index], }); } } } } Ok(nearest) } } #[cfg(not(target_arch = "wasm32"))] pub fn build_scene_occurrence_models( context: &three_d::Context, scene: &Scene, occurrence_indices: &[usize], ) -> Result { let mut selected = scene.clone(); selected.occurrences = occurrence_indices .iter() .map(|index| { scene .occurrences .get(*index) .cloned() .ok_or(RendererError::InvalidRange { context: "selected occurrence", index: *index, len: scene.occurrences.len(), }) }) .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 { 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, /// UVs shared by occurrences with identical geometry/transform/TEXMAP keys. pub uvs: Option>, pub material: MaterialId, pub bfc_cull: bool, pub bfc_ccw: bool, pub bfc_inverted: bool, pub reflection: bool, pub instances: Box<[SceneBatchInstance]>, } struct SceneInstanceBatchBuilder { node: NodeId, uvs: Option>, material: MaterialId, bfc_cull: bool, bfc_ccw: bool, bfc_inverted: bool, reflection: bool, instances: Vec, } impl SceneInstanceBatchBuilder { fn into_batch(self) -> SceneInstanceBatch { SceneInstanceBatch { node: self.node, uvs: self.uvs, material: self.material, bfc_cull: self.bfc_cull, bfc_ccw: self.bfc_ccw, bfc_inverted: self.bfc_inverted, reflection: self.reflection, instances: self.instances.into_boxed_slice(), } } } #[derive(Debug, Clone, PartialEq)] pub struct SceneRenderPlan { /// Planned batches retain all semantic occurrences for picking and diagnostics. pub batches: Box<[SceneInstanceBatch]>, /// Indices of planned batches that have non-empty face topology. pub upload_batch_indices: Box<[usize]>, /// Mapping from planned batch index to actual upload-object index. pub planned_to_upload: Box<[Option]>, pub occurrence_ranges: Arc<[Option]>, pub topologies: Arc<[SceneMeshTopology]>, pub edge_sources: Arc<[PlannedEdgeSource]>, /// Canonical occurrence records shared by face upload, edge output, and picking. pub planned_occurrences: Arc<[PlannedOccurrence]>, logical_parts: Arc<[Box<[usize]>]>, logical_bounds: Arc<[PickBounds]>, logical_part_bounds: Arc<[SceneBounds]>, logical_part_indices: std::collections::HashMap, index: SceneIndex, } fn planned_edge_source(geometry: &SceneGeometry) -> Result { let explicit: Vec<[[f32; 3]; 2]> = geometry .lines .iter() .enumerate() .filter_map(|(line_index, line)| { let Some(start) = geometry.points.get(line[0] as usize).copied() else { tracing::warn!( edge_kind = "line", record_index = line_index, indices = ?line, "dropping malformed edge geometry" ); return None; }; let Some(end) = geometry.points.get(line[1] as usize).copied() else { tracing::warn!( edge_kind = "line", record_index = line_index, indices = ?line, "dropping malformed edge geometry" ); return None; }; let start = match finite_f32_vec3(start) { Ok(start) => start, Err(_) => { tracing::warn!( edge_kind = "line", record_index = line_index, indices = ?line, "dropping malformed edge geometry" ); return None; } }; let end = match finite_f32_vec3(end) { Ok(end) => end, Err(_) => { tracing::warn!( edge_kind = "line", record_index = line_index, indices = ?line, "dropping malformed edge geometry" ); return None; } }; if start == end { tracing::warn!( edge_kind = "line", record_index = line_index, indices = ?line, "dropping malformed edge geometry" ); return None; } Some([start, end]) }) .collect(); let conditional: Vec = geometry .conditional_lines .iter() .enumerate() .filter_map(|(line_index, line)| { let Some(points) = line .iter() .map(|index| geometry.points.get(*index as usize).copied()) .collect::>>() else { tracing::warn!( edge_kind = "conditional-line", record_index = line_index, indices = ?line, "dropping malformed edge geometry" ); return None; }; let endpoints = [ match finite_f32_vec3(points[0]) { Ok(point) => point, Err(_) => { tracing::warn!( edge_kind = "conditional-line", record_index = line_index, indices = ?line, "dropping malformed edge geometry" ); return None; } }, match finite_f32_vec3(points[1]) { Ok(point) => point, Err(_) => { tracing::warn!( edge_kind = "conditional-line", record_index = line_index, indices = ?line, "dropping malformed edge geometry" ); return None; } }, ]; let controls = [ match finite_f32_vec3(points[2]) { Ok(point) => point, Err(_) => { tracing::warn!( edge_kind = "conditional-line", record_index = line_index, indices = ?line, "dropping malformed edge geometry" ); return None; } }, match finite_f32_vec3(points[3]) { Ok(point) => point, Err(_) => { tracing::warn!( edge_kind = "conditional-line", record_index = line_index, indices = ?line, "dropping malformed edge geometry" ); return None; } }, ]; if endpoints[0] == endpoints[1] { tracing::warn!( edge_kind = "conditional-line", record_index = line_index, indices = ?line, "dropping malformed edge geometry" ); return None; } Some((endpoints, controls)) }) .collect(); Ok(PlannedEdgeSource { explicit: explicit.into_boxed_slice(), conditional: conditional.into_boxed_slice(), }) } 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::, _>>()?; for occurrence in &scene.occurrences { if let Some(uvs) = &occurrence.uvs { let geometry_index = index.geometry_index(occurrence.node)?; let point_count = scene.geometries[geometry_index].points.len(); if uvs.len() != point_count || uvs.iter().flatten().any(|value| !value.is_finite()) { return Err(RendererError::InvalidRange { context: "occurrence UV alignment", index: uvs.len(), len: point_count, }); } } } let edge_sources = scene .geometries .iter() .map(planned_edge_source) .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(|| RendererError::NonFiniteTransform) }) .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 = Vec::::new(); let mut accumulator = SceneBatchAccumulator { pick_occurrences: &mut pick_occurrences, logical_part_indices: &mut logical_part_indices, logical_parts: &mut logical_parts, logical_bounds: &mut logical_bounds, logical_part_bounds: &mut logical_part_bounds, }; let batches = scene_instance_batches_for_mode(scene, &index, &local_bounds, &mut accumulator)?; 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, &batches, &upload_batches, &topologies)?; let ranges_complete = started.elapsed(); if diagnostics { tracing::debug!( "[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: batches.into_boxed_slice(), upload_batch_indices: upload_batches.into_boxed_slice(), planned_to_upload: planned_to_upload.into_boxed_slice(), occurrence_ranges: Arc::from(occurrence_ranges.into_boxed_slice()), topologies: Arc::from(topologies.into_boxed_slice()), edge_sources: Arc::from(edge_sources.into_boxed_slice()), planned_occurrences: Arc::from(pick_occurrences.into_boxed_slice()), logical_parts: Arc::from( logical_parts .into_iter() .map(Vec::into_boxed_slice) .collect::>() .into_boxed_slice(), ), logical_bounds: Arc::from(logical_bounds.into_boxed_slice()), logical_part_bounds: Arc::from(logical_part_bounds.into_boxed_slice()), logical_part_indices, index, }) } pub fn scene_instance_batches(scene: &Scene) -> Result, RendererError> { Ok(scene_render_plan(scene)?.batches.into_vec()) } struct SceneBatchAccumulator<'a> { pick_occurrences: &'a mut Vec, logical_part_indices: &'a mut std::collections::HashMap, logical_parts: &'a mut Vec>, logical_bounds: &'a mut Vec, logical_part_bounds: &'a mut Vec, } fn scene_instance_batches_for_mode( scene: &Scene, index: &SceneIndex, local_bounds: &[PickBounds], accumulator: &mut SceneBatchAccumulator<'_>, ) -> Result, RendererError> { #[derive(Clone, Copy, PartialEq, Eq, Hash)] struct BatchKey { node: NodeId, material: MaterialId, bfc_cull: bool, reverse_winding: bool, reflection: bool, textured_occurrence: Option, } let default_material = scene .materials .first() .map(|material| material.id) .unwrap_or(MaterialId(1)); let mut batches = Vec::::new(); 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 local = local_bounds[geometry_index]; let bounds = local .transformed(occurrence.transform) .ok_or_else(|| RendererError::NonFiniteTransform)?; let logical_part_occurrence_id = occurrence .logical_part_occurrence_id .clone() .unwrap_or_else(|| occurrence.canonical_occurrence_id.clone()); let group_index = if let Some(group_index) = accumulator .logical_part_indices .get(&logical_part_occurrence_id) .copied() { group_index } else { let group_index = u32::try_from(accumulator.logical_parts.len()) .map_err(|_| RendererError::ParseAdaptation("logical part group count overflow"))?; accumulator .logical_part_indices .insert(logical_part_occurrence_id.clone(), group_index); accumulator.logical_parts.push(Vec::new()); accumulator.logical_bounds.push(bounds); accumulator.logical_part_bounds.push(SceneBounds { min: bounds.min, max: bounds.max, }); group_index }; accumulator.pick_occurrences.push(PlannedOccurrence { occurrence_index, geometry_index, material: occurrence.material, logical_part_index: group_index, bounds: Some(SceneBounds { min: bounds.min, max: bounds.max, }), }); accumulator.logical_parts[group_index as usize].push(occurrence_index); accumulator.logical_bounds[group_index as usize] = accumulator.logical_bounds[group_index as usize].union(bounds); let aggregate = &mut accumulator.logical_part_bounds[group_index as usize]; 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]); } let material = occurrence.material.unwrap_or(default_material); let reverse_winding = occurrence.bfc_inverted ^ !occurrence.bfc_ccw; let reflection = scene_transform_reflection(occurrence.transform) .ok_or_else(|| RendererError::NonFiniteTransform)?; let key = BatchKey { node: occurrence.node, material, bfc_cull: occurrence.bfc_cull, reverse_winding, reflection, textured_occurrence: occurrence.uvs.as_ref().map(|_| 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(SceneInstanceBatchBuilder { node: occurrence.node, uvs: occurrence.uvs.clone(), material, bfc_cull: occurrence.bfc_cull, bfc_ccw: occurrence.bfc_ccw, bfc_inverted: reverse_winding, reflection, instances: vec![instance], }); batch_indices.insert(key, index); } } Ok(batches .into_iter() .map(SceneInstanceBatchBuilder::into_batch) .collect()) } #[derive(Clone, Copy, Debug, PartialEq)] pub struct LdrawLightingMaterialPolicy { pub version: &'static str, pub ambient_energy: f32, pub ambient_colour: [u8; 3], pub key_energy: f32, pub key_colour: [u8; 3], pub key_direction: [f32; 3], pub fill_energy: f32, pub fill_colour: [u8; 3], pub fill_direction: [f32; 3], pub albedo_is_direct_colour: bool, pub black_albedo: [u8; 3], pub black_roughness: f32, pub roughness: f32, pub metallic: f32, pub cull_back_faces: bool, pub depth_test: bool, pub depth_write: bool, pub alpha_blend: bool, } impl LdrawLightingMaterialPolicy { pub const VERSION: &'static str = "pm-ldraw-lighting-material-v1"; pub const fn ldraw() -> Self { Self { version: Self::VERSION, ambient_energy: 0.45, ambient_colour: [255, 255, 255], key_energy: 2.6, key_colour: [255, 255, 255], key_direction: [-0.45, -0.55, -0.70], fill_energy: 0.8, fill_colour: [210, 225, 255], fill_direction: [0.55, 0.35, -0.30], albedo_is_direct_colour: true, black_albedo: [28, 38, 40], black_roughness: 0.75, roughness: 0.72, metallic: 0.0, cull_back_faces: true, depth_test: true, depth_write: true, alpha_blend: false, } } } #[cfg(not(target_arch = "wasm32"))] pub struct NativeLights { ambient: AmbientLight, key: DirectionalLight, fill: DirectionalLight, } #[cfg(not(target_arch = "wasm32"))] impl NativeLights { pub fn new(context: &three_d::Context) -> Self { let policy = LdrawLightingMaterialPolicy::ldraw(); Self { ambient: AmbientLight::new( context, policy.ambient_energy, Srgba::new_opaque( policy.ambient_colour[0], policy.ambient_colour[1], policy.ambient_colour[2], ), ), key: DirectionalLight::new( context, policy.key_energy, Srgba::new_opaque( policy.key_colour[0], policy.key_colour[1], policy.key_colour[2], ), three_d::vec3( policy.key_direction[0], policy.key_direction[1], policy.key_direction[2], ), ), fill: DirectionalLight::new( context, policy.fill_energy, Srgba::new_opaque( policy.fill_colour[0], policy.fill_colour[1], policy.fill_colour[2], ), three_d::vec3( policy.fill_direction[0], policy.fill_direction[1], policy.fill_direction[2], ), ), } } pub fn as_refs(&self) -> [&dyn three_d::Light; 3] { [&self.ambient, &self.key, &self.fill] } } fn ldraw_colour(rgb: [u8; 3]) -> Srgba { ldraw_colour_with_policy(rgb, LdrawLightingMaterialPolicy::ldraw(), u8::MAX) } /// Hardcoded fallback for common LDraw colour codes when the colour table /// (LDConfig.ldr) is unavailable or doesn't define a code. This keeps models /// visually correct even when the library is incomplete or absent. fn fallback_colour_rgb(code: u32) -> Option<[u8; 3]> { Some(match code { 0 => [33, 33, 33], 1 => [0, 85, 191], 2 => [37, 122, 62], 3 => [44, 148, 180], 4 => [201, 26, 9], 5 => [220, 198, 152], 6 => [150, 157, 161], 7 => [160, 165, 169], 8 => [109, 110, 108], 9 => [180, 210, 227], 10 => [75, 151, 74], 11 => [90, 195, 195], 12 => [225, 213, 237], 13 => [199, 104, 13], 14 => [242, 205, 55], 15 => [242, 243, 242], 16 => [160, 165, 169], 17 => [225, 193, 110], 18 => [170, 125, 90], 19 => [228, 173, 200], 20 => [180, 210, 227], 21 => [221, 117, 46], 22 => [170, 127, 255], 23 => [170, 182, 149], 24 => [160, 143, 95], 25 => [254, 138, 24], 26 => [80, 36, 35], 27 => [105, 64, 39], 28 => [160, 160, 160], 29 => [120, 120, 120], 30 => [95, 124, 150], _ => return None, }) } fn ldraw_colour_with_policy(rgb: [u8; 3], policy: LdrawLightingMaterialPolicy, alpha: u8) -> Srgba { let [r, g, b] = if rgb == [0, 0, 0] { policy.black_albedo } else { rgb }; Srgba::new(r, g, b, alpha) } pub type NativeSceneModel = Vec>>; pub fn build_scene_model( context: &three_d::Context, scene: &Scene, ) -> Result { build_scene_model_for_view_with_resources(context, scene, None) } pub fn build_scene_model_for_view_with_resources( context: &three_d::Context, scene: &Scene, resources: Option<&[ModelResource]>, ) -> Result { let plan = scene_render_plan(scene)?; build_scene_model_from_batches( context, scene, &plan.index, &plan.batches, &plan.upload_batch_indices, &plan.topologies, resources, ) } pub type EdgeModel = Gm; pub type EdgeModelParts = (Option>, Option>); pub fn update_edge_model_culling_bounds( models: &mut [EdgeModel], viewer: &dyn Viewer, ) -> Result<(), RendererError> { for model in models { model.geometry.update_culling_aabb(viewer)?; } Ok(()) } const BASE_RIBBON_WIDTH_PIXELS: f32 = 1.5; const SELECTED_RIBBON_WIDTH_PIXELS: f32 = 3.0; #[cfg(not(target_arch = "wasm32"))] pub type SelectionModelParts = ( Option>, Option>, Option, ); #[cfg(not(target_arch = "wasm32"))] #[derive(Default)] pub struct NativeSelectionSlots { pub identity: Option, pub explicit: Option>, pub conditional: Option>, pub fallback: Option, pub generation: u64, pub conditional_generation: u64, } #[cfg(not(target_arch = "wasm32"))] impl NativeSelectionSlots { pub fn clear(&mut self) { self.identity = None; self.explicit = None; self.conditional = None; self.fallback = None; self.generation = self.generation.wrapping_add(1); self.conditional_generation = self.conditional_generation.wrapping_add(1); } pub fn install(&mut self, identity: SelectionIdentity, models: SelectionModelParts) { self.generation = self.generation.wrapping_add(1); self.conditional_generation = self.conditional_generation.wrapping_add(1); self.identity = Some(identity); self.explicit = models.0; self.conditional = models.1; self.fallback = models.2; } pub fn refilter_conditional( &mut self, generation: u64, conditional: Option>, ) -> bool { if self.identity.is_none() || generation != self.generation { return false; } self.conditional_generation = self.conditional_generation.wrapping_add(1); self.conditional = conditional; true } pub fn live_renderable_count(&self) -> usize { self.explicit.as_ref().map_or(0, Vec::len) + self.conditional.as_ref().map_or(0, Vec::len) + usize::from(self.fallback.is_some()) } } pub struct CompactSegmentGeometry { context: three_d::Context, ribbon_width: f32, endpoints: VertexBuffer, paired_endpoints: VertexBuffer, control1s: VertexBuffer, control2s: VertexBuffer, sides: VertexBuffer, transforms: ( InstanceBuffer, InstanceBuffer, InstanceBuffer, ), local_views: Option>, instance_count: u32, base_aabb: AxisAlignedBoundingBox, aabb: AxisAlignedBoundingBox, } fn compact_segment_aabb( endpoints: &[three_d::Vec3], transforms: &[Mat4], ) -> Result { if endpoints.is_empty() { return Err(RendererError::InvalidRange { context: "compact segment endpoints", index: 0, len: endpoints.len(), }); } if transforms.is_empty() { return Err(RendererError::InvalidRange { context: "compact segment transforms", index: 0, len: transforms.len(), }); } if endpoints.iter().any(|point| { [point.x, point.y, point.z] .into_iter() .any(|value| !value.is_finite()) }) { return Err(RendererError::NonFiniteTransform); } let local = AxisAlignedBoundingBox::new_with_positions(endpoints); let mut world = AxisAlignedBoundingBox::EMPTY; for transform in transforms { let transformed = local.transformed(*transform); if ![ transformed.min().x, transformed.min().y, transformed.min().z, ] .into_iter() .chain([ transformed.max().x, transformed.max().y, transformed.max().z, ]) .all(|value| value.is_finite()) { return Err(RendererError::NonFiniteTransform); } world.expand_with_aabb(transformed); } if [world.min().x, world.min().y, world.min().z] .into_iter() .chain([world.max().x, world.max().y, world.max().z]) .all(|value| value.is_finite()) { Ok(world) } else { Err(RendererError::NonFiniteTransform) } } fn validate_compact_segment_cardinality( segment_count: u64, control_count: Option, transform_count: u64, local_view_count: Option, ) -> Result<(u64, u32), RendererError> { let vertex_count = segment_count .checked_mul(6) .ok_or(RendererError::IndexOverflow { context: "compact segment vertex count", })?; if let Some(control_count) = control_count && control_count != segment_count { return Err(RendererError::InvalidRange { context: "compact segment controls", index: control_count as usize, len: segment_count as usize, }); } if let Some(local_view_count) = local_view_count && local_view_count != transform_count { return Err(RendererError::InvalidRange { context: "compact segment local views", index: local_view_count as usize, len: transform_count as usize, }); } let instance_count = u32::try_from(transform_count).map_err(|_| RendererError::IndexOverflow { context: "compact segment instance count", })?; Ok((vertex_count, instance_count)) } impl CompactSegmentGeometry { fn new( context: &three_d::Context, segments: &[[[f32; 3]; 2]], controls: Option<&[[[f32; 3]; 2]]>, transforms: &[Mat4], local_views: Option<&[[f32; 3]]>, ribbon_width: f32, ) -> Result { let (vertex_count, instance_count) = validate_compact_segment_cardinality( u64::try_from(segments.len()).map_err(|_| RendererError::IndexOverflow { context: "compact segment count", })?, controls .map(<[_]>::len) .map(u64::try_from) .transpose() .map_err(|_| RendererError::IndexOverflow { context: "compact segment control count", })?, u64::try_from(transforms.len()).map_err(|_| RendererError::IndexOverflow { context: "compact segment transform count", })?, local_views .map(<[_]>::len) .map(u64::try_from) .transpose() .map_err(|_| RendererError::IndexOverflow { context: "compact segment local-view count", })?, )?; let vertex_count = usize::try_from(vertex_count).map_err(|_| RendererError::IndexOverflow { context: "compact segment vertex address", })?; let mut endpoints = Vec::with_capacity(vertex_count); let mut paired_endpoints = Vec::with_capacity(vertex_count); let mut control1s = Vec::with_capacity(vertex_count); let mut control2s = Vec::with_capacity(vertex_count); let mut sides = Vec::with_capacity(vertex_count); for segment in segments { let a = three_d::vec3(segment[0][0], segment[0][1], segment[0][2]); let b = three_d::vec3(segment[1][0], segment[1][1], segment[1][2]); let control = controls .and_then(|controls| controls.get(endpoints.len() / 6).copied()) .unwrap_or([ [segment[0][0], segment[0][1], segment[0][2]], [segment[1][0], segment[1][1], segment[1][2]], ]); let c1 = three_d::vec3(control[0][0], control[0][1], control[0][2]); let c2 = three_d::vec3(control[1][0], control[1][1], control[1][2]); for (point, other, side) in [ (a, b, -1.0), (a, b, 1.0), (b, a, 1.0), (b, a, 1.0), (a, b, -1.0), (b, a, -1.0), ] { endpoints.push(point); paired_endpoints.push(other); control1s.push(c1); control2s.push(c2); sides.push(side); } } let base_aabb = compact_segment_aabb(&endpoints, transforms)?; let row1: Vec = transforms.iter().map(|m| m.row(0)).collect(); let row2: Vec = transforms.iter().map(|m| m.row(1)).collect(); let row3: Vec = transforms.iter().map(|m| m.row(2)).collect(); Ok(Self { context: context.clone(), ribbon_width, endpoints: VertexBuffer::new_with_data(context, &endpoints), paired_endpoints: VertexBuffer::new_with_data(context, &paired_endpoints), control1s: VertexBuffer::new_with_data(context, &control1s), control2s: VertexBuffer::new_with_data(context, &control2s), sides: VertexBuffer::new_with_data(context, &sides), transforms: ( InstanceBuffer::new_with_data(context, &row1), InstanceBuffer::new_with_data(context, &row2), InstanceBuffer::new_with_data(context, &row3), ), local_views: local_views.map(|views| { InstanceBuffer::new_with_data( context, &views .iter() .map(|view| three_d::vec3(view[0], view[1], view[2])) .collect::>(), ) }), instance_count, base_aabb, aabb: base_aabb, }) } } fn compact_segment_vertex_shader(ribbon_width_pixels: f32) -> String { let source = r#"uniform mat4 viewProjection; // Physical framebuffer dimensions in pixels, supplied for every draw. uniform vec2 viewport; in vec3 endpoint; in vec3 pairedEndpoint; in vec3 control1; in vec3 control2; in float side; in vec3 localView; in vec4 row1; in vec4 row2; in vec4 row3; out vec4 col; out vec3 pos; flat out int instance_id; bool clip_segment_to_plane(inout vec4 a, inout vec4 b, vec4 plane) { float da = dot(plane, a); float db = dot(plane, b); if (any(isnan(vec4(da, db, a.w, b.w))) || any(isinf(vec4(da, db, a.w, b.w)))) return false; if (da < 0.0 && db < 0.0) return false; if (da < 0.0) { float denominator = da - db; if (!(denominator > 0.0)) return false; a = mix(a, b, da / denominator); } else if (db < 0.0) { float denominator = db - da; if (!(denominator > 0.0)) return false; b = mix(b, a, db / denominator); } return true; } void main() { mat4 transform = mat4( vec4(row1.x, row2.x, row3.x, 0.0), vec4(row1.y, row2.y, row3.y, 0.0), vec4(row1.z, row2.z, row3.z, 0.0), vec4(row1.w, row2.w, row3.w, 1.0)); vec4 clip = viewProjection * transform * vec4(endpoint, 1.0); vec4 pairedClip = viewProjection * transform * vec4(pairedEndpoint, 1.0); vec3 worldEndpoint = (transform * vec4(endpoint, 1.0)).xyz; vec3 worldPairedEndpoint = (transform * vec4(pairedEndpoint, 1.0)).xyz; if (length(localView) > 0.0) { vec3 edge = pairedEndpoint - endpoint; vec3 c1 = control1 - endpoint; vec3 c2 = control2 - endpoint; float side1 = dot(cross(edge, c1), localView); float side2 = dot(cross(edge, c2), localView); if (side1 != side1 || side2 != side2 || abs(side1) > 1.0e30 || abs(side2) > 1.0e30 || ((side1 >= 0.0) != (side2 >= 0.0))) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); return; } } if (!clip_segment_to_plane(clip, pairedClip, vec4(0.0, 0.0, 1.0, 1.0)) || !clip_segment_to_plane(clip, pairedClip, vec4(0.0, 0.0, 0.0, 1.0)) || !(clip.w > 0.0) || !(pairedClip.w > 0.0) || any(isnan(vec4(clip.xy, pairedClip.xy))) || any(isinf(vec4(clip.xy, pairedClip.xy)))) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); return; } vec2 endpoint_ndc = clip.xy / clip.w; vec2 paired_ndc = pairedClip.xy / pairedClip.w; vec2 window_tangent = (paired_ndc - endpoint_ndc) * (0.5 * viewport); float tangent_length = length(window_tangent); if (!(tangent_length > 0.0) || !(viewport.x > 0.0) || !(viewport.y > 0.0) || any(isnan(vec4(endpoint_ndc, paired_ndc))) || any(isinf(vec4(endpoint_ndc, paired_ndc)))) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); return; } vec2 window_normal = vec2(-window_tangent.y, window_tangent.x) / tangent_length; vec2 ndc_offset = window_normal * (2.0 / viewport) * (0.5 * __RIBBON_WIDTH_PIXELS__); clip.xy += ndc_offset * side * clip.w; gl_Position = clip; pos = worldEndpoint; col = vec4(1.0); instance_id = gl_InstanceID; }"#; source.replace( "__RIBBON_WIDTH_PIXELS__", &format!("{:.7}", ribbon_width_pixels), ) } impl CompactSegmentGeometry { fn update_culling_aabb(&mut self, viewer: &dyn Viewer) -> Result<(), RendererError> { if self.base_aabb.is_empty() { self.aabb = self.base_aabb; return Ok(()); } let viewport = viewer.viewport(); if viewport.width == 0 || viewport.height == 0 { return Err(RendererError::InvalidRange { context: "ribbon culling viewport", index: 0, len: 0, }); } let view_projection = viewer.projection() * viewer.view(); let corners = [ self.base_aabb.min(), three_d::vec3( self.base_aabb.max().x, self.base_aabb.min().y, self.base_aabb.min().z, ), three_d::vec3( self.base_aabb.min().x, self.base_aabb.max().y, self.base_aabb.min().z, ), three_d::vec3( self.base_aabb.min().x, self.base_aabb.min().y, self.base_aabb.max().z, ), three_d::vec3( self.base_aabb.min().x, self.base_aabb.max().y, self.base_aabb.max().z, ), three_d::vec3( self.base_aabb.max().x, self.base_aabb.min().y, self.base_aabb.max().z, ), three_d::vec3( self.base_aabb.max().x, self.base_aabb.max().y, self.base_aabb.min().z, ), self.base_aabb.max(), ]; let projection_x_scale = view_projection.x.x.abs() + view_projection.y.x.abs() + view_projection.z.x.abs(); let projection_y_scale = view_projection.x.y.abs() + view_projection.y.y.abs() + view_projection.z.y.abs(); let projection_w_scale = view_projection.x.w.abs() + view_projection.y.w.abs() + view_projection.z.w.abs(); if !projection_x_scale.is_finite() || !projection_y_scale.is_finite() || !projection_w_scale.is_finite() || projection_x_scale <= 0.0 || projection_y_scale <= 0.0 { return Err(RendererError::NonFiniteTransform); } let mut radius = 0.0_f32; let mut minimum_w = f32::INFINITY; for corner in corners { let clip = view_projection * corner.extend(1.0); if !clip.x.is_finite() || !clip.y.is_finite() || !clip.w.is_finite() || clip.w <= 0.0 { return Err(RendererError::NonFiniteTransform); } minimum_w = minimum_w.min(clip.w); let radius_x = self.ribbon_width * clip.w / (viewport.width as f32 * projection_x_scale); let radius_y = self.ribbon_width * clip.w / (viewport.height as f32 * projection_y_scale); radius = radius.max(radius_x.max(radius_y)); } if !radius.is_finite() || radius > f32::MAX / 4.0 || minimum_w <= radius * projection_w_scale { return Err(RendererError::NonFiniteTransform); } let delta = three_d::vec3(radius, radius, radius); self.aabb = AxisAlignedBoundingBox::new_with_positions(&[ self.base_aabb.min() - delta, self.base_aabb.max() + delta, ]); if self.aabb.min().x.is_finite() && self.aabb.min().y.is_finite() && self.aabb.min().z.is_finite() && self.aabb.max().x.is_finite() && self.aabb.max().y.is_finite() && self.aabb.max().z.is_finite() { Ok(()) } else { Err(RendererError::NonFiniteTransform) } } } impl Geometry for CompactSegmentGeometry { fn draw(&self, viewer: &dyn Viewer, program: &Program, render_states: RenderStates) { program.use_uniform("viewProjection", viewer.projection() * viewer.view()); let viewport = viewer.viewport(); program.use_uniform( "viewport", three_d::vec2(viewport.width as f32, viewport.height as f32), ); program.use_vertex_attribute("endpoint", &self.endpoints); program.use_vertex_attribute("pairedEndpoint", &self.paired_endpoints); program.use_vertex_attribute("control1", &self.control1s); program.use_vertex_attribute("control2", &self.control2s); program.use_vertex_attribute("side", &self.sides); if let Some(local_views) = &self.local_views { program.use_instance_attribute("localView", local_views); } program.use_instance_attribute("row1", &self.transforms.0); program.use_instance_attribute("row2", &self.transforms.1); program.use_instance_attribute("row3", &self.transforms.2); program.draw_arrays_instanced( render_states, viewer.viewport(), self.endpoints.vertex_count(), self.instance_count, ); } fn vertex_shader_source(&self) -> String { compact_segment_vertex_shader(self.ribbon_width) } fn id(&self) -> ThreeGeometryId { ThreeGeometryId::Sprites } fn render_with_material( &self, material: &dyn Material, viewer: &dyn Viewer, lights: &[&dyn Light], ) { if let Err(error) = three_d::render_with_material(&self.context, viewer, self, material, lights) { tracing::error!(%error, "rendering compact geometry with material failed"); } } fn render_with_effect( &self, material: &dyn Effect, viewer: &dyn Viewer, lights: &[&dyn Light], color_texture: Option, depth_texture: Option, ) { if let Err(error) = three_d::render_with_effect( &self.context, viewer, self, material, lights, color_texture, depth_texture, ) { tracing::error!(%error, "rendering compact geometry with effect failed"); } } fn aabb(&self) -> AxisAlignedBoundingBox { self.aabb } } #[derive(Clone)] pub struct CompactSegmentMaterial { color: Srgba, render_states: RenderStates, material_type: MaterialType, } impl Material for CompactSegmentMaterial { fn fragment_shader_source(&self, _lights: &[&dyn Light]) -> String { "in vec4 col;\nuniform vec4 surfaceColor;\nlayout(location = 0) out vec4 outColor;\nvoid main(){outColor = surfaceColor * col;}".to_owned() } fn id(&self) -> EffectMaterialId { EffectMaterialId(0x0001) } fn use_uniforms(&self, program: &Program, _viewer: &dyn Viewer, _lights: &[&dyn Light]) { program.use_uniform("surfaceColor", self.color.to_linear_srgb()); } fn render_states(&self) -> RenderStates { self.render_states } fn material_type(&self) -> MaterialType { self.material_type } } #[cfg(not(target_arch = "wasm32"))] pub fn diagnostic_edge_models(label: &str, models: &[EdgeModel], camera: &Camera, generation: u64) { if std::env::var_os("POLYMODEL_LDRAW_RUNTIME_DIAGNOSTICS").is_none() { return; } let frustum = three_d::Frustum::new(camera.projection() * camera.view()); tracing::debug!( "[ldraw-runtime] slot={label} generation={generation} objects={} camera_position={:?} camera_target={:?} camera_distance={:.6} viewport={}x{}", models.len(), camera.position(), camera.target(), (camera.position() - camera.target()).magnitude(), camera.viewport().width, camera.viewport().height, ); for (index, model) in models.iter().enumerate() { let aabb = model.aabb(); let center_distance = (aabb.center() - camera.position()).magnitude(); tracing::debug!( "[ldraw-runtime] slot={label} object={index} material_type={:?} alpha={} render_states={:?} aabb_min={:?} aabb_max={:?} center_distance={center_distance:.6} frustum_visible={} segments={} instances={} vertices={}", model.material_type(), model.material.color.a, model.material.render_states, aabb.min(), aabb.max(), frustum.contains(aabb), model.geometry.endpoints.vertex_count() / 6, model.geometry.instance_count, model.geometry.endpoints.vertex_count(), ); } } #[cfg(not(target_arch = "wasm32"))] pub fn diagnostic_edge_model_slot(label: &str, models: Option<&[EdgeModel]>, generation: u64) { if std::env::var_os("POLYMODEL_LDRAW_RUNTIME_DIAGNOSTICS").is_some() { tracing::debug!( "[ldraw-runtime] slot-summary slot={label} generation={generation} present={} objects={}", models.is_some(), models.map_or(0, <[EdgeModel]>::len), ); } } pub fn build_edge_models_for_view( context: &three_d::Context, scene: &Scene, plan: &SceneRenderPlan, view: [f64; 3], ) -> Result { let topology_started = Instant::now(); let (explicit_inputs, conditional_inputs) = compact_edge_inputs_for_view(scene, plan, view)?; let stats = explicit_inputs.stats; let topology_cpu_ms = topology_started.elapsed().as_millis(); let upload_started = Instant::now(); let explicit = build_compact_edge_models(context, explicit_inputs.batches, false)?; let conditional = build_compact_edge_models(context, conditional_inputs.batches, false)?; let model_upload_ms = upload_started.elapsed().as_millis(); tracing::debug!( "[ldraw-preview] edge-output source_geometries={} planned_occurrences={} explicit_segments={} conditional_visible_segments={} vertices={} indices={} material_groups={} topology_cpu_ms={} model_upload_ms={}", stats.source_geometry_count, stats.planned_occurrences, stats.explicit_segments, stats.visible_conditional_segments, stats.generated_vertices, stats.generated_indices, stats.material_groups, topology_cpu_ms, model_upload_ms, ); Ok(( (!explicit.is_empty()).then_some(explicit), (!conditional.is_empty()).then_some(conditional), )) } pub fn build_conditional_edge_models_for_view( context: &three_d::Context, scene: &Scene, plan: &SceneRenderPlan, view: [f64; 3], ) -> Result>, RendererError> { let conditional_inputs = compact_conditional_edge_inputs_for_membership(scene, plan, view, None)?; let models = build_compact_edge_models(context, conditional_inputs.batches, false)?; Ok((!models.is_empty()).then_some(models)) } #[cfg(not(target_arch = "wasm32"))] fn selection_fallback( explicit_empty: bool, bounds: Option, ) -> Option { explicit_empty .then(|| bounds.map(SelectionFallback::BoundingBox)) .flatten() } #[cfg(not(target_arch = "wasm32"))] pub fn build_selected_edge_models_for_view( context: &three_d::Context, scene: &Scene, plan: &SceneRenderPlan, selection: &SelectionIdentity, view: [f64; 3], ) -> Result { let (explicit_inputs, _) = compact_edge_inputs_for_membership(scene, plan, view, Some(&selection.occurrence_indices))?; let conditional_inputs = compact_conditional_edge_inputs_for_membership( scene, plan, view, Some(&selection.occurrence_indices), )?; let explicit = build_compact_edge_models(context, explicit_inputs.batches, true)?; let conditional = build_compact_edge_models(context, conditional_inputs.batches, true)?; let fallback = selection_fallback(explicit.is_empty(), selection.bounds); Ok(( (!explicit.is_empty()).then_some(explicit), (!conditional.is_empty()).then_some(conditional), fallback, )) } #[cfg(not(target_arch = "wasm32"))] pub fn build_selected_conditional_edge_models_for_view( context: &three_d::Context, scene: &Scene, plan: &SceneRenderPlan, selection: &SelectionIdentity, view: [f64; 3], ) -> Result>, RendererError> { let inputs = compact_conditional_edge_inputs_for_membership( scene, plan, view, Some(&selection.occurrence_indices), )?; let models = build_compact_edge_models(context, inputs.batches, true)?; Ok((!models.is_empty()).then_some(models)) } pub type SceneModelParts = ( NativeSceneModel, EdgeModelParts, ScenePicker, SceneRenderPlan, ); pub fn build_scene_model_parts_for_style( context: &three_d::Context, scene: &Scene, view: [f64; 3], style: RenderStyle, ) -> Result { build_scene_model_parts_for_style_with_resources(context, scene, view, style, None) } pub fn build_scene_model_parts_for_style_with_resources( context: &three_d::Context, scene: &Scene, view: [f64; 3], style: RenderStyle, resources: Option<&[ModelResource]>, ) -> Result { let started = Instant::now(); let plan = scene_render_plan(scene)?; let plan_elapsed = started.elapsed(); tracing::debug!( "[ldraw-preview] native-build-stage=plan elapsed_ms={} cumulative_ms={}", plan_elapsed.as_millis(), plan_elapsed.as_millis() ); let static_started = Instant::now(); let static_model = build_scene_model_from_batches( context, scene, &plan.index, &plan.batches, &plan.upload_batch_indices, &plan.topologies, resources, )?; let static_elapsed = static_started.elapsed(); tracing::debug!( "[ldraw-preview] native-build-stage=static elapsed_ms={} cumulative_ms={}", static_elapsed.as_millis(), started.elapsed().as_millis() ); let (explicit_model, conditional_model) = if style == RenderStyle::Edges { let edge_started = Instant::now(); let models = build_edge_models_for_view(context, scene, &plan, view)?; tracing::debug!( "[ldraw-preview] native-build-stage=edge-output elapsed_ms={} cumulative_ms={}", edge_started.elapsed().as_millis(), started.elapsed().as_millis() ); models } else { (None, None) }; let picker = ScenePicker::from_plan(scene, &plan)?; tracing::debug!( "[ldraw-preview] native-build-stage=picker elapsed_ms={}", started.elapsed().as_millis() ); Ok(( static_model, (explicit_model, conditional_model), picker, plan, )) } pub fn build_scene_faces_and_picker( context: &three_d::Context, scene: &Scene, ) -> Result<(NativeSceneModel, ScenePicker), RendererError> { let started = Instant::now(); let plan = scene_render_plan(scene)?; tracing::debug!( "[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.batches, &plan.upload_batch_indices, &plan.topologies, None, )?; tracing::debug!( "[ldraw-preview] native-build-stage=static elapsed_ms={}", started.elapsed().as_millis() ); let picker = ScenePicker::from_plan(scene, &plan)?; tracing::debug!( "[ldraw-preview] native-build-stage=picker elapsed_ms={}", started.elapsed().as_millis() ); Ok((static_model, picker)) } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct EdgeBuildStats { pub source_geometry_count: usize, pub planned_occurrences: usize, pub explicit_segments: usize, pub visible_conditional_segments: usize, pub generated_vertices: usize, pub generated_indices: usize, pub material_groups: usize, pub source_segment_vertices: usize, pub instance_count: usize, pub gpu_bytes: usize, } #[derive(Clone, Debug)] struct CompactEdgeBatch { material: MaterialId, colour: u32, ribbon_width: f32, segments: Vec<[[f32; 3]; 2]>, controls: Option>, local_views: Option>, transforms: Vec, } #[derive(Clone, Debug, Default)] struct CompactEdgeInputs { batches: Vec, stats: EdgeBuildStats, } fn compact_edge_inputs_for_view( scene: &Scene, plan: &SceneRenderPlan, view: [f64; 3], ) -> Result<(CompactEdgeInputs, CompactEdgeInputs), RendererError> { compact_edge_inputs_for_membership(scene, plan, view, None) } fn compact_conditional_edge_inputs_for_membership( scene: &Scene, plan: &SceneRenderPlan, view: [f64; 3], selected: Option<&[usize]>, ) -> Result { let indices = selected.unwrap_or(&[]); let occurrences = if selected.is_some() { indices .iter() .map(|index| { plan.planned_occurrences .get(*index) .ok_or(RendererError::InvalidRange { context: "selected occurrence", index: *index, len: plan.planned_occurrences.len(), }) }) .collect::, _>>()? } else { plan.planned_occurrences.iter().collect() }; let mut conditional = std::collections::BTreeMap::<(usize, MaterialId), CompactEdgeBatch>::new(); let mut stats = EdgeBuildStats { source_geometry_count: plan.edge_sources.len(), planned_occurrences: plan.planned_occurrences.len(), ..Default::default() }; let default_material = scene .materials .first() .map(|material| material.id) .unwrap_or(MaterialId(1)); for occurrence in occurrences { let scene_occurrence = scene.occurrences.get(occurrence.occurrence_index).ok_or( RendererError::InvalidRange { context: "planned occurrence source", index: occurrence.occurrence_index, len: scene.occurrences.len(), }, )?; let material = occurrence.material.unwrap_or(default_material); let colour = scene .materials .iter() .find(|candidate| candidate.id == material) .ok_or(RendererError::MissingMaterial(material))? .colour; let source = &plan.edge_sources[occurrence.geometry_index]; if source.conditional.is_empty() { continue; } let local_view = conditional_line_view_for_transform(scene_occurrence.transform, view).unwrap_or(view); let batch = conditional .entry((occurrence.geometry_index, material)) .or_insert_with(|| CompactEdgeBatch { material, colour, ribbon_width: BASE_RIBBON_WIDTH_PIXELS, segments: source .conditional .iter() .map(|(endpoints, _)| *endpoints) .collect(), controls: Some( source .conditional .iter() .map(|(_, controls)| *controls) .collect(), ), local_views: Some(Vec::new()), transforms: Vec::new(), }); batch .transforms .push(scene_transform_to_mat4(scene_occurrence.transform)?); batch.local_views.as_mut().unwrap().push([ finite_f32(local_view[0])?, finite_f32(local_view[1])?, finite_f32(local_view[2])?, ]); stats.visible_conditional_segments += source.conditional.len(); } let batches = conditional .into_values() .filter(|batch| !batch.segments.is_empty()) .collect::>(); stats.material_groups = batches.len(); stats.generated_vertices = batches.iter().map(|batch| batch.segments.len() * 6).sum(); stats.source_segment_vertices = stats.generated_vertices; stats.instance_count = batches.iter().map(|batch| batch.transforms.len()).sum(); stats.gpu_bytes = stats.generated_vertices * (3 * 4 + 3 * 4 + 4) + stats.instance_count * (3 * 4 * 4); Ok(CompactEdgeInputs { batches, stats }) } fn compact_edge_inputs_for_membership( scene: &Scene, plan: &SceneRenderPlan, view: [f64; 3], selected: Option<&[usize]>, ) -> Result<(CompactEdgeInputs, CompactEdgeInputs), RendererError> { let mut stats = EdgeBuildStats { source_geometry_count: plan.edge_sources.len(), planned_occurrences: plan.planned_occurrences.len(), ..Default::default() }; let occurrences = selected .map(|indices| { indices .iter() .map(|index| { plan.planned_occurrences .get(*index) .ok_or(RendererError::InvalidRange { context: "selected occurrence", index: *index, len: plan.planned_occurrences.len(), }) }) .collect::, _>>() }) .unwrap_or_else(|| Ok(plan.planned_occurrences.iter().collect()))?; let mut explicit = std::collections::BTreeMap::<(usize, MaterialId), CompactEdgeBatch>::new(); let mut conditional = std::collections::BTreeMap::<(usize, MaterialId), CompactEdgeBatch>::new(); let default_material = scene .materials .first() .map(|material| material.id) .unwrap_or(MaterialId(1)); for occurrence in occurrences { let scene_occurrence = scene.occurrences.get(occurrence.occurrence_index).ok_or( RendererError::InvalidRange { context: "planned occurrence source", index: occurrence.occurrence_index, len: scene.occurrences.len(), }, )?; let material = occurrence.material.unwrap_or(default_material); let colour = scene .materials .iter() .find(|candidate| candidate.id == material) .ok_or(RendererError::MissingMaterial(material))? .colour; let source = &plan.edge_sources[occurrence.geometry_index]; let local_view = conditional_line_view_for_transform(scene_occurrence.transform, view).unwrap_or(view); let explicit_batch = explicit .entry((occurrence.geometry_index, material)) .or_insert_with(|| CompactEdgeBatch { material, colour, ribbon_width: BASE_RIBBON_WIDTH_PIXELS, segments: source.explicit.to_vec(), controls: None, local_views: None, transforms: Vec::new(), }); if !explicit_batch.segments.is_empty() { stats.explicit_segments += source.explicit.len(); explicit_batch .transforms .push(scene_transform_to_mat4(scene_occurrence.transform)?); } if !source.conditional.is_empty() { let batch = conditional .entry((occurrence.geometry_index, material)) .or_insert_with(|| CompactEdgeBatch { material, colour, ribbon_width: BASE_RIBBON_WIDTH_PIXELS, segments: source .conditional .iter() .map(|(endpoints, _)| *endpoints) .collect(), controls: Some( source .conditional .iter() .map(|(_, controls)| *controls) .collect(), ), local_views: Some(Vec::new()), transforms: Vec::new(), }); batch .transforms .push(scene_transform_to_mat4(scene_occurrence.transform)?); batch.local_views.as_mut().unwrap().push([ finite_f32(local_view[0])?, finite_f32(local_view[1])?, finite_f32(local_view[2])?, ]); stats.visible_conditional_segments += source.conditional.len(); } } let mut explicit_inputs = CompactEdgeInputs { batches: explicit .into_values() .filter(|batch| !batch.segments.is_empty()) .collect(), stats, }; let mut conditional_stats = stats; let conditional_batches = conditional .into_values() .filter(|batch| !batch.segments.is_empty()) .collect::>(); explicit_inputs.stats.material_groups = explicit_inputs.batches.len() + conditional_batches.len(); conditional_stats.material_groups = conditional_batches.len(); let explicit_vertices = explicit_inputs .batches .iter() .map(|batch| batch.segments.len() * 6) .sum::(); let conditional_vertices = conditional_batches .iter() .map(|batch| batch.segments.len() * 6) .sum::(); let explicit_instances = explicit_inputs .batches .iter() .map(|batch| batch.transforms.len()) .sum::(); let conditional_instances = conditional_batches .iter() .map(|batch| batch.transforms.len()) .sum::(); explicit_inputs.stats.generated_vertices = explicit_vertices + conditional_vertices; explicit_inputs.stats.generated_indices = 0; explicit_inputs.stats.source_segment_vertices = explicit_vertices + conditional_vertices; explicit_inputs.stats.instance_count = explicit_instances + conditional_instances; explicit_inputs.stats.gpu_bytes = explicit_inputs.stats.source_segment_vertices * (3 * 4 + 3 * 4 + 4) + explicit_inputs.stats.instance_count * (3 * 4 * 4); conditional_stats.generated_vertices = conditional_vertices; conditional_stats.generated_indices = 0; conditional_stats.source_segment_vertices = conditional_vertices; conditional_stats.instance_count = conditional_instances; conditional_stats.gpu_bytes = conditional_vertices * (3 * 4 + 3 * 4 + 4) + conditional_instances * (3 * 4 * 4); Ok(( explicit_inputs, CompactEdgeInputs { batches: conditional_batches, stats: conditional_stats, }, )) } fn finite_f32_vec3(point: [f64; 3]) -> Result<[f32; 3], RendererError> { Ok([ finite_f32(point[0])?, finite_f32(point[1])?, finite_f32(point[2])?, ]) } pub fn edge_build_stats( scene: &Scene, plan: &SceneRenderPlan, view: [f64; 3], ) -> Result { Ok(compact_edge_inputs_for_view(scene, plan, view)?.0.stats) } pub fn build_conditional_edge_topology_for_scene( scene: &Scene, plan: &SceneRenderPlan, view: [f64; 3], ) -> Result, RendererError> { let (_, conditional) = compact_edge_inputs_for_view(scene, plan, view)?; Ok(conditional .batches .into_iter() .map(|batch| { ( batch.material, batch.colour, SceneMeshTopology { positions: batch .segments .iter() .flat_map(|segment| segment.iter().copied()) .collect(), uvs: Box::default(), indices: Vec::new(), }, ) }) .collect()) } fn compact_segment_material(_colour: u32, selected: bool) -> CompactSegmentMaterial { CompactSegmentMaterial { color: if selected { Srgba::new(116, 180, 255, 200) } else { ldraw_colour([160, 165, 169]) }, render_states: if selected { RenderStates { cull: Cull::None, blend: Blend::Enabled { source_rgb_multiplier: BlendMultiplierType::SrcAlpha, source_alpha_multiplier: BlendMultiplierType::One, destination_rgb_multiplier: BlendMultiplierType::OneMinusSrcAlpha, destination_alpha_multiplier: BlendMultiplierType::OneMinusSrcAlpha, rgb_equation: BlendEquationType::Add, alpha_equation: BlendEquationType::Add, }, ..Default::default() } } else { RenderStates { cull: Cull::None, blend: Blend::Disabled, ..Default::default() } }, material_type: if selected { MaterialType::Transparent } else { MaterialType::Opaque }, } } fn build_compact_edge_models( context: &three_d::Context, batches: Vec, selected: bool, ) -> Result>, RendererError> { batches .into_iter() .map(|batch| { let geometry = CompactSegmentGeometry::new( context, &batch.segments, batch.controls.as_deref(), &batch.transforms, batch.local_views.as_deref(), if selected { SELECTED_RIBBON_WIDTH_PIXELS } else { batch.ribbon_width }, )?; Ok(Gm::new( geometry, compact_segment_material(batch.colour, selected), )) }) .collect() } fn accumulate_scene_stats( stats: &mut SceneStats, geometry: &SceneGeometry, ) -> Result<(), RendererError> { let points = u64::try_from(geometry.points.len()) .map_err(|_| RendererError::ParseAdaptation("point count overflow"))?; let triangles = u64::try_from(geometry.triangles.len()) .map_err(|_| RendererError::ParseAdaptation("triangle count overflow"))?; let quads = u64::try_from(geometry.quads.len()) .map_err(|_| RendererError::ParseAdaptation("quad count overflow"))?; let lines = u64::try_from(geometry.lines.len()) .map_err(|_| RendererError::ParseAdaptation("line count overflow"))?; let conditional_lines = u64::try_from(geometry.conditional_lines.len()) .map_err(|_| RendererError::ParseAdaptation("conditional-line count overflow"))?; stats.points = stats .points .checked_add(points) .ok_or(RendererError::ParseAdaptation("point count overflow"))?; stats.triangles = stats .triangles .checked_add(triangles) .and_then(|value| value.checked_add(quads.checked_mul(2)?)) .ok_or(RendererError::ParseAdaptation("triangle count overflow"))?; stats.lines = stats .lines .checked_add(lines) .ok_or(RendererError::ParseAdaptation("line count overflow"))?; stats.conditional_lines = stats .conditional_lines .checked_add(conditional_lines) .ok_or(RendererError::ParseAdaptation( "conditional-line count overflow", ))?; Ok(()) } fn scene_material_translucent(material: &SceneMaterial, texture_translucent: bool) -> bool { material.alpha < u8::MAX || texture_translucent } fn scene_material_type(translucent: bool) -> MaterialType { if translucent { MaterialType::Transparent } else { MaterialType::Opaque } } fn scene_material_render_states(translucent: bool, cull_back_faces: bool) -> RenderStates { RenderStates { cull: if cull_back_faces { Cull::Back } else { Cull::None }, blend: if translucent { Blend::Enabled { source_rgb_multiplier: BlendMultiplierType::SrcAlpha, source_alpha_multiplier: BlendMultiplierType::One, destination_rgb_multiplier: BlendMultiplierType::OneMinusSrcAlpha, destination_alpha_multiplier: BlendMultiplierType::OneMinusSrcAlpha, rgb_equation: BlendEquationType::Add, alpha_equation: BlendEquationType::Add, } } else { Blend::Disabled }, write_mask: if translucent { WriteMask::COLOR } else { WriteMask::COLOR_AND_DEPTH }, ..Default::default() } } fn build_scene_model_from_batches( context: &three_d::Context, scene: &Scene, index: &SceneIndex, batches: &[SceneInstanceBatch], upload_batch_indices: &[usize], topologies: &[SceneMeshTopology], resources: Option<&[ModelResource]>, ) -> Result { let resource_bytes = |texture: &polymodel_renderer_protocol::scene::SceneTexture| { let key = texture .resource_key .as_deref() .unwrap_or(texture.uri.as_str()); resources .and_then(|resources| { resources .iter() .find(|resource| resource.key == key || resource.path == key) }) .map(|resource| (key.to_owned(), &*resource.bytes)) .ok_or_else(|| RendererError::MissingTexture(texture.id)) }; let mut albedo_cache = HashMap::::new(); let mut gloss_cache = HashMap::::new(); let mut transparency_cache = HashMap::::new(); let mut material_cache = HashMap::<(MaterialId, bool), Arc>::new(); let model = upload_batch_indices .iter() .copied() .map(|batch_index| { let batch = batches .get(batch_index) .ok_or(RendererError::InvalidRange { context: "upload batch index", index: batch_index, len: batches.len(), })?; let _geometry = index.geometry(scene, batch.node)?; let transformations = batch .instances .iter() .map(|instance| { let occurrence = scene.occurrences.get(instance.occurrence_index).ok_or( RendererError::InvalidRange { context: "batch occurrence", index: instance.occurrence_index, len: scene.occurrences.len(), }, )?; scene_transform_to_mat4(occurrence.transform) }) .collect::, RendererError>>()?; let scene_material = index.material(scene, batch.material)?; let colour_rgb = scene_material .colour_rgb .or_else(|| fallback_colour_rgb(scene_material.colour)) .unwrap_or([160, 165, 169]); let policy = LdrawLightingMaterialPolicy::ldraw(); let alpha = scene_material.alpha; let mut cpu_material = CpuMaterial { albedo: ldraw_colour_with_policy(colour_rgb, policy, alpha), roughness: if colour_rgb == [0, 0, 0] { policy.black_roughness } else { policy.roughness }, metallic: policy.metallic, ..Default::default() }; let mut translucent = scene_material_translucent(scene_material, false); if let Some(texture_id) = scene_material.albedo_texture { let texture = scene .textures .iter() .find(|texture| texture.id == texture_id) .ok_or(RendererError::MissingTexture(texture_id))?; let (key, bytes) = resource_bytes(texture)?; if !albedo_cache.contains_key(&key) { let (width, height, pixels, transparency) = decode_albedo_png(bytes)?; transparency_cache.insert(key.clone(), transparency); albedo_cache.insert( key.clone(), Texture2D { name: texture.uri.to_string(), data: TextureData::RgbaU8(pixels), width, height, ..Default::default() }, ); } translucent |= *transparency_cache .get(&key) .ok_or(RendererError::MissingTexture( scene_material.albedo_texture.unwrap(), ))?; cpu_material.albedo_texture = albedo_cache.get(&key).cloned(); } if let Some(texture_id) = scene_material.metallic_roughness_texture { let texture = scene .textures .iter() .find(|texture| texture.id == texture_id) .ok_or(RendererError::MissingTexture(texture_id))?; let (key, bytes) = resource_bytes(texture)?; if !gloss_cache.contains_key(&key) { let decoded = decode_glossmap_png(bytes)?; gloss_cache.insert( key.clone(), Texture2D { name: texture.uri.to_string(), data: TextureData::RgbU8(decoded.packed_rgb), width: decoded.width, height: decoded.height, ..Default::default() }, ); } cpu_material.metallic_roughness_texture = gloss_cache.get(&key).cloned(); } let material_key = (batch.material, batch.bfc_cull && policy.cull_back_faces); let material = if let Some(material) = material_cache.get(&material_key) { material.clone() } else { let mut material = if matches!(scene_material_type(translucent), MaterialType::Transparent) { PhysicalMaterial::new_transparent(context, &cpu_material) } else { PhysicalMaterial::new_opaque(context, &cpu_material) }; material.render_states = scene_material_render_states(translucent, material_key.1); let material = Arc::new(material); material_cache.insert(material_key, material.clone()); material }; if transformations.is_empty() { return Ok(None); } let geometry_index = index.geometry_index(batch.node)?; let topology = topologies .get(geometry_index) .ok_or(RendererError::InvalidRange { context: "render plan geometry topology", index: geometry_index, len: topologies.len(), })?; 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 .positions .iter() .map(|point| three_d::vec3(point[0], point[1], point[2])) .collect(), ), indices: Indices::U32(indices), uvs: batch .uvs .as_deref() .map(|uvs| uvs) .or_else(|| (!topology.uvs.is_empty()).then_some(&*topology.uvs)) .map(|uvs: &[[f32; 2]]| { uvs.iter().map(|uv| three_d::vec2(uv[0], uv[1])).collect() }), ..Default::default() }; cpu_mesh.compute_normals(); if batch.reflection && let Some(normals) = cpu_mesh.normals.as_mut() { for normal in normals { *normal = -*normal; } } if cpu_mesh.normals.as_ref().map(Vec::len) != Some(topology.positions.len()) { return Err(RendererError::InvalidRange { context: "geometry normals", index: cpu_mesh.normals.as_ref().map_or(0, Vec::len), len: topology.positions.len(), }); } Ok(Some(Gm::new( InstancedMesh::new( context, &Instances { transformations, ..Default::default() }, &cpu_mesh, ), material, ))) }) .collect::, RendererError>>()? .into_iter() .flatten() .collect::>(); if model.is_empty() { Err(RendererError::ParseAdaptation( "scene contains no renderable instanced geometry", )) } else { Ok(model) } } #[cfg(not(target_arch = "wasm32"))] pub fn render_scene_window_options_traced( scene: &Scene, output: Option<&std::path::Path>, trace: NativeTrace, options: NativeRenderOptions, ) -> Result<(), RendererError> { use std::panic::{AssertUnwindSafe, catch_unwind}; use three_d::{ClearState, FrameOutput, Window, WindowSettings}; trace.record(NativeStage::Started); let bounds = scene.bounds.ok_or_else(|| { trace.finish(NativeTerminal::Failure("scene has no bounds".to_owned())); RendererError::ParseAdaptation("scene has no bounds") })?; if scene.stats.triangles == 0 { let error = RendererError::ParseAdaptation("scene has no triangles"); trace.finish(NativeTerminal::Failure(error.to_string())); return Err(error); } trace.record(NativeStage::LibraryResolved); trace.record(NativeStage::OmrParsed); trace.record(NativeStage::SceneAdapted); tracing::debug!( "[ldraw-preview] backend=wayland WAYLAND_DISPLAY={} XDG_RUNTIME_DIR={}", std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "".to_owned()), std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "".to_owned()), ); let window = Window::new(WindowSettings { title: "Polymodel LDraw Preview".into(), initial_size: Some((options.width, options.height)), max_size: Some((options.width, options.height)), ..Default::default() }) .map_err(|error| { let error = RendererError::External(format!("native window: {error}")); trace.finish(NativeTerminal::Failure(error.to_string())); error })?; trace.record(NativeStage::WindowCreated); tracing::debug!("[ldraw-preview] window/context created"); let context = window.gl(); trace.record(NativeStage::ContextCreated); let (static_model, (explicit_model, conditional_model), _, _) = match catch_unwind(AssertUnwindSafe(|| { build_scene_model_parts_for_style(&context, scene, [0.0, 0.0, 1.0], options.style) })) { Ok(Ok(parts)) => parts, Ok(Err(error)) => { trace.finish(NativeTerminal::Failure(error.to_string())); return Err(error); } Err(_) => { let error = RendererError::GpuResource("GPU resource creation panicked".to_owned()); trace.finish(NativeTerminal::Failure(error.to_string())); return Err(error); } }; trace.record(NativeStage::GpuResourcesCreated); let lights = NativeLights::new(&context); let viewport = window.viewport(); if viewport.width != options.width || viewport.height != options.height { let error = RendererError::External(format!( "native viewport mismatch: requested {}x{}, actual {}x{}", options.width, options.height, viewport.width, viewport.height )); trace.finish(NativeTerminal::Failure(error.to_string())); return Err(error); } let fit = scene_camera_fit(bounds, options.width, options.height) .map_err(|error| RendererError::External(error.to_string()))?; let (position, target, up, fov_degrees) = if let Some(case) = options.camera_case.as_ref() { let pose = case .native_pose(fit) .map_err(|error| RendererError::External(error.to_string()))?; (pose.position, pose.target, pose.up, pose.fov_degrees) } else { ( yaw_camera_position(fit.center, fit.position, options.yaw_degrees), fit.center, three_d::vec3(0.0, 0.0, 1.0), 45.0, ) }; if let (Some(case), Some(variant)) = (options.camera_case.as_ref(), options.variant.as_ref()) { let metadata = NativeCaptureMetadata { case_id: case.case_id.clone(), schema_version: case.schema_version.clone(), variant: variant.clone(), fixture_hash: pm90_fixture_hash(), requested_width: options.width, requested_height: options.height, actual_width: viewport.width, actual_height: viewport.height, native_path: "static".into(), }; emit_lighting_diagnostic(&metadata, scene); tracing::debug!( "[ldraw-preview] capture case={} schema={} variant={} occurrence={} fixture={} requested={}x{} actual={}x{}", metadata.case_id, metadata.schema_version, metadata.variant.variant_id, metadata.variant.occurrence_id, metadata.fixture_hash, metadata.requested_width, metadata.requested_height, metadata.actual_width, metadata.actual_height, ); } tracing::debug!( "[ldraw-preview] camera center={:?} position={:?} target={:?} up={:?} fov={:.1} near={:.3} far={:.3} yaw_degrees={:.1} viewport={}x{}", fit.center, position, target, up, fov_degrees, fit.near, fit.far, options.yaw_degrees, viewport.width, viewport.height, ); let projection = camera_space_clip_planes(bounds, position, target) .map_err(|error| RendererError::External(error.to_string()))?; let mut camera = Camera::new_perspective( viewport, position, target, up, three_d::degrees(fov_degrees), projection.near, projection.far, ); let mut control = OrbitControl::new(fit.center, fit.control_min, fit.control_max); let mut output_path = output.map(std::path::Path::to_owned); let callback_trace = trace.clone(); window.render_loop(move |mut frame_input| { let result = catch_unwind(AssertUnwindSafe(|| { callback_trace.record(NativeStage::FrameCallbackEntered); 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 projection = camera_space_clip_planes(bounds, camera.position(), camera.target()) .map_err(|error| RendererError::External(error.to_string()))?; let fov = { let projection_type = camera.projection_type(); match projection_type { three_d_asset::ProjectionType::Perspective { field_of_view_y } => { *field_of_view_y } _ => { return Err(RendererError::External( "camera projection is not perspective".to_owned(), )); } } }; camera.set_perspective_projection(fov, projection.near, projection.far); 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, &static_model, &light_refs); if let Some(explicit_model) = explicit_model.as_ref() { screen.render(&camera, explicit_model, &light_refs); } if let Some(conditional_model) = conditional_model.as_ref() { screen.render(&camera, conditional_model, &light_refs); } callback_trace.record(NativeStage::DrawCompleted); if frame_input.first_frame || headless { let pixels = screen.read_color::<[u8; 4]>(); callback_trace.record(NativeStage::ReadbackCompleted); if let Some(path) = output_path.take() { std::fs::create_dir_all( path.parent().unwrap_or_else(|| std::path::Path::new(".")), ) .map_err(|error| { RendererError::Io(format!("create preview output directory: {error}")) })?; std::fs::write( path, native_png_bytes( viewport.width, viewport.height, &pixels.into_iter().flatten().collect::>(), ), ) .map_err(|error| RendererError::Io(format!("write preview output: {error}")))?; callback_trace.record(NativeStage::OutputWritten); callback_trace.finish(NativeTerminal::Success); return Ok(FrameOutput { exit: true, ..Default::default() }); } } Ok(FrameOutput::default()) })); match result { Ok(Ok(output)) => output, Ok(Err(error)) => { callback_trace.finish(NativeTerminal::Failure(error.to_string())); FrameOutput { exit: true, ..Default::default() } } Err(_) => { callback_trace.finish(NativeTerminal::Failure("render callback panicked".into())); FrameOutput { exit: true, ..Default::default() } } } }); let snapshot = trace.snapshot(); match snapshot.terminal { Some(NativeTerminal::Success) | None if output.is_none() => Ok(()), Some(NativeTerminal::Success) => Ok(()), Some(NativeTerminal::Failure(error)) | Some(NativeTerminal::Timeout(error)) => { Err(RendererError::External(error)) } None => Err(RendererError::External( "native render exited without terminal state".to_owned(), )), } } pub fn conditional_line_visible( endpoints: [[f64; 3]; 2], controls: [[f64; 3]; 2], view: [f64; 3], ) -> bool { let edge = [ endpoints[1][0] - endpoints[0][0], endpoints[1][1] - endpoints[0][1], endpoints[1][2] - endpoints[0][2], ]; let projected_side = |point: [f64; 3]| { let to_control = [ point[0] - endpoints[0][0], point[1] - endpoints[0][1], point[2] - endpoints[0][2], ]; let cross = [ edge[1] * to_control[2] - edge[2] * to_control[1], edge[2] * to_control[0] - edge[0] * to_control[2], edge[0] * to_control[1] - edge[1] * to_control[0], ]; cross[0] * view[0] + cross[1] * view[1] + cross[2] * view[2] }; let first = projected_side(controls[0]); let second = projected_side(controls[1]); first.is_finite() && second.is_finite() && (first >= 0.0) == (second >= 0.0) } pub mod admission; mod texture; pub use texture::{ GlossMapPixels, decode_albedo_png, decode_glossmap_png, packed_roughness_from_alpha, packed_roughness_from_alpha_samples, texmap_uvs, }; #[cfg(test)] mod tests;