Files
webeditor3d/src/app/App.tsx

3534 lines
139 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
useEffect,
useRef,
useState,
type CSSProperties,
type ChangeEvent,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent
} from "react";
import { createCreateBoxBrushCommand } from "../commands/create-box-brush-command";
import { createImportModelAssetCommand } from "../commands/import-model-asset-command";
import { createMoveBoxBrushCommand } from "../commands/move-box-brush-command";
import { createResizeBoxBrushCommand } from "../commands/resize-box-brush-command";
import { createSetBoxBrushFaceMaterialCommand } from "../commands/set-box-brush-face-material-command";
import { createSetBoxBrushNameCommand } from "../commands/set-box-brush-name-command";
import { createSetBoxBrushFaceUvStateCommand } from "../commands/set-box-brush-face-uv-state-command";
import { createDeleteInteractionLinkCommand } from "../commands/delete-interaction-link-command";
import { createSetSceneNameCommand } from "../commands/set-scene-name-command";
import { createSetWorldSettingsCommand } from "../commands/set-world-settings-command";
import { createUpsertEntityCommand } from "../commands/upsert-entity-command";
import { createUpsertModelInstanceCommand } from "../commands/upsert-model-instance-command";
import { createUpsertInteractionLinkCommand } from "../commands/upsert-interaction-link-command";
import {
getSelectedBrushFaceId,
getSingleSelectedBrushId,
getSingleSelectedEntityId,
getSingleSelectedModelInstanceId,
isBrushFaceSelected,
isBrushSelected,
type EditorSelection
} from "../core/selection";
import type { Vec2, Vec3 } from "../core/vector";
import {
areModelInstancesEqual,
createModelInstance,
DEFAULT_MODEL_INSTANCE_POSITION,
DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES,
DEFAULT_MODEL_INSTANCE_SCALE,
type ModelInstance
} from "../assets/model-instances";
import {
getModelInstanceDisplayLabelById,
getSortedModelInstanceDisplayLabels
} from "../assets/model-instance-labels";
import {
importModelAssetFromFile,
loadModelAssetFromStorage,
disposeModelTemplate,
type LoadedModelAsset
} from "../assets/gltf-model-import";
import type { ModelAssetRecord, ProjectAssetRecord } from "../assets/project-assets";
import {
BOX_FACE_IDS,
DEFAULT_BOX_BRUSH_CENTER,
DEFAULT_BOX_BRUSH_SIZE,
createDefaultFaceUvState,
normalizeBrushName,
type BoxBrush,
type BoxFaceId,
type FaceUvRotationQuarterTurns,
type FaceUvState
} from "../document/brushes";
import { areWorldSettingsEqual, changeWorldBackgroundMode, type WorldBackgroundMode, type WorldSettings } from "../document/scene-document";
import { formatSceneDiagnosticSummary, validateSceneDocument } from "../document/scene-document-validation";
import { getBrowserProjectAssetStorageAccess, type ProjectAssetStorage } from "../assets/project-asset-storage";
import { DEFAULT_GRID_SIZE, snapPositiveSizeToGrid, snapVec3ToGrid } from "../geometry/grid-snapping";
import { createFitToFaceBoxBrushFaceUvState } from "../geometry/box-face-uvs";
import {
DEFAULT_ENTITY_POSITION,
DEFAULT_INTERACTABLE_PROMPT,
DEFAULT_INTERACTABLE_RADIUS,
DEFAULT_SOUND_EMITTER_GAIN,
DEFAULT_SOUND_EMITTER_RADIUS,
DEFAULT_TELEPORT_TARGET_YAW_DEGREES,
DEFAULT_TRIGGER_VOLUME_SIZE,
areEntityInstancesEqual,
createInteractableEntity,
createPlayerStartEntity,
createSoundEmitterEntity,
createTeleportTargetEntity,
createTriggerVolumeEntity,
getEntityInstances,
getEntityKindLabel,
getPrimaryPlayerStartEntity,
normalizeYawDegrees,
normalizeInteractablePrompt,
type EntityInstance,
type EntityKind
} from "../entities/entity-instances";
import { getEntityDisplayLabelById, getSortedEntityDisplayLabels } from "../entities/entity-labels";
import {
areInteractionLinksEqual,
createTeleportPlayerInteractionLink,
createToggleVisibilityInteractionLink,
getInteractionLinksForSource,
type InteractionLink,
type InteractionTriggerKind
} from "../interactions/interaction-links";
import { STARTER_MATERIAL_LIBRARY, type MaterialDef } from "../materials/starter-material-library";
import { RunnerCanvas } from "../runner-web/RunnerCanvas";
import type { FirstPersonTelemetry } from "../runtime-three/navigation-controller";
import type { RuntimeInteractionPrompt } from "../runtime-three/runtime-interaction-system";
import { buildRuntimeSceneFromDocument, type RuntimeNavigationMode, type RuntimeSceneDefinition } from "../runtime-three/runtime-scene-build";
import { validateRuntimeSceneBuild } from "../runtime-three/runtime-scene-validation";
import { Panel } from "../shared-ui/Panel";
import { createWorldBackgroundStyle } from "../shared-ui/world-background-style";
import { ViewportCanvas } from "../viewport-three/ViewportCanvas";
import type { EditorStore } from "./editor-store";
import { useEditorStoreState } from "./use-editor-store";
interface AppProps {
store: EditorStore;
initialStatusMessage?: string;
}
interface Vec2Draft {
x: string;
y: string;
}
interface Vec3Draft {
x: string;
y: string;
z: string;
}
type InteractionSourceEntity = Extract<EntityInstance, { kind: "triggerVolume" | "interactable" }>;
const FACE_LABELS: Record<BoxFaceId, string> = {
posX: "+X Right",
negX: "-X Left",
posY: "+Y Top",
negY: "-Y Bottom",
posZ: "+Z Front",
negZ: "-Z Back"
};
const STARTER_MATERIAL_ORDER = new Map(STARTER_MATERIAL_LIBRARY.map((material, index) => [material.id, index]));
function formatVec3(vector: Vec3): string {
return `${vector.x}, ${vector.y}, ${vector.z}`;
}
function formatDiagnosticCount(count: number, label: string): string {
return `${count} ${label}${count === 1 ? "" : "s"}`;
}
function getViewportCaption(toolMode: "select" | "box-create" | "play", brushCount: number): string {
if (toolMode === "play") {
return "Runner is active.";
}
if (toolMode === "box-create") {
return `Box Create is active. Click the grid to place a ${DEFAULT_BOX_BRUSH_SIZE.x} x ${DEFAULT_BOX_BRUSH_SIZE.y} x ${DEFAULT_BOX_BRUSH_SIZE.z} box.`;
}
return `${brushCount} box brush${brushCount === 1 ? "" : "es"} loaded. Middle-drag orbits, Shift + middle-drag pans, wheel zooms, and Numpad Comma frames the selection.`;
}
function createVec2Draft(vector: Vec2): Vec2Draft {
return {
x: String(vector.x),
y: String(vector.y)
};
}
function createVec3Draft(vector: Vec3): Vec3Draft {
return {
x: String(vector.x),
y: String(vector.y),
z: String(vector.z)
};
}
function readVec2Draft(draft: Vec2Draft, label: string): Vec2 {
const vector = {
x: Number(draft.x),
y: Number(draft.y)
};
if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y)) {
throw new Error(`${label} values must be finite numbers.`);
}
return vector;
}
function readPositiveVec2Draft(draft: Vec2Draft, label: string): Vec2 {
const vector = readVec2Draft(draft, label);
if (vector.x <= 0 || vector.y <= 0) {
throw new Error(`${label} values must remain positive.`);
}
return vector;
}
function readVec3Draft(draft: Vec3Draft, label: string): Vec3 {
const vector = {
x: Number(draft.x),
y: Number(draft.y),
z: Number(draft.z)
};
if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y) || !Number.isFinite(vector.z)) {
throw new Error(`${label} values must be finite numbers.`);
}
return vector;
}
function readYawDegreesDraft(source: string): number {
const yawDegrees = Number(source);
if (!Number.isFinite(yawDegrees)) {
throw new Error("Player start yaw must be a finite number.");
}
return normalizeYawDegrees(yawDegrees);
}
function readInteractablePromptDraft(source: string): string {
return normalizeInteractablePrompt(source);
}
function readNonNegativeNumberDraft(source: string, label: string): number {
const value = Number(source);
if (!Number.isFinite(value) || value < 0) {
throw new Error(`${label} must be a finite number greater than or equal to zero.`);
}
return value;
}
function readPositiveNumberDraft(source: string, label: string): number {
const value = Number(source);
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`${label} must be a finite number greater than zero.`);
}
return value;
}
function areVec2Equal(left: Vec2, right: Vec2): boolean {
return left.x === right.x && left.y === right.y;
}
function areVec3Equal(left: Vec3, right: Vec3): boolean {
return left.x === right.x && left.y === right.y && left.z === right.z;
}
function areFaceUvStatesEqual(left: FaceUvState, right: FaceUvState): boolean {
return (
areVec2Equal(left.offset, right.offset) &&
areVec2Equal(left.scale, right.scale) &&
left.rotationQuarterTurns === right.rotationQuarterTurns &&
left.flipU === right.flipU &&
left.flipV === right.flipV
);
}
function getSelectedBoxBrush(selection: EditorSelection, brushes: BoxBrush[]): BoxBrush | null {
const selectedBrushId = getSingleSelectedBrushId(selection);
if (selectedBrushId === null) {
return null;
}
return brushes.find((brush) => brush.id === selectedBrushId) ?? null;
}
function getSelectedEntity(selection: EditorSelection, entities: EntityInstance[]): EntityInstance | null {
const selectedEntityId = getSingleSelectedEntityId(selection);
if (selectedEntityId === null) {
return null;
}
return entities.find((entity) => entity.id === selectedEntityId) ?? null;
}
function getSelectedModelInstance(selection: EditorSelection, modelInstances: ModelInstance[]): ModelInstance | null {
const selectedModelInstanceId = getSingleSelectedModelInstanceId(selection);
if (selectedModelInstanceId === null) {
return null;
}
return modelInstances.find((modelInstance) => modelInstance.id === selectedModelInstanceId) ?? null;
}
function isModelAsset(asset: ProjectAssetRecord): asset is ModelAssetRecord {
return asset.kind === "model";
}
function formatByteLength(byteLength: number): string {
if (byteLength < 1024) {
return `${byteLength} B`;
}
const kilobytes = byteLength / 1024;
if (kilobytes < 1024) {
return `${kilobytes.toFixed(kilobytes >= 10 ? 0 : 1)} KB`;
}
return `${(kilobytes / 1024).toFixed(1)} MB`;
}
function formatModelBoundingBoxLabel(asset: ModelAssetRecord): string {
if (asset.metadata.boundingBox === null) {
return "Bounds unavailable";
}
const { size } = asset.metadata.boundingBox;
return `Bounds ${size.x.toFixed(2)} × ${size.y.toFixed(2)} × ${size.z.toFixed(2)} m`;
}
function formatModelAssetSummary(asset: ModelAssetRecord): string {
const details = [
asset.metadata.format.toUpperCase(),
formatByteLength(asset.byteLength),
`${asset.metadata.meshCount} mesh${asset.metadata.meshCount === 1 ? "" : "es"}`,
`${asset.metadata.materialNames.length} material${asset.metadata.materialNames.length === 1 ? "" : "s"}`
];
if (asset.metadata.animationNames.length > 0) {
details.push(`${asset.metadata.animationNames.length} animation${asset.metadata.animationNames.length === 1 ? "" : "s"}`);
}
return details.join(" • ");
}
function createModelInstancePlacementPosition(asset: ModelAssetRecord, anchor: Vec3 | null): Vec3 {
const boundingBox = asset.metadata.boundingBox;
if (anchor !== null) {
const floorOffset = boundingBox === null ? 0 : -boundingBox.min.y;
return {
x: anchor.x,
y: anchor.y + floorOffset,
z: anchor.z
};
}
return {
x: DEFAULT_MODEL_INSTANCE_POSITION.x,
y: boundingBox === null ? DEFAULT_MODEL_INSTANCE_POSITION.y : Math.max(DEFAULT_MODEL_INSTANCE_POSITION.y, -boundingBox.min.y),
z: DEFAULT_MODEL_INSTANCE_POSITION.z
};
}
function getBrushLabel(brush: BoxBrush, index: number): string {
return brush.name ?? `Box Brush ${index + 1}`;
}
function getBrushLabelById(brushId: string, brushes: BoxBrush[]): string {
const brushIndex = brushes.findIndex((brush) => brush.id === brushId);
return brushIndex === -1 ? "Box Brush" : getBrushLabel(brushes[brushIndex], brushIndex);
}
function getSelectedBrushLabel(selection: EditorSelection, brushes: BoxBrush[]): string {
const selectedBrushId = getSingleSelectedBrushId(selection);
if (selectedBrushId === null) {
return "No brush selected";
}
return getBrushLabelById(selectedBrushId, brushes);
}
function describeSelection(
selection: EditorSelection,
brushes: BoxBrush[],
modelInstances: Record<string, ModelInstance>,
assets: Record<string, ProjectAssetRecord>,
entities: Record<string, EntityInstance>
): string {
switch (selection.kind) {
case "none":
return "No authored selection";
case "brushes":
return `${selection.ids.length} brush selected (${getSelectedBrushLabel(selection, brushes)})`;
case "brushFace":
return `1 face selected (${FACE_LABELS[selection.faceId]} on ${getBrushLabelById(selection.brushId, brushes)})`;
case "entities":
return `${selection.ids.length} entity selected (${getEntityDisplayLabelById(selection.ids[0], entities)})`;
case "modelInstances":
return `${selection.ids.length} model instance selected (${getModelInstanceDisplayLabelById(selection.ids[0], modelInstances, assets)})`;
default:
return "Unknown selection";
}
}
function getInteractionTriggerLabel(trigger: InteractionTriggerKind): string {
switch (trigger) {
case "enter":
return "On Enter";
case "exit":
return "On Exit";
case "click":
return "On Click";
}
}
function getInteractionActionLabel(link: InteractionLink): string {
switch (link.action.type) {
case "teleportPlayer":
return "Teleport Player";
case "toggleVisibility":
return "Toggle Visibility";
}
}
function getVisibilityModeSelectValue(visible: boolean | undefined): "toggle" | "show" | "hide" {
if (visible === true) {
return "show";
}
if (visible === false) {
return "hide";
}
return "toggle";
}
function readVisibilityModeSelectValue(value: "toggle" | "show" | "hide"): boolean | undefined {
switch (value) {
case "toggle":
return undefined;
case "show":
return true;
case "hide":
return false;
}
}
function getDefaultTriggerVolumeLinkTrigger(triggerOnEnter: boolean, triggerOnExit: boolean): InteractionTriggerKind {
if (triggerOnEnter) {
return "enter";
}
if (triggerOnExit) {
return "exit";
}
return "enter";
}
function isInteractionSourceEntity(entity: EntityInstance | null): entity is InteractionSourceEntity {
return entity !== null && (entity.kind === "triggerVolume" || entity.kind === "interactable");
}
function getDefaultInteractionLinkTrigger(sourceEntity: InteractionSourceEntity): InteractionTriggerKind {
return sourceEntity.kind === "triggerVolume"
? getDefaultTriggerVolumeLinkTrigger(sourceEntity.triggerOnEnter, sourceEntity.triggerOnExit)
: "click";
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return "An unexpected error occurred.";
}
function isTextEntryTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
return (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target.isContentEditable
);
}
function isCommitIncrementKey(key: string): boolean {
return key === "ArrowUp" || key === "ArrowDown" || key === "PageUp" || key === "PageDown";
}
function blurActiveTextEntry() {
const activeElement = document.activeElement;
if (!(activeElement instanceof HTMLElement) || !isTextEntryTarget(activeElement)) {
return;
}
activeElement.blur();
}
function sortDocumentMaterials(materials: Record<string, MaterialDef>): MaterialDef[] {
return Object.values(materials).sort((left, right) => {
const leftStarterIndex = STARTER_MATERIAL_ORDER.get(left.id) ?? Number.MAX_SAFE_INTEGER;
const rightStarterIndex = STARTER_MATERIAL_ORDER.get(right.id) ?? Number.MAX_SAFE_INTEGER;
if (leftStarterIndex !== rightStarterIndex) {
return leftStarterIndex - rightStarterIndex;
}
return left.name.localeCompare(right.name);
});
}
function getMaterialPreviewStyle(material: MaterialDef): CSSProperties {
switch (material.pattern) {
case "grid":
return {
backgroundColor: material.baseColorHex,
backgroundImage: `linear-gradient(${material.accentColorHex} 2px, transparent 2px), linear-gradient(90deg, ${material.accentColorHex} 2px, transparent 2px)`,
backgroundSize: "18px 18px"
};
case "checker":
return {
backgroundColor: material.baseColorHex,
backgroundImage: `linear-gradient(45deg, ${material.accentColorHex} 25%, transparent 25%, transparent 75%, ${material.accentColorHex} 75%, ${material.accentColorHex}), linear-gradient(45deg, ${material.accentColorHex} 25%, transparent 25%, transparent 75%, ${material.accentColorHex} 75%, ${material.accentColorHex})`,
backgroundPosition: "0 0, 9px 9px",
backgroundSize: "18px 18px"
};
case "stripes":
return {
backgroundColor: material.baseColorHex,
backgroundImage: `repeating-linear-gradient(135deg, ${material.accentColorHex} 0 9px, transparent 9px 18px)`
};
case "diamond":
return {
backgroundColor: material.baseColorHex,
backgroundImage: `linear-gradient(45deg, ${material.accentColorHex} 12%, transparent 12%, transparent 88%, ${material.accentColorHex} 88%), linear-gradient(-45deg, ${material.accentColorHex} 12%, transparent 12%, transparent 88%, ${material.accentColorHex} 88%)`,
backgroundSize: "22px 22px"
};
}
}
function rotateQuarterTurns(rotationQuarterTurns: FaceUvRotationQuarterTurns): FaceUvRotationQuarterTurns {
return ((rotationQuarterTurns + 1) % 4) as FaceUvRotationQuarterTurns;
}
function formatRunnerFeetPosition(position: Vec3 | null): string {
if (position === null) {
return "n/a";
}
return `${position.x.toFixed(2)}, ${position.y.toFixed(2)}, ${position.z.toFixed(2)}`;
}
function formatWorldBackgroundLabel(world: WorldSettings): string {
return world.background.mode === "solid" ? "Solid" : "Vertical Gradient";
}
export function App({ store, initialStatusMessage }: AppProps) {
const editorState = useEditorStoreState(store);
const brushList = Object.values(editorState.document.brushes);
const entityList = getEntityInstances(editorState.document.entities);
const entityDisplayList = getSortedEntityDisplayLabels(editorState.document.entities);
const primaryPlayerStart = getPrimaryPlayerStartEntity(editorState.document.entities);
const materialList = sortDocumentMaterials(editorState.document.materials);
const selectedBrush = getSelectedBoxBrush(editorState.selection, brushList);
const selectedEntity = getSelectedEntity(editorState.selection, entityList);
const selectedModelInstance = getSelectedModelInstance(editorState.selection, Object.values(editorState.document.modelInstances));
const selectedFaceId = getSelectedBrushFaceId(editorState.selection);
const selectedFace = selectedBrush !== null && selectedFaceId !== null ? selectedBrush.faces[selectedFaceId] : null;
const selectedFaceMaterial =
selectedFace !== null && selectedFace.materialId !== null ? editorState.document.materials[selectedFace.materialId] ?? null : null;
const selectedModelAsset =
selectedModelInstance !== null ? (editorState.document.assets[selectedModelInstance.assetId] ?? null) : null;
const selectedPlayerStart = selectedEntity?.kind === "playerStart" ? selectedEntity : null;
const selectedSoundEmitter = selectedEntity?.kind === "soundEmitter" ? selectedEntity : null;
const selectedTriggerVolume = selectedEntity?.kind === "triggerVolume" ? selectedEntity : null;
const selectedTeleportTarget = selectedEntity?.kind === "teleportTarget" ? selectedEntity : null;
const selectedInteractable = selectedEntity?.kind === "interactable" ? selectedEntity : null;
const modelAssetList = Object.values(editorState.document.assets).filter(isModelAsset);
const modelInstanceDisplayList = getSortedModelInstanceDisplayLabels(editorState.document.modelInstances, editorState.document.assets);
const selectedInteractionSource = isInteractionSourceEntity(selectedEntity) ? selectedEntity : null;
const selectedTriggerVolumeLinks =
selectedTriggerVolume === null
? []
: getInteractionLinksForSource(editorState.document.interactionLinks, selectedTriggerVolume.id);
const selectedInteractableLinks =
selectedInteractable === null ? [] : getInteractionLinksForSource(editorState.document.interactionLinks, selectedInteractable.id);
const teleportTargetOptions = entityDisplayList.filter(({ entity }) => entity.kind === "teleportTarget");
const visibilityBrushOptions = brushList.map((brush, brushIndex) => ({
brush,
label: getBrushLabel(brush, brushIndex)
}));
const [sceneNameDraft, setSceneNameDraft] = useState(editorState.document.name);
const [brushNameDraft, setBrushNameDraft] = useState("");
const [positionDraft, setPositionDraft] = useState(createVec3Draft(DEFAULT_BOX_BRUSH_CENTER));
const [sizeDraft, setSizeDraft] = useState(createVec3Draft(DEFAULT_BOX_BRUSH_SIZE));
const [uvOffsetDraft, setUvOffsetDraft] = useState(createVec2Draft(createDefaultFaceUvState().offset));
const [uvScaleDraft, setUvScaleDraft] = useState(createVec2Draft(createDefaultFaceUvState().scale));
const [entityPositionDraft, setEntityPositionDraft] = useState(createVec3Draft(DEFAULT_ENTITY_POSITION));
const [playerStartYawDraft, setPlayerStartYawDraft] = useState("0");
const [soundEmitterRadiusDraft, setSoundEmitterRadiusDraft] = useState(String(DEFAULT_SOUND_EMITTER_RADIUS));
const [soundEmitterGainDraft, setSoundEmitterGainDraft] = useState(String(DEFAULT_SOUND_EMITTER_GAIN));
const [soundEmitterAutoplayDraft, setSoundEmitterAutoplayDraft] = useState(false);
const [soundEmitterLoopDraft, setSoundEmitterLoopDraft] = useState(false);
const [triggerVolumeSizeDraft, setTriggerVolumeSizeDraft] = useState(createVec3Draft(DEFAULT_TRIGGER_VOLUME_SIZE));
const [triggerOnEnterDraft, setTriggerOnEnterDraft] = useState(true);
const [triggerOnExitDraft, setTriggerOnExitDraft] = useState(false);
const [teleportTargetYawDraft, setTeleportTargetYawDraft] = useState(String(DEFAULT_TELEPORT_TARGET_YAW_DEGREES));
const [interactableRadiusDraft, setInteractableRadiusDraft] = useState(String(DEFAULT_INTERACTABLE_RADIUS));
const [interactablePromptDraft, setInteractablePromptDraft] = useState(DEFAULT_INTERACTABLE_PROMPT);
const [interactableEnabledDraft, setInteractableEnabledDraft] = useState(true);
const [modelPositionDraft, setModelPositionDraft] = useState(createVec3Draft(DEFAULT_MODEL_INSTANCE_POSITION));
const [modelRotationDraft, setModelRotationDraft] = useState(createVec3Draft(DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES));
const [modelScaleDraft, setModelScaleDraft] = useState(createVec3Draft(DEFAULT_MODEL_INSTANCE_SCALE));
const [ambientLightIntensityDraft, setAmbientLightIntensityDraft] = useState(String(editorState.document.world.ambientLight.intensity));
const [sunLightIntensityDraft, setSunLightIntensityDraft] = useState(String(editorState.document.world.sunLight.intensity));
const [sunDirectionDraft, setSunDirectionDraft] = useState(createVec3Draft(editorState.document.world.sunLight.direction));
const [statusMessage, setStatusMessage] = useState(initialStatusMessage ?? "Slice 2.3 click interactions and runner prompts ready.");
const [assetStatusMessage, setAssetStatusMessage] = useState<string | null>(null);
const [preferredNavigationMode, setPreferredNavigationMode] = useState<RuntimeNavigationMode>(
primaryPlayerStart === null ? "orbitVisitor" : "firstPerson"
);
const [activeNavigationMode, setActiveNavigationMode] = useState<RuntimeNavigationMode>(
primaryPlayerStart === null ? "orbitVisitor" : "firstPerson"
);
const [projectAssetStorage, setProjectAssetStorage] = useState<ProjectAssetStorage | null>(null);
const [projectAssetStorageReady, setProjectAssetStorageReady] = useState(false);
const [runtimeScene, setRuntimeScene] = useState<RuntimeSceneDefinition | null>(null);
const [runtimeMessage, setRuntimeMessage] = useState<string | null>(null);
const [firstPersonTelemetry, setFirstPersonTelemetry] = useState<FirstPersonTelemetry | null>(null);
const [runtimeInteractionPrompt, setRuntimeInteractionPrompt] = useState<RuntimeInteractionPrompt | null>(null);
const [loadedModelAssets, setLoadedModelAssets] = useState<Record<string, LoadedModelAsset>>({});
const [focusRequest, setFocusRequest] = useState<{ id: number; selection: EditorSelection }>({
id: 0,
selection: {
kind: "none"
}
});
const importInputRef = useRef<HTMLInputElement | null>(null);
const importModelInputRef = useRef<HTMLInputElement | null>(null);
const loadedModelAssetsRef = useRef<Record<string, LoadedModelAsset>>({});
const documentValidation = validateSceneDocument(editorState.document);
const runValidation = validateRuntimeSceneBuild(editorState.document, preferredNavigationMode);
const diagnostics = [...documentValidation.errors, ...documentValidation.warnings, ...runValidation.errors, ...runValidation.warnings];
const blockingDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
const warningDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "warning");
const documentStatusLabel =
documentValidation.errors.length === 0 ? "Valid" : formatDiagnosticCount(documentValidation.errors.length, "error");
const lastCommandLabel = editorState.lastCommandLabel ?? "No commands yet";
const runReadyLabel =
blockingDiagnostics.length > 0
? "Blocked"
: preferredNavigationMode === "firstPerson"
? "Ready for First Person"
: "Ready for Orbit Visitor";
useEffect(() => {
setSceneNameDraft(editorState.document.name);
}, [editorState.document.name]);
useEffect(() => {
setBrushNameDraft(selectedBrush?.name ?? "");
}, [selectedBrush]);
useEffect(() => {
if (selectedBrush === null) {
setPositionDraft(createVec3Draft(DEFAULT_BOX_BRUSH_CENTER));
setSizeDraft(createVec3Draft(DEFAULT_BOX_BRUSH_SIZE));
return;
}
setPositionDraft(createVec3Draft(selectedBrush.center));
setSizeDraft(createVec3Draft(selectedBrush.size));
}, [selectedBrush]);
useEffect(() => {
if (selectedFace === null) {
const defaultUvState = createDefaultFaceUvState();
setUvOffsetDraft(createVec2Draft(defaultUvState.offset));
setUvScaleDraft(createVec2Draft(defaultUvState.scale));
return;
}
setUvOffsetDraft(createVec2Draft(selectedFace.uv.offset));
setUvScaleDraft(createVec2Draft(selectedFace.uv.scale));
}, [selectedFace]);
useEffect(() => {
if (selectedEntity === null) {
setEntityPositionDraft(createVec3Draft(DEFAULT_ENTITY_POSITION));
setPlayerStartYawDraft("0");
setSoundEmitterRadiusDraft(String(DEFAULT_SOUND_EMITTER_RADIUS));
setSoundEmitterGainDraft(String(DEFAULT_SOUND_EMITTER_GAIN));
setSoundEmitterAutoplayDraft(false);
setSoundEmitterLoopDraft(false);
setTriggerVolumeSizeDraft(createVec3Draft(DEFAULT_TRIGGER_VOLUME_SIZE));
setTriggerOnEnterDraft(true);
setTriggerOnExitDraft(false);
setTeleportTargetYawDraft(String(DEFAULT_TELEPORT_TARGET_YAW_DEGREES));
setInteractableRadiusDraft(String(DEFAULT_INTERACTABLE_RADIUS));
setInteractablePromptDraft(DEFAULT_INTERACTABLE_PROMPT);
setInteractableEnabledDraft(true);
return;
}
setEntityPositionDraft(createVec3Draft(selectedEntity.position));
switch (selectedEntity.kind) {
case "playerStart":
setPlayerStartYawDraft(String(selectedEntity.yawDegrees));
break;
case "soundEmitter":
setSoundEmitterRadiusDraft(String(selectedEntity.radius));
setSoundEmitterGainDraft(String(selectedEntity.gain));
setSoundEmitterAutoplayDraft(selectedEntity.autoplay);
setSoundEmitterLoopDraft(selectedEntity.loop);
break;
case "triggerVolume":
setTriggerVolumeSizeDraft(createVec3Draft(selectedEntity.size));
setTriggerOnEnterDraft(selectedEntity.triggerOnEnter);
setTriggerOnExitDraft(selectedEntity.triggerOnExit);
break;
case "teleportTarget":
setTeleportTargetYawDraft(String(selectedEntity.yawDegrees));
break;
case "interactable":
setInteractableRadiusDraft(String(selectedEntity.radius));
setInteractablePromptDraft(selectedEntity.prompt);
setInteractableEnabledDraft(selectedEntity.enabled);
break;
}
}, [selectedEntity]);
useEffect(() => {
if (selectedModelInstance === null) {
setModelPositionDraft(createVec3Draft(DEFAULT_MODEL_INSTANCE_POSITION));
setModelRotationDraft(createVec3Draft(DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES));
setModelScaleDraft(createVec3Draft(DEFAULT_MODEL_INSTANCE_SCALE));
return;
}
setModelPositionDraft(createVec3Draft(selectedModelInstance.position));
setModelRotationDraft(createVec3Draft(selectedModelInstance.rotationDegrees));
setModelScaleDraft(createVec3Draft(selectedModelInstance.scale));
}, [selectedModelInstance]);
useEffect(() => {
setAmbientLightIntensityDraft(String(editorState.document.world.ambientLight.intensity));
}, [editorState.document.world.ambientLight.intensity]);
useEffect(() => {
setSunLightIntensityDraft(String(editorState.document.world.sunLight.intensity));
}, [editorState.document.world.sunLight.intensity]);
useEffect(() => {
setSunDirectionDraft(createVec3Draft(editorState.document.world.sunLight.direction));
}, [editorState.document.world.sunLight.direction]);
useEffect(() => {
loadedModelAssetsRef.current = loadedModelAssets;
}, [loadedModelAssets]);
useEffect(() => {
let cancelled = false;
void (async () => {
const access = await getBrowserProjectAssetStorageAccess();
if (cancelled) {
return;
}
setProjectAssetStorage(access.storage);
setAssetStatusMessage(access.diagnostic);
setProjectAssetStorageReady(true);
})().catch((error) => {
if (cancelled) {
return;
}
setProjectAssetStorage(null);
setProjectAssetStorageReady(true);
setAssetStatusMessage(getErrorMessage(error));
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!projectAssetStorageReady) {
return;
}
let cancelled = false;
const previousLoadedAssets = loadedModelAssetsRef.current;
const currentAssets = editorState.document.assets;
const previousLoadedAssetIds = new Set(Object.keys(previousLoadedAssets));
const nextLoadedAssets: Record<string, LoadedModelAsset> = {};
const syncModelAssets = async () => {
if (projectAssetStorage === null) {
for (const loadedAsset of Object.values(previousLoadedAssets)) {
disposeModelTemplate(loadedAsset.template);
}
if (!cancelled) {
setLoadedModelAssets({});
}
return;
}
for (const asset of Object.values(currentAssets)) {
if (!isModelAsset(asset)) {
continue;
}
previousLoadedAssetIds.delete(asset.id);
const cachedLoadedAsset = previousLoadedAssets[asset.id];
if (cachedLoadedAsset !== undefined && cachedLoadedAsset.storageKey === asset.storageKey) {
nextLoadedAssets[asset.id] = cachedLoadedAsset;
continue;
}
try {
nextLoadedAssets[asset.id] = await loadModelAssetFromStorage(projectAssetStorage, asset);
} catch (error) {
if (!cancelled) {
setAssetStatusMessage(`Model asset ${asset.sourceName} could not be restored: ${getErrorMessage(error)}`);
}
}
}
if (cancelled) {
for (const loadedAsset of Object.values(nextLoadedAssets)) {
if (previousLoadedAssets[loadedAsset.assetId] !== loadedAsset) {
disposeModelTemplate(loadedAsset.template);
}
}
return;
}
for (const assetId of previousLoadedAssetIds) {
const removedAsset = previousLoadedAssets[assetId];
if (removedAsset !== undefined) {
disposeModelTemplate(removedAsset.template);
}
}
loadedModelAssetsRef.current = nextLoadedAssets;
setLoadedModelAssets(nextLoadedAssets);
};
void syncModelAssets();
return () => {
cancelled = true;
};
}, [editorState.document.assets, projectAssetStorage, projectAssetStorageReady]);
useEffect(() => {
if (editorState.toolMode === "play") {
return;
}
const handleWindowKeyDown = (event: globalThis.KeyboardEvent) => {
if (isTextEntryTarget(event.target)) {
return;
}
if (
event.code !== "NumpadComma" &&
!(event.key === "," && event.location === globalThis.KeyboardEvent.DOM_KEY_LOCATION_NUMPAD)
) {
return;
}
event.preventDefault();
if (editorState.selection.kind === "none" && brushList.length === 0 && entityList.length === 0) {
setStatusMessage("Nothing authored yet to frame in the viewport.");
return;
}
setFocusRequest((current) => ({
id: current.id + 1,
selection: editorState.selection
}));
setStatusMessage(editorState.selection.kind === "none" ? "Framed the authored scene in the viewport." : "Framed the current selection.");
};
window.addEventListener("keydown", handleWindowKeyDown);
return () => {
window.removeEventListener("keydown", handleWindowKeyDown);
};
}, [editorState.selection, editorState.toolMode, brushList.length, entityList.length]);
const applySceneName = () => {
const normalizedName = sceneNameDraft.trim() || "Untitled Scene";
if (normalizedName === editorState.document.name) {
return;
}
store.executeCommand(createSetSceneNameCommand(normalizedName));
setStatusMessage(`Scene renamed to ${normalizedName}.`);
};
const requestViewportFocus = (selection: EditorSelection, status?: string) => {
setFocusRequest((current) => ({
id: current.id + 1,
selection
}));
if (status !== undefined) {
setStatusMessage(status);
}
};
const handleCreateBoxBrush = (center?: Vec3) => {
try {
store.executeCommand(createCreateBoxBrushCommand(center === undefined ? {} : { center }));
setStatusMessage(
center === undefined
? `Created a box brush snapped to the ${DEFAULT_GRID_SIZE}m grid.`
: `Created a box brush at snapped center ${formatVec3(center)}.`
);
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const applySelection = (
selection: EditorSelection,
source: "outliner" | "viewport" | "inspector" | "runner",
options: { focusViewport?: boolean } = {}
) => {
blurActiveTextEntry();
store.setSelection(selection);
const suffix = source === "outliner" && options.focusViewport ? " and framed it in the viewport" : "";
switch (selection.kind) {
case "none":
setStatusMessage(`${source === "viewport" ? "Viewport" : "Editor"} selection cleared${suffix}.`);
break;
case "brushes":
setStatusMessage(`Selected ${getBrushLabelById(selection.ids[0], brushList)} from the ${source}${suffix}.`);
break;
case "brushFace":
setStatusMessage(
`Selected ${FACE_LABELS[selection.faceId]} on ${getBrushLabelById(selection.brushId, brushList)} from the ${source}${suffix}.`
);
break;
case "entities":
setStatusMessage(`Selected ${getEntityDisplayLabelById(selection.ids[0], editorState.document.entities)} from the ${source}${suffix}.`);
break;
default:
setStatusMessage(`Selection updated from the ${source}${suffix}.`);
break;
}
if (options.focusViewport) {
requestViewportFocus(selection);
}
};
const applyPositionChange = () => {
if (selectedBrush === null) {
setStatusMessage("Select a box brush before moving it.");
return;
}
try {
const snappedCenter = snapVec3ToGrid(readVec3Draft(positionDraft, "Box brush position"), DEFAULT_GRID_SIZE);
if (areVec3Equal(snappedCenter, selectedBrush.center)) {
return;
}
store.executeCommand(
createMoveBoxBrushCommand({
brushId: selectedBrush.id,
center: snappedCenter
})
);
setStatusMessage("Moved selected box brush.");
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const applySizeChange = () => {
if (selectedBrush === null) {
setStatusMessage("Select a box brush before resizing it.");
return;
}
try {
const snappedSize = snapPositiveSizeToGrid(readVec3Draft(sizeDraft, "Box brush size"), DEFAULT_GRID_SIZE);
if (areVec3Equal(snappedSize, selectedBrush.size)) {
return;
}
store.executeCommand(
createResizeBoxBrushCommand({
brushId: selectedBrush.id,
size: snappedSize
})
);
setStatusMessage("Resized selected box brush.");
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const commitEntityChange = (currentEntity: EntityInstance, nextEntity: EntityInstance, successMessage: string) => {
if (areEntityInstancesEqual(currentEntity, nextEntity)) {
return;
}
store.executeCommand(
createUpsertEntityCommand({
entity: nextEntity,
label: `Update ${getEntityKindLabel(nextEntity.kind).toLowerCase()}`
})
);
setStatusMessage(successMessage);
};
const resolveNewEntityPosition = (kind: EntityKind): Vec3 => {
if (selectedBrush !== null) {
if (kind === "triggerVolume") {
return snapVec3ToGrid(selectedBrush.center, DEFAULT_GRID_SIZE);
}
return snapVec3ToGrid(
{
x: selectedBrush.center.x,
y: selectedBrush.center.y + selectedBrush.size.y * 0.5,
z: selectedBrush.center.z
},
DEFAULT_GRID_SIZE
);
}
if (selectedEntity !== null) {
return snapVec3ToGrid(selectedEntity.position, DEFAULT_GRID_SIZE);
}
return snapVec3ToGrid(DEFAULT_ENTITY_POSITION, DEFAULT_GRID_SIZE);
};
const handlePlaceEntity = (kind: EntityKind) => {
try {
const basePosition = resolveNewEntityPosition(kind);
let nextEntity: EntityInstance;
switch (kind) {
case "playerStart":
nextEntity = createPlayerStartEntity({
position: basePosition
});
break;
case "soundEmitter":
nextEntity = createSoundEmitterEntity({
position: basePosition
});
break;
case "triggerVolume":
nextEntity =
selectedBrush === null
? createTriggerVolumeEntity({
position: basePosition
})
: createTriggerVolumeEntity({
position: basePosition,
size: selectedBrush.size
});
break;
case "teleportTarget":
nextEntity = createTeleportTargetEntity({
position: basePosition
});
break;
case "interactable":
nextEntity = createInteractableEntity({
position: basePosition
});
break;
}
store.executeCommand(
createUpsertEntityCommand({
entity: nextEntity,
label: `Place ${getEntityKindLabel(kind).toLowerCase()}`
})
);
setStatusMessage(`Placed ${getEntityKindLabel(kind)}.`);
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const applyPlayerStartChange = () => {
if (selectedPlayerStart === null) {
setStatusMessage("Select a Player Start before editing it.");
return;
}
try {
const snappedPosition = snapVec3ToGrid(readVec3Draft(entityPositionDraft, "Player Start position"), DEFAULT_GRID_SIZE);
const yawDegrees = readYawDegreesDraft(playerStartYawDraft);
const nextEntity = createPlayerStartEntity({
id: selectedPlayerStart.id,
position: snappedPosition,
yawDegrees
});
commitEntityChange(selectedPlayerStart, nextEntity, "Updated Player Start.");
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const applySoundEmitterChange = () => {
if (selectedSoundEmitter === null) {
setStatusMessage("Select a Sound Emitter before editing it.");
return;
}
try {
const nextEntity = createSoundEmitterEntity({
id: selectedSoundEmitter.id,
position: snapVec3ToGrid(readVec3Draft(entityPositionDraft, "Sound Emitter position"), DEFAULT_GRID_SIZE),
radius: readPositiveNumberDraft(soundEmitterRadiusDraft, "Sound Emitter radius"),
gain: readNonNegativeNumberDraft(soundEmitterGainDraft, "Sound Emitter gain"),
autoplay: soundEmitterAutoplayDraft,
loop: soundEmitterLoopDraft
});
commitEntityChange(selectedSoundEmitter, nextEntity, "Updated Sound Emitter.");
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const applyTriggerVolumeChange = () => {
if (selectedTriggerVolume === null) {
setStatusMessage("Select a Trigger Volume before editing it.");
return;
}
try {
const nextEntity = createTriggerVolumeEntity({
id: selectedTriggerVolume.id,
position: snapVec3ToGrid(readVec3Draft(entityPositionDraft, "Trigger Volume position"), DEFAULT_GRID_SIZE),
size: snapPositiveSizeToGrid(readVec3Draft(triggerVolumeSizeDraft, "Trigger Volume size"), DEFAULT_GRID_SIZE),
triggerOnEnter: triggerOnEnterDraft,
triggerOnExit: triggerOnExitDraft
});
commitEntityChange(selectedTriggerVolume, nextEntity, "Updated Trigger Volume.");
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const applyTeleportTargetChange = () => {
if (selectedTeleportTarget === null) {
setStatusMessage("Select a Teleport Target before editing it.");
return;
}
try {
const nextEntity = createTeleportTargetEntity({
id: selectedTeleportTarget.id,
position: snapVec3ToGrid(readVec3Draft(entityPositionDraft, "Teleport Target position"), DEFAULT_GRID_SIZE),
yawDegrees: readYawDegreesDraft(teleportTargetYawDraft)
});
commitEntityChange(selectedTeleportTarget, nextEntity, "Updated Teleport Target.");
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const applyInteractableChange = () => {
if (selectedInteractable === null) {
setStatusMessage("Select an Interactable before editing it.");
return;
}
try {
const nextEntity = createInteractableEntity({
id: selectedInteractable.id,
position: snapVec3ToGrid(readVec3Draft(entityPositionDraft, "Interactable position"), DEFAULT_GRID_SIZE),
radius: readPositiveNumberDraft(interactableRadiusDraft, "Interactable radius"),
prompt: readInteractablePromptDraft(interactablePromptDraft),
enabled: interactableEnabledDraft
});
commitEntityChange(selectedInteractable, nextEntity, "Updated Interactable.");
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const commitInteractionLinkChange = (currentLink: InteractionLink, nextLink: InteractionLink, successMessage: string, label = "Update interaction link") => {
if (areInteractionLinksEqual(currentLink, nextLink)) {
return;
}
store.executeCommand(
createUpsertInteractionLinkCommand({
link: nextLink,
label
})
);
setStatusMessage(successMessage);
};
const getInteractionSourceEntityForLink = (link: InteractionLink): InteractionSourceEntity | null => {
const sourceEntity = editorState.document.entities[link.sourceEntityId];
return sourceEntity?.kind === "triggerVolume" || sourceEntity?.kind === "interactable" ? sourceEntity : null;
};
const handleAddTeleportInteractionLink = () => {
if (selectedInteractionSource === null) {
setStatusMessage("Select a Trigger Volume or Interactable before adding links.");
return;
}
const defaultTarget = teleportTargetOptions[0]?.entity;
if (defaultTarget === undefined || defaultTarget.kind !== "teleportTarget") {
setStatusMessage("Author a Teleport Target before adding a teleport link.");
return;
}
store.executeCommand(
createUpsertInteractionLinkCommand({
link: createTeleportPlayerInteractionLink({
sourceEntityId: selectedInteractionSource.id,
trigger: getDefaultInteractionLinkTrigger(selectedInteractionSource),
targetEntityId: defaultTarget.id
}),
label: "Add teleport interaction link"
})
);
setStatusMessage(`Added a teleport link to the selected ${selectedInteractionSource.kind === "triggerVolume" ? "Trigger Volume" : "Interactable"}.`);
};
const handleAddVisibilityInteractionLink = () => {
if (selectedInteractionSource === null) {
setStatusMessage("Select a Trigger Volume or Interactable before adding links.");
return;
}
const defaultTarget = visibilityBrushOptions[0]?.brush;
if (defaultTarget === undefined) {
setStatusMessage("Author at least one brush before adding a visibility link.");
return;
}
store.executeCommand(
createUpsertInteractionLinkCommand({
link: createToggleVisibilityInteractionLink({
sourceEntityId: selectedInteractionSource.id,
trigger: getDefaultInteractionLinkTrigger(selectedInteractionSource),
targetBrushId: defaultTarget.id
}),
label: "Add visibility interaction link"
})
);
setStatusMessage(`Added a visibility link to the selected ${selectedInteractionSource.kind === "triggerVolume" ? "Trigger Volume" : "Interactable"}.`);
};
const handleDeleteInteractionLink = (linkId: string) => {
try {
store.executeCommand(createDeleteInteractionLinkCommand(linkId));
setStatusMessage("Deleted interaction link.");
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const updateInteractionLinkTrigger = (link: InteractionLink, trigger: InteractionTriggerKind) => {
const sourceEntity = getInteractionSourceEntityForLink(link);
if (sourceEntity?.kind === "interactable" && trigger !== "click") {
setStatusMessage("Interactable links always use the click trigger.");
return;
}
if (sourceEntity?.kind === "triggerVolume" && trigger === "click") {
setStatusMessage("Trigger Volume links may only use enter or exit triggers.");
return;
}
const nextLink =
link.action.type === "teleportPlayer"
? createTeleportPlayerInteractionLink({
id: link.id,
sourceEntityId: link.sourceEntityId,
trigger,
targetEntityId: link.action.targetEntityId
})
: createToggleVisibilityInteractionLink({
id: link.id,
sourceEntityId: link.sourceEntityId,
trigger,
targetBrushId: link.action.targetBrushId,
visible: link.action.visible
});
commitInteractionLinkChange(link, nextLink, `Updated ${getInteractionTriggerLabel(trigger).toLowerCase()} trigger link.`);
};
const updateInteractionLinkActionType = (link: InteractionLink, actionType: InteractionLink["action"]["type"]) => {
const sourceEntity = getInteractionSourceEntityForLink(link);
if (sourceEntity === null || link.action.type === actionType) {
return;
}
if (actionType === "teleportPlayer") {
const defaultTarget = teleportTargetOptions[0]?.entity;
if (defaultTarget === undefined || defaultTarget.kind !== "teleportTarget") {
setStatusMessage("Author a Teleport Target before switching this link to teleport.");
return;
}
commitInteractionLinkChange(
link,
createTeleportPlayerInteractionLink({
id: link.id,
sourceEntityId: sourceEntity.id,
trigger: link.trigger,
targetEntityId: defaultTarget.id
}),
"Switched link action to teleport player."
);
return;
}
const defaultBrush = visibilityBrushOptions[0]?.brush;
if (defaultBrush === undefined) {
setStatusMessage("Author at least one brush before switching this link to visibility.");
return;
}
commitInteractionLinkChange(
link,
createToggleVisibilityInteractionLink({
id: link.id,
sourceEntityId: sourceEntity.id,
trigger: link.trigger,
targetBrushId: defaultBrush.id
}),
"Switched link action to toggle visibility."
);
};
const updateTeleportInteractionLinkTarget = (link: InteractionLink, targetEntityId: string) => {
if (link.action.type !== "teleportPlayer") {
return;
}
commitInteractionLinkChange(
link,
createTeleportPlayerInteractionLink({
id: link.id,
sourceEntityId: link.sourceEntityId,
trigger: link.trigger,
targetEntityId
}),
"Updated teleport link target."
);
};
const updateVisibilityInteractionLinkTarget = (link: InteractionLink, targetBrushId: string) => {
if (link.action.type !== "toggleVisibility") {
return;
}
commitInteractionLinkChange(
link,
createToggleVisibilityInteractionLink({
id: link.id,
sourceEntityId: link.sourceEntityId,
trigger: link.trigger,
targetBrushId,
visible: link.action.visible
}),
"Updated visibility link target."
);
};
const updateVisibilityInteractionMode = (link: InteractionLink, mode: "toggle" | "show" | "hide") => {
if (link.action.type !== "toggleVisibility") {
return;
}
commitInteractionLinkChange(
link,
createToggleVisibilityInteractionLink({
id: link.id,
sourceEntityId: link.sourceEntityId,
trigger: link.trigger,
targetBrushId: link.action.targetBrushId,
visible: readVisibilityModeSelectValue(mode)
}),
"Updated visibility link mode."
);
};
const renderInteractionLinksSection = (
sourceEntity: InteractionSourceEntity,
links: InteractionLink[],
addTeleportTestId: string,
addVisibilityTestId: string
) => (
<div className="form-section">
<div className="label">Links</div>
{links.length === 0 ? (
<div className="outliner-empty">
{sourceEntity.kind === "triggerVolume" ? "No trigger links authored yet." : "No click links authored yet."}
</div>
) : (
<div className="outliner-list">
{links.map((link, index) => (
<div key={link.id} className="outliner-item">
<div className="outliner-item__select">
<span className="outliner-item__title">{`Link ${index + 1}`}</span>
<span className="outliner-item__meta">{getInteractionActionLabel(link)}</span>
</div>
<div className="form-section">
<div className="vector-inputs vector-inputs--two">
<label className="form-field">
<span className="label">Trigger</span>
{sourceEntity.kind === "triggerVolume" ? (
<select
data-testid={`interaction-link-trigger-${link.id}`}
className="text-input"
value={link.trigger}
onChange={(event) => updateInteractionLinkTrigger(link, event.currentTarget.value as InteractionTriggerKind)}
>
<option value="enter">On Enter</option>
<option value="exit">On Exit</option>
</select>
) : (
<input
data-testid={`interaction-link-trigger-${link.id}`}
className="text-input"
type="text"
value={getInteractionTriggerLabel(link.trigger)}
readOnly
/>
)}
</label>
<label className="form-field">
<span className="label">Action</span>
<select
data-testid={`interaction-link-action-${link.id}`}
className="text-input"
value={link.action.type}
onChange={(event) => updateInteractionLinkActionType(link, event.currentTarget.value as InteractionLink["action"]["type"])}
>
<option value="teleportPlayer">Teleport Player</option>
<option value="toggleVisibility">Toggle Visibility</option>
</select>
</label>
</div>
</div>
{link.action.type === "teleportPlayer" ? (
<div className="form-section">
<label className="form-field">
<span className="label">Target</span>
<select
data-testid={`interaction-link-teleport-target-${link.id}`}
className="text-input"
value={link.action.targetEntityId}
onChange={(event) => updateTeleportInteractionLinkTarget(link, event.currentTarget.value)}
>
{teleportTargetOptions.map(({ entity, label }) => (
<option key={entity.id} value={entity.id}>
{label}
</option>
))}
</select>
</label>
</div>
) : (
<div className="form-section">
<div className="vector-inputs vector-inputs--two">
<label className="form-field">
<span className="label">Brush</span>
<select
data-testid={`interaction-link-visibility-target-${link.id}`}
className="text-input"
value={link.action.targetBrushId}
onChange={(event) => updateVisibilityInteractionLinkTarget(link, event.currentTarget.value)}
>
{visibilityBrushOptions.map(({ brush, label }) => (
<option key={brush.id} value={brush.id}>
{label}
</option>
))}
</select>
</label>
<label className="form-field">
<span className="label">Mode</span>
<select
data-testid={`interaction-link-visibility-mode-${link.id}`}
className="text-input"
value={getVisibilityModeSelectValue(link.action.visible)}
onChange={(event) =>
updateVisibilityInteractionMode(link, event.currentTarget.value as ReturnType<typeof getVisibilityModeSelectValue>)
}
>
<option value="toggle">Toggle</option>
<option value="show">Show</option>
<option value="hide">Hide</option>
</select>
</label>
</div>
</div>
)}
<div className="inline-actions">
<button
className="toolbar__button"
type="button"
data-testid={`delete-interaction-link-${link.id}`}
onClick={() => handleDeleteInteractionLink(link.id)}
>
Delete Link
</button>
</div>
</div>
))}
</div>
)}
<div className="inline-actions">
<button
className="toolbar__button"
type="button"
data-testid={addTeleportTestId}
disabled={teleportTargetOptions.length === 0}
onClick={handleAddTeleportInteractionLink}
>
Add Teleport Link
</button>
<button
className="toolbar__button"
type="button"
data-testid={addVisibilityTestId}
disabled={visibilityBrushOptions.length === 0}
onClick={handleAddVisibilityInteractionLink}
>
Add Visibility Link
</button>
</div>
</div>
);
const applyWorldSettings = (nextWorld: WorldSettings, label: string, successMessage: string) => {
if (areWorldSettingsEqual(editorState.document.world, nextWorld)) {
return;
}
try {
store.executeCommand(
createSetWorldSettingsCommand({
label,
world: nextWorld
})
);
setStatusMessage(successMessage);
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const applyWorldBackgroundMode = (mode: WorldBackgroundMode) => {
applyWorldSettings(
{
...editorState.document.world,
background: changeWorldBackgroundMode(editorState.document.world.background, mode)
},
"Set world background mode",
mode === "solid" ? "World background set to a solid color." : "World background set to a vertical gradient."
);
};
const applyWorldBackgroundColor = (colorHex: string) => {
if (editorState.document.world.background.mode !== "solid") {
return;
}
applyWorldSettings(
{
...editorState.document.world,
background: {
mode: "solid",
colorHex
}
},
"Set world background color",
"Updated the world background color."
);
};
const applyWorldGradientColor = (edge: "top" | "bottom", colorHex: string) => {
if (editorState.document.world.background.mode !== "verticalGradient") {
return;
}
applyWorldSettings(
{
...editorState.document.world,
background:
edge === "top"
? {
...editorState.document.world.background,
topColorHex: colorHex
}
: {
...editorState.document.world.background,
bottomColorHex: colorHex
}
},
edge === "top" ? "Set world gradient top color" : "Set world gradient bottom color",
edge === "top" ? "Updated the world gradient top color." : "Updated the world gradient bottom color."
);
};
const applyAmbientLightColor = (colorHex: string) => {
applyWorldSettings(
{
...editorState.document.world,
ambientLight: {
...editorState.document.world.ambientLight,
colorHex
}
},
"Set world ambient light color",
"Updated the world ambient light color."
);
};
const applyAmbientLightIntensity = () => {
try {
applyWorldSettings(
{
...editorState.document.world,
ambientLight: {
...editorState.document.world.ambientLight,
intensity: readNonNegativeNumberDraft(ambientLightIntensityDraft, "Ambient light intensity")
}
},
"Set world ambient light intensity",
"Updated the world ambient light intensity."
);
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const applySunLightColor = (colorHex: string) => {
applyWorldSettings(
{
...editorState.document.world,
sunLight: {
...editorState.document.world.sunLight,
colorHex
}
},
"Set world sun color",
"Updated the world sun color."
);
};
const applySunLightIntensity = () => {
try {
applyWorldSettings(
{
...editorState.document.world,
sunLight: {
...editorState.document.world.sunLight,
intensity: readNonNegativeNumberDraft(sunLightIntensityDraft, "Sun intensity")
}
},
"Set world sun intensity",
"Updated the world sun intensity."
);
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const applySunLightDirection = () => {
try {
const direction = readVec3Draft(sunDirectionDraft, "Sun direction");
if (direction.x === 0 && direction.y === 0 && direction.z === 0) {
throw new Error("Sun direction must not be the zero vector.");
}
applyWorldSettings(
{
...editorState.document.world,
sunLight: {
...editorState.document.world.sunLight,
direction
}
},
"Set world sun direction",
"Updated the world sun direction."
);
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const applyBrushNameChange = () => {
if (selectedBrush === null) {
setStatusMessage("Select a box brush before renaming it.");
return;
}
const nextName = normalizeBrushName(brushNameDraft);
if (selectedBrush.name === nextName) {
return;
}
try {
store.executeCommand(
createSetBoxBrushNameCommand({
brushId: selectedBrush.id,
name: brushNameDraft
})
);
setStatusMessage(nextName === undefined ? "Cleared the authored brush name." : `Renamed brush to ${nextName}.`);
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const handleDraftVectorKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>, applyChange: () => void) => {
if (event.key === "Enter") {
applyChange();
}
};
const scheduleDraftCommit = (applyChange: () => void) => {
window.setTimeout(() => {
applyChange();
}, 0);
};
const handleNumberInputPointerUp = (_event: ReactPointerEvent<HTMLInputElement>, applyChange: () => void) => {
scheduleDraftCommit(applyChange);
};
const handleNumberInputKeyUp = (event: ReactKeyboardEvent<HTMLInputElement>, applyChange: () => void) => {
if (!isCommitIncrementKey(event.key)) {
return;
}
scheduleDraftCommit(applyChange);
};
const handleSaveDraft = () => {
const result = store.saveDraft();
setStatusMessage(result.message);
};
const handleLoadDraft = () => {
const result = store.loadDraft();
setStatusMessage(result.message);
};
const handleExportJson = () => {
try {
const exportedJson = store.exportDocumentJson();
const blob = new Blob([exportedJson], { type: "application/json" });
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = `${editorState.document.name.replace(/\s+/g, "-").toLowerCase() || "scene"}.json`;
anchor.click();
URL.revokeObjectURL(objectUrl);
setStatusMessage("Scene document exported as JSON.");
} catch (error) {
const message = getErrorMessage(error);
setStatusMessage(message);
}
};
const handleImportButtonClick = () => {
importInputRef.current?.click();
};
const handleImportChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0];
if (file === undefined) {
return;
}
try {
const source = await file.text();
store.importDocumentJson(source);
setStatusMessage(`Imported ${file.name}.`);
} catch (error) {
setStatusMessage(getErrorMessage(error));
} finally {
event.currentTarget.value = "";
}
};
const applyFaceMaterial = (materialId: string) => {
if (selectedBrush === null || selectedFaceId === null || selectedFace === null) {
setStatusMessage("Select a single box face before applying a material.");
return;
}
if (selectedFace.materialId === materialId) {
setStatusMessage(`${FACE_LABELS[selectedFaceId]} already uses that material.`);
return;
}
try {
store.executeCommand(
createSetBoxBrushFaceMaterialCommand({
brushId: selectedBrush.id,
faceId: selectedFaceId,
materialId
})
);
setStatusMessage(`Applied ${editorState.document.materials[materialId]?.name ?? materialId} to ${FACE_LABELS[selectedFaceId]}.`);
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const clearFaceMaterial = () => {
if (selectedBrush === null || selectedFaceId === null || selectedFace === null) {
setStatusMessage("Select a single box face before clearing its material.");
return;
}
if (selectedFace.materialId === null) {
setStatusMessage(`${FACE_LABELS[selectedFaceId]} already uses the fallback face material.`);
return;
}
store.executeCommand(
createSetBoxBrushFaceMaterialCommand({
brushId: selectedBrush.id,
faceId: selectedFaceId,
materialId: null
})
);
setStatusMessage(`Cleared the authored material on ${FACE_LABELS[selectedFaceId]}.`);
};
const applyFaceUvState = (uvState: FaceUvState, label: string, successMessage: string) => {
if (selectedBrush === null || selectedFaceId === null || selectedFace === null) {
setStatusMessage("Select a single box face before editing UVs.");
return;
}
if (areFaceUvStatesEqual(selectedFace.uv, uvState)) {
setStatusMessage("That face UV state is already current.");
return;
}
try {
store.executeCommand(
createSetBoxBrushFaceUvStateCommand({
brushId: selectedBrush.id,
faceId: selectedFaceId,
uvState,
label
})
);
setStatusMessage(successMessage);
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const handleApplyUvDraft = () => {
if (selectedFace === null) {
setStatusMessage("Select a single box face before editing UVs.");
return;
}
try {
applyFaceUvState(
{
...selectedFace.uv,
offset: readVec2Draft(uvOffsetDraft, "Face UV offset"),
scale: readPositiveVec2Draft(uvScaleDraft, "Face UV scale")
},
"Set face UV offset and scale",
"Updated face UV offset and scale."
);
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const handleRotateUv = () => {
if (selectedFace === null) {
setStatusMessage("Select a single box face before rotating UVs.");
return;
}
applyFaceUvState(
{
...selectedFace.uv,
rotationQuarterTurns: rotateQuarterTurns(selectedFace.uv.rotationQuarterTurns)
},
"Rotate face UV 90 degrees",
"Rotated face UVs 90 degrees."
);
};
const handleFlipUv = (axis: "u" | "v") => {
if (selectedFace === null) {
setStatusMessage("Select a single box face before flipping UVs.");
return;
}
applyFaceUvState(
{
...selectedFace.uv,
flipU: axis === "u" ? !selectedFace.uv.flipU : selectedFace.uv.flipU,
flipV: axis === "v" ? !selectedFace.uv.flipV : selectedFace.uv.flipV
},
axis === "u" ? "Flip face UV U" : "Flip face UV V",
axis === "u" ? "Flipped face UVs on U." : "Flipped face UVs on V."
);
};
const handleFitUvToFace = () => {
if (selectedBrush === null || selectedFaceId === null) {
setStatusMessage("Select a single box face before fitting UVs.");
return;
}
applyFaceUvState(
createFitToFaceBoxBrushFaceUvState(selectedBrush, selectedFaceId),
"Fit face UV to face",
"Fit the selected face UVs to the face bounds."
);
};
const handleEnterPlayMode = () => {
if (blockingDiagnostics.length > 0) {
setStatusMessage(`Run mode blocked: ${formatSceneDiagnosticSummary(blockingDiagnostics)}`);
return;
}
try {
const nextRuntimeScene = buildRuntimeSceneFromDocument(editorState.document, {
navigationMode: preferredNavigationMode
});
const nextNavigationMode = preferredNavigationMode;
setRuntimeScene(nextRuntimeScene);
setRuntimeMessage(
nextRuntimeScene.spawn.source === "playerStart"
? "Running from the authored Player Start."
: "No Player Start is authored yet. Orbit Visitor opened first, with a fallback FPS spawn still available."
);
setFirstPersonTelemetry(null);
setRuntimeInteractionPrompt(null);
setActiveNavigationMode(nextNavigationMode);
store.enterPlayMode();
setStatusMessage(
nextNavigationMode === "firstPerson"
? "Entered run mode with first-person navigation."
: "Entered run mode with Orbit Visitor."
);
} catch (error) {
setStatusMessage(`Run mode could not start: ${getErrorMessage(error)}`);
}
};
const handleExitPlayMode = () => {
setRuntimeScene(null);
setRuntimeMessage(null);
setFirstPersonTelemetry(null);
setRuntimeInteractionPrompt(null);
store.exitPlayMode();
setStatusMessage("Returned to editor mode.");
};
const handleSetPreferredNavigationMode = (navigationMode: RuntimeNavigationMode) => {
setPreferredNavigationMode(navigationMode);
if (navigationMode === "firstPerson" && primaryPlayerStart === null) {
setStatusMessage("First Person selected. Author a Player Start before running, or switch back to Orbit Visitor.");
}
if (editorState.toolMode === "play") {
setActiveNavigationMode(navigationMode);
setStatusMessage(navigationMode === "firstPerson" ? "Runner switched to first-person navigation." : "Runner switched to Orbit Visitor.");
}
};
if (editorState.toolMode === "play" && runtimeScene !== null) {
return (
<div className="app-shell app-shell--play">
<header className="toolbar">
<div className="toolbar__brand">
<div className="toolbar__title">WebEditor3D</div>
<div className="toolbar__subtitle">Slice 2.3 click interactions and runner prompts</div>
</div>
<div className="toolbar__actions">
<div className="toolbar__group">
<button
className={`toolbar__button ${activeNavigationMode === "firstPerson" ? "toolbar__button--active" : ""}`}
type="button"
data-testid="runner-mode-first-person"
onClick={() => handleSetPreferredNavigationMode("firstPerson")}
>
First Person
</button>
<button
className={`toolbar__button ${activeNavigationMode === "orbitVisitor" ? "toolbar__button--active" : ""}`}
type="button"
data-testid="runner-mode-orbit-visitor"
onClick={() => handleSetPreferredNavigationMode("orbitVisitor")}
>
Orbit Visitor
</button>
</div>
<div className="toolbar__group">
<button className="toolbar__button toolbar__button--accent" type="button" data-testid="exit-run-mode" onClick={handleExitPlayMode}>
Return To Editor
</button>
</div>
</div>
</header>
<div className="runner-workspace">
<main className="runner-region">
<RunnerCanvas
runtimeScene={runtimeScene}
navigationMode={activeNavigationMode}
onRuntimeMessageChange={setRuntimeMessage}
onFirstPersonTelemetryChange={setFirstPersonTelemetry}
onInteractionPromptChange={setRuntimeInteractionPrompt}
/>
</main>
<aside className="side-column">
<Panel title="Runner">
<div className="stat-grid">
<div className="stat-card">
<div className="label">Navigation</div>
<div className="value">{activeNavigationMode === "firstPerson" ? "First Person" : "Orbit Visitor"}</div>
</div>
<div className="stat-card">
<div className="label">Spawn Source</div>
<div className="value">{runtimeScene.spawn.source === "playerStart" ? "Player Start" : "Fallback"}</div>
</div>
<div className="stat-card">
<div className="label">Pointer Lock</div>
<div className="value">
{activeNavigationMode === "firstPerson" ? (firstPersonTelemetry?.pointerLocked ? "active" : "idle") : "not used"}
</div>
</div>
<div className="stat-card">
<div className="label">Grounded</div>
<div className="value">{firstPersonTelemetry?.grounded ? "yes" : activeNavigationMode === "firstPerson" ? "no" : "n/a"}</div>
</div>
</div>
<div className="stat-card">
<div className="label">FPS Feet Position</div>
<div className="value" data-testid="runner-player-position">
{formatRunnerFeetPosition(firstPersonTelemetry?.feetPosition ?? runtimeScene.spawn.position)}
</div>
<div className="material-summary" data-testid="runner-spawn-state">
Spawn: {runtimeScene.spawn.source === "playerStart" ? "Player Start" : "Fallback"} at{" "}
{formatRunnerFeetPosition(runtimeScene.spawn.position)}
</div>
</div>
<div className="stat-card">
<div className="label">Interaction</div>
<div className="value" data-testid="runner-interaction-state">
{activeNavigationMode === "firstPerson" ? (runtimeInteractionPrompt === null ? "No target" : "Ready") : "Not available"}
</div>
<div className="material-summary" data-testid="runner-interaction-summary">
{activeNavigationMode === "firstPerson"
? runtimeInteractionPrompt === null
? "Aim at an authored Interactable and click when a prompt appears."
: `Click "${runtimeInteractionPrompt.prompt}" within ${runtimeInteractionPrompt.range.toFixed(1)}m.`
: "Switch to First Person to use click interactions."}
</div>
</div>
{runtimeMessage === null ? null : <div className="info-banner">{runtimeMessage}</div>}
{activeNavigationMode === "firstPerson" ? (
<div className="info-banner" data-testid="runner-interaction-help">
Mouse click activates the current prompt target. Keyboard/controller fallback is not active yet.
</div>
) : null}
</Panel>
</aside>
</div>
<footer className="status-bar">
<div>
<span className="status-bar__strong">Status:</span> {statusMessage}
</div>
<div>
<span className="status-bar__strong">Spawn:</span>{" "}
{runtimeScene.spawn.source === "playerStart" ? "Authored Player Start" : "Fallback runtime spawn"}
</div>
</footer>
</div>
);
}
return (
<div className="app-shell">
<header className="toolbar">
<label className="toolbar__scene-name">
<span className="visually-hidden">Scene Name</span>
<input
data-testid="toolbar-scene-name"
className="text-input toolbar__scene-name-input"
type="text"
value={sceneNameDraft}
onChange={(event) => setSceneNameDraft(event.currentTarget.value)}
onBlur={applySceneName}
onKeyDown={(event) => {
if (event.key === "Enter") {
applySceneName();
}
}}
/>
</label>
<div className="toolbar__actions">
<div className="toolbar__group">
<button className="toolbar__button" type="button" disabled={!editorState.storageAvailable} onClick={handleSaveDraft}>
Save Draft
</button>
<button className="toolbar__button" type="button" disabled={!editorState.storageAvailable} onClick={handleLoadDraft}>
Load Draft
</button>
<button className="toolbar__button" type="button" onClick={handleExportJson}>
Export JSON
</button>
<button className="toolbar__button" type="button" onClick={handleImportButtonClick}>
Import JSON
</button>
</div>
<div className="toolbar__group">
<button
className={`toolbar__button ${editorState.toolMode === "select" ? "toolbar__button--active" : ""}`}
type="button"
onClick={() => store.setToolMode("select")}
>
Select
</button>
<button
className={`toolbar__button ${editorState.toolMode === "box-create" ? "toolbar__button--active" : ""}`}
type="button"
onClick={() => store.setToolMode("box-create")}
>
Box Create
</button>
</div>
<div className="toolbar__group">
<button
className={`toolbar__button toolbar__button--accent ${blockingDiagnostics.length > 0 ? "toolbar__button--warn" : ""}`}
type="button"
data-testid="enter-run-mode"
onClick={handleEnterPlayMode}
>
Run Scene
</button>
</div>
<div className="toolbar__group">
<button
className={`toolbar__button ${preferredNavigationMode === "firstPerson" ? "toolbar__button--active" : ""}`}
type="button"
onClick={() => handleSetPreferredNavigationMode("firstPerson")}
>
First Person
</button>
<button
className={`toolbar__button ${preferredNavigationMode === "orbitVisitor" ? "toolbar__button--active" : ""}`}
type="button"
onClick={() => handleSetPreferredNavigationMode("orbitVisitor")}
>
Orbit Visitor
</button>
</div>
<div className="toolbar__group">
<button className="toolbar__button" type="button" disabled={!editorState.canUndo} onClick={() => store.undo()}>
Undo
</button>
<button className="toolbar__button" type="button" disabled={!editorState.canRedo} onClick={() => store.redo()}>
Redo
</button>
</div>
</div>
</header>
<div className="workspace">
<aside className="side-column">
<Panel title="Materials">
<div className="material-browser">
{materialList.map((material) => (
<button
key={material.id}
type="button"
data-testid={`material-button-${material.id}`}
className={`material-item ${selectedFace?.materialId === material.id ? "material-item--active" : ""}`}
disabled={selectedFace === null}
onClick={() => applyFaceMaterial(material.id)}
>
<span className="material-item__preview" style={getMaterialPreviewStyle(material)} aria-hidden="true" />
<span className="material-item__text">
<span className="material-item__title">{material.name}</span>
<span className="material-item__meta">{material.tags.join(" • ")}</span>
</span>
</button>
))}
</div>
<div className="inline-actions">
<button className="toolbar__button" type="button" disabled={selectedFace === null} onClick={clearFaceMaterial}>
Clear Face Material
</button>
</div>
</Panel>
<Panel title="Outliner">
<div className="outliner-section">
<div className="label">Brushes</div>
{brushList.length === 0 ? (
<div className="outliner-empty">Switch to Box Create and click in the viewport to place the first brush.</div>
) : (
<div className="outliner-list" data-testid="outliner-brush-list">
{brushList.map((brush, brushIndex) => (
<div
key={brush.id}
className={`outliner-item ${isBrushSelected(editorState.selection, brush.id) ? "outliner-item--selected" : ""}`}
>
<button
className="outliner-item__select"
type="button"
data-testid={`outliner-brush-${brush.id}`}
onClick={() =>
applySelection(
{
kind: "brushes",
ids: [brush.id]
},
"outliner",
{
focusViewport: true
}
)
}
>
<span className="outliner-item__title">{getBrushLabel(brush, brushIndex)}</span>
<span className="outliner-item__meta">Brush</span>
</button>
{selectedBrush?.id !== brush.id ? null : (
<label className="form-field outliner-item__editor">
<span className="label">Name</span>
<input
className="text-input text-input--dense"
data-testid="selected-brush-name"
type="text"
value={brushNameDraft}
placeholder={`Box Brush ${brushIndex + 1}`}
onChange={(event) => setBrushNameDraft(event.currentTarget.value)}
onBlur={applyBrushNameChange}
onKeyDown={(event) => {
if (event.key === "Enter") {
applyBrushNameChange();
}
}}
/>
</label>
)}
</div>
))}
</div>
)}
</div>
<div className="outliner-section">
<div className="label">Entities</div>
{entityDisplayList.length === 0 ? <div className="outliner-empty">No entities authored yet.</div> : null}
<div className="inline-actions">
<button className="toolbar__button" type="button" data-testid="place-player-start" onClick={() => handlePlaceEntity("playerStart")}>
Add Player Start
</button>
<button
className="toolbar__button"
type="button"
data-testid="add-entity-soundEmitter"
onClick={() => handlePlaceEntity("soundEmitter")}
>
Add Sound Emitter
</button>
</div>
<div className="inline-actions">
<button
className="toolbar__button"
type="button"
data-testid="add-entity-triggerVolume"
onClick={() => handlePlaceEntity("triggerVolume")}
>
Add Trigger Volume
</button>
<button
className="toolbar__button"
type="button"
data-testid="add-entity-teleportTarget"
onClick={() => handlePlaceEntity("teleportTarget")}
>
Add Teleport Target
</button>
</div>
<div className="inline-actions">
<button
className="toolbar__button"
type="button"
data-testid="add-entity-interactable"
onClick={() => handlePlaceEntity("interactable")}
>
Add Interactable
</button>
</div>
{entityDisplayList.length === 0 ? null : (
<div className="outliner-list">
{entityDisplayList.map(({ entity, label }) => (
<button
key={entity.id}
data-testid={`outliner-entity-${entity.id}`}
className={`outliner-item ${
editorState.selection.kind === "entities" && editorState.selection.ids.includes(entity.id)
? "outliner-item--selected"
: ""
}`}
type="button"
onClick={() =>
applySelection(
{
kind: "entities",
ids: [entity.id]
},
"outliner",
{
focusViewport: true
}
)
}
>
<span className="outliner-item__title">{label}</span>
<span className="outliner-item__meta">{getEntityKindLabel(entity.kind)}</span>
</button>
))}
</div>
)}
</div>
</Panel>
</aside>
<main className="viewport-region">
<div className="viewport-region__header">
<div className="viewport-region__title">Viewport</div>
<div className="viewport-region__caption">{getViewportCaption(editorState.toolMode, brushList.length)}</div>
</div>
<ViewportCanvas
world={editorState.document.world}
sceneDocument={editorState.document}
selection={editorState.selection}
toolMode={editorState.toolMode}
focusRequestId={focusRequest.id}
focusSelection={focusRequest.selection}
onSelectionChange={(selection) => applySelection(selection, "viewport")}
onCreateBoxBrush={handleCreateBoxBrush}
/>
</main>
<aside className="side-column">
<Panel title="World">
<div className="stat-card">
<div className="label">Background</div>
<div className="value" data-testid="world-background-mode-value">
{formatWorldBackgroundLabel(editorState.document.world)}
</div>
<div
className="world-background-preview"
data-testid="world-background-preview"
style={createWorldBackgroundStyle(editorState.document.world.background)}
/>
<div className="material-summary">
{editorState.document.world.background.mode === "solid"
? editorState.document.world.background.colorHex
: `${editorState.document.world.background.topColorHex} -> ${editorState.document.world.background.bottomColorHex}`}
</div>
</div>
<div className="form-section">
<div className="label">Background Mode</div>
<div className="inline-actions">
<button
className={`toolbar__button ${editorState.document.world.background.mode === "solid" ? "toolbar__button--active" : ""}`}
type="button"
data-testid="world-background-mode-solid"
onClick={() => applyWorldBackgroundMode("solid")}
>
Solid
</button>
<button
className={`toolbar__button ${
editorState.document.world.background.mode === "verticalGradient" ? "toolbar__button--active" : ""
}`}
type="button"
data-testid="world-background-mode-gradient"
onClick={() => applyWorldBackgroundMode("verticalGradient")}
>
Gradient
</button>
</div>
</div>
<div className="form-section">
<div className="label">Background Colors</div>
{editorState.document.world.background.mode === "solid" ? (
<label className="form-field">
<span className="label">Color</span>
<input
data-testid="world-background-solid-color"
className="color-input"
type="color"
value={editorState.document.world.background.colorHex}
onChange={(event) => applyWorldBackgroundColor(event.currentTarget.value)}
/>
</label>
) : (
<div className="vector-inputs vector-inputs--two">
<label className="form-field">
<span className="label">Top</span>
<input
data-testid="world-background-top-color"
className="color-input"
type="color"
value={editorState.document.world.background.topColorHex}
onChange={(event) => applyWorldGradientColor("top", event.currentTarget.value)}
/>
</label>
<label className="form-field">
<span className="label">Bottom</span>
<input
data-testid="world-background-bottom-color"
className="color-input"
type="color"
value={editorState.document.world.background.bottomColorHex}
onChange={(event) => applyWorldGradientColor("bottom", event.currentTarget.value)}
/>
</label>
</div>
)}
</div>
<div className="form-section">
<div className="label">Ambient Light</div>
<div className="vector-inputs vector-inputs--two">
<label className="form-field">
<span className="label">Color</span>
<input
data-testid="world-ambient-color"
className="color-input"
type="color"
value={editorState.document.world.ambientLight.colorHex}
onChange={(event) => applyAmbientLightColor(event.currentTarget.value)}
/>
</label>
<label className="form-field">
<span className="label">Intensity</span>
<input
data-testid="world-ambient-intensity"
className="text-input"
type="number"
min="0"
step="0.1"
value={ambientLightIntensityDraft}
onChange={(event) => setAmbientLightIntensityDraft(event.currentTarget.value)}
onBlur={applyAmbientLightIntensity}
onKeyDown={(event) => handleDraftVectorKeyDown(event, applyAmbientLightIntensity)}
onKeyUp={(event) => handleNumberInputKeyUp(event, applyAmbientLightIntensity)}
onPointerUp={(event) => handleNumberInputPointerUp(event, applyAmbientLightIntensity)}
/>
</label>
</div>
</div>
<div className="form-section">
<div className="label">Sun Light</div>
<div className="vector-inputs vector-inputs--two">
<label className="form-field">
<span className="label">Color</span>
<input
data-testid="world-sun-color"
className="color-input"
type="color"
value={editorState.document.world.sunLight.colorHex}
onChange={(event) => applySunLightColor(event.currentTarget.value)}
/>
</label>
<label className="form-field">
<span className="label">Intensity</span>
<input
data-testid="world-sun-intensity"
className="text-input"
type="number"
min="0"
step="0.1"
value={sunLightIntensityDraft}
onChange={(event) => setSunLightIntensityDraft(event.currentTarget.value)}
onBlur={applySunLightIntensity}
onKeyDown={(event) => handleDraftVectorKeyDown(event, applySunLightIntensity)}
onKeyUp={(event) => handleNumberInputKeyUp(event, applySunLightIntensity)}
onPointerUp={(event) => handleNumberInputPointerUp(event, applySunLightIntensity)}
/>
</label>
</div>
<div className="vector-inputs">
<label className="form-field">
<span className="label">Dir X</span>
<input
data-testid="world-sun-direction-x"
className="text-input"
type="number"
step="0.1"
value={sunDirectionDraft.x}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setSunDirectionDraft((draft) => ({ ...draft, x: nextValue }));
}}
onBlur={applySunLightDirection}
onKeyDown={(event) => handleDraftVectorKeyDown(event, applySunLightDirection)}
onKeyUp={(event) => handleNumberInputKeyUp(event, applySunLightDirection)}
onPointerUp={(event) => handleNumberInputPointerUp(event, applySunLightDirection)}
/>
</label>
<label className="form-field">
<span className="label">Dir Y</span>
<input
data-testid="world-sun-direction-y"
className="text-input"
type="number"
step="0.1"
value={sunDirectionDraft.y}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setSunDirectionDraft((draft) => ({ ...draft, y: nextValue }));
}}
onBlur={applySunLightDirection}
onKeyDown={(event) => handleDraftVectorKeyDown(event, applySunLightDirection)}
onKeyUp={(event) => handleNumberInputKeyUp(event, applySunLightDirection)}
onPointerUp={(event) => handleNumberInputPointerUp(event, applySunLightDirection)}
/>
</label>
<label className="form-field">
<span className="label">Dir Z</span>
<input
data-testid="world-sun-direction-z"
className="text-input"
type="number"
step="0.1"
value={sunDirectionDraft.z}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setSunDirectionDraft((draft) => ({ ...draft, z: nextValue }));
}}
onBlur={applySunLightDirection}
onKeyDown={(event) => handleDraftVectorKeyDown(event, applySunLightDirection)}
onKeyUp={(event) => handleNumberInputKeyUp(event, applySunLightDirection)}
onPointerUp={(event) => handleNumberInputPointerUp(event, applySunLightDirection)}
/>
</label>
</div>
</div>
</Panel>
<Panel title="Inspector">
<div className="stat-card">
<div className="label">Selection</div>
<div className="value">{describeSelection(editorState.selection, brushList, editorState.document.entities)}</div>
</div>
{selectedEntity !== null ? (
<>
<div className="stat-card">
<div className="label">Entity Kind</div>
<div className="value">{getEntityKindLabel(selectedEntity.kind)}</div>
</div>
<div className="form-section">
<div className="label">Position</div>
<div className="vector-inputs">
<label className="form-field">
<span className="label">X</span>
<input
data-testid={selectedEntity.kind === "playerStart" ? "player-start-position-x" : `${selectedEntity.kind}-position-x`}
className="text-input"
type="number"
step={DEFAULT_GRID_SIZE}
value={entityPositionDraft.x}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setEntityPositionDraft((draft) => ({ ...draft, x: nextValue }));
}}
onBlur={() => {
switch (selectedEntity.kind) {
case "playerStart":
applyPlayerStartChange();
break;
case "soundEmitter":
applySoundEmitterChange();
break;
case "triggerVolume":
applyTriggerVolumeChange();
break;
case "teleportTarget":
applyTeleportTargetChange();
break;
case "interactable":
applyInteractableChange();
break;
}
}}
onKeyDown={(event) =>
handleDraftVectorKeyDown(event, () => {
switch (selectedEntity.kind) {
case "playerStart":
applyPlayerStartChange();
break;
case "soundEmitter":
applySoundEmitterChange();
break;
case "triggerVolume":
applyTriggerVolumeChange();
break;
case "teleportTarget":
applyTeleportTargetChange();
break;
case "interactable":
applyInteractableChange();
break;
}
})
}
onKeyUp={(event) =>
handleNumberInputKeyUp(event, () => {
switch (selectedEntity.kind) {
case "playerStart":
applyPlayerStartChange();
break;
case "soundEmitter":
applySoundEmitterChange();
break;
case "triggerVolume":
applyTriggerVolumeChange();
break;
case "teleportTarget":
applyTeleportTargetChange();
break;
case "interactable":
applyInteractableChange();
break;
}
})
}
onPointerUp={(event) =>
handleNumberInputPointerUp(event, () => {
switch (selectedEntity.kind) {
case "playerStart":
applyPlayerStartChange();
break;
case "soundEmitter":
applySoundEmitterChange();
break;
case "triggerVolume":
applyTriggerVolumeChange();
break;
case "teleportTarget":
applyTeleportTargetChange();
break;
case "interactable":
applyInteractableChange();
break;
}
})
}
/>
</label>
<label className="form-field">
<span className="label">Y</span>
<input
data-testid={selectedEntity.kind === "playerStart" ? "player-start-position-y" : `${selectedEntity.kind}-position-y`}
className="text-input"
type="number"
step={DEFAULT_GRID_SIZE}
value={entityPositionDraft.y}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setEntityPositionDraft((draft) => ({ ...draft, y: nextValue }));
}}
onBlur={() => {
switch (selectedEntity.kind) {
case "playerStart":
applyPlayerStartChange();
break;
case "soundEmitter":
applySoundEmitterChange();
break;
case "triggerVolume":
applyTriggerVolumeChange();
break;
case "teleportTarget":
applyTeleportTargetChange();
break;
case "interactable":
applyInteractableChange();
break;
}
}}
onKeyDown={(event) =>
handleDraftVectorKeyDown(event, () => {
switch (selectedEntity.kind) {
case "playerStart":
applyPlayerStartChange();
break;
case "soundEmitter":
applySoundEmitterChange();
break;
case "triggerVolume":
applyTriggerVolumeChange();
break;
case "teleportTarget":
applyTeleportTargetChange();
break;
case "interactable":
applyInteractableChange();
break;
}
})
}
onKeyUp={(event) =>
handleNumberInputKeyUp(event, () => {
switch (selectedEntity.kind) {
case "playerStart":
applyPlayerStartChange();
break;
case "soundEmitter":
applySoundEmitterChange();
break;
case "triggerVolume":
applyTriggerVolumeChange();
break;
case "teleportTarget":
applyTeleportTargetChange();
break;
case "interactable":
applyInteractableChange();
break;
}
})
}
onPointerUp={(event) =>
handleNumberInputPointerUp(event, () => {
switch (selectedEntity.kind) {
case "playerStart":
applyPlayerStartChange();
break;
case "soundEmitter":
applySoundEmitterChange();
break;
case "triggerVolume":
applyTriggerVolumeChange();
break;
case "teleportTarget":
applyTeleportTargetChange();
break;
case "interactable":
applyInteractableChange();
break;
}
})
}
/>
</label>
<label className="form-field">
<span className="label">Z</span>
<input
data-testid={selectedEntity.kind === "playerStart" ? "player-start-position-z" : `${selectedEntity.kind}-position-z`}
className="text-input"
type="number"
step={DEFAULT_GRID_SIZE}
value={entityPositionDraft.z}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setEntityPositionDraft((draft) => ({ ...draft, z: nextValue }));
}}
onBlur={() => {
switch (selectedEntity.kind) {
case "playerStart":
applyPlayerStartChange();
break;
case "soundEmitter":
applySoundEmitterChange();
break;
case "triggerVolume":
applyTriggerVolumeChange();
break;
case "teleportTarget":
applyTeleportTargetChange();
break;
case "interactable":
applyInteractableChange();
break;
}
}}
onKeyDown={(event) =>
handleDraftVectorKeyDown(event, () => {
switch (selectedEntity.kind) {
case "playerStart":
applyPlayerStartChange();
break;
case "soundEmitter":
applySoundEmitterChange();
break;
case "triggerVolume":
applyTriggerVolumeChange();
break;
case "teleportTarget":
applyTeleportTargetChange();
break;
case "interactable":
applyInteractableChange();
break;
}
})
}
onKeyUp={(event) =>
handleNumberInputKeyUp(event, () => {
switch (selectedEntity.kind) {
case "playerStart":
applyPlayerStartChange();
break;
case "soundEmitter":
applySoundEmitterChange();
break;
case "triggerVolume":
applyTriggerVolumeChange();
break;
case "teleportTarget":
applyTeleportTargetChange();
break;
case "interactable":
applyInteractableChange();
break;
}
})
}
onPointerUp={(event) =>
handleNumberInputPointerUp(event, () => {
switch (selectedEntity.kind) {
case "playerStart":
applyPlayerStartChange();
break;
case "soundEmitter":
applySoundEmitterChange();
break;
case "triggerVolume":
applyTriggerVolumeChange();
break;
case "teleportTarget":
applyTeleportTargetChange();
break;
case "interactable":
applyInteractableChange();
break;
}
})
}
/>
</label>
</div>
</div>
{selectedPlayerStart !== null ? (
<div className="form-section">
<div className="label">Yaw</div>
<label className="form-field">
<span className="label">Degrees</span>
<input
data-testid="player-start-yaw"
className="text-input"
type="number"
step="1"
value={playerStartYawDraft}
onChange={(event) => setPlayerStartYawDraft(event.currentTarget.value)}
onBlur={applyPlayerStartChange}
onKeyDown={(event) => handleDraftVectorKeyDown(event, applyPlayerStartChange)}
onKeyUp={(event) => handleNumberInputKeyUp(event, applyPlayerStartChange)}
onPointerUp={(event) => handleNumberInputPointerUp(event, applyPlayerStartChange)}
/>
</label>
</div>
) : null}
{selectedSoundEmitter !== null ? (
<>
<div className="form-section">
<div className="label">Audio Shape</div>
<div className="vector-inputs vector-inputs--two">
<label className="form-field">
<span className="label">Radius</span>
<input
data-testid="sound-emitter-radius"
className="text-input"
type="number"
min="0.1"
step="0.1"
value={soundEmitterRadiusDraft}
onChange={(event) => setSoundEmitterRadiusDraft(event.currentTarget.value)}
onBlur={applySoundEmitterChange}
onKeyDown={(event) => handleDraftVectorKeyDown(event, applySoundEmitterChange)}
onKeyUp={(event) => handleNumberInputKeyUp(event, applySoundEmitterChange)}
onPointerUp={(event) => handleNumberInputPointerUp(event, applySoundEmitterChange)}
/>
</label>
<label className="form-field">
<span className="label">Gain</span>
<input
data-testid="sound-emitter-gain"
className="text-input"
type="number"
min="0"
step="0.1"
value={soundEmitterGainDraft}
onChange={(event) => setSoundEmitterGainDraft(event.currentTarget.value)}
onBlur={applySoundEmitterChange}
onKeyDown={(event) => handleDraftVectorKeyDown(event, applySoundEmitterChange)}
onKeyUp={(event) => handleNumberInputKeyUp(event, applySoundEmitterChange)}
onPointerUp={(event) => handleNumberInputPointerUp(event, applySoundEmitterChange)}
/>
</label>
</div>
</div>
<div className="form-section">
<div className="label">Playback</div>
<div className="vector-inputs vector-inputs--two">
<label className="form-field">
<span className="label">Autoplay</span>
<input
data-testid="sound-emitter-autoplay"
type="checkbox"
checked={soundEmitterAutoplayDraft}
onChange={(event) => {
setSoundEmitterAutoplayDraft(event.currentTarget.checked);
scheduleDraftCommit(applySoundEmitterChange);
}}
/>
</label>
<label className="form-field">
<span className="label">Loop</span>
<input
data-testid="sound-emitter-loop"
type="checkbox"
checked={soundEmitterLoopDraft}
onChange={(event) => {
setSoundEmitterLoopDraft(event.currentTarget.checked);
scheduleDraftCommit(applySoundEmitterChange);
}}
/>
</label>
</div>
</div>
</>
) : null}
{selectedTriggerVolume !== null ? (
<>
<div className="form-section">
<div className="label">Size</div>
<div className="vector-inputs">
<label className="form-field">
<span className="label">X</span>
<input
data-testid="trigger-volume-size-x"
className="text-input"
type="number"
min={DEFAULT_GRID_SIZE}
step={DEFAULT_GRID_SIZE}
value={triggerVolumeSizeDraft.x}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setTriggerVolumeSizeDraft((draft) => ({ ...draft, x: nextValue }));
}}
onBlur={applyTriggerVolumeChange}
onKeyDown={(event) => handleDraftVectorKeyDown(event, applyTriggerVolumeChange)}
onKeyUp={(event) => handleNumberInputKeyUp(event, applyTriggerVolumeChange)}
onPointerUp={(event) => handleNumberInputPointerUp(event, applyTriggerVolumeChange)}
/>
</label>
<label className="form-field">
<span className="label">Y</span>
<input
data-testid="trigger-volume-size-y"
className="text-input"
type="number"
min={DEFAULT_GRID_SIZE}
step={DEFAULT_GRID_SIZE}
value={triggerVolumeSizeDraft.y}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setTriggerVolumeSizeDraft((draft) => ({ ...draft, y: nextValue }));
}}
onBlur={applyTriggerVolumeChange}
onKeyDown={(event) => handleDraftVectorKeyDown(event, applyTriggerVolumeChange)}
onKeyUp={(event) => handleNumberInputKeyUp(event, applyTriggerVolumeChange)}
onPointerUp={(event) => handleNumberInputPointerUp(event, applyTriggerVolumeChange)}
/>
</label>
<label className="form-field">
<span className="label">Z</span>
<input
data-testid="trigger-volume-size-z"
className="text-input"
type="number"
min={DEFAULT_GRID_SIZE}
step={DEFAULT_GRID_SIZE}
value={triggerVolumeSizeDraft.z}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setTriggerVolumeSizeDraft((draft) => ({ ...draft, z: nextValue }));
}}
onBlur={applyTriggerVolumeChange}
onKeyDown={(event) => handleDraftVectorKeyDown(event, applyTriggerVolumeChange)}
onKeyUp={(event) => handleNumberInputKeyUp(event, applyTriggerVolumeChange)}
onPointerUp={(event) => handleNumberInputPointerUp(event, applyTriggerVolumeChange)}
/>
</label>
</div>
</div>
<div className="form-section">
<div className="label">Trigger Sources</div>
<div className="vector-inputs vector-inputs--two">
<label className="form-field">
<span className="label">On Enter</span>
<input
data-testid="trigger-volume-enter"
type="checkbox"
checked={triggerOnEnterDraft}
onChange={(event) => {
setTriggerOnEnterDraft(event.currentTarget.checked);
scheduleDraftCommit(applyTriggerVolumeChange);
}}
/>
</label>
<label className="form-field">
<span className="label">On Exit</span>
<input
data-testid="trigger-volume-exit"
type="checkbox"
checked={triggerOnExitDraft}
onChange={(event) => {
setTriggerOnExitDraft(event.currentTarget.checked);
scheduleDraftCommit(applyTriggerVolumeChange);
}}
/>
</label>
</div>
</div>
{renderInteractionLinksSection(
selectedTriggerVolume,
selectedTriggerVolumeLinks,
"add-trigger-teleport-link",
"add-trigger-visibility-link"
)}
</>
) : null}
{selectedTeleportTarget !== null ? (
<div className="form-section">
<div className="label">Yaw</div>
<label className="form-field">
<span className="label">Degrees</span>
<input
data-testid="teleport-target-yaw"
className="text-input"
type="number"
step="1"
value={teleportTargetYawDraft}
onChange={(event) => setTeleportTargetYawDraft(event.currentTarget.value)}
onBlur={applyTeleportTargetChange}
onKeyDown={(event) => handleDraftVectorKeyDown(event, applyTeleportTargetChange)}
onKeyUp={(event) => handleNumberInputKeyUp(event, applyTeleportTargetChange)}
onPointerUp={(event) => handleNumberInputPointerUp(event, applyTeleportTargetChange)}
/>
</label>
</div>
) : null}
{selectedInteractable !== null ? (
<>
<div className="form-section">
<div className="label">Interaction</div>
<div className="vector-inputs vector-inputs--two">
<label className="form-field">
<span className="label">Range</span>
<input
data-testid="interactable-radius"
className="text-input"
type="number"
min="0.1"
step="0.1"
value={interactableRadiusDraft}
onChange={(event) => setInteractableRadiusDraft(event.currentTarget.value)}
onBlur={applyInteractableChange}
onKeyDown={(event) => handleDraftVectorKeyDown(event, applyInteractableChange)}
onKeyUp={(event) => handleNumberInputKeyUp(event, applyInteractableChange)}
onPointerUp={(event) => handleNumberInputPointerUp(event, applyInteractableChange)}
/>
</label>
<label className="form-field">
<span className="label">Enabled</span>
<input
data-testid="interactable-enabled"
type="checkbox"
checked={interactableEnabledDraft}
onChange={(event) => {
setInteractableEnabledDraft(event.currentTarget.checked);
scheduleDraftCommit(applyInteractableChange);
}}
/>
</label>
</div>
<div className="material-summary">Range defines how close the player must be before the click prompt can activate.</div>
</div>
<div className="form-section">
<div className="label">Prompt</div>
<label className="form-field">
<span className="label">Text</span>
<input
data-testid="interactable-prompt"
className="text-input"
type="text"
value={interactablePromptDraft}
onChange={(event) => setInteractablePromptDraft(event.currentTarget.value)}
onBlur={applyInteractableChange}
onKeyDown={(event) => {
if (event.key === "Enter") {
applyInteractableChange();
}
}}
/>
</label>
</div>
{renderInteractionLinksSection(
selectedInteractable,
selectedInteractableLinks,
"add-interactable-teleport-link",
"add-interactable-visibility-link"
)}
</>
) : null}
</>
) : selectedBrush === null ? (
<div className="outliner-empty">Select a brush or entity to edit authored properties.</div>
) : (
<>
<div className="stat-card">
<div className="label">Brush Kind</div>
<div className="value">box</div>
</div>
<div className="form-section">
<div className="label">Center</div>
<div className="vector-inputs">
<label className="form-field">
<span className="label">X</span>
<input
data-testid="brush-center-x"
className="text-input"
type="number"
step={DEFAULT_GRID_SIZE}
value={positionDraft.x}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setPositionDraft((draft) => ({ ...draft, x: nextValue }));
}}
onBlur={applyPositionChange}
onKeyDown={(event) => handleDraftVectorKeyDown(event, applyPositionChange)}
onKeyUp={(event) => handleNumberInputKeyUp(event, applyPositionChange)}
onPointerUp={(event) => handleNumberInputPointerUp(event, applyPositionChange)}
/>
</label>
<label className="form-field">
<span className="label">Y</span>
<input
data-testid="brush-center-y"
className="text-input"
type="number"
step={DEFAULT_GRID_SIZE}
value={positionDraft.y}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setPositionDraft((draft) => ({ ...draft, y: nextValue }));
}}
onBlur={applyPositionChange}
onKeyDown={(event) => handleDraftVectorKeyDown(event, applyPositionChange)}
onKeyUp={(event) => handleNumberInputKeyUp(event, applyPositionChange)}
onPointerUp={(event) => handleNumberInputPointerUp(event, applyPositionChange)}
/>
</label>
<label className="form-field">
<span className="label">Z</span>
<input
data-testid="brush-center-z"
className="text-input"
type="number"
step={DEFAULT_GRID_SIZE}
value={positionDraft.z}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setPositionDraft((draft) => ({ ...draft, z: nextValue }));
}}
onBlur={applyPositionChange}
onKeyDown={(event) => handleDraftVectorKeyDown(event, applyPositionChange)}
onKeyUp={(event) => handleNumberInputKeyUp(event, applyPositionChange)}
onPointerUp={(event) => handleNumberInputPointerUp(event, applyPositionChange)}
/>
</label>
</div>
</div>
<div className="form-section">
<div className="label">Size</div>
<div className="vector-inputs">
<label className="form-field">
<span className="label">X</span>
<input
data-testid="brush-size-x"
className="text-input"
type="number"
min={DEFAULT_GRID_SIZE}
step={DEFAULT_GRID_SIZE}
value={sizeDraft.x}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setSizeDraft((draft) => ({ ...draft, x: nextValue }));
}}
onBlur={applySizeChange}
onKeyDown={(event) => handleDraftVectorKeyDown(event, applySizeChange)}
onKeyUp={(event) => handleNumberInputKeyUp(event, applySizeChange)}
onPointerUp={(event) => handleNumberInputPointerUp(event, applySizeChange)}
/>
</label>
<label className="form-field">
<span className="label">Y</span>
<input
data-testid="brush-size-y"
className="text-input"
type="number"
min={DEFAULT_GRID_SIZE}
step={DEFAULT_GRID_SIZE}
value={sizeDraft.y}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setSizeDraft((draft) => ({ ...draft, y: nextValue }));
}}
onBlur={applySizeChange}
onKeyDown={(event) => handleDraftVectorKeyDown(event, applySizeChange)}
onKeyUp={(event) => handleNumberInputKeyUp(event, applySizeChange)}
onPointerUp={(event) => handleNumberInputPointerUp(event, applySizeChange)}
/>
</label>
<label className="form-field">
<span className="label">Z</span>
<input
data-testid="brush-size-z"
className="text-input"
type="number"
min={DEFAULT_GRID_SIZE}
step={DEFAULT_GRID_SIZE}
value={sizeDraft.z}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setSizeDraft((draft) => ({ ...draft, z: nextValue }));
}}
onBlur={applySizeChange}
onKeyDown={(event) => handleDraftVectorKeyDown(event, applySizeChange)}
onKeyUp={(event) => handleNumberInputKeyUp(event, applySizeChange)}
onPointerUp={(event) => handleNumberInputPointerUp(event, applySizeChange)}
/>
</label>
</div>
</div>
<div className="form-section">
<div className="label">Faces</div>
<div className="face-grid">
{BOX_FACE_IDS.map((faceId) => (
<button
key={faceId}
type="button"
data-testid={`face-button-${faceId}`}
className={`face-chip ${isBrushFaceSelected(editorState.selection, selectedBrush.id, faceId) ? "face-chip--active" : ""}`}
onClick={() =>
applySelection(
{
kind: "brushFace",
brushId: selectedBrush.id,
faceId
},
"inspector"
)
}
>
<span className="face-chip__title">{FACE_LABELS[faceId]}</span>
<span className="face-chip__meta">{faceId}</span>
</button>
))}
</div>
</div>
{selectedFace === null || selectedFaceId === null ? (
<div className="outliner-empty">Select a face to edit its material and UV transform.</div>
) : (
<>
<div className="stat-card">
<div className="label">Active Face</div>
<div className="value">{FACE_LABELS[selectedFaceId]}</div>
<div className="material-summary" data-testid="selected-face-material-name">
Material: {selectedFaceMaterial?.name ?? "Fallback face color"}
</div>
</div>
<div className="form-section">
<div className="label">UV Offset</div>
<div className="vector-inputs vector-inputs--two">
<label className="form-field">
<span className="label">U</span>
<input
data-testid="face-uv-offset-x"
className="text-input"
type="number"
step="0.125"
value={uvOffsetDraft.x}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setUvOffsetDraft((draft) => ({ ...draft, x: nextValue }));
}}
onKeyDown={(event) => handleDraftVectorKeyDown(event, handleApplyUvDraft)}
/>
</label>
<label className="form-field">
<span className="label">V</span>
<input
data-testid="face-uv-offset-y"
className="text-input"
type="number"
step="0.125"
value={uvOffsetDraft.y}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setUvOffsetDraft((draft) => ({ ...draft, y: nextValue }));
}}
onKeyDown={(event) => handleDraftVectorKeyDown(event, handleApplyUvDraft)}
/>
</label>
</div>
</div>
<div className="form-section">
<div className="label">UV Scale</div>
<div className="vector-inputs vector-inputs--two">
<label className="form-field">
<span className="label">U</span>
<input
data-testid="face-uv-scale-x"
className="text-input"
type="number"
min="0.001"
step="0.125"
value={uvScaleDraft.x}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setUvScaleDraft((draft) => ({ ...draft, x: nextValue }));
}}
onKeyDown={(event) => handleDraftVectorKeyDown(event, handleApplyUvDraft)}
/>
</label>
<label className="form-field">
<span className="label">V</span>
<input
data-testid="face-uv-scale-y"
className="text-input"
type="number"
min="0.001"
step="0.125"
value={uvScaleDraft.y}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setUvScaleDraft((draft) => ({ ...draft, y: nextValue }));
}}
onKeyDown={(event) => handleDraftVectorKeyDown(event, handleApplyUvDraft)}
/>
</label>
</div>
</div>
<div className="inline-actions">
<button className="toolbar__button" type="button" data-testid="apply-face-uv" onClick={handleApplyUvDraft}>
Apply UV Offset/Scale
</button>
<button className="toolbar__button" type="button" onClick={handleRotateUv}>
Rotate 90
</button>
<button className="toolbar__button" type="button" onClick={() => handleFlipUv("u")}>
Flip U
</button>
<button className="toolbar__button" type="button" onClick={() => handleFlipUv("v")}>
Flip V
</button>
<button className="toolbar__button" type="button" onClick={handleFitUvToFace}>
Fit To Face
</button>
</div>
<div className="stat-card">
<div className="label">UV Flags</div>
<div className="value">Rotation {selectedFace.uv.rotationQuarterTurns * 90}°</div>
<div className="material-summary">
U {selectedFace.uv.flipU ? "flipped" : "normal"} · V {selectedFace.uv.flipV ? "flipped" : "normal"}
</div>
</div>
</>
)}
</>
)}
</Panel>
</aside>
</div>
<footer className="status-bar">
<div className="status-bar__item" data-testid="status-message">
<span className="status-bar__strong">Status:</span> {statusMessage}
</div>
<div className="status-bar__item" data-testid="status-document">
<span className="status-bar__strong">Document:</span> {documentStatusLabel}
</div>
<div className="status-bar__item" data-testid="status-run-preflight">
<span className="status-bar__strong">Run:</span> {runReadyLabel}
</div>
<div className="status-bar__item" data-testid="status-warnings">
<span className="status-bar__strong">Warnings:</span> {warningDiagnostics.length}
</div>
<div className="status-bar__item" data-testid="status-last-command">
<span className="status-bar__strong">Last:</span> {lastCommandLabel}
</div>
</footer>
<input
ref={importInputRef}
className="visually-hidden"
type="file"
accept=".json,application/json"
onChange={handleImportChange}
/>
</div>
);
}