Skip to main content

repame_view2d/
draw.rs

1//! World-anchored instanced draw list.
2//!
3//! [`actors_view`] presents one rig filling one surface. A game needs the
4//! opposite: one rig drawn many times per frame, all sharing a single world
5//! camera, ordered by z, each with its own placement and paint. That is this
6//! module.
7//!
8//! One [`ActorLibrary`] owns every rig and one [`SceneRenderer`], so tessellation
9//! is shared. Per frame the list is walked in z order; each distinct
10//! `(entry, frame, tint)` is prepared once and its [`PreparedScene`] reused by
11//! every instance with that combination. Placement rides in each draw's
12//! `transform` field, which is *not* part of the mesh cache key
13//! (`renamite_render_bridge::SceneRenderer`), so hundreds of instances of one
14//! rig cost one tessellation.
15//!
16//! Camera [`roll`](crate::Camera2d::roll) is not supported: renamite
17//! composes a view as scale plus offset, so a rotation has nowhere to enter.
18
19use std::cell::RefCell;
20use std::collections::HashMap;
21use std::rc::Rc;
22use std::sync::Arc;
23
24use crate::{Camera2d, PickEvent, click_within_slop_dp, dp_to_world, effective_fit};
25use glam::{DVec2, DVec4};
26use renamite_model::{Scene, ScenePaint};
27use renamite_render_bridge::{PreparedDraw, PreparedScene};
28use repose_core::input::{PointerButton, PointerEvent, PointerEventKind, PointerKind};
29use repose_core::locals::effective_density_scale;
30use repose_core::{
31    BlendMode, ClipOp, Modifier, Px, Rect, RenderContext, SceneNode, View,
32};
33use repose_ui::{Box as UiBox, ViewExt, ZStack};
34
35use crate::actors::{PlayerHost, PlayerHostRef, ViewTransform};
36
37/// Index into an [`ActorLibrary`].
38#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
39pub struct ActorId(pub usize);
40
41/// One rig plus the facts about it the draw loop needs.
42struct Entry {
43    host: PlayerHostRef,
44    /// Cached from the first prepared scene: whether any item paints an image.
45    images: Option<bool>,
46}
47
48pub struct ActorLibrary {
49    entries: Vec<Entry>,
50}
51
52impl Default for ActorLibrary {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58impl ActorLibrary {
59    pub fn new() -> Self {
60        Self {
61            entries: Vec::new(),
62        }
63    }
64
65    /// Add a rig from `.ren` source text.
66    pub fn push_ren_str(&mut self, source: &str) -> Result<ActorId, renamite_player::PlayerError> {
67        Ok(self.push(PlayerHost::from_ren_str(source)?))
68    }
69
70    /// Add a rig from packed `.renb` bytes.
71    pub fn push_ren_bytes(
72        &mut self,
73        bytes: &[u8],
74    ) -> Result<ActorId, renamite_player::PlayerError> {
75        Ok(self.push(PlayerHost::from_ren_bytes(bytes)?))
76    }
77
78    pub fn push(&mut self, host: PlayerHost) -> ActorId {
79        self.entries.push(Entry {
80            host: Rc::new(RefCell::new(host)),
81            images: None,
82        });
83        ActorId(self.entries.len() - 1)
84    }
85
86    pub fn len(&self) -> usize {
87        self.entries.len()
88    }
89
90    pub fn is_empty(&self) -> bool {
91        self.entries.is_empty()
92    }
93
94    pub fn host(&self, id: ActorId) -> Option<&PlayerHostRef> {
95        self.entries.get(id.0).map(|entry| &entry.host)
96    }
97}
98
99/// What a placement draws. One draw list carries all three media so a single
100/// surface can hold them in one z order; a rig is one kind of item, not the
101/// only kind of input.
102#[derive(Clone, Copy, Debug, PartialEq)]
103pub enum ActorItem {
104    /// A frame of a rig from an [`ActorLibrary`], drawn as vector geometry and
105    /// instanced when several placements share the frame.
106    Rig { actor: ActorId, frame: f64 },
107    /// One cell of an atlas page, drawn by the instanced sprite batch. `uv` is
108    /// the cell rectangle in page space; `size` is its unrotated extent in
109    /// pixels, before the placement's scale.
110    Raster {
111        page: u32,
112        uv_min: [f32; 2],
113        uv_max: [f32; 2],
114        size: [f32; 2],
115    },
116    /// A solid rectangle in the placement's own space, centred on the anchor
117    /// and sized `half`. Drawn through Repose's instanced rect pipe, so runs of
118    /// them batch.
119    Rect { half: [f32; 2] },
120}
121
122/// One placement of one item. Cheaper to clone than to configure.
123#[derive(Clone, Debug)]
124pub struct ActorDraw {
125    pub item: ActorItem,
126    /// World position of the anchor point.
127    pub world: DVec2,
128    pub rotation: f64,
129    /// Per-axis scale; multiply by `-1` to flip, so `flip` is sugar.
130    pub scale: DVec2,
131    /// Normalised point of the cell placed at `world`. `(0.5, 0.5)` centres it.
132    pub anchor: DVec2,
133    /// Multiplied into every solid fill. `1,1,1,1` leaves the rig untouched.
134    pub tint: DVec4,
135    /// Cell-local `[x, y, w, h]` to clip to, in the rig's own coordinates.
136    /// `None` draws the whole cell. A rotated clip is covered by the axis-aligned
137    /// bounds of its transformed corners, so it over-covers rather than cuts.
138    pub clip: Option<[f64; 4]>,
139    pub blend: BlendMode,
140    /// Higher paints later. Sorted once per frame.
141    pub z: f32,
142}
143
144impl Default for ActorDraw {
145    fn default() -> Self {
146        Self {
147            item: ActorItem::Rect { half: [0.0, 0.0] },
148            world: DVec2::ZERO,
149            rotation: 0.0,
150            scale: DVec2::ONE,
151            anchor: DVec2::splat(0.5),
152            tint: DVec4::ONE,
153            clip: None,
154            blend: BlendMode::Alpha,
155            z: 0.0,
156        }
157    }
158}
159
160#[derive(Default)]
161pub struct ActorDrawList {
162    draws: Vec<ActorDraw>,
163}
164
165impl ActorDrawList {
166    pub fn new() -> Self {
167        Self::default()
168    }
169
170    /// Adopt a frame's placements wholesale. The caller keeps ownership of the
171    /// vector and hands it over per frame, so this only moves it.
172    pub fn from_draws(draws: Vec<ActorDraw>) -> Self {
173        Self { draws }
174    }
175
176    pub fn clear(&mut self) {
177        self.draws.clear();
178    }
179
180    pub fn push(&mut self, draw: ActorDraw) {
181        self.draws.push(draw);
182    }
183
184    pub fn len(&self) -> usize {
185        self.draws.len()
186    }
187
188    pub fn is_empty(&self) -> bool {
189        self.draws.is_empty()
190    }
191
192    pub fn as_slice(&self) -> &[ActorDraw] {
193        &self.draws
194    }
195}
196
197/// `out = b` applied after `a`, in renamite's column-major layout:
198/// `out.x = v[0] * x + v[2] * y + v[4]` and `out.y = v[1] * x + v[3] * y + v[5]`.
199fn compose(a: [f64; 6], b: [f64; 6]) -> [f64; 6] {
200    [
201        b[0] * a[0] + b[2] * a[1],
202        b[1] * a[0] + b[3] * a[1],
203        b[0] * a[2] + b[2] * a[3],
204        b[1] * a[2] + b[3] * a[3],
205        b[0] * a[4] + b[2] * a[5] + b[4],
206        b[1] * a[4] + b[3] * a[5] + b[5],
207    ]
208}
209
210/// Local cell point -> world: scale, then rotate, then translate, about the
211/// anchor. Same column-major layout as [`compose`].
212fn instance_affine(draw: &ActorDraw, size: DVec2) -> [f64; 6] {
213    let (sin, cos) = draw.rotation.sin_cos();
214    let (sx, sy) = (draw.scale.x, draw.scale.y);
215    let affine = [cos * sx, sin * sx, -sin * sy, cos * sy, 0.0, 0.0];
216    let pivot = DVec2::new(draw.anchor.x * size.x, draw.anchor.y * size.y);
217    let rotated = apply(affine, pivot);
218    [
219        affine[0],
220        affine[1],
221        affine[2],
222        affine[3],
223        draw.world.x - rotated.x,
224        draw.world.y - rotated.y,
225    ]
226}
227
228fn view_affine(view: &ViewTransform) -> [f64; 6] {
229    [
230        view.scale,
231        0.0,
232        0.0,
233        view.scale,
234        view.offset.x,
235        view.offset.y,
236    ]
237}
238
239fn tint_key(tint: DVec4) -> [u64; 4] {
240    [
241        tint.x.to_bits(),
242        tint.y.to_bits(),
243        tint.z.to_bits(),
244        tint.w.to_bits(),
245    ]
246}
247
248fn artboard(player: &renamite_player::Player) -> DVec2 {
249    let comp = player.composition();
250    match player.project.document.compositions.get(comp) {
251        Some(c) => DVec2::new(c.size.0 as f64, c.size.1 as f64),
252        None => DVec2::ONE,
253    }
254}
255
256fn clip_mesh(x0: f64, y0: f64, x1: f64, y1: f64) -> Arc<repose_core::VectorMeshData> {
257    let vertices: Arc<[repose_core::VectorVertex]> = [
258        ([x0 as f32, y0 as f32], [0.0, 0.0]),
259        ([x1 as f32, y0 as f32], [1.0, 0.0]),
260        ([x1 as f32, y1 as f32], [1.0, 1.0]),
261        ([x0 as f32, y1 as f32], [0.0, 1.0]),
262    ]
263    .iter()
264    .map(|(pos, uv)| repose_core::VectorVertex {
265        pos: *pos,
266        color: [1.0, 1.0, 1.0, 1.0],
267        uv: *uv,
268    })
269    .collect();
270    Arc::new(repose_core::VectorMeshData {
271        vertices,
272        indices: [0u32, 1, 2, 0, 2, 3].into(),
273    })
274}
275
276fn apply(a: [f64; 6], p: DVec2) -> DVec2 {
277    DVec2::new(
278        a[0] * p.x + a[2] * p.y + a[4],
279        a[1] * p.x + a[3] * p.y + a[5],
280    )
281}
282
283/// Multiplying every solid fill by `tint`. Gradients and images are left
284/// alone: a uniform multiply over a gradient or a cropped bitmap has no
285/// defined meaning, and silently picking one would be worse than not tinting.
286fn tint_scene(scene: &Scene, tint: DVec4) -> Scene {
287    let mut out = scene.clone();
288    for item in &mut out.items {
289        if let ScenePaint::Solid(color) = &mut item.paint {
290            color.r *= tint.x;
291            color.g *= tint.y;
292            color.b *= tint.z;
293            color.a *= tint.w;
294        }
295    }
296    out
297}
298
299/// Fold an instance placement into every image item's local->world affine.
300///
301/// Vector draws retarget through `PreparedDraw::transform` instead, because
302/// that field is outside the mesh cache key. Images have no mesh, so folding
303/// into the affine costs nothing and keeps them exact under rotation.
304fn place_images(scene: &mut Scene, instance: [f64; 6]) {
305    for item in &mut scene.items {
306        if let ScenePaint::Image { affine, .. } = &mut item.paint {
307            *affine = compose(instance, *affine);
308        }
309    }
310}
311
312fn has_images(scene: &Scene) -> bool {
313    scene
314        .items
315        .iter()
316        .any(|item| matches!(item.paint, ScenePaint::Image { .. }))
317}
318
319/// Camera as renamite's scale-plus-offset view, matching `repame_view2d`'s
320/// `world_to_dp` exactly. That law is in dp; a `DrawScope` addresses physical
321/// px, so the caller scales by the density to land in the space the draws are
322/// emitted in.
323fn camera_view(camera: &Camera2d, canvas_dp: [f32; 2], world_size: [f32; 2]) -> ViewTransform {
324    let (scale, ox, oy) = effective_fit(canvas_dp, world_size, camera);
325    let [cx, cy] = camera.effective_center();
326    ViewTransform {
327        scale: scale as f64,
328        offset: DVec2::new(
329            f64::from(ox) - f64::from(cx - world_size[0] * 0.5) * f64::from(scale),
330            f64::from(oy) - f64::from(cy - world_size[1] * 0.5) * f64::from(scale),
331        ),
332    }
333}
334
335/// Camera view rescaled from dp into the canvas's physical px.
336fn camera_view_px(
337    camera: &Camera2d,
338    canvas_dp: [f32; 2],
339    world_size: [f32; 2],
340    density: f64,
341) -> ViewTransform {
342    let view = camera_view(camera, canvas_dp, world_size);
343    ViewTransform {
344        scale: view.scale * density,
345        offset: view.offset * density,
346    }
347}
348
349/// Retarget prepared vector draws onto one instance. Meshes are arcs, so this
350/// is a handful of f32 per draw.
351fn retarget_draws(base: &[PreparedDraw], instance: [f64; 6]) -> Vec<PreparedDraw> {
352    base.iter()
353        .map(|draw| match draw {
354            PreparedDraw::Vector {
355                mesh,
356                transform,
357                paint,
358                clips,
359                blend,
360            } => PreparedDraw::Vector {
361                mesh: mesh.clone(),
362                transform: compose(instance, transform.map(f64::from)).map(|value| value as f32),
363                paint: *paint,
364                clips: clips.clone(),
365                blend: *blend,
366            },
367            PreparedDraw::Image {
368                handle,
369                rect,
370                transform,
371                tint,
372                fit,
373                clips,
374                blend,
375            } => PreparedDraw::Image {
376                handle: *handle,
377                rect: *rect,
378                transform: *transform,
379                tint: *tint,
380                fit: *fit,
381                clips: clips.clone(),
382                blend: *blend,
383            },
384        })
385        .collect()
386}
387
388#[derive(Clone, Copy, PartialEq, Eq, Hash)]
389struct PrepKey {
390    actor: usize,
391    frame: u64,
392    tint: [u64; 4],
393}
394
395/// Atlas resources backing [`ActorItem::Raster`] placements. The pixel data
396/// rides an `Arc`; every view batches its own rows per frame from it.
397#[derive(Clone)]
398pub struct RasterAtlas {
399    pub desc: crate::BatchDesc,
400    pub uploads: std::sync::Arc<[crate::AtlasUpload]>,
401    pub batch_id: String,
402}
403
404/// One z-ordered world surface for all three [`ActorItem`] media. Rigs and
405/// rects are mesh placements and share the instanced mesh path; raster runs go
406/// to the atlas batch, which keeps its instancing. Because the whole list is
407/// emitted into one scene, the media interleave freely in z.
408pub fn actors_view_mixed(
409    library: Rc<RefCell<ActorLibrary>>,
410    draws: Rc<ActorDrawList>,
411    camera: Camera2d,
412    world_size: [f32; 2],
413    ctx: RenderContext,
414    atlas: Option<Rc<RasterAtlas>>,
415) -> View {
416    debug_assert!(
417        camera.roll.abs() < f32::EPSILON,
418        "actors_view_mixed does not apply Camera2d::roll"
419    );
420
421    let prepared: Rc<RefCell<HashMap<PrepKey, PreparedScene>>> = Rc::default();
422    let order: Rc<RefCell<Vec<u32>>> = Rc::default();
423
424    let modifier = Modifier::new().fill_max_size().hit_passthrough().painter(
425        move |scene: &mut repose_core::Scene, rect: Rect, _alpha: f32| {
426            let density = effective_density_scale().max(1e-6);
427            let canvas_dp = [rect.w / density, rect.h / density];
428            if canvas_dp[0] <= 1.0 || canvas_dp[1] <= 1.0 {
429                return;
430            }
431            let view = camera_view_px(&camera, canvas_dp, world_size, f64::from(density));
432
433            let entries = draws.as_slice();
434            {
435                let mut order = order.borrow_mut();
436                order.clear();
437                order.extend(0..entries.len() as u32);
438                order.sort_by(|a, b| compare_draws(&entries[*a as usize], &entries[*b as usize]));
439            }
440
441            prepared.borrow_mut().clear();
442            let sorted: Vec<u32> = order.borrow().clone();
443            let mut library = library.borrow_mut();
444            let mut cursor = 0usize;
445            let mut run_index = 0u32;
446            while cursor < sorted.len() {
447                let head = &entries[sorted[cursor] as usize];
448                let group = (medium_key(head.item), item_key(head.item), head.z.to_bits());
449                let mut end = cursor + 1;
450                while end < sorted.len() {
451                    let next = &entries[sorted[end] as usize];
452                    if (medium_key(next.item), item_key(next.item), next.z.to_bits()) != group
453                        || next.clip.is_some()
454                    {
455                        break;
456                    }
457                    end += 1;
458                }
459                let run = cursor..end;
460                cursor = end;
461                let run_id = run_index;
462                run_index += 1;
463                emit_run(
464                    run,
465                    run_id,
466                    &sorted,
467                    entries,
468                    &group.0,
469                    &mut library,
470                    &prepared,
471                    &view,
472                    &camera,
473                    canvas_dp,
474                    world_size,
475                    density,
476                    atlas.as_deref(),
477                    rect,
478                    scene,
479                    &ctx,
480                );
481            }
482        },
483    );
484    UiBox(modifier)
485}
486
487#[allow(clippy::too_many_arguments)]
488fn emit_run(
489    run: std::ops::Range<usize>,
490    run_id: u32,
491    sorted: &[u32],
492    entries: &[ActorDraw],
493    medium: &Medium,
494    library: &mut ActorLibrary,
495    prepared: &Rc<RefCell<HashMap<PrepKey, PreparedScene>>>,
496    view: &ViewTransform,
497    camera: &Camera2d,
498    canvas_dp: [f32; 2],
499    world_size: [f32; 2],
500    density: f32,
501    atlas: Option<&RasterAtlas>,
502    rect: Rect,
503    scene: &mut repose_core::Scene,
504    ctx: &RenderContext,
505) {
506    match medium {
507        Medium::Rect => {
508            let mesh = unit_quad();
509            let mut instances = Vec::with_capacity(run.len());
510            for i in run {
511                let draw = &entries[sorted[i] as usize];
512                let ActorItem::Rect { half } = draw.item else {
513                    continue;
514                };
515                instances.push(rect_instance(draw, half, view));
516            }
517            if instances.is_empty() {
518                return;
519            }
520            scene.nodes.push(SceneNode::VectorMeshInstanced {
521                mesh,
522                transforms: instances,
523                paint: repose_core::PaintDesc::Solid,
524                blend: BlendMode::Alpha,
525            });
526        }
527
528        Medium::Raster => {
529            let Some(atlas) = atlas else {
530                return;
531            };
532            let mut batch = crate::SpriteBatch::with_id(
533                format!("{}.r{}", atlas.batch_id, run_id),
534                atlas.desc.clone(),
535            );
536            batch.set_camera(camera.fit_matrix(canvas_dp, world_size));
537            for i in run {
538                let draw = &entries[sorted[i] as usize];
539                let ActorItem::Raster {
540                    page,
541                    uv_min,
542                    uv_max,
543                    size,
544                } = draw.item
545                else {
546                    continue;
547                };
548                batch.push_sprite(&sprite_instance(draw, page, uv_min, uv_max, size));
549            }
550            let fit = crate::effective_fit(canvas_dp, world_size, camera);
551            // A callback's rect is the viewport its camera matrix maps into, so
552            // the batch fills the surface and the fitted box rides a clip node:
553            // the deleted viewport scissored the surface camera to the box, and
554            // that lands on the same pixels. Mounting the batch on the box
555            // instead squeezes the full-canvas matrix into it.
556            scene.nodes.push(SceneNode::PushClip {
557                rect: fitted_box(rect, world_size, fit, camera.roll, density),
558                radius: [Px(0.0); 4],
559                op: ClipOp::Intersect,
560            });
561            scene
562                .nodes
563                .push(crate::BatchFrame::new(batch, atlas.uploads.clone()).node(rect));
564            scene.nodes.push(SceneNode::PopClip);
565        }
566
567        Medium::Rig => {
568            let head = &entries[sorted[run.start] as usize];
569            let ActorItem::Rig { actor, frame } = head.item else {
570                return;
571            };
572            let Some(entry) = library.entries.get_mut(actor.0) else {
573                return;
574            };
575            let PlayerHost {
576                player,
577                renderer,
578                dirty_images,
579                ..
580            } = &mut *entry.host.borrow_mut();
581            player.scrub(frame);
582            let raw = player.scene();
583            let size = artboard(player);
584            let images = *entry.images.get_or_insert_with(|| has_images(raw));
585            if *dirty_images {
586                renderer.sync_document_images(&player.project.document, ctx);
587                *dirty_images = false;
588            }
589
590            let key = PrepKey {
591                actor: actor.0,
592                frame: frame.to_bits(),
593                tint: tint_key(head.tint),
594            };
595            let source = if head.tint == DVec4::ONE {
596                raw.clone()
597            } else {
598                tint_scene(raw, head.tint)
599            };
600
601            if !images {
602                let mut cache = prepared.borrow_mut();
603                if !cache.contains_key(&key) {
604                    cache.insert(key, renderer.prepare(&source, view));
605                }
606                let base = cache.get(&key).expect("inserted above");
607                if run.len() > 1 && base.is_batchable() {
608                    let instances: Vec<[f64; 6]> = run
609                        .clone()
610                        .map(|i| instance_affine(&entries[sorted[i] as usize], size))
611                        .collect();
612                    renderer.append_repose_scene_instanced(base, &instances, scene);
613                    return;
614                }
615            }
616
617            for i in run {
618                let draw = &entries[sorted[i] as usize];
619                let instance = instance_affine(draw, size);
620                if images {
621                    let mut placed = source.clone();
622                    place_images(&mut placed, instance);
623                    let base = renderer.prepare(&placed, view);
624                    let retargeted = PreparedScene {
625                        draws: retarget_draws(&base.draws, instance),
626                        clips: base.clips,
627                    };
628                    open_clip(scene, draw.clip, instance, view);
629                    renderer.append_repose_scene(&retargeted, scene);
630                    close_clip(scene, draw.clip.is_some());
631                    continue;
632                }
633                let mut cache = prepared.borrow_mut();
634                let base = cache.get_mut(&key).expect("inserted above");
635                let retargeted = PreparedScene {
636                    draws: retarget_draws(&base.draws, instance),
637                    clips: std::mem::take(&mut base.clips),
638                };
639                drop(cache);
640                open_clip(scene, draw.clip, instance, view);
641                renderer.append_repose_scene(&retargeted, scene);
642                close_clip(scene, draw.clip.is_some());
643                if let Some(base) = prepared.borrow_mut().get_mut(&key) {
644                    base.clips = retargeted.clips;
645                }
646            }
647        }
648    }
649}
650
651/// The contain-fitted world box inside `rect`, in physical px: the region the
652/// deleted viewport kept by scissoring its surface camera to it. Roll rotates
653/// the box about the look point, so the clip is the axis-aligned bounds of the
654/// rotated box; the box is then held inside `rect`, because a callback may only
655/// paint inside its own rect. The renderer rounds a clip inward to whole pixels
656/// and a clip that rounds away blocks every draw inside it, so a box that is
657/// sub-pixel, zero-sized or lands between whole pixels falls back to `rect`,
658/// the deleted unclipped path: a degenerate box never clips the run away.
659fn fitted_box(
660    rect: Rect,
661    world_size: [f32; 2],
662    fit: (f32, f32, f32),
663    roll: f32,
664    density: f32,
665) -> Rect {
666    let (s, ox, oy) = fit;
667    let (bw, bh) = (world_size[0] * s * density, world_size[1] * s * density);
668    let cx = rect.x + ox * density + bw * 0.5;
669    let cy = rect.y + oy * density + bh * 0.5;
670    let (hw, hh) = (bw * 0.5, bh * 0.5);
671    let (c, sn) = (roll.cos().abs(), roll.sin().abs());
672    let (ex, ey) = (hw * c + hh * sn, hw * sn + hh * c);
673    let aabb = Rect {
674        x: cx - ex,
675        y: cy - ey,
676        w: 2.0 * ex,
677        h: 2.0 * ey,
678    };
679    if !aabb.x.is_finite() || !aabb.y.is_finite() || !aabb.w.is_finite() || !aabb.h.is_finite() {
680        return rect;
681    }
682    let x = aabb.x.max(rect.x);
683    let y = aabb.y.max(rect.y);
684    let clip = Rect {
685        x,
686        y,
687        w: ((aabb.x + aabb.w).min(rect.x + rect.w) - x).max(0.0),
688        h: ((aabb.y + aabb.h).min(rect.y + rect.h) - y).max(0.0),
689    };
690    let x0 = clip.x.ceil();
691    let y0 = clip.y.ceil();
692    if (clip.x + clip.w).floor() - x0 >= 1.0 && (clip.y + clip.h).floor() - y0 >= 1.0 {
693        clip
694    } else {
695        rect
696    }
697}
698
699#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
700enum Medium {
701    Rig,
702    Raster,
703    Rect,
704}
705
706fn medium_key(item: ActorItem) -> Medium {
707    match item {
708        ActorItem::Rig { .. } => Medium::Rig,
709        ActorItem::Raster { .. } => Medium::Raster,
710        ActorItem::Rect { .. } => Medium::Rect,
711    }
712}
713
714/// Painter's law: `z` decides, and equal `z` keeps the producer order -
715/// the stable sort preserves it, standing in for GameMaker's
716/// instance-creation order at equal depth. `REPAME_DRAW_ORDER=batch`
717/// folds the batch keys (medium, page, tint) into ties instead: fewer
718/// draw runs, but equal-`z` sprites then follow atlas packing rather
719/// than creation order (the "few elements" z-flips).
720fn compare_draws(a: &ActorDraw, b: &ActorDraw) -> std::cmp::Ordering {
721    match a.z.partial_cmp(&b.z).unwrap_or(std::cmp::Ordering::Equal) {
722        std::cmp::Ordering::Equal if batch_order() => medium_key(a.item)
723            .cmp(&medium_key(b.item))
724            .then_with(|| item_key(a.item).cmp(&item_key(b.item)))
725            .then_with(|| tint_key(a.tint).cmp(&tint_key(b.tint))),
726        other => other,
727    }
728}
729
730fn batch_order() -> bool {
731    static BATCH: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
732    *BATCH.get_or_init(|| std::env::var("REPAME_DRAW_ORDER").is_ok_and(|v| v == "batch"))
733}
734
735fn item_key(item: ActorItem) -> u64 {
736    match item {
737        ActorItem::Rig { actor, frame } => (actor.0 as u64) << 32 | (frame.to_bits() & 0xffff_ffff),
738        ActorItem::Raster { page, .. } => page as u64,
739        ActorItem::Rect { .. } => 0,
740    }
741}
742
743fn unit_quad() -> Arc<repose_core::VectorMeshData> {
744    thread_local! {
745        static QUAD: Arc<repose_core::VectorMeshData> = Arc::new(repose_core::VectorMeshData {
746            vertices: [
747                ([-1.0, -1.0], [0.0, 0.0]),
748                ([1.0, -1.0], [1.0, 0.0]),
749                ([1.0, 1.0], [1.0, 1.0]),
750                ([-1.0, 1.0], [0.0, 1.0]),
751            ]
752            .iter()
753            .map(|(pos, uv)| repose_core::VectorVertex {
754                pos: *pos,
755                color: [1.0, 1.0, 1.0, 1.0],
756                uv: *uv,
757            })
758            .collect::<Arc<[_]>>(),
759            indices: [0u32, 1, 2, 0, 2, 3].into(),
760        });
761    }
762    QUAD.with(Arc::clone)
763}
764
765fn rect_instance(draw: &ActorDraw, half: [f32; 2], view: &ViewTransform) -> [f32; 6] {
766    let full = [2.0 * half[0], 2.0 * half[1]];
767    let world = compose(instance_affine_size(draw, full), view_affine(view));
768    [
769        world[0] as f32,
770        world[1] as f32,
771        world[2] as f32,
772        world[3] as f32,
773        world[4] as f32,
774        world[5] as f32,
775    ]
776}
777
778/// Same placement law as [`instance_affine`] but with an explicit cell size,
779/// so a rect can size its own quad.
780fn instance_affine_size(draw: &ActorDraw, size: [f32; 2]) -> [f64; 6] {
781    let (sin, cos) = draw.rotation.sin_cos();
782    let (sx, sy) = (draw.scale.x, draw.scale.y);
783    let affine = [cos * sx, sin * sx, -sin * sy, cos * sy, 0.0, 0.0];
784    let pivot = DVec2::new(
785        draw.anchor.x * f64::from(size[0]),
786        draw.anchor.y * f64::from(size[1]),
787    );
788    let rotated = apply(affine, pivot);
789    [
790        affine[0],
791        affine[1],
792        affine[2],
793        affine[3],
794        draw.world.x - rotated.x,
795        draw.world.y - rotated.y,
796    ]
797}
798
799fn sprite_instance(
800    draw: &ActorDraw,
801    page: u32,
802    uv_min: [f32; 2],
803    uv_max: [f32; 2],
804    size: [f32; 2],
805) -> crate::SpriteInstance {
806    let (sx, sy) = (draw.scale.x as f32, draw.scale.y as f32);
807    crate::SpriteInstance {
808        center: glam::Vec2::new(draw.world.x as f32, draw.world.y as f32),
809        rotation: draw.rotation as f32,
810        size: glam::Vec2::new(size[0] * sx.abs(), size[1] * sy.abs()),
811        anchor: glam::Vec2::new(draw.anchor.x as f32, draw.anchor.y as f32),
812        flip_x: sx < 0.0,
813        flip_y: sy < 0.0,
814        uv_min: glam::Vec2::new(uv_min[0], uv_min[1]),
815        uv_max: glam::Vec2::new(uv_max[0], uv_max[1]),
816        color: [
817            draw.tint.x as f32,
818            draw.tint.y as f32,
819            draw.tint.z as f32,
820            draw.tint.w as f32,
821        ],
822        page,
823        z: draw.z,
824        blend: match draw.blend {
825            BlendMode::Add => crate::SpriteBlend::Additive,
826            BlendMode::Multiply => crate::SpriteBlend::Multiply,
827            _ => crate::SpriteBlend::Alpha,
828        },
829        light_mask: 1,
830    }
831}
832
833fn open_clip(
834    scene: &mut repose_core::Scene,
835    clip: Option<[f64; 4]>,
836    instance: [f64; 6],
837    view: &ViewTransform,
838) {
839    let Some([x, y, w, h]) = clip else {
840        return;
841    };
842    let world = compose(instance, view_affine(view));
843    let corners = [
844        apply(world, DVec2::new(x, y)),
845        apply(world, DVec2::new(x + w, y)),
846        apply(world, DVec2::new(x + w, y + h)),
847        apply(world, DVec2::new(x, y + h)),
848    ];
849    let (lo, hi) = corners.iter().fold(
850        (DVec2::splat(f64::INFINITY), DVec2::splat(f64::NEG_INFINITY)),
851        |(lo, hi), p| (lo.min(*p), hi.max(*p)),
852    );
853    scene.nodes.push(SceneNode::PushVectorClip {
854        mesh: clip_mesh(lo.x, lo.y, hi.x, hi.y),
855        op: repose_core::ClipOp::Intersect,
856    });
857}
858
859fn close_clip(scene: &mut repose_core::Scene, pushed: bool) {
860    if pushed {
861        scene.nodes.push(SceneNode::PopVectorClip);
862    }
863}
864
865/// World camera surface: every rig in `library`, instanced by `draws`.
866pub fn actors_view_world(
867    library: Rc<RefCell<ActorLibrary>>,
868    draws: Rc<ActorDrawList>,
869    camera: Camera2d,
870    world_size: [f32; 2],
871    ctx: RenderContext,
872) -> View {
873    actors_view_mixed(library, draws, camera, world_size, ctx, None)
874}
875
876/// Pointer plumbing for [`actors_view_world_with_events`]. The `DrawScope` half
877/// of a viewport has no hit channel of its own, so the camera law is replayed
878/// here to rebuild the `PickEvent`s a sprite viewport emits.
879struct PickState {
880    sink: Rc<dyn Fn(PickEvent)>,
881    camera: Camera2d,
882    world_size: [f32; 2],
883    canvas_dp: Rc<RefCell<[f32; 2]>>,
884    press: HashMap<u64, [f32; 2]>,
885}
886
887impl PickState {
888    fn project(&self, position: repose_core::Vec2) -> ([f32; 2], glam::Vec2) {
889        let density = effective_density_scale().max(1e-6);
890        let dp = [position.x as f32 / density, position.y as f32 / density];
891        let canvas = *self.canvas_dp.borrow();
892        if std::env::var("NT_PICK_TRACE").is_ok() {
893            eprintln!(
894                "nt-actors: canvas_dp={canvas:?} world_size={:?}",
895                self.world_size
896            );
897        }
898        let fit = effective_fit(canvas, self.world_size, &self.camera);
899        let world = dp_to_world(dp, self.world_size, self.camera.effective_center(), fit);
900        // `PickEvent.screen` is window-PHYSICAL px, not dp, so consumers
901        // convert once. Callers must pass `window_position`: Repose rewrites
902        // `event.position` to be hit-region-local (`repose-app` runtime), and
903        // a pick surface nested in an offset container would otherwise shift
904        // every pick by that origin.
905        (
906            [position.x as f32, position.y as f32],
907            glam::Vec2::new(world[0], world[1]),
908        )
909    }
910
911    fn is_contact(&self, event: &PointerEvent) -> bool {
912        matches!(event.kind, PointerKind::Touch | PointerKind::Pen)
913    }
914}
915
916/// World camera surface that also reports [`PickEvent`]s: press/release in
917/// world and screen space, hover, a click gated behind
918/// [`click_within_slop_dp`], and the touch triplet.
919pub fn actors_view_world_with_events(
920    library: Rc<RefCell<ActorLibrary>>,
921    draws: Rc<ActorDrawList>,
922    camera: Camera2d,
923    world_size: [f32; 2],
924    canvas_dp: [f32; 2],
925    ctx: RenderContext,
926    on_event: Rc<dyn Fn(PickEvent)>,
927) -> View {
928    actors_view_mixed_with_events(
929        library, draws, camera, world_size, canvas_dp, ctx, None, on_event,
930    )
931}
932
933/// [`actors_view_mixed`] with the [`PickEvent`] surface of
934/// [`actors_view_world_with_events`].
935pub fn actors_view_mixed_with_events(
936    library: Rc<RefCell<ActorLibrary>>,
937    draws: Rc<ActorDrawList>,
938    camera: Camera2d,
939    world_size: [f32; 2],
940    canvas_dp: [f32; 2],
941    ctx: RenderContext,
942    atlas: Option<Rc<RasterAtlas>>,
943    on_event: Rc<dyn Fn(PickEvent)>,
944) -> View {
945    let surface: Rc<RefCell<[f32; 2]>> =
946        Rc::new(RefCell::new([canvas_dp[0].max(1.0), canvas_dp[1].max(1.0)]));
947    let state: Rc<RefCell<PickState>> = Rc::new(RefCell::new(PickState {
948        sink: on_event,
949        camera,
950        world_size,
951        canvas_dp: surface.clone(),
952        press: HashMap::new(),
953    }));
954
955    let surface_view = actors_view_mixed(library, draws, camera, world_size, ctx, atlas);
956
957    let down = state.clone();
958    let up = state.clone();
959    let move_state = state.clone();
960    let leave = state.clone();
961    let sized = surface.clone();
962    let sized_paint = sized.clone();
963    let modifier = Modifier::new()
964        .fill_max_size()
965        // The painter is the only place the engine hands this node its laid
966        // out rect. `on_size_changed` alone left `canvas_dp` at its [1,1] value..
967        .painter(
968            move |_scene: &mut repose_core::Scene, rect: repose_core::Rect, _alpha: f32| {
969                let mut cell = sized_paint.borrow_mut();
970                if cell[0] < 1.0 || cell[1] < 1.0 {
971                    *cell = [rect.w.max(1.0), rect.h.max(1.0)];
972                }
973            },
974        )
975        .on_size_changed(move |size| {
976            *sized.borrow_mut() = [size.x.max(1.0), size.y.max(1.0)];
977        })
978        .on_pointer_down(move |event| {
979            let mut state = down.borrow_mut();
980            let (screen, world) = state.project(event.window_position);
981            let button = match event.event {
982                PointerEventKind::Down(button) => button,
983                _ => PointerButton::Primary,
984            };
985            state.press.insert(event.id.0, screen);
986            (state.sink)(PickEvent::Press {
987                world,
988                screen,
989                button,
990            });
991            if state.is_contact(&event) {
992                (state.sink)(PickEvent::TouchDown {
993                    id: event.id.0,
994                    screen,
995                });
996            }
997        })
998        .on_pointer_up(move |event| {
999            let mut state = up.borrow_mut();
1000            let (screen, world) = state.project(event.window_position);
1001            let button = match event.event {
1002                PointerEventKind::Up(button) => button,
1003                _ => PointerButton::Primary,
1004            };
1005            let origin = state.press.remove(&event.id.0);
1006            let density = effective_density_scale().max(1e-6);
1007            (state.sink)(PickEvent::Release {
1008                world,
1009                screen,
1010                button,
1011            });
1012            if state.is_contact(&event) {
1013                (state.sink)(PickEvent::TouchUp { id: event.id.0 });
1014            }
1015            if origin.is_some_and(|start| click_within_slop_dp(start, screen, density)) {
1016                (state.sink)(PickEvent::Click {
1017                    world,
1018                    screen,
1019                    button,
1020                });
1021            }
1022        })
1023        .on_pointer_move(move |event| {
1024            let state = move_state.borrow();
1025            let (screen, world) = state.project(event.window_position);
1026            (state.sink)(PickEvent::Hover { world, screen });
1027            if state.is_contact(&event) {
1028                (state.sink)(PickEvent::TouchMove {
1029                    id: event.id.0,
1030                    screen,
1031                });
1032            }
1033        })
1034        .on_pointer_leave(move |event| {
1035            let mut state = leave.borrow_mut();
1036            if state.press.remove(&event.id.0).is_some() {
1037                (state.sink)(PickEvent::TouchUp { id: event.id.0 });
1038            }
1039        });
1040
1041    // Later children stack on top, so the transparent hit region sits above
1042    // the surface it reports picks for.
1043    ZStack(Modifier::new().fill_max_size())
1044        .child(surface_view)
1045        .child(UiBox(modifier))
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050    use super::*;
1051    use crate::{effective_fit, world_to_dp};
1052    use renamite_animation::{Animated, Frame, FrameRate};
1053    use renamite_model::{
1054        Color, Document, FillRule, Node, NodeKind, Parent, ShapeKind, StyleKind, StylePaint,
1055    };
1056    use renamite_render_bridge::SceneRenderer;
1057    use std::cell::RefCell;
1058    use std::rc::Rc;
1059
1060    fn rig(size: DVec2, color: Color) -> Document {
1061        let mut doc = Document::empty();
1062        {
1063            let comp = doc.main_composition_mut().unwrap();
1064            comp.size = (size.x as u32, size.y as u32);
1065            comp.rate = FrameRate { num: 12, den: 1 };
1066            comp.range = (Frame(0), Frame(1));
1067        }
1068        let root = doc.create_node(Node::new("rig", NodeKind::Group));
1069        doc.attach(root, Parent::Comp(doc.main), 0).unwrap();
1070        let shape = doc.create_node(Node::new(
1071            "box",
1072            NodeKind::Shape(ShapeKind::Rect {
1073                pos: Animated::new(DVec2::new(size.x * 0.5, size.y * 0.5)),
1074                size: Animated::new(size),
1075                rounded: Animated::new(0.0),
1076            }),
1077        ));
1078        doc.attach(shape, Parent::Node(root), 0).unwrap();
1079        let fill = doc.create_node(Node::new(
1080            "fill",
1081            NodeKind::Style(StyleKind::Fill {
1082                paint: StylePaint::Solid {
1083                    color: Animated::new(color),
1084                },
1085                rule: FillRule::NonZero,
1086            }),
1087        ));
1088        doc.attach(fill, Parent::Node(root), 1).unwrap();
1089        doc
1090    }
1091
1092    fn view_affine(view: &ViewTransform) -> [f64; 6] {
1093        [
1094            view.scale,
1095            0.0,
1096            0.0,
1097            view.scale,
1098            view.offset.x,
1099            view.offset.y,
1100        ]
1101    }
1102
1103    fn close(a: DVec2, b: DVec2) -> bool {
1104        (a - b).length() < 1e-4
1105    }
1106
1107    /// The camera mapping must agree with the engine's own forward projection, or
1108    /// every actor lands somewhere the picking code does not expect.
1109    #[test]
1110    fn camera_view_agrees_with_world_to_dp() {
1111        let canvas_dp = [640.0, 360.0];
1112        let world_size = [800.0, 600.0];
1113        for center in [
1114            DVec2::new(0.0, 0.0),
1115            DVec2::new(400.0, 300.0),
1116            DVec2::new(913.0, -47.0),
1117        ] {
1118            for zoom in [1.0f32, 2.5, 0.4] {
1119                let camera = Camera2d {
1120                    center: center.as_vec2(),
1121                    zoom,
1122                    ..Camera2d::default()
1123                };
1124                let view = camera_view(&camera, canvas_dp, world_size);
1125                let affine = view_affine(&view);
1126                for world in [
1127                    DVec2::new(0.0, 0.0),
1128                    DVec2::new(400.0, 300.0),
1129                    DVec2::new(799.0, 599.0),
1130                ] {
1131                    let via_engine = world_to_dp(
1132                        [world.x as f32, world.y as f32],
1133                        world_size,
1134                        camera.effective_center(),
1135                        effective_fit(canvas_dp, world_size, &camera),
1136                    );
1137                    let via_actor = apply(affine, world);
1138                    assert!(
1139                        close(
1140                            via_actor,
1141                            DVec2::new(via_engine[0] as f64, via_engine[1] as f64)
1142                        ),
1143                        "center={center:?} zoom={zoom} world={world:?}: actor {via_actor:?} vs engine {via_engine:?}"
1144                    );
1145                }
1146            }
1147        }
1148    }
1149
1150    #[test]
1151    fn compose_is_apply_a_then_b() {
1152        let a = [2.0, 0.0, 0.0, 2.0, 10.0, 20.0];
1153        let b = [1.0, 0.0, 0.0, 1.0, -4.0, 6.0];
1154        let p = DVec2::new(3.0, 7.0);
1155        assert!(close(apply(compose(a, b), p), apply(b, apply(a, p))));
1156    }
1157
1158    #[test]
1159    fn compose_is_associative() {
1160        let a = [2.0, 0.5, -0.5, 3.0, 10.0, 20.0];
1161        let b = [1.0, 0.25, 0.75, 1.0, -4.0, 6.0];
1162        let c = [0.5, 0.0, 0.0, 0.5, 100.0, -50.0];
1163        let left = compose(compose(a, b), c);
1164        let right = compose(a, compose(b, c));
1165        for p in [DVec2::ZERO, DVec2::new(1.0, 2.0), DVec2::new(-9.0, 4.5)] {
1166            assert!(close(apply(left, p), apply(right, p)));
1167        }
1168    }
1169
1170    /// Anchored placement: the anchor point of the cell must land on `world`.
1171    #[test]
1172    fn instance_affine_places_the_anchor_on_world() {
1173        let size = DVec2::new(24.0, 32.0);
1174        for anchor in [
1175            DVec2::new(0.5, 0.5),
1176            DVec2::ZERO,
1177            DVec2::ONE,
1178            DVec2::new(0.25, 0.75),
1179        ] {
1180            let draw = ActorDraw {
1181                world: DVec2::new(120.0, -40.0),
1182                anchor,
1183                ..ActorDraw::default()
1184            };
1185            let affine = instance_affine(&draw, size);
1186            assert!(
1187                close(apply(affine, anchor * size), draw.world),
1188                "anchor {anchor:?}"
1189            );
1190        }
1191    }
1192
1193    #[test]
1194    fn instance_affine_rotation_and_flip() {
1195        let size = DVec2::new(10.0, 10.0);
1196
1197        // A quarter turn sends the point half a cell right of the anchor to half a
1198        // cell below it, because y grows downward.
1199        let turned = ActorDraw {
1200            world: DVec2::ZERO,
1201            anchor: DVec2::splat(0.5),
1202            rotation: std::f64::consts::FRAC_PI_2,
1203            ..ActorDraw::default()
1204        };
1205        assert!(close(
1206            apply(instance_affine(&turned, size), DVec2::new(10.0, 5.0)),
1207            DVec2::new(0.0, 5.0)
1208        ));
1209
1210        // A negative x scale mirrors about the anchor, which is not the same as
1211        // mirroring about the cell origin.
1212        let mirrored = ActorDraw {
1213            world: DVec2::splat(5.0),
1214            anchor: DVec2::splat(0.5),
1215            scale: DVec2::new(-1.0, 1.0),
1216            ..ActorDraw::default()
1217        };
1218        assert!(close(
1219            apply(instance_affine(&mirrored, size), DVec2::new(10.0, 5.0)),
1220            DVec2::new(0.0, 5.0)
1221        ));
1222    }
1223
1224    /// The load-bearing claim: retargeting the prepared transform must place every
1225    /// vertex exactly where baking the same placement into the scene before
1226    /// `prepare` would have.
1227    #[test]
1228    fn retarget_matches_baking_the_placement_into_the_scene() {
1229        let size = DVec2::new(24.0, 32.0);
1230        let doc = rig(size, Color::rgba(0.2, 0.6, 0.9, 1.0));
1231        let scene = renamite_model::evaluate(&doc, doc.main, 0.0);
1232        let view = ViewTransform {
1233            scale: 2.5,
1234            offset: DVec2::new(13.0, -7.0),
1235        };
1236
1237        for draw in [
1238            ActorDraw::default(),
1239            ActorDraw {
1240                world: DVec2::new(300.0, -125.0),
1241                ..ActorDraw::default()
1242            },
1243            ActorDraw {
1244                world: DVec2::new(-64.0, 41.0),
1245                rotation: 0.7,
1246                scale: DVec2::new(1.75, 0.5),
1247                anchor: DVec2::new(0.0, 1.0),
1248                ..ActorDraw::default()
1249            },
1250            ActorDraw {
1251                world: DVec2::new(10.0, 20.0),
1252                rotation: -2.1,
1253                scale: DVec2::new(-1.0, 2.0),
1254                anchor: DVec2::new(0.25, 0.75),
1255                ..ActorDraw::default()
1256            },
1257        ] {
1258            let instance = instance_affine(&draw, size);
1259
1260            let fast = SceneRenderer::new().prepare(&scene, &view);
1261            let fast = retarget_draws(&fast.draws, instance);
1262
1263            let mut baked = scene.clone();
1264            for item in &mut baked.items {
1265                item.path.apply_affine(kurbo::Affine::new(instance));
1266            }
1267            let slow = SceneRenderer::new().prepare(&baked, &view);
1268
1269            assert_eq!(fast.len(), slow.draws.len());
1270            for (fast, slow) in fast.iter().zip(&slow.draws) {
1271                let (
1272                    PreparedDraw::Vector {
1273                        mesh: fast_mesh,
1274                        transform: fast_transform,
1275                        ..
1276                    },
1277                    PreparedDraw::Vector {
1278                        mesh: slow_mesh,
1279                        transform: slow_transform,
1280                        ..
1281                    },
1282                ) = (fast, slow)
1283                else {
1284                    panic!("expected vector draws");
1285                };
1286                // Tessellation quantises, so compare the transformed extents
1287                // rather than vertex-for-vertex identity.
1288                let bounds = |mesh: &repose_core::VectorMeshData, t: &[f32; 6]| {
1289                    mesh.vertices
1290                        .iter()
1291                        .map(|v| {
1292                            DVec2::new(
1293                                f64::from(t[0]) * f64::from(v.pos[0])
1294                                    + f64::from(t[2]) * f64::from(v.pos[1])
1295                                    + f64::from(t[4]),
1296                                f64::from(t[1]) * f64::from(v.pos[0])
1297                                    + f64::from(t[3]) * f64::from(v.pos[1])
1298                                    + f64::from(t[5]),
1299                            )
1300                        })
1301                        .fold(
1302                            (DVec2::splat(f64::INFINITY), DVec2::splat(f64::NEG_INFINITY)),
1303                            |(lo, hi), p| (lo.min(p), hi.max(p)),
1304                        )
1305                };
1306                let (fast_bounds, slow_bounds) = (
1307                    bounds(fast_mesh, fast_transform),
1308                    bounds(slow_mesh, slow_transform),
1309                );
1310                assert!(
1311                    close(fast_bounds.0, slow_bounds.0) && close(fast_bounds.1, slow_bounds.1),
1312                    "placement {draw:?}\nfast {fast_bounds:?}\nslow {slow_bounds:?}"
1313                );
1314            }
1315        }
1316    }
1317
1318    #[test]
1319    fn tint_scene_scales_only_solid_fills() {
1320        let doc = rig(DVec2::new(4.0, 4.0), Color::rgba(0.5, 0.5, 0.5, 1.0));
1321        let scene = renamite_model::evaluate(&doc, doc.main, 0.0);
1322        let tinted = tint_scene(&scene, DVec4::new(0.5, 0.25, 1.0, 0.5));
1323        let ScenePaint::Solid(color) = tinted.items[0].paint else {
1324            panic!("expected solid");
1325        };
1326        assert!((color.r - 0.25).abs() < 1e-9, "r was {}", color.r);
1327        assert!((color.g - 0.125).abs() < 1e-9, "g was {}", color.g);
1328        assert!((color.b - 0.5).abs() < 1e-9, "b was {}", color.b);
1329        assert!((color.a - 0.5).abs() < 1e-9, "a was {}", color.a);
1330    }
1331
1332    #[test]
1333    fn library_round_trips_rigs_and_reports_ids() {
1334        let mut library = ActorLibrary::new();
1335        assert!(library.is_empty());
1336        let first = library
1337            .push_ren_str(crate::MINIMAL_REN)
1338            .expect("minimal document parses");
1339        let second = library
1340            .push_ren_str(crate::MINIMAL_REN)
1341            .expect("minimal document parses");
1342        assert_eq!(library.len(), 2);
1343        assert_eq!(first, ActorId(0));
1344        assert_eq!(second, ActorId(1));
1345        assert!(library.host(second).is_some());
1346        assert!(library.host(ActorId(2)).is_none());
1347    }
1348
1349    #[test]
1350    fn draw_list_sorts_by_z() {
1351        let mut list = ActorDrawList::new();
1352        for z in [3.0, -1.0, 2.0, 0.0] {
1353            list.push(ActorDraw {
1354                z,
1355                ..ActorDraw::default()
1356            });
1357        }
1358        let mut sorted = list.as_slice().to_vec();
1359        sorted.sort_by(|a, b| a.z.partial_cmp(&b.z).unwrap());
1360        assert_eq!(
1361            sorted.iter().map(|d| d.z).collect::<Vec<_>>(),
1362            vec![-1.0, 0.0, 2.0, 3.0]
1363        );
1364    }
1365
1366    #[test]
1367    fn mixed_media_share_one_z_order() {
1368        let rig = |z: f32| ActorDraw {
1369            item: ActorItem::Rig {
1370                actor: ActorId(0),
1371                frame: 0.0,
1372            },
1373            z,
1374            ..ActorDraw::default()
1375        };
1376        let raster = |z: f32| ActorDraw {
1377            item: ActorItem::Raster {
1378                page: 0,
1379                uv_min: [0.0, 0.0],
1380                uv_max: [1.0, 1.0],
1381                size: [8.0, 8.0],
1382            },
1383            z,
1384            ..ActorDraw::default()
1385        };
1386        let rect = |z: f32| ActorDraw {
1387            item: ActorItem::Rect { half: [4.0, 4.0] },
1388            z,
1389            ..ActorDraw::default()
1390        };
1391        let mut draws = [rig(1.0), raster(0.0), rect(2.0), rig(0.5)];
1392        draws.sort_by(compare_draws);
1393        let media: Vec<Medium> = draws.iter().map(|d| medium_key(d.item)).collect();
1394        assert_eq!(
1395            media,
1396            vec![Medium::Raster, Medium::Rig, Medium::Rig, Medium::Rect],
1397            "z is the major key across media: raster@0, rig@0.5, rig@1, rect@2"
1398        );
1399    }
1400
1401    #[test]
1402    fn world_view_constructs_without_a_backend() {
1403        let mut library = ActorLibrary::new();
1404        library.push_ren_str(crate::MINIMAL_REN).unwrap();
1405        let mut draws = ActorDrawList::new();
1406        draws.push(ActorDraw {
1407            world: DVec2::new(12.0, 34.0),
1408            ..ActorDraw::default()
1409        });
1410        let ctx = RenderContext::new();
1411        let _view = actors_view_world(
1412            Rc::new(RefCell::new(library)),
1413            Rc::new(draws),
1414            Camera2d::default(),
1415            [800.0, 600.0],
1416            ctx,
1417        );
1418    }
1419
1420    /// `PickState::project` returns window-physical px and unprojects the world
1421    /// point from dp. Emitting dp made every consumer's own px->dp conversion a
1422    /// second divide, landing clicks at half their position.
1423    #[test]
1424    fn project_emits_physical_px_and_unprojects_from_dp() {
1425        let canvas_dp = [800.0, 600.0];
1426        let world_size = [320.0, 240.0];
1427        let camera = Camera2d::default();
1428        let density = effective_density_scale().max(1e-6);
1429        let pick = PickState {
1430            sink: Rc::new(|_| {}),
1431            camera,
1432            world_size,
1433            canvas_dp: Rc::new(RefCell::new(canvas_dp)),
1434            press: HashMap::new(),
1435        };
1436        let physical = repose_core::Vec2 { x: 640.0, y: 480.0 };
1437        let (screen, world) = pick.project(physical);
1438        assert_eq!(screen, [640.0, 480.0], "screen must stay physical px");
1439        let dp = [640.0 / density, 480.0 / density];
1440        let fit = effective_fit(canvas_dp, world_size, &camera);
1441        let want = dp_to_world(dp, world_size, camera.effective_center(), fit);
1442        assert!(
1443            (world.x - want[0]).abs() < 1e-3 && (world.y - want[1]).abs() < 1e-3,
1444            "world must unproject through the dp screen, got {world:?} want {want:?}"
1445        );
1446    }
1447}