From 0eac096de58f09332ce43e8c65731995deaa9c24 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Mon, 13 Apr 2026 21:19:30 +0200 Subject: [PATCH] Add paths functionality to scene document and related components --- src/document/migrate-scene-document.ts | 97 ++++ src/document/paths.ts | 465 ++++++++++++++++++ src/document/scene-document-validation.ts | 89 +++- src/document/scene-document.ts | 11 +- src/entities/entity-instances.ts | 5 +- src/runtime-three/runtime-host.ts | 8 +- src/runtime-three/runtime-scene-build.ts | 2 - src/viewport-three/viewport-host.ts | 8 +- tests/domain/build-runtime-scene.test.ts | 18 +- tests/domain/rapier-collision-world.test.ts | 74 ++- .../domain/scene-document-validation.test.ts | 3 +- .../serialization/scene-document-json.test.ts | 1 + 12 files changed, 753 insertions(+), 28 deletions(-) create mode 100644 src/document/paths.ts diff --git a/src/document/migrate-scene-document.ts b/src/document/migrate-scene-document.ts index dbe0a422..5f974cc2 100644 --- a/src/document/migrate-scene-document.ts +++ b/src/document/migrate-scene-document.ts @@ -85,6 +85,7 @@ import { type FaceUvState } from "./brushes"; import { + PATH_FOUNDATION_SCENE_DOCUMENT_VERSION, BOX_BRUSH_SCENE_DOCUMENT_VERSION, ANIMATION_PLAYBACK_SCENE_DOCUMENT_VERSION, AUTHORED_OBJECT_STATE_SCENE_DOCUMENT_VERSION, @@ -138,6 +139,12 @@ import { type SceneLoadingScreenSettings, type SceneDocument } from "./scene-document"; +import { + createScenePath, + createScenePathPoint, + type ScenePath, + type ScenePathPoint +} from "./paths"; import { createDefaultProjectTimeSettings, normalizeProjectStartDayNumber, @@ -2938,6 +2945,69 @@ function readInteractionLinks( return interactionLinks; } +function readScenePathPointValue( + value: unknown, + label: string +): ScenePathPoint { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + + return createScenePathPoint({ + id: expectString(value.id, `${label}.id`), + position: readVec3(value.position, `${label}.position`) + }); +} + +function readScenePathValue(value: unknown, label: string): ScenePath { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + + if (!Array.isArray(value.points)) { + throw new Error(`${label}.points must be an array.`); + } + + return createScenePath({ + id: expectString(value.id, `${label}.id`), + name: + value.name === undefined ? undefined : expectString(value.name, `${label}.name`), + visible: expectBoolean(value.visible, `${label}.visible`), + enabled: expectBoolean(value.enabled, `${label}.enabled`), + loop: expectBoolean(value.loop, `${label}.loop`), + points: value.points.map((pointValue, index) => + readScenePathPointValue(pointValue, `${label}.points.${index}`) + ) + }); +} + +function readScenePaths( + value: unknown, + options: { allowMissing: boolean } +): SceneDocument["paths"] { + if (value === undefined && options.allowMissing) { + return {}; + } + + if (!isRecord(value)) { + throw new Error("paths must be a record."); + } + + const paths: SceneDocument["paths"] = {}; + + for (const [pathId, pathValue] of Object.entries(value)) { + const path = readScenePathValue(pathValue, `paths.${pathId}`); + + if (path.id !== pathId) { + throw new Error(`paths.${pathId}.id must match the registry key.`); + } + + paths[pathId] = path; + } + + return paths; +} + export function migrateSceneDocument(source: unknown): SceneDocument { if (!isRecord(source)) { throw new Error("Scene document must be a JSON object."); @@ -2956,6 +3026,7 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: {}, + paths: {}, modelInstances: expectEmptyCollection( source.modelInstances, "modelInstances" @@ -2981,6 +3052,7 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, true), + paths: {}, modelInstances: expectEmptyCollection( source.modelInstances, "modelInstances" @@ -3005,6 +3077,7 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), + paths: {}, modelInstances: expectEmptyCollection( source.modelInstances, "modelInstances" @@ -3029,6 +3102,7 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), + paths: {}, modelInstances: expectEmptyCollection( source.modelInstances, "modelInstances" @@ -3053,6 +3127,7 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), + paths: {}, modelInstances: expectEmptyCollection( source.modelInstances, "modelInstances" @@ -3077,6 +3152,7 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), + paths: {}, modelInstances: expectEmptyCollection( source.modelInstances, "modelInstances" @@ -3101,6 +3177,7 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), + paths: {}, modelInstances: expectEmptyCollection( source.modelInstances, "modelInstances" @@ -3127,6 +3204,7 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), + paths: {}, modelInstances: expectEmptyCollection( source.modelInstances, "modelInstances" @@ -3148,6 +3226,7 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), + paths: {}, modelInstances: expectEmptyCollection( source.modelInstances, "modelInstances" @@ -3170,6 +3249,7 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets, brushes: readBrushes(source.brushes, materials, false), + paths: {}, modelInstances: readModelInstances(source.modelInstances, assets), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: readInteractionLinks(source.interactionLinks) @@ -3189,6 +3269,7 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets, brushes: readBrushes(source.brushes, materials, false), + paths: {}, modelInstances: readModelInstances(source.modelInstances, assets), entities: readEntities(source.entities, { legacySoundEmitter: true }), interactionLinks: readInteractionLinks(source.interactionLinks) @@ -3211,6 +3292,7 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets, brushes: readBrushes(source.brushes, materials, false), + paths: {}, modelInstances: readModelInstances(source.modelInstances, assets), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: readInteractionLinks(source.interactionLinks) @@ -3230,6 +3312,7 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets, brushes: readBrushes(source.brushes, materials, false), + paths: {}, modelInstances: readModelInstances(source.modelInstances, assets), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: readInteractionLinks(source.interactionLinks) @@ -3250,6 +3333,7 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets, brushes: readBrushes(source.brushes, materials, false), + paths: {}, modelInstances: readModelInstances(source.modelInstances, assets), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: readInteractionLinks(source.interactionLinks) @@ -3272,6 +3356,7 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets, brushes: readBrushes(source.brushes, materials, false), + paths: {}, modelInstances: readModelInstances(source.modelInstances, assets), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: readInteractionLinks(source.interactionLinks) @@ -3292,6 +3377,7 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets, brushes: readBrushes(source.brushes, materials, false), + paths: {}, modelInstances: readModelInstances(source.modelInstances, assets), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: readInteractionLinks(source.interactionLinks) @@ -3312,6 +3398,7 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets, brushes: readBrushes(source.brushes, materials, false), + paths: {}, modelInstances: readModelInstances(source.modelInstances, assets), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: readInteractionLinks(source.interactionLinks) @@ -3367,6 +3454,9 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets, brushes: readBrushes(source.brushes, materials, false), + paths: readScenePaths(source.paths, { + allowMissing: source.version < PATH_FOUNDATION_SCENE_DOCUMENT_VERSION + }), modelInstances: readModelInstances(source.modelInstances, assets), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: readInteractionLinks(source.interactionLinks) @@ -3381,6 +3471,7 @@ function readProjectScene( options: { allowMissingLoadingScreen: boolean; allowMissingEditorPreferences: boolean; + allowMissingPaths: boolean; legacyProjectTimeValue?: unknown; } ): ProjectScene { @@ -3409,6 +3500,9 @@ function readProjectScene( legacyProjectTimeValue: options.legacyProjectTimeValue }), brushes: readBrushes(value.brushes, materials, false), + paths: readScenePaths(value.paths, { + allowMissing: options.allowMissingPaths + }), modelInstances: readModelInstances(value.modelInstances, assets), entities: readEntities(value.entities, { legacySoundEmitter: false }), interactionLinks: readInteractionLinks(value.interactionLinks) @@ -3455,6 +3549,8 @@ export function migrateProjectDocument(source: unknown): ProjectDocument { source.version < PROJECT_NAME_SCENE_DOCUMENT_VERSION; const allowMissingEditorPreferences = source.version < SCENE_EDITOR_PREFERENCES_SCENE_DOCUMENT_VERSION; + const allowMissingPaths = + source.version < PATH_FOUNDATION_SCENE_DOCUMENT_VERSION; const allowMissingTimeSettings = source.version < PROJECT_TIME_SYSTEM_SCENE_DOCUMENT_VERSION; @@ -3467,6 +3563,7 @@ export function migrateProjectDocument(source: unknown): ProjectDocument { { allowMissingLoadingScreen, allowMissingEditorPreferences, + allowMissingPaths, legacyProjectTimeValue: source.version < SCENE_DOCUMENT_VERSION ? source.time : undefined } diff --git a/src/document/paths.ts b/src/document/paths.ts new file mode 100644 index 00000000..54ef8982 --- /dev/null +++ b/src/document/paths.ts @@ -0,0 +1,465 @@ +import { createOpaqueId } from "../core/ids"; +import type { Vec3 } from "../core/vector"; + +export interface ScenePathPoint { + id: string; + position: Vec3; +} + +export interface ScenePath { + id: string; + kind: "path"; + name?: string; + visible: boolean; + enabled: boolean; + loop: boolean; + points: ScenePathPoint[]; +} + +export interface ResolvedScenePathSegment { + index: number; + startPointId: string; + endPointId: string; + start: Vec3; + end: Vec3; + length: number; + distanceStart: number; + distanceEnd: number; + tangent: Vec3; +} + +export interface ResolvedScenePath { + loop: boolean; + points: ScenePathPoint[]; + segments: ResolvedScenePathSegment[]; + totalLength: number; +} + +export const DEFAULT_SCENE_PATH_VISIBLE = true; +export const DEFAULT_SCENE_PATH_ENABLED = true; +export const DEFAULT_SCENE_PATH_LOOP = false; +export const MIN_SCENE_PATH_POINT_COUNT = 2; + +const DEFAULT_SCENE_PATH_POINT_POSITIONS: ReadonlyArray = [ + { + x: -1, + y: 0, + z: 0 + }, + { + x: 1, + y: 0, + z: 0 + } +]; + +function cloneVec3(vector: Vec3): Vec3 { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} + +function areVec3Equal(left: Vec3, right: Vec3): boolean { + return left.x === right.x && left.y === right.y && left.z === right.z; +} + +function assertFiniteVec3(vector: Vec3, label: string) { + if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y) || !Number.isFinite(vector.z)) { + throw new Error(`${label} must remain finite on every axis.`); + } +} + +function normalizeDelta(delta: Vec3): Vec3 { + const length = Math.hypot(delta.x, delta.y, delta.z); + + if (length <= 0) { + return { + x: 0, + y: 0, + z: 0 + }; + } + + return { + x: delta.x / length, + y: delta.y / length, + z: delta.z / length + }; +} + +function clampProgress(progress: number): number { + if (!Number.isFinite(progress)) { + throw new Error("Path progress must be a finite number."); + } + + if (progress <= 0) { + return 0; + } + + if (progress >= 1) { + return 1; + } + + return progress; +} + +function resolvePathSegmentSample( + path: ResolvedScenePath, + progress: number +): { segmentIndex: number | null; distance: number } { + if (path.segments.length === 0 || path.totalLength <= 0) { + return { + segmentIndex: null, + distance: 0 + }; + } + + const distance = clampProgress(progress) * path.totalLength; + + if (distance >= path.totalLength) { + return { + segmentIndex: path.segments.length - 1, + distance + }; + } + + const segmentIndex = path.segments.findIndex( + (segment) => distance <= segment.distanceEnd + ); + + return { + segmentIndex: segmentIndex === -1 ? path.segments.length - 1 : segmentIndex, + distance + }; +} + +function findNonZeroSegmentTangent( + path: ResolvedScenePath, + index: number +): Vec3 { + for (let candidateIndex = index; candidateIndex < path.segments.length; candidateIndex += 1) { + const candidate = path.segments[candidateIndex]; + + if (candidate !== undefined && candidate.length > 0) { + return cloneVec3(candidate.tangent); + } + } + + for (let candidateIndex = index - 1; candidateIndex >= 0; candidateIndex -= 1) { + const candidate = path.segments[candidateIndex]; + + if (candidate !== undefined && candidate.length > 0) { + return cloneVec3(candidate.tangent); + } + } + + return { + x: 0, + y: 0, + z: 0 + }; +} + +export function normalizeScenePathName( + name: string | null | undefined +): string | undefined { + if (name === undefined || name === null) { + return undefined; + } + + const trimmedName = name.trim(); + return trimmedName.length === 0 ? undefined : trimmedName; +} + +export function createScenePathPoint( + overrides: Partial> = {} +): ScenePathPoint { + const position = cloneVec3( + overrides.position ?? { + x: 0, + y: 0, + z: 0 + } + ); + + assertFiniteVec3(position, "Path point position"); + + return { + id: overrides.id ?? createOpaqueId("path-point"), + position + }; +} + +export function cloneScenePathPoint(point: ScenePathPoint): ScenePathPoint { + return createScenePathPoint(point); +} + +export function createDefaultScenePathPoints(anchor?: Vec3): ScenePathPoint[] { + return DEFAULT_SCENE_PATH_POINT_POSITIONS.map((position) => + createScenePathPoint({ + position: { + x: position.x + (anchor?.x ?? 0), + y: position.y + (anchor?.y ?? 0), + z: position.z + (anchor?.z ?? 0) + } + }) + ); +} + +export function createScenePath( + overrides: Partial< + Pick + > = {} +): ScenePath { + const points = + overrides.points === undefined + ? createDefaultScenePathPoints() + : overrides.points.map(cloneScenePathPoint); + const visible = overrides.visible ?? DEFAULT_SCENE_PATH_VISIBLE; + const enabled = overrides.enabled ?? DEFAULT_SCENE_PATH_ENABLED; + const loop = overrides.loop ?? DEFAULT_SCENE_PATH_LOOP; + + if (points.length < MIN_SCENE_PATH_POINT_COUNT) { + throw new Error( + `Paths must define at least ${MIN_SCENE_PATH_POINT_COUNT} points.` + ); + } + + if (typeof visible !== "boolean") { + throw new Error("Path visible must be a boolean."); + } + + if (typeof enabled !== "boolean") { + throw new Error("Path enabled must be a boolean."); + } + + if (typeof loop !== "boolean") { + throw new Error("Path loop must be a boolean."); + } + + const seenPointIds = new Set(); + + for (const point of points) { + if (point.id.trim().length === 0) { + throw new Error("Path point ids must be non-empty strings."); + } + + if (seenPointIds.has(point.id)) { + throw new Error(`Duplicate path point id ${point.id}.`); + } + + seenPointIds.add(point.id); + } + + return { + id: overrides.id ?? createOpaqueId("path"), + kind: "path", + name: normalizeScenePathName(overrides.name), + visible, + enabled, + loop, + points + }; +} + +export function cloneScenePath(path: ScenePath): ScenePath { + return createScenePath(path); +} + +export function areScenePathsEqual(left: ScenePath, right: ScenePath): boolean { + return ( + left.id === right.id && + left.kind === right.kind && + left.name === right.name && + left.visible === right.visible && + left.enabled === right.enabled && + left.loop === right.loop && + left.points.length === right.points.length && + left.points.every( + (point, index) => + point.id === right.points[index]?.id && + areVec3Equal(point.position, right.points[index].position) + ) + ); +} + +export function compareScenePaths(left: ScenePath, right: ScenePath): number { + const leftName = left.name ?? ""; + const rightName = right.name ?? ""; + + if (leftName !== rightName) { + return leftName.localeCompare(rightName); + } + + return left.id.localeCompare(right.id); +} + +export function getScenePaths(paths: Record): ScenePath[] { + return Object.values(paths).sort(compareScenePaths); +} + +export function getScenePathLabel(path: ScenePath, index: number): string { + return path.name ?? `Path ${index + 1}`; +} + +export function createAppendedScenePathPoint(path: ScenePath): ScenePathPoint { + const lastPoint = path.points.at(-1); + const previousPoint = + path.points.length > 1 ? path.points[path.points.length - 2] : null; + + if (lastPoint === undefined) { + return createScenePathPoint(); + } + + if (previousPoint === null) { + return createScenePathPoint({ + position: { + x: lastPoint.position.x + 1, + y: lastPoint.position.y, + z: lastPoint.position.z + } + }); + } + + const delta = { + x: lastPoint.position.x - previousPoint.position.x, + y: lastPoint.position.y - previousPoint.position.y, + z: lastPoint.position.z - previousPoint.position.z + }; + const offset = + delta.x === 0 && delta.y === 0 && delta.z === 0 + ? { + x: 1, + y: 0, + z: 0 + } + : delta; + + return createScenePathPoint({ + position: { + x: lastPoint.position.x + offset.x, + y: lastPoint.position.y + offset.y, + z: lastPoint.position.z + offset.z + } + }); +} + +export function resolveScenePath(path: Pick): ResolvedScenePath { + const points = path.points.map(cloneScenePathPoint); + const segmentPairs = points.slice(1).map((point, index) => ({ + start: points[index], + end: point + })); + + if (path.loop && points.length > 1) { + segmentPairs.push({ + start: points[points.length - 1], + end: points[0] + }); + } + + let totalLength = 0; + const segments = segmentPairs.map(({ start, end }, index) => { + const delta = { + x: end.position.x - start.position.x, + y: end.position.y - start.position.y, + z: end.position.z - start.position.z + }; + const length = Math.hypot(delta.x, delta.y, delta.z); + const segment: ResolvedScenePathSegment = { + index, + startPointId: start.id, + endPointId: end.id, + start: cloneVec3(start.position), + end: cloneVec3(end.position), + length, + distanceStart: totalLength, + distanceEnd: totalLength + length, + tangent: normalizeDelta(delta) + }; + + totalLength += length; + return segment; + }); + + return { + loop: path.loop, + points, + segments, + totalLength + }; +} + +export function getScenePathLength(path: Pick): number { + return resolveScenePath(path).totalLength; +} + +export function sampleResolvedScenePathPosition( + path: ResolvedScenePath, + progress: number +): Vec3 { + if (path.points.length === 0) { + return { + x: 0, + y: 0, + z: 0 + }; + } + + const { segmentIndex, distance } = resolvePathSegmentSample(path, progress); + + if (segmentIndex === null) { + return cloneVec3(path.points[0].position); + } + + const segment = path.segments[segmentIndex]; + + if (segment.length <= 0) { + return cloneVec3(segment.start); + } + + const localDistance = Math.min( + segment.length, + Math.max(0, distance - segment.distanceStart) + ); + const t = localDistance / segment.length; + + return { + x: segment.start.x + (segment.end.x - segment.start.x) * t, + y: segment.start.y + (segment.end.y - segment.start.y) * t, + z: segment.start.z + (segment.end.z - segment.start.z) * t + }; +} + +export function sampleScenePathPosition( + path: Pick, + progress: number +): Vec3 { + return sampleResolvedScenePathPosition(resolveScenePath(path), progress); +} + +export function sampleResolvedScenePathTangent( + path: ResolvedScenePath, + progress: number +): Vec3 { + const { segmentIndex } = resolvePathSegmentSample(path, progress); + + if (segmentIndex === null) { + return { + x: 0, + y: 0, + z: 0 + }; + } + + return findNonZeroSegmentTangent(path, segmentIndex); +} + +export function sampleScenePathTangent( + path: Pick, + progress: number +): Vec3 { + return sampleResolvedScenePathTangent(resolveScenePath(path), progress); +} diff --git a/src/document/scene-document-validation.ts b/src/document/scene-document-validation.ts index eda031a1..9367f64f 100644 --- a/src/document/scene-document-validation.ts +++ b/src/document/scene-document-validation.ts @@ -2141,17 +2141,84 @@ function validateSoundEmitterEntity( ); } - validateCharacterColliderSettings( - entity.collider, + if (!isNonNegativeFiniteNumber(entity.volume)) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-sound-emitter-volume", + "Sound Emitter volume must remain a finite number zero or greater.", + `${path}.volume` + ) + ); + } + + if (!isPositiveFiniteNumber(entity.refDistance)) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-sound-emitter-ref-distance", + "Sound Emitter ref distance must remain a finite number greater than zero.", + `${path}.refDistance` + ) + ); + } + + if (!isPositiveFiniteNumber(entity.maxDistance)) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-sound-emitter-max-distance", + "Sound Emitter max distance must remain a finite number greater than zero.", + `${path}.maxDistance` + ) + ); + } + + if ( + isPositiveFiniteNumber(entity.refDistance) && + isPositiveFiniteNumber(entity.maxDistance) && + entity.maxDistance < entity.refDistance + ) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-sound-emitter-distance-range", + "Sound Emitter max distance must be greater than or equal to ref distance.", + `${path}.maxDistance` + ) + ); + } + + if (!isBoolean(entity.autoplay)) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-sound-emitter-autoplay-flag", + "Sound Emitter autoplay must remain a boolean.", + `${path}.autoplay` + ) + ); + } + + if (!isBoolean(entity.loop)) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-sound-emitter-loop-flag", + "Sound Emitter loop must remain a boolean.", + `${path}.loop` + ) + ); + } + + validateSoundEmitterAudioAsset( + entity, path, + document, diagnostics, - { - codePrefix: "player-start", - label: "Player Start", - getHeight: getPlayerStartColliderHeight - } + entity.autoplay === true ? "error" : "warning" ); -) { +} function validateCharacterColliderSettings( collider: CharacterColliderSettings, @@ -2247,6 +2314,12 @@ function validateCharacterColliderSettings( ); } } + +function validateTriggerVolumeEntity( + entity: TriggerVolumeEntity, + path: string, + diagnostics: SceneDiagnostic[] +) { validateAuthoredEntityState(entity, path, diagnostics); if (!isFiniteVec3(entity.position)) { diff --git a/src/document/scene-document.ts b/src/document/scene-document.ts index 617226b0..527c2e7b 100644 --- a/src/document/scene-document.ts +++ b/src/document/scene-document.ts @@ -18,8 +18,10 @@ import { createDefaultProjectTimeSettings, type ProjectTimeSettings } from "./project-time-settings"; +import { type ScenePath } from "./paths"; -export const SCENE_DOCUMENT_VERSION = 42 as const; +export const SCENE_DOCUMENT_VERSION = 43 as const; +export const PATH_FOUNDATION_SCENE_DOCUMENT_VERSION = 43 as const; export const NPC_COLLIDER_SCENE_DOCUMENT_VERSION = 42 as const; export const NPC_ENTITY_FOUNDATION_SCENE_DOCUMENT_VERSION = 41 as const; export const WORLD_TIME_ENVIRONMENT_SCENE_DOCUMENT_VERSION = 40 as const; @@ -117,6 +119,7 @@ export interface ProjectScene { editorPreferences: SceneEditorPreferences; world: WorldSettings; brushes: Record; + paths: Record; modelInstances: Record; entities: Record; interactionLinks: Record; @@ -142,6 +145,7 @@ export interface SceneDocument { textures: Record; assets: Record; brushes: Record; + paths: Record; modelInstances: Record; entities: Record; interactionLinks: Record; @@ -163,6 +167,7 @@ export function createEmptySceneDocument( textures: {}, assets: {}, brushes: {}, + paths: {}, modelInstances: {}, entities: {}, interactionLinks: {} @@ -188,6 +193,7 @@ export function createEmptyProjectScene( ), world: overrides.world ?? createDefaultWorldSettings(), brushes: {}, + paths: {}, modelInstances: {}, entities: {}, interactionLinks: {} @@ -262,6 +268,7 @@ export function createSceneDocumentFromProject( textures: projectDocument.textures, assets: projectDocument.assets, brushes: scene.brushes, + paths: scene.paths, modelInstances: scene.modelInstances, entities: scene.entities, interactionLinks: scene.interactionLinks @@ -286,6 +293,7 @@ export function createProjectDocumentFromSceneDocument( editorPreferences: createDefaultSceneEditorPreferences(), world: sceneDocument.world, brushes: sceneDocument.brushes, + paths: sceneDocument.paths, modelInstances: sceneDocument.modelInstances, entities: sceneDocument.entities, interactionLinks: sceneDocument.interactionLinks @@ -318,6 +326,7 @@ export function applySceneDocumentToProject( name: sceneDocument.name, world: sceneDocument.world, brushes: sceneDocument.brushes, + paths: sceneDocument.paths, modelInstances: sceneDocument.modelInstances, entities: sceneDocument.entities, interactionLinks: sceneDocument.interactionLinks diff --git a/src/entities/entity-instances.ts b/src/entities/entity-instances.ts index 86fc8fda..01c69018 100644 --- a/src/entities/entity-instances.ts +++ b/src/entities/entity-instances.ts @@ -1348,9 +1348,10 @@ export function createNpcEntity( | "actorId" | "yawDegrees" | "modelAssetId" - | "collider" > - > = {} + > & { + collider?: Partial; + } = {} ): NpcEntity { const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION); const actorId = normalizeNpcActorId(overrides.actorId); diff --git a/src/runtime-three/runtime-host.ts b/src/runtime-three/runtime-host.ts index 36dbd7e7..8412f634 100644 --- a/src/runtime-three/runtime-host.ts +++ b/src/runtime-three/runtime-host.ts @@ -1303,8 +1303,12 @@ export class RuntimeHost { } for (const npc of npcs) { - const renderGroup = + const asset = npc.modelAssetId === null + ? null + : this.projectAssets[npc.modelAssetId] ?? null; + const renderGroup = + npc.modelAssetId === null || asset?.kind !== "model" ? this.createNpcColliderFallbackRenderGroup(npc) : createModelInstanceRenderGroup( { @@ -1330,7 +1334,7 @@ export class RuntimeHost { visible: false } }, - this.projectAssets[npc.modelAssetId], + asset, this.loadedModelAssets[npc.modelAssetId], false ); diff --git a/src/runtime-three/runtime-scene-build.ts b/src/runtime-three/runtime-scene-build.ts index 7bff892d..25967116 100644 --- a/src/runtime-three/runtime-scene-build.ts +++ b/src/runtime-three/runtime-scene-build.ts @@ -611,8 +611,6 @@ function buildRuntimeNpcCollider(npc: RuntimeNpc): RuntimeNpcCollider | null { } }; } - case "none": - return null; } } diff --git a/src/viewport-three/viewport-host.ts b/src/viewport-three/viewport-host.ts index 6729da0f..bc1a2fb4 100644 --- a/src/viewport-three/viewport-host.ts +++ b/src/viewport-three/viewport-host.ts @@ -4091,8 +4091,12 @@ export class ViewportHost { selected: boolean, previewShellColor?: number ): EntityRenderObjects { - if (entity.modelAssetId !== null) { - const asset = this.projectAssets[entity.modelAssetId]; + const asset = + entity.modelAssetId === null + ? null + : this.projectAssets[entity.modelAssetId] ?? null; + + if (entity.modelAssetId !== null && asset?.kind === "model") { const loadedAsset = this.loadedModelAssets[entity.modelAssetId]; const renderGroup = createModelInstanceRenderGroup( { diff --git a/tests/domain/build-runtime-scene.test.ts b/tests/domain/build-runtime-scene.test.ts index 6fed8aaf..d5f3585e 100644 --- a/tests/domain/build-runtime-scene.test.ts +++ b/tests/domain/build-runtime-scene.test.ts @@ -397,17 +397,17 @@ describe("buildRuntimeSceneFromDocument", () => { }, max: { x: 4, - y: 0, + y: 1.8, z: 4 }, center: { x: 0, - y: -0.5, + y: 0.4, z: 0 }, size: { x: 8, - y: 1, + y: 2.8, z: 8 } }); @@ -470,7 +470,7 @@ describe("buildRuntimeSceneFromDocument", () => { modelAssetId: modelAsset.id, collider: { mode: "capsule", - radius: 0.35, + radius: 0.3, height: 1.8, eyeHeight: 1.6 } @@ -659,20 +659,20 @@ describe("buildRuntimeSceneFromDocument", () => { }, shape: { mode: "capsule", - radius: 0.35, + radius: 0.3, height: 1.8, eyeHeight: 1.6 }, worldBounds: { min: { - x: -1.35, + x: -1.3, y: 0, - z: -2.35 + z: -2.3 }, max: { - x: -0.65, + x: -0.7, y: 1.8, - z: -1.65 + z: -1.7 } } }); diff --git a/tests/domain/rapier-collision-world.test.ts b/tests/domain/rapier-collision-world.test.ts index 28ae463e..7023a8e4 100644 --- a/tests/domain/rapier-collision-world.test.ts +++ b/tests/domain/rapier-collision-world.test.ts @@ -4,7 +4,10 @@ import { BoxGeometry, PlaneGeometry } from "three"; import { createModelInstance } from "../../src/assets/model-instances"; import { createBoxBrush } from "../../src/document/brushes"; import { createEmptySceneDocument } from "../../src/document/scene-document"; -import { createPlayerStartEntity } from "../../src/entities/entity-instances"; +import { + createNpcEntity, + createPlayerStartEntity +} from "../../src/entities/entity-instances"; import { RapierCollisionWorld } from "../../src/runtime-three/rapier-collision-world"; import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build"; import { createFixtureLoadedModelAssetFromGeometry } from "../helpers/model-collider-fixtures"; @@ -99,6 +102,75 @@ describe("RapierCollisionWorld", () => { } }); + it("blocks first-person motion against authored NPC colliders", async () => { + const floorBrush = createBoxBrush({ + id: "brush-floor-npc-collision", + center: { + x: 0, + y: -0.5, + z: 0 + }, + size: { + x: 10, + y: 1, + z: 10 + } + }); + const npc = createNpcEntity({ + id: "entity-npc-guard", + actorId: "actor-gate-guard", + position: { + x: 2, + y: 0, + z: 0 + }, + collider: { + mode: "box", + eyeHeight: 1.6, + boxSize: { + x: 1, + y: 1.8, + z: 1 + } + } + }); + const runtimeScene = buildRuntimeSceneFromDocument({ + ...createEmptySceneDocument({ name: "NPC Collision Scene" }), + brushes: { + [floorBrush.id]: floorBrush + }, + entities: { + [npc.id]: npc + } + }); + const collisionWorld = await RapierCollisionWorld.create( + runtimeScene.colliders, + runtimeScene.playerCollider + ); + + try { + const blocked = collisionWorld.resolveFirstPersonMotion( + { + x: 0, + y: 0, + z: 0 + }, + { + x: 3, + y: 0, + z: 0 + }, + runtimeScene.playerCollider + ); + + expect(blocked.feetPosition.x).toBeLessThan(1.21); + expect(blocked.feetPosition.y).toBeLessThan(0.02); + expect(blocked.collidedAxes.x).toBe(true); + } finally { + collisionWorld.dispose(); + } + }); + it("initializes and resolves first-person motion against terrain heightfield colliders", async () => { const terrainGeometry = new PlaneGeometry(8, 8, 4, 4); terrainGeometry.rotateX(-Math.PI / 2); diff --git a/tests/domain/scene-document-validation.test.ts b/tests/domain/scene-document-validation.test.ts index 018bd228..86c8c3d2 100644 --- a/tests/domain/scene-document-validation.test.ts +++ b/tests/domain/scene-document-validation.test.ts @@ -444,7 +444,7 @@ describe("validateSceneDocument", () => { actorId: "actor-town-guard", collider: { mode: "box", - eyeHeight: 2, + eyeHeight: 1.2, boxSize: { x: 0.7, y: 1.2, @@ -452,6 +452,7 @@ describe("validateSceneDocument", () => { } } }); + invalidColliderNpc.collider.eyeHeight = 2; const validation = validateSceneDocument({ ...createEmptySceneDocument(), diff --git a/tests/serialization/scene-document-json.test.ts b/tests/serialization/scene-document-json.test.ts index aa09b95d..52703d92 100644 --- a/tests/serialization/scene-document-json.test.ts +++ b/tests/serialization/scene-document-json.test.ts @@ -1662,6 +1662,7 @@ describe("scene document JSON", () => { const migratedDocument = migrateSceneDocument({ version: NPC_ENTITY_FOUNDATION_SCENE_DOCUMENT_VERSION, name: "NPC Collider Migration", + time: createDefaultProjectTimeSettings(), world: createEmptySceneDocument().world, materials: createEmptySceneDocument().materials, textures: {},