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    /// Write the display PLYs (`live/display_*.ply`, `live/latest.ply`) and the post-processing outputs. Off: only the
389    /// live diff stream and its heartbeat.
390    files: bool,
391}
392
393/// Zone event time (the first one received).
394#[derive(Clone, Copy, Default)]
395struct Stamps {
396    arrived: Option<SystemTime>,
397    preview: Option<SystemTime>,
398    pose: Option<SystemTime>,
399    refined: Option<SystemTime>,
400}
401
402impl SnapshotSink {
403    /// Create from the output folder and run.log.
404    pub fn new(out: PathBuf, log: RunLog) -> Self {
405        Self {
406            out,
407            log,
408            t0: None,
409            ranges: BTreeMap::new(),
410            times: BTreeMap::new(),
411            preview: BTreeMap::new(),
412            refined: BTreeMap::new(),
413            preview_models: BTreeMap::new(),
414            refined_models: BTreeMap::new(),
415            positions: HashMap::new(),
416            live: post::LiveDisplay::default(),
417            live_count: 0,
418            diff: None,
419            last_display: None,
420            beat: None,
421            files: true,
422        }
423    }
424
425    /// Writes the display PLYs and the post-processing outputs (default) or, off, only the live diff stream.
426    pub fn with_files(mut self, on: bool) -> Self {
427        self.files = on;
428        self
429    }
430
431    /// Updates the heartbeat state through `f` and rewrites `status.json`; a failure is only logged.
432    fn beat(&mut self, at: SystemTime, f: impl FnOnce(&mut Heartbeat)) {
433        let Some(b) = self.beat.as_mut() else { return };
434        f(b);
435        let t = epoch_secs(at) - epoch_secs(self.t0.unwrap_or(at));
436        if let Err(e) = b.write((t * 10.0).round() / 10.0) {
437            self.log.line(&format!("[live] status.json 실패: {e}"));
438        }
439    }
440
441    /// Sets the live display thinning stride (1 = keep all).
442    pub fn with_live_stride(mut self, stride: usize) -> Self {
443        self.live = post::LiveDisplay::new(post::LiveOptions { stride, ..post::LiveOptions::default() });
444        self
445    }
446
447    /// Turns the live diff stream (`live/stream/`) on.
448    pub fn with_live_stream(mut self, cfg: LiveStream) -> Self {
449        let dir = self.out.join("live").join("stream");
450        self.beat = Some(Heartbeat { dir: dir.clone(), stage: "registration", ..Default::default() });
451        self.diff = Some(DiffStream::new(dir, cfg));
452        self
453    }
454
455    fn drop_zone(&mut self, k: usize) {
456        self.ranges.remove(&k);
457        self.times.remove(&k);
458        self.preview.remove(&k);
459        self.refined.remove(&k);
460        self.preview_models.remove(&k);
461        self.refined_models.remove(&k);
462        self.live.forget(k);
463    }
464
465    /// Writes the display cloud for the refined zones received so far (`live/display_NN_TTTT.Ts_refined_K.ply`, and
466    /// `live/latest.ply` replaced atomically), every zone placed in the newest refined model's frame.
467    fn write_live(&mut self, k: usize, at: SystemTime, kind: &str) {
468        if !self.files && self.diff.is_none() {
469            return;
470        }
471        let t = Instant::now();
472        let clouds: BTreeMap<usize, &PointCloud> = self.refined.iter().map(|(j, c)| (*j, &**c)).collect();
473        let models: BTreeMap<usize, &Reconstruction> = self.refined_models.iter().map(|(j, m)| (*j, &**m)).collect();
474        // Stable display (no preview ever received; zone 0's preview always precedes zone 1's refined result): all zone
475        // clouds already share one frame, so they are not relocated.
476        self.live.set_relocate(!self.preview_models.is_empty());
477        let (cloud, zones) = self.live.compose_zoned(&clouds, &models);
478        let dir = self.out.join("live");
479        let rel = epoch_secs(at) - epoch_secs(self.t0.unwrap_or(at));
480        self.live_count += 1;
481        if !self.files {
482            self.write_stream(k, rel, kind, cloud, zones);
483            return;
484        }
485        let name = format!("display_{:02}_{rel:06.1}s_refined_{k}.ply", self.live_count);
486        let tmp = dir.join(".latest.ply.tmp");
487        let r = std::fs::create_dir_all(&dir)
488            .map_err(|e| e.to_string())
489            .and_then(|_| write_ply(dir.join(&name), &cloud, PlyLayout::XyzRgbNormal).map_err(|e| e.to_string()))
490            .and_then(|_| write_ply(&tmp, &cloud, PlyLayout::XyzRgbNormal).map_err(|e| e.to_string()))
491            .and_then(|_| std::fs::rename(&tmp, dir.join("latest.ply")).map_err(|e| e.to_string()));
492        self.log.add_time("live", t.elapsed());
493        match r {
494            Ok(()) => {
495                self.log.line(&format!("[live] {name}: 구역 {} 개, 점 {}, {:.2}s", clouds.len(), cloud.len(), t.elapsed().as_secs_f64()))
496            }
497            Err(e) => self.log.line(&format!("[live] {name} 실패: {e}")),
498        }
499        if self.diff.is_some() {
500            self.write_stream(k, rel, kind, cloud, zones);
501        }
502    }
503
504    /// One event of the live diff stream (`kind`: refined | replace | final); logs the time and the change counts.
505    fn write_stream(&mut self, zone: usize, t_rel: f64, kind: &str, cloud: PointCloud, zones: Vec<usize>) {
506        let t = Instant::now();
507        let Some(bs) = self.diff.as_mut() else { return };
508        let r = bs.write_event(t_rel, zone, kind, &cloud, &zones);
509        self.log.add_time("live_stream", t.elapsed());
510        match r {
511            Ok(st) => self.log.line(&format!(
512                "[live] stream seq {}: 추가 {} 이동 {} 제거 {}, 점 {}, {:.2} MB, {:.2}s",
513                bs.events_written(),
514                st.n_add,
515                st.n_move,
516                st.n_remove,
517                st.points,
518                st.bytes as f64 / 1e6,
519                t.elapsed().as_secs_f64()
520            )),
521            Err(e) => self.log.line(&format!("[live] stream {kind} 구역 {zone} 실패: {e}")),
522        }
523        self.last_display = Some((cloud, zones));
524    }
525
526    /// Post-process with the results collected so far. When finished, copy `timeline.txt` to `snapshots/`.
527    /// `final_model` = `AllRefinedDone::chain` (in stable display mode the GPS-aligned final model).
528    pub fn finish_with(&mut self, final_model: Option<&Reconstruction>) {
529        if !self.files {
530            return;
531        }
532        if self.preview_models.is_empty() && !self.refined.is_empty() {
533            let t0 = epoch_secs(self.t0.unwrap_or(UNIX_EPOCH));
534            let rel = |t: Option<SystemTime>| t.map(|t| epoch_secs(t) - t0);
535            let times: BTreeMap<usize, ZoneTimes> = self
536                .times
537                .iter()
538                .map(|(k, s)| {
539                    (
540                        *k,
541                        ZoneTimes {
542                            arrived: rel(s.arrived),
543                            preview_ready: None,
544                            refined_pose: rel(s.pose),
545                            refined_ready: rel(s.refined),
546                        },
547                    )
548                })
549                .collect();
550            let files: BTreeMap<usize, String> =
551                self.refined.keys().filter_map(|k| self.ranges.get(k).map(|r| (*k, zone_file("refined", r)))).collect();
552            let rf = self.refined.iter().filter_map(|(k, c)| files.get(k).map(|f| (*k, ZoneCloud { file: f.clone(), cloud: c }))).collect();
553            let display = self.refined_models.values().next_back().map(|m| &**m);
554            let t = Instant::now();
555            let models: BTreeMap<usize, &Reconstruction> = self.refined_models.iter().map(|(k, m)| (*k, &**m)).collect();
556            let r = post::run_stable(&self.out, &rf, &models, &times, display, final_model, self.log.logger());
557            self.log.add_time("post", t.elapsed());
558            if let Err(e) = r {
559                self.log.line(&format!("[post] 실패: {e}"));
560            }
561            let _ = std::fs::copy(self.out.join("timeline.txt"), self.out.join("snapshots").join("timeline.txt"));
562            return;
563        }
564        self.finish();
565    }
566
567    /// Post-process with the results collected so far (preview/refined alignment, per-event snapshots, re-anchoring). When finished, copy
568    /// `timeline.txt` to `snapshots/`.
569    pub fn finish(&mut self) {
570        if !self.files {
571            return;
572        }
573        let t0 = epoch_secs(self.t0.unwrap_or(UNIX_EPOCH));
574        let rel = |t: Option<SystemTime>| t.map(|t| epoch_secs(t) - t0);
575        let times: BTreeMap<usize, ZoneTimes> = self
576            .times
577            .iter()
578            .map(|(k, s)| {
579                (
580                    *k,
581                    ZoneTimes {
582                        arrived: rel(s.arrived),
583                        preview_ready: rel(s.preview),
584                        refined_pose: rel(s.pose),
585                        refined_ready: rel(s.refined),
586                    },
587                )
588            })
589            .collect();
590        let windows = self
591            .ranges
592            .iter()
593            .map(|(k, r)| {
594                let hi = k.checked_sub(1).and_then(|j| self.ranges.get(&j)).map_or(r.hi, |p| p.hi);
595                (*k, (r.lo, hi))
596            })
597            .collect();
598        let zc = |kind: &str, m: &BTreeMap<usize, Arc<PointCloud>>| -> BTreeMap<usize, (String, Arc<PointCloud>)> {
599            m.iter().filter_map(|(k, c)| self.ranges.get(k).map(|r| (*k, (zone_file(kind, r), Arc::clone(c))))).collect()
600        };
601        let (pv, rf) = (zc("preview", &self.preview), zc("refined", &self.refined));
602        let positions = &self.positions;
603        let pos = move |n: &str| positions.get(n).copied();
604        let z = PostZones {
605            out: &self.out,
606            preview: pv.iter().map(|(k, (f, c))| (*k, ZoneCloud { file: f.clone(), cloud: c })).collect(),
607            refined: rf.iter().map(|(k, (f, c))| (*k, ZoneCloud { file: f.clone(), cloud: c })).collect(),
608            preview_models: self.preview_models.iter().map(|(k, m)| (*k, &**m)).collect(),
609            refined_models: self.refined_models.iter().map(|(k, m)| (*k, &**m)).collect(),
610            times,
611            windows,
612            position: &pos,
613        };
614        let t = Instant::now();
615        let r = post::run_zones(&z, self.log.logger());
616        self.log.add_time("post", t.elapsed());
617        if let Err(e) = r {
618            self.log.line(&format!("[post] 실패: {e}"));
619        }
620        let _ = std::fs::copy(self.out.join("timeline.txt"), self.out.join("snapshots").join("timeline.txt"));
621    }
622}
623
624impl Sink for SnapshotSink {
625    fn kinds(&self) -> Vec<EventKind> {
626        vec![
627            EventKind::Started,
628            EventKind::FrameIngested,
629            EventKind::FeaturesExtracted,
630            EventKind::ZoneArrived,
631            EventKind::ZonePreview,
632            EventKind::PositionDone,
633            EventKind::RefineStarted,
634            EventKind::ZoneRefinedPose,
635            EventKind::ZoneRefined,
636            EventKind::Reset,
637            EventKind::ZoneInvalidated,
638            EventKind::AllRefinedDone,
639        ]
640    }
641
642    fn handle(&mut self, e: &Event) {
643        let at = e.meta().at;
644        if matches!(e, Event::Started { .. }) || self.t0.is_none() {
645            self.t0 = Some(at);
646        }
647        match e {
648            Event::Started { positions, .. } => self.beat(at, |b| b.positions_total = *positions),
649            Event::PositionDone { position, registered, .. } => {
650                let (p, r) = (*position + 1, *registered);
651                self.beat(at, |b| {
652                    b.stage = "triangulation";
653                    b.positions = p;
654                    b.images = r;
655                });
656            }
657            Event::RefineStarted { zone, .. } => self.beat(at, |b| {
658                b.stage = "ba";
659                b.zone = *zone;
660            }),
661            Event::FrameIngested { position, images, .. } => {
662                for n in images {
663                    self.positions.insert(n.clone(), *position);
664                }
665            }
666            Event::FeaturesExtracted { image, position, .. } => {
667                self.positions.insert(image.clone(), *position);
668            }
669            Event::ZoneArrived { range, .. } => {
670                self.ranges.insert(range.zone, *range);
671                self.times.entry(range.zone).or_default().arrived.get_or_insert(at);
672            }
673            Event::ZonePreview { range, cloud, model, dense, .. } => {
674                self.ranges.entry(range.zone).or_insert(*range);
675                self.times.entry(range.zone).or_default().preview.get_or_insert(at);
676                self.preview_models.insert(range.zone, Arc::clone(model));
677                if *dense {
678                    self.preview.insert(range.zone, Arc::clone(cloud));
679                }
680            }
681            Event::ZoneRefinedPose { range, model, .. } => {
682                self.beat(at, |b| {
683                    b.stage = "dense";
684                    b.zone = range.zone;
685                });
686                self.ranges.entry(range.zone).or_insert(*range);
687                self.times.entry(range.zone).or_default().pose.get_or_insert(at);
688                self.refined_models.insert(range.zone, Arc::clone(model));
689                self.live.forget(range.zone);
690            }
691            Event::ZoneRefined { range, cloud, dense, .. } => {
692                self.ranges.entry(range.zone).or_insert(*range);
693                self.times.entry(range.zone).or_default().refined.get_or_insert(at);
694                if *dense {
695                    // A zone that already has a cloud is a shown zone being re-densified or moved (`replace_shown`).
696                    let kind = if self.refined.contains_key(&range.zone) { "replace" } else { "refined" };
697                    self.refined.insert(range.zone, Arc::clone(cloud));
698                    self.write_live(range.zone, at, kind);
699                }
700            }
701            Event::Reset { position, .. } => {
702                let ks: Vec<usize> = self.ranges.values().filter(|r| r.hi > *position).map(|r| r.zone).collect();
703                for k in ks {
704                    self.drop_zone(k);
705                }
706            }
707            Event::ZoneInvalidated { zone, .. } => self.drop_zone(*zone),
708            Event::AllRefinedDone { chain, .. } => {
709                self.beat(at, |b| b.stage = "done");
710                // Final stream event: the display does not change (the ENU alignment of the final outputs is separate).
711                if let Some((cloud, zones)) = self.last_display.take() {
712                    let rel = epoch_secs(at) - epoch_secs(self.t0.unwrap_or(at));
713                    let zone = self.refined.keys().next_back().copied().unwrap_or(0);
714                    self.write_stream(zone, rel, "final", cloud, zones);
715                }
716                self.finish_with(chain.as_deref())
717            }
718            _ => {}
719        }
720    }
721}
722
723// ---------------------------------------------------------------------------------------------
724
725/// Default hook settings.
726#[derive(Clone, Debug, Default)]
727pub struct SinkOptions {
728    /// Also write the run.log contents to standard output.
729    pub echo: bool,
730    /// Save per-zone sparse models (`work/models/`).
731    pub save_models: bool,
732}
733
734/// Which default hooks to enable ([`DefaultSinks::with_set`]).
735#[derive(Clone, Copy, Debug, PartialEq)]
736pub struct SinkSet {
737    /// `timeline.txt`([`TimelineSink`]).
738    pub timeline: bool,
739    /// `run.log`·`DONE`([`RunLogSink`]).
740    pub run_log: bool,
741    /// Zone PLY `full/{preview,refined}/` ([`ZonePlySink`]).
742    pub zone_ply: bool,
743    /// Per-zone sparse models `work/models/` ([`ModelSink`]).
744    pub models: bool,
745    /// Alignment, snapshots, manifest, final_frame ([`SnapshotSink`]).
746    pub snapshots: bool,
747    /// Live diff stream `live/stream/` of the live display (needs `snapshots`; stable display only).
748    pub live_stream: Option<LiveStream>,
749    /// Live display thinning stride (1 = keep all).
750    pub live_stride: usize,
751    /// Display PLYs and post-processing outputs of [`SnapshotSink`] (off: only the live diff stream).
752    pub live_files: bool,
753}
754
755impl SinkSet {
756    /// Enable all.
757    pub fn all() -> Self {
758        Self {
759            timeline: true,
760            run_log: true,
761            zone_ply: true,
762            models: true,
763            snapshots: true,
764            live_stream: None,
765            live_stride: post::LiveOptions::default().stride,
766            live_files: true,
767        }
768    }
769}
770
771/// The default hook bundle of one output folder (handled in the order timeline -> run.log -> full PLY -> models -> snapshots).
772pub struct DefaultSinks {
773    sinks: Vec<Box<dyn Sink>>,
774    log: RunLog,
775}
776
777impl DefaultSinks {
778    /// Create `full/preview`, `full/refined` and `work` under `out`, and open `timeline.txt` and `run.log` anew.
779    /// It does not delete the folder.
780    pub fn new(out: &Path, opts: &SinkOptions) -> std::io::Result<Self> {
781        Self::with_set(out, opts.echo, SinkSet { models: opts.save_models, ..SinkSet::all() })
782    }
783
784    /// Enable only the chosen hooks. `work` is always created; `full/preview` and `full/refined` only when zone PLY is enabled.
785    /// 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
786    /// folder.
787    pub fn with_set(out: &Path, echo: bool, set: SinkSet) -> std::io::Result<Self> {
788        let dirs: &[&str] = if set.zone_ply { &["full/preview", "full/refined", "work"] } else { &["work"] };
789        for d in dirs {
790            std::fs::create_dir_all(out.join(d))?;
791        }
792        let run_log = out.join("run.log");
793        let log = RunLog::open(set.run_log.then_some(run_log.as_path()), echo)?;
794        let mut sinks: Vec<Box<dyn Sink>> = Vec::new();
795        if set.timeline {
796            sinks.push(Box::new(TimelineSink::create(&out.join("timeline.txt"))?));
797        }
798        sinks.push(Box::new(RunLogSink::new(log.clone(), set.run_log.then(|| out.join("DONE")))));
799        if set.zone_ply {
800            sinks.push(Box::new(ZonePlySink::new(out.join("full"), Some(log.clone()))));
801        }
802        if set.models {
803            sinks.push(Box::new(ModelSink::new(out.join("work").join("models"), Some(log.clone()))));
804        }
805        if set.snapshots {
806            let mut snap = SnapshotSink::new(out.to_path_buf(), log.clone()).with_live_stride(set.live_stride).with_files(set.live_files);
807            if let Some(cfg) = set.live_stream {
808                snap = snap.with_live_stream(cfg);
809            }
810            sinks.push(Box::new(snap));
811        }
812        Ok(Self { sinks, log })
813    }
814
815    /// The run.log handle (when other hooks write to the same file).
816    pub fn run_log(&self) -> RunLog {
817        self.log.clone()
818    }
819
820    /// An all-event hook (`Pipeline::on_any`) that keeps the event order.
821    pub fn into_hook(self) -> Hook {
822        any_hook(self)
823    }
824}
825
826impl Sink for DefaultSinks {
827    fn kinds(&self) -> Vec<EventKind> {
828        let mut v: Vec<EventKind> = Vec::new();
829        for s in &self.sinks {
830            for k in s.kinds() {
831                if !v.contains(&k) {
832                    v.push(k);
833                }
834            }
835        }
836        v
837    }
838    fn handle(&mut self, e: &Event) {
839        let k = e.kind();
840        for s in &mut self.sinks {
841            if s.kinds().contains(&k) {
842                s.handle(e);
843            }
844        }
845    }
846}
847
848/// The default hooks as a per-kind list (sharing state). For a synchronous pipeline (`sync(true)`) or direct calls.
849pub fn default_sinks(out: &Path, opts: &SinkOptions) -> std::io::Result<Vec<(EventKind, Hook)>> {
850    Ok(hooks(DefaultSinks::new(out, opts)?))
851}
852
853/// Attach the default hooks to a pipeline (a single `on_any`, so the order is preserved even in asynchronous mode).
854pub fn attach<R: Reducer>(p: Pipeline<R>, out: &Path, opts: &SinkOptions) -> std::io::Result<Pipeline<R>> {
855    Ok(p.on_any(DefaultSinks::new(out, opts)?.into_hook()))
856}
857
858#[cfg(test)]
859mod tests {
860    use super::*;
861    use crate::events::Meta;
862
863    fn meta(s: u64, n: u32) -> Meta {
864        Meta { version: 0, at: UNIX_EPOCH + Duration::new(s, n) }
865    }
866    fn r(zone: usize, lo: usize, hi: usize) -> ZoneRange {
867        ZoneRange { zone, lo, hi }
868    }
869
870    #[test]
871    fn heartbeat_status_json_follows_events() {
872        let dir = std::env::temp_dir().join(format!("c3d_status_{}", std::process::id()));
873        let _ = std::fs::remove_dir_all(&dir);
874        let log = RunLog::open(None, false).unwrap();
875        let cfg = LiveStream { enu: false, origin_gps: None };
876        let mut s = SnapshotSink::new(dir.clone(), log).with_live_stream(cfg);
877        let read = || crate::util::Json::parse(&std::fs::read_to_string(dir.join("live/stream/status.json")).unwrap()).unwrap();
878        s.handle(&Event::Started { meta: meta(100, 0), positions: Some(26) });
879        s.handle(&Event::PositionDone { meta: meta(102, 500_000_000), position: 14, registered: 44, expected: 45 });
880        let j = read();
881        assert_eq!(
882            j.dump(),
883            crate::util::Json::obj(vec![
884                ("t", crate::util::Json::Num("2.5".into())),
885                ("stage", crate::util::Json::Str("triangulation".into())),
886                ("zone", crate::util::Json::Int(0)),
887                ("positions", crate::util::Json::Int(15)),
888                ("positions_total", crate::util::Json::Int(26)),
889                ("images", crate::util::Json::Int(44)),
890            ])
891            .dump()
892        );
893        s.handle(&Event::RefineStarted { meta: meta(103, 0), zone: 1 });
894        let j = read();
895        assert_eq!(j.get("stage").map(|v| v.dump()), Some("\"ba\"".into()));
896        assert_eq!(j.get("zone").map(|v| v.dump()), Some("1".into()));
897        assert!(!dir.join("live/stream/status.json.tmp").exists());
898        let _ = std::fs::remove_dir_all(&dir);
899    }
900
901    #[test]
902    fn timeline_table() {
903        // Nanoseconds are a multiple of 100: Windows SystemTime has 100 ns resolution.
904        let m = || meta(1_700_000_000, 500);
905        let model = Arc::new(Reconstruction::new());
906        let cloud = Arc::new(PointCloud::default());
907        let cases: Vec<(Event, &str)> = vec![
908            (Event::Started { meta: m(), positions: Some(26) }, "start NPOS=26"),
909            (Event::ModelInitialized { meta: m(), registered: 42, position: 13, model: model.clone() }, "init_model Registered images: 42"),
910            (Event::BaseAdopted { meta: m(), zone: 0 }, "adopt refined 0 as base"),
911            (Event::PositionDone { meta: m(), position: 14, registered: 44, expected: 45 }, "pos 14 registered 44/45"),
912            (Event::ZoneArrived { meta: m(), range: r(1, 10, 26), model: model.clone() }, "region 1 arrived pos[10,26)"),
913            (
914                Event::ZonePreview {
915                    meta: m(),
916                    range: r(1, 10, 26),
917                    cloud: cloud.clone(),
918                    model: model.clone(),
919                    dense: true,
920                    frame: "gps".into(),
921                },
922                "preview 1 ready",
923            ),
924            (Event::RefineStarted { meta: m(), zone: 1 }, "refined 1 ba_start"),
925            (
926                Event::ZoneRefinedPose { meta: m(), range: r(1, 10, 26), registered: 48, mean_reproj_px: 0.25095, model: model.clone() },
927                "refined 1 pose_ready Registered images: 48 Mean reprojection error: 0.250950px ",
928            ),
929            (Event::ZoneRefined { meta: m(), range: r(1, 10, 26), cloud, model, dense: true }, "refined 1 ready"),
930            (Event::AllPositionsDone { meta: m() }, "all positions done"),
931            (Event::AllRefinedDone { meta: m(), chain: None }, "all refined done"),
932        ];
933        assert_eq!(cases.len(), TIMELINE_TABLE.len());
934        for ((e, want), (k, _)) in cases.iter().zip(TIMELINE_TABLE.iter()) {
935            assert_eq!(e.kind(), *k);
936            assert_eq!(TimelineSink::line(e).unwrap(), format!("1700000000.000000500 {want}"));
937        }
938        assert!(TimelineSink::line(&Event::Warning { meta: m(), message: "x".into() }).is_none());
939    }
940}