Skip to main content

oxiproj_transformations/conversions/
push_pop.rs

1//! `push` and `pop` pipeline coordinate-stack operations.
2//!
3//! Ported from PROJ `src/conversions/push_pop.cpp` (part of the pipeline
4//! implementation in `src/pipeline.cpp`).
5//!
6//! # Overview
7//!
8//! Within a PROJ pipeline, `push` saves selected coordinate components onto a
9//! stack and `pop` restores them. This lets the pipeline temporarily
10//! overwrite components for an intermediate step and then recover the originals.
11//!
12//! Example pipeline (preserve X/Y, do a vertical-only transform):
13//!
14//! ```text
15//! +proj=pipeline
16//!   +step +proj=push +v_1 +v_2
17//!   +step +proj=somevert ...
18//!   +step +proj=pop  +v_1 +v_2
19//! ```
20//!
21//! # Component selection
22//!
23//! The parameters `+v_1`, `+v_2`, `+v_3`, `+v_4` select which of the four
24//! coordinate slots (X/Y/Z/T) are pushed or popped. If **none** of the four
25//! flags is present the operation applies to **all four** components, mirroring
26//! PROJ's default behaviour.
27//!
28//! # Directionality
29//!
30//! | operation | forward  | inverse  |
31//! |-----------|----------|----------|
32//! | `push`    | save      | restore  |
33//! | `pop`     | restore   | save     |
34//!
35//! # Stack ownership and pipeline scoping
36//!
37//! PROJ stores the four push/pop stacks in each pipeline's own opaque state
38//! (`struct Pipeline::stack[4]` in `pipeline.cpp`); a `push`/`pop` step reaches
39//! it via `P->parent->opaque`, i.e. the specific `Pipeline` object that
40//! contains it. A nested `+proj=pipeline` step gets its *own* `Pipeline`
41//! object (and therefore its own, isolated stack) — two unrelated pipelines,
42//! or a pipeline nested inside another, never see each other's pushed values.
43//!
44//! This crate's [`Operation`] trait has no notion of "the pipeline object that
45//! owns me" (that ownership lives in `oxiproj-engine`'s `Pipeline`, outside
46//! this crate), so per-object storage is not directly available here. Instead
47//! this module reproduces the same isolation with an explicit, opt-in dynamic
48//! scope: [`enter_pipeline_scope`] opens a fresh, empty stack frame that every
49//! `push`/`pop` call on the current thread sees until the returned
50//! [`PipelineStackScope`] guard is dropped; dropping discards that frame
51//! (and anything left on it — e.g. a value pushed by a step whose sibling
52//! step, between the `push` and its matching `pop`, errored out and aborted
53//! the pipeline before the `pop` ran) and restores the enclosing frame.
54//! Nested scopes layer naturally with ordinary Rust call/return nesting,
55//! exactly mirroring nested `Pipeline` objects each getting their own stack.
56//!
57//! A single base frame always exists per thread and is never dropped, so
58//! callers that do not open an explicit scope keep the previous, unscoped
59//! behaviour (one shared frame for the life of the thread) — this is the
60//! degraded fallback used until `oxiproj-engine`'s pipeline driver is wired to
61//! call `enter_pipeline_scope()` around each pipeline object's per-point
62//! traversal.
63//!
64//! # Thread safety
65//!
66//! The stack frames are stored in a `thread_local!` `RefCell`, which is never
67//! shared across threads. The operation structs themselves contain only
68//! `[bool; 4]` and are therefore `Send + Sync`. [`PipelineStackScope`] is
69//! deliberately `!Send`/`!Sync` (it carries a `PhantomData<*const ()>`
70//! marker): the frame it opens and the frame it pops on `Drop` both live in
71//! *this thread's* `thread_local`, so moving the guard to another thread and
72//! dropping it there would pop a frame that thread never opened.
73
74use std::cell::RefCell;
75use std::marker::PhantomData;
76
77use crate::{TransBuild, TransParams};
78use oxiproj_core::{Coord, IoUnits, Operation, ProjError, ProjResult};
79
80// ---------------------------------------------------------------------------
81// Thread-local coordinate stack frames
82// ---------------------------------------------------------------------------
83
84/// One frame of the four coordinate-component stacks: index 0 → X (or λ),
85/// 1 → Y (or φ), 2 → Z, 3 → T.
86type StackFrame = [Vec<f64>; 4];
87
88fn empty_frame() -> StackFrame {
89    [Vec::new(), Vec::new(), Vec::new(), Vec::new()]
90}
91
92thread_local! {
93    /// Per-thread stack of `push`/`pop` coordinate-stack frames. See the
94    /// module-level "Stack ownership and pipeline scoping" section for the
95    /// full rationale. Frame 0 (the base frame) always exists.
96    ///
97    /// Using `RefCell` is safe here because each thread owns its own instance;
98    /// there is never cross-thread aliasing. The operation structs do **not**
99    /// hold a reference to this `RefCell`; they access it only via the
100    /// `thread_local!` key, so they remain `Send + Sync`.
101    static COORD_STACK_FRAMES: RefCell<Vec<StackFrame>> = RefCell::new(vec![empty_frame()]);
102}
103
104// ---------------------------------------------------------------------------
105// Pipeline-scoped stack isolation
106// ---------------------------------------------------------------------------
107
108/// RAII guard returned by [`enter_pipeline_scope`]. Dropping it discards the
109/// stack frame it opened — including any value left on it by an unmatched
110/// `push` — and restores the enclosing frame as current.
111#[must_use = "the pipeline stack scope is only active while this guard is alive; \
112              binding it to `_` drops it immediately and closes the scope"]
113#[derive(Debug)]
114pub struct PipelineStackScope {
115    // Not constructible outside this module; also forces `!Send`/`!Sync` so
116    // the guard cannot be dropped on a thread other than the one that opened
117    // its frame (see the module-level "Thread safety" section).
118    _marker: PhantomData<*const ()>,
119}
120
121/// Open a fresh, isolated `push`/`pop` coordinate-stack frame for the current
122/// thread, returning a guard that closes the frame (discarding anything left
123/// on it) when dropped.
124///
125/// Intended to be called once per constructed pipeline object, around each
126/// point it transforms — mirroring PROJ giving every `Pipeline` its own
127/// opaque `stack[4]`. Doing so gives two guarantees beyond a single shared
128/// thread-wide stack:
129///
130/// * **Cross-pipeline isolation** — two unrelated pipelines (or
131///   `Transformer`s) that both contain `push`/`pop`, used alternately on the
132///   same thread, can never see each other's stacked values, because each
133///   opens its own frame.
134/// * **Error-path cleanup** — if a step between `push` and its matching `pop`
135///   returns an error and the pipeline aborts before reaching the `pop`, the
136///   orphaned value is discarded when the scope guard drops instead of
137///   lingering on a shared stack for a later, unrelated point to pop.
138///
139/// Nested pipelines layer correctly: entering a nested scope while an outer
140/// one is still open (as happens when a nested pipeline's
141/// `forward_4d`/`inverse_4d` runs inside an outer pipeline's step loop)
142/// pushes an additional frame on top and restores the outer frame on drop.
143///
144/// Callers that never invoke this function are unaffected: all `push`/`pop`
145/// operations keep sharing the single base frame, exactly as before this
146/// scoping mechanism was introduced.
147pub fn enter_pipeline_scope() -> PipelineStackScope {
148    COORD_STACK_FRAMES.with(|cell| {
149        cell.borrow_mut().push(empty_frame());
150    });
151    PipelineStackScope {
152        _marker: PhantomData,
153    }
154}
155
156impl Drop for PipelineStackScope {
157    fn drop(&mut self) {
158        COORD_STACK_FRAMES.with(|cell| {
159            let mut frames = cell.borrow_mut();
160            // Never drop the base frame: it is the fallback shared frame used
161            // by callers that don't open an explicit scope.
162            if frames.len() > 1 {
163                frames.pop();
164            }
165        });
166    }
167}
168
169// ---------------------------------------------------------------------------
170// Helpers
171// ---------------------------------------------------------------------------
172
173/// Push selected components of `c` onto the current thread-local stack frame
174/// (the innermost frame opened by [`enter_pipeline_scope`], or the shared
175/// base frame if no scope is open).
176///
177/// Returns `c` unmodified; `push` is a pure side-effect on the stack.
178fn do_push(c: Coord, which: [bool; 4]) -> ProjResult<Coord> {
179    let v = c.v();
180    COORD_STACK_FRAMES.with(|cell| {
181        let mut frames = cell.borrow_mut();
182        if let Some(frame) = frames.last_mut() {
183            for i in 0..4 {
184                if which[i] {
185                    frame[i].push(v[i]);
186                }
187            }
188        }
189        // `frames` is never empty (the base frame is never popped), so the
190        // `None` arm is unreachable in practice; it is handled as a no-op
191        // rather than a panic to uphold the no-panics production policy.
192    });
193    Ok(c)
194}
195
196/// Pop selected components from the current thread-local stack frame into
197/// `c`.
198///
199/// Components not selected by `which` are passed through unchanged.
200/// If a stack is empty for a selected component (underflow), that component
201/// is left at its current value — this matches PROJ's silent behaviour.
202fn do_pop(c: Coord, which: [bool; 4]) -> ProjResult<Coord> {
203    let mut v = c.v();
204    COORD_STACK_FRAMES.with(|cell| {
205        let mut frames = cell.borrow_mut();
206        if let Some(frame) = frames.last_mut() {
207            for i in 0..4 {
208                if which[i] {
209                    if let Some(val) = frame[i].pop() {
210                        v[i] = val;
211                    }
212                    // Stack underflow: leave `v[i]` unchanged (PROJ silent pass-through)
213                }
214            }
215        }
216    });
217    Ok(Coord::new(v[0], v[1], v[2], v[3]))
218}
219
220// ---------------------------------------------------------------------------
221// Operation structs
222// ---------------------------------------------------------------------------
223
224/// `push` pipeline operation — saves coordinate components onto the thread-local stack.
225///
226/// Forward direction: push (save). Inverse direction: pop (restore).
227#[derive(Debug)]
228struct PushOp {
229    /// Which of the four coordinate components to push/pop.
230    which: [bool; 4],
231}
232
233/// `pop` pipeline operation — restores coordinate components from the thread-local stack.
234///
235/// Forward direction: pop (restore). Inverse direction: push (save).
236#[derive(Debug)]
237struct PopOp {
238    /// Which of the four coordinate components to push/pop.
239    which: [bool; 4],
240}
241
242// SAFETY: `PushOp`/`PopOp` contain only `[bool; 4]`, which is `Send + Sync`.
243// The thread-local `COORD_STACK_FRAMES` is never stored in the struct; it is
244// accessed only at call time via the `thread_local!` key. Therefore the
245// structs are safe to move and share across threads even though the backing
246// storage is thread-local.
247
248impl Operation for PushOp {
249    /// Forward: save selected components onto the stack, return `c` unchanged.
250    fn forward_4d(&self, c: Coord) -> ProjResult<Coord> {
251        do_push(c, self.which)
252    }
253
254    /// Inverse: restore selected components from the stack into `c`.
255    fn inverse_4d(&self, c: Coord) -> ProjResult<Coord> {
256        do_pop(c, self.which)
257    }
258
259    fn has_inverse(&self) -> bool {
260        true
261    }
262}
263
264impl Operation for PopOp {
265    /// Forward: restore selected components from the stack into `c`.
266    fn forward_4d(&self, c: Coord) -> ProjResult<Coord> {
267        do_pop(c, self.which)
268    }
269
270    /// Inverse: save selected components onto the stack, return `c` unchanged.
271    fn inverse_4d(&self, c: Coord) -> ProjResult<Coord> {
272        do_push(c, self.which)
273    }
274
275    fn has_inverse(&self) -> bool {
276        true
277    }
278}
279
280// ---------------------------------------------------------------------------
281// Parameter parsing
282// ---------------------------------------------------------------------------
283
284/// Parse `+v_1` … `+v_4` flags from the parameter block.
285///
286/// If **none** of the four flags is present, all four components are selected
287/// (PROJ default: push/pop everything when no component filter is given).
288fn parse_which(p: &TransParams) -> [bool; 4] {
289    let v1 = p.params.exists("v_1");
290    let v2 = p.params.exists("v_2");
291    let v3 = p.params.exists("v_3");
292    let v4 = p.params.exists("v_4");
293    if !v1 && !v2 && !v3 && !v4 {
294        // No filter → apply to all four components (PROJ default behaviour)
295        [true, true, true, true]
296    } else {
297        [v1, v2, v3, v4]
298    }
299}
300
301// ---------------------------------------------------------------------------
302// Public constructors
303// ---------------------------------------------------------------------------
304
305/// Construct the `push` coordinate-stack operation.
306///
307/// Reads `+v_1` … `+v_4` flags to determine which components are saved.
308/// If none are specified all four components are saved.
309pub fn new_push(p: &TransParams) -> ProjResult<TransBuild> {
310    let _ = ProjError::InvalidOp; // ensure error type is used (suppress lint)
311    Ok(TransBuild::new(
312        Box::new(PushOp {
313            which: parse_which(p),
314        }),
315        IoUnits::Whatever,
316        IoUnits::Whatever,
317    ))
318}
319
320/// Construct the `pop` coordinate-stack operation.
321///
322/// Reads `+v_1` … `+v_4` flags to determine which components are restored.
323/// If none are specified all four components are restored.
324pub fn new_pop(p: &TransParams) -> ProjResult<TransBuild> {
325    Ok(TransBuild::new(
326        Box::new(PopOp {
327            which: parse_which(p),
328        }),
329        IoUnits::Whatever,
330        IoUnits::Whatever,
331    ))
332}
333
334// ---------------------------------------------------------------------------
335// Tests
336// ---------------------------------------------------------------------------
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use oxiproj_core::{Coord, Ellipsoid};
342
343    // Minimal no-op parameter set for constructors that don't need params.
344    struct NoParams;
345    impl crate::TransParamLookup for NoParams {
346        fn get_dms(&self, _key: &str) -> Option<f64> {
347            None
348        }
349        fn get_f64(&self, _key: &str) -> Option<f64> {
350            None
351        }
352        fn get_int(&self, _key: &str) -> Option<i64> {
353            None
354        }
355        fn get_str(&self, _key: &str) -> Option<&str> {
356            None
357        }
358        fn get_bool(&self, _key: &str) -> bool {
359            false
360        }
361        fn exists(&self, _key: &str) -> bool {
362            false
363        }
364    }
365
366    // Parameter set that marks v_1 and v_2 as present.
367    struct V1V2Params;
368    impl crate::TransParamLookup for V1V2Params {
369        fn get_dms(&self, _key: &str) -> Option<f64> {
370            None
371        }
372        fn get_f64(&self, _key: &str) -> Option<f64> {
373            None
374        }
375        fn get_int(&self, _key: &str) -> Option<i64> {
376            None
377        }
378        fn get_str(&self, _key: &str) -> Option<&str> {
379            None
380        }
381        fn get_bool(&self, _key: &str) -> bool {
382            false
383        }
384        fn exists(&self, key: &str) -> bool {
385            key == "v_1" || key == "v_2"
386        }
387    }
388
389    fn wgs84() -> Ellipsoid {
390        Ellipsoid::named("WGS84").expect("WGS84 ellipsoid must be available")
391    }
392
393    /// Reset the thread-local stack frames to a single empty base frame
394    /// before each test, avoiding state leakage across tests that may share
395    /// a thread (e.g. under `cargo nextest`'s thread reuse).
396    fn clear_stacks() {
397        COORD_STACK_FRAMES.with(|cell| {
398            *cell.borrow_mut() = vec![empty_frame()];
399        });
400    }
401
402    /// Number of frames currently open on this thread (1 == just the base
403    /// frame, i.e. no [`enter_pipeline_scope`] guard is alive).
404    fn frame_count() -> usize {
405        COORD_STACK_FRAMES.with(|cell| cell.borrow().len())
406    }
407
408    #[test]
409    fn push_all_then_pop_all_round_trips() -> ProjResult<()> {
410        clear_stacks();
411        let push = PushOp {
412            which: [true, true, true, true],
413        };
414        let pop = PopOp {
415            which: [true, true, true, true],
416        };
417        let original = Coord::new(1.0, 2.0, 3.0, 4.0);
418        let after_push = push.forward_4d(original)?;
419        // push does not modify coordinates
420        assert_eq!(after_push.v(), [1.0, 2.0, 3.0, 4.0]);
421
422        // Overwrite the coordinate
423        let modified = Coord::new(9.0, 8.0, 7.0, 6.0);
424        let restored = pop.forward_4d(modified)?;
425        // pop should restore all four components
426        assert_eq!(restored.v(), [1.0, 2.0, 3.0, 4.0]);
427        Ok(())
428    }
429
430    #[test]
431    fn push_v1_v2_only_preserves_v3_v4() -> ProjResult<()> {
432        clear_stacks();
433        let push = PushOp {
434            which: [true, true, false, false],
435        };
436        let pop = PopOp {
437            which: [true, true, false, false],
438        };
439        let original = Coord::new(10.0, 20.0, 30.0, 40.0);
440        push.forward_4d(original)?;
441
442        // After modification only v3/v4 changed on the current coord
443        let modified = Coord::new(99.0, 88.0, 77.0, 66.0);
444        let restored = pop.forward_4d(modified)?;
445        // v_1 and v_2 restored; v_3 and v_4 come from `modified`
446        assert_eq!(restored.v()[0], 10.0);
447        assert_eq!(restored.v()[1], 20.0);
448        assert_eq!(restored.v()[2], 77.0);
449        assert_eq!(restored.v()[3], 66.0);
450        Ok(())
451    }
452
453    #[test]
454    fn push_inverse_acts_as_pop() -> ProjResult<()> {
455        clear_stacks();
456        let push = PushOp {
457            which: [true, true, true, true],
458        };
459        // Save via forward (push)
460        let saved = Coord::new(5.0, 6.0, 7.0, 8.0);
461        push.forward_4d(saved)?;
462
463        // Restore via inverse (pop)
464        let modified = Coord::new(0.0, 0.0, 0.0, 0.0);
465        let restored = push.inverse_4d(modified)?;
466        assert_eq!(restored.v(), [5.0, 6.0, 7.0, 8.0]);
467        Ok(())
468    }
469
470    #[test]
471    fn pop_inverse_acts_as_push() -> ProjResult<()> {
472        clear_stacks();
473        let pop = PopOp {
474            which: [true, true, true, true],
475        };
476        // Save via inverse (push)
477        let saved = Coord::new(11.0, 22.0, 33.0, 44.0);
478        pop.inverse_4d(saved)?;
479
480        // Restore via forward (pop)
481        let modified = Coord::new(0.0, 0.0, 0.0, 0.0);
482        let restored = pop.forward_4d(modified)?;
483        assert_eq!(restored.v(), [11.0, 22.0, 33.0, 44.0]);
484        Ok(())
485    }
486
487    #[test]
488    fn no_params_defaults_to_all_components() {
489        let which = parse_which(&TransParams {
490            ellipsoid: &wgs84(),
491            params: &NoParams,
492            registry: None,
493        });
494        assert_eq!(which, [true, true, true, true]);
495    }
496
497    #[test]
498    fn v1_v2_params_selects_first_two() {
499        let which = parse_which(&TransParams {
500            ellipsoid: &wgs84(),
501            params: &V1V2Params,
502            registry: None,
503        });
504        assert_eq!(which, [true, true, false, false]);
505    }
506
507    #[test]
508    fn new_push_builds_successfully() {
509        let ell = wgs84();
510        let p = TransParams {
511            ellipsoid: &ell,
512            params: &NoParams,
513            registry: None,
514        };
515        assert!(new_push(&p).is_ok());
516    }
517
518    #[test]
519    fn new_pop_builds_successfully() {
520        let ell = wgs84();
521        let p = TransParams {
522            ellipsoid: &ell,
523            params: &NoParams,
524            registry: None,
525        };
526        assert!(new_pop(&p).is_ok());
527    }
528
529    #[test]
530    fn stack_underflow_leaves_component_unchanged() -> ProjResult<()> {
531        clear_stacks();
532        let pop = PopOp {
533            which: [true, false, false, false],
534        };
535        // Stack is empty; component should remain as-is (no panic, no error)
536        let c = Coord::new(99.0, 1.0, 2.0, 3.0);
537        let result = pop.forward_4d(c)?;
538        assert_eq!(result.v()[0], 99.0); // unchanged since stack was empty
539        Ok(())
540    }
541
542    #[test]
543    fn push_and_pop_have_inverse() {
544        let push = PushOp { which: [true; 4] };
545        let pop = PopOp { which: [true; 4] };
546        assert!(push.has_inverse());
547        assert!(pop.has_inverse());
548    }
549
550    // ---- Pipeline-scoped stack isolation (cross-pipeline / error-path) ----
551
552    #[test]
553    fn enter_pipeline_scope_opens_and_closes_a_frame() {
554        clear_stacks();
555        assert_eq!(frame_count(), 1);
556        {
557            let _scope = enter_pipeline_scope();
558            assert_eq!(frame_count(), 2);
559            {
560                let _nested = enter_pipeline_scope();
561                assert_eq!(frame_count(), 3);
562            }
563            assert_eq!(frame_count(), 2);
564        }
565        assert_eq!(frame_count(), 1);
566    }
567
568    /// Regression test for the cross-pipeline contamination finding: two
569    /// "pipelines" (simulated by two `PushOp`/`PopOp` pairs, each guarded by
570    /// its own `enter_pipeline_scope`) used alternately on the same thread
571    /// must never observe each other's pushed values, even when the first
572    /// one aborts mid-pipeline (push with no matching pop, mirroring a step
573    /// between `push` and `pop` returning an error).
574    #[test]
575    fn cross_pipeline_scopes_do_not_contaminate() -> ProjResult<()> {
576        clear_stacks();
577        let push = PushOp { which: [true; 4] };
578        let pop = PopOp { which: [true; 4] };
579
580        // "Pipeline A" processes a point: push succeeds, then a later step
581        // fails and the pipeline aborts before reaching `pop`. The scope
582        // guard drops here (end of the `{ }` block) without a matching pop,
583        // exactly as `Pipeline::forward_4d` returning `Err` mid-way would
584        // leave the guard's block.
585        {
586            let _scope_a = enter_pipeline_scope();
587            let point_a = Coord::new(111.0, 222.0, 333.0, 444.0);
588            push.forward_4d(point_a)?;
589            // (simulated failing step here; `pop` is never reached)
590        }
591
592        // "Pipeline B" processes a completely unrelated point on the same
593        // thread. If the stacks were still a single shared thread-local
594        // (the pre-fix behaviour), B's `pop` would silently receive A's
595        // orphaned values instead of its own.
596        let restored_b = {
597            let _scope_b = enter_pipeline_scope();
598            let point_b = Coord::new(1.0, 2.0, 3.0, 4.0);
599            push.forward_4d(point_b)?;
600            pop.forward_4d(Coord::new(9.0, 9.0, 9.0, 9.0))?
601        };
602        assert_eq!(
603            restored_b.v(),
604            [1.0, 2.0, 3.0, 4.0],
605            "pipeline B must recover its own pushed point, not pipeline A's orphaned one"
606        );
607
608        // Back at the base (un-scoped) frame: pipeline A's orphaned push was
609        // discarded when its scope dropped, and pipeline B's scope is closed
610        // too, so a bare pop here (no scope open) must NOT see either of
611        // their leaked values — it hits the empty base frame (underflow) and
612        // leaves the component unchanged (PROJ silent pass-through).
613        clear_stacks();
614        let untouched = Coord::new(7.0, 8.0, 9.0, 10.0);
615        let after_pop = pop.forward_4d(untouched)?;
616        assert_eq!(after_pop.v(), untouched.v());
617        Ok(())
618    }
619
620    /// Nested-pipeline regression test: an inner pipeline's `push`/`pop`
621    /// (its own `enter_pipeline_scope`, opened while an outer scope is still
622    /// active — as happens when a nested `+proj=pipeline` step's
623    /// `forward_4d` runs inside an outer pipeline's step loop) must not
624    /// disturb the outer pipeline's own stacked value, and the outer scope's
625    /// value must still be there once the nested scope closes.
626    #[test]
627    fn nested_pipeline_scope_isolates_from_outer() -> ProjResult<()> {
628        clear_stacks();
629        let push = PushOp { which: [true; 4] };
630        let pop = PopOp { which: [true; 4] };
631
632        let _outer = enter_pipeline_scope();
633        let outer_point = Coord::new(1000.0, 2000.0, 3000.0, 4000.0);
634        push.forward_4d(outer_point)?;
635
636        // Nested pipeline: pushes and pops its own, different value, fully
637        // within its own nested scope.
638        {
639            let _inner = enter_pipeline_scope();
640            let inner_point = Coord::new(1.0, 1.0, 1.0, 1.0);
641            push.forward_4d(inner_point)?;
642            let inner_restored = pop.forward_4d(Coord::new(0.0, 0.0, 0.0, 0.0))?;
643            assert_eq!(
644                inner_restored.v(),
645                [1.0, 1.0, 1.0, 1.0],
646                "nested pipeline must recover its own value"
647            );
648        }
649
650        // Back in the outer scope: its pushed value must be intact, unmixed
651        // with anything the nested pipeline did.
652        let outer_restored = pop.forward_4d(Coord::new(0.0, 0.0, 0.0, 0.0))?;
653        assert_eq!(
654            outer_restored.v(),
655            [1000.0, 2000.0, 3000.0, 4000.0],
656            "outer pipeline's value must survive a nested pipeline's own push/pop"
657        );
658        Ok(())
659    }
660
661    /// Error-path cleanup regression test: if a pipeline aborts after `push`
662    /// but before its matching `pop` (simulated by dropping the scope guard
663    /// without popping), the orphaned value must not resurface for a later,
664    /// unrelated point processed in a fresh scope on the same thread.
665    #[test]
666    fn error_path_orphaned_push_is_discarded_on_scope_drop() -> ProjResult<()> {
667        clear_stacks();
668        let push = PushOp { which: [true; 4] };
669        let pop = PopOp { which: [true; 4] };
670
671        {
672            let _aborted_scope = enter_pipeline_scope();
673            push.forward_4d(Coord::new(42.0, 42.0, 42.0, 42.0))?;
674            // Pipeline aborts: matching `pop` never runs, scope drops here.
675        }
676
677        // A fresh point, fresh scope: must not see the orphaned 42.0 values.
678        let fresh_scope = enter_pipeline_scope();
679        let fresh_point = Coord::new(5.0, 6.0, 7.0, 8.0);
680        push.forward_4d(fresh_point)?;
681        let restored = pop.forward_4d(Coord::new(0.0, 0.0, 0.0, 0.0))?;
682        assert_eq!(restored.v(), [5.0, 6.0, 7.0, 8.0]);
683        drop(fresh_scope);
684        Ok(())
685    }
686}