Skip to main content

proof_engine/topology/
portals.rs

1// topology/portals.rs — Portals between topological spaces
2
3use glam::Vec2;
4
5// ─── Types ─────────────────────────────────────────────────────────────────
6
7/// The topology type on each side of a portal.
8#[derive(Clone, Copy, Debug, PartialEq)]
9pub enum TopologyType {
10    Euclidean,
11    Hyperbolic,
12    Spherical,
13    Toroidal,
14    Klein,
15}
16
17/// A portal frame: a line segment in 2D space with a facing direction.
18#[derive(Clone, Debug)]
19pub struct PortalFrame {
20    pub position: Vec2,
21    pub normal: Vec2,
22    pub width: f32,
23}
24
25impl PortalFrame {
26    pub fn new(position: Vec2, normal: Vec2, width: f32) -> Self {
27        Self {
28            position,
29            normal: normal.normalize(),
30            width,
31        }
32    }
33
34    /// Get the two endpoints of the portal frame.
35    pub fn endpoints(&self) -> (Vec2, Vec2) {
36        let tangent = Vec2::new(-self.normal.y, self.normal.x);
37        let half = tangent * (self.width / 2.0);
38        (self.position - half, self.position + half)
39    }
40
41    /// Check if a point is on the front side of the portal.
42    pub fn is_front_side(&self, point: Vec2) -> bool {
43        (point - self.position).dot(self.normal) > 0.0
44    }
45
46    /// Signed distance from a point to the portal plane.
47    pub fn signed_distance(&self, point: Vec2) -> f32 {
48        (point - self.position).dot(self.normal)
49    }
50}
51
52/// A portal connecting two frames, possibly with a topology change.
53#[derive(Clone, Debug)]
54pub struct Portal {
55    pub entry: PortalFrame,
56    pub exit: PortalFrame,
57    pub topology_change: TopologyType,
58}
59
60impl Portal {
61    pub fn new(entry: PortalFrame, exit: PortalFrame, topology_change: TopologyType) -> Self {
62        Self {
63            entry,
64            exit,
65            topology_change,
66        }
67    }
68
69    /// Check if an entity at `pos` moving with `vel` will cross this portal this frame.
70    /// Returns true if the entity crosses from front to back of the entry frame.
71    pub fn will_cross(&self, pos: Vec2, vel: Vec2, dt: f32) -> bool {
72        let d_before = self.entry.signed_distance(pos);
73        let d_after = self.entry.signed_distance(pos + vel * dt);
74
75        // Must go from positive to negative (front to back)
76        if d_before <= 0.0 || d_after > 0.0 {
77            return false;
78        }
79
80        // Check if crossing point is within the portal width
81        let t = d_before / (d_before - d_after);
82        let cross_point = pos + vel * dt * t;
83        let tangent = Vec2::new(-self.entry.normal.y, self.entry.normal.x);
84        let along = (cross_point - self.entry.position).dot(tangent);
85        along.abs() <= self.entry.width / 2.0
86    }
87}
88
89/// Transform a position and velocity through a portal.
90/// Maps coordinates from the entry frame to the exit frame.
91pub fn transform_through_portal(pos: Vec2, vel: Vec2, portal: &Portal) -> (Vec2, Vec2) {
92    let entry = &portal.entry;
93    let exit = &portal.exit;
94
95    // Compute local coordinates relative to entry frame
96    let entry_tangent = Vec2::new(-entry.normal.y, entry.normal.x);
97    let local_along = (pos - entry.position).dot(entry_tangent);
98    let local_through = (pos - entry.position).dot(entry.normal);
99
100    // Scale by width ratio
101    let scale = exit.width / entry.width.max(1e-6);
102    let scaled_along = local_along * scale;
103
104    // Map to exit frame (flipped normal — you emerge from the back side of exit)
105    let exit_tangent = Vec2::new(-exit.normal.y, exit.normal.x);
106    let new_pos = exit.position + exit_tangent * scaled_along - exit.normal * local_through;
107
108    // Transform velocity
109    let vel_along = vel.dot(entry_tangent);
110    let vel_through = vel.dot(entry.normal);
111    let new_vel = exit_tangent * vel_along * scale - exit.normal * vel_through;
112
113    (new_pos, new_vel)
114}
115
116// ─── Portal Manager ────────────────────────────────────────────────────────
117
118/// Manages a set of active portals and handles entity transitions.
119pub struct PortalManager {
120    pub portals: Vec<Portal>,
121}
122
123impl PortalManager {
124    pub fn new() -> Self {
125        Self {
126            portals: Vec::new(),
127        }
128    }
129
130    /// Add a portal.
131    pub fn add_portal(&mut self, portal: Portal) {
132        self.portals.push(portal);
133    }
134
135    /// Remove a portal by index.
136    pub fn remove_portal(&mut self, index: usize) {
137        if index < self.portals.len() {
138            self.portals.remove(index);
139        }
140    }
141
142    /// Update an entity position/velocity, checking all portals.
143    /// Returns (new_pos, new_vel, Option<topology_change>).
144    pub fn update_entity(
145        &self,
146        pos: Vec2,
147        vel: Vec2,
148        dt: f32,
149    ) -> (Vec2, Vec2, Option<TopologyType>) {
150        for portal in &self.portals {
151            if portal.will_cross(pos, vel, dt) {
152                let (new_pos, new_vel) = transform_through_portal(pos, vel, portal);
153                return (new_pos, new_vel, Some(portal.topology_change));
154            }
155        }
156        (pos + vel * dt, vel, None)
157    }
158
159    /// Find all portals visible from a given position (front side of entry).
160    pub fn visible_portals(&self, pos: Vec2) -> Vec<usize> {
161        self.portals
162            .iter()
163            .enumerate()
164            .filter(|(_, p)| p.entry.is_front_side(pos))
165            .map(|(i, _)| i)
166            .collect()
167    }
168}
169
170/// Compute the visible region through a portal from a viewpoint.
171/// Returns a trapezoid (4 corners) representing what can be seen through the portal.
172pub fn render_through_portal(viewpoint: Vec2, portal: &Portal, view_depth: f32) -> [Vec2; 4] {
173    let (left, right) = portal.entry.endpoints();
174
175    // Direction from viewpoint to each endpoint
176    let to_left = (left - viewpoint).normalize();
177    let to_right = (right - viewpoint).normalize();
178
179    // Project these rays through the portal into exit space
180    let entry = &portal.entry;
181    let exit = &portal.exit;
182    let exit_tangent = Vec2::new(-exit.normal.y, exit.normal.x);
183    let (exit_left, exit_right) = exit.endpoints();
184
185    // The visible region is a trapezoid from exit endpoints extending along the exit normal
186    let far_left = exit_left - exit.normal * view_depth;
187    let far_right = exit_right - exit.normal * view_depth;
188
189    [exit_left, exit_right, far_right, far_left]
190}
191
192// ─── Tests ─────────────────────────────────────────────────────────────────
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    fn make_simple_portal() -> Portal {
199        Portal::new(
200            PortalFrame::new(Vec2::new(5.0, 0.0), Vec2::new(-1.0, 0.0), 4.0),
201            PortalFrame::new(Vec2::new(20.0, 0.0), Vec2::new(1.0, 0.0), 4.0),
202            TopologyType::Euclidean,
203        )
204    }
205
206    #[test]
207    fn test_portal_frame_endpoints() {
208        let frame = PortalFrame::new(Vec2::new(5.0, 0.0), Vec2::new(1.0, 0.0), 4.0);
209        let (a, b) = frame.endpoints();
210        assert!((a.y - (-2.0)).abs() < 1e-4);
211        assert!((b.y - 2.0).abs() < 1e-4);
212        assert!((a.x - 5.0).abs() < 1e-4);
213    }
214
215    #[test]
216    fn test_portal_front_side() {
217        let frame = PortalFrame::new(Vec2::new(5.0, 0.0), Vec2::new(1.0, 0.0), 4.0);
218        assert!(frame.is_front_side(Vec2::new(10.0, 0.0)));
219        assert!(!frame.is_front_side(Vec2::new(0.0, 0.0)));
220    }
221
222    #[test]
223    fn test_will_cross() {
224        let portal = make_simple_portal();
225        // Moving left (in direction of entry normal = -x) through x=5
226        let crosses = portal.will_cross(
227            Vec2::new(6.0, 0.0),   // in front of entry
228            Vec2::new(-10.0, 0.0), // moving toward entry
229            1.0,
230        );
231        assert!(crosses, "Should cross the portal");
232    }
233
234    #[test]
235    fn test_will_not_cross_wrong_direction() {
236        let portal = make_simple_portal();
237        let crosses = portal.will_cross(
238            Vec2::new(6.0, 0.0),
239            Vec2::new(10.0, 0.0), // moving away
240            1.0,
241        );
242        assert!(!crosses);
243    }
244
245    #[test]
246    fn test_will_not_cross_outside_width() {
247        let portal = make_simple_portal();
248        let crosses = portal.will_cross(
249            Vec2::new(6.0, 10.0), // far from portal centerline
250            Vec2::new(-10.0, 0.0),
251            1.0,
252        );
253        assert!(!crosses);
254    }
255
256    #[test]
257    fn test_transform_through_portal() {
258        let portal = Portal::new(
259            PortalFrame::new(Vec2::new(5.0, 0.0), Vec2::new(-1.0, 0.0), 4.0),
260            PortalFrame::new(Vec2::new(20.0, 0.0), Vec2::new(1.0, 0.0), 4.0),
261            TopologyType::Euclidean,
262        );
263        // Point right at the entry position
264        let (new_pos, new_vel) = transform_through_portal(
265            Vec2::new(5.0, 0.0),
266            Vec2::new(-5.0, 0.0),
267            &portal,
268        );
269        // Should emerge at exit position
270        assert!((new_pos.x - 20.0).abs() < 1e-3, "new_pos.x = {}", new_pos.x);
271    }
272
273    #[test]
274    fn test_portal_manager_no_portals() {
275        let mgr = PortalManager::new();
276        let (pos, vel, topo) = mgr.update_entity(
277            Vec2::new(0.0, 0.0),
278            Vec2::new(1.0, 0.0),
279            1.0,
280        );
281        assert!((pos.x - 1.0).abs() < 1e-4);
282        assert!(topo.is_none());
283    }
284
285    #[test]
286    fn test_portal_manager_crossing() {
287        let mut mgr = PortalManager::new();
288        mgr.add_portal(make_simple_portal());
289
290        let (new_pos, _new_vel, topo) = mgr.update_entity(
291            Vec2::new(6.0, 0.0),
292            Vec2::new(-10.0, 0.0),
293            1.0,
294        );
295        assert!(topo.is_some());
296        assert_eq!(topo.unwrap(), TopologyType::Euclidean);
297    }
298
299    #[test]
300    fn test_visible_portals() {
301        let mut mgr = PortalManager::new();
302        mgr.add_portal(make_simple_portal());
303        let visible = mgr.visible_portals(Vec2::new(4.0, 0.0));
304        assert_eq!(visible.len(), 1);
305
306        let visible2 = mgr.visible_portals(Vec2::new(6.0, 0.0));
307        assert_eq!(visible2.len(), 0); // behind the entry normal
308    }
309
310    #[test]
311    fn test_render_through_portal() {
312        let portal = make_simple_portal();
313        let region = render_through_portal(Vec2::new(0.0, 0.0), &portal, 10.0);
314        // Should return 4 points
315        assert_eq!(region.len(), 4);
316    }
317
318    #[test]
319    fn test_portal_manager_remove() {
320        let mut mgr = PortalManager::new();
321        mgr.add_portal(make_simple_portal());
322        assert_eq!(mgr.portals.len(), 1);
323        mgr.remove_portal(0);
324        assert_eq!(mgr.portals.len(), 0);
325    }
326}