diff --git a/src/document/spline-corridor-junctions.ts b/src/document/spline-corridor-junctions.ts new file mode 100644 index 00000000..4c01e6a2 --- /dev/null +++ b/src/document/spline-corridor-junctions.ts @@ -0,0 +1,212 @@ +import { createOpaqueId } from "../core/ids"; +import type { Vec3 } from "../core/vector"; + +export type SplineCorridorJunctionTerrainMode = + | "none" + | "paintOnly" + | "flattenAndPaint"; + +export interface SplineCorridorJunctionConnection { + id: string; + pathId: string; + progress: number; + clipDistance: number; +} + +export interface SplineCorridorJunction { + id: string; + kind: "splineCorridorJunction"; + name?: string; + visible: boolean; + enabled: boolean; + center: Vec3; + radius: number; + materialId: string | null; + terrainMode: SplineCorridorJunctionTerrainMode; + connections: SplineCorridorJunctionConnection[]; +} + +export type SplineCorridorJunctionRegistry = Record< + string, + SplineCorridorJunction +>; + +export const DEFAULT_SPLINE_CORRIDOR_JUNCTION_VISIBLE = true; +export const DEFAULT_SPLINE_CORRIDOR_JUNCTION_ENABLED = true; +export const DEFAULT_SPLINE_CORRIDOR_JUNCTION_RADIUS = 1.5; +export const DEFAULT_SPLINE_CORRIDOR_JUNCTION_CLIP_DISTANCE = 1.5; +export const DEFAULT_SPLINE_CORRIDOR_JUNCTION_MATERIAL_ID = null; +export const DEFAULT_SPLINE_CORRIDOR_JUNCTION_TERRAIN_MODE: SplineCorridorJunctionTerrainMode = + "flattenAndPaint"; +export const MIN_SPLINE_CORRIDOR_JUNCTION_RADIUS = 0.05; +export const MAX_SPLINE_CORRIDOR_JUNCTION_RADIUS = 500; +export const MIN_SPLINE_CORRIDOR_JUNCTION_CLIP_DISTANCE = 0.05; +export const MAX_SPLINE_CORRIDOR_JUNCTION_CLIP_DISTANCE = 500; +export const SPLINE_CORRIDOR_JUNCTION_TERRAIN_MODES = [ + "none", + "paintOnly", + "flattenAndPaint" +] as const satisfies readonly SplineCorridorJunctionTerrainMode[]; + +function cloneVec3(vector: Vec3): Vec3 { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} + +function normalizePositiveFiniteNumber( + value: number, + min: number, + max: number, + label: string +): number { + if (!Number.isFinite(value)) { + throw new Error(`${label} must be finite.`); + } + + if (value < min || value > max) { + throw new Error(`${label} must be from ${min} to ${max}.`); + } + + return value; +} + +export function isSplineCorridorJunctionTerrainMode( + value: unknown +): value is SplineCorridorJunctionTerrainMode { + return SPLINE_CORRIDOR_JUNCTION_TERRAIN_MODES.includes( + value as SplineCorridorJunctionTerrainMode + ); +} + +export function normalizeSplineCorridorJunctionRadius(value: number): number { + return normalizePositiveFiniteNumber( + value, + MIN_SPLINE_CORRIDOR_JUNCTION_RADIUS, + MAX_SPLINE_CORRIDOR_JUNCTION_RADIUS, + "Spline corridor junction radius" + ); +} + +export function normalizeSplineCorridorJunctionClipDistance( + value: number +): number { + return normalizePositiveFiniteNumber( + value, + MIN_SPLINE_CORRIDOR_JUNCTION_CLIP_DISTANCE, + MAX_SPLINE_CORRIDOR_JUNCTION_CLIP_DISTANCE, + "Spline corridor junction clip distance" + ); +} + +export function normalizeSplineCorridorJunctionMaterialId( + value: string | null | undefined +): string | null { + if (value === undefined || value === null || value.trim().length === 0) { + return null; + } + + return value; +} + +export function normalizeSplineCorridorJunctionName( + value: string | undefined +): string | undefined { + if (value === undefined) { + return undefined; + } + + const trimmed = value.trim(); + + return trimmed.length === 0 ? undefined : trimmed; +} + +export function normalizeSplineCorridorJunctionTerrainMode( + value: unknown +): SplineCorridorJunctionTerrainMode { + if (isSplineCorridorJunctionTerrainMode(value)) { + return value; + } + + throw new Error( + "Spline corridor junction terrain mode must be none, paintOnly, or flattenAndPaint." + ); +} + +export function createSplineCorridorJunctionConnection( + overrides: Partial & { + pathId: string; + } +): SplineCorridorJunctionConnection { + if (overrides.pathId.trim().length === 0) { + throw new Error("Spline corridor junction connection path id is required."); + } + + return { + id: overrides.id ?? createOpaqueId("spline_junction_connection"), + pathId: overrides.pathId, + progress: + overrides.progress === undefined + ? 0 + : Math.min(1, Math.max(0, overrides.progress)), + clipDistance: normalizeSplineCorridorJunctionClipDistance( + overrides.clipDistance ?? DEFAULT_SPLINE_CORRIDOR_JUNCTION_CLIP_DISTANCE + ) + }; +} + +export function createSplineCorridorJunction( + overrides: Partial< + Omit + > & { + connections: Array< + Partial & { pathId: string } + >; + } +): SplineCorridorJunction { + if (overrides.connections.length < 2) { + throw new Error("Spline corridor junctions require at least two connections."); + } + + return { + id: overrides.id ?? createOpaqueId("spline_junction"), + kind: "splineCorridorJunction", + name: normalizeSplineCorridorJunctionName(overrides.name), + visible: overrides.visible ?? DEFAULT_SPLINE_CORRIDOR_JUNCTION_VISIBLE, + enabled: overrides.enabled ?? DEFAULT_SPLINE_CORRIDOR_JUNCTION_ENABLED, + center: + overrides.center === undefined + ? { x: 0, y: 0, z: 0 } + : cloneVec3(overrides.center), + radius: normalizeSplineCorridorJunctionRadius( + overrides.radius ?? DEFAULT_SPLINE_CORRIDOR_JUNCTION_RADIUS + ), + materialId: normalizeSplineCorridorJunctionMaterialId( + overrides.materialId ?? DEFAULT_SPLINE_CORRIDOR_JUNCTION_MATERIAL_ID + ), + terrainMode: + overrides.terrainMode === undefined + ? DEFAULT_SPLINE_CORRIDOR_JUNCTION_TERRAIN_MODE + : normalizeSplineCorridorJunctionTerrainMode(overrides.terrainMode), + connections: overrides.connections.map( + createSplineCorridorJunctionConnection + ) + }; +} + +export function cloneSplineCorridorJunction( + junction: SplineCorridorJunction +): SplineCorridorJunction { + return createSplineCorridorJunction(junction); +} + +export function getSplineCorridorJunctions( + junctions: SplineCorridorJunctionRegistry +): SplineCorridorJunction[] { + return Object.values(junctions).sort((left, right) => + (left.name ?? left.id).localeCompare(right.name ?? right.id) + ); +} +