import type { LoadedModelAsset } from "../assets/gltf-model-import";
import type { Vec3 } from "../core/vector";
import { getModelInstances } from "../assets/model-instances";
import {
  createActorControlTargetRef,
  createActiveSceneControlTargetRef,
  createAmbientLightIntensityControlChannelDescriptor,
  createCameraRigControlTargetRef,
  createControlTargetDescriptor,
  createDefaultResolvedControlSource,
  createEmptyRuntimeResolvedControlState,
  createInteractionControlTargetRef,
  createLightControlTargetRef,
  createLightIntensityControlChannelDescriptor,
  createModelInstanceControlTargetRef,
  createProjectGlobalControlTargetRef,
  createResolvedAmbientLightColorState,
  createResolvedActorAnimationPlaybackState,
  createResolvedActorPathAssignmentState,
  createResolvedAmbientLightIntensityChannelValue,
  createResolvedCameraRigOverrideState,
  createResolvedInteractionEnabledState,
  createResolvedLightColorState,
  createResolvedLightEnabledState,
  createResolvedLightIntensityChannelValue,
  createResolvedModelAnimationPlaybackState,
  createResolvedModelInstanceVisibilityState,
  createResolvedProjectTimePausedState,
  createResolvedSoundPlaybackState,
  createResolvedSoundVolumeChannelValue,
  createResolvedSunLightColorState,
  createResolvedSunLightIntensityChannelValue,
  createRuntimeControlSurfaceDefinition,
  createSoundEmitterControlTargetRef,
  createSoundVolumeControlChannelDescriptor,
  createSunLightIntensityControlChannelDescriptor,
  type RuntimeControlSurfaceDefinition
} from "../controls/control-surface";
import {
  cloneBrushGeometry,
  cloneBoxBrushVolumeSettings,
  cloneFaceUvState,
  type Brush,
  type BrushGeometry,
  type BrushKind,
  type BoxBrushLightFalloffMode,
  type BoxBrushVolumeSettings,
  type WhiteboxFaceId,
  type FaceUvState
} from "../document/brushes";
import type { SceneDocument } from "../document/scene-document";
import {
  cloneProjectTimeSettings,
  type ProjectTimeSettings
} from "../document/project-time-settings";
import {
  getScenePaths,
  resolveScenePath,
  type ScenePath,
  type ScenePathPoint
} from "../document/paths";
import {
  getTerrainBounds,
  getTerrainFootprintDepth,
  getTerrainFootprintWidth,
  getTerrains,
  type Terrain
} from "../document/terrains";
import {
  cloneWorldSettings,
  type WorldSettings
} from "../document/world-settings";
import {
  cloneProjectDialogue,
  type ProjectDialogue
} from "../dialogues/project-dialogues";
import {
  cloneProjectSequenceLibrary,
  type ProjectSequenceLibrary
} from "../sequencer/project-sequences";
import {
  cloneCameraRigLookAroundSettings,
  cloneCameraRigTargetRef,
  type CharacterColliderSettings,
  type CameraRigLookAroundSettings,
  type CameraRigRailPlacementMode,
  type CameraRigTargetRef,
  type CameraRigTransitionMode,
  clonePlayerStartInputBindings,
  createPlayerStartMovementTemplate,
  createPlayerStartInputBindings,
  getEntityInstances,
  getPrimaryEnabledPlayerStartEntity,
  type EntityInstance,
  type PlayerStartInputBindings,
  type PlayerStartJumpSettings,
  type PlayerStartMovementCapabilities,
  type PlayerStartCrouchSettings,
  type PlayerStartSprintSettings,
  type PlayerStartMovementTemplate
} from "../entities/entity-instances";
import { getBrushBounds } from "../geometry/whitebox-brush";
import { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh";
import { getBrushFaceIds } from "../geometry/whitebox-topology";
import {
  buildGeneratedModelCollider,
  type GeneratedColliderBounds,
  type GeneratedModelCollider
} from "../geometry/model-instance-collider-generation";
import {
  cloneInteractionLink,
  getInteractionLinks,
  type InteractionLink
} from "../interactions/interaction-links";
import { getInteractionLinkImpulseSteps } from "../sequencer/project-sequence-steps";
import {
  cloneMaterialDef,
  type MaterialDef
} from "../materials/starter-material-library";
import { cloneProjectScheduler } from "../scheduler/project-scheduler";
import {
  applyRuntimeProjectScheduleToControlState,
  createRuntimeProjectSchedulerState,
  resolveRuntimeProjectScheduleState,
  type RuntimeProjectSchedulerState,
  type RuntimeResolvedProjectScheduleState
} from "./runtime-project-scheduler";
import {
  deriveBoxLightVolumePointLights,
  type DerivedLightVolumePointLight
} from "./light-volume-utils";
import { assertRuntimeSceneBuildable } from "./runtime-scene-validation";
import type { RuntimeClockState } from "./runtime-project-time";
import {
  FIRST_PERSON_PLAYER_SHAPE,
  type FirstPersonPlayerShape
} from "./player-collision";

export type RuntimeNavigationMode = "firstPerson" | "thirdPerson";

export interface RuntimeBrushFace {
  materialId: string | null;
  material: MaterialDef | null;
  uv: FaceUvState;
}

export interface RuntimeBoxBrushInstance {
  id: string;
  kind: BrushKind;
  sideCount?: number;
  majorSegmentCount?: number;
  tubeSegmentCount?: number;
  visible: boolean;
  center: Vec3;
  rotationDegrees: Vec3;
  size: Vec3;
  geometry: BrushGeometry;
  faces: Record<WhiteboxFaceId, RuntimeBrushFace>;
  volume: BoxBrushVolumeSettings;
}

export interface RuntimeFogVolume {
  brushId: string;
  center: Vec3;
  rotationDegrees: Vec3;
  size: Vec3;
  colorHex: string;
  density: number;
  padding: number;
}

export interface RuntimeWaterVolume {
  brushId: string;
  center: Vec3;
  rotationDegrees: Vec3;
  size: Vec3;
  colorHex: string;
  surfaceOpacity: number;
  waveStrength: number;
}

export interface RuntimeLightVolume {
  brushId: string;
  enabled: boolean;
  center: Vec3;
  rotationDegrees: Vec3;
  size: Vec3;
  colorHex: string;
  intensity: number;
  padding: number;
  falloff: BoxBrushLightFalloffMode;
  lights: DerivedLightVolumePointLight[];
}

export interface RuntimeBoxVolumeCollection {
  fog: RuntimeFogVolume[];
  water: RuntimeWaterVolume[];
  light: RuntimeLightVolume[];
}

export interface RuntimeTerrain {
  id: string;
  name?: string;
  visible: boolean;
  collisionEnabled: boolean;
  position: Vec3;
  sampleCountX: number;
  sampleCountZ: number;
  cellSize: number;
  heights: number[];
  layers: Array<{
    materialId: string | null;
    material: MaterialDef | null;
  }>;
  paintWeights: number[];
}

export interface RuntimeTerrainHeightfieldCollider {
  kind: "heightfield";
  source: "terrain";
  terrainId: string;
  position: Vec3;
  rows: number;
  cols: number;
  heights: Float32Array;
  minX: number;
  maxX: number;
  minZ: number;
  maxZ: number;
  worldBounds: {
    min: Vec3;
    max: Vec3;
  };
}

export interface RuntimeBrushTriMeshCollider {
  kind: "trimesh";
  source: "brush";
  brushId: string;
  center: Vec3;
  rotationDegrees: Vec3;
  vertices: Float32Array;
  indices: Uint32Array;
  worldBounds: {
    min: Vec3;
    max: Vec3;
  };
}

export interface RuntimeNpcCollider {
  kind: "character";
  source: "npc";
  entityId: string;
  position: Vec3;
  rotationDegrees: Vec3;
  shape: FirstPersonPlayerShape;
  worldBounds: {
    min: Vec3;
    max: Vec3;
  };
}

export type RuntimeSceneCollider =
  | RuntimeBrushTriMeshCollider
  | RuntimeTerrainHeightfieldCollider
  | GeneratedModelCollider
  | RuntimeNpcCollider;

export interface RuntimeSceneBounds {
  min: Vec3;
  max: Vec3;
  center: Vec3;
  size: Vec3;
}

export interface RuntimePlayerStart {
  entityId: string;
  position: Vec3;
  yawDegrees: number;
  navigationMode: RuntimeNavigationMode;
  interactionReachMeters: number;
  interactionAngleDegrees: number;
  allowLookInputTargetSwitch: boolean;
  targetButtonCyclesActiveTarget: boolean;
  movement: RuntimePlayerMovement;
  inputBindings: PlayerStartInputBindings;
  collider: FirstPersonPlayerShape;
}

export interface RuntimePlayerMovement {
  templateKind: PlayerStartMovementTemplate["kind"];
  moveSpeed: number;
  maxSpeed: number;
  maxStepHeight: number;
  capabilities: PlayerStartMovementCapabilities;
  jump: PlayerStartJumpSettings;
  sprint: PlayerStartSprintSettings;
  crouch: PlayerStartCrouchSettings;
}

export interface RuntimeSceneEntry {
  entityId: string;
  position: Vec3;
  yawDegrees: number;
}

interface RuntimeCameraRigBase {
  entityId: string;
  priority: number;
  defaultActive: boolean;
  target: CameraRigTargetRef;
  targetOffset: Vec3;
  transitionMode: CameraRigTransitionMode;
  transitionDurationSeconds: number;
  lookAround: CameraRigLookAroundSettings;
}

export interface RuntimeFixedCameraRig extends RuntimeCameraRigBase {
  rigType: "fixed";
  position: Vec3;
}

interface RuntimeRailCameraRigBase extends RuntimeCameraRigBase {
  rigType: "rail";
  pathId: string;
  railPlacementMode: CameraRigRailPlacementMode;
}

export interface RuntimeNearestRailCameraRig extends RuntimeRailCameraRigBase {
  railPlacementMode: "nearestToTarget";
}

export interface RuntimeMappedRailCameraRig extends RuntimeRailCameraRigBase {
  railPlacementMode: "mapTargetBetweenPoints";
  trackStartPoint: Vec3;
  trackEndPoint: Vec3;
  railStartProgress: number;
  railEndProgress: number;
}

export type RuntimeRailCameraRig =
  | RuntimeNearestRailCameraRig
  | RuntimeMappedRailCameraRig;

export type RuntimeCameraRig = RuntimeFixedCameraRig | RuntimeRailCameraRig;

export interface RuntimeNpc {
  entityId: string;
  actorId: string;
  name?: string;
  visible: boolean;
  position: Vec3;
  yawDegrees: number;
  modelAssetId: string | null;
  dialogues: ProjectDialogue[];
  defaultDialogueId: string | null;
  collider: FirstPersonPlayerShape;
  activeRoutineTitle: string | null;
  animationClipName: string | null;
  animationLoop: boolean | undefined;
  resolvedPath: RuntimeResolvedNpcPathState | null;
}

export interface RuntimeNpcDefinition extends RuntimeNpc {
  active: boolean;
  activeRoutineId: string | null;
  authoredPosition: Vec3;
  authoredYawDegrees: number;
}

export interface RuntimeResolvedNpcPathState {
  pathId: string;
  progressMode: "deriveFromTime";
  speed: number;
  loop: boolean;
  smoothPath: boolean;
  elapsedHours: number;
  distance: number;
  progress: number;
  position: Vec3;
  tangent: Vec3;
  yawDegrees: number | null;
}

export interface RuntimeSoundEmitter {
  entityId: string;
  position: Vec3;
  audioAssetId: string | null;
  volume: number;
  refDistance: number;
  maxDistance: number;
  autoplay: boolean;
  loop: boolean;
}

export interface RuntimeTriggerVolume {
  entityId: string;
  position: Vec3;
  size: Vec3;
  triggerOnEnter: boolean;
  triggerOnExit: boolean;
}

export interface RuntimeTeleportTarget {
  entityId: string;
  position: Vec3;
  yawDegrees: number;
}

export interface RuntimeInteractable {
  entityId: string;
  position: Vec3;
  radius: number;
  prompt: string;
  interactionEnabled: boolean;
}

export interface RuntimePointLight {
  entityId: string;
  enabled: boolean;
  position: Vec3;
  colorHex: string;
  intensity: number;
  distance: number;
}

export interface RuntimeSpotLight {
  entityId: string;
  enabled: boolean;
  position: Vec3;
  direction: Vec3;
  colorHex: string;
  intensity: number;
  distance: number;
  angleDegrees: number;
}

export interface RuntimeLocalLightCollection {
  pointLights: RuntimePointLight[];
  spotLights: RuntimeSpotLight[];
}

export interface RuntimeModelInstance {
  instanceId: string;
  assetId: string;
  name?: string;
  visible: boolean;
  position: Vec3;
  rotationDegrees: Vec3;
  scale: Vec3;
  animationClipName?: string;
  animationAutoplay?: boolean;
  animationLoop?: boolean;
}

export interface RuntimePathPoint {
  pointId: string;
  position: Vec3;
}

export interface RuntimePathSegment {
  index: number;
  startPointId: string;
  endPointId: string;
  start: Vec3;
  end: Vec3;
  length: number;
  distanceStart: number;
  distanceEnd: number;
  tangent: Vec3;
}

export interface RuntimePath {
  id: string;
  name?: string;
  visible: boolean;
  enabled: boolean;
  loop: boolean;
  points: RuntimePathPoint[];
  segments: RuntimePathSegment[];
  totalLength: number;
}

export interface RuntimeEntityCollection {
  playerStarts: RuntimePlayerStart[];
  sceneEntries: RuntimeSceneEntry[];
  cameraRigs: RuntimeCameraRig[];
  npcs: RuntimeNpc[];
  soundEmitters: RuntimeSoundEmitter[];
  triggerVolumes: RuntimeTriggerVolume[];
  teleportTargets: RuntimeTeleportTarget[];
  interactables: RuntimeInteractable[];
}

export interface RuntimeSpawnPoint {
  source: "playerStart" | "sceneEntry" | "fallback";
  entityId: string | null;
  position: Vec3;
  yawDegrees: number;
}

export interface RuntimeSceneDefinition {
  time: ProjectTimeSettings;
  scheduler: RuntimeProjectSchedulerState;
  sequences: ProjectSequenceLibrary;
  world: WorldSettings;
  control: RuntimeControlSurfaceDefinition;
  localLights: RuntimeLocalLightCollection;
  brushes: RuntimeBoxBrushInstance[];
  terrains: RuntimeTerrain[];
  volumes: RuntimeBoxVolumeCollection;
  staticColliders: RuntimeSceneCollider[];
  colliders: RuntimeSceneCollider[];
  sceneBounds: RuntimeSceneBounds | null;
  modelInstances: RuntimeModelInstance[];
  paths: RuntimePath[];
  npcDefinitions: RuntimeNpcDefinition[];
  entities: RuntimeEntityCollection;
  interactionLinks: InteractionLink[];
  playerStart: RuntimePlayerStart | null;
  playerCollider: FirstPersonPlayerShape;
  playerMovement: RuntimePlayerMovement;
  playerInputBindings: PlayerStartInputBindings;
  navigationMode: RuntimeNavigationMode;
  spawn: RuntimeSpawnPoint;
}

export interface BuildRuntimeSceneOptions {
  navigationMode?: RuntimeNavigationMode;
  loadedModelAssets?: Record<string, LoadedModelAsset>;
  runtimeClock?: RuntimeClockState;
  sceneEntryId?: string | null;
}

export function resolveRuntimeNavigationMode(
  playerStartEntity: ReturnType<typeof getPrimaryEnabledPlayerStartEntity>,
  authoredOverride?: RuntimeNavigationMode
): RuntimeNavigationMode {
  if (authoredOverride !== undefined) {
    return authoredOverride;
  }

  return playerStartEntity?.navigationMode ?? "thirdPerson";
}

function cloneVec3(vector: Vec3): Vec3 {
  return {
    x: vector.x,
    y: vector.y,
    z: vector.z
  };
}

function createRuntimeCharacterShape(
  collider: CharacterColliderSettings
): FirstPersonPlayerShape {
  switch (collider.mode) {
    case "capsule":
      return {
        mode: "capsule",
        radius: collider.capsuleRadius,
        height: collider.capsuleHeight,
        eyeHeight: collider.eyeHeight
      };
    case "box":
      return {
        mode: "box",
        size: cloneVec3(collider.boxSize),
        eyeHeight: collider.eyeHeight
      };
    case "none":
      return {
        mode: "none",
        eyeHeight: collider.eyeHeight
      };
  }
}

function cloneRuntimeCharacterShape(
  shape: FirstPersonPlayerShape
): FirstPersonPlayerShape {
  switch (shape.mode) {
    case "capsule":
      return {
        mode: "capsule",
        radius: shape.radius,
        height: shape.height,
        eyeHeight: shape.eyeHeight
      };
    case "box":
      return {
        mode: "box",
        size: cloneVec3(shape.size),
        eyeHeight: shape.eyeHeight
      };
    case "none":
      return {
        mode: "none",
        eyeHeight: shape.eyeHeight
      };
  }
}

function cloneRuntimeResolvedNpcPathState(
  pathState: RuntimeResolvedNpcPathState
): RuntimeResolvedNpcPathState {
  return {
    pathId: pathState.pathId,
    progressMode: pathState.progressMode,
    speed: pathState.speed,
    loop: pathState.loop,
    smoothPath: pathState.smoothPath,
    elapsedHours: pathState.elapsedHours,
    distance: pathState.distance,
    progress: pathState.progress,
    position: cloneVec3(pathState.position),
    tangent: cloneVec3(pathState.tangent),
    yawDegrees: pathState.yawDegrees
  };
}

export function createRuntimeNpcFromDefinition(
  npc: RuntimeNpcDefinition
): RuntimeNpc {
  return {
    entityId: npc.entityId,
    actorId: npc.actorId,
    name: npc.name,
    visible: npc.visible,
    position: cloneVec3(npc.position),
    yawDegrees: npc.yawDegrees,
    modelAssetId: npc.modelAssetId,
    dialogues: npc.dialogues.map(cloneProjectDialogue),
    defaultDialogueId: npc.defaultDialogueId,
    collider: cloneRuntimeCharacterShape(npc.collider),
    activeRoutineTitle: npc.activeRoutineTitle,
    animationClipName: npc.animationClipName,
    animationLoop: npc.animationLoop,
    resolvedPath:
      npc.resolvedPath === null
        ? null
        : cloneRuntimeResolvedNpcPathState(npc.resolvedPath)
  };
}

function clonePlayerStartMovementCapabilities(
  capabilities: PlayerStartMovementCapabilities
): PlayerStartMovementCapabilities {
  return {
    jump: capabilities.jump,
    sprint: capabilities.sprint,
    crouch: capabilities.crouch
  };
}

function clonePlayerStartJumpSettings(
  jump: PlayerStartJumpSettings
): PlayerStartJumpSettings {
  return {
    speed: jump.speed,
    bufferMs: jump.bufferMs,
    coyoteTimeMs: jump.coyoteTimeMs,
    variableHeight: jump.variableHeight,
    maxHoldMs: jump.maxHoldMs,
    moveWhileJumping: jump.moveWhileJumping,
    moveWhileFalling: jump.moveWhileFalling,
    directionOnly: jump.directionOnly,
    bunnyHop: jump.bunnyHop,
    bunnyHopBoost: jump.bunnyHopBoost
  };
}

function clonePlayerStartSprintSettings(
  sprint: PlayerStartSprintSettings
): PlayerStartSprintSettings {
  return {
    speedMultiplier: sprint.speedMultiplier
  };
}

function clonePlayerStartCrouchSettings(
  crouch: PlayerStartCrouchSettings
): PlayerStartCrouchSettings {
  return {
    speedMultiplier: crouch.speedMultiplier
  };
}

function cloneRuntimePlayerMovement(
  movement: RuntimePlayerMovement
): RuntimePlayerMovement {
  return {
    templateKind: movement.templateKind,
    moveSpeed: movement.moveSpeed,
    maxSpeed: movement.maxSpeed,
    maxStepHeight: movement.maxStepHeight,
    capabilities: clonePlayerStartMovementCapabilities(movement.capabilities),
    jump: clonePlayerStartJumpSettings(movement.jump),
    sprint: clonePlayerStartSprintSettings(movement.sprint),
    crouch: clonePlayerStartCrouchSettings(movement.crouch)
  };
}

function buildRuntimePlayerMovement(
  template: PlayerStartMovementTemplate | undefined
): RuntimePlayerMovement {
  const resolvedTemplate = createPlayerStartMovementTemplate(template);

  return {
    templateKind: resolvedTemplate.kind,
    moveSpeed: resolvedTemplate.moveSpeed,
    maxSpeed: resolvedTemplate.maxSpeed,
    maxStepHeight: resolvedTemplate.maxStepHeight,
    capabilities: clonePlayerStartMovementCapabilities(
      resolvedTemplate.capabilities
    ),
    jump: clonePlayerStartJumpSettings(resolvedTemplate.jump),
    sprint: clonePlayerStartSprintSettings(resolvedTemplate.sprint),
    crouch: clonePlayerStartCrouchSettings(resolvedTemplate.crouch)
  };
}

function resolveRuntimeMaterial(
  document: SceneDocument,
  materialId: string | null
): MaterialDef | null {
  if (materialId === null) {
    return null;
  }

  const material = document.materials[materialId];

  if (material === undefined) {
    throw new Error(`Runtime build could not resolve material ${materialId}.`);
  }

  return cloneMaterialDef(material);
}

function buildRuntimeBrush(
  brush: Brush,
  document: SceneDocument
): RuntimeBoxBrushInstance {
  return {
    id: brush.id,
    kind: brush.kind,
    sideCount:
      brush.kind === "radialPrism" || brush.kind === "cone"
        ? brush.sideCount
        : undefined,
    majorSegmentCount:
      brush.kind === "torus" ? brush.majorSegmentCount : undefined,
    tubeSegmentCount:
      brush.kind === "torus" ? brush.tubeSegmentCount : undefined,
    visible: brush.visible,
    center: cloneVec3(brush.center),
    rotationDegrees: cloneVec3(brush.rotationDegrees),
    size: cloneVec3(brush.size),
    geometry: cloneBrushGeometry(brush.geometry),
    volume: cloneBoxBrushVolumeSettings(brush.volume),
    faces: Object.fromEntries(
      getBrushFaceIds(brush).map((faceId) => {
        const face = brush.faces[faceId];
        return [
          faceId,
          {
            materialId: face.materialId,
            material: resolveRuntimeMaterial(document, face.materialId),
            uv: cloneFaceUvState(face.uv)
          }
        ];
      })
    ) as Record<WhiteboxFaceId, RuntimeBrushFace>
  };
}

function buildRuntimeFogVolume(brush: Brush): RuntimeFogVolume {
  if (brush.volume.mode !== "fog") {
    throw new Error(`Cannot build fog volume from non-fog brush ${brush.id}.`);
  }

  return {
    brushId: brush.id,
    center: cloneVec3(brush.center),
    rotationDegrees: cloneVec3(brush.rotationDegrees),
    size: cloneVec3(brush.size),
    colorHex: brush.volume.fog.colorHex,
    density: brush.volume.fog.density,
    padding: brush.volume.fog.padding
  };
}

function buildRuntimeWaterVolume(brush: Brush): RuntimeWaterVolume {
  if (brush.volume.mode !== "water") {
    throw new Error(
      `Cannot build water volume from non-water brush ${brush.id}.`
    );
  }

  return {
    brushId: brush.id,
    center: cloneVec3(brush.center),
    rotationDegrees: cloneVec3(brush.rotationDegrees),
    size: cloneVec3(brush.size),
    colorHex: brush.volume.water.colorHex,
    surfaceOpacity: brush.volume.water.surfaceOpacity,
    waveStrength: brush.volume.water.waveStrength
  };
}

function buildRuntimeLightVolume(brush: Brush): RuntimeLightVolume {
  if (brush.volume.mode !== "light") {
    throw new Error(
      `Cannot build light volume from non-light brush ${brush.id}.`
    );
  }

  return {
    brushId: brush.id,
    enabled: brush.visible,
    center: cloneVec3(brush.center),
    rotationDegrees: cloneVec3(brush.rotationDegrees),
    size: cloneVec3(brush.size),
    colorHex: brush.volume.light.colorHex,
    intensity: brush.volume.light.intensity,
    padding: brush.volume.light.padding,
    falloff: brush.volume.light.falloff,
    lights: deriveBoxLightVolumePointLights({
      size: brush.size,
      intensity: brush.volume.light.intensity,
      padding: brush.volume.light.padding,
      falloff: brush.volume.light.falloff
    })
  };
}

function buildRuntimeCollider(brush: Brush): RuntimeBrushTriMeshCollider {
  const bounds = getBrushBounds(brush);
  const derivedMesh = buildBoxBrushDerivedMeshData(brush);

  return {
    kind: "trimesh",
    source: "brush",
    brushId: brush.id,
    center: cloneVec3(brush.center),
    rotationDegrees: cloneVec3(brush.rotationDegrees),
    vertices: derivedMesh.colliderVertices,
    indices: derivedMesh.colliderIndices,
    worldBounds: {
      min: cloneVec3(bounds.min),
      max: cloneVec3(bounds.max)
    }
  };
}

function buildRuntimeTerrain(
  terrain: Terrain,
  document: SceneDocument
): RuntimeTerrain {
  return {
    id: terrain.id,
    name: terrain.name,
    visible: terrain.visible,
    collisionEnabled: terrain.collisionEnabled,
    position: cloneVec3(terrain.position),
    sampleCountX: terrain.sampleCountX,
    sampleCountZ: terrain.sampleCountZ,
    cellSize: terrain.cellSize,
    heights: [...terrain.heights],
    layers: terrain.layers.map((layer) => ({
      materialId: layer.materialId,
      material: resolveRuntimeMaterial(document, layer.materialId)
    })),
    paintWeights: [...terrain.paintWeights]
  };
}

function buildRuntimeTerrainCollider(
  terrain: Terrain
): RuntimeTerrainHeightfieldCollider | null {
  if (!terrain.collisionEnabled) {
    return null;
  }

  const bounds = getTerrainBounds(terrain);

  return {
    kind: "heightfield",
    source: "terrain",
    terrainId: terrain.id,
    position: cloneVec3(terrain.position),
    rows: terrain.sampleCountX,
    cols: terrain.sampleCountZ,
    heights: new Float32Array(terrain.heights),
    minX: 0,
    maxX: getTerrainFootprintWidth(terrain),
    minZ: 0,
    maxZ: getTerrainFootprintDepth(terrain),
    worldBounds: {
      min: cloneVec3(bounds.min),
      max: cloneVec3(bounds.max)
    }
  };
}

function buildRuntimeModelInstance(
  modelInstance: SceneDocument["modelInstances"][string]
): RuntimeModelInstance {
  return {
    instanceId: modelInstance.id,
    assetId: modelInstance.assetId,
    name: modelInstance.name,
    visible: modelInstance.visible,
    position: cloneVec3(modelInstance.position),
    rotationDegrees: cloneVec3(modelInstance.rotationDegrees),
    scale: cloneVec3(modelInstance.scale),
    animationClipName: modelInstance.animationClipName,
    animationAutoplay: modelInstance.animationAutoplay,
    animationLoop: undefined
  };
}

function buildRuntimePathPoint(point: ScenePathPoint): RuntimePathPoint {
  return {
    pointId: point.id,
    position: cloneVec3(point.position)
  };
}

function buildRuntimePath(path: ScenePath): RuntimePath {
  const resolvedPath = resolveScenePath(path);

  return {
    id: path.id,
    name: path.name,
    visible: path.visible,
    enabled: path.enabled,
    loop: path.loop,
    points: resolvedPath.points.map(buildRuntimePathPoint),
    segments: resolvedPath.segments.map((segment) => ({
      index: segment.index,
      startPointId: segment.startPointId,
      endPointId: segment.endPointId,
      start: cloneVec3(segment.start),
      end: cloneVec3(segment.end),
      length: segment.length,
      distanceStart: segment.distanceStart,
      distanceEnd: segment.distanceEnd,
      tangent: cloneVec3(segment.tangent)
    })),
    totalLength: resolvedPath.totalLength
  };
}

function createRuntimePathLookup(
  paths: RuntimePath[]
): ReadonlyMap<string, RuntimePath> {
  return new Map(paths.map((path) => [path.id, path]));
}

function cloneResolvedActorPathToNpcPathState(
  pathState: NonNullable<
    RuntimeResolvedProjectScheduleState["actors"][number]["resolvedPath"]
  >
): RuntimeResolvedNpcPathState {
  return {
    pathId: pathState.pathId,
    progressMode: pathState.progressMode,
    speed: pathState.speed,
    loop: pathState.loop,
    smoothPath: pathState.smoothPath,
    elapsedHours: pathState.elapsedHours,
    distance: pathState.distance,
    progress: pathState.progress,
    position: cloneVec3(pathState.position),
    tangent: cloneVec3(pathState.tangent),
    yawDegrees: pathState.yawDegrees
  };
}

export function applyActorScheduleStateToNpcDefinition(
  npc: RuntimeNpcDefinition,
  actorState: RuntimeResolvedProjectScheduleState["actors"][number] | null
) {
  npc.active = actorState?.active ?? true;
  npc.activeRoutineId = actorState?.activeRoutineId ?? null;
  npc.activeRoutineTitle = actorState?.activeRoutineTitle ?? null;
  npc.animationClipName = actorState?.animationEffect?.clipName ?? null;
  npc.animationLoop = actorState?.animationEffect?.loop;
  npc.resolvedPath =
    actorState?.resolvedPath === null || actorState?.resolvedPath === undefined
      ? null
      : cloneResolvedActorPathToNpcPathState(actorState.resolvedPath);

  if (npc.resolvedPath !== null) {
    npc.position = cloneVec3(npc.resolvedPath.position);
    npc.yawDegrees = npc.resolvedPath.yawDegrees ?? npc.authoredYawDegrees;
    return;
  }

  npc.position = cloneVec3(npc.authoredPosition);
  npc.yawDegrees = npc.authoredYawDegrees;
}

function getColliderBounds(
  collider: RuntimeSceneCollider
): GeneratedColliderBounds {
  return {
    min: cloneVec3(collider.worldBounds.min),
    max: cloneVec3(collider.worldBounds.max)
  };
}

export function buildRuntimeNpcCollider(
  npc: RuntimeNpc | RuntimeNpcDefinition
): RuntimeNpcCollider | null {
  if (npc.collider.mode === "none") {
    return null;
  }

  const rotationDegrees = {
    x: 0,
    y: npc.yawDegrees,
    z: 0
  };

  switch (npc.collider.mode) {
    case "capsule": {
      return {
        kind: "character",
        source: "npc",
        entityId: npc.entityId,
        position: cloneVec3(npc.position),
        rotationDegrees,
        shape: {
          mode: "capsule",
          radius: npc.collider.radius,
          height: npc.collider.height,
          eyeHeight: npc.collider.eyeHeight
        },
        worldBounds: {
          min: {
            x: npc.position.x - npc.collider.radius,
            y: npc.position.y,
            z: npc.position.z - npc.collider.radius
          },
          max: {
            x: npc.position.x + npc.collider.radius,
            y: npc.position.y + npc.collider.height,
            z: npc.position.z + npc.collider.radius
          }
        }
      };
    }
    case "box": {
      const halfExtents = {
        x: npc.collider.size.x * 0.5,
        y: npc.collider.size.y * 0.5,
        z: npc.collider.size.z * 0.5
      };
      const yawRadians = (rotationDegrees.y * Math.PI) / 180;
      const cosine = Math.abs(Math.cos(yawRadians));
      const sine = Math.abs(Math.sin(yawRadians));
      const rotatedHalfX = halfExtents.x * cosine + halfExtents.z * sine;
      const rotatedHalfZ = halfExtents.x * sine + halfExtents.z * cosine;

      return {
        kind: "character",
        source: "npc",
        entityId: npc.entityId,
        position: cloneVec3(npc.position),
        rotationDegrees,
        shape: {
          mode: "box",
          size: cloneVec3(npc.collider.size),
          eyeHeight: npc.collider.eyeHeight
        },
        worldBounds: {
          min: {
            x: npc.position.x - rotatedHalfX,
            y: npc.position.y,
            z: npc.position.z - rotatedHalfZ
          },
          max: {
            x: npc.position.x + rotatedHalfX,
            y: npc.position.y + npc.collider.size.y,
            z: npc.position.z + rotatedHalfZ
          }
        }
      };
    }
  }
}

function combineColliderBounds(
  colliders: RuntimeSceneCollider[]
): RuntimeSceneBounds | null {
  if (colliders.length === 0) {
    return null;
  }

  const firstBounds = getColliderBounds(colliders[0]);
  const min = cloneVec3(firstBounds.min);
  const max = cloneVec3(firstBounds.max);

  for (const collider of colliders.slice(1)) {
    const bounds = getColliderBounds(collider);
    min.x = Math.min(min.x, bounds.min.x);
    min.y = Math.min(min.y, bounds.min.y);
    min.z = Math.min(min.z, bounds.min.z);
    max.x = Math.max(max.x, bounds.max.x);
    max.y = Math.max(max.y, bounds.max.y);
    max.z = Math.max(max.z, bounds.max.z);
  }

  return {
    min,
    max,
    center: {
      x: (min.x + max.x) * 0.5,
      y: (min.y + max.y) * 0.5,
      z: (min.z + max.z) * 0.5
    },
    size: {
      x: max.x - min.x,
      y: max.y - min.y,
      z: max.z - min.z
    }
  };
}

function getRuntimeTerrainSceneBounds(terrain: RuntimeTerrain): RuntimeSceneBounds {
  const bounds = getTerrainBounds({
    ...terrain,
    kind: "terrain",
    enabled: true
  });
  const min = cloneVec3(bounds.min);
  const max = cloneVec3(bounds.max);

  return {
    min,
    max,
    center: {
      x: (min.x + max.x) * 0.5,
      y: (min.y + max.y) * 0.5,
      z: (min.z + max.z) * 0.5
    },
    size: {
      x: max.x - min.x,
      y: max.y - min.y,
      z: max.z - min.z
    }
  };
}

function combineSceneBounds(
  colliderBounds: RuntimeSceneBounds | null,
  terrains: RuntimeTerrain[]
): RuntimeSceneBounds | null {
  const terrainBounds = terrains.map(getRuntimeTerrainSceneBounds);

  if (colliderBounds === null && terrainBounds.length === 0) {
    return null;
  }

  const min = colliderBounds?.min
    ? cloneVec3(colliderBounds.min)
    : cloneVec3(terrainBounds[0]!.min);
  const max = colliderBounds?.max
    ? cloneVec3(colliderBounds.max)
    : cloneVec3(terrainBounds[0]!.max);

  for (const bounds of terrainBounds) {
    min.x = Math.min(min.x, bounds.min.x);
    min.y = Math.min(min.y, bounds.min.y);
    min.z = Math.min(min.z, bounds.min.z);
    max.x = Math.max(max.x, bounds.max.x);
    max.y = Math.max(max.y, bounds.max.y);
    max.z = Math.max(max.z, bounds.max.z);
  }

  return {
    min,
    max,
    center: {
      x: (min.x + max.x) * 0.5,
      y: (min.y + max.y) * 0.5,
      z: (min.z + max.z) * 0.5
    },
    size: {
      x: max.x - min.x,
      y: max.y - min.y,
      z: max.z - min.z
    }
  };
}

function buildFallbackSpawn(
  sceneBounds: RuntimeSceneBounds | null
): RuntimeSpawnPoint {
  if (sceneBounds === null) {
    return {
      source: "fallback",
      entityId: null,
      position: {
        x: 0,
        y: 0,
        z: -4
      },
      yawDegrees: 0
    };
  }

  return {
    source: "fallback",
    entityId: null,
    position: {
      x: sceneBounds.center.x,
      y: sceneBounds.max.y + 0.1,
      z: sceneBounds.max.z + 3
    },
    yawDegrees: 180
  };
}

interface RuntimeSceneCollections {
  entities: RuntimeEntityCollection;
  localLights: RuntimeLocalLightCollection;
  npcDefinitions: RuntimeNpcDefinition[];
  scheduler: RuntimeResolvedProjectScheduleState;
}

function buildRuntimeControlSurface(
  document: SceneDocument,
  collections: RuntimeSceneCollections,
  modelInstances: RuntimeModelInstance[]
): RuntimeControlSurfaceDefinition {
  const targets: RuntimeControlSurfaceDefinition["targets"] = [];
  const channels: RuntimeControlSurfaceDefinition["channels"] = [];
  const resolved = createEmptyRuntimeResolvedControlState();
  const defaultSource = createDefaultResolvedControlSource();
  const seenActorIds = new Set<string>();
  const globalTarget = createProjectGlobalControlTargetRef();
  const sceneTarget = createActiveSceneControlTargetRef();
  const ambientLightDescriptor =
    createAmbientLightIntensityControlChannelDescriptor({
      target: sceneTarget,
      defaultValue: document.world.ambientLight.intensity
    });
  const sunLightDescriptor = createSunLightIntensityControlChannelDescriptor({
    target: sceneTarget,
    defaultValue: document.world.sunLight.intensity
  });

  targets.push(
    createControlTargetDescriptor(globalTarget, ["projectTimePause"])
  );
  resolved.discrete.push(
    createResolvedProjectTimePausedState({
      target: globalTarget,
      value: false,
      source: defaultSource
    }),
    createResolvedCameraRigOverrideState({
      target: globalTarget,
      entityId: null,
      source: defaultSource
    })
  );

  targets.push(
    createControlTargetDescriptor(sceneTarget, [
      "ambientLightIntensity",
      "ambientLightColor",
      "sunLightIntensity",
      "sunLightColor"
    ])
  );
  channels.push(ambientLightDescriptor, sunLightDescriptor);
  resolved.discrete.push(
    createResolvedAmbientLightColorState({
      target: sceneTarget,
      value: document.world.ambientLight.colorHex,
      source: defaultSource
    }),
    createResolvedSunLightColorState({
      target: sceneTarget,
      value: document.world.sunLight.colorHex,
      source: defaultSource
    })
  );
  resolved.channels.push(
    createResolvedAmbientLightIntensityChannelValue({
      descriptor: ambientLightDescriptor,
      value: document.world.ambientLight.intensity,
      source: defaultSource
    }),
    createResolvedSunLightIntensityChannelValue({
      descriptor: sunLightDescriptor,
      value: document.world.sunLight.intensity,
      source: defaultSource
    })
  );

  for (const npc of collections.npcDefinitions) {
    if (seenActorIds.has(npc.actorId)) {
      continue;
    }

    seenActorIds.add(npc.actorId);
    const target = createActorControlTargetRef(npc.actorId);
    const capabilities: Array<
      "actorPresence" | "actorAnimationPlayback" | "actorPathFollow"
    > = ["actorPresence"];
    const actorModelAsset =
      npc.modelAssetId === null ? undefined : document.assets[npc.modelAssetId];

    if (
      actorModelAsset !== undefined &&
      actorModelAsset.kind === "model" &&
      actorModelAsset.metadata.animationNames.length > 0
    ) {
      capabilities.push("actorAnimationPlayback");
    }

    if (getScenePaths(document.paths).some((path) => path.enabled)) {
      capabilities.push("actorPathFollow");
    }

    targets.push(
      createControlTargetDescriptor(target, capabilities)
    );
    resolved.discrete.push(
      createResolvedActorAnimationPlaybackState({
        target,
        clipName: null,
        loop: undefined,
        source: defaultSource
      }),
      createResolvedActorPathAssignmentState({
        target,
        pathId: null,
        speed: null,
        loop: false,
        smoothPath: true,
        progressMode: null,
        source: defaultSource
      })
    );
  }

  for (const cameraRig of collections.entities.cameraRigs) {
    targets.push(
      createControlTargetDescriptor(
        createCameraRigControlTargetRef(cameraRig.entityId),
        ["cameraRigOverride"]
      )
    );
  }

  for (const pointLight of collections.localLights.pointLights) {
    const target = createLightControlTargetRef(
      "pointLight",
      pointLight.entityId
    );
    const descriptor = createLightIntensityControlChannelDescriptor({
      target,
      defaultValue: pointLight.intensity
    });

    targets.push(
      createControlTargetDescriptor(target, [
        "lightEnabled",
        "lightIntensity",
        "lightColor"
      ])
    );
    channels.push(descriptor);
    resolved.discrete.push(
      createResolvedLightEnabledState({
        target,
        value: pointLight.enabled,
        source: defaultSource
      }),
      createResolvedLightColorState({
        target,
        value: pointLight.colorHex,
        source: defaultSource
      })
    );
    resolved.channels.push(
      createResolvedLightIntensityChannelValue({
        descriptor,
        value: pointLight.intensity,
        source: defaultSource
      })
    );
  }

  for (const spotLight of collections.localLights.spotLights) {
    const target = createLightControlTargetRef("spotLight", spotLight.entityId);
    const descriptor = createLightIntensityControlChannelDescriptor({
      target,
      defaultValue: spotLight.intensity
    });

    targets.push(
      createControlTargetDescriptor(target, [
        "lightEnabled",
        "lightIntensity",
        "lightColor"
      ])
    );
    channels.push(descriptor);
    resolved.discrete.push(
      createResolvedLightEnabledState({
        target,
        value: spotLight.enabled,
        source: defaultSource
      }),
      createResolvedLightColorState({
        target,
        value: spotLight.colorHex,
        source: defaultSource
      })
    );
    resolved.channels.push(
      createResolvedLightIntensityChannelValue({
        descriptor,
        value: spotLight.intensity,
        source: defaultSource
      })
    );
  }

  for (const soundEmitter of collections.entities.soundEmitters) {
    if (soundEmitter.audioAssetId === null) {
      continue;
    }

    const target = createSoundEmitterControlTargetRef(soundEmitter.entityId);
    const descriptor = createSoundVolumeControlChannelDescriptor({
      target,
      defaultValue: soundEmitter.volume
    });

    targets.push(
      createControlTargetDescriptor(target, ["soundPlayback", "soundVolume"])
    );
    channels.push(descriptor);
    resolved.discrete.push(
      createResolvedSoundPlaybackState({
        target,
        value: soundEmitter.autoplay,
        source: defaultSource
      })
    );
    resolved.channels.push(
      createResolvedSoundVolumeChannelValue({
        descriptor,
        value: soundEmitter.volume,
        source: defaultSource
      })
    );
  }

  for (const interactable of collections.entities.interactables) {
    const target = createInteractionControlTargetRef(
      "interactable",
      interactable.entityId
    );

    targets.push(
      createControlTargetDescriptor(target, ["interactionAvailability"])
    );
    resolved.discrete.push(
      createResolvedInteractionEnabledState({
        target,
        value: interactable.interactionEnabled,
        source: defaultSource
      })
    );
  }

  for (const modelInstance of modelInstances) {
    const authoredModelInstance =
      document.modelInstances[modelInstance.instanceId];
    const asset =
      authoredModelInstance === undefined
        ? undefined
        : document.assets[authoredModelInstance.assetId];
    const target = createModelInstanceControlTargetRef(modelInstance.instanceId);
    const hasAnimationPlayback =
      asset !== undefined &&
      asset.kind === "model" &&
      asset.metadata.animationNames.length > 0;
    const capabilities: Array<"animationPlayback" | "modelVisibility"> = [
      "modelVisibility"
    ];

    if (hasAnimationPlayback) {
      capabilities.unshift("animationPlayback");
    }

    targets.push(createControlTargetDescriptor(target, capabilities));
    resolved.discrete.push(
      createResolvedModelInstanceVisibilityState({
        target,
        value: modelInstance.visible,
        source: defaultSource
      })
    );

    if (hasAnimationPlayback) {
      resolved.discrete.push(
        createResolvedModelAnimationPlaybackState({
          target,
          clipName:
            modelInstance.animationAutoplay === true &&
            typeof modelInstance.animationClipName === "string"
              ? modelInstance.animationClipName
              : null,
          loop: modelInstance.animationLoop,
          source: defaultSource
        })
      );
    }
  }

  return createRuntimeControlSurfaceDefinition({
    targets,
    channels,
    baselineResolved: resolved,
    resolved: applyRuntimeProjectScheduleToControlState(
      resolved,
      collections.scheduler,
      resolved
    )
  });
}

function buildRuntimeSceneCollections(
  document: SceneDocument,
  runtimeClock: RuntimeClockState | null,
  paths: RuntimePath[]
): RuntimeSceneCollections {
  const runtimeEntities: RuntimeEntityCollection = {
    playerStarts: [],
    sceneEntries: [],
    cameraRigs: [],
    npcs: [],
    soundEmitters: [],
    triggerVolumes: [],
    teleportTargets: [],
    interactables: []
  };
  const localLights: RuntimeLocalLightCollection = {
    pointLights: [],
    spotLights: []
  };
  const npcDefinitions: RuntimeNpcDefinition[] = [];

  for (const entity of getEntityInstances(document.entities)) {
    if (!entity.enabled) {
      continue;
    }

    switch (entity.kind) {
      case "pointLight":
        localLights.pointLights.push({
          entityId: entity.id,
          enabled: entity.visible,
          position: cloneVec3(entity.position),
          colorHex: entity.colorHex,
          intensity: entity.intensity,
          distance: entity.distance
        });
        break;
      case "spotLight":
        localLights.spotLights.push({
          entityId: entity.id,
          enabled: entity.visible,
          position: cloneVec3(entity.position),
          direction: cloneVec3(entity.direction),
          colorHex: entity.colorHex,
          intensity: entity.intensity,
          distance: entity.distance,
          angleDegrees: entity.angleDegrees
        });
        break;
      case "playerStart":
        runtimeEntities.playerStarts.push({
          entityId: entity.id,
          position: cloneVec3(entity.position),
          yawDegrees: entity.yawDegrees,
          navigationMode: entity.navigationMode,
          interactionReachMeters: entity.interactionReachMeters,
          interactionAngleDegrees: entity.interactionAngleDegrees,
          allowLookInputTargetSwitch: entity.allowLookInputTargetSwitch,
          targetButtonCyclesActiveTarget: entity.targetButtonCyclesActiveTarget,
          movement: buildRuntimePlayerMovement(entity.movementTemplate),
          inputBindings: clonePlayerStartInputBindings(entity.inputBindings),
          collider: buildRuntimePlayerShape(entity)
        });
        break;
      case "sceneEntry":
        runtimeEntities.sceneEntries.push({
          entityId: entity.id,
          position: cloneVec3(entity.position),
          yawDegrees: entity.yawDegrees
        });
        break;
      case "cameraRig":
        runtimeEntities.cameraRigs.push(
          entity.rigType === "fixed"
            ? {
                entityId: entity.id,
                rigType: "fixed",
                priority: entity.priority,
                defaultActive: entity.defaultActive,
                position: cloneVec3(entity.position),
                target: cloneCameraRigTargetRef(entity.target),
                targetOffset: cloneVec3(entity.targetOffset),
                transitionMode: entity.transitionMode,
                transitionDurationSeconds: entity.transitionDurationSeconds,
                lookAround: cloneCameraRigLookAroundSettings(entity.lookAround)
              }
            : entity.railPlacementMode === "mapTargetBetweenPoints"
              ? {
                  entityId: entity.id,
                  rigType: "rail",
                  priority: entity.priority,
                  defaultActive: entity.defaultActive,
                  pathId: entity.pathId,
                  railPlacementMode: "mapTargetBetweenPoints",
                  trackStartPoint: cloneVec3(entity.trackStartPoint),
                  trackEndPoint: cloneVec3(entity.trackEndPoint),
                  railStartProgress: entity.railStartProgress,
                  railEndProgress: entity.railEndProgress,
                  target: cloneCameraRigTargetRef(entity.target),
                  targetOffset: cloneVec3(entity.targetOffset),
                  transitionMode: entity.transitionMode,
                  transitionDurationSeconds: entity.transitionDurationSeconds,
                  lookAround: cloneCameraRigLookAroundSettings(
                    entity.lookAround
                  )
                }
              : {
                  entityId: entity.id,
                  rigType: "rail",
                  priority: entity.priority,
                  defaultActive: entity.defaultActive,
                  pathId: entity.pathId,
                  railPlacementMode: "nearestToTarget",
                  target: cloneCameraRigTargetRef(entity.target),
                  targetOffset: cloneVec3(entity.targetOffset),
                  transitionMode: entity.transitionMode,
                  transitionDurationSeconds: entity.transitionDurationSeconds,
                  lookAround: cloneCameraRigLookAroundSettings(
                    entity.lookAround
                  )
                }
        );
        break;
      case "npc": {
        const npc: RuntimeNpcDefinition = {
          entityId: entity.id,
          actorId: entity.actorId,
          name: entity.name,
          visible: entity.visible,
          position: cloneVec3(entity.position),
          active: true,
          activeRoutineId: null,
          activeRoutineTitle: null,
          authoredPosition: cloneVec3(entity.position),
          yawDegrees: entity.yawDegrees,
          authoredYawDegrees: entity.yawDegrees,
          modelAssetId: entity.modelAssetId,
          dialogues: entity.dialogues.map(cloneProjectDialogue),
          defaultDialogueId: entity.defaultDialogueId,
          collider: createRuntimeCharacterShape(entity.collider),
          animationClipName: null,
          animationLoop: undefined,
          resolvedPath: null
        };
        npcDefinitions.push(npc);
        break;
      }
      case "soundEmitter":
        runtimeEntities.soundEmitters.push({
          entityId: entity.id,
          position: cloneVec3(entity.position),
          audioAssetId: entity.audioAssetId,
          volume: entity.volume,
          refDistance: entity.refDistance,
          maxDistance: entity.maxDistance,
          autoplay: entity.autoplay,
          loop: entity.loop
        });
        break;
      case "triggerVolume":
        runtimeEntities.triggerVolumes.push({
          entityId: entity.id,
          position: cloneVec3(entity.position),
          size: cloneVec3(entity.size),
          // Derive from links so flags are always correct regardless of stored entity state
          triggerOnEnter: Object.values(document.interactionLinks).some(
            (l) => l.sourceEntityId === entity.id && l.trigger === "enter"
          ),
          triggerOnExit: Object.values(document.interactionLinks).some(
            (l) => l.sourceEntityId === entity.id && l.trigger === "exit"
          )
        });
        break;
      case "teleportTarget":
        runtimeEntities.teleportTargets.push({
          entityId: entity.id,
          position: cloneVec3(entity.position),
          yawDegrees: entity.yawDegrees
        });
        break;
      case "interactable":
        runtimeEntities.interactables.push({
          entityId: entity.id,
          position: cloneVec3(entity.position),
          radius: entity.radius,
          prompt: entity.prompt,
          interactionEnabled: entity.interactionEnabled
        });
        break;
      default:
        assertNever(entity);
    }
  }

  const scheduler = resolveRuntimeProjectScheduleState({
    scheduler: document.scheduler,
    sequences: document.sequences,
    actorIds: npcDefinitions.map((npc) => npc.actorId),
    dayNumber:
      runtimeClock === null
        ? document.time.startDayNumber
        : runtimeClock.dayCount + 1,
    timeOfDayHours:
      runtimeClock === null
        ? document.time.startTimeOfDayHours
        : runtimeClock.timeOfDayHours,
    pathsById: createRuntimePathLookup(paths)
  });
  const scheduleByActorId = new Map(
    scheduler.actors.map((actorState) => [actorState.actorId, actorState])
  );

  for (const npc of npcDefinitions) {
    const actorState = scheduleByActorId.get(npc.actorId);
    applyActorScheduleStateToNpcDefinition(npc, actorState ?? null);

    if (npc.active) {
      runtimeEntities.npcs.push(createRuntimeNpcFromDefinition(npc));
    }
  }

  return {
    entities: runtimeEntities,
    localLights,
    npcDefinitions,
    scheduler
  };
}

function assertNever(value: never): never {
  throw new Error(
    `Unsupported runtime entity: ${String((value as EntityInstance).kind)}`
  );
}

function buildRuntimePlayerShape(
  playerStartEntity: ReturnType<typeof getPrimaryEnabledPlayerStartEntity>
): FirstPersonPlayerShape {
  if (playerStartEntity === null) {
    return FIRST_PERSON_PLAYER_SHAPE;
  }

  return createRuntimeCharacterShape(playerStartEntity.collider);
}

function resolveRuntimeSpawn(
  playerStart: RuntimePlayerStart | null,
  sceneEntries: RuntimeSceneEntry[],
  sceneBounds: RuntimeSceneBounds | null,
  sceneEntryId: string | null | undefined
): RuntimeSpawnPoint {
  if (sceneEntryId !== undefined && sceneEntryId !== null) {
    const sceneEntry =
      sceneEntries.find((entry) => entry.entityId === sceneEntryId) ?? null;

    if (sceneEntry === null) {
      throw new Error(
        `Runtime build could not resolve Scene Entry ${sceneEntryId}.`
      );
    }

    return {
      source: "sceneEntry",
      entityId: sceneEntry.entityId,
      position: cloneVec3(sceneEntry.position),
      yawDegrees: sceneEntry.yawDegrees
    };
  }

  if (playerStart !== null) {
    return {
      source: "playerStart",
      entityId: playerStart.entityId,
      position: cloneVec3(playerStart.position),
      yawDegrees: playerStart.yawDegrees
    };
  }

  return buildFallbackSpawn(sceneBounds);
}

export function buildRuntimeSceneFromDocument(
  document: SceneDocument,
  options: BuildRuntimeSceneOptions = {}
): RuntimeSceneDefinition {
  const playerStartEntity = getPrimaryEnabledPlayerStartEntity(
    document.entities
  );
  const navigationMode = resolveRuntimeNavigationMode(
    playerStartEntity,
    options.navigationMode
  );

  assertRuntimeSceneBuildable(document, {
    navigationMode,
    loadedModelAssets: options.loadedModelAssets
  });

  const enabledBrushes = Object.values(document.brushes).filter(
    (brush) => brush.enabled
  );
  const brushes = enabledBrushes.map((brush) =>
    buildRuntimeBrush(brush, document)
  );
  const enabledTerrains = getTerrains(document.terrains).filter(
    (terrain) => terrain.enabled
  );
  const terrains = enabledTerrains.map((terrain) =>
    buildRuntimeTerrain(terrain, document)
  );
  const staticColliders: RuntimeSceneCollider[] = [];
  const volumes: RuntimeBoxVolumeCollection = {
    fog: [],
    water: [],
    light: []
  };

  for (const brush of enabledBrushes) {
    if (brush.volume.mode === "none") {
      staticColliders.push(buildRuntimeCollider(brush));
      continue;
    }

    if (brush.kind !== "box") {
      staticColliders.push(buildRuntimeCollider(brush));
      continue;
    }

    if (brush.volume.mode === "fog") {
      volumes.fog.push(buildRuntimeFogVolume(brush));
      continue;
    }

    if (brush.volume.mode === "light") {
      volumes.light.push(buildRuntimeLightVolume(brush));
      continue;
    }

    volumes.water.push(buildRuntimeWaterVolume(brush));
  }

  for (const terrain of enabledTerrains) {
    const terrainCollider = buildRuntimeTerrainCollider(terrain);

    if (terrainCollider !== null) {
      staticColliders.push(terrainCollider);
    }
  }

  const enabledModelInstances = getModelInstances(
    document.modelInstances
  ).filter((modelInstance) => modelInstance.enabled);
  const modelInstances = enabledModelInstances.map(buildRuntimeModelInstance);
  const paths = getScenePaths(document.paths)
    .filter((path) => path.enabled)
    .map(buildRuntimePath);
  const collections = buildRuntimeSceneCollections(
    document,
    options.runtimeClock ?? null,
    paths
  );
  const control = buildRuntimeControlSurface(
    document,
    collections,
    modelInstances
  );
  const enabledBrushIds = new Set(enabledBrushes.map((brush) => brush.id));
  const enabledModelInstanceIds = new Set(
    enabledModelInstances.map((modelInstance) => modelInstance.id)
  );
  const enabledEntityIds = new Set(
    getEntityInstances(document.entities)
      .filter((entity) => entity.enabled)
      .map((entity) => entity.id)
  );
  const interactionLinks = getInteractionLinks(document.interactionLinks)
    .filter((link) => {
      if (!enabledEntityIds.has(link.sourceEntityId)) {
        return false;
      }

      switch (link.action.type) {
        case "teleportPlayer":
          return enabledEntityIds.has(link.action.targetEntityId);
        case "toggleVisibility":
          return enabledBrushIds.has(link.action.targetBrushId);
        case "playAnimation":
        case "stopAnimation":
          return enabledModelInstanceIds.has(link.action.targetModelInstanceId);
        case "playSound":
        case "stopSound":
          return enabledEntityIds.has(link.action.targetSoundEmitterId);
        case "runSequence":
          return getInteractionLinkImpulseSteps(link, document.sequences).length > 0;
        case "control":
          switch (link.action.effect.target.kind) {
            case "entity":
            case "interaction":
              return enabledEntityIds.has(link.action.effect.target.entityId);
            case "modelInstance":
              return enabledModelInstanceIds.has(
                link.action.effect.target.modelInstanceId
              );
          }
      }
    })
    .map((link) => cloneInteractionLink(link));
  const playerCollider = buildRuntimePlayerShape(playerStartEntity);
  const playerMovement = buildRuntimePlayerMovement(
    playerStartEntity?.movementTemplate
  );
  const playerInputBindings = createPlayerStartInputBindings(
    playerStartEntity?.inputBindings
  );
  const colliders = [...staticColliders];

  for (const npc of collections.entities.npcs) {
    const collider = buildRuntimeNpcCollider(npc);

    if (collider !== null) {
      colliders.push(collider);
    }
  }

  for (const modelInstance of enabledModelInstances) {
    const asset = document.assets[modelInstance.assetId];

    if (asset === undefined || asset.kind !== "model") {
      continue;
    }

    const generatedCollider = buildGeneratedModelCollider(
      modelInstance,
      asset,
      options.loadedModelAssets?.[modelInstance.assetId]
    );

    if (generatedCollider !== null) {
      staticColliders.push(generatedCollider);
      colliders.push(generatedCollider);
    }
  }

  const combinedSceneBounds = combineSceneBounds(
    combineColliderBounds(colliders),
    terrains
  );
  const playerStart =
    playerStartEntity === null
      ? null
      : {
          entityId: playerStartEntity.id,
          position: cloneVec3(playerStartEntity.position),
          yawDegrees: playerStartEntity.yawDegrees,
          navigationMode,
          interactionReachMeters: playerStartEntity.interactionReachMeters,
          interactionAngleDegrees: playerStartEntity.interactionAngleDegrees,
          allowLookInputTargetSwitch:
            playerStartEntity.allowLookInputTargetSwitch,
          targetButtonCyclesActiveTarget:
            playerStartEntity.targetButtonCyclesActiveTarget,
          movement: cloneRuntimePlayerMovement(playerMovement),
          inputBindings: clonePlayerStartInputBindings(playerInputBindings),
          collider: playerCollider
        };

  return {
    time: cloneProjectTimeSettings(document.time),
    scheduler: createRuntimeProjectSchedulerState({
      document: cloneProjectScheduler(document.scheduler),
      resolved: collections.scheduler
    }),
    sequences: cloneProjectSequenceLibrary(document.sequences),
    world: cloneWorldSettings(document.world),
    control,
    localLights: collections.localLights,
    brushes,
    terrains,
    volumes,
    staticColliders,
    colliders,
    sceneBounds: combinedSceneBounds,
    modelInstances,
    paths,
    npcDefinitions: collections.npcDefinitions,
    entities: collections.entities,
    interactionLinks,
    playerStart,
    playerCollider,
    playerMovement,
    playerInputBindings,
    navigationMode,
    spawn: resolveRuntimeSpawn(
      playerStart,
      collections.entities.sceneEntries,
      combinedSceneBounds,
      options.sceneEntryId
    )
  };
}
