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 whose
139                    // segments are grouped by face and de-duplicated inside each
140                    // face (see `scene_sync::upload_solid`), so a face's slice is
141                    // whatever that grouping produced — read it off the face's
142                    // `wire_first`/`wire_count` rather than deriving it from the
143                    // triangle range, which no longer predicts it.
144                    let mut run: Option<(u32, u32)> = None; // first, count
145                    let flush = |pass: &mut wgpu::RenderPass, run: &mut Option<(u32, u32)>| {
146                        if let Some((first, count)) = run.take() {
147                            if count > 0 {
148                                pass.draw_indexed(first..first + count, 0, 0..1);
149                            }
150                        }
151                    };
152                    for (index, _face) in solid.faces.iter().enumerate() {
153                        let range = &gpu_solid.faces[index];
154                        if range.wire_count == 0 {
155                            continue;
156                        }
157                        let first = range.wire_first;
158                        let count = range.wire_count;
159                        if !solid.visibility.is_face_visible(index) {
160                            flush(&mut pass, &mut run);
161                            continue;
162                        }
163                        match &mut run {
164                            Some((run_first, run_count)) if *run_first + *run_count == first => {
165                                *run_count += count;
166                            }
167                            _ => {
168                                flush(&mut pass, &mut run);
169                                run = Some((first, count));
170                            }
171                        }
172                    }
173                    flush(&mut pass, &mut run);
174                }
175            }
176
177            // 1. Shaded faces, coalesced into index-range runs per emphasis
178            //    state (base runs merge back into whole-solid draws). Skipped in
179            //    wireframe mode (1a draws the triangle wireframe instead). Picking
180            //    is CPU ray-based (pick.rs) and the overlay pass is separate.
181            if !params.settings.wireframe && params.settings.show_faces {
182                pass.set_pipeline(&self.mesh_pipeline);
183                for (solid, gpu_solid) in &visible_solids {
184                    if solid.mesh.indices.is_empty() {
185                        continue;
186                    }
187                    pass.set_vertex_buffer(0, gpu_solid.vertex_buf.slice(..));
188                    pass.set_index_buffer(gpu_solid.index_buf.slice(..), wgpu::IndexFormat::Uint32);
189                    // Fast path: no emphasis AND no hidden faces → one whole-mesh
190                    // draw. When any face is hidden we fall to the per-face loop
191                    // below, which coalesces contiguous VISIBLE faces and skips
192                    // the hidden ones' triangle ranges entirely.
193                    // The whole-mesh fast path also needs every face to share
194                    // the solid's colour — a solid with a per-face palette has
195                    // to walk its faces to bind the right style per run.
196                    let any_face_hidden = solid.visibility.any_face_hidden();
197                    let any_face_colored = !gpu_solid.face_styles.is_empty();
198                    if !has_emphasis && !any_face_hidden && !any_face_colored {
199                        pass.set_bind_group(1, &gpu_solid.base_style.bind, &[]);
200                        pass.draw_indexed(0..solid.mesh.indices.len() as u32, 0, 0..1);
201                        continue;
202                    }
203                    // The run key is (emphasis state, face-colour slot). A
204                    // selected or hovered face draws in the emphasis colour
205                    // whatever colour the model gave it, so its slot is
206                    // normalized away — two adjacent selected faces of
207                    // different colours still coalesce into one draw.
208                    let style_for = |state: EmphasisState, slot: Option<u32>| match state {
209                        EmphasisState::Base => match slot.and_then(|s| gpu_solid.face_styles.get(s as usize)) {
210                            Some(style) => &style.bind,
211                            None => &gpu_solid.base_style.bind,
212                        },
213                        EmphasisState::Selected => &self.styles.face_selected.bind,
214                        EmphasisState::Hovered => &self.styles.face_hovered.bind,
215                    };
216                    type FaceRun = (EmphasisState, Option<u32>, u32, u32); // state, slot, first, count
217                    let mut run: Option<FaceRun> = None;
218                    let flush = |pass: &mut wgpu::RenderPass, run: &mut Option<FaceRun>| {
219                        if let Some((state, slot, first, count)) = run.take() {
220                            if count > 0 {
221                                pass.set_bind_group(1, style_for(state, slot), &[]);
222                                pass.draw_indexed(first..first + count, 0, 0..1);
223                            }
224                        }
225                    };
226                    for (index, face) in solid.faces.iter().enumerate() {
227                        let range = &gpu_solid.faces[index];
228                        if range.index_count == 0 {
229                            continue;
230                        }
231                        // Hidden face: skip its triangles and break the run so
232                        // the surviving neighbours don't coalesce across the gap.
233                        if !solid.visibility.is_face_visible(index) {
234                            flush(&mut pass, &mut run);
235                            continue;
236                        }
237                        let state = if has_emphasis {
238                            params.emphasis.face_state(&solid.name, &face.name)
239                        } else {
240                            EmphasisState::Base
241                        };
242                        let slot = if state == EmphasisState::Base {
243                            range.style
244                        } else {
245                            None
246                        };
247                        match &mut run {
248                            Some((run_state, run_slot, first, count))
249                                if *run_state == state
250                                    && *run_slot == slot
251                                    && *first + *count == range.first_index =>
252                            {
253                                *count += range.index_count;
254                            }
255                            _ => {
256                                flush(&mut pass, &mut run);
257                                run = Some((state, slot, range.first_index, range.index_count));
258                            }
259                        }
260                    }
261                    flush(&mut pass, &mut run);
262                }
263            }
264
265            // 2. Occluded edge portions, dimmed (depth test inverted). Hidden
266            //    edges skip their segments (their occluded portion too).
267            if params.settings.show_edges
268                && params.settings.show_occluded_edges
269                && params.settings.hidden_edge_alpha > 0.0
270            {
271                pass.set_pipeline(&self.edge_hidden_pipeline);
272                pass.set_bind_group(1, &self.styles.edge_hidden.bind, &[]);
273                for (solid, gpu_solid) in &visible_solids {
274                    let Some(edge_buf) = &gpu_solid.edge_buf else { continue };
275                    if gpu_solid.occludable_edge_instances == 0 {
276                        continue;
277                    }
278                    pass.set_vertex_buffer(0, edge_buf.slice(..));
279                    if solid.visibility.any_edge_hidden() {
280                        // Single style already bound: draw only visible edges.
281                        draw_visible_edge_ranges(&mut pass, solid, gpu_solid, EdgeFilter::Real);
282                    } else {
283                        // Aux edges are not drawn occluded (`occludable_edge_instances`).
284                        pass.draw(0..6, 0..gpu_solid.occludable_edge_instances);
285                    }
286                }
287            }
288
289            // 3. Visible edges, per-edge emphasis runs; hidden edges skipped.
290            //    The whole pass is off when the Edges display toggle is.
291            if params.settings.show_edges {
292                let aux_shown = params.world_per_pixel <= AUX_EDGE_MAX_WORLD_PER_PIXEL;
293                pass.set_pipeline(&self.edge_visible_pipeline);
294                for (solid, gpu_solid) in &visible_solids {
295                    let Some(edge_buf) = &gpu_solid.edge_buf else { continue };
296                    if gpu_solid.edge_instances == 0 {
297                        continue;
298                    }
299                    pass.set_vertex_buffer(0, edge_buf.slice(..));
300                    let any_edge_hidden = solid.visibility.any_edge_hidden();
301                    // The real edges are a prefix of the buffer and the AUX
302                    // ones the rest; at rest the two draw in their own styles,
303                    // and the aux ones only while the view is close enough
304                    // for them to be told apart (`AUX_EDGE_MAX_WORLD_PER_PIXEL`).
305                    let split = gpu_solid.occludable_edge_instances;
306                    if !has_emphasis {
307                        pass.set_bind_group(1, &self.styles.edge_base.bind, &[]);
308                        if any_edge_hidden {
309                            draw_visible_edge_ranges(&mut pass, solid, gpu_solid, EdgeFilter::Real);
310                            if aux_shown {
311                                pass.set_bind_group(1, &self.styles.edge_aux.bind, &[]);
312                                draw_visible_edge_ranges(&mut pass, solid, gpu_solid, EdgeFilter::Aux);
313                            }
314                        } else {
315                            if split > 0 {
316                                pass.draw(0..6, 0..split);
317                            }
318                            if aux_shown && split < gpu_solid.edge_instances {
319                                pass.set_bind_group(1, &self.styles.edge_aux.bind, &[]);
320                                pass.draw(0..6, split..gpu_solid.edge_instances);
321                            }
322                        }
323                        continue;
324                    }
325                    // An aux edge at rest keeps its own style; hovered or
326                    // selected (its SOLID is), it emphasises like any edge.
327                    let style_for = |(state, aux): (EmphasisState, bool)| match state {
328                        EmphasisState::Base if aux => &self.styles.edge_aux.bind,
329                        EmphasisState::Base => &self.styles.edge_base.bind,
330                        EmphasisState::Selected => &self.styles.edge_selected.bind,
331                        EmphasisState::Hovered => &self.styles.edge_hovered.bind,
332                    };
333                    let mut run: Option<((EmphasisState, bool), u32, u32)> = None;
334                    let flush = |pass: &mut wgpu::RenderPass, run: &mut Option<((EmphasisState, bool), u32, u32)>| {
335                        if let Some((state, first, count)) = run.take() {
336                            if count > 0 {
337                                pass.set_bind_group(1, style_for(state), &[]);
338                                pass.draw(0..6, first..first + count);
339                            }
340                        }
341                    };
342                    for (index, edge) in solid.edges.iter().enumerate() {
343                        let range = &gpu_solid.edges[index];
344                        if range.instance_count == 0 {
345                            continue;
346                        }
347                        // Hidden edge: skip its segments and break the run.
348                        if !solid.visibility.is_edge_visible(index) || (edge.aux && !aux_shown) {
349                            flush(&mut pass, &mut run);
350                            continue;
351                        }
352                        let state = (params.emphasis.edge_state(&solid.name, &edge.name), edge.aux);
353                        match &mut run {
354                            Some((run_state, first, count))
355                                if *run_state == state
356                                    && *first + *count == range.first_instance =>
357                            {
358                                *count += range.instance_count;
359                            }
360                            _ => {
361                                flush(&mut pass, &mut run);
362                                run = Some((state, range.first_instance, range.instance_count));
363                            }
364                        }
365                    }
366                    flush(&mut pass, &mut run);
367                }
368            }
369
370            // 4. Selected/hovered face boundary outlines.
371            if has_emphasis {
372                // Bind the pipeline explicitly rather than inheriting whatever
373                // the passes above left set: pass 3 binds this same pipeline,
374                // but only when `show_edges` is on. With edges hidden the
375                // inherited pipeline was the mesh/wireframe one, whose slot 0
376                // is a Vertex-step `MeshVertex` — so the `draw(0..6, ..)` below
377                // read this Instance-step `EdgeInstance` buffer as vertices and
378                // overran it (a 4-segment boundary is 4 "vertices" at the same
379                // 24-byte stride), which wgpu reports as a fatal validation error.
380                pass.set_pipeline(&self.edge_visible_pipeline);
381                pass.set_bind_group(1, &self.styles.boundary.bind, &[]);
382                for (_, gpu_solid) in &visible_solids {
383                    let Some(boundary) = &gpu_solid.boundary else { continue };
384                    let Some(buf) = &boundary.buf else { continue };
385                    if boundary.count == 0 {
386                        continue;
387                    }
388                    pass.set_vertex_buffer(0, buf.slice(..));
389                    pass.draw(0..6, 0..boundary.count);
390                }
391            }
392
393            // 5. Vertex points; hidden vertices skip their point sprite.
394            if params.settings.show_vertices && params.settings.vertex_size_px > 0.0 {
395                pass.set_pipeline(&self.point_pipeline);
396                for (solid, gpu_solid) in &visible_solids {
397                    let Some(point_buf) = &gpu_solid.point_buf else { continue };
398                    if gpu_solid.point_count == 0 {
399                        continue;
400                    }
401                    // A fully-hidden points group draws nothing — skip before touching
402                    // the per-vertex loop below. When ALL vertices are hidden that loop
403                    // would still iterate every one of them each frame; the O(N)/frame
404                    // cost is invisible on native but throttles the WebGL backend on
405                    // point-heavy models (the "can't spin after hiding points" case).
406                    if solid.visibility.all_vertices_hidden(solid.vertices.len()) {
407                        continue;
408                    }
409                    pass.set_vertex_buffer(0, point_buf.slice(..));
410                    let any_vertex_hidden = solid.visibility.any_vertex_hidden();
411                    if has_emphasis || any_vertex_hidden {
412                        let tol = 1e-9_f64.max(params.world_per_pixel * 1e-3);
413                        let mut base_run: Option<(u32, u32)> = None;
414                        let mut emphasized: Vec<(EmphasisState, u32)> = Vec::new();
415                        for (index, vertex) in solid.vertices.iter().enumerate() {
416                            // Hidden vertex: skip its point and break the run.
417                            if !solid.visibility.is_vertex_visible(index) {
418                                if let Some((first, count)) = base_run.take() {
419                                    pass.set_bind_group(1, &self.styles.point_base.bind, &[]);
420                                    pass.draw(0..6, first..first + count);
421                                }
422                                continue;
423                            }
424                            let state = if has_emphasis {
425                                params.emphasis.vertex_state(&solid.name, vertex.position, tol)
426                            } else {
427                                EmphasisState::Base
428                            };
429                            if state == EmphasisState::Base {
430                                match &mut base_run {
431                                    Some((first, count)) if *first + *count == index as u32 => {
432                                        *count += 1
433                                    }
434                                    _ => {
435                                        if let Some((first, count)) = base_run.take() {
436                                            pass.set_bind_group(1, &self.styles.point_base.bind, &[]);
437                                            pass.draw(0..6, first..first + count);
438                                        }
439                                        base_run = Some((index as u32, 1));
440                                    }
441                                }
442                            } else {
443                                emphasized.push((state, index as u32));
444                            }
445                        }
446                        if let Some((first, count)) = base_run {
447                            pass.set_bind_group(1, &self.styles.point_base.bind, &[]);
448                            pass.draw(0..6, first..first + count);
449                        }
450                        for (state, index) in emphasized {
451                            let style = match state {
452                                EmphasisState::Selected => &self.styles.point_selected.bind,
453                                _ => &self.styles.point_hovered.bind,
454                            };
455                            pass.set_bind_group(1, style, &[]);
456                            pass.draw(0..6, index..index + 1);
457                        }
458                    } else {
459                        pass.set_bind_group(1, &self.styles.point_base.bind, &[]);
460                        pass.draw(0..6, 0..gpu_solid.point_count);
461                    }
462                }
463            }
464
465            // 6. World axes on top of nothing special (normal depth test).
466            if draw_axes {
467                if let Some(axis_buf) = &gpu_scene.axis_buf {
468                    pass.set_pipeline(&self.edge_visible_pipeline);
469                    pass.set_vertex_buffer(0, axis_buf.slice(..));
470                    for (index, style) in [
471                        &self.styles.axis_x,
472                        &self.styles.axis_y,
473                        &self.styles.axis_z,
474                    ]
475                    .iter()
476                    .enumerate()
477                    {
478                        pass.set_bind_group(1, &style.bind, &[]);
479                        let i = index as u32;
480                        pass.draw(0..6, i..i + 1);
481                    }
482                }
483            }
484        }
485
486        // --- Overlay-widget passes: the brep-gizmos overlay drawn
487        //     over the solids in a depth-cleared pass so widgets read on top.
488        //     The main overlay uses the scene camera + full viewport; the
489        //     ViewCube uses its own mini-camera + corner viewport.
490        if let Some(overlay) = params.overlay {
491            let make_tris = |ov: &brep_gizmos::Overlay| -> Option<wgpu::Buffer> {
492                let verts = overlay_tri_verts(ov);
493                (!verts.is_empty()).then(|| {
494                    self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
495                        label: Some("overlay tris"),
496                        contents: bytemuck::cast_slice(&verts),
497                        usage: wgpu::BufferUsages::VERTEX,
498                    })
499                })
500            };
501            let make_lines = |ov: &brep_gizmos::Overlay| -> Option<wgpu::Buffer> {
502                let insts = overlay_line_insts(ov);
503                (!insts.is_empty()).then(|| {
504                    self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
505                        label: Some("overlay lines"),
506                        contents: bytemuck::cast_slice(&insts),
507                        usage: wgpu::BufferUsages::VERTEX,
508                    })
509                })
510            };
511
512            let main_tri_count = overlay.main.tris.len() as u32;
513            let main_line_count = (overlay.main.lines.len() / 2) as u32;
514            let main_tri_buf = make_tris(&overlay.main);
515            let main_line_buf = make_lines(&overlay.main);
516            if main_tri_buf.is_some() || main_line_buf.is_some() {
517                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
518                    label: Some("overlay-main"),
519                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
520                        view: &targets.msaa_view,
521                        depth_slice: None,
522                        resolve_target: Some(resolve_view),
523                        ops: wgpu::Operations {
524                            load: wgpu::LoadOp::Load,
525                            store: wgpu::StoreOp::Store,
526                        },
527                    })],
528                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
529                        view: &targets.depth_view,
530                        depth_ops: Some(wgpu::Operations {
531                            load: wgpu::LoadOp::Clear(1.0),
532                            store: wgpu::StoreOp::Discard,
533                        }),
534                        stencil_ops: None,
535                    }),
536                    timestamp_writes: None,
537                    occlusion_query_set: None,
538                    multiview_mask: None,
539                });
540                pass.set_bind_group(0, &self.globals_bind, &[]);
541                pass.set_bind_group(1, &self.styles.overlay_line.bind, &[]);
542                if let Some(buf) = &main_tri_buf {
543                    pass.set_vertex_buffer(0, buf.slice(..));
544                    // The datum/construction PLANE tris come FIRST; draw them with
545                    // depth-write OFF so a translucent plane never occludes the
546                    // gizmo/dimension tris that follow. The rest keep depth-write
547                    // so gizmos self-occlude correctly.
548                    let plane_verts = (overlay.plane_tri_verts as u32).min(main_tri_count);
549                    if plane_verts > 0 {
550                        pass.set_pipeline(&self.overlay_tri_nodepth_pipeline);
551                        pass.draw(0..plane_verts, 0..1);
552                    }
553                    if plane_verts < main_tri_count {
554                        pass.set_pipeline(&self.overlay_tri_pipeline);
555                        pass.draw(plane_verts..main_tri_count, 0..1);
556                    }
557                }
558                if let Some(buf) = &main_line_buf {
559                    pass.set_pipeline(&self.overlay_line_pipeline);
560                    pass.set_vertex_buffer(0, buf.slice(..));
561                    pass.draw(0..6, 0..main_line_count);
562                }
563            }
564
565            if let Some(vc) = &overlay.viewcube {
566                let dpr = params.dpr.max(1e-3);
567                let mut x = (vc.rect_css[0] * dpr).max(0.0);
568                let mut y = (vc.rect_css[1] * dpr).max(0.0);
569                let mut w = (vc.rect_css[2] * dpr).max(1.0);
570                let mut h = (vc.rect_css[3] * dpr).max(1.0);
571                // Clamp the corner viewport to the framebuffer.
572                w = w.min(width as f32 - x).max(1.0);
573                h = h.min(height as f32 - y).max(1.0);
574                x = x.min(width as f32 - w).max(0.0);
575                y = y.min(height as f32 - h).max(0.0);
576
577                let vc_globals = Globals {
578                    view_proj: vc.view_proj,
579                    viewport: [w, h, dpr, 0.0],
580                    forward: [vc.forward[0], vc.forward[1], vc.forward[2], 0.0],
581                };
582                self.queue
583                    .write_buffer(&self.vc_globals_buf, 0, bytemuck::bytes_of(&vc_globals));
584
585                let vc_tri_count = vc.overlay.tris.len() as u32;
586                let vc_line_count = (vc.overlay.lines.len() / 2) as u32;
587                let vc_tri_buf = make_tris(&vc.overlay);
588                let vc_line_buf = make_lines(&vc.overlay);
589                if vc_tri_buf.is_some() || vc_line_buf.is_some() {
590                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
591                        label: Some("overlay-viewcube"),
592                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
593                            view: &targets.msaa_view,
594                            depth_slice: None,
595                            resolve_target: Some(resolve_view),
596                            ops: wgpu::Operations {
597                                load: wgpu::LoadOp::Load,
598                                store: wgpu::StoreOp::Store,
599                            },
600                        })],
601                        depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
602                            view: &targets.depth_view,
603                            depth_ops: Some(wgpu::Operations {
604                                load: wgpu::LoadOp::Clear(1.0),
605                                store: wgpu::StoreOp::Discard,
606                            }),
607                            stencil_ops: None,
608                        }),
609                        timestamp_writes: None,
610                        occlusion_query_set: None,
611                        multiview_mask: None,
612                    });
613                    pass.set_viewport(x, y, w, h, 0.0, 1.0);
614                    pass.set_scissor_rect(x as u32, y as u32, w as u32, h as u32);
615                    pass.set_bind_group(0, &self.vc_globals_bind, &[]);
616                    pass.set_bind_group(1, &self.styles.overlay_line.bind, &[]);
617                    if let Some(buf) = &vc_tri_buf {
618                        pass.set_pipeline(&self.overlay_tri_pipeline);
619                        pass.set_vertex_buffer(0, buf.slice(..));
620                        pass.draw(0..vc_tri_count, 0..1);
621                    }
622                    if let Some(buf) = &vc_line_buf {
623                        pass.set_pipeline(&self.overlay_line_pipeline);
624                        pass.set_vertex_buffer(0, buf.slice(..));
625                        pass.draw(0..6, 0..vc_line_count);
626                    }
627                }
628            }
629        }
630
631        self.queue.submit([encoder.finish()]);
632    }
633
634    /// Headless capture (R32/R34): render the scene and return PNG bytes
635    /// (8-bit RGB, no ancillary chunks — deterministic, R33).
636    #[cfg(not(target_arch = "wasm32"))]
637    pub fn render_to_png(
638        &mut self,
639        scene: &RenderScene,
640        camera: &Camera,
641        width: u32,
642        height: u32,
643    ) -> Result<Vec<u8>, String> {
644        let settings = RenderSettings::artifact();
645        let emphasis = Emphasis::default();
646        let mut gpu_scene = self.upload_scene_with(scene, &settings);
647        let params = FrameParams {
648            camera,
649            width,
650            height,
651            dpr: 1.0,
652            settings: &settings,
653            emphasis: &emphasis,
654            world_per_pixel: 0.0,
655            overlay: None,
656        };
657        let resolve = self.device.create_texture(&wgpu::TextureDescriptor {
658            label: Some("resolve"),
659            size: wgpu::Extent3d {
660                width,
661                height,
662                depth_or_array_layers: 1,
663            },
664            mip_level_count: 1,
665            sample_count: 1,
666            dimension: wgpu::TextureDimension::D2,
667            format: self.format,
668            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
669            view_formats: &[],
670        });
671        let resolve_view = resolve.create_view(&Default::default());
672        self.render_to_view(&mut gpu_scene, scene, &params, &resolve_view);
673
674        // Readback: rows padded to 256 bytes per wgpu's copy alignment.
675        let bytes_per_row = (width * 4).div_ceil(256) * 256;
676        let readback = self.device.create_buffer(&wgpu::BufferDescriptor {
677            label: Some("readback"),
678            size: bytes_per_row as u64 * height as u64,
679            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
680            mapped_at_creation: false,
681        });
682        let mut encoder = self
683            .device
684            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
685                label: Some("readback"),
686            });
687        encoder.copy_texture_to_buffer(
688            wgpu::TexelCopyTextureInfo {
689                texture: &resolve,
690                mip_level: 0,
691                origin: wgpu::Origin3d::ZERO,
692                aspect: wgpu::TextureAspect::All,
693            },
694            wgpu::TexelCopyBufferInfo {
695                buffer: &readback,
696                layout: wgpu::TexelCopyBufferLayout {
697                    offset: 0,
698                    bytes_per_row: Some(bytes_per_row),
699                    rows_per_image: None,
700                },
701            },
702            wgpu::Extent3d {
703                width,
704                height,
705                depth_or_array_layers: 1,
706            },
707        );
708        self.queue.submit([encoder.finish()]);
709
710        let slice = readback.slice(..);
711        let (sender, receiver) = std::sync::mpsc::channel();
712        slice.map_async(wgpu::MapMode::Read, move |result| {
713            let _ = sender.send(result);
714        });
715        self.device
716            .poll(wgpu::PollType::wait_indefinitely())
717            .map_err(|error| format!("wgpu poll: {error:?}"))?;
718        receiver
719            .recv()
720            .map_err(|_| "readback callback dropped".to_string())?
721            .map_err(|error| format!("readback map failed: {error:?}"))?;
722
723        let data = slice.get_mapped_range();
724        let mut rgb = Vec::with_capacity((width * height * 3) as usize);
725        for row in 0..height {
726            let start = (row * bytes_per_row) as usize;
727            for col in 0..width as usize {
728                let px = start + col * 4;
729                rgb.extend_from_slice(&data[px..px + 3]);
730            }
731        }
732        drop(data);
733        readback.unmap();
734
735        encode_png(&rgb, width, height)
736    }
737}
738
739/// Issue draw calls for a solid's VISIBLE edge instances only, coalescing
740/// contiguous edge ranges into as few `draw`s as possible. Used by the
741/// single-style edge passes (occluded/hidden, and the no-emphasis visible pass)
742/// when the solid has any hidden edge — the caller has already bound the style.
743/// The coarsest view, in world millimetres per CSS pixel, that still draws AUX
744/// edges — a board's copper outline. Past it a 0.2 mm track is under two
745/// pixels, so its outline would cover it instead of edging it and a dense track
746/// field reads as one slab; and a board seen whole at that scale is exactly
747/// when the most of its thousands of outline segments are on screen at once.
748/// Zooming in brings them back.
749const AUX_EDGE_MAX_WORLD_PER_PIXEL: f64 = 0.1;
750
751/// Which of a solid's edges a [`draw_visible_edge_ranges`] call draws.
752#[derive(Clone, Copy, PartialEq, Eq)]
753enum EdgeFilter {
754    /// The solid's own edges, not its display-only (aux) ones.
755    Real,
756    /// Only the display-only ones.
757    Aux,
758}
759
760fn draw_visible_edge_ranges(
761    pass: &mut wgpu::RenderPass<'_>,
762    solid: &SolidDisplay,
763    gpu_solid: &GpuSolid,
764    filter: EdgeFilter,
765) {
766    let mut run: Option<(u32, u32)> = None; // first_instance, count
767    for (index, edge) in solid.edges.iter().enumerate() {
768        let range = &gpu_solid.edges[index];
769        if range.instance_count == 0 {
770            continue;
771        }
772        if !solid.visibility.is_edge_visible(index) || edge.aux != (filter == EdgeFilter::Aux) {
773            if let Some((first, count)) = run.take() {
774                pass.draw(0..6, first..first + count);
775            }
776            continue;
777        }
778        match &mut run {
779            Some((first, count)) if *first + *count == range.first_instance => {
780                *count += range.instance_count;
781            }
782            _ => {
783                if let Some((first, count)) = run.take() {
784                    pass.draw(0..6, first..first + count);
785                }
786                run = Some((range.first_instance, range.instance_count));
787            }
788        }
789    }
790    if let Some((first, count)) = run {
791        pass.draw(0..6, first..first + count);
792    }
793}
794
795/// Convert a gizmo `Overlay`'s triangles into GPU vertices (per-vertex color).
796fn overlay_tri_verts(ov: &brep_gizmos::Overlay) -> Vec<OverlayTriVertex> {
797    ov.tris
798        .iter()
799        .map(|v| OverlayTriVertex {
800            position: v.pos,
801            normal: v.normal,
802            color: v.color,
803        })
804        .collect()
805}
806
807/// Convert a gizmo `Overlay`'s line segments (vertex pairs) into GPU instances
808/// (per-instance color; both endpoints of a gizmo segment share a color).
809fn overlay_line_insts(ov: &brep_gizmos::Overlay) -> Vec<OverlayLineInstance> {
810    ov.lines
811        .chunks_exact(2)
812        .map(|pair| OverlayLineInstance {
813            p0: pair[0].pos,
814            p1: pair[1].pos,
815            color: pair[0].color,
816        })
817        .collect()
818}