Skip to main content

konjure_lang/
spatial.rs

1//! Validated scene adaptation and fixed-step rigid-body simulation.
2//!
3//! Language execution, spatial validation, and physics form one transaction. Media
4//! components stay in the ECS for a host to resolve; this module performs no I/O.
5use crate::{Diagnostic, Limits, Machine, Snapshot, Span, Value, compile};
6use konjure_sdk::{self as sdk, EntityId, Frame, Geometry, Mesh, SceneDocument};
7use rapier3d::{
8    glamx::{DQuat, EulerRot},
9    prelude::*,
10};
11use serde::Serialize;
12use std::collections::{BTreeMap, BTreeSet};
13
14/// Fixed simulation interval in seconds.
15pub const FIXED_DT: f64 = 1.0 / 60.0;
16/// Maximum fixed ticks accepted by one synchronous host call.
17pub const MAX_STEP_COUNT: u32 = 600;
18/// Maximum points in a PointCloud component.
19pub const MAX_CLOUD_POINTS: usize = 256;
20const MAX_MESH_VERTICES: usize = 16_384;
21const MAX_MESH_INDICES: usize = 98_304;
22const MAX_SCENE_VERTICES: usize = 262_144;
23const GEOMETRIES: &[&str] = &[
24    "Sphere",
25    "Box",
26    "Cylinder",
27    "Quad",
28    "Line",
29    "Ray",
30    "Mesh",
31    "PointCloud",
32];
33
34/// An active collision pair from the latest committed physics tick.
35#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
36pub struct Contact {
37    /// First stable language entity ID, always smaller than `entity_b`.
38    pub entity_a: u64,
39    /// Second stable language entity ID.
40    pub entity_b: u64,
41}
42
43/// Renderer-neutral scene state. SDK IDs are `entity_N` for language entity N.
44#[derive(Clone, Debug, Serialize)]
45pub struct RenderOutput {
46    /// Semantic controls from the same committed state as the geometry below.
47    pub ui: konjure_sdk::presentation::UiDocument,
48    /// Validated scene graph; custom geometry references adapter-owned meshes below.
49    pub document: SceneDocument,
50    /// Owned local meshes keyed by stable SDK entity ID.
51    pub meshes: BTreeMap<EntityId, Mesh>,
52    /// Evaluated transforms at the committed simulation time.
53    pub frame: Frame,
54    /// Active collision pairs, sorted by language entity ID.
55    pub contacts: Vec<Contact>,
56}
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59enum BodyKind {
60    Dynamic,
61    Fixed,
62    Kinematic,
63}
64#[derive(Clone, Debug, PartialEq)]
65enum CollisionShape {
66    Sphere(f32),
67    Box(Vector),
68}
69#[derive(Clone, Debug, PartialEq)]
70struct BodyConfig {
71    kind: BodyKind,
72    shape: CollisionShape,
73    mass: f32,
74    restitution: f32,
75    friction: f32,
76}
77#[derive(Clone)]
78struct Binding {
79    handle: RigidBodyHandle,
80    config: BodyConfig,
81    transform: Value,
82    rigid_body: Value,
83}
84
85/// A reusable language machine coupled to a persistent deterministic Rapier world.
86pub struct SpatialRuntime {
87    machine: Machine,
88    world: PhysicsWorld,
89    bindings: BTreeMap<u64, Binding>,
90    contacts: Vec<Contact>,
91}
92
93impl Clone for SpatialRuntime {
94    fn clone(&self) -> Self {
95        // Rapier's pipeline and CCD solver contain scratch buffers. Persistent
96        // broad/narrow phase, islands, bodies, and joints must survive rollback.
97        Self {
98            machine: self.machine.clone(),
99            world: PhysicsWorld {
100                gravity: self.world.gravity,
101                integration_parameters: self.world.integration_parameters,
102                physics_pipeline: PhysicsPipeline::new(),
103                islands: self.world.islands.clone(),
104                broad_phase: self.world.broad_phase.clone(),
105                narrow_phase: self.world.narrow_phase.clone(),
106                bodies: self.world.bodies.clone(),
107                colliders: self.world.colliders.clone(),
108                impulse_joints: self.world.impulse_joints.clone(),
109                multibody_joints: self.world.multibody_joints.clone(),
110                ccd_solver: CCDSolver::new(),
111            },
112            bindings: self.bindings.clone(),
113            contacts: self.contacts.clone(),
114        }
115    }
116}
117
118fn accept_action_result(value: &Value) -> Result<(), Diagnostic> {
119    if let Value::Enum(value) = value
120        && value.case == "Err"
121        && matches!(&value.ty,crate::Type::Applied{name,..}if name=="Res")
122    {
123        let cause = value
124            .payload
125            .first()
126            .map(ToString::to_string)
127            .unwrap_or_else(|| "Unknown".into());
128        let code = match cause.as_str() {
129            "Overflow" => "numeric_overflow",
130            "DivisionByZero" => "division_by_zero",
131            "InexactConversion" => "inexact_conversion",
132            "Bounds" => "index_out_of_bounds",
133            "InvalidUtf8" => "invalid_utf8",
134            "InvalidShape" | "ShapeMismatch" => "shape_mismatch",
135            _ => "data_error",
136        };
137        return Err(Diagnostic::new(
138            code,
139            format!("action returned Err({cause})"),
140            value
141                .origin
142                .clone()
143                .unwrap_or_else(|| Span::new("main", 0, 0)),
144        ));
145    }
146    Ok(())
147}
148impl SpatialRuntime {
149    /// Compiles, initializes, and validates a program without advancing physics.
150    /// Imports use only the explicitly supplied module map.
151    pub fn new(source: &str, modules: &BTreeMap<String, String>) -> Result<Self, Vec<Diagnostic>> {
152        let program = compile(source, modules)?;
153        let machine = Machine::new(program, Limits::default()).map_err(|error| vec![error])?;
154        let mut runtime = Self {
155            machine,
156            world: PhysicsWorld::default(),
157            bindings: BTreeMap::new(),
158            contacts: Vec::new(),
159        };
160        runtime.world.integration_parameters.dt = FIXED_DT as f32;
161        runtime.normalize().map_err(|error| vec![error])?;
162        runtime.render().map_err(|error| vec![error])?;
163        runtime.reconcile(false).map_err(|error| vec![error])?;
164        Ok(runtime)
165    }
166
167    /// Returns the language fixed tick and terminal lifecycle state without
168    /// materializing a scene or snapshot.
169    #[must_use]
170    pub const fn execution_state(&self) -> (u64, bool) {
171        self.machine.execution_state()
172    }
173
174    /// Advances up to [`MAX_STEP_COUNT`] fixed ticks. A rejected batch preserves
175    /// all previously committed language and physics state, including impulses.
176    pub fn step(&mut self, count: u32) -> Result<Snapshot, Diagnostic> {
177        if count > MAX_STEP_COUNT {
178            return Err(error(
179                &Span::default(),
180                "step_limit",
181                format!("step count exceeds {MAX_STEP_COUNT}"),
182            ));
183        }
184        if count == 0 {
185            return Ok(self.snapshot());
186        }
187        let mut candidate = self.clone();
188        for _ in 0..count {
189            candidate.machine.tick(FIXED_DT)?;
190            candidate.normalize()?;
191            candidate.render()?;
192            candidate.reconcile(true)?;
193            candidate.world.step();
194            candidate.write_back()?;
195            candidate.render()?;
196            candidate.reconcile(false)?;
197        }
198        *self = candidate;
199        Ok(self.snapshot())
200    }
201
202    /// Runs system teardown and validates its spatial edits as one transaction.
203    /// Repeated calls return the finished snapshot without repeating callbacks.
204    ///
205    /// # Errors
206    /// A failed callback or invalid spatial edit preserves the running world.
207    pub fn finish(&mut self) -> Result<Snapshot, Diagnostic> {
208        if self.snapshot().finished {
209            return Ok(self.snapshot());
210        }
211        let mut candidate = self.clone();
212        candidate.machine.finish()?;
213        candidate.normalize()?;
214        candidate.render()?;
215        candidate.reconcile(false)?;
216        *self = candidate;
217        Ok(self.snapshot())
218    }
219
220    /// Invokes an exported function, validating all resulting spatial edits before
221    /// committing. Impulses are applied only when a subsequent tick succeeds.
222    pub fn invoke(&mut self, name: &str, arguments: &[Value]) -> Result<Value, Diagnostic> {
223        let mut candidate = self.clone();
224        let result = candidate.machine.invoke(name, arguments.to_vec())?;
225        accept_action_result(&result)?;
226        candidate.normalize()?;
227        candidate.render()?;
228        candidate.reconcile(false)?;
229        *self = candidate;
230        Ok(result)
231    }
232
233    /// Invokes a function stored in an exact component field. Resulting spatial
234    /// edits and physics reconciliation join the language transaction.
235    pub fn invoke_component(
236        &mut self,
237        id: u64,
238        component: &str,
239        field: &str,
240        arguments: &[Value],
241    ) -> Result<Value, Diagnostic> {
242        let mut candidate = self.clone();
243        let result =
244            candidate
245                .machine
246                .invoke_component(id, component, field, arguments.to_vec())?;
247        accept_action_result(&result)?;
248        candidate.normalize()?;
249        candidate.render()?;
250        candidate.reconcile(false)?;
251        *self = candidate;
252        Ok(result)
253    }
254
255    /// Invokes an unambiguous zero-argument or typed component method on one
256    /// entity, with the same spatial transaction as a named function call.
257    pub fn invoke_entity(
258        &mut self,
259        id: u64,
260        method: &str,
261        arguments: &[Value],
262    ) -> Result<Value, Diagnostic> {
263        let mut candidate = self.clone();
264        let result = candidate
265            .machine
266            .invoke_entity(id, method, arguments.to_vec())?;
267        accept_action_result(&result)?;
268        candidate.normalize()?;
269        candidate.render()?;
270        candidate.reconcile(false)?;
271        *self = candidate;
272        Ok(result)
273    }
274
275    /// Returns the committed ECS, globals, logs, and simulation clock.
276    pub fn snapshot(&self) -> Snapshot {
277        self.machine.snapshot()
278    }
279
280    /// Validates and adapts every geometry component to an SDK scene and mesh.
281    pub fn render(&self) -> Result<RenderOutput, Diagnostic> {
282        let snapshot = self.snapshot();
283        let ui = crate::ui::document(&snapshot, 0)
284            .map_err(|problem| error(&Span::default(), "invalid_ui", problem.to_string()))?;
285        let mut document = SceneDocument {
286            format: "konjure.scene".into(),
287            version: sdk::FORMAT_VERSION,
288            id: sdk::SceneId("language_scene".into()),
289            title: "Konjure language scene".into(),
290            entities: Vec::new(),
291            conversation: Vec::new(),
292        };
293        let mut meshes = BTreeMap::new();
294        let mut vertex_count = 0;
295        let parent_targets: BTreeSet<u64> = snapshot
296            .entities
297            .iter()
298            .filter_map(|entity| entity.components.get("Parent"))
299            .filter_map(|value| field(value, "entity", &Span::default()).ok())
300            .filter_map(|value| match value {
301                Value::Entity(id) => Some(*id),
302                _ => None,
303            })
304            .collect();
305        for entity in &snapshot.entities {
306            let span = &entity.source;
307            // Presentation values must remain valid even on resource-only entities.
308            let transform = entity
309                .components
310                .get("Transform")
311                .map(|value| transform(value, span))
312                .transpose()?
313                .unwrap_or_default();
314            let (color, material) = entity
315                .components
316                .get("Material")
317                .map(|value| material(value, span))
318                .transpose()?
319                .unwrap_or((
320                    sdk::Color {
321                        r: 111,
322                        g: 227,
323                        b: 255,
324                        a: 255,
325                    },
326                    sdk::Material {
327                        roughness: 0.6,
328                        emissive: 0.0,
329                    },
330                ));
331            let geometry_value = geometry_component(&entity.components, span)?;
332            if geometry_value.is_none()
333                && !entity.components.contains_key("Parent")
334                && !parent_targets.contains(&entity.id)
335            {
336                continue;
337            }
338            let id = EntityId(format!("entity_{}", entity.id));
339            let (geometry, mesh) = match geometry_value {
340                Some((name, value)) => geometry(name, value, &id, span)?,
341                None => (
342                    Geometry::Group,
343                    Mesh {
344                        positions: Vec::new(),
345                        normals: Vec::new(),
346                        indices: Vec::new(),
347                    },
348                ),
349            };
350            if !mesh.positions.is_empty() {
351                vertex_count += mesh.positions.len();
352                if vertex_count > MAX_SCENE_VERTICES {
353                    return Err(error(
354                        span,
355                        "mesh_limit",
356                        "scene exceeds its generated vertex budget",
357                    ));
358                }
359                meshes.insert(id.clone(), mesh);
360            }
361            let parent = entity
362                .components
363                .get("Parent")
364                .map(|value| {
365                    let Value::Entity(parent) = field(value, "entity", span)? else {
366                        return Err(error(
367                            span,
368                            "invalid_parent",
369                            "Parent.entity must be an Entity",
370                        ));
371                    };
372                    if !snapshot
373                        .entities
374                        .iter()
375                        .any(|candidate| candidate.id == *parent)
376                    {
377                        return Err(error(
378                            span,
379                            "unknown_parent",
380                            "Parent.entity must reference an existing entity",
381                        ));
382                    }
383                    Ok(EntityId(format!("entity_{parent}")))
384                })
385                .transpose()?;
386            document.entities.push(sdk::Entity {
387                id,
388                parent,
389                transform,
390                geometry,
391                color,
392                material,
393                animations: Vec::new(),
394                actions: BTreeMap::new(),
395            });
396        }
397        sdk::validate(&document).map_err(|diagnostic| {
398            error(&Span::default(), "invalid_scene", diagnostic.to_string())
399        })?;
400        // The SDK evaluates transforms; custom meshes are resolved by this adapter.
401        let mut sampling_document = document.clone();
402        for entity in &mut sampling_document.entities {
403            entity.geometry = Geometry::Group;
404        }
405        let evaluator = sdk::Runtime::new(sampling_document).map_err(|diagnostic| {
406            error(&Span::default(), "invalid_scene", diagnostic.to_string())
407        })?;
408        let mut frame = evaluator.sample(snapshot.time).map_err(|diagnostic| {
409            error(&Span::default(), "invalid_scene", diagnostic.to_string())
410        })?;
411        for draw in &mut frame.draws {
412            draw.mesh_entity = Some(draw.entity.clone());
413        }
414        Ok(RenderOutput {
415            ui,
416            document,
417            meshes,
418            frame,
419            contacts: self.contacts.clone(),
420        })
421    }
422
423    fn normalize(&mut self) -> Result<(), Diagnostic> {
424        let mut updates = Vec::new();
425        for entity in self.snapshot().entities {
426            if geometry_component(&entity.components, &entity.source)?.is_some() {
427                if !entity.components.contains_key("Transform") {
428                    updates.push((entity.id, default_transform()));
429                }
430                if !entity.components.contains_key("Material") {
431                    updates.push((entity.id, default_material()));
432                }
433            }
434        }
435        self.machine.upsert_components_for_adapter(&updates)
436    }
437
438    fn reconcile(&mut self, apply_forces: bool) -> Result<(), Diagnostic> {
439        let snapshot = self.snapshot();
440        let present: BTreeSet<_> = snapshot
441            .entities
442            .iter()
443            .filter(|entity| entity.components.contains_key("RigidBody"))
444            .map(|entity| entity.id)
445            .collect();
446        let removed: Vec<_> = self
447            .bindings
448            .keys()
449            .filter(|id| !present.contains(id))
450            .copied()
451            .collect();
452        for id in removed {
453            if let Some(binding) = self.bindings.remove(&id) {
454                self.world.remove_body(binding.handle);
455            }
456        }
457        let mut spatial_change = false;
458        for entity in &snapshot.entities {
459            let span = &entity.source;
460            let force = entity
461                .components
462                .get("Force")
463                .map(|value| vector(field(value, "value", span)?, span))
464                .transpose()?;
465            let impulse = entity
466                .components
467                .get("Impulse")
468                .map(|value| vector(field(value, "value", span)?, span))
469                .transpose()?;
470            let Some(body_value) = entity.components.get("RigidBody") else {
471                if force.is_some() || impulse.is_some() {
472                    return Err(error(
473                        span,
474                        "missing_body",
475                        "Force and Impulse require a dynamic RigidBody",
476                    ));
477                }
478                continue;
479            };
480            let transform_value = entity.components.get("Transform").ok_or_else(|| {
481                error(
482                    span,
483                    "missing_shape",
484                    "RigidBody requires a Sphere or Box geometry",
485                )
486            })?;
487            let transform = transform(transform_value, span)?;
488            let config = body_config(body_value, &entity.components, transform.scale, span)?;
489            if config.kind != BodyKind::Dynamic && (force.is_some() || impulse.is_some()) {
490                return Err(error(
491                    span,
492                    "invalid_force",
493                    "Force and Impulse require a dynamic RigidBody",
494                ));
495            }
496            let velocity = vector(field(body_value, "velocity", span)?, span)?;
497            let angular_velocity = vector(field(body_value, "angular_velocity", span)?, span)?;
498            if config.kind == BodyKind::Fixed
499                && (velocity != sdk::Vec3::ZERO || angular_velocity != sdk::Vec3::ZERO)
500            {
501                return Err(error(
502                    span,
503                    "fixed_velocity",
504                    "a fixed RigidBody cannot have nonzero velocity",
505                ));
506            }
507            let mut old = self.bindings.get(&entity.id).cloned();
508            if let Some(binding) = &old {
509                if binding.config.kind == BodyKind::Dynamic
510                    && (binding.transform.field("position") != transform_value.field("position")
511                        || binding.transform.field("rotation") != transform_value.field("rotation"))
512                {
513                    return Err(error(
514                        span,
515                        "dynamic_pose_edit",
516                        "dynamic body position and rotation are owned by physics; use velocity, Force, or Impulse",
517                    ));
518                }
519                if binding.config != config {
520                    self.world.remove_body(binding.handle);
521                    old = None;
522                }
523            }
524            let pose = pose(transform);
525            let handle = if let Some(binding) = old {
526                let body =
527                    self.world.bodies.get_mut(binding.handle).ok_or_else(|| {
528                        error(span, "physics_state", "missing persistent rigid body")
529                    })?;
530                if binding.transform != *transform_value {
531                    body.set_position(pose, true);
532                    spatial_change = true;
533                }
534                if binding.rigid_body.field("velocity") != body_value.field("velocity") {
535                    body.set_linvel(to_vector(velocity), true);
536                }
537                if binding.rigid_body.field("angular_velocity")
538                    != body_value.field("angular_velocity")
539                {
540                    body.set_angvel(to_vector(angular_velocity), true);
541                }
542                binding.handle
543            } else {
544                let builder = match config.kind {
545                    BodyKind::Dynamic => RigidBodyBuilder::dynamic(),
546                    BodyKind::Fixed => RigidBodyBuilder::fixed(),
547                    BodyKind::Kinematic => RigidBodyBuilder::kinematic_velocity_based(),
548                };
549                let collider = match config.shape {
550                    CollisionShape::Sphere(radius) => ColliderBuilder::ball(radius),
551                    CollisionShape::Box(size) => {
552                        ColliderBuilder::cuboid(size.x / 2.0, size.y / 2.0, size.z / 2.0)
553                    }
554                };
555                let (handle, _) = self.world.insert(
556                    builder
557                        .pose(pose)
558                        .linvel(to_vector(velocity))
559                        .angvel(to_vector(angular_velocity))
560                        .ccd_enabled(true)
561                        .user_data(u128::from(entity.id)),
562                    collider
563                        .mass(config.mass)
564                        .restitution(config.restitution)
565                        .friction(config.friction)
566                        .user_data(u128::from(entity.id)),
567                );
568                spatial_change = true;
569                handle
570            };
571            if apply_forces {
572                let body = self.world.bodies.get_mut(handle).ok_or_else(|| {
573                    error(
574                        span,
575                        "physics_state",
576                        "missing rigid body during force application",
577                    )
578                })?;
579                body.reset_forces(false);
580                if let Some(force) = force {
581                    body.add_force(to_vector(force), true);
582                }
583                if let Some(impulse) = impulse {
584                    body.apply_impulse(to_vector(impulse), true);
585                }
586            }
587            self.bindings.insert(
588                entity.id,
589                Binding {
590                    handle,
591                    config,
592                    transform: transform_value.clone(),
593                    rigid_body: body_value.clone(),
594                },
595            );
596        }
597        // Contacts describe a completed tick, and are invalidated by pose/shape edits.
598        if spatial_change {
599            self.contacts.clear();
600        }
601        self.contacts
602            .retain(|pair| present.contains(&pair.entity_a) && present.contains(&pair.entity_b));
603        Ok(())
604    }
605
606    fn write_back(&mut self) -> Result<(), Diagnostic> {
607        let mut updates = Vec::new();
608        let snapshot = self.snapshot();
609        for entity in &snapshot.entities {
610            let Some(binding) = self.bindings.get_mut(&entity.id) else {
611                continue;
612            };
613            let body = self.world.bodies.get(binding.handle).ok_or_else(|| {
614                error(
615                    &entity.source,
616                    "physics_state",
617                    "missing simulated rigid body",
618                )
619            })?;
620            let position = body.translation();
621            let (x, y, z) = body.rotation().to_euler(EulerRot::XYZ);
622            let mut transform_value = binding.transform.clone();
623            replace_field(
624                &mut transform_value,
625                "position",
626                vector_value(from_vector(position)),
627                &entity.source,
628            )?;
629            replace_field(
630                &mut transform_value,
631                "rotation",
632                vector_value(sdk::Vec3 {
633                    x: f64::from(x),
634                    y: f64::from(y),
635                    z: f64::from(z),
636                }),
637                &entity.source,
638            )?;
639            let mut rigid_body = binding.rigid_body.clone();
640            replace_field(
641                &mut rigid_body,
642                "velocity",
643                vector_value(from_vector(body.linvel())),
644                &entity.source,
645            )?;
646            replace_field(
647                &mut rigid_body,
648                "angular_velocity",
649                vector_value(from_vector(body.angvel())),
650                &entity.source,
651            )?;
652            binding.transform = transform_value.clone();
653            binding.rigid_body = rigid_body.clone();
654            updates.push((entity.id, transform_value));
655            updates.push((entity.id, rigid_body));
656            if entity.components.contains_key("Impulse") {
657                updates.push((
658                    entity.id,
659                    record("Impulse", [("value", vector_value(sdk::Vec3::ZERO))]),
660                ));
661            }
662        }
663        self.machine.patch_components_for_adapter(&updates)?;
664        let mut pairs = BTreeSet::new();
665        for contact in self
666            .world
667            .contact_pairs()
668            .filter(|contact| contact.has_any_active_contact())
669        {
670            let Some(a) = self.world.colliders.get(contact.collider1) else {
671                continue;
672            };
673            let Some(b) = self.world.colliders.get(contact.collider2) else {
674                continue;
675            };
676            let a = u64::try_from(a.user_data).map_err(|_| {
677                error(
678                    &Span::default(),
679                    "physics_state",
680                    "invalid contact entity ID",
681                )
682            })?;
683            let b = u64::try_from(b.user_data).map_err(|_| {
684                error(
685                    &Span::default(),
686                    "physics_state",
687                    "invalid contact entity ID",
688                )
689            })?;
690            pairs.insert(Contact {
691                entity_a: a.min(b),
692                entity_b: a.max(b),
693            });
694        }
695        self.contacts = pairs.into_iter().collect();
696        Ok(())
697    }
698}
699
700fn error(span: &Span, code: &str, message: impl Into<String>) -> Diagnostic {
701    Diagnostic::new(code, message, span.clone())
702}
703fn field<'a>(value: &'a Value, name: &str, span: &Span) -> Result<&'a Value, Diagnostic> {
704    value.field(name).ok_or_else(|| {
705        error(
706            span,
707            "spatial_field",
708            format!("{} requires field `{name}`", value.type_name()),
709        )
710    })
711}
712fn number(value: &Value, span: &Span) -> Result<f64, Diagnostic> {
713    match value {
714        Value::Number(number) if number.is_finite() && number.abs() <= sdk::MAX_COORDINATE_M => {
715            Ok(*number)
716        }
717        _ => Err(error(
718            span,
719            "spatial_number",
720            "spatial number must be finite and within +/-1,000,000",
721        )),
722    }
723}
724fn numeric_field(value: &Value, name: &str, span: &Span) -> Result<f64, Diagnostic> {
725    number(field(value, name, span)?, span)
726}
727fn positive(value: f64, span: &Span) -> Result<f64, Diagnostic> {
728    if value.is_finite() && (1e-6..=sdk::MAX_COORDINATE_M).contains(&value) {
729        Ok(value)
730    } else {
731        Err(error(
732            span,
733            "spatial_dimension",
734            "dimensions must be between 0.000001 and 1,000,000",
735        ))
736    }
737}
738fn text<'a>(value: &'a Value, span: &Span) -> Result<&'a str, Diagnostic> {
739    if let Value::Text(text) = value {
740        Ok(text)
741    } else {
742        Err(error(span, "spatial_type", "expected a Str"))
743    }
744}
745fn vector(value: &Value, span: &Span) -> Result<sdk::Vec3, Diagnostic> {
746    if value.type_name() != "Vec3" {
747        return Err(error(span, "spatial_type", "expected a Vec3"));
748    }
749    Ok(sdk::Vec3 {
750        x: numeric_field(value, "x", span)?,
751        y: numeric_field(value, "y", span)?,
752        z: numeric_field(value, "z", span)?,
753    })
754}
755fn tensor<'a>(value: &'a Value, span: &Span) -> Result<&'a sdk::data::Tensor, Diagnostic> {
756    if let Value::Tensor(value) = value {
757        Ok(value)
758    } else {
759        Err(error(span, "spatial_type", "expected a Tensor"))
760    }
761}
762
763// Materialize only at the renderer boundary; strided tensor views preserve their logical order.
764fn tensor_positions(
765    value: &Value,
766    maximum: usize,
767    span: &Span,
768) -> Result<Vec<sdk::Vec3>, Diagnostic> {
769    let tensor = tensor(value, span)?;
770    let shape = tensor.shape();
771    if tensor.dtype() != sdk::data::DType::F32
772        || shape.len() != 2
773        || shape[1] != 3
774        || shape[0] > maximum
775    {
776        return Err(error(
777            span,
778            "spatial_shape",
779            "positions require a bounded N by 3 Tensor[f32]",
780        ));
781    }
782    (0..shape[0])
783        .map(|row| {
784            let coordinate = |axis| {
785                let value = tensor
786                    .get(&[row as isize, axis])
787                    .and_then(|value| value.to_f64())
788                    .map_err(|e| error(span, "spatial_type", e.to_string()))?;
789                if value.abs() > sdk::MAX_COORDINATE_M {
790                    return Err(error(
791                        span,
792                        "spatial_dimension",
793                        "position exceeds coordinate bounds",
794                    ));
795                }
796                Ok(value)
797            };
798            Ok(sdk::Vec3 {
799                x: coordinate(0)?,
800                y: coordinate(1)?,
801                z: coordinate(2)?,
802            })
803        })
804        .collect()
805}
806fn record<const N: usize>(class: &str, fields: [(&str, Value); N]) -> Value {
807    Value::record(
808        class,
809        fields.into_iter().map(|(name, value)| (name.into(), value)),
810    )
811}
812fn vector_value(vector: sdk::Vec3) -> Value {
813    record(
814        "Vec3",
815        [
816            ("x", Value::Number(vector.x)),
817            ("y", Value::Number(vector.y)),
818            ("z", Value::Number(vector.z)),
819        ],
820    )
821}
822fn default_transform() -> Value {
823    record(
824        "Transform",
825        [
826            ("position", vector_value(sdk::Vec3::ZERO)),
827            ("rotation", vector_value(sdk::Vec3::ZERO)),
828            (
829                "scale",
830                vector_value(sdk::Vec3 {
831                    x: 1.0,
832                    y: 1.0,
833                    z: 1.0,
834                }),
835            ),
836        ],
837    )
838}
839fn default_material() -> Value {
840    record(
841        "Material",
842        [
843            ("color", Value::Text("#6fe3ff".into())),
844            ("roughness", Value::Number(0.6)),
845            ("emissive", Value::Number(0.0)),
846        ],
847    )
848}
849fn replace_field(
850    value: &mut Value,
851    key: &str,
852    replacement: Value,
853    span: &Span,
854) -> Result<(), Diagnostic> {
855    if let Value::Record(record) = value {
856        record.fields.insert(key.into(), replacement);
857        Ok(())
858    } else {
859        Err(error(span, "spatial_type", "component must be a record"))
860    }
861}
862fn to_vector(value: sdk::Vec3) -> Vector {
863    Vector::new(value.x as f32, value.y as f32, value.z as f32)
864}
865fn from_vector(value: Vector) -> sdk::Vec3 {
866    sdk::Vec3 {
867        x: f64::from(value.x),
868        y: f64::from(value.y),
869        z: f64::from(value.z),
870    }
871}
872fn pose(transform: sdk::Transform) -> Pose {
873    let q = transform.rotation;
874    Pose::from_parts(
875        to_vector(transform.translation),
876        Rotation::from_xyzw(q.x as f32, q.y as f32, q.z as f32, q.w as f32).normalize(),
877    )
878}
879fn transform(value: &Value, span: &Span) -> Result<sdk::Transform, Diagnostic> {
880    let translation = vector(field(value, "position", span)?, span)?;
881    let angles = vector(field(value, "rotation", span)?, span)?;
882    let scale = vector(field(value, "scale", span)?, span)?;
883    for dimension in [scale.x, scale.y, scale.z] {
884        positive(dimension, span)?;
885    }
886    let q = DQuat::from_euler(EulerRot::XYZ, angles.x, angles.y, angles.z);
887    Ok(sdk::Transform {
888        translation,
889        rotation: sdk::Quaternion {
890            x: q.x,
891            y: q.y,
892            z: q.z,
893            w: q.w,
894        },
895        scale,
896    })
897}
898fn material(value: &Value, span: &Span) -> Result<(sdk::Color, sdk::Material), Diagnostic> {
899    let color = text(field(value, "color", span)?, span)?;
900    let digits = color
901        .strip_prefix('#')
902        .filter(|digits| {
903            matches!(digits.len(), 6 | 8)
904                && digits.is_ascii()
905                && digits.bytes().all(|byte| byte.is_ascii_hexdigit())
906        })
907        .ok_or_else(|| {
908            error(
909                span,
910                "invalid_color",
911                "Material.color must be #RRGGBB or #RRGGBBAA",
912            )
913        })?;
914    let channel = |offset: usize| {
915        u8::from_str_radix(&digits[offset..offset + 2], 16)
916            .map_err(|_| error(span, "invalid_color", "invalid hexadecimal color"))
917    };
918    let roughness = numeric_field(value, "roughness", span)?;
919    let emissive = numeric_field(value, "emissive", span)?;
920    if !(0.0..=1.0).contains(&roughness) || !(0.0..=100.0).contains(&emissive) {
921        return Err(error(
922            span,
923            "invalid_material",
924            "roughness must be 0..=1 and emissive must be 0..=100",
925        ));
926    }
927    Ok((
928        sdk::Color {
929            r: channel(0)?,
930            g: channel(2)?,
931            b: channel(4)?,
932            a: if digits.len() == 8 { channel(6)? } else { 255 },
933        },
934        sdk::Material {
935            roughness: roughness as f32,
936            emissive: emissive as f32,
937        },
938    ))
939}
940fn geometry_component<'a>(
941    components: &'a BTreeMap<String, Value>,
942    span: &Span,
943) -> Result<Option<(&'static str, &'a Value)>, Diagnostic> {
944    let mut present = GEOMETRIES
945        .iter()
946        .filter_map(|name| components.get(*name).map(|value| (*name, value)));
947    let first = present.next();
948    if present.next().is_some() {
949        return Err(error(
950            span,
951            "multiple_geometry",
952            "an entity may have only one geometry component",
953        ));
954    }
955    Ok(first)
956}
957fn body_config(
958    value: &Value,
959    components: &BTreeMap<String, Value>,
960    scale: sdk::Vec3,
961    span: &Span,
962) -> Result<BodyConfig, Diagnostic> {
963    let kind = match text(field(value, "kind", span)?, span)? {
964        "dynamic" => BodyKind::Dynamic,
965        "fixed" => BodyKind::Fixed,
966        "kinematic" => BodyKind::Kinematic,
967        _ => {
968            return Err(error(
969                span,
970                "invalid_body",
971                "RigidBody.kind must be dynamic, fixed, or kinematic",
972            ));
973        }
974    };
975    let shape = match geometry_component(components, span)? {
976        Some(("Sphere", sphere)) => {
977            if scale.x != scale.y || scale.x != scale.z {
978                return Err(error(
979                    span,
980                    "unsupported_shape",
981                    "a physical Sphere requires uniform scale",
982                ));
983            }
984            CollisionShape::Sphere(
985                positive(numeric_field(sphere, "radius", span)? * scale.x, span)? as f32,
986            )
987        }
988        Some(("Box", box_value)) => {
989            let size = vector(field(box_value, "size", span)?, span)?;
990            CollisionShape::Box(Vector::new(
991                positive(size.x * scale.x, span)? as f32,
992                positive(size.y * scale.y, span)? as f32,
993                positive(size.z * scale.z, span)? as f32,
994            ))
995        }
996        _ => {
997            return Err(error(
998                span,
999                "unsupported_shape",
1000                "RigidBody collision geometry must be Sphere or Box",
1001            ));
1002        }
1003    };
1004    let mass = positive(numeric_field(value, "mass", span)?, span)? as f32;
1005    let restitution = numeric_field(value, "restitution", span)?;
1006    let friction = numeric_field(value, "friction", span)?;
1007    if !(0.0..=1.0).contains(&restitution) || !(0.0..=1.0).contains(&friction) {
1008        return Err(error(
1009            span,
1010            "invalid_body",
1011            "restitution and friction must be 0..=1",
1012        ));
1013    }
1014    Ok(BodyConfig {
1015        kind,
1016        shape,
1017        mass,
1018        restitution: restitution as f32,
1019        friction: friction as f32,
1020    })
1021}
1022
1023fn geometry(
1024    name: &str,
1025    value: &Value,
1026    id: &EntityId,
1027    span: &Span,
1028) -> Result<(Geometry, Mesh), Diagnostic> {
1029    let geometry = match name {
1030        "Sphere" => Geometry::Sphere {
1031            radius_m: positive(numeric_field(value, "radius", span)?, span)?,
1032        },
1033        "Box" => Geometry::Box {
1034            size_m: vector(field(value, "size", span)?, span)?,
1035        },
1036        "Cylinder" => Geometry::Cylinder {
1037            radius_m: positive(numeric_field(value, "radius", span)?, span)?,
1038            height_m: positive(numeric_field(value, "height", span)?, span)?,
1039        },
1040        "Line" => Geometry::Line {
1041            from_m: vector(field(value, "from", span)?, span)?,
1042            to_m: vector(field(value, "to", span)?, span)?,
1043        },
1044        "Ray" => {
1045            let origin = vector(field(value, "origin", span)?, span)?;
1046            let direction = vector(field(value, "direction", span)?, span)?;
1047            let length = positive(numeric_field(value, "length", span)?, span)?;
1048            let magnitude = direction.x.hypot(direction.y).hypot(direction.z);
1049            if magnitude <= 1e-12 {
1050                return Err(error(span, "invalid_ray", "Ray.direction must be nonzero"));
1051            }
1052            Geometry::Line {
1053                from_m: origin,
1054                to_m: sdk::Vec3 {
1055                    x: origin.x + direction.x / magnitude * length,
1056                    y: origin.y + direction.y / magnitude * length,
1057                    z: origin.z + direction.z / magnitude * length,
1058                },
1059            }
1060        }
1061        "Quad" => {
1062            let x = positive(numeric_field(value, "width", span)?, span)? / 2.0;
1063            let y = positive(numeric_field(value, "height", span)?, span)? / 2.0;
1064            return Ok((
1065                Geometry::Mesh {
1066                    asset: id.0.clone(),
1067                },
1068                Mesh {
1069                    positions: vec![
1070                        sdk::Vec3 {
1071                            x: -x,
1072                            y: -y,
1073                            z: 0.0,
1074                        },
1075                        sdk::Vec3 { x, y: -y, z: 0.0 },
1076                        sdk::Vec3 { x, y, z: 0.0 },
1077                        sdk::Vec3 { x: -x, y, z: 0.0 },
1078                    ],
1079                    normals: vec![
1080                        sdk::Vec3 {
1081                            x: 0.0,
1082                            y: 0.0,
1083                            z: 1.0
1084                        };
1085                        4
1086                    ],
1087                    indices: vec![0, 1, 2, 0, 2, 3],
1088                },
1089            ));
1090        }
1091        "Mesh" => {
1092            return Ok((
1093                Geometry::Mesh {
1094                    asset: id.0.clone(),
1095                },
1096                custom_mesh(value, span)?,
1097            ));
1098        }
1099        "PointCloud" => {
1100            return Ok((
1101                Geometry::Mesh {
1102                    asset: id.0.clone(),
1103                },
1104                point_cloud(value, span)?,
1105            ));
1106        }
1107        _ => {
1108            return Err(error(
1109                span,
1110                "unsupported_geometry",
1111                "unsupported geometry component",
1112            ));
1113        }
1114    };
1115    let mesh = sdk::tessellate(&geometry)
1116        .map_err(|diagnostic| error(span, "invalid_geometry", diagnostic.to_string()))?;
1117    Ok((geometry, mesh))
1118}
1119
1120pub(crate) fn custom_mesh(value: &Value, span: &Span) -> Result<Mesh, Diagnostic> {
1121    let positions = tensor_positions(field(value, "positions", span)?, MAX_MESH_VERTICES, span)?;
1122    let index_data = tensor(field(value, "indices", span)?, span)?;
1123    let shape = index_data.shape();
1124    if positions.len() < 3
1125        || index_data.dtype() != sdk::data::DType::U32
1126        || shape.len() != 2
1127        || shape[1] != 3
1128        || shape[0] == 0
1129        || shape[0] > MAX_MESH_INDICES / 3
1130    {
1131        return Err(error(
1132            span,
1133            "invalid_mesh",
1134            "Mesh requires N by 3 positions and M by 3 triangle indices within the mesh limits",
1135        ));
1136    }
1137    let mut indices = Vec::with_capacity(shape[0] * 3);
1138    for triangle in 0..shape[0] {
1139        for corner in 0..3 {
1140            let index = index_data
1141                .get(&[triangle as isize, corner])
1142                .map_err(|e| error(span, "invalid_mesh_index", e.to_string()))?
1143                .as_u128()
1144                .ok_or_else(|| error(span, "spatial_type", "mesh indices must be u32"))?;
1145            if index >= positions.len() as u128 {
1146                return Err(error(
1147                    span,
1148                    "invalid_mesh_index",
1149                    "mesh indices must address an existing vertex",
1150                ));
1151            }
1152            indices.push(index as u32);
1153        }
1154    }
1155    let supplied_normals =
1156        tensor_positions(field(value, "normals", span)?, MAX_MESH_VERTICES, span)?;
1157    if !supplied_normals.is_empty() && supplied_normals.len() != positions.len() {
1158        return Err(error(
1159            span,
1160            "invalid_mesh_normals",
1161            "Mesh.normals must be empty or have one N by 3 normal for every position",
1162        ));
1163    }
1164    let mut normals = vec![sdk::Vec3::ZERO; positions.len()];
1165    for triangle in indices.as_chunks::<3>().0 {
1166        let a = positions[triangle[0] as usize];
1167        let b = positions[triangle[1] as usize];
1168        let c = positions[triangle[2] as usize];
1169        let ab = sdk::Vec3 {
1170            x: b.x - a.x,
1171            y: b.y - a.y,
1172            z: b.z - a.z,
1173        };
1174        let ac = sdk::Vec3 {
1175            x: c.x - a.x,
1176            y: c.y - a.y,
1177            z: c.z - a.z,
1178        };
1179        let normal = sdk::Vec3 {
1180            x: ab.y * ac.z - ab.z * ac.y,
1181            y: ab.z * ac.x - ab.x * ac.z,
1182            z: ab.x * ac.y - ab.y * ac.x,
1183        };
1184        if normal.x.hypot(normal.y).hypot(normal.z) <= 1e-18 {
1185            if !supplied_normals.is_empty() {
1186                continue;
1187            }
1188            return Err(error(
1189                span,
1190                "degenerate_mesh",
1191                "mesh triangles must have nonzero area",
1192            ));
1193        }
1194        for &index in triangle {
1195            let n = &mut normals[index as usize];
1196            n.x += normal.x;
1197            n.y += normal.y;
1198            n.z += normal.z;
1199        }
1200    }
1201    if !supplied_normals.is_empty() {
1202        normals = supplied_normals;
1203        for normal in &mut normals {
1204            let length = normal.x.hypot(normal.y).hypot(normal.z);
1205            if !length.is_finite() || length <= 1e-18 {
1206                return Err(error(
1207                    span,
1208                    "invalid_mesh_normals",
1209                    "Mesh.normals must be finite and nonzero",
1210                ));
1211            }
1212            normal.x /= length;
1213            normal.y /= length;
1214            normal.z /= length;
1215        }
1216        return Ok(Mesh {
1217            positions,
1218            normals,
1219            indices,
1220        });
1221    }
1222    for normal in &mut normals {
1223        let length = normal.x.hypot(normal.y).hypot(normal.z);
1224        if length <= 1e-18 {
1225            return Err(error(
1226                span,
1227                "invalid_mesh_normals",
1228                "mesh vertices must have a nonzero accumulated normal; remove unused vertices and split opposing faces",
1229            ));
1230        }
1231        normal.x /= length;
1232        normal.y /= length;
1233        normal.z /= length;
1234    }
1235    Ok(Mesh {
1236        positions,
1237        normals,
1238        indices,
1239    })
1240}
1241
1242fn point_cloud(value: &Value, span: &Span) -> Result<Mesh, Diagnostic> {
1243    let points = tensor_positions(field(value, "points", span)?, MAX_CLOUD_POINTS, span)?;
1244    if points.is_empty() || points.len() > MAX_CLOUD_POINTS {
1245        return Err(error(
1246            span,
1247            "point_cloud_limit",
1248            format!("PointCloud requires 1..={MAX_CLOUD_POINTS} points"),
1249        ));
1250    }
1251    let radius = positive(numeric_field(value, "radius", span)?, span)?;
1252    let sphere = sdk::tessellate(&Geometry::Sphere { radius_m: radius })
1253        .map_err(|diagnostic| error(span, "invalid_geometry", diagnostic.to_string()))?;
1254    let mut mesh = Mesh {
1255        positions: Vec::with_capacity(sphere.positions.len() * points.len()),
1256        normals: Vec::with_capacity(sphere.normals.len() * points.len()),
1257        indices: Vec::with_capacity(sphere.indices.len() * points.len()),
1258    };
1259    for point in points {
1260        let offset = u32::try_from(mesh.positions.len())
1261            .map_err(|_| error(span, "mesh_limit", "point cloud exceeds index range"))?;
1262        for vertex in &sphere.positions {
1263            let vertex = sdk::Vec3 {
1264                x: vertex.x + point.x,
1265                y: vertex.y + point.y,
1266                z: vertex.z + point.z,
1267            };
1268            if [vertex.x, vertex.y, vertex.z]
1269                .iter()
1270                .any(|coordinate| coordinate.abs() > sdk::MAX_COORDINATE_M)
1271            {
1272                return Err(error(
1273                    span,
1274                    "spatial_dimension",
1275                    "point cloud surface exceeds coordinate bounds",
1276                ));
1277            }
1278            mesh.positions.push(vertex);
1279        }
1280        mesh.normals.extend_from_slice(&sphere.normals);
1281        mesh.indices
1282            .extend(sphere.indices.iter().map(|index| index + offset));
1283    }
1284    Ok(mesh)
1285}