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