Skip to main content

rusterix/map/
surface.rs

1use crate::{Map, Sector};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4use vek::{Vec2, Vec3};
5
6use earcutr::earcut;
7
8/// Animation type for billboards
9#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Default)]
10pub enum BillboardAnimation {
11    #[default]
12    None,
13    OpenUp,    // Gate opens upward
14    OpenRight, // Gate opens to the right
15    OpenDown,  // Gate opens downward
16    OpenLeft,  // Gate opens to the left
17    Fade,      // Gate fades in/out
18}
19
20/// Operation applied to a profile loop on this surface (non-destructive).
21#[derive(Serialize, Deserialize, Clone, Debug)]
22pub enum LoopOp {
23    None,
24    Relief {
25        height: f32,
26    }, // positive outward along surface normal
27    Recess {
28        depth: f32,
29    }, // positive inward along surface normal
30    Billboard {
31        tile_id: Option<Uuid>,         // Tile UUID to render on billboard
32        animation: BillboardAnimation, // Animation type
33        inset: f32,                    // Offset from surface (positive = along normal)
34    },
35    Window {
36        frame_tile_id: Option<Uuid>,
37        glass_tile_id: Option<Uuid>,
38        inset: f32,
39    },
40}
41
42impl LoopOp {
43    /// Convert this LoopOp to ActionProperties for use with the SurfaceAction trait system
44    pub fn to_action_properties(
45        &self,
46        target_side: i32,
47    ) -> crate::chunkbuilder::action::ActionProperties {
48        use crate::chunkbuilder::action::ActionProperties;
49
50        match self {
51            LoopOp::None => ActionProperties::default().with_target_side(target_side),
52            LoopOp::Relief { height } => ActionProperties::default()
53                .with_height(*height)
54                .with_target_side(target_side),
55            LoopOp::Recess { depth } => ActionProperties::default()
56                .with_depth(*depth)
57                .with_target_side(target_side),
58            LoopOp::Billboard {
59                tile_id,
60                animation,
61                inset,
62            } => ActionProperties::default()
63                .with_depth(*inset)
64                .with_target_side(target_side)
65                .with_tile_id(*tile_id)
66                .with_animation(*animation),
67            LoopOp::Window {
68                frame_tile_id,
69                inset,
70                ..
71            } => ActionProperties::default()
72                .with_depth(*inset)
73                .with_target_side(target_side)
74                .with_tile_id(*frame_tile_id),
75        }
76    }
77
78    /// Get the appropriate SurfaceAction implementation for this operation
79    pub fn get_action(&self) -> Option<Box<dyn crate::chunkbuilder::action::SurfaceAction>> {
80        use crate::chunkbuilder::action::{
81            BillboardAction, HoleAction, RecessAction, ReliefAction,
82        };
83
84        match self {
85            LoopOp::None => Some(Box::new(HoleAction)),
86            LoopOp::Relief { .. } => Some(Box::new(ReliefAction)),
87            LoopOp::Recess { .. } => Some(Box::new(RecessAction)),
88            LoopOp::Billboard { .. } => Some(Box::new(BillboardAction)),
89            LoopOp::Window { .. } => None,
90        }
91    }
92}
93
94/// One closed loop in the surface's UV/profile space.
95#[derive(Serialize, Deserialize, Clone, Debug)]
96pub struct ProfileLoop {
97    pub path: Vec<Vec2<f32>>, // points in UV space, assumed to be simple polygon
98    pub op: LoopOp,           // optional loop-specific op
99    /// The profile-map sector this loop came from. `None` for the outer host loop.
100    pub origin_profile_sector: Option<u32>,
101}
102
103/// Represents a geometric plane defined by an origin and a normal vector.
104#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default)]
105pub struct Plane {
106    pub origin: Vec3<f32>,
107    pub normal: Vec3<f32>,
108}
109
110/// Represents a 3D basis with right, up, and normal vectors.
111#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default)]
112pub struct Basis3 {
113    pub right: Vec3<f32>,
114    pub up: Vec3<f32>,
115    pub normal: Vec3<f32>,
116}
117
118/// Defines an editable plane with origin, axes for 2D editing, and a scale factor.
119#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default)]
120pub struct EditPlane {
121    pub origin: Vec3<f32>,
122    pub right: Vec3<f32>,
123    pub up: Vec3<f32>,
124    pub scale: f32,
125}
126
127/// Represents an attachment with a transform relative to a surface and optional mesh or procedural references.
128#[derive(Serialize, Deserialize, Clone, Debug)]
129pub struct Attachment {
130    pub id: Uuid,
131    pub surface_id: Uuid,
132    pub transform: [[f32; 4]; 4],
133    pub mesh_ref: Option<Uuid>,
134    pub proc_ref: Option<Uuid>,
135}
136
137/// UV mapping strategy for extruded side walls and caps.
138#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
139pub enum ExtrudeUV {
140    /// U follows edge length; V follows depth. Scales apply as multipliers.
141    Stretch { scale_u: f32, scale_v: f32 },
142    /// Planar UV for caps (using surface UV), stretch for sides with uniform scale.
143    PlanarFront { scale: f32 },
144}
145
146impl Default for ExtrudeUV {
147    fn default() -> Self {
148        Self::Stretch {
149            scale_u: 1.0,
150            scale_v: 1.0,
151        }
152    }
153}
154
155/// How this surface turns into 3D geometry.
156#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
157pub struct ExtrusionSpec {
158    pub enabled: bool,     // if false, flat cap only
159    pub depth: f32,        // thickness along +N (negative = -N)
160    pub cap_front: bool,   // cap at origin plane
161    pub cap_back: bool,    // cap at origin + depth
162    pub flip_normal: bool, // invert N at build-time if needed
163    pub uv: ExtrudeUV,     // UV mapping mode for sides/caps
164}
165
166impl Default for ExtrusionSpec {
167    fn default() -> Self {
168        Self {
169            enabled: false,
170            depth: 0.0,
171            cap_front: true,
172            cap_back: false,
173            flip_normal: false,
174            uv: ExtrudeUV::default(),
175        }
176    }
177}
178
179/// Represents a surface with the sector owner, geometry, and profile.
180#[derive(Serialize, Deserialize, Clone, Debug)]
181pub struct Surface {
182    pub id: Uuid,
183    pub sector_id: u32,
184
185    /// Geometric frame of the editable plane for this surface
186    pub plane: Plane,
187    pub frame: Basis3,
188    pub edit_uv: EditPlane,
189
190    /// Extrusion parameters for this surface (depth, caps, UVs).
191    #[serde(default)]
192    pub extrusion: ExtrusionSpec,
193
194    /// Uuid of the Profile
195    pub profile: Option<Uuid>,
196
197    /// Optional, the vertices of the surface in world coordinates, used in cases where we need to pass standalone surfaces.
198    #[serde(skip)]
199    pub world_vertices: Vec<Vec3<f32>>,
200}
201
202impl Surface {
203    pub fn new(sector_id: u32) -> Surface {
204        Surface {
205            id: Uuid::new_v4(),
206            sector_id,
207            plane: Plane::default(),
208            frame: Basis3::default(),
209            edit_uv: EditPlane::default(),
210            extrusion: ExtrusionSpec::default(),
211            profile: None,
212            world_vertices: vec![],
213        }
214    }
215
216    /// Returns true if the surface has valid (finite) transform values
217    pub fn is_valid(&self) -> bool {
218        self.plane.origin.x.is_finite()
219            && self.plane.origin.y.is_finite()
220            && self.plane.origin.z.is_finite()
221            && self.plane.normal.x.is_finite()
222            && self.plane.normal.y.is_finite()
223            && self.plane.normal.z.is_finite()
224            && self.frame.right.x.is_finite()
225            && self.frame.right.y.is_finite()
226            && self.frame.right.z.is_finite()
227            && self.frame.up.x.is_finite()
228            && self.frame.up.y.is_finite()
229            && self.frame.up.z.is_finite()
230            && self.frame.normal.x.is_finite()
231            && self.frame.normal.y.is_finite()
232            && self.frame.normal.z.is_finite()
233    }
234
235    /// Calculate the geometry
236    pub fn calculate_geometry(&mut self, map: &Map) {
237        if let Some(sector) = map.find_sector(self.sector_id) {
238            if let Some(points) = sector.vertices_world(map) {
239                // existing logic using `points`
240                let (centroid, mut normal) = newell_plane(&points);
241                if normal.magnitude() < 1e-6 {
242                    normal = Vec3::new(0.0, 1.0, 0.0);
243                }
244                let mut right = stable_right(&points, normal);
245                let mut up = normalize_or_zero(normal.cross(right));
246
247                if up.magnitude() < 1e-6 {
248                    // fallback: try swapping axes
249                    right = normalize_or_zero(normal.cross(Vec3::new(0.0, 1.0, 0.0)));
250                    up = normalize_or_zero(normal.cross(right));
251                }
252
253                if up.magnitude() < 1e-6 {
254                    // final fallback
255                    right = Vec3::new(1.0, 0.0, 0.0);
256                    up = normalize_or_zero(normal.cross(right));
257                }
258
259                // ensure orthonormal basis (flip right if needed)
260                let test_up = normalize_or_zero(normal.cross(right));
261                if test_up.magnitude() > 1e-6 && (test_up - up).magnitude() > 1e-6 {
262                    right = -right;
263                    up = normalize_or_zero(normal.cross(right));
264                }
265
266                self.plane.origin = centroid;
267                self.plane.normal = normal;
268
269                self.frame.right = right;
270                self.frame.up = up;
271                self.frame.normal = self.plane.normal;
272
273                self.edit_uv.origin = self.plane.origin;
274                self.edit_uv.right = self.frame.right;
275                self.edit_uv.up = self.frame.up;
276                self.edit_uv.scale = 1.0;
277                return;
278            } else {
279                self.plane = Default::default();
280                self.frame = Default::default();
281                self.edit_uv = Default::default();
282                return;
283            }
284        }
285        self.plane = Default::default();
286        self.frame = Default::default();
287        self.edit_uv = Default::default();
288    }
289
290    /// Map a UV point on the surface plane to world space (w = 0 plane).
291    pub fn uv_to_world(&self, uv: Vec2<f32>) -> Vec3<f32> {
292        self.edit_uv.origin
293            + self.edit_uv.right * uv.x * self.edit_uv.scale
294            + self.edit_uv.up * uv.y * self.edit_uv.scale
295    }
296
297    /// Map a UVW point (UV on the surface, W along the surface normal) to world space.
298    pub fn uvw_to_world(&self, uv: Vec2<f32>, w: f32) -> Vec3<f32> {
299        self.uv_to_world(uv) + self.frame.normal * w
300    }
301
302    pub fn world_to_uv(&self, p: Vec3<f32>) -> Vec2<f32> {
303        let rel = p - self.edit_uv.origin;
304        Vec2::new(rel.dot(self.edit_uv.right), rel.dot(self.edit_uv.up)) / self.edit_uv.scale
305    }
306
307    /// Compute the minimum UV of the owning sector projected onto this surface.
308    /// Used to rebase tile coordinates to a sector-local 0..N space.
309    pub fn sector_uv_min(&self, map: &Map) -> Option<Vec2<f32>> {
310        let loop_uv = self.sector_loop_uv(map)?;
311        if loop_uv.is_empty() {
312            return None;
313        }
314        let mut min = Vec2::new(f32::INFINITY, f32::INFINITY);
315        for p in &loop_uv {
316            min.x = min.x.min(p.x);
317            min.y = min.y.min(p.y);
318        }
319        Some(min)
320    }
321
322    /// Anchor UV for tile-local mapping.
323    /// For wall-like surfaces, use a world-anchored UV origin to keep painting
324    /// stable across inside/outside and adjacent coplanar surfaces.
325    /// For floor/ceiling-like surfaces, keep sector-local behavior.
326    pub fn tile_local_anchor_uv(&self, map: &Map) -> Vec2<f32> {
327        if self.plane.normal.y.abs() < 0.25 {
328            // world_to_uv(0) is the per-surface offset that converts local UV to global UV.
329            self.world_to_uv(Vec3::zero())
330        } else {
331            self.sector_uv_min(map).unwrap_or(Vec2::zero())
332        }
333    }
334
335    /// Returns true if tile-local X should be mirrored for this surface to keep
336    /// wall painting orientation stable independent of wall normal direction.
337    pub fn tile_local_flip_x(&self) -> bool {
338        if self.plane.normal.y.abs() >= 0.25 {
339            return false;
340        }
341        let rx = self.edit_uv.right.x.abs();
342        let rz = self.edit_uv.right.z.abs();
343        let dominant = if rx >= rz {
344            self.edit_uv.right.x
345        } else {
346            self.edit_uv.right.z
347        };
348        dominant < 0.0
349    }
350
351    /// Convert UV to continuous tile-local coordinates.
352    pub fn uv_to_tile_local(&self, uv: Vec2<f32>, map: &Map) -> Vec2<f32> {
353        let anchor = self.tile_local_anchor_uv(map);
354        let x = if self.tile_local_flip_x() {
355            anchor.x - uv.x
356        } else {
357            uv.x - anchor.x
358        };
359        let y = uv.y - anchor.y;
360        Vec2::new(x, y)
361    }
362
363    /// Convert continuous tile-local coordinates back to UV.
364    pub fn tile_local_to_uv(&self, local: Vec2<f32>, map: &Map) -> Vec2<f32> {
365        let anchor = self.tile_local_anchor_uv(map);
366        let x = if self.tile_local_flip_x() {
367            anchor.x - local.x
368        } else {
369            anchor.x + local.x
370        };
371        let y = anchor.y + local.y;
372        Vec2::new(x, y)
373    }
374
375    /// Map a world point to discrete tile coordinates (1x1 grid cells in UV space).
376    /// Returns (tile_x, tile_y) representing which tile cell the point falls into.
377    /// This is useful for tile override systems that assign different tiles to different regions.
378    pub fn world_to_tile(&self, p: Vec3<f32>) -> (i32, i32) {
379        let uv = self.world_to_uv(p);
380        (uv.x.floor() as i32, uv.y.floor() as i32)
381    }
382
383    /// Map a world point to sector-local tile coordinates.
384    /// Tile (0,0) starts at the minimum projected UV of this sector.
385    pub fn world_to_tile_local(&self, p: Vec3<f32>, map: &Map) -> (i32, i32) {
386        let uv = self.world_to_uv(p);
387        let local = self.uv_to_tile_local(uv, map);
388        (local.x.floor() as i32, local.y.floor() as i32)
389    }
390
391    /// Get the four world-space corners of a 1x1 tile cell at the given tile coordinates.
392    /// Corners are ordered around the cell starting at (tile_x, tile_y) and proceeding CCW.
393    pub fn tile_outline_world(&self, tile: (i32, i32)) -> [Vec3<f32>; 4] {
394        let (tx, ty) = tile;
395        let corners_uv = [
396            Vec2::new(tx as f32, ty as f32),
397            Vec2::new(tx as f32 + 1.0, ty as f32),
398            Vec2::new(tx as f32 + 1.0, ty as f32 + 1.0),
399            Vec2::new(tx as f32, ty as f32 + 1.0),
400        ];
401        corners_uv.map(|uv| self.uv_to_world(uv))
402    }
403
404    /// Get the world-space corners of a sector-local tile cell.
405    /// Tile (0,0) starts at the minimum projected UV of this sector.
406    pub fn tile_outline_world_local(&self, tile: (i32, i32), map: &Map) -> [Vec3<f32>; 4] {
407        let (tx, ty) = tile;
408        let corners_local = [
409            Vec2::new(tx as f32, ty as f32),
410            Vec2::new(tx as f32 + 1.0, ty as f32),
411            Vec2::new(tx as f32 + 1.0, ty as f32 + 1.0),
412            Vec2::new(tx as f32, ty as f32 + 1.0),
413        ];
414        let corners_uv = corners_local.map(|local| self.tile_local_to_uv(local, map));
415        corners_uv.map(|uv| self.uv_to_world(uv))
416    }
417
418    /// Project the owning sector polygon into this surface's UV space (CCW ensured).
419    pub fn sector_loop_uv(&self, map: &Map) -> Option<Vec<Vec2<f32>>> {
420        let sector = map.find_sector(self.sector_id)?;
421        let pts3 = sector.vertices_world(map)?;
422        if pts3.len() < 3 {
423            return None;
424        }
425        let mut uv: Vec<Vec2<f32>> = pts3.iter().map(|p| self.world_to_uv(*p)).collect();
426        if polygon_signed_area_uv(&uv) < 0.0 {
427            uv.reverse();
428        }
429        Some(uv)
430    }
431
432    /// Triangulate a cap defined by an outer loop and optional hole loops in UV space.
433    /// Returns (world_positions, triangle_indices, uv_positions).
434    pub fn triangulate_cap_with_holes(
435        &self,
436        outer_uv: &[Vec2<f32>],
437        holes_uv: &[Vec<Vec2<f32>>],
438    ) -> Option<(Vec<[f32; 4]>, Vec<(usize, usize, usize)>, Vec<[f32; 2]>)> {
439        if outer_uv.len() < 3 {
440            return None;
441        }
442        // Build flattened buffer: outer first, then each hole
443        let mut verts: Vec<Vec2<f32>> =
444            Vec::with_capacity(outer_uv.len() + holes_uv.iter().map(|h| h.len()).sum::<usize>());
445        let mut holes_idx: Vec<usize> = Vec::with_capacity(holes_uv.len());
446
447        // Outer (ensure CCW)
448        if polygon_signed_area_uv(outer_uv) < 0.0 {
449            let mut ccw = outer_uv.to_vec();
450            ccw.reverse();
451            verts.extend(ccw);
452        } else {
453            verts.extend_from_slice(outer_uv);
454        }
455        // Holes (ensure CW per earcut convention)
456        let mut offset = outer_uv.len();
457        for h in holes_uv {
458            holes_idx.push(offset);
459            if polygon_signed_area_uv(h) > 0.0 {
460                // if CCW, flip to CW
461                let mut cw = h.clone();
462                cw.reverse();
463                verts.extend(cw);
464            } else {
465                verts.extend_from_slice(h);
466            }
467            offset += h.len();
468        }
469
470        // Flatten to f64 for earcut
471        let flat: Vec<f64> = verts
472            .iter()
473            .flat_map(|v| [v.x as f64, v.y as f64])
474            .collect();
475        let idx = earcut(&flat, &holes_idx, 2).ok()?;
476        let indices: Vec<(usize, usize, usize)> =
477            idx.chunks_exact(3).map(|c| (c[2], c[1], c[0])).collect();
478
479        let verts_uv: Vec<[f32; 2]> = verts.iter().map(|v| [v.x, v.y]).collect();
480        let world_vertices: Vec<[f32; 4]> = verts
481            .iter()
482            .map(|uv| {
483                let p = self.uv_to_world(*uv);
484                [p.x, p.y, p.z, 1.0]
485            })
486            .collect();
487
488        Some((world_vertices, indices, verts_uv))
489    }
490
491    /// Normalized surface normal.
492    pub fn normal(&self) -> Vec3<f32> {
493        let n = self.plane.normal;
494        let m = n.magnitude();
495        if m > 1e-6 {
496            n / m
497        } else {
498            Vec3::new(0.0, 1.0, 0.0)
499        }
500    }
501
502    /// Triangulate the owning sector in this surface's local UV space and return world vertices, indices, and UVs.
503    /// This treats the sector's 3D polygon as the base face of the surface; any vertical/tilted walls are handled correctly.
504    pub fn triangulate(
505        &self,
506        sector: &Sector,
507        map: &Map,
508    ) -> Option<(Vec<[f32; 4]>, Vec<(usize, usize, usize)>, Vec<[f32; 2]>)> {
509        // 1) Get ordered 3D polygon for the sector
510        let points3 = sector.vertices_world(map)?;
511        if points3.len() < 3 {
512            return None;
513        }
514
515        // 2) Project to this surface's local UV space
516        let verts_uv: Vec<[f32; 2]> = points3
517            .iter()
518            .map(|p| {
519                let uv = self.world_to_uv(*p);
520                [uv.x, uv.y]
521            })
522            .collect();
523
524        // 3) Triangulate in 2D (UV) using earcut (no holes for now)
525        let flattened: Vec<f64> = verts_uv
526            .iter()
527            .flat_map(|v| [v[0] as f64, v[1] as f64])
528            .collect();
529        let holes: Vec<usize> = Vec::new();
530        let idx = earcut(&flattened, &holes, 2).ok()?; // Vec<usize>
531
532        // Convert to triangle triplets, flipping winding to match your renderer if needed
533        let indices: Vec<(usize, usize, usize)> =
534            idx.chunks_exact(3).map(|c| (c[2], c[1], c[0])).collect();
535
536        // 4) Map UV back to world using this surface's frame
537        let world_vertices: Vec<[f32; 4]> = verts_uv
538            .iter()
539            .map(|v| {
540                let p = self.uv_to_world(vek::Vec2::new(v[0], v[1]));
541                [p.x, p.y, p.z, 1.0]
542            })
543            .collect();
544
545        Some((world_vertices, indices, verts_uv))
546    }
547}
548
549fn normalize_or_zero(v: Vec3<f32>) -> Vec3<f32> {
550    let m = v.magnitude();
551    if m > 1e-6 { v / m } else { Vec3::zero() }
552}
553
554fn newell_plane(points: &[Vec3<f32>]) -> (Vec3<f32>, Vec3<f32>) {
555    let mut centroid = Vec3::zero();
556    let mut normal = Vec3::zero();
557    let n = points.len();
558    for i in 0..n {
559        let current = points[i];
560        let next = points[(i + 1) % n];
561        centroid += current;
562        normal.x += (current.y - next.y) * (current.z + next.z);
563        normal.y += (current.z - next.z) * (current.x + next.x);
564        normal.z += (current.x - next.x) * (current.y + next.y);
565    }
566    centroid /= n as f32;
567    let m = normal.magnitude();
568    if m > 1e-6 {
569        normal /= m;
570    } else {
571        normal = Vec3::zero();
572    }
573    (centroid, normal)
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579    use crate::Map;
580    use uuid::Uuid;
581
582    fn make_wall_surface(right: Vec3<f32>, up: Vec3<f32>, normal: Vec3<f32>) -> Surface {
583        Surface {
584            id: Uuid::new_v4(),
585            sector_id: 1,
586            plane: Plane {
587                origin: Vec3::zero(),
588                normal,
589            },
590            frame: Basis3 { right, up, normal },
591            edit_uv: EditPlane {
592                origin: Vec3::zero(),
593                right,
594                up,
595                scale: 1.0,
596            },
597            extrusion: ExtrusionSpec::default(),
598            profile: None,
599            world_vertices: vec![],
600        }
601    }
602
603    #[test]
604    fn wall_tile_local_x_is_consistent_for_opposite_orientations() {
605        let map = Map::new();
606        let inside = make_wall_surface(
607            Vec3::new(1.0, 0.0, 0.0),
608            Vec3::new(0.0, 1.0, 0.0),
609            Vec3::new(0.0, 0.0, 1.0),
610        );
611        let outside = make_wall_surface(
612            Vec3::new(-1.0, 0.0, 0.0),
613            Vec3::new(0.0, 1.0, 0.0),
614            Vec3::new(0.0, 0.0, -1.0),
615        );
616
617        let p = Vec3::new(0.2, 0.4, 0.0);
618        let a = inside.world_to_tile_local(p, &map);
619        let b = outside.world_to_tile_local(p, &map);
620        assert_eq!(a.0, b.0);
621    }
622
623    #[test]
624    fn wall_tile_local_y_uses_integer_bands() {
625        let map = Map::new();
626        let surface = make_wall_surface(
627            Vec3::new(1.0, 0.0, 0.0),
628            Vec3::new(0.0, 1.0, 0.0),
629            Vec3::new(0.0, 0.0, 1.0),
630        );
631
632        let low = surface.world_to_tile_local(Vec3::new(0.0, 0.2, 0.0), &map);
633        let high = surface.world_to_tile_local(Vec3::new(0.0, 1.2, 0.0), &map);
634        assert_eq!(low.1, 0);
635        assert_eq!(high.1, 1);
636    }
637
638    #[test]
639    fn tile_outline_local_roundtrips_back_to_same_tile_cell_center() {
640        let map = Map::new();
641        let surface = make_wall_surface(
642            Vec3::new(-1.0, 0.0, 0.0),
643            Vec3::new(0.0, 1.0, 0.0),
644            Vec3::new(0.0, 0.0, -1.0),
645        );
646
647        let center_local = Vec2::new(1.5, 2.5);
648        let center_world = surface.uv_to_world(surface.tile_local_to_uv(center_local, &map));
649        let cell = surface.world_to_tile_local(center_world, &map);
650        assert_eq!(cell, (1, 2));
651    }
652
653    #[test]
654    fn tile_local_helpers_do_not_change_world_to_uv_mapping() {
655        let map = Map::new();
656        let surface = make_wall_surface(
657            Vec3::new(-1.0, 0.0, 0.0),
658            Vec3::new(0.0, 1.0, 0.0),
659            Vec3::new(0.0, 0.0, -1.0),
660        );
661        let p = Vec3::new(0.7, 1.3, 0.0);
662        let before = surface.world_to_uv(p);
663
664        let _ = surface.world_to_tile_local(p, &map);
665        let _ = surface.tile_outline_world_local((2, 1), &map);
666
667        let after = surface.world_to_uv(p);
668        assert!((before.x - after.x).abs() < 1e-6);
669        assert!((before.y - after.y).abs() < 1e-6);
670    }
671}
672
673fn stable_right(points: &[Vec3<f32>], normal: Vec3<f32>) -> Vec3<f32> {
674    let n = points.len();
675    let mut max_len = 0.0;
676    let mut right = Vec3::zero();
677    for i in 0..n {
678        let edge = points[(i + 1) % n] - points[i];
679        let proj = edge - normal * normal.dot(edge);
680        let len = proj.magnitude();
681        if len > max_len {
682            max_len = len;
683            right = proj;
684        }
685    }
686    if max_len < 1e-6 {
687        // fallback: pick any axis orthogonal to normal
688        if normal.x.abs() < normal.y.abs() && normal.x.abs() < normal.z.abs() {
689            right = Vec3::new(0.0, -normal.z, normal.y);
690        } else if normal.y.abs() < normal.z.abs() {
691            right = Vec3::new(-normal.z, 0.0, normal.x);
692        } else {
693            right = Vec3::new(-normal.y, normal.x, 0.0);
694        }
695    }
696    normalize_or_zero(right)
697}
698
699fn polygon_signed_area_uv(poly: &[Vec2<f32>]) -> f32 {
700    if poly.len() < 3 {
701        return 0.0;
702    }
703    let mut a = 0.0f32;
704    for i in 0..poly.len() {
705        let p = poly[i];
706        let q = poly[(i + 1) % poly.len()];
707        a += p.x * q.y - q.x * p.y;
708    }
709    0.5 * a
710}