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        for entity in &snapshot.entities {
296            let span = &entity.source;
297            // Presentation values must remain valid even on resource-only entities.
298            let transform = entity
299                .components
300                .get("Transform")
301                .map(|value| transform(value, span))
302                .transpose()?
303                .unwrap_or_default();
304            let (color, material) = entity
305                .components
306                .get("Material")
307                .map(|value| material(value, span))
308                .transpose()?
309                .unwrap_or((
310                    sdk::Color {
311                        r: 111,
312                        g: 227,
313                        b: 255,
314                        a: 255,
315                    },
316                    sdk::Material {
317                        roughness: 0.6,
318                        emissive: 0.0,
319                    },
320                ));
321            let Some((name, value)) = geometry_component(&entity.components, span)? else {
322                continue;
323            };
324            let id = EntityId(format!("entity_{}", entity.id));
325            let (geometry, mesh) = geometry(name, value, &id, span)?;
326            vertex_count += mesh.positions.len();
327            if vertex_count > MAX_SCENE_VERTICES {
328                return Err(error(
329                    span,
330                    "mesh_limit",
331                    "scene exceeds its generated vertex budget",
332                ));
333            }
334            meshes.insert(id.clone(), mesh);
335            document.entities.push(sdk::Entity {
336                id,
337                parent: None,
338                transform,
339                geometry,
340                color,
341                material,
342                animations: Vec::new(),
343                actions: BTreeMap::new(),
344            });
345        }
346        sdk::validate(&document).map_err(|diagnostic| {
347            error(&Span::default(), "invalid_scene", diagnostic.to_string())
348        })?;
349        // The SDK evaluates transforms; custom meshes are resolved by this adapter.
350        let mut sampling_document = document.clone();
351        for entity in &mut sampling_document.entities {
352            entity.geometry = Geometry::Group;
353        }
354        let evaluator = sdk::Runtime::new(sampling_document).map_err(|diagnostic| {
355            error(&Span::default(), "invalid_scene", diagnostic.to_string())
356        })?;
357        let mut frame = evaluator.sample(snapshot.time).map_err(|diagnostic| {
358            error(&Span::default(), "invalid_scene", diagnostic.to_string())
359        })?;
360        for draw in &mut frame.draws {
361            draw.mesh_entity = Some(draw.entity.clone());
362        }
363        Ok(RenderOutput {
364            ui,
365            document,
366            meshes,
367            frame,
368            contacts: self.contacts.clone(),
369        })
370    }
371
372    fn normalize(&mut self) -> Result<(), Diagnostic> {
373        let mut updates = Vec::new();
374        for entity in self.snapshot().entities {
375            if geometry_component(&entity.components, &entity.source)?.is_some() {
376                if !entity.components.contains_key("Transform") {
377                    updates.push((entity.id, default_transform()));
378                }
379                if !entity.components.contains_key("Material") {
380                    updates.push((entity.id, default_material()));
381                }
382            }
383        }
384        self.machine.upsert_components_for_adapter(&updates)
385    }
386
387    fn reconcile(&mut self, apply_forces: bool) -> Result<(), Diagnostic> {
388        let snapshot = self.snapshot();
389        let present: BTreeSet<_> = snapshot
390            .entities
391            .iter()
392            .filter(|entity| entity.components.contains_key("RigidBody"))
393            .map(|entity| entity.id)
394            .collect();
395        let removed: Vec<_> = self
396            .bindings
397            .keys()
398            .filter(|id| !present.contains(id))
399            .copied()
400            .collect();
401        for id in removed {
402            if let Some(binding) = self.bindings.remove(&id) {
403                self.world.remove_body(binding.handle);
404            }
405        }
406        let mut spatial_change = false;
407        for entity in &snapshot.entities {
408            let span = &entity.source;
409            let force = entity
410                .components
411                .get("Force")
412                .map(|value| vector(field(value, "value", span)?, span))
413                .transpose()?;
414            let impulse = entity
415                .components
416                .get("Impulse")
417                .map(|value| vector(field(value, "value", span)?, span))
418                .transpose()?;
419            let Some(body_value) = entity.components.get("RigidBody") else {
420                if force.is_some() || impulse.is_some() {
421                    return Err(error(
422                        span,
423                        "missing_body",
424                        "Force and Impulse require a dynamic RigidBody",
425                    ));
426                }
427                continue;
428            };
429            let transform_value = entity.components.get("Transform").ok_or_else(|| {
430                error(
431                    span,
432                    "missing_shape",
433                    "RigidBody requires a Sphere or Box geometry",
434                )
435            })?;
436            let transform = transform(transform_value, span)?;
437            let config = body_config(body_value, &entity.components, transform.scale, span)?;
438            if config.kind != BodyKind::Dynamic && (force.is_some() || impulse.is_some()) {
439                return Err(error(
440                    span,
441                    "invalid_force",
442                    "Force and Impulse require a dynamic RigidBody",
443                ));
444            }
445            let velocity = vector(field(body_value, "velocity", span)?, span)?;
446            let angular_velocity = vector(field(body_value, "angular_velocity", span)?, span)?;
447            if config.kind == BodyKind::Fixed
448                && (velocity != sdk::Vec3::ZERO || angular_velocity != sdk::Vec3::ZERO)
449            {
450                return Err(error(
451                    span,
452                    "fixed_velocity",
453                    "a fixed RigidBody cannot have nonzero velocity",
454                ));
455            }
456            let mut old = self.bindings.get(&entity.id).cloned();
457            if let Some(binding) = &old {
458                if binding.config.kind == BodyKind::Dynamic
459                    && (binding.transform.field("position") != transform_value.field("position")
460                        || binding.transform.field("rotation") != transform_value.field("rotation"))
461                {
462                    return Err(error(
463                        span,
464                        "dynamic_pose_edit",
465                        "dynamic body position and rotation are owned by physics; use velocity, Force, or Impulse",
466                    ));
467                }
468                if binding.config != config {
469                    self.world.remove_body(binding.handle);
470                    old = None;
471                }
472            }
473            let pose = pose(transform);
474            let handle = if let Some(binding) = old {
475                let body =
476                    self.world.bodies.get_mut(binding.handle).ok_or_else(|| {
477                        error(span, "physics_state", "missing persistent rigid body")
478                    })?;
479                if binding.transform != *transform_value {
480                    body.set_position(pose, true);
481                    spatial_change = true;
482                }
483                if binding.rigid_body.field("velocity") != body_value.field("velocity") {
484                    body.set_linvel(to_vector(velocity), true);
485                }
486                if binding.rigid_body.field("angular_velocity")
487                    != body_value.field("angular_velocity")
488                {
489                    body.set_angvel(to_vector(angular_velocity), true);
490                }
491                binding.handle
492            } else {
493                let builder = match config.kind {
494                    BodyKind::Dynamic => RigidBodyBuilder::dynamic(),
495                    BodyKind::Fixed => RigidBodyBuilder::fixed(),
496                    BodyKind::Kinematic => RigidBodyBuilder::kinematic_velocity_based(),
497                };
498                let collider = match config.shape {
499                    CollisionShape::Sphere(radius) => ColliderBuilder::ball(radius),
500                    CollisionShape::Box(size) => {
501                        ColliderBuilder::cuboid(size.x / 2.0, size.y / 2.0, size.z / 2.0)
502                    }
503                };
504                let (handle, _) = self.world.insert(
505                    builder
506                        .pose(pose)
507                        .linvel(to_vector(velocity))
508                        .angvel(to_vector(angular_velocity))
509                        .ccd_enabled(true)
510                        .user_data(u128::from(entity.id)),
511                    collider
512                        .mass(config.mass)
513                        .restitution(config.restitution)
514                        .friction(config.friction)
515                        .user_data(u128::from(entity.id)),
516                );
517                spatial_change = true;
518                handle
519            };
520            if apply_forces {
521                let body = self.world.bodies.get_mut(handle).ok_or_else(|| {
522                    error(
523                        span,
524                        "physics_state",
525                        "missing rigid body during force application",
526                    )
527                })?;
528                body.reset_forces(false);
529                if let Some(force) = force {
530                    body.add_force(to_vector(force), true);
531                }
532                if let Some(impulse) = impulse {
533                    body.apply_impulse(to_vector(impulse), true);
534                }
535            }
536            self.bindings.insert(
537                entity.id,
538                Binding {
539                    handle,
540                    config,
541                    transform: transform_value.clone(),
542                    rigid_body: body_value.clone(),
543                },
544            );
545        }
546        // Contacts describe a completed tick, and are invalidated by pose/shape edits.
547        if spatial_change {
548            self.contacts.clear();
549        }
550        self.contacts
551            .retain(|pair| present.contains(&pair.entity_a) && present.contains(&pair.entity_b));
552        Ok(())
553    }
554
555    fn write_back(&mut self) -> Result<(), Diagnostic> {
556        let mut updates = Vec::new();
557        let snapshot = self.snapshot();
558        for entity in &snapshot.entities {
559            let Some(binding) = self.bindings.get_mut(&entity.id) else {
560                continue;
561            };
562            let body = self.world.bodies.get(binding.handle).ok_or_else(|| {
563                error(
564                    &entity.source,
565                    "physics_state",
566                    "missing simulated rigid body",
567                )
568            })?;
569            let position = body.translation();
570            let (x, y, z) = body.rotation().to_euler(EulerRot::XYZ);
571            let mut transform_value = binding.transform.clone();
572            replace_field(
573                &mut transform_value,
574                "position",
575                vector_value(from_vector(position)),
576                &entity.source,
577            )?;
578            replace_field(
579                &mut transform_value,
580                "rotation",
581                vector_value(sdk::Vec3 {
582                    x: f64::from(x),
583                    y: f64::from(y),
584                    z: f64::from(z),
585                }),
586                &entity.source,
587            )?;
588            let mut rigid_body = binding.rigid_body.clone();
589            replace_field(
590                &mut rigid_body,
591                "velocity",
592                vector_value(from_vector(body.linvel())),
593                &entity.source,
594            )?;
595            replace_field(
596                &mut rigid_body,
597                "angular_velocity",
598                vector_value(from_vector(body.angvel())),
599                &entity.source,
600            )?;
601            binding.transform = transform_value.clone();
602            binding.rigid_body = rigid_body.clone();
603            updates.push((entity.id, transform_value));
604            updates.push((entity.id, rigid_body));
605            if entity.components.contains_key("Impulse") {
606                updates.push((
607                    entity.id,
608                    record("Impulse", [("value", vector_value(sdk::Vec3::ZERO))]),
609                ));
610            }
611        }
612        self.machine.patch_components_for_adapter(&updates)?;
613        let mut pairs = BTreeSet::new();
614        for contact in self
615            .world
616            .contact_pairs()
617            .filter(|contact| contact.has_any_active_contact())
618        {
619            let Some(a) = self.world.colliders.get(contact.collider1) else {
620                continue;
621            };
622            let Some(b) = self.world.colliders.get(contact.collider2) else {
623                continue;
624            };
625            let a = u64::try_from(a.user_data).map_err(|_| {
626                error(
627                    &Span::default(),
628                    "physics_state",
629                    "invalid contact entity ID",
630                )
631            })?;
632            let b = u64::try_from(b.user_data).map_err(|_| {
633                error(
634                    &Span::default(),
635                    "physics_state",
636                    "invalid contact entity ID",
637                )
638            })?;
639            pairs.insert(Contact {
640                entity_a: a.min(b),
641                entity_b: a.max(b),
642            });
643        }
644        self.contacts = pairs.into_iter().collect();
645        Ok(())
646    }
647}
648
649fn error(span: &Span, code: &str, message: impl Into<String>) -> Diagnostic {
650    Diagnostic::new(code, message, span.clone())
651}
652fn field<'a>(value: &'a Value, name: &str, span: &Span) -> Result<&'a Value, Diagnostic> {
653    value.field(name).ok_or_else(|| {
654        error(
655            span,
656            "spatial_field",
657            format!("{} requires field `{name}`", value.type_name()),
658        )
659    })
660}
661fn number(value: &Value, span: &Span) -> Result<f64, Diagnostic> {
662    match value {
663        Value::Number(number) if number.is_finite() && number.abs() <= sdk::MAX_COORDINATE_M => {
664            Ok(*number)
665        }
666        _ => Err(error(
667            span,
668            "spatial_number",
669            "spatial number must be finite and within +/-1,000,000",
670        )),
671    }
672}
673fn numeric_field(value: &Value, name: &str, span: &Span) -> Result<f64, Diagnostic> {
674    number(field(value, name, span)?, span)
675}
676fn positive(value: f64, span: &Span) -> Result<f64, Diagnostic> {
677    if value.is_finite() && (1e-6..=sdk::MAX_COORDINATE_M).contains(&value) {
678        Ok(value)
679    } else {
680        Err(error(
681            span,
682            "spatial_dimension",
683            "dimensions must be between 0.000001 and 1,000,000",
684        ))
685    }
686}
687fn text<'a>(value: &'a Value, span: &Span) -> Result<&'a str, Diagnostic> {
688    if let Value::Text(text) = value {
689        Ok(text)
690    } else {
691        Err(error(span, "spatial_type", "expected a Str"))
692    }
693}
694fn vector(value: &Value, span: &Span) -> Result<sdk::Vec3, Diagnostic> {
695    if value.type_name() != "Vec3" {
696        return Err(error(span, "spatial_type", "expected a Vec3"));
697    }
698    Ok(sdk::Vec3 {
699        x: numeric_field(value, "x", span)?,
700        y: numeric_field(value, "y", span)?,
701        z: numeric_field(value, "z", span)?,
702    })
703}
704fn tensor<'a>(value: &'a Value, span: &Span) -> Result<&'a sdk::data::Tensor, Diagnostic> {
705    if let Value::Tensor(value) = value {
706        Ok(value)
707    } else {
708        Err(error(span, "spatial_type", "expected a Tensor"))
709    }
710}
711
712// Materialize only at the renderer boundary; strided tensor views preserve their logical order.
713fn tensor_positions(
714    value: &Value,
715    maximum: usize,
716    span: &Span,
717) -> Result<Vec<sdk::Vec3>, Diagnostic> {
718    let tensor = tensor(value, span)?;
719    let shape = tensor.shape();
720    if tensor.dtype() != sdk::data::DType::F32
721        || shape.len() != 2
722        || shape[1] != 3
723        || shape[0] > maximum
724    {
725        return Err(error(
726            span,
727            "spatial_shape",
728            "positions require a bounded N by 3 Tensor[f32]",
729        ));
730    }
731    (0..shape[0])
732        .map(|row| {
733            let coordinate = |axis| {
734                let value = tensor
735                    .get(&[row as isize, axis])
736                    .and_then(|value| value.to_f64())
737                    .map_err(|e| error(span, "spatial_type", e.to_string()))?;
738                if value.abs() > sdk::MAX_COORDINATE_M {
739                    return Err(error(
740                        span,
741                        "spatial_dimension",
742                        "position exceeds coordinate bounds",
743                    ));
744                }
745                Ok(value)
746            };
747            Ok(sdk::Vec3 {
748                x: coordinate(0)?,
749                y: coordinate(1)?,
750                z: coordinate(2)?,
751            })
752        })
753        .collect()
754}
755fn record<const N: usize>(class: &str, fields: [(&str, Value); N]) -> Value {
756    Value::record(
757        class,
758        fields.into_iter().map(|(name, value)| (name.into(), value)),
759    )
760}
761fn vector_value(vector: sdk::Vec3) -> Value {
762    record(
763        "Vec3",
764        [
765            ("x", Value::Number(vector.x)),
766            ("y", Value::Number(vector.y)),
767            ("z", Value::Number(vector.z)),
768        ],
769    )
770}
771fn default_transform() -> Value {
772    record(
773        "Transform",
774        [
775            ("position", vector_value(sdk::Vec3::ZERO)),
776            ("rotation", vector_value(sdk::Vec3::ZERO)),
777            (
778                "scale",
779                vector_value(sdk::Vec3 {
780                    x: 1.0,
781                    y: 1.0,
782                    z: 1.0,
783                }),
784            ),
785        ],
786    )
787}
788fn default_material() -> Value {
789    record(
790        "Material",
791        [
792            ("color", Value::Text("#6fe3ff".into())),
793            ("roughness", Value::Number(0.6)),
794            ("emissive", Value::Number(0.0)),
795        ],
796    )
797}
798fn replace_field(
799    value: &mut Value,
800    key: &str,
801    replacement: Value,
802    span: &Span,
803) -> Result<(), Diagnostic> {
804    if let Value::Record(record) = value {
805        record.fields.insert(key.into(), replacement);
806        Ok(())
807    } else {
808        Err(error(span, "spatial_type", "component must be a record"))
809    }
810}
811fn to_vector(value: sdk::Vec3) -> Vector {
812    Vector::new(value.x as f32, value.y as f32, value.z as f32)
813}
814fn from_vector(value: Vector) -> sdk::Vec3 {
815    sdk::Vec3 {
816        x: f64::from(value.x),
817        y: f64::from(value.y),
818        z: f64::from(value.z),
819    }
820}
821fn pose(transform: sdk::Transform) -> Pose {
822    let q = transform.rotation;
823    Pose::from_parts(
824        to_vector(transform.translation),
825        Rotation::from_xyzw(q.x as f32, q.y as f32, q.z as f32, q.w as f32).normalize(),
826    )
827}
828fn transform(value: &Value, span: &Span) -> Result<sdk::Transform, Diagnostic> {
829    let translation = vector(field(value, "position", span)?, span)?;
830    let angles = vector(field(value, "rotation", span)?, span)?;
831    let scale = vector(field(value, "scale", span)?, span)?;
832    for dimension in [scale.x, scale.y, scale.z] {
833        positive(dimension, span)?;
834    }
835    let q = DQuat::from_euler(EulerRot::XYZ, angles.x, angles.y, angles.z);
836    Ok(sdk::Transform {
837        translation,
838        rotation: sdk::Quaternion {
839            x: q.x,
840            y: q.y,
841            z: q.z,
842            w: q.w,
843        },
844        scale,
845    })
846}
847fn material(value: &Value, span: &Span) -> Result<(sdk::Color, sdk::Material), Diagnostic> {
848    let color = text(field(value, "color", span)?, span)?;
849    let digits = color
850        .strip_prefix('#')
851        .filter(|digits| {
852            matches!(digits.len(), 6 | 8)
853                && digits.is_ascii()
854                && digits.bytes().all(|byte| byte.is_ascii_hexdigit())
855        })
856        .ok_or_else(|| {
857            error(
858                span,
859                "invalid_color",
860                "Material.color must be #RRGGBB or #RRGGBBAA",
861            )
862        })?;
863    let channel = |offset: usize| {
864        u8::from_str_radix(&digits[offset..offset + 2], 16)
865            .map_err(|_| error(span, "invalid_color", "invalid hexadecimal color"))
866    };
867    let roughness = numeric_field(value, "roughness", span)?;
868    let emissive = numeric_field(value, "emissive", span)?;
869    if !(0.0..=1.0).contains(&roughness) || !(0.0..=100.0).contains(&emissive) {
870        return Err(error(
871            span,
872            "invalid_material",
873            "roughness must be 0..=1 and emissive must be 0..=100",
874        ));
875    }
876    Ok((
877        sdk::Color {
878            r: channel(0)?,
879            g: channel(2)?,
880            b: channel(4)?,
881            a: if digits.len() == 8 { channel(6)? } else { 255 },
882        },
883        sdk::Material {
884            roughness: roughness as f32,
885            emissive: emissive as f32,
886        },
887    ))
888}
889fn geometry_component<'a>(
890    components: &'a BTreeMap<String, Value>,
891    span: &Span,
892) -> Result<Option<(&'static str, &'a Value)>, Diagnostic> {
893    let mut present = GEOMETRIES
894        .iter()
895        .filter_map(|name| components.get(*name).map(|value| (*name, value)));
896    let first = present.next();
897    if present.next().is_some() {
898        return Err(error(
899            span,
900            "multiple_geometry",
901            "an entity may have only one geometry component",
902        ));
903    }
904    Ok(first)
905}
906fn body_config(
907    value: &Value,
908    components: &BTreeMap<String, Value>,
909    scale: sdk::Vec3,
910    span: &Span,
911) -> Result<BodyConfig, Diagnostic> {
912    let kind = match text(field(value, "kind", span)?, span)? {
913        "dynamic" => BodyKind::Dynamic,
914        "fixed" => BodyKind::Fixed,
915        "kinematic" => BodyKind::Kinematic,
916        _ => {
917            return Err(error(
918                span,
919                "invalid_body",
920                "RigidBody.kind must be dynamic, fixed, or kinematic",
921            ));
922        }
923    };
924    let shape = match geometry_component(components, span)? {
925        Some(("Sphere", sphere)) => {
926            if scale.x != scale.y || scale.x != scale.z {
927                return Err(error(
928                    span,
929                    "unsupported_shape",
930                    "a physical Sphere requires uniform scale",
931                ));
932            }
933            CollisionShape::Sphere(
934                positive(numeric_field(sphere, "radius", span)? * scale.x, span)? as f32,
935            )
936        }
937        Some(("Box", box_value)) => {
938            let size = vector(field(box_value, "size", span)?, span)?;
939            CollisionShape::Box(Vector::new(
940                positive(size.x * scale.x, span)? as f32,
941                positive(size.y * scale.y, span)? as f32,
942                positive(size.z * scale.z, span)? as f32,
943            ))
944        }
945        _ => {
946            return Err(error(
947                span,
948                "unsupported_shape",
949                "RigidBody collision geometry must be Sphere or Box",
950            ));
951        }
952    };
953    let mass = positive(numeric_field(value, "mass", span)?, span)? as f32;
954    let restitution = numeric_field(value, "restitution", span)?;
955    let friction = numeric_field(value, "friction", span)?;
956    if !(0.0..=1.0).contains(&restitution) || !(0.0..=1.0).contains(&friction) {
957        return Err(error(
958            span,
959            "invalid_body",
960            "restitution and friction must be 0..=1",
961        ));
962    }
963    Ok(BodyConfig {
964        kind,
965        shape,
966        mass,
967        restitution: restitution as f32,
968        friction: friction as f32,
969    })
970}
971
972fn geometry(
973    name: &str,
974    value: &Value,
975    id: &EntityId,
976    span: &Span,
977) -> Result<(Geometry, Mesh), Diagnostic> {
978    let geometry = match name {
979        "Sphere" => Geometry::Sphere {
980            radius_m: positive(numeric_field(value, "radius", span)?, span)?,
981        },
982        "Box" => Geometry::Box {
983            size_m: vector(field(value, "size", span)?, span)?,
984        },
985        "Cylinder" => Geometry::Cylinder {
986            radius_m: positive(numeric_field(value, "radius", span)?, span)?,
987            height_m: positive(numeric_field(value, "height", span)?, span)?,
988        },
989        "Line" => Geometry::Line {
990            from_m: vector(field(value, "from", span)?, span)?,
991            to_m: vector(field(value, "to", span)?, span)?,
992        },
993        "Ray" => {
994            let origin = vector(field(value, "origin", span)?, span)?;
995            let direction = vector(field(value, "direction", span)?, span)?;
996            let length = positive(numeric_field(value, "length", span)?, span)?;
997            let magnitude = direction.x.hypot(direction.y).hypot(direction.z);
998            if magnitude <= 1e-12 {
999                return Err(error(span, "invalid_ray", "Ray.direction must be nonzero"));
1000            }
1001            Geometry::Line {
1002                from_m: origin,
1003                to_m: sdk::Vec3 {
1004                    x: origin.x + direction.x / magnitude * length,
1005                    y: origin.y + direction.y / magnitude * length,
1006                    z: origin.z + direction.z / magnitude * length,
1007                },
1008            }
1009        }
1010        "Quad" => {
1011            let x = positive(numeric_field(value, "width", span)?, span)? / 2.0;
1012            let y = positive(numeric_field(value, "height", span)?, span)? / 2.0;
1013            return Ok((
1014                Geometry::Mesh {
1015                    asset: id.0.clone(),
1016                },
1017                Mesh {
1018                    positions: vec![
1019                        sdk::Vec3 {
1020                            x: -x,
1021                            y: -y,
1022                            z: 0.0,
1023                        },
1024                        sdk::Vec3 { x, y: -y, z: 0.0 },
1025                        sdk::Vec3 { x, y, z: 0.0 },
1026                        sdk::Vec3 { x: -x, y, z: 0.0 },
1027                    ],
1028                    normals: vec![
1029                        sdk::Vec3 {
1030                            x: 0.0,
1031                            y: 0.0,
1032                            z: 1.0
1033                        };
1034                        4
1035                    ],
1036                    indices: vec![0, 1, 2, 0, 2, 3],
1037                },
1038            ));
1039        }
1040        "Mesh" => {
1041            return Ok((
1042                Geometry::Mesh {
1043                    asset: id.0.clone(),
1044                },
1045                custom_mesh(value, span)?,
1046            ));
1047        }
1048        "PointCloud" => {
1049            return Ok((
1050                Geometry::Mesh {
1051                    asset: id.0.clone(),
1052                },
1053                point_cloud(value, span)?,
1054            ));
1055        }
1056        _ => {
1057            return Err(error(
1058                span,
1059                "unsupported_geometry",
1060                "unsupported geometry component",
1061            ));
1062        }
1063    };
1064    let mesh = sdk::tessellate(&geometry)
1065        .map_err(|diagnostic| error(span, "invalid_geometry", diagnostic.to_string()))?;
1066    Ok((geometry, mesh))
1067}
1068
1069fn custom_mesh(value: &Value, span: &Span) -> Result<Mesh, Diagnostic> {
1070    let positions = tensor_positions(field(value, "positions", span)?, MAX_MESH_VERTICES, span)?;
1071    let index_data = tensor(field(value, "indices", span)?, span)?;
1072    let shape = index_data.shape();
1073    if positions.len() < 3
1074        || index_data.dtype() != sdk::data::DType::U32
1075        || shape.len() != 2
1076        || shape[1] != 3
1077        || shape[0] == 0
1078        || shape[0] > MAX_MESH_INDICES / 3
1079    {
1080        return Err(error(
1081            span,
1082            "invalid_mesh",
1083            "Mesh requires N by 3 positions and M by 3 triangle indices within the mesh limits",
1084        ));
1085    }
1086    let mut indices = Vec::with_capacity(shape[0] * 3);
1087    for triangle in 0..shape[0] {
1088        for corner in 0..3 {
1089            let index = index_data
1090                .get(&[triangle as isize, corner])
1091                .map_err(|e| error(span, "invalid_mesh_index", e.to_string()))?
1092                .as_u128()
1093                .ok_or_else(|| error(span, "spatial_type", "mesh indices must be u32"))?;
1094            if index >= positions.len() as u128 {
1095                return Err(error(
1096                    span,
1097                    "invalid_mesh_index",
1098                    "mesh indices must address an existing vertex",
1099                ));
1100            }
1101            indices.push(index as u32);
1102        }
1103    }
1104    let mut normals = vec![sdk::Vec3::ZERO; positions.len()];
1105    for triangle in indices.as_chunks::<3>().0 {
1106        let a = positions[triangle[0] as usize];
1107        let b = positions[triangle[1] as usize];
1108        let c = positions[triangle[2] as usize];
1109        let ab = sdk::Vec3 {
1110            x: b.x - a.x,
1111            y: b.y - a.y,
1112            z: b.z - a.z,
1113        };
1114        let ac = sdk::Vec3 {
1115            x: c.x - a.x,
1116            y: c.y - a.y,
1117            z: c.z - a.z,
1118        };
1119        let normal = sdk::Vec3 {
1120            x: ab.y * ac.z - ab.z * ac.y,
1121            y: ab.z * ac.x - ab.x * ac.z,
1122            z: ab.x * ac.y - ab.y * ac.x,
1123        };
1124        if normal.x.hypot(normal.y).hypot(normal.z) <= 1e-18 {
1125            return Err(error(
1126                span,
1127                "degenerate_mesh",
1128                "mesh triangles must have nonzero area",
1129            ));
1130        }
1131        for &index in triangle {
1132            let n = &mut normals[index as usize];
1133            n.x += normal.x;
1134            n.y += normal.y;
1135            n.z += normal.z;
1136        }
1137    }
1138    for normal in &mut normals {
1139        let length = normal.x.hypot(normal.y).hypot(normal.z);
1140        if length <= 1e-18 {
1141            return Err(error(
1142                span,
1143                "invalid_mesh_normals",
1144                "mesh vertices must have a nonzero accumulated normal; remove unused vertices and split opposing faces",
1145            ));
1146        }
1147        normal.x /= length;
1148        normal.y /= length;
1149        normal.z /= length;
1150    }
1151    Ok(Mesh {
1152        positions,
1153        normals,
1154        indices,
1155    })
1156}
1157
1158fn point_cloud(value: &Value, span: &Span) -> Result<Mesh, Diagnostic> {
1159    let points = tensor_positions(field(value, "points", span)?, MAX_CLOUD_POINTS, span)?;
1160    if points.is_empty() || points.len() > MAX_CLOUD_POINTS {
1161        return Err(error(
1162            span,
1163            "point_cloud_limit",
1164            format!("PointCloud requires 1..={MAX_CLOUD_POINTS} points"),
1165        ));
1166    }
1167    let radius = positive(numeric_field(value, "radius", span)?, span)?;
1168    let sphere = sdk::tessellate(&Geometry::Sphere { radius_m: radius })
1169        .map_err(|diagnostic| error(span, "invalid_geometry", diagnostic.to_string()))?;
1170    let mut mesh = Mesh {
1171        positions: Vec::with_capacity(sphere.positions.len() * points.len()),
1172        normals: Vec::with_capacity(sphere.normals.len() * points.len()),
1173        indices: Vec::with_capacity(sphere.indices.len() * points.len()),
1174    };
1175    for point in points {
1176        let offset = u32::try_from(mesh.positions.len())
1177            .map_err(|_| error(span, "mesh_limit", "point cloud exceeds index range"))?;
1178        for vertex in &sphere.positions {
1179            let vertex = sdk::Vec3 {
1180                x: vertex.x + point.x,
1181                y: vertex.y + point.y,
1182                z: vertex.z + point.z,
1183            };
1184            if [vertex.x, vertex.y, vertex.z]
1185                .iter()
1186                .any(|coordinate| coordinate.abs() > sdk::MAX_COORDINATE_M)
1187            {
1188                return Err(error(
1189                    span,
1190                    "spatial_dimension",
1191                    "point cloud surface exceeds coordinate bounds",
1192                ));
1193            }
1194            mesh.positions.push(vertex);
1195        }
1196        mesh.normals.extend_from_slice(&sphere.normals);
1197        mesh.indices
1198            .extend(sphere.indices.iter().map(|index| index + offset));
1199    }
1200    Ok(mesh)
1201}