1use sim_kernel::{Error, Expr, Result, Symbol};
4use sim_lib_scene::{Anchor, AnchorSpace, Transform3};
5use sim_table_core::{TableOp, TablePath};
6use sim_value::{access, build};
7
8pub const WORKSPACE_LAYOUT_NAMESPACE: &str = "workspace";
10
11pub const WORKSPACE_LAYOUT_KIND: &str = "layout";
13
14pub const WORKSPACE_LAYOUT_TABLE_NAMESPACE: &str = "workspace-layout";
16
17const DEFAULT_LAYOUT_KEY: &str = "default";
18const DEFAULT_WORLD_ANCHOR: &str = "workspace";
19
20#[derive(Clone, Debug, PartialEq)]
22pub struct SpatialLayout {
23 panels: Vec<PanelLayout>,
24}
25
26impl SpatialLayout {
27 pub fn single_panel() -> Self {
29 Self {
30 panels: vec![PanelLayout::default()],
31 }
32 }
33
34 pub fn default_arc() -> Self {
36 WorkspaceLayout::default_arc().to_spatial_layout()
37 }
38
39 pub fn from_expr(expr: &Expr) -> Result<Self> {
45 reject_runtime_fields(expr)?;
46 let Some(Expr::List(items) | Expr::Vector(items)) = access::field(expr, "panels") else {
47 return Ok(Self::single_panel());
48 };
49 if items.is_empty() {
50 return Ok(Self::single_panel());
51 }
52 let panels = items
53 .iter()
54 .enumerate()
55 .map(|(index, item)| PanelLayout::from_expr(index, item))
56 .collect::<Result<Vec<_>>>()?;
57 Ok(Self { panels })
58 }
59
60 pub fn panels(&self) -> &[PanelLayout] {
62 &self.panels
63 }
64}
65
66#[derive(Clone, Debug, PartialEq)]
68pub struct WorkspaceLayout {
69 panels: Vec<PanelPlacement>,
70 glance: GlancePreference,
71}
72
73impl WorkspaceLayout {
74 pub fn default_arc() -> Self {
76 Self {
77 panels: vec![
78 PanelPlacement::new(
79 Symbol::new("main"),
80 AnchorSpace::World,
81 Transform3::new([0.0, 1.2, -1.6], [0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0]),
82 )
83 .with_world_anchor(Symbol::new(DEFAULT_WORLD_ANCHOR)),
84 ],
85 glance: GlancePreference::default(),
86 }
87 }
88
89 pub fn new(panels: Vec<PanelPlacement>, glance: GlancePreference) -> Result<Self> {
91 if panels.is_empty() {
92 return Err(Error::HostError(
93 "workspace/layout must contain at least one panel".to_owned(),
94 ));
95 }
96 Ok(Self { panels, glance })
97 }
98
99 pub fn panels(&self) -> &[PanelPlacement] {
101 &self.panels
102 }
103
104 pub fn glance(&self) -> &GlancePreference {
106 &self.glance
107 }
108
109 pub fn to_expr(&self) -> Expr {
111 build::map(vec![
112 (
113 "kind",
114 Expr::Symbol(Symbol::qualified(
115 WORKSPACE_LAYOUT_NAMESPACE,
116 WORKSPACE_LAYOUT_KIND,
117 )),
118 ),
119 (
120 "panels",
121 build::list(self.panels.iter().map(PanelPlacement::to_expr).collect()),
122 ),
123 ("glance", self.glance.to_expr()),
124 ])
125 }
126
127 pub fn from_expr(expr: &Expr) -> Result<Self> {
129 reject_runtime_fields(expr)?;
130 expect_kind(expr, WORKSPACE_LAYOUT_KIND, "workspace/layout")?;
131 let panels_expr = access::required(expr, "panels", "workspace/layout")?;
132 let panels = match panels_expr {
133 Expr::List(items) | Expr::Vector(items) => items
134 .iter()
135 .map(PanelPlacement::from_expr)
136 .collect::<Result<Vec<_>>>()?,
137 _ => {
138 return Err(Error::HostError(
139 "workspace/layout panels field must be a list".to_owned(),
140 ));
141 }
142 };
143 let glance = access::field(expr, "glance")
144 .map(GlancePreference::from_expr)
145 .transpose()?
146 .unwrap_or_default();
147 Self::new(panels, glance)
148 }
149
150 fn to_spatial_layout(&self) -> SpatialLayout {
151 SpatialLayout {
152 panels: self
153 .panels
154 .iter()
155 .map(PanelPlacement::to_panel_layout)
156 .collect(),
157 }
158 }
159}
160
161#[derive(Clone, Debug, PartialEq)]
163pub struct PanelPlacement {
164 pub panel_id: Symbol,
166 pub space: AnchorSpace,
168 pub transform: Transform3,
170 pub world_anchor: Option<Symbol>,
172}
173
174impl PanelPlacement {
175 pub fn new(panel_id: Symbol, space: AnchorSpace, transform: Transform3) -> Self {
177 Self {
178 panel_id,
179 space,
180 transform,
181 world_anchor: None,
182 }
183 }
184
185 pub fn with_world_anchor(mut self, world_anchor: Symbol) -> Self {
187 self.world_anchor = Some(world_anchor);
188 self
189 }
190
191 pub fn to_expr(&self) -> Expr {
193 let mut fields = vec![
194 (
195 "kind",
196 Expr::Symbol(Symbol::qualified(
197 WORKSPACE_LAYOUT_NAMESPACE,
198 "panel-placement",
199 )),
200 ),
201 ("panel-id", Expr::Symbol(self.panel_id.clone())),
202 ("space", self.space.to_expr()),
203 ("transform", self.transform.to_expr()),
204 ];
205 if let Some(anchor) = &self.world_anchor {
206 fields.push(("world-anchor", Expr::Symbol(anchor.clone())));
207 }
208 build::map(fields)
209 }
210
211 pub fn from_expr(expr: &Expr) -> Result<Self> {
213 reject_runtime_fields(expr)?;
214 expect_kind(expr, "panel-placement", "workspace/panel-placement")?;
215 let panel_id = access::required_sym(expr, "panel-id", "workspace/panel-placement")?;
216 let space = AnchorSpace::from_expr(access::required(
217 expr,
218 "space",
219 "workspace/panel-placement",
220 )?)?;
221 let transform = Transform3::from_expr(access::required(
222 expr,
223 "transform",
224 "workspace/panel-placement",
225 )?)?;
226 Ok(Self {
227 panel_id,
228 space,
229 transform,
230 world_anchor: access::field_sym(expr, "world-anchor"),
231 })
232 }
233
234 fn to_panel_layout(&self) -> PanelLayout {
235 let target = self
236 .world_anchor
237 .as_ref()
238 .map(Symbol::as_qualified_str)
239 .unwrap_or_else(|| DEFAULT_WORLD_ANCHOR.to_owned());
240 PanelLayout {
241 id: self.panel_id.as_qualified_str(),
242 anchor: Anchor::new(self.space, target),
243 transform: self.transform.clone(),
244 }
245 }
246}
247
248#[derive(Clone, Debug, Default, PartialEq, Eq)]
250pub enum GlancePreference {
251 #[default]
253 UrgencyFirst,
254 ItemClass(Symbol),
256}
257
258impl GlancePreference {
259 pub fn item_class(class: Symbol) -> Self {
261 Self::ItemClass(class)
262 }
263
264 pub fn preferred_item_class(&self) -> Option<&Symbol> {
266 match self {
267 Self::UrgencyFirst => None,
268 Self::ItemClass(class) => Some(class),
269 }
270 }
271
272 pub fn to_expr(&self) -> Expr {
274 let mut fields = vec![
275 (
276 "kind",
277 Expr::Symbol(Symbol::qualified(
278 WORKSPACE_LAYOUT_NAMESPACE,
279 "glance-preference",
280 )),
281 ),
282 (
283 "mode",
284 build::sym(match self {
285 Self::UrgencyFirst => "urgency-first",
286 Self::ItemClass(_) => "item-class",
287 }),
288 ),
289 ];
290 if let Self::ItemClass(class) = self {
291 fields.push(("item-class", Expr::Symbol(class.clone())));
292 }
293 build::map(fields)
294 }
295
296 pub fn from_expr(expr: &Expr) -> Result<Self> {
298 reject_runtime_fields(expr)?;
299 expect_kind(expr, "glance-preference", "workspace/glance-preference")?;
300 let mode = access::required_sym(expr, "mode", "workspace/glance-preference")?;
301 if mode.namespace.is_some() {
302 return Err(Error::HostError(
303 "workspace/glance-preference mode must be unqualified".to_owned(),
304 ));
305 }
306 match mode.name.as_ref() {
307 "urgency-first" => Ok(Self::UrgencyFirst),
308 "item-class" => Ok(Self::ItemClass(access::required_sym(
309 expr,
310 "item-class",
311 "workspace/glance-preference",
312 )?)),
313 other => Err(Error::HostError(format!(
314 "unknown workspace/glance-preference mode {other}"
315 ))),
316 }
317 }
318}
319
320#[derive(Clone, Debug, PartialEq)]
322pub struct PanelLayout {
323 pub id: String,
325 pub anchor: Anchor,
327 pub transform: Transform3,
329}
330
331impl Default for PanelLayout {
332 fn default() -> Self {
333 Self {
334 id: "panel-0".to_owned(),
335 anchor: Anchor::new(AnchorSpace::World, "workspace"),
336 transform: Transform3::identity(),
337 }
338 }
339}
340
341impl PanelLayout {
342 fn from_expr(index: usize, expr: &Expr) -> Result<Self> {
343 reject_runtime_fields(expr)?;
344 let default = Self {
345 id: format!("panel-{index}"),
346 ..Self::default()
347 };
348 Ok(Self {
349 id: access::field_str(expr, "id")
350 .map(str::to_owned)
351 .unwrap_or(default.id),
352 anchor: access::field(expr, "anchor")
353 .map(Anchor::from_expr)
354 .transpose()?
355 .unwrap_or(default.anchor),
356 transform: access::field(expr, "transform")
357 .map(Transform3::from_expr)
358 .transpose()?
359 .unwrap_or(default.transform),
360 })
361 }
362}
363
364pub fn layout_expr(value: &Expr) -> Option<&Expr> {
366 access::field(value, "workspace-layout")
367 .or_else(|| access::field(value, "spatial-workspace-layout"))
368 .or_else(|| access::field(value, "spatial-layout"))
369 .or_else(|| access::field(value, "layout"))
370}
371
372pub fn arrange_spatial_panels(scene: Expr, layout: Option<&Expr>) -> Result<Expr> {
374 let layout = layout
375 .map(spatial_layout_from_expr)
376 .transpose()?
377 .unwrap_or_else(SpatialLayout::default_arc);
378 let panels = layout
379 .panels()
380 .iter()
381 .map(|panel| {
382 sim_lib_scene::panel(
383 panel.id.clone(),
384 scene.clone(),
385 panel.anchor.clone(),
386 panel.transform.clone(),
387 )
388 })
389 .collect();
390 Ok(sim_lib_scene::spatial(panels))
391}
392
393pub fn layout_table_key(path: &TablePath) -> Symbol {
395 let key = if path.segments().is_empty() {
396 DEFAULT_LAYOUT_KEY.to_owned()
397 } else {
398 path.segments().join(".")
399 };
400 Symbol::qualified(WORKSPACE_LAYOUT_TABLE_NAMESPACE, key)
401}
402
403pub fn layout_save_op(path: &TablePath, layout: &WorkspaceLayout) -> TableOp {
405 TableOp::Set(layout_table_key(path), layout.to_expr())
406}
407
408pub fn layout_load_op(path: &TablePath) -> TableOp {
410 TableOp::Get(layout_table_key(path))
411}
412
413fn spatial_layout_from_expr(expr: &Expr) -> Result<SpatialLayout> {
414 if access::field(expr, "kind").is_some() {
415 WorkspaceLayout::from_expr(expr).map(|layout| layout.to_spatial_layout())
416 } else {
417 SpatialLayout::from_expr(expr)
418 }
419}
420
421fn expect_kind(expr: &Expr, name: &str, context: &str) -> Result<()> {
422 let kind = access::required_sym(expr, "kind", context)?;
423 if kind.namespace.as_deref() == Some(WORKSPACE_LAYOUT_NAMESPACE) && kind.name.as_ref() == name {
424 Ok(())
425 } else {
426 Err(Error::HostError(format!(
427 "{context} kind must be {WORKSPACE_LAYOUT_NAMESPACE}/{name}"
428 )))
429 }
430}
431
432fn reject_runtime_fields(expr: &Expr) -> Result<()> {
433 match expr {
434 Expr::Map(entries) => {
435 for (key, value) in entries {
436 if is_runtime_key(key) {
437 return Err(Error::HostError(
438 "spatial layout must not carry runtime tracking fields".to_owned(),
439 ));
440 }
441 reject_runtime_fields(value)?;
442 }
443 }
444 Expr::List(items) | Expr::Vector(items) | Expr::Set(items) => {
445 for item in items {
446 reject_runtime_fields(item)?;
447 }
448 }
449 _ => {}
450 }
451 Ok(())
452}
453
454fn is_runtime_key(key: &Expr) -> bool {
455 matches!(
456 key,
457 Expr::Symbol(symbol)
458 if symbol.namespace.is_none() && matches!(symbol.name.as_ref(), "pose" | "tick")
459 )
460}