use crate::{ cache::{source_fingerprint, ViewerCache, MAX_VIEWER_HTML_BYTES}, interference::{self, CheckReport}, load_status::{valid_load_id, LoadReporter, LoadTracker}, motion::{prepare_motion, PreparedMotion, MAX_MOTION_COMPONENTS}, project::{Motion, Project}, }; use look::{ config::{LightingConfig, UpAxis}, scene::{compile_scene, prepare_source_textures, CompiledScene}, timing::Timings, }; use percent_encoding::percent_decode_str; use serde_json::json; use std::{ collections::{HashMap, VecDeque}, fs, io::Read, path::{Component, Path, PathBuf}, process::Command, sync::{mpsc, Arc, Mutex, MutexGuard}, thread, time::{SystemTime, UNIX_EPOCH}, }; use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; const SHELL_HTML: &str = include_str!("viewer_shell.html"); const LOGO_PNG: &[u8] = include_bytes!("../assets/burr-logo.png"); const VIEWER_FRAMING_MARGIN: f32 = 1.3; const SERVER_WORKERS: usize = 4; const MAX_MEMORY_VIEWERS: usize = 32; const MAX_MEMORY_VIEWER_BYTES: usize = 256 * 1024 * 1024; const SKIP_DIRECTORIES: [&str; 10] = [ ".git", ".jj", ".next", "__cadgen__", "__pycache__", "build", "dist", "node_modules", "target", "venv", ]; #[derive(Clone, Debug, PartialEq, Eq)] struct ModelFile { relative_path: String, name: String, format: &'static str, version: String, } struct CachedModel { version: String, scene: Arc, report: Option, } #[derive(Default)] struct ModelCache { models: HashMap, viewers: HashMap>, viewer_order: VecDeque, viewer_bytes: usize, #[cfg(test)] compile_count: usize, } struct RenderedViewer { html: Arc, cache: &'static str, } struct RenderSelection<'a> { relative_path: &'a str, theme: ViewerTheme, focus: Option, motion_id: Option<&'a str>, } #[derive(Clone, Debug, Hash, PartialEq, Eq)] struct FocusPair { indexes: Vec, } impl FocusPair { fn from_query(value: Option<&str>) -> Result, String> { let Some(value) = value else { return Ok(None); }; let mut indexes = value .split(',') .map(|index| { index .parse::() .map_err(|_| "Viewer focus contains an invalid component index.".to_string()) }) .collect::, _>>()?; if indexes.len() < 2 || indexes.len() > 4096 { return Err("Viewer focus must contain 2 to 4096 component indexes.".to_string()); } indexes.sort_unstable(); if indexes.windows(2).any(|pair| pair[0] == pair[1]) { return Err("Viewer focus must contain different components.".to_string()); } Ok(Some(Self { indexes })) } fn label(&self) -> String { self.indexes .iter() .map(usize::to_string) .collect::>() .join(",") } } #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] enum ViewerTheme { Dark, Light, } impl ViewerTheme { fn from_query(value: Option<&str>) -> Self { match value { Some("light") => Self::Light, _ => Self::Dark, } } fn name(self) -> &'static str { match self { Self::Dark => "dark", Self::Light => "light", } } fn canvas_background(self) -> &'static str { match self { Self::Dark => "#0c0d10", Self::Light => "#c9ced0", } } fn lighting(self) -> LightingConfig { let mut lighting = LightingConfig::default(); if self == Self::Light { lighting.ambient = 0.22; lighting.intensity = 0.95; } lighting } fn viewer_css(self) -> &'static str { match self { Self::Dark => DARK_VIEWER_THEME_CSS, Self::Light => LIGHT_VIEWER_THEME_CSS, } } } const DARK_VIEWER_THEME_CSS: &str = r#" body { background-color: #0c0d10; color: #f3f4f5; } .header-bar, .toolbar, .legend { background: rgba(17, 18, 21, 0.88); border-color: #2a2d33; box-shadow: 0 12px 36px rgba(0, 0, 0, 0.42); } .model-title { color: #f3f4f5; } .format-badge { background: #405e6d; color: #f7fbfd; } .stat-item, .btn { color: #929aa1; } .stat-value, .legend kbd { color: #c8cdd1; } .btn:hover, .legend kbd { background: #24272c; } .legend { color: #747c83; } "#; const LIGHT_VIEWER_THEME_CSS: &str = r#" body { background-color: #c9ced0; color: #1b1d1f; } .header-bar, .toolbar, .legend { background: rgba(250, 249, 246, 0.9); border-color: #c8c7c1; box-shadow: 0 12px 32px rgba(31, 35, 38, 0.16); } .model-title { color: #1b1d1f; } .format-badge { background: #4f6e7d; color: #ffffff; } .stat-item, .btn { color: #687077; } .stat-value, .legend kbd { color: #34383c; } .btn:hover, .legend kbd { background: #e7e5df; color: #17191b; } .legend { color: #6b7277; } "#; pub fn run(start: PathBuf) -> Result<(), String> { let project = Arc::new(Project::discover(&start)?); let requested_port = std::env::var("BURR_VIEWER_PORT") .ok() .map(|value| { value .parse::() .map_err(|_| "BURR_VIEWER_PORT must be a number from 0 to 65535.".to_string()) }) .transpose()? .unwrap_or(0); let server = Server::http(("127.0.0.1", requested_port)) .map_err(|error| format!("Failed to start Burr viewer: {error}"))?; let address = server .server_addr() .to_ip() .ok_or_else(|| "Burr viewer did not receive an IP address.".to_string())?; let port = address.port(); let url = format!("http://127.0.0.1:{port}/"); println!("BURR PROJECT {}", project.root().display()); println!("OPEN {url}"); println!("Watching STEP, STL, and GLB files. Press Ctrl-C to stop."); if std::env::var_os("BURR_VIEWER_NO_OPEN").is_none() { if let Err(error) = open_browser(&url) { eprintln!("burr: could not open the browser automatically: {error}"); eprintln!("burr: open {url} manually"); } } let cache = Arc::new(Mutex::new(ModelCache::default())); let load_tracker = Arc::new(LoadTracker::default()); let viewer_cache = Arc::new(ViewerCache::from_environment()); let (sender, receiver) = mpsc::channel::(); let receiver = Arc::new(Mutex::new(receiver)); for _ in 0..SERVER_WORKERS { let receiver = Arc::clone(&receiver); let project = Arc::clone(&project); let cache = Arc::clone(&cache); let load_tracker = Arc::clone(&load_tracker); let viewer_cache = Arc::clone(&viewer_cache); thread::spawn(move || loop { let request = { let Ok(receiver) = receiver.lock() else { break; }; match receiver.recv() { Ok(request) => request, Err(_) => break, } }; if let Err(error) = handle_request( request, &project, &cache, &load_tracker, &viewer_cache, port, ) { eprintln!("Viewer request failed: {error}"); } }); } for request in server.incoming_requests() { if request_is_priority(&request) { if let Err(error) = handle_request( request, &project, &cache, &load_tracker, &viewer_cache, port, ) { eprintln!("Viewer request failed: {error}"); } continue; } sender .send(request) .map_err(|_| "Burr viewer workers stopped unexpectedly.".to_string())?; } Ok(()) } fn request_is_priority(request: &Request) -> bool { matches!( request.url().split('?').next(), Some("/api/health" | "/api/load-status") ) } fn handle_request( request: Request, project: &Project, cache: &Mutex, load_tracker: &LoadTracker, viewer_cache: &ViewerCache, expected_host_port: u16, ) -> Result<(), String> { if !request_host_is_loopback(&request, expected_host_port) { return respond( request, 403, "text/plain; charset=utf-8", "Forbidden".to_string(), ); } if request.method() != &Method::Get { return respond( request, 405, "text/plain; charset=utf-8", "Method not allowed".to_string(), ); } let url = request.url().to_string(); let route = url.split('?').next().unwrap_or("/"); if let Some(id) = route.strip_prefix("/mesh/") { let Some(path) = viewer_cache.mesh_path(id) else { return respond_json_error(request, 400, "Invalid mesh id."); }; let Ok(file) = fs::File::open(path) else { return respond_json_error(request, 404, "Mesh is unavailable. Reopen the model."); }; let length = file.metadata().map_err(|e| e.to_string())?.len() as usize; let content_type = Header::from_bytes("Content-Type", "application/octet-stream") .map_err(|_| "Invalid mesh content type.".to_string())?; let cache_control = Header::from_bytes("Cache-Control", "private, max-age=31536000, immutable") .map_err(|_| "Invalid mesh cache header.".to_string())?; return request .respond(Response::new( StatusCode(200), vec![content_type, cache_control], file, Some(length), None, )) .map_err(|e| format!("Failed to send mesh: {e}")); } match route { "/" => respond( request, 200, "text/html; charset=utf-8", SHELL_HTML.to_string(), ), "/api/health" => respond( request, 200, "application/json; charset=utf-8", json!({ "status": "ok" }).to_string(), ), "/api/project" => respond( request, 200, "application/json; charset=utf-8", project.public_state().to_string(), ), "/api/tree" => match scan_models(project) { Ok(files) => { let root_name = project .root() .file_name() .and_then(|name| name.to_str()) .unwrap_or("models"); let files = files .iter() .map(|file| { json!({ "path": file.relative_path, "name": file.name, "format": file.format, "version": file.version, }) }) .collect::>(); let motions = project.public_state()["motions"].clone(); respond( request, 200, "application/json; charset=utf-8", json!({ "root": root_name, "files": files, "motions": motions }).to_string(), ) } Err(error) => respond_json_error(request, 500, &error), }, "/api/load-status" => { let Some(load_id) = query_value(&url, "id") else { return respond_json_error(request, 400, "No load id was provided."); }; if !valid_load_id(&load_id) { return respond_json_error(request, 400, "The load id was invalid."); } respond( request, 200, "application/json; charset=utf-8", serde_json::to_string(&load_tracker.status(&load_id)) .map_err(|error| format!("Failed to encode load status: {error}"))?, ) } "/assets/burr-logo.png" | "/favicon.ico" => { respond_bytes(request, 200, "image/png", LOGO_PNG.to_vec()) } "/api/checks" => { let Some(relative_path) = query_value(&url, "path") else { return respond_json_error(request, 400, "No model path was provided."); }; match check_model(project, &relative_path, cache) { Ok(report) => respond( request, 200, "application/json; charset=utf-8", serde_json::to_string(&report) .map_err(|error| format!("Failed to encode check result: {error}"))?, ), Err(error) => respond_json_error(request, 422, &error), } } "/viewer" => { let Some(relative_path) = query_value(&url, "path") else { return respond_html_error(request, 400, "No model path was provided."); }; let theme = ViewerTheme::from_query(query_value(&url, "theme").as_deref()); let focus = match FocusPair::from_query(query_value(&url, "focus").as_deref()) { Ok(focus) => focus, Err(error) => return respond_html_error(request, 400, &error), }; let motion_id = query_value(&url, "motion"); let load_id = query_value(&url, "load"); if load_id.as_deref().is_some_and(|id| !valid_load_id(id)) { return respond_html_error(request, 400, "The load id was invalid."); } let reporter = load_tracker.reporter(load_id.as_deref()); reporter.start(&relative_path); let rendered = { let mut cache = lock_model_cache(cache)?; render_model( project, RenderSelection { relative_path: &relative_path, theme, focus, motion_id: motion_id.as_deref(), }, &mut cache, viewer_cache, &reporter, ) }; match rendered { Ok(rendered) => match ViewerBody::new(rendered.html, load_id.as_deref()) { Ok(html) => { reporter.ready(rendered.cache); respond_viewer(request, html) } Err(error) => { reporter.failed(&error); respond_html_error(request, 422, &error) } }, Err(error) => { reporter.failed(&error); respond_html_error(request, 422, &error) } } } _ => respond( request, 404, "text/plain; charset=utf-8", "Not found".to_string(), ), } } fn render_model( project: &Project, selection: RenderSelection<'_>, cache: &mut ModelCache, viewer_cache: &ViewerCache, reporter: &LoadReporter<'_>, ) -> Result { if let Some(motion_id) = selection.motion_id { if selection.focus.is_some() { return Err( "Motion playback is unavailable while components are highlighted.".to_string(), ); } let motion = project .motion(motion_id) .cloned() .ok_or_else(|| format!("Unknown Burr motion: {motion_id}"))?; return render_motion_model( project, selection.relative_path, selection.theme, &motion, cache, viewer_cache, reporter, ); } reporter.stage( "Reading source", &format!( "Checking {} against the reusable viewer cache.", selection.relative_path ), ); let path = resolve_model_path(project, selection.relative_path)?; let fingerprint = source_fingerprint(&path)?; let viewer_key = viewer_cache_key( selection.relative_path, &path, &fingerprint, selection.theme, selection.focus.clone(), None, ); if let Some(html) = memory_viewer(cache, &viewer_key).filter(|html| viewer_cache.meshes_available(html)) { return Ok(RenderedViewer { html, cache: "memory", }); } match viewer_cache.load(&viewer_key) { Ok(Some(html)) if viewer_cache.meshes_available(&html) => { let html = Arc::new(html); remember_viewer(cache, viewer_key, html.clone()); return Ok(RenderedViewer { html, cache: "disk", }); } Ok(_) => {} Err(error) => eprintln!("burr: {error}; rebuilding the viewer"), } let html = { let cached = load_model(project, selection.relative_path, cache, Some(reporter))?; if let Some(focus) = &selection.focus { if focus .indexes .iter() .any(|&index| index >= cached.scene.instances.len()) { return Err("Viewer focus references a missing component.".to_string()); } } let scene = &cached.scene; reporter.stage( "Building viewer", "Encoding the compiled geometry for the local browser.", ); let lighting = selection.theme.lighting(); let html = crate::binary_viewer::generate_html_viewer( scene, selection.relative_path, &lighting, selection.theme.canvas_background(), viewer_cache, selection.focus.as_ref().map(|f| f.indexes.as_slice()), None, ) .map_err(|error| { format!( "Look could not build the viewer for {}: {error:#}", selection.relative_path ) })?; let html = inject_binary_draw(inject_viewer_render_modes(html)?); inject_viewer_theme(html, selection.theme, selection.focus.clone())? }; let html = Arc::new(html); persist_viewer(viewer_cache, &viewer_key, &html); remember_viewer(cache, viewer_key, html.clone()); Ok(RenderedViewer { html, cache: "generated", }) } fn render_motion_model( project: &Project, relative_path: &str, theme: ViewerTheme, motion: &Motion, cache: &mut ModelCache, viewer_cache: &ViewerCache, reporter: &LoadReporter<'_>, ) -> Result { if relative_path != motion.model { return Err(format!( "Motion '{}' does not include model {relative_path}.", motion.id )); } let initial_progress = 0.0; reporter.stage( "Reading source", "Checking the motion model against the reusable viewer cache.", ); let model_path = resolve_model_path(project, &motion.model)?; let model_fingerprint = source_fingerprint(&model_path)?; let motion_signature = motion.cache_signature(); let viewer_key = viewer_cache_key( relative_path, &model_path, &model_fingerprint, theme, None, Some(&motion_signature), ); if let Some(html) = memory_viewer(cache, &viewer_key).filter(|html| viewer_cache.meshes_available(html)) { return Ok(RenderedViewer { html, cache: "memory", }); } match viewer_cache.load(&viewer_key) { Ok(Some(html)) if viewer_cache.meshes_available(&html) => { let html = Arc::new(html); remember_viewer(cache, viewer_key, html.clone()); return Ok(RenderedViewer { html, cache: "disk", }); } Ok(_) => {} Err(error) => eprintln!("burr: {error}; rebuilding the viewer"), } let source_scene = load_model(project, &motion.model, cache, Some(reporter))? .scene .clone(); reporter.stage( "Preparing motion", "Applying the configured joints and generating the playback frames.", ); let prepared = prepare_motion( &source_scene, &motion.joints, UpAxis::Z, motion.duration_ms, initial_progress, )?; reporter.stage( "Building viewer", "Encoding the compiled motion for the local browser.", ); let lighting = theme.lighting(); let html = crate::binary_viewer::generate_html_viewer( &source_scene, relative_path, &lighting, theme.canvas_background(), viewer_cache, None, Some(&prepared), ) .map_err(|error| format!("Look could not build motion '{}': {error:#}", motion.id))?; let html = inject_binary_draw(inject_viewer_render_modes(html)?); let html = inject_viewer_motion(html, &prepared)?; let html = inject_viewer_theme(html, theme, None)?; let html = Arc::new(html); persist_viewer(viewer_cache, &viewer_key, &html); remember_viewer(cache, viewer_key, html.clone()); Ok(RenderedViewer { html, cache: "generated", }) } fn check_model( project: &Project, relative_path: &str, cache: &Mutex, ) -> Result { let path = resolve_model_path(project, relative_path)?; let format = model_format(&path); let (version, scene) = { let mut cache = lock_model_cache(cache)?; let cached = load_model(project, relative_path, &mut cache, None)?; if let Some(report) = cached.report.as_ref() { return Ok(report.clone()); } (cached.version.clone(), cached.scene.clone()) }; let report = if format == Some("STEP") { interference::analyze_scene_from_source(relative_path, &version, &scene, &path) } else { CheckReport::unsupported( relative_path, &version, "Assembly interference currently supports STEP files only.", ) }; let mut cache = lock_model_cache(cache)?; if let Some(cached) = cache.models.get_mut(&path) { if cached.version == version { cached.report = Some(report.clone()); } } Ok(report) } fn load_model<'a>( project: &Project, relative_path: &str, cache: &'a mut ModelCache, reporter: Option<&LoadReporter<'_>>, ) -> Result<&'a mut CachedModel, String> { let path = resolve_model_path(project, relative_path)?; let version = file_version(&path)?; let current = cache .models .get(&path) .is_some_and(|cached| cached.version == version); if current { if let Some(reporter) = reporter { reporter.stage( "Preparing viewer", &format!("Reusing compiled geometry for {relative_path}."), ); } return cache .models .get_mut(&path) .ok_or_else(|| "Viewer model cache became unavailable.".to_string()); } let mut timings = Timings::default(); let up_axis = if model_format(&path) == Some("GLB") { UpAxis::Y } else { UpAxis::Z }; if let Some(reporter) = reporter { reporter.stage( "Tessellating geometry", &format!("Look is compiling {relative_path} locally."), ); } #[cfg(test)] { cache.compile_count += 1; } // Keep only the active compiled model: browsing several assemblies must not // retain all their CPU meshes. In-flight checks retain their own Arc. cache.models.clear(); let mut scene = compile_scene(&path, up_axis, &mut timings) .map_err(|error| format!("Look could not render {relative_path}: {error:#}"))?; if let Some(reporter) = reporter { reporter.stage( "Preparing materials", &format!("Decoding the textures and materials for {relative_path}."), ); } prepare_source_textures(&mut scene, &mut timings) .map_err(|error| format!("Look could not prepare {relative_path}: {error:#}"))?; // Look's self-contained viewer fits directly to the scene sphere. Leave a // little breathing room for the shorter iframe viewport created by Burr's // navigation shell and for models whose diagonal approaches that sphere. scene.fit_radius *= VIEWER_FRAMING_MARGIN; cache.models.insert( path.clone(), CachedModel { version, scene: Arc::new(scene), report: None, }, ); cache .models .get_mut(&path) .ok_or_else(|| "Viewer model cache became unavailable.".to_string()) } fn viewer_cache_key( relative_path: &str, source_path: &Path, source_fingerprint: &str, theme: ViewerTheme, focus: Option, motion: Option<&str>, ) -> String { // Dependency pins and viewer code are part of the content identity even // when a development build keeps the same Burr version number. let mut implementation = blake3::Hasher::new(); for bytes in [ include_bytes!("../Cargo.lock").as_slice(), include_bytes!("binary_viewer.rs").as_slice(), include_bytes!("cache.rs").as_slice(), include_bytes!("motion.rs").as_slice(), include_bytes!("viewer.rs").as_slice(), ] { implementation.update(bytes); } let implementation = implementation.finalize().to_hex(); format!( "burr-viewer-v7-binary\nburr={}\nimplementation={}\nstep-policy={}\nsource={}\nfingerprint={}\nrelative={}\ntheme={}\nfocus={}\nmotion={}", env!("CARGO_PKG_VERSION"), implementation, look::step::meshing_policy::current_identity(), source_path.display(), source_fingerprint, relative_path, theme.name(), focus.map_or_else(|| "none".to_string(), |focus| focus.label()), motion.unwrap_or("none"), ) } fn persist_viewer(cache: &ViewerCache, key: &str, html: &str) { match cache.store(key, html) { Ok(_) => {} Err(error) => eprintln!("burr: {error}; continuing without a reusable viewer"), } } fn remember_viewer(cache: &mut ModelCache, key: String, html: Arc) { remember_viewer_with_limits( cache, key, html, MAX_MEMORY_VIEWERS, MAX_MEMORY_VIEWER_BYTES, MAX_VIEWER_HTML_BYTES, ); } fn memory_viewer(cache: &mut ModelCache, key: &str) -> Option> { let html = cache.viewers.get(key)?.clone(); cache.viewer_order.retain(|candidate| candidate != key); cache.viewer_order.push_back(key.to_string()); Some(html) } fn remember_viewer_with_limits( cache: &mut ModelCache, key: String, html: Arc, max_entries: usize, max_total_bytes: usize, max_entry_bytes: usize, ) { let html_bytes = html.len(); if max_entries == 0 || html_bytes > max_entry_bytes || html_bytes > max_total_bytes { return; } if let Some(previous) = cache.viewers.remove(&key) { cache.viewer_bytes = cache.viewer_bytes.saturating_sub(previous.len()); cache.viewer_order.retain(|candidate| candidate != &key); } while !cache.viewers.is_empty() && (cache.viewers.len() >= max_entries || cache.viewer_bytes.saturating_add(html_bytes) > max_total_bytes) { let Some(oldest) = cache.viewer_order.pop_front() else { cache.viewers.clear(); cache.viewer_bytes = 0; break; }; if let Some(evicted) = cache.viewers.remove(&oldest) { cache.viewer_bytes = cache.viewer_bytes.saturating_sub(evicted.len()); } } cache.viewer_bytes = cache.viewer_bytes.saturating_add(html_bytes); cache.viewer_order.push_back(key.clone()); cache.viewers.insert(key, html); } fn lock_model_cache(cache: &Mutex) -> Result, String> { cache .lock() .map_err(|_| "Burr model cache became unavailable.".to_string()) } fn request_host_is_loopback(request: &Request, expected_port: u16) -> bool { request .headers() .iter() .find(|header| header.field.equiv("Host")) .is_some_and(|header| host_is_loopback(header.value.as_str(), expected_port)) } fn host_is_loopback(host: &str, expected_port: u16) -> bool { host == format!("127.0.0.1:{expected_port}") || host.eq_ignore_ascii_case(&format!("localhost:{expected_port}")) || host == format!("[::1]:{expected_port}") } fn inject_viewer_theme( html: String, theme: ViewerTheme, focus: Option, ) -> Result { let marker = ""; let Some(index) = html.find(marker) else { return Err("Look viewer HTML did not contain a head element.".to_string()); }; let theme_style = format!( "", theme.name(), theme.viewer_css() ); let focus_marker = focus.map_or_else(String::new, |focus| { format!( "", focus.label() ) }); let mut themed = String::with_capacity(html.len() + theme_style.len() + focus_marker.len()); themed.push_str(&html[..index]); themed.push_str(&theme_style); themed.push_str(&focus_marker); themed.push_str(&html[index..]); Ok(themed) } /// Stream the cached page around a small request-specific load notification. struct ViewerBody { html: Arc, notification: Vec, split: usize, offset: usize, } impl ViewerBody { fn new(html: Arc, load_id: Option<&str>) -> Result { let (split, notification) = if let Some(load_id) = load_id { let index = html .find("") .ok_or("Viewer HTML did not contain a head element.")?; let script = format!( r#""# ); (index, script.into_bytes()) } else { (0, Vec::new()) }; Ok(Self { html, notification, split, offset: 0, }) } fn len(&self) -> usize { self.html.len() + self.notification.len() } } impl Read for ViewerBody { fn read(&mut self, output: &mut [u8]) -> std::io::Result { let bytes = if self.offset < self.split { &self.html.as_bytes()[self.offset..self.split] } else if self.offset < self.split + self.notification.len() { &self.notification[self.offset - self.split..] } else { &self.html.as_bytes()[self.offset - self.notification.len()..] }; let count = output.len().min(bytes.len()); output[..count].copy_from_slice(&bytes[..count]); self.offset += count; Ok(count) } } fn respond_viewer(request: Request, body: ViewerBody) -> Result<(), String> { let content_type = Header::from_bytes("Content-Type", "text/html; charset=utf-8") .map_err(|_| "Failed to create viewer content type.".to_string())?; let cache_control = Header::from_bytes("Cache-Control", "no-store") .map_err(|_| "Failed to create viewer cache header.".to_string())?; let length = body.len(); request .respond(Response::new( StatusCode(200), vec![content_type, cache_control], body, Some(length), None, )) .map_err(|error| format!("Failed to send viewer response: {error}")) } fn inject_binary_draw(html: String) -> String { html.replace( "gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_INT, 0);", r#"const drawMeshes = () => { for (const mesh of burrMeshes) { gl.bindVertexArray(mesh.vao); if (mesh.color) gl.vertexAttrib4fv(gl.getAttribLocation(program, 'aColor'), mesh.color); gl.drawElementsInstanced(gl.TRIANGLES, mesh.count, gl.UNSIGNED_INT, 0, mesh.instances); } }; if (burrManifest.highlight) { // Context cannot write depth or hide a selected occurrence in either mode. gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); gl.depthMask(false); gl.uniform1i(uFocusPassLoc, 1); drawMeshes(); // Selected geometry is opaque and depth-tested against other selected geometry. gl.disable(gl.BLEND); gl.depthMask(true); gl.uniform1i(uFocusPassLoc, 2); drawMeshes(); gl.uniform1i(uFocusPassLoc, 0); } else { drawMeshes(); } if (!window.burrFirstFrame) { window.burrFirstFrame = performance.now(); window.dispatchEvent(new Event('burr:first-frame')); }"#, ) } fn inject_viewer_render_modes(mut html: String) -> Result { let replacements = [ ( "uniform vec3 uCameraPos;\n out vec4 fragColor;", "uniform vec3 uCameraPos;\n uniform float uOpacity;\n out vec4 fragColor;", "fragment opacity uniform", ), ( "fragColor = vec4(col, 1.0);", "fragColor = vec4(col, uOpacity);", "fragment opacity output", ), ( "const uCamPosLoc = gl.getUniformLocation(program, 'uCameraPos');", "const uCamPosLoc = gl.getUniformLocation(program, 'uCameraPos');\n const uOpacityLoc = gl.getUniformLocation(program, 'uOpacity');", "opacity uniform location", ), ( "gl.enable(gl.DEPTH_TEST);\n gl.clearColor", "gl.enable(gl.DEPTH_TEST);\n gl.depthMask(true);\n gl.clearColor", "depth-buffer reset", ), ( "gl.uniform3fv(uCamPosLoc, camPos);\n\n gl.bindVertexArray(vao);", "gl.uniform3fv(uCamPosLoc, camPos);\n burrApplyMotionFrame(gl);\n\n const xRay = burrRenderMode === 'x-ray';\n gl.uniform1f(uOpacityLoc, xRay ? 0.28 : 1.0);\n if (xRay) {\n gl.enable(gl.BLEND);\n gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);\n gl.depthMask(false);\n } else {\n gl.disable(gl.BLEND);\n gl.depthMask(true);\n }\n\n gl.bindVertexArray(vao);", "render-mode state", ), ( "gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_INT, 0);", "gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_INT, 0);\n gl.depthMask(true);\n burrCaptureSnapshot(canvas);", "depth-buffer restore", ), ]; for (needle, replacement, label) in replacements { if !html.contains(needle) { return Err(format!( "Look viewer HTML did not contain the expected {label} hook." )); } html = html.replacen(needle, replacement, 1); } let marker = ""; let Some(index) = html.find(marker) else { return Err("Look viewer HTML did not contain a head element.".to_string()); }; let controls = r#""#; html.insert_str(index, controls); Ok(html) } fn inject_viewer_motion(mut html: String, motion: &PreparedMotion) -> Result { if html.contains("const burrManifest") { let header = format!("in float aBurrInstance;\n uniform mat4 uBurrInstanceTransforms[{MAX_MOTION_COMPONENTS}];"); html = html.replace("in float aBurrInstance;", &header); html = html.replace("vNormal = normalize(aNormalTransform * aNormal);", "vNormal = normalize(mat3(transpose(inverse(uBurrInstanceTransforms[int(aBurrInstance)]))) * aNormal);"); html = html.replace( "aTransform * vec4(aPosition, 1.0)", "uBurrInstanceTransforms[int(aBurrInstance)] * vec4(aPosition, 1.0)", ); let arrays = "const burrManifest ="; let frames = format!( "const burrMotionFrames = burrDecodeFrames(\"{}\");\n const burrManifest =", motion.frames_base64 ); html = html.replacen(arrays, &frames, 1); html = html.replace("const uModelLoc = gl.getUniformLocation(program, 'uModel');", "const uModelLoc = gl.getUniformLocation(program, 'uModel');\n window.uBurrInstanceTransformsLoc = gl.getUniformLocation(program, 'uBurrInstanceTransforms[0]');\n window.burrMotionFrames = burrMotionFrames;"); } else { return Err("Motion viewer did not contain the binary mesh manifest.".to_string()); } let marker = ""; let Some(index) = html.find(marker) else { return Err("Look viewer HTML did not contain a head element.".to_string()); }; let motion_script = format!( r#""#, frame_count = motion.frame_count, instance_count = motion.instance_count, duration_ms = motion.duration_ms, initial_progress = motion.initial_progress, ); html.insert_str(index, &motion_script); Ok(html) } fn scan_models(project: &Project) -> Result, String> { let mut files = Vec::new(); for model_root in project.model_roots() { collect_models(project.root(), model_root, &mut files)?; } files.sort_by(|left, right| { left.relative_path .to_ascii_lowercase() .cmp(&right.relative_path.to_ascii_lowercase()) .then_with(|| left.relative_path.cmp(&right.relative_path)) }); Ok(files) } fn collect_models(root: &Path, directory: &Path, files: &mut Vec) -> Result<(), String> { let entries = fs::read_dir(directory) .map_err(|error| format!("Failed to scan {}: {error}", directory.display()))?; let mut entries = entries .collect::, _>>() .map_err(|error| format!("Failed to scan {}: {error}", directory.display()))?; entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase()); for entry in entries { let file_type = entry .file_type() .map_err(|error| format!("Failed to inspect {}: {error}", entry.path().display()))?; if file_type.is_symlink() { continue; } let name = entry.file_name(); let name = name.to_string_lossy(); if name.starts_with('.') { continue; } if file_type.is_dir() { if SKIP_DIRECTORIES.contains(&name.as_ref()) { continue; } collect_models(root, &entry.path(), files)?; continue; } if !file_type.is_file() { continue; } let path = entry.path(); let Some(format) = model_format(&path) else { continue; }; let relative = path .strip_prefix(root) .map_err(|_| format!("Model escaped the viewer root: {}", path.display()))?; let Some(relative_path) = portable_path(relative) else { continue; }; let Some(name) = path .file_name() .and_then(|name| name.to_str()) .map(ToOwned::to_owned) else { continue; }; files.push(ModelFile { relative_path, name, format, version: file_version(&path)?, }); } Ok(()) } fn resolve_model_path(project: &Project, relative_path: &str) -> Result { let relative = Path::new(relative_path); if relative.as_os_str().is_empty() || relative.is_absolute() || relative.components().any(|component| { matches!( component, Component::ParentDir | Component::RootDir | Component::Prefix(_) ) }) { return Err("Model path must remain inside the viewer root.".to_string()); } let candidate = project.root().join(relative); let canonical = candidate .canonicalize() .map_err(|error| format!("Model does not exist: {relative_path} ({error})"))?; if !canonical.starts_with(project.root()) || !project.contains_model(&canonical) { return Err("Model path must remain inside a configured model path.".to_string()); } if !canonical.is_file() || model_format(&canonical).is_none() { return Err(format!("Unsupported model file: {relative_path}")); } Ok(canonical) } fn model_format(path: &Path) -> Option<&'static str> { match path .extension() .and_then(|extension| extension.to_str())? .to_ascii_lowercase() .as_str() { "step" | "stp" => Some("STEP"), "stl" => Some("STL"), "glb" => Some("GLB"), _ => None, } } fn portable_path(path: &Path) -> Option { path.components() .map(|component| match component { Component::Normal(part) => part.to_str().map(ToOwned::to_owned), _ => None, }) .collect::>>() .map(|components| components.join("/")) } fn file_version(path: &Path) -> Result { let metadata = fs::metadata(path) .map_err(|error| format!("Failed to inspect {}: {error}", path.display()))?; let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH); let duration = modified.duration_since(UNIX_EPOCH).unwrap_or_default(); Ok(format!( "{}-{}-{}", metadata.len(), duration.as_secs(), duration.subsec_nanos() )) } fn query_value(url: &str, wanted: &str) -> Option { let query = url.split_once('?')?.1; query.split('&').find_map(|pair| { let (key, value) = pair.split_once('=').unwrap_or((pair, "")); (key == wanted).then(|| { percent_decode_str(value) .decode_utf8() .ok() .map(|value| value.into_owned()) })? }) } fn respond(request: Request, status: u16, content_type: &str, body: String) -> Result<(), String> { respond_bytes(request, status, content_type, body.into_bytes()) } fn respond_bytes( request: Request, status: u16, content_type: &str, body: Vec, ) -> Result<(), String> { let content_type = Header::from_bytes("Content-Type", content_type) .map_err(|_| "Failed to create Content-Type header.".to_string())?; let cache_control = Header::from_bytes("Cache-Control", "no-store") .map_err(|_| "Failed to create Cache-Control header.".to_string())?; request .respond( Response::from_data(body) .with_status_code(StatusCode(status)) .with_header(content_type) .with_header(cache_control), ) .map_err(|error| format!("Failed to send viewer response: {error}")) } fn respond_json_error(request: Request, status: u16, message: &str) -> Result<(), String> { respond( request, status, "application/json; charset=utf-8", json!({ "error": message }).to_string(), ) } fn respond_html_error(request: Request, status: u16, message: &str) -> Result<(), String> { let message = escape_html(message); respond( request, status, "text/html; charset=utf-8", format!( "

Could not open model

{message}

" ), ) } fn escape_html(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } fn open_browser(url: &str) -> Result<(), String> { #[cfg(target_os = "macos")] let mut command = { let mut command = Command::new("open"); command.arg(url); command }; #[cfg(target_os = "windows")] let mut command = { let mut command = Command::new("cmd"); command.args(["/C", "start", "", url]); command }; #[cfg(all(unix, not(target_os = "macos")))] let mut command = { let mut command = Command::new("xdg-open"); command.arg(url); command }; command .spawn() .map(|_| ()) .map_err(|error| format!("Failed to open the browser at {url}: {error}")) } #[cfg(test)] mod tests { use super::*; use std::io::Write; use tempfile::tempdir; #[test] fn scan_models_filters_and_sorts_supported_files() { let temp = tempdir().unwrap(); fs::create_dir_all(temp.path().join("models/enclosure")).unwrap(); fs::create_dir_all(temp.path().join("models/__cadgen__/components")).unwrap(); fs::create_dir_all(temp.path().join("node_modules/ignored")).unwrap(); fs::write(temp.path().join("models/zeta.stl"), "solid empty\nendsolid").unwrap(); fs::write(temp.path().join("models/enclosure/alpha.STEP"), "STEP").unwrap(); fs::write(temp.path().join("models/readme.txt"), "ignore me").unwrap(); fs::write( temp.path().join("models/__cadgen__/components/render.glb"), "generated render cache", ) .unwrap(); fs::write( temp.path().join("node_modules/ignored/hidden.glb"), "ignore me", ) .unwrap(); let project = Project::discover(temp.path()).unwrap(); let models = scan_models(&project).unwrap(); assert_eq!(models.len(), 2); assert_eq!(models[0].relative_path, "models/enclosure/alpha.STEP"); assert_eq!(models[0].format, "STEP"); assert_eq!(models[1].relative_path, "models/zeta.stl"); assert_eq!(models[1].format, "STL"); } #[test] fn resolve_model_path_rejects_escape_and_unsupported_files() { let temp = tempdir().unwrap(); fs::write(temp.path().join("part.step"), "STEP").unwrap(); fs::write(temp.path().join("notes.txt"), "notes").unwrap(); let project = Project::discover(temp.path()).unwrap(); let root = project.root(); assert_eq!( resolve_model_path(&project, "part.step").unwrap(), root.join("part.step") ); assert!(resolve_model_path(&project, "../outside.step").is_err()); assert!(resolve_model_path(&project, "notes.txt").is_err()); } #[test] fn file_version_changes_when_model_changes() { let temp = tempdir().unwrap(); let path = temp.path().join("part.stl"); fs::write(&path, "solid empty\nendsolid").unwrap(); let before = file_version(&path).unwrap(); let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap(); writeln!(file, "changed").unwrap(); let after = file_version(&path).unwrap(); assert_ne!(before, after); } #[test] fn query_values_are_percent_decoded() { assert_eq!( query_value("/viewer?path=models%2Fmotor%20mount.step&v=1", "path").as_deref(), Some("models/motor mount.step") ); } #[test] fn viewer_theme_defaults_dark_and_accepts_light() { assert_eq!(ViewerTheme::from_query(None), ViewerTheme::Dark); assert_eq!(ViewerTheme::from_query(Some("unknown")), ViewerTheme::Dark); assert_eq!(ViewerTheme::from_query(Some("light")), ViewerTheme::Light); } #[test] fn viewer_theme_is_injected_into_look_html() { let html = "".to_string(); let themed = inject_viewer_theme( html, ViewerTheme::Light, Some(FocusPair { indexes: vec![2, 5], }), ) .unwrap(); assert!(themed.contains("data-burr-theme=\"light\"")); assert!(themed.contains("background-color: #c9ced0")); assert!(themed.contains("name=\"burr-highlighted-components\" content=\"2,5\"")); assert!(themed.contains(""# .to_string(); let rendered = inject_viewer_render_modes(html).unwrap(); assert!(rendered.contains("name=\"burr-render-modes\" content=\"x-ray,solid\"")); assert!(rendered.contains("let burrRenderMode = \"x-ray\"")); assert!(rendered.contains("fragColor = vec4(col, uOpacity);")); assert!(rendered.contains("xRay ? 0.28 : 1.0")); assert!(rendered.contains("burr:set-render-mode")); assert!(rendered.contains("name=\"burr-snapshot-export\" content=\"png\"")); assert!(rendered.contains("burr:export-snapshot")); assert!(rendered.contains("canvas.toBlob")); assert!(rendered.contains("burrCaptureSnapshot(canvas);")); } #[test] fn cached_pages_are_shared_while_load_notifications_stream_independently() { let original = "雪part"; let html = Arc::new(original.to_string()); let mut cache = ModelCache::default(); remember_viewer(&mut cache, "part".into(), html.clone()); let cached = memory_viewer(&mut cache, "part").unwrap(); assert!(Arc::ptr_eq(&html, &cached)); for id in ["load-1", "load-2"] { let mut body = ViewerBody::new(cached.clone(), Some(id)).unwrap(); assert!(Arc::ptr_eq(&html, &body.html)); let mut bytes = Vec::new(); let mut chunk = [0_u8; 7]; loop { let count = body.read(&mut chunk).unwrap(); if count == 0 { break; } bytes.extend_from_slice(&chunk[..count]); } assert_eq!(bytes.len(), body.len()); let streamed = String::from_utf8(bytes).unwrap(); assert!(streamed.contains(&format!("loadId: \"{id}\""))); assert!(streamed.contains("雪")); } assert_eq!(html.as_str(), original); } #[test] fn viewer_ready_notification_carries_the_specific_load_id() { let html = "".to_string(); let mut body = ViewerBody::new(Arc::new(html), Some("window-3")).unwrap(); let mut rendered = String::new(); body.read_to_string(&mut rendered).unwrap(); assert!(rendered.contains("name=\"burr-load-id\" content=\"window-3\"")); assert!(rendered.contains("type: \"burr:viewer-ready\", loadId: \"window-3\"")); } #[test] fn viewer_motion_injects_rigid_component_playback() { let path = Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/interference/separated.step"); let mut timings = Timings::default(); let from = compile_scene(&path, UpAxis::Z, &mut timings).unwrap(); let joints = [crate::project::MotionJoint::Revolute { components: vec!["moving".to_string()], origin_mm: [0.0, 0.0, 0.0], axis: [0.0, 0.0, 1.0], angle_degrees: 90.0, }]; let motion = prepare_motion(&from, &joints, UpAxis::Z, 800, 0.0).unwrap(); let temp = tempdir().unwrap(); let viewer_cache = ViewerCache::at(temp.path().join("cache")); let html = crate::binary_viewer::generate_html_viewer( &from, "motion.step", &LightingConfig::default(), "#0c0d10", &viewer_cache, None, Some(&motion), ) .unwrap(); let html = inject_viewer_render_modes(html).unwrap(); let html = inject_viewer_motion(html, &motion).unwrap(); assert!(html.contains("name=\"burr-motion\" content=\"rigid-poses\"")); assert!(html.contains("in float aBurrInstance;")); assert!(html.contains("uniform mat4 uBurrInstanceTransforms[32];")); assert!(html.contains("burrMotionFrames.subarray(start, end)")); assert!(html.contains("burr:set-motion-progress")); assert!(html.contains("burr:toggle-motion")); assert!(html.contains("burrApplyMotionFrame(gl);")); } #[test] fn motion_compiles_only_its_single_source_model() { let temp = tempdir().unwrap(); fs::create_dir_all(temp.path().join("models")).unwrap(); fs::create_dir_all(temp.path().join(".burr")).unwrap(); fs::copy( Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/interference/separated.step"), temp.path().join("models/assembly.step"), ) .unwrap(); fs::write( temp.path().join(".burr/config.toml"), r#"schema_version = "burr.project.v2" [project] models = ["models"] [[motions]] id = "fold" label = "Fold" model = "models/assembly.step" from_label = "Open" to_label = "Closed" duration_ms = 800 [[motions.joints]] type = "revolute" components = ["moving"] origin_mm = [0.0, 0.0, 0.0] axis = [0.0, 0.0, 1.0] angle_degrees = 90.0 "#, ) .unwrap(); let project = Project::discover(temp.path()).unwrap(); let motion = project.motion("fold").unwrap(); let mut model_cache = ModelCache::default(); let viewer_cache = ViewerCache::at(temp.path().join("cache")); let tracker = LoadTracker::default(); let reporter = tracker.reporter(None); let first = render_motion_model( &project, "models/assembly.step", ViewerTheme::Dark, motion, &mut model_cache, &viewer_cache, &reporter, ) .unwrap(); let second = render_motion_model( &project, "models/assembly.step", ViewerTheme::Dark, motion, &mut model_cache, &viewer_cache, &reporter, ) .unwrap(); assert_eq!(first.cache, "generated"); assert_eq!(second.cache, "memory"); assert_eq!(model_cache.compile_count, 1); assert_eq!(model_cache.models.len(), 1); } #[test] fn viewer_focus_sorts_groups_and_rejects_invalid_selections() { assert_eq!( FocusPair::from_query(Some("5,2")).unwrap(), Some(FocusPair { indexes: vec![2, 5], }) ); assert_eq!( FocusPair::from_query(Some("5,2,9,1")).unwrap(), Some(FocusPair { indexes: vec![1, 2, 5, 9] }) ); assert!(FocusPair::from_query(Some("2")).is_err()); assert!(FocusPair::from_query(Some("2,2")).is_err()); assert!(FocusPair::from_query(Some("two,5")).is_err()); } #[test] fn memory_viewer_cache_enforces_entry_and_total_byte_limits() { let mut cache = ModelCache::default(); remember_viewer_with_limits( &mut cache, "a".into(), Arc::new("1234".to_string()), 2, 8, 6, ); remember_viewer_with_limits( &mut cache, "b".into(), Arc::new("5678".to_string()), 2, 8, 6, ); assert_eq!(cache.viewer_bytes, 8); assert_eq!( memory_viewer(&mut cache, "a") .as_ref() .map(|html| html.as_str()), Some("1234") ); remember_viewer_with_limits( &mut cache, "c".into(), Arc::new("9012".to_string()), 2, 8, 6, ); assert!(cache.viewers.contains_key("a")); assert!(cache.viewers.contains_key("c")); assert!(!cache.viewers.contains_key("b")); assert_eq!(cache.viewer_bytes, 8); remember_viewer_with_limits( &mut cache, "large".into(), Arc::new("1234567".to_string()), 2, 8, 6, ); assert!(!cache.viewers.contains_key("large")); assert_eq!(cache.viewer_bytes, 8); } #[test] fn host_guard_accepts_only_loopback_names_on_the_bound_port() { assert!(host_is_loopback("127.0.0.1:43120", 43120)); assert!(host_is_loopback("localhost:43120", 43120)); assert!(host_is_loopback("LOCALHOST:43120", 43120)); assert!(host_is_loopback("[::1]:43120", 43120)); assert!(!host_is_loopback("attacker.example:43120", 43120)); assert!(!host_is_loopback("127.0.0.1:43121", 43120)); assert!(!host_is_loopback("localhost", 43120)); } }