Skip to main content

brep_render/render/
frame.rs

1use super::*;
2
3impl RenderCore {
4    /// Render one frame into `resolve_view` (a single-sample view of the
5    /// core's format). This is the whole engine core; every presentation shell
6    /// funnels through it.
7    pub fn render_to_view(
8        &mut self,
9        gpu_scene: &mut GpuScene,
10        scene: &RenderScene,
11        params: &FrameParams,
12        resolve_view: &wgpu::TextureView,
13    ) {
14        let width = params.width.max(1);
15        let height = params.height.max(1);
16        self.ensure_targets(width, height);
17        self.write_global_styles(params.settings);
18
19        let globals = Globals {
20            view_proj: params.camera.view_proj,
21            viewport: [width as f32, height as f32, params.dpr.max(1e-3), 0.0],
22            forward: [
23                params.camera.forward[0],
24                params.camera.forward[1],
25                params.camera.forward[2],
26                0.0,
27            ],
28        };
29        self.queue
30            .write_buffer(&self.globals_buf, 0, bytemuck::bytes_of(&globals));
31
32        // World-axis helper (R20): three world-axis segments sized in CSS px.
33        let axis_len = params.settings.axis_length_px as f64 * params.world_per_pixel;
34        let draw_axes = params.settings.axis_length_px > 0.0 && axis_len.is_finite() && axis_len > 0.0;
35        if draw_axes {
36            let l = axis_len as f32;
37            let segments = [
38                EdgeInstance { p0: [0.0; 3], p1: [l, 0.0, 0.0] },
39                EdgeInstance { p0: [0.0; 3], p1: [0.0, l, 0.0] },
40                EdgeInstance { p0: [0.0; 3], p1: [0.0, 0.0, l] },
41            ];
42            match &gpu_scene.axis_buf {
43                Some(buf) => self.queue.write_buffer(buf, 0, bytemuck::cast_slice(&segments)),
44                None => {
45                    gpu_scene.axis_buf = Some(self.device.create_buffer_init(
46                        &wgpu::util::BufferInitDescriptor {
47                            label: Some("world axes"),
48                            contents: bytemuck::cast_slice(&segments),
49                            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
50                        },
51                    ));
52                }
53            }
54        }
55
56        // Refresh emphasis boundary buffers.
57        let has_emphasis = !params.emphasis.is_empty();
58        for solid in scene.solids() {
59            if let Some(gpu_solid) = gpu_scene.solids.get_mut(&solid.name) {
60                self.sync_boundary(gpu_solid, solid, params.emphasis);
61            }
62        }
63
64        let bg = params.settings.background;
65        let targets = self.targets.as_ref().expect("targets ensured");
66        let mut encoder = self
67            .device
68            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
69                label: Some("brep-render frame"),
70            });
71        {
72            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
73                label: Some("scene"),
74                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
75                    view: &targets.msaa_view,
76                    depth_slice: None,
77                    resolve_target: Some(resolve_view),
78                    ops: wgpu::Operations {
79                        load: wgpu::LoadOp::Clear(wgpu::Color {
80                            r: bg[0] as f64,
81                            g: bg[1] as f64,
82                            b: bg[2] as f64,
83                            a: 1.0,
84                        }),
85                        store: wgpu::StoreOp::Store,
86                    },
87                })],
88                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
89                    view: &targets.depth_view,
90                    depth_ops: Some(wgpu::Operations {
91                        load: wgpu::LoadOp::Clear(1.0),
92                        store: wgpu::StoreOp::Discard,
93                    }),
94                    stencil_ops: None,
95                }),
96                timestamp_writes: None,
97                occlusion_query_set: None,
98                multiview_mask: None,
99            });
100            pass.set_bind_group(0, &self.globals_bind, &[]);
101
102            let visible_solids: Vec<(&SolidDisplay, &GpuSolid)> = gpu_scene
103                .order
104                .iter()
105                .filter_map(|name| {
106                    let solid = scene.solid(name)?;
107                    if !solid.visible {
108                        return None;
109                    }
110                    Some((solid, gpu_scene.solids.get(name)?))
111                })
112                .collect();
113
114            // 1a. Wireframe view: draw each solid's tessellated triangle mesh as
115            //     a line list (base color, no fill), so the face triangles read
116            //     as a wireframe. Picking is CPU ray-based (pick.rs), unaffected.
117            //     A HIDDEN face skips its triangles' wire segments too, exactly as
118            //     the shaded pass masks its triangle ranges — otherwise hiding a
119            //     face (or the whole Faces group) would be a no-op in wireframe view.
120            if params.settings.wireframe && params.settings.show_faces {
121                pass.set_pipeline(&self.wire_pipeline);
122                for (solid, gpu_solid) in &visible_solids {
123                    if gpu_solid.wire_index_count == 0 {
124                        continue;
125                    }
126                    pass.set_vertex_buffer(0, gpu_solid.vertex_buf.slice(..));
127                    pass.set_index_buffer(
128                        gpu_solid.wire_index_buf.slice(..),
129                        wgpu::IndexFormat::Uint32,
130                    );
131                    pass.set_bind_group(1, &gpu_solid.base_style.bind, &[]);
132                    // Fast path: no hidden face → one whole-buffer wire draw.
133                    if !solid.visibility.any_face_hidden() {
134                        pass.draw_indexed(0..gpu_solid.wire_index_count, 0, 0..1);
135                        continue;
136                    }
137                    // Otherwise coalesce contiguous VISIBLE faces' wire ranges and
138                    // skip the hidden ones. The wire buffer is a LINE LIST built per
139                    // triangle (6 indices/triangle — 3 edges × 2 endpoints), in mesh
140                    // triangle order, so face i occupies wire indices
141                    // `tri_start*6 .. (tri_start+tri_count)*6` (NOT the shaded
142                    // `gpu_solid.faces` ranges, which are ×3 for the triangle buffer).
143                    let mut run: Option<(u32, u32)> = None; // first, count
144                    let flush = |pass: &mut wgpu::RenderPass, run: &mut Option<(u32, u32)>| {
145                        if let Some((first, count)) = run.take() {
146                            if count > 0 {
147                                pass.draw_indexed(first..first + count, 0, 0..1);
148                            }
149                        }
150                    };
151                    for (index, face) in solid.faces.iter().enumerate() {
152                        if face.tri_count == 0 {
153                            continue;
154                        }
155                        let first = face.tri_start * 6;
156                        let count = face.tri_count * 6;
157                        if !solid.visibility.is_face_visible(index) {
158                            flush(&mut pass, &mut run);
159                            continue;
160                        }
161                        match &mut run {
162                            Some((run_first, run_count)) if *run_first + *run_count == first => {
163                                *run_count += count;
164                            }
165                            _ => {
166                                flush(&mut pass, &mut run);
167                                run = Some((first, count));
168                            }
169                        }
170                    }
171                    flush(&mut pass, &mut run);
172                }
173            }
174
175            // 1. Shaded faces, coalesced into index-range runs per emphasis
176            //    state (base runs merge back into whole-solid draws). Skipped in
177            //    wireframe mode (1a draws the triangle wireframe instead). Picking
178            //    is CPU ray-based (pick.rs) and the overlay pass is separate.
179            if !params.settings.wireframe && params.settings.show_faces {
180                pass.set_pipeline(&self.mesh_pipeline);
181                for (solid, gpu_solid) in &visible_solids {
182                    if solid.mesh.indices.is_empty() {
183                        continue;
184                    }
185                    pass.set_vertex_buffer(0, gpu_solid.vertex_buf.slice(..));
186                    pass.set_index_buffer(gpu_solid.index_buf.slice(..), wgpu::IndexFormat::Uint32);
187                    // Fast path: no emphasis AND no hidden faces → one whole-mesh
188                    // draw. When any face is hidden we fall to the per-face loop
189                    // below, which coalesces contiguous VISIBLE faces and skips
190                    // the hidden ones' triangle ranges entirely.
191                    // The whole-mesh fast path also needs every face to share
192                    // the solid's colour — a solid with a per-face palette has
193                    // to walk its faces to bind the right style per run.
194                    let any_face_hidden = solid.visibility.any_face_hidden();
195                    let any_face_colored = !gpu_solid.face_styles.is_empty();
196                    if !has_emphasis && !any_face_hidden && !any_face_colored {
197                        pass.set_bind_group(1, &gpu_solid.base_style.bind, &[]);
198                        pass.draw_indexed(0..solid.mesh.indices.len() as u32, 0, 0..1);
199                        continue;
200                    }
201                    // The run key is (emphasis state, face-colour slot). A
202                    // selected or hovered face draws in the emphasis colour
203                    // whatever colour the model gave it, so its slot is
204                    // normalized away — two adjacent selected faces of
205                    // different colours still coalesce into one draw.
206                    let style_for = |state: EmphasisState, slot: Option<u32>| match state {
207                        EmphasisState::Base => match slot.and_then(|s| gpu_solid.face_styles.get(s as usize)) {
208                            Some(style) => &style.bind,
209                            None => &gpu_solid.base_style.bind,
210                        },
211                        EmphasisState::Selected => &self.styles.face_selected.bind,
212                        EmphasisState::Hovered => &self.styles.face_hovered.bind,
213                    };
214                    type FaceRun = (EmphasisState, Option<u32>, u32, u32); // state, slot, first, count
215                    let mut run: Option<FaceRun> = None;
216                    let flush = |pass: &mut wgpu::RenderPass, run: &mut Option<FaceRun>| {
217                        if let Some((state, slot, first, count)) = run.take() {
218                            if count > 0 {
219                                pass.set_bind_group(1, style_for(state, slot), &[]);
220                                pass.draw_indexed(first..first + count, 0, 0..1);
221                            }
222                        }
223                    };
224                    for (index, face) in solid.faces.iter().enumerate() {
225                        let range = &gpu_solid.faces[index];
226                        if range.index_count == 0 {
227                            continue;
228                        }
229                        // Hidden face: skip its triangles and break the run so
230                        // the surviving neighbours don't coalesce across the gap.
231                        if !solid.visibility.is_face_visible(index) {
232                            flush(&mut pass, &mut run);
233                            continue;
234                        }
235                        let state = if has_emphasis {
236                            params.emphasis.face_state(&solid.name, &face.name)
237                        } else {
238                            EmphasisState::Base
239                        };
240                        let slot = if state == EmphasisState::Base {
241                            range.style
242                        } else {
243                            None
244                        };
245                        match &mut run {
246                            Some((run_state, run_slot, first, count))
247                                if *run_state == state
248                                    && *run_slot == slot
249                                    && *first + *count == range.first_index =>
250                            {
251                                *count += range.index_count;
252                            }
253                            _ => {
254                                flush(&mut pass, &mut run);
255                                run = Some((state, slot, range.first_index, range.index_count));
256                            }
257                        }
258                    }
259                    flush(&mut pass, &mut run);
260                }
261            }
262
263            // 2. Occluded edge portions, dimmed (depth test inverted). Hidden
264            //    edges skip their segments (their occluded portion too).
265            if params.settings.show_edges && params.settings.hidden_edge_alpha > 0.0 {
266                pass.set_pipeline(&self.edge_hidden_pipeline);
267                pass.set_bind_group(1, &self.styles.edge_hidden.bind, &[]);
268                for (solid, gpu_solid) in &visible_solids {
269                    let Some(edge_buf) = &gpu_solid.edge_buf else { continue };
270                    if gpu_solid.edge_instances == 0 {
271                        continue;
272                    }
273                    pass.set_vertex_buffer(0, edge_buf.slice(..));
274                    if solid.visibility.any_edge_hidden() {
275                        // Single style already bound: draw only visible edges.
276                        draw_visible_edge_ranges(&mut pass, solid, gpu_solid);
277                    } else {
278                        pass.draw(0..6, 0..gpu_solid.edge_instances);
279                    }
280                }
281            }
282
283            // 3. Visible edges, per-edge emphasis runs; hidden edges skipped.
284            //    The whole pass is off when the Edges display toggle is.
285            if params.settings.show_edges {
286                pass.set_pipeline(&self.edge_visible_pipeline);
287                for (solid, gpu_solid) in &visible_solids {
288                    let Some(edge_buf) = &gpu_solid.edge_buf else { continue };
289                    if gpu_solid.edge_instances == 0 {
290                        continue;
291                    }
292                    pass.set_vertex_buffer(0, edge_buf.slice(..));
293                    let any_edge_hidden = solid.visibility.any_edge_hidden();
294                    if !has_emphasis {
295                        pass.set_bind_group(1, &self.styles.edge_base.bind, &[]);
296                        if any_edge_hidden {
297                            draw_visible_edge_ranges(&mut pass, solid, gpu_solid);
298                        } else {
299                            pass.draw(0..6, 0..gpu_solid.edge_instances);
300                        }
301                        continue;
302                    }
303                    let style_for = |state: EmphasisState| match state {
304                        EmphasisState::Base => &self.styles.edge_base.bind,
305                        EmphasisState::Selected => &self.styles.edge_selected.bind,
306                        EmphasisState::Hovered => &self.styles.edge_hovered.bind,
307                    };
308                    let mut run: Option<(EmphasisState, u32, u32)> = None;
309                    let flush = |pass: &mut wgpu::RenderPass, run: &mut Option<(EmphasisState, u32, u32)>| {
310                        if let Some((state, first, count)) = run.take() {
311                            if count > 0 {
312                                pass.set_bind_group(1, style_for(state), &[]);
313                                pass.draw(0..6, first..first + count);
314                            }
315                        }
316                    };
317                    for (index, edge) in solid.edges.iter().enumerate() {
318                        let range = &gpu_solid.edges[index];
319                        if range.instance_count == 0 {
320                            continue;
321                        }
322                        // Hidden edge: skip its segments and break the run.
323                        if !solid.visibility.is_edge_visible(index) {
324                            flush(&mut pass, &mut run);
325                            continue;
326                        }
327                        let state = params.emphasis.edge_state(&solid.name, &edge.name);
328                        match &mut run {
329                            Some((run_state, first, count))
330                                if *run_state == state
331                                    && *first + *count == range.first_instance =>
332                            {
333                                *count += range.instance_count;
334                            }
335                            _ => {
336                                flush(&mut pass, &mut run);
337                                run = Some((state, range.first_instance, range.instance_count));
338                            }
339                        }
340                    }
341                    flush(&mut pass, &mut run);
342                }
343            }
344
345            // 4. Selected/hovered face boundary outlines.
346            if has_emphasis {
347                pass.set_bind_group(1, &self.styles.boundary.bind, &[]);
348                for (_, gpu_solid) in &visible_solids {
349                    let Some(boundary) = &gpu_solid.boundary else { continue };
350                    let Some(buf) = &boundary.buf else { continue };
351                    if boundary.count == 0 {
352                        continue;
353                    }
354                    pass.set_vertex_buffer(0, buf.slice(..));
355                    pass.draw(0..6, 0..boundary.count);
356                }
357            }
358
359            // 5. Vertex points; hidden vertices skip their point sprite.
360            if params.settings.show_vertices && params.settings.vertex_size_px > 0.0 {
361                pass.set_pipeline(&self.point_pipeline);
362                for (solid, gpu_solid) in &visible_solids {
363                    let Some(point_buf) = &gpu_solid.point_buf else { continue };
364                    if gpu_solid.point_count == 0 {
365                        continue;
366                    }
367                    // A fully-hidden points group draws nothing — skip before touching
368                    // the per-vertex loop below. When ALL vertices are hidden that loop
369                    // would still iterate every one of them each frame; the O(N)/frame
370                    // cost is invisible on native but throttles the WebGL backend on
371                    // point-heavy models (the "can't spin after hiding points" case).
372                    if solid.visibility.all_vertices_hidden(solid.vertices.len()) {
373                        continue;
374                    }
375                    pass.set_vertex_buffer(0, point_buf.slice(..));
376                    let any_vertex_hidden = solid.visibility.any_vertex_hidden();
377                    if has_emphasis || any_vertex_hidden {
378                        let tol = 1e-9_f64.max(params.world_per_pixel * 1e-3);
379                        let mut base_run: Option<(u32, u32)> = None;
380                        let mut emphasized: Vec<(EmphasisState, u32)> = Vec::new();
381                        for (index, vertex) in solid.vertices.iter().enumerate() {
382                            // Hidden vertex: skip its point and break the run.
383                            if !solid.visibility.is_vertex_visible(index) {
384                                if let Some((first, count)) = base_run.take() {
385                                    pass.set_bind_group(1, &self.styles.point_base.bind, &[]);
386                                    pass.draw(0..6, first..first + count);
387                                }
388                                continue;
389                            }
390                            let state = if has_emphasis {
391                                params.emphasis.vertex_state(&solid.name, vertex.position, tol)
392                            } else {
393                                EmphasisState::Base
394                            };
395                            if state == EmphasisState::Base {
396                                match &mut base_run {
397                                    Some((first, count)) if *first + *count == index as u32 => {
398                                        *count += 1
399                                    }
400                                    _ => {
401                                        if let Some((first, count)) = base_run.take() {
402                                            pass.set_bind_group(1, &self.styles.point_base.bind, &[]);
403                                            pass.draw(0..6, first..first + count);
404                                        }
405                                        base_run = Some((index as u32, 1));
406                                    }
407                                }
408                            } else {
409                                emphasized.push((state, index as u32));
410                            }
411                        }
412                        if let Some((first, count)) = base_run {
413                            pass.set_bind_group(1, &self.styles.point_base.bind, &[]);
414                            pass.draw(0..6, first..first + count);
415                        }
416                        for (state, index) in emphasized {
417                            let style = match state {
418                                EmphasisState::Selected => &self.styles.point_selected.bind,
419                                _ => &self.styles.point_hovered.bind,
420                            };
421                            pass.set_bind_group(1, style, &[]);
422                            pass.draw(0..6, index..index + 1);
423                        }
424                    } else {
425                        pass.set_bind_group(1, &self.styles.point_base.bind, &[]);
426                        pass.draw(0..6, 0..gpu_solid.point_count);
427                    }
428                }
429            }
430
431            // 6. World axes on top of nothing special (normal depth test).
432            if draw_axes {
433                if let Some(axis_buf) = &gpu_scene.axis_buf {
434                    pass.set_pipeline(&self.edge_visible_pipeline);
435                    pass.set_vertex_buffer(0, axis_buf.slice(..));
436                    for (index, style) in [
437                        &self.styles.axis_x,
438                        &self.styles.axis_y,
439                        &self.styles.axis_z,
440                    ]
441                    .iter()
442                    .enumerate()
443                    {
444                        pass.set_bind_group(1, &style.bind, &[]);
445                        let i = index as u32;
446                        pass.draw(0..6, i..i + 1);
447                    }
448                }
449            }
450        }
451
452        // --- Overlay-widget passes: the brep-gizmos overlay drawn
453        //     over the solids in a depth-cleared pass so widgets read on top.
454        //     The main overlay uses the scene camera + full viewport; the
455        //     ViewCube uses its own mini-camera + corner viewport.
456        if let Some(overlay) = params.overlay {
457            let make_tris = |ov: &brep_gizmos::Overlay| -> Option<wgpu::Buffer> {
458                let verts = overlay_tri_verts(ov);
459                (!verts.is_empty()).then(|| {
460                    self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
461                        label: Some("overlay tris"),
462                        contents: bytemuck::cast_slice(&verts),
463                        usage: wgpu::BufferUsages::VERTEX,
464                    })
465                })
466            };
467            let make_lines = |ov: &brep_gizmos::Overlay| -> Option<wgpu::Buffer> {
468                let insts = overlay_line_insts(ov);
469                (!insts.is_empty()).then(|| {
470                    self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
471                        label: Some("overlay lines"),
472                        contents: bytemuck::cast_slice(&insts),
473                        usage: wgpu::BufferUsages::VERTEX,
474                    })
475                })
476            };
477
478            let main_tri_count = overlay.main.tris.len() as u32;
479            let main_line_count = (overlay.main.lines.len() / 2) as u32;
480            let main_tri_buf = make_tris(&overlay.main);
481            let main_line_buf = make_lines(&overlay.main);
482            if main_tri_buf.is_some() || main_line_buf.is_some() {
483                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
484                    label: Some("overlay-main"),
485                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
486                        view: &targets.msaa_view,
487                        depth_slice: None,
488                        resolve_target: Some(resolve_view),
489                        ops: wgpu::Operations {
490                            load: wgpu::LoadOp::Load,
491                            store: wgpu::StoreOp::Store,
492                        },
493                    })],
494                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
495                        view: &targets.depth_view,
496                        depth_ops: Some(wgpu::Operations {
497                            load: wgpu::LoadOp::Clear(1.0),
498                            store: wgpu::StoreOp::Discard,
499                        }),
500                        stencil_ops: None,
501                    }),
502                    timestamp_writes: None,
503                    occlusion_query_set: None,
504                    multiview_mask: None,
505                });
506                pass.set_bind_group(0, &self.globals_bind, &[]);
507                pass.set_bind_group(1, &self.styles.overlay_line.bind, &[]);
508                if let Some(buf) = &main_tri_buf {
509                    pass.set_vertex_buffer(0, buf.slice(..));
510                    // The datum/construction PLANE tris come FIRST; draw them with
511                    // depth-write OFF so a translucent plane never occludes the
512                    // gizmo/dimension tris that follow. The rest keep depth-write
513                    // so gizmos self-occlude correctly.
514                    let plane_verts = (overlay.plane_tri_verts as u32).min(main_tri_count);
515                    if plane_verts > 0 {
516                        pass.set_pipeline(&self.overlay_tri_nodepth_pipeline);
517                        pass.draw(0..plane_verts, 0..1);
518                    }
519                    if plane_verts < main_tri_count {
520                        pass.set_pipeline(&self.overlay_tri_pipeline);
521                        pass.draw(plane_verts..main_tri_count, 0..1);
522                    }
523                }
524                if let Some(buf) = &main_line_buf {
525                    pass.set_pipeline(&self.overlay_line_pipeline);
526                    pass.set_vertex_buffer(0, buf.slice(..));
527                    pass.draw(0..6, 0..main_line_count);
528                }
529            }
530
531            if let Some(vc) = &overlay.viewcube {
532                let dpr = params.dpr.max(1e-3);
533                let mut x = (vc.rect_css[0] * dpr).max(0.0);
534                let mut y = (vc.rect_css[1] * dpr).max(0.0);
535                let mut w = (vc.rect_css[2] * dpr).max(1.0);
536                let mut h = (vc.rect_css[3] * dpr).max(1.0);
537                // Clamp the corner viewport to the framebuffer.
538                w = w.min(width as f32 - x).max(1.0);
539                h = h.min(height as f32 - y).max(1.0);
540                x = x.min(width as f32 - w).max(0.0);
541                y = y.min(height as f32 - h).max(0.0);
542
543                let vc_globals = Globals {
544                    view_proj: vc.view_proj,
545                    viewport: [w, h, dpr, 0.0],
546                    forward: [vc.forward[0], vc.forward[1], vc.forward[2], 0.0],
547                };
548                self.queue
549                    .write_buffer(&self.vc_globals_buf, 0, bytemuck::bytes_of(&vc_globals));
550
551                let vc_tri_count = vc.overlay.tris.len() as u32;
552                let vc_line_count = (vc.overlay.lines.len() / 2) as u32;
553                let vc_tri_buf = make_tris(&vc.overlay);
554                let vc_line_buf = make_lines(&vc.overlay);
555                if vc_tri_buf.is_some() || vc_line_buf.is_some() {
556                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
557                        label: Some("overlay-viewcube"),
558                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
559                            view: &targets.msaa_view,
560                            depth_slice: None,
561                            resolve_target: Some(resolve_view),
562                            ops: wgpu::Operations {
563                                load: wgpu::LoadOp::Load,
564                                store: wgpu::StoreOp::Store,
565                            },
566                        })],
567                        depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
568                            view: &targets.depth_view,
569                            depth_ops: Some(wgpu::Operations {
570                                load: wgpu::LoadOp::Clear(1.0),
571                                store: wgpu::StoreOp::Discard,
572                            }),
573                            stencil_ops: None,
574                        }),
575                        timestamp_writes: None,
576                        occlusion_query_set: None,
577                        multiview_mask: None,
578                    });
579                    pass.set_viewport(x, y, w, h, 0.0, 1.0);
580                    pass.set_scissor_rect(x as u32, y as u32, w as u32, h as u32);
581                    pass.set_bind_group(0, &self.vc_globals_bind, &[]);
582                    pass.set_bind_group(1, &self.styles.overlay_line.bind, &[]);
583                    if let Some(buf) = &vc_tri_buf {
584                        pass.set_pipeline(&self.overlay_tri_pipeline);
585                        pass.set_vertex_buffer(0, buf.slice(..));
586                        pass.draw(0..vc_tri_count, 0..1);
587                    }
588                    if let Some(buf) = &vc_line_buf {
589                        pass.set_pipeline(&self.overlay_line_pipeline);
590                        pass.set_vertex_buffer(0, buf.slice(..));
591                        pass.draw(0..6, 0..vc_line_count);
592                    }
593                }
594            }
595        }
596
597        self.queue.submit([encoder.finish()]);
598    }
599
600    /// Headless capture (R32/R34): render the scene and return PNG bytes
601    /// (8-bit RGB, no ancillary chunks — deterministic, R33).
602    #[cfg(not(target_arch = "wasm32"))]
603    pub fn render_to_png(
604        &mut self,
605        scene: &RenderScene,
606        camera: &Camera,
607        width: u32,
608        height: u32,
609    ) -> Result<Vec<u8>, String> {
610        let settings = RenderSettings::artifact();
611        let emphasis = Emphasis::default();
612        let mut gpu_scene = self.upload_scene_with(scene, &settings);
613        let params = FrameParams {
614            camera,
615            width,
616            height,
617            dpr: 1.0,
618            settings: &settings,
619            emphasis: &emphasis,
620            world_per_pixel: 0.0,
621            overlay: None,
622        };
623        let resolve = self.device.create_texture(&wgpu::TextureDescriptor {
624            label: Some("resolve"),
625            size: wgpu::Extent3d {
626                width,
627                height,
628                depth_or_array_layers: 1,
629            },
630            mip_level_count: 1,
631            sample_count: 1,
632            dimension: wgpu::TextureDimension::D2,
633            format: self.format,
634            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
635            view_formats: &[],
636        });
637        let resolve_view = resolve.create_view(&Default::default());
638        self.render_to_view(&mut gpu_scene, scene, &params, &resolve_view);
639
640        // Readback: rows padded to 256 bytes per wgpu's copy alignment.
641        let bytes_per_row = (width * 4).div_ceil(256) * 256;
642        let readback = self.device.create_buffer(&wgpu::BufferDescriptor {
643            label: Some("readback"),
644            size: bytes_per_row as u64 * height as u64,
645            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
646            mapped_at_creation: false,
647        });
648        let mut encoder = self
649            .device
650            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
651                label: Some("readback"),
652            });
653        encoder.copy_texture_to_buffer(
654            wgpu::TexelCopyTextureInfo {
655                texture: &resolve,
656                mip_level: 0,
657                origin: wgpu::Origin3d::ZERO,
658                aspect: wgpu::TextureAspect::All,
659            },
660            wgpu::TexelCopyBufferInfo {
661                buffer: &readback,
662                layout: wgpu::TexelCopyBufferLayout {
663                    offset: 0,
664                    bytes_per_row: Some(bytes_per_row),
665                    rows_per_image: None,
666                },
667            },
668            wgpu::Extent3d {
669                width,
670                height,
671                depth_or_array_layers: 1,
672            },
673        );
674        self.queue.submit([encoder.finish()]);
675
676        let slice = readback.slice(..);
677        let (sender, receiver) = std::sync::mpsc::channel();
678        slice.map_async(wgpu::MapMode::Read, move |result| {
679            let _ = sender.send(result);
680        });
681        self.device
682            .poll(wgpu::PollType::wait_indefinitely())
683            .map_err(|error| format!("wgpu poll: {error:?}"))?;
684        receiver
685            .recv()
686            .map_err(|_| "readback callback dropped".to_string())?
687            .map_err(|error| format!("readback map failed: {error:?}"))?;
688
689        let data = slice.get_mapped_range();
690        let mut rgb = Vec::with_capacity((width * height * 3) as usize);
691        for row in 0..height {
692            let start = (row * bytes_per_row) as usize;
693            for col in 0..width as usize {
694                let px = start + col * 4;
695                rgb.extend_from_slice(&data[px..px + 3]);
696            }
697        }
698        drop(data);
699        readback.unmap();
700
701        encode_png(&rgb, width, height)
702    }
703}
704
705/// Issue draw calls for a solid's VISIBLE edge instances only, coalescing
706/// contiguous edge ranges into as few `draw`s as possible. Used by the
707/// single-style edge passes (occluded/hidden, and the no-emphasis visible pass)
708/// when the solid has any hidden edge — the caller has already bound the style.
709fn draw_visible_edge_ranges(
710    pass: &mut wgpu::RenderPass<'_>,
711    solid: &SolidDisplay,
712    gpu_solid: &GpuSolid,
713) {
714    let mut run: Option<(u32, u32)> = None; // first_instance, count
715    for (index, _edge) in solid.edges.iter().enumerate() {
716        let range = &gpu_solid.edges[index];
717        if range.instance_count == 0 {
718            continue;
719        }
720        if !solid.visibility.is_edge_visible(index) {
721            if let Some((first, count)) = run.take() {
722                pass.draw(0..6, first..first + count);
723            }
724            continue;
725        }
726        match &mut run {
727            Some((first, count)) if *first + *count == range.first_instance => {
728                *count += range.instance_count;
729            }
730            _ => {
731                if let Some((first, count)) = run.take() {
732                    pass.draw(0..6, first..first + count);
733                }
734                run = Some((range.first_instance, range.instance_count));
735            }
736        }
737    }
738    if let Some((first, count)) = run {
739        pass.draw(0..6, first..first + count);
740    }
741}
742
743/// Convert a gizmo `Overlay`'s triangles into GPU vertices (per-vertex color).
744fn overlay_tri_verts(ov: &brep_gizmos::Overlay) -> Vec<OverlayTriVertex> {
745    ov.tris
746        .iter()
747        .map(|v| OverlayTriVertex {
748            position: v.pos,
749            normal: v.normal,
750            color: v.color,
751        })
752        .collect()
753}
754
755/// Convert a gizmo `Overlay`'s line segments (vertex pairs) into GPU instances
756/// (per-instance color; both endpoints of a gizmo segment share a color).
757fn overlay_line_insts(ov: &brep_gizmos::Overlay) -> Vec<OverlayLineInstance> {
758    ov.lines
759        .chunks_exact(2)
760        .map(|pair| OverlayLineInstance {
761            p0: pair[0].pos,
762            p1: pair[1].pos,
763            color: pair[0].color,
764        })
765        .collect()
766}