Skip to main content

brep_render/
render.rs

1//! The wgpu render core — windowing-agnostic (the dual-target seam).
2//!
3//! [`RenderCore::render_to_view`] draws a [`RenderScene`] into ANY
4//! `wgpu::TextureView` (MSAA 4x): shaded faces with per-face selection/hover
5//! emphasis (R17), screen-constant-width edges with occluded portions dimmed
6//! rather than dropped (R17), selected-face boundary outlines, vertex point
7//! sprites, and the world-axis helper (R20). Presentation shells stay thin:
8//! - headless: [`RenderCore::render_to_png`] (render-to-texture → readback →
9//!   PNG bytes) — the artifact binary and R34 screenshot capture;
10//! - desktop: the winit shell hands the surface texture's view here;
11//! - web: the wasm canvas shell does the same.
12//!
13//! GPU buffers are retained per solid and reused while the solid's scene
14//! revision is unchanged (R10 — reused solids keep their buffers across
15//! history reruns). Determinism (R33): same scene + camera + size + styles ⇒
16//! the same command stream on the same device ⇒ identical PNG bytes.
17
18use crate::camera::Camera;
19use crate::color::{solid_color_srgb, srgb_to_linear};
20use crate::scene::{RenderScene, SolidDisplay};
21use crate::style::{Emphasis, EmphasisState, FaceColorMode, RenderSettings, Rgba};
22use std::collections::{HashMap, HashSet};
23use wgpu::util::DeviceExt;
24
25/// MSAA sample count (R7-equivalent quality).
26pub const SAMPLES: u32 = 4;
27/// Offscreen color format. Non-sRGB: the shaders encode explicitly, so the
28/// clear color's bytes land in the PNG exactly.
29pub const COLOR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
30const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
31
32/// Boundary-edge depth bias: NDC in orthographic, scaled by the remaining
33/// depth range in perspective (see vs_edge). Faces also carry rasterizer bias.
34const EDGE_NUDGE: f32 = 2e-4;
35/// A Line art step's outline widths outside the silhouette, CSS px.
36const LINE_ART_PLACED_OUTLINE_PX: f32 = 1.5;
37const LINE_ART_ADDING_OUTLINE_PX: f32 = 2.5;
38/// How far back (of the fitted depth range) an outline is pushed beyond what
39/// its faces' slopes call for (`vs_hull`).
40const LINE_ART_OUTLINE_DEPTH: f32 = 2e-3;
41
42#[repr(C)]
43#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
44struct Globals {
45    view_proj: [[f32; 4]; 4],
46    viewport: [f32; 4],
47    forward: [f32; 4],
48}
49
50/// A solid's pose on the GPU: the model matrix its buffers are drawn
51/// through, and the inverse transpose of its linear part for the normals.
52#[repr(C)]
53#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
54struct ModelParams {
55    model: [f32; 16],
56    normal: [f32; 16],
57}
58
59const IDENTITY4: [f32; 16] = [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];
60
61impl ModelParams {
62    fn of(pose: Option<&[f32; 16]>) -> Self {
63        let Some(m) = pose else { return Self { model: IDENTITY4, normal: IDENTITY4 } };
64        // The normals' matrix: the inverse transpose of the 3×3 linear part
65        // (equal to it for a rotation; it keeps them normal under a scale).
66        let a = |row: usize, col: usize| m[col * 4 + row] as f64;
67        let cof = |r0: usize, r1: usize, c0: usize, c1: usize| a(r0, c0) * a(r1, c1) - a(r0, c1) * a(r1, c0);
68        let det = a(0, 0) * cof(1, 2, 1, 2) - a(0, 1) * cof(1, 2, 0, 2) + a(0, 2) * cof(1, 2, 0, 1);
69        let mut normal = IDENTITY4;
70        if det.abs() > 1e-30 {
71            // (A⁻¹)ᵀ = cofactor(A) / det.
72            let c = [
73                [cof(1, 2, 1, 2), -cof(1, 2, 0, 2), cof(1, 2, 0, 1)],
74                [-cof(0, 2, 1, 2), cof(0, 2, 0, 2), -cof(0, 2, 0, 1)],
75                [cof(0, 1, 1, 2), -cof(0, 1, 0, 2), cof(0, 1, 0, 1)],
76            ];
77            for row in 0..3 {
78                for col in 0..3 {
79                    normal[col * 4 + row] = (c[row][col] / det) as f32;
80                }
81            }
82        }
83        Self { model: *m, normal }
84    }
85}
86
87/// Every solid pose of one GPU scene in ONE uniform buffer, a [`MODEL_STRIDE`]
88/// slot each, bound at the solid's offset (group 2, dynamic offset). A frame
89/// that moves 500 solids writes the changed range once: a write per solid was
90/// most of the frame (~0.25-0.8 ms a write under a software adapter).
91struct ModelArena {
92    buf: wgpu::Buffer,
93    bind: wgpu::BindGroup,
94    capacity: u32,
95    data: Vec<u8>,
96    free: Vec<u32>,
97    next: u32,
98    /// The byte range written since the last flush.
99    dirty: Option<(usize, usize)>,
100    /// The buffer is new (grown): the whole of it is written.
101    fresh: bool,
102}
103
104/// One pose slot: the uniform offset alignment every adapter honours.
105const MODEL_STRIDE: u64 = 256;
106
107fn model_binding(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, buf: &wgpu::Buffer, label: &str) -> wgpu::BindGroup {
108    device.create_bind_group(&wgpu::BindGroupDescriptor {
109        label: Some(label),
110        layout,
111        entries: &[wgpu::BindGroupEntry {
112            binding: 0,
113            resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
114                buffer: buf,
115                offset: 0,
116                size: std::num::NonZeroU64::new(std::mem::size_of::<ModelParams>() as u64),
117            }),
118        }],
119    })
120}
121
122fn model_buffer(device: &wgpu::Device, slots: u32, label: &str) -> wgpu::Buffer {
123    device.create_buffer(&wgpu::BufferDescriptor {
124        label: Some(label),
125        size: slots as u64 * MODEL_STRIDE,
126        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
127        mapped_at_creation: false,
128    })
129}
130
131impl ModelArena {
132    fn new(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, capacity: u32) -> Self {
133        let capacity = capacity.max(16);
134        let buf = model_buffer(device, capacity, "solid poses");
135        let bind = model_binding(device, layout, &buf, "solid poses");
136        Self { buf, bind, capacity, data: vec![0; capacity as usize * MODEL_STRIDE as usize], free: Vec::new(), next: 0, dirty: None, fresh: true }
137    }
138
139    fn alloc(&mut self) -> u32 {
140        self.free.pop().unwrap_or_else(|| {
141            self.next += 1;
142            self.next - 1
143        })
144    }
145
146    fn release(&mut self, slot: u32) {
147        self.free.push(slot);
148    }
149
150    fn set(&mut self, slot: u32, pose: Option<&[f32; 16]>) {
151        let need = (slot as usize + 1) * MODEL_STRIDE as usize;
152        if self.data.len() < need {
153            self.data.resize(need.next_power_of_two(), 0);
154        }
155        let at = slot as usize * MODEL_STRIDE as usize;
156        let params = ModelParams::of(pose);
157        let bytes = bytemuck::bytes_of(&params);
158        self.data[at..at + bytes.len()].copy_from_slice(bytes);
159        let (lo, hi) = self.dirty.unwrap_or((at, at + bytes.len()));
160        self.dirty = Some((lo.min(at), hi.max(at + bytes.len())));
161    }
162
163    fn offset(slot: u32) -> u32 {
164        (slot as u64 * MODEL_STRIDE) as u32
165    }
166
167    /// Put what changed on the GPU: one write (or a new, larger buffer).
168    fn flush(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, layout: &wgpu::BindGroupLayout) {
169        let slots = (self.data.len() / MODEL_STRIDE as usize) as u32;
170        if slots > self.capacity {
171            self.capacity = slots;
172            self.buf = model_buffer(device, slots, "solid poses");
173            self.bind = model_binding(device, layout, &self.buf, "solid poses");
174            self.fresh = true;
175        }
176        if std::mem::take(&mut self.fresh) {
177            queue.write_buffer(&self.buf, 0, &self.data[..self.capacity as usize * MODEL_STRIDE as usize]);
178        } else if let Some((lo, hi)) = self.dirty {
179            let hi = hi.next_multiple_of(4);
180            queue.write_buffer(&self.buf, lo as u64, &self.data[lo..hi]);
181        }
182        self.dirty = None;
183    }
184}
185
186/// A single identity pose for what the solid pipelines draw that is not a
187/// solid's own buffers (bound at offset 0).
188struct IdentityModel {
189    _buf: wgpu::Buffer,
190    bind: wgpu::BindGroup,
191}
192
193impl IdentityModel {
194    fn new(device: &wgpu::Device, queue: &wgpu::Queue, layout: &wgpu::BindGroupLayout) -> Self {
195        let buf = model_buffer(device, 1, "identity pose");
196        queue.write_buffer(&buf, 0, bytemuck::bytes_of(&ModelParams::of(None)));
197        let bind = model_binding(device, layout, &buf, "identity pose");
198        Self { _buf: buf, bind }
199    }
200}
201
202#[repr(C)]
203#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
204struct StyleParams {
205    color: [f32; 4],
206    params: [f32; 4],
207}
208
209#[repr(C)]
210#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
211struct MeshVertex {
212    position: [f32; 3],
213    normal: [f32; 3],
214}
215
216#[repr(C)]
217#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
218struct EdgeInstance {
219    p0: [f32; 3],
220    p1: [f32; 3],
221}
222
223#[repr(C)]
224#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
225struct PointInstance {
226    center: [f32; 3],
227}
228
229/// One overlay-widget line segment (per-instance colored, screen-constant width).
230#[repr(C)]
231#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
232struct OverlayLineInstance {
233    p0: [f32; 3],
234    p1: [f32; 3],
235    color: [f32; 4],
236}
237
238/// One overlay-widget triangle vertex (per-vertex colored, shaded).
239#[repr(C)]
240#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
241struct OverlayTriVertex {
242    position: [f32; 3],
243    normal: [f32; 3],
244    color: [f32; 4],
245}
246
247/// One uniform style buffer + its bind group (a material variant).
248struct StyleBuf {
249    buf: wgpu::Buffer,
250    bind: wgpu::BindGroup,
251}
252
253impl StyleBuf {
254    fn new(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, label: &str) -> Self {
255        let buf = device.create_buffer(&wgpu::BufferDescriptor {
256            label: Some(label),
257            size: std::mem::size_of::<StyleParams>() as u64,
258            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
259            mapped_at_creation: false,
260        });
261        let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
262            label: Some(label),
263            layout,
264            entries: &[wgpu::BindGroupEntry {
265                binding: 0,
266                resource: buf.as_entire_binding(),
267            }],
268        });
269        Self { buf, bind }
270    }
271
272    fn write(&self, queue: &wgpu::Queue, color: Rgba, params: [f32; 4]) {
273        queue.write_buffer(&self.buf, 0, bytemuck::bytes_of(&StyleParams { color, params }));
274    }
275}
276
277/// The global material variants (updated from [`RenderSettings`] each frame).
278struct GlobalStyles {
279    face_selected: StyleBuf,
280    face_hovered: StyleBuf,
281    edge_base: StyleBuf,
282    edge_selected: StyleBuf,
283    edge_hovered: StyleBuf,
284    edge_hidden: StyleBuf,
285    /// A display-only (AUX) edge at rest — a board's copper outline.
286    edge_aux: StyleBuf,
287    boundary: StyleBuf,
288    point_base: StyleBuf,
289    point_selected: StyleBuf,
290    point_hovered: StyleBuf,
291    axis_x: StyleBuf,
292    axis_y: StyleBuf,
293    axis_z: StyleBuf,
294    /// Overlay-widget line width (CSS px in `params.x`); color is per-instance.
295    overlay_line: StyleBuf,
296    /// A Line art instructions step ([`crate::scene::RenderScene::line_art`]):
297    /// the unlit fills, the visible edges and the outlines of the parts
298    /// earlier steps placed and of the parts the step adds.
299    line_art_placed_face: StyleBuf,
300    line_art_adding_face: StyleBuf,
301    line_art_placed_edge: StyleBuf,
302    line_art_adding_edge: StyleBuf,
303    line_art_placed_outline: StyleBuf,
304    line_art_adding_outline: StyleBuf,
305}
306
307struct FaceRange {
308    first_index: u32,
309    index_count: u32,
310    /// This face's slice of the WIREFRAME index buffer. Not derivable from
311    /// `first_index`: the wire buffer holds one segment per DISTINCT mesh edge
312    /// of the face, so its length depends on how the face's triangles share
313    /// edges, not on how many there are.
314    wire_first: u32,
315    wire_count: u32,
316    /// Index into [`GpuSolid::face_styles`] when this face carries its OWN
317    /// colour (a `color` metadata attribute on the face name); `None` = shade
318    /// with the solid's `base_style`.
319    style: Option<u32>,
320}
321
322/// The hasher behind the wireframe's per-face edge de-duplication.
323///
324/// The keys are pairs of mesh vertex INDICES — dense, small, and entirely under
325/// our control, so there is no adversary for SipHash to defend against, and its
326/// per-key cost is the whole cost of the de-duplication (three probes per
327/// triangle, at every solid upload). This is the multiply-xor mix `rustc-hash`
328/// uses, written out rather than depended on: `rustc-hash` only rides into this
329/// build through wgpu's internals, and a published crate should not take a
330/// direct dependency for fifteen lines.
331#[derive(Default)]
332struct EdgeHasher(u64);
333
334impl std::hash::Hasher for EdgeHasher {
335    fn write(&mut self, bytes: &[u8]) {
336        for &byte in bytes {
337            self.write_u32(u32::from(byte));
338        }
339    }
340
341    fn write_u32(&mut self, value: u32) {
342        // 2^64 / golden ratio: the odd multiplier spreads low-index bits into
343        // the high ones, which is where `finish` folds them back down from.
344        self.0 = (self.0 ^ u64::from(value)).wrapping_mul(0x9E37_79B9_7F4A_7C15);
345    }
346
347    fn finish(&self) -> u64 {
348        self.0 ^ (self.0 >> 32)
349    }
350}
351
352type BuildEdgeHasher = std::hash::BuildHasherDefault<EdgeHasher>;
353
354struct EdgeRange {
355    first_instance: u32,
356    instance_count: u32,
357}
358
359struct GpuSolid {
360    revision: u64,
361    /// The shape revision these buffers were built from (`shape_revision`, or
362    /// the revision itself for an unposed display): a display of the same
363    /// shape is re-posed by rewriting `vertex_buf`, `edge_buf` and `point_buf`.
364    shape: u64,
365    vertex_count: u32,
366    vertex_buf: wgpu::Buffer,
367    index_buf: wgpu::Buffer,
368    /// Line-list indices for the wireframe view (each triangle → its 3 edges).
369    wire_index_buf: wgpu::Buffer,
370    wire_index_count: u32,
371    faces: Vec<FaceRange>,
372    edge_buf: Option<wgpu::Buffer>,
373    edges: Vec<EdgeRange>,
374    edge_instances: u32,
375    /// The instances drawn by the OCCLUDED-edge pass: every edge's up to the
376    /// first AUX one. A display-only outline (a board's copper) is not dimmed
377    /// through what hides it — the bottom copper would ghost through the board
378    /// — and aux edges trail the real ones, so it is a prefix.
379    occludable_edge_instances: u32,
380    point_buf: Option<wgpu::Buffer>,
381    point_count: u32,
382    /// Per-solid base face style (uniform or hashed color; metadata override).
383    base_style: StyleBuf,
384    /// De-duplicated palette of PER-FACE styles — one entry per distinct face
385    /// colour on this solid, referenced by [`FaceRange::style`]. Empty for the
386    /// overwhelmingly common solid whose faces are all one colour, which is what
387    /// keeps the whole-mesh fast path in `frame.rs` alive.
388    face_styles: Vec<StyleBuf>,
389    /// Selected/hovered face boundary outline segments (cache keyed by the
390    /// emphasis generation).
391    boundary: Option<BoundaryBuf>,
392    /// This solid's slot in its scene's [`ModelArena`] (the pose its buffers
393    /// are drawn through; identity unless posed).
394    model_slot: u32,
395    /// The buffers hold the geometry of `shape` itself, as uploaded: a pose
396    /// of that shape is then a matrix write. False once a re-pose rewrote
397    /// them (a drag), until the next upload.
398    holds_shape: bool,
399}
400
401struct BoundaryBuf {
402    emphasis_generation: u64,
403    revision: u64,
404    buf: Option<wgpu::Buffer>,
405    count: u32,
406}
407
408/// The Work part context tint: assembly components around the part being
409/// edited are drawn in this colour, alpha-blended without depth writes and
410/// without pick handles.
411pub const WORK_PART_CONTEXT_RGBA: [f32; 4] = [0.45, 0.55, 0.65, 0.22];
412
413/// A scene uploaded to GPU buffers, retained across frames; sync with
414/// [`RenderCore::sync_scene`].
415#[derive(Default)]
416pub struct GpuScene {
417    solids: HashMap<String, GpuSolid>,
418    /// Draw order (scene insertion order at last sync).
419    order: Vec<String>,
420    settings_generation: u64,
421    axis_buf: Option<wgpu::Buffer>,
422    /// Count of solid (re)uploads (R10 observability: a reused solid must NOT
423    /// bump this across a sync).
424    uploads: u64,
425    /// Count of in-place re-poses (positions rewritten, nothing rebuilt).
426    reposes: u64,
427    /// Count of poses drawn through a matrix (a 128-byte write, no vertex).
428    pose_writes: u64,
429    /// Every solid's pose, one buffer (made on the first sync).
430    models: Option<ModelArena>,
431}
432
433impl GpuScene {
434    /// Total solid buffer uploads since creation (test/telemetry hook for the
435    /// reused-buffer fast path).
436    pub fn upload_count(&self) -> u64 {
437        self.uploads
438    }
439
440    /// Total in-place re-poses since creation (a rigidly moved solid whose
441    /// buffers were rewritten rather than rebuilt).
442    pub fn repose_count(&self) -> u64 {
443        self.reposes
444    }
445
446    /// Total poses taken by a matrix write alone (the buffers kept the
447    /// shape's geometry and nothing of it was rewritten).
448    pub fn pose_write_count(&self) -> u64 {
449        self.pose_writes
450    }
451}
452
453struct CachedTargets {
454    width: u32,
455    height: u32,
456    msaa_view: wgpu::TextureView,
457    depth_view: wgpu::TextureView,
458}
459
460/// Everything a frame needs beyond the scene buffers.
461pub struct FrameParams<'a> {
462    pub camera: &'a Camera,
463    /// Physical pixel size of the target.
464    pub width: u32,
465    pub height: u32,
466    /// Device pixel ratio (CSS px → physical px) for line/point widths.
467    pub dpr: f32,
468    pub settings: &'a RenderSettings,
469    pub emphasis: &'a Emphasis,
470    /// World units per CSS pixel (for the screen-sized axis helper).
471    pub world_per_pixel: f64,
472    /// In-scene overlay widgets: transform gizmo, ViewCube, datum /
473    /// dimension / curve visuals. `None` on the artifact/headless path.
474    pub overlay: Option<&'a crate::widgets::WidgetOverlay>,
475}
476
477/// Create a headless device: adapter picked by `BREP_RENDER_ADAPTER`
478/// (case-insensitive substring of the adapter name, e.g. "llvmpipe") or wgpu's
479/// high-performance default. Native only — the browser shell gets its device
480/// from the canvas context.
481#[cfg(not(target_arch = "wasm32"))]
482pub fn create_headless_device() -> Result<(wgpu::Device, wgpu::Queue, String), String> {
483    let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
484    let want = std::env::var("BREP_RENDER_ADAPTER").ok();
485    let adapter = if let Some(want) = &want {
486        let want_lower = want.to_lowercase();
487        pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all()))
488            .into_iter()
489            .find(|adapter| adapter.get_info().name.to_lowercase().contains(&want_lower))
490            .ok_or_else(|| format!("no adapter matching BREP_RENDER_ADAPTER={want}"))?
491    } else {
492        pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
493            power_preference: wgpu::PowerPreference::HighPerformance,
494            compatible_surface: None,
495            force_fallback_adapter: false,
496        }))
497        .map_err(|error| format!("no wgpu adapter available: {error}"))?
498    };
499    let info = adapter.get_info();
500    let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
501        label: Some("brep-render"),
502        ..Default::default()
503    }))
504    .map_err(|error| format!("wgpu device request failed: {error}"))?;
505    Ok((device, queue, format!("{} ({:?})", info.name, info.backend)))
506}
507
508/// The window-agnostic renderer: pipelines + layouts for one color format.
509pub struct RenderCore {
510    pub device: wgpu::Device,
511    pub queue: wgpu::Queue,
512    format: wgpu::TextureFormat,
513    globals_buf: wgpu::Buffer,
514    globals_bind: wgpu::BindGroup,
515    style_layout: wgpu::BindGroupLayout,
516    /// Group 2 of the solid pipelines: a solid's pose in its scene's
517    /// [`ModelArena`], at a dynamic offset.
518    model_layout: wgpu::BindGroupLayout,
519    /// Bound for whatever the solid pipelines draw that is not a solid's own
520    /// buffers (face boundaries, the world axes).
521    identity_model: IdentityModel,
522    mesh_pipeline: wgpu::RenderPipeline,
523    /// The Line art outline pass (`vs_hull`/`fs_hull`).
524    hull_pipeline: wgpu::RenderPipeline,
525    context_pipeline: wgpu::RenderPipeline,
526    context_style: StyleBuf,
527    /// A selected / hovered context face: the selection colours, still
528    /// translucent (the context pass blends without depth writes).
529    context_selected: StyleBuf,
530    context_hovered: StyleBuf,
531    wire_pipeline: wgpu::RenderPipeline,
532    edge_visible_pipeline: wgpu::RenderPipeline,
533    edge_hidden_pipeline: wgpu::RenderPipeline,
534    point_pipeline: wgpu::RenderPipeline,
535    /// Overlay-widget passes: shaded per-vertex tris + screen-constant
536    /// per-instance lines, drawn over the solids in a depth-cleared pass.
537    overlay_tri_pipeline: wgpu::RenderPipeline,
538    /// Same as `overlay_tri_pipeline` but WITHOUT depth-write — for the datum/
539    /// construction PLANES, which are translucent UI aids that must NOT occlude
540    /// the gizmos/dimensions drawn after them (a plane no longer hides an offset
541    /// arrow behind it). The gizmo tris still use the depth-writing pipeline so
542    /// they self-occlude correctly.
543    overlay_tri_nodepth_pipeline: wgpu::RenderPipeline,
544    overlay_line_pipeline: wgpu::RenderPipeline,
545    /// Second globals buffer for the ViewCube pass (its own mini-camera).
546    vc_globals_buf: wgpu::Buffer,
547    vc_globals_bind: wgpu::BindGroup,
548    styles: GlobalStyles,
549    targets: Option<CachedTargets>,
550}
551
552/// 8-bit RGB PNG encode (no text/time chunks — byte-deterministic).
553#[cfg(not(target_arch = "wasm32"))]
554fn encode_png(rgb: &[u8], width: u32, height: u32) -> Result<Vec<u8>, String> {
555    brep_gizmos::raster::encode_png(rgb, width, height, png::ColorType::Rgb)
556}
557
558mod pipelines;
559mod scene_sync;
560mod frame;