Skip to main content

qualia_client_core/chora/
api.rs

1//! Canvas / Chora host API methods for [`WebizenHostApi`].
2
3use std::path::Path;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use ed25519_dalek::Signer;
7use qualia_core_db::domains::geospatial::canvas_query::query_region;
8use qualia_core_db::domains::geospatial::render_surface::{
9    build_surface_descriptor, RenderSurfaceDescriptor, BACKEND_WEBGPU,
10};
11use qualia_core_db::domains::geospatial::spatial::{SpatialElement, SpatiotemporalQuadTree};
12use qualia_core_db::domains::geospatial::spatial_sync::{
13    merge_planted_assets, PlantedAsset, SpatialSyncCell,
14};
15use qualia_core_db::query::spawn_decay::spawn_decay_alpha;
16
17use crate::canvas_state;
18use crate::canvas_store;
19use crate::canvas_world;
20use crate::wellfair::api::WebizenHostApi;
21use crate::wellfair::blob_store::BlobStore;
22use crate::wellfair::sync_protocol::SyncOperation;
23
24fn now_unix() -> u64 {
25    SystemTime::now()
26        .duration_since(UNIX_EPOCH)
27        .map(|d| d.as_secs())
28        .unwrap_or(0)
29}
30
31impl WebizenHostApi {
32    /// Set temporal slice for time-travel navigation (P3).
33    pub fn set_temporal_slice(&self, t_value: f64) -> Result<(), String> {
34        let storage_root = self.chora_storage_root();
35        let mut state = canvas_state::load(storage_root);
36        state.temporal_t = t_value.max(0.0) as u64;
37        canvas_state::save(storage_root, &state).map_err(|e| e.to_string())
38    }
39
40    /// Current temporal scrub position (unix seconds).
41    pub fn get_temporal_slice(&self) -> u64 {
42        canvas_state::load(self.chora_storage_root()).temporal_t
43    }
44
45    fn canvas_store(&self) -> Result<canvas_store::CanvasWorldStore, String> {
46        canvas_store::CanvasWorldStore::open(self.chora_storage_root()).map_err(|e| e.to_string())
47    }
48
49    /// List all saved canvas world configurations.
50    pub fn list_canvas_worlds(&self) -> Result<Vec<serde_json::Value>, String> {
51        let store = self.canvas_store()?;
52        let worlds = store.list().map_err(|e| e.to_string())?;
53        Ok(worlds
54            .iter()
55            .map(|w| {
56                serde_json::json!({
57                    "id": w.id,
58                    "title": w.title,
59                    "layerCount": w.layer_stack.len(),
60                    "assetCount": w.assets.len(),
61                    "temporalRange": w.temporal_range,
62                    "origin": { "lat": w.origin_lat, "lon": w.origin_lon },
63                })
64            })
65            .collect())
66    }
67
68    /// Load one world config by id.
69    pub fn get_canvas_world(&self, world_id: &str) -> Result<serde_json::Value, String> {
70        let store = self.canvas_store()?;
71        let world = store
72            .get(world_id)
73            .map_err(|e| e.to_string())?
74            .ok_or_else(|| format!("world not found: {world_id}"))?;
75        serde_json::to_value(&world).map_err(|e| e.to_string())
76    }
77
78    /// Save (upsert) a world configuration.
79    pub fn save_canvas_world(&self, config_json: &str) -> Result<(), String> {
80        let config: canvas_world::CanvasWorldConfig =
81            serde_json::from_str(config_json).map_err(|e| e.to_string())?;
82        self.canvas_store()?
83            .upsert(config, now_unix())
84            .map_err(|e| e.to_string())
85    }
86
87    /// Remove a world by id.
88    pub fn delete_canvas_world(&self, world_id: &str) -> Result<bool, String> {
89        self.canvas_store()?
90            .remove(world_id)
91            .map_err(|e| e.to_string())
92    }
93
94    /// Seed the P0 demo world if the store is empty.
95    pub fn seed_canvas_demo(&self) -> Result<bool, String> {
96        self.canvas_store()?
97            .seed_if_empty(now_unix())
98            .map_err(|e| e.to_string())
99    }
100
101    /// Seed P8 flagship canvas worlds (history, biosphere, council, SDG, GLAM) when absent.
102    pub fn seed_flagship_worlds(&self) -> Result<u32, String> {
103        let seeded = super::seed_all_flagships(&self.canvas_store()?, now_unix())
104            .map_err(|e| e.to_string())?;
105        Ok(seeded as u32)
106    }
107
108    /// Set the active world for Chora navigation.
109    pub fn set_active_canvas_world(&self, world_id: &str) -> Result<(), String> {
110        let store = self.canvas_store()?;
111        if store.get(world_id).map_err(|e| e.to_string())?.is_none() {
112            return Err(format!("world not found: {world_id}"));
113        }
114        let storage_root = self.chora_storage_root();
115        let mut state = canvas_state::load(storage_root);
116        state.active_world_id = world_id.to_string();
117        canvas_state::save(storage_root, &state).map_err(|e| e.to_string())
118    }
119
120    /// Active world id + temporal scrub state.
121    pub fn canvas_navigation_state(&self) -> serde_json::Value {
122        let state = canvas_state::load(self.chora_storage_root());
123        serde_json::json!({
124            "activeWorldId": state.active_world_id,
125            "temporalT": state.temporal_t,
126            "rampSecs": state.ramp_secs,
127        })
128    }
129
130    /// Query assets visible in a bbox at the current temporal slice (P4 entry).
131    pub fn query_canvas_region(
132        &self,
133        x1: f64,
134        y1: f64,
135        x2: f64,
136        y2: f64,
137    ) -> Result<Vec<serde_json::Value>, String> {
138        let nav = canvas_state::load(self.chora_storage_root());
139        let store = self.canvas_store()?;
140        let world = store
141            .get(&nav.active_world_id)
142            .map_err(|e| e.to_string())?
143            .ok_or_else(|| format!("active world not found: {}", nav.active_world_id))?;
144
145        let mut tree = SpatiotemporalQuadTree::new((-180.0, -90.0, 180.0, 90.0));
146        for (i, asset) in world.assets.iter().enumerate() {
147            let vf = asset.valid_from.unwrap_or(0);
148            let vu = asset.valid_until.unwrap_or(u64::MAX);
149            let alpha = spawn_decay_alpha(
150                nav.temporal_t,
151                vf,
152                asset.valid_until,
153                nav.ramp_secs,
154                nav.ramp_secs,
155            );
156            if alpha <= 0.0 {
157                continue;
158            }
159            let (lat, lon) = match (asset.lat, asset.lon) {
160                (Some(la), Some(lo)) => (la, lo),
161                _ => (world.origin_lat, world.origin_lon),
162            };
163            let bounds = (lon - 0.01, lat - 0.01, lon + 0.01, lat + 0.01);
164            tree.insert(SpatialElement {
165                h3_index: i as u64,
166                bounds,
167                t0: vf,
168                t1: vu,
169            });
170        }
171
172        let ramp = nav.ramp_secs;
173        let t0 = nav.temporal_t.saturating_sub(ramp);
174        let t1 = nav.temporal_t.saturating_add(ramp);
175        let hits = query_region(&tree, (x1, y1, x2, y2), (t0, t1));
176
177        Ok(hits
178            .iter()
179            .filter_map(|&idx| world.assets.get(idx as usize))
180            .map(|a| {
181                let vf = a.valid_from.unwrap_or(0);
182                let alpha = spawn_decay_alpha(
183                    nav.temporal_t,
184                    vf,
185                    a.valid_until,
186                    nav.ramp_secs,
187                    nav.ramp_secs,
188                );
189                serde_json::json!({
190                    "assetId": a.asset_id,
191                    "licence": a.licence,
192                    "lat": a.lat,
193                    "lon": a.lon,
194                    "alpha": alpha,
195                })
196            })
197            .collect())
198    }
199
200    /// Request asset bytes by content hash, local path, or urn (Phase 6).
201    ///
202    /// - 64-char hex hash → content-addressed blob store lookup
203    /// - `local://…` or filesystem path → read from storage root
204    /// - `urn:…` → not yet implemented (honest error, no fabricated bytes)
205    pub fn request_asset_stream(&self, asset_id: &str) -> Result<Vec<u8>, String> {
206        let asset_id = asset_id.trim();
207        if asset_id.is_empty() {
208            return Err("asset_id must not be empty".into());
209        }
210
211        if asset_id.starts_with("urn:") {
212            return Err(format!(
213                "remote urn asset fetch is not implemented offline: {asset_id}"
214            ));
215        }
216
217        let storage_root = self.chora_storage_root();
218        if asset_id.len() == 64 && asset_id.bytes().all(|b| b.is_ascii_hexdigit()) {
219            let store = BlobStore::open(storage_root).map_err(|e| e.to_string())?;
220            return store
221                .get(asset_id)
222                .map_err(|e| e.to_string())?
223                .ok_or_else(|| format!("blob not found for hash {asset_id}"));
224        }
225
226        let path = if asset_id.starts_with("local://") {
227            let rel = asset_id.trim_start_matches("local://");
228            storage_root.join("wellfair/canvas").join(rel)
229        } else if Path::new(asset_id).is_absolute() {
230            Path::new(asset_id).to_path_buf()
231        } else {
232            storage_root.join(asset_id)
233        };
234
235        std::fs::read(&path).map_err(|e| format!("failed to read asset '{asset_id}': {e}"))
236    }
237
238    /// Expose a WebGPU/canvas proxy surface configuration to the qapp context (Phase 6).
239    pub fn get_render_surface(&self) -> Result<String, String> {
240        const DEFAULT_WIDTH: u32 = 1280;
241        const DEFAULT_HEIGHT: u32 = 720;
242
243        let storage_root = self.chora_storage_root();
244        let nav = canvas_state::load(storage_root);
245        let store = self.canvas_store()?;
246        let world = store
247            .get(&nav.active_world_id)
248            .map_err(|e| e.to_string())?
249            .unwrap_or_else(canvas_world::CanvasWorldConfig::default);
250
251        let desc: RenderSurfaceDescriptor = build_surface_descriptor(
252            DEFAULT_WIDTH,
253            DEFAULT_HEIGHT,
254            BACKEND_WEBGPU,
255            nav.temporal_t,
256            &nav.active_world_id,
257            world.origin_lat,
258            world.origin_lon,
259        )?;
260
261        serde_json::to_string(&desc).map_err(|e| e.to_string())
262    }
263
264    /// Publish a planted asset to the spatial sync protocol (Phase 7)
265    pub fn publish_planted_asset(&self, asset: PlantedAsset) -> Result<(), String> {
266        let payload_summary = serde_json::to_string(&asset).map_err(|e| e.to_string())?;
267        let op = SyncOperation::new(
268            uuid::Uuid::new_v4().to_string(),
269            format!("urn:qualia:spatial_plant:{}", asset.asset_id),
270            "spatial_plant",
271            self.chora_owner_did().to_string(),
272            "Public",
273            payload_summary,
274            asset.lamport,
275            now_unix() as u32,
276        );
277        let signature = self.chora_signing_key().sign(&op.signing_payload());
278        let signed_op = op.with_signature(hex::encode(signature.to_bytes()));
279
280        // Submit directly to local inbox; daemon handles outbox/relay
281        self.admit_sync_operation(&signed_op).map(|_| ())
282    }
283
284    /// Pull and merge planted assets for a specific spatial cell (Phase 7)
285    pub fn pull_spatial_assets(&self, cell_id: u64) -> Result<Vec<PlantedAsset>, String> {
286        let ops = self.validated_sync_operations()?;
287        let mut local_assets = Vec::new();
288
289        for op in ops {
290            if op.kind == "spatial_plant" {
291                if let Ok(asset) = serde_json::from_str::<PlantedAsset>(&op.payload_summary) {
292                    if asset.cell_id == cell_id {
293                        local_assets.push(asset);
294                    }
295                }
296            }
297        }
298
299        // Use merge_planted_assets to resolve overlapping operations using LWW
300        // Remote is empty here because the inbox already contains both local and remote operations
301        // and we just need to reduce them to the final active state.
302        let final_assets = merge_planted_assets(&local_assets, &[]);
303
304        // Filter out deleted tombstones
305        Ok(final_assets.into_iter().filter(|a| !a.deleted).collect())
306    }
307
308    /// Pull spatial assets for a cell and return merged state as JSON (Phase 7).
309    pub fn sync_cell(&self, cell_id: u64) -> Result<String, String> {
310        let assets = self.pull_spatial_assets(cell_id)?;
311        let cell = SpatialSyncCell::from_assets(cell_id, &assets);
312        serde_json::to_string(&cell).map_err(|e| e.to_string())
313    }
314}