Skip to main content

cumulus3d_cli/
sinks.rs

1/*
2 * sinks.rs
3 *
4 * Copyright (c) 2026 ParkSangWoo
5 *
6 * Author(s):
7 *
8 *      ParkSangWoo <dev.parksangwoo@gmail.com>
9 *
10 *
11 * This file is part of cumulus3d.
12 *
13 * Licensed under the Apache License, Version 2.0 (the "License");
14 * you may not use this file except in compliance with the License.
15 * You may obtain a copy of the License at
16 *
17 *      http://www.apache.org/licenses/LICENSE-2.0
18 *
19 * Unless required by applicable law or agreed to in writing, software
20 * distributed under the License is distributed on an "AS IS" BASIS,
21 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
22 * See the License for the specific language governing permissions and
23 * limitations under the License.
24 *
25 * SPDX-License-Identifier: Apache-2.0
26 */
27
28//! Default output hooks: receive events and produce the file output of `cumulus3d stream`.
29//!
30//! | Hook | Events received | Output |
31//! |---|---|---|
32//! | [`TimelineSink`] | events that have text (table below) | `timeline.txt` (`epoch seconds.nanoseconds text`) |
33//! | [`RunLogSink`] | events that have text, `Log`, `Finished` | `run.log` (+ standard output), `DONE` |
34//! | [`ZonePlySink`] | `ZonePreview`, `ZoneRefined` (`dense` true) | `full/preview/preview_KK_posLO-HI.ply`, `full/refined/refined_KK_posLO-HI.ply` |
35//! | [`ModelSink`] | `ZoneArrived`, `ZonePreview`, `ZoneAdjusted`, `ZoneRefinedPose`, `AllRefinedDone` | `work/models/{snap,preview,ba,refined}_K/`, `work/models/chain/` |
36//! | [`SnapshotSink`] | zone events, `AllRefinedDone` | `aligned/`, `snapshots/event_NN_TTTT.Ts_KIND_K.ply`, `snapshots/manifest.json`, `snapshots/timeline.txt`, `final_frame/`, `live/`, run.log summary |
37//!
38//! Event -> timeline text conversion table ([`Event::timeline_text`], [`TIMELINE_TABLE`]):
39//!
40//! | Event | Text |
41//! |---|---|
42//! | `Started { positions: Some(N) }` | `start NPOS=N` |
43//! | `ModelInitialized { registered: N }` | `init_model Registered images: N` |
44//! | `BaseAdopted { zone: K }` | `adopt refined K as base` |
45//! | `PositionDone { position: P, registered: R, expected: E }` | `pos P registered R/E` |
46//! | `ZoneArrived { range: K [LO,HI) }` | `region K arrived pos[LO,HI)` |
47//! | `ZonePreview { range: K }` | `preview K ready` |
48//! | `RefineStarted { zone: K }` | `refined K ba_start` |
49//! | `ZoneRefinedPose { range: K, registered: N, mean_reproj_px: X }` | `refined K pose_ready Registered images: N Mean reprojection error: X(6 decimal places)px ` (including the trailing space) |
50//! | `ZoneRefined { range: K }` | `refined K ready` |
51//! | `AllPositionsDone` | `all positions done` |
52//! | `AllRefinedDone` | `all refined done` |
53//!
54//! Ordering: the hooks of one output folder must be bundled in a single [`DefaultSinks`] and handled in event order.
55//! In an asynchronous pipeline the order between per-kind lanes is not guaranteed, so use [`attach`] (a single `on_any`).
56//! [`default_sinks`] (a per-kind list) is for synchronous mode (`Pipeline::sync(true)`) or direct calls.
57
58use crate::blocks::{DiffStream, Heartbeat, LiveStream};
59use crate::events::{Event, EventKind, ZoneRange};
60use crate::pipeline::{Pipeline, Reducer};
61use crate::post::{self, PostZones, ZoneCloud, ZoneTimes};
62use crate::util::Logger;
63use cumulus3d_core::interop::{write_model_binary, ImageOrder};
64use cumulus3d_core::io::{write_ply, PlyLayout, PointCloud};
65use cumulus3d_core::Reconstruction;
66use std::collections::{BTreeMap, HashMap};
67use std::fs::File;
68use std::io::Write;
69use std::path::{Path, PathBuf};
70use std::sync::{Arc, Mutex};
71use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
72
73/// One hook (the shape passed to the pipeline's `.on`/`.on_any`).
74pub type Hook = Box<dyn FnMut(&Event) + Send>;
75
76/// Event kinds that produce timeline text (conversion rules in [`Event::timeline_text`]).
77pub const TIMELINE_TABLE: [(EventKind, &str); 11] = [
78    (EventKind::Started, "start NPOS={positions}"),
79    (EventKind::ModelInitialized, "init_model Registered images: {registered}"),
80    (EventKind::BaseAdopted, "adopt refined {zone} as base"),
81    (EventKind::PositionDone, "pos {position} registered {registered}/{expected}"),
82    (EventKind::ZoneArrived, "region {zone} arrived pos[{lo},{hi})"),
83    (EventKind::ZonePreview, "preview {zone} ready"),
84    (EventKind::RefineStarted, "refined {zone} ba_start"),
85    (
86        EventKind::ZoneRefinedPose,
87        "refined {zone} pose_ready Registered images: {registered} Mean reprojection error: {mean_reproj_px:.6}px ",
88    ),
89    (EventKind::ZoneRefined, "refined {zone} ready"),
90    (EventKind::AllPositionsDone, "all positions done"),
91    (EventKind::AllRefinedDone, "all refined done"),
92];
93
94/// A hook that receives events and produces output.
95pub trait Sink: Send {
96    /// Event kinds it handles.
97    fn kinds(&self) -> Vec<EventKind>;
98    /// Handle one event. A failure is recorded in run.log and does not panic.
99    fn handle(&mut self, e: &Event);
100}
101
102/// One hook as a per-kind hook list (sharing the same state). Use it only in synchronous mode if ordering between kinds is needed.
103pub fn hooks<S: Sink + 'static>(sink: S) -> Vec<(EventKind, Hook)> {
104    let kinds = sink.kinds();
105    let shared = Arc::new(Mutex::new(sink));
106    kinds
107        .into_iter()
108        .map(|k| {
109            let s = Arc::clone(&shared);
110            let h: Hook = Box::new(move |e: &Event| s.lock().unwrap_or_else(|p| p.into_inner()).handle(e));
111            (k, h)
112        })
113        .collect()
114}
115
116/// One hook as an all-event hook (`on_any`). It filters and handles only the kinds it accepts.
117pub fn any_hook<S: Sink + 'static>(mut sink: S) -> Hook {
118    let kinds = sink.kinds();
119    Box::new(move |e: &Event| {
120        if kinds.contains(&e.kind()) {
121            sink.handle(e)
122        }
123    })
124}
125
126fn epoch_secs(t: SystemTime) -> f64 {
127    t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs_f64()
128}
129
130fn zone_file(kind: &str, r: &ZoneRange) -> String {
131    format!("{kind}_{:02}_pos{}-{}.ply", r.zone, r.lo, r.hi)
132}
133
134// ---------------------------------------------------------------------------------------------
135
136/// Shared handle to run.log. Several hooks (such as [`SnapshotSink`], which writes a summary) write to the same file.
137#[derive(Clone)]
138pub struct RunLog {
139    inner: Arc<RunLogInner>,
140}
141
142struct RunLogInner {
143    logger: Logger,
144    /// Stage times measured in hooks (e.g. `post`). They are added to the per-stage times of `Finished`.
145    extra_times: Mutex<Vec<(String, Duration)>>,
146}
147
148impl RunLog {
149    /// Create `path` anew (if `None`, no file and only standard output, when `echo`).
150    pub fn open(path: Option<&Path>, echo: bool) -> std::io::Result<Self> {
151        Ok(Self { inner: Arc::new(RunLogInner { logger: Logger::new(path, echo)?, extra_times: Mutex::new(Vec::new()) }) })
152    }
153    /// Internal recorder.
154    pub fn logger(&self) -> &Logger {
155        &self.inner.logger
156    }
157    /// Record one line.
158    pub fn line(&self, s: &str) {
159        self.inner.logger.line(s)
160    }
161    /// Record with the `[HH:MM:SS] ` prefix of the given time.
162    pub fn stamped_at(&self, at: SystemTime, s: &str) {
163        self.inner.logger.stamped_at(at, s)
164    }
165    /// Add stage time (same names accumulate).
166    pub fn add_time(&self, stage: &str, d: Duration) {
167        if let Ok(mut v) = self.inner.extra_times.lock() {
168            v.push((stage.to_string(), d));
169        }
170    }
171    fn take_times(&self) -> Vec<(String, Duration)> {
172        self.inner.extra_times.lock().map(|mut v| std::mem::take(&mut *v)).unwrap_or_default()
173    }
174}
175
176// ---------------------------------------------------------------------------------------------
177
178/// `timeline.txt`: for every event that has text, one line `epoch seconds.nanoseconds text` (time = `meta.at`).
179pub struct TimelineSink {
180    file: File,
181}
182
183impl TimelineSink {
184    /// Create timeline.txt anew at `path`.
185    pub fn create(path: &Path) -> std::io::Result<Self> {
186        Ok(Self { file: File::create(path)? })
187    }
188
189    /// One event line (None if there is no text).
190    pub fn line(e: &Event) -> Option<String> {
191        let s = e.timeline_text()?;
192        let t = e.meta().at.duration_since(UNIX_EPOCH).unwrap_or_default();
193        Some(format!("{}.{:09} {s}", t.as_secs(), t.subsec_nanos()))
194    }
195}
196
197impl Sink for TimelineSink {
198    fn kinds(&self) -> Vec<EventKind> {
199        TIMELINE_TABLE.iter().map(|(k, _)| *k).collect()
200    }
201    fn handle(&mut self, e: &Event) {
202        if let Some(l) = Self::line(e) {
203            let _ = writeln!(self.file, "{l}");
204            let _ = self.file.flush();
205        }
206    }
207}
208
209// ---------------------------------------------------------------------------------------------
210
211/// `run.log`: timeline text is `[HH:MM:SS] text`, `Log` as is (with the time prefix if `stamped`),
212/// `Finished` is followed by the `DONE` file after the per-stage times (including the hook-measured `post`) and the final chain model
213/// summary.
214/// `Warning`/`Error` come together with a `Log` of the same content, so they are not written separately.
215pub struct RunLogSink {
216    log: RunLog,
217    done: Option<PathBuf>,
218}
219
220impl RunLogSink {
221    /// `done` = the empty file to create on `Finished` (usually `<out>/DONE`).
222    pub fn new(log: RunLog, done: Option<PathBuf>) -> Self {
223        Self { log, done }
224    }
225}
226
227impl Sink for RunLogSink {
228    fn kinds(&self) -> Vec<EventKind> {
229        let mut v: Vec<EventKind> = TIMELINE_TABLE.iter().map(|(k, _)| *k).collect();
230        v.extend([EventKind::Log, EventKind::Finished]);
231        v
232    }
233    fn handle(&mut self, e: &Event) {
234        if let Some(s) = e.timeline_text() {
235            self.log.stamped_at(e.meta().at, &s);
236            return;
237        }
238        match e {
239            Event::Log { meta, line, stamped } => {
240                if *stamped {
241                    self.log.stamped_at(meta.at, line)
242                } else {
243                    self.log.line(line)
244                }
245            }
246            Event::Finished { pipeline_secs, stage_times, chain_stats, .. } => {
247                let mut m: BTreeMap<String, (Duration, usize)> = BTreeMap::new();
248                for (s, d, n) in stage_times {
249                    let x = m.entry(s.clone()).or_default();
250                    x.0 += *d;
251                    x.1 += n;
252                }
253                for (s, d) in self.log.take_times() {
254                    let x = m.entry(s).or_default();
255                    x.0 += d;
256                    x.1 += 1;
257                }
258                self.log.line(&format!("== 단계별 시간 (파이프라인 {pipeline_secs:.1}s, 배경 스레드와 겹친 누적)"));
259                for (stage, (d, n)) in m {
260                    self.log.line(&format!("{stage:<14} {:>9.2}s  ({n}회)", d.as_secs_f64()));
261                }
262                if !chain_stats.is_empty() {
263                    self.log.line("== 최종 체인 모델");
264                    for l in chain_stats {
265                        self.log.line(l);
266                    }
267                }
268                if let Some(p) = &self.done {
269                    if let Err(err) = std::fs::write(p, b"") {
270                        self.log.line(&format!("{}: {err}", p.display()));
271                    }
272                }
273            }
274            _ => {}
275        }
276    }
277}
278
279// ---------------------------------------------------------------------------------------------
280
281/// `full/preview/preview_KK_posLO-HI.ply`, `full/refined/refined_KK_posLO-HI.ply`
282/// (x y z nx ny nz red green blue). Events whose `dense` is false (no densification or failed) are not written.
283pub struct ZonePlySink {
284    full: PathBuf,
285    log: Option<RunLog>,
286}
287
288impl ZonePlySink {
289    /// `full` = `full/` of the output folder.
290    pub fn new(full: PathBuf, log: Option<RunLog>) -> Self {
291        Self { full, log }
292    }
293
294    fn write(&self, kind: &str, r: &ZoneRange, c: &PointCloud) {
295        let path = self.full.join(kind).join(zone_file(kind, r));
296        let res = path
297            .parent()
298            .map_or(Ok(()), std::fs::create_dir_all)
299            .map_err(|e| e.to_string())
300            .and_then(|_| write_ply(&path, c, PlyLayout::XyzNormalRgb).map_err(|e| e.to_string()));
301        if let (Err(e), Some(l)) = (res, &self.log) {
302            l.line(&format!("[dense] {kind} {} PLY 쓰기 실패: {e}", r.zone));
303        }
304    }
305}
306
307impl Sink for ZonePlySink {
308    fn kinds(&self) -> Vec<EventKind> {
309        vec![EventKind::ZonePreview, EventKind::ZoneRefined]
310    }
311    fn handle(&mut self, e: &Event) {
312        match e {
313            Event::ZonePreview { range, cloud, dense: true, .. } => self.write("preview", range, cloud),
314            Event::ZoneRefined { range, cloud, dense: true, .. } => self.write("refined", range, cloud),
315            _ => {}
316        }
317    }
318}
319
320// ---------------------------------------------------------------------------------------------
321
322/// `--save-models`: per-zone sparse models under `work/models/`.
323/// `ZoneArrived` → `snap_K`, `ZonePreview` → `preview_K`, `ZoneAdjusted` → `ba_K`, `ZoneRefinedPose` → `refined_K`,
324/// `AllRefinedDone` → `chain`.
325pub struct ModelSink {
326    dir: PathBuf,
327    log: Option<RunLog>,
328}
329
330impl ModelSink {
331    /// `dir` = usually `<out>/work/models`.
332    pub fn new(dir: PathBuf, log: Option<RunLog>) -> Self {
333        Self { dir, log }
334    }
335
336    fn save(&self, rec: &Reconstruction, name: &str) {
337        let d = self.dir.join(name);
338        let _ = std::fs::create_dir_all(&d);
339        if let (Err(e), Some(l)) = (write_model_binary(rec, &d, ImageOrder::Registration), &self.log) {
340            l.line(&format!("모델 저장 실패 {}: {e}", d.display()));
341        }
342    }
343}
344
345impl Sink for ModelSink {
346    fn kinds(&self) -> Vec<EventKind> {
347        vec![EventKind::ZoneArrived, EventKind::ZonePreview, EventKind::ZoneAdjusted, EventKind::ZoneRefinedPose, EventKind::AllRefinedDone]
348    }
349    fn handle(&mut self, e: &Event) {
350        match e {
351            Event::ZoneArrived { range, model, .. } => self.save(model, &format!("snap_{}", range.zone)),
352            Event::ZonePreview { range, model, .. } => self.save(model, &format!("preview_{}", range.zone)),
353            Event::ZoneAdjusted { zone, model, .. } => self.save(model, &format!("ba_{zone}")),
354            Event::ZoneRefinedPose { range, model, .. } => self.save(model, &format!("refined_{}", range.zone)),
355            Event::AllRefinedDone { chain: Some(c), .. } => self.save(c, "chain"),
356            _ => {}
357        }
358    }
359}
360
361// ---------------------------------------------------------------------------------------------
362
363/// Hook for alignment, masking, per-event snapshots, manifest and re-anchoring (final_frame). It collects zone results and
364/// builds them all at once with [`post::run_zones`] at `AllRefinedDone`. Times are seconds relative to `Started` (or the first received
365/// event if absent).
366///
367/// - Alignment range of preview K (>= 1) = [lo of zone K, hi of zone K-1) (the overlap with the previous zone).
368/// - Image name -> position is collected from `FrameIngested` and `FeaturesExtracted`.
369/// - `Reset { position }` drops zones with hi > position, and `ZoneInvalidated` drops that zone.
370pub struct SnapshotSink {
371    out: PathBuf,
372    log: RunLog,
373    t0: Option<SystemTime>,
374    ranges: BTreeMap<usize, ZoneRange>,
375    times: BTreeMap<usize, Stamps>,
376    preview: BTreeMap<usize, Arc<PointCloud>>,
377    refined: BTreeMap<usize, Arc<PointCloud>>,
378    preview_models: BTreeMap<usize, Arc<Reconstruction>>,
379    refined_models: BTreeMap<usize, Arc<Reconstruction>>,
380    positions: HashMap<String, usize>,
381    live: post::LiveDisplay,
382    live_count: usize,
383    /// Live diff stream of the live display (`live/stream/`; stable display only) and the display it last wrote.
384    diff: Option<DiffStream>,
385    last_display: Option<(PointCloud, Vec<usize>)>,
386    /// Progress heartbeat (`live/stream/status.json`), on with the block stream.
387    beat: Option<Heartbeat>,
388}
389
390/// Zone event time (the first one received).
391#[derive(Clone, Copy, Default)]
392struct Stamps {
393    arrived: Option<SystemTime>,
394    preview: Option<SystemTime>,
395    pose: Option<SystemTime>,
396    refined: Option<SystemTime>,
397}
398
399impl SnapshotSink {
400    /// Create from the output folder and run.log.
401    pub fn new(out: PathBuf, log: RunLog) -> Self {
402        Self {
403            out,
404            log,
405            t0: None,
406            ranges: BTreeMap::new(),
407            times: BTreeMap::new(),
408            preview: BTreeMap::new(),
409            refined: BTreeMap::new(),
410            preview_models: BTreeMap::new(),
411            refined_models: BTreeMap::new(),
412            positions: HashMap::new(),
413            live: post::LiveDisplay::default(),
414            live_count: 0,
415            diff: None,
416            last_display: None,
417            beat: None,
418        }
419    }
420
421    /// Updates the heartbeat state through `f` and rewrites `status.json`; a failure is only logged.
422    fn beat(&mut self, at: SystemTime, f: impl FnOnce(&mut Heartbeat)) {
423        let Some(b) = self.beat.as_mut() else { return };
424        f(b);
425        let t = epoch_secs(at) - epoch_secs(self.t0.unwrap_or(at));
426        if let Err(e) = b.write((t * 10.0).round() / 10.0) {
427            self.log.line(&format!("[live] status.json 실패: {e}"));
428        }
429    }
430
431    /// Sets the live display thinning stride (1 = keep all).
432    pub fn with_live_stride(mut self, stride: usize) -> Self {
433        self.live = post::LiveDisplay::new(post::LiveOptions { stride, ..post::LiveOptions::default() });
434        self
435    }
436
437    /// Turns the live diff stream (`live/stream/`) on.
438    pub fn with_live_stream(mut self, cfg: LiveStream) -> Self {
439        let dir = self.out.join("live").join("stream");
440        self.beat = Some(Heartbeat { dir: dir.clone(), stage: "registration", ..Default::default() });
441        self.diff = Some(DiffStream::new(dir, cfg));
442        self
443    }
444
445    fn drop_zone(&mut self, k: usize) {
446        self.ranges.remove(&k);
447        self.times.remove(&k);
448        self.preview.remove(&k);
449        self.refined.remove(&k);
450        self.preview_models.remove(&k);
451        self.refined_models.remove(&k);
452        self.live.forget(k);
453    }
454
455    /// Writes the display cloud for the refined zones received so far (`live/display_NN_TTTT.Ts_refined_K.ply`, and
456    /// `live/latest.ply` replaced atomically), every zone placed in the newest refined model's frame.
457    fn write_live(&mut self, k: usize, at: SystemTime, kind: &str) {
458        let t = Instant::now();
459        let clouds: BTreeMap<usize, &PointCloud> = self.refined.iter().map(|(j, c)| (*j, &**c)).collect();
460        let models: BTreeMap<usize, &Reconstruction> = self.refined_models.iter().map(|(j, m)| (*j, &**m)).collect();
461        // Stable display (no preview ever received; zone 0's preview always precedes zone 1's refined result): all zone
462        // clouds already share one frame, so they are not relocated.
463        self.live.set_relocate(!self.preview_models.is_empty());
464        let (cloud, zones) = self.live.compose_zoned(&clouds, &models);
465        let dir = self.out.join("live");
466        let rel = epoch_secs(at) - epoch_secs(self.t0.unwrap_or(at));
467        self.live_count += 1;
468        let name = format!("display_{:02}_{rel:06.1}s_refined_{k}.ply", self.live_count);
469        let tmp = dir.join(".latest.ply.tmp");
470        let r = std::fs::create_dir_all(&dir)
471            .map_err(|e| e.to_string())
472            .and_then(|_| write_ply(dir.join(&name), &cloud, PlyLayout::XyzRgbNormal).map_err(|e| e.to_string()))
473            .and_then(|_| write_ply(&tmp, &cloud, PlyLayout::XyzRgbNormal).map_err(|e| e.to_string()))
474            .and_then(|_| std::fs::rename(&tmp, dir.join("latest.ply")).map_err(|e| e.to_string()));
475        self.log.add_time("live", t.elapsed());
476        match r {
477            Ok(()) => {
478                self.log.line(&format!("[live] {name}: 구역 {} 개, 점 {}, {:.2}s", clouds.len(), cloud.len(), t.elapsed().as_secs_f64()))
479            }
480            Err(e) => self.log.line(&format!("[live] {name} 실패: {e}")),
481        }
482        if self.diff.is_some() {
483            self.write_stream(k, rel, kind, cloud, zones);
484        }
485    }
486
487    /// One event of the live diff stream (`kind`: refined | replace | final); logs the time and the change counts.
488    fn write_stream(&mut self, zone: usize, t_rel: f64, kind: &str, cloud: PointCloud, zones: Vec<usize>) {
489        let t = Instant::now();
490        let Some(bs) = self.diff.as_mut() else { return };
491        let r = bs.write_event(t_rel, zone, kind, &cloud, &zones);
492        self.log.add_time("live_stream", t.elapsed());
493        match r {
494            Ok(st) => self.log.line(&format!(
495                "[live] stream seq {}: 추가 {} 이동 {} 제거 {}, 점 {}, {:.2} MB, {:.2}s",
496                bs.events_written(),
497                st.n_add,
498                st.n_move,
499                st.n_remove,
500                st.points,
501                st.bytes as f64 / 1e6,
502                t.elapsed().as_secs_f64()
503            )),
504            Err(e) => self.log.line(&format!("[live] stream {kind} 구역 {zone} 실패: {e}")),
505        }
506        self.last_display = Some((cloud, zones));
507    }
508
509    /// Post-process with the results collected so far. When finished, copy `timeline.txt` to `snapshots/`.
510    /// `final_model` = `AllRefinedDone::chain` (in stable display mode the GPS-aligned final model).
511    pub fn finish_with(&mut self, final_model: Option<&Reconstruction>) {
512        if self.preview_models.is_empty() && !self.refined.is_empty() {
513            let t0 = epoch_secs(self.t0.unwrap_or(UNIX_EPOCH));
514            let rel = |t: Option<SystemTime>| t.map(|t| epoch_secs(t) - t0);
515            let times: BTreeMap<usize, ZoneTimes> = self
516                .times
517                .iter()
518                .map(|(k, s)| {
519                    (
520                        *k,
521                        ZoneTimes {
522                            arrived: rel(s.arrived),
523                            preview_ready: None,
524                            refined_pose: rel(s.pose),
525                            refined_ready: rel(s.refined),
526                        },
527                    )
528                })
529                .collect();
530            let files: BTreeMap<usize, String> =
531                self.refined.keys().filter_map(|k| self.ranges.get(k).map(|r| (*k, zone_file("refined", r)))).collect();
532            let rf = self.refined.iter().filter_map(|(k, c)| files.get(k).map(|f| (*k, ZoneCloud { file: f.clone(), cloud: c }))).collect();
533            let display = self.refined_models.values().next_back().map(|m| &**m);
534            let t = Instant::now();
535            let models: BTreeMap<usize, &Reconstruction> = self.refined_models.iter().map(|(k, m)| (*k, &**m)).collect();
536            let r = post::run_stable(&self.out, &rf, &models, &times, display, final_model, self.log.logger());
537            self.log.add_time("post", t.elapsed());
538            if let Err(e) = r {
539                self.log.line(&format!("[post] 실패: {e}"));
540            }
541            let _ = std::fs::copy(self.out.join("timeline.txt"), self.out.join("snapshots").join("timeline.txt"));
542            return;
543        }
544        self.finish();
545    }
546
547    /// Post-process with the results collected so far (preview/refined alignment, per-event snapshots, re-anchoring). When finished, copy
548    /// `timeline.txt` to `snapshots/`.
549    pub fn finish(&mut self) {
550        let t0 = epoch_secs(self.t0.unwrap_or(UNIX_EPOCH));
551        let rel = |t: Option<SystemTime>| t.map(|t| epoch_secs(t) - t0);
552        let times: BTreeMap<usize, ZoneTimes> = self
553            .times
554            .iter()
555            .map(|(k, s)| {
556                (
557                    *k,
558                    ZoneTimes {
559                        arrived: rel(s.arrived),
560                        preview_ready: rel(s.preview),
561                        refined_pose: rel(s.pose),
562                        refined_ready: rel(s.refined),
563                    },
564                )
565            })
566            .collect();
567        let windows = self
568            .ranges
569            .iter()
570            .map(|(k, r)| {
571                let hi = k.checked_sub(1).and_then(|j| self.ranges.get(&j)).map_or(r.hi, |p| p.hi);
572                (*k, (r.lo, hi))
573            })
574            .collect();
575        let zc = |kind: &str, m: &BTreeMap<usize, Arc<PointCloud>>| -> BTreeMap<usize, (String, Arc<PointCloud>)> {
576            m.iter().filter_map(|(k, c)| self.ranges.get(k).map(|r| (*k, (zone_file(kind, r), Arc::clone(c))))).collect()
577        };
578        let (pv, rf) = (zc("preview", &self.preview), zc("refined", &self.refined));
579        let positions = &self.positions;
580        let pos = move |n: &str| positions.get(n).copied();
581        let z = PostZones {
582            out: &self.out,
583            preview: pv.iter().map(|(k, (f, c))| (*k, ZoneCloud { file: f.clone(), cloud: c })).collect(),
584            refined: rf.iter().map(|(k, (f, c))| (*k, ZoneCloud { file: f.clone(), cloud: c })).collect(),
585            preview_models: self.preview_models.iter().map(|(k, m)| (*k, &**m)).collect(),
586            refined_models: self.refined_models.iter().map(|(k, m)| (*k, &**m)).collect(),
587            times,
588            windows,
589            position: &pos,
590        };
591        let t = Instant::now();
592        let r = post::run_zones(&z, self.log.logger());
593        self.log.add_time("post", t.elapsed());
594        if let Err(e) = r {
595            self.log.line(&format!("[post] 실패: {e}"));
596        }
597        let _ = std::fs::copy(self.out.join("timeline.txt"), self.out.join("snapshots").join("timeline.txt"));
598    }
599}
600
601impl Sink for SnapshotSink {
602    fn kinds(&self) -> Vec<EventKind> {
603        vec![
604            EventKind::Started,
605            EventKind::FrameIngested,
606            EventKind::FeaturesExtracted,
607            EventKind::ZoneArrived,
608            EventKind::ZonePreview,
609            EventKind::PositionDone,
610            EventKind::RefineStarted,
611            EventKind::ZoneRefinedPose,
612            EventKind::ZoneRefined,
613            EventKind::Reset,
614            EventKind::ZoneInvalidated,
615            EventKind::AllRefinedDone,
616        ]
617    }
618
619    fn handle(&mut self, e: &Event) {
620        let at = e.meta().at;
621        if matches!(e, Event::Started { .. }) || self.t0.is_none() {
622            self.t0 = Some(at);
623        }
624        match e {
625            Event::Started { positions, .. } => self.beat(at, |b| b.positions_total = *positions),
626            Event::PositionDone { position, registered, .. } => {
627                let (p, r) = (*position + 1, *registered);
628                self.beat(at, |b| {
629                    b.stage = "triangulation";
630                    b.positions = p;
631                    b.images = r;
632                });
633            }
634            Event::RefineStarted { zone, .. } => self.beat(at, |b| {
635                b.stage = "ba";
636                b.zone = *zone;
637            }),
638            Event::FrameIngested { position, images, .. } => {
639                for n in images {
640                    self.positions.insert(n.clone(), *position);
641                }
642            }
643            Event::FeaturesExtracted { image, position, .. } => {
644                self.positions.insert(image.clone(), *position);
645            }
646            Event::ZoneArrived { range, .. } => {
647                self.ranges.insert(range.zone, *range);
648                self.times.entry(range.zone).or_default().arrived.get_or_insert(at);
649            }
650            Event::ZonePreview { range, cloud, model, dense, .. } => {
651                self.ranges.entry(range.zone).or_insert(*range);
652                self.times.entry(range.zone).or_default().preview.get_or_insert(at);
653                self.preview_models.insert(range.zone, Arc::clone(model));
654                if *dense {
655                    self.preview.insert(range.zone, Arc::clone(cloud));
656                }
657            }
658            Event::ZoneRefinedPose { range, model, .. } => {
659                self.beat(at, |b| {
660                    b.stage = "dense";
661                    b.zone = range.zone;
662                });
663                self.ranges.entry(range.zone).or_insert(*range);
664                self.times.entry(range.zone).or_default().pose.get_or_insert(at);
665                self.refined_models.insert(range.zone, Arc::clone(model));
666                self.live.forget(range.zone);
667            }
668            Event::ZoneRefined { range, cloud, dense, .. } => {
669                self.ranges.entry(range.zone).or_insert(*range);
670                self.times.entry(range.zone).or_default().refined.get_or_insert(at);
671                if *dense {
672                    // A zone that already has a cloud is a shown zone being re-densified or moved (`replace_shown`).
673                    let kind = if self.refined.contains_key(&range.zone) { "replace" } else { "refined" };
674                    self.refined.insert(range.zone, Arc::clone(cloud));
675                    self.write_live(range.zone, at, kind);
676                }
677            }
678            Event::Reset { position, .. } => {
679                let ks: Vec<usize> = self.ranges.values().filter(|r| r.hi > *position).map(|r| r.zone).collect();
680                for k in ks {
681                    self.drop_zone(k);
682                }
683            }
684            Event::ZoneInvalidated { zone, .. } => self.drop_zone(*zone),
685            Event::AllRefinedDone { chain, .. } => {
686                self.beat(at, |b| b.stage = "done");
687                // Final stream event: the display does not change (the ENU alignment of the final outputs is separate).
688                if let Some((cloud, zones)) = self.last_display.take() {
689                    let rel = epoch_secs(at) - epoch_secs(self.t0.unwrap_or(at));
690                    let zone = self.refined.keys().next_back().copied().unwrap_or(0);
691                    self.write_stream(zone, rel, "final", cloud, zones);
692                }
693                self.finish_with(chain.as_deref())
694            }
695            _ => {}
696        }
697    }
698}
699
700// ---------------------------------------------------------------------------------------------
701
702/// Default hook settings.
703#[derive(Clone, Debug, Default)]
704pub struct SinkOptions {
705    /// Also write the run.log contents to standard output.
706    pub echo: bool,
707    /// Save per-zone sparse models (`work/models/`).
708    pub save_models: bool,
709}
710
711/// Which default hooks to enable ([`DefaultSinks::with_set`]).
712#[derive(Clone, Copy, Debug, PartialEq)]
713pub struct SinkSet {
714    /// `timeline.txt`([`TimelineSink`]).
715    pub timeline: bool,
716    /// `run.log`·`DONE`([`RunLogSink`]).
717    pub run_log: bool,
718    /// Zone PLY `full/{preview,refined}/` ([`ZonePlySink`]).
719    pub zone_ply: bool,
720    /// Per-zone sparse models `work/models/` ([`ModelSink`]).
721    pub models: bool,
722    /// Alignment, snapshots, manifest, final_frame ([`SnapshotSink`]).
723    pub snapshots: bool,
724    /// Live diff stream `live/stream/` of the live display (needs `snapshots`; stable display only).
725    pub live_stream: Option<LiveStream>,
726    /// Live display thinning stride (1 = keep all).
727    pub live_stride: usize,
728}
729
730impl SinkSet {
731    /// Enable all.
732    pub fn all() -> Self {
733        Self {
734            timeline: true,
735            run_log: true,
736            zone_ply: true,
737            models: true,
738            snapshots: true,
739            live_stream: None,
740            live_stride: post::LiveOptions::default().stride,
741        }
742    }
743}
744
745/// The default hook bundle of one output folder (handled in the order timeline -> run.log -> full PLY -> models -> snapshots).
746pub struct DefaultSinks {
747    sinks: Vec<Box<dyn Sink>>,
748    log: RunLog,
749}
750
751impl DefaultSinks {
752    /// Create `full/preview`, `full/refined` and `work` under `out`, and open `timeline.txt` and `run.log` anew.
753    /// It does not delete the folder.
754    pub fn new(out: &Path, opts: &SinkOptions) -> std::io::Result<Self> {
755        Self::with_set(out, opts.echo, SinkSet { models: opts.save_models, ..SinkSet::all() })
756    }
757
758    /// Enable only the chosen hooks. `work` is always created; `full/preview` and `full/refined` only when zone PLY is enabled.
759    /// If run.log is off, records go only to standard output when `echo` is set, and `DONE` is not created either. It does not delete the
760    /// folder.
761    pub fn with_set(out: &Path, echo: bool, set: SinkSet) -> std::io::Result<Self> {
762        let dirs: &[&str] = if set.zone_ply { &["full/preview", "full/refined", "work"] } else { &["work"] };
763        for d in dirs {
764            std::fs::create_dir_all(out.join(d))?;
765        }
766        let run_log = out.join("run.log");
767        let log = RunLog::open(set.run_log.then_some(run_log.as_path()), echo)?;
768        let mut sinks: Vec<Box<dyn Sink>> = Vec::new();
769        if set.timeline {
770            sinks.push(Box::new(TimelineSink::create(&out.join("timeline.txt"))?));
771        }
772        sinks.push(Box::new(RunLogSink::new(log.clone(), set.run_log.then(|| out.join("DONE")))));
773        if set.zone_ply {
774            sinks.push(Box::new(ZonePlySink::new(out.join("full"), Some(log.clone()))));
775        }
776        if set.models {
777            sinks.push(Box::new(ModelSink::new(out.join("work").join("models"), Some(log.clone()))));
778        }
779        if set.snapshots {
780            let mut snap = SnapshotSink::new(out.to_path_buf(), log.clone()).with_live_stride(set.live_stride);
781            if let Some(cfg) = set.live_stream {
782                snap = snap.with_live_stream(cfg);
783            }
784            sinks.push(Box::new(snap));
785        }
786        Ok(Self { sinks, log })
787    }
788
789    /// The run.log handle (when other hooks write to the same file).
790    pub fn run_log(&self) -> RunLog {
791        self.log.clone()
792    }
793
794    /// An all-event hook (`Pipeline::on_any`) that keeps the event order.
795    pub fn into_hook(self) -> Hook {
796        any_hook(self)
797    }
798}
799
800impl Sink for DefaultSinks {
801    fn kinds(&self) -> Vec<EventKind> {
802        let mut v: Vec<EventKind> = Vec::new();
803        for s in &self.sinks {
804            for k in s.kinds() {
805                if !v.contains(&k) {
806                    v.push(k);
807                }
808            }
809        }
810        v
811    }
812    fn handle(&mut self, e: &Event) {
813        let k = e.kind();
814        for s in &mut self.sinks {
815            if s.kinds().contains(&k) {
816                s.handle(e);
817            }
818        }
819    }
820}
821
822/// The default hooks as a per-kind list (sharing state). For a synchronous pipeline (`sync(true)`) or direct calls.
823pub fn default_sinks(out: &Path, opts: &SinkOptions) -> std::io::Result<Vec<(EventKind, Hook)>> {
824    Ok(hooks(DefaultSinks::new(out, opts)?))
825}
826
827/// Attach the default hooks to a pipeline (a single `on_any`, so the order is preserved even in asynchronous mode).
828pub fn attach<R: Reducer>(p: Pipeline<R>, out: &Path, opts: &SinkOptions) -> std::io::Result<Pipeline<R>> {
829    Ok(p.on_any(DefaultSinks::new(out, opts)?.into_hook()))
830}
831
832#[cfg(test)]
833mod tests {
834    use super::*;
835    use crate::events::Meta;
836
837    fn meta(s: u64, n: u32) -> Meta {
838        Meta { version: 0, at: UNIX_EPOCH + Duration::new(s, n) }
839    }
840    fn r(zone: usize, lo: usize, hi: usize) -> ZoneRange {
841        ZoneRange { zone, lo, hi }
842    }
843
844    #[test]
845    fn heartbeat_status_json_follows_events() {
846        let dir = std::env::temp_dir().join(format!("c3d_status_{}", std::process::id()));
847        let _ = std::fs::remove_dir_all(&dir);
848        let log = RunLog::open(None, false).unwrap();
849        let cfg = LiveStream { enu: false, origin_gps: None };
850        let mut s = SnapshotSink::new(dir.clone(), log).with_live_stream(cfg);
851        let read = || crate::util::Json::parse(&std::fs::read_to_string(dir.join("live/stream/status.json")).unwrap()).unwrap();
852        s.handle(&Event::Started { meta: meta(100, 0), positions: Some(26) });
853        s.handle(&Event::PositionDone { meta: meta(102, 500_000_000), position: 14, registered: 44, expected: 45 });
854        let j = read();
855        assert_eq!(
856            j.dump(),
857            crate::util::Json::obj(vec![
858                ("t", crate::util::Json::Num("2.5".into())),
859                ("stage", crate::util::Json::Str("triangulation".into())),
860                ("zone", crate::util::Json::Int(0)),
861                ("positions", crate::util::Json::Int(15)),
862                ("positions_total", crate::util::Json::Int(26)),
863                ("images", crate::util::Json::Int(44)),
864            ])
865            .dump()
866        );
867        s.handle(&Event::RefineStarted { meta: meta(103, 0), zone: 1 });
868        let j = read();
869        assert_eq!(j.get("stage").map(|v| v.dump()), Some("\"ba\"".into()));
870        assert_eq!(j.get("zone").map(|v| v.dump()), Some("1".into()));
871        assert!(!dir.join("live/stream/status.json.tmp").exists());
872        let _ = std::fs::remove_dir_all(&dir);
873    }
874
875    #[test]
876    fn timeline_table() {
877        // Nanoseconds are a multiple of 100: Windows SystemTime has 100 ns resolution.
878        let m = || meta(1_700_000_000, 500);
879        let model = Arc::new(Reconstruction::new());
880        let cloud = Arc::new(PointCloud::default());
881        let cases: Vec<(Event, &str)> = vec![
882            (Event::Started { meta: m(), positions: Some(26) }, "start NPOS=26"),
883            (Event::ModelInitialized { meta: m(), registered: 42, position: 13, model: model.clone() }, "init_model Registered images: 42"),
884            (Event::BaseAdopted { meta: m(), zone: 0 }, "adopt refined 0 as base"),
885            (Event::PositionDone { meta: m(), position: 14, registered: 44, expected: 45 }, "pos 14 registered 44/45"),
886            (Event::ZoneArrived { meta: m(), range: r(1, 10, 26), model: model.clone() }, "region 1 arrived pos[10,26)"),
887            (
888                Event::ZonePreview {
889                    meta: m(),
890                    range: r(1, 10, 26),
891                    cloud: cloud.clone(),
892                    model: model.clone(),
893                    dense: true,
894                    frame: "gps".into(),
895                },
896                "preview 1 ready",
897            ),
898            (Event::RefineStarted { meta: m(), zone: 1 }, "refined 1 ba_start"),
899            (
900                Event::ZoneRefinedPose { meta: m(), range: r(1, 10, 26), registered: 48, mean_reproj_px: 0.25095, model: model.clone() },
901                "refined 1 pose_ready Registered images: 48 Mean reprojection error: 0.250950px ",
902            ),
903            (Event::ZoneRefined { meta: m(), range: r(1, 10, 26), cloud, model, dense: true }, "refined 1 ready"),
904            (Event::AllPositionsDone { meta: m() }, "all positions done"),
905            (Event::AllRefinedDone { meta: m(), chain: None }, "all refined done"),
906        ];
907        assert_eq!(cases.len(), TIMELINE_TABLE.len());
908        for ((e, want), (k, _)) in cases.iter().zip(TIMELINE_TABLE.iter()) {
909            assert_eq!(e.kind(), *k);
910            assert_eq!(TimelineSink::line(e).unwrap(), format!("1700000000.000000500 {want}"));
911        }
912        assert!(TimelineSink::line(&Event::Warning { meta: m(), message: "x".into() }).is_none());
913    }
914}