BREP_render 0.15.0

BREP Rust rendering engine: kernel-fed scene store + wgpu renderer (headless artifact, desktop window, and wasm canvas shells).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
//! The wgpu render core — windowing-agnostic (the dual-target seam).
//!
//! [`RenderCore::render_to_view`] draws a [`RenderScene`] into ANY
//! `wgpu::TextureView` (MSAA 4x): shaded faces with per-face selection/hover
//! emphasis (R17), screen-constant-width edges with occluded portions dimmed
//! rather than dropped (R17), selected-face boundary outlines, vertex point
//! sprites, and the world-axis helper (R20). Presentation shells stay thin:
//! - headless: [`RenderCore::render_to_png`] (render-to-texture → readback →
//!   PNG bytes) — the artifact binary and R34 screenshot capture;
//! - desktop: the winit shell hands the surface texture's view here;
//! - web: the wasm canvas shell does the same.
//!
//! GPU buffers are retained per solid and reused while the solid's scene
//! revision is unchanged (R10 — reused solids keep their buffers across
//! history reruns). Determinism (R33): same scene + camera + size + styles ⇒
//! the same command stream on the same device ⇒ identical PNG bytes.

use crate::camera::Camera;
use crate::color::{solid_color_srgb, srgb_to_linear};
use crate::scene::{RenderScene, SolidDisplay};
use crate::style::{Emphasis, EmphasisState, FaceColorMode, RenderSettings, Rgba};
use std::collections::{HashMap, HashSet};
use wgpu::util::DeviceExt;

/// MSAA sample count (R7-equivalent quality).
pub const SAMPLES: u32 = 4;
/// Offscreen color format. Non-sRGB: the shaders encode explicitly, so the
/// clear color's bytes land in the PNG exactly.
pub const COLOR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;

/// Boundary-edge depth bias: NDC in orthographic, scaled by the remaining
/// depth range in perspective (see vs_edge). Faces also carry rasterizer bias.
const EDGE_NUDGE: f32 = 2e-4;
/// A Line art step's outline widths outside the silhouette, CSS px.
const LINE_ART_PLACED_OUTLINE_PX: f32 = 1.5;
const LINE_ART_ADDING_OUTLINE_PX: f32 = 2.5;
/// How far back (of the fitted depth range) an outline is pushed beyond what
/// its faces' slopes call for (`vs_hull`).
const LINE_ART_OUTLINE_DEPTH: f32 = 2e-3;

#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Globals {
    view_proj: [[f32; 4]; 4],
    viewport: [f32; 4],
    forward: [f32; 4],
}

/// A solid's pose on the GPU: the model matrix its buffers are drawn
/// through, and the inverse transpose of its linear part for the normals.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct ModelParams {
    model: [f32; 16],
    normal: [f32; 16],
}

const 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];

impl ModelParams {
    fn of(pose: Option<&[f32; 16]>) -> Self {
        let Some(m) = pose else { return Self { model: IDENTITY4, normal: IDENTITY4 } };
        // The normals' matrix: the inverse transpose of the 3×3 linear part
        // (equal to it for a rotation; it keeps them normal under a scale).
        let a = |row: usize, col: usize| m[col * 4 + row] as f64;
        let cof = |r0: usize, r1: usize, c0: usize, c1: usize| a(r0, c0) * a(r1, c1) - a(r0, c1) * a(r1, c0);
        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);
        let mut normal = IDENTITY4;
        if det.abs() > 1e-30 {
            // (A⁻¹)ᵀ = cofactor(A) / det.
            let c = [
                [cof(1, 2, 1, 2), -cof(1, 2, 0, 2), cof(1, 2, 0, 1)],
                [-cof(0, 2, 1, 2), cof(0, 2, 0, 2), -cof(0, 2, 0, 1)],
                [cof(0, 1, 1, 2), -cof(0, 1, 0, 2), cof(0, 1, 0, 1)],
            ];
            for row in 0..3 {
                for col in 0..3 {
                    normal[col * 4 + row] = (c[row][col] / det) as f32;
                }
            }
        }
        Self { model: *m, normal }
    }
}

/// Every solid pose of one GPU scene in ONE uniform buffer, a [`MODEL_STRIDE`]
/// slot each, bound at the solid's offset (group 2, dynamic offset). A frame
/// that moves 500 solids writes the changed range once: a write per solid was
/// most of the frame (~0.25-0.8 ms a write under a software adapter).
struct ModelArena {
    buf: wgpu::Buffer,
    bind: wgpu::BindGroup,
    capacity: u32,
    data: Vec<u8>,
    free: Vec<u32>,
    next: u32,
    /// The byte range written since the last flush.
    dirty: Option<(usize, usize)>,
    /// The buffer is new (grown): the whole of it is written.
    fresh: bool,
}

/// One pose slot: the uniform offset alignment every adapter honours.
const MODEL_STRIDE: u64 = 256;

fn model_binding(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, buf: &wgpu::Buffer, label: &str) -> wgpu::BindGroup {
    device.create_bind_group(&wgpu::BindGroupDescriptor {
        label: Some(label),
        layout,
        entries: &[wgpu::BindGroupEntry {
            binding: 0,
            resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
                buffer: buf,
                offset: 0,
                size: std::num::NonZeroU64::new(std::mem::size_of::<ModelParams>() as u64),
            }),
        }],
    })
}

fn model_buffer(device: &wgpu::Device, slots: u32, label: &str) -> wgpu::Buffer {
    device.create_buffer(&wgpu::BufferDescriptor {
        label: Some(label),
        size: slots as u64 * MODEL_STRIDE,
        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
        mapped_at_creation: false,
    })
}

impl ModelArena {
    fn new(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, capacity: u32) -> Self {
        let capacity = capacity.max(16);
        let buf = model_buffer(device, capacity, "solid poses");
        let bind = model_binding(device, layout, &buf, "solid poses");
        Self { buf, bind, capacity, data: vec![0; capacity as usize * MODEL_STRIDE as usize], free: Vec::new(), next: 0, dirty: None, fresh: true }
    }

    fn alloc(&mut self) -> u32 {
        self.free.pop().unwrap_or_else(|| {
            self.next += 1;
            self.next - 1
        })
    }

    fn release(&mut self, slot: u32) {
        self.free.push(slot);
    }

    fn set(&mut self, slot: u32, pose: Option<&[f32; 16]>) {
        let need = (slot as usize + 1) * MODEL_STRIDE as usize;
        if self.data.len() < need {
            self.data.resize(need.next_power_of_two(), 0);
        }
        let at = slot as usize * MODEL_STRIDE as usize;
        let params = ModelParams::of(pose);
        let bytes = bytemuck::bytes_of(&params);
        self.data[at..at + bytes.len()].copy_from_slice(bytes);
        let (lo, hi) = self.dirty.unwrap_or((at, at + bytes.len()));
        self.dirty = Some((lo.min(at), hi.max(at + bytes.len())));
    }

    fn offset(slot: u32) -> u32 {
        (slot as u64 * MODEL_STRIDE) as u32
    }

    /// Put what changed on the GPU: one write (or a new, larger buffer).
    fn flush(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, layout: &wgpu::BindGroupLayout) {
        let slots = (self.data.len() / MODEL_STRIDE as usize) as u32;
        if slots > self.capacity {
            self.capacity = slots;
            self.buf = model_buffer(device, slots, "solid poses");
            self.bind = model_binding(device, layout, &self.buf, "solid poses");
            self.fresh = true;
        }
        if std::mem::take(&mut self.fresh) {
            queue.write_buffer(&self.buf, 0, &self.data[..self.capacity as usize * MODEL_STRIDE as usize]);
        } else if let Some((lo, hi)) = self.dirty {
            let hi = hi.next_multiple_of(4);
            queue.write_buffer(&self.buf, lo as u64, &self.data[lo..hi]);
        }
        self.dirty = None;
    }
}

/// A single identity pose for what the solid pipelines draw that is not a
/// solid's own buffers (bound at offset 0).
struct IdentityModel {
    _buf: wgpu::Buffer,
    bind: wgpu::BindGroup,
}

impl IdentityModel {
    fn new(device: &wgpu::Device, queue: &wgpu::Queue, layout: &wgpu::BindGroupLayout) -> Self {
        let buf = model_buffer(device, 1, "identity pose");
        queue.write_buffer(&buf, 0, bytemuck::bytes_of(&ModelParams::of(None)));
        let bind = model_binding(device, layout, &buf, "identity pose");
        Self { _buf: buf, bind }
    }
}

#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct StyleParams {
    color: [f32; 4],
    params: [f32; 4],
}

#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct MeshVertex {
    position: [f32; 3],
    normal: [f32; 3],
}

#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct EdgeInstance {
    p0: [f32; 3],
    p1: [f32; 3],
}

#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct PointInstance {
    center: [f32; 3],
}

/// One overlay-widget line segment (per-instance colored, screen-constant width).
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct OverlayLineInstance {
    p0: [f32; 3],
    p1: [f32; 3],
    color: [f32; 4],
}

/// One overlay-widget triangle vertex (per-vertex colored, shaded).
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct OverlayTriVertex {
    position: [f32; 3],
    normal: [f32; 3],
    color: [f32; 4],
}

/// One uniform style buffer + its bind group (a material variant).
struct StyleBuf {
    buf: wgpu::Buffer,
    bind: wgpu::BindGroup,
}

impl StyleBuf {
    fn new(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, label: &str) -> Self {
        let buf = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some(label),
            size: std::mem::size_of::<StyleParams>() as u64,
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });
        let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some(label),
            layout,
            entries: &[wgpu::BindGroupEntry {
                binding: 0,
                resource: buf.as_entire_binding(),
            }],
        });
        Self { buf, bind }
    }

    fn write(&self, queue: &wgpu::Queue, color: Rgba, params: [f32; 4]) {
        queue.write_buffer(&self.buf, 0, bytemuck::bytes_of(&StyleParams { color, params }));
    }
}

/// The global material variants (updated from [`RenderSettings`] each frame).
struct GlobalStyles {
    face_selected: StyleBuf,
    face_hovered: StyleBuf,
    edge_base: StyleBuf,
    edge_selected: StyleBuf,
    edge_hovered: StyleBuf,
    edge_hidden: StyleBuf,
    /// A display-only (AUX) edge at rest — a board's copper outline.
    edge_aux: StyleBuf,
    boundary: StyleBuf,
    point_base: StyleBuf,
    point_selected: StyleBuf,
    point_hovered: StyleBuf,
    axis_x: StyleBuf,
    axis_y: StyleBuf,
    axis_z: StyleBuf,
    /// Overlay-widget line width (CSS px in `params.x`); color is per-instance.
    overlay_line: StyleBuf,
    /// A Line art instructions step ([`crate::scene::RenderScene::line_art`]):
    /// the unlit fills, the visible edges and the outlines of the parts
    /// earlier steps placed and of the parts the step adds.
    line_art_placed_face: StyleBuf,
    line_art_adding_face: StyleBuf,
    line_art_placed_edge: StyleBuf,
    line_art_adding_edge: StyleBuf,
    line_art_placed_outline: StyleBuf,
    line_art_adding_outline: StyleBuf,
}

struct FaceRange {
    first_index: u32,
    index_count: u32,
    /// This face's slice of the WIREFRAME index buffer. Not derivable from
    /// `first_index`: the wire buffer holds one segment per DISTINCT mesh edge
    /// of the face, so its length depends on how the face's triangles share
    /// edges, not on how many there are.
    wire_first: u32,
    wire_count: u32,
    /// Index into [`GpuSolid::face_styles`] when this face carries its OWN
    /// colour (a `color` metadata attribute on the face name); `None` = shade
    /// with the solid's `base_style`.
    style: Option<u32>,
}

/// The hasher behind the wireframe's per-face edge de-duplication.
///
/// The keys are pairs of mesh vertex INDICES — dense, small, and entirely under
/// our control, so there is no adversary for SipHash to defend against, and its
/// per-key cost is the whole cost of the de-duplication (three probes per
/// triangle, at every solid upload). This is the multiply-xor mix `rustc-hash`
/// uses, written out rather than depended on: `rustc-hash` only rides into this
/// build through wgpu's internals, and a published crate should not take a
/// direct dependency for fifteen lines.
#[derive(Default)]
struct EdgeHasher(u64);

impl std::hash::Hasher for EdgeHasher {
    fn write(&mut self, bytes: &[u8]) {
        for &byte in bytes {
            self.write_u32(u32::from(byte));
        }
    }

    fn write_u32(&mut self, value: u32) {
        // 2^64 / golden ratio: the odd multiplier spreads low-index bits into
        // the high ones, which is where `finish` folds them back down from.
        self.0 = (self.0 ^ u64::from(value)).wrapping_mul(0x9E37_79B9_7F4A_7C15);
    }

    fn finish(&self) -> u64 {
        self.0 ^ (self.0 >> 32)
    }
}

type BuildEdgeHasher = std::hash::BuildHasherDefault<EdgeHasher>;

struct EdgeRange {
    first_instance: u32,
    instance_count: u32,
}

struct GpuSolid {
    revision: u64,
    /// The shape revision these buffers were built from (`shape_revision`, or
    /// the revision itself for an unposed display): a display of the same
    /// shape is re-posed by rewriting `vertex_buf`, `edge_buf` and `point_buf`.
    shape: u64,
    vertex_count: u32,
    vertex_buf: wgpu::Buffer,
    index_buf: wgpu::Buffer,
    /// Line-list indices for the wireframe view (each triangle → its 3 edges).
    wire_index_buf: wgpu::Buffer,
    wire_index_count: u32,
    faces: Vec<FaceRange>,
    edge_buf: Option<wgpu::Buffer>,
    edges: Vec<EdgeRange>,
    edge_instances: u32,
    /// The instances drawn by the OCCLUDED-edge pass: every edge's up to the
    /// first AUX one. A display-only outline (a board's copper) is not dimmed
    /// through what hides it — the bottom copper would ghost through the board
    /// — and aux edges trail the real ones, so it is a prefix.
    occludable_edge_instances: u32,
    point_buf: Option<wgpu::Buffer>,
    point_count: u32,
    /// Per-solid base face style (uniform or hashed color; metadata override).
    base_style: StyleBuf,
    /// De-duplicated palette of PER-FACE styles — one entry per distinct face
    /// colour on this solid, referenced by [`FaceRange::style`]. Empty for the
    /// overwhelmingly common solid whose faces are all one colour, which is what
    /// keeps the whole-mesh fast path in `frame.rs` alive.
    face_styles: Vec<StyleBuf>,
    /// Selected/hovered face boundary outline segments (cache keyed by the
    /// emphasis generation).
    boundary: Option<BoundaryBuf>,
    /// This solid's slot in its scene's [`ModelArena`] (the pose its buffers
    /// are drawn through; identity unless posed).
    model_slot: u32,
    /// The buffers hold the geometry of `shape` itself, as uploaded: a pose
    /// of that shape is then a matrix write. False once a re-pose rewrote
    /// them (a drag), until the next upload.
    holds_shape: bool,
}

struct BoundaryBuf {
    emphasis_generation: u64,
    revision: u64,
    buf: Option<wgpu::Buffer>,
    count: u32,
}

/// The Work part context tint: assembly components around the part being
/// edited are drawn in this colour, alpha-blended without depth writes and
/// without pick handles.
pub const WORK_PART_CONTEXT_RGBA: [f32; 4] = [0.45, 0.55, 0.65, 0.22];

/// A scene uploaded to GPU buffers, retained across frames; sync with
/// [`RenderCore::sync_scene`].
#[derive(Default)]
pub struct GpuScene {
    solids: HashMap<String, GpuSolid>,
    /// Draw order (scene insertion order at last sync).
    order: Vec<String>,
    settings_generation: u64,
    axis_buf: Option<wgpu::Buffer>,
    /// Count of solid (re)uploads (R10 observability: a reused solid must NOT
    /// bump this across a sync).
    uploads: u64,
    /// Count of in-place re-poses (positions rewritten, nothing rebuilt).
    reposes: u64,
    /// Count of poses drawn through a matrix (a 128-byte write, no vertex).
    pose_writes: u64,
    /// Every solid's pose, one buffer (made on the first sync).
    models: Option<ModelArena>,
}

impl GpuScene {
    /// Total solid buffer uploads since creation (test/telemetry hook for the
    /// reused-buffer fast path).
    pub fn upload_count(&self) -> u64 {
        self.uploads
    }

    /// Total in-place re-poses since creation (a rigidly moved solid whose
    /// buffers were rewritten rather than rebuilt).
    pub fn repose_count(&self) -> u64 {
        self.reposes
    }

    /// Total poses taken by a matrix write alone (the buffers kept the
    /// shape's geometry and nothing of it was rewritten).
    pub fn pose_write_count(&self) -> u64 {
        self.pose_writes
    }
}

struct CachedTargets {
    width: u32,
    height: u32,
    msaa_view: wgpu::TextureView,
    depth_view: wgpu::TextureView,
}

/// Everything a frame needs beyond the scene buffers.
pub struct FrameParams<'a> {
    pub camera: &'a Camera,
    /// Physical pixel size of the target.
    pub width: u32,
    pub height: u32,
    /// Device pixel ratio (CSS px → physical px) for line/point widths.
    pub dpr: f32,
    pub settings: &'a RenderSettings,
    pub emphasis: &'a Emphasis,
    /// World units per CSS pixel (for the screen-sized axis helper).
    pub world_per_pixel: f64,
    /// In-scene overlay widgets: transform gizmo, ViewCube, datum /
    /// dimension / curve visuals. `None` on the artifact/headless path.
    pub overlay: Option<&'a crate::widgets::WidgetOverlay>,
}

/// Create a headless device: adapter picked by `BREP_RENDER_ADAPTER`
/// (case-insensitive substring of the adapter name, e.g. "llvmpipe") or wgpu's
/// high-performance default. Native only — the browser shell gets its device
/// from the canvas context.
#[cfg(not(target_arch = "wasm32"))]
pub fn create_headless_device() -> Result<(wgpu::Device, wgpu::Queue, String), String> {
    let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
    let want = std::env::var("BREP_RENDER_ADAPTER").ok();
    let adapter = if let Some(want) = &want {
        let want_lower = want.to_lowercase();
        pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all()))
            .into_iter()
            .find(|adapter| adapter.get_info().name.to_lowercase().contains(&want_lower))
            .ok_or_else(|| format!("no adapter matching BREP_RENDER_ADAPTER={want}"))?
    } else {
        pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
            power_preference: wgpu::PowerPreference::HighPerformance,
            compatible_surface: None,
            force_fallback_adapter: false,
        }))
        .map_err(|error| format!("no wgpu adapter available: {error}"))?
    };
    let info = adapter.get_info();
    let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
        label: Some("brep-render"),
        ..Default::default()
    }))
    .map_err(|error| format!("wgpu device request failed: {error}"))?;
    Ok((device, queue, format!("{} ({:?})", info.name, info.backend)))
}

/// The window-agnostic renderer: pipelines + layouts for one color format.
pub struct RenderCore {
    pub device: wgpu::Device,
    pub queue: wgpu::Queue,
    format: wgpu::TextureFormat,
    globals_buf: wgpu::Buffer,
    globals_bind: wgpu::BindGroup,
    style_layout: wgpu::BindGroupLayout,
    /// Group 2 of the solid pipelines: a solid's pose in its scene's
    /// [`ModelArena`], at a dynamic offset.
    model_layout: wgpu::BindGroupLayout,
    /// Bound for whatever the solid pipelines draw that is not a solid's own
    /// buffers (face boundaries, the world axes).
    identity_model: IdentityModel,
    mesh_pipeline: wgpu::RenderPipeline,
    /// The Line art outline pass (`vs_hull`/`fs_hull`).
    hull_pipeline: wgpu::RenderPipeline,
    context_pipeline: wgpu::RenderPipeline,
    context_style: StyleBuf,
    /// A selected / hovered context face: the selection colours, still
    /// translucent (the context pass blends without depth writes).
    context_selected: StyleBuf,
    context_hovered: StyleBuf,
    wire_pipeline: wgpu::RenderPipeline,
    edge_visible_pipeline: wgpu::RenderPipeline,
    edge_hidden_pipeline: wgpu::RenderPipeline,
    point_pipeline: wgpu::RenderPipeline,
    /// Overlay-widget passes: shaded per-vertex tris + screen-constant
    /// per-instance lines, drawn over the solids in a depth-cleared pass.
    overlay_tri_pipeline: wgpu::RenderPipeline,
    /// Same as `overlay_tri_pipeline` but WITHOUT depth-write — for the datum/
    /// construction PLANES, which are translucent UI aids that must NOT occlude
    /// the gizmos/dimensions drawn after them (a plane no longer hides an offset
    /// arrow behind it). The gizmo tris still use the depth-writing pipeline so
    /// they self-occlude correctly.
    overlay_tri_nodepth_pipeline: wgpu::RenderPipeline,
    overlay_line_pipeline: wgpu::RenderPipeline,
    /// Second globals buffer for the ViewCube pass (its own mini-camera).
    vc_globals_buf: wgpu::Buffer,
    vc_globals_bind: wgpu::BindGroup,
    styles: GlobalStyles,
    targets: Option<CachedTargets>,
}

/// 8-bit RGB PNG encode (no text/time chunks — byte-deterministic).
#[cfg(not(target_arch = "wasm32"))]
fn encode_png(rgb: &[u8], width: u32, height: u32) -> Result<Vec<u8>, String> {
    brep_gizmos::raster::encode_png(rgb, width, height, png::ColorType::Rgb)
}

mod pipelines;
mod scene_sync;
mod frame;