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