import { getSingleSelectedBrushId, getSingleSelectedEntityId, getSingleSelectedModelInstanceId, type EditorSelection } from "../core/selection"; import type { Vec3 } from "../core/vector"; import type { BoxBrush } from "../document/brushes"; import type { SceneDocument } from "../document/scene-document"; import type { EntityInstance } from "../entities/entity-instances"; import type { ModelInstance } from "../assets/model-instances"; import type { ProjectAssetRecord } from "../assets/project-assets"; const PLAYER_START_FOCUS_HALF_EXTENTS: Vec3 = { x: 0.35, y: 0.3, z: 0.55 }; const TELEPORT_TARGET_FOCUS_HALF_EXTENTS: Vec3 = { x: 0.42, y: 0.28, z: 0.42 }; interface FocusBoundsAccumulator { min: Vec3; max: Vec3; } export interface ViewportFocusTarget { center: Vec3; radius: number; } function createEmptyBoundsAccumulator(): FocusBoundsAccumulator { return { min: { x: Number.POSITIVE_INFINITY, y: Number.POSITIVE_INFINITY, z: Number.POSITIVE_INFINITY }, max: { x: Number.NEGATIVE_INFINITY, y: Number.NEGATIVE_INFINITY, z: Number.NEGATIVE_INFINITY } }; } function includeBounds(bounds: FocusBoundsAccumulator, min: Vec3, max: Vec3) { bounds.min.x = Math.min(bounds.min.x, min.x); bounds.min.y = Math.min(bounds.min.y, min.y); bounds.min.z = Math.min(bounds.min.z, min.z); bounds.max.x = Math.max(bounds.max.x, max.x); bounds.max.y = Math.max(bounds.max.y, max.y); bounds.max.z = Math.max(bounds.max.z, max.z); } function finishBounds(bounds: FocusBoundsAccumulator): ViewportFocusTarget | null { if (!Number.isFinite(bounds.min.x) || !Number.isFinite(bounds.max.x)) { return null; } const center = { x: (bounds.min.x + bounds.max.x) * 0.5, y: (bounds.min.y + bounds.max.y) * 0.5, z: (bounds.min.z + bounds.max.z) * 0.5 }; const radius = Math.max( 0.5, Math.hypot(bounds.max.x - bounds.min.x, bounds.max.y - bounds.min.y, bounds.max.z - bounds.min.z) * 0.5 ); return { center, radius }; } function createBrushFocusTarget(brush: BoxBrush): ViewportFocusTarget { return { center: { ...brush.center }, radius: Math.max(0.5, Math.hypot(brush.size.x, brush.size.y, brush.size.z) * 0.5) }; } function includeBrush(bounds: FocusBoundsAccumulator, brush: BoxBrush) { const halfSize = { x: brush.size.x * 0.5, y: brush.size.y * 0.5, z: brush.size.z * 0.5 }; includeBounds( bounds, { x: brush.center.x - halfSize.x, y: brush.center.y - halfSize.y, z: brush.center.z - halfSize.z }, { x: brush.center.x + halfSize.x, y: brush.center.y + halfSize.y, z: brush.center.z + halfSize.z } ); } function includePlayerStart(bounds: FocusBoundsAccumulator, position: Vec3) { includeBounds( bounds, { x: position.x - PLAYER_START_FOCUS_HALF_EXTENTS.x, y: position.y, z: position.z - PLAYER_START_FOCUS_HALF_EXTENTS.z }, { x: position.x + PLAYER_START_FOCUS_HALF_EXTENTS.x, y: position.y + PLAYER_START_FOCUS_HALF_EXTENTS.y * 2, z: position.z + PLAYER_START_FOCUS_HALF_EXTENTS.z } ); } function createBoundsFocusTarget(center: Vec3, halfExtents: Vec3, minimumRadius: number): ViewportFocusTarget { return { center, radius: Math.max(minimumRadius, Math.hypot(halfExtents.x, halfExtents.y, halfExtents.z)) }; } function createPlayerStartFocusTarget(position: Vec3): ViewportFocusTarget { return createBoundsFocusTarget( { x: position.x, y: position.y + PLAYER_START_FOCUS_HALF_EXTENTS.y, z: position.z }, PLAYER_START_FOCUS_HALF_EXTENTS, 0.45 ); } function includeTeleportTarget(bounds: FocusBoundsAccumulator, position: Vec3) { includeBounds( bounds, { x: position.x - TELEPORT_TARGET_FOCUS_HALF_EXTENTS.x, y: position.y, z: position.z - TELEPORT_TARGET_FOCUS_HALF_EXTENTS.z }, { x: position.x + TELEPORT_TARGET_FOCUS_HALF_EXTENTS.x, y: position.y + TELEPORT_TARGET_FOCUS_HALF_EXTENTS.y * 2, z: position.z + TELEPORT_TARGET_FOCUS_HALF_EXTENTS.z } ); } function createTeleportTargetFocusTarget(position: Vec3): ViewportFocusTarget { return createBoundsFocusTarget( { x: position.x, y: position.y + TELEPORT_TARGET_FOCUS_HALF_EXTENTS.y, z: position.z }, TELEPORT_TARGET_FOCUS_HALF_EXTENTS, 0.45 ); } function getModelInstanceBoundingBox(modelInstance: ModelInstance, asset: ProjectAssetRecord | undefined): { center: Vec3; size: Vec3 } { if (asset?.kind === "model" && asset.metadata.boundingBox !== null) { const boundingBox = asset.metadata.boundingBox; const scaledMin = { x: boundingBox.min.x * modelInstance.scale.x, y: boundingBox.min.y * modelInstance.scale.y, z: boundingBox.min.z * modelInstance.scale.z }; const scaledMax = { x: boundingBox.max.x * modelInstance.scale.x, y: boundingBox.max.y * modelInstance.scale.y, z: boundingBox.max.z * modelInstance.scale.z }; return { center: { x: modelInstance.position.x + (scaledMin.x + scaledMax.x) * 0.5, y: modelInstance.position.y + (scaledMin.y + scaledMax.y) * 0.5, z: modelInstance.position.z + (scaledMin.z + scaledMax.z) * 0.5 }, size: { x: Math.abs(scaledMax.x - scaledMin.x), y: Math.abs(scaledMax.y - scaledMin.y), z: Math.abs(scaledMax.z - scaledMin.z) } }; } return { center: { ...modelInstance.position }, size: { x: modelInstance.scale.x, y: modelInstance.scale.y, z: modelInstance.scale.z } }; } function includeModelInstance(bounds: FocusBoundsAccumulator, modelInstance: ModelInstance, asset: ProjectAssetRecord | undefined) { const modelBounds = getModelInstanceBoundingBox(modelInstance, asset); const halfSize = { x: modelBounds.size.x * 0.5, y: modelBounds.size.y * 0.5, z: modelBounds.size.z * 0.5 }; includeBounds( bounds, { x: modelBounds.center.x - halfSize.x, y: modelBounds.center.y - halfSize.y, z: modelBounds.center.z - halfSize.z }, { x: modelBounds.center.x + halfSize.x, y: modelBounds.center.y + halfSize.y, z: modelBounds.center.z + halfSize.z } ); } function createModelInstanceFocusTarget(modelInstance: ModelInstance, asset: ProjectAssetRecord | undefined): ViewportFocusTarget { const modelBounds = getModelInstanceBoundingBox(modelInstance, asset); return createBoundsFocusTarget( modelBounds.center, { x: modelBounds.size.x * 0.5, y: modelBounds.size.y * 0.5, z: modelBounds.size.z * 0.5 }, 0.5 ); } function includeSphereEntity(bounds: FocusBoundsAccumulator, position: Vec3, radius: number) { includeBounds( bounds, { x: position.x - radius, y: position.y - radius, z: position.z - radius }, { x: position.x + radius, y: position.y + radius, z: position.z + radius } ); } function createSphereEntityFocusTarget(position: Vec3, radius: number, minimumRadius: number): ViewportFocusTarget { return { center: { x: position.x, y: position.y, z: position.z }, radius: Math.max(minimumRadius, radius) }; } function includeTriggerVolume(bounds: FocusBoundsAccumulator, position: Vec3, size: Vec3) { const halfSize = { x: size.x * 0.5, y: size.y * 0.5, z: size.z * 0.5 }; includeBounds( bounds, { x: position.x - halfSize.x, y: position.y - halfSize.y, z: position.z - halfSize.z }, { x: position.x + halfSize.x, y: position.y + halfSize.y, z: position.z + halfSize.z } ); } function createTriggerVolumeFocusTarget(position: Vec3, size: Vec3): ViewportFocusTarget { const halfSize = { x: size.x * 0.5, y: size.y * 0.5, z: size.z * 0.5 }; return createBoundsFocusTarget( { x: position.x, y: position.y, z: position.z }, halfSize, 0.75 ); } function includeEntity(bounds: FocusBoundsAccumulator, entity: EntityInstance) { switch (entity.kind) { case "pointLight": includeSphereEntity(bounds, entity.position, Math.max(0.5, entity.distance)); break; case "spotLight": includeSphereEntity(bounds, entity.position, Math.max(0.75, entity.distance)); break; case "playerStart": includePlayerStart(bounds, entity.position); break; case "soundEmitter": includeSphereEntity(bounds, entity.position, Math.max(0.4, entity.maxDistance)); break; case "triggerVolume": includeTriggerVolume(bounds, entity.position, entity.size); break; case "teleportTarget": includeTeleportTarget(bounds, entity.position); break; case "interactable": includeSphereEntity(bounds, entity.position, Math.max(0.4, entity.radius)); break; } } function createEntityFocusTarget(entity: EntityInstance): ViewportFocusTarget { switch (entity.kind) { case "pointLight": return createSphereEntityFocusTarget(entity.position, Math.max(0.6, entity.distance), 0.75); case "spotLight": return createSphereEntityFocusTarget(entity.position, Math.max(0.8, entity.distance), 0.9); case "playerStart": return createPlayerStartFocusTarget(entity.position); case "soundEmitter": return createSphereEntityFocusTarget(entity.position, entity.maxDistance, 0.75); case "triggerVolume": return createTriggerVolumeFocusTarget(entity.position, entity.size); case "teleportTarget": return createTeleportTargetFocusTarget(entity.position); case "interactable": return createSphereEntityFocusTarget(entity.position, entity.radius, 0.65); } } function getSceneFocusTarget(document: SceneDocument): ViewportFocusTarget | null { const bounds = createEmptyBoundsAccumulator(); for (const brush of Object.values(document.brushes)) { includeBrush(bounds, brush); } for (const modelInstance of Object.values(document.modelInstances)) { includeModelInstance(bounds, modelInstance, document.assets[modelInstance.assetId]); } for (const entity of Object.values(document.entities)) { includeEntity(bounds, entity); } return finishBounds(bounds); } export function resolveViewportFocusTarget(document: SceneDocument, selection: EditorSelection): ViewportFocusTarget | null { const selectedBrushId = getSingleSelectedBrushId(selection); if (selectedBrushId !== null) { const brush = document.brushes[selectedBrushId]; if (brush !== undefined && brush.kind === "box") { return createBrushFocusTarget(brush); } } const selectedEntityId = getSingleSelectedEntityId(selection); if (selectedEntityId !== null) { const entity = document.entities[selectedEntityId]; if (entity !== undefined) { return createEntityFocusTarget(entity); } } const selectedModelInstanceId = getSingleSelectedModelInstanceId(selection); if (selectedModelInstanceId !== null) { const modelInstance = document.modelInstances[selectedModelInstanceId]; if (modelInstance !== undefined) { return createModelInstanceFocusTarget(modelInstance, document.assets[modelInstance.assetId]); } } return getSceneFocusTarget(document); }