Skip to main content

nightshade_api/editor/
protocol.rs

1//! The editor's wire format: page to worker messages, worker to page state
2//! mirrors, and the external agent request and response types.
3
4#[cfg(feature = "protocol-agent")]
5mod agent;
6#[cfg(feature = "protocol-agent")]
7pub use agent::*;
8
9use crate::command::Command;
10use nightshade::ecs::audio::components::AudioSource;
11use nightshade::ecs::camera::components::Camera;
12use nightshade::ecs::decal::components::Decal;
13use nightshade::ecs::event::Event;
14use nightshade::ecs::gizmos::GizmoMode;
15use nightshade::ecs::light::components::Light;
16use nightshade::ecs::scene::{SceneChunkConfig, SceneLayerConfig};
17use nightshade::ecs::text::components::TextProperties;
18use nightshade::ecs::vfx::components::{Beam, LightningBolt, Trail, VfxAnimator};
19use nightshade::ecs::water::components::Water;
20pub use nightshade::prelude::{
21    Atmosphere, CharacterControllerComponent, ColliderComponent, NavMeshAgent, RigidBodyComponent,
22};
23use nightshade::render::material::Material;
24use nightshade::render::particles::ParticleEmitter;
25use serde::{Deserialize, Serialize};
26
27pub use nightshade::ecs::audio::components::AudioSource as AudioSourceComponent;
28pub use nightshade::ecs::camera::components::{
29    Camera as CameraComponent, OrthographicCamera, PerspectiveCamera, Projection,
30};
31pub use nightshade::ecs::decal::components::Decal as DecalComponent;
32pub use nightshade::ecs::gizmos::GizmoMode as GizmoModeKind;
33pub use nightshade::ecs::light::components::{AreaLightShape, Light as LightComponent, LightType};
34pub use nightshade::ecs::physics::components::ColliderShape;
35pub use nightshade::ecs::physics::types::RigidBodyType;
36pub use nightshade::ecs::primitives::{CameraCullingMask, CullingMask};
37pub use nightshade::ecs::text::components::TextProperties as TextPropertiesComponent;
38pub use nightshade::ecs::vfx::components::{
39    Beam as BeamComponent, LightningBolt as LightningBoltComponent, Trail as TrailComponent,
40    VfxAnimator as VfxAnimatorComponent,
41};
42pub use nightshade::ecs::water::components::Water as WaterComponent;
43pub use nightshade::prelude::{Atmosphere as AtmosphereKind, Vec2, Vec3, Vec4};
44pub use nightshade::render::material::AlphaMode;
45pub use nightshade::render::material::Material as MaterialComponent;
46pub use nightshade::render::particles::ParticleEmitter as ParticleEmitterComponent;
47pub use nightshade::render::render_layer::RenderLayer;
48
49/// Envelope field carrying the serialized message in every `postMessage`.
50pub const MESSAGE_KEY: &str = "message";
51/// Envelope field carrying the transferred `OffscreenCanvas` (on `Init` only).
52pub const CANVAS_KEY: &str = "canvas";
53/// Envelope field carrying transferred binary bytes (file drops, map saves).
54pub const BYTES_KEY: &str = "bytes";
55/// Envelope field carrying the transferred glTF bytes of a multi-file bundle.
56pub const GLTF_KEY: &str = "gltf";
57/// Envelope field carrying a `{ name: Uint8Array }` object of bundle resources.
58pub const RESOURCES_KEY: &str = "resources";
59
60/// Lifecycle phase of a forwarded touch contact.
61#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
62pub enum TouchPhase {
63    Started,
64    Moved,
65    Ended,
66    Cancelled,
67}
68
69/// A parametric primitive shape used for spawning and block placement.
70#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
71#[cfg_attr(
72    feature = "protocol-agent",
73    derive(enum2schema::Schema),
74    schema(string_enum)
75)]
76pub enum ShapeKind {
77    Cube,
78    Plane,
79    Cylinder,
80    Cone,
81    Sphere,
82    Torus,
83}
84
85/// A light kind for the Add menu.
86#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
87#[cfg_attr(
88    feature = "protocol-agent",
89    derive(enum2schema::Schema),
90    schema(string_enum)
91)]
92pub enum LightKind {
93    Point,
94    Spot,
95    Directional,
96}
97
98/// A procedural greybox generator.
99#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
100#[cfg_attr(
101    feature = "protocol-agent",
102    derive(enum2schema::Schema),
103    schema(string_enum)
104)]
105pub enum GeneratorKind {
106    Building,
107    Room,
108    Tower,
109    Stairs,
110    Columns,
111    Perimeter,
112    CityBlock,
113    Courtyard,
114    Wfc,
115    WfcCity,
116}
117
118/// Asset browser category for Polyhaven.
119#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
120#[cfg_attr(
121    feature = "protocol-agent",
122    derive(enum2schema::Schema),
123    schema(string_enum)
124)]
125pub enum PolyhavenCategory {
126    Hdris,
127    Models,
128}
129
130pub use crate::reflect::{ComponentKind, ComponentPatch};
131
132/// Animation playback control for the selected entity's player.
133#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
134#[cfg_attr(feature = "protocol-agent", derive(enum2schema::Schema))]
135pub enum AnimationCommand {
136    Play { index: u32 },
137    PlayAll,
138    Pause,
139    Resume,
140    Stop,
141    Seek { time: f32 },
142    SetSpeed { speed: f32 },
143    SetLooping { looping: bool },
144}
145
146/// Procedural generation settings, mirrored from the worker's resource so the
147/// panel edits round-trip.
148#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
149#[cfg_attr(feature = "protocol-agent", derive(enum2schema::Schema))]
150pub struct GenerationSettings {
151    pub footprint_x: f32,
152    pub footprint_z: f32,
153    pub stories: u32,
154    pub story_height: f32,
155    pub wall_thickness: f32,
156    pub floor_thickness: f32,
157    pub window_spacing: f32,
158    pub seed: u32,
159    pub variation: f32,
160}
161
162/// Every world-level rendering setting the inspector's world sections edit,
163/// synced both ways as one block.
164#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
165pub struct RenderSettingsState {
166    pub ambient_light: [f32; 4],
167    pub bloom_enabled: bool,
168    pub bloom_intensity: f32,
169    pub bloom_threshold: f32,
170    pub bloom_knee: f32,
171    pub bloom_filter_radius: f32,
172    pub ssao_enabled: bool,
173    pub ssao_radius: f32,
174    pub ssao_intensity: f32,
175    pub ssao_bias: f32,
176    pub ssao_sample_count: u32,
177    pub ssao_visualization: bool,
178    pub ssgi_enabled: bool,
179    pub ssgi_radius: f32,
180    pub ssgi_intensity: f32,
181    pub ssgi_max_steps: u32,
182    pub ssr_enabled: bool,
183    pub ssr_max_steps: u32,
184    pub ssr_thickness: f32,
185    pub ssr_max_distance: f32,
186    pub ssr_stride: f32,
187    pub ssr_fade_start: f32,
188    pub ssr_fade_end: f32,
189    pub ssr_intensity: f32,
190    pub fog_enabled: bool,
191    pub fog_color: [f32; 3],
192    pub fog_start: f32,
193    pub fog_end: f32,
194    pub dof_enabled: bool,
195    pub dof_focus_distance: f32,
196    pub dof_focus_range: f32,
197    pub dof_max_blur_radius: f32,
198    pub dof_bokeh_threshold: f32,
199    pub dof_bokeh_intensity: f32,
200    pub dof_quality: u32,
201    pub taa_enabled: bool,
202    pub render_scale: f32,
203    pub ibl_blend_factor: f32,
204    pub pbr_debug_index: u32,
205    pub unlit_mode: bool,
206    pub selection_outline_enabled: bool,
207    pub selection_outline_color: [f32; 4],
208    pub exposure: f32,
209    pub saturation: f32,
210    pub contrast: f32,
211    pub brightness: f32,
212    pub gamma: f32,
213    pub show_grid: bool,
214    pub show_sky: bool,
215    pub show_normals: bool,
216    pub navmesh_debug: bool,
217    pub atmosphere: Atmosphere,
218}
219
220/// Editor-side state the page mirrors for toolbar and panel highlights.
221#[derive(Clone, PartialEq, Serialize, Deserialize)]
222pub struct EditorFlags {
223    pub gizmo_mode: GizmoMode,
224    pub snap_enabled: bool,
225    pub snap_translation_step: f32,
226    pub snap_rotation_step_degrees: f32,
227    pub snap_scale_step: f32,
228    pub greybox_enabled: bool,
229    pub greybox_show_final: bool,
230    pub placement_active: bool,
231    pub placement_shape: ShapeKind,
232    pub placement_size: [f32; 3],
233    pub placement_orient: bool,
234    pub placement_status: String,
235    pub push_pull_active: bool,
236    pub scripting_active: bool,
237    pub playground_active: bool,
238    pub fly_mode: bool,
239    pub viewer_mode: bool,
240    pub frame_on_load: bool,
241    pub day_night_hour: f32,
242    pub day_night_auto: bool,
243    pub skeleton_view: bool,
244    pub rotation_speed: f32,
245    pub active_palette: usize,
246    pub edit_mode_target: Option<u32>,
247    pub generation: GenerationSettings,
248    pub global_scripts: Vec<nightshade::ecs::script::components::GlobalScript>,
249}
250
251/// An ordered edit to the scene's global script list.
252#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
253#[cfg_attr(feature = "protocol-agent", derive(enum2schema::Schema))]
254pub enum GlobalScriptCommand {
255    Add,
256    AddTemplate { name: String, source: String },
257    Remove(u32),
258    MoveUp(u32),
259    MoveDown(u32),
260    SetEnabled { index: u32, enabled: bool },
261    SetSource { index: u32, source: String },
262    Rename { index: u32, name: String },
263}
264
265/// One row of the flattened scene tree, in depth-first order.
266#[derive(Clone, PartialEq, Serialize, Deserialize)]
267pub struct TreeRow {
268    pub id: u32,
269    pub name: String,
270    pub depth: u32,
271    pub has_children: bool,
272    pub camera: bool,
273    pub light: bool,
274    pub mesh: bool,
275}
276
277/// A greybox material palette swatch.
278#[derive(Clone, PartialEq, Serialize, Deserialize)]
279pub struct PaletteEntry {
280    pub label: String,
281    pub color: [f32; 3],
282}
283
284/// Prefab link shown in the inspector for instanced prefab roots.
285#[derive(Clone, PartialEq, Serialize, Deserialize)]
286pub struct PrefabLink {
287    pub name: String,
288    pub source_path: Option<String>,
289}
290
291/// One mesh's morph targets for the scene-wide morph panel: the entity id, a
292/// display name, and the current weight per target.
293#[derive(Clone, PartialEq, Serialize, Deserialize)]
294pub struct MorphMeshInfo {
295    pub id: u32,
296    pub name: String,
297    pub weights: Vec<f32>,
298}
299
300/// Animation state of the selected entity, for the inspector's player section.
301#[derive(Clone, PartialEq, Serialize, Deserialize)]
302pub struct AnimationInfo {
303    pub clips: Vec<String>,
304    pub current: Option<u32>,
305    pub playing: bool,
306    pub time: f32,
307    pub duration: f32,
308    pub speed: f32,
309    pub looping: bool,
310}
311
312/// The selected entity's full editable detail. Rotation is Euler degrees.
313#[derive(Clone, Serialize, Deserialize)]
314pub struct SelectedEntity {
315    pub id: u32,
316    /// Bumped whenever a committed edit or undo changed this entity, so the
317    /// page re-seeds its local editor copies exactly then.
318    pub revision: u32,
319    pub name: String,
320    pub translation: [f32; 3],
321    pub rotation: [f32; 3],
322    pub scale: [f32; 3],
323    pub mesh: Option<String>,
324    pub material_name: Option<String>,
325    pub tags: Vec<String>,
326    pub layer: Option<u32>,
327    pub chunk: Option<u32>,
328    pub prefab: Option<PrefabLink>,
329    pub animation: Option<AnimationInfo>,
330    pub morph_weights: Option<Vec<f32>>,
331    pub selected_count: u32,
332    pub visibility: Option<bool>,
333    pub casts_shadow: bool,
334    pub light: Option<Light>,
335    pub camera: Option<Camera>,
336    pub is_active_camera: bool,
337    pub rigid_body: Option<RigidBodyComponent>,
338    pub collider: Option<ColliderComponent>,
339    pub character_controller: Option<CharacterControllerComponent>,
340    pub navmesh_agent: Option<NavMeshAgent>,
341    pub particle_emitter: Option<Box<ParticleEmitter>>,
342    pub decal: Option<Decal>,
343    pub water: Option<Water>,
344    pub audio_source: Option<AudioSource>,
345    pub render_layer: Option<RenderLayer>,
346    pub culling_mask: Option<CullingMask>,
347    pub camera_culling_mask: Option<CameraCullingMask>,
348    pub ignore_parent_scale: bool,
349    pub text: Option<(String, TextProperties)>,
350    pub script: Option<String>,
351    pub beam: Option<Box<Beam>>,
352    pub lightning_bolt: Option<Box<LightningBolt>>,
353    pub trail: Option<Box<Trail>>,
354    pub vfx_animator: Option<Box<VfxAnimator>>,
355    pub present: Vec<ComponentKind>,
356    pub addable: Vec<ComponentKind>,
357}
358
359/// A material registry entry for the materials panel.
360#[derive(Clone, Serialize, Deserialize)]
361pub struct MaterialEntry {
362    pub name: String,
363    pub material: Material,
364}
365
366/// A Khronos sample-asset entry for the browser grid.
367#[derive(Clone, PartialEq, Serialize, Deserialize)]
368pub struct KhronosEntry {
369    pub label: String,
370    pub thumbnail: Option<String>,
371}
372
373/// A Polyhaven entry for the browser grid.
374#[derive(Clone, PartialEq, Serialize, Deserialize)]
375pub struct PolyhavenEntry {
376    pub slug: String,
377    pub name: String,
378    pub thumbnail: String,
379}
380
381/// Scene layer and chunk authoring data.
382#[derive(Clone, PartialEq, Serialize, Deserialize)]
383pub struct SceneStructure {
384    pub layers: Vec<SceneLayerConfig>,
385    pub chunks: Vec<SceneChunkConfig>,
386}
387
388/// Editor commands that need no payload beyond what they carry. Entity
389/// references are raw entity ids resolved by the worker.
390#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
391#[cfg_attr(feature = "protocol-agent", derive(enum2schema::Schema))]
392pub enum EditorAction {
393    ToggleGroundGrid,
394    ToggleSky,
395    ToggleLitUnlit,
396    ToggleViewerMode,
397    SetViewerMode(bool),
398    FrameScene,
399    ToggleFrameOnLoad,
400    ClearScene,
401    SnapSelectionToFloor,
402    BakeNavmesh,
403    ToggleNavmeshDebug,
404    SetAtmosphere(Atmosphere),
405    SetGizmoMode(GizmoMode),
406    RandomizeBoth,
407    SelectEntity(u32),
408    ToggleSelectEntity(u32),
409    ClearSelection,
410    AddEntityChild(u32),
411    ViewCamera(u32),
412    ToggleSnap,
413    ToggleSkeletonView,
414    SetMaterialVariant(Option<String>),
415    ToggleCameraGizmos,
416    ToggleShowNormals,
417    ToggleDayNightCycle,
418    SetDayNightHour(f32),
419    LoadKhronosByIndex(usize),
420    LoadPolyhavenByIndex {
421        category: PolyhavenCategory,
422        index: usize,
423    },
424    SetPolyhavenResolution(u32),
425    RefreshBrowsers,
426    DeepLink(String),
427    SpawnLines,
428    SpawnMeshes,
429    Spawn3DText,
430    SpawnTextLattice,
431    ClothSponza,
432    NewProject,
433    LoadExampleProject,
434    SaveProject,
435    ExportGlb,
436    SaveSelectedAsPrefab,
437    RefreshSelectedPrefab,
438    BreakSelectedPrefabLink,
439    SpawnPrefab(String),
440    Undo,
441    Redo,
442    DeleteEntity(u32),
443    DeleteSelectedEntity,
444    DuplicateEntity(u32),
445    DuplicateSelectedEntity,
446    AddShape(ShapeKind),
447    AddInstancedShape(ShapeKind),
448    ConvertSimilarToInstanced,
449    AddEmpty,
450    AddCamera,
451    AddPlayerSpawn,
452    AddSavePoint,
453    AddPatrolPoint,
454    AddTriggerVolume,
455    StampDecalAtCursor,
456    AddLight(LightKind),
457    AddTagToSelected(String),
458    RemoveTagFromSelected(String),
459    ToggleGreyboxMode,
460    ApplyGreyboxToSelection,
461    SnapSelectionToGrid,
462    MarkSelectionAsBrush,
463    ToggleShowFinal,
464    ToggleScripting,
465    /// Start or stop scripting mode (run the scene's scripts) explicitly,
466    /// unlike `ToggleScripting`.
467    SetScripting(bool),
468    /// Enter or leave the playground: snapshot the authored scene and clear to a
469    /// clean scratch scene that runs a single lesson script, or restore the scene
470    /// on the way out.
471    SetPlayground(bool),
472    /// Replace the playground's lesson script and reset its scratch scene.
473    LoadLesson(String),
474    GlobalScript(GlobalScriptCommand),
475    TogglePushPull,
476    SetPlacementActive(bool),
477    SetPlacementShape(ShapeKind),
478    SetPlacementSize([f32; 3]),
479    SetOrientToNormal(bool),
480    RunGenerator(GeneratorKind),
481    SetGenerationSettings(GenerationSettings),
482    SetPaletteIndex(usize),
483    SetSnapEnabled(bool),
484    SetSnapSteps {
485        translation: f32,
486        rotation_degrees: f32,
487        scale: f32,
488    },
489    SetRotationSpeed(f32),
490    SetEntityLayer {
491        id: u32,
492        layer: Option<u32>,
493    },
494    SetEntityChunk {
495        id: u32,
496        chunk: Option<u32>,
497    },
498    AddSceneLayer,
499    RemoveSceneLayer(u32),
500    UpdateSceneLayer(SceneLayerConfig),
501    AddSceneChunk,
502    RemoveSceneChunk(u32),
503    UpdateSceneChunk(SceneChunkConfig),
504}
505
506/// A camera entity available for multi-view tiling.
507#[derive(Clone, Serialize, Deserialize)]
508pub struct CameraEntry {
509    pub id: u32,
510    pub name: String,
511}
512
513/// One camera's tile in the multi-view layout, in physical canvas pixels with
514/// the origin at the canvas top-left.
515#[derive(Clone, Copy, Serialize, Deserialize)]
516pub struct ViewportTile {
517    pub camera: u32,
518    pub x: f32,
519    pub y: f32,
520    pub width: f32,
521    pub height: f32,
522}
523
524/// Page to worker. Pixel quantities are physical surface pixels (CSS pixels
525/// times the device pixel ratio), origin at the canvas top-left.
526#[derive(Clone, Serialize, Deserialize)]
527pub enum ClientMessage {
528    /// Sent once with the `OffscreenCanvas` in the transfer list.
529    Init {
530        width: f32,
531        height: f32,
532    },
533    Resize {
534        width: f32,
535        height: f32,
536    },
537    /// Replaces the camera tile layout. An empty list returns to the single
538    /// full-canvas view.
539    SetViewportTiles(Vec<ViewportTile>),
540    PointerMove {
541        x: f32,
542        y: f32,
543    },
544    /// Raw pointer motion (movementX/Y), forwarded while pointer lock is held
545    /// so fly and play modes get first-person look input.
546    PointerMotion {
547        dx: f32,
548        dy: f32,
549    },
550    /// A mouse button changed. `button` is 0 left, 1 middle, 2 right.
551    PointerButton {
552        button: u8,
553        pressed: bool,
554    },
555    Wheel {
556        delta: f32,
557    },
558    Touch {
559        id: u64,
560        phase: TouchPhase,
561        x: f32,
562        y: f32,
563    },
564    /// A keyboard event forwarded while the canvas owns the keyboard. `code`
565    /// is the DOM `KeyboardEvent.code` string.
566    Key {
567        code: String,
568        pressed: bool,
569        text: Option<String>,
570    },
571    Action(Box<EditorAction>),
572    SetTransform {
573        id: u32,
574        translation: [f32; 3],
575        rotation: [f32; 3],
576        scale: [f32; 3],
577        committed: bool,
578    },
579    SetEntityName {
580        id: u32,
581        name: String,
582    },
583    SetComponent {
584        id: u32,
585        payload: Box<ComponentPatch>,
586        committed: bool,
587    },
588    AddComponent {
589        id: u32,
590        kind: ComponentKind,
591    },
592    RemoveComponent {
593        id: u32,
594        kind: ComponentKind,
595    },
596    Animation {
597        id: u32,
598        command: AnimationCommand,
599    },
600    UpdateMaterial {
601        name: String,
602        material: Box<Material>,
603        committed: bool,
604    },
605    SetRenderSettings(Box<RenderSettingsState>),
606    /// A dropped or picked file's bytes are in the `bytes` envelope field.
607    /// Routed by extension: .glb/.gltf import, .hdr skybox, .nsmap/.json map,
608    /// .nsprefab prefab.
609    DropAsset {
610        name: String,
611    },
612    /// A multi-file glTF: `gltf` holds the document bytes, `resources` a
613    /// `{ name: Uint8Array }` object of buffers and images.
614    LoadGltfBundle {
615        name: String,
616    },
617    /// Replace the selected greybox brush's art with this glTF (bytes envelope).
618    UpgradeBrush {
619        name: String,
620    },
621    /// Submit a command from the console builder. `command` is the json of a
622    /// [`Command`](crate::Command), the one command layer. The worker
623    /// deserializes it and pushes it onto the participant command buffer, the
624    /// same path a script takes, applied on the next dispatch.
625    SubmitCommand {
626        command: String,
627    },
628    /// Fetch an asset by url and register its bytes under `name` in the
629    /// script runtime's asset map, so an embedded lesson reaches a glb or hdr
630    /// through `assets.<name>` without carrying the bytes. The worker fetches
631    /// asynchronously and acks with [`WorkerMessage::ScriptAssetLoaded`] or
632    /// [`WorkerMessage::ScriptAssetFailed`].
633    LoadScriptAsset {
634        name: String,
635        url: String,
636    },
637    /// External agent traffic (Claude Code via the MCP bridge), forwarded over
638    /// the page's WebSocket relay onto this same postMessage path.
639    #[cfg(feature = "protocol-agent")]
640    Agent(Box<AgentRequest>),
641}
642
643/// Worker to page.
644#[derive(Clone, Serialize, Deserialize)]
645pub enum WorkerMessage {
646    Ready {
647        adapter: String,
648    },
649    Stats {
650        fps: f32,
651        entity_count: u32,
652    },
653    /// The active camera's world-space basis, for the page's nav gizmo.
654    Camera {
655        right: [f32; 3],
656        up: [f32; 3],
657        forward: [f32; 3],
658    },
659    /// A [`ClientMessage::LoadScriptAsset`] finished and its bytes are in the
660    /// script asset map under `name`.
661    ScriptAssetLoaded {
662        name: String,
663    },
664    /// A [`ClientMessage::LoadScriptAsset`] failed to fetch.
665    ScriptAssetFailed {
666        name: String,
667        error: String,
668    },
669    Scene {
670        rows: Vec<TreeRow>,
671    },
672    Selected {
673        detail: Option<Box<SelectedEntity>>,
674    },
675    /// Every camera entity in the world, independent of scene tree filtering,
676    /// so multi-view tiling sees cameras inside collapsed prefab instances.
677    Cameras {
678        entries: Vec<CameraEntry>,
679    },
680    /// The selected entity's animation playhead, streamed while it changes.
681    Animation {
682        time: f32,
683        duration: f32,
684        playing: bool,
685        clip: Option<u32>,
686    },
687    /// One mesh's morph weights, streamed while they change (animation-driven
688    /// or edited), so the inspector and scene panel sliders follow.
689    MorphWeights {
690        id: u32,
691        weights: Vec<f32>,
692    },
693    /// Every mesh in the scene that carries morph targets, synced when the set
694    /// changes. Weight values stream separately via `MorphWeights`.
695    MorphMeshes {
696        entries: Vec<MorphMeshInfo>,
697    },
698    /// Every material variant name in the scene plus the active one, synced
699    /// whenever either changes.
700    MaterialVariants {
701        names: Vec<String>,
702        active: Option<String>,
703    },
704    RenderSettings(Box<RenderSettingsState>),
705    Flags(Box<EditorFlags>),
706    Materials {
707        entries: Vec<MaterialEntry>,
708    },
709    Tags {
710        entries: Vec<(String, u32)>,
711    },
712    Structure(SceneStructure),
713    Palette {
714        entries: Vec<PaletteEntry>,
715    },
716    KhronosList {
717        entries: Vec<KhronosEntry>,
718    },
719    PolyhavenList {
720        category: PolyhavenCategory,
721        entries: Vec<PolyhavenEntry>,
722    },
723    Loading {
724        active: bool,
725        label: String,
726    },
727    /// Project status for the title bar.
728    Project {
729        name: String,
730        modified: bool,
731        model: Option<String>,
732        mode: String,
733    },
734    /// Serialized file bytes for the page to save (bytes envelope field).
735    SaveFile {
736        name: String,
737    },
738    /// Ask the page to acquire or release pointer lock on the canvas.
739    PointerLock {
740        locked: bool,
741    },
742    /// Scroll the tree to this entity after a viewport pick.
743    FocusEntity {
744        id: u32,
745    },
746    UndoState {
747        can_undo: bool,
748        can_redo: bool,
749    },
750    Toast {
751        message: String,
752        error: bool,
753    },
754    /// New command and event traffic from the participant cycle, streamed each
755    /// frame the journal has anything, for the command and event log panel.
756    CommandLog {
757        entries: Vec<CommandLogEntry>,
758    },
759    /// External agent responses and delta batches, relayed back to the MCP
760    /// bridge.
761    #[cfg(feature = "protocol-agent")]
762    Agent(Box<AgentResponse>),
763}
764
765/// Whether a [`CommandLogEntry`] is a command that was applied or an event that
766/// was published.
767#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
768pub enum CommandLogKind {
769    Command,
770    Event,
771    Error,
772}
773
774/// One line in the command and event log: the cycle it happened in, whether it
775/// was a command or an event, a short label, and the human readable detail.
776#[derive(Clone, Debug, Serialize, Deserialize)]
777pub struct CommandLogEntry {
778    pub cycle: u64,
779    pub kind: CommandLogKind,
780    pub label: String,
781    pub detail: String,
782    /// The entities this entry names, by id, so the log row can select and
783    /// highlight them in the viewport.
784    pub entities: Vec<u32>,
785}
786
787impl CommandLogEntry {
788    /// Builds a log row from a published event: its cycle, a label and detail
789    /// read off the typed value, and the entities it names.
790    pub fn from_event(cycle: u64, event: &Event) -> Self {
791        Self {
792            cycle,
793            kind: CommandLogKind::Event,
794            label: event_label(event).to_string(),
795            detail: event_detail(event),
796            entities: event_entities(event),
797        }
798    }
799
800    /// Builds a log row from a command the script runtime or the console
801    /// produced. The variant name is the label, its serialized arguments are the
802    /// detail, and the entity ids are read off the value, so a tool surfaces the
803    /// traffic without parsing formatted strings back apart.
804    pub fn from_command(cycle: u64, command: &Command) -> Self {
805        use enum2schema::serde_json::Value;
806        let value: Value = enum2schema::serde_json::to_value(command).unwrap_or(Value::Null);
807        let detail = value
808            .as_object()
809            .and_then(|object| object.values().next())
810            .map(|inner| inner.to_string())
811            .unwrap_or_else(|| value.to_string());
812        let mut entities = Vec::new();
813        collect_entity_ids(&value, &mut entities);
814        Self {
815            cycle,
816            kind: CommandLogKind::Command,
817            label: command.name().to_string(),
818            detail,
819            entities,
820        }
821    }
822
823    /// Builds a log row from a script error, so a failed compile or tick is
824    /// visible in the log and the message carries its `(line N, ...)` for the
825    /// editor to underline.
826    pub fn from_error(cycle: u64, message: String) -> Self {
827        Self {
828            cycle,
829            kind: CommandLogKind::Error,
830            label: "error".to_string(),
831            detail: message,
832            entities: Vec::new(),
833        }
834    }
835}
836
837fn event_label(event: &Event) -> &'static str {
838    match event {
839        Event::Collision { .. } => "Collision",
840        Event::Despawned { .. } => "Despawned",
841        Event::AnimationFinished { .. } => "AnimationFinished",
842        Event::AnimationEvent { .. } => "AnimationEvent",
843        Event::NavigationArrived { .. } => "NavigationArrived",
844    }
845}
846
847fn event_detail(event: &Event) -> String {
848    match event {
849        Event::Collision {
850            a,
851            b,
852            sensor,
853            started,
854        } => format!(
855            "#{} and #{}, {}{}",
856            a.id,
857            b.id,
858            if *started { "started" } else { "ended" },
859            if *sensor { ", sensor" } else { "" }
860        ),
861        Event::AnimationEvent { entity, name } => format!("#{} {}", entity.id, name),
862        Event::Despawned { entity }
863        | Event::AnimationFinished { entity }
864        | Event::NavigationArrived { entity } => format!("#{}", entity.id),
865    }
866}
867
868fn event_entities(event: &Event) -> Vec<u32> {
869    match event {
870        Event::Collision { a, b, .. } => vec![a.id, b.id],
871        Event::AnimationEvent { entity, .. } => vec![entity.id],
872        Event::Despawned { entity }
873        | Event::AnimationFinished { entity }
874        | Event::NavigationArrived { entity } => vec![entity.id],
875    }
876}
877
878/// Walks a serialized command for the entity ids it names, reading the
879/// `{ "Existing": id }` and `{ "Entity": { "id": id } }` reference forms.
880fn collect_entity_ids(value: &enum2schema::serde_json::Value, ids: &mut Vec<u32>) {
881    use enum2schema::serde_json::Value;
882    match value {
883        Value::Object(map) => {
884            if let Some(id) = map.get("Existing").and_then(Value::as_u64) {
885                ids.push(id as u32);
886            }
887            if let Some(id) = map
888                .get("Entity")
889                .and_then(Value::as_object)
890                .and_then(|entity| entity.get("id"))
891                .and_then(Value::as_u64)
892            {
893                ids.push(id as u32);
894            }
895            for nested in map.values() {
896                collect_entity_ids(nested, ids);
897            }
898        }
899        Value::Array(array) => {
900            for nested in array {
901                collect_entity_ids(nested, ids);
902            }
903        }
904        _ => {}
905    }
906}
907
908#[cfg(all(test, feature = "protocol-agent"))]
909mod schema_tests {
910    use super::EditorAction;
911    use enum2schema::Schema;
912    use enum2schema::serde_json::Value;
913
914    /// JSON Schema draft 2020-12 forbids an array-valued `items` (the old
915    /// draft-07 tuple form). Positional tuples must use `prefixItems`. The
916    /// Anthropic tool API validates against 2020-12 and rejects the whole tool
917    /// set otherwise, so the generated action schema must stay clean.
918    fn assert_no_array_items(value: &Value) {
919        match value {
920            Value::Object(object) => {
921                if let Some(items) = object.get("items") {
922                    assert!(
923                        !items.is_array(),
924                        "array-valued `items` is invalid in draft 2020-12, use `prefixItems`: {object:?}"
925                    );
926                }
927                for nested in object.values() {
928                    assert_no_array_items(nested);
929                }
930            }
931            Value::Array(array) => {
932                for nested in array {
933                    assert_no_array_items(nested);
934                }
935            }
936            _ => {}
937        }
938    }
939
940    #[test]
941    fn editor_action_schema_is_draft_2020_12() {
942        assert_no_array_items(&EditorAction::schema());
943    }
944}