Add paths functionality to scene document and related components
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
465
src/document/paths.ts
Normal file
465
src/document/paths.ts
Normal file
@@ -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<Vec3> = [
|
||||
{
|
||||
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<Pick<ScenePathPoint, "id" | "position">> = {}
|
||||
): 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, "id" | "name" | "visible" | "enabled" | "loop" | "points">
|
||||
> = {}
|
||||
): 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<string>();
|
||||
|
||||
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<string, ScenePath>): 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<ScenePath, "loop" | "points">): 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<ScenePath, "loop" | "points">): 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<ScenePath, "loop" | "points">,
|
||||
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<ScenePath, "loop" | "points">,
|
||||
progress: number
|
||||
): Vec3 {
|
||||
return sampleResolvedScenePathTangent(resolveScenePath(path), progress);
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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<string, Brush>;
|
||||
paths: Record<string, ScenePath>;
|
||||
modelInstances: Record<string, ModelInstance>;
|
||||
entities: Record<string, EntityInstance>;
|
||||
interactionLinks: Record<string, InteractionLink>;
|
||||
@@ -142,6 +145,7 @@ export interface SceneDocument {
|
||||
textures: Record<string, never>;
|
||||
assets: Record<string, ProjectAssetRecord>;
|
||||
brushes: Record<string, Brush>;
|
||||
paths: Record<string, ScenePath>;
|
||||
modelInstances: Record<string, ModelInstance>;
|
||||
entities: Record<string, EntityInstance>;
|
||||
interactionLinks: Record<string, InteractionLink>;
|
||||
@@ -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
|
||||
|
||||
@@ -1348,9 +1348,10 @@ export function createNpcEntity(
|
||||
| "actorId"
|
||||
| "yawDegrees"
|
||||
| "modelAssetId"
|
||||
| "collider"
|
||||
>
|
||||
> = {}
|
||||
> & {
|
||||
collider?: Partial<NpcColliderSettings>;
|
||||
} = {}
|
||||
): NpcEntity {
|
||||
const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION);
|
||||
const actorId = normalizeNpcActorId(overrides.actorId);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -611,8 +611,6 @@ function buildRuntimeNpcCollider(npc: RuntimeNpc): RuntimeNpcCollider | null {
|
||||
}
|
||||
};
|
||||
}
|
||||
case "none":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
Reference in New Issue
Block a user