use crate::blocks::{DiffStream, Heartbeat, LiveStream};
use crate::events::{Event, EventKind, ZoneRange};
use crate::pipeline::{Pipeline, Reducer};
use crate::post::{self, PostZones, ZoneCloud, ZoneTimes};
use crate::util::Logger;
use cumulus3d_core::interop::{write_model_binary, ImageOrder};
use cumulus3d_core::io::{write_ply, PlyLayout, PointCloud};
use cumulus3d_core::Reconstruction;
use std::collections::{BTreeMap, HashMap};
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
pub type Hook = Box<dyn FnMut(&Event) + Send>;
pub const TIMELINE_TABLE: [(EventKind, &str); 11] = [
(EventKind::Started, "start NPOS={positions}"),
(EventKind::ModelInitialized, "init_model Registered images: {registered}"),
(EventKind::BaseAdopted, "adopt refined {zone} as base"),
(EventKind::PositionDone, "pos {position} registered {registered}/{expected}"),
(EventKind::ZoneArrived, "region {zone} arrived pos[{lo},{hi})"),
(EventKind::ZonePreview, "preview {zone} ready"),
(EventKind::RefineStarted, "refined {zone} ba_start"),
(
EventKind::ZoneRefinedPose,
"refined {zone} pose_ready Registered images: {registered} Mean reprojection error: {mean_reproj_px:.6}px ",
),
(EventKind::ZoneRefined, "refined {zone} ready"),
(EventKind::AllPositionsDone, "all positions done"),
(EventKind::AllRefinedDone, "all refined done"),
];
pub trait Sink: Send {
fn kinds(&self) -> Vec<EventKind>;
fn handle(&mut self, e: &Event);
}
pub fn hooks<S: Sink + 'static>(sink: S) -> Vec<(EventKind, Hook)> {
let kinds = sink.kinds();
let shared = Arc::new(Mutex::new(sink));
kinds
.into_iter()
.map(|k| {
let s = Arc::clone(&shared);
let h: Hook = Box::new(move |e: &Event| s.lock().unwrap_or_else(|p| p.into_inner()).handle(e));
(k, h)
})
.collect()
}
pub fn any_hook<S: Sink + 'static>(mut sink: S) -> Hook {
let kinds = sink.kinds();
Box::new(move |e: &Event| {
if kinds.contains(&e.kind()) {
sink.handle(e)
}
})
}
fn epoch_secs(t: SystemTime) -> f64 {
t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs_f64()
}
fn zone_file(kind: &str, r: &ZoneRange) -> String {
format!("{kind}_{:02}_pos{}-{}.ply", r.zone, r.lo, r.hi)
}
#[derive(Clone)]
pub struct RunLog {
inner: Arc<RunLogInner>,
}
struct RunLogInner {
logger: Logger,
extra_times: Mutex<Vec<(String, Duration)>>,
}
impl RunLog {
pub fn open(path: Option<&Path>, echo: bool) -> std::io::Result<Self> {
Ok(Self { inner: Arc::new(RunLogInner { logger: Logger::new(path, echo)?, extra_times: Mutex::new(Vec::new()) }) })
}
pub fn logger(&self) -> &Logger {
&self.inner.logger
}
pub fn line(&self, s: &str) {
self.inner.logger.line(s)
}
pub fn stamped_at(&self, at: SystemTime, s: &str) {
self.inner.logger.stamped_at(at, s)
}
pub fn add_time(&self, stage: &str, d: Duration) {
if let Ok(mut v) = self.inner.extra_times.lock() {
v.push((stage.to_string(), d));
}
}
fn take_times(&self) -> Vec<(String, Duration)> {
self.inner.extra_times.lock().map(|mut v| std::mem::take(&mut *v)).unwrap_or_default()
}
}
pub struct TimelineSink {
file: File,
}
impl TimelineSink {
pub fn create(path: &Path) -> std::io::Result<Self> {
Ok(Self { file: File::create(path)? })
}
pub fn line(e: &Event) -> Option<String> {
let s = e.timeline_text()?;
let t = e.meta().at.duration_since(UNIX_EPOCH).unwrap_or_default();
Some(format!("{}.{:09} {s}", t.as_secs(), t.subsec_nanos()))
}
}
impl Sink for TimelineSink {
fn kinds(&self) -> Vec<EventKind> {
TIMELINE_TABLE.iter().map(|(k, _)| *k).collect()
}
fn handle(&mut self, e: &Event) {
if let Some(l) = Self::line(e) {
let _ = writeln!(self.file, "{l}");
let _ = self.file.flush();
}
}
}
pub struct RunLogSink {
log: RunLog,
done: Option<PathBuf>,
}
impl RunLogSink {
pub fn new(log: RunLog, done: Option<PathBuf>) -> Self {
Self { log, done }
}
}
impl Sink for RunLogSink {
fn kinds(&self) -> Vec<EventKind> {
let mut v: Vec<EventKind> = TIMELINE_TABLE.iter().map(|(k, _)| *k).collect();
v.extend([EventKind::Log, EventKind::Finished]);
v
}
fn handle(&mut self, e: &Event) {
if let Some(s) = e.timeline_text() {
self.log.stamped_at(e.meta().at, &s);
return;
}
match e {
Event::Log { meta, line, stamped } => {
if *stamped {
self.log.stamped_at(meta.at, line)
} else {
self.log.line(line)
}
}
Event::Finished { pipeline_secs, stage_times, chain_stats, .. } => {
let mut m: BTreeMap<String, (Duration, usize)> = BTreeMap::new();
for (s, d, n) in stage_times {
let x = m.entry(s.clone()).or_default();
x.0 += *d;
x.1 += n;
}
for (s, d) in self.log.take_times() {
let x = m.entry(s).or_default();
x.0 += d;
x.1 += 1;
}
self.log.line(&format!("== 단계별 시간 (파이프라인 {pipeline_secs:.1}s, 배경 스레드와 겹친 누적)"));
for (stage, (d, n)) in m {
self.log.line(&format!("{stage:<14} {:>9.2}s ({n}회)", d.as_secs_f64()));
}
if !chain_stats.is_empty() {
self.log.line("== 최종 체인 모델");
for l in chain_stats {
self.log.line(l);
}
}
if let Some(p) = &self.done {
if let Err(err) = std::fs::write(p, b"") {
self.log.line(&format!("{}: {err}", p.display()));
}
}
}
_ => {}
}
}
}
pub struct ZonePlySink {
full: PathBuf,
log: Option<RunLog>,
}
impl ZonePlySink {
pub fn new(full: PathBuf, log: Option<RunLog>) -> Self {
Self { full, log }
}
fn write(&self, kind: &str, r: &ZoneRange, c: &PointCloud) {
let path = self.full.join(kind).join(zone_file(kind, r));
let res = path
.parent()
.map_or(Ok(()), std::fs::create_dir_all)
.map_err(|e| e.to_string())
.and_then(|_| write_ply(&path, c, PlyLayout::XyzNormalRgb).map_err(|e| e.to_string()));
if let (Err(e), Some(l)) = (res, &self.log) {
l.line(&format!("[dense] {kind} {} PLY 쓰기 실패: {e}", r.zone));
}
}
}
impl Sink for ZonePlySink {
fn kinds(&self) -> Vec<EventKind> {
vec![EventKind::ZonePreview, EventKind::ZoneRefined]
}
fn handle(&mut self, e: &Event) {
match e {
Event::ZonePreview { range, cloud, dense: true, .. } => self.write("preview", range, cloud),
Event::ZoneRefined { range, cloud, dense: true, .. } => self.write("refined", range, cloud),
_ => {}
}
}
}
pub struct ModelSink {
dir: PathBuf,
log: Option<RunLog>,
}
impl ModelSink {
pub fn new(dir: PathBuf, log: Option<RunLog>) -> Self {
Self { dir, log }
}
fn save(&self, rec: &Reconstruction, name: &str) {
let d = self.dir.join(name);
let _ = std::fs::create_dir_all(&d);
if let (Err(e), Some(l)) = (write_model_binary(rec, &d, ImageOrder::Registration), &self.log) {
l.line(&format!("모델 저장 실패 {}: {e}", d.display()));
}
}
}
impl Sink for ModelSink {
fn kinds(&self) -> Vec<EventKind> {
vec![EventKind::ZoneArrived, EventKind::ZonePreview, EventKind::ZoneAdjusted, EventKind::ZoneRefinedPose, EventKind::AllRefinedDone]
}
fn handle(&mut self, e: &Event) {
match e {
Event::ZoneArrived { range, model, .. } => self.save(model, &format!("snap_{}", range.zone)),
Event::ZonePreview { range, model, .. } => self.save(model, &format!("preview_{}", range.zone)),
Event::ZoneAdjusted { zone, model, .. } => self.save(model, &format!("ba_{zone}")),
Event::ZoneRefinedPose { range, model, .. } => self.save(model, &format!("refined_{}", range.zone)),
Event::AllRefinedDone { chain: Some(c), .. } => self.save(c, "chain"),
_ => {}
}
}
}
pub struct SnapshotSink {
out: PathBuf,
log: RunLog,
t0: Option<SystemTime>,
ranges: BTreeMap<usize, ZoneRange>,
times: BTreeMap<usize, Stamps>,
preview: BTreeMap<usize, Arc<PointCloud>>,
refined: BTreeMap<usize, Arc<PointCloud>>,
preview_models: BTreeMap<usize, Arc<Reconstruction>>,
refined_models: BTreeMap<usize, Arc<Reconstruction>>,
positions: HashMap<String, usize>,
live: post::LiveDisplay,
live_count: usize,
diff: Option<DiffStream>,
last_display: Option<(PointCloud, Vec<usize>)>,
beat: Option<Heartbeat>,
files: bool,
}
#[derive(Clone, Copy, Default)]
struct Stamps {
arrived: Option<SystemTime>,
preview: Option<SystemTime>,
pose: Option<SystemTime>,
refined: Option<SystemTime>,
}
impl SnapshotSink {
pub fn new(out: PathBuf, log: RunLog) -> Self {
Self {
out,
log,
t0: None,
ranges: BTreeMap::new(),
times: BTreeMap::new(),
preview: BTreeMap::new(),
refined: BTreeMap::new(),
preview_models: BTreeMap::new(),
refined_models: BTreeMap::new(),
positions: HashMap::new(),
live: post::LiveDisplay::default(),
live_count: 0,
diff: None,
last_display: None,
beat: None,
files: true,
}
}
pub fn with_files(mut self, on: bool) -> Self {
self.files = on;
self
}
fn beat(&mut self, at: SystemTime, f: impl FnOnce(&mut Heartbeat)) {
let Some(b) = self.beat.as_mut() else { return };
f(b);
let t = epoch_secs(at) - epoch_secs(self.t0.unwrap_or(at));
if let Err(e) = b.write((t * 10.0).round() / 10.0) {
self.log.line(&format!("[live] status.json 실패: {e}"));
}
}
pub fn with_live_stride(mut self, stride: usize) -> Self {
self.live = post::LiveDisplay::new(post::LiveOptions { stride, ..post::LiveOptions::default() });
self
}
pub fn with_live_stream(mut self, cfg: LiveStream) -> Self {
let dir = self.out.join("live").join("stream");
self.beat = Some(Heartbeat { dir: dir.clone(), stage: "registration", ..Default::default() });
self.diff = Some(DiffStream::new(dir, cfg));
self
}
fn drop_zone(&mut self, k: usize) {
self.ranges.remove(&k);
self.times.remove(&k);
self.preview.remove(&k);
self.refined.remove(&k);
self.preview_models.remove(&k);
self.refined_models.remove(&k);
self.live.forget(k);
}
fn write_live(&mut self, k: usize, at: SystemTime, kind: &str) {
if !self.files && self.diff.is_none() {
return;
}
let t = Instant::now();
let clouds: BTreeMap<usize, &PointCloud> = self.refined.iter().map(|(j, c)| (*j, &**c)).collect();
let models: BTreeMap<usize, &Reconstruction> = self.refined_models.iter().map(|(j, m)| (*j, &**m)).collect();
self.live.set_relocate(!self.preview_models.is_empty());
let (cloud, zones) = self.live.compose_zoned(&clouds, &models);
let dir = self.out.join("live");
let rel = epoch_secs(at) - epoch_secs(self.t0.unwrap_or(at));
self.live_count += 1;
if !self.files {
self.write_stream(k, rel, kind, cloud, zones);
return;
}
let name = format!("display_{:02}_{rel:06.1}s_refined_{k}.ply", self.live_count);
let tmp = dir.join(".latest.ply.tmp");
let r = std::fs::create_dir_all(&dir)
.map_err(|e| e.to_string())
.and_then(|_| write_ply(dir.join(&name), &cloud, PlyLayout::XyzRgbNormal).map_err(|e| e.to_string()))
.and_then(|_| write_ply(&tmp, &cloud, PlyLayout::XyzRgbNormal).map_err(|e| e.to_string()))
.and_then(|_| std::fs::rename(&tmp, dir.join("latest.ply")).map_err(|e| e.to_string()));
self.log.add_time("live", t.elapsed());
match r {
Ok(()) => {
self.log.line(&format!("[live] {name}: 구역 {} 개, 점 {}, {:.2}s", clouds.len(), cloud.len(), t.elapsed().as_secs_f64()))
}
Err(e) => self.log.line(&format!("[live] {name} 실패: {e}")),
}
if self.diff.is_some() {
self.write_stream(k, rel, kind, cloud, zones);
}
}
fn write_stream(&mut self, zone: usize, t_rel: f64, kind: &str, cloud: PointCloud, zones: Vec<usize>) {
let t = Instant::now();
let Some(bs) = self.diff.as_mut() else { return };
let r = bs.write_event(t_rel, zone, kind, &cloud, &zones);
self.log.add_time("live_stream", t.elapsed());
match r {
Ok(st) => self.log.line(&format!(
"[live] stream seq {}: 추가 {} 이동 {} 제거 {}, 점 {}, {:.2} MB, {:.2}s",
bs.events_written(),
st.n_add,
st.n_move,
st.n_remove,
st.points,
st.bytes as f64 / 1e6,
t.elapsed().as_secs_f64()
)),
Err(e) => self.log.line(&format!("[live] stream {kind} 구역 {zone} 실패: {e}")),
}
self.last_display = Some((cloud, zones));
}
pub fn finish_with(&mut self, final_model: Option<&Reconstruction>) {
if !self.files {
return;
}
if self.preview_models.is_empty() && !self.refined.is_empty() {
let t0 = epoch_secs(self.t0.unwrap_or(UNIX_EPOCH));
let rel = |t: Option<SystemTime>| t.map(|t| epoch_secs(t) - t0);
let times: BTreeMap<usize, ZoneTimes> = self
.times
.iter()
.map(|(k, s)| {
(
*k,
ZoneTimes {
arrived: rel(s.arrived),
preview_ready: None,
refined_pose: rel(s.pose),
refined_ready: rel(s.refined),
},
)
})
.collect();
let files: BTreeMap<usize, String> =
self.refined.keys().filter_map(|k| self.ranges.get(k).map(|r| (*k, zone_file("refined", r)))).collect();
let rf = self.refined.iter().filter_map(|(k, c)| files.get(k).map(|f| (*k, ZoneCloud { file: f.clone(), cloud: c }))).collect();
let display = self.refined_models.values().next_back().map(|m| &**m);
let t = Instant::now();
let models: BTreeMap<usize, &Reconstruction> = self.refined_models.iter().map(|(k, m)| (*k, &**m)).collect();
let r = post::run_stable(&self.out, &rf, &models, ×, display, final_model, self.log.logger());
self.log.add_time("post", t.elapsed());
if let Err(e) = r {
self.log.line(&format!("[post] 실패: {e}"));
}
let _ = std::fs::copy(self.out.join("timeline.txt"), self.out.join("snapshots").join("timeline.txt"));
return;
}
self.finish();
}
pub fn finish(&mut self) {
if !self.files {
return;
}
let t0 = epoch_secs(self.t0.unwrap_or(UNIX_EPOCH));
let rel = |t: Option<SystemTime>| t.map(|t| epoch_secs(t) - t0);
let times: BTreeMap<usize, ZoneTimes> = self
.times
.iter()
.map(|(k, s)| {
(
*k,
ZoneTimes {
arrived: rel(s.arrived),
preview_ready: rel(s.preview),
refined_pose: rel(s.pose),
refined_ready: rel(s.refined),
},
)
})
.collect();
let windows = self
.ranges
.iter()
.map(|(k, r)| {
let hi = k.checked_sub(1).and_then(|j| self.ranges.get(&j)).map_or(r.hi, |p| p.hi);
(*k, (r.lo, hi))
})
.collect();
let zc = |kind: &str, m: &BTreeMap<usize, Arc<PointCloud>>| -> BTreeMap<usize, (String, Arc<PointCloud>)> {
m.iter().filter_map(|(k, c)| self.ranges.get(k).map(|r| (*k, (zone_file(kind, r), Arc::clone(c))))).collect()
};
let (pv, rf) = (zc("preview", &self.preview), zc("refined", &self.refined));
let positions = &self.positions;
let pos = move |n: &str| positions.get(n).copied();
let z = PostZones {
out: &self.out,
preview: pv.iter().map(|(k, (f, c))| (*k, ZoneCloud { file: f.clone(), cloud: c })).collect(),
refined: rf.iter().map(|(k, (f, c))| (*k, ZoneCloud { file: f.clone(), cloud: c })).collect(),
preview_models: self.preview_models.iter().map(|(k, m)| (*k, &**m)).collect(),
refined_models: self.refined_models.iter().map(|(k, m)| (*k, &**m)).collect(),
times,
windows,
position: &pos,
};
let t = Instant::now();
let r = post::run_zones(&z, self.log.logger());
self.log.add_time("post", t.elapsed());
if let Err(e) = r {
self.log.line(&format!("[post] 실패: {e}"));
}
let _ = std::fs::copy(self.out.join("timeline.txt"), self.out.join("snapshots").join("timeline.txt"));
}
}
impl Sink for SnapshotSink {
fn kinds(&self) -> Vec<EventKind> {
vec![
EventKind::Started,
EventKind::FrameIngested,
EventKind::FeaturesExtracted,
EventKind::ZoneArrived,
EventKind::ZonePreview,
EventKind::PositionDone,
EventKind::RefineStarted,
EventKind::ZoneRefinedPose,
EventKind::ZoneRefined,
EventKind::Reset,
EventKind::ZoneInvalidated,
EventKind::AllRefinedDone,
]
}
fn handle(&mut self, e: &Event) {
let at = e.meta().at;
if matches!(e, Event::Started { .. }) || self.t0.is_none() {
self.t0 = Some(at);
}
match e {
Event::Started { positions, .. } => self.beat(at, |b| b.positions_total = *positions),
Event::PositionDone { position, registered, .. } => {
let (p, r) = (*position + 1, *registered);
self.beat(at, |b| {
b.stage = "triangulation";
b.positions = p;
b.images = r;
});
}
Event::RefineStarted { zone, .. } => self.beat(at, |b| {
b.stage = "ba";
b.zone = *zone;
}),
Event::FrameIngested { position, images, .. } => {
for n in images {
self.positions.insert(n.clone(), *position);
}
}
Event::FeaturesExtracted { image, position, .. } => {
self.positions.insert(image.clone(), *position);
}
Event::ZoneArrived { range, .. } => {
self.ranges.insert(range.zone, *range);
self.times.entry(range.zone).or_default().arrived.get_or_insert(at);
}
Event::ZonePreview { range, cloud, model, dense, .. } => {
self.ranges.entry(range.zone).or_insert(*range);
self.times.entry(range.zone).or_default().preview.get_or_insert(at);
self.preview_models.insert(range.zone, Arc::clone(model));
if *dense {
self.preview.insert(range.zone, Arc::clone(cloud));
}
}
Event::ZoneRefinedPose { range, model, .. } => {
self.beat(at, |b| {
b.stage = "dense";
b.zone = range.zone;
});
self.ranges.entry(range.zone).or_insert(*range);
self.times.entry(range.zone).or_default().pose.get_or_insert(at);
self.refined_models.insert(range.zone, Arc::clone(model));
self.live.forget(range.zone);
}
Event::ZoneRefined { range, cloud, dense, .. } => {
self.ranges.entry(range.zone).or_insert(*range);
self.times.entry(range.zone).or_default().refined.get_or_insert(at);
if *dense {
let kind = if self.refined.contains_key(&range.zone) { "replace" } else { "refined" };
self.refined.insert(range.zone, Arc::clone(cloud));
self.write_live(range.zone, at, kind);
}
}
Event::Reset { position, .. } => {
let ks: Vec<usize> = self.ranges.values().filter(|r| r.hi > *position).map(|r| r.zone).collect();
for k in ks {
self.drop_zone(k);
}
}
Event::ZoneInvalidated { zone, .. } => self.drop_zone(*zone),
Event::AllRefinedDone { chain, .. } => {
self.beat(at, |b| b.stage = "done");
if let Some((cloud, zones)) = self.last_display.take() {
let rel = epoch_secs(at) - epoch_secs(self.t0.unwrap_or(at));
let zone = self.refined.keys().next_back().copied().unwrap_or(0);
self.write_stream(zone, rel, "final", cloud, zones);
}
self.finish_with(chain.as_deref())
}
_ => {}
}
}
}
#[derive(Clone, Debug, Default)]
pub struct SinkOptions {
pub echo: bool,
pub save_models: bool,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SinkSet {
pub timeline: bool,
pub run_log: bool,
pub zone_ply: bool,
pub models: bool,
pub snapshots: bool,
pub live_stream: Option<LiveStream>,
pub live_stride: usize,
pub live_files: bool,
}
impl SinkSet {
pub fn all() -> Self {
Self {
timeline: true,
run_log: true,
zone_ply: true,
models: true,
snapshots: true,
live_stream: None,
live_stride: post::LiveOptions::default().stride,
live_files: true,
}
}
}
pub struct DefaultSinks {
sinks: Vec<Box<dyn Sink>>,
log: RunLog,
}
impl DefaultSinks {
pub fn new(out: &Path, opts: &SinkOptions) -> std::io::Result<Self> {
Self::with_set(out, opts.echo, SinkSet { models: opts.save_models, ..SinkSet::all() })
}
pub fn with_set(out: &Path, echo: bool, set: SinkSet) -> std::io::Result<Self> {
let dirs: &[&str] = if set.zone_ply { &["full/preview", "full/refined", "work"] } else { &["work"] };
for d in dirs {
std::fs::create_dir_all(out.join(d))?;
}
let run_log = out.join("run.log");
let log = RunLog::open(set.run_log.then_some(run_log.as_path()), echo)?;
let mut sinks: Vec<Box<dyn Sink>> = Vec::new();
if set.timeline {
sinks.push(Box::new(TimelineSink::create(&out.join("timeline.txt"))?));
}
sinks.push(Box::new(RunLogSink::new(log.clone(), set.run_log.then(|| out.join("DONE")))));
if set.zone_ply {
sinks.push(Box::new(ZonePlySink::new(out.join("full"), Some(log.clone()))));
}
if set.models {
sinks.push(Box::new(ModelSink::new(out.join("work").join("models"), Some(log.clone()))));
}
if set.snapshots {
let mut snap = SnapshotSink::new(out.to_path_buf(), log.clone()).with_live_stride(set.live_stride).with_files(set.live_files);
if let Some(cfg) = set.live_stream {
snap = snap.with_live_stream(cfg);
}
sinks.push(Box::new(snap));
}
Ok(Self { sinks, log })
}
pub fn run_log(&self) -> RunLog {
self.log.clone()
}
pub fn into_hook(self) -> Hook {
any_hook(self)
}
}
impl Sink for DefaultSinks {
fn kinds(&self) -> Vec<EventKind> {
let mut v: Vec<EventKind> = Vec::new();
for s in &self.sinks {
for k in s.kinds() {
if !v.contains(&k) {
v.push(k);
}
}
}
v
}
fn handle(&mut self, e: &Event) {
let k = e.kind();
for s in &mut self.sinks {
if s.kinds().contains(&k) {
s.handle(e);
}
}
}
}
pub fn default_sinks(out: &Path, opts: &SinkOptions) -> std::io::Result<Vec<(EventKind, Hook)>> {
Ok(hooks(DefaultSinks::new(out, opts)?))
}
pub fn attach<R: Reducer>(p: Pipeline<R>, out: &Path, opts: &SinkOptions) -> std::io::Result<Pipeline<R>> {
Ok(p.on_any(DefaultSinks::new(out, opts)?.into_hook()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::Meta;
fn meta(s: u64, n: u32) -> Meta {
Meta { version: 0, at: UNIX_EPOCH + Duration::new(s, n) }
}
fn r(zone: usize, lo: usize, hi: usize) -> ZoneRange {
ZoneRange { zone, lo, hi }
}
#[test]
fn heartbeat_status_json_follows_events() {
let dir = std::env::temp_dir().join(format!("c3d_status_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let log = RunLog::open(None, false).unwrap();
let cfg = LiveStream { enu: false, origin_gps: None };
let mut s = SnapshotSink::new(dir.clone(), log).with_live_stream(cfg);
let read = || crate::util::Json::parse(&std::fs::read_to_string(dir.join("live/stream/status.json")).unwrap()).unwrap();
s.handle(&Event::Started { meta: meta(100, 0), positions: Some(26) });
s.handle(&Event::PositionDone { meta: meta(102, 500_000_000), position: 14, registered: 44, expected: 45 });
let j = read();
assert_eq!(
j.dump(),
crate::util::Json::obj(vec![
("t", crate::util::Json::Num("2.5".into())),
("stage", crate::util::Json::Str("triangulation".into())),
("zone", crate::util::Json::Int(0)),
("positions", crate::util::Json::Int(15)),
("positions_total", crate::util::Json::Int(26)),
("images", crate::util::Json::Int(44)),
])
.dump()
);
s.handle(&Event::RefineStarted { meta: meta(103, 0), zone: 1 });
let j = read();
assert_eq!(j.get("stage").map(|v| v.dump()), Some("\"ba\"".into()));
assert_eq!(j.get("zone").map(|v| v.dump()), Some("1".into()));
assert!(!dir.join("live/stream/status.json.tmp").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn timeline_table() {
let m = || meta(1_700_000_000, 500);
let model = Arc::new(Reconstruction::new());
let cloud = Arc::new(PointCloud::default());
let cases: Vec<(Event, &str)> = vec![
(Event::Started { meta: m(), positions: Some(26) }, "start NPOS=26"),
(Event::ModelInitialized { meta: m(), registered: 42, position: 13, model: model.clone() }, "init_model Registered images: 42"),
(Event::BaseAdopted { meta: m(), zone: 0 }, "adopt refined 0 as base"),
(Event::PositionDone { meta: m(), position: 14, registered: 44, expected: 45 }, "pos 14 registered 44/45"),
(Event::ZoneArrived { meta: m(), range: r(1, 10, 26), model: model.clone() }, "region 1 arrived pos[10,26)"),
(
Event::ZonePreview {
meta: m(),
range: r(1, 10, 26),
cloud: cloud.clone(),
model: model.clone(),
dense: true,
frame: "gps".into(),
},
"preview 1 ready",
),
(Event::RefineStarted { meta: m(), zone: 1 }, "refined 1 ba_start"),
(
Event::ZoneRefinedPose { meta: m(), range: r(1, 10, 26), registered: 48, mean_reproj_px: 0.25095, model: model.clone() },
"refined 1 pose_ready Registered images: 48 Mean reprojection error: 0.250950px ",
),
(Event::ZoneRefined { meta: m(), range: r(1, 10, 26), cloud, model, dense: true }, "refined 1 ready"),
(Event::AllPositionsDone { meta: m() }, "all positions done"),
(Event::AllRefinedDone { meta: m(), chain: None }, "all refined done"),
];
assert_eq!(cases.len(), TIMELINE_TABLE.len());
for ((e, want), (k, _)) in cases.iter().zip(TIMELINE_TABLE.iter()) {
assert_eq!(e.kind(), *k);
assert_eq!(TimelineSink::line(e).unwrap(), format!("1700000000.000000500 {want}"));
}
assert!(TimelineSink::line(&Event::Warning { meta: m(), message: "x".into() }).is_none());
}
}