unlit3d_examples/scenes/outline.rs
1//! The outline scene: one picture drawn by both ways of drawing an outline.
2//!
3//! Every body is one mesh drawn twice from one buffer — lit, and again spread
4//! along its own normals with its front faces culled — so the second draw
5//! shows only where it reaches past the first: a band around the silhouette.
6//! The band is a width in world units, so a body further from the camera wears
7//! a thinner one.
8//!
9//! Beside them, screen-space strokes draw what a silhouette cannot: the orbit
10//! the bodies stand on, the ticks along it, and a pair of armillary rings
11//! circling the largest body. A stroke is a width in physical pixels, so it
12//! keeps its own width across the whole ring — the near side and the far side
13//! alike — where the band narrows with distance.
14//!
15//! The strokes come in both of the source's ink behaviours. The ticks are
16//! opaque: they cover what they draw over and write the depth that hides the
17//! strokes behind them. The orbit and the armillary rings are washes, blended
18//! over the frame instead of covering it, so a ring shows the body it passes
19//! in front of and two crossing rings deepen where they share a pixel. The
20//! rings circle a body whose far side they pass behind, so the same frame
21//! holds a wash in front of a body and one hidden by it.
22//!
23//! Three short opaque strokes stand left of the orbit to pin the ways a
24//! stroke ends: flat-cut, round at one end, and a dot with both ends
25//! rounded. The rings are closed and the ticks are hidden among their
26//! neighbours, so without them no picture exercised the caps a stroke's
27//! fragment shader draws its ends with.
28//!
29//! The two are in one scene because the picture is the argument: a frame
30//! holding either alone would leave the other's promise unpinned. The scene
31//! is declared twice — once for a single-sampled target and once for a
32//! multisampled one — because an opaque stroke's antialiasing follows the
33//! target's sample count: single-sampled it cuts at half coverage, and
34//! multisampled the cut yields to alpha-to-coverage, which spreads that same
35//! coverage over the samples and keeps the edge smooth.
36//!
37//! The bodies carry vertex normals because the shell is pushed along them; a
38//! mesh without normals cannot be spread at all. One body in the frame carries
39//! none, and is drawn lit anyway: its normals come from the screen-space
40//! derivatives of its own surface, which is the fallback a lit draw takes when a
41//! mesh has no normal channel. The two paths are in one frame because the
42//! library has both, and a capture holding only the baked one would leave the
43//! fallback unverified.
44
45use super::{SceneControl, SceneDef, SceneOptions, TEST_SIZE, camera_looking_at};
46use unlit_ecs::{Bundle, DynamicBundle, bundle};
47use unlit_wgpu::pipeline::LitOptions;
48use unlit3d::prelude::*;
49
50/// Where the camera looks down from.
51const CAMERA_EYE: glam::Vec3 = glam::Vec3::new(0.00, 2.75, 4.05);
52
53/// What it looks at: a little above the orbit, so the ring is seen from above.
54const CAMERA_TARGET: glam::Vec3 = glam::Vec3::new(0.00, 0.55, 0.00);
55
56/// The band's width, in world units.
57const HULL_WIDTH: f32 = 0.055;
58
59/// The colour the band is drawn in.
60const INK: glam::Vec4 = glam::Vec4::new(0.01, 0.01, 0.04, 1.0);
61
62/// The snapshot the single-sampled capture freezes its frame under.
63const SNAPSHOT: &str = "outline.webp";
64
65/// The snapshot the multisampled capture freezes its frame under.
66const MSAA_SNAPSHOT: &str = "outline_msaa.webp";
67
68/// The ink the opaque ticks carry: the bytes the segment stream holds, which
69/// the fragment writes into a linear target before it is encoded.
70///
71/// The band's colour reaches the shader as a float and is quantized to bytes on
72/// the way in, so the stroke states the quantized bytes rather than the floats
73/// again: two colours that look alike in the source would otherwise differ by
74/// a few counts on screen and the scene would show two inks.
75const INK_STROKE: [u8; 4] = [3, 3, 10, 255];
76
77/// [`INK_STROKE`] washed by [`WASH_ALPHA`]: the blended orbit's ink, the same
78/// colour at the alpha a wash blends with.
79const INK_WASH: [u8; 4] = [3, 3, 10, 102];
80
81/// The paper the whole scene is drawn on.
82const PAPER: wgpu::Color = wgpu::Color {
83 r: 0.8,
84 g: 0.78,
85 b: 0.72,
86 a: 1.0,
87};
88
89/// The ambient term every body receives before the sun is added.
90const AMBIENT: glam::Vec4 = glam::Vec4::new(0.2, 0.2, 0.24, 0.0);
91
92/// Where the one directional light hangs and what it aims at: from above and
93/// to the left, so each body is lit on one side and turned away on the other.
94const SUN: (glam::Vec3, glam::Vec3) = (
95 glam::Vec3::new(-3.0, 4.0, 2.0),
96 glam::Vec3::new(0.0, 0.45, 0.0),
97);
98
99/// How bright that light is.
100const SUN_INTENSITY: f32 = 0.55;
101
102/// The orbit's radius, and the height it is drawn at.
103///
104/// Wide enough to enclose every body's whole silhouette, including the band
105/// around it: a ring the bodies straddle would cross their own surfaces, and a
106/// stroke drawn through a body reads as a mistake rather than as ink.
107const ORBIT_RADIUS: f32 = 1.75;
108const ORBIT_HEIGHT: f32 = 0.0;
109
110/// How many segments the orbit is drawn with, and its half width in physical
111/// pixels.
112const ORBIT_SEGMENTS: u32 = 128;
113const ORBIT_HALF_WIDTH: f32 = 1.75;
114
115/// How many ticks stand on the orbit, how tall they are and how thick.
116const TICKS: u32 = 24;
117const TICK_HEIGHT: f32 = 0.22;
118const TICK_HALF_WIDTH: f32 = 1.25;
119
120/// The alpha the blended inks are washed to.
121///
122/// Light enough that what a ring passes in front of reads through it, deep
123/// enough that two rings crossing share a pixel of clearly deeper ink.
124const WASH_ALPHA: f32 = 0.4;
125
126/// The armillary rings circling the largest body: each is drawn around an
127/// axis the frame tilts differently, so the two cross twice and each passes
128/// in front of the body it circles on one side and behind it on the other.
129///
130/// The colour is a saturated ink washed to [`WASH_ALPHA`]; the rings are drawn
131/// wider than the orbit so their crossings read at a glance.
132const ARMILLARY: [(glam::Vec3, [u8; 3]); 2] = [
133 (glam::Vec3::new(0.0, 0.9, 1.0), [36, 96, 220]),
134 (glam::Vec3::new(0.9, 0.3, 0.6), [210, 60, 60]),
135];
136const ARMILLARY_RADIUS: f32 = 0.98;
137const ARMILLARY_SEGMENTS: u32 = 96;
138const ARMILLARY_HALF_WIDTH: f32 = 2.5;
139
140/// The point both armillary rings are centred on: the largest body's centre,
141/// so each ring hugs it — outside the band [`HULL_WIDTH`] draws, inside the
142/// orbit that encloses the whole group.
143const ARMILLARY_CENTRE: glam::Vec3 = glam::Vec3::new(0.0, 0.92, -0.2);
144
145/// The half width the cap strokes wear, wide enough for the ends to read.
146const CAPS_HALF_WIDTH: f32 = 3.5;
147
148/// The ink the cap strokes wear: dark enough to read against the paper and
149/// against the orbit they stand beside, opaque so the coverage is the
150/// shader's own.
151const CAPS_INK: [u8; 4] = [40, 36, 32, 255];
152
153/// How finely the smooth spheres are split — fine enough that a baked normal is
154/// what the gradient follows rather than the facets.
155const SLICES: u32 = 48;
156const STACKS: u32 = 24;
157
158/// How coarsely the one faceted body is split.
159///
160/// Coarse on purpose: it carries no normals, so it is shaded by the normals its
161/// own triangles give it, and a facet whose corners disagree about their
162/// direction is exactly what makes that shading visible as facets rather than
163/// as the smooth gradient the baked normal would produce.
164const FACETS: (u32, u32) = (7, 5);
165
166/// The two bodies without normals: where each stands, how big it is and what it
167/// is tinted.
168///
169/// They stand in front of the subject, mirrored about the middle of the frame,
170/// so the derivative-shaded pair is the nearest thing in the picture — its
171/// facets are what a frame is read against.
172const FACETED: [(glam::Vec3, f32, glam::Vec4); 2] = [
173 (
174 glam::Vec3::new(-0.62, 0.56, 0.95),
175 0.3,
176 glam::Vec4::new(0.34, 0.29, 0.52, 1.0),
177 ),
178 (
179 glam::Vec3::new(0.62, 0.56, 0.95),
180 0.3,
181 glam::Vec4::new(0.29, 0.36, 0.54, 1.0),
182 ),
183];
184
185/// The bodies with normals: where each stands, how big it is and what it is
186/// tinted.
187///
188/// Mirrored about the middle of the frame, with the largest in the centre and
189/// the two smaller ones standing back. The same world-space band comes out
190/// narrower on the ones further from the camera — the one thing a picture of a
191/// single body could not show.
192const BODIES: [(glam::Vec3, f32, glam::Vec4); 3] = [
193 (
194 glam::Vec3::new(0.0, 0.92, -0.2),
195 0.62,
196 glam::Vec4::new(0.85, 0.44, 0.2, 1.0),
197 ),
198 (
199 glam::Vec3::new(-1.2, 0.72, -0.3),
200 0.42,
201 glam::Vec4::new(0.22, 0.5, 0.72, 1.0),
202 ),
203 (
204 glam::Vec3::new(1.2, 0.72, -0.3),
205 0.42,
206 glam::Vec4::new(0.72, 0.66, 0.26, 1.0),
207 ),
208];
209
210/// A body: the mesh both of its draws read, and the two pipelines they use.
211///
212/// The two entities spawned from it share the mesh handle, so the shell adds
213/// no memory to the body it surrounds — the pair is one buffer and two
214/// variants of one pipeline family.
215struct Outlined {
216 /// Where the body stands and how big it is.
217 transform: Transform,
218 /// The mesh both draws read.
219 mesh: GpuMesh,
220 /// The pipeline the body is drawn with.
221 body: GpuRenderPipeline,
222 /// The pipeline its shell is drawn with.
223 shell: GpuRenderPipeline,
224 /// The body's own tint, which the shell does not use.
225 tint: glam::Vec4,
226}
227
228impl Outlined {
229 /// The shell: the same mesh, spread and with the body's faces culled.
230 fn spawn_shell(&self, world: &mut World, scope: Entity) {
231 super::spawn_in(
232 world,
233 scope,
234 bundle![
235 self.transform.clone(),
236 self.mesh.clone(),
237 self.shell.clone(),
238 InstanceColor::new(INK),
239 InstanceHullWidth::new(HULL_WIDTH),
240 ],
241 );
242 }
243}
244
245impl Bundle for Outlined {
246 fn into_bundle(self) -> DynamicBundle {
247 bundle![
248 self.transform,
249 InstanceColor::new(self.tint),
250 self.mesh,
251 self.body,
252 ]
253 }
254}
255
256/// The scene: outlined bodies standing on an orbit drawn with strokes, on a
257/// single-sampled target.
258pub static SCENE: SceneDef = SceneDef {
259 id: "outline",
260 title: "Inked outlines",
261 description: "inverted-hull bands beside screen-space strokes",
262 size: TEST_SIZE,
263 baseline: Some(TEST_SIZE),
264 clear: Some(PAPER),
265 frames: 1,
266 step_seconds: None,
267 samples: 1,
268 depth: true,
269 ui: false,
270 reproducible_ui: false,
271 build,
272};
273
274/// The same scene on a multisampled target.
275///
276/// The world is the one [`SCENE`] builds — the difference is the sample count
277/// the capture binds, which is the one thing that changes how an opaque stroke
278/// is antialiased: single-sampled it cuts at half its coverage, and
279/// multisampled the cut yields to alpha-to-coverage, which spreads that same
280/// coverage over the samples and keeps the edge smooth.
281pub static MSAA_SCENE: SceneDef = SceneDef {
282 id: "outline_msaa",
283 title: "Inked outlines, multisampled",
284 description: "the outline scene on a four-sample target",
285 size: TEST_SIZE,
286 baseline: Some(TEST_SIZE),
287 clear: Some(PAPER),
288 frames: 1,
289 step_seconds: None,
290 samples: 4,
291 depth: true,
292 ui: false,
293 reproducible_ui: false,
294 build: build_msaa,
295};
296
297/// Build the single-sampled scene's world content.
298fn build(
299 world: &mut World,
300 context: RenderContext,
301 pass: Entity,
302 ui: Option<Entity>,
303 size: (u32, u32),
304 options: SceneOptions,
305) -> SceneControl {
306 built(SNAPSHOT, world, context, pass, ui, size, options)
307}
308
309/// Build the multisampled scene's world content: the same world, frozen under
310/// the other snapshot's name.
311fn build_msaa(
312 world: &mut World,
313 context: RenderContext,
314 pass: Entity,
315 ui: Option<Entity>,
316 size: (u32, u32),
317 options: SceneOptions,
318) -> SceneControl {
319 built(MSAA_SNAPSHOT, world, context, pass, ui, size, options)
320}
321
322/// The core both builds share; `snapshot` is the name frame 0 freezes under.
323fn built(
324 snapshot: &'static str,
325 world: &mut World,
326 context: RenderContext,
327 pass: Entity,
328 _ui: Option<Entity>,
329 size: (u32, u32),
330 _options: SceneOptions,
331) -> SceneControl {
332 let mut source = MeshSource::new(&world.root_scope(), context);
333 source.register_lit_family(&world.root_scope());
334
335 // The default options draw unlit; these bodies are lit, so the flag is the
336 // one thing that differs.
337 let mut lit = super::default_options();
338 lit.unlit = false;
339 let body_key = LitPipelineKey::new(lit);
340 // The shell is a second variant of the same family: the one that spreads
341 // each vertex along its normal and culls the faces the body draws. It tests
342 // depth but writes none, which is what keeps the band visible whatever order
343 // the two draws land in.
344 let shell_key = LitPipelineKey::new(LitOptions::inverted_hull());
345
346 // The camera hangs in the shared subtree. Two sources draw this frame — the
347 // meshes and the strokes — and a source reads only its own subtree, so a
348 // camera beside one of them would be invisible to the other.
349 let camera_entity = super::spawn_shared(
350 world,
351 bundle![camera_looking_at(CAMERA_EYE, CAMERA_TARGET, size)],
352 );
353
354 let source_entity = world
355 .scope_mut(pass)
356 .expect("the pass is alive")
357 .spawn(bundle![Source::new(source)]);
358
359 // One smooth sphere, shared by every body and by both of its draws.
360 let (positions, normals, indices) = sphere(SLICES, STACKS);
361 let mesh = super::with_mesh_source(world, source_entity, |source, world| {
362 source.allocate_lit_mesh(
363 world,
364 &body_key.options,
365 LitMeshDesc {
366 positions: &positions,
367 normals: Some(&normals),
368 indices: Some(&indices),
369 ..Default::default()
370 },
371 )
372 });
373
374 // The faceted body's mesh carries positions and indices alone. A lit draw of
375 // it shades by the normals its own surface gives it, derived per fragment
376 // from the screen-space derivatives of the positions — the fallback a mesh
377 // without a normal channel takes. It is coarse so the facets read.
378 let (positions, _, indices) = sphere(FACETS.0, FACETS.1);
379 let faceted = super::with_mesh_source(world, source_entity, |source, world| {
380 source.allocate_lit_mesh(
381 world,
382 &body_key.options,
383 LitMeshDesc {
384 positions: &positions,
385 indices: Some(&indices),
386 ..Default::default()
387 },
388 )
389 });
390
391 for (translation, radius, tint) in BODIES {
392 let outlined = Outlined {
393 transform: Transform {
394 translation,
395 rotation: glam::Quat::IDENTITY,
396 scale: glam::Vec3::splat(radius),
397 },
398 mesh: mesh.clone(),
399 body: GpuRenderPipeline::new(body_key.clone()),
400 shell: GpuRenderPipeline::new(shell_key.clone()),
401 tint,
402 };
403 // The shell draws the faces the body culls, and at every pixel it is
404 // behind the body, so the body covers it wherever the two coincide
405 // whatever order they land in. The spawn order here is arbitrary.
406 outlined.spawn_shell(world, source_entity);
407 super::spawn_in(world, source_entity, outlined);
408 }
409
410 // The bodies without normals. They have no shell — the shell is pushed along
411 // the normals a mesh carries, and these carry none — so they are here for the
412 // shading their own facets give them.
413 for (translation, radius, tint) in FACETED {
414 super::spawn_in(
415 world,
416 source_entity,
417 bundle![
418 Transform {
419 translation,
420 rotation: glam::Quat::IDENTITY,
421 scale: glam::Vec3::splat(radius),
422 },
423 InstanceColor::new(tint),
424 faceted.clone(),
425 GpuRenderPipeline::new(body_key.clone()),
426 ],
427 );
428 }
429
430 super::spawn_in(world, source_entity, bundle![AmbientLight::new(AMBIENT)]);
431 super::spawn_in(
432 world,
433 source_entity,
434 super::PlacedLight::aiming(
435 LightSource::new(DirectionalLight {
436 color: glam::Vec4::new(1.0, 0.97, 0.9, 0.0),
437 intensity: SUN_INTENSITY,
438 }),
439 SUN.0,
440 SUN.1,
441 ),
442 );
443
444 // The strokes are their own source, and each one belongs to its entity:
445 // the source collects the Line components its scope sees every frame, so
446 // the ring is edited the way the bodies are. The entities go under the
447 // source's own, because a source draws its subtree and the shared one.
448 //
449 // The orbit and the armillary rings are washes — blended over the frame
450 // and hidden by what stands in front of them — and the ticks are opaque,
451 // so one source draws both of the source's ink behaviours.
452 let lines = world
453 .scope_mut(pass)
454 .expect("the pass is alive")
455 .spawn(bundle![Source::new(LineSource::new())]);
456 let mut strokes = orbit();
457 strokes.extend(armillary());
458 strokes.extend(caps_strokes());
459 for stroke in strokes {
460 super::spawn_in(world, lines, bundle![stroke]);
461 }
462
463 SceneControl {
464 // A single frame: nothing moves, so only the camera's aspect is
465 // advanced, keeping a resized window from stretching the ring.
466 advance: Box::new(move |world, _frame, _delta, frame_size| {
467 super::aim_camera(
468 world,
469 world.shared(),
470 camera_entity,
471 CAMERA_EYE,
472 CAMERA_TARGET,
473 frame_size,
474 );
475 }),
476 snapshot: Box::new(move |frame| (frame == 0).then(|| snapshot.to_owned())),
477 }
478}
479
480/// The unit sphere every body is scaled from, as `(positions, normals,
481/// indices)`.
482///
483/// The normals are the unit positions themselves, so the shell spreads along
484/// the same directions the body shades by. The winding is counter-clockwise
485/// seen from outside, which is what makes the faces the body draws the ones
486/// the shell culls.
487///
488/// A body that carries no normals is the same geometry: whether the vertices
489/// disagree about their direction is what decides between the two shading paths,
490/// not the mesh the allocator was handed.
491fn sphere(slices: u32, stacks: u32) -> (Vec<[f32; 3]>, Vec<[f32; 3]>, Vec<u32>) {
492 let row = slices + 1;
493 let mut positions = Vec::with_capacity((row * (stacks + 1)) as usize);
494 let mut normals = Vec::with_capacity(positions.capacity());
495 for stack in 0..=stacks {
496 let polar = core::f32::consts::PI * stack as f32 / stacks as f32;
497 for slice in 0..=slices {
498 let azimuth = core::f32::consts::TAU * slice as f32 / slices as f32;
499 let normal = glam::Vec3::new(
500 polar.sin() * azimuth.cos(),
501 polar.cos(),
502 polar.sin() * azimuth.sin(),
503 );
504 normals.push(normal.to_array());
505 positions.push(normal.to_array());
506 }
507 }
508 let mut indices = Vec::with_capacity((slices * stacks * 6) as usize);
509 for stack in 0..stacks {
510 for slice in 0..slices {
511 let corner = stack * row + slice;
512 indices.extend_from_slice(&[
513 corner,
514 corner + row,
515 corner + row + 1,
516 corner,
517 corner + row + 1,
518 corner + 1,
519 ]);
520 }
521 }
522 (positions, normals, indices)
523}
524
525/// The orbit and its ticks, as the strokes the line source draws.
526///
527/// The ring is closed by repeating its first station last, and every tick is
528/// its own segment, so no two of them are joined by a stroke. The ring is a
529/// wash — blended over the frame, its ink carrying [`WASH_ALPHA`] — and the
530/// ticks are opaque, so one orbit holds both of the source's ink behaviours.
531fn orbit() -> Vec<Line> {
532 let mut segments = Vec::with_capacity(ORBIT_SEGMENTS as usize + TICKS as usize);
533 let station = |step: u32| {
534 let angle = core::f32::consts::TAU * step as f32 / ORBIT_SEGMENTS as f32;
535 glam::Vec3::new(
536 ORBIT_RADIUS * angle.cos(),
537 ORBIT_HEIGHT,
538 ORBIT_RADIUS * angle.sin(),
539 )
540 };
541 for step in 0..ORBIT_SEGMENTS {
542 segments.push(Line::blended(
543 LineSegment::new(station(step).to_array(), station(step + 1).to_array())
544 .with_color(INK_WASH)
545 .with_half_width(ORBIT_HALF_WIDTH),
546 ));
547 }
548 // The ticks stand on the ring, spaced round it rather than at one per
549 // segment: they are the marks the ring is read against, and one at every
550 // few stations keeps them from merging into a second, ragged ring.
551 for tick in 0..TICKS {
552 let step = tick * ORBIT_SEGMENTS / TICKS;
553 let foot = station(step);
554 let head = foot + glam::Vec3::Y * TICK_HEIGHT;
555 segments.push(Line::opaque(
556 LineSegment::new(foot.to_array(), head.to_array())
557 .with_color(INK_STROKE)
558 .with_half_width(TICK_HALF_WIDTH),
559 ));
560 }
561 segments
562}
563
564/// The armillary rings, as the segments the stroke source draws.
565///
566/// Each ring circles the largest body around its own tilted axis: the axis is
567/// the ring's normal, and the frame it is drawn in is the pair of directions
568/// `glam` reports orthogonal to it. Two different tilts make the rings cross
569/// twice, and circling a body puts each ring in front of it on one half and
570/// behind it on the other — the picture a wash needs, of depth acting on a
571/// stroke that covers nothing.
572fn armillary() -> impl Iterator<Item = Line> {
573 ARMILLARY.iter().flat_map(|(axis, ink)| {
574 let (u, v) = axis.any_orthonormal_pair();
575 let station = move |step: u32| {
576 let angle = core::f32::consts::TAU * step as f32 / ARMILLARY_SEGMENTS as f32;
577 ARMILLARY_CENTRE
578 + u * (ARMILLARY_RADIUS * angle.cos())
579 + v * (ARMILLARY_RADIUS * angle.sin())
580 };
581 (0..ARMILLARY_SEGMENTS).map(move |step| {
582 Line::blended(
583 LineSegment::new(station(step).to_array(), station(step + 1).to_array())
584 .with_color(wash(*ink))
585 .with_half_width(ARMILLARY_HALF_WIDTH),
586 )
587 })
588 })
589}
590
591/// The ink a wash carries: `rgb` washed to [`WASH_ALPHA`].
592fn wash(rgb: [u8; 3]) -> [u8; 4] {
593 [rgb[0], rgb[1], rgb[2], (WASH_ALPHA * 255.0) as u8]
594}
595
596/// The three cap strokes standing left of the orbit, where nothing else
597/// draws: flat-cut, round at the head end, and a dot — both ends rounded,
598/// the ends coincident. The frame freezes them so a change in how a stroke
599/// ends cannot land unreviewed: the caps are a shader decision the rings
600/// and the ticks never exercise, closed rings having no ends at all.
601/// unreviewed: the caps are a shader decision the rings and the ticks never
602/// exercise, closed rings having no ends at all.
603fn caps_strokes() -> [Line; 3] {
604 // Flat: neither end rounded, the record's default.
605 let flat = stroke(
606 glam::Vec3::new(-2.15, 0.30, 0.0),
607 glam::Vec3::new(-2.15, 1.05, 0.0),
608 0,
609 );
610 // Round at the head end only.
611 let round_head = stroke(
612 glam::Vec3::new(-1.75, 0.30, 0.0),
613 glam::Vec3::new(-1.75, 1.05, 0.0),
614 LineSegment::ROUND_B,
615 );
616 // A dot: both ends rounded and the ends coincident.
617 let dot = stroke(
618 glam::Vec3::new(-1.35, 0.30, 0.0),
619 glam::Vec3::new(-1.35, 0.30, 0.0),
620 LineSegment::ROUND_A | LineSegment::ROUND_B,
621 );
622 [flat, round_head, dot]
623}
624
625/// One opaque vertical cap stroke from `foot` up to `head`, with `caps` as
626/// the record's end flags.
627fn stroke(foot: glam::Vec3, head: glam::Vec3, caps: u32) -> Line {
628 let mut segment = LineSegment::new(foot.to_array(), head.to_array())
629 .with_color(CAPS_INK)
630 .with_half_width(CAPS_HALF_WIDTH);
631 segment.caps = caps;
632 Line::opaque(segment)
633}