3534 lines
139 KiB
TypeScript
3534 lines
139 KiB
TypeScript
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>
|
||
);
|
||
}
|