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        self.render_to_view_with_context(gpu_scene, scene, params, resolve_view, None);
15    }
16
17    /// Draw optional, non-pickable assembly context after the opaque work part.
18    pub fn render_to_view_with_context(
19        &mut self,
20        gpu_scene: &mut GpuScene,
21        scene: &RenderScene,
22        params: &FrameParams,
23        resolve_view: &wgpu::TextureView,
24        context: Option<(&GpuScene, &RenderScene)>,
25    ) {
26        let width = params.width.max(1);
27        let height = params.height.max(1);
28        self.ensure_targets(width, height);
29        self.write_global_styles(params.settings);
30
31        let globals = Globals {
32            view_proj: params.camera.view_proj,
33            viewport: [width as f32, height as f32, params.dpr.max(1e-3), 0.0],
34            forward: [
35                params.camera.forward[0],
36                params.camera.forward[1],
37                params.camera.forward[2],
38                0.0,
39            ],
40        };
41        self.queue
42            .write_buffer(&self.globals_buf, 0, bytemuck::bytes_of(&globals));
43
44        // World-axis helper (R20): three world-axis segments sized in CSS px.
45        let axis_len = params.settings.axis_length_px as f64 * params.world_per_pixel;
46        let draw_axes = params.settings.axis_length_px > 0.0 && axis_len.is_finite() && axis_len > 0.0;
47        if draw_axes {
48            let l = axis_len as f32;
49            let segments = [
50                EdgeInstance { p0: [0.0; 3], p1: [l, 0.0, 0.0] },
51                EdgeInstance { p0: [0.0; 3], p1: [0.0, l, 0.0] },
52                EdgeInstance { p0: [0.0; 3], p1: [0.0, 0.0, l] },
53            ];
54            match &gpu_scene.axis_buf {
55                Some(buf) => self.queue.write_buffer(buf, 0, bytemuck::cast_slice(&segments)),
56                None => {
57                    gpu_scene.axis_buf = Some(self.device.create_buffer_init(
58                        &wgpu::util::BufferInitDescriptor {
59                            label: Some("world axes"),
60                            contents: bytemuck::cast_slice(&segments),
61                            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
62                        },
63                    ));
64                }
65            }
66        }
67
68        // Refresh emphasis boundary buffers.
69        let has_emphasis = !params.emphasis.is_empty();
70        // A solid a transparency clip made see-through draws in the blended pass.
71        let translucent = |solid: &SolidDisplay| solid.opacity.is_some_and(|opacity| opacity < 1.0);
72        for solid in scene.solids() {
73            if let Some(gpu_solid) = gpu_scene.solids.get_mut(&solid.name) {
74                self.sync_boundary(gpu_solid, solid, params.emphasis);
75            }
76        }
77
78        let bg = params.settings.background;
79        let targets = self.targets.as_ref().expect("targets ensured");
80        let mut encoder = self
81            .device
82            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
83                label: Some("brep-render frame"),
84            });
85        {
86            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
87                label: Some("scene"),
88                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
89                    view: &targets.msaa_view,
90                    depth_slice: None,
91                    resolve_target: Some(resolve_view),
92                    ops: wgpu::Operations {
93                        load: wgpu::LoadOp::Clear(wgpu::Color {
94                            r: bg[0] as f64,
95                            g: bg[1] as f64,
96                            b: bg[2] as f64,
97                            a: 1.0,
98                        }),
99                        store: wgpu::StoreOp::Store,
100                    },
101                })],
102                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
103                    view: &targets.depth_view,
104                    depth_ops: Some(wgpu::Operations {
105                        load: wgpu::LoadOp::Clear(1.0),
106                        store: wgpu::StoreOp::Discard,
107                    }),
108                    stencil_ops: None,
109                }),
110                timestamp_writes: None,
111                occlusion_query_set: None,
112                multiview_mask: None,
113            });
114            pass.set_bind_group(0, &self.globals_bind, &[]);
115            // The solid pipelines' group 2: a solid's pose, set beside each
116            // solid's buffers; identity for anything else they draw.
117            pass.set_bind_group(2, &self.identity_model.bind, &[0]);
118
119            let visible_solids: Vec<(&SolidDisplay, &GpuSolid)> = gpu_scene
120                .order
121                .iter()
122                .filter_map(|name| {
123                    let solid = scene.solid(name)?;
124                    if !solid.visible {
125                        return None;
126                    }
127                    Some((solid, gpu_scene.solids.get(name)?))
128                })
129                .collect();
130
131            // 1a. Wireframe view: draw each solid's tessellated triangle mesh as
132            //     a line list (base color, no fill), so the face triangles read
133            //     as a wireframe. Picking is CPU ray-based (pick.rs), unaffected.
134            //     A HIDDEN face skips its triangles' wire segments too, exactly as
135            //     the shaded pass masks its triangle ranges — otherwise hiding a
136            //     face (or the whole Faces group) would be a no-op in wireframe view.
137            if params.settings.wireframe && params.settings.show_faces {
138                pass.set_pipeline(&self.wire_pipeline);
139                for (solid, gpu_solid) in &visible_solids {
140                    if gpu_solid.wire_index_count == 0 {
141                        continue;
142                    }
143                    bind_pose(&mut pass, gpu_scene.models.as_ref(), &self.identity_model, gpu_solid.model_slot);
144                    pass.set_vertex_buffer(0, gpu_solid.vertex_buf.slice(..));
145                    pass.set_index_buffer(
146                        gpu_solid.wire_index_buf.slice(..),
147                        wgpu::IndexFormat::Uint32,
148                    );
149                    pass.set_bind_group(1, &gpu_solid.base_style.bind, &[]);
150                    // Fast path: no hidden face → one whole-buffer wire draw.
151                    if !solid.visibility.any_face_hidden() {
152                        pass.draw_indexed(0..gpu_solid.wire_index_count, 0, 0..1);
153                        continue;
154                    }
155                    // Otherwise coalesce contiguous VISIBLE faces' wire ranges and
156                    // skip the hidden ones. The wire buffer is a LINE LIST whose
157                    // segments are grouped by face and de-duplicated inside each
158                    // face (see `scene_sync::upload_solid`), so a face's slice is
159                    // whatever that grouping produced — read it off the face's
160                    // `wire_first`/`wire_count` rather than deriving it from the
161                    // triangle range, which no longer predicts it.
162                    let mut run: Option<(u32, u32)> = None; // first, count
163                    let flush = |pass: &mut wgpu::RenderPass, run: &mut Option<(u32, u32)>| {
164                        if let Some((first, count)) = run.take() {
165                            if count > 0 {
166                                pass.draw_indexed(first..first + count, 0, 0..1);
167                            }
168                        }
169                    };
170                    for (index, _face) in solid.faces.iter().enumerate() {
171                        let range = &gpu_solid.faces[index];
172                        if range.wire_count == 0 {
173                            continue;
174                        }
175                        let first = range.wire_first;
176                        let count = range.wire_count;
177                        if !solid.visibility.is_face_visible(index) {
178                            flush(&mut pass, &mut run);
179                            continue;
180                        }
181                        match &mut run {
182                            Some((run_first, run_count)) if *run_first + *run_count == first => {
183                                *run_count += count;
184                            }
185                            _ => {
186                                flush(&mut pass, &mut run);
187                                run = Some((first, count));
188                            }
189                        }
190                    }
191                    flush(&mut pass, &mut run);
192                }
193            }
194
195            // 1. Shaded faces, coalesced into index-range runs per emphasis
196            //    state (base runs merge back into whole-solid draws). Skipped in
197            //    wireframe mode (1a draws the triangle wireframe instead). Picking
198            //    is CPU ray-based (pick.rs) and the overlay pass is separate.
199            if !params.settings.wireframe && params.settings.show_faces {
200                pass.set_pipeline(&self.mesh_pipeline);
201                for (solid, gpu_solid) in &visible_solids {
202                    if solid.mesh.indices.is_empty() || translucent(solid) {
203                        continue;
204                    }
205                    bind_pose(&mut pass, gpu_scene.models.as_ref(), &self.identity_model, gpu_solid.model_slot);
206                    pass.set_vertex_buffer(0, gpu_solid.vertex_buf.slice(..));
207                    pass.set_index_buffer(gpu_solid.index_buf.slice(..), wgpu::IndexFormat::Uint32);
208                    // Fast path: no emphasis AND no hidden faces → one whole-mesh
209                    // draw. When any face is hidden we fall to the per-face loop
210                    // below, which coalesces contiguous VISIBLE faces and skips
211                    // the hidden ones' triangle ranges entirely.
212                    // The whole-mesh fast path also needs every face to share
213                    // the solid's colour — a solid with a per-face palette has
214                    // to walk its faces to bind the right style per run.
215                    let any_face_hidden = solid.visibility.any_face_hidden();
216                    let any_face_colored = !gpu_solid.face_styles.is_empty();
217                    if !has_emphasis && !any_face_hidden && !any_face_colored {
218                        pass.set_bind_group(1, &gpu_solid.base_style.bind, &[]);
219                        pass.draw_indexed(0..solid.mesh.indices.len() as u32, 0, 0..1);
220                        continue;
221                    }
222                    // The run key is (emphasis state, face-colour slot). A
223                    // selected or hovered face draws in the emphasis colour
224                    // whatever colour the model gave it, so its slot is
225                    // normalized away — two adjacent selected faces of
226                    // different colours still coalesce into one draw.
227                    let style_for = |state: EmphasisState, slot: Option<u32>| match state {
228                        EmphasisState::Base => match slot.and_then(|s| gpu_solid.face_styles.get(s as usize)) {
229                            Some(style) => &style.bind,
230                            None => &gpu_solid.base_style.bind,
231                        },
232                        EmphasisState::Selected => &self.styles.face_selected.bind,
233                        EmphasisState::Hovered => &self.styles.face_hovered.bind,
234                    };
235                    type FaceRun = (EmphasisState, Option<u32>, u32, u32); // state, slot, first, count
236                    let mut run: Option<FaceRun> = None;
237                    let flush = |pass: &mut wgpu::RenderPass, run: &mut Option<FaceRun>| {
238                        if let Some((state, slot, first, count)) = run.take() {
239                            if count > 0 {
240                                pass.set_bind_group(1, style_for(state, slot), &[]);
241                                pass.draw_indexed(first..first + count, 0, 0..1);
242                            }
243                        }
244                    };
245                    for (index, face) in solid.faces.iter().enumerate() {
246                        let range = &gpu_solid.faces[index];
247                        if range.index_count == 0 {
248                            continue;
249                        }
250                        // Hidden face: skip its triangles and break the run so
251                        // the surviving neighbours don't coalesce across the gap.
252                        if !solid.visibility.is_face_visible(index) {
253                            flush(&mut pass, &mut run);
254                            continue;
255                        }
256                        let state = if has_emphasis {
257                            params.emphasis.face_state(&solid.name, &face.name)
258                        } else {
259                            EmphasisState::Base
260                        };
261                        let slot = if state == EmphasisState::Base {
262                            range.style
263                        } else {
264                            None
265                        };
266                        match &mut run {
267                            Some((run_state, run_slot, first, count))
268                                if *run_state == state
269                                    && *run_slot == slot
270                                    && *first + *count == range.first_index =>
271                            {
272                                *count += range.index_count;
273                            }
274                            _ => {
275                                flush(&mut pass, &mut run);
276                                run = Some((state, slot, range.first_index, range.index_count));
277                            }
278                        }
279                    }
280                    flush(&mut pass, &mut run);
281                }
282            }
283
284            // 2. Occluded edge portions, dimmed (depth test inverted). Hidden
285            //    edges skip their segments (their occluded portion too).
286            if params.settings.show_edges
287                && params.settings.show_occluded_edges
288                && params.settings.hidden_edge_alpha > 0.0
289            {
290                pass.set_pipeline(&self.edge_hidden_pipeline);
291                pass.set_bind_group(1, &self.styles.edge_hidden.bind, &[]);
292                for (solid, gpu_solid) in &visible_solids {
293                    let Some(edge_buf) = &gpu_solid.edge_buf else { continue };
294                    if gpu_solid.occludable_edge_instances == 0 {
295                        continue;
296                    }
297                    bind_pose(&mut pass, gpu_scene.models.as_ref(), &self.identity_model, gpu_solid.model_slot);
298                    pass.set_vertex_buffer(0, edge_buf.slice(..));
299                    if solid.visibility.any_edge_hidden() {
300                        // Single style already bound: draw only visible edges.
301                        draw_visible_edge_ranges(&mut pass, solid, gpu_solid, EdgeFilter::Real);
302                    } else {
303                        // Aux edges are not drawn occluded (`occludable_edge_instances`).
304                        pass.draw(0..6, 0..gpu_solid.occludable_edge_instances);
305                    }
306                }
307            }
308
309            // 3. Visible edges, per-edge emphasis runs; hidden edges skipped.
310            //    The whole pass is off when the Edges display toggle is.
311            if params.settings.show_edges {
312                let aux_shown = params.world_per_pixel <= AUX_EDGE_MAX_WORLD_PER_PIXEL;
313                pass.set_pipeline(&self.edge_visible_pipeline);
314                for (solid, gpu_solid) in &visible_solids {
315                    let Some(edge_buf) = &gpu_solid.edge_buf else { continue };
316                    if gpu_solid.edge_instances == 0 {
317                        continue;
318                    }
319                    bind_pose(&mut pass, gpu_scene.models.as_ref(), &self.identity_model, gpu_solid.model_slot);
320                    pass.set_vertex_buffer(0, edge_buf.slice(..));
321                    let any_edge_hidden = solid.visibility.any_edge_hidden();
322                    // The real edges are a prefix of the buffer and the AUX
323                    // ones the rest; at rest the two draw in their own styles,
324                    // and the aux ones only while the view is close enough
325                    // for them to be told apart (`AUX_EDGE_MAX_WORLD_PER_PIXEL`).
326                    let split = gpu_solid.occludable_edge_instances;
327                    if !has_emphasis {
328                        pass.set_bind_group(1, &self.styles.edge_base.bind, &[]);
329                        if any_edge_hidden {
330                            draw_visible_edge_ranges(&mut pass, solid, gpu_solid, EdgeFilter::Real);
331                            if aux_shown {
332                                pass.set_bind_group(1, &self.styles.edge_aux.bind, &[]);
333                                draw_visible_edge_ranges(&mut pass, solid, gpu_solid, EdgeFilter::Aux);
334                            }
335                        } else {
336                            if split > 0 {
337                                pass.draw(0..6, 0..split);
338                            }
339                            if aux_shown && split < gpu_solid.edge_instances {
340                                pass.set_bind_group(1, &self.styles.edge_aux.bind, &[]);
341                                pass.draw(0..6, split..gpu_solid.edge_instances);
342                            }
343                        }
344                        continue;
345                    }
346                    // An aux edge at rest keeps its own style; hovered or
347                    // selected (its SOLID is), it emphasises like any edge.
348                    let style_for = |(state, aux): (EmphasisState, bool)| match state {
349                        EmphasisState::Base if aux => &self.styles.edge_aux.bind,
350                        EmphasisState::Base => &self.styles.edge_base.bind,
351                        EmphasisState::Selected => &self.styles.edge_selected.bind,
352                        EmphasisState::Hovered => &self.styles.edge_hovered.bind,
353                    };
354                    let mut run: Option<((EmphasisState, bool), u32, u32)> = None;
355                    let flush = |pass: &mut wgpu::RenderPass, run: &mut Option<((EmphasisState, bool), u32, u32)>| {
356                        if let Some((state, first, count)) = run.take() {
357                            if count > 0 {
358                                pass.set_bind_group(1, style_for(state), &[]);
359                                pass.draw(0..6, first..first + count);
360                            }
361                        }
362                    };
363                    for (index, edge) in solid.edges.iter().enumerate() {
364                        let range = &gpu_solid.edges[index];
365                        if range.instance_count == 0 {
366                            continue;
367                        }
368                        // Hidden edge: skip its segments and break the run.
369                        if !solid.visibility.is_edge_visible(index) || (edge.aux && !aux_shown) {
370                            flush(&mut pass, &mut run);
371                            continue;
372                        }
373                        let state = (params.emphasis.edge_state(&solid.name, &edge.name), edge.aux);
374                        match &mut run {
375                            Some((run_state, first, count))
376                                if *run_state == state
377                                    && *first + *count == range.first_instance =>
378                            {
379                                *count += range.instance_count;
380                            }
381                            _ => {
382                                flush(&mut pass, &mut run);
383                                run = Some((state, range.first_instance, range.instance_count));
384                            }
385                        }
386                    }
387                    flush(&mut pass, &mut run);
388                }
389            }
390
391            // 4. Selected/hovered face boundary outlines.
392            if has_emphasis {
393                // Bind the pipeline explicitly rather than inheriting whatever
394                // the passes above left set: pass 3 binds this same pipeline,
395                // but only when `show_edges` is on. With edges hidden the
396                // inherited pipeline was the mesh/wireframe one, whose slot 0
397                // is a Vertex-step `MeshVertex` — so the `draw(0..6, ..)` below
398                // read this Instance-step `EdgeInstance` buffer as vertices and
399                // overran it (a 4-segment boundary is 4 "vertices" at the same
400                // 24-byte stride), which wgpu reports as a fatal validation error.
401                pass.set_pipeline(&self.edge_visible_pipeline);
402                pass.set_bind_group(1, &self.styles.boundary.bind, &[]);
403                for (_, gpu_solid) in &visible_solids {
404                    let Some(boundary) = &gpu_solid.boundary else { continue };
405                    let Some(buf) = &boundary.buf else { continue };
406                    if boundary.count == 0 {
407                        continue;
408                    }
409                    pass.set_bind_group(2, &self.identity_model.bind, &[0]);
410                    pass.set_vertex_buffer(0, buf.slice(..));
411                    pass.draw(0..6, 0..boundary.count);
412                }
413            }
414
415            // 5. Vertex points; hidden vertices skip their point sprite.
416            if params.settings.show_vertices && params.settings.vertex_size_px > 0.0 {
417                pass.set_pipeline(&self.point_pipeline);
418                for (solid, gpu_solid) in &visible_solids {
419                    let Some(point_buf) = &gpu_solid.point_buf else { continue };
420                    if gpu_solid.point_count == 0 {
421                        continue;
422                    }
423                    // A fully-hidden points group draws nothing — skip before touching
424                    // the per-vertex loop below. When ALL vertices are hidden that loop
425                    // would still iterate every one of them each frame; the O(N)/frame
426                    // cost is invisible on native but throttles the WebGL backend on
427                    // point-heavy models (the "can't spin after hiding points" case).
428                    if solid.visibility.all_vertices_hidden(solid.vertices.len()) {
429                        continue;
430                    }
431                    bind_pose(&mut pass, gpu_scene.models.as_ref(), &self.identity_model, gpu_solid.model_slot);
432                    pass.set_vertex_buffer(0, point_buf.slice(..));
433                    let any_vertex_hidden = solid.visibility.any_vertex_hidden();
434                    if has_emphasis || any_vertex_hidden {
435                        let tol = 1e-9_f64.max(params.world_per_pixel * 1e-3);
436                        let mut base_run: Option<(u32, u32)> = None;
437                        let mut emphasized: Vec<(EmphasisState, u32)> = Vec::new();
438                        for (index, vertex) in solid.vertices.iter().enumerate() {
439                            // Hidden vertex: skip its point and break the run.
440                            if !solid.visibility.is_vertex_visible(index) {
441                                if let Some((first, count)) = base_run.take() {
442                                    pass.set_bind_group(1, &self.styles.point_base.bind, &[]);
443                                    pass.draw(0..6, first..first + count);
444                                }
445                                continue;
446                            }
447                            let state = if has_emphasis {
448                                params.emphasis.vertex_state(&solid.name, vertex.position, tol)
449                            } else {
450                                EmphasisState::Base
451                            };
452                            if state == EmphasisState::Base {
453                                match &mut base_run {
454                                    Some((first, count)) if *first + *count == index as u32 => {
455                                        *count += 1
456                                    }
457                                    _ => {
458                                        if let Some((first, count)) = base_run.take() {
459                                            pass.set_bind_group(1, &self.styles.point_base.bind, &[]);
460                                            pass.draw(0..6, first..first + count);
461                                        }
462                                        base_run = Some((index as u32, 1));
463                                    }
464                                }
465                            } else {
466                                emphasized.push((state, index as u32));
467                            }
468                        }
469                        if let Some((first, count)) = base_run {
470                            pass.set_bind_group(1, &self.styles.point_base.bind, &[]);
471                            pass.draw(0..6, first..first + count);
472                        }
473                        for (state, index) in emphasized {
474                            let style = match state {
475                                EmphasisState::Selected => &self.styles.point_selected.bind,
476                                _ => &self.styles.point_hovered.bind,
477                            };
478                            pass.set_bind_group(1, style, &[]);
479                            pass.draw(0..6, index..index + 1);
480                        }
481                    } else {
482                        pass.set_bind_group(1, &self.styles.point_base.bind, &[]);
483                        pass.draw(0..6, 0..gpu_solid.point_count);
484                    }
485                }
486            }
487
488            // 6. World axes on top of nothing special (normal depth test).
489            if draw_axes {
490                if let Some(axis_buf) = &gpu_scene.axis_buf {
491                    pass.set_pipeline(&self.edge_visible_pipeline);
492                    pass.set_bind_group(2, &self.identity_model.bind, &[0]);
493                    pass.set_vertex_buffer(0, axis_buf.slice(..));
494                    for (index, style) in [
495                        &self.styles.axis_x,
496                        &self.styles.axis_y,
497                        &self.styles.axis_z,
498                    ]
499                    .iter()
500                    .enumerate()
501                    {
502                        pass.set_bind_group(1, &style.bind, &[]);
503                        let i = index as u32;
504                        pass.draw(0..6, i..i + 1);
505                    }
506                }
507            }
508            // Translucent solids (a motion study's transparency clip): after
509            // every opaque thing, through the blended context pipeline (depth
510            // test on, depth write off), each with its own base style, whose
511            // alpha `face_base_color` took from the display's opacity.
512            if !params.settings.wireframe && params.settings.show_faces {
513                let mut any = false;
514                for (solid, gpu_solid) in visible_solids.iter().filter(|(solid, _)| translucent(solid) && !solid.mesh.indices.is_empty()) {
515                    if !any {
516                        pass.set_pipeline(&self.context_pipeline);
517                        any = true;
518                    }
519                    bind_pose(&mut pass, gpu_scene.models.as_ref(), &self.identity_model, gpu_solid.model_slot);
520                    pass.set_vertex_buffer(0, gpu_solid.vertex_buf.slice(..));
521                    pass.set_index_buffer(gpu_solid.index_buf.slice(..), wgpu::IndexFormat::Uint32);
522                    pass.set_bind_group(1, &gpu_solid.base_style.bind, &[]);
523                    if !solid.visibility.any_face_hidden() {
524                        pass.draw_indexed(0..solid.mesh.indices.len() as u32, 0, 0..1);
525                        continue;
526                    }
527                    for (index, face) in gpu_solid.faces.iter().enumerate() {
528                        if solid.visibility.is_face_visible(index) {
529                            pass.draw_indexed(face.first_index..face.first_index + face.index_count, 0, 0..1);
530                        }
531                    }
532                }
533            }
534            if let Some((gpu_context, context_scene)) = context {
535                // Blend without depth writes: surroundings must not hide the
536                // editable geometry. Keep the work part's depth buffer intact.
537                self.context_style.write(&self.queue, context_scene.tint.unwrap_or(super::WORK_PART_CONTEXT_RGBA), [0.0; 4]);
538                pass.set_pipeline(&self.context_pipeline);
539                pass.set_bind_group(1, &self.context_style.bind, &[]);
540                for solid in context_scene.solids().iter().filter(|solid| solid.visible) {
541                    let Some(gpu) = gpu_context.solids.get(&solid.name) else { continue };
542                    bind_pose(&mut pass, gpu_context.models.as_ref(), &self.identity_model, gpu.model_slot);
543                    pass.set_vertex_buffer(0, gpu.vertex_buf.slice(..));
544                    pass.set_index_buffer(gpu.index_buf.slice(..), wgpu::IndexFormat::Uint32);
545                    if !solid.visibility.any_face_hidden() {
546                        pass.draw_indexed(0..solid.mesh.indices.len() as u32, 0, 0..1);
547                        continue;
548                    }
549                    for (index, face) in gpu.faces.iter().enumerate() {
550                        if solid.visibility.is_face_visible(index) {
551                            pass.draw_indexed(face.first_index..face.first_index + face.index_count, 0, 0..1);
552                        }
553                    }
554                }
555                // The context face picked or hovered, over its translucent self;
556                // or the context edge a sketch would copy, as a lit edge.
557                for (solid_name, face_name, selected) in &context_scene.highlight {
558                    let Some(solid) = context_scene.solid(solid_name) else { continue };
559                    let Some(gpu) = gpu_context.solids.get(solid_name) else { continue };
560                    let Some(index) = solid.faces.iter().position(|face| &face.name == face_name) else {
561                        let edge = solid.edges.iter().position(|edge| &edge.name == face_name);
562                        let (Some(index), Some(edge_buf)) = (edge, &gpu.edge_buf) else { continue };
563                        let Some(range) = gpu.edges.get(index) else { continue };
564                        pass.set_pipeline(&self.edge_visible_pipeline);
565                        pass.set_bind_group(1, if *selected { &self.styles.edge_selected.bind } else { &self.styles.edge_hovered.bind }, &[]);
566                        bind_pose(&mut pass, gpu_context.models.as_ref(), &self.identity_model, gpu.model_slot);
567                        pass.set_vertex_buffer(0, edge_buf.slice(..));
568                        pass.draw(0..6, range.first_instance..range.first_instance + range.instance_count);
569                        pass.set_pipeline(&self.context_pipeline);
570                        continue;
571                    };
572                    let Some(range) = gpu.faces.get(index) else { continue };
573                    pass.set_bind_group(1, if *selected { &self.context_selected.bind } else { &self.context_hovered.bind }, &[]);
574                    bind_pose(&mut pass, gpu_context.models.as_ref(), &self.identity_model, gpu.model_slot);
575                    pass.set_vertex_buffer(0, gpu.vertex_buf.slice(..));
576                    pass.set_index_buffer(gpu.index_buf.slice(..), wgpu::IndexFormat::Uint32);
577                    pass.draw_indexed(range.first_index..range.first_index + range.index_count, 0, 0..1);
578                }
579            }
580        }
581
582        // --- Overlay-widget passes: the brep-gizmos overlay drawn
583        //     over the solids in a depth-cleared pass so widgets read on top.
584        //     The main overlay uses the scene camera + full viewport; the
585        //     ViewCube uses its own mini-camera + corner viewport.
586        if let Some(overlay) = params.overlay {
587            let make_tris = |ov: &brep_gizmos::Overlay| -> Option<wgpu::Buffer> {
588                let verts = overlay_tri_verts(ov);
589                (!verts.is_empty()).then(|| {
590                    self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
591                        label: Some("overlay tris"),
592                        contents: bytemuck::cast_slice(&verts),
593                        usage: wgpu::BufferUsages::VERTEX,
594                    })
595                })
596            };
597            let make_lines = |ov: &brep_gizmos::Overlay| -> Option<wgpu::Buffer> {
598                let insts = overlay_line_insts(ov);
599                (!insts.is_empty()).then(|| {
600                    self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
601                        label: Some("overlay lines"),
602                        contents: bytemuck::cast_slice(&insts),
603                        usage: wgpu::BufferUsages::VERTEX,
604                    })
605                })
606            };
607
608            let main_tri_count = overlay.main.tris.len() as u32;
609            let main_line_count = (overlay.main.lines.len() / 2) as u32;
610            let main_tri_buf = make_tris(&overlay.main);
611            let main_line_buf = make_lines(&overlay.main);
612            if main_tri_buf.is_some() || main_line_buf.is_some() {
613                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
614                    label: Some("overlay-main"),
615                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
616                        view: &targets.msaa_view,
617                        depth_slice: None,
618                        resolve_target: Some(resolve_view),
619                        ops: wgpu::Operations {
620                            load: wgpu::LoadOp::Load,
621                            store: wgpu::StoreOp::Store,
622                        },
623                    })],
624                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
625                        view: &targets.depth_view,
626                        depth_ops: Some(wgpu::Operations {
627                            load: wgpu::LoadOp::Clear(1.0),
628                            store: wgpu::StoreOp::Discard,
629                        }),
630                        stencil_ops: None,
631                    }),
632                    timestamp_writes: None,
633                    occlusion_query_set: None,
634                    multiview_mask: None,
635                });
636                pass.set_bind_group(0, &self.globals_bind, &[]);
637                pass.set_bind_group(1, &self.styles.overlay_line.bind, &[]);
638                if let Some(buf) = &main_tri_buf {
639                    pass.set_vertex_buffer(0, buf.slice(..));
640                    // The datum/construction PLANE tris come FIRST; draw them with
641                    // depth-write OFF so a translucent plane never occludes the
642                    // gizmo/dimension tris that follow. The rest keep depth-write
643                    // so gizmos self-occlude correctly.
644                    let plane_verts = (overlay.plane_tri_verts as u32).min(main_tri_count);
645                    if plane_verts > 0 {
646                        pass.set_pipeline(&self.overlay_tri_nodepth_pipeline);
647                        pass.draw(0..plane_verts, 0..1);
648                    }
649                    if plane_verts < main_tri_count {
650                        pass.set_pipeline(&self.overlay_tri_pipeline);
651                        pass.draw(plane_verts..main_tri_count, 0..1);
652                    }
653                }
654                if let Some(buf) = &main_line_buf {
655                    pass.set_pipeline(&self.overlay_line_pipeline);
656                    pass.set_vertex_buffer(0, buf.slice(..));
657                    pass.draw(0..6, 0..main_line_count);
658                }
659            }
660
661            if let Some(vc) = &overlay.viewcube {
662                let dpr = params.dpr.max(1e-3);
663                let mut x = (vc.rect_css[0] * dpr).max(0.0);
664                let mut y = (vc.rect_css[1] * dpr).max(0.0);
665                let mut w = (vc.rect_css[2] * dpr).max(1.0);
666                let mut h = (vc.rect_css[3] * dpr).max(1.0);
667                // Clamp the corner viewport to the framebuffer.
668                w = w.min(width as f32 - x).max(1.0);
669                h = h.min(height as f32 - y).max(1.0);
670                x = x.min(width as f32 - w).max(0.0);
671                y = y.min(height as f32 - h).max(0.0);
672
673                let vc_globals = Globals {
674                    view_proj: vc.view_proj,
675                    viewport: [w, h, dpr, 0.0],
676                    forward: [vc.forward[0], vc.forward[1], vc.forward[2], 0.0],
677                };
678                self.queue
679                    .write_buffer(&self.vc_globals_buf, 0, bytemuck::bytes_of(&vc_globals));
680
681                let vc_tri_count = vc.overlay.tris.len() as u32;
682                let vc_line_count = (vc.overlay.lines.len() / 2) as u32;
683                let vc_tri_buf = make_tris(&vc.overlay);
684                let vc_line_buf = make_lines(&vc.overlay);
685                if vc_tri_buf.is_some() || vc_line_buf.is_some() {
686                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
687                        label: Some("overlay-viewcube"),
688                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
689                            view: &targets.msaa_view,
690                            depth_slice: None,
691                            resolve_target: Some(resolve_view),
692                            ops: wgpu::Operations {
693                                load: wgpu::LoadOp::Load,
694                                store: wgpu::StoreOp::Store,
695                            },
696                        })],
697                        depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
698                            view: &targets.depth_view,
699                            depth_ops: Some(wgpu::Operations {
700                                load: wgpu::LoadOp::Clear(1.0),
701                                store: wgpu::StoreOp::Discard,
702                            }),
703                            stencil_ops: None,
704                        }),
705                        timestamp_writes: None,
706                        occlusion_query_set: None,
707                        multiview_mask: None,
708                    });
709                    pass.set_viewport(x, y, w, h, 0.0, 1.0);
710                    pass.set_scissor_rect(x as u32, y as u32, w as u32, h as u32);
711                    pass.set_bind_group(0, &self.vc_globals_bind, &[]);
712                    pass.set_bind_group(1, &self.styles.overlay_line.bind, &[]);
713                    if let Some(buf) = &vc_tri_buf {
714                        pass.set_pipeline(&self.overlay_tri_pipeline);
715                        pass.set_vertex_buffer(0, buf.slice(..));
716                        pass.draw(0..vc_tri_count, 0..1);
717                    }
718                    if let Some(buf) = &vc_line_buf {
719                        pass.set_pipeline(&self.overlay_line_pipeline);
720                        pass.set_vertex_buffer(0, buf.slice(..));
721                        pass.draw(0..6, 0..vc_line_count);
722                    }
723                }
724            }
725        }
726
727        self.queue.submit([encoder.finish()]);
728    }
729
730    /// Headless capture (R32/R34): render the scene and return PNG bytes
731    /// (8-bit RGB, no ancillary chunks — deterministic, R33).
732    #[cfg(not(target_arch = "wasm32"))]
733    pub fn render_to_png(
734        &mut self,
735        scene: &RenderScene,
736        camera: &Camera,
737        width: u32,
738        height: u32,
739    ) -> Result<Vec<u8>, String> {
740        let settings = RenderSettings::artifact();
741        let mut gpu_scene = self.upload_scene_with(scene, &settings);
742        self.render_gpu_scene_to_png(&mut gpu_scene, scene, camera, width, height)
743    }
744
745    /// [`Self::render_to_png`] from buffers the caller already holds (synced
746    /// with [`RenderSettings::artifact`]): what the viewport's retained GPU
747    /// scene would draw.
748    #[cfg(not(target_arch = "wasm32"))]
749    pub(crate) fn render_gpu_scene_to_png(
750        &mut self,
751        gpu_scene: &mut GpuScene,
752        scene: &RenderScene,
753        camera: &Camera,
754        width: u32,
755        height: u32,
756    ) -> Result<Vec<u8>, String> {
757        let settings = RenderSettings::artifact();
758        let rgb = self.render_gpu_scene_to_rgb(gpu_scene, scene, camera, width, height, &settings)?;
759        encode_png(&rgb, width, height)
760    }
761
762    /// Draw `scene` through `gpu_scene` (synced or uploaded by the caller) at
763    /// `width`×`height` under `settings` and read the frame back as packed
764    /// RGB, top row first: what a motion study's video export encodes frame
765    /// by frame with the viewport's own render core and the user's settings.
766    #[cfg(not(target_arch = "wasm32"))]
767    pub fn render_gpu_scene_to_rgb(
768        &mut self,
769        gpu_scene: &mut GpuScene,
770        scene: &RenderScene,
771        camera: &Camera,
772        width: u32,
773        height: u32,
774        settings: &RenderSettings,
775    ) -> Result<Vec<u8>, String> {
776        let emphasis = Emphasis::default();
777        let params = FrameParams {
778            camera,
779            width,
780            height,
781            dpr: 1.0,
782            settings,
783            emphasis: &emphasis,
784            world_per_pixel: 0.0,
785            overlay: None,
786        };
787        let resolve = self.device.create_texture(&wgpu::TextureDescriptor {
788            label: Some("resolve"),
789            size: wgpu::Extent3d {
790                width,
791                height,
792                depth_or_array_layers: 1,
793            },
794            mip_level_count: 1,
795            sample_count: 1,
796            dimension: wgpu::TextureDimension::D2,
797            format: self.format,
798            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
799            view_formats: &[],
800        });
801        let resolve_view = resolve.create_view(&Default::default());
802        self.render_to_view(gpu_scene, scene, &params, &resolve_view);
803
804        // Readback: rows padded to 256 bytes per wgpu's copy alignment.
805        let bytes_per_row = (width * 4).div_ceil(256) * 256;
806        let readback = self.device.create_buffer(&wgpu::BufferDescriptor {
807            label: Some("readback"),
808            size: bytes_per_row as u64 * height as u64,
809            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
810            mapped_at_creation: false,
811        });
812        let mut encoder = self
813            .device
814            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
815                label: Some("readback"),
816            });
817        encoder.copy_texture_to_buffer(
818            wgpu::TexelCopyTextureInfo {
819                texture: &resolve,
820                mip_level: 0,
821                origin: wgpu::Origin3d::ZERO,
822                aspect: wgpu::TextureAspect::All,
823            },
824            wgpu::TexelCopyBufferInfo {
825                buffer: &readback,
826                layout: wgpu::TexelCopyBufferLayout {
827                    offset: 0,
828                    bytes_per_row: Some(bytes_per_row),
829                    rows_per_image: None,
830                },
831            },
832            wgpu::Extent3d {
833                width,
834                height,
835                depth_or_array_layers: 1,
836            },
837        );
838        self.queue.submit([encoder.finish()]);
839
840        let slice = readback.slice(..);
841        let (sender, receiver) = std::sync::mpsc::channel();
842        slice.map_async(wgpu::MapMode::Read, move |result| {
843            let _ = sender.send(result);
844        });
845        self.device
846            .poll(wgpu::PollType::wait_indefinitely())
847            .map_err(|error| format!("wgpu poll: {error:?}"))?;
848        receiver
849            .recv()
850            .map_err(|_| "readback callback dropped".to_string())?
851            .map_err(|error| format!("readback map failed: {error:?}"))?;
852
853        let data = slice.get_mapped_range();
854        let mut rgb = Vec::with_capacity((width * height * 3) as usize);
855        for row in 0..height {
856            let start = (row * bytes_per_row) as usize;
857            for col in 0..width as usize {
858                let px = start + col * 4;
859                rgb.extend_from_slice(&data[px..px + 3]);
860            }
861        }
862        drop(data);
863        readback.unmap();
864        Ok(rgb)
865    }
866}
867
868/// Issue draw calls for a solid's VISIBLE edge instances only, coalescing
869/// contiguous edge ranges into as few `draw`s as possible. Used by the
870/// single-style edge passes (occluded/hidden, and the no-emphasis visible pass)
871/// when the solid has any hidden edge — the caller has already bound the style.
872/// The coarsest view, in world millimetres per CSS pixel, that still draws AUX
873/// edges — a board's copper outline. Past it a 0.2 mm track is under two
874/// pixels, so its outline would cover it instead of edging it and a dense track
875/// field reads as one slab; and a board seen whole at that scale is exactly
876/// when the most of its thousands of outline segments are on screen at once.
877/// Zooming in brings them back.
878const AUX_EDGE_MAX_WORLD_PER_PIXEL: f64 = 0.1;
879
880/// Which of a solid's edges a [`draw_visible_edge_ranges`] call draws.
881#[derive(Clone, Copy, PartialEq, Eq)]
882enum EdgeFilter {
883    /// The solid's own edges, not its display-only (aux) ones.
884    Real,
885    /// Only the display-only ones.
886    Aux,
887}
888
889fn draw_visible_edge_ranges(
890    pass: &mut wgpu::RenderPass<'_>,
891    solid: &SolidDisplay,
892    gpu_solid: &GpuSolid,
893    filter: EdgeFilter,
894) {
895    let mut run: Option<(u32, u32)> = None; // first_instance, count
896    for (index, edge) in solid.edges.iter().enumerate() {
897        let range = &gpu_solid.edges[index];
898        if range.instance_count == 0 {
899            continue;
900        }
901        if !solid.visibility.is_edge_visible(index) || edge.aux != (filter == EdgeFilter::Aux) {
902            if let Some((first, count)) = run.take() {
903                pass.draw(0..6, first..first + count);
904            }
905            continue;
906        }
907        match &mut run {
908            Some((first, count)) if *first + *count == range.first_instance => {
909                *count += range.instance_count;
910            }
911            _ => {
912                if let Some((first, count)) = run.take() {
913                    pass.draw(0..6, first..first + count);
914                }
915                run = Some((range.first_instance, range.instance_count));
916            }
917        }
918    }
919    if let Some((first, count)) = run {
920        pass.draw(0..6, first..first + count);
921    }
922}
923
924/// Convert a gizmo `Overlay`'s triangles into GPU vertices (per-vertex color).
925fn overlay_tri_verts(ov: &brep_gizmos::Overlay) -> Vec<OverlayTriVertex> {
926    ov.tris
927        .iter()
928        .map(|v| OverlayTriVertex {
929            position: v.pos,
930            normal: v.normal,
931            color: v.color,
932        })
933        .collect()
934}
935
936/// Convert a gizmo `Overlay`'s line segments (vertex pairs) into GPU instances
937/// (per-instance color; both endpoints of a gizmo segment share a color).
938fn overlay_line_insts(ov: &brep_gizmos::Overlay) -> Vec<OverlayLineInstance> {
939    ov.lines
940        .chunks_exact(2)
941        .map(|pair| OverlayLineInstance {
942            p0: pair[0].pos,
943            p1: pair[1].pos,
944            color: pair[0].color,
945        })
946        .collect()
947}
948
949/// Bind a solid's pose: its slot of the scene's pose buffer (identity when
950/// the scene holds none yet).
951fn bind_pose(pass: &mut wgpu::RenderPass<'_>, models: Option<&ModelArena>, identity: &IdentityModel, slot: u32) {
952    match models {
953        Some(models) => pass.set_bind_group(2, &models.bind, &[ModelArena::offset(slot)]),
954        None => pass.set_bind_group(2, &identity.bind, &[0]),
955    }
956}