diff --git a/web/.eslintrc.js b/web/.eslintrc.js index 65ffad84..89f84d06 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -8,11 +8,11 @@ module.exports = { jsx: true, }, }, - plugins: ['@typescript-eslint', 'jest', 'import'], + plugins: ['@typescript-eslint', 'vitest', 'import'], extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', - 'plugin:jest/recommended', + 'plugin:vitest/recommended', 'plugin:import/errors', 'plugin:import/warnings', 'plugin:import/typescript', diff --git a/web/.gitignore b/web/.gitignore index c6bba591..231e5465 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -2,10 +2,8 @@ logs *.log npm-debug.log* -yarn-debug.log* -yarn-error.log* +pnpm-debug.log* lerna-debug.log* -.pnpm-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json @@ -82,6 +80,7 @@ web_modules/ # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache +.tmp/ # Next.js build output .next diff --git a/web/examples/character-follow-camera.ts b/web/examples/character-follow-camera.ts new file mode 100644 index 00000000..cab948e5 --- /dev/null +++ b/web/examples/character-follow-camera.ts @@ -0,0 +1,1655 @@ +import { AssetDatabase, type AssetImporter } from '@axrone/asset-core'; +import { + createGltfImporter, + type GltfAssetSchemaLike, +} from '@axrone/asset-gltf'; +import { Component, Transform, script } from '@axrone/ecs-runtime'; +import { Quat, Vec3 } from '@axrone/numeric'; +import { + Animator, + createUnlitColorShaderDefinition, + DirectionalLight, + FilterMode, + FollowCameraController, + MeshRenderer, + Scene, + WrapMode, +} from '@axrone/scene-3d'; +import { + loadGltfSceneIntoScene, + type LoadGltfSceneIntoSceneResult, +} from '@axrone/scene-runtime-gltf'; +import { bindSceneToContainer } from './example-runtime'; +import type { ExampleContext, SceneExample } from './example-types'; + +const CHARACTER_MODEL_URL = '/models/GM_AssetStore_3D_Character.glb'; +const DESK_MODEL_URL = '/models/GM_AssetStore_3D_CityDesk.glb'; +const COLOR_PALETTE_URL = '/color_palette/color-palette.jpg'; +const CHARACTER_PALETTE_SAMPLER_ID = 'character-demo.palette-sampler'; +const CHARACTER_PALETTE_TEXTURE_ID = 'character-demo.palette-texture'; +const CHARACTER_PALETTE_MATERIAL_ID = 'character-demo.character-palette-material'; +const CHARACTER_REFERENCE_SHADER_ID = 'examples/character-reference'; +const CHARACTER_REFERENCE_MATERIAL_ID = 'character-demo.reference-material'; +const CHARACTER_REFERENCE_BAR_X_MESH_ID = 'character-demo.reference-bar-x'; +const CHARACTER_REFERENCE_BAR_Z_MESH_ID = 'character-demo.reference-bar-z'; +const CHARACTER_TRAIL_MATERIAL_ID = 'character-demo.trail-material'; +const CHARACTER_TRAIL_MESH_ID = 'character-demo.trail-mesh'; +const CHARACTER_TRAIL_POINT_COUNT = 14; +const CHARACTER_TELEMETRY_SYSTEM_ID = 'character-follow-camera.telemetry'; + +type SceneActor = ReturnType; + +interface SceneBounds { + readonly min: Vec3; + readonly max: Vec3; + readonly center: Vec3; + readonly size: Vec3; +} + +interface CharacterClipSet { + readonly ids: readonly string[]; + readonly idle: string | null; + readonly run: string | null; + readonly walk: string | null; +} + +interface DashboardHandle { + readonly controlsHost: HTMLElement; + setStatus(next: string, color?: string): void; + setAnimationState(next: string): void; + setClipSummary(next: string): void; + setMotionTelemetry(position: Readonly, speed: number): void; + setHierarchyRoot(rootTransform: Transform): void; + refreshSelection(): void; + dispose(): void; +} + +const computeSmoothingFactor = (damping: number, deltaSeconds: number): number => { + if (damping <= 0 || deltaSeconds <= 0) { + return 1; + } + + return 1 - Math.exp(-damping * deltaSeconds); +}; + +const normalizeClipName = (value: string): string => + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, ''); + +const formatClipLabel = (clipId: string | null | undefined): string => { + if (!clipId) { + return 'Unavailable'; + } + + return normalizeClipName(clipId) === 'iddle' ? 'Idle' : clipId; +}; + +const formatWorldPositionLabel = (position: Readonly): string => + `${position.x.toFixed(2)}, ${position.z.toFixed(2)}`; + +const formatSpeedLabel = (speed: number): string => `${speed.toFixed(2)} u/s`; + +const formatDebugVec3 = (value: Readonly): string => + `${value.x.toFixed(3)}, ${value.y.toFixed(3)}, ${value.z.toFixed(3)}`; + +const formatDebugQuat = (value: Readonly): string => + `${value.x.toFixed(3)}, ${value.y.toFixed(3)}, ${value.z.toFixed(3)}, ${value.w.toFixed(3)}`; + +const getActorFromTransform = (transform: Transform | null | undefined): SceneActor | null => + ((transform as unknown as { actor?: SceneActor | undefined })?.actor ?? null); + +const resolveCharacterClipSet = (animator: Animator | null): CharacterClipSet => { + const serialized = animator?.serialize() as { clips?: readonly { id?: unknown }[] } | undefined; + const ids = (serialized?.clips ?? []) + .map((clip) => (typeof clip.id === 'string' ? clip.id : null)) + .filter((clipId): clipId is string => Boolean(clipId)); + + const findClip = (...aliases: readonly string[]): string | null => + ids.find((clipId) => aliases.some((alias) => normalizeClipName(clipId).includes(alias))) ?? + null; + + return { + ids, + idle: findClip('idle', 'iddle') ?? ids[0] ?? null, + run: findClip('run'), + walk: findClip('walk'), + }; +}; + +const computeActorsBounds = ( + actors: readonly SceneActor[], + database: AssetDatabase +): SceneBounds | null => { + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let minZ = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + let maxZ = Number.NEGATIVE_INFINITY; + let found = false; + + for (const actor of actors) { + const renderer = actor.getComponent(MeshRenderer); + const transform = actor.getComponent(Transform); + if (!renderer?.meshId || !transform) { + continue; + } + + const meshAsset = database.get({ key: renderer.meshId, kind: 'gltf.mesh' }); + const meshBounds = meshAsset?.data.bounds; + if (!meshBounds) { + continue; + } + + const corners = [ + new Vec3(meshBounds.min[0], meshBounds.min[1], meshBounds.min[2]), + new Vec3(meshBounds.min[0], meshBounds.min[1], meshBounds.max[2]), + new Vec3(meshBounds.min[0], meshBounds.max[1], meshBounds.min[2]), + new Vec3(meshBounds.min[0], meshBounds.max[1], meshBounds.max[2]), + new Vec3(meshBounds.max[0], meshBounds.min[1], meshBounds.min[2]), + new Vec3(meshBounds.max[0], meshBounds.min[1], meshBounds.max[2]), + new Vec3(meshBounds.max[0], meshBounds.max[1], meshBounds.min[2]), + new Vec3(meshBounds.max[0], meshBounds.max[1], meshBounds.max[2]), + ]; + + for (const corner of corners) { + const worldCorner = transform.worldMatrix.transformVec3(corner, new Vec3()); + minX = Math.min(minX, worldCorner.x); + minY = Math.min(minY, worldCorner.y); + minZ = Math.min(minZ, worldCorner.z); + maxX = Math.max(maxX, worldCorner.x); + maxY = Math.max(maxY, worldCorner.y); + maxZ = Math.max(maxZ, worldCorner.z); + } + + found = true; + } + + if (!found) { + return null; + } + + return { + min: new Vec3(minX, minY, minZ), + max: new Vec3(maxX, maxY, maxZ), + center: new Vec3((minX + maxX) * 0.5, (minY + maxY) * 0.5, (minZ + maxZ) * 0.5), + size: new Vec3(maxX - minX, maxY - minY, maxZ - minZ), + }; +}; + +const collectImportedRootTransforms = (actors: readonly SceneActor[]): readonly Transform[] => { + const roots: Transform[] = []; + + for (const actor of actors) { + const transform = actor.getComponent(Transform); + if (!transform || transform.parent) { + continue; + } + + roots.push(transform); + } + + return roots; +}; + +const createImportedModelContainer = ( + scene: Scene, + actors: readonly SceneActor[], + name: string +): SceneActor => { + const container = scene.createActor({ name }); + const containerTransform = container.requireComponent(Transform); + + for (const rootTransform of collectImportedRootTransforms(actors)) { + rootTransform.parent = containerTransform; + } + + return container; +}; + +const fitImportedModel = ( + actors: readonly SceneActor[], + container: SceneActor, + database: AssetDatabase, + options: { + readonly targetHeight: number; + readonly position: Vec3; + readonly groundY?: number; + readonly yaw?: number; + } +): SceneBounds | null => { + const containerTransform = container.requireComponent(Transform); + + if (typeof options.yaw === 'number') { + containerTransform.rotation = Quat.fromEuler(0, options.yaw, 0); + } + + const initialBounds = computeActorsBounds(actors, database); + if (!initialBounds) { + containerTransform.position = options.position.clone(); + return null; + } + + if (initialBounds.size.y > 1e-5) { + const scale = options.targetHeight / initialBounds.size.y; + containerTransform.scale = new Vec3(scale, scale, scale); + } + + const scaledBounds = computeActorsBounds(actors, database); + if (!scaledBounds) { + return null; + } + + const groundY = options.groundY ?? 0; + containerTransform.position = new Vec3( + options.position.x - scaledBounds.center.x, + groundY - scaledBounds.min.y, + options.position.z - scaledBounds.center.z + ); + + return computeActorsBounds(actors, database); +}; + +const registerGroundAssets = (scene: Scene): void => { + scene.registerShader({ + id: 'examples/character-ground', + cull: false, + vertexSource: `#version 300 es +layout(location = 0) in vec3 a_Position; +layout(location = 1) in vec3 a_Normal; +layout(location = 2) in vec2 a_UV0; +uniform mat4 u_Model; +uniform mat4 u_View; +uniform mat4 u_Projection; +out vec2 v_UV0; +out vec3 v_WorldNormal; +void main() { + v_UV0 = a_UV0; + v_WorldNormal = normalize(mat3(u_Model) * a_Normal); + gl_Position = u_Projection * u_View * u_Model * vec4(a_Position, 1.0); +}`, + fragmentSource: `#version 300 es +precision highp float; +uniform vec3 u_LightDirection; +uniform vec3 u_BaseColor; +uniform vec3 u_LineColor; +uniform vec3 u_FadeColor; +in vec2 v_UV0; +in vec3 v_WorldNormal; +out vec4 o_Color; + +void main() { + vec2 gridUv = v_UV0 * 24.0; + vec2 cell = abs(fract(gridUv - 0.5) - 0.5) / fwidth(gridUv); + float major = 1.0 - min(min(cell.x, cell.y), 1.0); + float radial = clamp(length(v_UV0 - 0.5) * 1.35, 0.0, 1.0); + float diffuse = max(dot(normalize(v_WorldNormal), normalize(-u_LightDirection)), 0.0); + vec3 gridColor = mix(u_BaseColor, u_LineColor, major * 0.7); + vec3 base = mix(gridColor, u_FadeColor, radial * 0.55); + vec3 lit = base * (0.45 + diffuse * 0.55); + o_Color = vec4(lit, 1.0); +}`, + uniforms: [ + 'u_Model', + 'u_View', + 'u_Projection', + 'u_LightDirection', + 'u_BaseColor', + 'u_LineColor', + 'u_FadeColor', + ], + }); + + scene.createPlaneMesh('character-demo-ground', 64, 64); + scene.createMaterial({ + id: 'character-demo-ground-material', + shaderId: 'examples/character-ground', + uniforms: { + u_LightDirection: [-0.45, -0.85, -0.25], + u_BaseColor: [0.14, 0.16, 0.19], + u_LineColor: [0.83, 0.62, 0.34], + u_FadeColor: [0.08, 0.1, 0.13], + }, + }); +}; + +const createGround = (scene: Scene): void => { + registerGroundAssets(scene); + + const ground = scene.createRenderableActor( + { name: 'Ground' }, + { + meshId: 'character-demo-ground', + materialId: 'character-demo-ground-material', + } + ); + ground.requireComponent(Transform).position = new Vec3(0, -0.01, 0); +}; + +const createReferenceMarker = (scene: Scene): void => { + const shader = scene.registerShader( + createUnlitColorShaderDefinition(CHARACTER_REFERENCE_SHADER_ID) + ); + + scene.createBoxMesh(CHARACTER_REFERENCE_BAR_X_MESH_ID, 0.72, 0.04, 0.12); + scene.createBoxMesh(CHARACTER_REFERENCE_BAR_Z_MESH_ID, 0.12, 0.04, 0.72); + scene.createMaterial({ + id: CHARACTER_REFERENCE_MATERIAL_ID, + shaderId: shader.id, + uniforms: { + u_Color: [0.2, 0.82, 1, 1], + }, + }); + + const segments = [ + { + name: 'SpawnMarkerNorth', + meshId: CHARACTER_REFERENCE_BAR_X_MESH_ID, + position: new Vec3(0, 0.025, 0.42), + }, + { + name: 'SpawnMarkerSouth', + meshId: CHARACTER_REFERENCE_BAR_X_MESH_ID, + position: new Vec3(0, 0.025, -0.42), + }, + { + name: 'SpawnMarkerEast', + meshId: CHARACTER_REFERENCE_BAR_Z_MESH_ID, + position: new Vec3(0.42, 0.025, 0), + }, + { + name: 'SpawnMarkerWest', + meshId: CHARACTER_REFERENCE_BAR_Z_MESH_ID, + position: new Vec3(-0.42, 0.025, 0), + }, + ] as const; + + for (const segment of segments) { + const actor = scene.createRenderableActor( + { name: segment.name }, + { + meshId: segment.meshId, + materialId: CHARACTER_REFERENCE_MATERIAL_ID, + receiveLighting: false, + } + ); + actor.requireComponent(Transform).position = segment.position.clone(); + } +}; + +const createMotionTrail = (scene: Scene): readonly SceneActor[] => { + scene.createSphereMesh(CHARACTER_TRAIL_MESH_ID, 0.09, 12); + scene.createMaterial({ + id: CHARACTER_TRAIL_MATERIAL_ID, + shaderId: CHARACTER_REFERENCE_SHADER_ID, + uniforms: { + u_Color: [1, 0.76, 0.28, 1], + }, + }); + + const actors: SceneActor[] = []; + for (let index = 0; index < CHARACTER_TRAIL_POINT_COUNT; index += 1) { + const actor = scene.createRenderableActor( + { name: `MotionTrail${index}` }, + { + meshId: CHARACTER_TRAIL_MESH_ID, + materialId: CHARACTER_TRAIL_MATERIAL_ID, + receiveLighting: false, + } + ); + actor.requireComponent(Transform).position = new Vec3(0, -100 - index, 0); + actors.push(actor); + } + + return actors; +}; + +const createLighting = (scene: Scene): void => { + const sun = scene.createActor({ name: 'KeyLight' }); + const light = sun.addComponent(DirectionalLight, { + color: [1, 0.95, 0.9], + intensity: 1.45, + primary: true, + }); + light.primary = true; + + const sunTransform = sun.requireComponent(Transform); + sunTransform.position = new Vec3(8, 12, 6); + sunTransform.lookAt(new Vec3(0, 0.5, 0)); +}; + +const loadLocalGltf = async ( + scene: Scene, + database: AssetDatabase, + uri: string, + namePrefix: string +): Promise => { + const response = await fetch(uri); + if (!response.ok) { + throw new Error(`${uri} returned ${response.status}`); + } + + const bytes = new Uint8Array(await response.arrayBuffer()); + const receipt = await database.import({ + kind: 'bytes', + data: bytes, + uri, + mimeType: 'model/gltf-binary', + }); + + return loadGltfSceneIntoScene( + scene, + database, + { key: receipt.primary.key, kind: 'gltf.document' }, + { clearExisting: false, namePrefix } + ); +}; + +const registerCharacterPaletteMaterial = async (scene: Scene): Promise => { + scene.registerSampler({ + id: CHARACTER_PALETTE_SAMPLER_ID, + minFilter: FilterMode.NEAREST, + magFilter: FilterMode.NEAREST, + wrapS: WrapMode.CLAMP_TO_EDGE, + wrapT: WrapMode.CLAMP_TO_EDGE, + }); + + await scene.registerTexture({ + id: CHARACTER_PALETTE_TEXTURE_ID, + samplerId: CHARACTER_PALETTE_SAMPLER_ID, + source: { + kind: 'url', + url: COLOR_PALETTE_URL, + }, + }); + + scene.createMaterial({ + id: CHARACTER_PALETTE_MATERIAL_ID, + shaderId: 'gltf/pbr', + uniforms: { + _BaseColorFactor: [1, 1, 1, 1], + _BaseColorTexture_TexCoord: 0, + _MetallicFactor: 0, + _RoughnessFactor: 0.94, + }, + textures: { + _BaseColorTexture: { + textureId: CHARACTER_PALETTE_TEXTURE_ID, + samplerId: CHARACTER_PALETTE_SAMPLER_ID, + }, + }, + }); +}; + +const applyMaterialToImportedRenderers = ( + actors: readonly SceneActor[], + materialId: string, + predicate: (renderer: MeshRenderer) => boolean = () => true +): number => { + let appliedCount = 0; + + for (const actor of actors) { + const renderer = actor.getComponent(MeshRenderer); + if (!renderer?.meshId || !predicate(renderer)) { + continue; + } + + renderer.materialId = materialId; + appliedCount += 1; + } + + return appliedCount; +}; + +const createDashboard = (container: HTMLElement): DashboardHandle => { + const hud = document.createElement('div'); + Object.assign(hud.style, { + position: 'absolute', + inset: '0', + pointerEvents: 'none', + zIndex: '10', + }); + + const debugPanel = document.createElement('section'); + Object.assign(debugPanel.style, { + position: 'absolute', + top: '20px', + left: '20px', + width: '360px', + maxHeight: 'calc(100% - 40px)', + padding: '18px 18px 16px', + borderRadius: '18px', + border: '1px solid rgba(255, 255, 255, 0.1)', + background: 'linear-gradient(160deg, rgba(17, 20, 26, 0.92), rgba(10, 12, 16, 0.82))', + backdropFilter: 'blur(14px)', + color: '#f7f1e7', + fontFamily: '"IBM Plex Sans", "Segoe UI", sans-serif', + boxShadow: '0 24px 48px rgba(0, 0, 0, 0.35)', + pointerEvents: 'auto', + display: 'flex', + flexDirection: 'column', + gap: '12px', + }); + + const title = document.createElement('div'); + title.textContent = 'Character Hierarchy'; + Object.assign(title.style, { + fontSize: '18px', + fontWeight: '700', + letterSpacing: '0.06em', + marginBottom: '6px', + }); + + const subtitle = document.createElement('div'); + subtitle.textContent = + 'Imported character node tree. Select any node to inspect its live local and world transform values.'; + Object.assign(subtitle.style, { + fontSize: '12px', + lineHeight: '1.6', + color: '#d7d0c4', + marginBottom: '4px', + }); + + const metrics = document.createElement('div'); + Object.assign(metrics.style, { + display: 'grid', + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + gap: '10px', + }); + + const createMetric = (label: string) => { + const card = document.createElement('div'); + Object.assign(card.style, { + padding: '10px 12px', + borderRadius: '12px', + background: 'rgba(255, 255, 255, 0.04)', + border: '1px solid rgba(255, 255, 255, 0.06)', + }); + + const metricLabel = document.createElement('div'); + metricLabel.textContent = label; + Object.assign(metricLabel.style, { + fontSize: '10px', + letterSpacing: '0.12em', + color: '#a8b1ba', + marginBottom: '6px', + textTransform: 'uppercase', + }); + + const metricValue = document.createElement('div'); + metricValue.textContent = 'Pending'; + Object.assign(metricValue.style, { + fontSize: '14px', + fontWeight: '600', + color: '#fff7ec', + }); + + card.appendChild(metricLabel); + card.appendChild(metricValue); + metrics.appendChild(card); + return metricValue; + }; + + const stateValue = createMetric('Animation'); + const clipValue = createMetric('Loaded Clips'); + const positionValue = createMetric('World XZ'); + const speedValue = createMetric('Speed'); + + positionValue.textContent = '0.00, 0.00'; + speedValue.textContent = '0.00 u/s'; + + const treeLabel = document.createElement('div'); + treeLabel.textContent = 'Nodes'; + Object.assign(treeLabel.style, { + fontSize: '10px', + fontWeight: '700', + letterSpacing: '0.12em', + textTransform: 'uppercase', + color: '#96a4b1', + }); + + const hierarchyHost = document.createElement('div'); + Object.assign(hierarchyHost.style, { + minHeight: '220px', + maxHeight: '320px', + overflow: 'auto', + borderRadius: '14px', + border: '1px solid rgba(255, 255, 255, 0.06)', + background: 'rgba(255, 255, 255, 0.03)', + padding: '8px', + display: 'flex', + flexDirection: 'column', + gap: '4px', + }); + + const inspectorLabel = document.createElement('div'); + inspectorLabel.textContent = 'Transform Inspector'; + Object.assign(inspectorLabel.style, { + fontSize: '10px', + fontWeight: '700', + letterSpacing: '0.12em', + textTransform: 'uppercase', + color: '#96a4b1', + }); + + const inspectorHost = document.createElement('div'); + Object.assign(inspectorHost.style, { + borderRadius: '14px', + border: '1px solid rgba(255, 255, 255, 0.06)', + background: 'rgba(255, 255, 255, 0.03)', + padding: '10px 12px', + display: 'flex', + flexDirection: 'column', + gap: '8px', + }); + + const createInspectorRow = (label: string) => { + const row = document.createElement('div'); + Object.assign(row.style, { + display: 'grid', + gridTemplateColumns: '94px minmax(0, 1fr)', + gap: '10px', + alignItems: 'start', + }); + + const labelElement = document.createElement('div'); + labelElement.textContent = label; + Object.assign(labelElement.style, { + fontSize: '11px', + color: '#a8b1ba', + textTransform: 'uppercase', + letterSpacing: '0.08em', + }); + + const valueElement = document.createElement('div'); + valueElement.textContent = 'Pending'; + Object.assign(valueElement.style, { + fontSize: '12px', + lineHeight: '1.5', + color: '#fff7ec', + fontFamily: '"IBM Plex Mono", Consolas, monospace', + wordBreak: 'break-word', + }); + + row.appendChild(labelElement); + row.appendChild(valueElement); + inspectorHost.appendChild(row); + + return valueElement; + }; + + const selectedNodeValue = createInspectorRow('Node'); + const parentNodeValue = createInspectorRow('Parent'); + const childCountValue = createInspectorRow('Children'); + const localPositionValue = createInspectorRow('Local Pos'); + const worldPositionValue = createInspectorRow('World Pos'); + const localRotationValue = createInspectorRow('Local Rot'); + const worldRotationValue = createInspectorRow('World Rot'); + const localScaleValue = createInspectorRow('Local Scale'); + const worldScaleValue = createInspectorRow('World Scale'); + + const statusValue = document.createElement('div'); + statusValue.textContent = 'Loading local assets...'; + Object.assign(statusValue.style, { + fontSize: '12px', + lineHeight: '1.6', + color: '#c9d6df', + whiteSpace: 'pre-wrap', + borderRadius: '12px', + background: 'rgba(255, 255, 255, 0.03)', + border: '1px solid rgba(255, 255, 255, 0.06)', + padding: '10px 12px', + maxHeight: '160px', + overflow: 'auto', + }); + + const treeButtons = new Map(); + let selectedTransform: Transform | null = null; + + const refreshSelection = () => { + if (!selectedTransform) { + selectedNodeValue.textContent = 'None'; + parentNodeValue.textContent = 'None'; + childCountValue.textContent = '0'; + localPositionValue.textContent = '0.000, 0.000, 0.000'; + worldPositionValue.textContent = '0.000, 0.000, 0.000'; + localRotationValue.textContent = '0.000, 0.000, 0.000, 1.000'; + worldRotationValue.textContent = '0.000, 0.000, 0.000, 1.000'; + localScaleValue.textContent = '1.000, 1.000, 1.000'; + worldScaleValue.textContent = '1.000, 1.000, 1.000'; + return; + } + + const actor = getActorFromTransform(selectedTransform); + const parentActor = getActorFromTransform(selectedTransform.parent); + selectedNodeValue.textContent = actor?.name ?? 'Unnamed'; + parentNodeValue.textContent = parentActor?.name ?? 'None'; + childCountValue.textContent = String(selectedTransform.children.length); + localPositionValue.textContent = formatDebugVec3(selectedTransform.position); + worldPositionValue.textContent = formatDebugVec3(selectedTransform.worldPosition); + localRotationValue.textContent = formatDebugQuat(selectedTransform.rotation); + worldRotationValue.textContent = formatDebugQuat(selectedTransform.worldRotation); + localScaleValue.textContent = formatDebugVec3(selectedTransform.scale); + worldScaleValue.textContent = formatDebugVec3(selectedTransform.worldScale); + }; + + const syncSelectionStyles = () => { + for (const [transform, button] of treeButtons) { + const isSelected = transform === selectedTransform; + button.style.background = isSelected ? 'rgba(227, 161, 76, 0.22)' : 'transparent'; + button.style.borderColor = isSelected + ? 'rgba(227, 161, 76, 0.55)' + : 'rgba(255, 255, 255, 0.04)'; + button.style.color = isSelected ? '#fff4df' : '#d8e0e7'; + } + }; + + const selectTransform = (transform: Transform) => { + selectedTransform = transform; + syncSelectionStyles(); + refreshSelection(); + }; + + const appendTransformNode = (transform: Transform, depth: number) => { + const actor = getActorFromTransform(transform); + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = `${transform.children.length > 0 ? '▾' : '•'} ${actor?.name ?? 'Unnamed Node'}`; + Object.assign(button.style, { + appearance: 'none', + background: 'transparent', + border: '1px solid rgba(255, 255, 255, 0.04)', + borderRadius: '10px', + color: '#d8e0e7', + cursor: 'pointer', + fontFamily: '"IBM Plex Mono", Consolas, monospace', + fontSize: '11px', + lineHeight: '1.4', + padding: `7px 10px 7px ${10 + depth * 16}px`, + textAlign: 'left', + width: '100%', + }); + button.addEventListener('click', () => { + selectTransform(transform); + }); + treeButtons.set(transform, button); + hierarchyHost.appendChild(button); + + for (const child of transform.children) { + appendTransformNode(child, depth + 1); + } + }; + + debugPanel.appendChild(title); + debugPanel.appendChild(subtitle); + debugPanel.appendChild(metrics); + debugPanel.appendChild(treeLabel); + debugPanel.appendChild(hierarchyHost); + debugPanel.appendChild(inspectorLabel); + debugPanel.appendChild(inspectorHost); + debugPanel.appendChild(statusValue); + + const controlsPanel = document.createElement('section'); + Object.assign(controlsPanel.style, { + position: 'absolute', + top: '20px', + right: '20px', + width: '280px', + padding: '18px', + borderRadius: '18px', + border: '1px solid rgba(255, 255, 255, 0.08)', + background: 'linear-gradient(180deg, rgba(19, 23, 28, 0.9), rgba(12, 14, 18, 0.84))', + backdropFilter: 'blur(14px)', + color: '#ecf1f5', + fontFamily: '"IBM Plex Sans", "Segoe UI", sans-serif', + boxShadow: '0 24px 48px rgba(0, 0, 0, 0.28)', + pointerEvents: 'auto', + overflow: 'hidden', + }); + + const controlsTitle = document.createElement('div'); + controlsTitle.textContent = 'Camera + Locomotion'; + Object.assign(controlsTitle.style, { + fontSize: '15px', + fontWeight: '700', + marginBottom: '14px', + }); + + const controlsHost = document.createElement('div'); + Object.assign(controlsHost.style, { + display: 'flex', + flexDirection: 'column', + gap: '12px', + }); + + controlsPanel.appendChild(controlsTitle); + controlsPanel.appendChild(controlsHost); + + hud.appendChild(debugPanel); + hud.appendChild(controlsPanel); + container.appendChild(hud); + + refreshSelection(); + + return { + controlsHost, + setStatus(next: string, color: string = '#c9d6df') { + statusValue.textContent = next; + statusValue.style.color = color; + }, + setAnimationState(next: string) { + stateValue.textContent = next; + }, + setClipSummary(next: string) { + clipValue.textContent = next; + }, + setMotionTelemetry(position: Readonly, speed: number) { + positionValue.textContent = formatWorldPositionLabel(position); + speedValue.textContent = formatSpeedLabel(speed); + }, + setHierarchyRoot(rootTransform: Transform) { + hierarchyHost.replaceChildren(); + treeButtons.clear(); + appendTransformNode(rootTransform, 0); + selectTransform(rootTransform); + }, + refreshSelection, + dispose() { + hud.remove(); + }, + }; +}; + +const addSectionLabel = (host: HTMLElement, label: string): void => { + const element = document.createElement('div'); + element.textContent = label; + Object.assign(element.style, { + fontSize: '10px', + fontWeight: '700', + letterSpacing: '0.12em', + textTransform: 'uppercase', + color: '#96a4b1', + paddingTop: '6px', + }); + host.appendChild(element); +}; + +const addSlider = ( + host: HTMLElement, + options: { + readonly label: string; + readonly min: number; + readonly max: number; + readonly step: number; + readonly value: number; + readonly format?: (value: number) => string; + readonly onChange: (value: number) => void; + } +): void => { + const row = document.createElement('label'); + Object.assign(row.style, { + display: 'flex', + flexDirection: 'column', + gap: '7px', + }); + + const top = document.createElement('div'); + Object.assign(top.style, { + display: 'flex', + justifyContent: 'space-between', + gap: '12px', + fontSize: '12px', + color: '#d1dae2', + }); + + const label = document.createElement('span'); + label.textContent = options.label; + + const value = document.createElement('span'); + value.textContent = options.format?.(options.value) ?? options.value.toFixed(2); + Object.assign(value.style, { + color: '#fff9f0', + fontFamily: '"IBM Plex Mono", Consolas, monospace', + }); + + const input = document.createElement('input'); + input.type = 'range'; + input.min = String(options.min); + input.max = String(options.max); + input.step = String(options.step); + input.value = String(options.value); + Object.assign(input.style, { + width: '100%', + accentColor: '#e3a14c', + cursor: 'pointer', + margin: '0', + }); + + input.addEventListener('input', (event) => { + const nextValue = Number((event.target as HTMLInputElement).value); + value.textContent = options.format?.(nextValue) ?? nextValue.toFixed(2); + options.onChange(nextValue); + }); + + top.appendChild(label); + top.appendChild(value); + row.appendChild(top); + row.appendChild(input); + host.appendChild(row); +}; + +const addReadonlyValue = (host: HTMLElement, label: string, value: string): void => { + const row = document.createElement('div'); + Object.assign(row.style, { + display: 'flex', + justifyContent: 'space-between', + gap: '12px', + fontSize: '12px', + color: '#d1dae2', + }); + + const title = document.createElement('span'); + title.textContent = label; + + const body = document.createElement('span'); + body.textContent = value; + Object.assign(body.style, { + color: '#fff9f0', + fontFamily: '"IBM Plex Mono", Consolas, monospace', + textAlign: 'right', + }); + + row.appendChild(title); + row.appendChild(body); + host.appendChild(row); +}; + +@script({ scriptName: 'CharacterLocomotionController' }) +class CharacterLocomotionController extends Component { + public moveSpeed = 5.2; + public acceleration = 16; + public deceleration = 20; + public turnSpeed = 14; + public transitionDuration = 0.16; + public onStateChanged?: (label: string, clipId: string | null) => void; + + private readonly _pressedKeys = new Set(); + private readonly _velocity = new Vec3(); + private readonly _desiredVelocity = new Vec3(); + private readonly _movementDirection = new Vec3(); + private readonly _cameraForward = new Vec3(); + private readonly _cameraRight = new Vec3(); + private _cameraTransform?: Transform; + private _inputScopeElement: HTMLElement | null = null; + private _animator: Animator | null = null; + private _idleClipId: string | null = null; + private _moveClipId: string | null = null; + private _activeClipId: string | null = null; + private _yaw = 0; + + setCameraReference(transform: Transform | undefined): this { + this._cameraTransform = transform; + return this; + } + + setInputScopeElement(element: HTMLElement | null | undefined): this { + this._inputScopeElement = element ?? null; + return this; + } + + bindAnimator(animator: Animator | null, clips: CharacterClipSet): this { + this._animator = animator; + this._idleClipId = clips.idle; + this._moveClipId = clips.run ?? clips.walk ?? clips.idle; + + if (this._animator && this._idleClipId) { + this._transitionTo(this._idleClipId, true); + } + + return this; + } + + awake(): void { + const trackedKeys = new Set(['KeyW', 'KeyA', 'KeyS', 'KeyD']); + const clearPressedKeys = () => { + this._pressedKeys.clear(); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (!trackedKeys.has(event.code)) { + return; + } + + if (!this._shouldCaptureKeyboardInput()) { + return; + } + + event.preventDefault(); + this._pressedKeys.add(event.code); + }; + + const onKeyUp = (event: KeyboardEvent) => { + if (!trackedKeys.has(event.code)) { + return; + } + + if (!this._shouldCaptureKeyboardInput()) { + return; + } + + event.preventDefault(); + this._pressedKeys.delete(event.code); + }; + + globalThis.addEventListener('keydown', onKeyDown, { passive: false, capture: true }); + globalThis.addEventListener('keyup', onKeyUp, { passive: false, capture: true }); + globalThis.addEventListener('blur', clearPressedKeys); + this._inputScopeElement?.addEventListener('blur', clearPressedKeys); + + (this as { _cleanupInput?: () => void })._cleanupInput = () => { + globalThis.removeEventListener('keydown', onKeyDown, true); + globalThis.removeEventListener('keyup', onKeyUp, true); + globalThis.removeEventListener('blur', clearPressedKeys); + this._inputScopeElement?.removeEventListener('blur', clearPressedKeys); + clearPressedKeys(); + }; + } + + update(deltaTime: number): void { + const transform = this.transform as Transform | undefined; + if (!transform) { + return; + } + + const deltaSeconds = Math.max(0, deltaTime / 1000); + const inputX = + (this._pressedKeys.has('KeyD') ? 1 : 0) - + (this._pressedKeys.has('KeyA') ? 1 : 0); + const inputZ = + (this._pressedKeys.has('KeyW') ? 1 : 0) - + (this._pressedKeys.has('KeyS') ? 1 : 0); + + this._movementDirection.x = 0; + this._movementDirection.y = 0; + this._movementDirection.z = 0; + + if (inputX !== 0 || inputZ !== 0) { + const forward = this._resolvePlanarCameraVector(Vec3.BACK, this._cameraForward); + const right = this._resolvePlanarCameraVector(Vec3.RIGHT, this._cameraRight); + + this._movementDirection.x = right.x * inputX + forward.x * inputZ; + this._movementDirection.z = right.z * inputX + forward.z * inputZ; + + if (this._movementDirection.lengthSquared() > 1e-6) { + this._movementDirection.normalize(); + } + } + + this._desiredVelocity.x = this._movementDirection.x * this.moveSpeed; + this._desiredVelocity.y = 0; + this._desiredVelocity.z = this._movementDirection.z * this.moveSpeed; + + const velocityBlend = computeSmoothingFactor( + this._movementDirection.lengthSquared() > 1e-6 ? this.acceleration : this.deceleration, + deltaSeconds + ); + Vec3.lerp(this._velocity, this._desiredVelocity, velocityBlend, this._velocity); + + if (this._velocity.lengthSquared() > 1e-6) { + const position = transform.position.clone(); + position.x += this._velocity.x * deltaSeconds; + position.z += this._velocity.z * deltaSeconds; + transform.position = position; + } + + const facingDirection = + this._movementDirection.lengthSquared() > 1e-6 ? this._movementDirection : this._velocity; + if (facingDirection.lengthSquared() > 1e-6) { + const targetYaw = Math.atan2(facingDirection.x, facingDirection.z); + const deltaYaw = Math.atan2( + Math.sin(targetYaw - this._yaw), + Math.cos(targetYaw - this._yaw) + ); + this._yaw += deltaYaw * computeSmoothingFactor(this.turnSpeed, deltaSeconds); + transform.rotation = Quat.fromEuler(0, this._yaw, 0); + } + + const isMoving = + this._movementDirection.lengthSquared() > 1e-6 || this._velocity.lengthSquared() > 0.04; + this._transitionTo( + isMoving ? this._moveClipId ?? this._idleClipId : this._idleClipId ?? this._moveClipId, + false + ); + } + + onDestroy(): void { + (this as { _cleanupInput?: () => void })._cleanupInput?.(); + } + + private _resolvePlanarCameraVector( + localDirection: Readonly, + out: Vec3 + ): Vec3 { + if (this._cameraTransform) { + Quat.rotateVector(this._cameraTransform.worldRotation, localDirection, out); + out.y = 0; + if (out.lengthSquared() > 1e-6) { + out.normalize(); + return out; + } + } + + out.x = localDirection.x; + out.y = 0; + out.z = localDirection.z; + if (out.lengthSquared() > 1e-6) { + out.normalize(); + } + return out; + } + + private _shouldCaptureKeyboardInput(): boolean { + const activeElement = document.activeElement; + if (!(activeElement instanceof HTMLElement) || activeElement === document.body) { + return true; + } + + const tagName = activeElement.tagName; + if (tagName === 'TEXTAREA' || tagName === 'SELECT' || activeElement.isContentEditable) { + return false; + } + + if (tagName === 'INPUT') { + const input = activeElement as HTMLInputElement; + const textLikeTypes = new Set([ + 'text', + 'search', + 'email', + 'password', + 'tel', + 'url', + 'number', + ]); + if (textLikeTypes.has((input.type || 'text').toLowerCase())) { + return false; + } + } + + if ( + activeElement.closest('#editor-host') || + activeElement.closest('.monaco-editor') || + activeElement.getAttribute('role') === 'textbox' + ) { + return false; + } + + return true; + } + + private _transitionTo(clipId: string | null, immediate: boolean): void { + if (!this._animator || !clipId || clipId === this._activeClipId) { + return; + } + + this._activeClipId = clipId; + + try { + if (immediate || !this._animator.clipId) { + this._animator.play(clipId); + } else { + this._animator.crossFade(clipId, this.transitionDuration); + } + } catch { + this._animator.play(clipId); + } + + this.onStateChanged?.(formatClipLabel(clipId), clipId); + } +} + +const characterFollowCameraExample: SceneExample = { + id: 'character-follow-camera', + title: 'Character Follow Camera', + description: + 'Imports a local animated glTF character, switches between Idle and Run at runtime, and drives it with camera-relative WASD movement.', + tags: ['scene', 'gltf', 'animation', 'camera', 'controller'], + order: 4, + async mount({ container }: ExampleContext) { + container.replaceChildren(); + + const shell = document.createElement('div'); + Object.assign(shell.style, { + position: 'relative', + width: '100%', + height: '100%', + }); + + const sceneHost = document.createElement('div'); + Object.assign(sceneHost.style, { + width: '100%', + height: '100%', + cursor: 'grab', + outline: 'none', + }); + sceneHost.tabIndex = 0; + sceneHost.setAttribute('aria-label', 'Character follow camera preview'); + + shell.appendChild(sceneHost); + container.appendChild(shell); + + const viewportWidth = sceneHost.clientWidth || 1280; + const viewportHeight = sceneHost.clientHeight || 720; + const dashboard = createDashboard(shell); + const scene = new Scene({ + width: viewportWidth, + height: viewportHeight, + autoStart: true, + parent: sceneHost, + appendToDom: true, + createCanvas: () => document.createElement('canvas'), + clearColor: [0.03, 0.035, 0.045, 1], + ambientLight: [0.34, 0.34, 0.38], + }); + + scene.registerComponent(CharacterLocomotionController); + scene.registerComponent(FollowCameraController); + + createGround(scene); + createReferenceMarker(scene); + const motionTrailActors = createMotionTrail(scene); + createLighting(scene); + + const cleanupResize = bindSceneToContainer(scene, sceneHost, viewportWidth, viewportHeight); + const database = new AssetDatabase({ + importers: [ + createGltfImporter() as AssetImporter, + ], + }); + + let removeInteractionListeners = () => {}; + let deferredDeskPaletteBinding: ReturnType | null = null; + + try { + const [characterLoadResult, deskLoadResult] = await Promise.allSettled([ + loadLocalGltf(scene, database, CHARACTER_MODEL_URL, 'Character '), + loadLocalGltf(scene, database, DESK_MODEL_URL, 'Desk '), + ]); + + if (characterLoadResult.status !== 'fulfilled') { + throw characterLoadResult.reason; + } + + const characterActors = characterLoadResult.value.actors as readonly SceneActor[]; + await registerCharacterPaletteMaterial(scene); + const characterContainer = createImportedModelContainer( + scene, + characterActors, + 'CharacterRoot' + ); + const paletteRendererCount = applyMaterialToImportedRenderers( + characterActors, + CHARACTER_PALETTE_MATERIAL_ID + ); + const characterBounds = fitImportedModel( + characterActors, + characterContainer, + database, + { + targetHeight: 2.15, + position: new Vec3(0, 0, 0), + } + ); + + let deskPaletteRendererCount = 0; + let deskActorsForPaletteBinding: readonly SceneActor[] = []; + if (deskLoadResult.status === 'fulfilled') { + const deskActors = deskLoadResult.value.actors as readonly SceneActor[]; + deskActorsForPaletteBinding = deskActors; + const deskContainer = createImportedModelContainer(scene, deskActors, 'DeskRoot'); + fitImportedModel(deskActors, deskContainer, database, { + targetHeight: 1.75, + position: new Vec3(4.4, 0, -2.8), + yaw: -0.62, + }); + } + + const animator = + characterActors + .map((actor) => actor.getComponent(Animator)) + .find((component): component is Animator => Boolean(component)) ?? null; + const clipSet = resolveCharacterClipSet(animator); + dashboard.setClipSummary( + clipSet.ids.length > 0 + ? clipSet.ids.map((clipId) => formatClipLabel(clipId)).join(', ') + : 'No clips' + ); + dashboard.setAnimationState(formatClipLabel(clipSet.idle ?? clipSet.run)); + + const characterRig = characterContainer.requireComponent(Transform); + dashboard.setHierarchyRoot(characterRig); + const locomotion = characterContainer.addComponent(CharacterLocomotionController); + locomotion.bindAnimator(animator, clipSet); + locomotion.setInputScopeElement(sceneHost); + locomotion.onStateChanged = (label) => dashboard.setAnimationState(label); + dashboard.setMotionTelemetry(characterRig.worldPosition, 0); + + const camera = scene.createCameraActor( + { name: 'FollowCamera' }, + { primary: true, fieldOfView: 46, near: 0.1, far: 200 } + ); + const followCamera = camera.addComponent(FollowCameraController, { + distance: 8.4, + minDistance: 3, + maxDistance: 11.5, + azimuth: 0.62, + elevation: 0.44, + targetOffset: [0, Math.max(1.28, (characterBounds?.size.y ?? 2) * 0.64), 0], + positionDamping: 4.6, + targetDamping: 5.2, + }); + followCamera.setTarget(characterRig); + locomotion.setCameraReference(camera.requireComponent(Transform)); + + const previousTelemetryPosition = characterRig.worldPosition.clone(); + const lastTrailDropPosition = characterRig.worldPosition.clone(); + let trailWriteIndex = 0; + scene.loop.addSystem({ + id: CHARACTER_TELEMETRY_SYSTEM_ID, + priority: 110, + enabled: true, + update(context) { + const position = characterRig.worldPosition; + const deltaSeconds = Math.max(1 / 1000, context.delta / 1000); + const deltaX = position.x - previousTelemetryPosition.x; + const deltaY = position.y - previousTelemetryPosition.y; + const deltaZ = position.z - previousTelemetryPosition.z; + const speed = Math.hypot(deltaX, deltaY, deltaZ) / deltaSeconds; + dashboard.setMotionTelemetry(position, speed); + dashboard.refreshSelection(); + + const planarTravel = Math.hypot( + position.x - lastTrailDropPosition.x, + position.z - lastTrailDropPosition.z + ); + if (speed >= 0.35 && planarTravel >= 0.7) { + motionTrailActors[trailWriteIndex] + .requireComponent(Transform) + .position = new Vec3(position.x, 0.09, position.z); + trailWriteIndex = (trailWriteIndex + 1) % motionTrailActors.length; + lastTrailDropPosition.x = position.x; + lastTrailDropPosition.y = position.y; + lastTrailDropPosition.z = position.z; + } + + previousTelemetryPosition.x = position.x; + previousTelemetryPosition.y = position.y; + previousTelemetryPosition.z = position.z; + }, + }); + + let orbiting = false; + let pointerId = -1; + let previousX = 0; + let previousY = 0; + + const handlePointerDown = (event: PointerEvent) => { + if (event.button !== 2 && event.button !== 1) { + sceneHost.focus({ preventScroll: true }); + return; + } + + orbiting = true; + pointerId = event.pointerId; + previousX = event.clientX; + previousY = event.clientY; + sceneHost.focus({ preventScroll: true }); + sceneHost.setPointerCapture(event.pointerId); + sceneHost.style.cursor = 'grabbing'; + event.preventDefault(); + }; + + const handlePointerMove = (event: PointerEvent) => { + if (!orbiting || event.pointerId !== pointerId) { + return; + } + + const deltaX = event.clientX - previousX; + const deltaY = event.clientY - previousY; + previousX = event.clientX; + previousY = event.clientY; + + followCamera.orbit(-deltaX * 0.0125, -deltaY * 0.0095); + event.preventDefault(); + }; + + const endOrbit = (event: PointerEvent) => { + if (event.pointerId !== pointerId) { + return; + } + + orbiting = false; + pointerId = -1; + sceneHost.style.cursor = 'grab'; + if (sceneHost.hasPointerCapture(event.pointerId)) { + sceneHost.releasePointerCapture(event.pointerId); + } + }; + + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + followCamera.zoom(event.deltaY * 0.009); + }; + + const handleContextMenu = (event: MouseEvent) => { + event.preventDefault(); + }; + + sceneHost.addEventListener('pointerdown', handlePointerDown); + sceneHost.addEventListener('pointermove', handlePointerMove); + sceneHost.addEventListener('pointerup', endOrbit); + sceneHost.addEventListener('pointercancel', endOrbit); + sceneHost.addEventListener('wheel', handleWheel, { passive: false }); + sceneHost.addEventListener('contextmenu', handleContextMenu); + sceneHost.focus({ preventScroll: true }); + + removeInteractionListeners = () => { + sceneHost.removeEventListener('pointerdown', handlePointerDown); + sceneHost.removeEventListener('pointermove', handlePointerMove); + sceneHost.removeEventListener('pointerup', endOrbit); + sceneHost.removeEventListener('pointercancel', endOrbit); + sceneHost.removeEventListener('wheel', handleWheel); + sceneHost.removeEventListener('contextmenu', handleContextMenu); + }; + + addSectionLabel(dashboard.controlsHost, 'Camera'); + addSlider(dashboard.controlsHost, { + label: 'Distance', + min: 3, + max: 11.5, + step: 0.1, + value: followCamera.distance, + format: (value) => value.toFixed(1), + onChange: (value) => { + followCamera.distance = value; + followCamera.snap(); + }, + }); + addSlider(dashboard.controlsHost, { + label: 'Azimuth', + min: -180, + max: 180, + step: 1, + value: (followCamera.azimuth * 180) / Math.PI, + format: (value) => `${value.toFixed(0)}deg`, + onChange: (value) => { + followCamera.azimuth = (value * Math.PI) / 180; + followCamera.snap(); + }, + }); + addSlider(dashboard.controlsHost, { + label: 'Elevation', + min: 10, + max: 70, + step: 1, + value: (followCamera.elevation * 180) / Math.PI, + format: (value) => `${value.toFixed(0)}deg`, + onChange: (value) => { + followCamera.elevation = (value * Math.PI) / 180; + followCamera.snap(); + }, + }); + addSlider(dashboard.controlsHost, { + label: 'Target Height', + min: 0.8, + max: 2.8, + step: 0.05, + value: followCamera.targetOffset.y, + format: (value) => value.toFixed(2), + onChange: (value) => { + followCamera.targetOffset = new Vec3( + followCamera.targetOffset.x, + value, + followCamera.targetOffset.z + ); + followCamera.snap(); + }, + }); + addSlider(dashboard.controlsHost, { + label: 'Camera Smoothing', + min: 1, + max: 14, + step: 0.5, + value: followCamera.positionDamping, + format: (value) => value.toFixed(1), + onChange: (value) => { + followCamera.positionDamping = value; + }, + }); + + addSectionLabel(dashboard.controlsHost, 'Locomotion'); + addSlider(dashboard.controlsHost, { + label: 'Move Speed', + min: 1.5, + max: 8, + step: 0.1, + value: locomotion.moveSpeed, + format: (value) => value.toFixed(1), + onChange: (value) => { + locomotion.moveSpeed = value; + }, + }); + addSlider(dashboard.controlsHost, { + label: 'Turn Smoothing', + min: 3, + max: 20, + step: 0.5, + value: locomotion.turnSpeed, + format: (value) => value.toFixed(1), + onChange: (value) => { + locomotion.turnSpeed = value; + }, + }); + addSlider(dashboard.controlsHost, { + label: 'Anim Blend', + min: 0.05, + max: 0.4, + step: 0.01, + value: locomotion.transitionDuration, + format: (value) => `${value.toFixed(2)}s`, + onChange: (value) => { + locomotion.transitionDuration = value; + }, + }); + + addSectionLabel(dashboard.controlsHost, 'Runtime'); + addReadonlyValue( + dashboard.controlsHost, + 'Character Clips', + clipSet.ids.length > 0 ? String(clipSet.ids.length) : '0' + ); + addReadonlyValue( + dashboard.controlsHost, + 'Environment Prop', + deskLoadResult.status === 'fulfilled' ? 'CityDesk loaded' : 'Unavailable' + ); + + const diagnosticSummary = [ + ...characterLoadResult.value.diagnostics, + ...(deskLoadResult.status === 'fulfilled' ? deskLoadResult.value.diagnostics : []), + ] + .slice(0, 4) + .map((entry) => `${entry.level.toUpperCase()} ${entry.code}`) + .join('\n'); + + const updateStatus = () => { + dashboard.setStatus( + `Character loaded from ${CHARACTER_MODEL_URL}\nEnvironment prop ${ + deskLoadResult.status === 'fulfilled' ? 'loaded' : 'skipped' + }.\nPalette material bound from ${COLOR_PALETTE_URL} to ${paletteRendererCount} character renderer${ + paletteRendererCount === 1 ? '' : 's' + } because the GLB does not embed material data.${ + deskPaletteRendererCount > 0 + ? `\nPalette material also bound to ${deskPaletteRendererCount} desk renderer${deskPaletteRendererCount === 1 ? '' : 's'}.` + : '' + }${diagnosticSummary ? `\n${diagnosticSummary}` : '\nNo importer warnings.'}`, + '#d7e2ea' + ); + }; + + updateStatus(); + + if (deskActorsForPaletteBinding.length > 0) { + deferredDeskPaletteBinding = globalThis.setTimeout(() => { + deskPaletteRendererCount = applyMaterialToImportedRenderers( + deskActorsForPaletteBinding, + CHARACTER_PALETTE_MATERIAL_ID, + (renderer) => + renderer.materialId === null || renderer.materialId.endsWith('#material/default') + ); + updateStatus(); + }, 150); + } + + const root = globalThis as { scene?: Scene }; + root.scene = scene; + + return { + dispose() { + if (deferredDeskPaletteBinding !== null) { + globalThis.clearTimeout(deferredDeskPaletteBinding); + } + scene.loop.removeSystem(CHARACTER_TELEMETRY_SYSTEM_ID); + removeInteractionListeners(); + cleanupResize(); + dashboard.dispose(); + database.dispose(); + if (root.scene === scene) { + delete root.scene; + } + scene.dispose(); + container.replaceChildren(); + }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + dashboard.setStatus( + `Character demo failed to initialize.\n${message}`, + '#ffb4b4' + ); + + return { + dispose() { + removeInteractionListeners(); + cleanupResize(); + dashboard.dispose(); + database.dispose(); + scene.dispose(); + container.replaceChildren(); + }, + }; + } + }, +}; + +export default characterFollowCameraExample; \ No newline at end of file diff --git a/web/examples/engine-benchmark.html b/web/examples/engine-benchmark.html index 3291e4b7..5351cdb9 100644 --- a/web/examples/engine-benchmark.html +++ b/web/examples/engine-benchmark.html @@ -604,11 +604,27 @@

Submitted Triangles

- Axrone setup time + Axrone build time + 0 ms +
+
+ Axrone first render + 0 ms +
+
+ Axrone total setup 0 ms
- Three.js setup time + Three.js build time + 0 ms +
+
+ Three.js first render + 0 ms +
+
+ Three.js total setup 0 ms
@@ -627,6 +643,7 @@

Methodology

  • Both engines receive the same deterministic object descriptors.
  • Motion field, camera path and color assignment are matched.
  • Materials are intentionally unlit to reduce shading noise.
  • +
  • Setup is split into scene build and first render for clearer startup analysis.
  • Axrone draw submissions are inferred from current scene source behavior.
  • Three.js draw submissions and triangles come from renderer frame stats.
  • Use the same browser session for relative comparison, not absolute marketing claims.
  • diff --git a/web/examples/main.ts b/web/examples/main.ts index e556c185..44ae38e4 100644 --- a/web/examples/main.ts +++ b/web/examples/main.ts @@ -24,6 +24,47 @@ const sourceLoaders = import.meta.glob('./*.ts', { const ignoredModules = new Set(['./main.ts', './example-types.ts', './example-runtime.ts']); const sourceStoragePrefix = 'axrone:examples:source:'; +interface PersistedExampleSource { + readonly version: 1; + readonly baseHash: string; + readonly source: string; +} + +const hashSource = (source: string): string => { + let hash = 2166136261; + + for (let index = 0; index < source.length; index += 1) { + hash ^= source.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + + return (hash >>> 0).toString(16).padStart(8, '0'); +}; + +const parsePersistedExampleSource = ( + value: string | null +): PersistedExampleSource | undefined => { + if (!value) { + return undefined; + } + + try { + const parsed = JSON.parse(value) as Partial; + + if ( + parsed.version !== 1 || + typeof parsed.baseHash !== 'string' || + typeof parsed.source !== 'string' + ) { + return undefined; + } + + return parsed as PersistedExampleSource; + } catch { + return undefined; + } +}; + const resolveExamples = async (): Promise => { const entries = Object.entries(moduleLoaders).filter(([path]) => !ignoredModules.has(path)); const descriptors = await Promise.all( @@ -57,6 +98,68 @@ const resolveExamples = async (): Promise => { }); }; +const normalizeExamplePath = (value: string): string => + value.replace(/\\/g, '/').split('?')[0]!.replace(/^\.?\//, '').replace(/^\//, ''); + +const toRawExampleUrl = (path: string): string => { + const normalizedPath = path.replace(/^\.\//, ''); + const url = new URL(normalizedPath, import.meta.url); + url.searchParams.set('raw', ''); + url.searchParams.set('t', String(Date.now())); + return url.toString(); +}; + +const fetchLatestDescriptorSource = async ( + descriptor: ExampleDescriptor +): Promise => { + try { + const rawModule = (await import( + /* @vite-ignore */ toRawExampleUrl(descriptor.path) + )) as { default?: unknown }; + + if (typeof rawModule.default !== 'string') { + return undefined; + } + + return rawModule.default; + } catch { + return undefined; + } +}; + +const loadLatestDescriptorSource = async (descriptor: ExampleDescriptor): Promise => { + const fetchedSource = await fetchLatestDescriptorSource(descriptor); + if (fetchedSource !== undefined) { + return fetchedSource; + } + + const sourceLoader = sourceLoaders[descriptor.path]; + if (!sourceLoader) { + return descriptor.source; + } + + try { + return await sourceLoader(); + } catch { + return descriptor.source; + } +}; + +const refreshDescriptorSource = async ( + descriptor: ExampleDescriptor +): Promise => { + const latestSource = await loadLatestDescriptorSource(descriptor); + + if (latestSource === descriptor.source) { + return descriptor; + } + + return { + ...descriptor, + source: latestSource, + }; +}; + let editorModulePromise: Promise | undefined; let compilerModulePromise: Promise | undefined; @@ -183,6 +286,7 @@ const editorPanel = app.querySelector('.editor-panel'); if ( !exampleSelect || !host || + !status || !editorHost || !editorCaption || !editorStatus || @@ -215,8 +319,9 @@ let currentDescriptor: ExampleDescriptor | undefined; let currentRunToken = 0; let isApplyingEditorSource = false; let autoRun = true; // Default to enabled -let rerunTimer: number | undefined; +let rerunTimer: ReturnType | undefined; let editor: LiveEditorController | undefined; +let editorPromise: Promise | undefined; // Status Helpers const setStatus = (message: string, mode: 'loading' | 'ready' | 'error' = 'ready') => { @@ -230,17 +335,39 @@ const setEditorStatus = (message: string, mode: 'loading' | 'ready' | 'error' = }; // Local Storage Helpers -const readPersistedSource = (path: string): string | undefined => { +const readPersistedSource = (descriptor: ExampleDescriptor): string | undefined => { try { - return globalThis.localStorage.getItem(`${sourceStoragePrefix}${path}`) ?? undefined; + const storageKey = `${sourceStoragePrefix}${descriptor.path}`; + const payload = parsePersistedExampleSource(globalThis.localStorage.getItem(storageKey)); + + if (!payload) { + globalThis.localStorage.removeItem(storageKey); + return undefined; + } + + if (payload.baseHash !== hashSource(descriptor.source)) { + globalThis.localStorage.removeItem(storageKey); + return undefined; + } + + return payload.source; } catch { return undefined; } }; -const persistSource = (path: string, source: string) => { +const persistSource = (descriptor: ExampleDescriptor, source: string) => { try { - globalThis.localStorage.setItem(`${sourceStoragePrefix}${path}`, source); + const payload: PersistedExampleSource = { + version: 1, + baseHash: hashSource(descriptor.source), + source, + }; + + globalThis.localStorage.setItem( + `${sourceStoragePrefix}${descriptor.path}`, + JSON.stringify(payload) + ); } catch { // Ignore environments where local storage is unavailable. } @@ -255,7 +382,7 @@ const clearPersistedSource = (path: string) => { }; const getEffectiveSource = (descriptor: ExampleDescriptor): string => { - return sourceOverrides.get(descriptor.path) ?? readPersistedSource(descriptor.path) ?? descriptor.source; + return sourceOverrides.get(descriptor.path) ?? readPersistedSource(descriptor) ?? descriptor.source; }; const syncSourceOverride = (descriptor: ExampleDescriptor, source: string) => { @@ -266,7 +393,7 @@ const syncSourceOverride = (descriptor: ExampleDescriptor, source: string) => { } sourceOverrides.set(descriptor.path, source); - persistSource(descriptor.path, source); + persistSource(descriptor, source); }; const updateEditorCaption = (descriptor: ExampleDescriptor, source: string) => { @@ -304,54 +431,68 @@ const ensureEditor = async (descriptor: ExampleDescriptor): Promise { - if (isApplyingEditorSource || !currentDescriptor || !editor) { - return; - } - - const nextSource = editor.getValue(); - - if (nextSource === currentDescriptor.source) { - sourceOverrides.delete(currentDescriptor.path); - clearPersistedSource(currentDescriptor.path); - } else { - sourceOverrides.set(currentDescriptor.path, nextSource); - persistSource(currentDescriptor.path, nextSource); - } - - updateEditorCaption(currentDescriptor, nextSource); + if (editorPromise) { + return editorPromise; + } - if (!autoRun) { - setEditorStatus('Changes pending. Use Run to refresh.', 'ready'); - return; - } + editorPromise = (async () => { + const [editorModule, compilerModule] = await Promise.all([ + getEditorModule(), + getCompilerModule(), + ]); + editorSupportedImports.textContent = `Supported: ${compilerModule + .getSupportedPlaygroundImports() + .join(', ')}`; - // Debounce: Wait 500ms after last keystroke before refreshing - cancelScheduledRun(); - setEditorStatus('Typing... refreshing soon', 'loading'); - rerunTimer = globalThis.setTimeout(() => { - rerunTimer = undefined; - void runCurrentSource('live'); - }, 500); - }, - }); + const initialSource = compilerModule.normalizePlaygroundSource( + getEffectiveSource(descriptor) + ); + syncSourceOverride(descriptor, initialSource); + + const createdEditor = editorModule.createLiveEditor({ + container: editorHost, + value: initialSource, + path: descriptor.path, + onChange: () => { + if (isApplyingEditorSource || !currentDescriptor || !editor) { + return; + } + + const nextSource = editor.getValue(); + + if (nextSource === currentDescriptor.source) { + sourceOverrides.delete(currentDescriptor.path); + clearPersistedSource(currentDescriptor.path); + } else { + sourceOverrides.set(currentDescriptor.path, nextSource); + persistSource(currentDescriptor, nextSource); + } + + updateEditorCaption(currentDescriptor, nextSource); + + if (!autoRun) { + setEditorStatus('Changes pending. Use Run to refresh.', 'ready'); + return; + } + + cancelScheduledRun(); + setEditorStatus('Typing... refreshing soon', 'loading'); + rerunTimer = globalThis.setTimeout(() => { + rerunTimer = undefined; + void runCurrentSource('live'); + }, 500); + }, + }); + + editor = createdEditor; + return createdEditor; + })(); - return editor; + try { + return await editorPromise; + } finally { + editorPromise = undefined; + } }; const syncEditorToDescriptor = async (descriptor: ExampleDescriptor) => { @@ -374,18 +515,55 @@ const syncEditorToDescriptor = async (descriptor: ExampleDescriptor) => { liveEditor.focus(); }; +const syncLatestDescriptorToEditor = async ( + descriptor: ExampleDescriptor, + liveEditor: LiveEditorController +): Promise => { + const refreshedDescriptor = await refreshDescriptorSource(descriptor); + + if (refreshedDescriptor === descriptor) { + return descriptor; + } + + const compilerModule = await getCompilerModule(); + const nextSource = compilerModule.normalizePlaygroundSource( + getEffectiveSource(refreshedDescriptor) + ); + + currentDescriptor = refreshedDescriptor; + isApplyingEditorSource = true; + liveEditor.setValue(nextSource); + isApplyingEditorSource = false; + syncSourceOverride(refreshedDescriptor, nextSource); + updateEditorCaption(refreshedDescriptor, nextSource); + + return refreshedDescriptor; +}; + +const shouldPreservePlaygroundEdits = (descriptor: ExampleDescriptor): boolean => + sourceOverrides.has(descriptor.path); + const runCurrentSource = async (reason: 'select' | 'manual' | 'live') => { if (!currentDescriptor) { return; } - const descriptor = currentDescriptor; + let descriptor = currentDescriptor; cancelScheduledRun(); const runToken = ++currentRunToken; const compilerModule = await getCompilerModule(); const liveEditor = editor; + + if ( + liveEditor && + !sourceOverrides.has(descriptor.path) && + liveEditor.getValue() === descriptor.source + ) { + descriptor = await syncLatestDescriptorToEditor(descriptor, liveEditor); + } + const source = liveEditor?.getValue() ?? getEffectiveSource(descriptor); const normalizedSource = compilerModule.normalizePlaygroundSource(source); @@ -448,21 +626,26 @@ const runCurrentSource = async (reason: 'select' | 'manual' | 'live') => { }; const selectExample = async (descriptor: ExampleDescriptor) => { - if (currentDescriptor?.path === descriptor.path) { + const nextDescriptor = await refreshDescriptorSource(descriptor); + + if ( + currentDescriptor?.path === nextDescriptor.path && + currentDescriptor.source === nextDescriptor.source + ) { return; } currentRunToken += 1; cancelScheduledRun(); - currentDescriptor = descriptor; - exampleSelect.value = descriptor.example.id; + currentDescriptor = nextDescriptor; + exampleSelect.value = nextDescriptor.example.id; setStatus('Loading example...', 'loading'); try { - await syncEditorToDescriptor(descriptor); + await syncEditorToDescriptor(nextDescriptor); await runCurrentSource('select'); - location.hash = descriptor.example.id; + location.hash = nextDescriptor.example.id; } catch (error) { const message = error instanceof Error ? error.message : String(error); setStatus(message, 'error'); @@ -470,31 +653,76 @@ const selectExample = async (descriptor: ExampleDescriptor) => { } }; +if (import.meta.hot) { + import.meta.hot.on('vite:afterUpdate', (payload: { updates: Array<{ path: string }> }) => { + void (async () => { + if (!currentDescriptor || !editor || shouldPreservePlaygroundEdits(currentDescriptor)) { + return; + } + + const currentPath = normalizeExamplePath(currentDescriptor.path); + const touchesCurrentExample = payload.updates.some((update) => + normalizeExamplePath(update.path).endsWith(currentPath) + ); + + if (!touchesCurrentExample) { + return; + } + + const compilerModule = await getCompilerModule(); + const refreshedDescriptor = await refreshDescriptorSource(currentDescriptor); + if (refreshedDescriptor.source === currentDescriptor.source) { + return; + } + + currentDescriptor = refreshedDescriptor; + const nextSource = compilerModule.normalizePlaygroundSource(refreshedDescriptor.source); + isApplyingEditorSource = true; + editor.setValue(nextSource); + isApplyingEditorSource = false; + syncSourceOverride(refreshedDescriptor, nextSource); + updateEditorCaption(refreshedDescriptor, nextSource); + + if (!autoRun) { + setEditorStatus('Source updated from disk. Click Run to refresh.', 'ready'); + return; + } + + await runCurrentSource('live'); + })(); + }); +} + // Event Listeners runButton.addEventListener('click', () => { void runCurrentSource('manual'); }); resetButton.addEventListener('click', () => { - if (!currentDescriptor || !editor) { - return; - } + void (async () => { + if (!currentDescriptor || !editor) { + return; + } - sourceOverrides.delete(currentDescriptor.path); - clearPersistedSource(currentDescriptor.path); + const latestDescriptor = await refreshDescriptorSource(currentDescriptor); + currentDescriptor = latestDescriptor; - isApplyingEditorSource = true; - editor.setValue(currentDescriptor.source); - isApplyingEditorSource = false; + sourceOverrides.delete(latestDescriptor.path); + clearPersistedSource(latestDescriptor.path); - updateEditorCaption(currentDescriptor, currentDescriptor.source); + isApplyingEditorSource = true; + editor.setValue(latestDescriptor.source); + isApplyingEditorSource = false; - if (!autoRun) { - setEditorStatus('Source reset. Click Run to refresh.', 'ready'); - return; - } + updateEditorCaption(latestDescriptor, latestDescriptor.source); - void runCurrentSource('manual'); + if (!autoRun) { + setEditorStatus('Source reset. Click Run to refresh.', 'ready'); + return; + } + + await runCurrentSource('manual'); + })(); }); autoRunToggle.addEventListener('change', () => { diff --git a/web/examples/playground/engine-benchmark.ts b/web/examples/playground/engine-benchmark.ts index 051c52b0..db6e867f 100644 --- a/web/examples/playground/engine-benchmark.ts +++ b/web/examples/playground/engine-benchmark.ts @@ -1,11 +1,11 @@ import { Transform } from '@axrone/ecs-runtime'; +import { createSphere } from '@axrone/geometry'; import { - Camera, - MeshRenderer, Scene, + SceneGeometryMeshBuilder, createUnlitColorShaderDefinition, } from '@axrone/scene-3d'; -import { Mat4, Quat, Vec3 } from '@axrone/numeric'; +import { Quat, Vec3 } from '@axrone/numeric'; import * as THREE from 'three'; type WorkloadType = 'draw-call' | 'triangle' | 'mixed'; @@ -28,7 +28,24 @@ type EngineStats = { readonly frameTimes: number[]; drawCalls: number; triangles: number; + setupBuildTimeMs: number; + firstRenderTimeMs: number; setupTimeMs: number; + readonly buildPhases: Record; +}; + +type BuildPhaseBreakdown = Readonly>; + +type EngineSummary = { + averageFps: number; + p95FrameTimeMs: number; + frameCount: number; + drawCalls: number; + triangles: number; + setupBuildTimeMs: number; + firstRenderTimeMs: number; + setupTimeMs: number; + buildPhases: BuildPhaseBreakdown; }; type BenchmarkRuntime = { @@ -59,22 +76,8 @@ type BenchmarkSnapshot = { threeTriangles: 'renderer.info.render.triangles'; }; engines: { - axrone: { - averageFps: number; - p95FrameTimeMs: number; - frameCount: number; - drawCalls: number; - triangles: number; - setupTimeMs: number; - }; - three: { - averageFps: number; - p95FrameTimeMs: number; - frameCount: number; - drawCalls: number; - triangles: number; - setupTimeMs: number; - }; + axrone: EngineSummary; + three: EngineSummary; }; winners: { fps: string; @@ -84,6 +87,34 @@ type BenchmarkSnapshot = { }; }; +type BenchmarkRunOptions = { + workload?: WorkloadType; + comparisonMode?: ComparisonMode; + objectCount?: number; + durationMs?: number; + durationSeconds?: number; +}; + +type BenchmarkRunRequest = BenchmarkRunOptions & { + timeoutMs?: number; +}; + +type BenchmarkCompletionWaiter = { + resolve(snapshot: BenchmarkSnapshot): void; + reject(error: Error): void; + timeoutId: number | null; +}; + +type BenchmarkAutomationApi = { + configure(options?: BenchmarkRunOptions): BenchmarkSnapshot; + start(options?: BenchmarkRunOptions): BenchmarkSnapshot; + stop(): BenchmarkSnapshot; + reset(): BenchmarkSnapshot; + getSnapshot(): BenchmarkSnapshot; + waitForCompletion(timeoutMs?: number): Promise; + runOnce(options?: BenchmarkRunRequest): Promise; +}; + const byId = (id: string): T => { const element = document.getElementById(id); if (!element) { @@ -121,6 +152,10 @@ const ui = { threeDraws: byId('three-draws'), axroneTris: byId('axrone-tris'), threeTris: byId('three-tris'), + axroneSetupBuild: byId('axrone-setup-build'), + threeSetupBuild: byId('three-setup-build'), + axroneFirstRender: byId('axrone-first-render'), + threeFirstRender: byId('three-first-render'), axroneSetup: byId('axrone-setup'), threeSetup: byId('three-setup'), fpsWinner: byId('fps-winner'), @@ -173,6 +208,7 @@ const state = { | null, axrone: null as BenchmarkRuntime | null, three: null as BenchmarkRuntime | null, + completionWaiters: [] as BenchmarkCompletionWaiter[], }; const mean = (values: readonly number[]): number => @@ -191,6 +227,40 @@ const percentile = (values: readonly number[], ratio: number): number => { const formatNumber = (value: number): string => value.toLocaleString('en-US'); const round = (value: number, digits = 2): number => Number(value.toFixed(digits)); +const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)); + +const capturePhase = (buildPhases: Record, phaseName: string, action: () => T): T => { + const startedAt = performance.now(); + + try { + return action(); + } finally { + buildPhases[phaseName] = (buildPhases[phaseName] ?? 0) + (performance.now() - startedAt); + } +}; + +const roundBuildPhases = ( + buildPhases: Record | null | undefined +): BuildPhaseBreakdown => { + if (!buildPhases) { + return {}; + } + + return Object.fromEntries( + Object.entries(buildPhases) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([phaseName, duration]) => [phaseName, round(duration, 2)]) + ); +}; + +const snapRangeValue = (input: HTMLInputElement, rawValue: number): string => { + const min = Number(input.min || 0); + const max = Number(input.max || min); + const step = Number(input.step || 1) || 1; + const snapped = min + Math.round((rawValue - min) / step) * step; + return String(clamp(snapped, min, max)); +}; + const colorFromIndex = (index: number, count: number): readonly [number, number, number] => { const hue = (index / Math.max(1, count)) * 0.82; const saturation = 0.66; @@ -301,19 +371,29 @@ const applyThreeStyleOrbitPose = ( position: Readonly, target: Readonly, threeCamera: THREE.PerspectiveCamera, - transform: Transform + transform: Transform, + rotationScratch: Quat ): void => { threeCamera.position.set(position.x, position.y, position.z); threeCamera.lookAt(target.x, target.y, target.z); - transform.position = new Vec3(position.x, position.y, position.z); - transform.rotation = new Quat( - threeCamera.quaternion.x, - threeCamera.quaternion.y, - threeCamera.quaternion.z, - threeCamera.quaternion.w - ); + transform.position = position as Vec3; + rotationScratch.x = threeCamera.quaternion.x; + rotationScratch.y = threeCamera.quaternion.y; + rotationScratch.z = threeCamera.quaternion.z; + rotationScratch.w = threeCamera.quaternion.w; + transform.rotation = rotationScratch; +}; + +const setVec3 = (target: Vec3, x: number, y: number, z: number): Vec3 => { + target.x = x; + target.y = y; + target.z = z; + return target; }; +const setQuatFromEuler = (target: Quat, x: number, y: number, z: number): Quat => + Quat.fromEuler(x, y, z, target); + const resizeCanvas = ( canvas: HTMLCanvasElement, host: HTMLElement, @@ -335,79 +415,130 @@ const createAxroneRuntime = ( resizeCanvas(canvas, host); const setupStartedAt = performance.now(); - const scene = new Scene({ - canvas, - width: host.clientWidth, - height: host.clientHeight, - pixelRatio: Math.min(devicePixelRatio || 1, 2), - worldConfig: { - enableValidation: false, - }, - autoStart: false, - clearColor: [0.03, 0.06, 0.1, 1], + const buildPhases: Record = {}; + const scene = capturePhase(buildPhases, 'sceneSetupMs', () => + new Scene({ + canvas, + width: host.clientWidth, + height: host.clientHeight, + pixelRatio: Math.min(devicePixelRatio || 1, 2), + worldConfig: { + enableValidation: false, + }, + autoStart: false, + clearColor: [0.03, 0.06, 0.1, 1], + }) + ); + + capturePhase(buildPhases, 'materialSetupMs', () => { + scene.registerShader(createUnlitColorShaderDefinition('benchmark/unlit-color')); + scene.createMaterial({ + id: 'benchmark/material', + shaderId: 'benchmark/unlit-color', + uniforms: { + u_Color: [1, 1, 1, 1], + }, + }); }); - scene.registerShader(createUnlitColorShaderDefinition('benchmark/unlit-color')); - scene.createMaterial({ - id: 'benchmark/material', - shaderId: 'benchmark/unlit-color', - uniforms: { - u_Color: [1, 1, 1, 1], - }, + const { boxMesh, sphereMesh } = capturePhase(buildPhases, 'meshSetupMs', () => { + const meshBuilder = new SceneGeometryMeshBuilder(); + const needsBoxMesh = descriptors.some((descriptor) => descriptor.kind === 'box'); + const needsSphereMesh = descriptors.some((descriptor) => descriptor.kind === 'sphere'); + + return { + boxMesh: needsBoxMesh ? scene.createBoxMesh('benchmark/box', 1, 1, 1) : null, + sphereMesh: needsSphereMesh + ? scene.registerMesh( + meshBuilder.createDefinition( + 'benchmark/sphere', + createSphere({ + radius: 0.65, + widthSegments: 16, + heightSegments: 16, + generateNormals: true, + generateTexCoords: true, + generateTangents: false, + }) + ) + ) + : null, + }; }); - const boxMesh = scene.createBoxMesh('benchmark/box', 1, 1, 1); - const sphereMesh = scene.createSphereMesh('benchmark/sphere', 0.65, 16); - const cameraActor = scene.createCameraActor({ autoStart: false }, { primary: true, fieldOfView: 58 }); - const cameraComponent = cameraActor.requireComponent(Camera); + const cameraActor = capturePhase(buildPhases, 'cameraSetupMs', () => + scene.createCameraActor({ autoStart: false }, { primary: true, fieldOfView: 58 }) + ); const cameraTransform = cameraActor.requireComponent(Transform); - const orbit = computeSceneOrbit(descriptors); + const orbit = capturePhase(buildPhases, 'cameraFramingMs', () => computeSceneOrbit(descriptors)); const orbitCameraReference = new THREE.PerspectiveCamera(58, 1, 0.1, 1000); - let currentCameraPosition = new Vec3( + const currentCameraPosition = new Vec3( orbit.target.x + orbit.radius, orbit.target.y + orbit.height, orbit.target.z ); - - cameraComponent.getViewMatrix = () => - Mat4.lookAt(currentCameraPosition, orbit.target, Vec3.UP, new Mat4()); + const cameraRotationScratch = new Quat(); + const positionScratch = new Vec3(); + const rotationScratch = new Quat(); const renderables: { readonly transform: Transform; readonly descriptor: WorkloadDescriptor }[] = []; const sphereScale = new Vec3(0.72, 0.72, 0.72); const boxScale = new Vec3(0.84, 0.84, 0.84); + const sharedActorConfig = Object.freeze({ autoStart: false }); + + const renderableConfigs = capturePhase(buildPhases, 'renderableConfigMs', () => + descriptors.map((descriptor) => ({ + actorConfig: sharedActorConfig, + rendererConfig: { + meshId: + descriptor.kind === 'sphere' + ? (sphereMesh?.id ?? 'benchmark/sphere') + : (boxMesh?.id ?? 'benchmark/box'), + materialId: 'benchmark/material', + }, + })) + ); - scene.world.batchStructureChanges(() => { - for (const descriptor of descriptors) { - const actor = scene.createRenderableActor( - { autoStart: false }, - { - meshId: descriptor.kind === 'sphere' ? sphereMesh.id : boxMesh.id, - materialId: 'benchmark/material', - } - ); + const createdRenderables = capturePhase(buildPhases, 'renderableCreateMs', () => + scene.createRenderableActors(renderableConfigs, buildPhases) + ); - const transform = actor.requireComponent(Transform); + capturePhase(buildPhases, 'renderableInitMs', () => { + for (let index = 0; index < descriptors.length; index += 1) { + const descriptor = descriptors[index]!; + const created = createdRenderables[index]!; + const transform = created.transform; transform.position = descriptor.basePosition; transform.scale = descriptor.kind === 'sphere' ? sphereScale : boxScale; - - const renderer = actor.getComponent(MeshRenderer); - renderer?.setUniform('u_Color', [ + created.renderer.setUniform('u_Color', [ descriptor.color[0], descriptor.color[1], descriptor.color[2], 1, ]); - renderables.push({ transform, descriptor }); } }); + capturePhase(buildPhases, 'cameraPoseApplyMs', () => { + applyThreeStyleOrbitPose( + currentCameraPosition, + orbit.target, + orbitCameraReference, + cameraTransform, + cameraRotationScratch + ); + }); + const stats: EngineStats = { frameCount: 0, frameTimes: [], drawCalls: 0, triangles: 0, + setupBuildTimeMs: 0, + firstRenderTimeMs: 0, setupTimeMs: 0, + buildPhases, }; const syncMetrics = () => { const renderStats = scene.renderStats; @@ -415,53 +546,63 @@ const createAxroneRuntime = ( stats.triangles = renderStats.trianglesSubmitted; }; - scene.addSystem( - { - id: 'benchmark/update', - query: ['Transform'] as const, - priority: 0, - enabled: true, - execute: (_entities, deltaTime) => { - const elapsed = scene.loop.elapsed; - currentCameraPosition = new Vec3( - orbit.target.x + Math.cos(elapsed * 0.00017) * orbit.radius, - orbit.target.y + orbit.height, - orbit.target.z + Math.sin(elapsed * 0.00017) * orbit.radius - ); - applyThreeStyleOrbitPose( - currentCameraPosition, - orbit.target, - orbitCameraReference, - cameraTransform - ); - - for (const { transform, descriptor } of renderables) { - transform.position = new Vec3( - descriptor.basePosition.x, - descriptor.basePosition.y + - Math.sin(elapsed * descriptor.bobSpeed + descriptor.bobPhase) * - descriptor.bobAmplitude, - descriptor.basePosition.z + capturePhase(buildPhases, 'systemSetupMs', () => { + scene.addSystem( + { + id: 'benchmark/update', + query: ['Transform'] as const, + priority: 0, + enabled: true, + execute: (_entities, deltaTime) => { + const elapsed = scene.loop.elapsed; + setVec3( + currentCameraPosition, + orbit.target.x + Math.cos(elapsed * 0.00017) * orbit.radius, + orbit.target.y + orbit.height, + orbit.target.z + Math.sin(elapsed * 0.00017) * orbit.radius ); - transform.rotation = Quat.fromEuler( - elapsed * descriptor.spin.x, - elapsed * descriptor.spin.y, - elapsed * descriptor.spin.z + applyThreeStyleOrbitPose( + currentCameraPosition, + orbit.target, + orbitCameraReference, + cameraTransform, + cameraRotationScratch ); - } - - if (deltaTime > 0) { - stats.frameTimes.push(deltaTime); - } - stats.frameCount += 1; - }, - } - ); + for (const { transform, descriptor } of renderables) { + transform.position = setVec3( + positionScratch, + descriptor.basePosition.x, + descriptor.basePosition.y + + Math.sin(elapsed * descriptor.bobSpeed + descriptor.bobPhase) * + descriptor.bobAmplitude, + descriptor.basePosition.z + ); + transform.rotation = setQuatFromEuler( + rotationScratch, + elapsed * descriptor.spin.x, + elapsed * descriptor.spin.y, + elapsed * descriptor.spin.z + ); + } + + if (deltaTime > 0) { + stats.frameTimes.push(deltaTime); + } + + stats.frameCount += 1; + }, + } + ); + }); + const buildCompletedAt = performance.now(); scene.renderNow(); syncMetrics(); - stats.setupTimeMs = performance.now() - setupStartedAt; + const firstRenderCompletedAt = performance.now(); + stats.setupBuildTimeMs = buildCompletedAt - setupStartedAt; + stats.firstRenderTimeMs = firstRenderCompletedAt - buildCompletedAt; + stats.setupTimeMs = firstRenderCompletedAt - setupStartedAt; return { stats, @@ -496,7 +637,10 @@ const createThreeRuntime = ( frameTimes: [], drawCalls: 0, triangles: 0, + setupBuildTimeMs: 0, + firstRenderTimeMs: 0, setupTimeMs: 0, + buildPhases: {}, }; const syncMetrics = () => { stats.drawCalls = renderer.info.render.calls; @@ -504,55 +648,77 @@ const createThreeRuntime = ( }; const setupStartedAt = performance.now(); - const renderer = new THREE.WebGLRenderer({ - canvas, - antialias: false, - alpha: false, - powerPreference: 'high-performance', + const renderer = capturePhase(stats.buildPhases, 'rendererSetupMs', () => { + const createdRenderer = new THREE.WebGLRenderer({ + canvas, + antialias: false, + alpha: false, + powerPreference: 'high-performance', + }); + createdRenderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2)); + createdRenderer.setSize(host.clientWidth, host.clientHeight, false); + return createdRenderer; }); - renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2)); - renderer.setSize(host.clientWidth, host.clientHeight, false); - const scene = new THREE.Scene(); - scene.background = new THREE.Color(0x050b14); + const scene = capturePhase(stats.buildPhases, 'sceneSetupMs', () => { + const createdScene = new THREE.Scene(); + createdScene.background = new THREE.Color(0x050b14); + return createdScene; + }); - const camera = new THREE.PerspectiveCamera( - 58, - host.clientWidth / Math.max(1, host.clientHeight), - 0.1, - 1000 - ); - scene.add(camera); + const camera = capturePhase(stats.buildPhases, 'cameraSetupMs', () => { + const createdCamera = new THREE.PerspectiveCamera( + 58, + host.clientWidth / Math.max(1, host.clientHeight), + 0.1, + 1000 + ); + scene.add(createdCamera); + return createdCamera; + }); - const boxGeometry = new THREE.BoxGeometry(1, 1, 1); - const sphereGeometry = new THREE.SphereGeometry(0.65, 16, 16); + const { boxGeometry, sphereGeometry } = capturePhase(stats.buildPhases, 'geometrySetupMs', () => ({ + boxGeometry: new THREE.BoxGeometry(1, 1, 1), + sphereGeometry: new THREE.SphereGeometry(0.65, 16, 16), + })); const objects: { readonly mesh: THREE.Mesh; readonly descriptor: WorkloadDescriptor }[] = []; - for (const descriptor of descriptors) { - const material = new THREE.MeshBasicMaterial({ - color: new THREE.Color(...descriptor.color), - }); - const mesh = new THREE.Mesh( - descriptor.kind === 'sphere' ? sphereGeometry : boxGeometry, - material - ); + capturePhase(stats.buildPhases, 'objectCreateMs', () => { + for (const descriptor of descriptors) { + const material = new THREE.MeshBasicMaterial({ + color: new THREE.Color(...descriptor.color), + }); + const mesh = new THREE.Mesh( + descriptor.kind === 'sphere' ? sphereGeometry : boxGeometry, + material + ); - mesh.position.set( - descriptor.basePosition.x, - descriptor.basePosition.y, - descriptor.basePosition.z - ); - mesh.scale.setScalar(descriptor.scale); - mesh.frustumCulled = comparisonMode === 'three-culling'; - scene.add(mesh); - objects.push({ mesh, descriptor }); - } + mesh.position.set( + descriptor.basePosition.x, + descriptor.basePosition.y, + descriptor.basePosition.z + ); + mesh.scale.setScalar(descriptor.scale); + mesh.frustumCulled = comparisonMode === 'three-culling'; + scene.add(mesh); + objects.push({ mesh, descriptor }); + } + }); let rafId = 0; let previousFrameTime = 0; let paused = true; let logicalElapsed = 0; - const orbit = computeSceneOrbit(descriptors); + const orbit = capturePhase(stats.buildPhases, 'cameraFramingMs', () => computeSceneOrbit(descriptors)); + + capturePhase(stats.buildPhases, 'cameraPoseApplyMs', () => { + camera.position.set( + orbit.target.x + orbit.radius, + orbit.target.y + orbit.height, + orbit.target.z + ); + camera.lookAt(orbit.target.x, orbit.target.y, orbit.target.z); + }); const frame = (timestamp: number) => { if (paused) { @@ -596,9 +762,13 @@ const createThreeRuntime = ( rafId = requestAnimationFrame(frame); }; + const buildCompletedAt = performance.now(); renderer.render(scene, camera); syncMetrics(); - stats.setupTimeMs = performance.now() - setupStartedAt; + const firstRenderCompletedAt = performance.now(); + stats.setupBuildTimeMs = buildCompletedAt - setupStartedAt; + stats.firstRenderTimeMs = firstRenderCompletedAt - buildCompletedAt; + stats.setupTimeMs = firstRenderCompletedAt - setupStartedAt; return { stats, @@ -712,6 +882,18 @@ const refreshMetrics = () => { ui.threeDraws.textContent = threeStats ? formatNumber(threeStats.drawCalls) : '0'; ui.axroneTris.textContent = axroneStats ? formatNumber(axroneStats.triangles) : '0'; ui.threeTris.textContent = threeStats ? formatNumber(threeStats.triangles) : '0'; + ui.axroneSetupBuild.textContent = axroneStats + ? `${axroneStats.setupBuildTimeMs.toFixed(1)} ms` + : '0 ms'; + ui.threeSetupBuild.textContent = threeStats + ? `${threeStats.setupBuildTimeMs.toFixed(1)} ms` + : '0 ms'; + ui.axroneFirstRender.textContent = axroneStats + ? `${axroneStats.firstRenderTimeMs.toFixed(1)} ms` + : '0 ms'; + ui.threeFirstRender.textContent = threeStats + ? `${threeStats.firstRenderTimeMs.toFixed(1)} ms` + : '0 ms'; ui.axroneSetup.textContent = axroneStats ? `${axroneStats.setupTimeMs.toFixed(1)} ms` : '0 ms'; ui.threeSetup.textContent = threeStats ? `${threeStats.setupTimeMs.toFixed(1)} ms` : '0 ms'; @@ -733,7 +915,7 @@ const refreshMetrics = () => { ); }; -const computeEngineSummary = (stats: EngineStats | null | undefined) => { +const computeEngineSummary = (stats: EngineStats | null | undefined): EngineSummary => { const avgFrame = stats ? mean(stats.frameTimes) : 0; const fps = avgFrame > 0 ? 1000 / avgFrame : 0; const p95 = stats ? percentile(stats.frameTimes, 0.95) : 0; @@ -744,7 +926,10 @@ const computeEngineSummary = (stats: EngineStats | null | undefined) => { frameCount: stats?.frameCount ?? 0, drawCalls: stats?.drawCalls ?? 0, triangles: stats?.triangles ?? 0, + setupBuildTimeMs: round(stats?.setupBuildTimeMs ?? 0, 2), + firstRenderTimeMs: round(stats?.firstRenderTimeMs ?? 0, 2), setupTimeMs: round(stats?.setupTimeMs ?? 0, 2), + buildPhases: roundBuildPhases(stats?.buildPhases), }; }; @@ -806,6 +991,57 @@ const createBenchmarkSnapshot = (): BenchmarkSnapshot => { }; }; +const resolveCompletionWaiters = (snapshot: BenchmarkSnapshot) => { + const waiters = state.completionWaiters.splice(0, state.completionWaiters.length); + + for (const waiter of waiters) { + if (waiter.timeoutId !== null) { + clearTimeout(waiter.timeoutId); + } + waiter.resolve(snapshot); + } +}; + +const rejectCompletionWaiters = (error: Error) => { + const waiters = state.completionWaiters.splice(0, state.completionWaiters.length); + + for (const waiter of waiters) { + if (waiter.timeoutId !== null) { + clearTimeout(waiter.timeoutId); + } + waiter.reject(error); + } +}; + +const waitForBenchmarkCompletion = ( + timeoutMs = Math.max(state.durationMs + 15_000, 30_000) +): Promise => { + if (!state.running) { + return Promise.resolve(createBenchmarkSnapshot()); + } + + return new Promise((resolve, reject) => { + const waiter: BenchmarkCompletionWaiter = { + resolve, + reject, + timeoutId: null, + }; + + if (timeoutMs > 0) { + waiter.timeoutId = window.setTimeout(() => { + const index = state.completionWaiters.indexOf(waiter); + if (index >= 0) { + state.completionWaiters.splice(index, 1); + } + + reject(new Error(`Benchmark timed out after ${timeoutMs} ms.`)); + }, timeoutMs); + } + + state.completionWaiters.push(waiter); + }); +}; + const copyText = async (value: string) => { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(value); @@ -823,9 +1059,9 @@ const copyText = async (value: string) => { document.body.removeChild(textarea); }; -const stopBenchmark = (reason: 'manual' | 'completed') => { +const stopBenchmark = (reason: 'manual' | 'completed'): BenchmarkSnapshot => { if (!state.running) { - return; + return createBenchmarkSnapshot(); } state.running = false; @@ -845,6 +1081,10 @@ const stopBenchmark = (reason: 'manual' | 'completed') => { reason === 'completed' ? 'Benchmark completed' : 'Benchmark stopped'; ui.summaryCopy.textContent = 'Interpret the result as a workload-specific comparison. Draw-call heavy scenes and triangle-heavy scenes can favor different renderer architectures.'; + + const snapshot = createBenchmarkSnapshot(); + resolveCompletionWaiters(snapshot); + return snapshot; }; const monitor = (timestamp: number) => { @@ -864,7 +1104,36 @@ const monitor = (timestamp: number) => { state.monitorRaf = requestAnimationFrame(monitor); }; -const startBenchmark = () => { +const applyBenchmarkRunOptions = (options: BenchmarkRunOptions = {}) => { + if (options.workload) { + ui.workload.value = options.workload; + } + + if (options.comparisonMode) { + ui.comparisonMode.value = options.comparisonMode; + } + + if (typeof options.objectCount === 'number' && Number.isFinite(options.objectCount)) { + ui.objectCount.value = snapRangeValue(ui.objectCount, options.objectCount); + } + + const durationSeconds = + typeof options.durationMs === 'number' && Number.isFinite(options.durationMs) + ? options.durationMs / 1000 + : options.durationSeconds; + if (typeof durationSeconds === 'number' && Number.isFinite(durationSeconds)) { + ui.duration.value = snapRangeValue(ui.duration, durationSeconds); + } + + syncControls(); +}; + +const startBenchmark = (options: BenchmarkRunOptions = {}) => { + if (state.running) { + stopBenchmark('manual'); + } + + applyBenchmarkRunOptions(options); teardownRuntimes(); ui.errorText.textContent = ''; ui.errorText.className = ''; @@ -902,6 +1171,8 @@ const startBenchmark = () => { setRunningUi(); refreshMetrics(); state.monitorRaf = requestAnimationFrame(monitor); + + return createBenchmarkSnapshot(); }; const resetBenchmark = () => { @@ -982,6 +1253,7 @@ ui.startButton.addEventListener('click', () => { ui.summaryTitle.textContent = 'Benchmark setup failed'; ui.summaryCopy.textContent = 'Check the error line and verify WebGL2 support plus local package resolution.'; + rejectCompletionWaiters(error instanceof Error ? error : new Error(String(error))); } }); @@ -1008,3 +1280,24 @@ window.addEventListener('resize', handleResize); syncControls(); resetBenchmark(); + +(window as Window & { __AXRONE_ENGINE_BENCHMARK__?: BenchmarkAutomationApi }).__AXRONE_ENGINE_BENCHMARK__ = { + configure: (options = {}) => { + applyBenchmarkRunOptions(options); + refreshMetrics(); + return createBenchmarkSnapshot(); + }, + start: (options = {}) => startBenchmark(options), + stop: () => stopBenchmark('manual'), + reset: () => { + resetBenchmark(); + return createBenchmarkSnapshot(); + }, + getSnapshot: () => createBenchmarkSnapshot(), + waitForCompletion: (timeoutMs?: number) => waitForBenchmarkCompletion(timeoutMs), + runOnce: async (options = {}) => { + const { timeoutMs, ...runOptions } = options; + startBenchmark(runOptions); + return waitForBenchmarkCompletion(timeoutMs ?? Math.max(state.durationMs + 15_000, 30_000)); + }, +}; diff --git a/web/examples/playground/live-example-runtime.ts b/web/examples/playground/live-example-runtime.ts index f78ea19f..bd3a5abe 100644 --- a/web/examples/playground/live-example-runtime.ts +++ b/web/examples/playground/live-example-runtime.ts @@ -4,6 +4,7 @@ import * as axroneEcsRuntime from '@axrone/ecs-runtime'; import * as axroneGameLoop from '@axrone/game-loop'; import * as axroneGeometry from '@axrone/geometry'; import * as axroneInput from '@axrone/input'; +import * as axroneMemory from '@axrone/memory'; import * as axroneNumeric from '@axrone/numeric'; import * as axroneParticleSystem from '@axrone/particle-system'; import * as axronePhysics from '@axrone/physics'; @@ -33,6 +34,7 @@ const supportedModules = { '@axrone/game-loop': axroneGameLoop, '@axrone/geometry': axroneGeometry, '@axrone/input': axroneInput, + '@axrone/memory': axroneMemory, '@axrone/numeric': axroneNumeric, '@axrone/particle-system': axroneParticleSystem, '@axrone/physics': axronePhysics, diff --git a/web/examples/playground/source-compat.ts b/web/examples/playground/source-compat.ts index d862bf9b..58b45d8c 100644 --- a/web/examples/playground/source-compat.ts +++ b/web/examples/playground/source-compat.ts @@ -82,11 +82,7 @@ const assetCoreRuntimeExports = new Set([ 'isAssetImporter', ]); -const sceneRuntimeExports = new Set([ - 'Animator', - 'Camera', - 'PrefabNodeBinding', -]); +const sceneRuntimeExports = new Set(['Animator', 'Camera', 'PrefabNodeBinding']); const renderWebgl2RuntimeExports = new Set([ 'FilterMode', @@ -162,6 +158,7 @@ const coreImportMigrationPriority = [ '@axrone/geometry', '@axrone/physics', '@axrone/render-webgl2', + '@axrone/memory', '@axrone/numeric', '@axrone/random', '@axrone/game-loop', @@ -245,9 +242,7 @@ const parseImportSpecifiers = (clause: string): readonly ParsedImportSpecifier[] const renderImportSpecifier = (specifier: ParsedImportSpecifier): string => { const aliasSuffix = - specifier.localName !== specifier.importedName - ? ` as ${specifier.localName}` - : ''; + specifier.localName !== specifier.importedName ? ` as ${specifier.localName}` : ''; return `${specifier.isTypeOnly ? 'type ' : ''}${specifier.importedName}${aliasSuffix}`; }; @@ -335,7 +330,8 @@ const upsertNamedImport = ( const prefix = source.slice(0, insertionIndex); const suffix = source.slice(insertionIndex); const needsLeadingNewline = prefix.length > 0 && !prefix.endsWith('\n'); - const normalizedSuffix = suffix.startsWith('\n') || suffix.length === 0 ? suffix : `\n${suffix}`; + const normalizedSuffix = + suffix.startsWith('\n') || suffix.length === 0 ? suffix : `\n${suffix}`; return { nextSource: `${prefix}${needsLeadingNewline ? '\n' : ''}${nextDeclaration}${normalizedSuffix}`, @@ -354,8 +350,10 @@ export const normalizePlaygroundSource = ( const migrations = buildCoreImportMigrations(supportedModules); const coreSpecifiers = parseImportSpecifiers(coreImportMatch[1]); - const { migratedSpecifiersByModule, remainingCoreSpecifiers } = - partitionCoreImportSpecifiers(coreSpecifiers, migrations); + const { migratedSpecifiersByModule, remainingCoreSpecifiers } = partitionCoreImportSpecifiers( + coreSpecifiers, + migrations + ); if (migratedSpecifiersByModule.length === 0) { return source; @@ -433,9 +431,7 @@ export const validateSupportedModuleImports = ( continue; } - diagnostics.push( - `Module "${moduleName}" does not export "${specifier.importedName}".` - ); + diagnostics.push(`Module "${moduleName}" does not export "${specifier.importedName}".`); } } diff --git a/web/examples/public/fonts/kenpixel.ttf b/web/examples/public/fonts/kenpixel.ttf new file mode 100644 index 00000000..286cc57f Binary files /dev/null and b/web/examples/public/fonts/kenpixel.ttf differ diff --git a/web/examples/scene-2d-animation-masking.ts b/web/examples/scene-2d-animation-masking.ts new file mode 100644 index 00000000..5e602b28 --- /dev/null +++ b/web/examples/scene-2d-animation-masking.ts @@ -0,0 +1,222 @@ +import { Transform } from '@axrone/ecs-runtime'; +import { Vec3 } from '@axrone/numeric'; +import { Color, Scene2D, createSpriteAtlas } from '@axrone/scene-2d'; +import type { ExampleContext, SceneExample } from './example-types'; + +const bindScene2DToContainer = ( + scene: Pick, + container: HTMLElement, + fallbackWidth: number, + fallbackHeight: number +): (() => void) => { + const resize = () => { + const rect = container.getBoundingClientRect(); + scene.resize( + Math.max(1, Math.floor(rect.width || fallbackWidth)), + Math.max(1, Math.floor(rect.height || fallbackHeight)) + ); + }; + + resize(); + + if (typeof ResizeObserver !== 'undefined') { + const observer = new ResizeObserver(() => resize()); + observer.observe(container); + return () => observer.disconnect(); + } + + const handleResize = () => resize(); + globalThis.addEventListener('resize', handleResize); + return () => globalThis.removeEventListener('resize', handleResize); +}; + +const setPixel = ( + data: Uint8Array, + width: number, + x: number, + y: number, + rgba: readonly [number, number, number, number] +): void => { + const offset = (y * width + x) * 4; + data[offset] = rgba[0]; + data[offset + 1] = rgba[1]; + data[offset + 2] = rgba[2]; + data[offset + 3] = rgba[3]; +}; + +const createHeroAtlasTexture = (): number[] => { + const width = 64; + const height = 32; + const data = new Uint8Array(width * height * 4); + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const frameIndex = x < 32 ? 0 : 1; + const localX = x % 32; + const localY = y; + const insideBody = localX >= 6 && localX <= 25 && localY >= 5 && localY <= 27; + const insideFace = localX >= 9 && localX <= 22 && localY >= 8 && localY <= 19; + const leftEye = localX >= (frameIndex === 0 ? 12 : 11) && localX <= (frameIndex === 0 ? 14 : 13) && localY >= 12 && localY <= 14; + const rightEye = localX >= (frameIndex === 0 ? 18 : 19) && localX <= (frameIndex === 0 ? 20 : 21) && localY >= 12 && localY <= 14; + const mouth = localY >= 18 && localY <= 19 && localX >= 12 && localX <= 19; + + let pixel: readonly [number, number, number, number] = [0, 0, 0, 0]; + if (insideBody) { + pixel = frameIndex === 0 ? [255, 148, 78, 255] : [72, 198, 255, 255]; + } + if (insideFace) { + pixel = frameIndex === 0 ? [255, 223, 182, 255] : [214, 241, 255, 255]; + } + if (leftEye || rightEye) { + pixel = [15, 23, 42, 255]; + } + if (mouth) { + pixel = frameIndex === 0 ? [120, 53, 15, 255] : [8, 47, 73, 255]; + } + + setPixel(data, width, x, y, pixel); + } + } + + return Array.from(data); +}; + +const scene2DAnimationMaskingExample: SceneExample = { + id: 'scene-2d-animation-masking', + title: 'Scene 2D Animation + Masking', + description: + 'Uses the new atlas animator and inherited sprite masks to stage moving 2D character lanes with shared batching.', + tags: ['scene-2d', 'animation', 'masking', 'sprite'], + order: 6, + async mount({ container }: ExampleContext) { + container.replaceChildren(); + + const scene = new Scene2D({ + width: container.clientWidth || 960, + height: container.clientHeight || 540, + autoStart: true, + parent: container, + appendToDom: true, + createCanvas: () => document.createElement('canvas'), + }); + + const cleanupResize = bindScene2DToContainer(scene, container, 960, 540); + + await scene.registerTexture({ + id: 'scene2d.hero-atlas', + source: { + kind: 'data', + width: 64, + height: 32, + channels: 4, + data: createHeroAtlasTexture(), + }, + generateMipmaps: false, + }); + + const atlas = createSpriteAtlas({ + id: 'scene2d.hero', + textureId: 'scene2d.hero-atlas', + textureSize: { width: 64, height: 32 }, + frames: [ + { + id: 'hero/idle-0', + region: { x: 0, y: 0, width: 32, height: 32 }, + sourceSize: { width: 1.6, height: 1.6 }, + }, + { + id: 'hero/idle-1', + region: { x: 32, y: 0, width: 32, height: 32 }, + sourceSize: { width: 1.6, height: 1.6 }, + }, + ], + animations: [ + { + id: 'idle', + frames: [ + { frameId: 'hero/idle-0', durationMs: 120 }, + { frameId: 'hero/idle-1', durationMs: 120 }, + ], + }, + ], + }); + + const camera = scene.createCameraActor( + { name: 'Scene2DCamera' }, + { + primary: true, + orthographic: true, + orthographicSize: 5.5, + clearColor: [0.05, 0.07, 0.11, 1], + } + ); + + const leftMask = scene.createMaskActor( + { name: 'LeftLaneMask' }, + { size: [4.1, 4.6], shape: 'rounded-rect', cornerRadius: 0.48 } + ); + leftMask.getComponent(Transform)!.position = new Vec3(-2.6, 0, 0); + const rightMask = scene.createMaskActor( + { name: 'RightLaneMask' }, + { size: [4.1, 4.6], shape: 'circle' } + ); + rightMask.getComponent(Transform)!.position = new Vec3(2.6, 0, 0); + + const animatedTransforms: Transform[] = []; + const laneDefinitions = [ + { parent: leftMask, color: Color.fromHex('#ff9d4dff'), phase: 0 }, + { parent: rightMask, color: Color.fromHex('#7dd3fcff'), phase: Math.PI * 0.5 }, + ] as const; + + for (const lane of laneDefinitions) { + for (let index = 0; index < 5; index += 1) { + const actor = scene.createAnimatedSpriteActor( + { name: `${lane.parent.name}-sprite-${index}` }, + { + color: lane.color, + }, + { + atlas, + clipId: 'idle', + speed: 0.9 + index * 0.12, + } + ); + + actor.setParent(lane.parent); + const transform = actor.getComponent(Transform)!; + transform.position = new Vec3(-1.8 + index * 0.9, 1.4 - index * 0.7, 0); + animatedTransforms.push(transform); + } + } + + let frameHandle = 0; + const startTime = performance.now(); + const animate = () => { + const elapsed = (performance.now() - startTime) / 1000; + for (let index = 0; index < animatedTransforms.length; index += 1) { + const transform = animatedTransforms[index]!; + const lanePhase = index < 5 ? 0 : Math.PI * 0.5; + const localIndex = index % 5; + transform.position = new Vec3( + Math.sin(elapsed * 1.5 + lanePhase + localIndex * 0.45) * 1.7, + 1.4 - localIndex * 0.7, + 0 + ); + } + + frameHandle = globalThis.requestAnimationFrame(animate); + }; + + frameHandle = globalThis.requestAnimationFrame(animate); + + return { + dispose() { + globalThis.cancelAnimationFrame(frameHandle); + cleanupResize(); + scene.dispose(); + }, + }; + }, +}; + +export default scene2DAnimationMaskingExample; \ No newline at end of file diff --git a/web/examples/scene-2d-nine-slice.ts b/web/examples/scene-2d-nine-slice.ts new file mode 100644 index 00000000..ff4543e6 --- /dev/null +++ b/web/examples/scene-2d-nine-slice.ts @@ -0,0 +1,200 @@ +import { Transform } from '@axrone/ecs-runtime'; +import { Vec3 } from '@axrone/numeric'; +import { Color, Scene2D, SpriteRenderer, createSpriteAtlas } from '@axrone/scene-2d'; +import type { ExampleContext, SceneExample } from './example-types'; + +const bindScene2DToContainer = ( + scene: Pick, + container: HTMLElement, + fallbackWidth: number, + fallbackHeight: number +): (() => void) => { + const resize = () => { + const rect = container.getBoundingClientRect(); + scene.resize( + Math.max(1, Math.floor(rect.width || fallbackWidth)), + Math.max(1, Math.floor(rect.height || fallbackHeight)) + ); + }; + + resize(); + + if (typeof ResizeObserver !== 'undefined') { + const observer = new ResizeObserver(() => resize()); + observer.observe(container); + return () => observer.disconnect(); + } + + const handleResize = () => resize(); + globalThis.addEventListener('resize', handleResize); + return () => globalThis.removeEventListener('resize', handleResize); +}; + +const setPixel = ( + data: Uint8Array, + width: number, + x: number, + y: number, + rgba: readonly [number, number, number, number] +): void => { + const offset = (y * width + x) * 4; + data[offset] = rgba[0]; + data[offset + 1] = rgba[1]; + data[offset + 2] = rgba[2]; + data[offset + 3] = rgba[3]; +}; + +const createNineSliceAtlasTexture = (): number[] => { + const width = 36; + const height = 18; + const data = new Uint8Array(width * height * 4); + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const frameOffset = x < 18 ? 0 : 18; + const localX = x - frameOffset; + const border = localX < 3 || localX >= 15 || y < 3 || y >= 15; + const corner = (localX < 3 || localX >= 15) && (y < 3 || y >= 15); + const highlight = !border && ((localX + y) % 5 === 0 || Math.abs(localX - y) <= 1); + const warm = frameOffset === 0; + + let pixel: readonly [number, number, number, number]; + if (corner) { + pixel = warm ? [255, 249, 214, 255] : [230, 246, 255, 255]; + } else if (border) { + pixel = warm ? [255, 181, 96, 255] : [88, 170, 255, 255]; + } else if (highlight) { + pixel = warm ? [255, 206, 144, 235] : [152, 214, 255, 235]; + } else { + pixel = warm ? [242, 140, 84, 224] : [48, 108, 224, 224]; + } + + setPixel(data, width, x, y, pixel); + } + } + + return Array.from(data); +}; + +const scene2DNineSliceExample: SceneExample = { + id: 'scene-2d-nine-slice', + title: 'Scene 2D Nine Slice Panels', + description: + 'Demonstrates slice-border metadata flowing from atlas frames into live nine-slice panel scaling in the new 2D renderer.', + tags: ['scene-2d', 'nine-slice', 'ui-panel', 'sprite'], + order: 7, + async mount({ container }: ExampleContext) { + container.replaceChildren(); + + const scene = new Scene2D({ + width: container.clientWidth || 960, + height: container.clientHeight || 540, + autoStart: true, + parent: container, + appendToDom: true, + createCanvas: () => document.createElement('canvas'), + }); + + const cleanupResize = bindScene2DToContainer(scene, container, 960, 540); + + await scene.registerTexture({ + id: 'scene2d.panel-atlas', + source: { + kind: 'data', + width: 36, + height: 18, + channels: 4, + data: createNineSliceAtlasTexture(), + }, + generateMipmaps: false, + }); + + const atlas = createSpriteAtlas({ + id: 'scene2d.panel', + textureId: 'scene2d.panel-atlas', + textureSize: { width: 36, height: 18 }, + frames: [ + { + id: 'panel/warm', + region: { x: 0, y: 0, width: 18, height: 18 }, + sourceSize: { width: 18, height: 18 }, + sliceBorder: { left: 3, right: 3, top: 3, bottom: 3 }, + }, + { + id: 'panel/cool', + region: { x: 18, y: 0, width: 18, height: 18 }, + sourceSize: { width: 18, height: 18 }, + sliceBorder: { left: 3, right: 3, top: 3, bottom: 3 }, + }, + ], + }); + + const camera = scene.createCameraActor( + { name: 'Scene2DCamera' }, + { + primary: true, + orthographic: true, + orthographicSize: 5.5, + clearColor: [0.07, 0.09, 0.14, 1], + } + ); + + const warmPanel = scene.createSpriteActor( + { name: 'WarmPanel' }, + { + frame: atlas.getFrame('panel/warm')!, + size: [4.2, 2.8], + color: Color.fromHex('#fff3d4ff'), + } + ); + warmPanel.getComponent(Transform)!.position = new Vec3(-3.2, 0.8, 0); + + const coolPanel = scene.createSpriteActor( + { name: 'CoolPanel' }, + { + frame: atlas.getFrame('panel/cool')!, + size: [5.6, 3.4], + color: Color.fromHex('#e4f4ffff'), + } + ); + coolPanel.getComponent(Transform)!.position = new Vec3(0, -0.1, 0); + + const accentPanel = scene.createSpriteActor( + { name: 'AccentPanel' }, + { + frame: atlas.getFrame('panel/warm')!, + size: [3.4, 2.2], + color: Color.fromHex('#ffd9e7ff'), + } + ); + accentPanel.getComponent(Transform)!.position = new Vec3(3.2, 1.1, 0); + + const panels = [ + warmPanel.getComponent(SpriteRenderer)!, + coolPanel.getComponent(SpriteRenderer)!, + accentPanel.getComponent(SpriteRenderer)!, + ]; + + let frameHandle = 0; + const startTime = performance.now(); + const animate = () => { + const elapsed = (performance.now() - startTime) / 1000; + panels[0]!.setSize(4.2 + Math.sin(elapsed * 1.2) * 1.8, 2.8 + Math.cos(elapsed * 1.6) * 0.5); + panels[1]!.setSize(5.6 + Math.cos(elapsed * 0.9) * 2.2, 3.4 + Math.sin(elapsed * 1.4) * 0.9); + panels[2]!.setSize(3.4 + Math.sin(elapsed * 1.8) * 1.1, 2.2 + Math.cos(elapsed * 1.3) * 0.4); + frameHandle = globalThis.requestAnimationFrame(animate); + }; + + frameHandle = globalThis.requestAnimationFrame(animate); + + return { + dispose() { + globalThis.cancelAnimationFrame(frameHandle); + cleanupResize(); + scene.dispose(); + }, + }; + }, +}; + +export default scene2DNineSliceExample; \ No newline at end of file diff --git a/web/examples/scene-local-glb-viewer.ts b/web/examples/scene-local-glb-viewer.ts new file mode 100644 index 00000000..9ac205d7 --- /dev/null +++ b/web/examples/scene-local-glb-viewer.ts @@ -0,0 +1,1179 @@ +import { AssetDatabase, type AssetImporter } from '@axrone/asset-core'; +import { Transform } from '@axrone/ecs-runtime'; +import { Vec3 } from '@axrone/numeric'; +import { + createGltfImporter, + type GltfAssetSchemaLike, +} from '@axrone/asset-gltf'; +import { + Animator, + Camera, + DirectionalLight, + MeshRenderer, + OrbitCameraController, + Scene, +} from '@axrone/scene-3d'; +import { + loadGltfSceneIntoScene, + type LoadGltfSceneIntoSceneResult, +} from '@axrone/scene-runtime-gltf'; +import { bindSceneToContainer } from './example-runtime'; +import type { ExampleContext, SceneExample } from './example-types'; +import { DRACO_DECODER_WASM_URL } from './ui/example-helpers'; + +const LOCAL_GLTF_GROUND_SHADER_ID = 'examples/local-glb-viewer-ground'; +const LOCAL_GLTF_GROUND_MATERIAL_ID = 'local-glb-viewer.ground-material'; +const LOCAL_GLTF_GROUND_MESH_ID = 'local-glb-viewer.ground-mesh'; +const LOCAL_GLTF_WHITE_TEXTURE_ID = 'local-glb-viewer.white-texture'; + +type SceneActor = ReturnType; + +interface SceneBounds { + readonly min: Vec3; + readonly max: Vec3; + readonly center: Vec3; + readonly size: Vec3; +} + +interface AnimatorRuntime { + readonly animator: Animator; + readonly clipIds: ReadonlySet; +} + +interface ClipEntry { + readonly id: string; + readonly duration: number | null; + readonly animatorCount: number; +} + +interface ViewerStage { + readonly scene: Scene; + readonly database: AssetDatabase; + readonly orbit: OrbitCameraController; + dispose(): void; +} + +interface LoadedViewerState { + readonly load: LoadGltfSceneIntoSceneResult; + readonly animators: readonly AnimatorRuntime[]; + readonly clips: readonly ClipEntry[]; +} + +interface ViewerPanelHandle { + readonly fileInput: HTMLInputElement; + readonly dropZone: HTMLElement; + readonly chooseButton: HTMLButtonElement; + readonly playButton: HTMLButtonElement; + readonly pauseButton: HTMLButtonElement; + readonly stopButton: HTMLButtonElement; + readonly speedInput: HTMLInputElement; + setBusy(value: boolean): void; + setStatus(next: string, tone?: 'neutral' | 'success' | 'error'): void; + setSummary(next: string): void; + setFileLabel(next: string): void; + setSelectedClip(next: string | null): void; + setSpeed(next: number): void; + setClipEntries( + clips: readonly ClipEntry[], + selectedClipId: string | null, + onSelect: (clipId: string) => void + ): void; + dispose(): void; +} + +const syncButtonDisabledState = (button: HTMLButtonElement): void => { + const disabled = button.disabled; + button.style.opacity = disabled ? '0.55' : '1'; + button.style.cursor = disabled ? 'not-allowed' : 'pointer'; +}; + +const normalizeWheelDelta = (event: WheelEvent): number => { + switch (event.deltaMode) { + case WheelEvent.DOM_DELTA_LINE: + return event.deltaY * 16; + case WheelEvent.DOM_DELTA_PAGE: + return event.deltaY * 96; + default: + return event.deltaY; + } +}; + +const createPanelButton = (label: string): HTMLButtonElement => { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = label; + Object.assign(button.style, { + appearance: 'none', + border: '1px solid rgba(148, 163, 184, 0.25)', + background: 'rgba(15, 23, 42, 0.72)', + color: '#e2e8f0', + borderRadius: '10px', + padding: '10px 12px', + fontSize: '12px', + fontWeight: '600', + letterSpacing: '0.02em', + cursor: 'pointer', + transition: 'background 120ms ease, border-color 120ms ease, opacity 120ms ease', + } satisfies Partial); + + button.addEventListener('mouseenter', () => { + if (!button.disabled) { + button.style.background = 'rgba(30, 41, 59, 0.94)'; + button.style.borderColor = 'rgba(125, 211, 252, 0.45)'; + } + }); + + button.addEventListener('mouseleave', () => { + button.style.background = 'rgba(15, 23, 42, 0.72)'; + button.style.borderColor = 'rgba(148, 163, 184, 0.25)'; + }); + + syncButtonDisabledState(button); + + return button; +}; + +const createViewerPanel = (shell: HTMLElement): ViewerPanelHandle => { + const panel = document.createElement('section'); + Object.assign(panel.style, { + position: 'absolute', + top: '24px', + left: '24px', + width: '360px', + maxHeight: 'calc(100% - 48px)', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + gap: '14px', + padding: '18px', + borderRadius: '20px', + border: '1px solid rgba(56, 189, 248, 0.25)', + background: 'rgba(2, 6, 23, 0.8)', + backdropFilter: 'blur(14px)', + color: '#e2e8f0', + fontFamily: 'Consolas, "SFMono-Regular", ui-monospace, monospace', + boxShadow: '0 22px 60px rgba(15, 23, 42, 0.38)', + pointerEvents: 'auto', + zIndex: '1', + } satisfies Partial); + + const eyebrow = document.createElement('div'); + eyebrow.textContent = 'LOCAL GLB VIEWER'; + Object.assign(eyebrow.style, { + fontSize: '11px', + fontWeight: '700', + letterSpacing: '0.16em', + color: '#7dd3fc', + } satisfies Partial); + + const title = document.createElement('div'); + title.textContent = 'Upload a local animated model'; + Object.assign(title.style, { + fontSize: '20px', + lineHeight: '1.2', + fontWeight: '700', + color: '#f8fafc', + } satisfies Partial); + + const subtitle = document.createElement('div'); + subtitle.textContent = 'The scene recenters your GLB on a plane, neutralizes imported materials, and lists animation clips for playback.'; + Object.assign(subtitle.style, { + fontSize: '12px', + lineHeight: '1.55', + color: '#cbd5e1', + } satisfies Partial); + + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = '.glb,model/gltf-binary'; + fileInput.style.display = 'none'; + + const chooseButton = createPanelButton('Choose GLB'); + Object.assign(chooseButton.style, { + background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.96), rgba(37, 99, 235, 0.96))', + border: 'none', + color: '#eff6ff', + boxShadow: '0 12px 26px rgba(14, 165, 233, 0.28)', + } satisfies Partial); + + const dropZone = document.createElement('label'); + Object.assign(dropZone.style, { + display: 'flex', + flexDirection: 'column', + gap: '12px', + alignItems: 'stretch', + padding: '16px', + borderRadius: '16px', + border: '1px dashed rgba(125, 211, 252, 0.35)', + background: 'rgba(15, 23, 42, 0.54)', + cursor: 'pointer', + transition: 'border-color 120ms ease, background 120ms ease', + } satisfies Partial); + + const dropText = document.createElement('div'); + dropText.textContent = 'Drop a .glb file here or browse from disk.'; + Object.assign(dropText.style, { + fontSize: '12px', + lineHeight: '1.5', + color: '#e2e8f0', + } satisfies Partial); + + const fileLabel = document.createElement('div'); + fileLabel.textContent = 'No file loaded yet.'; + Object.assign(fileLabel.style, { + fontSize: '11px', + color: '#94a3b8', + } satisfies Partial); + + dropZone.appendChild(chooseButton); + dropZone.appendChild(dropText); + dropZone.appendChild(fileLabel); + dropZone.appendChild(fileInput); + + const status = document.createElement('div'); + status.textContent = 'Ready. Load a local GLB to inspect clips and preview animation.'; + Object.assign(status.style, { + fontSize: '12px', + lineHeight: '1.55', + color: '#cbd5e1', + whiteSpace: 'pre-wrap', + } satisfies Partial); + + const summary = document.createElement('div'); + summary.textContent = 'Scene is waiting for a model.'; + Object.assign(summary.style, { + fontSize: '11px', + color: '#7dd3fc', + lineHeight: '1.45', + } satisfies Partial); + + const clipHeader = document.createElement('div'); + clipHeader.textContent = 'Animation Clips'; + Object.assign(clipHeader.style, { + fontSize: '11px', + fontWeight: '700', + letterSpacing: '0.12em', + textTransform: 'uppercase', + color: '#94a3b8', + } satisfies Partial); + + const selectedClip = document.createElement('div'); + selectedClip.textContent = 'Selected clip: none'; + Object.assign(selectedClip.style, { + fontSize: '12px', + lineHeight: '1.45', + color: '#f8fafc', + } satisfies Partial); + + const clipList = document.createElement('div'); + Object.assign(clipList.style, { + display: 'flex', + flexDirection: 'column', + gap: '8px', + maxHeight: '240px', + overflowY: 'auto', + paddingRight: '4px', + } satisfies Partial); + + const controlsRow = document.createElement('div'); + Object.assign(controlsRow.style, { + display: 'grid', + gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', + gap: '8px', + } satisfies Partial); + + const playButton = createPanelButton('Play'); + const pauseButton = createPanelButton('Pause'); + const stopButton = createPanelButton('Stop'); + controlsRow.appendChild(playButton); + controlsRow.appendChild(pauseButton); + controlsRow.appendChild(stopButton); + + const speedRow = document.createElement('div'); + Object.assign(speedRow.style, { + display: 'flex', + flexDirection: 'column', + gap: '8px', + } satisfies Partial); + + const speedHeader = document.createElement('div'); + Object.assign(speedHeader.style, { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '12px', + } satisfies Partial); + + const speedLabel = document.createElement('span'); + speedLabel.textContent = 'Playback Speed'; + Object.assign(speedLabel.style, { + fontSize: '11px', + fontWeight: '700', + letterSpacing: '0.12em', + textTransform: 'uppercase', + color: '#94a3b8', + } satisfies Partial); + + const speedValue = document.createElement('span'); + speedValue.textContent = '1.00x'; + Object.assign(speedValue.style, { + fontSize: '12px', + color: '#e2e8f0', + } satisfies Partial); + + speedHeader.appendChild(speedLabel); + speedHeader.appendChild(speedValue); + + const speedInput = document.createElement('input'); + speedInput.type = 'range'; + speedInput.min = '0'; + speedInput.max = '2.5'; + speedInput.step = '0.05'; + speedInput.value = '1'; + Object.assign(speedInput.style, { + width: '100%', + accentColor: '#38bdf8', + } satisfies Partial); + + speedRow.appendChild(speedHeader); + speedRow.appendChild(speedInput); + + panel.appendChild(eyebrow); + panel.appendChild(title); + panel.appendChild(subtitle); + panel.appendChild(dropZone); + panel.appendChild(status); + panel.appendChild(summary); + panel.appendChild(clipHeader); + panel.appendChild(selectedClip); + panel.appendChild(clipList); + panel.appendChild(controlsRow); + panel.appendChild(speedRow); + + shell.appendChild(panel); + + const placeholder = document.createElement('div'); + placeholder.textContent = 'No clips discovered in the current file.'; + Object.assign(placeholder.style, { + fontSize: '12px', + lineHeight: '1.5', + color: '#64748b', + padding: '12px 4px 4px 2px', + } satisfies Partial); + clipList.appendChild(placeholder); + + chooseButton.addEventListener('click', (event) => { + event.preventDefault(); + if (!chooseButton.disabled) { + fileInput.click(); + } + }); + + return { + fileInput, + dropZone, + chooseButton, + playButton, + pauseButton, + stopButton, + speedInput, + setBusy(value: boolean) { + chooseButton.disabled = value; + playButton.disabled = value; + pauseButton.disabled = value; + stopButton.disabled = value; + speedInput.disabled = value; + syncButtonDisabledState(chooseButton); + syncButtonDisabledState(playButton); + syncButtonDisabledState(pauseButton); + syncButtonDisabledState(stopButton); + dropZone.style.opacity = value ? '0.82' : '1'; + }, + setStatus(next: string, tone: 'neutral' | 'success' | 'error' = 'neutral') { + status.textContent = next; + status.style.color = + tone === 'error' + ? '#fca5a5' + : tone === 'success' + ? '#bbf7d0' + : '#cbd5e1'; + }, + setSummary(next: string) { + summary.textContent = next; + }, + setFileLabel(next: string) { + fileLabel.textContent = next; + }, + setSelectedClip(next: string | null) { + selectedClip.textContent = `Selected clip: ${next ?? 'none'}`; + }, + setSpeed(next: number) { + speedInput.value = String(next); + speedValue.textContent = `${next.toFixed(2)}x`; + }, + setClipEntries(clips, selectedClipId, onSelect) { + clipList.replaceChildren(); + + if (clips.length === 0) { + clipList.appendChild(placeholder); + return; + } + + for (const clip of clips) { + const button = document.createElement('button'); + button.type = 'button'; + Object.assign(button.style, { + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) auto auto', + alignItems: 'center', + gap: '10px', + padding: '11px 12px', + borderRadius: '12px', + border: clip.id === selectedClipId + ? '1px solid rgba(56, 189, 248, 0.65)' + : '1px solid rgba(148, 163, 184, 0.18)', + background: clip.id === selectedClipId + ? 'rgba(8, 47, 73, 0.86)' + : 'rgba(15, 23, 42, 0.46)', + color: '#e2e8f0', + cursor: 'pointer', + fontFamily: 'inherit', + textAlign: 'left', + } satisfies Partial); + + const name = document.createElement('span'); + name.textContent = clip.id; + Object.assign(name.style, { + fontSize: '12px', + fontWeight: '600', + color: '#f8fafc', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + } satisfies Partial); + + const duration = document.createElement('span'); + duration.textContent = + clip.duration === null ? '--' : `${clip.duration.toFixed(2)}s`; + Object.assign(duration.style, { + fontSize: '11px', + color: '#7dd3fc', + } satisfies Partial); + + const count = document.createElement('span'); + count.textContent = `${clip.animatorCount}x`; + Object.assign(count.style, { + fontSize: '11px', + color: '#94a3b8', + } satisfies Partial); + + button.appendChild(name); + button.appendChild(duration); + button.appendChild(count); + button.addEventListener('click', () => onSelect(clip.id)); + clipList.appendChild(button); + } + }, + dispose() { + panel.remove(); + }, + }; +}; + +const extractAnimatorClipEntries = (animator: Animator): readonly { id: string; duration: number | null }[] => { + const serialized = animator.serialize() as { + clips?: readonly { id?: unknown; duration?: unknown }[]; + }; + + return (serialized.clips ?? []) + .map((clip) => ({ + id: typeof clip.id === 'string' ? clip.id : null, + duration: + typeof clip.duration === 'number' && Number.isFinite(clip.duration) + ? clip.duration + : null, + })) + .filter((clip): clip is { id: string; duration: number | null } => clip.id !== null); +}; + +const collectAnimatorRuntimes = ( + actors: readonly SceneActor[] +): readonly AnimatorRuntime[] => + actors + .map((actor) => actor.getComponent(Animator)) + .filter((animator): animator is Animator => animator !== undefined) + .map((animator) => { + const clipIds = new Set(extractAnimatorClipEntries(animator).map((clip) => clip.id)); + return { + animator, + clipIds, + } satisfies AnimatorRuntime; + }); + +const collectClipEntries = ( + animators: readonly AnimatorRuntime[] +): readonly ClipEntry[] => { + const entries = new Map(); + + for (const runtime of animators) { + for (const clip of extractAnimatorClipEntries(runtime.animator)) { + const existing = entries.get(clip.id); + if (existing) { + entries.set(clip.id, { + id: clip.id, + duration: existing.duration ?? clip.duration, + animatorCount: existing.animatorCount + 1, + }); + continue; + } + + entries.set(clip.id, { + id: clip.id, + duration: clip.duration, + animatorCount: 1, + }); + } + } + + return [...entries.values()].sort((left, right) => left.id.localeCompare(right.id)); +}; + +const computeActorsBounds = ( + actors: readonly SceneActor[], + database: AssetDatabase +): SceneBounds | null => { + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let minZ = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + let maxZ = Number.NEGATIVE_INFINITY; + let found = false; + + for (const actor of actors) { + const renderer = actor.getComponent(MeshRenderer); + const transform = actor.getComponent(Transform); + if (!renderer?.meshId || !transform) { + continue; + } + + const meshAsset = database.get({ key: renderer.meshId, kind: 'gltf.mesh' }); + const meshBounds = meshAsset?.data.bounds; + if (!meshBounds) { + continue; + } + + const corners = [ + new Vec3(meshBounds.min[0], meshBounds.min[1], meshBounds.min[2]), + new Vec3(meshBounds.min[0], meshBounds.min[1], meshBounds.max[2]), + new Vec3(meshBounds.min[0], meshBounds.max[1], meshBounds.min[2]), + new Vec3(meshBounds.min[0], meshBounds.max[1], meshBounds.max[2]), + new Vec3(meshBounds.max[0], meshBounds.min[1], meshBounds.min[2]), + new Vec3(meshBounds.max[0], meshBounds.min[1], meshBounds.max[2]), + new Vec3(meshBounds.max[0], meshBounds.max[1], meshBounds.min[2]), + new Vec3(meshBounds.max[0], meshBounds.max[1], meshBounds.max[2]), + ]; + + for (const corner of corners) { + const worldCorner = transform.worldMatrix.transformVec3(corner, new Vec3()); + minX = Math.min(minX, worldCorner.x); + minY = Math.min(minY, worldCorner.y); + minZ = Math.min(minZ, worldCorner.z); + maxX = Math.max(maxX, worldCorner.x); + maxY = Math.max(maxY, worldCorner.y); + maxZ = Math.max(maxZ, worldCorner.z); + } + + found = true; + } + + if (!found) { + return null; + } + + return { + min: new Vec3(minX, minY, minZ), + max: new Vec3(maxX, maxY, maxZ), + center: new Vec3((minX + maxX) * 0.5, (minY + maxY) * 0.5, (minZ + maxZ) * 0.5), + size: new Vec3(maxX - minX, maxY - minY, maxZ - minZ), + }; +}; + +const collectImportedRootTransforms = (actors: readonly SceneActor[]): readonly Transform[] => { + const roots: Transform[] = []; + + for (const actor of actors) { + const transform = actor.getComponent(Transform); + if (!transform || transform.parent) { + continue; + } + + roots.push(transform); + } + + return roots; +}; + +const createImportedModelContainer = ( + scene: Scene, + actors: readonly SceneActor[] +): SceneActor => { + const container = scene.createActor({ name: 'UploadedModelRoot' }); + const containerTransform = container.requireComponent(Transform); + + for (const rootTransform of collectImportedRootTransforms(actors)) { + rootTransform.parent = containerTransform; + } + + return container; +}; + +const fitImportedModelToViewer = ( + actors: readonly SceneActor[], + container: SceneActor, + database: AssetDatabase +): SceneBounds | null => { + const containerTransform = container.requireComponent(Transform); + const initialBounds = computeActorsBounds(actors, database); + + if (!initialBounds) { + containerTransform.position = Vec3.ZERO.clone(); + return null; + } + + if (initialBounds.size.y > 1e-5) { + const scale = 3.2 / initialBounds.size.y; + containerTransform.scale = new Vec3(scale, scale, scale); + } + + const scaledBounds = computeActorsBounds(actors, database); + if (!scaledBounds) { + return null; + } + + containerTransform.position = new Vec3( + -scaledBounds.center.x, + -scaledBounds.min.y, + -scaledBounds.center.z + ); + + return computeActorsBounds(actors, database); +}; + +const registerGroundAssets = (scene: Scene): void => { + scene.registerShader({ + id: LOCAL_GLTF_GROUND_SHADER_ID, + cull: false, + vertexSource: `#version 300 es +layout(location = 0) in vec3 a_Position; +layout(location = 1) in vec3 a_Normal; +layout(location = 2) in vec2 a_UV0; +uniform mat4 u_Model; +uniform mat4 u_View; +uniform mat4 u_Projection; +out vec2 v_UV0; +out vec3 v_WorldNormal; +void main() { + v_UV0 = a_UV0; + v_WorldNormal = normalize(mat3(u_Model) * a_Normal); + gl_Position = u_Projection * u_View * u_Model * vec4(a_Position, 1.0); +}`, + fragmentSource: `#version 300 es +precision highp float; +uniform vec3 u_LightDirection; +uniform vec3 u_BaseColor; +uniform vec3 u_LineColor; +uniform vec3 u_FadeColor; +in vec2 v_UV0; +in vec3 v_WorldNormal; +out vec4 o_Color; + +void main() { + vec2 gridUv = v_UV0 * 18.0; + vec2 cell = abs(fract(gridUv - 0.5) - 0.5) / fwidth(gridUv); + float line = 1.0 - min(min(cell.x, cell.y), 1.0); + float radial = clamp(length(v_UV0 - 0.5) * 1.45, 0.0, 1.0); + float diffuse = max(dot(normalize(v_WorldNormal), normalize(-u_LightDirection)), 0.0); + vec3 base = mix(mix(u_BaseColor, u_LineColor, line * 0.55), u_FadeColor, radial * 0.45); + vec3 lit = base * (0.42 + diffuse * 0.58); + o_Color = vec4(lit, 1.0); +}`, + uniforms: [ + 'u_Model', + 'u_View', + 'u_Projection', + 'u_LightDirection', + 'u_BaseColor', + 'u_LineColor', + 'u_FadeColor', + ], + }); + + scene.createPlaneMesh(LOCAL_GLTF_GROUND_MESH_ID, 36, 36); + scene.createMaterial({ + id: LOCAL_GLTF_GROUND_MATERIAL_ID, + shaderId: LOCAL_GLTF_GROUND_SHADER_ID, + uniforms: { + u_LightDirection: [-0.45, -0.9, -0.24], + u_BaseColor: [0.11, 0.13, 0.16], + u_LineColor: [0.76, 0.83, 0.92], + u_FadeColor: [0.04, 0.06, 0.08], + }, + }); +}; + +const createGround = (scene: Scene): void => { + registerGroundAssets(scene); + const ground = scene.createRenderableActor( + { name: 'GroundPlane' }, + { + meshId: LOCAL_GLTF_GROUND_MESH_ID, + materialId: LOCAL_GLTF_GROUND_MATERIAL_ID, + } + ); + ground.requireComponent(Transform).position = new Vec3(0, -0.01, 0); +}; + +const createLighting = (scene: Scene): void => { + const keyLight = scene.createActor({ name: 'ViewerKeyLight' }); + keyLight.addComponent(DirectionalLight, { + color: [1, 0.97, 0.93], + intensity: 1.35, + primary: true, + }); + keyLight.requireComponent(Transform).position = new Vec3(8, 10, 6); +}; + +const registerViewerTextures = async (scene: Scene): Promise => { + await scene.registerTexture({ + id: LOCAL_GLTF_WHITE_TEXTURE_ID, + source: { + kind: 'data', + width: 1, + height: 1, + channels: 4, + data: [255, 255, 255, 255], + }, + generateMipmaps: false, + }); +}; + +const createViewerStage = async (sceneHost: HTMLElement): Promise => { + sceneHost.replaceChildren(); + + const viewportWidth = sceneHost.clientWidth || 960; + const viewportHeight = sceneHost.clientHeight || 540; + const scene = new Scene({ + width: viewportWidth, + height: viewportHeight, + autoStart: true, + parent: sceneHost, + appendToDom: true, + createCanvas: () => document.createElement('canvas'), + clearColor: [0.02, 0.03, 0.05, 1], + ambientLight: [0.24, 0.24, 0.26], + }); + const cleanupResize = bindSceneToContainer( + scene, + sceneHost, + viewportWidth, + viewportHeight + ); + + const database = new AssetDatabase({ + importers: [ + createGltfImporter({ + dracoDecoder: { + wasmUrl: DRACO_DECODER_WASM_URL, + }, + }) as AssetImporter, + ], + }); + + createGround(scene); + createLighting(scene); + await registerViewerTextures(scene); + + const camera = scene.createCameraActor( + { name: 'ViewerCamera' }, + { primary: true, fieldOfView: 46, near: 0.1, far: 1000 } + ); + const orbit = camera.addComponent(OrbitCameraController, { + target: [0, 1.1, 0], + distance: 6.8, + minDistance: 1.4, + maxDistance: 512, + azimuth: 0.52, + elevation: 0.22, + autoRotateSpeed: 0.24, + }); + + return { + scene, + database, + orbit, + dispose() { + cleanupResize(); + database.dispose(); + scene.dispose(); + sceneHost.replaceChildren(); + }, + }; +}; + +const neutralizeImportedMaterials = ( + scene: Scene, + load: LoadGltfSceneIntoSceneResult +): void => { + for (const materialKey of load.prefab.data.materialKeys) { + scene.setMaterialUniform(materialKey, '_BaseColorFactor', [0.84, 0.84, 0.86, 1]); + scene.setMaterialUniform(materialKey, '_MetallicFactor', 0.04); + scene.setMaterialUniform(materialKey, '_RoughnessFactor', 0.94); + scene.setMaterialUniform(materialKey, '_EmissiveFactor', [0, 0, 0]); + + const material = scene.getMaterial(materialKey); + if (material?.textureBindings.includes('_BaseColorTexture')) { + scene.setMaterialTexture(materialKey, '_BaseColorTexture', LOCAL_GLTF_WHITE_TEXTURE_ID); + } + } +}; + +const frameImportedScene = ( + stage: ViewerStage, + load: LoadGltfSceneIntoSceneResult +): SceneBounds | null => { + const container = createImportedModelContainer(stage.scene, load.actors as readonly SceneActor[]); + const fittedBounds = fitImportedModelToViewer( + load.actors as readonly SceneActor[], + container, + stage.database + ); + + if (!fittedBounds) { + stage.orbit.target = [0, 1.1, 0]; + stage.orbit.distance = 6.8; + return null; + } + + const target = new Vec3( + 0, + fittedBounds.center.y + Math.max(0.2, fittedBounds.size.y * 0.08), + 0 + ); + const radius = Math.max( + 0.85, + Math.hypot(fittedBounds.size.x, fittedBounds.size.y, fittedBounds.size.z) * 0.5 + ); + + stage.orbit.target = [target.x, target.y, target.z]; + stage.orbit.distance = Math.max(3.2, radius * 2.35); + stage.orbit.azimuth = 0.56; + stage.orbit.elevation = 0.2; + + return fittedBounds; +}; + +const loadFileIntoStage = async ( + stage: ViewerStage, + file: File +): Promise => { + const bytes = new Uint8Array(await file.arrayBuffer()); + const receipt = await stage.database.import({ + kind: 'bytes', + data: bytes, + uri: file.name, + mimeType: file.type || 'model/gltf-binary', + }); + + const load = await loadGltfSceneIntoScene( + stage.scene, + stage.database, + { key: receipt.primary.key, kind: 'gltf.document' }, + { clearExisting: false, namePrefix: 'Upload ' } + ); + + for (const actor of load.actors) { + const importedCamera = actor.getComponent(Camera); + if (importedCamera) { + importedCamera.primary = false; + } + } + + neutralizeImportedMaterials(stage.scene, load); + frameImportedScene(stage, load); + + const animators = collectAnimatorRuntimes(load.actors as readonly SceneActor[]); + const clips = collectClipEntries(animators); + + return { + load, + animators, + clips, + }; +}; + +const applyClipSelection = ( + animators: readonly AnimatorRuntime[], + clipId: string, + speed: number, + autoplay: boolean +): void => { + for (const runtime of animators) { + if (!runtime.clipIds.has(clipId)) { + continue; + } + + runtime.animator.loop = true; + runtime.animator.speed = speed; + runtime.animator.clipId = clipId; + + if (autoplay) { + runtime.animator.play(clipId); + continue; + } + + runtime.animator.stop(true); + } +}; + +const summarizeDiagnostics = (diagnostics: readonly { level: string; code: string }[]): string => { + const compact = diagnostics.slice(0, 3).map((entry) => `${entry.level.toUpperCase()} ${entry.code}`); + return compact.length > 0 ? `\n${compact.join('\n')}` : ''; +}; + +const localGlbViewerExample: SceneExample = { + id: 'scene-local-glb-viewer', + title: 'Scene Local GLB Viewer', + description: 'Loads a local GLB, recenters it over a ground plane, applies neutral default shading, and lets you preview animation clips with an orbiting camera.', + tags: ['scene', 'gltf', 'glb', 'animation', 'local', 'viewer'], + order: 11, + async mount({ container }: ExampleContext) { + container.replaceChildren(); + + const shell = document.createElement('div'); + Object.assign(shell.style, { + position: 'relative', + width: '100%', + height: '100%', + overflow: 'hidden', + } satisfies Partial); + + const sceneHost = document.createElement('div'); + Object.assign(sceneHost.style, { + width: '100%', + height: '100%', + } satisfies Partial); + + shell.appendChild(sceneHost); + container.appendChild(shell); + + const panel = createViewerPanel(shell); + let destroyed = false; + let loadToken = 0; + let stage: ViewerStage | null = null; + let animators: readonly AnimatorRuntime[] = []; + let selectedClipId: string | null = null; + let currentSpeed = 1; + + const syncClipUi = (clips: readonly ClipEntry[]) => { + panel.setClipEntries(clips, selectedClipId, (clipId) => { + selectedClipId = clipId; + panel.setSelectedClip(selectedClipId); + applyClipSelection(animators, clipId, currentSpeed, true); + panel.setStatus(`Playing '${clipId}' on ${animators.filter((entry) => entry.clipIds.has(clipId)).length} animator(s).`, 'success'); + syncClipUi(clips); + }); + + panel.playButton.disabled = selectedClipId === null || animators.length === 0; + panel.pauseButton.disabled = animators.length === 0; + panel.stopButton.disabled = animators.length === 0; + syncButtonDisabledState(panel.playButton); + syncButtonDisabledState(panel.pauseButton); + syncButtonDisabledState(panel.stopButton); + }; + + const rebuildStage = async (file: File | null): Promise => { + const token = ++loadToken; + panel.setBusy(true); + selectedClipId = null; + animators = []; + panel.setSelectedClip(null); + panel.setSummary(file ? 'Preparing fresh viewer stage...' : 'Viewer ready.'); + syncClipUi([]); + + const previousStage = stage; + stage = null; + previousStage?.dispose(); + + const nextStage = await createViewerStage(sceneHost); + if (destroyed || token !== loadToken) { + nextStage.dispose(); + return; + } + + stage = nextStage; + + if (!file) { + panel.setBusy(false); + panel.setStatus('Ready. Load a local GLB to inspect clips and preview animation.', 'neutral'); + panel.setSummary('Auto-orbit camera is active over the empty ground stage. Use the mouse wheel to zoom.'); + panel.setFileLabel('No file loaded yet.'); + return; + } + + panel.setStatus(`Loading ${file.name}...`, 'neutral'); + panel.setSummary('Importing local bytes through the Axrone glTF pipeline.'); + panel.setFileLabel(`${file.name} · ${(file.size / 1024 / 1024).toFixed(2)} MB`); + + try { + const loaded = await loadFileIntoStage(nextStage, file); + if (destroyed || token !== loadToken) { + return; + } + + animators = loaded.animators; + currentSpeed = Number(panel.speedInput.value) || 1; + + if (loaded.clips.length > 0) { + selectedClipId = loaded.clips[0]!.id; + applyClipSelection(animators, selectedClipId, currentSpeed, false); + } + + syncClipUi(loaded.clips); + panel.setSelectedClip(selectedClipId); + panel.setSummary( + `Loaded ${loaded.load.actors.length} actors, ${loaded.load.prefab.data.materialKeys.length} material(s), and ${loaded.clips.length} clip(s).` + ); + panel.setStatus( + loaded.clips.length > 0 + ? `Imported successfully. Pick a clip or press Play to start.${summarizeDiagnostics(loaded.load.diagnostics)}` + : `Imported successfully, but this file does not expose animation clips.${summarizeDiagnostics(loaded.load.diagnostics)}`, + 'success' + ); + } catch (error) { + if (destroyed || token !== loadToken) { + return; + } + + const message = error instanceof Error ? error.message : String(error); + panel.setStatus(`Local GLB load failed.\n${message}`, 'error'); + panel.setSummary('The viewer stage is still alive; choose another file to retry.'); + panel.setSelectedClip(null); + syncClipUi([]); + } finally { + if (!destroyed && token === loadToken) { + panel.setBusy(false); + panel.setSpeed(currentSpeed); + } + } + }; + + const handleFile = async (file: File | null | undefined) => { + if (!file) { + return; + } + + const normalizedName = file.name.trim().toLowerCase(); + if (!normalizedName.endsWith('.glb')) { + panel.setStatus('Only local .glb files are enabled in this viewer for now.', 'error'); + return; + } + + await rebuildStage(file); + }; + + panel.fileInput.addEventListener('change', async () => { + const file = panel.fileInput.files?.[0]; + panel.fileInput.value = ''; + await handleFile(file); + }); + + const setDropActive = (active: boolean) => { + panel.dropZone.style.borderColor = active + ? 'rgba(56, 189, 248, 0.72)' + : 'rgba(125, 211, 252, 0.35)'; + panel.dropZone.style.background = active + ? 'rgba(8, 47, 73, 0.58)' + : 'rgba(15, 23, 42, 0.54)'; + }; + + panel.dropZone.addEventListener('dragover', (event) => { + event.preventDefault(); + setDropActive(true); + }); + panel.dropZone.addEventListener('dragenter', (event) => { + event.preventDefault(); + setDropActive(true); + }); + panel.dropZone.addEventListener('dragleave', (event) => { + if (!panel.dropZone.contains(event.relatedTarget as Node | null)) { + setDropActive(false); + } + }); + panel.dropZone.addEventListener('drop', async (event) => { + event.preventDefault(); + setDropActive(false); + await handleFile(event.dataTransfer?.files?.[0]); + }); + + panel.playButton.addEventListener('click', () => { + if (!selectedClipId) { + return; + } + + applyClipSelection(animators, selectedClipId, currentSpeed, true); + panel.setStatus(`Playing '${selectedClipId}'.`, 'success'); + }); + + panel.pauseButton.addEventListener('click', () => { + for (const runtime of animators) { + runtime.animator.pause(); + } + + panel.setStatus('Playback paused.', 'neutral'); + }); + + panel.stopButton.addEventListener('click', () => { + for (const runtime of animators) { + runtime.animator.stop(true); + } + + panel.setStatus('Playback stopped and rewound to frame 0.', 'neutral'); + }); + + panel.speedInput.addEventListener('input', () => { + currentSpeed = Number(panel.speedInput.value) || 1; + panel.setSpeed(currentSpeed); + + for (const runtime of animators) { + runtime.animator.speed = currentSpeed; + } + }); + + const handleWheel = (event: WheelEvent) => { + if (!stage) { + return; + } + + event.preventDefault(); + stage.orbit.zoom(normalizeWheelDelta(event) * 0.009); + }; + + sceneHost.addEventListener('wheel', handleWheel, { passive: false }); + + panel.setSpeed(currentSpeed); + await rebuildStage(null); + + return { + dispose() { + destroyed = true; + sceneHost.removeEventListener('wheel', handleWheel); + stage?.dispose(); + panel.dispose(); + container.replaceChildren(); + }, + }; + }, +}; + +export default localGlbViewerExample; \ No newline at end of file diff --git a/web/examples/ui-component-studio.ts b/web/examples/ui-component-studio.ts new file mode 100644 index 00000000..f593133d --- /dev/null +++ b/web/examples/ui-component-studio.ts @@ -0,0 +1,353 @@ +import { + createUIButton, + createUICanvas, + createUIEditBox, + createUILayout, + createUIPageView, + createUIProgressBar, + createUIRichText, + createUIScrollView, + createUISlider, + createUIToggle, + createUIWidget, +} from '@axrone/ui'; +import type { ExampleContext, SceneExample } from './example-types'; +import { createUIExampleHost } from './ui/example-helpers'; + +const uiComponentStudioExample: SceneExample = { + id: 'ui-component-studio', + title: 'UI Component Studio', + description: 'Presents the full built-in UI surface with canvas, layout, input, paging, scrolling, and authored control states.', + tags: ['ui', 'components', 'editor-ready', 'controls'], + order: 9, + async mount({ container }: ExampleContext) { + const host = await createUIExampleHost({ container, bindInput: true }); + const { runtime } = host; + + const canvas = createUICanvas(runtime, { + style: { + background: '#050b16b8', + }, + }); + const workspace = createUILayout(runtime, { + parent: canvas, + layout: { + position: 'absolute', + anchor: 'stretch', + inset: { top: 24, right: 24, bottom: 24, left: 24 }, + direction: 'row', + gap: 18, + }, + }); + const leftColumn = createUILayout(runtime, { + parent: workspace, + layout: { + width: 332, + gap: 14, + padding: 18, + }, + style: { + background: '#0f172ae8', + borderColor: '#67e8f988', + borderWidth: 1, + radius: 18, + }, + }); + const rightColumn = createUILayout(runtime, { + parent: workspace, + layout: { + grow: 1, + gap: 14, + padding: 18, + }, + style: { + background: '#0b1322e8', + borderColor: '#60a5fa66', + borderWidth: 1, + radius: 18, + }, + }); + + const title = createUIRichText(runtime, { + parent: leftColumn, + value: 'AXRONE UI SURFACE', + text: { + size: 20, + underline: true, + underlineColor: '#67e8f9ff', + shadowColor: '#00000099', + shadowOffsetX: 2, + shadowOffsetY: 1, + }, + }); + createUIRichText(runtime, { + parent: leftColumn, + value: 'BUTTON CANVAS EDITBOX LAYOUT PAGEVIEW PROGRESSBAR RICHTEXT SCROLLVIEW SLIDER TOGGLE WIDGET', + text: { + size: 12, + color: '#93c5fdff', + wrap: 'word', + }, + }); + + const pageView = createUIPageView(runtime, { + parent: rightColumn, + layout: { + width: '100%', + height: 220, + }, + }); + + const pageOne = createUILayout(runtime, { + layout: { + anchor: 'stretch', + width: '100%', + height: '100%', + padding: 18, + gap: 10, + }, + style: { + background: '#111827ff', + borderColor: '#34d39988', + borderWidth: 1, + radius: 16, + }, + }); + createUIRichText(runtime, { + parent: pageOne, + value: 'PAGE 1 LAYOUT + WIDGET', + text: { + size: 16, + color: '#f8fafcff', + }, + }); + createUIWidget(runtime, { + parent: pageOne, + layout: { + width: '100%', + height: 92, + padding: 14, + }, + style: { + background: '#0f172aff', + borderColor: '#38bdf888', + borderWidth: 1, + radius: 14, + }, + text: { + value: 'GENERIC WIDGET SURFACE WITH TEXT, BORDER, RADIUS, AND CONTENT LAYOUT.', + family: 'OverlayBitmap', + size: 14, + color: '#dbeafeff', + wrap: 'word', + }, + }); + + const pageTwo = createUILayout(runtime, { + layout: { + anchor: 'stretch', + width: '100%', + height: '100%', + padding: 18, + gap: 10, + }, + style: { + background: '#131c30ff', + borderColor: '#a78bfa88', + borderWidth: 1, + radius: 16, + }, + }); + createUIRichText(runtime, { + parent: pageTwo, + value: 'PAGE 2 RICHTEXT', + text: { + size: 16, + color: '#f8fafcff', + }, + }); + createUIRichText(runtime, { + parent: pageTwo, + value: 'OUTLINE, SHADOW, UNDERLINE, CARET, AND SELECTION ARE PART OF THE SAME TEXT PIPELINE.', + text: { + size: 14, + color: '#f5d0feff', + outlineColor: '#7c3aedff', + outlineWidth: 1, + shadowColor: '#000000aa', + shadowOffsetX: 1, + shadowOffsetY: 1, + underline: true, + underlineColor: '#c084fcff', + selectionStart: 0, + selectionEnd: 8, + selectionColor: '#7c3aed66', + caretIndex: 18, + caretColor: '#f8fafcff', + wrap: 'word', + }, + }); + + const pageThree = createUILayout(runtime, { + layout: { + anchor: 'stretch', + width: '100%', + height: '100%', + padding: 18, + gap: 10, + }, + style: { + background: '#0f1c1fff', + borderColor: '#22c55e88', + borderWidth: 1, + radius: 16, + }, + }); + createUIRichText(runtime, { + parent: pageThree, + value: 'PAGE 3 PAGEVIEW', + text: { + size: 16, + color: '#f8fafcff', + }, + }); + createUIRichText(runtime, { + parent: pageThree, + value: 'DOT INDICATORS AND NEXT/PREV ACTIONS MAKE PAGE SWITCHING FEEL LIKE A NATIVE UI SURFACE.', + text: { + size: 14, + color: '#bbf7d0ff', + wrap: 'word', + }, + }); + + pageView.addPage(pageOne.root); + pageView.addPage(pageTwo.root); + pageView.addPage(pageThree.root); + + const editBox = createUIEditBox(runtime, { + parent: leftColumn, + placeholder: 'Name this HUD surface', + onChange: (value) => { + title.setText(value.trim().length > 0 ? value.toUpperCase() : 'AXRONE UI SURFACE'); + }, + }); + const progress = createUIProgressBar(runtime, { + parent: leftColumn, + label: 'Runtime Sync', + min: 0, + max: 100, + value: 64, + }); + const slider = createUISlider(runtime, { + parent: leftColumn, + label: 'Completion', + min: 0, + max: 100, + step: 5, + value: 64, + onChange: (value) => { + progress.setValue(value); + }, + }); + const toggle = createUIToggle(runtime, { + parent: leftColumn, + label: 'Allow paging', + checked: true, + onChange: (checked) => { + nextButton.setDisabled(!checked); + }, + }); + const actions = createUILayout(runtime, { + parent: leftColumn, + layout: { + direction: 'row', + gap: 10, + width: '100%', + }, + }); + const nextButton = createUIButton(runtime, { + parent: actions, + label: 'Next Page', + variant: 'primary', + onPress: () => { + pageView.next(); + }, + }); + createUIButton(runtime, { + parent: actions, + label: 'Reset', + variant: 'neutral', + onPress: () => { + pageView.setPage(0); + progress.setValue(64); + slider.setValue(64); + editBox.setValue(''); + title.setText('AXRONE UI SURFACE'); + }, + }); + + const scrollView = createUIScrollView(runtime, { + parent: rightColumn, + layout: { + width: '100%', + height: 166, + }, + }); + const feed = createUILayout(runtime, { + parent: scrollView, + layout: { + width: '100%', + gap: 8, + }, + }); + + const feedRows = [ + ['BUTTON', 'State-aware focus, hover, pressed, disabled'], + ['EDITBOX', 'Caret, insertion, selection, submit semantics'], + ['SLIDER', 'Keyboard and pointer-driven numeric authoring'], + ['TOGGLE', 'Boolean state with authored visuals'], + ['SCROLLVIEW', 'Clipped content with content offsets'], + ['PAGEVIEW', 'Structured multi-page UI with indicators'], + ]; + + for (const [heading, body] of feedRows) { + const card = createUILayout(runtime, { + parent: feed, + layout: { + width: '100%', + padding: 12, + gap: 6, + }, + style: { + background: '#111827ff', + borderColor: '#334155ff', + borderWidth: 1, + radius: 14, + }, + }); + createUIRichText(runtime, { + parent: card, + value: heading, + text: { + size: 14, + color: '#f8fafcff', + wrap: 'none', + }, + }); + createUIRichText(runtime, { + parent: card, + value: body, + text: { + size: 12, + color: '#94a3b8ff', + wrap: 'word', + }, + }); + } + + return host; + }, +}; + +export default uiComponentStudioExample; \ No newline at end of file diff --git a/web/examples/ui-dynamic-font-gallery.ts b/web/examples/ui-dynamic-font-gallery.ts new file mode 100644 index 00000000..23c5e007 --- /dev/null +++ b/web/examples/ui-dynamic-font-gallery.ts @@ -0,0 +1,271 @@ +import type { TextBlockInput, UIRuntime, WidgetId, WidgetLayoutInput, WidgetStyleInput } from '@axrone/ui'; +import type { ExampleContext, SceneExample } from './example-types'; +import { createDemoPanel, createUIExampleHost, resolveExampleAssetUrl } from './ui/example-helpers'; + +const DYNAMIC_FONT_FAMILY = 'Kenney Pixel Dynamic'; +const DYNAMIC_FONT_URL = 'fonts/kenpixel.ttf'; +const PULSE_LABEL = 'AXRONE 88'; + +const createDynamicText = ( + runtime: UIRuntime, + value: string, + size: number, + options: { + readonly color?: string; + readonly layout?: WidgetLayoutInput; + readonly style?: WidgetStyleInput; + readonly text?: Partial; + } = {} +): WidgetId => + runtime.createWidget({ + role: 'text', + layout: options.layout, + style: options.style, + text: { + value, + family: DYNAMIC_FONT_FAMILY, + size, + color: options.color ?? '#f8fafcff', + ...(options.text ?? {}), + }, + }); + +const formatSize = (value: number): string => value.toString().padStart(2, '0'); + +const uiDynamicFontGalleryExample: SceneExample = { + id: 'ui-dynamic-font-gallery', + title: 'UI Dynamic Font Gallery', + description: + 'Loads a real TTF file through the WebGL2 UI font runtime, then shows the same glyphs across multiple raster sizes and an animated pulse line.', + tags: ['ui', 'text', 'font', 'webgl2'], + order: 8, + async mount({ container }: ExampleContext) { + const host = await createUIExampleHost({ + container, + clearColor: [0.02, 0.03, 0.06, 1], + cubeColor: [0.14, 0.46, 0.82, 1], + atlasFilter: 'linear', + }); + const { runtime, scene } = host; + + const fontUrl = resolveExampleAssetUrl(DYNAMIC_FONT_URL); + const faceId = await runtime.fonts.load({ + kind: 'url', + url: fontUrl, + contentType: 'font/ttf', + family: DYNAMIC_FONT_FAMILY, + face: 'Regular', + weight: 400, + style: 'normal', + cacheKey: 'examples:ui-dynamic-font-gallery:kenpixel', + }); + const faceInfo = runtime.fonts.getFaceInfo(faceId); + + const panel = createDemoPanel(runtime, { + width: 724, + height: 388, + gap: 14, + }, { + background: '#08111dcc', + borderColor: '#60a5faaa', + }); + const title = createDynamicText(runtime, 'DYNAMIC FONT RUNTIME', 24, { + layout: { width: '100%', height: 30 }, + text: { wrap: 'none' }, + }); + const subtitle = createDynamicText(runtime, 'REAL TTF URL SOURCE LIVE RASTER CACHE WEBGL2 ATLAS', 12, { + color: '#93c5fdff', + layout: { width: '100%', height: 16 }, + text: { wrap: 'none', letterSpacing: 0.8 }, + }); + + const content = runtime.createWidget({ + layout: { + width: '100%', + height: 286, + display: 'stack', + direction: 'row', + gap: 12, + }, + }); + const leftCard = runtime.createWidget({ + layout: { + grow: 1, + height: '100%', + padding: 14, + display: 'stack', + direction: 'column', + gap: 10, + }, + style: { + background: '#0f172aff', + borderColor: '#1e3a5fff', + borderWidth: 1, + radius: 16, + }, + }); + const rightCard = runtime.createWidget({ + layout: { + width: 244, + height: '100%', + padding: 14, + display: 'stack', + direction: 'column', + gap: 10, + }, + style: { + background: '#101827ff', + borderColor: '#1e293bff', + borderWidth: 1, + radius: 16, + }, + }); + + const pulseSize = createDynamicText(runtime, 'PULSE SIZE 00 PX', 14, { + color: '#7dd3fcff', + layout: { width: '100%', height: 18 }, + text: { wrap: 'none' }, + }); + const pulseHero = createDynamicText(runtime, PULSE_LABEL, 46, { + layout: { width: '100%', height: 72 }, + text: { + wrap: 'none', + shadowColor: '#020617dd', + shadowOffsetX: 2, + shadowOffsetY: 2, + }, + }); + const pulseCopy = createDynamicText(runtime, 'SAME CODEPOINTS MOVING THROUGH MULTIPLE RASTER SIZES', 12, { + color: '#cbd5e1ff', + layout: { width: '100%', height: 16 }, + text: { wrap: 'none', letterSpacing: 0.5 }, + }); + + const staticLabel = createDynamicText(runtime, 'STATIC SCALE CHECK', 14, { + color: '#e2e8f0ff', + layout: { width: '100%', height: 18 }, + text: { wrap: 'none' }, + }); + const smallLine = createDynamicText(runtime, PULSE_LABEL, 18, { + color: '#bfdbfeff', + layout: { width: '100%', height: 24 }, + text: { wrap: 'none' }, + }); + const mediumLine = createDynamicText(runtime, PULSE_LABEL, 28, { + color: '#dbeafeff', + layout: { width: '100%', height: 34 }, + text: { wrap: 'none' }, + }); + const largeLine = createDynamicText(runtime, PULSE_LABEL, 40, { + color: '#f8fafcff', + layout: { width: '100%', height: 48 }, + text: { wrap: 'none' }, + }); + const kerningLine = createDynamicText(runtime, 'AV WA TO LT 112233', 24, { + color: '#fde68aff', + layout: { width: '100%', height: 28 }, + text: { + wrap: 'none', + shadowColor: '#111827cc', + shadowOffsetX: 2, + shadowOffsetY: 2, + }, + }); + + const sourceCard = createDynamicText(runtime, 'BINARY FONT SOURCE', 14, { + color: '#f8fafcff', + layout: { width: '100%', height: 18 }, + text: { wrap: 'none' }, + }); + const sourceValue = createDynamicText(runtime, DYNAMIC_FONT_URL.toUpperCase(), 12, { + color: '#7dd3fcff', + layout: { width: '100%', height: 34 }, + text: { wrap: 'word', maxLines: 2 }, + }); + const faceValue = createDynamicText( + runtime, + `FACE ${faceInfo?.face?.toUpperCase() ?? 'REGULAR'} UPM ${faceInfo?.unitsPerEm ?? 0}`, + 12, + { + color: '#cbd5e1ff', + layout: { width: '100%', height: 18 }, + text: { wrap: 'none' }, + } + ); + const metricsValue = createDynamicText( + runtime, + `ASC ${faceInfo?.ascent ?? 0} DESC ${faceInfo?.descent ?? 0} GAP ${faceInfo?.lineGap ?? 0}`, + 12, + { + color: '#cbd5e1ff', + layout: { width: '100%', height: 18 }, + text: { wrap: 'none' }, + } + ); + const atlasValue = createDynamicText(runtime, 'ATLAS FILTER LINEAR', 12, { + color: '#a5f3fcff', + layout: { width: '100%', height: 18 }, + text: { wrap: 'none' }, + }); + const cacheValue = createDynamicText(runtime, 'CACHE KEY = CODEPOINT + RASTER SIZE', 12, { + color: '#fca5a5ff', + layout: { width: '100%', height: 34 }, + text: { wrap: 'word', maxLines: 2 }, + }); + + runtime.appendChild(runtime.root, panel); + runtime.appendChild(panel, title); + runtime.appendChild(panel, subtitle); + runtime.appendChild(panel, content); + runtime.appendChild(content, leftCard); + runtime.appendChild(content, rightCard); + + runtime.appendChild(leftCard, pulseSize); + runtime.appendChild(leftCard, pulseHero); + runtime.appendChild(leftCard, pulseCopy); + runtime.appendChild(leftCard, staticLabel); + runtime.appendChild(leftCard, smallLine); + runtime.appendChild(leftCard, mediumLine); + runtime.appendChild(leftCard, largeLine); + runtime.appendChild(leftCard, kerningLine); + + runtime.appendChild(rightCard, sourceCard); + runtime.appendChild(rightCard, sourceValue); + runtime.appendChild(rightCard, faceValue); + runtime.appendChild(rightCard, metricsValue); + runtime.appendChild(rightCard, atlasValue); + runtime.appendChild(rightCard, cacheValue); + + const animationSystemId = 'ui-dynamic-font-gallery.animate'; + scene.loop.addSystem({ + id: animationSystemId, + priority: 140, + enabled: true, + update(context) { + const animatedSize = 24 + Math.round((Math.sin(context.elapsed * 0.0022) * 0.5 + 0.5) * 30); + runtime.updateWidget(pulseHero, { + text: { + size: animatedSize, + }, + layout: { + height: Math.max(72, animatedSize + 24), + }, + }); + runtime.updateWidget(pulseSize, { + text: { + value: `PULSE SIZE ${formatSize(animatedSize)} PX`, + }, + }); + }, + }); + + return { + dispose() { + scene.loop.removeSystem(animationSystemId); + host.dispose(); + }, + }; + }, +}; + +export default uiDynamicFontGalleryExample; diff --git a/web/examples/ui/example-helpers.ts b/web/examples/ui/example-helpers.ts index d02397e4..13fc5c8b 100644 --- a/web/examples/ui/example-helpers.ts +++ b/web/examples/ui/example-helpers.ts @@ -8,6 +8,12 @@ import { bindSceneToContainer } from '../example-runtime'; import type { ExampleHandle } from '../example-types'; export const UI_DEMO_FONT_FAMILY = 'OverlayBitmap'; +export const resolveExampleAssetUrl = (path: string): string => + new URL(path.replace(/^\/+/, ''), document.baseURI).toString(); +export const DRACO_DECODER_WASM_URL = new URL( + '../../node_modules/draco3dgltf/draco_decoder_gltf.wasm', + import.meta.url +).toString(); const GLYPH_PATTERNS = { '?': ['.###.', '...#.', '..#..', '..#..', '..#..', '.....', '..#..'], @@ -466,6 +472,7 @@ export interface UIExampleHostOptions { readonly bindInput?: boolean; readonly clearColor?: readonly [number, number, number, number]; readonly cubeColor?: readonly [number, number, number, number]; + readonly atlasFilter?: 'nearest' | 'linear'; } export const createUIExampleHost = async ( @@ -509,7 +516,7 @@ export const createUIExampleHost = async ( ui: () => runtime.commit({ width: scene.canvas.width, height: scene.canvas.height }), priority: -1000, renderer: { - atlasFilter: 'nearest', + atlasFilter: options.atlasFilter ?? 'nearest', }, }); diff --git a/web/package-lock.json b/web/package-lock.json deleted file mode 100644 index d394f4ab..00000000 --- a/web/package-lock.json +++ /dev/null @@ -1,18234 +0,0 @@ -{ - "name": "axrone", - "version": "0.0.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "axrone", - "version": "0.0.1", - "workspaces": [ - "packages/*" - ], - "dependencies": { - "@types/three": "^0.183.1", - "three": "^0.183.2" - }, - "devDependencies": { - "@commitlint/cli": "^19.8.0", - "@commitlint/config-conventional": "^19.8.0", - "@playwright/test": "^1.48.0", - "@rollup/plugin-commonjs": "^28.0.3", - "@rollup/plugin-node-resolve": "^16.0.1", - "@rollup/plugin-terser": "^0.4.4", - "@tailwindcss/postcss": "^4.2.2", - "@types/node": "^22.14.1", - "@typescript-eslint/eslint-plugin": "^8.30.1", - "@typescript-eslint/parser": "^8.30.1", - "@vitest/browser": "^2.1.8", - "@vitest/coverage-v8": "^2.1.8", - "@vitest/ui": "^2.1.8", - "autoprefixer": "^10.4.27", - "eslint": "^9.24.0", - "eslint-config-prettier": "^10.1.2", - "eslint-plugin-import": "^2.31.0", - "happy-dom": "^15.11.7", - "husky": "^9.1.7", - "lerna": "^8.2.2", - "lint-staged": "^15.5.1", - "monaco-editor": "^0.55.1", - "playwright": "^1.48.0", - "postcss": "^8.5.8", - "prettier": "^3.5.3", - "punycode2": "^1.0.1", - "rimraf": "^6.0.1", - "rollup": "^4.40.0", - "rollup-plugin-dts": "^6.2.1", - "rollup-plugin-peer-deps-external": "^2.2.4", - "rollup-plugin-typescript2": "^0.36.0", - "tailwindcss": "^4.2.2", - "tsx": "^4.20.3", - "typedoc": "^0.28.2", - "typedoc-plugin-markdown": "^4.6.2", - "typescript": "^5.8.3", - "vite": "^6.0.7", - "vitest": "^2.1.8" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@axrone/asset-2d": { - "resolved": "packages/asset-2d", - "link": true - }, - "node_modules/@axrone/asset-core": { - "resolved": "packages/asset-core", - "link": true - }, - "node_modules/@axrone/asset-gltf": { - "resolved": "packages/asset-gltf", - "link": true - }, - "node_modules/@axrone/ecs-events": { - "resolved": "packages/ecs-events", - "link": true - }, - "node_modules/@axrone/ecs-query": { - "resolved": "packages/ecs-query", - "link": true - }, - "node_modules/@axrone/ecs-runtime": { - "resolved": "packages/ecs-runtime", - "link": true - }, - "node_modules/@axrone/ecs-storage": { - "resolved": "packages/ecs-storage", - "link": true - }, - "node_modules/@axrone/ecs-world-support": { - "resolved": "packages/ecs-world-support", - "link": true - }, - "node_modules/@axrone/event": { - "resolved": "packages/event", - "link": true - }, - "node_modules/@axrone/game-loop": { - "resolved": "packages/game-loop", - "link": true - }, - "node_modules/@axrone/geometry": { - "resolved": "packages/geometry", - "link": true - }, - "node_modules/@axrone/input": { - "resolved": "packages/input", - "link": true - }, - "node_modules/@axrone/input-core": { - "resolved": "packages/input-core", - "link": true - }, - "node_modules/@axrone/numeric": { - "resolved": "packages/numeric", - "link": true - }, - "node_modules/@axrone/observer": { - "resolved": "packages/observer", - "link": true - }, - "node_modules/@axrone/particle-system": { - "resolved": "packages/particle-system", - "link": true - }, - "node_modules/@axrone/physics": { - "resolved": "packages/physics", - "link": true - }, - "node_modules/@axrone/physics-2d": { - "resolved": "packages/physics-2d", - "link": true - }, - "node_modules/@axrone/physics-3d": { - "resolved": "packages/physics-3d", - "link": true - }, - "node_modules/@axrone/physics-core": { - "resolved": "packages/physics-core", - "link": true - }, - "node_modules/@axrone/random": { - "resolved": "packages/random", - "link": true - }, - "node_modules/@axrone/render-2d": { - "resolved": "packages/render-2d", - "link": true - }, - "node_modules/@axrone/render-3d": { - "resolved": "packages/render-3d", - "link": true - }, - "node_modules/@axrone/render-core": { - "resolved": "packages/render-core", - "link": true - }, - "node_modules/@axrone/render-webgl2": { - "resolved": "packages/render-webgl2", - "link": true - }, - "node_modules/@axrone/runtime-profile-2d": { - "resolved": "packages/runtime-profile-2d", - "link": true - }, - "node_modules/@axrone/runtime-profile-3d": { - "resolved": "packages/runtime-profile-3d", - "link": true - }, - "node_modules/@axrone/runtime-profile-core": { - "resolved": "packages/runtime-profile-core", - "link": true - }, - "node_modules/@axrone/runtime-profile-full": { - "resolved": "packages/runtime-profile-full", - "link": true - }, - "node_modules/@axrone/scene-2d": { - "resolved": "packages/scene-2d", - "link": true - }, - "node_modules/@axrone/scene-3d": { - "resolved": "packages/scene-3d", - "link": true - }, - "node_modules/@axrone/scene-runtime": { - "resolved": "packages/scene-runtime", - "link": true - }, - "node_modules/@axrone/scene-runtime-gltf": { - "resolved": "packages/scene-runtime-gltf", - "link": true - }, - "node_modules/@axrone/tween": { - "resolved": "packages/tween", - "link": true - }, - "node_modules/@axrone/ui": { - "resolved": "packages/ui", - "link": true - }, - "node_modules/@axrone/ui-webgl2": { - "resolved": "packages/ui-webgl2", - "link": true - }, - "node_modules/@axrone/utility": { - "resolved": "packages/utility", - "link": true - }, - "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", - "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", - "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", - "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@bundled-es-modules/cookie": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", - "integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cookie": "^0.7.2" - } - }, - "node_modules/@bundled-es-modules/statuses": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz", - "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==", - "dev": true, - "license": "ISC", - "dependencies": { - "statuses": "^2.0.1" - } - }, - "node_modules/@bundled-es-modules/tough-cookie": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz", - "integrity": "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@types/tough-cookie": "^4.0.5", - "tough-cookie": "^4.1.4" - } - }, - "node_modules/@commitlint/cli": { - "version": "19.8.0", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-19.8.0.tgz", - "integrity": "sha512-t/fCrLVu+Ru01h0DtlgHZXbHV2Y8gKocTR5elDOqIRUzQd0/6hpt2VIWOj9b3NDo7y4/gfxeR2zRtXq/qO6iUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/format": "^19.8.0", - "@commitlint/lint": "^19.8.0", - "@commitlint/load": "^19.8.0", - "@commitlint/read": "^19.8.0", - "@commitlint/types": "^19.8.0", - "tinyexec": "^0.3.0", - "yargs": "^17.0.0" - }, - "bin": { - "commitlint": "cli.js" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/config-conventional": { - "version": "19.8.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-19.8.0.tgz", - "integrity": "sha512-9I2kKJwcAPwMoAj38hwqFXG0CzS2Kj+SAByPUQ0SlHTfb7VUhYVmo7G2w2tBrqmOf7PFd6MpZ/a1GQJo8na8kw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^19.8.0", - "conventional-changelog-conventionalcommits": "^7.0.2" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/config-validator": { - "version": "19.8.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-19.8.0.tgz", - "integrity": "sha512-+r5ZvD/0hQC3w5VOHJhGcCooiAVdynFlCe2d6I9dU+PvXdV3O+fU4vipVg+6hyLbQUuCH82mz3HnT/cBQTYYuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^19.8.0", - "ajv": "^8.11.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/config-validator/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@commitlint/config-validator/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/@commitlint/ensure": { - "version": "19.8.0", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-19.8.0.tgz", - "integrity": "sha512-kNiNU4/bhEQ/wutI1tp1pVW1mQ0QbAjfPRo5v8SaxoVV+ARhkB8Wjg3BSseNYECPzWWfg/WDqQGIfV1RaBFQZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^19.8.0", - "lodash.camelcase": "^4.3.0", - "lodash.kebabcase": "^4.1.1", - "lodash.snakecase": "^4.1.1", - "lodash.startcase": "^4.4.0", - "lodash.upperfirst": "^4.3.1" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/execute-rule": { - "version": "19.8.0", - "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-19.8.0.tgz", - "integrity": "sha512-fuLeI+EZ9x2v/+TXKAjplBJWI9CNrHnyi5nvUQGQt4WRkww/d95oVRsc9ajpt4xFrFmqMZkd/xBQHZDvALIY7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/format": { - "version": "19.8.0", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-19.8.0.tgz", - "integrity": "sha512-EOpA8IERpQstxwp/WGnDArA7S+wlZDeTeKi98WMOvaDLKbjptuHWdOYYr790iO7kTCif/z971PKPI2PkWMfOxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^19.8.0", - "chalk": "^5.3.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/format/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@commitlint/is-ignored": { - "version": "19.8.0", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-19.8.0.tgz", - "integrity": "sha512-L2Jv9yUg/I+jF3zikOV0rdiHUul9X3a/oU5HIXhAJLE2+TXTnEBfqYP9G5yMw/Yb40SnR764g4fyDK6WR2xtpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^19.8.0", - "semver": "^7.6.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/lint": { - "version": "19.8.0", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-19.8.0.tgz", - "integrity": "sha512-+/NZKyWKSf39FeNpqhfMebmaLa1P90i1Nrb1SrA7oSU5GNN/lksA4z6+ZTnsft01YfhRZSYMbgGsARXvkr/VLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/is-ignored": "^19.8.0", - "@commitlint/parse": "^19.8.0", - "@commitlint/rules": "^19.8.0", - "@commitlint/types": "^19.8.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/load": { - "version": "19.8.0", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-19.8.0.tgz", - "integrity": "sha512-4rvmm3ff81Sfb+mcWT5WKlyOa+Hd33WSbirTVUer0wjS1Hv/Hzr07Uv1ULIV9DkimZKNyOwXn593c+h8lsDQPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/config-validator": "^19.8.0", - "@commitlint/execute-rule": "^19.8.0", - "@commitlint/resolve-extends": "^19.8.0", - "@commitlint/types": "^19.8.0", - "chalk": "^5.3.0", - "cosmiconfig": "^9.0.0", - "cosmiconfig-typescript-loader": "^6.1.0", - "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2", - "lodash.uniq": "^4.5.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/load/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@commitlint/message": { - "version": "19.8.0", - "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-19.8.0.tgz", - "integrity": "sha512-qs/5Vi9bYjf+ZV40bvdCyBn5DvbuelhR6qewLE8Bh476F7KnNyLfdM/ETJ4cp96WgeeHo6tesA2TMXS0sh5X4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/parse": { - "version": "19.8.0", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-19.8.0.tgz", - "integrity": "sha512-YNIKAc4EXvNeAvyeEnzgvm1VyAe0/b3Wax7pjJSwXuhqIQ1/t2hD3OYRXb6D5/GffIvaX82RbjD+nWtMZCLL7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^19.8.0", - "conventional-changelog-angular": "^7.0.0", - "conventional-commits-parser": "^5.0.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/parse/node_modules/conventional-commits-parser": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", - "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-text-path": "^2.0.0", - "JSONStream": "^1.3.5", - "meow": "^12.0.1", - "split2": "^4.0.0" - }, - "bin": { - "conventional-commits-parser": "cli.mjs" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/@commitlint/parse/node_modules/is-text-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz", - "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "text-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@commitlint/parse/node_modules/meow": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", - "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@commitlint/parse/node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/@commitlint/parse/node_modules/text-extensions": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", - "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@commitlint/read": { - "version": "19.8.0", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-19.8.0.tgz", - "integrity": "sha512-6ywxOGYajcxK1y1MfzrOnwsXO6nnErna88gRWEl3qqOOP8MDu/DTeRkGLXBFIZuRZ7mm5yyxU5BmeUvMpNte5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/top-level": "^19.8.0", - "@commitlint/types": "^19.8.0", - "git-raw-commits": "^4.0.0", - "minimist": "^1.2.8", - "tinyexec": "^0.3.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/read/node_modules/dargs": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz", - "integrity": "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@commitlint/read/node_modules/git-raw-commits": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz", - "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "dargs": "^8.0.0", - "meow": "^12.0.1", - "split2": "^4.0.0" - }, - "bin": { - "git-raw-commits": "cli.mjs" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/@commitlint/read/node_modules/meow": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", - "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@commitlint/read/node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/@commitlint/resolve-extends": { - "version": "19.8.0", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-19.8.0.tgz", - "integrity": "sha512-CLanRQwuG2LPfFVvrkTrBR/L/DMy3+ETsgBqW1OvRxmzp/bbVJW0Xw23LnnExgYcsaFtos967lul1CsbsnJlzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/config-validator": "^19.8.0", - "@commitlint/types": "^19.8.0", - "global-directory": "^4.0.1", - "import-meta-resolve": "^4.0.0", - "lodash.mergewith": "^4.6.2", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/rules": { - "version": "19.8.0", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-19.8.0.tgz", - "integrity": "sha512-IZ5IE90h6DSWNuNK/cwjABLAKdy8tP8OgGVGbXe1noBEX5hSsu00uRlLu6JuruiXjWJz2dZc+YSw3H0UZyl/mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/ensure": "^19.8.0", - "@commitlint/message": "^19.8.0", - "@commitlint/to-lines": "^19.8.0", - "@commitlint/types": "^19.8.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/to-lines": { - "version": "19.8.0", - "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-19.8.0.tgz", - "integrity": "sha512-3CKLUw41Cur8VMjh16y8LcsOaKbmQjAKCWlXx6B0vOUREplp6em9uIVhI8Cv934qiwkbi2+uv+mVZPnXJi1o9A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/top-level": { - "version": "19.8.0", - "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-19.8.0.tgz", - "integrity": "sha512-Rphgoc/omYZisoNkcfaBRPQr4myZEHhLPx2/vTXNLjiCw4RgfPR1wEgUpJ9OOmDCiv5ZyIExhprNLhteqH4FuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^7.0.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/top-level/node_modules/find-up": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", - "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^7.2.0", - "path-exists": "^5.0.0", - "unicorn-magic": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@commitlint/top-level/node_modules/locate-path": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", - "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^6.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@commitlint/top-level/node_modules/p-limit": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@commitlint/top-level/node_modules/p-locate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", - "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@commitlint/top-level/node_modules/path-exists": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/@commitlint/top-level/node_modules/yocto-queue": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", - "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@commitlint/types": { - "version": "19.8.0", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-19.8.0.tgz", - "integrity": "sha512-LRjP623jPyf3Poyfb0ohMj8I3ORyBDOwXAgxxVPbSD0unJuW2mJWeiRfaQinjtccMqC5Wy1HOMfa4btKjbNxbg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/conventional-commits-parser": "^5.0.0", - "chalk": "^5.3.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/types/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@dimforge/rapier3d-compat": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", - "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", - "license": "Apache-2.0" - }, - "node_modules/@emnapi/core": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", - "integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@emnapi/wasi-threads": "1.0.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", - "integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", - "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", - "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", - "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", - "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", - "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", - "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", - "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", - "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", - "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", - "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", - "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", - "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", - "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", - "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", - "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", - "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", - "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", - "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", - "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", - "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", - "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", - "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", - "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", - "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", - "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", - "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.6.1.tgz", - "integrity": "sha512-KTsJMmobmbrFLe3LDh0PC2FXpcSYJt/MLjlkh/9LEnmKYLSYmT/0EW9JWANjeoemiuZrmogti0tW5Ch+qNUYDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.6", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.1.tgz", - "integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.24.0.tgz", - "integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", - "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.13.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", - "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@gerrit0/mini-shiki": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.2.3.tgz", - "integrity": "sha512-yemSYr0Oiqk5NAQRfbD5DKUTlThiZw1MxTMx/YpQTg6m4QRJDtV2JTYSuNevgx1ayy/O7x+uwDjh3IgECGFY/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/engine-oniguruma": "^3.2.2", - "@shikijs/langs": "^3.2.2", - "@shikijs/themes": "^3.2.2", - "@shikijs/types": "^3.2.2", - "@shikijs/vscode-textmate": "^10.0.2" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", - "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@hutson/parse-repository-url": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@hutson/parse-repository-url/-/parse-repository-url-3.0.2.tgz", - "integrity": "sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@inquirer/confirm": { - "version": "5.1.14", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.14.tgz", - "integrity": "sha512-5yR4IBfe0kXe59r1YCTG8WXkUbl7Z35HK87Sw+WUyGD8wNUx7JvY7laahzeytyE1oLn74bQnL7hstctQxisQ8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.1.15", - "@inquirer/type": "^3.0.8" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "10.1.15", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.15.tgz", - "integrity": "sha512-8xrp836RZvKkpNbVvgWUlxjT4CraKk2q+I3Ksy+seI2zkcE+y6wNs1BVhgcv8VyImFecUhdQrYLdW32pAjwBdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/figures": "^1.0.13", - "@inquirer/type": "^3.0.8", - "ansi-escapes": "^4.3.2", - "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core/node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/@inquirer/core/node_modules/mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@inquirer/core/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@inquirer/core/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@inquirer/figures": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.13.tgz", - "integrity": "sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/type": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.8.tgz", - "integrity": "sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/string-locale-compare": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@isaacs/string-locale-compare/-/string-locale-compare-1.1.0.tgz", - "integrity": "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@lerna/create": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@lerna/create/-/create-8.2.2.tgz", - "integrity": "sha512-1yn1MvWn2Yz0SFgTTQnef2m1YedF7KwqLLVIOrGkgQrkVHzsveAIk1A1RcRa2yyUh+siKI1YcJ7lUZIEt+qQ3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@npmcli/arborist": "7.5.4", - "@npmcli/package-json": "5.2.0", - "@npmcli/run-script": "8.1.0", - "@nx/devkit": ">=17.1.2 < 21", - "@octokit/plugin-enterprise-rest": "6.0.1", - "@octokit/rest": "20.1.2", - "aproba": "2.0.0", - "byte-size": "8.1.1", - "chalk": "4.1.0", - "clone-deep": "4.0.1", - "cmd-shim": "6.0.3", - "color-support": "1.1.3", - "columnify": "1.6.0", - "console-control-strings": "^1.1.0", - "conventional-changelog-core": "5.0.1", - "conventional-recommended-bump": "7.0.1", - "cosmiconfig": "9.0.0", - "dedent": "1.5.3", - "execa": "5.0.0", - "fs-extra": "^11.2.0", - "get-stream": "6.0.0", - "git-url-parse": "14.0.0", - "glob-parent": "6.0.2", - "globby": "11.1.0", - "graceful-fs": "4.2.11", - "has-unicode": "2.0.1", - "ini": "^1.3.8", - "init-package-json": "6.0.3", - "inquirer": "^8.2.4", - "is-ci": "3.0.1", - "is-stream": "2.0.0", - "js-yaml": "4.1.0", - "libnpmpublish": "9.0.9", - "load-json-file": "6.2.0", - "lodash": "^4.17.21", - "make-dir": "4.0.0", - "minimatch": "3.0.5", - "multimatch": "5.0.0", - "node-fetch": "2.6.7", - "npm-package-arg": "11.0.2", - "npm-packlist": "8.0.2", - "npm-registry-fetch": "^17.1.0", - "nx": ">=17.1.2 < 21", - "p-map": "4.0.0", - "p-map-series": "2.1.0", - "p-queue": "6.6.2", - "p-reduce": "^2.1.0", - "pacote": "^18.0.6", - "pify": "5.0.0", - "read-cmd-shim": "4.0.0", - "resolve-from": "5.0.0", - "rimraf": "^4.4.1", - "semver": "^7.3.4", - "set-blocking": "^2.0.0", - "signal-exit": "3.0.7", - "slash": "^3.0.0", - "ssri": "^10.0.6", - "string-width": "^4.2.3", - "strong-log-transformer": "2.1.0", - "tar": "6.2.1", - "temp-dir": "1.0.0", - "upath": "2.0.1", - "uuid": "^10.0.0", - "validate-npm-package-license": "^3.0.4", - "validate-npm-package-name": "5.0.1", - "wide-align": "1.1.5", - "write-file-atomic": "5.0.1", - "write-pkg": "4.0.0", - "yargs": "17.7.2", - "yargs-parser": "21.1.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@lerna/create/node_modules/@npmcli/package-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-5.2.0.tgz", - "integrity": "sha512-qe/kiqqkW0AGtvBjL8TJKZk/eBBSpnJkUWvHdQ9jM2lKHXRYYJuyNpJPlJw3c8QjC2ow6NZYiLExhUaeJelbxQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^5.0.0", - "glob": "^10.2.2", - "hosted-git-info": "^7.0.0", - "json-parse-even-better-errors": "^3.0.0", - "normalize-package-data": "^6.0.0", - "proc-log": "^4.0.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@lerna/create/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@lerna/create/node_modules/chalk": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", - "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@lerna/create/node_modules/minimatch": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.5.tgz", - "integrity": "sha512-tUpxzX0VAzJHjLu0xUfFv1gwVp9ba3IOuRAVH2EGuRW8a5emA2FlACLqiT/lDVtS1W+TGNwqz3sWaNyLgDJWuw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@lerna/create/node_modules/minipass": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", - "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/@lerna/create/node_modules/npm-package-arg": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.2.tgz", - "integrity": "sha512-IGN0IAwmhDJwy13Wc8k+4PEbTPhpJnMtfR53ZbOyjkvmEcLS4nCwp6mvMWjS5sUjeiW3mpx6cHmuhKEu9XmcQw==", - "dev": true, - "license": "ISC", - "dependencies": { - "hosted-git-info": "^7.0.0", - "proc-log": "^4.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^5.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@lerna/create/node_modules/pify": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", - "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@lerna/create/node_modules/rimraf": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-4.4.1.tgz", - "integrity": "sha512-Gk8NlF062+T9CqNGn6h4tls3k6T1+/nXdOcSZVikNVtlRdYpA7wRJJMoXmuvOnLW844rPjdQ7JgXCYM6PPC/og==", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^9.2.0" - }, - "bin": { - "rimraf": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@lerna/create/node_modules/rimraf/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@lerna/create/node_modules/rimraf/node_modules/glob": { - "version": "9.3.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", - "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "minimatch": "^8.0.2", - "minipass": "^4.2.4", - "path-scurry": "^1.6.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@lerna/create/node_modules/rimraf/node_modules/minimatch": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz", - "integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@loaders.gl/core": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.4.1.tgz", - "integrity": "sha512-/s4IuvCCQUepvhjLnmePwQppGko2d1pxRS+sp7lyExU0uiqo5dVsAKaCZ2VnddBkFWgDVb/wvcZUBmv/dWcj0Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "@loaders.gl/loader-utils": "4.4.1", - "@loaders.gl/schema": "4.4.1", - "@loaders.gl/schema-utils": "4.4.1", - "@loaders.gl/worker-utils": "4.4.1", - "@probe.gl/log": "^4.1.1" - } - }, - "node_modules/@loaders.gl/images": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@loaders.gl/images/-/images-4.4.1.tgz", - "integrity": "sha512-v9A4BliEKGxhLuEbh0Ke8ElUlp04KxpKIknUtXXWoEaszAMTSrHI3YhaL/JdRlHraC1VUF/sjzbSBFkKh7nxJg==", - "license": "MIT", - "dependencies": { - "@loaders.gl/loader-utils": "4.4.1" - }, - "peerDependencies": { - "@loaders.gl/core": "~4.4.0" - } - }, - "node_modules/@loaders.gl/loader-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@loaders.gl/loader-utils/-/loader-utils-4.4.1.tgz", - "integrity": "sha512-waosL7VtVRfXsNOXtAM3rOjZyNQD0lQBlhuB5/oY+E+lNzYNFlzgiGXiDOwBpcs7dK7kW2Vv8+KcxyIGIyXOtg==", - "license": "MIT", - "dependencies": { - "@loaders.gl/schema": "4.4.1", - "@loaders.gl/worker-utils": "4.4.1", - "@probe.gl/log": "^4.1.1", - "@probe.gl/stats": "^4.1.1" - } - }, - "node_modules/@loaders.gl/schema": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@loaders.gl/schema/-/schema-4.4.1.tgz", - "integrity": "sha512-s7NjEnyK6jZvJJSWj/mHq+S9mHRHVzIYtFP+C7sMf1gVCQbdkt6OSAMUWRzwPr9+whQNVWjZ9pbLsI/IPW3zvw==", - "license": "MIT", - "dependencies": { - "@types/geojson": "^7946.0.7", - "apache-arrow": ">= 17.0.0" - } - }, - "node_modules/@loaders.gl/schema-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@loaders.gl/schema-utils/-/schema-utils-4.4.1.tgz", - "integrity": "sha512-4upip2O6MFaWzk68/lnna7P2uRj9NQ8MIk/ff3CLbciP5/9lKl1qyuzObz5JrJRYzfGB6I81vpOn6FSVQ6m6KQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@loaders.gl/schema": "4.4.1", - "@types/geojson": "^7946.0.7", - "apache-arrow": ">= 17.0.0" - }, - "peerDependencies": { - "@loaders.gl/core": "~4.4.0" - } - }, - "node_modules/@loaders.gl/textures": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@loaders.gl/textures/-/textures-4.4.1.tgz", - "integrity": "sha512-r1//6sO29GOHso+IvXQ3GrvXZ4cl03VWc34XcnXPn3sAV7O96uRGd5xkyx60lMYAl7Jv7qK/smT3z4Mdxdd4aA==", - "license": "MIT", - "dependencies": { - "@loaders.gl/images": "4.4.1", - "@loaders.gl/loader-utils": "4.4.1", - "@loaders.gl/schema": "4.4.1", - "@loaders.gl/worker-utils": "4.4.1", - "@math.gl/types": "^4.1.0", - "ktx-parse": "^0.7.0", - "texture-compressor": "^1.0.2" - }, - "peerDependencies": { - "@loaders.gl/core": "~4.4.0" - } - }, - "node_modules/@loaders.gl/worker-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@loaders.gl/worker-utils/-/worker-utils-4.4.1.tgz", - "integrity": "sha512-ovMyIyj9dlChuHuD64Bel7Mir2UYlmLqlZ9MMzVxzTTLvaudJoNAXi6Disp0ooxwF62ZqjNXXutaSbS6UDeuIg==", - "license": "MIT", - "peerDependencies": { - "@loaders.gl/core": "~4.4.0" - } - }, - "node_modules/@math.gl/types": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@math.gl/types/-/types-4.1.0.tgz", - "integrity": "sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==", - "license": "MIT" - }, - "node_modules/@mswjs/interceptors": { - "version": "0.39.6", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.6.tgz", - "integrity": "sha512-bndDP83naYYkfayr/qhBHMhk0YGwS1iv6vaEGcr0SQbO0IZtbOPqjKjds/WcG+bJA+1T5vCx6kprKOzn5Bg+Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/logger": "^0.3.0", - "@open-draft/until": "^2.0.0", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "strict-event-emitter": "^0.5.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz", - "integrity": "sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@emnapi/core": "^1.1.0", - "@emnapi/runtime": "^1.1.0", - "@tybys/wasm-util": "^0.9.0" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@npmcli/agent": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz", - "integrity": "sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og==", - "dev": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/agent/node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@npmcli/agent/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@npmcli/arborist": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/@npmcli/arborist/-/arborist-7.5.4.tgz", - "integrity": "sha512-nWtIc6QwwoUORCRNzKx4ypHqCk3drI+5aeYdMTQQiRCcn4lOOgfQh7WyZobGYTxXPSq1VwV53lkpN/BRlRk08g==", - "dev": true, - "license": "ISC", - "dependencies": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/fs": "^3.1.1", - "@npmcli/installed-package-contents": "^2.1.0", - "@npmcli/map-workspaces": "^3.0.2", - "@npmcli/metavuln-calculator": "^7.1.1", - "@npmcli/name-from-folder": "^2.0.0", - "@npmcli/node-gyp": "^3.0.0", - "@npmcli/package-json": "^5.1.0", - "@npmcli/query": "^3.1.0", - "@npmcli/redact": "^2.0.0", - "@npmcli/run-script": "^8.1.0", - "bin-links": "^4.0.4", - "cacache": "^18.0.3", - "common-ancestor-path": "^1.0.1", - "hosted-git-info": "^7.0.2", - "json-parse-even-better-errors": "^3.0.2", - "json-stringify-nice": "^1.1.4", - "lru-cache": "^10.2.2", - "minimatch": "^9.0.4", - "nopt": "^7.2.1", - "npm-install-checks": "^6.2.0", - "npm-package-arg": "^11.0.2", - "npm-pick-manifest": "^9.0.1", - "npm-registry-fetch": "^17.0.1", - "pacote": "^18.0.6", - "parse-conflict-json": "^3.0.0", - "proc-log": "^4.2.0", - "proggy": "^2.0.0", - "promise-all-reject-late": "^1.0.0", - "promise-call-limit": "^3.0.1", - "read-package-json-fast": "^3.0.2", - "semver": "^7.3.7", - "ssri": "^10.0.6", - "treeverse": "^3.0.0", - "walk-up-path": "^3.0.1" - }, - "bin": { - "arborist": "bin/index.js" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/arborist/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@npmcli/fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.1.tgz", - "integrity": "sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==", - "dev": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/git": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.8.tgz", - "integrity": "sha512-liASfw5cqhjNW9UFd+ruwwdEf/lbOAQjLL2XY2dFW/bkJheXDYZgOyul/4gVvEV4BWkTXjYGmDqMw9uegdbJNQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/promise-spawn": "^7.0.0", - "ini": "^4.1.3", - "lru-cache": "^10.0.1", - "npm-pick-manifest": "^9.0.0", - "proc-log": "^4.0.0", - "promise-inflight": "^1.0.1", - "promise-retry": "^2.0.1", - "semver": "^7.3.5", - "which": "^4.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/git/node_modules/ini": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", - "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/installed-package-contents": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.1.0.tgz", - "integrity": "sha512-c8UuGLeZpm69BryRykLuKRyKFZYJsZSCT4aVY5ds4omyZqJ172ApzgfKJ5eV/r3HgLdUYgFVe54KSFVjKoe27w==", - "dev": true, - "license": "ISC", - "dependencies": { - "npm-bundled": "^3.0.0", - "npm-normalize-package-bin": "^3.0.0" - }, - "bin": { - "installed-package-contents": "bin/index.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/map-workspaces": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@npmcli/map-workspaces/-/map-workspaces-3.0.6.tgz", - "integrity": "sha512-tkYs0OYnzQm6iIRdfy+LcLBjcKuQCeE5YLb8KnrIlutJfheNaPvPpgoFEyEFgbjzl5PLZ3IA/BWAwRU0eHuQDA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/name-from-folder": "^2.0.0", - "glob": "^10.2.2", - "minimatch": "^9.0.0", - "read-package-json-fast": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/map-workspaces/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@npmcli/metavuln-calculator": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/metavuln-calculator/-/metavuln-calculator-7.1.1.tgz", - "integrity": "sha512-Nkxf96V0lAx3HCpVda7Vw4P23RILgdi/5K1fmj2tZkWIYLpXAN8k2UVVOsW16TsS5F8Ws2I7Cm+PU1/rsVF47g==", - "dev": true, - "license": "ISC", - "dependencies": { - "cacache": "^18.0.0", - "json-parse-even-better-errors": "^3.0.0", - "pacote": "^18.0.0", - "proc-log": "^4.1.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/name-from-folder": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/name-from-folder/-/name-from-folder-2.0.0.tgz", - "integrity": "sha512-pwK+BfEBZJbKdNYpHHRTNBwBoqrN/iIMO0AiGvYsp3Hoaq0WbgGSWQR6SCldZovoDpY3yje5lkFUe6gsDgJ2vg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/node-gyp": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-3.0.0.tgz", - "integrity": "sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/package-json": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-5.2.1.tgz", - "integrity": "sha512-f7zYC6kQautXHvNbLEWgD/uGu1+xCn9izgqBfgItWSx22U0ZDekxN08A1vM8cTxj/cRVe0Q94Ode+tdoYmIOOQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^5.0.0", - "glob": "^10.2.2", - "hosted-git-info": "^7.0.0", - "json-parse-even-better-errors": "^3.0.0", - "normalize-package-data": "^6.0.0", - "proc-log": "^4.0.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/promise-spawn": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.2.tgz", - "integrity": "sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "which": "^4.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/query": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/query/-/query-3.1.0.tgz", - "integrity": "sha512-C/iR0tk7KSKGldibYIB9x8GtO/0Bd0I2mhOaDb8ucQL/bQVTmGoeREaFj64Z5+iCBRf3dQfed0CjJL7I8iTkiQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "postcss-selector-parser": "^6.0.10" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/redact": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-2.0.1.tgz", - "integrity": "sha512-YgsR5jCQZhVmTJvjduTOIHph0L73pK8xwMVaDY0PatySqVM9AZj93jpoXYSJqfHFxFkN9dmqTw6OiqExsS3LPw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/run-script": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-8.1.0.tgz", - "integrity": "sha512-y7efHHwghQfk28G2z3tlZ67pLG0XdfYbcVG26r7YIXALRsrVQcTq4/tdenSmdOrEsNahIYA/eh8aEVROWGFUDg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/node-gyp": "^3.0.0", - "@npmcli/package-json": "^5.0.0", - "@npmcli/promise-spawn": "^7.0.0", - "node-gyp": "^10.0.0", - "proc-log": "^4.0.0", - "which": "^4.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@nx/devkit": { - "version": "20.8.0", - "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-20.8.0.tgz", - "integrity": "sha512-0616zW0Krwb5frNZ7C0HUItonCDiAHY9UYSTyJm6hnal0Xc6XkJuEAFNjbx2sEOopO85CEAMNeYEHkRyWsSxCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ejs": "^3.1.7", - "enquirer": "~2.3.6", - "ignore": "^5.0.4", - "minimatch": "9.0.3", - "semver": "^7.5.3", - "tmp": "~0.2.1", - "tslib": "^2.3.0", - "yargs-parser": "21.1.1" - }, - "peerDependencies": { - "nx": ">= 19 <= 21" - } - }, - "node_modules/@nx/devkit/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@nx/nx-win32-x64-msvc": { - "version": "20.8.0", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-20.8.0.tgz", - "integrity": "sha512-0P5r+bDuSNvoWys+6C1/KqGpYlqwSHpigCcyRzR62iZpT3OooZv+nWO06RlURkxMR8LNvYXTSSLvoLkjxqM8uQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@octokit/auth-token": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", - "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/core": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.1.tgz", - "integrity": "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/auth-token": "^4.0.0", - "@octokit/graphql": "^7.1.0", - "@octokit/request": "^8.4.1", - "@octokit/request-error": "^5.1.1", - "@octokit/types": "^13.0.0", - "before-after-hook": "^2.2.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/endpoint": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.6.tgz", - "integrity": "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^13.1.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/graphql": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.1.tgz", - "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/request": "^8.4.1", - "@octokit/types": "^13.0.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/openapi-types": { - "version": "24.2.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", - "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@octokit/plugin-enterprise-rest": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@octokit/plugin-enterprise-rest/-/plugin-enterprise-rest-6.0.1.tgz", - "integrity": "sha512-93uGjlhUD+iNg1iWhUENAtJata6w5nE+V4urXOAlIXdco6xNZtUSfYY8dzp3Udy74aqO/B5UZL80x/YMa5PKRw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@octokit/plugin-paginate-rest": { - "version": "11.4.4-cjs.2", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.4.4-cjs.2.tgz", - "integrity": "sha512-2dK6z8fhs8lla5PaOTgqfCGBxgAv/le+EhPs27KklPhm1bKObpu6lXzwfUEQ16ajXzqNrKMujsFyo9K2eaoISw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^13.7.0" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": "5" - } - }, - "node_modules/@octokit/plugin-request-log": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-4.0.1.tgz", - "integrity": "sha512-GihNqNpGHorUrO7Qa9JbAl0dbLnqJVrV8OXe2Zm5/Y4wFkZQDfTreBzVmiRfJVfE4mClXdihHnbpyyO9FSX4HA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": "5" - } - }, - "node_modules/@octokit/plugin-rest-endpoint-methods": { - "version": "13.3.2-cjs.1", - "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.3.2-cjs.1.tgz", - "integrity": "sha512-VUjIjOOvF2oELQmiFpWA1aOPdawpyaCUqcEBc/UOUnj3Xp6DJGrJ1+bjUIIDzdHjnFNO6q57ODMfdEZnoBkCwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^13.8.0" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": "^5" - } - }, - "node_modules/@octokit/request": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.4.1.tgz", - "integrity": "sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/endpoint": "^9.0.6", - "@octokit/request-error": "^5.1.1", - "@octokit/types": "^13.1.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/request-error": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.1.1.tgz", - "integrity": "sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^13.1.0", - "deprecation": "^2.0.0", - "once": "^1.4.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/rest": { - "version": "20.1.2", - "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-20.1.2.tgz", - "integrity": "sha512-GmYiltypkHHtihFwPRxlaorG5R9VAHuk/vbszVoRTGXnAsY60wYLkh/E2XiFmdZmqrisw+9FaazS1i5SbdWYgA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/core": "^5.0.2", - "@octokit/plugin-paginate-rest": "11.4.4-cjs.2", - "@octokit/plugin-request-log": "^4.0.0", - "@octokit/plugin-rest-endpoint-methods": "13.3.2-cjs.1" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/types": { - "version": "13.10.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", - "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^24.2.0" - } - }, - "node_modules/@open-draft/deferred-promise": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", - "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@open-draft/logger": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", - "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-node-process": "^1.2.0", - "outvariant": "^1.4.0" - } - }, - "node_modules/@open-draft/until": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", - "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@playwright/test": { - "version": "1.54.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.2.tgz", - "integrity": "sha512-A+znathYxPf+72riFd1r1ovOLqsIIB0jKIoPjyK2kqEIe30/6jF6BC7QNluHuwUmsD2tv1XZVugN8GqfTMOxsA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.54.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, - "license": "MIT" - }, - "node_modules/@probe.gl/env": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@probe.gl/env/-/env-4.1.1.tgz", - "integrity": "sha512-+68seNDMVsEegRB47pFA/Ws1Fjy8agcFYXxzorKToyPcD6zd+gZ5uhwoLd7TzsSw6Ydns//2KEszWn+EnNHTbA==", - "license": "MIT" - }, - "node_modules/@probe.gl/log": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@probe.gl/log/-/log-4.1.1.tgz", - "integrity": "sha512-kcZs9BT44pL7hS1OkRGKYRXI/SN9KejUlPD+BY40DguRLzdC5tLG/28WGMyfKdn/51GT4a0p+0P8xvDn1Ez+Kg==", - "license": "MIT", - "dependencies": { - "@probe.gl/env": "4.1.1" - } - }, - "node_modules/@probe.gl/stats": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@probe.gl/stats/-/stats-4.1.1.tgz", - "integrity": "sha512-4VpAyMHOqydSvPlEyHwXaE+AkIdR03nX+Qhlxsk2D/IW4OVmDZgIsvJB1cDzyEEtcfKcnaEbfXeiPgejBceT6g==", - "license": "MIT" - }, - "node_modules/@rollup/plugin-commonjs": { - "version": "28.0.3", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.3.tgz", - "integrity": "sha512-pyltgilam1QPdn+Zd9gaCfOLcnjMEJ9gV+bTw6/r73INdvzf1ah9zLIJBm+kW7R6IUFIQ1YO+VqZtYxZNWFPEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "commondir": "^1.0.1", - "estree-walker": "^2.0.2", - "fdir": "^6.2.0", - "is-reference": "1.2.1", - "magic-string": "^0.30.3", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=16.0.0 || 14 >= 14.17" - }, - "peerDependencies": { - "rollup": "^2.68.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-commonjs/node_modules/fdir": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.3.tgz", - "integrity": "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-commonjs/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.1.tgz", - "integrity": "sha512-tk5YCxJWIG81umIvNkSod2qK5KyQW19qcBF/B78n1bjtOON6gzKoVeSzAE8yHCZEDmqkHKkxplExA8KzdJLJpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "@types/resolve": "1.20.2", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.22.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.78.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-terser": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", - "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "serialize-javascript": "^6.0.1", - "smob": "^1.0.0", - "terser": "^5.17.4" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", - "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.0.tgz", - "integrity": "sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.2.2.tgz", - "integrity": "sha512-vyXRnWVCSvokwbaUD/8uPn6Gqsf5Hv7XwcW4AgiU4Z2qwy19sdr6VGzMdheKKN58tJOOe5MIKiNb901bgcUXYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.2.2", - "@shikijs/vscode-textmate": "^10.0.2" - } - }, - "node_modules/@shikijs/langs": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.2.2.tgz", - "integrity": "sha512-NY0Urg2dV9ETt3JIOWoMPuoDNwte3geLZ4M1nrPHbkDS8dWMpKcEwlqiEIGqtwZNmt5gKyWpR26ln2Bg2ecPgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.2.2" - } - }, - "node_modules/@shikijs/themes": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.2.2.tgz", - "integrity": "sha512-Zuq4lgAxVKkb0FFdhHSdDkALuRpsj1so1JdihjKNQfgM78EHxV2JhO10qPsMrm01FkE3mDRTdF68wfmsqjt6HA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.2.2" - } - }, - "node_modules/@shikijs/types": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.2.2.tgz", - "integrity": "sha512-a5TiHk7EH5Lso8sHcLHbVNNhWKP0Wi3yVnXnu73g86n3WoDgEra7n3KszyeCGuyoagspQ2fzvy4cpSc8pKhb0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sigstore/bundle": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.3.2.tgz", - "integrity": "sha512-wueKWDk70QixNLB363yHc2D2ItTgYiMTdPwK8D9dKQMR3ZQ0c35IxP5xnwQ8cNLoCgCRcHf14kE+CLIvNX1zmA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.3.2" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@sigstore/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-1.1.0.tgz", - "integrity": "sha512-JzBqdVIyqm2FRQCulY6nbQzMpJJpSiJ8XXWMhtOX9eKgaXXpfNOF53lzQEjIydlStnd/eFtuC1dW4VYdD93oRg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@sigstore/protobuf-specs": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.3.3.tgz", - "integrity": "sha512-RpacQhBlwpBWd7KEJsRKcBQalbV28fvkxwTOJIqhIuDysMMaJW47V4OqW30iJB9uRpqOSxxEAQFdr8tTattReQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@sigstore/sign": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.3.2.tgz", - "integrity": "sha512-5Vz5dPVuunIIvC5vBb0APwo7qKA4G9yM48kPWJT+OEERs40md5GoUR1yedwpekWZ4m0Hhw44m6zU+ObsON+iDA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^2.3.2", - "@sigstore/core": "^1.0.0", - "@sigstore/protobuf-specs": "^0.3.2", - "make-fetch-happen": "^13.0.1", - "proc-log": "^4.2.0", - "promise-retry": "^2.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@sigstore/tuf": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.3.4.tgz", - "integrity": "sha512-44vtsveTPUpqhm9NCrbU8CWLe3Vck2HO1PNLw7RIajbB7xhtn5RBPm1VNSCMwqGYHhDsBJG8gDF0q4lgydsJvw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.3.2", - "tuf-js": "^2.2.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@sigstore/verify": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-1.2.1.tgz", - "integrity": "sha512-8iKx79/F73DKbGfRf7+t4dqrc0bRr0thdPrxAtCKWRm/F0tG71i6O1rvlnScncJLLBZHn3h8M3c1BSUAb9yu8g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^2.3.2", - "@sigstore/core": "^1.1.0", - "@sigstore/protobuf-specs": "^0.3.2" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@swc/helpers": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", - "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", - "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.2.2" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", - "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-x64": "4.2.2", - "@tailwindcss/oxide-freebsd-x64": "4.2.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-x64-musl": "4.2.2", - "@tailwindcss/oxide-wasm32-wasi": "4.2.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", - "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", - "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", - "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", - "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", - "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", - "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", - "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", - "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", - "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", - "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.8.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.8.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", - "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", - "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/postcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", - "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.2.2", - "@tailwindcss/oxide": "4.2.2", - "postcss": "^8.5.6", - "tailwindcss": "4.2.2" - } - }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/dom/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@testing-library/dom/node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@testing-library/dom/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, - "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tufjs/canonical-json": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", - "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@tufjs/models": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-2.0.1.tgz", - "integrity": "sha512-92F7/SFyufn4DXsha9+QfKnN03JGqtMFMXgSHbZOo8JG59WkTni7UzAouNQDf7AuP9OAMxVOPQcqG3sB7w+kkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tufjs/canonical-json": "2.0.0", - "minimatch": "^9.0.4" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@tufjs/models/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@tweenjs/tween.js": { - "version": "23.1.3", - "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", - "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", - "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/command-line-args": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz", - "integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==", - "license": "MIT" - }, - "node_modules/@types/command-line-usage": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.4.tgz", - "integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==", - "license": "MIT" - }, - "node_modules/@types/conventional-commits-parser": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.1.tgz", - "integrity": "sha512-7uz5EHdzz2TqoMfV7ee61Egf5y6NkcO4FB/1iCCQnbeiI1F3xzv3vK5dBCXUCLQgGYS+mUeigK1iKQzvED+QnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/geojson": { - "version": "7946.0.16", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", - "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "license": "MIT" - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/minimatch": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", - "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.14.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", - "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/normalize-package-data": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", - "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/resolve": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/stats.js": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", - "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", - "license": "MIT" - }, - "node_modules/@types/statuses": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", - "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/three": { - "version": "0.183.1", - "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", - "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", - "license": "MIT", - "dependencies": { - "@dimforge/rapier3d-compat": "~0.12.0", - "@tweenjs/tween.js": "~23.1.3", - "@types/stats.js": "*", - "@types/webxr": ">=0.5.17", - "@webgpu/types": "*", - "fflate": "~0.8.2", - "meshoptimizer": "~1.0.1" - } - }, - "node_modules/@types/tough-cookie": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", - "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/webxr": { - "version": "0.5.24", - "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", - "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", - "license": "MIT" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.30.1.tgz", - "integrity": "sha512-v+VWphxMjn+1t48/jO4t950D6KR8JaJuNXzi33Ve6P8sEmPr5k6CEXjdGwT6+LodVnEa91EQCtwjWNUCPweo+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.30.1", - "@typescript-eslint/type-utils": "8.30.1", - "@typescript-eslint/utils": "8.30.1", - "@typescript-eslint/visitor-keys": "8.30.1", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.30.1.tgz", - "integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.30.1", - "@typescript-eslint/types": "8.30.1", - "@typescript-eslint/typescript-estree": "8.30.1", - "@typescript-eslint/visitor-keys": "8.30.1", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.30.1.tgz", - "integrity": "sha512-+C0B6ChFXZkuaNDl73FJxRYT0G7ufVPOSQkqkpM/U198wUwUFOtgo1k/QzFh1KjpBitaK7R1tgjVz6o9HmsRPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.30.1", - "@typescript-eslint/visitor-keys": "8.30.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.30.1.tgz", - "integrity": "sha512-64uBF76bfQiJyHgZISC7vcNz3adqQKIccVoKubyQcOnNcdJBvYOILV1v22Qhsw3tw3VQu5ll8ND6hycgAR5fEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "8.30.1", - "@typescript-eslint/utils": "8.30.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.0.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.30.1.tgz", - "integrity": "sha512-81KawPfkuulyWo5QdyG/LOKbspyyiW+p4vpn4bYO7DM/hZImlVnFwrpCTnmNMOt8CvLRr5ojI9nU1Ekpw4RcEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.30.1.tgz", - "integrity": "sha512-kQQnxymiUy9tTb1F2uep9W6aBiYODgq5EMSk6Nxh4Z+BDUoYUSa029ISs5zTzKBFnexQEh71KqwjKnRz58lusQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.30.1", - "@typescript-eslint/visitor-keys": "8.30.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.0.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.30.1.tgz", - "integrity": "sha512-T/8q4R9En2tcEsWPQgB5BQ0XJVOtfARcUvOa8yJP3fh9M/mXraLxZrkCfGb6ChrO/V3W+Xbd04RacUEqk1CFEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.30.1", - "@typescript-eslint/types": "8.30.1", - "@typescript-eslint/typescript-estree": "8.30.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.30.1.tgz", - "integrity": "sha512-aEhgas7aJ6vZnNFC7K4/vMGDGyOiqWcYZPpIWrTKuTAlsvDNKy2GFDqh9smL+iq069ZvR0YzEeq0B8NJlLzjFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.30.1", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@vitest/browser": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-2.1.9.tgz", - "integrity": "sha512-AHDanTP4Ed6J5R6wRBcWRQ+AxgMnNJxsbaa229nFQz5KOMFZqlW11QkIDoLgCjBOpQ1+c78lTN5jVxO8ME+S4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@testing-library/dom": "^10.4.0", - "@testing-library/user-event": "^14.5.2", - "@vitest/mocker": "2.1.9", - "@vitest/utils": "2.1.9", - "magic-string": "^0.30.12", - "msw": "^2.6.4", - "sirv": "^3.0.0", - "tinyrainbow": "^1.2.0", - "ws": "^8.18.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "playwright": "*", - "vitest": "2.1.9", - "webdriverio": "*" - }, - "peerDependenciesMeta": { - "playwright": { - "optional": true - }, - "safaridriver": { - "optional": true - }, - "webdriverio": { - "optional": true - } - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/browser/node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/browser/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/@vitest/browser/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/@vitest/browser/node_modules/vite": { - "version": "5.4.19", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", - "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/@vitest/coverage-v8": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", - "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.7", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.12", - "magicast": "^0.3.5", - "std-env": "^3.8.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "2.1.9", - "vitest": "2.1.9" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/coverage-v8/node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@vitest/coverage-v8/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@vitest/coverage-v8/node_modules/test-exclude": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", - "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^9.0.4" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/ui": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-2.1.9.tgz", - "integrity": "sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "2.1.9", - "fflate": "^0.8.2", - "flatted": "^3.3.1", - "pathe": "^1.1.2", - "sirv": "^3.0.0", - "tinyglobby": "^0.2.10", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "vitest": "2.1.9" - } - }, - "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@webgpu/types": { - "version": "0.1.69", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", - "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@yarnpkg/lockfile": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", - "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/@yarnpkg/parsers": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@yarnpkg/parsers/-/parsers-3.0.2.tgz", - "integrity": "sha512-/HcYgtUSiJiot/XWGLOlGxPYUG65+/31V8oqk17vZLW1xlCoR4PampyePljOxY2n8/3jz9+tIFzICsyGujJZoA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "js-yaml": "^3.10.0", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=18.12.0" - } - }, - "node_modules/@yarnpkg/parsers/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@yarnpkg/parsers/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@zkochan/js-yaml": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@zkochan/js-yaml/-/js-yaml-0.0.7.tgz", - "integrity": "sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/abab": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true - }, - "node_modules/abbrev": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", - "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-globals": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", - "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "acorn": "^8.1.0", - "acorn-walk": "^8.0.2" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/add-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/add-stream/-/add-stream-1.0.0.tgz", - "integrity": "sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/apache-arrow": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.1.0.tgz", - "integrity": "sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.11", - "@types/command-line-args": "^5.2.3", - "@types/command-line-usage": "^5.0.4", - "@types/node": "^24.0.3", - "command-line-args": "^6.0.1", - "command-line-usage": "^7.0.1", - "flatbuffers": "^25.1.24", - "json-bignum": "^0.0.3", - "tslib": "^2.6.2" - }, - "bin": { - "arrow2csv": "bin/arrow2csv.js" - } - }, - "node_modules/apache-arrow/node_modules/@types/node": { - "version": "24.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", - "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/apache-arrow/node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "license": "MIT" - }, - "node_modules/aproba": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", - "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/array-back": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.3.tgz", - "integrity": "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==", - "license": "MIT", - "engines": { - "node": ">=12.17" - } - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-differ": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-3.0.0.tgz", - "integrity": "sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/array-ify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", - "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", - "dev": true, - "license": "MIT" - }, - "node_modules/array-includes": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", - "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "is-string": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", - "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-shim-unscopables": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/arrify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", - "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true, - "license": "MIT" - }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/autoprefixer": { - "version": "10.4.27", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", - "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001774", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/axios": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", - "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.13", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", - "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/before-after-hook": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", - "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/bin-links": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-4.0.4.tgz", - "integrity": "sha512-cMtq4W5ZsEwcutJrVId+a/tjt8GSbS+h0oNkdl6+6rBuEv8Ot33Bevj5KPm40t309zuhVic8NjpuL42QCiJWWA==", - "dev": true, - "license": "ISC", - "dependencies": { - "cmd-shim": "^6.0.0", - "npm-normalize-package-bin": "^3.0.0", - "read-cmd-shim": "^4.0.0", - "write-file-atomic": "^5.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/byte-size": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/byte-size/-/byte-size-8.1.1.tgz", - "integrity": "sha512-tUkzZWK0M/qdoLEqikxBWe4kumyuwjl3HO6zHTr4yEI23EojPtLYXdG1+AQY7MN0cGyNDvEaJ8wiYQm6P2bPxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.17" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cacache": { - "version": "18.0.4", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.4.tgz", - "integrity": "sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^3.1.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^4.0.0", - "ssri": "^10.0.0", - "tar": "^6.1.11", - "unique-filename": "^3.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase-keys": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", - "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "camelcase": "^5.3.1", - "map-obj": "^4.0.0", - "quick-lru": "^4.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/camelcase-keys/node_modules/map-obj": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", - "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001784", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", - "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chai": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.1.tgz", - "integrity": "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk-template": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", - "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", - "license": "MIT", - "dependencies": { - "chalk": "^4.1.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/chalk-template?sponsor=1" - } - }, - "node_modules/chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", - "dev": true, - "license": "MIT" - }, - "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/cli-truncate/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/cli-truncate/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/cli-width": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 10" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/clone-deep": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", - "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4", - "kind-of": "^6.0.2", - "shallow-clone": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/cmd-shim": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-6.0.3.tgz", - "integrity": "sha512-FMabTRlc5t5zjdenF6mS0MBeFZm0XqHqeOkcskKFb/LYCcRQ5fVgLOHVc4Lq9CqABd9zhjwPjMBCJvMCziSVtA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "dev": true, - "license": "ISC", - "bin": { - "color-support": "bin.js" - } - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/columnify": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/columnify/-/columnify-1.6.0.tgz", - "integrity": "sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "strip-ansi": "^6.0.1", - "wcwidth": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/command-line-args": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-6.0.2.tgz", - "integrity": "sha512-AIjYVxrV9X752LmPDLbVYv8aMCuHPSLZJXEo2qo/xJfv+NYhaZ4sMSF01rM+gHPaMgvPM0l5D/F+Qx+i2WfSmQ==", - "license": "MIT", - "dependencies": { - "array-back": "^6.2.3", - "find-replace": "^5.0.2", - "lodash.camelcase": "^4.3.0", - "typical": "^7.3.0" - }, - "engines": { - "node": ">=12.20" - }, - "peerDependencies": { - "@75lb/nature": "latest" - }, - "peerDependenciesMeta": { - "@75lb/nature": { - "optional": true - } - } - }, - "node_modules/command-line-usage": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.4.tgz", - "integrity": "sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==", - "license": "MIT", - "dependencies": { - "array-back": "^6.2.2", - "chalk-template": "^0.4.0", - "table-layout": "^4.1.1", - "typical": "^7.3.0" - }, - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/common-ancestor-path": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", - "integrity": "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==", - "dev": true, - "license": "ISC" - }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "dev": true, - "license": "MIT" - }, - "node_modules/compare-func": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", - "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-ify": "^1.0.0", - "dot-prop": "^5.1.0" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "dev": true, - "engines": [ - "node >= 6.0" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" - } - }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/conventional-changelog-angular": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", - "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "compare-func": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/conventional-changelog-conventionalcommits": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz", - "integrity": "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==", - "dev": true, - "license": "ISC", - "dependencies": { - "compare-func": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/conventional-changelog-core": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/conventional-changelog-core/-/conventional-changelog-core-5.0.1.tgz", - "integrity": "sha512-Rvi5pH+LvgsqGwZPZ3Cq/tz4ty7mjijhr3qR4m9IBXNbxGGYgTVVO+duXzz9aArmHxFtwZ+LRkrNIMDQzgoY4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "add-stream": "^1.0.0", - "conventional-changelog-writer": "^6.0.0", - "conventional-commits-parser": "^4.0.0", - "dateformat": "^3.0.3", - "get-pkg-repo": "^4.2.1", - "git-raw-commits": "^3.0.0", - "git-remote-origin-url": "^2.0.0", - "git-semver-tags": "^5.0.0", - "normalize-package-data": "^3.0.3", - "read-pkg": "^3.0.0", - "read-pkg-up": "^3.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/conventional-changelog-core/node_modules/find-up": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", - "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/conventional-changelog-core/node_modules/hosted-git-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", - "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/conventional-changelog-core/node_modules/locate-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", - "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^2.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/conventional-changelog-core/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/conventional-changelog-core/node_modules/normalize-package-data": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", - "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^4.0.1", - "is-core-module": "^2.5.0", - "semver": "^7.3.4", - "validate-npm-package-license": "^3.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/conventional-changelog-core/node_modules/p-limit": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", - "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/conventional-changelog-core/node_modules/p-locate": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", - "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/conventional-changelog-core/node_modules/p-try": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", - "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/conventional-changelog-core/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/conventional-changelog-core/node_modules/read-pkg-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-3.0.0.tgz", - "integrity": "sha512-YFzFrVvpC6frF1sz8psoHDBGF7fLPc+llq/8NB43oagqWkx8ar5zYtsTORtOjw9W2RHLpWP+zTWwBvf1bCmcSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^2.0.0", - "read-pkg": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/conventional-changelog-preset-loader": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-preset-loader/-/conventional-changelog-preset-loader-3.0.0.tgz", - "integrity": "sha512-qy9XbdSLmVnwnvzEisjxdDiLA4OmV3o8db+Zdg4WiFw14fP3B6XNz98X0swPPpkTd/pc1K7+adKgEDM1JCUMiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/conventional-changelog-writer": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-6.0.1.tgz", - "integrity": "sha512-359t9aHorPw+U+nHzUXHS5ZnPBOizRxfQsWT5ZDHBfvfxQOAik+yfuhKXG66CN5LEWPpMNnIMHUTCKeYNprvHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "conventional-commits-filter": "^3.0.0", - "dateformat": "^3.0.3", - "handlebars": "^4.7.7", - "json-stringify-safe": "^5.0.1", - "meow": "^8.1.2", - "semver": "^7.0.0", - "split": "^1.0.1" - }, - "bin": { - "conventional-changelog-writer": "cli.js" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/conventional-commits-filter": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-3.0.0.tgz", - "integrity": "sha512-1ymej8b5LouPx9Ox0Dw/qAO2dVdfpRFq28e5Y0jJEU8ZrLdy0vOSkkIInwmxErFGhg6SALro60ZrwYFVTUDo4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash.ismatch": "^4.4.0", - "modify-values": "^1.0.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/conventional-commits-parser": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-4.0.0.tgz", - "integrity": "sha512-WRv5j1FsVM5FISJkoYMR6tPk07fkKT0UodruX4je86V4owk451yjXAKzKAPOs9l7y59E2viHUS9eQ+dfUA9NSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-text-path": "^1.0.1", - "JSONStream": "^1.3.5", - "meow": "^8.1.2", - "split2": "^3.2.2" - }, - "bin": { - "conventional-commits-parser": "cli.js" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/conventional-recommended-bump": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/conventional-recommended-bump/-/conventional-recommended-bump-7.0.1.tgz", - "integrity": "sha512-Ft79FF4SlOFvX4PkwFDRnaNiIVX7YbmqGU0RwccUaiGvgp3S0a8ipR2/Qxk31vclDNM+GSdJOVs2KrsUCjblVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "concat-stream": "^2.0.0", - "conventional-changelog-preset-loader": "^3.0.0", - "conventional-commits-filter": "^3.0.0", - "conventional-commits-parser": "^4.0.0", - "git-raw-commits": "^3.0.0", - "git-semver-tags": "^5.0.0", - "meow": "^8.1.2" - }, - "bin": { - "conventional-recommended-bump": "cli.js" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/cosmiconfig-typescript-loader": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.1.0.tgz", - "integrity": "sha512-tJ1w35ZRUiM5FeTzT7DtYWAFFv37ZLqSRkGi2oeCK1gPhvaWjkAtfXvLmvE1pRfxxp9aQo6ba/Pvg1dKj05D4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "jiti": "^2.4.1" - }, - "engines": { - "node": ">=v18" - }, - "peerDependencies": { - "@types/node": "*", - "cosmiconfig": ">=9", - "typescript": ">=5" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cross-spawn/node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/cross-spawn/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cssom": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "cssom": "~0.3.6" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cssstyle/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/dargs": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/dargs/-/dargs-7.0.0.tgz", - "integrity": "sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/dateformat": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz", - "integrity": "sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/decamelize-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", - "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", - "dev": true, - "license": "MIT", - "dependencies": { - "decamelize": "^1.1.0", - "map-obj": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decimal.js": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", - "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/dedent": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", - "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/deprecation": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", - "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-indent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-5.0.0.tgz", - "integrity": "sha512-rlpvsxUtM0PQvy9iZe640/IWwWYyBsTApREbA1pHOpmOUIl9MkP/U4z7vTtg4Oaojvqhxt7sdufnT0EzGaR31g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dir-glob/node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/domexception": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", - "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/dompurify": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", - "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", - "dev": true, - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, - "node_modules/dot-prop": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-obj": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dotenv": { - "version": "16.4.7", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", - "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dotenv-expand": { - "version": "11.0.7", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", - "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dotenv": "^16.4.5" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dotenv-expand/node_modules/dotenv": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", - "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/draco3dgltf": { - "version": "1.5.7", - "resolved": "https://registry.npmjs.org/draco3dgltf/-/draco3dgltf-1.5.7.tgz", - "integrity": "sha512-LeqcpmoHIyYUi0z70/H3tMkGj8QhqVxq6FJGPjlzR24BNkQ6jyMheMvFKJBI0dzGZrEOUyQEmZ8axM1xRrbRiw==", - "license": "Apache-2.0" - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/duplexer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", - "dev": true, - "license": "MIT" - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/ejs": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", - "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "jake": "^10.8.5" - }, - "bin": { - "ejs": "bin/cli.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.331", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", - "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/enquirer": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", - "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-colors": "^4.1.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/envinfo": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.13.0.tgz", - "integrity": "sha512-cvcaMr7KqXVh4nyzGTVqTum+gAiL265x5jUWQIDLq//zOGbW+gSW/C+OWLleY/rs9Qole6AZLMXPbtIFQbqu+Q==", - "dev": true, - "license": "MIT", - "bin": { - "envinfo": "dist/cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true, - "license": "MIT" - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-abstract": { - "version": "1.23.9", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", - "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.0", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-regex": "^1.2.1", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.0", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.3", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.3", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.18" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/esbuild": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", - "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.8", - "@esbuild/android-arm": "0.25.8", - "@esbuild/android-arm64": "0.25.8", - "@esbuild/android-x64": "0.25.8", - "@esbuild/darwin-arm64": "0.25.8", - "@esbuild/darwin-x64": "0.25.8", - "@esbuild/freebsd-arm64": "0.25.8", - "@esbuild/freebsd-x64": "0.25.8", - "@esbuild/linux-arm": "0.25.8", - "@esbuild/linux-arm64": "0.25.8", - "@esbuild/linux-ia32": "0.25.8", - "@esbuild/linux-loong64": "0.25.8", - "@esbuild/linux-mips64el": "0.25.8", - "@esbuild/linux-ppc64": "0.25.8", - "@esbuild/linux-riscv64": "0.25.8", - "@esbuild/linux-s390x": "0.25.8", - "@esbuild/linux-x64": "0.25.8", - "@esbuild/netbsd-arm64": "0.25.8", - "@esbuild/netbsd-x64": "0.25.8", - "@esbuild/openbsd-arm64": "0.25.8", - "@esbuild/openbsd-x64": "0.25.8", - "@esbuild/openharmony-arm64": "0.25.8", - "@esbuild/sunos-x64": "0.25.8", - "@esbuild/win32-arm64": "0.25.8", - "@esbuild/win32-ia32": "0.25.8", - "@esbuild/win32-x64": "0.25.8" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/eslint": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz", - "integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.0", - "@eslint/core": "^0.12.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.24.0", - "@eslint/plugin-kit": "^0.2.7", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-config-prettier": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.2.tgz", - "integrity": "sha512-Epgp/EofAUeEpIdZkW60MHKvPyru1ruQJxPL+WIycnaPApuseK0Zpkrh/FwL9oIpQvIhJwV7ptOy0DWUjTlCiA==", - "dev": true, - "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-module-utils": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", - "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.31.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", - "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.8", - "array.prototype.findlastindex": "^1.2.5", - "array.prototype.flat": "^1.3.2", - "array.prototype.flatmap": "^1.3.2", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.0", - "hasown": "^2.0.2", - "is-core-module": "^2.15.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.0", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.8", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.14.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true, - "license": "MIT" - }, - "node_modules/execa": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.0.0.tgz", - "integrity": "sha512-ov6w/2LCiuyO4RLYGdpFGjkcs0wMTgGE8PrkTHikeUy5iJekXyPIKUjifk5CsE0pt7sMCrMZ3YNqoCj6idQOnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/execa/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/expect-type": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", - "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/exponential-backoff": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", - "integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/external-editor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", - "dev": true, - "license": "MIT", - "dependencies": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/external-editor/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/external-editor/node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "os-tmpdir": "~1.0.2" - }, - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "license": "MIT" - }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/figures/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/filelist": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "minimatch": "^5.0.1" - } - }, - "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-cache-dir": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", - "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", - "dev": true, - "license": "MIT", - "dependencies": { - "commondir": "^1.0.1", - "make-dir": "^3.0.2", - "pkg-dir": "^4.1.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/avajs/find-cache-dir?sponsor=1" - } - }, - "node_modules/find-cache-dir/node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/find-cache-dir/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/find-replace": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-5.0.2.tgz", - "integrity": "sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==", - "license": "MIT", - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@75lb/nature": "latest" - }, - "peerDependenciesMeta": { - "@75lb/nature": { - "optional": true - } - } - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "license": "BSD-3-Clause", - "bin": { - "flat": "cli.js" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatbuffers": { - "version": "25.9.23", - "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", - "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", - "license": "Apache-2.0" - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/front-matter": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/front-matter/-/front-matter-4.0.2.tgz", - "integrity": "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-yaml": "^3.13.1" - } - }, - "node_modules/front-matter/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/front-matter/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, - "license": "MIT" - }, - "node_modules/fs-extra": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", - "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/fs-minipass": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", - "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-pkg-repo": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/get-pkg-repo/-/get-pkg-repo-4.2.1.tgz", - "integrity": "sha512-2+QbHjFRfGB74v/pYWjd5OhU3TDIC2Gv/YKUTk/tCvAz0pkn/Mz6P3uByuBimLOcPvN2jYdScl3xGFSrx0jEcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@hutson/parse-repository-url": "^3.0.0", - "hosted-git-info": "^4.0.0", - "through2": "^2.0.0", - "yargs": "^16.2.0" - }, - "bin": { - "get-pkg-repo": "src/cli.js" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-pkg-repo/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/get-pkg-repo/node_modules/hosted-git-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", - "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/get-pkg-repo/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/get-pkg-repo/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/get-pkg-repo/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/get-port": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", - "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.0.tgz", - "integrity": "sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-tsconfig": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", - "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/git-raw-commits": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-3.0.0.tgz", - "integrity": "sha512-b5OHmZ3vAgGrDn/X0kS+9qCfNKWe4K/jFnhwzVWWg0/k5eLa3060tZShrRg8Dja5kPc+YjS0Gc6y7cRr44Lpjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "dargs": "^7.0.0", - "meow": "^8.1.2", - "split2": "^3.2.2" - }, - "bin": { - "git-raw-commits": "cli.js" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/git-remote-origin-url": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/git-remote-origin-url/-/git-remote-origin-url-2.0.0.tgz", - "integrity": "sha512-eU+GGrZgccNJcsDH5LkXR3PB9M958hxc7sbA8DFJjrv9j4L2P/eZfKhM+QD6wyzpiv+b1BpK0XrYCxkovtjSLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "gitconfiglocal": "^1.0.0", - "pify": "^2.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/git-remote-origin-url/node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/git-semver-tags": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/git-semver-tags/-/git-semver-tags-5.0.1.tgz", - "integrity": "sha512-hIvOeZwRbQ+7YEUmCkHqo8FOLQZCEn18yevLHADlFPZY02KJGsu5FZt9YW/lybfK2uhWFI7Qg/07LekJiTv7iA==", - "dev": true, - "license": "MIT", - "dependencies": { - "meow": "^8.1.2", - "semver": "^7.0.0" - }, - "bin": { - "git-semver-tags": "cli.js" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/git-up": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/git-up/-/git-up-7.0.0.tgz", - "integrity": "sha512-ONdIrbBCFusq1Oy0sC71F5azx8bVkvtZtMJAsv+a6lz5YAmbNnLD6HAB4gptHZVLPR8S2/kVN6Gab7lryq5+lQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-ssh": "^1.4.0", - "parse-url": "^8.1.0" - } - }, - "node_modules/git-url-parse": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/git-url-parse/-/git-url-parse-14.0.0.tgz", - "integrity": "sha512-NnLweV+2A4nCvn4U/m2AoYu0pPKlsmhK9cknG7IMwsjFY1S2jxM+mAhsDxyxfCIGfGaD+dozsyX4b6vkYc83yQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "git-up": "^7.0.0" - } - }, - "node_modules/gitconfiglocal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/gitconfiglocal/-/gitconfiglocal-1.0.0.tgz", - "integrity": "sha512-spLUXeTAVHxDtKsJc8FkFVgFtMdEN9qPGpL23VfSHx4fP4+Ds097IXLvymbnDH8FnmxX5Nr9bPw3A+AQ6mWEaQ==", - "dev": true, - "license": "BSD", - "dependencies": { - "ini": "^1.3.2" - } - }, - "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/global-directory": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", - "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ini": "4.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/global-directory/node_modules/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "node_modules/graphql": { - "version": "16.11.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", - "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, - "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "node_modules/happy-dom": { - "version": "15.11.7", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-15.11.7.tgz", - "integrity": "sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^4.5.0", - "webidl-conversions": "^7.0.0", - "whatwg-mimetype": "^3.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/hard-rejection": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", - "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/headers-polyfill": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", - "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/hosted-git-info": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", - "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^10.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "whatwg-encoding": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/http-proxy-agent/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/https-proxy-agent/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", - "dev": true, - "license": "MIT", - "bin": { - "husky": "bin.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/ignore-walk": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.5.tgz", - "integrity": "sha512-VuuG0wCnjhnylG1ABXT3dAuIpTNDs/G8jlpmwXY03fXoXy/8ZK8/T+hMzt8L4WnrLCJgdybqgPagnF/f97cg3A==", - "dev": true, - "license": "ISC", - "dependencies": { - "minimatch": "^9.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/ignore-walk/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/image-size": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.7.5.tgz", - "integrity": "sha512-Hiyv+mXHfFEP7LzUL/llg9RwFxxY+o9N3JVLIeG5E7iFIFAalxvRU9UZthBdYDEVnzHMgjnKJPPpay5BWf1g9g==", - "license": "MIT", - "bin": { - "image-size": "bin/image-size.js" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-fresh/node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/import-meta-resolve": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", - "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC" - }, - "node_modules/init-package-json": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/init-package-json/-/init-package-json-6.0.3.tgz", - "integrity": "sha512-Zfeb5ol+H+eqJWHTaGca9BovufyGeIfr4zaaBorPmJBMrJ+KBnN+kQx2ZtXdsotUTgldHmHQV44xvUWOUA7E2w==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/package-json": "^5.0.0", - "npm-package-arg": "^11.0.0", - "promzard": "^1.0.0", - "read": "^3.0.1", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4", - "validate-npm-package-name": "^5.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/inquirer": { - "version": "8.2.6", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", - "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.1", - "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "external-editor": "^3.0.3", - "figures": "^3.0.0", - "lodash": "^4.17.21", - "mute-stream": "0.0.8", - "ora": "^5.4.1", - "run-async": "^2.4.0", - "rxjs": "^7.5.5", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6", - "wrap-ansi": "^6.0.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/inquirer/node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/inquirer/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, - "engines": { - "node": ">= 12" - } - }, - "node_modules/ip-address/node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-ci": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", - "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ci-info": "^3.2.0" - }, - "bin": { - "is-ci": "bin.js" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-node-process": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", - "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-plain-obj": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", - "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-ssh": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.4.1.tgz", - "integrity": "sha512-JNeu1wQsHjyHgn9NcWTaXq6zWSR6hqE0++zhfZlkFBbScNkyvxCdeV8sRkSBaeLKxmbpR21brail63ACNxJ0Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "protocols": "^2.0.1" - } - }, - "node_modules/is-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", - "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-text-path": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-1.0.1.tgz", - "integrity": "sha512-xFuJpne9oFz5qDaodwmmG08e3CawH/2ZV8Qqza1Ko7Sk8POWbkRdwIoAWVhqvq0XeUzANEhKo2n0IXUGBm7A/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "text-extensions": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16" - } - }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", - "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jake": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", - "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "async": "^3.2.3", - "chalk": "^4.0.2", - "filelist": "^1.0.4", - "minimatch": "^3.1.2" - }, - "bin": { - "jake": "bin/cli.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsdom": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", - "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "abab": "^2.0.6", - "acorn": "^8.8.1", - "acorn-globals": "^7.0.0", - "cssom": "^0.5.0", - "cssstyle": "^2.3.0", - "data-urls": "^3.0.2", - "decimal.js": "^10.4.2", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.2", - "parse5": "^7.1.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^4.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0", - "ws": "^8.11.0", - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "canvas": "^2.5.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/json-bignum": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz", - "integrity": "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-parse-better-errors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", - "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-parse-even-better-errors": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz", - "integrity": "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stringify-nice": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/json-stringify-nice/-/json-stringify-nice-1.1.4.tgz", - "integrity": "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw==", - "dev": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsonc-parser": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", - "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", - "dev": true, - "engines": [ - "node >= 0.2.0" - ], - "license": "MIT" - }, - "node_modules/JSONStream": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", - "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", - "dev": true, - "license": "(MIT OR Apache-2.0)", - "dependencies": { - "jsonparse": "^1.2.0", - "through": ">=2.2.7 <3" - }, - "bin": { - "JSONStream": "bin.js" - }, - "engines": { - "node": "*" - } - }, - "node_modules/just-diff": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/just-diff/-/just-diff-6.0.2.tgz", - "integrity": "sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA==", - "dev": true, - "license": "MIT" - }, - "node_modules/just-diff-apply": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/just-diff-apply/-/just-diff-apply-5.5.0.tgz", - "integrity": "sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw==", - "dev": true, - "license": "MIT" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ktx-parse": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-0.7.1.tgz", - "integrity": "sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ==", - "license": "MIT" - }, - "node_modules/lerna": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/lerna/-/lerna-8.2.2.tgz", - "integrity": "sha512-GkqBELTG4k7rfzAwRok2pKBvhNo046Hfwcj7TuhDah3q58/BBBAqvIFLfqEI5fglnNOs6maMSn6/MWjccQE55A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@lerna/create": "8.2.2", - "@npmcli/arborist": "7.5.4", - "@npmcli/package-json": "5.2.0", - "@npmcli/run-script": "8.1.0", - "@nx/devkit": ">=17.1.2 < 21", - "@octokit/plugin-enterprise-rest": "6.0.1", - "@octokit/rest": "20.1.2", - "aproba": "2.0.0", - "byte-size": "8.1.1", - "chalk": "4.1.0", - "clone-deep": "4.0.1", - "cmd-shim": "6.0.3", - "color-support": "1.1.3", - "columnify": "1.6.0", - "console-control-strings": "^1.1.0", - "conventional-changelog-angular": "7.0.0", - "conventional-changelog-core": "5.0.1", - "conventional-recommended-bump": "7.0.1", - "cosmiconfig": "9.0.0", - "dedent": "1.5.3", - "envinfo": "7.13.0", - "execa": "5.0.0", - "fs-extra": "^11.2.0", - "get-port": "5.1.1", - "get-stream": "6.0.0", - "git-url-parse": "14.0.0", - "glob-parent": "6.0.2", - "globby": "11.1.0", - "graceful-fs": "4.2.11", - "has-unicode": "2.0.1", - "import-local": "3.1.0", - "ini": "^1.3.8", - "init-package-json": "6.0.3", - "inquirer": "^8.2.4", - "is-ci": "3.0.1", - "is-stream": "2.0.0", - "jest-diff": ">=29.4.3 < 30", - "js-yaml": "4.1.0", - "libnpmaccess": "8.0.6", - "libnpmpublish": "9.0.9", - "load-json-file": "6.2.0", - "lodash": "^4.17.21", - "make-dir": "4.0.0", - "minimatch": "3.0.5", - "multimatch": "5.0.0", - "node-fetch": "2.6.7", - "npm-package-arg": "11.0.2", - "npm-packlist": "8.0.2", - "npm-registry-fetch": "^17.1.0", - "nx": ">=17.1.2 < 21", - "p-map": "4.0.0", - "p-map-series": "2.1.0", - "p-pipe": "3.1.0", - "p-queue": "6.6.2", - "p-reduce": "2.1.0", - "p-waterfall": "2.1.1", - "pacote": "^18.0.6", - "pify": "5.0.0", - "read-cmd-shim": "4.0.0", - "resolve-from": "5.0.0", - "rimraf": "^4.4.1", - "semver": "^7.3.8", - "set-blocking": "^2.0.0", - "signal-exit": "3.0.7", - "slash": "3.0.0", - "ssri": "^10.0.6", - "string-width": "^4.2.3", - "strong-log-transformer": "2.1.0", - "tar": "6.2.1", - "temp-dir": "1.0.0", - "typescript": ">=3 < 6", - "upath": "2.0.1", - "uuid": "^10.0.0", - "validate-npm-package-license": "3.0.4", - "validate-npm-package-name": "5.0.1", - "wide-align": "1.1.5", - "write-file-atomic": "5.0.1", - "write-pkg": "4.0.0", - "yargs": "17.7.2", - "yargs-parser": "21.1.1" - }, - "bin": { - "lerna": "dist/cli.js" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/lerna/node_modules/@npmcli/package-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-5.2.0.tgz", - "integrity": "sha512-qe/kiqqkW0AGtvBjL8TJKZk/eBBSpnJkUWvHdQ9jM2lKHXRYYJuyNpJPlJw3c8QjC2ow6NZYiLExhUaeJelbxQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^5.0.0", - "glob": "^10.2.2", - "hosted-git-info": "^7.0.0", - "json-parse-even-better-errors": "^3.0.0", - "normalize-package-data": "^6.0.0", - "proc-log": "^4.0.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/lerna/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/lerna/node_modules/chalk": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", - "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/lerna/node_modules/import-local": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", - "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lerna/node_modules/minimatch": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.5.tgz", - "integrity": "sha512-tUpxzX0VAzJHjLu0xUfFv1gwVp9ba3IOuRAVH2EGuRW8a5emA2FlACLqiT/lDVtS1W+TGNwqz3sWaNyLgDJWuw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/lerna/node_modules/minipass": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", - "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/lerna/node_modules/npm-package-arg": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.2.tgz", - "integrity": "sha512-IGN0IAwmhDJwy13Wc8k+4PEbTPhpJnMtfR53ZbOyjkvmEcLS4nCwp6mvMWjS5sUjeiW3mpx6cHmuhKEu9XmcQw==", - "dev": true, - "license": "ISC", - "dependencies": { - "hosted-git-info": "^7.0.0", - "proc-log": "^4.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^5.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/lerna/node_modules/pify": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", - "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lerna/node_modules/rimraf": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-4.4.1.tgz", - "integrity": "sha512-Gk8NlF062+T9CqNGn6h4tls3k6T1+/nXdOcSZVikNVtlRdYpA7wRJJMoXmuvOnLW844rPjdQ7JgXCYM6PPC/og==", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^9.2.0" - }, - "bin": { - "rimraf": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/lerna/node_modules/rimraf/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/lerna/node_modules/rimraf/node_modules/glob": { - "version": "9.3.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", - "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "minimatch": "^8.0.2", - "minipass": "^4.2.4", - "path-scurry": "^1.6.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/lerna/node_modules/rimraf/node_modules/minimatch": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz", - "integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/libnpmaccess": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/libnpmaccess/-/libnpmaccess-8.0.6.tgz", - "integrity": "sha512-uM8DHDEfYG6G5gVivVl+yQd4pH3uRclHC59lzIbSvy7b5FEwR+mU49Zq1jEyRtRFv7+M99mUW9S0wL/4laT4lw==", - "dev": true, - "license": "ISC", - "dependencies": { - "npm-package-arg": "^11.0.2", - "npm-registry-fetch": "^17.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/libnpmpublish": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/libnpmpublish/-/libnpmpublish-9.0.9.tgz", - "integrity": "sha512-26zzwoBNAvX9AWOPiqqF6FG4HrSCPsHFkQm7nT+xU1ggAujL/eae81RnCv4CJ2In9q9fh10B88sYSzKCUh/Ghg==", - "dev": true, - "license": "ISC", - "dependencies": { - "ci-info": "^4.0.0", - "normalize-package-data": "^6.0.1", - "npm-package-arg": "^11.0.2", - "npm-registry-fetch": "^17.0.1", - "proc-log": "^4.2.0", - "semver": "^7.3.7", - "sigstore": "^2.2.0", - "ssri": "^10.0.6" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/libnpmpublish/node_modules/ci-info": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz", - "integrity": "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "uc.micro": "^2.0.0" - } - }, - "node_modules/lint-staged": { - "version": "15.5.1", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.1.tgz", - "integrity": "sha512-6m7u8mue4Xn6wK6gZvSCQwBvMBR36xfY24nF5bMTf2MHDYG6S3yhJuOgdYVw99hsjyDt2d4z168b3naI8+NWtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^5.4.1", - "commander": "^13.1.0", - "debug": "^4.4.0", - "execa": "^8.0.1", - "lilconfig": "^3.1.3", - "listr2": "^8.2.5", - "micromatch": "^4.0.8", - "pidtree": "^0.6.0", - "string-argv": "^0.3.2", - "yaml": "^2.7.0" - }, - "bin": { - "lint-staged": "bin/lint-staged.js" - }, - "engines": { - "node": ">=18.12.0" - }, - "funding": { - "url": "https://opencollective.com/lint-staged" - } - }, - "node_modules/lint-staged/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/lint-staged/node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/lint-staged/node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=16.17.0" - } - }, - "node_modules/lint-staged/node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/lint-staged/node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/listr2": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.2.tgz", - "integrity": "sha512-vsBzcU4oE+v0lj4FhVLzr9dBTv4/fHIa57l+GCwovP8MoFNZJTOhGU8PXd4v2VJCbECAaijBiHntiekFMLvo0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^4.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/listr2/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/listr2/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/listr2/node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true, - "license": "MIT" - }, - "node_modules/listr2/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/listr2/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/load-json-file": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-6.2.0.tgz", - "integrity": "sha512-gUD/epcRms75Cw8RT1pUdHugZYM5ce64ucs2GEISABwkRsOQr0q2wm/MV2TKThycIe5e0ytRweW2RZxclogCdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.15", - "parse-json": "^5.0.0", - "strip-bom": "^4.0.0", - "type-fest": "^0.6.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/load-json-file/node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "license": "MIT" - }, - "node_modules/lodash.ismatch": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", - "integrity": "sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.kebabcase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", - "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.snakecase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.startcase": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", - "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.uniq": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.upperfirst": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", - "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-escapes": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", - "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", - "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", - "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/loupe": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.0.tgz", - "integrity": "sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/lunr": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", - "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-fetch-happen": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz", - "integrity": "sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/agent": "^2.0.0", - "cacache": "^18.0.0", - "http-cache-semantics": "^4.1.1", - "is-lambda": "^1.0.1", - "minipass": "^7.0.2", - "minipass-fetch": "^3.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "proc-log": "^4.2.0", - "promise-retry": "^2.0.1", - "ssri": "^10.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/map-obj": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", - "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.0", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" - }, - "bin": { - "markdown-it": "bin/markdown-it.mjs" - } - }, - "node_modules/marked": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", - "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", - "dev": true, - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "dev": true, - "license": "MIT" - }, - "node_modules/meow": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", - "integrity": "sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/minimist": "^1.2.0", - "camelcase-keys": "^6.2.2", - "decamelize-keys": "^1.1.0", - "hard-rejection": "^2.1.0", - "minimist-options": "4.1.0", - "normalize-package-data": "^3.0.0", - "read-pkg-up": "^7.0.1", - "redent": "^3.0.0", - "trim-newlines": "^3.0.0", - "type-fest": "^0.18.0", - "yargs-parser": "^20.2.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/meow/node_modules/hosted-git-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", - "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/meow/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/meow/node_modules/normalize-package-data": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", - "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^4.0.1", - "is-core-module": "^2.5.0", - "semver": "^7.3.4", - "validate-npm-package-license": "^3.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/meow/node_modules/type-fest": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", - "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/meow/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/meshoptimizer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", - "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==", - "license": "MIT" - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimatch/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minimist-options": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", - "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "arrify": "^1.0.1", - "is-plain-obj": "^1.1.0", - "kind-of": "^6.0.3" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-collect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", - "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-fetch": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.5.tgz", - "integrity": "sha512-2N8elDQAtSnFV0Dk7gt15KHsS0Fyz6CbYZ360h0WTYV1Ty46li3rAXVOQj1THMNLdmrD9Vt5pBPtWtVkpwGBqg==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/modify-values": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", - "integrity": "sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/monaco-editor": { - "version": "0.55.1", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", - "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "dompurify": "3.2.7", - "marked": "14.0.0" - } - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/msw": { - "version": "2.10.5", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.10.5.tgz", - "integrity": "sha512-0EsQCrCI1HbhpBWd89DvmxY6plmvrM96b0sCIztnvcNHQbXn5vqwm1KlXslo6u4wN9LFGLC1WFjjgljcQhe40A==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@bundled-es-modules/cookie": "^2.0.1", - "@bundled-es-modules/statuses": "^1.0.1", - "@bundled-es-modules/tough-cookie": "^0.1.6", - "@inquirer/confirm": "^5.0.0", - "@mswjs/interceptors": "^0.39.1", - "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/until": "^2.1.0", - "@types/cookie": "^0.6.0", - "@types/statuses": "^2.0.4", - "graphql": "^16.8.1", - "headers-polyfill": "^4.0.2", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "path-to-regexp": "^6.3.0", - "picocolors": "^1.1.1", - "strict-event-emitter": "^0.5.1", - "type-fest": "^4.26.1", - "yargs": "^17.7.2" - }, - "bin": { - "msw": "cli/index.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mswjs" - }, - "peerDependencies": { - "typescript": ">= 4.8.x" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/msw/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/multimatch": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-5.0.0.tgz", - "integrity": "sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/minimatch": "^3.0.3", - "array-differ": "^3.0.0", - "array-union": "^2.1.0", - "arrify": "^2.0.1", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/multimatch/node_modules/arrify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", - "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "dev": true, - "license": "ISC" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/node-gyp": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.3.1.tgz", - "integrity": "sha512-Pp3nFHBThHzVtNY7U6JfPjvT/DTE8+o/4xKsLQtBoU+j2HLsGlhcfzflAoUreaJbNmYnX+LlLi0qjV8kpyO6xQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "glob": "^10.3.10", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^13.0.0", - "nopt": "^7.0.0", - "proc-log": "^4.1.0", - "semver": "^7.3.5", - "tar": "^6.2.1", - "which": "^4.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/node-machine-id": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/node-machine-id/-/node-machine-id-1.1.12.tgz", - "integrity": "sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", - "dev": true, - "license": "MIT" - }, - "node_modules/nopt": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", - "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", - "dev": true, - "license": "ISC", - "dependencies": { - "abbrev": "^2.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/normalize-package-data": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", - "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^7.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm-bundled": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.1.tgz", - "integrity": "sha512-+AvaheE/ww1JEwRHOrn4WHNzOxGtVp+adrg2AeZS/7KuxGUYFuBta98wYpfHBbJp6Tg6j1NKSEVHNcfZzJHQwQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "npm-normalize-package-bin": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm-install-checks": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-6.3.0.tgz", - "integrity": "sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "semver": "^7.1.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm-normalize-package-bin": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", - "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm-package-arg": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", - "integrity": "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==", - "dev": true, - "license": "ISC", - "dependencies": { - "hosted-git-info": "^7.0.0", - "proc-log": "^4.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^5.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm-packlist": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-8.0.2.tgz", - "integrity": "sha512-shYrPFIS/JLP4oQmAwDyk5HcyysKW8/JLTEA32S0Z5TzvpaeeX2yMFfoK1fjEBnCBvVyIB/Jj/GBFdm0wsgzbA==", - "dev": true, - "license": "ISC", - "dependencies": { - "ignore-walk": "^6.0.4" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm-pick-manifest": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.1.0.tgz", - "integrity": "sha512-nkc+3pIIhqHVQr085X9d2JzPzLyjzQS96zbruppqC9aZRm/x8xx6xhI98gHtsfELP2bE+loHq8ZaHFHhe+NauA==", - "dev": true, - "license": "ISC", - "dependencies": { - "npm-install-checks": "^6.0.0", - "npm-normalize-package-bin": "^3.0.0", - "npm-package-arg": "^11.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm-registry-fetch": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-17.1.0.tgz", - "integrity": "sha512-5+bKQRH0J1xG1uZ1zMNvxW0VEyoNWgJpY9UDuluPFLKDfJ9u2JmmjmTJV1srBGQOROfdBMiVvnH2Zvpbm+xkVA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/redact": "^2.0.0", - "jsonparse": "^1.3.1", - "make-fetch-happen": "^13.0.0", - "minipass": "^7.0.2", - "minipass-fetch": "^3.0.0", - "minizlib": "^2.1.2", - "npm-package-arg": "^11.0.0", - "proc-log": "^4.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/nwsapi": { - "version": "2.2.20", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", - "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/nx": { - "version": "20.8.0", - "resolved": "https://registry.npmjs.org/nx/-/nx-20.8.0.tgz", - "integrity": "sha512-+BN5B5DFBB5WswD8flDDTnr4/bf1VTySXOv60aUAllHqR+KS6deT0p70TTMZF4/A2n/L2UCWDaDro37MGaYozA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@napi-rs/wasm-runtime": "0.2.4", - "@yarnpkg/lockfile": "^1.1.0", - "@yarnpkg/parsers": "3.0.2", - "@zkochan/js-yaml": "0.0.7", - "axios": "^1.8.3", - "chalk": "^4.1.0", - "cli-cursor": "3.1.0", - "cli-spinners": "2.6.1", - "cliui": "^8.0.1", - "dotenv": "~16.4.5", - "dotenv-expand": "~11.0.6", - "enquirer": "~2.3.6", - "figures": "3.2.0", - "flat": "^5.0.2", - "front-matter": "^4.0.2", - "ignore": "^5.0.4", - "jest-diff": "^29.4.1", - "jsonc-parser": "3.2.0", - "lines-and-columns": "2.0.3", - "minimatch": "9.0.3", - "node-machine-id": "1.1.12", - "npm-run-path": "^4.0.1", - "open": "^8.4.0", - "ora": "5.3.0", - "resolve.exports": "2.0.3", - "semver": "^7.5.3", - "string-width": "^4.2.3", - "tar-stream": "~2.2.0", - "tmp": "~0.2.1", - "tsconfig-paths": "^4.1.2", - "tslib": "^2.3.0", - "yaml": "^2.6.0", - "yargs": "^17.6.2", - "yargs-parser": "21.1.1" - }, - "bin": { - "nx": "bin/nx.js", - "nx-cloud": "bin/nx-cloud.js" - }, - "optionalDependencies": { - "@nx/nx-darwin-arm64": "20.8.0", - "@nx/nx-darwin-x64": "20.8.0", - "@nx/nx-freebsd-x64": "20.8.0", - "@nx/nx-linux-arm-gnueabihf": "20.8.0", - "@nx/nx-linux-arm64-gnu": "20.8.0", - "@nx/nx-linux-arm64-musl": "20.8.0", - "@nx/nx-linux-x64-gnu": "20.8.0", - "@nx/nx-linux-x64-musl": "20.8.0", - "@nx/nx-win32-arm64-msvc": "20.8.0", - "@nx/nx-win32-x64-msvc": "20.8.0" - }, - "peerDependencies": { - "@swc-node/register": "^1.8.0", - "@swc/core": "^1.3.85" - }, - "peerDependenciesMeta": { - "@swc-node/register": { - "optional": true - }, - "@swc/core": { - "optional": true - } - } - }, - "node_modules/nx/node_modules/cli-spinners": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.6.1.tgz", - "integrity": "sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/nx/node_modules/lines-and-columns": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.3.tgz", - "integrity": "sha512-cNOjgCnLB+FnvWWtyRTzmB3POJ+cXxTA81LoW7u8JdmhfXzriropYwpjShnz1QLLWsQwY7nIxoDmcPTwphDK9w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/nx/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/nx/node_modules/tsconfig-paths": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", - "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "json5": "^2.2.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/ora": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.3.0.tgz", - "integrity": "sha512-zAKMgGXUim0Jyd6CXK9lraBnD3H5yPGBPPOkC23a2BG6hsm4Zu6OQSjQuEtV0BHDf4aKHcUFvJiGRrFuW3MG8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "log-symbols": "^4.0.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/outvariant": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", - "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", - "dev": true, - "license": "MIT" - }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-locate/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-map-series": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/p-map-series/-/p-map-series-2.1.0.tgz", - "integrity": "sha512-RpYIIK1zXSNEOdwxcfe7FdvGcs7+y5n8rifMhMNWvaxRNMPINJHF5GDeuVxWqnfrcHPSCnp7Oo5yNXHId9Av2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/p-pipe": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-pipe/-/p-pipe-3.1.0.tgz", - "integrity": "sha512-08pj8ATpzMR0Y80x50yJHn37NF6vjrqHutASaX5LiH5npS9XPvrUmscd9MF5R4fuYRHOxQR1FfMIlF7AzwoPqw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-queue": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", - "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.4", - "p-timeout": "^3.2.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-reduce": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-2.1.0.tgz", - "integrity": "sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/p-timeout": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", - "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-finally": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/p-waterfall": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-waterfall/-/p-waterfall-2.1.1.tgz", - "integrity": "sha512-RRTnDb2TBG/epPRI2yYXsimO0v3BXC8Yd3ogr1545IaqKK17VGhbWVeGGN+XfCm/08OK8635nH31c8bATkHuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-reduce": "^2.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/pacote": { - "version": "18.0.6", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-18.0.6.tgz", - "integrity": "sha512-+eK3G27SMwsB8kLIuj4h1FUhHtwiEUo21Tw8wNjmvdlpOEr613edv+8FUsTj/4F/VN5ywGE19X18N7CC2EJk6A==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^5.0.0", - "@npmcli/installed-package-contents": "^2.0.1", - "@npmcli/package-json": "^5.1.0", - "@npmcli/promise-spawn": "^7.0.0", - "@npmcli/run-script": "^8.0.0", - "cacache": "^18.0.0", - "fs-minipass": "^3.0.0", - "minipass": "^7.0.2", - "npm-package-arg": "^11.0.0", - "npm-packlist": "^8.0.0", - "npm-pick-manifest": "^9.0.0", - "npm-registry-fetch": "^17.0.0", - "proc-log": "^4.0.0", - "promise-retry": "^2.0.1", - "sigstore": "^2.2.0", - "ssri": "^10.0.0", - "tar": "^6.1.11" - }, - "bin": { - "pacote": "bin/index.js" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-conflict-json": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/parse-conflict-json/-/parse-conflict-json-3.0.1.tgz", - "integrity": "sha512-01TvEktc68vwbJOtWZluyWeVGWjP+bZwXtPDMQVbBKzbJ/vZBif0L69KH1+cHv1SZ6e0FKLvjyHe8mqsIqYOmw==", - "dev": true, - "license": "ISC", - "dependencies": { - "json-parse-even-better-errors": "^3.0.0", - "just-diff": "^6.0.0", - "just-diff-apply": "^5.2.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse-json/node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/parse-path": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/parse-path/-/parse-path-7.1.0.tgz", - "integrity": "sha512-EuCycjZtfPcjWk7KTksnJ5xPMvWGA/6i4zrLYhRG0hGvC3GPU/jGUj3Cy+ZR0v30duV3e23R95T1lE2+lsndSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "protocols": "^2.0.0" - } - }, - "node_modules/parse-url": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/parse-url/-/parse-url-8.1.0.tgz", - "integrity": "sha512-xDvOoLU5XRrcOZvnI6b8zA6n9O9ejNk/GExuz1yBuWUGn9KA97GI6HTs6u02wKara1CeVmZhH+0TZFdWScR89w==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse-path": "^7.0.0" - } - }, - "node_modules/parse5": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", - "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "entities": "^4.5.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-type": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", - "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pidtree": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", - "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", - "dev": true, - "license": "MIT", - "bin": { - "pidtree": "bin/pidtree.js" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/playwright": { - "version": "1.54.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.2.tgz", - "integrity": "sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.54.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.54.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.2.tgz", - "integrity": "sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/proc-log": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", - "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/proggy": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/proggy/-/proggy-2.0.0.tgz", - "integrity": "sha512-69agxLtnI8xBs9gUGqEnK26UfiexpHy+KUpBQWabiytQjnn5wFY8rklAi7GRfABIuPNnQ/ik48+LGLkYYJcy4A==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/promise-all-reject-late": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-all-reject-late/-/promise-all-reject-late-1.0.1.tgz", - "integrity": "sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw==", - "dev": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/promise-call-limit": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/promise-call-limit/-/promise-call-limit-3.0.2.tgz", - "integrity": "sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw==", - "dev": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/promzard": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/promzard/-/promzard-1.0.2.tgz", - "integrity": "sha512-2FPputGL+mP3jJ3UZg/Dl9YOkovB7DX0oOr+ck5QbZ5MtORtds8k/BZdn+02peDLI8/YWbmzx34k5fA+fHvCVQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "read": "^3.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/protocols": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/protocols/-/protocols-2.0.2.tgz", - "integrity": "sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, - "license": "MIT" - }, - "node_modules/psl": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "funding": { - "url": "https://github.com/sponsors/lupomontero" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/punycode.js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/punycode2": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/punycode2/-/punycode2-1.0.1.tgz", - "integrity": "sha512-+TXpd9YRW4YUZZPoRHJ3DILtWwootGc2DsgvfHmklQ8It1skINAuqSdqizt5nlTaBmwrYACHkHApCXjc9gHk2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/quick-lru": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", - "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/randombytes/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/read": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/read/-/read-3.0.1.tgz", - "integrity": "sha512-SLBrDU/Srs/9EoWhU5GdbAoxG1GzpQHo/6qiGItaoLJ1thmYpcNIM1qISEUvyHBzfGlWIyd6p2DNi1oV1VmAuw==", - "dev": true, - "license": "ISC", - "dependencies": { - "mute-stream": "^1.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/read-cmd-shim": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-4.0.0.tgz", - "integrity": "sha512-yILWifhaSEEytfXI76kB9xEEiG1AiozaCJZ83A87ytjRiN+jVibXjedjCRNjoZviinhG+4UkalO3mWTd8u5O0Q==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/read-package-json-fast": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-3.0.2.tgz", - "integrity": "sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw==", - "dev": true, - "license": "ISC", - "dependencies": { - "json-parse-even-better-errors": "^3.0.0", - "npm-normalize-package-bin": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/read-pkg": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", - "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "load-json-file": "^4.0.0", - "normalize-package-data": "^2.3.2", - "path-type": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/read-pkg-up": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", - "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.1.0", - "read-pkg": "^5.2.0", - "type-fest": "^0.8.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg-up/node_modules/hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", - "dev": true, - "license": "ISC" - }, - "node_modules/read-pkg-up/node_modules/normalize-package-data": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" - } - }, - "node_modules/read-pkg-up/node_modules/read-pkg": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", - "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/normalize-package-data": "^2.4.0", - "normalize-package-data": "^2.5.0", - "parse-json": "^5.0.0", - "type-fest": "^0.6.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up/node_modules/read-pkg/node_modules/type-fest": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", - "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/read-pkg-up/node_modules/type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg/node_modules/hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", - "dev": true, - "license": "ISC" - }, - "node_modules/read-pkg/node_modules/load-json-file": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", - "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2", - "parse-json": "^4.0.0", - "pify": "^3.0.0", - "strip-bom": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/read-pkg/node_modules/normalize-package-data": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" - } - }, - "node_modules/read-pkg/node_modules/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", - "dev": true, - "license": "MIT", - "dependencies": { - "error-ex": "^1.3.1", - "json-parse-better-errors": "^1.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/read-pkg/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/read/node_modules/mute-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", - "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/resolve.exports": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", - "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/rimraf": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", - "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^11.0.0", - "package-json-from-dist": "^1.0.0" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.1.tgz", - "integrity": "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^4.0.1", - "minimatch": "^10.0.0", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/jackspeak": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.0.tgz", - "integrity": "sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/lru-cache": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", - "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", - "dev": true, - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", - "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/path-scurry": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", - "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rollup": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.0.tgz", - "integrity": "sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.7" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.40.0", - "@rollup/rollup-android-arm64": "4.40.0", - "@rollup/rollup-darwin-arm64": "4.40.0", - "@rollup/rollup-darwin-x64": "4.40.0", - "@rollup/rollup-freebsd-arm64": "4.40.0", - "@rollup/rollup-freebsd-x64": "4.40.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.40.0", - "@rollup/rollup-linux-arm-musleabihf": "4.40.0", - "@rollup/rollup-linux-arm64-gnu": "4.40.0", - "@rollup/rollup-linux-arm64-musl": "4.40.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.40.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.40.0", - "@rollup/rollup-linux-riscv64-gnu": "4.40.0", - "@rollup/rollup-linux-riscv64-musl": "4.40.0", - "@rollup/rollup-linux-s390x-gnu": "4.40.0", - "@rollup/rollup-linux-x64-gnu": "4.40.0", - "@rollup/rollup-linux-x64-musl": "4.40.0", - "@rollup/rollup-win32-arm64-msvc": "4.40.0", - "@rollup/rollup-win32-ia32-msvc": "4.40.0", - "@rollup/rollup-win32-x64-msvc": "4.40.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/rollup-plugin-dts": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-6.2.1.tgz", - "integrity": "sha512-sR3CxYUl7i2CHa0O7bA45mCrgADyAQ0tVtGSqi3yvH28M+eg1+g5d7kQ9hLvEz5dorK3XVsH5L2jwHLQf72DzA==", - "dev": true, - "license": "LGPL-3.0-only", - "dependencies": { - "magic-string": "^0.30.17" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/Swatinem" - }, - "optionalDependencies": { - "@babel/code-frame": "^7.26.2" - }, - "peerDependencies": { - "rollup": "^3.29.4 || ^4", - "typescript": "^4.5 || ^5.0" - } - }, - "node_modules/rollup-plugin-peer-deps-external": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/rollup-plugin-peer-deps-external/-/rollup-plugin-peer-deps-external-2.2.4.tgz", - "integrity": "sha512-AWdukIM1+k5JDdAqV/Cxd+nejvno2FVLVeZ74NKggm3Q5s9cbbcOgUPGdbxPi4BXu7xGaZ8HG12F+thImYu/0g==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "rollup": "*" - } - }, - "node_modules/rollup-plugin-typescript2": { - "version": "0.36.0", - "resolved": "https://registry.npmjs.org/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.36.0.tgz", - "integrity": "sha512-NB2CSQDxSe9+Oe2ahZbf+B4bh7pHwjV5L+RSYpCu7Q5ROuN94F9b6ioWwKfz3ueL3KTtmX4o2MUH2cgHDIEUsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^4.1.2", - "find-cache-dir": "^3.3.2", - "fs-extra": "^10.0.0", - "semver": "^7.5.4", - "tslib": "^2.6.2" - }, - "peerDependencies": { - "rollup": ">=1.26.3", - "typescript": ">=2.4.0" - } - }, - "node_modules/rollup-plugin-typescript2/node_modules/@rollup/pluginutils": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", - "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "estree-walker": "^2.0.1", - "picomatch": "^2.2.2" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/rollup-plugin-typescript2/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/run-async": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true, - "license": "ISC" - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/shallow-clone": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", - "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", - "dev": true, - "license": "MIT", - "dependencies": { - "kind-of": "^6.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/sigstore": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.3.1.tgz", - "integrity": "sha512-8G+/XDU8wNsJOQS5ysDVO0Etg9/2uA5gR9l4ZwijjlwxBcrU6RPfwi2+jJmbP+Ap1Hlp/nVAaEO4Fj22/SL2gQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^2.3.2", - "@sigstore/core": "^1.0.0", - "@sigstore/protobuf-specs": "^0.3.2", - "@sigstore/sign": "^2.3.2", - "@sigstore/tuf": "^2.3.4", - "@sigstore/verify": "^1.2.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/sirv": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", - "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/smob": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", - "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==", - "dev": true, - "license": "MIT" - }, - "node_modules/socks": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", - "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^9.0.5", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/sort-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-2.0.0.tgz", - "integrity": "sha512-/dPCrG1s3ePpWm6yBbxZq5Be1dXGLyLn9Z791chDC3NFrpkVbWGzkBwPN1knaciexFXgRJ7hzdnwZ4stHSDmjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-plain-obj": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true, - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.21", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", - "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/split": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", - "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "through": "2" - }, - "engines": { - "node": "*" - } - }, - "node_modules/split2": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", - "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", - "dev": true, - "license": "ISC", - "dependencies": { - "readable-stream": "^3.0.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, - "node_modules/ssri": { - "version": "10.0.6", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz", - "integrity": "sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/std-env": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", - "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", - "dev": true, - "license": "MIT" - }, - "node_modules/strict-event-emitter": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", - "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/string-argv": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", - "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.19" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strong-log-transformer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/strong-log-transformer/-/strong-log-transformer-2.1.0.tgz", - "integrity": "sha512-B3Hgul+z0L9a236FAUC9iZsL+nVHgoCJnqCbN588DjYxvGXaXaaFbfmQ/JhvKjZwsOukuR72XbHv71Qkug0HxA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "duplexer": "^0.1.1", - "minimist": "^1.2.0", - "through": "^2.3.4" - }, - "bin": { - "sl-log-transformer": "bin/sl-log-transformer.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/table-layout": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz", - "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==", - "license": "MIT", - "dependencies": { - "array-back": "^6.2.2", - "wordwrapjs": "^5.1.0" - }, - "engines": { - "node": ">=12.17" - } - }, - "node_modules/tailwindcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "dev": true, - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tar/node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/temp-dir": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", - "integrity": "sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/terser": { - "version": "5.39.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", - "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/text-extensions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-1.9.0.tgz", - "integrity": "sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/texture-compressor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz", - "integrity": "sha512-dStVgoaQ11mA5htJ+RzZ51ZxIZqNOgWKAIvtjLrW1AliQQLCmrDqNzQZ8Jh91YealQ95DXt4MEduLzJmbs6lig==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.10", - "image-size": "^0.7.4" - }, - "bin": { - "texture-compressor": "bin/texture-compressor.js" - } - }, - "node_modules/texture-compressor/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/three": { - "version": "0.183.2", - "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", - "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", - "license": "MIT" - }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/through2": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", - "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "readable-stream": "~2.3.6", - "xtend": "~4.0.1" - } - }, - "node_modules/through2/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/through2/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/through2/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tmp": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie/node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/treeverse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/treeverse/-/treeverse-3.0.0.tgz", - "integrity": "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/trim-newlines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", - "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, - "node_modules/tsconfig-paths/node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tsx": { - "version": "4.20.3", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz", - "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.25.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tuf-js": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.2.1.tgz", - "integrity": "sha512-GwIJau9XaA8nLVbUXsN3IlFi7WmQ48gBUrl3FTkkL/XLu/POhBzfmX9hd33FNMX1qAsfl6ozO1iMmW9NC8YniA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tufjs/models": "2.0.1", - "debug": "^4.3.4", - "make-fetch-happen": "^13.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", - "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/typedoc": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.2.tgz", - "integrity": "sha512-9Giuv+eppFKnJ0oi+vxqLM817b/IrIsEMYgy3jj6zdvppAfDqV3d6DXL2vXUg2TnlL62V48th25Zf/tcQKAJdg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@gerrit0/mini-shiki": "^3.2.2", - "lunr": "^2.3.9", - "markdown-it": "^14.1.0", - "minimatch": "^9.0.5", - "yaml": "^2.7.1" - }, - "bin": { - "typedoc": "bin/typedoc" - }, - "engines": { - "node": ">= 18", - "pnpm": ">= 10" - }, - "peerDependencies": { - "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x" - } - }, - "node_modules/typedoc-plugin-markdown": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-4.6.2.tgz", - "integrity": "sha512-JVCIoK7FDN3t3PSLkwDyrBFyjtDznqDCJotP9evxhLyb6bEZTqhAGB0tPy1RmgHuY2WoAONMrsWs8LfLsD+A6A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "typedoc": "0.28.x" - } - }, - "node_modules/typedoc/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typical": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", - "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", - "license": "MIT", - "engines": { - "node": ">=12.17" - } - }, - "node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "dev": true, - "license": "MIT" - }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unicorn-magic": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", - "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/unique-filename": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz", - "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==", - "dev": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^4.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/unique-slug": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz", - "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/universal-user-agent": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", - "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/upath": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", - "integrity": "sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4", - "yarn": "*" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "node_modules/validate-npm-package-name": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", - "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/vite-node/node_modules/vite": { - "version": "5.4.19", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", - "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/vitest/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/vitest/node_modules/vite": { - "version": "5.4.19", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", - "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/w3c-xmlserializer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", - "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/walk-up-path": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-3.0.1.tgz", - "integrity": "sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==", - "dev": true, - "license": "ISC" - }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tr46": "^3.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^16.13.0 || >=18.0.0" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/wordwrapjs": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz", - "integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==", - "license": "MIT", - "engines": { - "node": ">=12.17" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/write-file-atomic": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", - "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/write-file-atomic/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/write-json-file": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/write-json-file/-/write-json-file-3.2.0.tgz", - "integrity": "sha512-3xZqT7Byc2uORAatYiP3DHUUAVEkNOswEWNs9H5KXiicRTvzYzYqKjYc4G7p+8pltvAw641lVByKVtMpf+4sYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-indent": "^5.0.0", - "graceful-fs": "^4.1.15", - "make-dir": "^2.1.0", - "pify": "^4.0.1", - "sort-keys": "^2.0.0", - "write-file-atomic": "^2.4.2" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/write-json-file/node_modules/make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^4.0.1", - "semver": "^5.6.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/write-json-file/node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/write-json-file/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/write-json-file/node_modules/write-file-atomic": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.3.tgz", - "integrity": "sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "graceful-fs": "^4.1.11", - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.2" - } - }, - "node_modules/write-pkg": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/write-pkg/-/write-pkg-4.0.0.tgz", - "integrity": "sha512-v2UQ+50TNf2rNHJ8NyWttfm/EJUBWMJcx6ZTYZr6Qp52uuegWw/lBkCtCbnYZEmPRNL61m+u67dAmGxo+HTULA==", - "dev": true, - "license": "MIT", - "dependencies": { - "sort-keys": "^2.0.0", - "type-fest": "^0.4.1", - "write-json-file": "^3.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/write-pkg/node_modules/type-fest": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.4.1.tgz", - "integrity": "sha512-IwzA/LSfD2vC1/YDYMv/zHP4rDF1usCwllsDpbolT3D4fUepIO7f9K70jjmUewU/LmGUKJcwcVtDCpnKk4BPMw==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=6" - } - }, - "node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/yaml": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", - "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", - "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/asset-2d": { - "name": "@axrone/asset-2d", - "version": "0.1.0", - "dependencies": { - "@axrone/asset-core": "^0.1.0" - } - }, - "packages/asset-core": { - "name": "@axrone/asset-core", - "version": "0.1.0", - "dependencies": { - "@axrone/random": "^0.0.1" - } - }, - "packages/asset-gltf": { - "name": "@axrone/asset-gltf", - "version": "0.1.0", - "dependencies": { - "@axrone/asset-core": "^0.1.0", - "@axrone/numeric": "^0.0.1", - "@axrone/render-webgl2": "^0.1.0", - "@loaders.gl/textures": "^4.4.1", - "draco3dgltf": "^1.5.7", - "meshoptimizer": "^1.0.1" - } - }, - "packages/ecs-events": { - "name": "@axrone/ecs-events", - "version": "0.1.0" - }, - "packages/ecs-query": { - "name": "@axrone/ecs-query", - "version": "0.1.0" - }, - "packages/ecs-runtime": { - "name": "@axrone/ecs-runtime", - "version": "0.1.0", - "dependencies": { - "@axrone/ecs-events": "^0.1.0", - "@axrone/ecs-query": "^0.1.0", - "@axrone/ecs-storage": "^0.1.0", - "@axrone/ecs-world-support": "^0.1.0", - "@axrone/numeric": "^0.0.1", - "@axrone/utility": "^0.0.1" - } - }, - "packages/ecs-storage": { - "name": "@axrone/ecs-storage", - "version": "0.1.0", - "dependencies": { - "@axrone/utility": "^0.0.1" - } - }, - "packages/ecs-world-support": { - "name": "@axrone/ecs-world-support", - "version": "0.1.0" - }, - "packages/event": { - "name": "@axrone/event", - "version": "0.1.0", - "dependencies": { - "@axrone/utility": "^0.0.1" - } - }, - "packages/game-loop": { - "name": "@axrone/game-loop", - "version": "0.1.0" - }, - "packages/geometry": { - "name": "@axrone/geometry", - "version": "0.1.0", - "dependencies": { - "@axrone/numeric": "^0.0.1", - "@axrone/utility": "^0.0.1" - } - }, - "packages/input": { - "name": "@axrone/input", - "version": "0.1.0", - "dependencies": { - "@axrone/event": "^0.1.0" - } - }, - "packages/input-core": { - "name": "@axrone/input-core", - "version": "0.1.0", - "dependencies": { - "@axrone/input": "^0.1.0" - } - }, - "packages/numeric": { - "name": "@axrone/numeric", - "version": "0.0.1", - "license": "ISC", - "dependencies": { - "@axrone/random": "^0.0.1", - "@axrone/utility": "^0.0.1" - } - }, - "packages/observer": { - "name": "@axrone/observer", - "version": "0.1.0", - "dependencies": { - "@axrone/event": "^0.1.0", - "@axrone/utility": "^0.0.1" - } - }, - "packages/particle-system": { - "name": "@axrone/particle-system", - "version": "0.1.0", - "dependencies": { - "@axrone/event": "^0.1.0", - "@axrone/geometry": "^0.1.0", - "@axrone/numeric": "^0.0.1", - "@axrone/random": "^0.0.1", - "@axrone/utility": "^0.0.1" - } - }, - "packages/physics": { - "name": "@axrone/physics", - "version": "0.1.0", - "dependencies": { - "@axrone/geometry": "^0.1.0", - "@axrone/numeric": "^0.0.1", - "@axrone/physics-2d": "^0.1.0", - "@axrone/physics-3d": "^0.1.0", - "@axrone/physics-core": "^0.1.0" - } - }, - "packages/physics-2d": { - "name": "@axrone/physics-2d", - "version": "0.1.0", - "dependencies": { - "@axrone/ecs-runtime": "^0.1.0", - "@axrone/geometry": "^0.1.0", - "@axrone/numeric": "^0.0.1", - "@axrone/physics-core": "^0.1.0" - } - }, - "packages/physics-3d": { - "name": "@axrone/physics-3d", - "version": "0.1.0", - "dependencies": { - "@axrone/ecs-runtime": "^0.1.0", - "@axrone/numeric": "^0.0.1", - "@axrone/physics-core": "^0.1.0" - } - }, - "packages/physics-core": { - "name": "@axrone/physics-core", - "version": "0.1.0", - "dependencies": { - "@axrone/numeric": "^0.0.1" - } - }, - "packages/random": { - "name": "@axrone/random", - "version": "0.0.1", - "license": "ISC" - }, - "packages/render-2d": { - "name": "@axrone/render-2d", - "version": "0.1.0", - "dependencies": { - "@axrone/render-core": "^0.1.0" - } - }, - "packages/render-3d": { - "name": "@axrone/render-3d", - "version": "0.1.0", - "dependencies": { - "@axrone/render-core": "^0.1.0" - } - }, - "packages/render-core": { - "name": "@axrone/render-core", - "version": "0.1.0", - "dependencies": { - "@axrone/numeric": "^0.0.1" - } - }, - "packages/render-webgl2": { - "name": "@axrone/render-webgl2", - "version": "0.1.0", - "dependencies": { - "@axrone/ecs-runtime": "^0.1.0", - "@axrone/geometry": "^0.1.0", - "@axrone/numeric": "^0.0.1", - "@axrone/utility": "^0.0.1" - } - }, - "packages/runtime-profile-2d": { - "name": "@axrone/runtime-profile-2d", - "version": "0.1.0", - "dependencies": { - "@axrone/asset-2d": "^0.1.0", - "@axrone/input-core": "^0.1.0", - "@axrone/physics-2d": "^0.1.0", - "@axrone/physics-core": "^0.1.0", - "@axrone/render-2d": "^0.1.0", - "@axrone/scene-2d": "^0.1.0", - "@axrone/scene-runtime": "^0.1.0" - } - }, - "packages/runtime-profile-3d": { - "name": "@axrone/runtime-profile-3d", - "version": "0.1.0", - "dependencies": { - "@axrone/asset-core": "^0.1.0", - "@axrone/asset-gltf": "^0.1.0", - "@axrone/input-core": "^0.1.0", - "@axrone/physics-3d": "^0.1.0", - "@axrone/physics-core": "^0.1.0", - "@axrone/render-3d": "^0.1.0", - "@axrone/render-webgl2": "^0.1.0", - "@axrone/scene-3d": "^0.1.0", - "@axrone/scene-runtime": "^0.1.0" - } - }, - "packages/runtime-profile-core": { - "name": "@axrone/runtime-profile-core", - "version": "0.1.0", - "dependencies": { - "@axrone/input-core": "^0.1.0", - "@axrone/scene-runtime": "^0.1.0" - } - }, - "packages/runtime-profile-full": { - "name": "@axrone/runtime-profile-full", - "version": "0.1.0", - "dependencies": { - "@axrone/asset-2d": "^0.1.0", - "@axrone/asset-core": "^0.1.0", - "@axrone/asset-gltf": "^0.1.0", - "@axrone/input-core": "^0.1.0", - "@axrone/physics-2d": "^0.1.0", - "@axrone/physics-3d": "^0.1.0", - "@axrone/physics-core": "^0.1.0", - "@axrone/render-2d": "^0.1.0", - "@axrone/render-3d": "^0.1.0", - "@axrone/render-webgl2": "^0.1.0", - "@axrone/scene-2d": "^0.1.0", - "@axrone/scene-3d": "^0.1.0", - "@axrone/scene-runtime": "^0.1.0" - } - }, - "packages/scene-2d": { - "name": "@axrone/scene-2d", - "version": "0.1.0", - "dependencies": { - "@axrone/ecs-runtime": "^0.1.0", - "@axrone/numeric": "^0.0.1", - "@axrone/random": "^0.0.1", - "@axrone/scene-runtime": "^0.1.0", - "@axrone/utility": "^0.0.1" - } - }, - "packages/scene-3d": { - "name": "@axrone/scene-3d", - "version": "0.1.0", - "dependencies": { - "@axrone/ecs-runtime": "^0.1.0", - "@axrone/numeric": "^0.0.1", - "@axrone/random": "^0.0.1", - "@axrone/render-webgl2": "^0.1.0", - "@axrone/scene-runtime": "^0.1.0", - "@axrone/utility": "^0.0.1" - } - }, - "packages/scene-runtime": { - "name": "@axrone/scene-runtime", - "version": "0.1.0", - "dependencies": { - "@axrone/ecs-runtime": "^0.1.0", - "@axrone/game-loop": "^0.1.0", - "@axrone/geometry": "^0.1.0", - "@axrone/numeric": "^0.0.1", - "@axrone/random": "^0.0.1", - "@axrone/render-webgl2": "^0.1.0", - "@axrone/utility": "^0.0.1" - } - }, - "packages/scene-runtime-gltf": { - "name": "@axrone/scene-runtime-gltf", - "version": "0.1.0", - "dependencies": { - "@axrone/asset-gltf": "^0.1.0", - "@axrone/ecs-runtime": "^0.1.0", - "@axrone/numeric": "^0.0.1", - "@axrone/render-webgl2": "^0.1.0", - "@axrone/scene-3d": "^0.1.0", - "@axrone/scene-runtime": "^0.1.0" - } - }, - "packages/tween": { - "name": "@axrone/tween", - "version": "0.1.0", - "dependencies": { - "@axrone/event": "^0.1.0", - "@axrone/numeric": "^0.0.1", - "@axrone/utility": "^0.0.1" - } - }, - "packages/ui": { - "name": "@axrone/ui", - "version": "0.0.1" - }, - "packages/ui-webgl2": { - "name": "@axrone/ui-webgl2", - "version": "0.0.1", - "dependencies": { - "@axrone/game-loop": "^0.1.0", - "@axrone/render-core": "^0.1.0", - "@axrone/scene-runtime": "^0.1.0", - "@axrone/ui": "^0.0.1" - } - }, - "packages/utility": { - "name": "@axrone/utility", - "version": "0.0.1", - "license": "ISC" - } - } -} diff --git a/web/package.json b/web/package.json index e4712722..5e355003 100644 --- a/web/package.json +++ b/web/package.json @@ -3,6 +3,7 @@ "version": "0.0.1", "private": true, "type": "module", + "packageManager": "yarn@1.22.22", "workspaces": [ "packages/*" ], @@ -22,16 +23,22 @@ "test:render-governance": "vitest run --config vitest.config.architecture.ts tests/architecture/render-split", "test:input-governance": "vitest run --config vitest.config.architecture.ts tests/architecture/input-split", "test:consumer-migration": "vitest run --config vitest.config.architecture.ts tests/architecture/scene-split/core-consumer-migration-boundary.test.ts", - "test:hardening": "npm run test:profiles && npm run test:render-governance && npm run test:input-governance && npm run test:consumer-migration", + "test:duplicate-governance": "node ./scripts/duplicate-governance.mjs", + "test:utility-governance": "node ./scripts/utility-governance.mjs", + "test:hardening": "yarn test:profiles && yarn test:render-governance && yarn test:input-governance && yarn test:consumer-migration && yarn test:duplicate-governance && yarn test:utility-governance", "test:runtime-profile-governance": "node ./scripts/runtime-profile-governance.mjs", + "test:engine-benchmark-governance": "node ./scripts/engine-benchmark-governance.mjs", "test:watch": "vitest --watch", "test:ui": "vitest --ui", "test:browser": "vitest run --config vitest.config.browser.ts", "test:webgl": "playwright test --config playwright.config.ts", + "bench:engine": "node ./scripts/engine-benchmark-runner.mjs", + "bench:engine:quick": "node ./scripts/engine-benchmark-runner.mjs --iterations=3 --warmup=1 --durationSec=5 --objectCounts=2600 --comparisonModes=no-culling", + "bench:engine:startup": "node ./scripts/engine-benchmark-runner.mjs --iterations=3 --warmup=1 --durationSec=5 --objectCounts=19600 --comparisonModes=no-culling --isolateRuns", "test:coverage": "vitest --coverage", - "test:all": "npm run test && npm run test:architecture && npm run test:browser && npm run test:webgl", - "ci:engine-hardening": "npm run test:architecture && npm run verify:engine-hardening", - "verify:engine-hardening": "npm run test:hardening && npm run build && npm run test:runtime-profile-governance && npm run examples:build", + "test:all": "yarn test && yarn test:architecture && yarn test:browser && yarn test:webgl", + "ci:engine-hardening": "yarn test:architecture && yarn verify:engine-hardening", + "verify:engine-hardening": "yarn test:hardening && yarn build && yarn test:runtime-profile-governance && yarn test:engine-benchmark-governance -- --refresh --headless --strictStability && yarn examples:build", "docs": "typedoc" }, "devDependencies": { @@ -52,8 +59,10 @@ "eslint": "^9.24.0", "eslint-config-prettier": "^10.1.2", "eslint-plugin-import": "^2.31.0", + "eslint-plugin-vitest": "^0.5.4", "happy-dom": "^15.11.7", "husky": "^9.1.7", + "jscpd": "^4.0.9", "lerna": "^8.2.2", "lint-staged": "^15.5.1", "monaco-editor": "^0.55.1", diff --git a/web/packages/animation/package.json b/web/packages/animation/package.json new file mode 100644 index 00000000..7906f109 --- /dev/null +++ b/web/packages/animation/package.json @@ -0,0 +1,83 @@ +{ + "name": "@axrone/animation", + "version": "0.1.0", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./types": { + "types": "./dist/types.d.ts", + "import": "./dist/types.mjs", + "require": "./dist/types.js" + }, + "./clip": { + "types": "./dist/clip.d.ts", + "import": "./dist/clip.mjs", + "require": "./dist/clip.js" + }, + "./blend-tree": { + "types": "./dist/blend-tree.d.ts", + "import": "./dist/blend-tree.mjs", + "require": "./dist/blend-tree.js" + }, + "./state-machine": { + "types": "./dist/state-machine.d.ts", + "import": "./dist/state-machine.mjs", + "require": "./dist/state-machine.js" + }, + "./retargeting": { + "types": "./dist/retargeting.d.ts", + "import": "./dist/retargeting.mjs", + "require": "./dist/retargeting.js" + }, + "./ik": { + "types": "./dist/ik.d.ts", + "import": "./dist/ik.mjs", + "require": "./dist/ik.js" + }, + "./skinning": { + "types": "./dist/skinning.d.ts", + "import": "./dist/skinning.mjs", + "require": "./dist/skinning.js" + }, + "./controller": { + "types": "./dist/controller.d.ts", + "import": "./dist/controller.mjs", + "require": "./dist/controller.js" + }, + "./pose": { + "types": "./dist/pose.d.ts", + "import": "./dist/pose.mjs", + "require": "./dist/pose.js" + }, + "./rig": { + "types": "./dist/rig.d.ts", + "import": "./dist/rig.mjs", + "require": "./dist/rig.js" + }, + "./errors": { + "types": "./dist/errors.d.ts", + "import": "./dist/errors.mjs", + "require": "./dist/errors.js" + } + }, + "scripts": { + "build": "rollup -c rollup.config.mjs", + "clean": "rimraf dist", + "test": "vitest run" + }, + "dependencies": { + "@axrone/memory": "^0.0.1", + "@axrone/numeric": "^0.0.1", + "@axrone/utility": "^0.0.1" + } +} diff --git a/web/packages/animation/rollup.config.mjs b/web/packages/animation/rollup.config.mjs new file mode 100644 index 00000000..8a0ae168 --- /dev/null +++ b/web/packages/animation/rollup.config.mjs @@ -0,0 +1,66 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createPackageConfig } from '../../build/create-package-config.mjs'; + +const packageDir = path.dirname(fileURLToPath(import.meta.url)); + +export default [ + ...createPackageConfig({ + packageDir, + }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/types.ts', + outputBasename: 'types', + }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/clip.ts', + outputBasename: 'clip', + }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/blend-tree.ts', + outputBasename: 'blend-tree', + }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/state-machine.ts', + outputBasename: 'state-machine', + }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/retargeting.ts', + outputBasename: 'retargeting', + }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/ik.ts', + outputBasename: 'ik', + }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/skinning.ts', + outputBasename: 'skinning', + }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/controller.ts', + outputBasename: 'controller', + }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/pose.ts', + outputBasename: 'pose', + }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/rig.ts', + outputBasename: 'rig', + }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/errors.ts', + outputBasename: 'errors', + }), +]; \ No newline at end of file diff --git a/web/packages/animation/src/__tests__/animation-stack.test.ts b/web/packages/animation/src/__tests__/animation-stack.test.ts new file mode 100644 index 00000000..e99c7c8d --- /dev/null +++ b/web/packages/animation/src/__tests__/animation-stack.test.ts @@ -0,0 +1,1035 @@ +import { describe, expect, it } from 'vitest'; +import { AnimationBlendGraph } from '../blend-graph'; +import { AnimationClip } from '../clip'; +import { AnimationControllerGraph } from '../controller-graph'; +import { AnimationController } from '../controller'; +import { solvePlanarGrounding } from '../grounding'; +import { AnimationIkLayer } from '../ik'; +import { AnimationMotionMatchDatabase } from '../motion-matching'; +import { optimizeAnimationClipDefinition } from '../optimization'; +import { AnimationCurveLayout, AnimationFrame, AnimationPose, AnimationWorldPose } from '../pose'; +import { AnimationRetargeter } from '../retargeting'; +import { AnimationRig } from '../rig'; +import { AnimationClipStreamingScheduler } from '../streaming'; +import { + decodeAnimationClipStreamingChunkPayload, + encodeAnimationClipStreamingChunkPayload, +} from '../streaming-chunk'; + +describe('Animation stack', () => { + it('extracts root motion while consuming the animated root bone', () => { + const controller = new AnimationController({ + rig: { + bones: [{ name: 'hips' }], + }, + clips: [ + { + id: 'walk', + tracks: [ + { + target: 'hips', + path: 'translation', + times: [0, 1], + values: [0, 0, 0, 2, 0, 0], + }, + ], + }, + ], + layers: [ + { + id: 'base', + stateMachine: { + entryState: 'walk', + states: [ + { + id: 'walk', + motion: { kind: 'clip', clipId: 'walk' }, + loop: false, + }, + ], + }, + }, + ], + rootMotion: { + bone: 'hips', + consume: true, + projectTranslationAxes: [true, false, false], + }, + }); + + const result = controller.update(0.5); + + expect(result.rootMotion.translation[0]).toBeCloseTo(1); + expect(result.rootMotion.translation[1]).toBeCloseTo(0); + expect(result.frame.pose.translations[0]).toBeCloseTo(0); + expect(result.frame.pose.translations[1]).toBeCloseTo(0); + expect(result.frame.pose.translations[2]).toBeCloseTo(0); + }); + + it('evaluates a 1D blend tree from controller parameters', () => { + const controller = new AnimationController({ + rig: { + bones: [{ name: 'hips' }], + }, + clips: [ + { + id: 'idle', + tracks: [ + { + target: 'hips', + path: 'translation', + times: [0, 1], + values: [0, 0, 0, 0, 0, 0], + }, + ], + }, + { + id: 'run', + tracks: [ + { + target: 'hips', + path: 'translation', + times: [0, 1], + values: [0, 0, 0, 2, 0, 0], + }, + ], + }, + ], + parameters: [{ name: 'speed', kind: 'float', defaultValue: 0 }], + layers: [ + { + id: 'base', + stateMachine: { + entryState: 'locomotion', + states: [ + { + id: 'locomotion', + motion: { + kind: 'blend1d', + parameter: 'speed', + children: [ + { + threshold: 0, + motion: { kind: 'clip', clipId: 'idle' }, + }, + { + threshold: 1, + motion: { kind: 'clip', clipId: 'run' }, + }, + ], + }, + }, + ], + }, + }, + ], + }); + + controller.seek(0.5); + controller.parameters.setFloat('speed', 0.5); + const frame = controller.evaluate(); + + expect(frame.pose.translations[0]).toBeCloseTo(0.5); + expect(frame.pose.translations[1]).toBeCloseTo(0); + expect(frame.pose.translations[2]).toBeCloseTo(0); + }); + + it('evaluates a 2D blend tree from controller parameters', () => { + const controller = new AnimationController({ + rig: { + bones: [{ name: 'hips' }], + }, + clips: [ + { + id: 'idle', + tracks: [ + { + target: 'hips', + path: 'translation', + times: [0, 1], + values: [0, 0, 0, 0, 0, 0], + }, + ], + }, + { + id: 'run', + tracks: [ + { + target: 'hips', + path: 'translation', + times: [0, 1], + values: [2, 0, 0, 2, 0, 0], + }, + ], + }, + ], + parameters: [ + { name: 'moveX', kind: 'float', defaultValue: 0 }, + { name: 'moveY', kind: 'float', defaultValue: 0 }, + ], + layers: [ + { + id: 'base', + stateMachine: { + entryState: 'locomotion', + states: [ + { + id: 'locomotion', + motion: { + kind: 'blend2d', + parameterX: 'moveX', + parameterY: 'moveY', + children: [ + { + position: [0, 0], + motion: { kind: 'clip', clipId: 'idle' }, + }, + { + position: [1, 1], + motion: { kind: 'clip', clipId: 'run' }, + }, + ], + }, + }, + ], + }, + }, + ], + }); + + controller.seek(0.5); + controller.parameters.setFloat('moveX', 1); + controller.parameters.setFloat('moveY', 1); + const frame = controller.evaluate(); + + expect(frame.pose.translations[0]).toBeCloseTo(2); + expect(frame.pose.translations[1]).toBeCloseTo(0); + expect(frame.pose.translations[2]).toBeCloseTo(0); + }); + + it('evaluates a direct blend tree from controller parameters', () => { + const controller = new AnimationController({ + rig: { + bones: [{ name: 'hips' }], + }, + clips: [ + { + id: 'idle', + tracks: [ + { + target: 'hips', + path: 'translation', + times: [0, 1], + values: [1, 0, 0, 1, 0, 0], + }, + ], + }, + { + id: 'attack', + tracks: [ + { + target: 'hips', + path: 'translation', + times: [0, 1], + values: [3, 0, 0, 3, 0, 0], + }, + ], + }, + ], + parameters: [ + { name: 'idleWeight', kind: 'float', defaultValue: 0 }, + { name: 'attackWeight', kind: 'float', defaultValue: 0 }, + ], + layers: [ + { + id: 'base', + stateMachine: { + entryState: 'mix', + states: [ + { + id: 'mix', + motion: { + kind: 'direct', + children: [ + { + parameter: 'idleWeight', + motion: { kind: 'clip', clipId: 'idle' }, + }, + { + parameter: 'attackWeight', + motion: { kind: 'clip', clipId: 'attack' }, + }, + ], + }, + }, + ], + }, + }, + ], + }); + + controller.parameters.setFloat('idleWeight', 0); + controller.parameters.setFloat('attackWeight', 1); + const frame = controller.evaluate(); + + expect(frame.pose.translations[0]).toBeCloseTo(3); + expect(frame.pose.translations[1]).toBeCloseTo(0); + expect(frame.pose.translations[2]).toBeCloseTo(0); + }); + + it('evaluates an additive blend tree from controller parameters', () => { + const controller = new AnimationController({ + rig: { + bones: [{ name: 'hips' }], + }, + clips: [ + { + id: 'base', + tracks: [ + { + target: 'hips', + path: 'translation', + times: [0, 1], + values: [1, 0, 0, 1, 0, 0], + }, + ], + }, + { + id: 'offset', + tracks: [ + { + target: 'hips', + path: 'translation', + times: [0, 1], + values: [2, 0, 0, 2, 0, 0], + }, + ], + }, + ], + parameters: [{ name: 'intensity', kind: 'float', defaultValue: 0 }], + layers: [ + { + id: 'base', + stateMachine: { + entryState: 'aim', + states: [ + { + id: 'aim', + motion: { + kind: 'additive', + base: { kind: 'clip', clipId: 'base' }, + additive: { kind: 'clip', clipId: 'offset' }, + parameter: 'intensity', + weight: 1, + }, + }, + ], + }, + }, + ], + }); + + controller.parameters.setFloat('intensity', 0.5); + const frame = controller.evaluate(); + + expect(frame.pose.translations[0]).toBeCloseTo(2); + expect(frame.pose.translations[1]).toBeCloseTo(0); + expect(frame.pose.translations[2]).toBeCloseTo(0); + }); + + it('keeps the last keyframe when a non-looping state seeks to clip end', () => { + const controller = new AnimationController({ + rig: { + bones: [{ name: 'hips' }], + }, + clips: [ + { + id: 'pose', + tracks: [ + { + target: 'hips', + path: 'translation', + times: [0, 1], + values: [0, 0, 0, 1, 0, 0], + }, + ], + }, + ], + layers: [ + { + id: 'base', + stateMachine: { + entryState: 'pose', + states: [ + { + id: 'pose', + motion: { kind: 'clip', clipId: 'pose' }, + loop: false, + }, + ], + }, + }, + ], + }); + + controller.seek(1); + const frame = controller.evaluate(); + + expect(frame.pose.translations[0]).toBeCloseTo(1); + expect(frame.pose.translations[1]).toBeCloseTo(0); + expect(frame.pose.translations[2]).toBeCloseTo(0); + }); + + it('samples linear rotation tracks with quaternion slerp so antipodal keys do not flip the pose', () => { + const controller = new AnimationController({ + rig: { + bones: [{ name: 'hips' }], + }, + clips: [ + { + id: 'pose', + tracks: [ + { + target: 'hips', + path: 'rotation', + interpolation: 'LINEAR', + times: [0, 1], + values: [ + 0, + 0, + Math.SQRT1_2, + Math.SQRT1_2, + 0, + 0, + -Math.SQRT1_2, + -Math.SQRT1_2, + ], + }, + ], + }, + ], + layers: [ + { + id: 'base', + stateMachine: { + entryState: 'pose', + states: [ + { + id: 'pose', + motion: { kind: 'clip', clipId: 'pose' }, + loop: true, + }, + ], + }, + }, + ], + }); + + controller.seek(0.5); + const frame = controller.evaluate(); + const rotation = frame.pose.rotations.subarray(0, 4); + const dot = + rotation[2]! * Math.SQRT1_2 + + rotation[3]! * Math.SQRT1_2; + + expect(Math.abs(dot)).toBeCloseTo(1, 5); + }); + + it('builds blend graphs through the fluent authoring API and exposes active clip activity', () => { + const motion = AnimationBlendGraph.blend1d('speed') + .addChild(0, AnimationBlendGraph.clip('idle')) + .addChild(1, AnimationBlendGraph.clip('run')) + .build(); + + expect( + AnimationBlendGraph.validate(motion, { + knownClipIds: ['idle', 'run'], + knownParameters: ['speed'], + }) + ).toEqual([]); + + const controller = new AnimationController({ + rig: { + bones: [{ name: 'hips' }], + }, + clips: [ + { + id: 'idle', + tracks: [ + { + target: 'hips', + path: 'translation', + times: [0, 1], + values: [0, 0, 0, 0, 0, 0], + }, + ], + }, + { + id: 'run', + tracks: [ + { + target: 'hips', + path: 'translation', + times: [0, 1], + values: [0, 0, 0, 2, 0, 0], + }, + ], + }, + ], + parameters: [{ name: 'speed', kind: 'float', defaultValue: 0 }], + layers: [ + { + id: 'base', + stateMachine: { + entryState: 'locomotion', + states: [ + { + id: 'locomotion', + motion, + }, + ], + }, + }, + ], + }); + + controller.parameters.setFloat('speed', 1); + controller.seek(0.5); + const frame = controller.evaluate(); + + expect(frame.pose.translations[0]).toBeCloseTo(1); + expect(controller.activeClips).toEqual([ + expect.objectContaining({ + clipId: 'run', + layerId: 'base', + stateId: 'locomotion', + }), + ]); + expect(controller.profile.activeClipCount).toBe(1); + }); + + it('builds controller definitions through the fluent controller graph API', () => { + const definition = AnimationControllerGraph.controller({ + bones: [{ name: 'hips' }], + }) + .addClip({ + id: 'idle', + tracks: [ + { + target: 'hips', + path: 'translation', + times: [0, 1], + values: [0, 0, 0, 0, 0, 0], + }, + ], + }) + .addClip({ + id: 'run', + tracks: [ + { + target: 'hips', + path: 'translation', + times: [0, 1], + values: [0, 0, 0, 2, 0, 0], + }, + ], + }) + .parameter('speed', 'float', 0) + .layer( + 'base', + AnimationControllerGraph.machine('idle') + .state('idle', AnimationBlendGraph.clip('idle'), (state) => { + state.transitionTo('run', (transition) => { + transition.withDuration(0.1).whenFloat('speed', '>', 0.5); + }); + }) + .state('run', AnimationBlendGraph.clip('run'), (state) => { + state.transitionTo('idle', (transition) => { + transition.withDuration(0.1).whenFloat('speed', '<=', 0.5); + }); + }), + (layer) => { + layer.withWeight(1).withBoneMask(['hips']); + } + ) + .withRootMotion({ + bone: 'hips', + consume: true, + }) + .build(); + + expect(AnimationControllerGraph.validateController(definition)).toEqual([]); + + const controller = new AnimationController(definition); + controller.parameters.setFloat('speed', 1); + controller.update(0.25); + controller.update(0.25); + + expect(controller.activeClips).toEqual([ + expect.objectContaining({ + clipId: 'run', + layerId: 'base', + stateId: 'run', + }), + ]); + }); + + it('validates controller graph references against clips, parameters, and bones', () => { + const definition = AnimationControllerGraph.controller({ + bones: [{ name: 'hips' }], + }) + .addClip({ + id: 'idle', + tracks: [ + { + target: 'hips', + path: 'translation', + times: [0, 1], + values: [0, 0, 0, 0, 0, 0], + }, + ], + }) + .parameter('speed', 'float', 0) + .layer( + 'base', + AnimationControllerGraph.machine('idle') + .state( + 'idle', + AnimationBlendGraph.blend1d('speed') + .addChild(0, AnimationBlendGraph.clip('idle')) + .addChild(1, AnimationBlendGraph.clip('missing-clip')) + ) + .anyState('missing-state', (transition) => { + transition.whenFloat('missing-parameter', '>', 0.1); + }), + (layer) => { + layer.withBoneMask(['missing-bone']); + } + ) + .withRootMotion({ + bone: 'missing-bone', + }) + .build(); + + const diagnostics = AnimationControllerGraph.validateController(definition); + + expect(diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'animation.controller.motion.clip.unknown', + }), + expect.objectContaining({ + code: 'animation.controller.parameter.unknown', + }), + expect.objectContaining({ + code: 'animation.controller.state.unknown', + }), + expect.objectContaining({ + code: 'animation.controller.bone.unknown', + }), + expect.objectContaining({ + code: 'animation.controller.rootMotion.bone.unknown', + }), + ]) + ); + }); + + it('retargets translations with configured scaling', () => { + const retargeter = new AnimationRetargeter({ + sourceRig: { + bones: [{ name: 'hips' }], + }, + targetRig: { + bones: [{ name: 'pelvis' }], + }, + mappings: [ + { + sourceBone: 'hips', + targetBone: 'pelvis', + translationMode: 'scaled', + scaleTranslation: 2, + }, + ], + }); + const sourceRig = new AnimationRig({ bones: [{ name: 'hips' }] }); + const sourceFrame = new AnimationFrame(sourceRig, new AnimationCurveLayout()); + sourceFrame.pose.translations[0] = 1; + sourceFrame.pose.translations[1] = 2; + sourceFrame.pose.translations[2] = 3; + + const targetFrame = retargeter.retargetPose(sourceFrame); + + expect(targetFrame.pose.translations[0]).toBeCloseTo(2); + expect(targetFrame.pose.translations[1]).toBeCloseTo(4); + expect(targetFrame.pose.translations[2]).toBeCloseTo(6); + }); + + it('solves a simple CCD IK chain toward the requested target', () => { + const rig = new AnimationRig({ + bones: [ + { name: 'root' }, + { name: 'tip', parent: 'root', translation: [1, 0, 0] }, + ], + }); + const pose = new AnimationPose(rig.boneCount).reset(rig); + const ikLayer = new AnimationIkLayer(rig, { + id: 'aim', + jobs: [ + { + id: 'reach', + solver: 'ccd', + rootBone: 'root', + tipBone: 'tip', + targetPosition: [0, 1, 0], + precision: 1e-4, + maxIterations: 24, + }, + ], + }); + + ikLayer.apply(pose); + + const worldPose = new AnimationWorldPose(rig.boneCount).update(rig, pose); + const tipOffset = rig.indexOfBone('tip') * 3; + expect(worldPose.translations[tipOffset]).toBeCloseTo(0, 3); + expect(worldPose.translations[tipOffset + 1]).toBeCloseTo(1, 3); + expect(worldPose.translations[tipOffset + 2]).toBeCloseTo(0, 3); + }); + + it('collects animation notifies and controller profiling data during updates', () => { + const controller = new AnimationController({ + rig: { + bones: [{ name: 'hips' }], + }, + clips: [ + { + id: 'attack', + events: [ + { + id: 'swing', + name: 'attack:swing', + time: 0.5, + payload: { damage: 12 }, + tags: ['combat'], + }, + ], + tracks: [ + { + target: 'hips', + path: 'translation', + times: [0, 1], + values: [0, 0, 0, 1, 0, 0], + }, + ], + }, + ], + layers: [ + { + id: 'base', + stateMachine: { + entryState: 'attack', + states: [ + { + id: 'attack', + motion: { kind: 'clip', clipId: 'attack' }, + loop: false, + }, + ], + }, + }, + ], + }); + + const result = controller.update(0.75); + + expect(result.events).toEqual([ + expect.objectContaining({ + clipId: 'attack', + layerId: 'base', + stateId: 'attack', + name: 'attack:swing', + id: 'swing', + payload: { damage: 12 }, + tags: ['combat'], + }), + ]); + expect(result.profile.emittedEventCount).toBe(1); + expect(result.profile.sampledTrackCount).toBe(1); + expect(result.profile.activeLayers).toEqual([ + expect.objectContaining({ + layerId: 'base', + stateId: 'attack', + transitioning: false, + }), + ]); + }); + + it('schedules streamed clip chunks for active playback and preload windows', () => { + const rig = new AnimationRig({ + bones: [{ name: 'root' }], + }); + const clip = new AnimationClip( + { + id: 'walk', + tracks: [ + { + target: 'root', + path: 'translation', + times: [0, 1], + values: [0, 0, 0, 1, 0, 0], + }, + ], + streaming: { + mode: 'streamed', + sourceUri: 'clips/walk.anim', + chunkDuration: 0.5, + preloadWindow: 0.3, + }, + }, + rig, + new AnimationCurveLayout() + ); + const scheduler = new AnimationClipStreamingScheduler([clip]); + + let snapshot = scheduler.update([ + { + clipId: 'walk', + layerId: 'base', + stateId: 'walk', + layerWeight: 1, + motionWeight: 1, + loop: false, + time: 0.4, + normalizedTime: 0.4, + }, + ]); + + expect(snapshot.ready).toBe(false); + expect(snapshot.pendingRequests).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + clipId: 'walk', + chunkId: 'walk:virtual:0', + reason: 'active', + }), + expect.objectContaining({ + clipId: 'walk', + chunkId: 'walk:virtual:1', + reason: 'preload', + }), + ]) + ); + + scheduler.markChunkLoaded('walk', 'walk:virtual:0'); + snapshot = scheduler.update([ + { + clipId: 'walk', + layerId: 'base', + stateId: 'walk', + layerWeight: 1, + motionWeight: 1, + loop: false, + time: 0.4, + normalizedTime: 0.4, + }, + ]); + + expect(snapshot.ready).toBe(true); + expect(snapshot.clips[0]).toEqual( + expect.objectContaining({ + activeChunkIds: ['walk:virtual:0'], + requestedChunkIds: expect.arrayContaining(['walk:virtual:1']), + }) + ); + }); + + it('decodes and applies streamed clip chunk payloads onto placeholder clips', () => { + const rig = new AnimationRig({ + bones: [{ name: 'root' }], + }); + const curveLayout = new AnimationCurveLayout(); + const clip = new AnimationClip( + { + id: 'walk', + duration: 1, + tracks: [], + streaming: { + mode: 'streamed', + sourceUri: 'clips/walk.anim', + chunkDuration: 1, + }, + }, + rig, + curveLayout + ); + const chunk = decodeAnimationClipStreamingChunkPayload( + encodeAnimationClipStreamingChunkPayload({ + version: 1, + clipId: 'walk', + startTime: 0, + endTime: 1, + tracks: [ + { + target: 'root', + path: 'translation', + times: [0, 1], + values: [0, 0, 0, 1, 0, 0], + }, + ], + }) + ); + + clip.applyStreamingChunk(chunk, { clipId: 'walk', startTime: 0, endTime: 1 }); + const frame = new AnimationFrame(rig, curveLayout); + clip.sampleTime(0.5, frame); + + expect(clip.definition.tracks).toHaveLength(1); + expect(frame.pose.translations[0]).toBeCloseTo(0.5, 5); + expect(frame.pose.translations[1]).toBeCloseTo(0, 5); + expect(frame.pose.translations[2]).toBeCloseTo(0, 5); + }); + + it('merges sequential streamed chunk payloads into a single clip track timeline', () => { + const rig = new AnimationRig({ + bones: [{ name: 'root' }], + }); + const curveLayout = new AnimationCurveLayout(); + const clip = new AnimationClip( + { + id: 'walk', + duration: 1, + tracks: [], + streaming: { + mode: 'streamed', + sourceUri: 'clips/walk.anim', + chunkDuration: 0.5, + }, + }, + rig, + curveLayout + ); + + clip.applyStreamingChunk( + decodeAnimationClipStreamingChunkPayload( + encodeAnimationClipStreamingChunkPayload({ + version: 1, + clipId: 'walk', + startTime: 0, + endTime: 0.5, + tracks: [ + { + target: 'root', + path: 'translation', + times: [0, 0.5], + values: [0, 0, 0, 0.5, 0, 0], + }, + ], + }) + ), + { clipId: 'walk', startTime: 0, endTime: 0.5 } + ); + clip.applyStreamingChunk( + decodeAnimationClipStreamingChunkPayload( + encodeAnimationClipStreamingChunkPayload({ + version: 1, + clipId: 'walk', + startTime: 0.5, + endTime: 1, + tracks: [ + { + target: 'root', + path: 'translation', + times: [0.5, 1], + values: [0.5, 0, 0, 1, 0, 0], + }, + ], + }) + ), + { clipId: 'walk', startTime: 0.5, endTime: 1 } + ); + + const frame = new AnimationFrame(rig, curveLayout); + clip.sampleTime(0.75, frame); + + expect(clip.definition.tracks[0]?.times).toEqual(new Float32Array([0, 0.5, 1])); + expect(frame.pose.translations[0]).toBeCloseTo(0.75, 5); + }); + + it('supports motion matching, grounding, and clip optimization helpers', () => { + const optimized = optimizeAnimationClipDefinition({ + id: 'stride', + tags: ['locomotion'], + features: [ + { + time: 0.5, + trajectoryPosition: [1, 0, 0], + facingDirection: [1, 0, 0], + tags: ['forward'], + }, + ], + footContacts: [ + { + bone: 'foot', + startTime: 0, + endTime: 0.5, + lockTranslationAxes: [true, true, true], + }, + ], + compression: { + codec: 'keyframe-reduced', + positionTolerance: 1e-3, + }, + tracks: [ + { + target: 'root', + path: 'translation', + times: [0, 0.5, 1], + values: [0, 0, 0, 0.5, 0, 0, 1, 0, 0], + }, + ], + }); + const database = new AnimationMotionMatchDatabase([ + optimized, + { + id: 'turn', + tags: ['turn'], + features: [ + { + time: 0.5, + trajectoryPosition: [0, 0, 1], + facingDirection: [0, 0, 1], + }, + ], + tracks: [ + { + target: 'root', + path: 'translation', + times: [0, 1], + values: [0, 0, 0, 0, 0, 1], + }, + ], + }, + ]); + const rig = new AnimationRig({ + bones: [ + { name: 'root' }, + { name: 'foot', parent: 'root' }, + ], + }); + const clip = new AnimationClip(optimized, rig, new AnimationCurveLayout()); + + expect(optimized.tracks[0]?.keyframeCount).toBe(2); + expect( + database.query({ + desiredTrajectoryPosition: [1, 0, 0], + desiredFacingDirection: [1, 0, 0], + requiredTags: ['locomotion'], + })[0] + ).toEqual( + expect.objectContaining({ + clipId: 'stride', + }) + ); + expect(solvePlanarGrounding(clip, 0.25, { foot: 0.2 }).rootOffset[1]).toBeCloseTo(-0.2, 5); + }); +}); \ No newline at end of file diff --git a/web/packages/animation/src/blend-graph.ts b/web/packages/animation/src/blend-graph.ts new file mode 100644 index 00000000..3b9fe5bc --- /dev/null +++ b/web/packages/animation/src/blend-graph.ts @@ -0,0 +1,374 @@ +import type { + AnimationBlendTreeAdditiveDefinition, + AnimationBlendTreeDefinition, + AnimationMotionClipDefinition, + AnimationMotionDefinition, +} from './types'; + +export interface AnimationBlendGraphDiagnostic { + readonly code: string; + readonly message: string; + readonly path: string; +} + +export interface AnimationBlendGraphValidationOptions { + readonly knownClipIds?: readonly string[]; + readonly knownParameters?: readonly string[]; +} + +export interface AnimationMotionBuilder { + build(): AnimationMotionDefinition; +} + +type AnimationMotionInput = AnimationMotionDefinition | AnimationMotionBuilder; + +const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const freezeMotionDefinition = (motion: AnimationMotionDefinition): AnimationMotionDefinition => { + switch (motion.kind) { + case 'clip': + return Object.freeze({ + kind: 'clip', + clipId: motion.clipId, + ...(isFiniteNumber(motion.timeScale) ? { timeScale: motion.timeScale } : {}), + ...(isFiniteNumber(motion.cycleOffset) ? { cycleOffset: motion.cycleOffset } : {}), + } satisfies AnimationMotionClipDefinition); + case 'blend1d': + return Object.freeze({ + kind: 'blend1d', + parameter: motion.parameter, + children: Object.freeze( + motion.children.map((child) => + Object.freeze({ + threshold: child.threshold, + motion: freezeMotionDefinition(child.motion), + }) + ) + ), + }); + case 'blend2d': + return Object.freeze({ + kind: 'blend2d', + parameterX: motion.parameterX, + parameterY: motion.parameterY, + children: Object.freeze( + motion.children.map((child) => + Object.freeze({ + position: Object.freeze([child.position[0], child.position[1]]) as readonly [number, number], + motion: freezeMotionDefinition(child.motion), + }) + ) + ), + }); + case 'direct': + return Object.freeze({ + kind: 'direct', + children: Object.freeze( + motion.children.map((child) => + Object.freeze({ + motion: freezeMotionDefinition(child.motion), + ...(typeof child.parameter === 'string' ? { parameter: child.parameter } : {}), + ...(isFiniteNumber(child.weight) ? { weight: child.weight } : {}), + }) + ) + ), + }); + case 'additive': + return Object.freeze({ + kind: 'additive', + base: freezeMotionDefinition(motion.base), + additive: freezeMotionDefinition(motion.additive), + ...(typeof motion.parameter === 'string' ? { parameter: motion.parameter } : {}), + ...(isFiniteNumber(motion.weight) ? { weight: motion.weight } : {}), + } satisfies AnimationBlendTreeAdditiveDefinition); + default: + return motion; + } +}; + +const toMotionDefinition = (motion: AnimationMotionInput): AnimationMotionDefinition => + typeof (motion as AnimationMotionBuilder).build === 'function' + ? (motion as AnimationMotionBuilder).build() + : freezeMotionDefinition(motion as AnimationMotionDefinition); + +export class AnimationClipMotionBuilder implements AnimationMotionBuilder { + constructor( + private readonly _clipId: string, + private readonly _timeScale?: number, + private readonly _cycleOffset?: number + ) {} + + withTimeScale(timeScale: number): AnimationClipMotionBuilder { + return new AnimationClipMotionBuilder(this._clipId, timeScale, this._cycleOffset); + } + + withCycleOffset(cycleOffset: number): AnimationClipMotionBuilder { + return new AnimationClipMotionBuilder(this._clipId, this._timeScale, cycleOffset); + } + + build(): AnimationMotionDefinition { + return freezeMotionDefinition({ + kind: 'clip', + clipId: this._clipId, + ...(isFiniteNumber(this._timeScale) ? { timeScale: this._timeScale } : {}), + ...(isFiniteNumber(this._cycleOffset) ? { cycleOffset: this._cycleOffset } : {}), + }); + } +} + +export class AnimationBlend1DGraphBuilder implements AnimationMotionBuilder { + private readonly _children: { threshold: number; motion: AnimationMotionInput }[] = []; + + constructor(private readonly _parameter: string) {} + + addChild(threshold: number, motion: AnimationMotionInput): this { + this._children.push({ threshold, motion }); + return this; + } + + build(): AnimationMotionDefinition { + return freezeMotionDefinition({ + kind: 'blend1d', + parameter: this._parameter, + children: Object.freeze( + [...this._children] + .sort((left, right) => left.threshold - right.threshold) + .map((child) => + Object.freeze({ + threshold: child.threshold, + motion: toMotionDefinition(child.motion), + }) + ) + ), + }); + } +} + +export class AnimationBlend2DGraphBuilder implements AnimationMotionBuilder { + private readonly _children: { x: number; y: number; motion: AnimationMotionInput }[] = []; + + constructor( + private readonly _parameterX: string, + private readonly _parameterY: string + ) {} + + addChild(x: number, y: number, motion: AnimationMotionInput): this { + this._children.push({ x, y, motion }); + return this; + } + + build(): AnimationMotionDefinition { + return freezeMotionDefinition({ + kind: 'blend2d', + parameterX: this._parameterX, + parameterY: this._parameterY, + children: Object.freeze( + this._children.map((child) => + Object.freeze({ + position: Object.freeze([child.x, child.y]) as readonly [number, number], + motion: toMotionDefinition(child.motion), + }) + ) + ), + }); + } +} + +export class AnimationDirectBlendGraphBuilder implements AnimationMotionBuilder { + private readonly _children: { + motion: AnimationMotionInput; + parameter?: string; + weight?: number; + }[] = []; + + addChild( + motion: AnimationMotionInput, + options: { parameter?: string; weight?: number } = {} + ): this { + this._children.push({ motion, parameter: options.parameter, weight: options.weight }); + return this; + } + + build(): AnimationMotionDefinition { + return freezeMotionDefinition({ + kind: 'direct', + children: Object.freeze( + this._children.map((child) => + Object.freeze({ + motion: toMotionDefinition(child.motion), + ...(typeof child.parameter === 'string' ? { parameter: child.parameter } : {}), + ...(isFiniteNumber(child.weight) ? { weight: child.weight } : {}), + }) + ) + ), + }); + } +} + +export class AnimationAdditiveBlendGraphBuilder implements AnimationMotionBuilder { + private _parameter?: string; + private _weight?: number; + + constructor( + private readonly _base: AnimationMotionInput, + private readonly _additive: AnimationMotionInput + ) {} + + withParameter(parameter: string): this { + this._parameter = parameter; + return this; + } + + withWeight(weight: number): this { + this._weight = weight; + return this; + } + + build(): AnimationMotionDefinition { + return freezeMotionDefinition({ + kind: 'additive', + base: toMotionDefinition(this._base), + additive: toMotionDefinition(this._additive), + ...(typeof this._parameter === 'string' ? { parameter: this._parameter } : {}), + ...(isFiniteNumber(this._weight) ? { weight: this._weight } : {}), + }); + } +} + +export const createAnimationClipMotion = ( + clipId: string, + options: { timeScale?: number; cycleOffset?: number } = {} +): AnimationClipMotionBuilder => + new AnimationClipMotionBuilder(clipId, options.timeScale, options.cycleOffset); + +export const createAnimationBlend1DGraph = (parameter: string): AnimationBlend1DGraphBuilder => + new AnimationBlend1DGraphBuilder(parameter); + +export const createAnimationBlend2DGraph = ( + parameterX: string, + parameterY: string +): AnimationBlend2DGraphBuilder => new AnimationBlend2DGraphBuilder(parameterX, parameterY); + +export const createAnimationDirectBlendGraph = (): AnimationDirectBlendGraphBuilder => + new AnimationDirectBlendGraphBuilder(); + +export const createAnimationAdditiveBlendGraph = ( + base: AnimationMotionInput, + additive: AnimationMotionInput +): AnimationAdditiveBlendGraphBuilder => new AnimationAdditiveBlendGraphBuilder(base, additive); + +export const buildAnimationMotionDefinition = (motion: AnimationMotionInput): AnimationMotionDefinition => + toMotionDefinition(motion); + +const pushDiagnostic = ( + diagnostics: AnimationBlendGraphDiagnostic[], + code: string, + message: string, + path: string +): void => { + diagnostics.push(Object.freeze({ code, message, path })); +}; + +const validateMotion = ( + motion: AnimationMotionDefinition, + diagnostics: AnimationBlendGraphDiagnostic[], + options: AnimationBlendGraphValidationOptions, + path: string +): void => { + switch (motion.kind) { + case 'clip': + if (options.knownClipIds && options.knownClipIds.includes(String(motion.clipId)) === false) { + pushDiagnostic(diagnostics, 'animation.blendGraph.clip.unknown', `Unknown clip '${motion.clipId}'`, path); + } + break; + case 'blend1d': + if (motion.children.length === 0) { + pushDiagnostic(diagnostics, 'animation.blendGraph.children.empty', '1D blend graphs require at least one child', path); + } + if (options.knownParameters && options.knownParameters.includes(String(motion.parameter)) === false) { + pushDiagnostic(diagnostics, 'animation.blendGraph.parameter.unknown', `Unknown parameter '${motion.parameter}'`, `${path}.parameter`); + } + for (let index = 0; index < motion.children.length; index += 1) { + const child = motion.children[index]!; + if (!isFiniteNumber(child.threshold)) { + pushDiagnostic(diagnostics, 'animation.blendGraph.threshold.invalid', '1D child threshold must be finite', `${path}.children[${index}]`); + } + validateMotion(child.motion, diagnostics, options, `${path}.children[${index}].motion`); + } + break; + case 'blend2d': + if (motion.children.length === 0) { + pushDiagnostic(diagnostics, 'animation.blendGraph.children.empty', '2D blend graphs require at least one child', path); + } + if (options.knownParameters && options.knownParameters.includes(String(motion.parameterX)) === false) { + pushDiagnostic(diagnostics, 'animation.blendGraph.parameter.unknown', `Unknown parameter '${motion.parameterX}'`, `${path}.parameterX`); + } + if (options.knownParameters && options.knownParameters.includes(String(motion.parameterY)) === false) { + pushDiagnostic(diagnostics, 'animation.blendGraph.parameter.unknown', `Unknown parameter '${motion.parameterY}'`, `${path}.parameterY`); + } + for (let index = 0; index < motion.children.length; index += 1) { + const child = motion.children[index]!; + if (!isFiniteNumber(child.position[0]) || !isFiniteNumber(child.position[1])) { + pushDiagnostic(diagnostics, 'animation.blendGraph.position.invalid', '2D child position must be finite', `${path}.children[${index}]`); + } + validateMotion(child.motion, diagnostics, options, `${path}.children[${index}].motion`); + } + break; + case 'direct': + if (motion.children.length === 0) { + pushDiagnostic(diagnostics, 'animation.blendGraph.children.empty', 'Direct blend graphs require at least one child', path); + } + for (let index = 0; index < motion.children.length; index += 1) { + const child = motion.children[index]!; + if ( + typeof child.parameter === 'string' && + options.knownParameters && + options.knownParameters.includes(child.parameter) === false + ) { + pushDiagnostic(diagnostics, 'animation.blendGraph.parameter.unknown', `Unknown parameter '${child.parameter}'`, `${path}.children[${index}].parameter`); + } + if (child.weight !== undefined && !isFiniteNumber(child.weight)) { + pushDiagnostic(diagnostics, 'animation.blendGraph.weight.invalid', 'Direct child weight must be finite', `${path}.children[${index}].weight`); + } + validateMotion(child.motion, diagnostics, options, `${path}.children[${index}].motion`); + } + break; + case 'additive': + if ( + typeof motion.parameter === 'string' && + options.knownParameters && + options.knownParameters.includes(motion.parameter) === false + ) { + pushDiagnostic(diagnostics, 'animation.blendGraph.parameter.unknown', `Unknown parameter '${motion.parameter}'`, `${path}.parameter`); + } + if (motion.weight !== undefined && !isFiniteNumber(motion.weight)) { + pushDiagnostic(diagnostics, 'animation.blendGraph.weight.invalid', 'Additive weight must be finite', `${path}.weight`); + } + validateMotion(motion.base, diagnostics, options, `${path}.base`); + validateMotion(motion.additive, diagnostics, options, `${path}.additive`); + break; + default: + pushDiagnostic(diagnostics, 'animation.blendGraph.kind.unsupported', `Unsupported motion kind '${String((motion as AnimationBlendTreeDefinition).kind)}'`, path); + break; + } +}; + +export const validateAnimationMotionDefinition = ( + motion: AnimationMotionInput, + options: AnimationBlendGraphValidationOptions = {} +): readonly AnimationBlendGraphDiagnostic[] => { + const diagnostics: AnimationBlendGraphDiagnostic[] = []; + validateMotion(toMotionDefinition(motion), diagnostics, options, 'motion'); + return Object.freeze(diagnostics); +}; + +export const AnimationBlendGraph = Object.freeze({ + clip: createAnimationClipMotion, + blend1d: createAnimationBlend1DGraph, + blend2d: createAnimationBlend2DGraph, + direct: createAnimationDirectBlendGraph, + additive: createAnimationAdditiveBlendGraph, + build: buildAnimationMotionDefinition, + validate: validateAnimationMotionDefinition, +}); \ No newline at end of file diff --git a/web/packages/animation/src/blend-tree.ts b/web/packages/animation/src/blend-tree.ts new file mode 100644 index 00000000..2f0d6519 --- /dev/null +++ b/web/packages/animation/src/blend-tree.ts @@ -0,0 +1,1062 @@ +import { ObjectPool } from '@axrone/memory'; +import { AnimationStateMachineError, AnimationValidationError } from './errors'; +import { quatDot, quatIdentity, quatMultiply, quatNormalize, quatSlerp } from './math'; +import { AnimationParameterStore } from './parameters'; +import { applyAdditiveFrame, blendFrame, blendWeightedFrames, AnimationFrame, type AnimationCurveLayout } from './pose'; +import type { AnimationRig } from './rig'; +import { AnimationClip } from './clip'; +import type { + AnimationControllerClipActivity, + AnimationBlendTreeDefinition, + AnimationControllerEvent, + AnimationMotionDefinition, +} from './types'; + +export interface AnimationMotionEvaluationContext { + readonly rig: AnimationRig; + readonly parameters: AnimationParameterStore; + readonly restFrame: AnimationFrame; + readonly scratch: AnimationScratchPool; +} + +export class AnimationScratchPool { + private readonly _framePool: ObjectPool; + private readonly _activeFrames: AnimationFrame[] = []; + + constructor( + private readonly _rig: AnimationRig, + private readonly _curveLayout: AnimationCurveLayout, + private readonly _curveDefaults?: ArrayLike + ) { + this._framePool = new ObjectPool({ + initialCapacity: 8, + maxCapacity: 256, + minFree: 8, + expansionStrategy: 'multiplicative', + expansionFactor: 1.5, + allocationStrategy: 'least-recently-used', + evictionPolicy: 'lru', + resetOnRecycle: true, + preallocate: false, + autoExpand: true, + enableMetrics: false, + name: 'AnimationScratchPool', + factory: () => new AnimationFrame(this._rig, this._curveLayout), + resetHandler: (frame) => { + frame.reset(this._rig, this._curveDefaults); + }, + }); + } + + reset(): void { + for (let index = this._activeFrames.length - 1; index >= 0; index -= 1) { + this._framePool.release(this._activeFrames[index]!); + } + this._activeFrames.length = 0; + } + + acquire(): AnimationFrame { + const frame = this._framePool.acquire(); + frame.reset(this._rig, this._curveDefaults); + this._activeFrames.push(frame); + return frame; + } +} + +export type AnimationCompiledMotion = + | { + readonly kind: 'clip'; + readonly clip: AnimationClip; + readonly timeScale: number; + readonly cycleOffset: number; + } + | { + readonly kind: 'blend1d'; + readonly parameter: string; + readonly children: readonly { + readonly threshold: number; + readonly motion: AnimationCompiledMotion; + }[]; + } + | { + readonly kind: 'blend2d'; + readonly parameterX: string; + readonly parameterY: string; + readonly children: readonly { + readonly x: number; + readonly y: number; + readonly motion: AnimationCompiledMotion; + }[]; + } + | { + readonly kind: 'direct'; + readonly children: readonly { + readonly parameter?: string; + readonly weight: number; + readonly motion: AnimationCompiledMotion; + }[]; + } + | { + readonly kind: 'additive'; + readonly base: AnimationCompiledMotion; + readonly additive: AnimationCompiledMotion; + readonly parameter?: string; + readonly weight: number; + }; + +const resolveMotionTime = ( + normalizedTime: number, + duration: number, + cycleOffset: number, + loop: boolean +): number => { + if (duration <= 0) { + return 0; + } + const offsetTime = normalizedTime + cycleOffset; + if (!loop) { + const normalized = Math.max(0, Math.min(1, offsetTime)); + return normalized * duration; + } + const wrapped = offsetTime % 1; + const normalized = wrapped < 0 ? wrapped + 1 : wrapped; + return normalized * duration; +}; + +const compileBlendTree = ( + definition: AnimationBlendTreeDefinition, + clips: ReadonlyMap +): AnimationCompiledMotion => { + switch (definition.kind) { + case 'blend1d': + if (definition.children.length === 0) { + throw new AnimationValidationError('1D blend trees require at least one child'); + } + return Object.freeze({ + kind: 'blend1d', + parameter: definition.parameter, + children: Object.freeze( + [...definition.children] + .map((child) => + Object.freeze({ + threshold: child.threshold, + motion: compileMotion(child.motion, clips), + }) + ) + .sort((left, right) => left.threshold - right.threshold) + ), + }); + case 'blend2d': + if (definition.children.length === 0) { + throw new AnimationValidationError('2D blend trees require at least one child'); + } + return Object.freeze({ + kind: 'blend2d', + parameterX: definition.parameterX, + parameterY: definition.parameterY, + children: Object.freeze( + definition.children.map((child) => + Object.freeze({ + x: child.position[0], + y: child.position[1], + motion: compileMotion(child.motion, clips), + }) + ) + ), + }); + case 'direct': + if (definition.children.length === 0) { + throw new AnimationValidationError('Direct blend trees require at least one child'); + } + return Object.freeze({ + kind: 'direct', + children: Object.freeze( + definition.children.map((child) => + Object.freeze({ + parameter: child.parameter, + weight: child.weight ?? 1, + motion: compileMotion(child.motion, clips), + }) + ) + ), + }); + case 'additive': + return Object.freeze({ + kind: 'additive', + base: compileMotion(definition.base, clips), + additive: compileMotion(definition.additive, clips), + parameter: definition.parameter, + weight: definition.weight ?? 1, + }); + default: + throw new AnimationValidationError(`Unsupported blend tree '${String((definition as { kind?: unknown }).kind)}'`); + } +}; + +export const compileMotion = ( + definition: AnimationMotionDefinition, + clips: ReadonlyMap +): AnimationCompiledMotion => { + if (definition.kind === 'clip') { + const clip = clips.get(definition.clipId); + if (!clip) { + throw new AnimationValidationError(`Unknown animation clip '${definition.clipId}'`); + } + return Object.freeze({ + kind: 'clip', + clip, + timeScale: definition.timeScale ?? 1, + cycleOffset: definition.cycleOffset ?? 0, + }); + } + return compileBlendTree(definition, clips); +}; + +const resolveDirectChildWeight = ( + parameters: AnimationParameterStore, + parameter: string | undefined, + weight: number +): number => { + if (!parameter) { + return Math.max(0, weight); + } + const value = parameters.get(parameter); + return typeof value === 'number' ? Math.max(0, value * weight) : value ? Math.max(0, weight) : 0; +}; + +const resolveBlend2DWeights = ( + x: number, + y: number, + children: AnimationCompiledMotion extends never ? never : readonly { + readonly x: number; + readonly y: number; + readonly motion: AnimationCompiledMotion; + }[] +): number[] => { + const distances = new Array(children.length); + let total = 0; + for (let index = 0; index < children.length; index += 1) { + const child = children[index]!; + const dx = x - child.x; + const dy = y - child.y; + const distanceSquared = dx * dx + dy * dy; + if (distanceSquared <= 1e-12) { + const result = new Array(children.length).fill(0); + result[index] = 1; + return result; + } + const inverseDistance = 1 / Math.sqrt(distanceSquared); + distances[index] = inverseDistance; + total += inverseDistance; + } + if (total <= 0) { + return new Array(children.length).fill(1 / Math.max(1, children.length)); + } + return distances.map((value) => value / total); +}; + +export const resolveMotionDuration = ( + motion: AnimationCompiledMotion, + parameters: AnimationParameterStore +): number => { + switch (motion.kind) { + case 'clip': + return motion.clip.duration / Math.max(Math.abs(motion.timeScale), 1e-6); + case 'blend1d': { + const parameterValue = parameters.get(motion.parameter); + const input = typeof parameterValue === 'number' ? parameterValue : parameterValue ? 1 : 0; + if (motion.children.length === 1) { + return resolveMotionDuration(motion.children[0]!.motion, parameters); + } + if (input <= motion.children[0]!.threshold) { + return resolveMotionDuration(motion.children[0]!.motion, parameters); + } + for (let index = 0; index < motion.children.length - 1; index += 1) { + const left = motion.children[index]!; + const right = motion.children[index + 1]!; + if (input > right.threshold) { + continue; + } + const alpha = (input - left.threshold) / Math.max(1e-6, right.threshold - left.threshold); + return ( + resolveMotionDuration(left.motion, parameters) * (1 - alpha) + + resolveMotionDuration(right.motion, parameters) * alpha + ); + } + return resolveMotionDuration(motion.children[motion.children.length - 1]!.motion, parameters); + } + case 'blend2d': { + const parameterX = parameters.get(motion.parameterX); + const parameterY = parameters.get(motion.parameterY); + const weights = resolveBlend2DWeights( + typeof parameterX === 'number' ? parameterX : parameterX ? 1 : 0, + typeof parameterY === 'number' ? parameterY : parameterY ? 1 : 0, + motion.children + ); + let total = 0; + for (let index = 0; index < motion.children.length; index += 1) { + total += resolveMotionDuration(motion.children[index]!.motion, parameters) * weights[index]!; + } + return total; + } + case 'direct': { + let weightedDuration = 0; + let totalWeight = 0; + for (let index = 0; index < motion.children.length; index += 1) { + const child = motion.children[index]!; + const weight = resolveDirectChildWeight(parameters, child.parameter, child.weight); + if (weight <= 0) { + continue; + } + totalWeight += weight; + weightedDuration += resolveMotionDuration(child.motion, parameters) * weight; + } + return totalWeight > 0 ? weightedDuration / totalWeight : 0; + } + case 'additive': + return resolveMotionDuration(motion.base, parameters); + default: + throw new AnimationStateMachineError(`Unsupported motion kind '${String((motion as { kind?: unknown }).kind)}'`); + } +}; + +export const evaluateMotion = ( + motion: AnimationCompiledMotion, + normalizedTime: number, + context: AnimationMotionEvaluationContext, + out: AnimationFrame, + loop: boolean = true +): AnimationFrame => { + switch (motion.kind) { + case 'clip': { + out.reset(context.rig, context.restFrame.curves.values); + const time = resolveMotionTime( + normalizedTime * motion.timeScale, + motion.clip.duration, + motion.cycleOffset, + loop + ); + return motion.clip.sampleTime(time, out); + } + case 'blend1d': { + const parameterValue = context.parameters.get(motion.parameter); + const input = typeof parameterValue === 'number' ? parameterValue : parameterValue ? 1 : 0; + if (motion.children.length === 1 || input <= motion.children[0]!.threshold) { + return evaluateMotion(motion.children[0]!.motion, normalizedTime, context, out, loop); + } + for (let index = 0; index < motion.children.length - 1; index += 1) { + const left = motion.children[index]!; + const right = motion.children[index + 1]!; + if (input > right.threshold) { + continue; + } + const alpha = + (input - left.threshold) / Math.max(1e-6, right.threshold - left.threshold); + const leftFrame = context.scratch.acquire(); + const rightFrame = context.scratch.acquire(); + evaluateMotion(left.motion, normalizedTime, context, leftFrame, loop); + evaluateMotion(right.motion, normalizedTime, context, rightFrame, loop); + return blendFrame(out, leftFrame, rightFrame, alpha); + } + return evaluateMotion( + motion.children[motion.children.length - 1]!.motion, + normalizedTime, + context, + out, + loop + ); + } + case 'blend2d': { + const parameterX = context.parameters.get(motion.parameterX); + const parameterY = context.parameters.get(motion.parameterY); + const weights = resolveBlend2DWeights( + typeof parameterX === 'number' ? parameterX : parameterX ? 1 : 0, + typeof parameterY === 'number' ? parameterY : parameterY ? 1 : 0, + motion.children + ); + const frames = new Array(motion.children.length); + for (let index = 0; index < motion.children.length; index += 1) { + const frame = context.scratch.acquire(); + evaluateMotion(motion.children[index]!.motion, normalizedTime, context, frame, loop); + frames[index] = frame; + } + return blendWeightedFrames(out, frames, weights, context.restFrame); + } + case 'direct': { + const frames: AnimationFrame[] = []; + const weights: number[] = []; + for (let index = 0; index < motion.children.length; index += 1) { + const child = motion.children[index]!; + const weight = resolveDirectChildWeight(context.parameters, child.parameter, child.weight); + if (weight <= 0) { + continue; + } + const frame = context.scratch.acquire(); + evaluateMotion(child.motion, normalizedTime, context, frame, loop); + frames.push(frame); + weights.push(weight); + } + if (frames.length === 0) { + return out.copyFrom(context.restFrame); + } + if (frames.length === 1) { + return out.copyFrom(frames[0]!); + } + return blendWeightedFrames(out, frames, weights, context.restFrame); + } + case 'additive': { + const baseFrame = context.scratch.acquire(); + const additiveFrame = context.scratch.acquire(); + evaluateMotion(motion.base, normalizedTime, context, baseFrame, loop); + evaluateMotion(motion.additive, normalizedTime, context, additiveFrame, loop); + const parameterWeight = motion.parameter ? context.parameters.get(motion.parameter) : motion.weight; + const resolvedWeight = + typeof parameterWeight === 'number' + ? parameterWeight + : parameterWeight + ? motion.weight + : 0; + return applyAdditiveFrame(out, baseFrame, additiveFrame, context.restFrame, resolvedWeight); + } + default: + throw new AnimationStateMachineError(`Unsupported motion kind '${String((motion as { kind?: unknown }).kind)}'`); + } +}; + +export const collectMotionEvents = ( + motion: AnimationCompiledMotion, + previousNormalizedTime: number, + currentNormalizedTime: number, + loop: boolean, + parameters: AnimationParameterStore, + layerId: string, + stateId: string, + layerWeight: number, + motionWeight: number, + out: AnimationControllerEvent[] = [] +): readonly AnimationControllerEvent[] => { + const resolvedLayerWeight = Math.max(0, Math.min(1, layerWeight)); + const resolvedMotionWeight = Math.max(0, motionWeight); + if (resolvedLayerWeight <= 0 || resolvedMotionWeight <= 0) { + return out; + } + + switch (motion.kind) { + case 'clip': { + const hits = motion.clip.collectEvents( + resolveMotionTime( + previousNormalizedTime * motion.timeScale, + motion.clip.duration, + motion.cycleOffset, + loop + ), + resolveMotionTime( + currentNormalizedTime * motion.timeScale, + motion.clip.duration, + motion.cycleOffset, + loop + ), + loop + ); + for (let index = 0; index < hits.length; index += 1) { + const event = hits[index]!; + out.push( + Object.freeze({ + ...event, + layerId, + stateId, + layerWeight: resolvedLayerWeight, + motionWeight: resolvedMotionWeight, + } satisfies AnimationControllerEvent) + ); + } + return out; + } + case 'blend1d': { + const parameterValue = parameters.get(motion.parameter); + const input = typeof parameterValue === 'number' ? parameterValue : parameterValue ? 1 : 0; + if (motion.children.length === 1 || input <= motion.children[0]!.threshold) { + return collectMotionEvents( + motion.children[0]!.motion, + previousNormalizedTime, + currentNormalizedTime, + loop, + parameters, + layerId, + stateId, + resolvedLayerWeight, + resolvedMotionWeight, + out + ); + } + for (let index = 0; index < motion.children.length - 1; index += 1) { + const left = motion.children[index]!; + const right = motion.children[index + 1]!; + if (input > right.threshold) { + continue; + } + const alpha = (input - left.threshold) / Math.max(1e-6, right.threshold - left.threshold); + collectMotionEvents( + left.motion, + previousNormalizedTime, + currentNormalizedTime, + loop, + parameters, + layerId, + stateId, + resolvedLayerWeight, + resolvedMotionWeight * (1 - alpha), + out + ); + collectMotionEvents( + right.motion, + previousNormalizedTime, + currentNormalizedTime, + loop, + parameters, + layerId, + stateId, + resolvedLayerWeight, + resolvedMotionWeight * alpha, + out + ); + return out; + } + return collectMotionEvents( + motion.children[motion.children.length - 1]!.motion, + previousNormalizedTime, + currentNormalizedTime, + loop, + parameters, + layerId, + stateId, + resolvedLayerWeight, + resolvedMotionWeight, + out + ); + } + case 'blend2d': { + const parameterX = parameters.get(motion.parameterX); + const parameterY = parameters.get(motion.parameterY); + const weights = resolveBlend2DWeights( + typeof parameterX === 'number' ? parameterX : parameterX ? 1 : 0, + typeof parameterY === 'number' ? parameterY : parameterY ? 1 : 0, + motion.children + ); + for (let index = 0; index < motion.children.length; index += 1) { + const childWeight = weights[index] ?? 0; + if (childWeight <= 0) { + continue; + } + collectMotionEvents( + motion.children[index]!.motion, + previousNormalizedTime, + currentNormalizedTime, + loop, + parameters, + layerId, + stateId, + resolvedLayerWeight, + resolvedMotionWeight * childWeight, + out + ); + } + return out; + } + case 'direct': { + for (let index = 0; index < motion.children.length; index += 1) { + const child = motion.children[index]!; + const childWeight = resolveDirectChildWeight(parameters, child.parameter, child.weight); + if (childWeight <= 0) { + continue; + } + collectMotionEvents( + child.motion, + previousNormalizedTime, + currentNormalizedTime, + loop, + parameters, + layerId, + stateId, + resolvedLayerWeight, + resolvedMotionWeight * childWeight, + out + ); + } + return out; + } + case 'additive': { + collectMotionEvents( + motion.base, + previousNormalizedTime, + currentNormalizedTime, + loop, + parameters, + layerId, + stateId, + resolvedLayerWeight, + resolvedMotionWeight, + out + ); + const parameterWeight = motion.parameter ? parameters.get(motion.parameter) : motion.weight; + const additiveWeight = + typeof parameterWeight === 'number' ? parameterWeight : parameterWeight ? motion.weight : 0; + if (additiveWeight > 0) { + collectMotionEvents( + motion.additive, + previousNormalizedTime, + currentNormalizedTime, + loop, + parameters, + layerId, + stateId, + resolvedLayerWeight, + resolvedMotionWeight * additiveWeight, + out + ); + } + return out; + } + default: + throw new AnimationStateMachineError(`Unsupported motion kind '${String((motion as { kind?: unknown }).kind)}'`); + } +}; + +export const collectMotionClipActivities = ( + motion: AnimationCompiledMotion, + normalizedTime: number, + loop: boolean, + parameters: AnimationParameterStore, + layerId: string, + stateId: string, + layerWeight: number, + motionWeight: number, + out: AnimationControllerClipActivity[] = [] +): readonly AnimationControllerClipActivity[] => { + const resolvedLayerWeight = Math.max(0, Math.min(1, layerWeight)); + const resolvedMotionWeight = Math.max(0, motionWeight); + if (resolvedLayerWeight <= 0 || resolvedMotionWeight <= 0) { + return out; + } + + switch (motion.kind) { + case 'clip': { + const time = resolveMotionTime( + normalizedTime * motion.timeScale, + motion.clip.duration, + motion.cycleOffset, + loop + ); + out.push( + Object.freeze({ + clipId: motion.clip.id, + layerId, + stateId, + layerWeight: resolvedLayerWeight, + motionWeight: resolvedMotionWeight, + loop, + time, + normalizedTime: motion.clip.duration > 0 ? time / motion.clip.duration : 0, + } satisfies AnimationControllerClipActivity) + ); + return out; + } + case 'blend1d': { + const parameterValue = parameters.get(motion.parameter); + const input = typeof parameterValue === 'number' ? parameterValue : parameterValue ? 1 : 0; + if (motion.children.length === 1 || input <= motion.children[0]!.threshold) { + return collectMotionClipActivities( + motion.children[0]!.motion, + normalizedTime, + loop, + parameters, + layerId, + stateId, + resolvedLayerWeight, + resolvedMotionWeight, + out + ); + } + for (let index = 0; index < motion.children.length - 1; index += 1) { + const left = motion.children[index]!; + const right = motion.children[index + 1]!; + if (input > right.threshold) { + continue; + } + const alpha = (input - left.threshold) / Math.max(1e-6, right.threshold - left.threshold); + collectMotionClipActivities( + left.motion, + normalizedTime, + loop, + parameters, + layerId, + stateId, + resolvedLayerWeight, + resolvedMotionWeight * (1 - alpha), + out + ); + collectMotionClipActivities( + right.motion, + normalizedTime, + loop, + parameters, + layerId, + stateId, + resolvedLayerWeight, + resolvedMotionWeight * alpha, + out + ); + return out; + } + return collectMotionClipActivities( + motion.children[motion.children.length - 1]!.motion, + normalizedTime, + loop, + parameters, + layerId, + stateId, + resolvedLayerWeight, + resolvedMotionWeight, + out + ); + } + case 'blend2d': { + const parameterX = parameters.get(motion.parameterX); + const parameterY = parameters.get(motion.parameterY); + const weights = resolveBlend2DWeights( + typeof parameterX === 'number' ? parameterX : parameterX ? 1 : 0, + typeof parameterY === 'number' ? parameterY : parameterY ? 1 : 0, + motion.children + ); + for (let index = 0; index < motion.children.length; index += 1) { + const childWeight = weights[index] ?? 0; + if (childWeight <= 0) { + continue; + } + collectMotionClipActivities( + motion.children[index]!.motion, + normalizedTime, + loop, + parameters, + layerId, + stateId, + resolvedLayerWeight, + resolvedMotionWeight * childWeight, + out + ); + } + return out; + } + case 'direct': { + for (let index = 0; index < motion.children.length; index += 1) { + const child = motion.children[index]!; + const childWeight = resolveDirectChildWeight(parameters, child.parameter, child.weight); + if (childWeight <= 0) { + continue; + } + collectMotionClipActivities( + child.motion, + normalizedTime, + loop, + parameters, + layerId, + stateId, + resolvedLayerWeight, + resolvedMotionWeight * childWeight, + out + ); + } + return out; + } + case 'additive': { + collectMotionClipActivities( + motion.base, + normalizedTime, + loop, + parameters, + layerId, + stateId, + resolvedLayerWeight, + resolvedMotionWeight, + out + ); + const parameterWeight = motion.parameter ? parameters.get(motion.parameter) : motion.weight; + const additiveWeight = + typeof parameterWeight === 'number' ? parameterWeight : parameterWeight ? motion.weight : 0; + if (additiveWeight > 0) { + collectMotionClipActivities( + motion.additive, + normalizedTime, + loop, + parameters, + layerId, + stateId, + resolvedLayerWeight, + resolvedMotionWeight * additiveWeight, + out + ); + } + return out; + } + default: + throw new AnimationStateMachineError(`Unsupported motion kind '${String((motion as { kind?: unknown }).kind)}'`); + } +}; + +const blendRootDelta = ( + rotations: readonly Float32Array[], + weights: readonly number[], + outRotation: Float32Array +): void => { + let qx = 0; + let qy = 0; + let qz = 0; + let qw = 0; + let total = 0; + let reference = -1; + for (let index = 0; index < rotations.length; index += 1) { + const weight = Math.max(0, weights[index] ?? 0); + if (weight <= 0) { + continue; + } + total += weight; + const rotation = rotations[index]!; + const sign = reference >= 0 && quatDot(rotations[reference]!, 0, rotation, 0) < 0 ? -1 : 1; + qx += rotation[0]! * weight * sign; + qy += rotation[1]! * weight * sign; + qz += rotation[2]! * weight * sign; + qw += rotation[3]! * weight * sign; + if (reference < 0) { + reference = index; + } + } + if (total <= 0) { + quatIdentity(outRotation, 0); + return; + } + outRotation[0] = qx / total; + outRotation[1] = qy / total; + outRotation[2] = qz / total; + outRotation[3] = qw / total; + quatNormalize(outRotation, 0, outRotation, 0); +}; + +export const extractMotionRootDelta = ( + motion: AnimationCompiledMotion, + previousNormalizedTime: number, + currentNormalizedTime: number, + loop: boolean, + rootBoneIndex: number, + rig: AnimationRig, + parameters: AnimationParameterStore, + outTranslation: Float32Array, + outRotation: Float32Array +): void => { + switch (motion.kind) { + case 'clip': { + motion.clip.extractBoneDelta( + rootBoneIndex, + resolveMotionTime( + previousNormalizedTime * motion.timeScale, + motion.clip.duration, + motion.cycleOffset, + loop + ), + resolveMotionTime( + currentNormalizedTime * motion.timeScale, + motion.clip.duration, + motion.cycleOffset, + loop + ), + loop, + rig, + outTranslation, + outRotation + ); + return; + } + case 'blend1d': { + const parameterValue = parameters.get(motion.parameter); + const input = typeof parameterValue === 'number' ? parameterValue : parameterValue ? 1 : 0; + if (motion.children.length === 1 || input <= motion.children[0]!.threshold) { + return extractMotionRootDelta( + motion.children[0]!.motion, + previousNormalizedTime, + currentNormalizedTime, + loop, + rootBoneIndex, + rig, + parameters, + outTranslation, + outRotation + ); + } + for (let index = 0; index < motion.children.length - 1; index += 1) { + const left = motion.children[index]!; + const right = motion.children[index + 1]!; + if (input > right.threshold) { + continue; + } + const alpha = + (input - left.threshold) / Math.max(1e-6, right.threshold - left.threshold); + const leftTranslation = new Float32Array(3); + const rightTranslation = new Float32Array(3); + const leftRotation = new Float32Array(4); + const rightRotation = new Float32Array(4); + extractMotionRootDelta( + left.motion, + previousNormalizedTime, + currentNormalizedTime, + loop, + rootBoneIndex, + rig, + parameters, + leftTranslation, + leftRotation + ); + extractMotionRootDelta( + right.motion, + previousNormalizedTime, + currentNormalizedTime, + loop, + rootBoneIndex, + rig, + parameters, + rightTranslation, + rightRotation + ); + outTranslation[0] = leftTranslation[0]! + (rightTranslation[0]! - leftTranslation[0]!) * alpha; + outTranslation[1] = leftTranslation[1]! + (rightTranslation[1]! - leftTranslation[1]!) * alpha; + outTranslation[2] = leftTranslation[2]! + (rightTranslation[2]! - leftTranslation[2]!) * alpha; + quatSlerp(outRotation, 0, leftRotation, 0, rightRotation, 0, alpha); + return; + } + return extractMotionRootDelta( + motion.children[motion.children.length - 1]!.motion, + previousNormalizedTime, + currentNormalizedTime, + loop, + rootBoneIndex, + rig, + parameters, + outTranslation, + outRotation + ); + } + case 'blend2d': { + const parameterX = parameters.get(motion.parameterX); + const parameterY = parameters.get(motion.parameterY); + const weights = resolveBlend2DWeights( + typeof parameterX === 'number' ? parameterX : parameterX ? 1 : 0, + typeof parameterY === 'number' ? parameterY : parameterY ? 1 : 0, + motion.children + ); + const rotations: Float32Array[] = []; + outTranslation.fill(0); + for (let index = 0; index < motion.children.length; index += 1) { + const translation = new Float32Array(3); + const rotation = new Float32Array(4); + extractMotionRootDelta( + motion.children[index]!.motion, + previousNormalizedTime, + currentNormalizedTime, + loop, + rootBoneIndex, + rig, + parameters, + translation, + rotation + ); + outTranslation[0] += translation[0]! * weights[index]!; + outTranslation[1] += translation[1]! * weights[index]!; + outTranslation[2] += translation[2]! * weights[index]!; + rotations.push(rotation); + } + blendRootDelta(rotations, weights, outRotation); + return; + } + case 'direct': { + const weights: number[] = []; + const rotations: Float32Array[] = []; + outTranslation.fill(0); + for (let index = 0; index < motion.children.length; index += 1) { + const child = motion.children[index]!; + const weight = resolveDirectChildWeight(parameters, child.parameter, child.weight); + if (weight <= 0) { + continue; + } + const translation = new Float32Array(3); + const rotation = new Float32Array(4); + extractMotionRootDelta( + child.motion, + previousNormalizedTime, + currentNormalizedTime, + loop, + rootBoneIndex, + rig, + parameters, + translation, + rotation + ); + outTranslation[0] += translation[0]! * weight; + outTranslation[1] += translation[1]! * weight; + outTranslation[2] += translation[2]! * weight; + weights.push(weight); + rotations.push(rotation); + } + const totalWeight = weights.reduce((accumulator, value) => accumulator + value, 0); + if (totalWeight > 0) { + outTranslation[0] /= totalWeight; + outTranslation[1] /= totalWeight; + outTranslation[2] /= totalWeight; + } + blendRootDelta(rotations, weights, outRotation); + return; + } + case 'additive': { + const baseTranslation = new Float32Array(3); + const additiveTranslation = new Float32Array(3); + const baseRotation = new Float32Array(4); + const additiveRotation = new Float32Array(4); + extractMotionRootDelta( + motion.base, + previousNormalizedTime, + currentNormalizedTime, + loop, + rootBoneIndex, + rig, + parameters, + baseTranslation, + baseRotation + ); + extractMotionRootDelta( + motion.additive, + previousNormalizedTime, + currentNormalizedTime, + loop, + rootBoneIndex, + rig, + parameters, + additiveTranslation, + additiveRotation + ); + const parameterWeight = motion.parameter ? parameters.get(motion.parameter) : motion.weight; + const resolvedWeight = + typeof parameterWeight === 'number' + ? parameterWeight + : parameterWeight + ? motion.weight + : 0; + outTranslation[0] = baseTranslation[0]! + additiveTranslation[0]! * resolvedWeight; + outTranslation[1] = baseTranslation[1]! + additiveTranslation[1]! * resolvedWeight; + outTranslation[2] = baseTranslation[2]! + additiveTranslation[2]! * resolvedWeight; + quatIdentity(outRotation, 0); + quatSlerp(outRotation, 0, outRotation, 0, additiveRotation, 0, resolvedWeight); + quatMultiply(outRotation, 0, baseRotation, 0, outRotation, 0); + quatNormalize(outRotation, 0, outRotation, 0); + return; + } + default: + throw new AnimationStateMachineError(`Unsupported motion kind '${String((motion as { kind?: unknown }).kind)}'`); + } +}; diff --git a/web/packages/animation/src/brands.ts b/web/packages/animation/src/brands.ts new file mode 100644 index 00000000..8c2f9cce --- /dev/null +++ b/web/packages/animation/src/brands.ts @@ -0,0 +1,15 @@ +import type { Brand } from '@axrone/utility'; + +export type { Brand }; + +export type AnimationRigId = Brand; +export type AnimationClipId = Brand; +export type AnimationLayerId = Brand; +export type AnimationStateId = Brand; +export type AnimationParameterId = Brand; +export type AnimationCurveId = Brand; +export type AnimationIkJobId = Brand; +export type AnimationRetargetProfileId = Brand; + +export const brandString = (value: string): Brand => + value as Brand; diff --git a/web/packages/animation/src/clip.ts b/web/packages/animation/src/clip.ts new file mode 100644 index 00000000..61c1f220 --- /dev/null +++ b/web/packages/animation/src/clip.ts @@ -0,0 +1,846 @@ +import { AnimationSamplingError, AnimationValidationError } from './errors'; +import { clamp, quatCopy, quatIdentity, quatInvert, quatMultiply, quatNormalize, quatSlerp, toFloat32Array, vec3Copy, vec3Lerp } from './math'; +import type { AnimationCurveLayout, AnimationFrame } from './pose'; +import type { AnimationRig } from './rig'; +import { + applyAnimationClipStreamingChunkDefinition, + type AnimationClipStreamingChunkApplicationOptions, + type AnimationClipStreamingChunkPayload, +} from './streaming-chunk'; +import type { + AnimationClipCompressionDefinition, + AnimationClipDefinition, + AnimationClipEventDefinition, + AnimationClipEventOccurrence, + AnimationFootContactDefinition, + AnimationFootContactState, + AnimationInterpolation, + AnimationMotionFeatureDefinition, + AnimationTrackDefinition, + AnimationClipStreamingCatalogDefinition, + AnimationClipStreamingChunkDefinition, + AnimationClipStreamingDefinition, +} from './types'; + +const enum AnimationInterpolationMode { + Linear = 0, + Step = 1, + CubicSpline = 2, +} + +interface AnimationBoneTrack { + readonly targetIndex: number; + readonly path: AnimationTrackDefinition['path']; + readonly interpolation: AnimationInterpolationMode; + readonly times: Float32Array; + readonly values: Float32Array; + readonly keyframeCount: number; + readonly valueComponentCount: number; + readonly sampleStride: number; +} + +interface AnimationCurveTrack extends AnimationBoneTrack { + readonly curveOffset: number; + readonly curveComponentCount: number; +} + +const resolveInterpolationMode = ( + value: AnimationInterpolation | undefined +): AnimationInterpolationMode => { + switch (value) { + case 'STEP': + return AnimationInterpolationMode.Step; + case 'CUBICSPLINE': + return AnimationInterpolationMode.CubicSpline; + case 'LINEAR': + case undefined: + return AnimationInterpolationMode.Linear; + default: + throw new AnimationValidationError(`Unsupported interpolation '${String(value)}'`); + } +}; + +const findFrameIndex = (times: Float32Array, time: number): number => { + if (times.length <= 1 || time <= times[0]!) { + return 0; + } + const lastIndex = times.length - 1; + if (time >= times[lastIndex]!) { + return Math.max(0, lastIndex - 1); + } + + let low = 0; + let high = lastIndex; + while (low <= high) { + const mid = (low + high) >> 1; + const start = times[mid]!; + const end = times[mid + 1] ?? Number.POSITIVE_INFINITY; + if (time < start) { + high = mid - 1; + continue; + } + if (time >= end) { + low = mid + 1; + continue; + } + return mid; + } + + return Math.max(0, Math.min(lastIndex - 1, low)); +}; + +const resolveTrackStride = (track: AnimationTrackDefinition, times: Float32Array, values: Float32Array): { + readonly keyframeCount: number; + readonly valueComponentCount: number; + readonly sampleStride: number; +} => { + const keyframeCount = track.keyframeCount ?? times.length; + if (!Number.isInteger(keyframeCount) || keyframeCount <= 0) { + throw new AnimationValidationError(`Animation track '${track.target}/${track.path}' has invalid keyframeCount`); + } + if (times.length !== keyframeCount) { + throw new AnimationValidationError( + `Animation track '${track.target}/${track.path}' times length does not match keyframeCount` + ); + } + const interpolation = resolveInterpolationMode(track.interpolation); + const sampleStride = + track.sampleStride ?? + (keyframeCount > 0 ? values.length / keyframeCount : track.valueComponentCount ?? 0); + const valueComponentCount = + track.valueComponentCount ?? + (interpolation === AnimationInterpolationMode.CubicSpline ? sampleStride / 3 : sampleStride); + + if ( + !Number.isInteger(sampleStride) || + !Number.isInteger(valueComponentCount) || + sampleStride <= 0 || + valueComponentCount <= 0 || + sampleStride * keyframeCount !== values.length + ) { + throw new AnimationValidationError( + `Animation track '${track.target}/${track.path}' has inconsistent values layout` + ); + } + + if (track.path === 'translation' || track.path === 'scale') { + if (valueComponentCount !== 3) { + throw new AnimationValidationError( + `Animation track '${track.target}/${track.path}' requires 3 value components` + ); + } + } + if (track.path === 'rotation' && valueComponentCount !== 4) { + throw new AnimationValidationError( + `Animation track '${track.target}/${track.path}' requires 4 value components` + ); + } + + return { + keyframeCount, + valueComponentCount, + sampleStride, + }; +}; + +const sampleTrack = ( + track: AnimationBoneTrack, + time: number, + componentCount: number, + out: Float32Array, + outOffset: number +): void => { + const frameIndex = findFrameIndex(track.times, time); + const nextIndex = Math.min(track.keyframeCount - 1, frameIndex + 1); + const startTime = track.times[frameIndex] ?? 0; + const endTime = track.times[nextIndex] ?? startTime; + const duration = Math.max(0, endTime - startTime); + const alpha = duration > 0 ? clamp((time - startTime) / duration, 0, 1) : 0; + + if (track.interpolation === AnimationInterpolationMode.Step || frameIndex === nextIndex) { + const baseOffset = + frameIndex * track.sampleStride + + (track.interpolation === AnimationInterpolationMode.CubicSpline + ? track.valueComponentCount + : 0); + for (let componentIndex = 0; componentIndex < componentCount; componentIndex += 1) { + out[outOffset + componentIndex] = + track.values[baseOffset + componentIndex] ?? (componentIndex === 3 ? 1 : 0); + } + return; + } + + if (track.interpolation === AnimationInterpolationMode.CubicSpline) { + const leftBase = frameIndex * track.sampleStride; + const rightBase = nextIndex * track.sampleStride; + const s = alpha; + const s2 = s * s; + const s3 = s2 * s; + const h00 = 2 * s3 - 3 * s2 + 1; + const h10 = s3 - 2 * s2 + s; + const h01 = -2 * s3 + 3 * s2; + const h11 = s3 - s2; + for (let componentIndex = 0; componentIndex < componentCount; componentIndex += 1) { + const inTangent = track.values[rightBase + componentIndex] ?? 0; + const value0 = track.values[leftBase + track.valueComponentCount + componentIndex] ?? 0; + const outTangent = + track.values[leftBase + track.valueComponentCount * 2 + componentIndex] ?? 0; + const value1 = + track.values[rightBase + track.valueComponentCount + componentIndex] ?? 0; + out[outOffset + componentIndex] = + h00 * value0 + + h10 * duration * outTangent + + h01 * value1 + + h11 * duration * inTangent; + } + return; + } + + const leftOffset = frameIndex * track.sampleStride; + const rightOffset = nextIndex * track.sampleStride; + if (track.path === 'rotation' && componentCount === 4) { + quatSlerp(out, outOffset, track.values, leftOffset, track.values, rightOffset, alpha); + return; + } + + for (let componentIndex = 0; componentIndex < componentCount; componentIndex += 1) { + const left = track.values[leftOffset + componentIndex] ?? 0; + const right = track.values[rightOffset + componentIndex] ?? left; + out[outOffset + componentIndex] = left + (right - left) * alpha; + } +}; + +const wrapClipTime = (time: number, duration: number): number => { + if (duration <= 0) { + return 0; + } + const wrapped = time % duration; + return wrapped < 0 ? wrapped + duration : wrapped; +}; + +const cloneMetadataRecord = ( + value: Readonly> | null | undefined +): Readonly> | null | undefined => { + if (value === null || value === undefined) { + return value; + } + + const cloned: Record = {}; + for (const [key, entry] of Object.entries(value)) { + cloned[key] = entry; + } + return Object.freeze(cloned); +}; + +const sanitizeTags = (value: readonly string[] | undefined): readonly string[] => + Object.freeze( + [...new Set((value ?? []).filter((entry): entry is string => typeof entry === 'string' && entry.length > 0))] + ); + +const sanitizeClipEvents = ( + events: readonly AnimationClipEventDefinition[] | undefined, + duration: number +): readonly AnimationClipEventDefinition[] => + Object.freeze( + (events ?? []) + .filter( + (event): event is AnimationClipEventDefinition => + Boolean( + event && + typeof event.name === 'string' && + event.name.length > 0 && + typeof event.time === 'number' && + Number.isFinite(event.time) + ) + ) + .map((event) => + Object.freeze({ + ...(typeof event.id === 'string' && event.id.length > 0 ? { id: event.id } : {}), + name: event.name, + time: clamp(event.time, 0, duration), + ...(event.payload !== undefined + ? { payload: cloneMetadataRecord(event.payload) ?? null } + : {}), + ...(event.tags && event.tags.length > 0 ? { tags: sanitizeTags(event.tags) } : {}), + } satisfies AnimationClipEventDefinition) + ) + .sort((left, right) => left.time - right.time) + ); + +const sanitizeFootContacts = ( + contacts: readonly AnimationFootContactDefinition[] | undefined, + duration: number +): readonly AnimationFootContactDefinition[] => + Object.freeze( + (contacts ?? []) + .filter( + (contact): contact is AnimationFootContactDefinition => + Boolean( + contact && + typeof contact.bone === 'string' && + contact.bone.length > 0 && + typeof contact.startTime === 'number' && + typeof contact.endTime === 'number' && + Number.isFinite(contact.startTime) && + Number.isFinite(contact.endTime) + ) + ) + .map((contact) => { + const startTime = clamp(Math.min(contact.startTime, contact.endTime), 0, duration); + const endTime = clamp(Math.max(contact.startTime, contact.endTime), 0, duration); + return Object.freeze({ + bone: contact.bone, + startTime, + endTime, + ...(contact.lockTranslationAxes + ? { + lockTranslationAxes: Object.freeze([ + contact.lockTranslationAxes[0], + contact.lockTranslationAxes[1], + contact.lockTranslationAxes[2], + ]) as readonly [boolean, boolean, boolean], + } + : {}), + ...(contact.metadata ? { metadata: cloneMetadataRecord(contact.metadata) ?? {} } : {}), + } satisfies AnimationFootContactDefinition); + }) + ); + +const sanitizeMotionFeatures = ( + features: readonly AnimationMotionFeatureDefinition[] | undefined, + duration: number +): readonly AnimationMotionFeatureDefinition[] => + Object.freeze( + (features ?? []) + .filter( + (feature): feature is AnimationMotionFeatureDefinition => + Boolean(feature && typeof feature.time === 'number' && Number.isFinite(feature.time)) + ) + .map((feature) => + Object.freeze({ + time: clamp(feature.time, 0, duration), + ...(feature.trajectoryPosition + ? { + trajectoryPosition: Object.freeze([ + feature.trajectoryPosition[0], + feature.trajectoryPosition[1], + feature.trajectoryPosition[2], + ]) as readonly [number, number, number], + } + : {}), + ...(feature.facingDirection + ? { + facingDirection: Object.freeze([ + feature.facingDirection[0], + feature.facingDirection[1], + feature.facingDirection[2], + ]) as readonly [number, number, number], + } + : {}), + ...(feature.tags && feature.tags.length > 0 + ? { tags: sanitizeTags(feature.tags) } + : {}), + ...(typeof feature.costBias === 'number' && Number.isFinite(feature.costBias) + ? { costBias: feature.costBias } + : {}), + } satisfies AnimationMotionFeatureDefinition) + ) + .sort((left, right) => left.time - right.time) + ); + +const sanitizeStreamingChunks = ( + chunks: readonly AnimationClipStreamingChunkDefinition[] | undefined, + duration: number +): readonly AnimationClipStreamingChunkDefinition[] | undefined => { + if (!Array.isArray(chunks) || chunks.length === 0) { + return undefined; + } + + const sanitized = chunks + .filter( + (chunk): chunk is AnimationClipStreamingChunkDefinition => + Boolean( + chunk && + typeof chunk.uri === 'string' && + chunk.uri.length > 0 && + typeof chunk.startTime === 'number' && + typeof chunk.endTime === 'number' && + Number.isFinite(chunk.startTime) && + Number.isFinite(chunk.endTime) + ) + ) + .map((chunk) => { + const startTime = clamp(Math.min(chunk.startTime, chunk.endTime), 0, duration); + const endTime = clamp(Math.max(chunk.startTime, chunk.endTime), 0, duration); + return Object.freeze({ + ...(typeof chunk.id === 'string' && chunk.id.length > 0 ? { id: chunk.id } : {}), + uri: chunk.uri, + startTime, + endTime, + ...(typeof chunk.byteOffset === 'number' && Number.isFinite(chunk.byteOffset) + ? { byteOffset: Math.max(0, Math.trunc(chunk.byteOffset)) } + : {}), + ...(typeof chunk.byteLength === 'number' && Number.isFinite(chunk.byteLength) + ? { byteLength: Math.max(0, Math.trunc(chunk.byteLength)) } + : {}), + ...(typeof chunk.mimeType === 'string' && chunk.mimeType.length > 0 + ? { mimeType: chunk.mimeType } + : {}), + } satisfies AnimationClipStreamingChunkDefinition); + }) + .sort((left, right) => left.startTime - right.startTime || left.endTime - right.endTime); + + return sanitized.length > 0 ? Object.freeze(sanitized) : undefined; +}; + +const sanitizeStreamingCatalog = ( + catalog: AnimationClipStreamingCatalogDefinition | undefined, + duration: number +): AnimationClipStreamingCatalogDefinition | undefined => { + if (!catalog) { + return undefined; + } + + const chunks = sanitizeStreamingChunks(catalog.chunks, duration); + if (!chunks) { + return undefined; + } + + return Object.freeze({ + ...(typeof catalog.id === 'string' && catalog.id.length > 0 ? { id: catalog.id } : {}), + chunks, + } satisfies AnimationClipStreamingCatalogDefinition); +}; + +const sanitizeStreamingDefinition = ( + streaming: AnimationClipStreamingDefinition | undefined, + duration: number +): AnimationClipStreamingDefinition | null => { + if (!streaming) { + return null; + } + + const catalog = sanitizeStreamingCatalog(streaming.catalog, duration); + return Object.freeze({ + ...(typeof streaming.mode === 'string' ? { mode: streaming.mode } : {}), + ...(typeof streaming.chunkDuration === 'number' && Number.isFinite(streaming.chunkDuration) + ? { chunkDuration: Math.max(0, streaming.chunkDuration) } + : {}), + ...(typeof streaming.preloadWindow === 'number' && Number.isFinite(streaming.preloadWindow) + ? { preloadWindow: Math.max(0, streaming.preloadWindow) } + : {}), + ...(typeof streaming.priority === 'number' && Number.isFinite(streaming.priority) + ? { priority: Math.trunc(streaming.priority) } + : {}), + ...(typeof streaming.sourceUri === 'string' && streaming.sourceUri.length > 0 + ? { sourceUri: streaming.sourceUri } + : {}), + ...(typeof streaming.catalogUri === 'string' && streaming.catalogUri.length > 0 + ? { catalogUri: streaming.catalogUri } + : {}), + ...(catalog ? { catalog } : {}), + } satisfies AnimationClipStreamingDefinition); +}; + +interface AnimationClipMutableFields { + id: string; + duration: number; + translationTracks: readonly AnimationBoneTrack[]; + rotationTracks: readonly AnimationBoneTrack[]; + scaleTracks: readonly AnimationBoneTrack[]; + curveTracks: readonly AnimationCurveTrack[]; + events: readonly AnimationClipEventDefinition[]; + footContacts: readonly AnimationFootContactDefinition[]; + tags: readonly string[]; + features: readonly AnimationMotionFeatureDefinition[]; + compression: AnimationClipCompressionDefinition | null; + streaming: AnimationClipStreamingDefinition | null; +} + +export class AnimationClip { + readonly id!: string; + readonly duration!: number; + readonly translationTracks!: readonly AnimationBoneTrack[]; + readonly rotationTracks!: readonly AnimationBoneTrack[]; + readonly scaleTracks!: readonly AnimationBoneTrack[]; + readonly curveTracks!: readonly AnimationCurveTrack[]; + readonly events!: readonly AnimationClipEventDefinition[]; + readonly footContacts!: readonly AnimationFootContactDefinition[]; + readonly tags!: readonly string[]; + readonly features!: readonly AnimationMotionFeatureDefinition[]; + readonly compression!: AnimationClipCompressionDefinition | null; + readonly streaming!: AnimationClipStreamingDefinition | null; + + private readonly _translationTrackByTarget = new Map(); + private readonly _rotationTrackByTarget = new Map(); + private readonly _scaleTrackByTarget = new Map(); + private readonly _rig: AnimationRig; + private readonly _curveLayout: AnimationCurveLayout; + private readonly _sampleStartTranslation = new Float32Array(3); + private readonly _sampleEndTranslation = new Float32Array(3); + private readonly _sampleStartRotation = new Float32Array(4); + private readonly _sampleEndRotation = new Float32Array(4); + private readonly _sampleMidTranslation = new Float32Array(3); + private readonly _sampleMidRotation = new Float32Array(4); + private readonly _sampleZeroRotation = new Float32Array(4); + private readonly _inverseQuaternion = new Float32Array(4); + private _definition!: AnimationClipDefinition; + + constructor( + definition: AnimationClipDefinition, + rig: AnimationRig, + curveLayout: AnimationCurveLayout + ) { + this._rig = rig; + this._curveLayout = curveLayout; + this._applyDefinition(definition); + } + + get definition(): AnimationClipDefinition { + return this._definition; + } + + applyStreamingChunk( + payload: AnimationClipStreamingChunkPayload, + options: AnimationClipStreamingChunkApplicationOptions = {} + ): this { + this._applyDefinition( + applyAnimationClipStreamingChunkDefinition(this._definition, payload, { + clipId: this.id, + ...options, + }) + ); + return this; + } + + private _applyDefinition(definition: AnimationClipDefinition): void { + if (!definition || typeof definition.id !== 'string' || definition.id.length === 0) { + throw new AnimationValidationError('Animation clips require a non-empty id'); + } + + const translationTracks: AnimationBoneTrack[] = []; + const rotationTracks: AnimationBoneTrack[] = []; + const scaleTracks: AnimationBoneTrack[] = []; + const curveTracks: AnimationCurveTrack[] = []; + let resolvedDuration = + typeof definition.duration === 'number' && Number.isFinite(definition.duration) + ? definition.duration + : 0; + + this._translationTrackByTarget.clear(); + this._rotationTrackByTarget.clear(); + this._scaleTrackByTarget.clear(); + + for (let trackIndex = 0; trackIndex < definition.tracks.length; trackIndex += 1) { + const track = definition.tracks[trackIndex]!; + const times = toFloat32Array(track.times); + const values = toFloat32Array(track.values); + const { keyframeCount, valueComponentCount, sampleStride } = resolveTrackStride( + track, + times, + values + ); + resolvedDuration = Math.max(resolvedDuration, times[times.length - 1] ?? 0); + const baseTrack = Object.freeze({ + targetIndex: track.path === 'weights' ? -1 : this._rig.indexOfBone(track.target), + path: track.path, + interpolation: resolveInterpolationMode(track.interpolation), + times, + values, + keyframeCount, + valueComponentCount, + sampleStride, + } satisfies AnimationBoneTrack); + + switch (track.path) { + case 'translation': + translationTracks.push(baseTrack); + this._translationTrackByTarget.set(baseTrack.targetIndex, baseTrack); + break; + case 'rotation': + rotationTracks.push(baseTrack); + this._rotationTrackByTarget.set(baseTrack.targetIndex, baseTrack); + break; + case 'scale': + scaleTracks.push(baseTrack); + this._scaleTrackByTarget.set(baseTrack.targetIndex, baseTrack); + break; + case 'weights': { + const curveBinding = this._curveLayout.get(track.target); + if (!curveBinding) { + throw new AnimationValidationError( + `Animation clip '${definition.id}' references unknown curve '${track.target}'` + ); + } + if (curveBinding.componentCount !== valueComponentCount) { + throw new AnimationValidationError( + `Animation clip '${definition.id}' curve '${track.target}' component count does not match layout` + ); + } + curveTracks.push( + Object.freeze({ + ...baseTrack, + curveOffset: curveBinding.offset, + curveComponentCount: curveBinding.componentCount, + }) + ); + break; + } + default: + throw new AnimationValidationError('Unsupported animation track path'); + } + } + + const mutable = this as unknown as AnimationClipMutableFields; + mutable.id = definition.id; + mutable.duration = resolvedDuration; + mutable.translationTracks = Object.freeze(translationTracks); + mutable.rotationTracks = Object.freeze(rotationTracks); + mutable.scaleTracks = Object.freeze(scaleTracks); + mutable.curveTracks = Object.freeze(curveTracks); + mutable.events = sanitizeClipEvents(definition.events, mutable.duration); + mutable.footContacts = sanitizeFootContacts(definition.footContacts, mutable.duration); + mutable.tags = sanitizeTags(definition.tags); + mutable.features = sanitizeMotionFeatures(definition.features, mutable.duration); + mutable.compression = definition.compression + ? Object.freeze({ ...definition.compression }) + : null; + mutable.streaming = sanitizeStreamingDefinition(definition.streaming, mutable.duration); + this._definition = definition; + } + + sampleTime(timeSeconds: number, frame: AnimationFrame): AnimationFrame { + const sampleTimeValue = clamp(timeSeconds, 0, this.duration); + for (let trackIndex = 0; trackIndex < this.translationTracks.length; trackIndex += 1) { + const track = this.translationTracks[trackIndex]!; + sampleTrack(track, sampleTimeValue, 3, frame.pose.translations, track.targetIndex * 3); + } + for (let trackIndex = 0; trackIndex < this.rotationTracks.length; trackIndex += 1) { + const track = this.rotationTracks[trackIndex]!; + sampleTrack(track, sampleTimeValue, 4, frame.pose.rotations, track.targetIndex * 4); + quatNormalize(frame.pose.rotations, track.targetIndex * 4, frame.pose.rotations, track.targetIndex * 4); + } + for (let trackIndex = 0; trackIndex < this.scaleTracks.length; trackIndex += 1) { + const track = this.scaleTracks[trackIndex]!; + sampleTrack(track, sampleTimeValue, 3, frame.pose.scales, track.targetIndex * 3); + } + for (let trackIndex = 0; trackIndex < this.curveTracks.length; trackIndex += 1) { + const track = this.curveTracks[trackIndex]!; + sampleTrack( + track, + sampleTimeValue, + track.curveComponentCount, + frame.curves.values, + track.curveOffset + ); + } + return frame; + } + + sampleNormalizedTime(normalizedTime: number, frame: AnimationFrame): AnimationFrame { + const timeSeconds = clamp(normalizedTime, 0, 1) * this.duration; + return this.sampleTime(timeSeconds, frame); + } + + collectEvents( + startTimeSeconds: number, + endTimeSeconds: number, + loop: boolean, + out: AnimationClipEventOccurrence[] = [] + ): readonly AnimationClipEventOccurrence[] { + if (this.events.length === 0 || this.duration <= 0) { + return out; + } + + const pushRange = (rangeStart: number, rangeEnd: number): void => { + for (let index = 0; index < this.events.length; index += 1) { + const event = this.events[index]!; + if (event.time <= rangeStart || event.time > rangeEnd) { + continue; + } + out.push( + Object.freeze({ + clipId: this.id, + ...(event.id ? { id: event.id } : {}), + name: event.name, + time: event.time, + normalizedTime: this.duration > 0 ? event.time / this.duration : 0, + ...(event.payload !== undefined ? { payload: event.payload } : {}), + ...(event.tags ? { tags: event.tags } : {}), + } satisfies AnimationClipEventOccurrence) + ); + } + }; + + const start = loop ? wrapClipTime(startTimeSeconds, this.duration) : clamp(startTimeSeconds, 0, this.duration); + const end = loop ? wrapClipTime(endTimeSeconds, this.duration) : clamp(endTimeSeconds, 0, this.duration); + if (loop && end < start) { + pushRange(start, this.duration); + pushRange(-Number.EPSILON, end); + return out; + } + + pushRange(start, end); + return out; + } + + sampleFootContacts(timeSeconds: number): readonly AnimationFootContactState[] { + if (this.footContacts.length === 0 || this.duration <= 0) { + return EMPTY_CONTACTS; + } + + const sampleTimeValue = clamp(timeSeconds, 0, this.duration); + return Object.freeze( + this.footContacts.map((contact) => { + const active = sampleTimeValue >= contact.startTime && sampleTimeValue <= contact.endTime; + const span = Math.max(Number.EPSILON, contact.endTime - contact.startTime); + const normalized = clamp((sampleTimeValue - contact.startTime) / span, 0, 1); + const ramp = Math.min(normalized, 1 - normalized); + const weight = active ? Math.min(1, Math.max(0.25, ramp * 4)) : 0; + return Object.freeze({ + bone: contact.bone, + active, + weight, + normalizedTime: this.duration > 0 ? sampleTimeValue / this.duration : 0, + ...(contact.lockTranslationAxes + ? { lockTranslationAxes: contact.lockTranslationAxes } + : {}), + ...(contact.metadata ? { metadata: contact.metadata } : {}), + } satisfies AnimationFootContactState); + }) + ); + } + + sampleBoneTransform( + boneIndex: number, + timeSeconds: number, + rig: AnimationRig, + outTranslation: Float32Array, + outRotation: Float32Array, + outScale?: Float32Array + ): void { + const translationOffset = boneIndex * 3; + const rotationOffset = boneIndex * 4; + vec3Copy(outTranslation, 0, rig.restTranslations, translationOffset); + quatCopy(outRotation, 0, rig.restRotations, rotationOffset); + if (outScale) { + vec3Copy(outScale, 0, rig.restScales, translationOffset); + } + + const sampleTimeValue = clamp(timeSeconds, 0, this.duration); + const translationTrack = this._translationTrackByTarget.get(boneIndex); + if (translationTrack) { + sampleTrack(translationTrack, sampleTimeValue, 3, outTranslation, 0); + } + const rotationTrack = this._rotationTrackByTarget.get(boneIndex); + if (rotationTrack) { + sampleTrack(rotationTrack, sampleTimeValue, 4, outRotation, 0); + quatNormalize(outRotation, 0, outRotation, 0); + } + if (outScale) { + const scaleTrack = this._scaleTrackByTarget.get(boneIndex); + if (scaleTrack) { + sampleTrack(scaleTrack, sampleTimeValue, 3, outScale, 0); + } + } + } + + extractBoneDelta( + boneIndex: number, + startTimeSeconds: number, + endTimeSeconds: number, + loop: boolean, + rig: AnimationRig, + outTranslation: Float32Array, + outRotation: Float32Array + ): void { + if (this.duration <= 0) { + outTranslation.fill(0); + quatIdentity(outRotation, 0); + return; + } + + if (loop && endTimeSeconds < startTimeSeconds) { + this.sampleBoneTransform( + boneIndex, + startTimeSeconds, + rig, + this._sampleStartTranslation, + this._sampleStartRotation + ); + this.sampleBoneTransform( + boneIndex, + this.duration, + rig, + this._sampleMidTranslation, + this._sampleMidRotation + ); + this.sampleBoneTransform(boneIndex, 0, rig, this._sampleEndTranslation, this._sampleZeroRotation); + this.sampleBoneTransform( + boneIndex, + endTimeSeconds, + rig, + outTranslation, + outRotation + ); + + outTranslation[0] = + (this._sampleMidTranslation[0]! - this._sampleStartTranslation[0]!) + + (outTranslation[0]! - this._sampleEndTranslation[0]!); + outTranslation[1] = + (this._sampleMidTranslation[1]! - this._sampleStartTranslation[1]!) + + (outTranslation[1]! - this._sampleEndTranslation[1]!); + outTranslation[2] = + (this._sampleMidTranslation[2]! - this._sampleStartTranslation[2]!) + + (outTranslation[2]! - this._sampleEndTranslation[2]!); + + quatInvert(this._inverseQuaternion, 0, this._sampleStartRotation, 0); + quatMultiply(this._sampleMidRotation, 0, this._inverseQuaternion, 0, this._sampleMidRotation, 0); + quatInvert(this._inverseQuaternion, 0, this._sampleZeroRotation, 0); + quatMultiply(outRotation, 0, this._inverseQuaternion, 0, outRotation, 0); + quatMultiply(outRotation, 0, this._sampleMidRotation, 0, outRotation, 0); + quatNormalize(outRotation, 0, outRotation, 0); + return; + } + + this.sampleBoneTransform( + boneIndex, + loop ? wrapClipTime(startTimeSeconds, this.duration) : clamp(startTimeSeconds, 0, this.duration), + rig, + this._sampleStartTranslation, + this._sampleStartRotation + ); + this.sampleBoneTransform( + boneIndex, + loop ? wrapClipTime(endTimeSeconds, this.duration) : clamp(endTimeSeconds, 0, this.duration), + rig, + this._sampleEndTranslation, + this._sampleEndRotation + ); + + outTranslation[0] = this._sampleEndTranslation[0]! - this._sampleStartTranslation[0]!; + outTranslation[1] = this._sampleEndTranslation[1]! - this._sampleStartTranslation[1]!; + outTranslation[2] = this._sampleEndTranslation[2]! - this._sampleStartTranslation[2]!; + quatInvert(this._inverseQuaternion, 0, this._sampleStartRotation, 0); + quatMultiply(outRotation, 0, this._inverseQuaternion, 0, this._sampleEndRotation, 0); + quatNormalize(outRotation, 0, outRotation, 0); + } +} + +export const createAnimationClips = ( + definitions: readonly AnimationClipDefinition[], + rig: AnimationRig, + curveLayout: AnimationCurveLayout +): ReadonlyMap => { + const clips = new Map(); + for (let index = 0; index < definitions.length; index += 1) { + const definition = definitions[index]!; + if (clips.has(definition.id)) { + throw new AnimationValidationError(`Duplicate animation clip '${definition.id}'`); + } + clips.set(definition.id, new AnimationClip(definition, rig, curveLayout)); + } + return clips; +}; + +const EMPTY_CONTACTS = Object.freeze([]) as readonly AnimationFootContactState[]; \ No newline at end of file diff --git a/web/packages/animation/src/controller-graph.ts b/web/packages/animation/src/controller-graph.ts new file mode 100644 index 00000000..e0a43c77 --- /dev/null +++ b/web/packages/animation/src/controller-graph.ts @@ -0,0 +1,1185 @@ +import { + buildAnimationMotionDefinition, + type AnimationMotionBuilder, + validateAnimationMotionDefinition, +} from './blend-graph'; +import type { + AnimationConditionDefinition, + AnimationControllerDefinition, + AnimationIkJobDefinition, + AnimationIkLayerDefinition, + AnimationLayerBlendMode, + AnimationLayerDefinition, + AnimationMotionDefinition, + AnimationParameterDefinition, + AnimationParameterKind, + AnimationParameterValue, + AnimationRigDefinition, + AnimationRootMotionDefinition, + AnimationStateDefinition, + AnimationStateMachineDefinition, + AnimationTransitionDefinition, + AnimationTransitionOperator, +} from './types'; + +export interface AnimationControllerGraphDiagnostic { + readonly code: string; + readonly message: string; + readonly path: string; +} + +export interface AnimationControllerGraphValidationOptions { + readonly knownClipIds?: readonly string[]; + readonly knownParameters?: readonly string[]; + readonly knownBones?: readonly string[]; +} + +type AnimationMotionInput = AnimationMotionDefinition | AnimationMotionBuilder; +type AnimationTransitionInput = AnimationTransitionDefinition | AnimationTransitionBuilder; +type AnimationStateInput = AnimationStateDefinition | AnimationStateBuilder; +type AnimationStateMachineInput = AnimationStateMachineDefinition | AnimationStateMachineBuilder; +type AnimationIkLayerInput = AnimationIkLayerDefinition | AnimationIkLayerBuilder; +type AnimationLayerInput = AnimationLayerDefinition | AnimationLayerBuilder; +type AnimationControllerInput = + | AnimationControllerDefinition + | AnimationControllerBuilder; + +const VALID_LAYER_MODES = new Set(['override', 'additive']); + +const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const pushDiagnostic = ( + diagnostics: AnimationControllerGraphDiagnostic[], + code: string, + message: string, + path: string +): void => { + diagnostics.push(Object.freeze({ code, message, path })); +}; + +const cloneCondition = (condition: AnimationConditionDefinition): AnimationConditionDefinition => { + switch (condition.kind) { + case 'float': + case 'int': + return Object.freeze({ + kind: condition.kind, + parameter: condition.parameter, + operator: condition.operator, + value: condition.value, + }); + case 'bool': + return Object.freeze({ + kind: 'bool', + parameter: condition.parameter, + value: condition.value, + }); + case 'trigger': + return Object.freeze({ + kind: 'trigger', + parameter: condition.parameter, + }); + default: + return condition; + } +}; + +const cloneRigDefinition = (rig: AnimationRigDefinition): AnimationRigDefinition => + Object.freeze({ + ...(typeof rig.id === 'string' ? { id: rig.id } : {}), + bones: Object.freeze( + rig.bones.map((bone) => + Object.freeze({ + name: bone.name, + ...(bone.parent !== undefined ? { parent: bone.parent } : {}), + ...(bone.translation ? { translation: Object.freeze([...bone.translation]) as readonly [number, number, number] } : {}), + ...(bone.rotation + ? { rotation: Object.freeze([...bone.rotation]) as readonly [number, number, number, number] } + : {}), + ...(bone.scale ? { scale: Object.freeze([...bone.scale]) as readonly [number, number, number] } : {}), + ...(bone.inverseBindMatrix + ? { + inverseBindMatrix: + bone.inverseBindMatrix instanceof Float32Array + ? new Float32Array(bone.inverseBindMatrix) + : Object.freeze([...bone.inverseBindMatrix]), + } + : {}), + }) + ) + ), + }); + +const cloneParameterDefinition = ( + parameter: AnimationParameterDefinition +): AnimationParameterDefinition => + Object.freeze({ + name: parameter.name, + kind: parameter.kind, + ...(parameter.defaultValue !== undefined ? { defaultValue: parameter.defaultValue } : {}), + }); + +const cloneRootMotionDefinition = ( + rootMotion: AnimationRootMotionDefinition +): AnimationRootMotionDefinition => + Object.freeze({ + bone: rootMotion.bone, + ...(typeof rootMotion.consume === 'boolean' ? { consume: rootMotion.consume } : {}), + ...(rootMotion.projectTranslationAxes + ? { + projectTranslationAxes: Object.freeze([ + rootMotion.projectTranslationAxes[0], + rootMotion.projectTranslationAxes[1], + rootMotion.projectTranslationAxes[2], + ]) as readonly [boolean, boolean, boolean], + } + : {}), + ...(typeof rootMotion.extractRotation === 'boolean' + ? { extractRotation: rootMotion.extractRotation } + : {}), + }); + +const cloneClipDefinition = ( + clip: AnimationControllerDefinition['clips'][number] +): AnimationControllerDefinition['clips'][number] => + Object.freeze({ + id: clip.id, + ...(isFiniteNumber(clip.duration) ? { duration: clip.duration } : {}), + tracks: Object.freeze( + clip.tracks.map((track) => + Object.freeze({ + target: track.target, + path: track.path, + ...(typeof track.interpolation === 'string' + ? { interpolation: track.interpolation } + : {}), + times: track.times instanceof Float32Array ? new Float32Array(track.times) : [...track.times], + values: + track.values instanceof Float32Array + ? new Float32Array(track.values) + : [...track.values], + ...(isFiniteNumber(track.keyframeCount) ? { keyframeCount: track.keyframeCount } : {}), + ...(isFiniteNumber(track.sampleStride) ? { sampleStride: track.sampleStride } : {}), + ...(isFiniteNumber(track.valueComponentCount) + ? { valueComponentCount: track.valueComponentCount } + : {}), + }) + ) + ), + ...(clip.events + ? { + events: Object.freeze( + clip.events.map((event) => + Object.freeze({ + ...(typeof event.id === 'string' ? { id: event.id } : {}), + name: event.name, + time: event.time, + ...(event.payload ? { payload: Object.freeze({ ...event.payload }) } : {}), + ...(event.tags ? { tags: Object.freeze([...event.tags]) } : {}), + }) + ) + ), + } + : {}), + ...(clip.footContacts + ? { + footContacts: Object.freeze( + clip.footContacts.map((contact) => + Object.freeze({ + bone: contact.bone, + startTime: contact.startTime, + endTime: contact.endTime, + ...(contact.lockTranslationAxes + ? { + lockTranslationAxes: Object.freeze([ + contact.lockTranslationAxes[0], + contact.lockTranslationAxes[1], + contact.lockTranslationAxes[2], + ]) as readonly [boolean, boolean, boolean], + } + : {}), + ...(contact.metadata + ? { metadata: Object.freeze({ ...contact.metadata }) } + : {}), + }) + ) + ), + } + : {}), + ...(clip.tags ? { tags: Object.freeze([...clip.tags]) } : {}), + ...(clip.features + ? { + features: Object.freeze( + clip.features.map((feature) => + Object.freeze({ + time: feature.time, + ...(feature.trajectoryPosition + ? { + trajectoryPosition: Object.freeze([ + feature.trajectoryPosition[0], + feature.trajectoryPosition[1], + feature.trajectoryPosition[2], + ]) as readonly [number, number, number], + } + : {}), + ...(feature.facingDirection + ? { + facingDirection: Object.freeze([ + feature.facingDirection[0], + feature.facingDirection[1], + feature.facingDirection[2], + ]) as readonly [number, number, number], + } + : {}), + ...(feature.tags ? { tags: Object.freeze([...feature.tags]) } : {}), + ...(isFiniteNumber(feature.costBias) ? { costBias: feature.costBias } : {}), + }) + ) + ), + } + : {}), + ...(clip.compression ? { compression: Object.freeze({ ...clip.compression }) } : {}), + ...(clip.streaming ? { streaming: Object.freeze({ ...clip.streaming }) } : {}), + }); + +export class AnimationTransitionBuilder { + private _duration?: number; + private _offset?: number; + private _exitTime?: number; + private _fixedDuration?: boolean; + private _canInterrupt?: boolean; + private _priority?: number; + private readonly _conditions: AnimationConditionDefinition[] = []; + + constructor(public readonly to: string) {} + + withDuration(duration: number): this { + this._duration = duration; + return this; + } + + withOffset(offset: number): this { + this._offset = offset; + return this; + } + + withExitTime(exitTime: number): this { + this._exitTime = exitTime; + return this; + } + + withFixedDuration(fixedDuration = true): this { + this._fixedDuration = fixedDuration; + return this; + } + + withInterruptible(canInterrupt = true): this { + this._canInterrupt = canInterrupt; + return this; + } + + withPriority(priority: number): this { + this._priority = priority; + return this; + } + + addCondition(condition: AnimationConditionDefinition): this { + this._conditions.push(cloneCondition(condition)); + return this; + } + + whenFloat(parameter: string, operator: AnimationTransitionOperator, value: number): this { + return this.addCondition({ kind: 'float', parameter, operator, value }); + } + + whenInt(parameter: string, operator: AnimationTransitionOperator, value: number): this { + return this.addCondition({ kind: 'int', parameter, operator, value }); + } + + whenBool(parameter: string, value: boolean): this { + return this.addCondition({ kind: 'bool', parameter, value }); + } + + whenTriggered(parameter: string): this { + return this.addCondition({ kind: 'trigger', parameter }); + } + + build(): AnimationTransitionDefinition { + return buildAnimationTransitionDefinition({ + to: this.to, + ...(isFiniteNumber(this._duration) ? { duration: this._duration } : {}), + ...(isFiniteNumber(this._offset) ? { offset: this._offset } : {}), + ...(isFiniteNumber(this._exitTime) ? { exitTime: this._exitTime } : {}), + ...(typeof this._fixedDuration === 'boolean' ? { fixedDuration: this._fixedDuration } : {}), + ...(typeof this._canInterrupt === 'boolean' ? { canInterrupt: this._canInterrupt } : {}), + ...(isFiniteNumber(this._priority) ? { priority: this._priority } : {}), + ...(this._conditions.length > 0 ? { conditions: Object.freeze([...this._conditions]) } : {}), + }); + } +} + +export class AnimationStateBuilder { + private _speed?: number; + private _loop?: boolean; + private readonly _transitions: AnimationTransitionInput[] = []; + + constructor( + public readonly id: string, + private _motion: AnimationMotionInput + ) {} + + withMotion(motion: AnimationMotionInput): this { + this._motion = motion; + return this; + } + + withSpeed(speed: number): this { + this._speed = speed; + return this; + } + + withLoop(loop: boolean): this { + this._loop = loop; + return this; + } + + addTransition(transition: AnimationTransitionInput): this { + this._transitions.push(transition); + return this; + } + + transitionTo( + to: string, + configure?: (transition: AnimationTransitionBuilder) => void + ): this { + const transition = new AnimationTransitionBuilder(to); + configure?.(transition); + return this.addTransition(transition); + } + + build(): AnimationStateDefinition { + return Object.freeze({ + id: this.id, + motion: buildAnimationMotionDefinition(this._motion), + ...(isFiniteNumber(this._speed) ? { speed: this._speed } : {}), + ...(typeof this._loop === 'boolean' ? { loop: this._loop } : {}), + ...(this._transitions.length > 0 + ? { transitions: Object.freeze(this._transitions.map(buildAnimationTransitionDefinition)) } + : {}), + }); + } +} + +export class AnimationStateMachineBuilder { + private _entryState?: string; + private readonly _states: AnimationStateInput[] = []; + private readonly _anyStateTransitions: AnimationTransitionInput[] = []; + + constructor(entryState?: string) { + this._entryState = entryState; + } + + withEntryState(entryState: string): this { + this._entryState = entryState; + return this; + } + + addState(state: AnimationStateInput): this { + this._states.push(state); + if (!this._entryState) { + this._entryState = 'build' in state ? state.id : state.id; + } + return this; + } + + state( + id: string, + motion: AnimationMotionInput, + configure?: (state: AnimationStateBuilder) => void + ): this { + const state = new AnimationStateBuilder(id, motion); + configure?.(state); + return this.addState(state); + } + + addAnyStateTransition(transition: AnimationTransitionInput): this { + this._anyStateTransitions.push(transition); + return this; + } + + anyState( + to: string, + configure?: (transition: AnimationTransitionBuilder) => void + ): this { + const transition = new AnimationTransitionBuilder(to); + configure?.(transition); + return this.addAnyStateTransition(transition); + } + + build(): AnimationStateMachineDefinition { + return buildAnimationStateMachineDefinition({ + entryState: this._entryState ?? this._states[0]?.id ?? '', + states: Object.freeze(this._states.map(buildAnimationStateDefinition)), + ...(this._anyStateTransitions.length > 0 + ? { + anyStateTransitions: Object.freeze( + this._anyStateTransitions.map(buildAnimationTransitionDefinition) + ), + } + : {}), + }); + } +} + +export class AnimationIkLayerBuilder { + private _weight?: number; + private readonly _jobs: AnimationIkJobDefinition[] = []; + + constructor(public readonly id: string) {} + + withWeight(weight: number): this { + this._weight = weight; + return this; + } + + addJob(job: AnimationIkJobDefinition): this { + this._jobs.push(Object.freeze({ ...job })); + return this; + } + + build(): AnimationIkLayerDefinition { + return buildAnimationIkLayerDefinition({ + id: this.id, + ...(isFiniteNumber(this._weight) ? { weight: this._weight } : {}), + jobs: Object.freeze(this._jobs.map((job) => Object.freeze({ ...job }))), + }); + } +} + +export class AnimationLayerBuilder { + private _weight?: number; + private _mode?: AnimationLayerBlendMode; + private _boneMask?: string[]; + private _stateMachine: AnimationStateMachineInput; + private readonly _ikLayers: AnimationIkLayerInput[] = []; + + constructor( + public readonly id: string, + stateMachine: AnimationStateMachineInput + ) { + this._stateMachine = stateMachine; + } + + withWeight(weight: number): this { + this._weight = weight; + return this; + } + + withMode(mode: AnimationLayerBlendMode): this { + this._mode = mode; + return this; + } + + withBoneMask(bones: readonly string[]): this { + this._boneMask = [...bones]; + return this; + } + + withStateMachine(stateMachine: AnimationStateMachineInput): this { + this._stateMachine = stateMachine; + return this; + } + + addIkLayer(layer: AnimationIkLayerInput): this { + this._ikLayers.push(layer); + return this; + } + + build(): AnimationLayerDefinition { + return Object.freeze({ + id: this.id, + ...(isFiniteNumber(this._weight) ? { weight: this._weight } : {}), + ...(this._mode ? { mode: this._mode } : {}), + ...(this._boneMask ? { boneMask: Object.freeze([...this._boneMask]) } : {}), + stateMachine: buildAnimationStateMachineDefinition(this._stateMachine), + ...(this._ikLayers.length > 0 + ? { ikLayers: Object.freeze(this._ikLayers.map(buildAnimationIkLayerDefinition)) } + : {}), + }); + } +} + +export class AnimationControllerBuilder { + private readonly _clips: AnimationControllerDefinition['clips'][number][] = []; + private readonly _parameters: AnimationParameterDefinition[] = []; + private readonly _layers: AnimationLayerInput[] = []; + private _rootMotion?: AnimationRootMotionDefinition | null; + + constructor(private readonly _rig: AnimationRigDefinition) {} + + addClip(clip: AnimationControllerDefinition['clips'][number]): this { + this._clips.push(clip); + return this; + } + + addParameter(parameter: AnimationParameterDefinition): this { + this._parameters.push(parameter); + return this; + } + + parameter( + name: string, + kind: TKind, + defaultValue?: AnimationParameterValue + ): this { + return this.addParameter({ + name, + kind, + ...(defaultValue !== undefined ? { defaultValue } : {}), + }); + } + + addLayer(layer: AnimationLayerInput): this { + this._layers.push(layer); + return this; + } + + layer( + id: string, + stateMachine: AnimationStateMachineInput, + configure?: (layer: AnimationLayerBuilder) => void + ): this { + const layer = new AnimationLayerBuilder(id, stateMachine); + configure?.(layer); + return this.addLayer(layer); + } + + withRootMotion(rootMotion: AnimationRootMotionDefinition | null): this { + this._rootMotion = rootMotion; + return this; + } + + build(): AnimationControllerDefinition { + return buildAnimationControllerDefinition({ + rig: this._rig, + clips: Object.freeze(this._clips.map(cloneClipDefinition)), + layers: Object.freeze(this._layers.map(buildAnimationLayerDefinition)), + ...(this._parameters.length > 0 + ? { parameters: Object.freeze(this._parameters.map(cloneParameterDefinition)) } + : {}), + ...(this._rootMotion !== undefined + ? { rootMotion: this._rootMotion ? cloneRootMotionDefinition(this._rootMotion) : null } + : {}), + }); + } +} + +export const createAnimationTransition = (to: string): AnimationTransitionBuilder => + new AnimationTransitionBuilder(to); + +export const createAnimationState = ( + id: string, + motion: AnimationMotionInput +): AnimationStateBuilder => new AnimationStateBuilder(id, motion); + +export const createAnimationStateMachine = ( + entryState?: string +): AnimationStateMachineBuilder => new AnimationStateMachineBuilder(entryState); + +export const createAnimationIkLayer = (id: string): AnimationIkLayerBuilder => + new AnimationIkLayerBuilder(id); + +export const createAnimationLayer = ( + id: string, + stateMachine: AnimationStateMachineInput +): AnimationLayerBuilder => new AnimationLayerBuilder(id, stateMachine); + +export const createAnimationController = ( + rig: AnimationRigDefinition +): AnimationControllerBuilder => new AnimationControllerBuilder(rig); + +export const buildAnimationTransitionDefinition = ( + transition: AnimationTransitionInput +): AnimationTransitionDefinition => + transition instanceof AnimationTransitionBuilder + ? transition.build() + : Object.freeze({ + to: transition.to, + ...(isFiniteNumber(transition.duration) ? { duration: transition.duration } : {}), + ...(isFiniteNumber(transition.offset) ? { offset: transition.offset } : {}), + ...(isFiniteNumber(transition.exitTime) ? { exitTime: transition.exitTime } : {}), + ...(typeof transition.fixedDuration === 'boolean' + ? { fixedDuration: transition.fixedDuration } + : {}), + ...(typeof transition.canInterrupt === 'boolean' + ? { canInterrupt: transition.canInterrupt } + : {}), + ...(isFiniteNumber(transition.priority) ? { priority: transition.priority } : {}), + ...(transition.conditions + ? { conditions: Object.freeze(transition.conditions.map(cloneCondition)) } + : {}), + }); + +export const buildAnimationStateDefinition = (state: AnimationStateInput): AnimationStateDefinition => + state instanceof AnimationStateBuilder + ? state.build() + : Object.freeze({ + id: state.id, + motion: buildAnimationMotionDefinition(state.motion), + ...(isFiniteNumber(state.speed) ? { speed: state.speed } : {}), + ...(typeof state.loop === 'boolean' ? { loop: state.loop } : {}), + ...(state.transitions + ? { + transitions: Object.freeze( + state.transitions.map(buildAnimationTransitionDefinition) + ), + } + : {}), + }); + +export const buildAnimationStateMachineDefinition = ( + stateMachine: AnimationStateMachineInput +): AnimationStateMachineDefinition => + stateMachine instanceof AnimationStateMachineBuilder + ? stateMachine.build() + : Object.freeze({ + entryState: stateMachine.entryState, + states: Object.freeze(stateMachine.states.map(buildAnimationStateDefinition)), + ...(stateMachine.anyStateTransitions + ? { + anyStateTransitions: Object.freeze( + stateMachine.anyStateTransitions.map(buildAnimationTransitionDefinition) + ), + } + : {}), + }); + +export const buildAnimationIkLayerDefinition = ( + layer: AnimationIkLayerInput +): AnimationIkLayerDefinition => + layer instanceof AnimationIkLayerBuilder + ? layer.build() + : Object.freeze({ + id: layer.id, + ...(isFiniteNumber(layer.weight) ? { weight: layer.weight } : {}), + jobs: Object.freeze(layer.jobs.map((job) => Object.freeze({ ...job }))), + }); + +export const buildAnimationLayerDefinition = (layer: AnimationLayerInput): AnimationLayerDefinition => + layer instanceof AnimationLayerBuilder + ? layer.build() + : Object.freeze({ + id: layer.id, + ...(isFiniteNumber(layer.weight) ? { weight: layer.weight } : {}), + ...(layer.mode ? { mode: layer.mode } : {}), + ...(layer.boneMask ? { boneMask: Object.freeze([...layer.boneMask]) } : {}), + stateMachine: buildAnimationStateMachineDefinition(layer.stateMachine), + ...(layer.ikLayers + ? { ikLayers: Object.freeze(layer.ikLayers.map(buildAnimationIkLayerDefinition)) } + : {}), + }); + +export const buildAnimationControllerDefinition = ( + controller: AnimationControllerInput +): AnimationControllerDefinition => + controller instanceof AnimationControllerBuilder + ? controller.build() + : Object.freeze({ + rig: cloneRigDefinition(controller.rig), + clips: Object.freeze(controller.clips.map(cloneClipDefinition)), + layers: Object.freeze(controller.layers.map(buildAnimationLayerDefinition)), + ...(controller.parameters + ? { parameters: Object.freeze(controller.parameters.map(cloneParameterDefinition)) } + : {}), + ...(controller.rootMotion !== undefined + ? { + rootMotion: controller.rootMotion + ? cloneRootMotionDefinition(controller.rootMotion) + : null, + } + : {}), + }); + +const validateCondition = ( + condition: AnimationConditionDefinition, + diagnostics: AnimationControllerGraphDiagnostic[], + options: AnimationControllerGraphValidationOptions, + path: string +): void => { + if ( + options.knownParameters && + options.knownParameters.includes(String(condition.parameter)) === false + ) { + pushDiagnostic( + diagnostics, + 'animation.controller.parameter.unknown', + `Unknown parameter '${condition.parameter}'`, + `${path}.parameter` + ); + } + switch (condition.kind) { + case 'float': + case 'int': + if (!isFiniteNumber(condition.value)) { + pushDiagnostic( + diagnostics, + 'animation.controller.condition.value.invalid', + 'Numeric transition conditions require a finite value', + `${path}.value` + ); + } + break; + case 'bool': + case 'trigger': + break; + default: + pushDiagnostic( + diagnostics, + 'animation.controller.condition.kind.unsupported', + `Unsupported condition kind '${String((condition as { kind?: unknown }).kind)}'`, + path + ); + break; + } +}; + +const validateTransition = ( + transition: AnimationTransitionDefinition, + diagnostics: AnimationControllerGraphDiagnostic[], + options: AnimationControllerGraphValidationOptions, + knownStates: ReadonlySet, + path: string +): void => { + if (knownStates.has(String(transition.to)) === false) { + pushDiagnostic( + diagnostics, + 'animation.controller.state.unknown', + `Unknown transition target '${transition.to}'`, + `${path}.to` + ); + } + if (transition.duration !== undefined && !isFiniteNumber(transition.duration)) { + pushDiagnostic( + diagnostics, + 'animation.controller.transition.duration.invalid', + 'Transition duration must be finite', + `${path}.duration` + ); + } + if (transition.offset !== undefined && !isFiniteNumber(transition.offset)) { + pushDiagnostic( + diagnostics, + 'animation.controller.transition.offset.invalid', + 'Transition offset must be finite', + `${path}.offset` + ); + } + if (transition.exitTime !== undefined && !isFiniteNumber(transition.exitTime)) { + pushDiagnostic( + diagnostics, + 'animation.controller.transition.exitTime.invalid', + 'Transition exitTime must be finite', + `${path}.exitTime` + ); + } + if (transition.priority !== undefined && !isFiniteNumber(transition.priority)) { + pushDiagnostic( + diagnostics, + 'animation.controller.transition.priority.invalid', + 'Transition priority must be finite', + `${path}.priority` + ); + } + for (let index = 0; index < (transition.conditions?.length ?? 0); index += 1) { + validateCondition( + transition.conditions![index]!, + diagnostics, + options, + `${path}.conditions[${index}]` + ); + } +}; + +const validateState = ( + state: AnimationStateDefinition, + diagnostics: AnimationControllerGraphDiagnostic[], + options: AnimationControllerGraphValidationOptions, + knownStates: ReadonlySet, + path: string +): void => { + if (!String(state.id)) { + pushDiagnostic( + diagnostics, + 'animation.controller.state.id.empty', + 'States require a non-empty id', + `${path}.id` + ); + } + if (state.speed !== undefined && !isFiniteNumber(state.speed)) { + pushDiagnostic( + diagnostics, + 'animation.controller.state.speed.invalid', + 'State speed must be finite', + `${path}.speed` + ); + } + const motionDiagnostics = validateAnimationMotionDefinition(state.motion, { + knownClipIds: options.knownClipIds, + knownParameters: options.knownParameters, + }); + for (let index = 0; index < motionDiagnostics.length; index += 1) { + const diagnostic = motionDiagnostics[index]!; + pushDiagnostic( + diagnostics, + diagnostic.code.replace('animation.blendGraph', 'animation.controller.motion'), + diagnostic.message, + `${path}.motion${diagnostic.path === 'motion' ? '' : diagnostic.path.slice('motion'.length)}` + ); + } + for (let index = 0; index < (state.transitions?.length ?? 0); index += 1) { + validateTransition( + state.transitions![index]!, + diagnostics, + options, + knownStates, + `${path}.transitions[${index}]` + ); + } +}; + +export const validateAnimationStateMachineDefinition = ( + stateMachine: AnimationStateMachineInput, + options: AnimationControllerGraphValidationOptions = {} +): readonly AnimationControllerGraphDiagnostic[] => { + const diagnostics: AnimationControllerGraphDiagnostic[] = []; + const resolved = buildAnimationStateMachineDefinition(stateMachine); + + if (resolved.states.length === 0) { + pushDiagnostic( + diagnostics, + 'animation.controller.states.empty', + 'State machines require at least one state', + 'stateMachine.states' + ); + } + + const knownStates = new Set(); + for (let index = 0; index < resolved.states.length; index += 1) { + const state = resolved.states[index]!; + if (knownStates.has(String(state.id))) { + pushDiagnostic( + diagnostics, + 'animation.controller.state.duplicate', + `Duplicate state '${state.id}'`, + `stateMachine.states[${index}].id` + ); + } + knownStates.add(String(state.id)); + } + + if (knownStates.has(String(resolved.entryState)) === false) { + pushDiagnostic( + diagnostics, + 'animation.controller.state.entry.unknown', + `Unknown entry state '${resolved.entryState}'`, + 'stateMachine.entryState' + ); + } + + for (let index = 0; index < resolved.states.length; index += 1) { + validateState( + resolved.states[index]!, + diagnostics, + options, + knownStates, + `stateMachine.states[${index}]` + ); + } + + for (let index = 0; index < (resolved.anyStateTransitions?.length ?? 0); index += 1) { + validateTransition( + resolved.anyStateTransitions![index]!, + diagnostics, + options, + knownStates, + `stateMachine.anyStateTransitions[${index}]` + ); + } + + return Object.freeze(diagnostics); +}; + +const validateIkLayer = ( + layer: AnimationIkLayerDefinition, + diagnostics: AnimationControllerGraphDiagnostic[], + options: AnimationControllerGraphValidationOptions, + path: string +): void => { + if (!String(layer.id)) { + pushDiagnostic( + diagnostics, + 'animation.controller.ikLayer.id.empty', + 'IK layers require a non-empty id', + `${path}.id` + ); + } + if (layer.weight !== undefined && !isFiniteNumber(layer.weight)) { + pushDiagnostic( + diagnostics, + 'animation.controller.ikLayer.weight.invalid', + 'IK layer weight must be finite', + `${path}.weight` + ); + } + if (layer.jobs.length === 0) { + pushDiagnostic( + diagnostics, + 'animation.controller.ikLayer.jobs.empty', + 'IK layers require at least one job', + `${path}.jobs` + ); + } + for (let index = 0; index < layer.jobs.length; index += 1) { + const job = layer.jobs[index]!; + if ( + options.knownBones && + options.knownBones.includes(job.rootBone) === false + ) { + pushDiagnostic( + diagnostics, + 'animation.controller.bone.unknown', + `Unknown bone '${job.rootBone}'`, + `${path}.jobs[${index}].rootBone` + ); + } + if ( + options.knownBones && + options.knownBones.includes(job.tipBone) === false + ) { + pushDiagnostic( + diagnostics, + 'animation.controller.bone.unknown', + `Unknown bone '${job.tipBone}'`, + `${path}.jobs[${index}].tipBone` + ); + } + if ( + typeof job.targetBone === 'string' && + options.knownBones && + options.knownBones.includes(job.targetBone) === false + ) { + pushDiagnostic( + diagnostics, + 'animation.controller.bone.unknown', + `Unknown bone '${job.targetBone}'`, + `${path}.jobs[${index}].targetBone` + ); + } + } +}; + +export const validateAnimationLayerDefinition = ( + layer: AnimationLayerInput, + options: AnimationControllerGraphValidationOptions = {} +): readonly AnimationControllerGraphDiagnostic[] => { + const diagnostics: AnimationControllerGraphDiagnostic[] = []; + const resolved = buildAnimationLayerDefinition(layer); + + if (!String(resolved.id)) { + pushDiagnostic( + diagnostics, + 'animation.controller.layer.id.empty', + 'Layers require a non-empty id', + 'layer.id' + ); + } + if (resolved.weight !== undefined && !isFiniteNumber(resolved.weight)) { + pushDiagnostic( + diagnostics, + 'animation.controller.layer.weight.invalid', + 'Layer weight must be finite', + 'layer.weight' + ); + } + if (resolved.mode !== undefined && VALID_LAYER_MODES.has(resolved.mode) === false) { + pushDiagnostic( + diagnostics, + 'animation.controller.layer.mode.invalid', + `Unsupported layer mode '${String(resolved.mode)}'`, + 'layer.mode' + ); + } + for (let index = 0; index < (resolved.boneMask?.length ?? 0); index += 1) { + const bone = resolved.boneMask![index]!; + if (options.knownBones && options.knownBones.includes(bone) === false) { + pushDiagnostic( + diagnostics, + 'animation.controller.bone.unknown', + `Unknown bone '${bone}'`, + `layer.boneMask[${index}]` + ); + } + } + + diagnostics.push( + ...validateAnimationStateMachineDefinition(resolved.stateMachine, options).map((diagnostic) => + Object.freeze({ + code: diagnostic.code, + message: diagnostic.message, + path: `layer.${diagnostic.path}`, + }) + ) + ); + + for (let index = 0; index < (resolved.ikLayers?.length ?? 0); index += 1) { + validateIkLayer(resolved.ikLayers![index]!, diagnostics, options, `layer.ikLayers[${index}]`); + } + + return Object.freeze(diagnostics); +}; + +export const validateAnimationControllerDefinition = ( + controller: AnimationControllerInput, + options: AnimationControllerGraphValidationOptions = {} +): readonly AnimationControllerGraphDiagnostic[] => { + const diagnostics: AnimationControllerGraphDiagnostic[] = []; + const resolved = buildAnimationControllerDefinition(controller); + const knownClipIds = options.knownClipIds ?? resolved.clips.map((clip) => String(clip.id)); + const knownParameters = + options.knownParameters ?? resolved.parameters?.map((parameter) => parameter.name) ?? []; + const knownBones = options.knownBones ?? resolved.rig.bones.map((bone) => bone.name); + + if (resolved.rig.bones.length === 0) { + pushDiagnostic( + diagnostics, + 'animation.controller.rig.bones.empty', + 'Controllers require at least one rig bone', + 'controller.rig.bones' + ); + } + const seenBones = new Set(); + for (let index = 0; index < resolved.rig.bones.length; index += 1) { + const bone = resolved.rig.bones[index]!; + if (!bone.name) { + pushDiagnostic( + diagnostics, + 'animation.controller.bone.name.empty', + 'Rig bones require a non-empty name', + `controller.rig.bones[${index}].name` + ); + } + if (seenBones.has(bone.name)) { + pushDiagnostic( + diagnostics, + 'animation.controller.bone.duplicate', + `Duplicate rig bone '${bone.name}'`, + `controller.rig.bones[${index}].name` + ); + } + seenBones.add(bone.name); + } + + if (resolved.clips.length === 0) { + pushDiagnostic( + diagnostics, + 'animation.controller.clips.empty', + 'Controllers require at least one clip', + 'controller.clips' + ); + } + const seenClips = new Set(); + for (let index = 0; index < resolved.clips.length; index += 1) { + const clipId = String(resolved.clips[index]!.id); + if (seenClips.has(clipId)) { + pushDiagnostic( + diagnostics, + 'animation.controller.clip.duplicate', + `Duplicate clip '${clipId}'`, + `controller.clips[${index}].id` + ); + } + seenClips.add(clipId); + } + + const seenParameters = new Set(); + for (let index = 0; index < (resolved.parameters?.length ?? 0); index += 1) { + const parameter = resolved.parameters![index]!; + if (seenParameters.has(parameter.name)) { + pushDiagnostic( + diagnostics, + 'animation.controller.parameter.duplicate', + `Duplicate parameter '${parameter.name}'`, + `controller.parameters[${index}].name` + ); + } + seenParameters.add(parameter.name); + } + + if (resolved.layers.length === 0) { + pushDiagnostic( + diagnostics, + 'animation.controller.layers.empty', + 'Controllers require at least one layer', + 'controller.layers' + ); + } + const seenLayers = new Set(); + for (let index = 0; index < resolved.layers.length; index += 1) { + const layer = resolved.layers[index]!; + if (seenLayers.has(String(layer.id))) { + pushDiagnostic( + diagnostics, + 'animation.controller.layer.duplicate', + `Duplicate layer '${layer.id}'`, + `controller.layers[${index}].id` + ); + } + seenLayers.add(String(layer.id)); + + diagnostics.push( + ...validateAnimationLayerDefinition(layer, { + knownClipIds, + knownParameters, + knownBones, + }).map((diagnostic) => + Object.freeze({ + code: diagnostic.code, + message: diagnostic.message, + path: diagnostic.path.replace(/^layer\./, `controller.layers[${index}].`), + }) + ) + ); + } + + if ( + resolved.rootMotion && + knownBones.includes(resolved.rootMotion.bone) === false + ) { + pushDiagnostic( + diagnostics, + 'animation.controller.rootMotion.bone.unknown', + `Unknown root motion bone '${resolved.rootMotion.bone}'`, + 'controller.rootMotion.bone' + ); + } + + return Object.freeze(diagnostics); +}; + +export const AnimationControllerGraph = Object.freeze({ + transition: createAnimationTransition, + state: createAnimationState, + machine: createAnimationStateMachine, + ikLayer: createAnimationIkLayer, + layer: createAnimationLayer, + controller: createAnimationController, + buildTransition: buildAnimationTransitionDefinition, + buildState: buildAnimationStateDefinition, + buildMachine: buildAnimationStateMachineDefinition, + buildIkLayer: buildAnimationIkLayerDefinition, + buildLayer: buildAnimationLayerDefinition, + buildController: buildAnimationControllerDefinition, + validateMachine: validateAnimationStateMachineDefinition, + validateLayer: validateAnimationLayerDefinition, + validateController: validateAnimationControllerDefinition, +}); \ No newline at end of file diff --git a/web/packages/animation/src/controller.ts b/web/packages/animation/src/controller.ts new file mode 100644 index 00000000..dbeea56d --- /dev/null +++ b/web/packages/animation/src/controller.ts @@ -0,0 +1,505 @@ +import { createAnimationClips, AnimationClip } from './clip'; +import { AnimationScratchPool, type AnimationMotionEvaluationContext } from './blend-tree'; +import { AnimationStateMachineError, AnimationValidationError } from './errors'; +import { AnimationIkLayer } from './ik'; +import { AnimationParameterStore } from './parameters'; +import { + AnimationCurveLayout, + AnimationMask, + AnimationFrame, + applyAdditiveFrame, + blendFrame, +} from './pose'; +import { AnimationRig } from './rig'; +import { + commitLayerRuntime, + collectLayerClipActivities, + collectLayerEvents, + compileStateMachine, + createLayerRuntime, + crossFadeLayerState, + evaluateLayerRuntime, + extractLayerRootDelta, + forceLayerState, + updateLayerRuntime, + type AnimationCompiledStateMachine, + type AnimationLayerRuntime, +} from './state-machine'; +import { quatCopy } from './math'; +import type { + AnimationClipDefinition, + AnimationControllerClipActivity, + AnimationControllerEvent, + AnimationControllerDefinition, + AnimationControllerProfile, + AnimationCurveBindingDefinition, + AnimationLayerBlendMode, + AnimationParameterDefinition, + AnimationRootMotionDelta, +} from './types'; + +interface AnimationCompiledLayer { + readonly id: string; + readonly mode: AnimationLayerBlendMode; + readonly machine: AnimationCompiledStateMachine; + readonly mask: AnimationMask | null; + readonly ikLayers: readonly AnimationIkLayer[]; +} + +export interface AnimationControllerUpdateResult { + readonly frame: AnimationFrame; + readonly rootMotion: AnimationRootMotionDelta; + readonly events: readonly AnimationControllerEvent[]; + readonly activeClips: readonly AnimationControllerClipActivity[]; + readonly profile: AnimationControllerProfile; +} + +const now = (): number => + typeof performance !== 'undefined' && typeof performance.now === 'function' + ? performance.now() + : Date.now(); + +const inferCurveBindings = ( + clips: readonly AnimationClipDefinition[] +): readonly AnimationCurveBindingDefinition[] => { + const bindings = new Map(); + for (let clipIndex = 0; clipIndex < clips.length; clipIndex += 1) { + const clip = clips[clipIndex]!; + for (let trackIndex = 0; trackIndex < clip.tracks.length; trackIndex += 1) { + const track = clip.tracks[trackIndex]!; + if (track.path !== 'weights') { + continue; + } + const keyframeCount = track.keyframeCount ?? track.times.length; + const sampleStride = + track.sampleStride ?? + (keyframeCount > 0 ? track.values.length / keyframeCount : track.valueComponentCount ?? 0); + const componentCount = + track.valueComponentCount ?? + (track.interpolation === 'CUBICSPLINE' ? sampleStride / 3 : sampleStride); + if (!Number.isInteger(componentCount) || componentCount <= 0) { + throw new AnimationValidationError( + `Animation curve binding '${track.target}' has invalid component count` + ); + } + bindings.set(track.target, Math.max(bindings.get(track.target) ?? 0, componentCount)); + } + } + return Object.freeze( + [...bindings.entries()].map(([id, componentCount]) => + Object.freeze({ id, componentCount }) + ) + ); +}; + +export class AnimationController< + TParameters extends readonly AnimationParameterDefinition[] = readonly AnimationParameterDefinition[], +> { + readonly rig: AnimationRig; + readonly parameters: AnimationParameterStore; + readonly curveLayout: AnimationCurveLayout; + readonly clips: ReadonlyMap; + readonly currentFrame: AnimationFrame; + + private readonly _restFrame: AnimationFrame; + private readonly _scratchPool: AnimationScratchPool; + private readonly _layers: readonly AnimationCompiledLayer[]; + private readonly _layerRuntimes: AnimationLayerRuntime[]; + private readonly _layerWeights: Float32Array; + private readonly _evaluationContext: AnimationMotionEvaluationContext; + private readonly _rootMotionTranslation = new Float32Array(3); + private readonly _rootMotionRotation = new Float32Array([0, 0, 0, 1]); + private readonly _rootMotionConfig: NonNullable | null; + private readonly _rootMotionBoneIndex: number; + private _events: readonly AnimationControllerEvent[] = Object.freeze([]); + private _activeClips: readonly AnimationControllerClipActivity[] = Object.freeze([]); + private _profile: AnimationControllerProfile = Object.freeze({ + evaluationTimeMs: 0, + sampledTrackCount: 0, + activeClipCount: 0, + emittedEventCount: 0, + rootMotionTranslationMagnitude: 0, + rootMotionRotationW: 1, + activeLayers: Object.freeze([]), + }); + + constructor(definition: AnimationControllerDefinition) { + this.rig = new AnimationRig(definition.rig); + this.parameters = new AnimationParameterStore(definition.parameters ?? ([] as unknown as TParameters)); + this.curveLayout = new AnimationCurveLayout(inferCurveBindings(definition.clips)); + this.clips = createAnimationClips(definition.clips, this.rig, this.curveLayout); + this._restFrame = new AnimationFrame(this.rig, this.curveLayout); + this.currentFrame = new AnimationFrame(this.rig, this.curveLayout); + this._scratchPool = new AnimationScratchPool(this.rig, this.curveLayout, this._restFrame.curves.values); + this._evaluationContext = { + rig: this.rig, + parameters: this.parameters, + restFrame: this._restFrame, + scratch: this._scratchPool, + }; + this._layers = Object.freeze( + definition.layers.map((layer) => { + const mask = layer.boneMask + ? layer.boneMask.reduce((accumulator, boneName) => { + accumulator.set(this.rig.indexOfBone(boneName), true); + return accumulator; + }, new AnimationMask(this.rig.boneCount, false)) + : null; + return Object.freeze({ + id: String(layer.id), + mode: layer.mode ?? 'override', + machine: compileStateMachine(layer.stateMachine, this.clips), + mask, + ikLayers: Object.freeze( + (layer.ikLayers ?? []).map((ikLayer) => new AnimationIkLayer(this.rig, ikLayer)) + ), + } satisfies AnimationCompiledLayer); + }) + ); + if (this._layers.length === 0) { + throw new AnimationValidationError('Animation controllers require at least one layer'); + } + this._layerRuntimes = this._layers.map((layer) => createLayerRuntime(layer.machine)); + this._layerWeights = new Float32Array(this._layers.length); + for (let layerIndex = 0; layerIndex < this._layers.length; layerIndex += 1) { + this._layerWeights[layerIndex] = definition.layers[layerIndex]?.weight ?? 1; + } + this._rootMotionConfig = definition.rootMotion ?? null; + this._rootMotionBoneIndex = + this._rootMotionConfig && typeof this._rootMotionConfig.bone === 'string' + ? this.rig.indexOfBone(this._rootMotionConfig.bone) + : -1; + this.evaluate(); + } + + get rootMotion(): AnimationRootMotionDelta { + return { + translation: [ + this._rootMotionTranslation[0], + this._rootMotionTranslation[1], + this._rootMotionTranslation[2], + ], + rotation: [ + this._rootMotionRotation[0], + this._rootMotionRotation[1], + this._rootMotionRotation[2], + this._rootMotionRotation[3], + ], + }; + } + + get events(): readonly AnimationControllerEvent[] { + return this._events; + } + + get activeClips(): readonly AnimationControllerClipActivity[] { + return this._activeClips; + } + + get profile(): AnimationControllerProfile { + return this._profile; + } + + update(deltaSeconds: number): AnimationControllerUpdateResult { + const startTime = now(); + for (let layerIndex = 0; layerIndex < this._layers.length; layerIndex += 1) { + updateLayerRuntime( + this._layers[layerIndex]!.machine, + this._layerRuntimes[layerIndex]!, + this.parameters, + Math.max(0, deltaSeconds) + ); + } + this._composeCurrentFrame(true); + for (let layerIndex = 0; layerIndex < this._layers.length; layerIndex += 1) { + commitLayerRuntime(this._layerRuntimes[layerIndex]!); + } + this._updateProfile(now() - startTime); + return { + frame: this.currentFrame, + rootMotion: this.rootMotion, + events: this.events, + activeClips: this.activeClips, + profile: this.profile, + }; + } + + evaluate(): AnimationFrame { + const startTime = now(); + this._composeCurrentFrame(false); + this._updateProfile(now() - startTime); + return this.currentFrame; + } + + play(stateId: string, layerId: string | undefined = this._layers[0]!.id): this { + const layerIndex = this._resolveLayerIndex(layerId); + forceLayerState(this._layers[layerIndex]!.machine, this._layerRuntimes[layerIndex]!, stateId, 0); + this.evaluate(); + return this; + } + + crossFade(stateId: string, durationSeconds: number, layerId: string | undefined = this._layers[0]!.id): this { + const layerIndex = this._resolveLayerIndex(layerId); + crossFadeLayerState( + this._layers[layerIndex]!.machine, + this._layerRuntimes[layerIndex]!, + stateId, + durationSeconds, + 0 + ); + return this; + } + + seek(timeSeconds: number, layerId: string | undefined = this._layers[0]!.id): this { + const layerIndex = this._resolveLayerIndex(layerId); + const runtime = this._layerRuntimes[layerIndex]!; + const state = this._layers[layerIndex]!.machine.states[runtime.currentStateIndex]!; + const duration = Math.max(1e-6, state.motion.kind === 'clip' ? state.motion.clip.duration : 1); + runtime.currentNormalizedTime = Math.max(0, timeSeconds) / duration; + runtime.previousNormalizedTime = runtime.currentNormalizedTime; + this.evaluate(); + return this; + } + + setLayerWeight(layerId: string, weight: number): this { + this._layerWeights[this._resolveLayerIndex(layerId)] = weight; + return this; + } + + dispose(): void { + this._rootMotionTranslation.fill(0); + quatCopy(this._rootMotionRotation, 0, [0, 0, 0, 1], 0); + } + + private _resolveLayerIndex(layerId: string | undefined): number { + const resolvedId = layerId ?? this._layers[0]!.id; + const layerIndex = this._layers.findIndex((layer) => layer.id === resolvedId); + if (layerIndex < 0) { + throw new AnimationStateMachineError(`Unknown animation layer '${resolvedId}'`); + } + return layerIndex; + } + + private _composeCurrentFrame(updateRootMotion: boolean): void { + this.currentFrame.reset(this.rig, this._restFrame.curves.values); + this._rootMotionTranslation.fill(0); + quatCopy(this._rootMotionRotation, 0, [0, 0, 0, 1], 0); + const events: AnimationControllerEvent[] = []; + const activeClips: AnimationControllerClipActivity[] = []; + + for (let layerIndex = 0; layerIndex < this._layers.length; layerIndex += 1) { + const layer = this._layers[layerIndex]!; + const runtime = this._layerRuntimes[layerIndex]!; + const layerWeight = Math.max(0, Math.min(1, this._layerWeights[layerIndex]!)); + if (layerWeight <= 0) { + continue; + } + + this._scratchPool.reset(); + const layerFrame = this._scratchPool.acquire(); + evaluateLayerRuntime(layer.machine, runtime, this._evaluationContext, layerFrame); + for (let ikIndex = 0; ikIndex < layer.ikLayers.length; ikIndex += 1) { + layer.ikLayers[ikIndex]!.apply(layerFrame.pose); + } + + if (layerIndex === 0) { + if (layer.mode === 'additive') { + applyAdditiveFrame( + this.currentFrame, + this.currentFrame, + layerFrame, + this._restFrame, + layerWeight, + layer.mask ?? undefined + ); + } else { + blendFrame( + this.currentFrame, + this._restFrame, + layerFrame, + layerWeight, + layer.mask ?? undefined + ); + } + } else if (layer.mode === 'additive') { + applyAdditiveFrame( + this.currentFrame, + this.currentFrame, + layerFrame, + this._restFrame, + layerWeight, + layer.mask ?? undefined + ); + } else { + const baseFrame = this._scratchPool.acquire().copyFrom(this.currentFrame); + blendFrame( + this.currentFrame, + baseFrame, + layerFrame, + layerWeight, + layer.mask ?? undefined + ); + } + + if (updateRootMotion && layerIndex === 0 && this._rootMotionBoneIndex >= 0 && this._rootMotionConfig) { + extractLayerRootDelta( + layer.machine, + runtime, + this._rootMotionBoneIndex, + this._evaluationContext, + this._rootMotionTranslation, + this._rootMotionRotation + ); + } + + if (updateRootMotion) { + collectLayerEvents( + layer.machine, + runtime, + this._evaluationContext, + layer.id, + layerWeight, + events + ); + } + + collectLayerClipActivities( + layer.machine, + runtime, + this._evaluationContext, + layer.id, + layerWeight, + activeClips + ); + } + + if (this._rootMotionConfig && this._rootMotionBoneIndex >= 0) { + const translationOffset = this._rootMotionBoneIndex * 3; + const rotationOffset = this._rootMotionBoneIndex * 4; + const axes = this._rootMotionConfig.projectTranslationAxes ?? [true, true, true] as const; + if (this._rootMotionConfig.consume !== false) { + for (let axisIndex = 0; axisIndex < 3; axisIndex += 1) { + if (axes[axisIndex]) { + this.currentFrame.pose.translations[translationOffset + axisIndex] = + this.rig.restTranslations[translationOffset + axisIndex]!; + } + } + if (this._rootMotionConfig.extractRotation !== false) { + quatCopy( + this.currentFrame.pose.rotations, + rotationOffset, + this.rig.restRotations, + rotationOffset + ); + } + } + for (let axisIndex = 0; axisIndex < 3; axisIndex += 1) { + if (!axes[axisIndex]) { + this._rootMotionTranslation[axisIndex] = 0; + } + } + if (this._rootMotionConfig.extractRotation === false) { + quatCopy(this._rootMotionRotation, 0, [0, 0, 0, 1], 0); + } + } + + this._events = updateRootMotion + ? Object.freeze( + [...events].sort( + (left, right) => + left.time - right.time || + left.layerId.localeCompare(right.layerId) || + left.stateId.localeCompare(right.stateId) + ) + ) + : Object.freeze([]); + this._activeClips = Object.freeze( + [...activeClips].sort( + (left, right) => + right.motionWeight - left.motionWeight || + left.layerId.localeCompare(right.layerId) || + left.stateId.localeCompare(right.stateId) || + left.clipId.localeCompare(right.clipId) + ) + ); + } + + private _countMotionTracks(motion: AnimationCompiledStateMachine['states'][number]['motion']): number { + switch (motion.kind) { + case 'clip': + return ( + motion.clip.translationTracks.length + + motion.clip.rotationTracks.length + + motion.clip.scaleTracks.length + + motion.clip.curveTracks.length + ); + case 'blend1d': + case 'blend2d': + case 'direct': + return motion.children.reduce( + (sum, child) => sum + this._countMotionTracks(child.motion), + 0 + ); + case 'additive': + return this._countMotionTracks(motion.base) + this._countMotionTracks(motion.additive); + default: + return 0; + } + } + + private _updateProfile(evaluationTimeMs: number): void { + const activeLayers = this._layers.map((layer, index) => { + const runtime = this._layerRuntimes[index]!; + if (!runtime.transition) { + const state = layer.machine.states[runtime.currentStateIndex]!; + return Object.freeze({ + layerId: layer.id, + stateId: state.id, + normalizedTime: runtime.currentNormalizedTime, + weight: this._layerWeights[index] ?? 1, + transitioning: false, + }); + } + + const sourceState = layer.machine.states[runtime.transition.sourceStateIndex]!; + const targetState = layer.machine.states[runtime.transition.targetStateIndex]!; + return Object.freeze({ + layerId: layer.id, + stateId: `${sourceState.id}->${targetState.id}`, + normalizedTime: runtime.transition.targetNormalizedTime, + weight: this._layerWeights[index] ?? 1, + transitioning: true, + transitionProgress: runtime.transition.progress, + }); + }); + + const sampledTrackCount = this._layers.reduce((sum, layer, index) => { + if ((this._layerWeights[index] ?? 0) <= 0) { + return sum; + } + const runtime = this._layerRuntimes[index]!; + if (!runtime.transition) { + return sum + this._countMotionTracks(layer.machine.states[runtime.currentStateIndex]!.motion); + } + return ( + sum + + this._countMotionTracks(layer.machine.states[runtime.transition.sourceStateIndex]!.motion) + + this._countMotionTracks(layer.machine.states[runtime.transition.targetStateIndex]!.motion) + ); + }, 0); + + this._profile = Object.freeze({ + evaluationTimeMs, + sampledTrackCount, + activeClipCount: this._activeClips.length, + emittedEventCount: this._events.length, + rootMotionTranslationMagnitude: Math.hypot( + this._rootMotionTranslation[0], + this._rootMotionTranslation[1], + this._rootMotionTranslation[2] + ), + rootMotionRotationW: this._rootMotionRotation[3] ?? 1, + activeLayers: Object.freeze(activeLayers), + }); + } +} \ No newline at end of file diff --git a/web/packages/animation/src/errors.ts b/web/packages/animation/src/errors.ts new file mode 100644 index 00000000..1055eb63 --- /dev/null +++ b/web/packages/animation/src/errors.ts @@ -0,0 +1,49 @@ +export class AnimationError extends Error { + constructor( + message: string, + readonly code: string, + readonly cause?: unknown + ) { + super(message); + this.name = 'AnimationError'; + Object.setPrototypeOf(this, new.target.prototype); + ( + Error as typeof Error & { captureStackTrace?: (target: object, ctor: Function) => void } + ).captureStackTrace?.(this, this.constructor); + } +} + +export class AnimationValidationError extends AnimationError { + constructor(message: string, cause?: unknown) { + super(message, 'ANIMATION_VALIDATION_ERROR', cause); + this.name = 'AnimationValidationError'; + } +} + +export class AnimationSamplingError extends AnimationError { + constructor(message: string, cause?: unknown) { + super(message, 'ANIMATION_SAMPLING_ERROR', cause); + this.name = 'AnimationSamplingError'; + } +} + +export class AnimationStateMachineError extends AnimationError { + constructor(message: string, cause?: unknown) { + super(message, 'ANIMATION_STATE_MACHINE_ERROR', cause); + this.name = 'AnimationStateMachineError'; + } +} + +export class AnimationRetargetingError extends AnimationError { + constructor(message: string, cause?: unknown) { + super(message, 'ANIMATION_RETARGETING_ERROR', cause); + this.name = 'AnimationRetargetingError'; + } +} + +export class AnimationIkError extends AnimationError { + constructor(message: string, cause?: unknown) { + super(message, 'ANIMATION_IK_ERROR', cause); + this.name = 'AnimationIkError'; + } +} \ No newline at end of file diff --git a/web/packages/animation/src/grounding.ts b/web/packages/animation/src/grounding.ts new file mode 100644 index 00000000..f49fb95b --- /dev/null +++ b/web/packages/animation/src/grounding.ts @@ -0,0 +1,56 @@ +import { AnimationClip } from './clip'; +import type { AnimationGroundingContactResult, AnimationGroundingResult } from './types'; + +const resolveBoneHeight = ( + heights: Readonly> | ReadonlyMap, + bone: string +): number => { + if (heights instanceof Map) { + return heights.get(bone) ?? 0; + } + + const value = (heights as Readonly>)[bone]; + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +}; + +export const solvePlanarGrounding = ( + clip: AnimationClip, + timeSeconds: number, + boneHeights: Readonly> | ReadonlyMap, + groundHeight: number = 0 +): AnimationGroundingResult => { + const contacts = clip.sampleFootContacts(timeSeconds) + .filter((contact) => contact.active && contact.weight > 0) + .map((contact) => { + const groundOffset = groundHeight - resolveBoneHeight(boneHeights, contact.bone); + return Object.freeze({ + bone: contact.bone, + weight: contact.weight, + groundOffset, + ...(contact.lockTranslationAxes + ? { lockTranslationAxes: contact.lockTranslationAxes } + : {}), + } satisfies AnimationGroundingContactResult); + }); + + if (contacts.length === 0) { + return Object.freeze({ + rootOffset: Object.freeze([0, 0, 0]) as readonly [number, number, number], + contacts: Object.freeze([]), + }); + } + + let totalWeight = 0; + let accumulatedOffset = 0; + for (let index = 0; index < contacts.length; index += 1) { + const contact = contacts[index]!; + totalWeight += contact.weight; + accumulatedOffset += contact.groundOffset * contact.weight; + } + + const rootYOffset = totalWeight > 0 ? accumulatedOffset / totalWeight : 0; + return Object.freeze({ + rootOffset: Object.freeze([0, rootYOffset, 0]) as readonly [number, number, number], + contacts: Object.freeze(contacts), + }); +}; \ No newline at end of file diff --git a/web/packages/animation/src/ik.ts b/web/packages/animation/src/ik.ts new file mode 100644 index 00000000..2ac3fd51 --- /dev/null +++ b/web/packages/animation/src/ik.ts @@ -0,0 +1,330 @@ +import { AnimationIkError, AnimationValidationError } from './errors'; +import { quatCopy, quatFromTo, quatIdentity, quatInvert, quatMultiply, quatNormalize, quatSlerp, vec3Length, vec3Normalize, vec3Subtract } from './math'; +import { AnimationWorldPose, type AnimationPose } from './pose'; +import type { AnimationRig } from './rig'; +import type { AnimationIkJobDefinition, AnimationIkLayerDefinition, AnimationIkTarget } from './types'; + +interface AnimationCompiledIkJob { + readonly id: string; + readonly solver: 'fabrik' | 'ccd'; + readonly chain: Int32Array; + readonly rootIndex: number; + readonly tipIndex: number; + readonly targetBoneIndex?: number; + readonly precision: number; + readonly maxIterations: number; + readonly weight: number; + readonly preserveTipRotation: boolean; + readonly targetPosition: Float32Array; + readonly targetRotation: Float32Array; +} + +const buildChain = (rig: AnimationRig, rootIndex: number, tipIndex: number): Int32Array => { + const chain: number[] = []; + let current = tipIndex; + while (current >= 0) { + chain.push(current); + if (current === rootIndex) { + return Int32Array.from(chain.reverse()); + } + current = rig.parentIndices[current]!; + } + throw new AnimationValidationError( + `IK chain root '${rig.getBoneName(rootIndex)}' is not an ancestor of tip '${rig.getBoneName(tipIndex)}'` + ); +}; + +export class AnimationIkLayer { + readonly id: string; + readonly weight: number; + readonly jobs: readonly AnimationCompiledIkJob[]; + + private readonly _jobById = new Map(); + private readonly _worldPose: AnimationWorldPose; + private readonly _scratchQuaternion = new Float32Array(4); + private readonly _scratchQuaternionB = new Float32Array(4); + private readonly _scratchQuaternionC = new Float32Array(4); + private readonly _scratchVectors = new Float32Array(24); + + constructor(private readonly _rig: AnimationRig, definition: AnimationIkLayerDefinition) { + if (!definition || typeof definition.id !== 'string' || definition.id.length === 0) { + throw new AnimationValidationError('IK layers require a non-empty id'); + } + this.id = definition.id; + this.weight = definition.weight ?? 1; + this._worldPose = new AnimationWorldPose(_rig.boneCount); + this.jobs = Object.freeze( + definition.jobs.map((jobDefinition) => { + const rootIndex = _rig.indexOfBone(jobDefinition.rootBone); + const tipIndex = _rig.indexOfBone(jobDefinition.tipBone); + const chain = buildChain(_rig, rootIndex, tipIndex); + const job = Object.freeze({ + id: String(jobDefinition.id), + solver: jobDefinition.solver, + chain, + rootIndex, + tipIndex, + targetBoneIndex: + typeof jobDefinition.targetBone === 'string' + ? _rig.tryIndexOfBone(jobDefinition.targetBone) + : undefined, + precision: + typeof jobDefinition.precision === 'number' && Number.isFinite(jobDefinition.precision) + ? Math.max(1e-5, jobDefinition.precision) + : 1e-3, + maxIterations: + typeof jobDefinition.maxIterations === 'number' && Number.isFinite(jobDefinition.maxIterations) + ? Math.max(1, Math.trunc(jobDefinition.maxIterations)) + : 12, + weight: + typeof jobDefinition.weight === 'number' && Number.isFinite(jobDefinition.weight) + ? jobDefinition.weight + : 1, + preserveTipRotation: jobDefinition.preserveTipRotation ?? false, + targetPosition: new Float32Array(jobDefinition.targetPosition ?? [0, 0, 0]), + targetRotation: new Float32Array(jobDefinition.targetRotation ?? [0, 0, 0, 1]), + } satisfies AnimationCompiledIkJob); + if (this._jobById.has(job.id)) { + throw new AnimationValidationError(`Duplicate IK job '${job.id}'`); + } + this._jobById.set(job.id, job); + return job; + }) + ); + } + + setTarget(jobId: string, target: AnimationIkTarget): this { + const job = this._jobById.get(jobId); + if (!job) { + throw new AnimationIkError(`Unknown IK job '${jobId}'`); + } + job.targetPosition.set(target.position); + if (target.rotation) { + job.targetRotation.set(target.rotation); + } + return this; + } + + apply(pose: AnimationPose, weight: number = this.weight): AnimationPose { + const layerWeight = Math.max(0, Math.min(1, weight)); + if (layerWeight <= 0) { + return pose; + } + for (let jobIndex = 0; jobIndex < this.jobs.length; jobIndex += 1) { + const job = this.jobs[jobIndex]!; + const jobWeight = Math.max(0, Math.min(1, layerWeight * job.weight)); + if (jobWeight <= 0) { + continue; + } + switch (job.solver) { + case 'fabrik': + this._solveFabrik(job, pose, jobWeight); + break; + case 'ccd': + default: + this._solveCcd(job, pose, jobWeight); + break; + } + if (job.preserveTipRotation) { + this._applyTipRotation(job, pose, jobWeight); + } + } + return pose; + } + + private _resolveTarget(job: AnimationCompiledIkJob, pose: AnimationPose): void { + if (job.targetBoneIndex === undefined) { + return; + } + this._worldPose.update(this._rig, pose); + const targetOffset = job.targetBoneIndex * 3; + const rotationOffset = job.targetBoneIndex * 4; + job.targetPosition[0] = this._worldPose.translations[targetOffset]!; + job.targetPosition[1] = this._worldPose.translations[targetOffset + 1]!; + job.targetPosition[2] = this._worldPose.translations[targetOffset + 2]!; + job.targetRotation[0] = this._worldPose.rotations[rotationOffset]!; + job.targetRotation[1] = this._worldPose.rotations[rotationOffset + 1]!; + job.targetRotation[2] = this._worldPose.rotations[rotationOffset + 2]!; + job.targetRotation[3] = this._worldPose.rotations[rotationOffset + 3]!; + } + + private _solveCcd(job: AnimationCompiledIkJob, pose: AnimationPose, weight: number): void { + this._resolveTarget(job, pose); + this._worldPose.update(this._rig, pose); + + for (let iteration = 0; iteration < job.maxIterations; iteration += 1) { + const tipOffset = job.tipIndex * 3; + vec3Subtract(this._scratchVectors, 0, job.targetPosition, 0, this._worldPose.translations, tipOffset); + if (vec3Length(this._scratchVectors, 0) <= job.precision) { + break; + } + + for (let chainIndex = job.chain.length - 2; chainIndex >= 0; chainIndex -= 1) { + const boneIndex = job.chain[chainIndex]!; + const boneTranslationOffset = boneIndex * 3; + const boneRotationOffset = boneIndex * 4; + vec3Subtract(this._scratchVectors, 0, this._worldPose.translations, tipOffset, this._worldPose.translations, boneTranslationOffset); + vec3Subtract(this._scratchVectors, 3, job.targetPosition, 0, this._worldPose.translations, boneTranslationOffset); + if (vec3Length(this._scratchVectors, 0) <= 1e-8 || vec3Length(this._scratchVectors, 3) <= 1e-8) { + continue; + } + + quatFromTo(this._scratchQuaternion, 0, this._scratchVectors, 0, this._scratchVectors, 3, this._scratchVectors); + quatMultiply( + this._scratchQuaternionB, + 0, + this._scratchQuaternion, + 0, + this._worldPose.rotations, + boneRotationOffset + ); + const parentIndex = this._rig.parentIndices[boneIndex]!; + if (parentIndex >= 0) { + quatInvert(this._scratchQuaternionC, 0, this._worldPose.rotations, parentIndex * 4); + quatMultiply(this._scratchQuaternionB, 0, this._scratchQuaternionC, 0, this._scratchQuaternionB, 0); + } + quatSlerp( + pose.rotations, + boneRotationOffset, + pose.rotations, + boneRotationOffset, + this._scratchQuaternionB, + 0, + weight + ); + quatNormalize(pose.rotations, boneRotationOffset, pose.rotations, boneRotationOffset); + this._worldPose.update(this._rig, pose); + } + } + } + + private _solveFabrik(job: AnimationCompiledIkJob, pose: AnimationPose, weight: number): void { + this._resolveTarget(job, pose); + this._worldPose.update(this._rig, pose); + const chainLength = job.chain.length; + const positions = new Float32Array(chainLength * 3); + const lengths = new Float32Array(Math.max(0, chainLength - 1)); + let totalLength = 0; + + for (let index = 0; index < chainLength; index += 1) { + const boneIndex = job.chain[index]!; + positions[index * 3] = this._worldPose.translations[boneIndex * 3]!; + positions[index * 3 + 1] = this._worldPose.translations[boneIndex * 3 + 1]!; + positions[index * 3 + 2] = this._worldPose.translations[boneIndex * 3 + 2]!; + if (index > 0) { + vec3Subtract(this._scratchVectors, 0, positions, index * 3, positions, (index - 1) * 3); + lengths[index - 1] = vec3Length(this._scratchVectors, 0); + totalLength += lengths[index - 1]!; + } + } + + const rootBaseX = positions[0]!; + const rootBaseY = positions[1]!; + const rootBaseZ = positions[2]!; + vec3Subtract(this._scratchVectors, 0, job.targetPosition, 0, positions, 0); + const rootDistance = vec3Length(this._scratchVectors, 0); + + if (rootDistance >= totalLength) { + vec3Normalize(this._scratchVectors, 0, this._scratchVectors, 0, 1, 0, 0); + for (let index = 1; index < chainLength; index += 1) { + positions[index * 3] = positions[(index - 1) * 3]! + this._scratchVectors[0]! * lengths[index - 1]!; + positions[index * 3 + 1] = positions[(index - 1) * 3 + 1]! + this._scratchVectors[1]! * lengths[index - 1]!; + positions[index * 3 + 2] = positions[(index - 1) * 3 + 2]! + this._scratchVectors[2]! * lengths[index - 1]!; + } + } else { + for (let iteration = 0; iteration < job.maxIterations; iteration += 1) { + positions[(chainLength - 1) * 3] = job.targetPosition[0]!; + positions[(chainLength - 1) * 3 + 1] = job.targetPosition[1]!; + positions[(chainLength - 1) * 3 + 2] = job.targetPosition[2]!; + + for (let index = chainLength - 2; index >= 0; index -= 1) { + vec3Subtract(this._scratchVectors, 0, positions, index * 3, positions, (index + 1) * 3); + vec3Normalize(this._scratchVectors, 0, this._scratchVectors, 0, 1, 0, 0); + positions[index * 3] = positions[(index + 1) * 3]! + this._scratchVectors[0]! * lengths[index]!; + positions[index * 3 + 1] = positions[(index + 1) * 3 + 1]! + this._scratchVectors[1]! * lengths[index]!; + positions[index * 3 + 2] = positions[(index + 1) * 3 + 2]! + this._scratchVectors[2]! * lengths[index]!; + } + + positions[0] = rootBaseX; + positions[1] = rootBaseY; + positions[2] = rootBaseZ; + for (let index = 1; index < chainLength; index += 1) { + vec3Subtract(this._scratchVectors, 0, positions, index * 3, positions, (index - 1) * 3); + vec3Normalize(this._scratchVectors, 0, this._scratchVectors, 0, 1, 0, 0); + positions[index * 3] = positions[(index - 1) * 3]! + this._scratchVectors[0]! * lengths[index - 1]!; + positions[index * 3 + 1] = positions[(index - 1) * 3 + 1]! + this._scratchVectors[1]! * lengths[index - 1]!; + positions[index * 3 + 2] = positions[(index - 1) * 3 + 2]! + this._scratchVectors[2]! * lengths[index - 1]!; + } + + vec3Subtract( + this._scratchVectors, + 0, + job.targetPosition, + 0, + positions, + (chainLength - 1) * 3 + ); + if (vec3Length(this._scratchVectors, 0) <= job.precision) { + break; + } + } + } + + for (let chainIndex = 0; chainIndex < chainLength - 1; chainIndex += 1) { + const boneIndex = job.chain[chainIndex]!; + const boneRotationOffset = boneIndex * 4; + const currentChildIndex = job.chain[chainIndex + 1]!; + vec3Subtract(this._scratchVectors, 0, this._worldPose.translations, currentChildIndex * 3, this._worldPose.translations, boneIndex * 3); + vec3Subtract(this._scratchVectors, 3, positions, (chainIndex + 1) * 3, positions, chainIndex * 3); + if (vec3Length(this._scratchVectors, 0) <= 1e-8 || vec3Length(this._scratchVectors, 3) <= 1e-8) { + continue; + } + quatFromTo(this._scratchQuaternion, 0, this._scratchVectors, 0, this._scratchVectors, 3, this._scratchVectors); + quatMultiply( + this._scratchQuaternionB, + 0, + this._scratchQuaternion, + 0, + this._worldPose.rotations, + boneRotationOffset + ); + const parentIndex = this._rig.parentIndices[boneIndex]!; + if (parentIndex >= 0) { + quatInvert(this._scratchQuaternionC, 0, this._worldPose.rotations, parentIndex * 4); + quatMultiply(this._scratchQuaternionB, 0, this._scratchQuaternionC, 0, this._scratchQuaternionB, 0); + } + quatSlerp( + pose.rotations, + boneRotationOffset, + pose.rotations, + boneRotationOffset, + this._scratchQuaternionB, + 0, + weight + ); + quatNormalize(pose.rotations, boneRotationOffset, pose.rotations, boneRotationOffset); + this._worldPose.update(this._rig, pose); + } + } + + private _applyTipRotation(job: AnimationCompiledIkJob, pose: AnimationPose, weight: number): void { + this._worldPose.update(this._rig, pose); + const tipRotationOffset = job.tipIndex * 4; + const parentIndex = this._rig.parentIndices[job.tipIndex]!; + quatCopy(this._scratchQuaternion, 0, job.targetRotation, 0); + if (parentIndex >= 0) { + quatInvert(this._scratchQuaternionB, 0, this._worldPose.rotations, parentIndex * 4); + quatMultiply(this._scratchQuaternion, 0, this._scratchQuaternionB, 0, this._scratchQuaternion, 0); + } + quatSlerp( + pose.rotations, + tipRotationOffset, + pose.rotations, + tipRotationOffset, + this._scratchQuaternion, + 0, + weight + ); + quatNormalize(pose.rotations, tipRotationOffset, pose.rotations, tipRotationOffset); + } +} \ No newline at end of file diff --git a/web/packages/animation/src/index.ts b/web/packages/animation/src/index.ts new file mode 100644 index 00000000..4ffaa720 --- /dev/null +++ b/web/packages/animation/src/index.ts @@ -0,0 +1,18 @@ +export * from './brands'; +export * from './blend-graph'; +export * from './clip'; +export * from './controller-graph'; +export * from './controller'; +export * from './errors'; +export * from './grounding'; +export * from './ik'; +export * from './motion-matching'; +export * from './optimization'; +export * from './pose'; +export * from './retargeting'; +export * from './rig'; +export * from './skinning'; +export * from './state-machine'; +export * from './streaming'; +export * from './streaming-chunk'; +export * from './types'; \ No newline at end of file diff --git a/web/packages/animation/src/math.ts b/web/packages/animation/src/math.ts new file mode 100644 index 00000000..f98dbfcc --- /dev/null +++ b/web/packages/animation/src/math.ts @@ -0,0 +1,671 @@ +import { clamp as numericClamp, Mat4, Quat, Vec3 } from '@axrone/numeric'; +import { ObjectPool } from '@axrone/memory'; + +export const ANIMATION_EPSILON = 1e-6; + +const createObjectPool = ( + name: string, + factory: () => T, + resetHandler: (value: T) => void +): ObjectPool => + new ObjectPool({ + initialCapacity: 16, + maxCapacity: 512, + minFree: 8, + expansionStrategy: 'multiplicative', + expansionFactor: 1.5, + allocationStrategy: 'least-recently-used', + evictionPolicy: 'lru', + resetOnRecycle: true, + preallocate: false, + autoExpand: true, + enableMetrics: false, + name, + factory, + resetHandler, + }); + +const setMat4Identity = (value: Mat4): void => { + const data = value.data as unknown as number[]; + data[0] = 1; + data[1] = 0; + data[2] = 0; + data[3] = 0; + data[4] = 0; + data[5] = 1; + data[6] = 0; + data[7] = 0; + data[8] = 0; + data[9] = 0; + data[10] = 1; + data[11] = 0; + data[12] = 0; + data[13] = 0; + data[14] = 0; + data[15] = 1; +}; + +const vec3Pool = createObjectPool('AnimationVec3Pool', () => new Vec3(), (value) => { + value.x = 0; + value.y = 0; + value.z = 0; +}); + +const quatPool = createObjectPool('AnimationQuatPool', () => new Quat(), (value) => { + value.x = 0; + value.y = 0; + value.z = 0; + value.w = 1; +}); + +const mat4Pool = createObjectPool('AnimationMat4Pool', () => new Mat4(), (value) => { + setMat4Identity(value); +}); + +const loadVec3 = (source: ArrayLike, offset: number, out: Vec3): Vec3 => { + out.x = Number(source[offset] ?? 0); + out.y = Number(source[offset + 1] ?? 0); + out.z = Number(source[offset + 2] ?? 0); + return out; +}; + +const writeVec3 = (target: Float32Array, offset: number, value: Readonly): void => { + target[offset] = value.x; + target[offset + 1] = value.y; + target[offset + 2] = value.z; +}; + +const loadQuat = (source: ArrayLike, offset: number, out: Quat): Quat => { + out.x = Number(source[offset] ?? 0); + out.y = Number(source[offset + 1] ?? 0); + out.z = Number(source[offset + 2] ?? 0); + out.w = Number(source[offset + 3] ?? 1); + return out; +}; + +const writeQuat = (target: Float32Array, offset: number, value: Readonly): void => { + target[offset] = value.x; + target[offset + 1] = value.y; + target[offset + 2] = value.z; + target[offset + 3] = value.w; +}; + +const loadMat4 = (source: ArrayLike, offset: number, out: Mat4): Mat4 => { + const data = out.data as unknown as number[]; + for (let index = 0; index < 16; index += 1) { + data[index] = Number(source[offset + index] ?? (index % 5 === 0 ? 1 : 0)); + } + return out; +}; + +const writeMat4 = (target: Float32Array, offset: number, value: Mat4): void => { + const data = value.data; + for (let index = 0; index < 16; index += 1) { + target[offset + index] = Number(data[index] ?? (index % 5 === 0 ? 1 : 0)); + } +}; + +export const clamp = (value: number, min: number, max: number): number => + numericClamp(value, min, max); + +export const toFloat32Array = (value: readonly number[] | Float32Array): Float32Array => + value instanceof Float32Array ? new Float32Array(value) : new Float32Array(value); + +export const vec3Set = ( + target: Float32Array, + offset: number, + x: number, + y: number, + z: number +): void => { + target[offset] = x; + target[offset + 1] = y; + target[offset + 2] = z; +}; + +export const vec3Copy = ( + target: Float32Array, + targetOffset: number, + source: ArrayLike, + sourceOffset: number +): void => { + target[targetOffset] = Number(source[sourceOffset] ?? 0); + target[targetOffset + 1] = Number(source[sourceOffset + 1] ?? 0); + target[targetOffset + 2] = Number(source[sourceOffset + 2] ?? 0); +}; + +export const vec3Add = ( + target: Float32Array, + targetOffset: number, + left: ArrayLike, + leftOffset: number, + right: ArrayLike, + rightOffset: number +): void => { + const leftVector = vec3Pool.acquire(); + const rightVector = vec3Pool.acquire(); + const resultVector = vec3Pool.acquire(); + try { + Vec3.add(loadVec3(left, leftOffset, leftVector), loadVec3(right, rightOffset, rightVector), resultVector); + writeVec3(target, targetOffset, resultVector); + } finally { + vec3Pool.release(resultVector); + vec3Pool.release(rightVector); + vec3Pool.release(leftVector); + } +}; + +export const vec3Subtract = ( + target: Float32Array, + targetOffset: number, + left: ArrayLike, + leftOffset: number, + right: ArrayLike, + rightOffset: number +): void => { + const leftVector = vec3Pool.acquire(); + const rightVector = vec3Pool.acquire(); + const resultVector = vec3Pool.acquire(); + try { + Vec3.subtract( + loadVec3(left, leftOffset, leftVector), + loadVec3(right, rightOffset, rightVector), + resultVector + ); + writeVec3(target, targetOffset, resultVector); + } finally { + vec3Pool.release(resultVector); + vec3Pool.release(rightVector); + vec3Pool.release(leftVector); + } +}; + +export const vec3Multiply = ( + target: Float32Array, + targetOffset: number, + left: ArrayLike, + leftOffset: number, + right: ArrayLike, + rightOffset: number +): void => { + const leftVector = vec3Pool.acquire(); + const rightVector = vec3Pool.acquire(); + const resultVector = vec3Pool.acquire(); + try { + Vec3.multiply( + loadVec3(left, leftOffset, leftVector), + loadVec3(right, rightOffset, rightVector), + resultVector + ); + writeVec3(target, targetOffset, resultVector); + } finally { + vec3Pool.release(resultVector); + vec3Pool.release(rightVector); + vec3Pool.release(leftVector); + } +}; + +export const vec3Scale = ( + target: Float32Array, + targetOffset: number, + source: ArrayLike, + sourceOffset: number, + scalar: number +): void => { + const sourceVector = vec3Pool.acquire(); + const resultVector = vec3Pool.acquire(); + try { + Vec3.multiplyScalar(loadVec3(source, sourceOffset, sourceVector), scalar, resultVector); + writeVec3(target, targetOffset, resultVector); + } finally { + vec3Pool.release(resultVector); + vec3Pool.release(sourceVector); + } +}; + +export const vec3Lerp = ( + target: Float32Array, + targetOffset: number, + left: ArrayLike, + leftOffset: number, + right: ArrayLike, + rightOffset: number, + alpha: number +): void => { + const leftVector = vec3Pool.acquire(); + const rightVector = vec3Pool.acquire(); + const resultVector = vec3Pool.acquire(); + try { + Vec3.lerp( + loadVec3(left, leftOffset, leftVector), + loadVec3(right, rightOffset, rightVector), + alpha, + resultVector + ); + writeVec3(target, targetOffset, resultVector); + } finally { + vec3Pool.release(resultVector); + vec3Pool.release(rightVector); + vec3Pool.release(leftVector); + } +}; + +export const vec3Dot = ( + left: ArrayLike, + leftOffset: number, + right: ArrayLike, + rightOffset: number +): number => { + const leftVector = vec3Pool.acquire(); + const rightVector = vec3Pool.acquire(); + try { + return Vec3.dot(loadVec3(left, leftOffset, leftVector), loadVec3(right, rightOffset, rightVector)); + } finally { + vec3Pool.release(rightVector); + vec3Pool.release(leftVector); + } +}; + +export const vec3Cross = ( + target: Float32Array, + targetOffset: number, + left: ArrayLike, + leftOffset: number, + right: ArrayLike, + rightOffset: number +): void => { + const leftVector = vec3Pool.acquire(); + const rightVector = vec3Pool.acquire(); + const resultVector = vec3Pool.acquire(); + try { + Vec3.cross( + loadVec3(left, leftOffset, leftVector), + loadVec3(right, rightOffset, rightVector), + resultVector + ); + writeVec3(target, targetOffset, resultVector); + } finally { + vec3Pool.release(resultVector); + vec3Pool.release(rightVector); + vec3Pool.release(leftVector); + } +}; + +export const vec3LengthSquared = (source: ArrayLike, offset: number): number => { + const sourceVector = vec3Pool.acquire(); + try { + return Vec3.lengthSquared(loadVec3(source, offset, sourceVector)); + } finally { + vec3Pool.release(sourceVector); + } +}; + +export const vec3Length = (source: ArrayLike, offset: number): number => { + const sourceVector = vec3Pool.acquire(); + try { + return Vec3.len(loadVec3(source, offset, sourceVector)); + } finally { + vec3Pool.release(sourceVector); + } +}; + +export const vec3Normalize = ( + target: Float32Array, + targetOffset: number, + source: ArrayLike, + sourceOffset: number, + fallbackX = 0, + fallbackY = 0, + fallbackZ = 0 +): void => { + const sourceVector = vec3Pool.acquire(); + const resultVector = vec3Pool.acquire(); + try { + loadVec3(source, sourceOffset, sourceVector); + if (Vec3.len(sourceVector) <= ANIMATION_EPSILON) { + resultVector.x = fallbackX; + resultVector.y = fallbackY; + resultVector.z = fallbackZ; + } else { + Vec3.normalize(sourceVector, resultVector); + } + writeVec3(target, targetOffset, resultVector); + } finally { + vec3Pool.release(resultVector); + vec3Pool.release(sourceVector); + } +}; + +export const quatIdentity = (target: Float32Array, offset: number): void => { + target[offset] = 0; + target[offset + 1] = 0; + target[offset + 2] = 0; + target[offset + 3] = 1; +}; + +export const quatCopy = ( + target: Float32Array, + targetOffset: number, + source: ArrayLike, + sourceOffset: number +): void => { + target[targetOffset] = Number(source[sourceOffset] ?? 0); + target[targetOffset + 1] = Number(source[sourceOffset + 1] ?? 0); + target[targetOffset + 2] = Number(source[sourceOffset + 2] ?? 0); + target[targetOffset + 3] = Number(source[sourceOffset + 3] ?? 1); +}; + +export const quatNormalize = ( + target: Float32Array, + targetOffset: number, + source: ArrayLike, + sourceOffset: number +): void => { + const sourceQuaternion = quatPool.acquire(); + const resultQuaternion = quatPool.acquire(); + try { + loadQuat(source, sourceOffset, sourceQuaternion); + if (Quat.lengthSquared(sourceQuaternion) <= ANIMATION_EPSILON) { + resultQuaternion.x = 0; + resultQuaternion.y = 0; + resultQuaternion.z = 0; + resultQuaternion.w = 1; + } else { + Quat.normalize(sourceQuaternion, resultQuaternion); + } + writeQuat(target, targetOffset, resultQuaternion); + } finally { + quatPool.release(resultQuaternion); + quatPool.release(sourceQuaternion); + } +}; + +export const quatDot = ( + left: ArrayLike, + leftOffset: number, + right: ArrayLike, + rightOffset: number +): number => { + const leftQuaternion = quatPool.acquire(); + const rightQuaternion = quatPool.acquire(); + try { + return Quat.dot(loadQuat(left, leftOffset, leftQuaternion), loadQuat(right, rightOffset, rightQuaternion)); + } finally { + quatPool.release(rightQuaternion); + quatPool.release(leftQuaternion); + } +}; + +export const quatMultiply = ( + target: Float32Array, + targetOffset: number, + left: ArrayLike, + leftOffset: number, + right: ArrayLike, + rightOffset: number +): void => { + const leftQuaternion = quatPool.acquire(); + const rightQuaternion = quatPool.acquire(); + const resultQuaternion = quatPool.acquire(); + try { + Quat.multiply( + loadQuat(left, leftOffset, leftQuaternion), + loadQuat(right, rightOffset, rightQuaternion), + resultQuaternion + ); + writeQuat(target, targetOffset, resultQuaternion); + } finally { + quatPool.release(resultQuaternion); + quatPool.release(rightQuaternion); + quatPool.release(leftQuaternion); + } +}; + +export const quatInvert = ( + target: Float32Array, + targetOffset: number, + source: ArrayLike, + sourceOffset: number +): void => { + const sourceQuaternion = quatPool.acquire(); + const resultQuaternion = quatPool.acquire(); + try { + loadQuat(source, sourceOffset, sourceQuaternion); + if (Quat.lengthSquared(sourceQuaternion) <= ANIMATION_EPSILON) { + resultQuaternion.x = 0; + resultQuaternion.y = 0; + resultQuaternion.z = 0; + resultQuaternion.w = 1; + } else { + Quat.inverse(sourceQuaternion, resultQuaternion); + } + writeQuat(target, targetOffset, resultQuaternion); + } finally { + quatPool.release(resultQuaternion); + quatPool.release(sourceQuaternion); + } +}; + +export const quatSlerp = ( + target: Float32Array, + targetOffset: number, + left: ArrayLike, + leftOffset: number, + right: ArrayLike, + rightOffset: number, + alpha: number +): void => { + const leftQuaternion = quatPool.acquire(); + const rightQuaternion = quatPool.acquire(); + const resultQuaternion = quatPool.acquire(); + try { + Quat.slerp( + loadQuat(left, leftOffset, leftQuaternion), + loadQuat(right, rightOffset, rightQuaternion), + alpha, + resultQuaternion + ); + if (Quat.lengthSquared(resultQuaternion) <= ANIMATION_EPSILON) { + resultQuaternion.x = 0; + resultQuaternion.y = 0; + resultQuaternion.z = 0; + resultQuaternion.w = 1; + } else { + Quat.normalize(resultQuaternion, resultQuaternion); + } + writeQuat(target, targetOffset, resultQuaternion); + } finally { + quatPool.release(resultQuaternion); + quatPool.release(rightQuaternion); + quatPool.release(leftQuaternion); + } +}; + +export const quatApplyToVec3 = ( + target: Float32Array, + targetOffset: number, + quaternion: ArrayLike, + quaternionOffset: number, + vector: ArrayLike, + vectorOffset: number +): void => { + const quaternionValue = quatPool.acquire(); + const vectorValue = vec3Pool.acquire(); + const resultVector = vec3Pool.acquire(); + try { + Quat.rotateVector( + loadQuat(quaternion, quaternionOffset, quaternionValue), + loadVec3(vector, vectorOffset, vectorValue), + resultVector + ); + writeVec3(target, targetOffset, resultVector); + } finally { + vec3Pool.release(resultVector); + vec3Pool.release(vectorValue); + quatPool.release(quaternionValue); + } +}; + +export const quatFromTo = ( + target: Float32Array, + targetOffset: number, + from: ArrayLike, + fromOffset: number, + to: ArrayLike, + toOffset: number, + scratch: Float32Array +): void => { + const fromVector = vec3Pool.acquire(); + const toVector = vec3Pool.acquire(); + const axisVector = vec3Pool.acquire(); + const resultQuaternion = quatPool.acquire(); + try { + loadVec3(from, fromOffset, fromVector); + loadVec3(to, toOffset, toVector); + if (Vec3.len(fromVector) <= ANIMATION_EPSILON || Vec3.len(toVector) <= ANIMATION_EPSILON) { + quatIdentity(target, targetOffset); + return; + } + + Vec3.normalize(fromVector, fromVector); + Vec3.normalize(toVector, toVector); + const dot = numericClamp(Vec3.dot(fromVector, toVector), -1, 1); + if (dot >= 1 - ANIMATION_EPSILON) { + quatIdentity(target, targetOffset); + return; + } + + if (dot <= -1 + ANIMATION_EPSILON) { + if (Math.abs(fromVector.x) > Math.abs(fromVector.z)) { + axisVector.x = -fromVector.y; + axisVector.y = fromVector.x; + axisVector.z = 0; + } else { + axisVector.x = 0; + axisVector.y = -fromVector.z; + axisVector.z = fromVector.y; + } + } else { + Vec3.cross(fromVector, toVector, axisVector); + } + + if (Vec3.len(axisVector) <= ANIMATION_EPSILON) { + axisVector.x = 1; + axisVector.y = 0; + axisVector.z = 0; + } else { + Vec3.normalize(axisVector, axisVector); + } + + Quat.fromAxisAngle(axisVector, dot <= -1 + ANIMATION_EPSILON ? Math.PI : Math.acos(dot), resultQuaternion); + Quat.normalize(resultQuaternion, resultQuaternion); + writeQuat(target, targetOffset, resultQuaternion); + + if (scratch.length >= 9) { + scratch[0] = fromVector.x; + scratch[1] = fromVector.y; + scratch[2] = fromVector.z; + scratch[3] = toVector.x; + scratch[4] = toVector.y; + scratch[5] = toVector.z; + scratch[6] = axisVector.x; + scratch[7] = axisVector.y; + scratch[8] = axisVector.z; + } + } finally { + quatPool.release(resultQuaternion); + vec3Pool.release(axisVector); + vec3Pool.release(toVector); + vec3Pool.release(fromVector); + } +}; + +export const composeMatrix = ( + target: Float32Array, + targetOffset: number, + translation: ArrayLike, + translationOffset: number, + rotation: ArrayLike, + rotationOffset: number, + scale: ArrayLike, + scaleOffset: number +): void => { + const translationVector = vec3Pool.acquire(); + const scaleVector = vec3Pool.acquire(); + const rotationQuaternion = quatPool.acquire(); + const translationMatrix = mat4Pool.acquire(); + const rotationMatrix = mat4Pool.acquire(); + const scaleMatrix = mat4Pool.acquire(); + const resultMatrix = mat4Pool.acquire(); + try { + loadVec3(translation, translationOffset, translationVector); + loadVec3(scale, scaleOffset, scaleVector); + loadQuat(rotation, rotationOffset, rotationQuaternion); + if (Quat.lengthSquared(rotationQuaternion) <= ANIMATION_EPSILON) { + rotationQuaternion.x = 0; + rotationQuaternion.y = 0; + rotationQuaternion.z = 0; + rotationQuaternion.w = 1; + } else { + Quat.normalize(rotationQuaternion, rotationQuaternion); + } + + Mat4.translate(translationVector, translationMatrix); + Mat4.fromQuaternion(rotationQuaternion, rotationMatrix); + Mat4.scale(scaleVector, scaleMatrix); + Mat4.multiply(translationMatrix, rotationMatrix, resultMatrix); + Mat4.multiply(resultMatrix, scaleMatrix, resultMatrix); + writeMat4(target, targetOffset, resultMatrix); + } finally { + mat4Pool.release(resultMatrix); + mat4Pool.release(scaleMatrix); + mat4Pool.release(rotationMatrix); + mat4Pool.release(translationMatrix); + quatPool.release(rotationQuaternion); + vec3Pool.release(scaleVector); + vec3Pool.release(translationVector); + } +}; + +export const mat4Multiply = ( + target: Float32Array, + targetOffset: number, + left: ArrayLike, + leftOffset: number, + right: ArrayLike, + rightOffset: number +): void => { + const leftMatrix = mat4Pool.acquire(); + const rightMatrix = mat4Pool.acquire(); + const resultMatrix = mat4Pool.acquire(); + try { + Mat4.multiply(loadMat4(left, leftOffset, leftMatrix), loadMat4(right, rightOffset, rightMatrix), resultMatrix); + writeMat4(target, targetOffset, resultMatrix); + } finally { + mat4Pool.release(resultMatrix); + mat4Pool.release(rightMatrix); + mat4Pool.release(leftMatrix); + } +}; + +export const mat4Invert = ( + target: Float32Array, + targetOffset: number, + source: ArrayLike, + sourceOffset: number +): boolean => { + const sourceMatrix = mat4Pool.acquire(); + const resultMatrix = mat4Pool.acquire(); + try { + try { + Mat4.invert(loadMat4(source, sourceOffset, sourceMatrix), resultMatrix); + } catch { + return false; + } + writeMat4(target, targetOffset, resultMatrix); + return true; + } finally { + mat4Pool.release(resultMatrix); + mat4Pool.release(sourceMatrix); + } +}; diff --git a/web/packages/animation/src/motion-matching.ts b/web/packages/animation/src/motion-matching.ts new file mode 100644 index 00000000..97247ebc --- /dev/null +++ b/web/packages/animation/src/motion-matching.ts @@ -0,0 +1,180 @@ +import { AnimationClip } from './clip'; +import type { + AnimationClipDefinition, + AnimationMotionFeatureDefinition, + AnimationMotionMatchQuery, + AnimationMotionMatchResult, +} from './types'; + +interface AnimationMotionMatchEntry { + readonly clipId: string; + readonly time: number; + readonly tags: readonly string[]; + readonly trajectoryPosition?: readonly [number, number, number]; + readonly facingDirection?: readonly [number, number, number]; + readonly costBias: number; +} + +const EMPTY_TAGS = Object.freeze([]) as readonly string[]; + +const normalizeTags = (value: readonly string[] | undefined): readonly string[] => + Object.freeze( + [...new Set((value ?? []).filter((entry): entry is string => typeof entry === 'string' && entry.length > 0))] + ); + +const readClipMetadata = ( + clip: AnimationClip | AnimationClipDefinition +): { + readonly id: string; + readonly tags: readonly string[]; + readonly features: readonly AnimationMotionFeatureDefinition[]; +} => + clip instanceof AnimationClip + ? { + id: clip.id, + tags: clip.tags, + features: clip.features, + } + : { + id: clip.id, + tags: normalizeTags(clip.tags), + features: Object.freeze([...(clip.features ?? [])]), + }; + +const squaredDistance3 = ( + left: readonly [number, number, number], + right: readonly [number, number, number] +): number => { + const dx = left[0] - right[0]; + const dy = left[1] - right[1]; + const dz = left[2] - right[2]; + return dx * dx + dy * dy + dz * dz; +}; + +const normalizedDot3 = ( + left: readonly [number, number, number], + right: readonly [number, number, number] +): number => { + const leftLength = Math.hypot(left[0], left[1], left[2]); + const rightLength = Math.hypot(right[0], right[1], right[2]); + if (leftLength <= Number.EPSILON || rightLength <= Number.EPSILON) { + return 1; + } + return ( + (left[0] * right[0] + left[1] * right[1] + left[2] * right[2]) / + (leftLength * rightLength) + ); +}; + +export class AnimationMotionMatchDatabase { + private readonly _entries: readonly AnimationMotionMatchEntry[]; + + constructor(clips: readonly (AnimationClip | AnimationClipDefinition)[]) { + this._entries = Object.freeze( + clips.flatMap((clip) => { + const metadata = readClipMetadata(clip); + if (metadata.features.length === 0) { + return [ + Object.freeze({ + clipId: metadata.id, + time: 0, + tags: metadata.tags, + costBias: 0, + } satisfies AnimationMotionMatchEntry), + ]; + } + + return metadata.features.map((feature) => + Object.freeze({ + clipId: metadata.id, + time: feature.time, + tags: normalizeTags([...(metadata.tags ?? EMPTY_TAGS), ...(feature.tags ?? EMPTY_TAGS)]), + ...(feature.trajectoryPosition + ? { + trajectoryPosition: Object.freeze([ + feature.trajectoryPosition[0], + feature.trajectoryPosition[1], + feature.trajectoryPosition[2], + ]) as readonly [number, number, number], + } + : {}), + ...(feature.facingDirection + ? { + facingDirection: Object.freeze([ + feature.facingDirection[0], + feature.facingDirection[1], + feature.facingDirection[2], + ]) as readonly [number, number, number], + } + : {}), + costBias: + typeof feature.costBias === 'number' && Number.isFinite(feature.costBias) + ? feature.costBias + : 0, + } satisfies AnimationMotionMatchEntry) + ); + }) + ); + } + + get size(): number { + return this._entries.length; + } + + query(query: AnimationMotionMatchQuery): readonly AnimationMotionMatchResult[] { + const requiredTags = new Set(normalizeTags(query.requiredTags)); + const excludedTags = new Set(normalizeTags(query.excludedTags)); + const continuityBias = + typeof query.continuityBias === 'number' && Number.isFinite(query.continuityBias) + ? query.continuityBias + : 0; + const maxResults = + typeof query.maxResults === 'number' && Number.isFinite(query.maxResults) + ? Math.max(1, Math.trunc(query.maxResults)) + : 1; + + return Object.freeze( + this._entries + .filter((entry) => { + for (const tag of requiredTags) { + if (entry.tags.includes(tag) === false) { + return false; + } + } + for (const tag of excludedTags) { + if (entry.tags.includes(tag)) { + return false; + } + } + return true; + }) + .map((entry) => { + let score = entry.costBias; + if (query.desiredTrajectoryPosition && entry.trajectoryPosition) { + score += squaredDistance3(query.desiredTrajectoryPosition, entry.trajectoryPosition); + } else if (query.desiredTrajectoryPosition) { + score += 10; + } + + if (query.desiredFacingDirection && entry.facingDirection) { + score += 1 - normalizedDot3(query.desiredFacingDirection, entry.facingDirection); + } else if (query.desiredFacingDirection) { + score += 5; + } + + if (query.currentClipId && query.currentClipId === entry.clipId) { + score -= continuityBias; + } + + return Object.freeze({ + clipId: entry.clipId, + time: entry.time, + score, + tags: entry.tags, + } satisfies AnimationMotionMatchResult); + }) + .sort((left, right) => left.score - right.score) + .slice(0, maxResults) + ); + } +} \ No newline at end of file diff --git a/web/packages/animation/src/optimization.ts b/web/packages/animation/src/optimization.ts new file mode 100644 index 00000000..fe3b9ee8 --- /dev/null +++ b/web/packages/animation/src/optimization.ts @@ -0,0 +1,178 @@ +import { AnimationValidationError } from './errors'; +import type { + AnimationClipCompressionDefinition, + AnimationClipDefinition, + AnimationTrackDefinition, +} from './types'; + +const getTrackComponentCount = (track: AnimationTrackDefinition): number => { + if (typeof track.valueComponentCount === 'number' && Number.isFinite(track.valueComponentCount)) { + return track.valueComponentCount; + } + switch (track.path) { + case 'translation': + case 'scale': + return 3; + case 'rotation': + return 4; + case 'weights': { + const keyframeCount = track.keyframeCount ?? track.times.length; + if (keyframeCount <= 0) { + return 0; + } + return Math.max(1, Math.trunc(track.values.length / keyframeCount)); + } + default: + return 0; + } +}; + +const getTolerance = ( + track: AnimationTrackDefinition, + compression: AnimationClipCompressionDefinition +): number => { + switch (track.path) { + case 'translation': + return compression.positionTolerance ?? 1e-4; + case 'rotation': + return (compression.rotationToleranceDegrees ?? 0.25) * (Math.PI / 180); + case 'scale': + return compression.scaleTolerance ?? 1e-4; + case 'weights': + return compression.curveTolerance ?? 1e-4; + default: + return 1e-4; + } +}; + +const canRemoveLinearKeyframe = ( + track: AnimationTrackDefinition, + index: number, + componentCount: number, + tolerance: number, + times: readonly number[], + values: readonly number[] +): boolean => { + if (track.interpolation === 'STEP') { + return false; + } + if (track.interpolation === 'CUBICSPLINE') { + return false; + } + + const prevTime = times[index - 1] ?? 0; + const currentTime = times[index] ?? 0; + const nextTime = times[index + 1] ?? currentTime; + const span = nextTime - prevTime; + if (span <= Number.EPSILON) { + return false; + } + + const alpha = (currentTime - prevTime) / span; + const prevOffset = (index - 1) * componentCount; + const currentOffset = index * componentCount; + const nextOffset = (index + 1) * componentCount; + + for (let componentIndex = 0; componentIndex < componentCount; componentIndex += 1) { + const prevValue = values[prevOffset + componentIndex] ?? 0; + const currentValue = values[currentOffset + componentIndex] ?? 0; + const nextValue = values[nextOffset + componentIndex] ?? 0; + const predicted = prevValue + (nextValue - prevValue) * alpha; + if (track.path === 'rotation') { + if (Math.abs(predicted - currentValue) > tolerance) { + return false; + } + continue; + } + if (Math.abs(predicted - currentValue) > tolerance) { + return false; + } + } + + return true; +}; + +const optimizeTrack = ( + track: AnimationTrackDefinition, + compression: AnimationClipCompressionDefinition +): AnimationTrackDefinition => { + const keyframeCount = track.keyframeCount ?? track.times.length; + if (keyframeCount <= 2 || compression.codec === 'none') { + return track; + } + + const componentCount = getTrackComponentCount(track); + if (componentCount <= 0) { + throw new AnimationValidationError(`Animation track '${track.target}/${track.path}' has invalid component count`); + } + + const times = [...track.times]; + const values = [...track.values]; + const tolerance = getTolerance(track, compression); + const keep = new Array(keyframeCount).fill(true); + + for (let index = 1; index < keyframeCount - 1; index += 1) { + if (!canRemoveLinearKeyframe(track, index, componentCount, tolerance, times, values)) { + continue; + } + keep[index] = false; + } + + const optimizedTimes: number[] = []; + const optimizedValues: number[] = []; + for (let index = 0; index < keyframeCount; index += 1) { + if (!keep[index]) { + continue; + } + optimizedTimes.push(times[index]!); + const valueOffset = index * componentCount; + for (let componentIndex = 0; componentIndex < componentCount; componentIndex += 1) { + optimizedValues.push(values[valueOffset + componentIndex] ?? 0); + } + } + + return Object.freeze({ + ...track, + keyframeCount: optimizedTimes.length, + valueComponentCount: componentCount, + sampleStride: componentCount, + times: new Float32Array(optimizedTimes), + values: new Float32Array(optimizedValues), + } satisfies AnimationTrackDefinition); +}; + +export const optimizeAnimationClipDefinition = ( + clip: AnimationClipDefinition, + compression: AnimationClipCompressionDefinition = clip.compression ?? { codec: 'keyframe-reduced' } +): AnimationClipDefinition => + Object.freeze({ + ...clip, + compression: Object.freeze({ + codec: compression.codec ?? 'keyframe-reduced', + ...(compression.positionTolerance !== undefined + ? { positionTolerance: compression.positionTolerance } + : {}), + ...(compression.rotationToleranceDegrees !== undefined + ? { rotationToleranceDegrees: compression.rotationToleranceDegrees } + : {}), + ...(compression.scaleTolerance !== undefined ? { scaleTolerance: compression.scaleTolerance } : {}), + ...(compression.curveTolerance !== undefined ? { curveTolerance: compression.curveTolerance } : {}), + ...(compression.preserveStepTracks !== undefined + ? { preserveStepTracks: compression.preserveStepTracks } + : {}), + }), + tracks: Object.freeze(clip.tracks.map((track) => optimizeTrack(track, compression))), + }); + +export const optimizeAnimationClipDefinitions = ( + clips: readonly AnimationClipDefinition[], + compression?: AnimationClipCompressionDefinition +): readonly AnimationClipDefinition[] => + Object.freeze( + clips.map((clip) => + optimizeAnimationClipDefinition( + clip, + compression ?? clip.compression ?? { codec: 'keyframe-reduced' } + ) + ) + ); \ No newline at end of file diff --git a/web/packages/animation/src/parameters.ts b/web/packages/animation/src/parameters.ts new file mode 100644 index 00000000..6a73f010 --- /dev/null +++ b/web/packages/animation/src/parameters.ts @@ -0,0 +1,198 @@ +import { AnimationStateMachineError, AnimationValidationError } from './errors'; +import type { + AnimationParameterDefinition, + AnimationParameterKind, + AnimationParameterMap, + AnimationParameterValue, +} from './types'; + +const enum AnimationParameterKindId { + Float = 0, + Int = 1, + Bool = 2, + Trigger = 3, +} + +const resolveParameterKindId = (kind: AnimationParameterKind): AnimationParameterKindId => { + switch (kind) { + case 'float': + return AnimationParameterKindId.Float; + case 'int': + return AnimationParameterKindId.Int; + case 'bool': + return AnimationParameterKindId.Bool; + case 'trigger': + return AnimationParameterKindId.Trigger; + default: + throw new AnimationValidationError(`Unsupported animation parameter kind '${String(kind)}'`); + } +}; + +export class AnimationParameterStore< + TDefinitions extends readonly AnimationParameterDefinition[] = readonly AnimationParameterDefinition[], +> { + private readonly _definitions: readonly AnimationParameterDefinition[]; + private readonly _indexByName = new Map(); + private readonly _kinds: Uint8Array; + private readonly _numbers: Float64Array; + private readonly _booleans: Uint8Array; + + constructor(definitions: TDefinitions = [] as unknown as TDefinitions) { + this._definitions = Object.freeze( + definitions.map((definition) => { + if (!definition || typeof definition.name !== 'string' || definition.name.length === 0) { + throw new AnimationValidationError('Animation parameters require a non-empty name'); + } + return Object.freeze({ + name: definition.name, + kind: definition.kind, + defaultValue: definition.defaultValue, + }); + }) + ); + this._kinds = new Uint8Array(this._definitions.length); + this._numbers = new Float64Array(this._definitions.length); + this._booleans = new Uint8Array(this._definitions.length); + + for (let index = 0; index < this._definitions.length; index += 1) { + const definition = this._definitions[index]!; + if (this._indexByName.has(definition.name)) { + throw new AnimationValidationError( + `Duplicate animation parameter '${definition.name}'` + ); + } + + this._indexByName.set(definition.name, index); + const kindId = resolveParameterKindId(definition.kind); + this._kinds[index] = kindId; + switch (kindId) { + case AnimationParameterKindId.Float: + case AnimationParameterKindId.Int: + this._numbers[index] = + typeof definition.defaultValue === 'number' && Number.isFinite(definition.defaultValue) + ? definition.defaultValue + : 0; + break; + case AnimationParameterKindId.Bool: + case AnimationParameterKindId.Trigger: + this._booleans[index] = definition.defaultValue === true ? 1 : 0; + break; + } + } + } + + get definitions(): readonly AnimationParameterDefinition[] { + return this._definitions; + } + + has(name: string): boolean { + return this._indexByName.has(name); + } + + getKind(name: string): AnimationParameterKind { + const index = this._getIndex(name); + return this._definitions[index]!.kind; + } + + get(name: TName): AnimationParameterValue { + const index = this._getIndex(name); + switch (this._kinds[index]) { + case AnimationParameterKindId.Float: + case AnimationParameterKindId.Int: + return this._numbers[index] as AnimationParameterValue; + case AnimationParameterKindId.Bool: + case AnimationParameterKindId.Trigger: + return Boolean(this._booleans[index]) as AnimationParameterValue; + default: + throw new AnimationStateMachineError(`Unsupported parameter runtime kind for '${name}'`); + } + } + + set(name: string, value: AnimationParameterValue): this { + const index = this._getIndex(name); + switch (this._kinds[index]) { + case AnimationParameterKindId.Float: + this._numbers[index] = typeof value === 'number' && Number.isFinite(value) ? value : 0; + return this; + case AnimationParameterKindId.Int: + this._numbers[index] = typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : 0; + return this; + case AnimationParameterKindId.Bool: + case AnimationParameterKindId.Trigger: + this._booleans[index] = value === true ? 1 : 0; + return this; + default: + throw new AnimationStateMachineError(`Unsupported parameter runtime kind for '${name}'`); + } + } + + setFloat(name: string, value: number): this { + return this.set(name, value); + } + + setInt(name: string, value: number): this { + return this.set(name, value); + } + + setBool(name: string, value: boolean): this { + return this.set(name, value); + } + + setTrigger(name: string): this { + return this.set(name, true); + } + + resetTrigger(name: string): this { + const index = this._getIndex(name); + if (this._kinds[index] !== AnimationParameterKindId.Trigger) { + throw new AnimationStateMachineError(`Parameter '${name}' is not a trigger`); + } + this._booleans[index] = 0; + return this; + } + + consumeTrigger(name: string): boolean { + const index = this._getIndex(name); + if (this._kinds[index] !== AnimationParameterKindId.Trigger) { + throw new AnimationStateMachineError(`Parameter '${name}' is not a trigger`); + } + const active = this._booleans[index] === 1; + this._booleans[index] = 0; + return active; + } + + clearTriggers(): this { + for (let index = 0; index < this._definitions.length; index += 1) { + if (this._kinds[index] === AnimationParameterKindId.Trigger) { + this._booleans[index] = 0; + } + } + return this; + } + + copyFrom(other: AnimationParameterStore): this { + if (other._definitions.length !== this._definitions.length) { + throw new AnimationStateMachineError('Cannot copy animation parameters with different layouts'); + } + + this._numbers.set(other._numbers); + this._booleans.set(other._booleans); + return this; + } + + snapshot(): AnimationParameterMap { + const snapshot: Record = {}; + for (let index = 0; index < this._definitions.length; index += 1) { + snapshot[this._definitions[index]!.name] = this.get(this._definitions[index]!.name); + } + return snapshot as AnimationParameterMap; + } + + private _getIndex(name: string): number { + const index = this._indexByName.get(name); + if (index === undefined) { + throw new AnimationStateMachineError(`Unknown animation parameter '${name}'`); + } + return index; + } +} \ No newline at end of file diff --git a/web/packages/animation/src/pose.ts b/web/packages/animation/src/pose.ts new file mode 100644 index 00000000..319a8068 --- /dev/null +++ b/web/packages/animation/src/pose.ts @@ -0,0 +1,484 @@ +import { AnimationValidationError } from './errors'; +import { clamp, quatApplyToVec3, quatCopy, quatDot, quatIdentity, quatInvert, quatMultiply, quatNormalize, quatSlerp, vec3Add, vec3Copy, vec3Lerp, vec3Multiply } from './math'; +import type { AnimationCurveBindingDefinition } from './types'; +import type { AnimationRig } from './rig'; + +export interface AnimationCurveBinding { + readonly id: string; + readonly componentCount: number; + readonly offset: number; +} + +export class AnimationCurveLayout { + readonly bindings: readonly AnimationCurveBinding[]; + readonly componentCount: number; + + private readonly _bindingById = new Map(); + + constructor(definitions: readonly AnimationCurveBindingDefinition[] = []) { + let offset = 0; + const bindings: AnimationCurveBinding[] = []; + for (let index = 0; index < definitions.length; index += 1) { + const definition = definitions[index]!; + if (!definition || typeof definition.id !== 'string' || definition.id.length === 0) { + throw new AnimationValidationError('Animation curves require a non-empty id'); + } + if (!Number.isInteger(definition.componentCount) || definition.componentCount <= 0) { + throw new AnimationValidationError( + `Animation curve '${definition.id}' requires a positive componentCount` + ); + } + if (this._bindingById.has(definition.id)) { + throw new AnimationValidationError(`Duplicate animation curve '${definition.id}'`); + } + const binding = Object.freeze({ + id: definition.id, + componentCount: definition.componentCount, + offset, + }); + bindings.push(binding); + this._bindingById.set(binding.id, binding); + offset += binding.componentCount; + } + this.bindings = Object.freeze(bindings); + this.componentCount = offset; + } + + has(id: string): boolean { + return this._bindingById.has(id); + } + + get(id: string): AnimationCurveBinding | undefined { + return this._bindingById.get(id); + } +} + +export class AnimationCurveStore { + readonly values: Float32Array; + + constructor( + readonly layout: AnimationCurveLayout, + initialValues?: ArrayLike + ) { + this.values = new Float32Array(layout.componentCount); + if (initialValues) { + this.values.set(Array.from(initialValues).slice(0, layout.componentCount)); + } + } + + reset(defaultValues?: ArrayLike): this { + this.values.fill(0); + if (defaultValues) { + this.values.set(Array.from(defaultValues).slice(0, this.values.length)); + } + return this; + } + + copyFrom(other: AnimationCurveStore): this { + if (other.values.length !== this.values.length) { + throw new AnimationValidationError('Animation curve layouts are incompatible'); + } + this.values.set(other.values); + return this; + } + + read(id: string): Float32Array | null { + const binding = this.layout.get(id); + if (!binding) { + return null; + } + return this.values.subarray(binding.offset, binding.offset + binding.componentCount); + } + + write(id: string, value: ArrayLike): this { + const binding = this.layout.get(id); + if (!binding) { + throw new AnimationValidationError(`Unknown animation curve '${id}'`); + } + for (let componentIndex = 0; componentIndex < binding.componentCount; componentIndex += 1) { + this.values[binding.offset + componentIndex] = Number(value[componentIndex] ?? 0); + } + return this; + } +} + +export class AnimationPose { + readonly translations: Float32Array; + readonly rotations: Float32Array; + readonly scales: Float32Array; + + constructor(readonly boneCount: number) { + this.translations = new Float32Array(boneCount * 3); + this.rotations = new Float32Array(boneCount * 4); + this.scales = new Float32Array(boneCount * 3); + } + + copyFrom(other: AnimationPose): this { + if (other.boneCount !== this.boneCount) { + throw new AnimationValidationError('Animation poses have different bone counts'); + } + this.translations.set(other.translations); + this.rotations.set(other.rotations); + this.scales.set(other.scales); + return this; + } + + reset(rig: AnimationRig): this { + this.translations.set(rig.restTranslations); + this.rotations.set(rig.restRotations); + this.scales.set(rig.restScales); + return this; + } +} + +export class AnimationFrame { + readonly pose: AnimationPose; + readonly curves: AnimationCurveStore; + + constructor(rig: AnimationRig, curveLayout: AnimationCurveLayout) { + this.pose = new AnimationPose(rig.boneCount).reset(rig); + this.curves = new AnimationCurveStore(curveLayout); + } + + reset(rig: AnimationRig, curveDefaults?: ArrayLike): this { + this.pose.reset(rig); + this.curves.reset(curveDefaults); + return this; + } + + copyFrom(other: AnimationFrame): this { + this.pose.copyFrom(other.pose); + this.curves.copyFrom(other.curves); + return this; + } +} + +export class AnimationMask { + private readonly _bits: Uint32Array; + + constructor(readonly boneCount: number, fill: boolean = false) { + this._bits = new Uint32Array(Math.max(1, Math.ceil(boneCount / 32))); + if (fill) { + this.fill(true); + } + } + + has(index: number): boolean { + const bucket = index >> 5; + const bit = index & 31; + return (this._bits[bucket] & (1 << bit)) !== 0; + } + + set(index: number, enabled: boolean): this { + const bucket = index >> 5; + const bit = index & 31; + if (enabled) { + this._bits[bucket] |= 1 << bit; + } else { + this._bits[bucket] &= ~(1 << bit); + } + return this; + } + + fill(enabled: boolean): this { + this._bits.fill(enabled ? 0xffffffff : 0); + return this; + } +} + +export class AnimationWorldPose { + readonly translations: Float32Array; + readonly rotations: Float32Array; + readonly scales: Float32Array; + + private readonly _scratchVector = new Float32Array(3); + + constructor(readonly boneCount: number) { + this.translations = new Float32Array(boneCount * 3); + this.rotations = new Float32Array(boneCount * 4); + this.scales = new Float32Array(boneCount * 3); + } + + update(rig: AnimationRig, pose: AnimationPose): this { + for (let orderIndex = 0; orderIndex < rig.evaluationOrder.length; orderIndex += 1) { + const boneIndex = rig.evaluationOrder[orderIndex]!; + const localTranslationOffset = boneIndex * 3; + const localRotationOffset = boneIndex * 4; + const parentIndex = rig.parentIndices[boneIndex]!; + if (parentIndex < 0) { + this.translations.set( + pose.translations.subarray(localTranslationOffset, localTranslationOffset + 3), + localTranslationOffset + ); + this.rotations.set( + pose.rotations.subarray(localRotationOffset, localRotationOffset + 4), + localRotationOffset + ); + this.scales.set( + pose.scales.subarray(localTranslationOffset, localTranslationOffset + 3), + localTranslationOffset + ); + continue; + } + + const parentTranslationOffset = parentIndex * 3; + const parentRotationOffset = parentIndex * 4; + vec3Multiply( + this._scratchVector, + 0, + pose.translations, + localTranslationOffset, + this.scales, + parentTranslationOffset + ); + quatApplyToVec3( + this._scratchVector, + 0, + this.rotations, + parentRotationOffset, + this._scratchVector, + 0 + ); + vec3Add( + this.translations, + localTranslationOffset, + this.translations, + parentTranslationOffset, + this._scratchVector, + 0 + ); + quatMultiply( + this.rotations, + localRotationOffset, + this.rotations, + parentRotationOffset, + pose.rotations, + localRotationOffset + ); + quatNormalize(this.rotations, localRotationOffset, this.rotations, localRotationOffset); + vec3Multiply( + this.scales, + localTranslationOffset, + this.scales, + parentTranslationOffset, + pose.scales, + localTranslationOffset + ); + } + + return this; + } +} + +export const blendFrame = ( + target: AnimationFrame, + base: AnimationFrame, + overlay: AnimationFrame, + weight: number, + mask?: AnimationMask +): AnimationFrame => { + const alpha = clamp(weight, 0, 1); + if (alpha <= 0) { + return target.copyFrom(base); + } + if (alpha >= 1 && !mask) { + return target.copyFrom(overlay); + } + + target.copyFrom(base); + for (let boneIndex = 0; boneIndex < target.pose.boneCount; boneIndex += 1) { + if (mask && !mask.has(boneIndex)) { + continue; + } + const translationOffset = boneIndex * 3; + const rotationOffset = boneIndex * 4; + vec3Lerp( + target.pose.translations, + translationOffset, + base.pose.translations, + translationOffset, + overlay.pose.translations, + translationOffset, + alpha + ); + quatSlerp( + target.pose.rotations, + rotationOffset, + base.pose.rotations, + rotationOffset, + overlay.pose.rotations, + rotationOffset, + alpha + ); + vec3Lerp( + target.pose.scales, + translationOffset, + base.pose.scales, + translationOffset, + overlay.pose.scales, + translationOffset, + alpha + ); + } + for (let index = 0; index < target.curves.values.length; index += 1) { + target.curves.values[index] = + base.curves.values[index]! + + (overlay.curves.values[index]! - base.curves.values[index]!) * alpha; + } + return target; +}; + +export const blendWeightedFrames = ( + target: AnimationFrame, + frames: readonly AnimationFrame[], + weights: readonly number[], + restFrame: AnimationFrame, + mask?: AnimationMask +): AnimationFrame => { + target.copyFrom(restFrame); + const boneCount = target.pose.boneCount; + for (let boneIndex = 0; boneIndex < boneCount; boneIndex += 1) { + if (mask && !mask.has(boneIndex)) { + continue; + } + const translationOffset = boneIndex * 3; + const rotationOffset = boneIndex * 4; + let totalWeight = 0; + let tx = 0; + let ty = 0; + let tz = 0; + let sx = 0; + let sy = 0; + let sz = 0; + let qx = 0; + let qy = 0; + let qz = 0; + let qw = 0; + let referenceIndex = -1; + + for (let frameIndex = 0; frameIndex < frames.length; frameIndex += 1) { + const frame = frames[frameIndex]!; + const weight = Math.max(0, weights[frameIndex] ?? 0); + if (weight <= 0) { + continue; + } + totalWeight += weight; + tx += frame.pose.translations[translationOffset]! * weight; + ty += frame.pose.translations[translationOffset + 1]! * weight; + tz += frame.pose.translations[translationOffset + 2]! * weight; + sx += frame.pose.scales[translationOffset]! * weight; + sy += frame.pose.scales[translationOffset + 1]! * weight; + sz += frame.pose.scales[translationOffset + 2]! * weight; + const sign = + referenceIndex >= 0 && + quatDot( + frames[referenceIndex]!.pose.rotations, + rotationOffset, + frame.pose.rotations, + rotationOffset + ) < 0 + ? -1 + : 1; + qx += frame.pose.rotations[rotationOffset]! * weight * sign; + qy += frame.pose.rotations[rotationOffset + 1]! * weight * sign; + qz += frame.pose.rotations[rotationOffset + 2]! * weight * sign; + qw += frame.pose.rotations[rotationOffset + 3]! * weight * sign; + if (referenceIndex < 0) { + referenceIndex = frameIndex; + } + } + + if (totalWeight <= 0) { + continue; + } + + const inverseWeight = 1 / totalWeight; + target.pose.translations[translationOffset] = tx * inverseWeight; + target.pose.translations[translationOffset + 1] = ty * inverseWeight; + target.pose.translations[translationOffset + 2] = tz * inverseWeight; + target.pose.scales[translationOffset] = sx * inverseWeight; + target.pose.scales[translationOffset + 1] = sy * inverseWeight; + target.pose.scales[translationOffset + 2] = sz * inverseWeight; + target.pose.rotations[rotationOffset] = qx * inverseWeight; + target.pose.rotations[rotationOffset + 1] = qy * inverseWeight; + target.pose.rotations[rotationOffset + 2] = qz * inverseWeight; + target.pose.rotations[rotationOffset + 3] = qw * inverseWeight; + quatNormalize(target.pose.rotations, rotationOffset, target.pose.rotations, rotationOffset); + } + + for (let curveIndex = 0; curveIndex < target.curves.values.length; curveIndex += 1) { + let totalWeight = 0; + let accumulated = 0; + for (let frameIndex = 0; frameIndex < frames.length; frameIndex += 1) { + const weight = Math.max(0, weights[frameIndex] ?? 0); + if (weight <= 0) { + continue; + } + totalWeight += weight; + accumulated += frames[frameIndex]!.curves.values[curveIndex]! * weight; + } + target.curves.values[curveIndex] = totalWeight > 0 ? accumulated / totalWeight : 0; + } + return target; +}; + +export const applyAdditiveFrame = ( + target: AnimationFrame, + base: AnimationFrame, + additive: AnimationFrame, + restFrame: AnimationFrame, + weight: number, + mask?: AnimationMask +): AnimationFrame => { + const alpha = clamp(weight, 0, 1); + target.copyFrom(base); + if (alpha <= 0) { + return target; + } + const inverseRest = new Float32Array(4); + const deltaRotation = new Float32Array(4); + const scaledRotation = new Float32Array(4); + + for (let boneIndex = 0; boneIndex < target.pose.boneCount; boneIndex += 1) { + if (mask && !mask.has(boneIndex)) { + continue; + } + const translationOffset = boneIndex * 3; + const rotationOffset = boneIndex * 4; + target.pose.translations[translationOffset] += + (additive.pose.translations[translationOffset]! - restFrame.pose.translations[translationOffset]!) * alpha; + target.pose.translations[translationOffset + 1] += + (additive.pose.translations[translationOffset + 1]! - + restFrame.pose.translations[translationOffset + 1]!) * + alpha; + target.pose.translations[translationOffset + 2] += + (additive.pose.translations[translationOffset + 2]! - + restFrame.pose.translations[translationOffset + 2]!) * + alpha; + target.pose.scales[translationOffset] += + (additive.pose.scales[translationOffset]! - restFrame.pose.scales[translationOffset]!) * alpha; + target.pose.scales[translationOffset + 1] += + (additive.pose.scales[translationOffset + 1]! - restFrame.pose.scales[translationOffset + 1]!) * alpha; + target.pose.scales[translationOffset + 2] += + (additive.pose.scales[translationOffset + 2]! - restFrame.pose.scales[translationOffset + 2]!) * alpha; + quatIdentity(scaledRotation, 0); + quatInvert(inverseRest, 0, restFrame.pose.rotations, rotationOffset); + quatMultiply(deltaRotation, 0, inverseRest, 0, additive.pose.rotations, rotationOffset); + quatSlerp(scaledRotation, 0, scaledRotation, 0, deltaRotation, 0, alpha); + quatMultiply( + target.pose.rotations, + rotationOffset, + base.pose.rotations, + rotationOffset, + scaledRotation, + 0 + ); + quatNormalize(target.pose.rotations, rotationOffset, target.pose.rotations, rotationOffset); + } + + for (let curveIndex = 0; curveIndex < target.curves.values.length; curveIndex += 1) { + target.curves.values[curveIndex] += + (additive.curves.values[curveIndex]! - restFrame.curves.values[curveIndex]!) * alpha; + } + return target; +}; \ No newline at end of file diff --git a/web/packages/animation/src/retargeting.ts b/web/packages/animation/src/retargeting.ts new file mode 100644 index 00000000..61ab7014 --- /dev/null +++ b/web/packages/animation/src/retargeting.ts @@ -0,0 +1,159 @@ +import { AnimationRetargetingError, AnimationValidationError } from './errors'; +import { quatCopy, quatInvert, quatMultiply, quatNormalize, vec3Copy } from './math'; +import { AnimationFrame, type AnimationCurveLayout } from './pose'; +import { AnimationRig } from './rig'; +import type { + AnimationRetargetBoneMappingDefinition, + AnimationRetargetProfileDefinition, + AnimationRetargetRotationMode, + AnimationRetargetTranslationMode, +} from './types'; + +interface AnimationCompiledRetargetMapping { + readonly sourceIndex: number; + readonly targetIndex: number; + readonly translationMode: AnimationRetargetTranslationMode; + readonly rotationMode: AnimationRetargetRotationMode; + readonly translationScale: number; + readonly rotationOffset: Float32Array; +} + +const createAutomaticMappings = ( + sourceRig: AnimationRig, + targetRig: AnimationRig +): readonly AnimationRetargetBoneMappingDefinition[] => + Object.freeze( + sourceRig.boneNames + .filter((name) => targetRig.hasBone(name)) + .map((name) => Object.freeze({ sourceBone: name, targetBone: name })) + ); + +export class AnimationRetargeter { + readonly sourceRig: AnimationRig; + readonly targetRig: AnimationRig; + readonly mappings: readonly AnimationCompiledRetargetMapping[]; + private readonly _scratchInverse = new Float32Array(4); + + constructor(definition: AnimationRetargetProfileDefinition) { + this.sourceRig = new AnimationRig(definition.sourceRig); + this.targetRig = new AnimationRig(definition.targetRig); + const mappings = definition.mappings ?? createAutomaticMappings(this.sourceRig, this.targetRig); + if (mappings.length === 0) { + throw new AnimationValidationError('Animation retargeting requires at least one mapping'); + } + this.mappings = Object.freeze( + mappings.map((mapping) => { + const sourceIndex = this.sourceRig.indexOfBone(mapping.sourceBone); + const targetIndex = this.targetRig.indexOfBone(mapping.targetBone); + const sourceRotationOffset = sourceIndex * 4; + const targetRotationOffset = targetIndex * 4; + const inverse = new Float32Array(4); + const rotationOffset = new Float32Array(4); + quatInvert(inverse, 0, this.sourceRig.restRotations, sourceRotationOffset); + quatMultiply( + rotationOffset, + 0, + this.targetRig.restRotations, + targetRotationOffset, + inverse, + 0 + ); + quatNormalize(rotationOffset, 0, rotationOffset, 0); + return Object.freeze({ + sourceIndex, + targetIndex, + translationMode: mapping.translationMode ?? 'scaled', + rotationMode: mapping.rotationMode ?? 'offset', + translationScale: + typeof mapping.scaleTranslation === 'number' && Number.isFinite(mapping.scaleTranslation) + ? mapping.scaleTranslation + : 1, + rotationOffset, + }); + }) + ); + } + + retargetPose(sourceFrame: AnimationFrame, out?: AnimationFrame): AnimationFrame { + const targetFrame = out ?? new AnimationFrame(this.targetRig, sourceFrame.curves.layout as AnimationCurveLayout); + targetFrame.reset(this.targetRig, sourceFrame.curves.values); + targetFrame.curves.copyFrom(sourceFrame.curves); + for (let index = 0; index < this.mappings.length; index += 1) { + const mapping = this.mappings[index]!; + const sourceTranslationOffset = mapping.sourceIndex * 3; + const sourceRotationOffset = mapping.sourceIndex * 4; + const targetTranslationOffset = mapping.targetIndex * 3; + const targetRotationOffset = mapping.targetIndex * 4; + switch (mapping.translationMode) { + case 'none': + vec3Copy( + targetFrame.pose.translations, + targetTranslationOffset, + this.targetRig.restTranslations, + targetTranslationOffset + ); + break; + case 'absolute': + vec3Copy( + targetFrame.pose.translations, + targetTranslationOffset, + sourceFrame.pose.translations, + sourceTranslationOffset + ); + break; + case 'scaled': + default: + targetFrame.pose.translations[targetTranslationOffset] = + sourceFrame.pose.translations[sourceTranslationOffset]! * mapping.translationScale; + targetFrame.pose.translations[targetTranslationOffset + 1] = + sourceFrame.pose.translations[sourceTranslationOffset + 1]! * mapping.translationScale; + targetFrame.pose.translations[targetTranslationOffset + 2] = + sourceFrame.pose.translations[sourceTranslationOffset + 2]! * mapping.translationScale; + break; + } + + switch (mapping.rotationMode) { + case 'copy': + quatCopy( + targetFrame.pose.rotations, + targetRotationOffset, + sourceFrame.pose.rotations, + sourceRotationOffset + ); + break; + case 'offset': + default: + quatMultiply( + targetFrame.pose.rotations, + targetRotationOffset, + mapping.rotationOffset, + 0, + sourceFrame.pose.rotations, + sourceRotationOffset + ); + quatNormalize( + targetFrame.pose.rotations, + targetRotationOffset, + targetFrame.pose.rotations, + targetRotationOffset + ); + break; + } + + targetFrame.pose.scales[targetTranslationOffset] = + sourceFrame.pose.scales[sourceTranslationOffset]!; + targetFrame.pose.scales[targetTranslationOffset + 1] = + sourceFrame.pose.scales[sourceTranslationOffset + 1]!; + targetFrame.pose.scales[targetTranslationOffset + 2] = + sourceFrame.pose.scales[sourceTranslationOffset + 2]!; + } + return targetFrame; + } + + retargetInto(sourceFrame: AnimationFrame, targetFrame: AnimationFrame): AnimationFrame { + if (targetFrame.pose.boneCount !== this.targetRig.boneCount) { + throw new AnimationRetargetingError('Target frame is not compatible with the retarget target rig'); + } + return this.retargetPose(sourceFrame, targetFrame); + } +} \ No newline at end of file diff --git a/web/packages/animation/src/rig.ts b/web/packages/animation/src/rig.ts new file mode 100644 index 00000000..b8cb8532 --- /dev/null +++ b/web/packages/animation/src/rig.ts @@ -0,0 +1,279 @@ +import { brandString, type AnimationRigId } from './brands'; +import { AnimationValidationError } from './errors'; +import { composeMatrix, quatApplyToVec3, quatMultiply, quatNormalize, vec3Add, vec3Multiply } from './math'; +import type { AnimationRigDefinition } from './types'; + +const IDENTITY_TRANSLATION = Object.freeze([0, 0, 0] as const); +const IDENTITY_ROTATION = Object.freeze([0, 0, 0, 1] as const); +const IDENTITY_SCALE = Object.freeze([1, 1, 1] as const); +const IDENTITY_MATRIX = Object.freeze([ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, +] as const); + +const resolveParentIndex = ( + definition: AnimationRigDefinition, + boneIndex: number, + indexByName: ReadonlyMap +): number => { + const parent = definition.bones[boneIndex]!.parent; + if (parent === undefined || parent === null) { + return -1; + } + if (typeof parent === 'number') { + return parent; + } + const resolved = indexByName.get(parent); + if (resolved === undefined) { + throw new AnimationValidationError( + `Animation rig bone '${definition.bones[boneIndex]!.name}' references missing parent '${parent}'` + ); + } + return resolved; +}; + +const buildEvaluationOrder = (parentIndices: Int32Array): Int32Array => { + const order: number[] = []; + const temporary = new Uint8Array(parentIndices.length); + const permanent = new Uint8Array(parentIndices.length); + + const visit = (index: number): void => { + if (permanent[index] === 1) { + return; + } + if (temporary[index] === 1) { + throw new AnimationValidationError('Animation rig contains a parent cycle'); + } + + temporary[index] = 1; + const parentIndex = parentIndices[index]!; + if (parentIndex >= 0) { + visit(parentIndex); + } + temporary[index] = 0; + permanent[index] = 1; + order.push(index); + }; + + for (let index = 0; index < parentIndices.length; index += 1) { + visit(index); + } + + return Int32Array.from(order); +}; + +export class AnimationRig { + readonly id: AnimationRigId; + readonly boneCount: number; + readonly boneNames: readonly string[]; + readonly parentIndices: Int32Array; + readonly childIndices: readonly Int32Array[]; + readonly rootIndices: Int32Array; + readonly evaluationOrder: Int32Array; + readonly restTranslations: Float32Array; + readonly restRotations: Float32Array; + readonly restScales: Float32Array; + readonly inverseBindMatrices: Float32Array | null; + + private readonly _indexByName = new Map(); + + constructor(definition: AnimationRigDefinition) { + if (!definition || !Array.isArray(definition.bones) || definition.bones.length === 0) { + throw new AnimationValidationError('Animation rig requires at least one bone'); + } + + this.id = brandString<'AnimationRigId'>( + typeof definition.id === 'string' && definition.id.length > 0 + ? definition.id + : 'animation/rig' + ); + this.boneCount = definition.bones.length; + const boneNames = new Array(this.boneCount); + this.parentIndices = new Int32Array(this.boneCount); + this.restTranslations = new Float32Array(this.boneCount * 3); + this.restRotations = new Float32Array(this.boneCount * 4); + this.restScales = new Float32Array(this.boneCount * 3); + let inverseBindMatrices: Float32Array | null = null; + + for (let index = 0; index < this.boneCount; index += 1) { + const bone = definition.bones[index]!; + if (!bone || typeof bone.name !== 'string' || bone.name.length === 0) { + throw new AnimationValidationError('Animation rig bones require a non-empty name'); + } + if (this._indexByName.has(bone.name)) { + throw new AnimationValidationError(`Duplicate animation rig bone '${bone.name}'`); + } + this._indexByName.set(bone.name, index); + boneNames[index] = bone.name; + } + + for (let index = 0; index < this.boneCount; index += 1) { + const bone = definition.bones[index]!; + const parentIndex = resolveParentIndex(definition, index, this._indexByName); + if (parentIndex === index) { + throw new AnimationValidationError( + `Animation rig bone '${bone.name}' cannot parent itself` + ); + } + if (parentIndex >= this.boneCount) { + throw new AnimationValidationError( + `Animation rig bone '${bone.name}' references out-of-range parent index ${parentIndex}` + ); + } + this.parentIndices[index] = parentIndex; + this.restTranslations.set(bone.translation ?? IDENTITY_TRANSLATION, index * 3); + this.restRotations.set(bone.rotation ?? IDENTITY_ROTATION, index * 4); + this.restScales.set(bone.scale ?? IDENTITY_SCALE, index * 3); + + if (bone.inverseBindMatrix) { + if (!inverseBindMatrices) { + inverseBindMatrices = new Float32Array(this.boneCount * 16); + for (let matrixIndex = 0; matrixIndex < this.boneCount; matrixIndex += 1) { + inverseBindMatrices.set(IDENTITY_MATRIX, matrixIndex * 16); + } + } + const source = bone.inverseBindMatrix; + if (source.length !== 16) { + throw new AnimationValidationError( + `Animation rig bone '${bone.name}' inverse bind matrix must contain 16 values` + ); + } + inverseBindMatrices.set(source, index * 16); + } + } + + this.boneNames = Object.freeze(boneNames); + this.inverseBindMatrices = inverseBindMatrices; + this.evaluationOrder = buildEvaluationOrder(this.parentIndices); + + const childBuckets = Array.from({ length: this.boneCount }, () => [] as number[]); + const roots: number[] = []; + for (let index = 0; index < this.boneCount; index += 1) { + const parentIndex = this.parentIndices[index]!; + if (parentIndex >= 0) { + childBuckets[parentIndex]!.push(index); + } else { + roots.push(index); + } + } + this.childIndices = Object.freeze( + childBuckets.map((bucket) => Int32Array.from(bucket)) + ); + this.rootIndices = Int32Array.from(roots); + } + + hasBone(name: string): boolean { + return this._indexByName.has(name); + } + + indexOfBone(name: string): number { + const index = this._indexByName.get(name); + if (index === undefined) { + throw new AnimationValidationError(`Unknown animation rig bone '${name}'`); + } + return index; + } + + tryIndexOfBone(name: string): number | undefined { + return this._indexByName.get(name); + } + + getParentIndex(index: number): number { + return this.parentIndices[index] ?? -1; + } + + getBoneName(index: number): string { + const name = this.boneNames[index]; + if (!name) { + throw new AnimationValidationError(`Unknown animation rig bone index '${index}'`); + } + return name; + } + + createRestMatrixPalette(): Float32Array { + const palette = new Float32Array(this.boneCount * 16); + const worldTranslations = new Float32Array(this.boneCount * 3); + const worldRotations = new Float32Array(this.boneCount * 4); + const worldScales = new Float32Array(this.boneCount * 3); + const scratchVector = new Float32Array(3); + + for (let orderIndex = 0; orderIndex < this.evaluationOrder.length; orderIndex += 1) { + const boneIndex = this.evaluationOrder[orderIndex]!; + const parentIndex = this.parentIndices[boneIndex]!; + const localTranslationOffset = boneIndex * 3; + const localRotationOffset = boneIndex * 4; + if (parentIndex < 0) { + worldTranslations.set( + this.restTranslations.subarray(localTranslationOffset, localTranslationOffset + 3), + localTranslationOffset + ); + worldRotations.set( + this.restRotations.subarray(localRotationOffset, localRotationOffset + 4), + localRotationOffset + ); + worldScales.set( + this.restScales.subarray(localTranslationOffset, localTranslationOffset + 3), + localTranslationOffset + ); + } else { + const parentTranslationOffset = parentIndex * 3; + const parentRotationOffset = parentIndex * 4; + vec3Multiply( + scratchVector, + 0, + this.restTranslations, + localTranslationOffset, + worldScales, + parentTranslationOffset + ); + quatApplyToVec3( + scratchVector, + 0, + worldRotations, + parentRotationOffset, + scratchVector, + 0 + ); + vec3Add( + worldTranslations, + localTranslationOffset, + worldTranslations, + parentTranslationOffset, + scratchVector, + 0 + ); + quatMultiply( + worldRotations, + localRotationOffset, + worldRotations, + parentRotationOffset, + this.restRotations, + localRotationOffset + ); + quatNormalize(worldRotations, localRotationOffset, worldRotations, localRotationOffset); + vec3Multiply( + worldScales, + localTranslationOffset, + worldScales, + parentTranslationOffset, + this.restScales, + localTranslationOffset + ); + } + composeMatrix( + palette, + boneIndex * 16, + worldTranslations, + boneIndex * 3, + worldRotations, + boneIndex * 4, + worldScales, + boneIndex * 3 + ); + } + + return palette; + } +} \ No newline at end of file diff --git a/web/packages/animation/src/skinning.ts b/web/packages/animation/src/skinning.ts new file mode 100644 index 00000000..7906686e --- /dev/null +++ b/web/packages/animation/src/skinning.ts @@ -0,0 +1,87 @@ +import { composeMatrix, mat4Invert, mat4Multiply } from './math'; +import type { AnimationWorldPose } from './pose'; +import type { AnimationRig } from './rig'; + +export interface AnimationSkinningPaletteOptions { + readonly meshWorldMatrix: ArrayLike; + readonly jointWorldMatrices: readonly ArrayLike[]; + readonly inverseBindMatrices?: ArrayLike | null; + readonly out?: Float32Array; +} + +const writeIdentity = (target: Float32Array, offset: number): void => { + target[offset] = 1; + target[offset + 1] = 0; + target[offset + 2] = 0; + target[offset + 3] = 0; + target[offset + 4] = 0; + target[offset + 5] = 1; + target[offset + 6] = 0; + target[offset + 7] = 0; + target[offset + 8] = 0; + target[offset + 9] = 0; + target[offset + 10] = 1; + target[offset + 11] = 0; + target[offset + 12] = 0; + target[offset + 13] = 0; + target[offset + 14] = 0; + target[offset + 15] = 1; +}; + +export const computeSkinningPalette = ({ + meshWorldMatrix, + jointWorldMatrices, + inverseBindMatrices, + out, +}: AnimationSkinningPaletteOptions): Float32Array => { + const palette = out ?? new Float32Array(jointWorldMatrices.length * 16); + const inverseMeshMatrix = new Float32Array(16); + const scratchMatrix = new Float32Array(16); + if (!mat4Invert(inverseMeshMatrix, 0, meshWorldMatrix, 0)) { + palette.fill(0); + for (let index = 0; index < jointWorldMatrices.length; index += 1) { + writeIdentity(palette, index * 16); + } + return palette; + } + + for (let jointIndex = 0; jointIndex < jointWorldMatrices.length; jointIndex += 1) { + mat4Multiply(scratchMatrix, 0, inverseMeshMatrix, 0, jointWorldMatrices[jointIndex]!, 0); + if (inverseBindMatrices) { + mat4Multiply(palette, jointIndex * 16, scratchMatrix, 0, inverseBindMatrices, jointIndex * 16); + } else { + palette.set(scratchMatrix, jointIndex * 16); + } + } + + return palette; +}; + +export const computeRigSkinningPalette = ( + rig: AnimationRig, + worldPose: AnimationWorldPose, + out?: Float32Array +): Float32Array => { + const palette = out ?? new Float32Array(rig.boneCount * 16); + const worldMatrix = new Float32Array(16); + const scratch = new Float32Array(16); + for (let boneIndex = 0; boneIndex < rig.boneCount; boneIndex += 1) { + composeMatrix( + worldMatrix, + 0, + worldPose.translations, + boneIndex * 3, + worldPose.rotations, + boneIndex * 4, + worldPose.scales, + boneIndex * 3 + ); + if (rig.inverseBindMatrices) { + mat4Multiply(scratch, 0, worldMatrix, 0, rig.inverseBindMatrices, boneIndex * 16); + palette.set(scratch, boneIndex * 16); + } else { + palette.set(worldMatrix, boneIndex * 16); + } + } + return palette; +}; \ No newline at end of file diff --git a/web/packages/animation/src/state-machine.ts b/web/packages/animation/src/state-machine.ts new file mode 100644 index 00000000..3c59686b --- /dev/null +++ b/web/packages/animation/src/state-machine.ts @@ -0,0 +1,599 @@ +import { + collectMotionClipActivities, + compileMotion, + evaluateMotion, + extractMotionRootDelta, + resolveMotionDuration, + type AnimationCompiledMotion, + type AnimationMotionEvaluationContext, +} from './blend-tree'; +import { collectMotionEvents } from './blend-tree'; +import { AnimationStateMachineError, AnimationValidationError } from './errors'; +import { AnimationParameterStore } from './parameters'; +import { blendFrame, type AnimationFrame } from './pose'; +import { quatIdentity, quatSlerp } from './math'; +import { AnimationClip } from './clip'; +import type { + AnimationConditionDefinition, + AnimationControllerClipActivity, + AnimationStateMachineDefinition, + AnimationTransitionDefinition, + AnimationControllerEvent, +} from './types'; + +export interface AnimationCompiledTransition { + readonly targetStateIndex: number; + readonly duration: number; + readonly offset: number; + readonly exitTime?: number; + readonly fixedDuration: boolean; + readonly canInterrupt: boolean; + readonly priority: number; + readonly conditions: readonly AnimationConditionDefinition[]; +} + +export interface AnimationCompiledState { + readonly id: string; + readonly motion: AnimationCompiledMotion; + readonly speed: number; + readonly loop: boolean; + readonly transitions: readonly AnimationCompiledTransition[]; +} + +export interface AnimationCompiledStateMachine { + readonly entryStateIndex: number; + readonly states: readonly AnimationCompiledState[]; + readonly anyStateTransitions: readonly AnimationCompiledTransition[]; + readonly stateIndexById: ReadonlyMap; +} + +export interface AnimationTransitionRuntime { + sourceStateIndex: number; + targetStateIndex: number; + durationSeconds: number; + progress: number; + previousProgress: number; + sourceNormalizedTime: number; + previousSourceNormalizedTime: number; + targetNormalizedTime: number; + previousTargetNormalizedTime: number; + complete: boolean; +} + +export interface AnimationLayerRuntime { + currentStateIndex: number; + currentNormalizedTime: number; + previousNormalizedTime: number; + transition: AnimationTransitionRuntime | null; +} + +const normalizePriority = (value: number | undefined): number => + typeof value === 'number' && Number.isFinite(value) ? value : 0; + +const sortTransitions = ( + transitions: readonly AnimationCompiledTransition[] +): readonly AnimationCompiledTransition[] => + Object.freeze([...transitions].sort((left, right) => right.priority - left.priority)); + +const evaluateCondition = ( + condition: AnimationConditionDefinition, + parameters: AnimationParameterStore +): boolean => { + switch (condition.kind) { + case 'float': + case 'int': { + const value = parameters.get(condition.parameter); + const numericValue = typeof value === 'number' ? value : value ? 1 : 0; + switch (condition.operator) { + case '<': + return numericValue < condition.value; + case '<=': + return numericValue <= condition.value; + case '>': + return numericValue > condition.value; + case '>=': + return numericValue >= condition.value; + case '==': + return numericValue === condition.value; + case '!=': + return numericValue !== condition.value; + default: + return false; + } + } + case 'bool': + return parameters.get(condition.parameter) === condition.value; + case 'trigger': + return parameters.get(condition.parameter) === true; + default: + return false; + } +}; + +const consumeTransitionTriggers = ( + transition: AnimationCompiledTransition, + parameters: AnimationParameterStore +): void => { + for (let index = 0; index < transition.conditions.length; index += 1) { + const condition = transition.conditions[index]!; + if (condition.kind === 'trigger') { + parameters.consumeTrigger(condition.parameter); + } + } +}; + +const crossedExitTime = ( + previousNormalizedTime: number, + currentNormalizedTime: number, + exitTime: number, + loop: boolean +): boolean => { + if (!loop || currentNormalizedTime >= previousNormalizedTime) { + return previousNormalizedTime < exitTime && currentNormalizedTime >= exitTime; + } + return exitTime >= previousNormalizedTime || exitTime <= currentNormalizedTime; +}; + +const resolveTransitionCandidate = ( + machine: AnimationCompiledStateMachine, + stateIndex: number, + previousNormalizedTime: number, + currentNormalizedTime: number, + parameters: AnimationParameterStore +): AnimationCompiledTransition | undefined => { + const state = machine.states[stateIndex]!; + const candidates = [...machine.anyStateTransitions, ...state.transitions].sort( + (left, right) => right.priority - left.priority + ); + for (let index = 0; index < candidates.length; index += 1) { + const transition = candidates[index]!; + if ( + transition.exitTime !== undefined && + !crossedExitTime(previousNormalizedTime, currentNormalizedTime, transition.exitTime, state.loop) + ) { + continue; + } + let matches = true; + for (let conditionIndex = 0; conditionIndex < transition.conditions.length; conditionIndex += 1) { + if (!evaluateCondition(transition.conditions[conditionIndex]!, parameters)) { + matches = false; + break; + } + } + if (matches) { + return transition; + } + } + return undefined; +}; + +const resolveStateDurationSeconds = ( + state: AnimationCompiledState, + parameters: AnimationParameterStore +): number => + Math.max(1e-6, resolveMotionDuration(state.motion, parameters) / Math.max(Math.abs(state.speed), 1e-6)); + +export const compileStateMachine = ( + definition: AnimationStateMachineDefinition, + clips: ReadonlyMap +): AnimationCompiledStateMachine => { + if (!definition || !Array.isArray(definition.states) || definition.states.length === 0) { + throw new AnimationValidationError('Animation state machines require at least one state'); + } + const stateIndexById = new Map(); + for (let index = 0; index < definition.states.length; index += 1) { + const state = definition.states[index]!; + if (!state || typeof state.id !== 'string' || state.id.length === 0) { + throw new AnimationValidationError('Animation states require a non-empty id'); + } + if (stateIndexById.has(state.id)) { + throw new AnimationValidationError(`Duplicate animation state '${state.id}'`); + } + stateIndexById.set(state.id, index); + } + const compileTransition = (transition: AnimationTransitionDefinition): AnimationCompiledTransition => { + const targetStateIndex = stateIndexById.get(transition.to); + if (targetStateIndex === undefined) { + throw new AnimationValidationError(`Unknown animation transition target '${transition.to}'`); + } + return Object.freeze({ + targetStateIndex, + duration: typeof transition.duration === 'number' && Number.isFinite(transition.duration) ? transition.duration : 0, + offset: typeof transition.offset === 'number' && Number.isFinite(transition.offset) ? transition.offset : 0, + exitTime: + typeof transition.exitTime === 'number' && Number.isFinite(transition.exitTime) + ? transition.exitTime + : undefined, + fixedDuration: transition.fixedDuration ?? false, + canInterrupt: transition.canInterrupt ?? false, + priority: normalizePriority(transition.priority), + conditions: Object.freeze([...(transition.conditions ?? [])]), + }); + }; + + const states = Object.freeze( + definition.states.map((state) => + Object.freeze({ + id: state.id, + motion: compileMotion(state.motion, clips), + speed: + typeof state.speed === 'number' && Number.isFinite(state.speed) ? state.speed : 1, + loop: state.loop ?? true, + transitions: sortTransitions( + Object.freeze( + (state.transitions ?? []).map((transition: AnimationTransitionDefinition) => + compileTransition(transition) + ) + ) + ), + } satisfies AnimationCompiledState) + ) + ); + const entryStateIndex = stateIndexById.get(definition.entryState); + if (entryStateIndex === undefined) { + throw new AnimationValidationError(`Unknown animation entry state '${definition.entryState}'`); + } + + return Object.freeze({ + entryStateIndex, + states, + anyStateTransitions: sortTransitions( + Object.freeze( + (definition.anyStateTransitions ?? []).map((transition: AnimationTransitionDefinition) => + compileTransition(transition) + ) + ) + ), + stateIndexById, + }); +}; + +export const createLayerRuntime = (machine: AnimationCompiledStateMachine): AnimationLayerRuntime => ({ + currentStateIndex: machine.entryStateIndex, + currentNormalizedTime: 0, + previousNormalizedTime: 0, + transition: null, +}); + +export const forceLayerState = ( + machine: AnimationCompiledStateMachine, + runtime: AnimationLayerRuntime, + stateId: string, + normalizedTime: number = 0 +): void => { + const stateIndex = machine.stateIndexById.get(stateId); + if (stateIndex === undefined) { + throw new AnimationStateMachineError(`Unknown animation state '${stateId}'`); + } + runtime.currentStateIndex = stateIndex; + runtime.currentNormalizedTime = normalizedTime; + runtime.previousNormalizedTime = normalizedTime; + runtime.transition = null; +}; + +export const crossFadeLayerState = ( + machine: AnimationCompiledStateMachine, + runtime: AnimationLayerRuntime, + stateId: string, + durationSeconds: number, + offset: number = 0 +): void => { + const stateIndex = machine.stateIndexById.get(stateId); + if (stateIndex === undefined) { + throw new AnimationStateMachineError(`Unknown animation state '${stateId}'`); + } + runtime.transition = { + sourceStateIndex: runtime.currentStateIndex, + targetStateIndex: stateIndex, + durationSeconds: Math.max(0, durationSeconds), + progress: 0, + previousProgress: 0, + sourceNormalizedTime: runtime.currentNormalizedTime, + previousSourceNormalizedTime: runtime.currentNormalizedTime, + targetNormalizedTime: offset, + previousTargetNormalizedTime: offset, + complete: false, + }; +}; + +export const updateLayerRuntime = ( + machine: AnimationCompiledStateMachine, + runtime: AnimationLayerRuntime, + parameters: AnimationParameterStore, + deltaSeconds: number +): void => { + if (runtime.transition) { + const transition = runtime.transition; + const sourceState = machine.states[transition.sourceStateIndex]!; + const targetState = machine.states[transition.targetStateIndex]!; + const sourceDuration = resolveStateDurationSeconds(sourceState, parameters); + const targetDuration = resolveStateDurationSeconds(targetState, parameters); + transition.previousSourceNormalizedTime = transition.sourceNormalizedTime; + transition.previousTargetNormalizedTime = transition.targetNormalizedTime; + transition.previousProgress = transition.progress; + transition.sourceNormalizedTime = sourceState.loop + ? ((transition.sourceNormalizedTime + deltaSeconds / sourceDuration) % 1 + 1) % 1 + : Math.min(1, transition.sourceNormalizedTime + deltaSeconds / sourceDuration); + transition.targetNormalizedTime = targetState.loop + ? ((transition.targetNormalizedTime + deltaSeconds / targetDuration) % 1 + 1) % 1 + : Math.min(1, transition.targetNormalizedTime + deltaSeconds / targetDuration); + if (transition.durationSeconds <= 0) { + transition.progress = 1; + transition.complete = true; + return; + } + transition.progress = Math.min(1, transition.progress + deltaSeconds / transition.durationSeconds); + transition.complete = transition.progress >= 1; + return; + } + + runtime.previousNormalizedTime = runtime.currentNormalizedTime; + const currentState = machine.states[runtime.currentStateIndex]!; + const durationSeconds = resolveStateDurationSeconds(currentState, parameters); + runtime.currentNormalizedTime = currentState.loop + ? ((runtime.currentNormalizedTime + deltaSeconds / durationSeconds) % 1 + 1) % 1 + : Math.min(1, runtime.currentNormalizedTime + deltaSeconds / durationSeconds); + + const candidate = resolveTransitionCandidate( + machine, + runtime.currentStateIndex, + runtime.previousNormalizedTime, + runtime.currentNormalizedTime, + parameters + ); + if (!candidate) { + return; + } + + consumeTransitionTriggers(candidate, parameters); + const duration = candidate.fixedDuration + ? candidate.duration + : candidate.duration * durationSeconds; + runtime.transition = { + sourceStateIndex: runtime.currentStateIndex, + targetStateIndex: candidate.targetStateIndex, + durationSeconds: Math.max(0, duration), + progress: 0, + previousProgress: 0, + sourceNormalizedTime: runtime.currentNormalizedTime, + previousSourceNormalizedTime: runtime.currentNormalizedTime, + targetNormalizedTime: candidate.offset, + previousTargetNormalizedTime: candidate.offset, + complete: false, + }; +} + +export const evaluateLayerRuntime = ( + machine: AnimationCompiledStateMachine, + runtime: AnimationLayerRuntime, + context: AnimationMotionEvaluationContext, + out: AnimationFrame +): AnimationFrame => { + if (!runtime.transition) { + const state = machine.states[runtime.currentStateIndex]!; + return evaluateMotion( + state.motion, + runtime.currentNormalizedTime, + context, + out, + state.loop + ); + } + const sourceState = machine.states[runtime.transition.sourceStateIndex]!; + const targetState = machine.states[runtime.transition.targetStateIndex]!; + const sourceFrame = context.scratch.acquire(); + const targetFrame = context.scratch.acquire(); + evaluateMotion( + sourceState.motion, + runtime.transition.sourceNormalizedTime, + context, + sourceFrame, + sourceState.loop + ); + evaluateMotion( + targetState.motion, + runtime.transition.targetNormalizedTime, + context, + targetFrame, + targetState.loop + ); + return blendFrame(out, sourceFrame, targetFrame, runtime.transition.progress); +}; + +export const extractLayerRootDelta = ( + machine: AnimationCompiledStateMachine, + runtime: AnimationLayerRuntime, + rootBoneIndex: number, + context: AnimationMotionEvaluationContext, + outTranslation: Float32Array, + outRotation: Float32Array +): void => { + if (rootBoneIndex < 0) { + outTranslation.fill(0); + quatIdentity(outRotation, 0); + return; + } + if (!runtime.transition) { + const state = machine.states[runtime.currentStateIndex]!; + extractMotionRootDelta( + state.motion, + runtime.previousNormalizedTime, + runtime.currentNormalizedTime, + state.loop, + rootBoneIndex, + context.rig, + context.parameters, + outTranslation, + outRotation + ); + return; + } + + const sourceTranslation = new Float32Array(3); + const targetTranslation = new Float32Array(3); + const sourceRotation = new Float32Array(4); + const targetRotation = new Float32Array(4); + const sourceState = machine.states[runtime.transition.sourceStateIndex]!; + const targetState = machine.states[runtime.transition.targetStateIndex]!; + + extractMotionRootDelta( + sourceState.motion, + runtime.transition.previousSourceNormalizedTime, + runtime.transition.sourceNormalizedTime, + sourceState.loop, + rootBoneIndex, + context.rig, + context.parameters, + sourceTranslation, + sourceRotation + ); + extractMotionRootDelta( + targetState.motion, + runtime.transition.previousTargetNormalizedTime, + runtime.transition.targetNormalizedTime, + targetState.loop, + rootBoneIndex, + context.rig, + context.parameters, + targetTranslation, + targetRotation + ); + + outTranslation[0] = + sourceTranslation[0]! + + (targetTranslation[0]! - sourceTranslation[0]!) * runtime.transition.progress; + outTranslation[1] = + sourceTranslation[1]! + + (targetTranslation[1]! - sourceTranslation[1]!) * runtime.transition.progress; + outTranslation[2] = + sourceTranslation[2]! + + (targetTranslation[2]! - sourceTranslation[2]!) * runtime.transition.progress; + quatIdentity(outRotation, 0); + quatSlerp(outRotation, 0, sourceRotation, 0, targetRotation, 0, runtime.transition.progress); +} + +export const collectLayerEvents = ( + machine: AnimationCompiledStateMachine, + runtime: AnimationLayerRuntime, + context: AnimationMotionEvaluationContext, + layerId: string, + layerWeight: number, + out: AnimationControllerEvent[] = [] +): readonly AnimationControllerEvent[] => { + if (layerWeight <= 0) { + return out; + } + + if (!runtime.transition) { + const state = machine.states[runtime.currentStateIndex]!; + return collectMotionEvents( + state.motion, + runtime.previousNormalizedTime, + runtime.currentNormalizedTime, + state.loop, + context.parameters, + layerId, + state.id, + layerWeight, + 1, + out + ); + } + + const sourceState = machine.states[runtime.transition.sourceStateIndex]!; + const targetState = machine.states[runtime.transition.targetStateIndex]!; + const sourceWeight = Math.max(0, 1 - runtime.transition.progress); + const targetWeight = Math.max(0, runtime.transition.progress); + + collectMotionEvents( + sourceState.motion, + runtime.transition.previousSourceNormalizedTime, + runtime.transition.sourceNormalizedTime, + sourceState.loop, + context.parameters, + layerId, + sourceState.id, + layerWeight, + sourceWeight, + out + ); + collectMotionEvents( + targetState.motion, + runtime.transition.previousTargetNormalizedTime, + runtime.transition.targetNormalizedTime, + targetState.loop, + context.parameters, + layerId, + targetState.id, + layerWeight, + targetWeight, + out + ); + return out; +}; + +export const collectLayerClipActivities = ( + machine: AnimationCompiledStateMachine, + runtime: AnimationLayerRuntime, + context: AnimationMotionEvaluationContext, + layerId: string, + layerWeight: number, + out: AnimationControllerClipActivity[] = [] +): readonly AnimationControllerClipActivity[] => { + if (layerWeight <= 0) { + return out; + } + + if (!runtime.transition) { + const state = machine.states[runtime.currentStateIndex]!; + return collectMotionClipActivities( + state.motion, + runtime.currentNormalizedTime, + state.loop, + context.parameters, + layerId, + state.id, + layerWeight, + 1, + out + ); + } + + const sourceState = machine.states[runtime.transition.sourceStateIndex]!; + const targetState = machine.states[runtime.transition.targetStateIndex]!; + const sourceWeight = Math.max(0, 1 - runtime.transition.progress); + const targetWeight = Math.max(0, runtime.transition.progress); + + collectMotionClipActivities( + sourceState.motion, + runtime.transition.sourceNormalizedTime, + sourceState.loop, + context.parameters, + layerId, + sourceState.id, + layerWeight, + sourceWeight, + out + ); + collectMotionClipActivities( + targetState.motion, + runtime.transition.targetNormalizedTime, + targetState.loop, + context.parameters, + layerId, + targetState.id, + layerWeight, + targetWeight, + out + ); + return out; +}; +export const commitLayerRuntime = (runtime: AnimationLayerRuntime): void => { + if (!runtime.transition || !runtime.transition.complete) { + return; + } + runtime.currentStateIndex = runtime.transition.targetStateIndex; + runtime.currentNormalizedTime = runtime.transition.targetNormalizedTime; + runtime.previousNormalizedTime = runtime.transition.targetNormalizedTime; + runtime.transition = null; +}; \ No newline at end of file diff --git a/web/packages/animation/src/streaming-chunk.ts b/web/packages/animation/src/streaming-chunk.ts new file mode 100644 index 00000000..10dd6b19 --- /dev/null +++ b/web/packages/animation/src/streaming-chunk.ts @@ -0,0 +1,376 @@ +import { AnimationValidationError } from './errors'; +import { isRecord } from '@axrone/utility'; +import type { AnimationClipDefinition, AnimationTrackDefinition } from './types'; + +export type AnimationClipStreamingChunkMergeMode = 'replace-range' | 'replace-all'; + +export interface AnimationClipStreamingChunkPayload { + readonly version?: 1; + readonly clipId?: string; + readonly mergeMode?: AnimationClipStreamingChunkMergeMode; + readonly startTime?: number; + readonly endTime?: number; + readonly duration?: number; + readonly tracks: readonly AnimationTrackDefinition[]; +} + +export interface AnimationClipStreamingChunkApplicationOptions { + readonly clipId?: string; + readonly startTime?: number; + readonly endTime?: number; +} + +interface AnimationTrackSampleFrame { + readonly time: number; + readonly sample: readonly number[]; +} + +const textDecoder = new TextDecoder(); +const textEncoder = new TextEncoder(); + +const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const toReadonlyNumberArray = ( + value: readonly number[] | Float32Array +): readonly number[] | Float32Array => (value instanceof Float32Array ? new Float32Array(value) : [...value]); + +const toTrackKey = (track: AnimationTrackDefinition): string => `${track.path}:${track.target}`; + +const getTrackKeyframeCount = (track: AnimationTrackDefinition): number => track.keyframeCount ?? track.times.length; + +const getTrackSampleStride = (track: AnimationTrackDefinition): number => { + const keyframeCount = getTrackKeyframeCount(track); + if (keyframeCount <= 0) { + return 0; + } + if (typeof track.sampleStride === 'number' && Number.isFinite(track.sampleStride)) { + return Math.trunc(track.sampleStride); + } + const valueLength = track.values.length; + return Math.trunc(valueLength / keyframeCount); +}; + +const getTrackComponentCount = (track: AnimationTrackDefinition): number => { + if (typeof track.valueComponentCount === 'number' && Number.isFinite(track.valueComponentCount)) { + return Math.trunc(track.valueComponentCount); + } + switch (track.path) { + case 'translation': + case 'scale': + return 3; + case 'rotation': + return 4; + case 'weights': { + const stride = getTrackSampleStride(track); + if (track.interpolation === 'CUBICSPLINE') { + return Math.trunc(stride / 3); + } + return stride; + } + default: + return 0; + } +}; + +const normalizeTrackDefinition = (track: AnimationTrackDefinition): AnimationTrackDefinition => { + if (!track || typeof track.target !== 'string' || track.target.length === 0) { + throw new AnimationValidationError('Streaming animation tracks require a non-empty target'); + } + const keyframeCount = getTrackKeyframeCount(track); + const sampleStride = getTrackSampleStride(track); + const componentCount = getTrackComponentCount(track); + if (!Number.isInteger(keyframeCount) || keyframeCount < 0) { + throw new AnimationValidationError( + `Streaming animation track '${track.target}/${track.path}' has invalid keyframeCount` + ); + } + if (!Number.isInteger(sampleStride) || sampleStride < 0) { + throw new AnimationValidationError( + `Streaming animation track '${track.target}/${track.path}' has invalid sampleStride` + ); + } + if (!Number.isInteger(componentCount) || componentCount < 0) { + throw new AnimationValidationError( + `Streaming animation track '${track.target}/${track.path}' has invalid valueComponentCount` + ); + } + if (track.times.length !== keyframeCount) { + throw new AnimationValidationError( + `Streaming animation track '${track.target}/${track.path}' times length does not match keyframeCount` + ); + } + if (sampleStride * keyframeCount !== track.values.length) { + throw new AnimationValidationError( + `Streaming animation track '${track.target}/${track.path}' has inconsistent values length` + ); + } + + return Object.freeze({ + target: track.target, + path: track.path, + ...(typeof track.interpolation === 'string' ? { interpolation: track.interpolation } : {}), + times: toReadonlyNumberArray(track.times), + values: toReadonlyNumberArray(track.values), + ...(keyframeCount > 0 ? { keyframeCount } : {}), + ...(sampleStride > 0 ? { sampleStride } : {}), + ...(componentCount > 0 ? { valueComponentCount: componentCount } : {}), + }); +}; + +const normalizeChunkPayload = ( + payload: AnimationClipStreamingChunkPayload +): AnimationClipStreamingChunkPayload => { + if (!Array.isArray(payload.tracks)) { + throw new AnimationValidationError('Animation streaming chunks require a tracks array'); + } + + return Object.freeze({ + ...(payload.version === 1 ? { version: 1 as const } : {}), + ...(typeof payload.clipId === 'string' && payload.clipId.length > 0 ? { clipId: payload.clipId } : {}), + ...(payload.mergeMode === 'replace-all' ? { mergeMode: 'replace-all' as const } : {}), + ...(isFiniteNumber(payload.startTime) ? { startTime: payload.startTime } : {}), + ...(isFiniteNumber(payload.endTime) ? { endTime: payload.endTime } : {}), + ...(isFiniteNumber(payload.duration) ? { duration: Math.max(0, payload.duration) } : {}), + tracks: Object.freeze(payload.tracks.map(normalizeTrackDefinition)), + }); +}; + +const decodeChunkSource = ( + source: string | Uint8Array | ArrayBuffer | ArrayBufferView +): string => { + if (typeof source === 'string') { + return source; + } + if (source instanceof Uint8Array) { + return textDecoder.decode(source); + } + if (ArrayBuffer.isView(source)) { + return textDecoder.decode(new Uint8Array(source.buffer, source.byteOffset, source.byteLength)); + } + return textDecoder.decode(new Uint8Array(source)); +}; + +const extractTrackFrames = (track: AnimationTrackDefinition): AnimationTrackSampleFrame[] => { + const keyframeCount = getTrackKeyframeCount(track); + const sampleStride = getTrackSampleStride(track); + const frames: AnimationTrackSampleFrame[] = []; + for (let index = 0; index < keyframeCount; index += 1) { + const time = Number(track.times[index] ?? 0); + const sampleOffset = index * sampleStride; + frames.push( + Object.freeze({ + time, + sample: Object.freeze( + Array.from({ length: sampleStride }, (_, sampleIndex) => + Number(track.values[sampleOffset + sampleIndex] ?? 0) + ) + ), + }) + ); + } + return frames; +}; + +const buildTrackFromFrames = ( + template: AnimationTrackDefinition, + frames: readonly AnimationTrackSampleFrame[] +): AnimationTrackDefinition => { + const sampleStride = getTrackSampleStride(template); + const times = new Float32Array(frames.length); + const values = new Float32Array(frames.length * sampleStride); + for (let index = 0; index < frames.length; index += 1) { + const frame = frames[index]!; + times[index] = frame.time; + const valueOffset = index * sampleStride; + for (let sampleIndex = 0; sampleIndex < sampleStride; sampleIndex += 1) { + values[valueOffset + sampleIndex] = frame.sample[sampleIndex] ?? 0; + } + } + + return Object.freeze({ + target: template.target, + path: template.path, + ...(typeof template.interpolation === 'string' ? { interpolation: template.interpolation } : {}), + times, + values, + keyframeCount: frames.length, + sampleStride, + valueComponentCount: getTrackComponentCount(template), + }); +}; + +const mergeTrackDefinitions = ( + existing: AnimationTrackDefinition | undefined, + incoming: AnimationTrackDefinition, + mergeMode: AnimationClipStreamingChunkMergeMode, + startTime: number, + endTime: number +): AnimationTrackDefinition => { + if (!existing || mergeMode === 'replace-all') { + return incoming; + } + + if ( + existing.path !== incoming.path || + existing.target !== incoming.target || + existing.interpolation !== incoming.interpolation || + getTrackSampleStride(existing) !== getTrackSampleStride(incoming) || + getTrackComponentCount(existing) !== getTrackComponentCount(incoming) + ) { + const retainedExisting = extractTrackFrames(existing).filter( + (frame) => frame.time < startTime || frame.time > endTime + ); + if (retainedExisting.length > 0) { + throw new AnimationValidationError( + `Streaming chunk for '${incoming.target}/${incoming.path}' cannot be merged with an incompatible existing track` + ); + } + return incoming; + } + + const retainedExistingFrames = extractTrackFrames(existing).filter( + (frame) => frame.time < startTime || frame.time > endTime + ); + const incomingFrames = extractTrackFrames(incoming); + const merged = [...retainedExistingFrames, ...incomingFrames] + .sort((left, right) => left.time - right.time); + + const deduped: AnimationTrackSampleFrame[] = []; + for (let index = 0; index < merged.length; index += 1) { + const frame = merged[index]!; + const previous = deduped[deduped.length - 1]; + if (previous && Math.abs(previous.time - frame.time) <= 1e-6) { + deduped[deduped.length - 1] = frame; + continue; + } + deduped.push(frame); + } + + return buildTrackFromFrames(incoming, deduped); +}; + +const inferChunkRange = ( + payload: AnimationClipStreamingChunkPayload, + options: AnimationClipStreamingChunkApplicationOptions +): readonly [number, number] => { + if (isFiniteNumber(payload.startTime) || isFiniteNumber(payload.endTime)) { + const start = isFiniteNumber(payload.startTime) ? payload.startTime : payload.endTime ?? 0; + const end = isFiniteNumber(payload.endTime) ? payload.endTime : payload.startTime ?? start; + return start <= end ? [start, end] : [end, start]; + } + if (isFiniteNumber(options.startTime) || isFiniteNumber(options.endTime)) { + const start = isFiniteNumber(options.startTime) ? options.startTime : options.endTime ?? 0; + const end = isFiniteNumber(options.endTime) ? options.endTime : options.startTime ?? start; + return start <= end ? [start, end] : [end, start]; + } + + let start = Number.POSITIVE_INFINITY; + let end = 0; + for (let index = 0; index < payload.tracks.length; index += 1) { + const track = payload.tracks[index]!; + const firstTime = Number(track.times[0] ?? 0); + const lastTime = Number(track.times[Math.max(0, track.times.length - 1)] ?? 0); + start = Math.min(start, firstTime); + end = Math.max(end, lastTime); + } + if (!Number.isFinite(start)) { + return [0, 0]; + } + return [start, end]; +}; + +const resolveDuration = ( + base: AnimationClipDefinition, + payload: AnimationClipStreamingChunkPayload, + tracks: readonly AnimationTrackDefinition[] +): number => { + if (isFiniteNumber(payload.duration)) { + return Math.max(0, payload.duration); + } + + let duration = + typeof base.duration === 'number' && Number.isFinite(base.duration) ? Math.max(0, base.duration) : 0; + for (let index = 0; index < tracks.length; index += 1) { + const track = tracks[index]!; + duration = Math.max(duration, Number(track.times[Math.max(0, track.times.length - 1)] ?? 0)); + } + return duration; +}; + +export const encodeAnimationClipStreamingChunkPayload = ( + payload: AnimationClipStreamingChunkPayload +): Uint8Array => textEncoder.encode(JSON.stringify(normalizeChunkPayload(payload))); + +export const decodeAnimationClipStreamingChunkPayload = ( + source: string | Uint8Array | ArrayBuffer | ArrayBufferView +): AnimationClipStreamingChunkPayload => { + let parsed: unknown; + try { + parsed = JSON.parse(decodeChunkSource(source)); + } catch (error) { + throw new AnimationValidationError( + `Failed to parse animation streaming chunk payload: ${error instanceof Error ? error.message : String(error)}` + ); + } + if (!isRecord(parsed)) { + throw new AnimationValidationError('Animation streaming chunk payload must be a JSON object'); + } + return normalizeChunkPayload(parsed as unknown as AnimationClipStreamingChunkPayload); +}; + +export const applyAnimationClipStreamingChunkDefinition = ( + base: AnimationClipDefinition, + payload: AnimationClipStreamingChunkPayload, + options: AnimationClipStreamingChunkApplicationOptions = {} +): AnimationClipDefinition => { + const normalizedPayload = normalizeChunkPayload(payload); + const expectedClipId = options.clipId ?? base.id; + if (normalizedPayload.clipId && normalizedPayload.clipId !== expectedClipId) { + throw new AnimationValidationError( + `Streaming chunk targets clip '${normalizedPayload.clipId}', expected '${expectedClipId}'` + ); + } + + const mergeMode = normalizedPayload.mergeMode ?? 'replace-range'; + const [startTime, endTime] = inferChunkRange(normalizedPayload, options); + const incomingByKey = new Map(); + for (let index = 0; index < normalizedPayload.tracks.length; index += 1) { + const track = normalizedPayload.tracks[index]!; + const key = toTrackKey(track); + if (incomingByKey.has(key)) { + throw new AnimationValidationError( + `Streaming chunk contains duplicate track '${track.target}/${track.path}'` + ); + } + incomingByKey.set(key, track); + } + + const mergedTracks: AnimationTrackDefinition[] = []; + const consumedKeys = new Set(); + for (let index = 0; index < base.tracks.length; index += 1) { + const existing = normalizeTrackDefinition(base.tracks[index]!); + const key = toTrackKey(existing); + const incoming = incomingByKey.get(key); + if (!incoming) { + mergedTracks.push(existing); + continue; + } + mergedTracks.push(mergeTrackDefinitions(existing, incoming, mergeMode, startTime, endTime)); + consumedKeys.add(key); + } + + for (const [key, incoming] of incomingByKey.entries()) { + if (consumedKeys.has(key)) { + continue; + } + mergedTracks.push(incoming); + } + + return Object.freeze({ + ...base, + id: expectedClipId, + duration: resolveDuration(base, normalizedPayload, mergedTracks), + tracks: Object.freeze(mergedTracks), + } satisfies AnimationClipDefinition); +}; \ No newline at end of file diff --git a/web/packages/animation/src/streaming.ts b/web/packages/animation/src/streaming.ts new file mode 100644 index 00000000..914db2b9 --- /dev/null +++ b/web/packages/animation/src/streaming.ts @@ -0,0 +1,481 @@ +import { AnimationClip } from './clip'; +import type { AnimationControllerClipActivity } from './types'; + +export type AnimationClipStreamingChunkStatus = 'idle' | 'requested' | 'loaded' | 'failed'; +export type AnimationClipStreamingRequestReason = 'active' | 'preload'; + +export interface AnimationClipStreamingRequest { + readonly clipId: string; + readonly chunkId: string; + readonly uri: string; + readonly startTime: number; + readonly endTime: number; + readonly reason: AnimationClipStreamingRequestReason; + readonly priority: number; + readonly weight: number; + readonly mimeType?: string; + readonly byteOffset?: number; + readonly byteLength?: number; +} + +export interface AnimationClipStreamingChunkSnapshot { + readonly clipId: string; + readonly chunkId: string; + readonly uri: string; + readonly startTime: number; + readonly endTime: number; + readonly status: AnimationClipStreamingChunkStatus; + readonly active: boolean; + readonly withinPreloadWindow: boolean; + readonly weight: number; + readonly requestCount: number; + readonly lastRequestReason?: AnimationClipStreamingRequestReason; + readonly error?: string; + readonly mimeType?: string; + readonly byteOffset?: number; + readonly byteLength?: number; +} + +export interface AnimationClipStreamingStateSnapshot { + readonly clipId: string; + readonly enabled: boolean; + readonly mode: 'resident' | 'streamed'; + readonly ready: boolean; + readonly activeWeight: number; + readonly preloadWindow: number; + readonly priority: number; + readonly activeChunkIds: readonly string[]; + readonly requestedChunkIds: readonly string[]; + readonly loadedChunkIds: readonly string[]; + readonly failedChunkIds: readonly string[]; + readonly pendingRequests: readonly AnimationClipStreamingRequest[]; + readonly chunks: readonly AnimationClipStreamingChunkSnapshot[]; +} + +export interface AnimationStreamingSnapshot { + readonly ready: boolean; + readonly pendingRequests: readonly AnimationClipStreamingRequest[]; + readonly clips: readonly AnimationClipStreamingStateSnapshot[]; +} + +interface AnimationResolvedStreamingChunk { + readonly id: string; + readonly uri: string; + readonly startTime: number; + readonly endTime: number; + readonly mimeType?: string; + readonly byteOffset?: number; + readonly byteLength?: number; +} + +interface AnimationStreamingChunkState { + readonly chunk: AnimationResolvedStreamingChunk; + status: AnimationClipStreamingChunkStatus; + active: boolean; + withinPreloadWindow: boolean; + weight: number; + requestCount: number; + lastRequestReason?: AnimationClipStreamingRequestReason; + error?: string; +} + +interface AnimationClipStreamingRecord { + readonly clip: AnimationClip; + readonly mode: 'resident' | 'streamed'; + readonly preloadWindow: number; + readonly priority: number; + readonly chunks: readonly AnimationResolvedStreamingChunk[]; + readonly chunkStates: Map; +} + +const EMPTY_SNAPSHOT: AnimationStreamingSnapshot = Object.freeze({ + ready: true, + pendingRequests: Object.freeze([]), + clips: Object.freeze([]), +}); + +const clamp = (value: number, min: number, max: number): number => + Math.max(min, Math.min(max, value)); + +const toChunkId = (clipId: string, chunkId: string | undefined, index: number): string => + typeof chunkId === 'string' && chunkId.length > 0 ? chunkId : `${clipId}:chunk:${index}`; + +const normalizeTime = (time: number, duration: number, loop: boolean): number => { + if (!Number.isFinite(time) || duration <= 0) { + return 0; + } + if (!loop) { + return clamp(time, 0, duration); + } + const wrapped = time % duration; + return wrapped < 0 ? wrapped + duration : wrapped; +}; + +const resolveWindowRanges = ( + time: number, + preloadWindow: number, + duration: number, + loop: boolean +): readonly (readonly [number, number])[] => { + if (duration <= 0) { + return Object.freeze([[0, 0] as const]); + } + const start = normalizeTime(time, duration, loop); + if (preloadWindow <= 0) { + return Object.freeze([[start, start] as const]); + } + if (!loop) { + return Object.freeze([[start, clamp(start + preloadWindow, 0, duration)] as const]); + } + const end = start + preloadWindow; + if (end <= duration) { + return Object.freeze([[start, end] as const]); + } + return Object.freeze([ + [start, duration] as const, + [0, end % duration] as const, + ]); +}; + +const chunkContainsTime = ( + chunk: AnimationResolvedStreamingChunk, + time: number, + duration: number +): boolean => { + if (duration <= 0) { + return false; + } + if (time === duration) { + return chunk.startTime <= duration && chunk.endTime >= duration; + } + return time >= chunk.startTime && time < chunk.endTime; +}; + +const chunkIntersectsRanges = ( + chunk: AnimationResolvedStreamingChunk, + ranges: readonly (readonly [number, number])[] +): boolean => { + for (let index = 0; index < ranges.length; index += 1) { + const range = ranges[index]!; + if (range[0] === range[1]) { + if (chunk.startTime <= range[0] && chunk.endTime >= range[1]) { + return true; + } + continue; + } + if (chunk.startTime < range[1] && chunk.endTime > range[0]) { + return true; + } + } + return false; +}; + +const buildVirtualStreamingChunks = (clip: AnimationClip): readonly AnimationResolvedStreamingChunk[] => { + const chunkDuration = clip.streaming?.chunkDuration; + const sourceUri = clip.streaming?.sourceUri; + if (!sourceUri || !chunkDuration || !Number.isFinite(chunkDuration) || chunkDuration <= 0 || clip.duration <= 0) { + return Object.freeze([]); + } + + const chunks: AnimationResolvedStreamingChunk[] = []; + const count = Math.max(1, Math.ceil(clip.duration / chunkDuration)); + for (let index = 0; index < count; index += 1) { + const startTime = Math.min(clip.duration, index * chunkDuration); + const endTime = index === count - 1 ? clip.duration : Math.min(clip.duration, startTime + chunkDuration); + chunks.push( + Object.freeze({ + id: `${clip.id}:virtual:${index}`, + uri: sourceUri, + startTime, + endTime, + }) + ); + } + return Object.freeze(chunks); +}; + +const buildStreamingRecord = (clip: AnimationClip): AnimationClipStreamingRecord | null => { + const streaming = clip.streaming; + if (!streaming || streaming.mode !== 'streamed') { + return null; + } + + const chunks = streaming.catalog?.chunks.length + ? Object.freeze( + streaming.catalog.chunks.map((chunk, index) => + Object.freeze({ + id: toChunkId(clip.id, chunk.id, index), + uri: chunk.uri, + startTime: chunk.startTime, + endTime: chunk.endTime, + ...(typeof chunk.mimeType === 'string' ? { mimeType: chunk.mimeType } : {}), + ...(typeof chunk.byteOffset === 'number' ? { byteOffset: chunk.byteOffset } : {}), + ...(typeof chunk.byteLength === 'number' ? { byteLength: chunk.byteLength } : {}), + }) + ) + ) + : buildVirtualStreamingChunks(clip); + + if (chunks.length === 0) { + return null; + } + + return { + clip, + mode: 'streamed', + preloadWindow: + typeof streaming.preloadWindow === 'number' && Number.isFinite(streaming.preloadWindow) + ? Math.max(0, streaming.preloadWindow) + : 0, + priority: + typeof streaming.priority === 'number' && Number.isFinite(streaming.priority) + ? Math.trunc(streaming.priority) + : 0, + chunks, + chunkStates: new Map( + chunks.map((chunk) => [ + chunk.id, + { + chunk, + status: 'idle' as AnimationClipStreamingChunkStatus, + active: false, + withinPreloadWindow: false, + weight: 0, + requestCount: 0, + }, + ]) + ), + }; +}; + +const freezeArray = (items: readonly T[]): readonly T[] => Object.freeze([...items]); + +export class AnimationClipStreamingScheduler { + private readonly _records = new Map(); + private _snapshot: AnimationStreamingSnapshot = EMPTY_SNAPSHOT; + + constructor(clips: Iterable | ReadonlyMap) { + const mapLike = clips as ReadonlyMap; + const iterable = typeof mapLike.values === 'function' && typeof mapLike.get === 'function' + ? mapLike.values() + : (clips as Iterable); + for (const clip of iterable) { + const record = buildStreamingRecord(clip); + if (record) { + this._records.set(clip.id, record); + } + } + } + + get snapshot(): AnimationStreamingSnapshot { + return this._snapshot; + } + + update(activities: readonly AnimationControllerClipActivity[]): AnimationStreamingSnapshot { + if (this._records.size === 0) { + this._snapshot = EMPTY_SNAPSHOT; + return this._snapshot; + } + + for (const record of this._records.values()) { + for (const state of record.chunkStates.values()) { + state.active = false; + state.withinPreloadWindow = false; + state.weight = 0; + } + } + + const pendingRequests: AnimationClipStreamingRequest[] = []; + for (let index = 0; index < activities.length; index += 1) { + const activity = activities[index]!; + const record = this._records.get(activity.clipId); + if (!record) { + continue; + } + const effectiveWeight = Math.max(0, activity.layerWeight * activity.motionWeight); + if (effectiveWeight <= 0) { + continue; + } + const activeTime = normalizeTime(activity.time, record.clip.duration, activity.loop); + const ranges = resolveWindowRanges( + activity.time, + record.preloadWindow, + record.clip.duration, + activity.loop + ); + + for (const state of record.chunkStates.values()) { + const active = chunkContainsTime(state.chunk, activeTime, record.clip.duration); + const withinPreloadWindow = chunkIntersectsRanges(state.chunk, ranges); + if (!active && !withinPreloadWindow) { + continue; + } + state.active = state.active || active; + state.withinPreloadWindow = state.withinPreloadWindow || withinPreloadWindow; + state.weight = Math.max(state.weight, effectiveWeight); + } + } + + for (const record of this._records.values()) { + for (const state of record.chunkStates.values()) { + if ((state.active || state.withinPreloadWindow) && state.status === 'idle') { + state.status = 'requested'; + state.requestCount += 1; + state.lastRequestReason = state.active ? 'active' : 'preload'; + state.error = undefined; + pendingRequests.push( + Object.freeze({ + clipId: record.clip.id, + chunkId: state.chunk.id, + uri: state.chunk.uri, + startTime: state.chunk.startTime, + endTime: state.chunk.endTime, + reason: state.lastRequestReason, + priority: record.priority, + weight: state.weight, + ...(typeof state.chunk.mimeType === 'string' ? { mimeType: state.chunk.mimeType } : {}), + ...(typeof state.chunk.byteOffset === 'number' ? { byteOffset: state.chunk.byteOffset } : {}), + ...(typeof state.chunk.byteLength === 'number' ? { byteLength: state.chunk.byteLength } : {}), + } satisfies AnimationClipStreamingRequest) + ); + } + } + } + + pendingRequests.sort( + (left, right) => + (left.reason === right.reason ? 0 : left.reason === 'active' ? -1 : 1) || + right.priority - left.priority || + right.weight - left.weight || + left.startTime - right.startTime || + left.clipId.localeCompare(right.clipId) + ); + + const snapshots = [...this._records.values()] + .map((record) => { + const chunkSnapshots = [...record.chunkStates.values()] + .map((state) => + Object.freeze({ + clipId: record.clip.id, + chunkId: state.chunk.id, + uri: state.chunk.uri, + startTime: state.chunk.startTime, + endTime: state.chunk.endTime, + status: state.status, + active: state.active, + withinPreloadWindow: state.withinPreloadWindow, + weight: state.weight, + requestCount: state.requestCount, + ...(state.lastRequestReason ? { lastRequestReason: state.lastRequestReason } : {}), + ...(state.error ? { error: state.error } : {}), + ...(typeof state.chunk.mimeType === 'string' ? { mimeType: state.chunk.mimeType } : {}), + ...(typeof state.chunk.byteOffset === 'number' ? { byteOffset: state.chunk.byteOffset } : {}), + ...(typeof state.chunk.byteLength === 'number' ? { byteLength: state.chunk.byteLength } : {}), + } satisfies AnimationClipStreamingChunkSnapshot) + ) + .sort((left, right) => left.startTime - right.startTime || left.endTime - right.endTime); + const activeChunkIds = freezeArray( + chunkSnapshots.filter((chunk) => chunk.active).map((chunk) => chunk.chunkId) + ); + const requestedChunkIds = freezeArray( + chunkSnapshots.filter((chunk) => chunk.status === 'requested').map((chunk) => chunk.chunkId) + ); + const loadedChunkIds = freezeArray( + chunkSnapshots.filter((chunk) => chunk.status === 'loaded').map((chunk) => chunk.chunkId) + ); + const failedChunkIds = freezeArray( + chunkSnapshots.filter((chunk) => chunk.status === 'failed').map((chunk) => chunk.chunkId) + ); + const activeWeight = chunkSnapshots.reduce( + (max, chunk) => (chunk.active ? Math.max(max, chunk.weight) : max), + 0 + ); + const ready = activeChunkIds.length === 0 || activeChunkIds.every((chunkId) => loadedChunkIds.includes(chunkId)); + return Object.freeze({ + clipId: record.clip.id, + enabled: true, + mode: record.mode, + ready, + activeWeight, + preloadWindow: record.preloadWindow, + priority: record.priority, + activeChunkIds, + requestedChunkIds, + loadedChunkIds, + failedChunkIds, + pendingRequests: freezeArray( + pendingRequests.filter((request) => request.clipId === record.clip.id) + ), + chunks: freezeArray(chunkSnapshots), + } satisfies AnimationClipStreamingStateSnapshot); + }) + .sort((left, right) => right.activeWeight - left.activeWeight || right.priority - left.priority || left.clipId.localeCompare(right.clipId)); + + this._snapshot = Object.freeze({ + ready: snapshots.every((clip) => clip.ready), + pendingRequests: freezeArray(pendingRequests), + clips: freezeArray(snapshots), + }); + return this._snapshot; + } + + markChunkRequested(clipId: string, chunkIdOrUri: string): boolean { + const state = this._resolveChunkState(clipId, chunkIdOrUri); + if (!state) { + return false; + } + state.status = 'requested'; + state.error = undefined; + return true; + } + + markChunkLoaded(clipId: string, chunkIdOrUri: string): boolean { + const state = this._resolveChunkState(clipId, chunkIdOrUri); + if (!state) { + return false; + } + state.status = 'loaded'; + state.error = undefined; + return true; + } + + markChunkFailed(clipId: string, chunkIdOrUri: string, error?: string): boolean { + const state = this._resolveChunkState(clipId, chunkIdOrUri); + if (!state) { + return false; + } + state.status = 'failed'; + state.error = typeof error === 'string' && error.length > 0 ? error : 'failed'; + return true; + } + + reset(clipId?: string): void { + const records = clipId ? [this._records.get(clipId)].filter(Boolean) : [...this._records.values()]; + for (let index = 0; index < records.length; index += 1) { + const record = records[index]!; + for (const state of record.chunkStates.values()) { + state.status = 'idle'; + state.active = false; + state.withinPreloadWindow = false; + state.weight = 0; + state.requestCount = 0; + state.lastRequestReason = undefined; + state.error = undefined; + } + } + this._snapshot = EMPTY_SNAPSHOT; + } + + private _resolveChunkState(clipId: string, chunkIdOrUri: string): AnimationStreamingChunkState | undefined { + const record = this._records.get(clipId); + if (!record) { + return undefined; + } + for (const state of record.chunkStates.values()) { + if (state.chunk.id === chunkIdOrUri || state.chunk.uri === chunkIdOrUri) { + return state; + } + } + return undefined; + } +} \ No newline at end of file diff --git a/web/packages/animation/src/types.ts b/web/packages/animation/src/types.ts new file mode 100644 index 00000000..94ebaac4 --- /dev/null +++ b/web/packages/animation/src/types.ts @@ -0,0 +1,430 @@ +import type { + AnimationClipId, + AnimationCurveId, + AnimationIkJobId, + AnimationLayerId, + AnimationParameterId, + AnimationRetargetProfileId, + AnimationRigId, + AnimationStateId, +} from './brands'; + +export type AnimationTrackPath = 'translation' | 'rotation' | 'scale' | 'weights'; +export type AnimationInterpolation = 'LINEAR' | 'STEP' | 'CUBICSPLINE'; +export type AnimationLayerBlendMode = 'override' | 'additive'; +export type AnimationTransitionOperator = '<' | '<=' | '>' | '>=' | '==' | '!='; +export type AnimationParameterKind = 'float' | 'int' | 'bool' | 'trigger'; +export type AnimationIkSolver = 'fabrik' | 'ccd'; +export type AnimationRetargetTranslationMode = 'none' | 'absolute' | 'scaled'; +export type AnimationRetargetRotationMode = 'copy' | 'offset'; + +export type AnimationVector2Tuple = readonly [number, number]; +export type AnimationVector3Tuple = readonly [number, number, number]; +export type AnimationQuaternionTuple = readonly [number, number, number, number]; +export type AnimationMatrix4Tuple = readonly [ + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, +]; + +export type AnimationParameterValue = + TKind extends 'bool' | 'trigger' ? boolean : number; + +export interface AnimationBoneDefinition { + readonly name: string; + readonly parent?: string | number | null; + readonly translation?: AnimationVector3Tuple; + readonly rotation?: AnimationQuaternionTuple; + readonly scale?: AnimationVector3Tuple; + readonly inverseBindMatrix?: AnimationMatrix4Tuple | readonly number[] | Float32Array; +} + +export interface AnimationRigDefinition { + readonly id?: AnimationRigId | string; + readonly bones: readonly AnimationBoneDefinition[]; +} + +export interface AnimationParameterDefinition< + TName extends string = string, + TKind extends AnimationParameterKind = AnimationParameterKind, +> { + readonly name: TName; + readonly kind: TKind; + readonly defaultValue?: AnimationParameterValue; +} + +export type AnimationParameterMap< + TDefinitions extends readonly AnimationParameterDefinition[] = readonly AnimationParameterDefinition[], +> = { + readonly [TEntry in TDefinitions[number] as TEntry['name']]: TEntry extends AnimationParameterDefinition< + string, + infer TKind + > + ? AnimationParameterValue + : never; +}; + +export interface AnimationTrackBase< + TPath extends AnimationTrackPath = AnimationTrackPath, + TTarget extends string = string, +> { + readonly target: TTarget; + readonly path: TPath; + readonly interpolation?: AnimationInterpolation; + readonly times: readonly number[] | Float32Array; + readonly values: readonly number[] | Float32Array; + readonly keyframeCount?: number; + readonly sampleStride?: number; + readonly valueComponentCount?: number; +} + +export type AnimationTrackDefinition = + | AnimationTrackBase<'translation'> + | AnimationTrackBase<'rotation'> + | AnimationTrackBase<'scale'> + | AnimationTrackBase<'weights'>; + +export interface AnimationClipEventDefinition { + readonly id?: string; + readonly name: string; + readonly time: number; + readonly payload?: Readonly> | null; + readonly tags?: readonly string[]; +} + +export interface AnimationFootContactDefinition { + readonly bone: string; + readonly startTime: number; + readonly endTime: number; + readonly lockTranslationAxes?: readonly [boolean, boolean, boolean]; + readonly metadata?: Readonly>; +} + +export interface AnimationMotionFeatureDefinition { + readonly time: number; + readonly trajectoryPosition?: AnimationVector3Tuple; + readonly facingDirection?: AnimationVector3Tuple; + readonly tags?: readonly string[]; + readonly costBias?: number; +} + +export interface AnimationClipCompressionDefinition { + readonly codec?: 'none' | 'keyframe-reduced'; + readonly positionTolerance?: number; + readonly rotationToleranceDegrees?: number; + readonly scaleTolerance?: number; + readonly curveTolerance?: number; + readonly preserveStepTracks?: boolean; +} + +export interface AnimationClipStreamingDefinition { + readonly mode?: 'resident' | 'streamed'; + readonly chunkDuration?: number; + readonly preloadWindow?: number; + readonly priority?: number; + readonly sourceUri?: string; + readonly catalogUri?: string; + readonly catalog?: AnimationClipStreamingCatalogDefinition; +} + +export interface AnimationClipStreamingChunkDefinition { + readonly id?: string; + readonly uri: string; + readonly startTime: number; + readonly endTime: number; + readonly byteOffset?: number; + readonly byteLength?: number; + readonly mimeType?: string; +} + +export interface AnimationClipStreamingCatalogDefinition { + readonly id?: string; + readonly chunks: readonly AnimationClipStreamingChunkDefinition[]; +} + +export interface AnimationClipDefinition { + readonly id: AnimationClipId | string; + readonly duration?: number; + readonly tracks: readonly AnimationTrackDefinition[]; + readonly events?: readonly AnimationClipEventDefinition[]; + readonly footContacts?: readonly AnimationFootContactDefinition[]; + readonly tags?: readonly string[]; + readonly features?: readonly AnimationMotionFeatureDefinition[]; + readonly compression?: AnimationClipCompressionDefinition; + readonly streaming?: AnimationClipStreamingDefinition; +} + +export interface AnimationClipEventOccurrence { + readonly clipId: string; + readonly id?: string; + readonly name: string; + readonly time: number; + readonly normalizedTime: number; + readonly payload?: Readonly> | null; + readonly tags?: readonly string[]; +} + +export interface AnimationFootContactState { + readonly bone: string; + readonly active: boolean; + readonly weight: number; + readonly normalizedTime: number; + readonly lockTranslationAxes?: readonly [boolean, boolean, boolean]; + readonly metadata?: Readonly>; +} + +export interface AnimationRootMotionDefinition { + readonly bone: string; + readonly consume?: boolean; + readonly projectTranslationAxes?: readonly [boolean, boolean, boolean]; + readonly extractRotation?: boolean; +} + +export interface AnimationMotionClipDefinition { + readonly kind: 'clip'; + readonly clipId: AnimationClipId | string; + readonly timeScale?: number; + readonly cycleOffset?: number; +} + +export interface AnimationBlendTreeChild1D { + readonly threshold: number; + readonly motion: AnimationMotionDefinition; +} + +export interface AnimationBlendTreeChild2D { + readonly position: AnimationVector2Tuple; + readonly motion: AnimationMotionDefinition; +} + +export interface AnimationBlendTreeDirectChild { + readonly motion: AnimationMotionDefinition; + readonly parameter?: AnimationParameterId | string; + readonly weight?: number; +} + +export interface AnimationBlendTree1DDefinition { + readonly kind: 'blend1d'; + readonly parameter: AnimationParameterId | string; + readonly children: readonly AnimationBlendTreeChild1D[]; +} + +export interface AnimationBlendTree2DDefinition { + readonly kind: 'blend2d'; + readonly parameterX: AnimationParameterId | string; + readonly parameterY: AnimationParameterId | string; + readonly children: readonly AnimationBlendTreeChild2D[]; +} + +export interface AnimationBlendTreeDirectDefinition { + readonly kind: 'direct'; + readonly children: readonly AnimationBlendTreeDirectChild[]; +} + +export interface AnimationBlendTreeAdditiveDefinition { + readonly kind: 'additive'; + readonly base: AnimationMotionDefinition; + readonly additive: AnimationMotionDefinition; + readonly parameter?: AnimationParameterId | string; + readonly weight?: number; +} + +export type AnimationBlendTreeDefinition = + | AnimationBlendTree1DDefinition + | AnimationBlendTree2DDefinition + | AnimationBlendTreeDirectDefinition + | AnimationBlendTreeAdditiveDefinition; + +export type AnimationMotionDefinition = + | AnimationMotionClipDefinition + | AnimationBlendTreeDefinition; + +export type AnimationConditionDefinition = + | { + readonly kind: 'float' | 'int'; + readonly parameter: AnimationParameterId | string; + readonly operator: AnimationTransitionOperator; + readonly value: number; + } + | { + readonly kind: 'bool'; + readonly parameter: AnimationParameterId | string; + readonly value: boolean; + } + | { + readonly kind: 'trigger'; + readonly parameter: AnimationParameterId | string; + }; + +export interface AnimationTransitionDefinition { + readonly to: AnimationStateId | string; + readonly duration?: number; + readonly offset?: number; + readonly exitTime?: number; + readonly fixedDuration?: boolean; + readonly canInterrupt?: boolean; + readonly priority?: number; + readonly conditions?: readonly AnimationConditionDefinition[]; +} + +export interface AnimationStateDefinition { + readonly id: AnimationStateId | string; + readonly motion: AnimationMotionDefinition; + readonly speed?: number; + readonly loop?: boolean; + readonly transitions?: readonly AnimationTransitionDefinition[]; +} + +export interface AnimationStateMachineDefinition { + readonly entryState: AnimationStateId | string; + readonly states: readonly AnimationStateDefinition[]; + readonly anyStateTransitions?: readonly AnimationTransitionDefinition[]; +} + +export interface AnimationIkJobDefinition { + readonly id: AnimationIkJobId | string; + readonly solver: AnimationIkSolver; + readonly rootBone: string; + readonly tipBone: string; + readonly targetPosition?: AnimationVector3Tuple; + readonly targetRotation?: AnimationQuaternionTuple; + readonly targetBone?: string; + readonly precision?: number; + readonly maxIterations?: number; + readonly weight?: number; + readonly preserveTipRotation?: boolean; +} + +export interface AnimationIkLayerDefinition { + readonly id: string; + readonly weight?: number; + readonly jobs: readonly AnimationIkJobDefinition[]; +} + +export interface AnimationLayerDefinition { + readonly id: AnimationLayerId | string; + readonly weight?: number; + readonly mode?: AnimationLayerBlendMode; + readonly boneMask?: readonly string[]; + readonly stateMachine: AnimationStateMachineDefinition; + readonly ikLayers?: readonly AnimationIkLayerDefinition[]; +} + +export interface AnimationRetargetBoneMappingDefinition { + readonly sourceBone: string; + readonly targetBone: string; + readonly translationMode?: AnimationRetargetTranslationMode; + readonly rotationMode?: AnimationRetargetRotationMode; + readonly scaleTranslation?: number; +} + +export interface AnimationRetargetProfileDefinition { + readonly id?: AnimationRetargetProfileId | string; + readonly sourceRig: AnimationRigDefinition; + readonly targetRig: AnimationRigDefinition; + readonly mappings?: readonly AnimationRetargetBoneMappingDefinition[]; +} + +export interface AnimationControllerDefinition< + TParameters extends readonly AnimationParameterDefinition[] = readonly AnimationParameterDefinition[], +> { + readonly rig: AnimationRigDefinition; + readonly clips: readonly AnimationClipDefinition[]; + readonly layers: readonly AnimationLayerDefinition[]; + readonly parameters?: TParameters; + readonly rootMotion?: AnimationRootMotionDefinition | null; +} + +export interface AnimationCurveBindingDefinition { + readonly id: AnimationCurveId | string; + readonly componentCount: number; +} + +export interface AnimationRootMotionDelta { + readonly translation: readonly [number, number, number]; + readonly rotation: readonly [number, number, number, number]; +} + +export interface AnimationControllerEvent extends AnimationClipEventOccurrence { + readonly layerId: string; + readonly stateId: string; + readonly layerWeight: number; + readonly motionWeight: number; +} + +export interface AnimationControllerClipActivity { + readonly clipId: string; + readonly layerId: string; + readonly stateId: string; + readonly layerWeight: number; + readonly motionWeight: number; + readonly loop: boolean; + readonly time: number; + readonly normalizedTime: number; +} + +export interface AnimationControllerLayerProfile { + readonly layerId: string; + readonly stateId: string; + readonly normalizedTime: number; + readonly weight: number; + readonly transitioning: boolean; + readonly transitionProgress?: number; +} + +export interface AnimationControllerProfile { + readonly evaluationTimeMs: number; + readonly sampledTrackCount: number; + readonly activeClipCount: number; + readonly emittedEventCount: number; + readonly rootMotionTranslationMagnitude: number; + readonly rootMotionRotationW: number; + readonly activeLayers: readonly AnimationControllerLayerProfile[]; +} + +export interface AnimationIkTarget { + readonly position: AnimationVector3Tuple; + readonly rotation?: AnimationQuaternionTuple; +} + +export interface AnimationMotionMatchQuery { + readonly requiredTags?: readonly string[]; + readonly excludedTags?: readonly string[]; + readonly desiredTrajectoryPosition?: AnimationVector3Tuple; + readonly desiredFacingDirection?: AnimationVector3Tuple; + readonly currentClipId?: string | null; + readonly continuityBias?: number; + readonly maxResults?: number; +} + +export interface AnimationMotionMatchResult { + readonly clipId: string; + readonly time: number; + readonly score: number; + readonly tags: readonly string[]; +} + +export interface AnimationGroundingContactResult { + readonly bone: string; + readonly weight: number; + readonly groundOffset: number; + readonly lockTranslationAxes?: readonly [boolean, boolean, boolean]; +} + +export interface AnimationGroundingResult { + readonly rootOffset: AnimationVector3Tuple; + readonly contacts: readonly AnimationGroundingContactResult[]; +} \ No newline at end of file diff --git a/web/packages/ecs-events/tsconfig.build.json b/web/packages/animation/tsconfig.build.json similarity index 100% rename from web/packages/ecs-events/tsconfig.build.json rename to web/packages/animation/tsconfig.build.json diff --git a/web/packages/asset-2d/package.json b/web/packages/asset-2d/package.json index d7e81562..f7946eb7 100644 --- a/web/packages/asset-2d/package.json +++ b/web/packages/asset-2d/package.json @@ -21,6 +21,7 @@ "test": "vitest run" }, "dependencies": { - "@axrone/asset-core": "^0.1.0" + "@axrone/asset-core": "^0.1.0", + "@axrone/utility": "^0.0.1" } } \ No newline at end of file diff --git a/web/packages/asset-2d/src/__tests__/sprite-atlas-importer.test.ts b/web/packages/asset-2d/src/__tests__/sprite-atlas-importer.test.ts new file mode 100644 index 00000000..f9e4893c --- /dev/null +++ b/web/packages/asset-2d/src/__tests__/sprite-atlas-importer.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { AssetDatabase } from '@axrone/asset-core'; +import { + createAsset2DImportPipeline, + type Asset2DImportSchema, +} from '../sprite-atlas-importer'; + +describe('asset-2d sprite atlas import pipeline', () => { + it('imports canonical atlas JSON into the normalized sprite atlas schema', async () => { + const database = new AssetDatabase({ + pipeline: createAsset2DImportPipeline(), + }); + + const receipt = await database.import({ + kind: 'json', + uri: 'content/hero.spriteatlas.json', + data: { + id: 'atlas/hero', + textureId: 'hero-texture', + textureSize: { width: 64, height: 32 }, + frames: [ + { + id: 'hero/idle-0', + region: { x: 0, y: 0, width: 32, height: 32 }, + sourceSize: { width: 32, height: 32 }, + }, + ], + animations: [ + { + id: 'idle', + frames: ['hero/idle-0'], + }, + ], + }, + }); + + expect(receipt.importerId).toBe('asset-2d.sprite-atlas.json'); + expect(receipt.primary.kind).toBe('spriteAtlas'); + expect(receipt.primary.data.textureId).toBe('hero-texture'); + expect(receipt.primary.data.frames[0]?.id).toBe('hero/idle-0'); + expect(receipt.primary.data.animations[0]?.frames[0]?.frameId).toBe('hero/idle-0'); + }); + + it('imports TexturePacker atlas JSON and derives a canonical sprite atlas definition', async () => { + const database = new AssetDatabase({ + pipeline: createAsset2DImportPipeline(), + }); + + const receipt = await database.import({ + kind: 'text', + uri: 'content/ui.atlas.json', + mimeType: 'application/json', + data: JSON.stringify({ + frames: { + 'panel/default.png': { + frame: { x: 0, y: 0, w: 18, h: 18 }, + rotated: false, + trimmed: false, + sourceSize: { w: 18, h: 18 }, + spriteSourceSize: { x: 0, y: 0, w: 18, h: 18 }, + pivot: { x: 0.5, y: 0.5 }, + duration: 90, + }, + }, + meta: { + image: 'ui-atlas-texture', + size: { w: 18, h: 18 }, + scale: '1', + }, + }), + }); + + expect(receipt.importerId).toBe('asset-2d.sprite-atlas.texturepacker'); + expect(receipt.primary.kind).toBe('spriteAtlas'); + expect(receipt.primary.data.textureId).toBe('ui-atlas-texture'); + expect(receipt.primary.data.frames[0]?.sourceSize.width).toBe(18); + expect(receipt.primary.data.frames[0]?.pivot.x).toBe(0.5); + }); +}); diff --git a/web/packages/asset-2d/src/__tests__/sprite-atlas.test.ts b/web/packages/asset-2d/src/__tests__/sprite-atlas.test.ts new file mode 100644 index 00000000..60d9854d --- /dev/null +++ b/web/packages/asset-2d/src/__tests__/sprite-atlas.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { + createSpriteAtlas, + serializeSpriteAtlasDefinition, +} from '../sprite-atlas'; + +describe('createSpriteAtlas', () => { + it('normalizes regions into uv space and resolves animation clips', () => { + const atlas = createSpriteAtlas({ + id: 'atlas/ui', + textureId: 'ui-atlas', + textureSize: { width: 128, height: 64 }, + frames: [ + { + id: 'idle/0', + region: { x: 0, y: 0, width: 32, height: 16 }, + sourceSize: { width: 32, height: 16 }, + sliceBorder: { left: 4, right: 4, top: 4, bottom: 4 }, + }, + { + id: 'idle/1', + region: { x: 32, y: 0, width: 32, height: 16 }, + durationMs: 40, + }, + ], + animations: [ + { + id: 'idle', + fps: 10, + frames: ['idle/0', { frameId: 'idle/1', durationMs: 80 }], + }, + ], + }); + + const firstFrame = atlas.getFrame('idle/0'); + const clip = atlas.getAnimation('idle'); + + expect(firstFrame?.textureId).toBe('ui-atlas'); + expect(firstFrame?.uvRect.x).toBe(0); + expect(firstFrame?.uvRect.y).toBe(0); + expect(firstFrame?.uvRect.width).toBe(0.25); + expect(firstFrame?.uvRect.height).toBe(0.25); + expect(firstFrame?.sliceBorder?.left).toBe(4); + + expect(clip?.frames).toHaveLength(2); + expect(clip?.frames[0]?.durationMs).toBe(100); + expect(clip?.frames[1]?.durationMs).toBe(80); + expect(clip?.durationMs).toBe(180); + expect(clip?.loop).toBe(true); + }); + + it('rejects duplicate frame ids', () => { + expect(() => + createSpriteAtlas({ + id: 'atlas/dup', + textureId: 'dup', + textureSize: { width: 64, height: 64 }, + frames: [ + { id: 'frame', region: { x: 0, y: 0, width: 16, height: 16 } }, + { id: 'frame', region: { x: 16, y: 0, width: 16, height: 16 } }, + ], + }) + ).toThrow(/Duplicate sprite atlas frame id/); + }); + + it('round-trips atlas definitions through serialization', () => { + const atlas = createSpriteAtlas({ + id: 'atlas/panel', + textureId: 'panel-texture', + textureSize: { width: 36, height: 18 }, + frames: [ + { + id: 'panel/default', + region: { x: 0, y: 0, width: 18, height: 18 }, + sourceSize: { width: 18, height: 18 }, + sliceBorder: { left: 6, right: 6, top: 6, bottom: 6 }, + }, + ], + animations: [ + { + id: 'pulse', + loop: false, + frames: [{ frameId: 'panel/default', durationMs: 120 }], + }, + ], + }); + + const cloned = createSpriteAtlas(serializeSpriteAtlasDefinition(atlas)); + + expect(cloned.textureId).toBe('panel-texture'); + expect(cloned.frames[0]?.sliceBorder).toEqual({ + left: 6, + right: 6, + top: 6, + bottom: 6, + }); + expect(cloned.animations[0]?.frames[0]?.durationMs).toBe(120); + }); +}); \ No newline at end of file diff --git a/web/packages/asset-2d/src/index.ts b/web/packages/asset-2d/src/index.ts index 1ea28915..53aaeb3b 100644 --- a/web/packages/asset-2d/src/index.ts +++ b/web/packages/asset-2d/src/index.ts @@ -12,4 +12,38 @@ export type Asset2DCapability = typeof ASSET_2D_CAPABILITY; export const getAsset2DCapability = (): Asset2DCapability => ASSET_2D_CAPABILITY; +export type { + Asset2DBorderLike, + Asset2DRectLike, + Asset2DSizeLike, + Asset2DVec2Like, + SpriteAnimationClip, + SpriteAnimationClipDefinition, + SpriteAnimationFrame, + SpriteAnimationFrameDefinition, + SpriteAtlas, + SpriteAtlasDefinition, + SpriteAtlasFrame, + SpriteAtlasFrameDefinition, +} from './sprite-atlas'; +export { + Asset2DError, + Asset2DValidationError, + createSpriteAtlas, + getSpriteAnimationClip, + getSpriteAtlasFrame, + serializeSpriteAtlasDefinition, +} from './sprite-atlas'; +export type { + Asset2DImportKind, + Asset2DImportPipelineOptions, + Asset2DImportResult, + Asset2DImportSchema, +} from './sprite-atlas-importer'; +export { + createAsset2DImportPipeline, + createSpriteAtlasJsonImporter, + createTexturePackerSpriteAtlasImporter, +} from './sprite-atlas-importer'; + export * from '@axrone/asset-core'; \ No newline at end of file diff --git a/web/packages/asset-2d/src/sprite-atlas-importer.ts b/web/packages/asset-2d/src/sprite-atlas-importer.ts new file mode 100644 index 00000000..0f7e520e --- /dev/null +++ b/web/packages/asset-2d/src/sprite-atlas-importer.ts @@ -0,0 +1,339 @@ +import { AssetImportPipeline, type AssetImportPipelineOptions, type AssetImportSource, type AssetImporter, type AssetImportResult } from '@axrone/asset-core'; +import { isPlainObject } from '@axrone/utility'; +import { + createSpriteAtlas, + serializeSpriteAtlasDefinition, + type SpriteAnimationClipDefinition, + type SpriteAtlasDefinition, + type SpriteAtlasFrameDefinition, +} from './sprite-atlas'; + +export type Asset2DImportKind = 'spriteAtlas'; + +export type Asset2DImportSchema = { + readonly [key: string]: unknown; + readonly spriteAtlas: SpriteAtlasDefinition; +}; + +export type Asset2DImportResult = AssetImportResult; + +export interface Asset2DImportPipelineOptions + extends Omit, 'importers'> { + readonly importers?: readonly AssetImporter[]; +} + +interface TexturePackerAtlasFrameSourceSize { + readonly w: number; + readonly h: number; +} + +interface TexturePackerAtlasFramePivot { + readonly x: number; + readonly y: number; +} + +interface TexturePackerAtlasFrameRect { + readonly x: number; + readonly y: number; + readonly w: number; + readonly h: number; +} + +interface TexturePackerAtlasFrameEntry { + readonly frame: TexturePackerAtlasFrameRect; + readonly rotated?: boolean; + readonly trimmed?: boolean; + readonly sourceSize?: TexturePackerAtlasFrameSourceSize; + readonly spriteSourceSize?: TexturePackerAtlasFrameRect; + readonly pivot?: TexturePackerAtlasFramePivot; + readonly duration?: number; +} + +interface TexturePackerAtlasMeta { + readonly image?: string; + readonly size?: TexturePackerAtlasFrameSourceSize; + readonly scale?: string; + readonly frameTags?: readonly { + readonly name: string; + readonly from: number; + readonly to: number; + readonly direction?: 'forward' | 'reverse' | 'pingpong'; + }[]; +} + +interface TexturePackerAtlasPayload { + readonly frames: Readonly>; + readonly meta?: TexturePackerAtlasMeta; +} + +const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const isTexturePackerAtlasPayload = (value: unknown): value is TexturePackerAtlasPayload => + isPlainObject(value) && isPlainObject(value.frames); + +const isCanonicalSpriteAtlasPayload = (value: unknown): value is SpriteAtlasDefinition => + isPlainObject(value) && + typeof value.id === 'string' && + typeof value.textureId === 'string' && + isPlainObject(value.textureSize) && + isFiniteNumber(value.textureSize.width) && + isFiniteNumber(value.textureSize.height) && + Array.isArray(value.frames); + +const readJsonLikeSource = (source: AssetImportSource): unknown => { + if (source.kind === 'json') { + return source.data; + } + + if (source.kind === 'text') { + return JSON.parse(source.data) as unknown; + } + + throw new Error(`Unsupported sprite atlas import source kind: ${source.kind}`); +}; + +const deriveAtlasIdFromSource = (source: AssetImportSource, fallback: string): string => { + const uri = source.uri?.trim(); + if (!uri) { + return fallback; + } + + const leaf = uri.split(/[\\/]/).pop() ?? fallback; + return leaf + .replace(/\.spriteatlas\.json$/i, '') + .replace(/\.atlas\.json$/i, '') + .replace(/\.json$/i, '') + .replace(/\.[^.]+$/i, '') || fallback; +}; + +const normalizeCanonicalFrames = ( + frames: readonly SpriteAtlasFrameDefinition[] +): readonly SpriteAtlasFrameDefinition[] => + frames.map((frame) => ({ + id: frame.id, + region: { + x: frame.region.x, + y: frame.region.y, + width: frame.region.width, + height: frame.region.height, + }, + sourceSize: frame.sourceSize + ? { + width: frame.sourceSize.width, + height: frame.sourceSize.height, + } + : undefined, + pivot: frame.pivot + ? { + x: frame.pivot.x, + y: frame.pivot.y, + } + : undefined, + sliceBorder: frame.sliceBorder + ? { + left: frame.sliceBorder.left, + right: frame.sliceBorder.right, + top: frame.sliceBorder.top, + bottom: frame.sliceBorder.bottom, + } + : undefined, + durationMs: frame.durationMs, + })); + +const normalizeCanonicalAnimations = ( + animations: readonly SpriteAnimationClipDefinition[] | undefined +): readonly SpriteAnimationClipDefinition[] | undefined => + animations?.map((clip) => ({ + id: clip.id, + fps: clip.fps, + loop: clip.loop, + frames: clip.frames.map((entry) => + typeof entry === 'string' + ? entry + : { + frameId: entry.frameId, + durationMs: entry.durationMs, + } + ), + })); + +const normalizeCanonicalSpriteAtlasDefinition = ( + source: AssetImportSource, + payload: SpriteAtlasDefinition +): SpriteAtlasDefinition => { + const atlas = createSpriteAtlas({ + id: payload.id || deriveAtlasIdFromSource(source, 'sprite-atlas'), + textureId: payload.textureId, + textureSize: { + width: payload.textureSize.width, + height: payload.textureSize.height, + }, + frames: normalizeCanonicalFrames(payload.frames), + animations: normalizeCanonicalAnimations(payload.animations), + }); + + return serializeSpriteAtlasDefinition(atlas); +}; + +const normalizeTexturePackerFrame = ( + id: string, + entry: TexturePackerAtlasFrameEntry +): SpriteAtlasFrameDefinition => ({ + id, + region: { + x: entry.frame.x, + y: entry.frame.y, + width: entry.frame.w, + height: entry.frame.h, + }, + sourceSize: entry.sourceSize + ? { + width: entry.sourceSize.w, + height: entry.sourceSize.h, + } + : undefined, + pivot: entry.pivot + ? { + x: entry.pivot.x, + y: entry.pivot.y, + } + : undefined, + durationMs: isFiniteNumber(entry.duration) ? entry.duration : undefined, +}); + +const normalizeTexturePackerAtlasDefinition = ( + source: AssetImportSource, + payload: TexturePackerAtlasPayload +): SpriteAtlasDefinition => { + const meta = payload.meta ?? {}; + const textureSize = meta.size; + if (!textureSize) { + throw new Error('TexturePacker atlas metadata is missing the source texture size'); + } + + const textureId = meta.image?.trim() || deriveAtlasIdFromSource(source, 'sprite-atlas'); + const frames = Object.entries(payload.frames).map(([id, entry]) => + normalizeTexturePackerFrame(id, entry) + ); + + const animations = meta.frameTags?.length + ? meta.frameTags.map((tag) => ({ + id: tag.name, + loop: tag.direction !== 'reverse', + frames: Array.from( + { length: Math.max(0, tag.to - tag.from + 1) }, + (_, index) => { + const frameIndex = tag.direction === 'reverse' ? tag.to - index : tag.from + index; + const frame = frames[frameIndex]; + if (!frame) { + throw new Error( + `TexturePacker frame tag '${tag.name}' references missing frame index ${frameIndex}` + ); + } + + return { + frameId: frame.id, + durationMs: frame.durationMs ?? 1000 / 12, + }; + } + ), + })) + : undefined; + + const atlas = createSpriteAtlas({ + id: deriveAtlasIdFromSource(source, textureId), + textureId, + textureSize: { + width: textureSize.w, + height: textureSize.h, + }, + frames, + animations, + }); + + return serializeSpriteAtlasDefinition(atlas); +}; + +export const createSpriteAtlasJsonImporter = (): AssetImporter => ({ + id: 'asset-2d.sprite-atlas.json', + priority: 20, + sourceKinds: ['json', 'text'], + extensions: ['spriteatlas.json', 'atlas.json', 'json'], + canImport: ({ source }) => { + try { + return isCanonicalSpriteAtlasPayload(readJsonLikeSource(source)); + } catch { + return false; + } + }, + import: ({ source }) => { + const payload = readJsonLikeSource(source); + if (!isCanonicalSpriteAtlasPayload(payload)) { + throw new Error('Source does not contain a canonical sprite atlas definition'); + } + + const definition = normalizeCanonicalSpriteAtlasDefinition(source, payload); + return { + primary: { + kind: 'spriteAtlas', + data: definition, + name: definition.id, + metadata: source.uri + ? { + uri: source.uri, + mimeType: source.mimeType, + } + : undefined, + }, + }; + }, +}); + +export const createTexturePackerSpriteAtlasImporter = (): AssetImporter => ({ + id: 'asset-2d.sprite-atlas.texturepacker', + priority: 10, + sourceKinds: ['json', 'text'], + extensions: ['json'], + canImport: ({ source }) => { + try { + const payload = readJsonLikeSource(source); + return isTexturePackerAtlasPayload(payload); + } catch { + return false; + } + }, + import: ({ source }) => { + const payload = readJsonLikeSource(source); + if (!isTexturePackerAtlasPayload(payload)) { + throw new Error('Source does not contain a TexturePacker atlas payload'); + } + + const definition = normalizeTexturePackerAtlasDefinition(source, payload); + return { + primary: { + kind: 'spriteAtlas', + data: definition, + name: definition.id, + metadata: source.uri + ? { + uri: source.uri, + mimeType: source.mimeType, + } + : undefined, + }, + }; + }, +}); + +export const createAsset2DImportPipeline = ( + options: Asset2DImportPipelineOptions = {} +): AssetImportPipeline => + new AssetImportPipeline({ + ...options, + importers: [ + createSpriteAtlasJsonImporter(), + createTexturePackerSpriteAtlasImporter(), + ...(options.importers ?? []), + ], + }); diff --git a/web/packages/asset-2d/src/sprite-atlas.ts b/web/packages/asset-2d/src/sprite-atlas.ts new file mode 100644 index 00000000..75bd433b --- /dev/null +++ b/web/packages/asset-2d/src/sprite-atlas.ts @@ -0,0 +1,359 @@ +export interface Asset2DVec2Like { + readonly x: number; + readonly y: number; +} + +export interface Asset2DSizeLike { + readonly width: number; + readonly height: number; +} + +export interface Asset2DRectLike extends Asset2DVec2Like, Asset2DSizeLike {} + +export interface Asset2DBorderLike { + readonly left: number; + readonly right: number; + readonly top: number; + readonly bottom: number; +} + +export interface SpriteAtlasFrameDefinition { + readonly id: string; + readonly region: Asset2DRectLike; + readonly sourceSize?: Asset2DSizeLike; + readonly pivot?: Asset2DVec2Like; + readonly sliceBorder?: Asset2DBorderLike; + readonly durationMs?: number; +} + +export interface SpriteAnimationFrameDefinition { + readonly frameId: string; + readonly durationMs?: number; +} + +export interface SpriteAnimationClipDefinition { + readonly id: string; + readonly frames: readonly (string | SpriteAnimationFrameDefinition)[]; + readonly fps?: number; + readonly loop?: boolean; +} + +export interface SpriteAtlasDefinition { + readonly id: string; + readonly textureId: string; + readonly textureSize: Asset2DSizeLike; + readonly frames: readonly SpriteAtlasFrameDefinition[]; + readonly animations?: readonly SpriteAnimationClipDefinition[]; +} + +export interface SpriteAtlasFrame { + readonly id: string; + readonly textureId: string; + readonly region: Readonly; + readonly sourceSize: Readonly; + readonly uvRect: Readonly; + readonly pivot: Readonly; + readonly sliceBorder: Readonly | null; + readonly durationMs: number | null; +} + +export interface SpriteAnimationFrame { + readonly frame: SpriteAtlasFrame; + readonly durationMs: number; +} + +export interface SpriteAnimationClip { + readonly id: string; + readonly frames: readonly SpriteAnimationFrame[]; + readonly durationMs: number; + readonly loop: boolean; +} + +export interface SpriteAtlas { + readonly id: string; + readonly textureId: string; + readonly textureSize: Readonly; + readonly frames: readonly SpriteAtlasFrame[]; + readonly animations: readonly SpriteAnimationClip[]; + getFrame(id: string): SpriteAtlasFrame | undefined; + getAnimation(id: string): SpriteAnimationClip | undefined; +} + +export class Asset2DError extends Error { + constructor(message: string, readonly code: string, readonly cause?: unknown) { + super(message); + this.name = 'Asset2DError'; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class Asset2DValidationError extends Asset2DError { + constructor(message: string, cause?: unknown) { + super(message, 'ASSET_2D_VALIDATION_ERROR', cause); + this.name = 'Asset2DValidationError'; + } +} + +const freezeVec2 = (value: Asset2DVec2Like): Readonly => + Object.freeze({ x: value.x, y: value.y }); + +const freezeSize = (value: Asset2DSizeLike): Readonly => + Object.freeze({ width: value.width, height: value.height }); + +const freezeRect = (value: Asset2DRectLike): Readonly => + Object.freeze({ x: value.x, y: value.y, width: value.width, height: value.height }); + +const freezeBorder = ( + value: Asset2DBorderLike | undefined +): Readonly | null => + value + ? Object.freeze({ + left: value.left, + right: value.right, + top: value.top, + bottom: value.bottom, + }) + : null; + +const assertFinite = (label: string, value: number): void => { + if (!Number.isFinite(value)) { + throw new Asset2DValidationError(`${label} must be a finite number`); + } +}; + +const assertPositive = (label: string, value: number): void => { + assertFinite(label, value); + if (value <= 0) { + throw new Asset2DValidationError(`${label} must be greater than zero`); + } +}; + +const assertNonNegative = (label: string, value: number): void => { + assertFinite(label, value); + if (value < 0) { + throw new Asset2DValidationError(`${label} must be zero or greater`); + } +}; + +const normalizeClipFrame = ( + entry: string | SpriteAnimationFrameDefinition +): SpriteAnimationFrameDefinition => + typeof entry === 'string' + ? Object.freeze({ frameId: entry }) + : Object.freeze({ frameId: entry.frameId, durationMs: entry.durationMs }); + +const resolveFrameDuration = ( + frame: SpriteAtlasFrame, + entry: SpriteAnimationFrameDefinition, + fallbackDurationMs: number +): number => { + const resolved = entry.durationMs ?? frame.durationMs ?? fallbackDurationMs; + assertPositive(`Animation frame duration for '${frame.id}'`, resolved); + return resolved; +}; + +export const createSpriteAtlas = (definition: SpriteAtlasDefinition): SpriteAtlas => { + if (!definition.id) { + throw new Asset2DValidationError('Sprite atlas id is required'); + } + + if (!definition.textureId) { + throw new Asset2DValidationError('Sprite atlas textureId is required'); + } + + assertPositive('Sprite atlas texture width', definition.textureSize.width); + assertPositive('Sprite atlas texture height', definition.textureSize.height); + + const textureSize = freezeSize(definition.textureSize); + const frameMap = new Map(); + const frames = definition.frames.map((frameDefinition) => { + if (!frameDefinition.id) { + throw new Asset2DValidationError('Sprite atlas frame id is required'); + } + + if (frameMap.has(frameDefinition.id)) { + throw new Asset2DValidationError( + `Duplicate sprite atlas frame id '${frameDefinition.id}'` + ); + } + + assertNonNegative(`Frame '${frameDefinition.id}' region.x`, frameDefinition.region.x); + assertNonNegative(`Frame '${frameDefinition.id}' region.y`, frameDefinition.region.y); + assertPositive(`Frame '${frameDefinition.id}' region.width`, frameDefinition.region.width); + assertPositive( + `Frame '${frameDefinition.id}' region.height`, + frameDefinition.region.height + ); + + if ( + frameDefinition.region.x + frameDefinition.region.width > textureSize.width || + frameDefinition.region.y + frameDefinition.region.height > textureSize.height + ) { + throw new Asset2DValidationError( + `Frame '${frameDefinition.id}' exceeds the atlas texture bounds` + ); + } + + const sourceSize = freezeSize( + frameDefinition.sourceSize ?? { + width: frameDefinition.region.width, + height: frameDefinition.region.height, + } + ); + assertPositive(`Frame '${frameDefinition.id}' source width`, sourceSize.width); + assertPositive(`Frame '${frameDefinition.id}' source height`, sourceSize.height); + + const pivot = freezeVec2(frameDefinition.pivot ?? { x: 0.5, y: 0.5 }); + assertFinite(`Frame '${frameDefinition.id}' pivot.x`, pivot.x); + assertFinite(`Frame '${frameDefinition.id}' pivot.y`, pivot.y); + + const sliceBorder = freezeBorder(frameDefinition.sliceBorder); + if (sliceBorder) { + assertNonNegative(`Frame '${frameDefinition.id}' sliceBorder.left`, sliceBorder.left); + assertNonNegative(`Frame '${frameDefinition.id}' sliceBorder.right`, sliceBorder.right); + assertNonNegative(`Frame '${frameDefinition.id}' sliceBorder.top`, sliceBorder.top); + assertNonNegative( + `Frame '${frameDefinition.id}' sliceBorder.bottom`, + sliceBorder.bottom + ); + } + + if (frameDefinition.durationMs !== undefined) { + assertPositive( + `Frame '${frameDefinition.id}' durationMs`, + frameDefinition.durationMs + ); + } + + const frame = Object.freeze({ + id: frameDefinition.id, + textureId: definition.textureId, + region: freezeRect(frameDefinition.region), + sourceSize, + uvRect: freezeRect({ + x: frameDefinition.region.x / textureSize.width, + y: frameDefinition.region.y / textureSize.height, + width: frameDefinition.region.width / textureSize.width, + height: frameDefinition.region.height / textureSize.height, + }), + pivot, + sliceBorder, + durationMs: frameDefinition.durationMs ?? null, + } satisfies SpriteAtlasFrame); + + frameMap.set(frame.id, frame); + return frame; + }); + + const animationMap = new Map(); + const animations = (definition.animations ?? []).map((clipDefinition) => { + if (!clipDefinition.id) { + throw new Asset2DValidationError('Sprite animation clip id is required'); + } + + if (animationMap.has(clipDefinition.id)) { + throw new Asset2DValidationError( + `Duplicate sprite animation clip id '${clipDefinition.id}'` + ); + } + + if (clipDefinition.frames.length === 0) { + throw new Asset2DValidationError( + `Sprite animation clip '${clipDefinition.id}' must include at least one frame` + ); + } + + const fallbackDurationMs = 1000 / Math.max(1, clipDefinition.fps ?? 12); + const clipFrames = clipDefinition.frames.map((entry) => { + const normalizedEntry = normalizeClipFrame(entry); + const frame = frameMap.get(normalizedEntry.frameId); + + if (!frame) { + throw new Asset2DValidationError( + `Sprite animation clip '${clipDefinition.id}' references missing frame '${normalizedEntry.frameId}'` + ); + } + + return Object.freeze({ + frame, + durationMs: resolveFrameDuration(frame, normalizedEntry, fallbackDurationMs), + } satisfies SpriteAnimationFrame); + }); + + const durationMs = clipFrames.reduce((total, frame) => total + frame.durationMs, 0); + const clip = Object.freeze({ + id: clipDefinition.id, + frames: Object.freeze(clipFrames), + durationMs, + loop: clipDefinition.loop ?? true, + } satisfies SpriteAnimationClip); + + animationMap.set(clip.id, clip); + return clip; + }); + + return Object.freeze({ + id: definition.id, + textureId: definition.textureId, + textureSize, + frames: Object.freeze(frames), + animations: Object.freeze(animations), + getFrame: (id: string) => frameMap.get(id), + getAnimation: (id: string) => animationMap.get(id), + } satisfies SpriteAtlas); +}; + +export const getSpriteAtlasFrame = ( + atlas: SpriteAtlas, + frameId: string +): SpriteAtlasFrame | undefined => atlas.getFrame(frameId); + +export const getSpriteAnimationClip = ( + atlas: SpriteAtlas, + clipId: string +): SpriteAnimationClip | undefined => atlas.getAnimation(clipId); + +export const serializeSpriteAtlasDefinition = ( + atlas: SpriteAtlas +): SpriteAtlasDefinition => ({ + id: atlas.id, + textureId: atlas.textureId, + textureSize: { + width: atlas.textureSize.width, + height: atlas.textureSize.height, + }, + frames: atlas.frames.map((frame) => ({ + id: frame.id, + region: { + x: frame.region.x, + y: frame.region.y, + width: frame.region.width, + height: frame.region.height, + }, + sourceSize: { + width: frame.sourceSize.width, + height: frame.sourceSize.height, + }, + pivot: { + x: frame.pivot.x, + y: frame.pivot.y, + }, + sliceBorder: frame.sliceBorder + ? { + left: frame.sliceBorder.left, + right: frame.sliceBorder.right, + top: frame.sliceBorder.top, + bottom: frame.sliceBorder.bottom, + } + : undefined, + durationMs: frame.durationMs ?? undefined, + })), + animations: atlas.animations.map((clip) => ({ + id: clip.id, + loop: clip.loop, + frames: clip.frames.map((frame) => ({ + frameId: frame.frame.id, + durationMs: frame.durationMs, + })), + })), +}); \ No newline at end of file diff --git a/web/packages/asset-core/package.json b/web/packages/asset-core/package.json index 5aa04887..73a3f887 100644 --- a/web/packages/asset-core/package.json +++ b/web/packages/asset-core/package.json @@ -21,6 +21,7 @@ "test": "vitest run" }, "dependencies": { - "@axrone/random": "^0.0.1" + "@axrone/random": "^0.0.1", + "@axrone/utility": "^0.0.1" } -} \ No newline at end of file +} diff --git a/web/packages/asset-core/src/disposable.ts b/web/packages/asset-core/src/disposable.ts index 9318a614..45b3f049 100644 --- a/web/packages/asset-core/src/disposable.ts +++ b/web/packages/asset-core/src/disposable.ts @@ -1,4 +1 @@ -export interface IDisposable { - dispose(): void; - readonly isDisposed: boolean; -} \ No newline at end of file +export type { IDisposable } from '@axrone/utility'; diff --git a/web/packages/asset-core/src/internal/snapshot-serialization.ts b/web/packages/asset-core/src/internal/snapshot-serialization.ts index 53bb0204..07af143a 100644 --- a/web/packages/asset-core/src/internal/snapshot-serialization.ts +++ b/web/packages/asset-core/src/internal/snapshot-serialization.ts @@ -2,6 +2,7 @@ import { AssetSnapshotError, resolveAssetMessage, } from '../errors'; +import { isPlainObject, isRecord } from '@axrone/utility'; import type { AssetBinaryCodec, AssetBinaryPersistenceOptions, @@ -36,18 +37,6 @@ const BASE64_CODES = (() => { return table; })(); -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === 'object'; - -const isPlainObject = (value: unknown): value is Record => { - if (!isRecord(value)) { - return false; - } - - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -}; - export const isAssetJsonValue = (value: unknown): value is AssetJsonValue => { if ( value === null || diff --git a/web/packages/asset-core/src/internal/stored-asset.ts b/web/packages/asset-core/src/internal/stored-asset.ts index 0fd281e4..1dcd4798 100644 --- a/web/packages/asset-core/src/internal/stored-asset.ts +++ b/web/packages/asset-core/src/internal/stored-asset.ts @@ -2,6 +2,7 @@ import { getBytes, isTypedArrayView, } from './snapshot-serialization'; +import { isRecord } from '@axrone/utility'; import { asAssetFingerprint, asAssetId, @@ -26,9 +27,6 @@ import type { const EMPTY_STRING_ARRAY = Object.freeze([]) as readonly string[]; const EMPTY_PROPERTIES = Object.freeze({}) as Readonly>; -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === 'object'; - export const uniqueStrings = (values: readonly string[]): readonly string[] => { if (values.length === 0) { return EMPTY_STRING_ARRAY; diff --git a/web/packages/asset-gltf/package.json b/web/packages/asset-gltf/package.json index bbf72889..1ca38ee4 100644 --- a/web/packages/asset-gltf/package.json +++ b/web/packages/asset-gltf/package.json @@ -21,9 +21,12 @@ "test": "vitest run" }, "dependencies": { + "@axrone/animation": "^0.1.0", "@axrone/asset-core": "^0.1.0", "@axrone/numeric": "^0.0.1", + "@axrone/render-core": "^0.1.0", "@axrone/render-webgl2": "^0.1.0", + "@axrone/utility": "^0.0.1", "@loaders.gl/textures": "^4.4.1", "draco3dgltf": "^1.5.7", "meshoptimizer": "^1.0.1" diff --git a/web/packages/asset-gltf/src/__tests__/animation-streaming.test.ts b/web/packages/asset-gltf/src/__tests__/animation-streaming.test.ts new file mode 100644 index 00000000..19ee051a --- /dev/null +++ b/web/packages/asset-gltf/src/__tests__/animation-streaming.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { decodeAnimationClipStreamingChunkPayload } from '@axrone/animation'; +import { + createPortableAnimationManifestResource, + createPortableAnimationStreamingClipBundle, + DEFAULT_ANIMATION_STREAMING_CHUNK_MIME_TYPE, +} from '@axrone/asset-gltf'; + +describe('portable animation streaming authoring', () => { + it('builds chunk resources and manifest-ready clip metadata from animation tracks', () => { + const bundle = createPortableAnimationStreamingClipBundle({ + clip: { + id: 'Move', + duration: 2, + tags: ['locomotion'], + tracks: [ + { + target: 'node/1', + path: 'translation', + interpolation: 'LINEAR', + times: new Float32Array([0, 0.5, 1, 1.5, 2]), + values: new Float32Array([ + 0, 0, 0, + 0.5, 0, 0, + 1, 0, 0, + 1.5, 0, 0, + 2, 0, 0, + ]), + }, + ], + streaming: { + chunkDuration: 1, + preloadWindow: 0.5, + priority: 4, + }, + }, + sourceUri: 'clips/move.bin', + }); + + expect(bundle.clip).toMatchObject({ + id: 'Move', + tags: ['locomotion'], + streaming: expect.objectContaining({ + mode: 'streamed', + sourceUri: 'clips/move.bin', + chunkDuration: 1, + preloadWindow: 0.5, + priority: 4, + catalog: expect.objectContaining({ + id: 'move-stream', + chunks: [ + expect.objectContaining({ + id: 'move-0', + uri: 'clips/move.0.bin', + startTime: 0, + endTime: 1, + mimeType: DEFAULT_ANIMATION_STREAMING_CHUNK_MIME_TYPE, + }), + expect.objectContaining({ + id: 'move-1', + uri: 'clips/move.1.bin', + startTime: 1, + endTime: 2, + mimeType: DEFAULT_ANIMATION_STREAMING_CHUNK_MIME_TYPE, + }), + ], + }), + }), + }); + expect(bundle.resources).toHaveLength(2); + expect(bundle.resources[0]?.data).toBeInstanceOf(Uint8Array); + expect(bundle.resources[1]?.data).toBeInstanceOf(Uint8Array); + + const firstPayload = decodeAnimationClipStreamingChunkPayload(bundle.resources[0]!.data as Uint8Array); + const secondPayload = decodeAnimationClipStreamingChunkPayload(bundle.resources[1]!.data as Uint8Array); + + expect(firstPayload).toMatchObject({ + version: 1, + clipId: 'Move', + startTime: 0, + endTime: 1, + duration: 2, + }); + expect(Array.from(firstPayload.tracks[0]!.times)).toEqual([0, 0.5, 1, 1.5]); + expect(Array.from(secondPayload.tracks[0]!.times)).toEqual([0.5, 1, 1.5, 2]); + + const manifestResource = createPortableAnimationManifestResource('rig.animation-manifest.json', { + clips: [bundle.clip], + }); + + expect(manifestResource.mimeType).toBe('application/json'); + expect(JSON.parse(manifestResource.data as string)).toMatchObject({ + clips: [ + { + id: 'Move', + streaming: expect.objectContaining({ + catalog: expect.objectContaining({ + chunks: [ + expect.objectContaining({ + uri: 'clips/move.0.bin', + }), + expect.objectContaining({ + uri: 'clips/move.1.bin', + }), + ], + }), + }), + }, + ], + }); + }); +}); \ No newline at end of file diff --git a/web/packages/asset-gltf/src/__tests__/gltf-importer.test.ts b/web/packages/asset-gltf/src/__tests__/gltf-importer.test.ts index 188ddade..b51b7eff 100644 --- a/web/packages/asset-gltf/src/__tests__/gltf-importer.test.ts +++ b/web/packages/asset-gltf/src/__tests__/gltf-importer.test.ts @@ -6,6 +6,8 @@ import { } from '@axrone/asset-core'; import { createGltfImporter, + createPortableAnimationManifestResource, + createPortableAnimationStreamingClipBundle, createGltfTextureTranscodeStage, createPassthroughGltfTextureTranscoder, GltfTextureTranscoderRegistry, @@ -19,6 +21,7 @@ const triangleIndices = new Uint16Array([0, 1, 2]); const pngHeaderBytes = new Uint8Array([137, 80, 78, 71]); let dracoEncoderModulePromise: Promise | undefined; +let dracoDecoderModulePromise: Promise | undefined; const loadDracoEncoderModule = async (): Promise => { dracoEncoderModulePromise ??= import('draco3dgltf').then((module) => @@ -27,6 +30,13 @@ const loadDracoEncoderModule = async (): Promise => { return dracoEncoderModulePromise; }; +const loadDracoDecoderModule = async (): Promise => { + dracoDecoderModulePromise ??= import('draco3dgltf').then((module) => + module.createDecoderModule({}) + ); + return dracoDecoderModulePromise; +}; + const createBinaryBlob = (): Uint8Array => { const image = pngHeaderBytes; const total = trianglePositions.byteLength + triangleIndices.byteLength + image.byteLength + 2; @@ -594,7 +604,7 @@ const createRigBinaryBlob = (): Uint8Array => { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, - 0, 0, 0, 1, + -0.5, 0.25, 0, 1, ]); const animationTimes = new Float32Array([0, 1]); const animationTranslations = new Float32Array([ @@ -796,6 +806,212 @@ const createRigJson = (): GltfRootJson => ({ scene: 0, }); +const createRigJsonWithAnimationMetadata = (): GltfRootJson => { + const base = createRigJson(); + return { + ...base, + scenes: [ + { + ...base.scenes![0]!, + extras: { + axrone: { + animation: { + parameters: [ + { + name: 'speed', + kind: 'float', + defaultValue: 0.25, + }, + ], + layers: [ + { + id: 'base', + weight: 1, + stateMachine: { + entryState: 'move', + states: [ + { + id: 'move', + motion: { + kind: 'clip', + clipId: 'Move', + }, + loop: true, + }, + ], + }, + ikLayers: [ + { + id: 'reach', + jobs: [ + { + id: 'aim', + solver: 'ccd', + rootBone: 'node/0', + tipBone: 'node/1', + targetPosition: [1, 0, 0], + maxIterations: 8, + }, + ], + }, + ], + }, + ], + rootMotion: { + bone: 'node/1', + consume: true, + projectTranslationAxes: [true, false, false], + }, + }, + }, + }, + }, + ], + }; +}; + +const createPortableRigAnimationManifest = () => ({ + controller: { + parameters: [ + { + name: 'speed', + kind: 'float', + defaultValue: 0.5, + }, + ], + layers: [ + { + id: 'base', + weight: 1, + stateMachine: { + entryState: 'move', + states: [ + { + id: 'move', + motion: { + kind: 'clip', + clipId: 'Move', + }, + loop: true, + }, + ], + }, + }, + ], + rootMotion: { + bone: 'node/1', + consume: true, + projectTranslationAxes: [true, false, false], + }, + }, + clips: [ + { + id: 'Move', + tags: ['locomotion'], + events: [ + { + id: 'step', + name: 'footstep', + time: 0.5, + tags: ['sfx'], + payload: { + surface: 'stone', + }, + }, + ], + footContacts: [ + { + bone: 'node/1', + startTime: 0, + endTime: 0.5, + lockTranslationAxes: [true, true, true], + }, + ], + features: [ + { + time: 0.5, + trajectoryPosition: [1, 0, 0], + facingDirection: [1, 0, 0], + tags: ['forward'], + }, + ], + compression: { + codec: 'keyframe-reduced', + positionTolerance: 0.001, + }, + streaming: { + mode: 'streamed', + chunkDuration: 0.25, + preloadWindow: 0.5, + priority: 2, + sourceUri: 'clips/move.bin', + catalog: { + id: 'move-stream', + chunks: [ + { + id: 'move-0', + uri: 'clips/move.0.bin', + startTime: 0, + endTime: 0.5, + byteOffset: 0, + byteLength: 128, + mimeType: 'application/octet-stream', + }, + { + id: 'move-1', + uri: 'clips/move.1.bin', + startTime: 0.5, + endTime: 1, + byteOffset: 128, + byteLength: 128, + mimeType: 'application/octet-stream', + }, + ], + }, + }, + }, + ], +}); + +const createPortableRigFeatureExportManifest = () => ({ + clips: [ + { + id: 'Move', + tags: ['global-locomotion'], + featureExport: { + rootNodeId: 'node/1', + sampleInterval: 0.5, + forwardAxis: [0, 0, 1], + tags: ['derived'], + costBias: 1.5, + }, + streaming: { + mode: 'streamed', + chunkDuration: 0.5, + priority: 1, + sourceUri: 'clips/move.bin', + }, + }, + ], + scenes: [ + { + scene: 0, + clips: [ + { + id: 'Move', + tags: ['scene-override'], + streaming: { + mode: 'streamed', + chunkDuration: 0.5, + priority: 7, + sourceUri: 'clips/move-scene.bin', + }, + }, + ], + }, + ], +}); + const createMorphBinaryBlob = (): Uint8Array => { const morphPositions = new Float32Array([ 1, 0, 0, @@ -972,7 +1188,6 @@ describe('glTF importer', () => { state: { status: 'transcoded', transcoderId: 'test.texture.transcoder', - targetFormat: TextureFormat.BC7_RGBA, }, diagnostics: [ { @@ -1239,8 +1454,8 @@ describe('glTF importer', () => { }); expect(skin?.data.inverseBindMatrices).toEqual( new Float32Array([ - 1, 0, 0, 0, - 0, 1, 0, 0, + 1, 0, 0, -0.5, + 0, 1, 0, 0.25, 0, 0, 1, 0, 0, 0, 0, 1, ]) @@ -1366,6 +1581,457 @@ describe('glTF importer', () => { ); }); + it('emits scene-level animation controller metadata into prefab animator snapshots', async () => { + const database = new AssetDatabase({ + importers: [createGltfImporter()], + }); + + const receipt = await database.import({ + kind: 'bytes', + data: createGlb(createRigJsonWithAnimationMetadata(), createRigBinaryBlob()), + uri: 'models/rig-metadata.glb', + mimeType: 'model/gltf-binary', + }); + + const scene = receipt.primary.data.scenes[0]; + const prefab = receipt.assets.find((entry) => entry.kind === 'gltf.prefab'); + const animator = prefab?.data.definition.actors[0]?.components.find( + (component) => component.type === 'Animator' + ); + + expect(scene?.animationController).toMatchObject({ + parameters: [ + expect.objectContaining({ + name: 'speed', + kind: 'float', + defaultValue: 0.25, + }), + ], + layers: [ + expect.objectContaining({ + id: 'base', + stateMachine: expect.objectContaining({ + entryState: 'move', + }), + ikLayers: [ + expect.objectContaining({ + id: 'reach', + jobs: [ + expect.objectContaining({ + id: 'aim', + solver: 'ccd', + rootBone: 'node/0', + tipBone: 'node/1', + }), + ], + }), + ], + }), + ], + rootMotion: expect.objectContaining({ + bone: 'node/1', + consume: true, + projectTranslationAxes: [true, false, false], + }), + }); + expect(prefab?.data.animationController).toMatchObject(scene?.animationController ?? {}); + expect(animator?.data).toMatchObject({ + parameters: [ + expect.objectContaining({ + name: 'speed', + kind: 'float', + }), + ], + layers: [ + expect.objectContaining({ + id: 'base', + stateMachine: expect.objectContaining({ + entryState: 'move', + }), + }), + ], + rootMotion: expect.objectContaining({ + bone: 'node/1', + }), + clipId: 'Move', + playing: true, + }); + }); + + it('ingests portable sidecar animation manifests and propagates clip metadata into imported assets', async () => { + const database = new AssetDatabase({ + importers: [createGltfImporter()], + }); + const packageJson: GltfRootJson = { + ...createRigJson(), + buffers: [ + { + uri: 'rig.bin', + byteLength: 212, + }, + ], + }; + + const receipt = await database.import({ + kind: 'custom', + format: 'gltf-package', + data: { + json: packageJson, + resources: [ + { + uri: 'rig.bin', + data: createRigBinaryBlob(), + mimeType: 'application/octet-stream', + }, + createPortableAnimationManifestResource( + 'rig.animation-manifest.json', + createPortableRigAnimationManifest() + ), + ], + }, + uri: 'models/rig.gltf', + mimeType: 'model/gltf+json', + }); + + const scene = receipt.primary.data.scenes[0]; + const prefab = receipt.assets.find((entry) => entry.kind === 'gltf.prefab'); + const animation = receipt.assets.find((entry) => entry.kind === 'gltf.animation'); + const animator = prefab?.data.definition.actors[0]?.components.find( + (component) => component.type === 'Animator' + ); + + expect(animation?.data).toMatchObject({ + id: 'Move', + tags: ['locomotion'], + events: [ + expect.objectContaining({ + id: 'step', + name: 'footstep', + }), + ], + footContacts: [ + expect.objectContaining({ + bone: 'node/1', + }), + ], + features: [ + expect.objectContaining({ + trajectoryPosition: [1, 0, 0], + }), + ], + compression: expect.objectContaining({ + codec: 'keyframe-reduced', + }), + streaming: expect.objectContaining({ + mode: 'streamed', + sourceUri: 'clips/move.bin', + catalog: expect.objectContaining({ + id: 'move-stream', + chunks: [ + expect.objectContaining({ + id: 'move-0', + uri: 'clips/move.0.bin', + }), + expect.objectContaining({ + id: 'move-1', + uri: 'clips/move.1.bin', + }), + ], + }), + }), + }); + expect(scene?.animationController).toMatchObject({ + parameters: [ + expect.objectContaining({ + name: 'speed', + kind: 'float', + }), + ], + layers: [ + expect.objectContaining({ + id: 'base', + }), + ], + rootMotion: expect.objectContaining({ + bone: 'node/1', + }), + clips: expect.arrayContaining([ + expect.objectContaining({ + id: 'Move', + tags: ['locomotion'], + streaming: expect.objectContaining({ + mode: 'streamed', + catalog: expect.objectContaining({ + chunks: expect.arrayContaining([ + expect.objectContaining({ + uri: 'clips/move.0.bin', + }), + ]), + }), + }), + }), + ]), + }); + expect(prefab?.data.animationController).toMatchObject(scene?.animationController ?? {}); + expect(animator?.data).toMatchObject({ + parameters: [ + expect.objectContaining({ + name: 'speed', + }), + ], + layers: [ + expect.objectContaining({ + id: 'base', + }), + ], + clips: expect.arrayContaining([ + expect.objectContaining({ + id: 'Move', + tags: ['locomotion'], + events: expect.arrayContaining([ + expect.objectContaining({ + name: 'footstep', + }), + ]), + footContacts: expect.arrayContaining([ + expect.objectContaining({ + bone: 'node/1', + }), + ]), + streaming: expect.objectContaining({ + mode: 'streamed', + catalog: expect.objectContaining({ + chunks: expect.arrayContaining([ + expect.objectContaining({ + uri: 'clips/move.0.bin', + }), + ]), + }), + }), + }), + ]), + }); + }); + + it('derives motion features from featureExport hints and applies scene-specific clip overrides', async () => { + const database = new AssetDatabase({ + importers: [createGltfImporter()], + }); + const packageJson: GltfRootJson = { + ...createRigJson(), + buffers: [ + { + uri: 'rig.bin', + byteLength: 212, + }, + ], + }; + + const receipt = await database.import({ + kind: 'custom', + format: 'gltf-package', + data: { + json: packageJson, + resources: [ + { + uri: 'rig.bin', + data: createRigBinaryBlob(), + mimeType: 'application/octet-stream', + }, + createPortableAnimationManifestResource( + 'rig.animation-manifest.json', + createPortableRigFeatureExportManifest() + ), + ], + }, + uri: 'models/rig-feature-export.gltf', + mimeType: 'model/gltf+json', + }); + + const animation = receipt.assets.find((entry) => entry.kind === 'gltf.animation'); + const scene = receipt.primary.data.scenes[0]; + const prefab = receipt.assets.find((entry) => entry.kind === 'gltf.prefab'); + const animator = prefab?.data.definition.actors[0]?.components.find( + (component) => component.type === 'Animator' + ); + + expect(animation?.data).toMatchObject({ + id: 'Move', + tags: ['global-locomotion'], + features: [ + expect.objectContaining({ + time: 0, + trajectoryPosition: [0, 0, 0], + tags: ['derived'], + costBias: 1.5, + }), + expect.objectContaining({ + time: 0.5, + trajectoryPosition: [0.5, 0, 0], + facingDirection: [1, 0, 0], + }), + expect.objectContaining({ + time: 1, + trajectoryPosition: [1, 0, 0], + facingDirection: [1, 0, 0], + }), + ], + streaming: expect.objectContaining({ + priority: 1, + sourceUri: 'clips/move.bin', + }), + }); + expect(scene?.animationController).toMatchObject({ + clips: expect.arrayContaining([ + expect.objectContaining({ + id: 'Move', + tags: ['scene-override'], + features: expect.arrayContaining([ + expect.objectContaining({ + trajectoryPosition: [0.5, 0, 0], + }), + ]), + streaming: expect.objectContaining({ + priority: 7, + sourceUri: 'clips/move-scene.bin', + }), + }), + ]), + }); + expect(prefab?.data.animationController).toMatchObject(scene?.animationController ?? {}); + expect(animator?.data).toMatchObject({ + clips: expect.arrayContaining([ + expect.objectContaining({ + id: 'Move', + tags: ['scene-override'], + features: expect.arrayContaining([ + expect.objectContaining({ + time: 1, + trajectoryPosition: [1, 0, 0], + }), + ]), + streaming: expect.objectContaining({ + priority: 7, + }), + }), + ]), + }); + }); + + it('imports authored portable streamed chunk bundles through the sidecar manifest contract', async () => { + const database = new AssetDatabase({ + importers: [createGltfImporter()], + }); + const packageJson: GltfRootJson = { + ...createRigJson(), + buffers: [ + { + uri: 'rig.bin', + byteLength: 212, + }, + ], + }; + const streamedClip = createPortableAnimationStreamingClipBundle({ + clip: { + id: 'Move', + duration: 1, + tags: ['stream-authored'], + tracks: [ + { + target: 'node/1', + path: 'translation', + interpolation: 'LINEAR', + times: new Float32Array([0, 0.5, 1]), + values: new Float32Array([ + 0, 0, 0, + 0.5, 0, 0, + 1, 0, 0, + ]), + }, + ], + streaming: { + chunkDuration: 0.5, + preloadWindow: 0.5, + priority: 3, + }, + }, + sourceUri: 'clips/move.bin', + }); + + const receipt = await database.import({ + kind: 'custom', + format: 'gltf-package', + data: { + json: packageJson, + resources: [ + { + uri: 'rig.bin', + data: createRigBinaryBlob(), + mimeType: 'application/octet-stream', + }, + createPortableAnimationManifestResource('rig.animation-manifest.json', { + clips: [streamedClip.clip], + }), + ...streamedClip.resources, + ], + }, + uri: 'models/rig-streamed-bundle.gltf', + mimeType: 'model/gltf+json', + }); + + const animation = receipt.assets.find((entry) => entry.kind === 'gltf.animation'); + const prefab = receipt.assets.find((entry) => entry.kind === 'gltf.prefab'); + const animator = prefab?.data.definition.actors[0]?.components.find( + (component) => component.type === 'Animator' + ); + + expect(animation?.data).toMatchObject({ + id: 'Move', + tags: ['stream-authored'], + streaming: expect.objectContaining({ + mode: 'streamed', + sourceUri: 'clips/move.bin', + chunkDuration: 0.5, + preloadWindow: 0.5, + priority: 3, + catalog: expect.objectContaining({ + chunks: [ + expect.objectContaining({ + uri: 'clips/move.0.bin', + startTime: 0, + endTime: 0.5, + }), + expect.objectContaining({ + uri: 'clips/move.1.bin', + startTime: 0.5, + endTime: 1, + }), + ], + }), + }), + }); + expect(animator?.data).toMatchObject({ + clips: expect.arrayContaining([ + expect.objectContaining({ + id: 'Move', + tags: ['stream-authored'], + streaming: expect.objectContaining({ + catalog: expect.objectContaining({ + chunks: expect.arrayContaining([ + expect.objectContaining({ + uri: 'clips/move.0.bin', + }), + expect.objectContaining({ + uri: 'clips/move.1.bin', + }), + ]), + }), + }), + }), + ]), + }); + }); + it('builds scene snapshots that carry imported glTF materials, shaders, and bytes-backed textures', async () => { const database = new AssetDatabase({ importers: [createGltfImporter()], @@ -1411,6 +2077,51 @@ describe('glTF importer', () => { ); }); + it('creates a neutral fallback material for glTF meshes that do not author materials', async () => { + const database = new AssetDatabase({ + importers: [createGltfImporter()], + }); + + const json = createTriangleJson(); + delete json.materials; + delete json.images; + delete json.textures; + delete json.samplers; + delete json.meshes[0]?.primitives[0]?.material; + + const receipt = await database.import({ + kind: 'bytes', + data: createGlb(json, createBinaryBlob()), + uri: 'models/materialless-triangle.glb', + mimeType: 'model/gltf-binary', + }); + + const material = receipt.assets.find((entry) => entry.kind === 'gltf.material'); + expect(material?.key).toContain('material/default'); + expect(material?.data.definition.uniforms).toMatchObject({ + _BaseColorFactor: [0.84, 0.84, 0.86, 1], + _MetallicFactor: 0.04, + _RoughnessFactor: 0.94, + _AlphaMode: 0, + _DoubleSided: 0, + }); + + const built = createGltfSceneSnapshot(database, receipt.primary.reference); + expect(built.snapshot.materials).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: material?.key, + shaderId: 'gltf/pbr', + uniforms: expect.objectContaining({ + _BaseColorFactor: [0.84, 0.84, 0.86, 1], + _MetallicFactor: 0.04, + _RoughnessFactor: 0.94, + }), + }), + ]) + ); + }); + it('builds scene snapshots that preserve runtime-ready KTX2 textures as compressed scene sources', async () => { const binary = createBinaryBlob(); const ktx2 = createKtx2Texture([ @@ -1844,4 +2555,42 @@ describe('glTF importer', () => { 'gltf.extension.unsupported' ); }); + + it('passes configured Draco wasm resolution into custom decoder module factories', async () => { + let locateFile: + | ((path: string, scriptDirectory: string) => string) + | undefined; + let moduleFactoryCallCount = 0; + const database = new AssetDatabase({ + importers: [ + createGltfImporter({ + dracoDecoder: { + wasmUrl: '/vendor/draco_decoder_gltf.wasm', + moduleFactory: async (moduleConfig) => { + moduleFactoryCallCount += 1; + locateFile = moduleConfig?.locateFile; + return loadDracoDecoderModule(); + }, + }, + }), + ], + }); + const compressed = await createDracoCompressedTriangle(); + + const receipt = await database.import({ + kind: 'bytes', + data: createGlb(compressed.json, compressed.bin), + uri: 'models/triangle-draco-configured.glb', + mimeType: 'model/gltf-binary', + }); + + const mesh = receipt.assets.find((entry) => entry.kind === 'gltf.mesh'); + expect(mesh?.data.definition.vertexCount).toBe(3); + expect(mesh?.data.definition.indices).toEqual(new Uint16Array([0, 1, 2])); + expect(moduleFactoryCallCount).toBe(1); + expect(locateFile?.('draco_decoder_gltf.wasm', '/scripts/')).toBe( + '/vendor/draco_decoder_gltf.wasm' + ); + expect(locateFile?.('other.js', '/scripts/')).toBe('/scripts/other.js'); + }); }); diff --git a/web/packages/asset-gltf/src/animation-manifest.ts b/web/packages/asset-gltf/src/animation-manifest.ts new file mode 100644 index 00000000..c0e03a98 --- /dev/null +++ b/web/packages/asset-gltf/src/animation-manifest.ts @@ -0,0 +1,220 @@ +import type { + GltfAnimationClipMetadata, + GltfAnimationControllerMetadata, + GltfPackageResourceInput, +} from './types'; +import { cloneSerializable } from '@axrone/utility'; + +export interface PortableAnimationFeatureExportDefinition { + readonly rootNodeId?: string; + readonly rootNodeIndex?: number; + readonly sampleInterval?: number; + readonly sampleTimes?: readonly number[]; + readonly forwardAxis?: readonly [number, number, number]; + readonly tags?: readonly string[]; + readonly costBias?: number; +} + +export interface PortableAnimationClipManifestEntry extends Omit { + readonly id?: string; + readonly clipId?: string; + readonly animationIndex?: number; + readonly featureExport?: PortableAnimationFeatureExportDefinition; +} + +export interface PortableAnimationManifestSceneEntry { + readonly scene?: number; + readonly sceneName?: string; + readonly controller?: GltfAnimationControllerMetadata; + readonly clips?: readonly PortableAnimationClipManifestEntry[]; +} + +export interface PortableAnimationManifest { + readonly controller?: GltfAnimationControllerMetadata; + readonly scenes?: readonly PortableAnimationManifestSceneEntry[]; + readonly clips?: readonly PortableAnimationClipManifestEntry[]; +} + +const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const cloneFrozenSerializable = (value: T): T => cloneSerializable(value, { freeze: true }); + +const cloneStringArray = (value: readonly string[] | undefined): readonly string[] | undefined => { + if (!Array.isArray(value)) { + return undefined; + } + const tags = [...new Set(value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0))]; + return tags.length > 0 ? Object.freeze(tags) : undefined; +}; + +const cloneFeatureExport = ( + value: PortableAnimationFeatureExportDefinition | undefined +): PortableAnimationFeatureExportDefinition | undefined => { + if (!value) { + return undefined; + } + const sampleTimes = Array.isArray(value.sampleTimes) + ? Object.freeze( + value.sampleTimes + .filter((entry): entry is number => isFiniteNumber(entry)) + .map((entry) => Math.max(0, entry)) + ) + : undefined; + const forwardAxis = + Array.isArray(value.forwardAxis) && + value.forwardAxis.length === 3 && + value.forwardAxis.every((entry) => isFiniteNumber(entry)) + ? (Object.freeze([ + value.forwardAxis[0], + value.forwardAxis[1], + value.forwardAxis[2], + ]) as readonly [number, number, number]) + : undefined; + const featureExport = { + ...(typeof value.rootNodeId === 'string' && value.rootNodeId.length > 0 + ? { rootNodeId: value.rootNodeId } + : {}), + ...(isFiniteNumber(value.rootNodeIndex) + ? { rootNodeIndex: Math.max(0, Math.trunc(value.rootNodeIndex)) } + : {}), + ...(isFiniteNumber(value.sampleInterval) && value.sampleInterval > 0 + ? { sampleInterval: value.sampleInterval } + : {}), + ...(sampleTimes && sampleTimes.length > 0 ? { sampleTimes } : {}), + ...(forwardAxis ? { forwardAxis } : {}), + ...(cloneStringArray(value.tags) ? { tags: cloneStringArray(value.tags) } : {}), + ...(isFiniteNumber(value.costBias) ? { costBias: value.costBias } : {}), + } satisfies PortableAnimationFeatureExportDefinition; + return Object.keys(featureExport).length > 0 ? Object.freeze(featureExport) : undefined; +}; + +const cloneClipMetadata = ( + clip: PortableAnimationClipManifestEntry | undefined +): PortableAnimationClipManifestEntry | undefined => { + if (!clip) { + return undefined; + } + const featureExport = cloneFeatureExport(clip.featureExport); + const cloned = { + ...(typeof clip.id === 'string' && clip.id.length > 0 ? { id: clip.id } : {}), + ...(typeof clip.clipId === 'string' && clip.clipId.length > 0 ? { clipId: clip.clipId } : {}), + ...(isFiniteNumber(clip.animationIndex) ? { animationIndex: Math.max(0, Math.trunc(clip.animationIndex)) } : {}), + ...(clip.events ? { events: cloneFrozenSerializable(clip.events) } : {}), + ...(clip.footContacts ? { footContacts: cloneFrozenSerializable(clip.footContacts) } : {}), + ...(cloneStringArray(clip.tags) ? { tags: cloneStringArray(clip.tags) } : {}), + ...(clip.features ? { features: cloneFrozenSerializable(clip.features) } : {}), + ...(clip.compression ? { compression: cloneFrozenSerializable(clip.compression) } : {}), + ...(clip.streaming ? { streaming: cloneFrozenSerializable(clip.streaming) } : {}), + ...(featureExport ? { featureExport } : {}), + } satisfies PortableAnimationClipManifestEntry; + return cloned.id || cloned.clipId || cloned.animationIndex !== undefined ? Object.freeze(cloned) : undefined; +}; + +const cloneControllerMetadata = ( + controller: GltfAnimationControllerMetadata | undefined +): GltfAnimationControllerMetadata | undefined => { + if (!controller) { + return undefined; + } + const clips = Array.isArray(controller.clips) + ? Object.freeze( + controller.clips + .map((clip) => cloneClipMetadata(clip)) + .filter((clip): clip is PortableAnimationClipManifestEntry => Boolean(clip)) + .map((clip) => + Object.freeze({ + id: clip.id ?? clip.clipId!, + ...(clip.events ? { events: clip.events } : {}), + ...(clip.footContacts ? { footContacts: clip.footContacts } : {}), + ...(clip.tags ? { tags: clip.tags } : {}), + ...(clip.features ? { features: clip.features } : {}), + ...(clip.compression ? { compression: clip.compression } : {}), + ...(clip.streaming ? { streaming: clip.streaming } : {}), + } satisfies GltfAnimationClipMetadata) + ) + ) + : undefined; + const cloned = { + ...(controller.parameters ? { parameters: cloneFrozenSerializable(controller.parameters) } : {}), + ...(controller.layers ? { layers: cloneFrozenSerializable(controller.layers) } : {}), + ...(controller.rootMotion !== undefined + ? { rootMotion: cloneFrozenSerializable(controller.rootMotion) } + : {}), + ...(clips && clips.length > 0 ? { clips } : {}), + } satisfies GltfAnimationControllerMetadata; + return Object.keys(cloned).length > 0 ? Object.freeze(cloned) : undefined; +}; + +export const createPortableAnimationManifest = ( + manifest: PortableAnimationManifest +): PortableAnimationManifest => { + const clips = Array.isArray(manifest.clips) + ? Object.freeze( + manifest.clips + .map((clip: PortableAnimationClipManifestEntry) => cloneClipMetadata(clip)) + .filter( + (clip: PortableAnimationClipManifestEntry | undefined): clip is PortableAnimationClipManifestEntry => + Boolean(clip) + ) + ) + : undefined; + const scenes = Array.isArray(manifest.scenes) + ? Object.freeze( + manifest.scenes + .map((scene) => { + const sceneClips = Array.isArray(scene.clips) + ? Object.freeze( + scene.clips + .map((clip: PortableAnimationClipManifestEntry) => cloneClipMetadata(clip)) + .filter( + (clip: PortableAnimationClipManifestEntry | undefined): clip is PortableAnimationClipManifestEntry => + Boolean(clip) + ) + ) + : undefined; + const controller = cloneControllerMetadata(scene.controller); + const entry = { + ...(isFiniteNumber(scene.scene) ? { scene: Math.max(0, Math.trunc(scene.scene)) } : {}), + ...(typeof scene.sceneName === 'string' && scene.sceneName.length > 0 + ? { sceneName: scene.sceneName } + : {}), + ...(controller ? { controller } : {}), + ...(sceneClips && sceneClips.length > 0 ? { clips: sceneClips } : {}), + } satisfies PortableAnimationManifestSceneEntry; + return Object.keys(entry).length > 0 ? Object.freeze(entry) : undefined; + }) + .filter((scene): scene is PortableAnimationManifestSceneEntry => Boolean(scene)) + ) + : undefined; + const controller = cloneControllerMetadata(manifest.controller); + + return Object.freeze({ + ...(controller ? { controller } : {}), + ...(scenes && scenes.length > 0 ? { scenes } : {}), + ...(clips && clips.length > 0 ? { clips } : {}), + }); +}; + +export const serializePortableAnimationManifest = ( + manifest: PortableAnimationManifest, + indent: number = 2 +): string => JSON.stringify(createPortableAnimationManifest(manifest), null, clampIndent(indent)); + +export const createPortableAnimationManifestResource = ( + uri: string, + manifest: PortableAnimationManifest, + indent: number = 2 +): GltfPackageResourceInput => + Object.freeze({ + uri, + data: serializePortableAnimationManifest(manifest, indent), + mimeType: 'application/json', + }); + +const clampIndent = (value: number): number => { + if (!Number.isFinite(value)) { + return 2; + } + return Math.max(0, Math.min(8, Math.trunc(value))); +}; \ No newline at end of file diff --git a/web/packages/asset-gltf/src/animation-streaming.ts b/web/packages/asset-gltf/src/animation-streaming.ts new file mode 100644 index 00000000..cd066cb3 --- /dev/null +++ b/web/packages/asset-gltf/src/animation-streaming.ts @@ -0,0 +1,504 @@ +import { + encodeAnimationClipStreamingChunkPayload, + type AnimationClipDefinition, + type AnimationClipStreamingCatalogDefinition, + type AnimationClipStreamingChunkDefinition, + type AnimationClipStreamingChunkMergeMode, + type AnimationClipStreamingChunkPayload, + type AnimationTrackDefinition, +} from '@axrone/animation'; +import { + createPortableAnimationManifest, + type PortableAnimationClipManifestEntry, +} from './animation-manifest'; +import type { GltfPackageResourceInput } from './types'; + +export const DEFAULT_ANIMATION_STREAMING_CHUNK_MIME_TYPE = + 'application/vnd.axrone.animation.clip-chunk+json'; + +export interface AnimationStreamingChunkResourceOptions { + readonly uri: string; + readonly payload: AnimationClipStreamingChunkPayload; + readonly mimeType?: string; +} + +export interface PortableAnimationStreamingChunkRangeDefinition { + readonly id?: string; + readonly uri?: string; + readonly startTime: number; + readonly endTime: number; + readonly mimeType?: string; +} + +export interface PortableAnimationStreamingClipSource + extends Pick< + AnimationClipDefinition, + 'id' | 'duration' | 'tracks' | 'events' | 'footContacts' | 'tags' | 'features' | 'compression' | 'streaming' + > {} + +export interface PortableAnimationStreamingClipBundleOptions { + readonly clip: PortableAnimationStreamingClipSource; + readonly sourceUri?: string; + readonly chunkDuration?: number; + readonly chunks?: readonly PortableAnimationStreamingChunkRangeDefinition[]; + readonly mergeMode?: AnimationClipStreamingChunkMergeMode; + readonly mimeType?: string; + readonly preloadWindow?: number; + readonly priority?: number; + readonly catalogId?: string; + readonly catalogUri?: string; +} + +export interface PortableAnimationStreamingClipBundle { + readonly clip: PortableAnimationClipManifestEntry; + readonly catalog: AnimationClipStreamingCatalogDefinition; + readonly payloads: readonly AnimationClipStreamingChunkPayload[]; + readonly resources: readonly GltfPackageResourceInput[]; +} + +interface NormalizedChunkRange extends PortableAnimationStreamingChunkRangeDefinition { + readonly id: string; + readonly uri: string; + readonly startTime: number; + readonly endTime: number; +} + +const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const sanitizeIdSegment = (value: string): string => { + const sanitized = value + .trim() + .replace(/[^A-Za-z0-9_-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + return sanitized.length > 0 ? sanitized : 'clip'; +}; + +const basenameOfUri = (value: string): string => { + const slashIndex = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')); + return slashIndex >= 0 ? value.slice(slashIndex + 1) : value; +}; + +const splitExtension = (value: string): readonly [string, string] => { + const basename = basenameOfUri(value); + const extensionIndex = basename.lastIndexOf('.'); + if (extensionIndex <= 0) { + return [value, '']; + } + const absoluteIndex = value.length - (basename.length - extensionIndex); + return [value.slice(0, absoluteIndex), value.slice(absoluteIndex)]; +}; + +const resolveChunkStem = (clipId: string, sourceUri: string | undefined): string => { + if (sourceUri && sourceUri.length > 0) { + const [stem] = splitExtension(sourceUri); + return stem; + } + return sanitizeIdSegment(clipId); +}; + +const resolveChunkCatalogId = ( + clipId: string, + sourceUri: string | undefined, + explicitCatalogId: string | undefined, + fallbackCatalogId: string | undefined +): string => { + if (typeof explicitCatalogId === 'string' && explicitCatalogId.length > 0) { + return explicitCatalogId; + } + if (typeof fallbackCatalogId === 'string' && fallbackCatalogId.length > 0) { + return fallbackCatalogId; + } + return `${sanitizeIdSegment(basenameOfUri(resolveChunkStem(clipId, sourceUri)))}-stream`; +}; + +const createChunkUri = (sourceUri: string, chunkIndex: number): string => { + const [stem, extension] = splitExtension(sourceUri); + return `${stem}.${chunkIndex}${extension}`; +}; + +const resolveClipId = (clip: PortableAnimationStreamingClipSource): string => { + const clipId = `${clip.id ?? ''}`.trim(); + if (clipId.length === 0) { + throw new Error('Portable animation streaming clip bundles require a non-empty clip id'); + } + return clipId; +}; + +const getTrackKeyframeCount = (track: AnimationTrackDefinition): number => + isFiniteNumber(track.keyframeCount) ? Math.max(0, Math.trunc(track.keyframeCount)) : track.times.length; + +const getTrackSampleStride = (track: AnimationTrackDefinition, keyframeCount: number): number => { + if (isFiniteNumber(track.sampleStride)) { + return Math.max(0, Math.trunc(track.sampleStride)); + } + return keyframeCount > 0 ? Math.max(0, Math.trunc(track.values.length / keyframeCount)) : 0; +}; + +const getTrackValueComponentCount = ( + track: AnimationTrackDefinition, + sampleStride: number +): number => { + if (isFiniteNumber(track.valueComponentCount)) { + return Math.max(0, Math.trunc(track.valueComponentCount)); + } + return track.interpolation === 'CUBICSPLINE' ? Math.max(0, Math.trunc(sampleStride / 3)) : sampleStride; +}; + +const toFloat32Array = (value: readonly number[] | Float32Array): Float32Array => + value instanceof Float32Array ? new Float32Array(value) : Float32Array.from(value); + +const inferClipDuration = (clip: PortableAnimationStreamingClipSource): number => { + let duration = isFiniteNumber(clip.duration) ? Math.max(0, clip.duration) : 0; + for (let index = 0; index < clip.tracks.length; index += 1) { + const track = clip.tracks[index]!; + const keyframeCount = getTrackKeyframeCount(track); + if (keyframeCount <= 0) { + continue; + } + const lastTime = Number(track.times[Math.max(0, keyframeCount - 1)] ?? 0); + duration = Math.max(duration, lastTime); + } + return duration; +}; + +const createChunkRangesFromDuration = ( + duration: number, + chunkDuration: number, + clipId: string, + sourceUri: string | undefined +): readonly NormalizedChunkRange[] => { + if (!isFiniteNumber(chunkDuration) || chunkDuration <= 0) { + throw new Error('Portable animation streaming clip bundles require a positive chunkDuration'); + } + + const ranges: NormalizedChunkRange[] = []; + const defaultStem = resolveChunkStem(clipId, sourceUri); + const safeDuration = Math.max(0, duration); + + if (safeDuration <= 0) { + if (!sourceUri) { + throw new Error( + 'Portable animation streaming clip bundles require sourceUri when chunk URIs must be generated' + ); + } + ranges.push( + Object.freeze({ + id: `${sanitizeIdSegment(basenameOfUri(defaultStem))}-0`, + uri: createChunkUri(sourceUri, 0), + startTime: 0, + endTime: 0, + }) + ); + return Object.freeze(ranges); + } + + let startTime = 0; + let chunkIndex = 0; + while (startTime < safeDuration - 1e-6 || chunkIndex === 0) { + const endTime = Math.min(safeDuration, startTime + chunkDuration); + if (!sourceUri) { + throw new Error( + 'Portable animation streaming clip bundles require sourceUri when chunk URIs must be generated' + ); + } + ranges.push( + Object.freeze({ + id: `${sanitizeIdSegment(basenameOfUri(defaultStem))}-${chunkIndex}`, + uri: createChunkUri(sourceUri, chunkIndex), + startTime, + endTime, + }) + ); + if (endTime >= safeDuration) { + break; + } + startTime = endTime; + chunkIndex += 1; + } + + return Object.freeze(ranges); +}; + +const normalizeChunkRanges = ( + options: PortableAnimationStreamingClipBundleOptions, + clipId: string, + duration: number, + sourceUri: string | undefined +): readonly NormalizedChunkRange[] => { + const explicitRanges = + options.chunks ?? + options.clip.streaming?.catalog?.chunks?.map((chunk) => ({ + id: chunk.id, + uri: chunk.uri, + startTime: chunk.startTime, + endTime: chunk.endTime, + mimeType: chunk.mimeType, + })); + + const defaultStem = resolveChunkStem(clipId, sourceUri); + const ranges = explicitRanges + ? explicitRanges.map((entry, index) => { + const startTime = Math.max(0, Math.min(entry.startTime, entry.endTime)); + const endTime = Math.max(0, Math.max(entry.startTime, entry.endTime)); + const id = + typeof entry.id === 'string' && entry.id.length > 0 + ? entry.id + : `${sanitizeIdSegment(basenameOfUri(defaultStem))}-${index}`; + const uri = + typeof entry.uri === 'string' && entry.uri.length > 0 + ? entry.uri + : sourceUri + ? createChunkUri(sourceUri, index) + : undefined; + if (!uri) { + throw new Error( + 'Portable animation streaming clip bundles require chunk URIs or a sourceUri to derive them' + ); + } + return Object.freeze({ + id, + uri, + startTime, + endTime, + ...(typeof entry.mimeType === 'string' && entry.mimeType.length > 0 + ? { mimeType: entry.mimeType } + : {}), + }); + }) + : createChunkRangesFromDuration( + duration, + options.chunkDuration ?? options.clip.streaming?.chunkDuration ?? Number.NaN, + clipId, + sourceUri + ); + + if (ranges.length === 0) { + throw new Error('Portable animation streaming clip bundles require at least one chunk range'); + } + + const sorted = [...ranges].sort((left, right) => { + if (left.startTime !== right.startTime) { + return left.startTime - right.startTime; + } + if (left.endTime !== right.endTime) { + return left.endTime - right.endTime; + } + return left.uri.localeCompare(right.uri); + }); + + const seenIds = new Set(); + const seenUris = new Set(); + for (let index = 0; index < sorted.length; index += 1) { + const range = sorted[index]!; + if (seenIds.has(range.id)) { + throw new Error(`Portable animation streaming clip bundles require unique chunk ids: '${range.id}'`); + } + if (seenUris.has(range.uri)) { + throw new Error(`Portable animation streaming clip bundles require unique chunk URIs: '${range.uri}'`); + } + seenIds.add(range.id); + seenUris.add(range.uri); + } + + return Object.freeze(sorted); +}; + +const collectTrackChunkIndices = ( + times: Float32Array, + startTime: number, + endTime: number +): readonly number[] => { + const selected = new Set(); + let previousIndex = -1; + let nextIndex = -1; + for (let index = 0; index < times.length; index += 1) { + const time = Number(times[index] ?? 0); + if (time < startTime) { + previousIndex = index; + continue; + } + if (time <= endTime) { + selected.add(index); + continue; + } + nextIndex = index; + break; + } + if (previousIndex >= 0) { + selected.add(previousIndex); + } + if (nextIndex >= 0) { + selected.add(nextIndex); + } + + if (selected.size === 0 && times.length > 0) { + const firstTime = Number(times[0] ?? 0); + if (endTime < firstTime) { + selected.add(0); + } else { + selected.add(times.length - 1); + } + } + + return Object.freeze([...selected].sort((left, right) => left - right)); +}; + +const createChunkTrackDefinition = ( + track: AnimationTrackDefinition, + startTime: number, + endTime: number +): AnimationTrackDefinition => { + const keyframeCount = getTrackKeyframeCount(track); + const sampleStride = getTrackSampleStride(track, keyframeCount); + const valueComponentCount = getTrackValueComponentCount(track, sampleStride); + const times = toFloat32Array(track.times); + const values = toFloat32Array(track.values); + + if (times.length !== keyframeCount) { + throw new Error(`Animation track '${track.target}/${track.path}' has inconsistent keyframe timing`); + } + if (sampleStride * keyframeCount !== values.length) { + throw new Error(`Animation track '${track.target}/${track.path}' has inconsistent sample stride`); + } + + const selectedIndices = collectTrackChunkIndices(times, startTime, endTime); + const chunkTimes = new Float32Array(selectedIndices.length); + const chunkValues = new Float32Array(selectedIndices.length * sampleStride); + for (let index = 0; index < selectedIndices.length; index += 1) { + const keyframeIndex = selectedIndices[index]!; + chunkTimes[index] = times[keyframeIndex] ?? 0; + const valueOffset = keyframeIndex * sampleStride; + const chunkOffset = index * sampleStride; + for (let componentIndex = 0; componentIndex < sampleStride; componentIndex += 1) { + chunkValues[chunkOffset + componentIndex] = values[valueOffset + componentIndex] ?? 0; + } + } + + return Object.freeze({ + target: track.target, + path: track.path, + ...(typeof track.interpolation === 'string' ? { interpolation: track.interpolation } : {}), + times: Object.freeze(Array.from(chunkTimes)), + values: Object.freeze(Array.from(chunkValues)), + keyframeCount: chunkTimes.length, + sampleStride, + valueComponentCount, + } satisfies AnimationTrackDefinition); +}; + +const createChunkPayload = ( + clip: PortableAnimationStreamingClipSource, + clipId: string, + startTime: number, + endTime: number, + duration: number, + mergeMode: AnimationClipStreamingChunkMergeMode +): AnimationClipStreamingChunkPayload => + Object.freeze({ + version: 1, + clipId, + ...(mergeMode === 'replace-all' ? { mergeMode } : {}), + startTime, + endTime, + duration, + tracks: Object.freeze( + clip.tracks.map((track) => createChunkTrackDefinition(track, startTime, endTime)) + ), + }); + +export const createAnimationStreamingChunkResource = ( + options: AnimationStreamingChunkResourceOptions +): GltfPackageResourceInput => { + if (typeof options.uri !== 'string' || options.uri.length === 0) { + throw new Error('Animation streaming chunk resources require a non-empty uri'); + } + + return Object.freeze({ + uri: options.uri, + data: encodeAnimationClipStreamingChunkPayload(options.payload), + mimeType: options.mimeType ?? DEFAULT_ANIMATION_STREAMING_CHUNK_MIME_TYPE, + }); +}; + +export const createPortableAnimationStreamingClipBundle = ( + options: PortableAnimationStreamingClipBundleOptions +): PortableAnimationStreamingClipBundle => { + const clipId = resolveClipId(options.clip); + const duration = inferClipDuration(options.clip); + const mergeMode = options.mergeMode ?? 'replace-range'; + const sourceUri = options.sourceUri ?? options.clip.streaming?.sourceUri; + const catalogUri = options.catalogUri ?? options.clip.streaming?.catalogUri; + const ranges = normalizeChunkRanges(options, clipId, duration, sourceUri); + const payloads = Object.freeze( + ranges.map((range) => createChunkPayload(options.clip, clipId, range.startTime, range.endTime, duration, mergeMode)) + ); + const resources = Object.freeze( + ranges.map((range, index) => + createAnimationStreamingChunkResource({ + uri: range.uri, + payload: payloads[index]!, + mimeType: options.mimeType ?? range.mimeType ?? DEFAULT_ANIMATION_STREAMING_CHUNK_MIME_TYPE, + }) + ) + ); + const catalog = Object.freeze({ + id: resolveChunkCatalogId(clipId, sourceUri, options.catalogId, options.clip.streaming?.catalog?.id), + chunks: Object.freeze( + ranges.map( + (range, index) => + Object.freeze({ + id: range.id, + uri: range.uri, + startTime: range.startTime, + endTime: range.endTime, + byteLength: + resources[index]!.data instanceof Uint8Array + ? resources[index]!.data.byteLength + : undefined, + mimeType: resources[index]!.mimeType, + } satisfies AnimationClipStreamingChunkDefinition) + ) + ), + } satisfies AnimationClipStreamingCatalogDefinition); + + const manifest = createPortableAnimationManifest({ + clips: [ + { + id: clipId, + ...(options.clip.events ? { events: options.clip.events } : {}), + ...(options.clip.footContacts ? { footContacts: options.clip.footContacts } : {}), + ...(options.clip.tags ? { tags: options.clip.tags } : {}), + ...(options.clip.features ? { features: options.clip.features } : {}), + ...(options.clip.compression ? { compression: options.clip.compression } : {}), + streaming: { + mode: 'streamed', + ...(isFiniteNumber(options.chunkDuration ?? options.clip.streaming?.chunkDuration) + ? { chunkDuration: options.chunkDuration ?? options.clip.streaming?.chunkDuration } + : {}), + ...(isFiniteNumber(options.preloadWindow ?? options.clip.streaming?.preloadWindow) + ? { preloadWindow: options.preloadWindow ?? options.clip.streaming?.preloadWindow } + : {}), + ...(isFiniteNumber(options.priority ?? options.clip.streaming?.priority) + ? { priority: Math.trunc(options.priority ?? options.clip.streaming?.priority ?? 0) } + : {}), + ...(typeof sourceUri === 'string' && sourceUri.length > 0 ? { sourceUri } : {}), + ...(typeof catalogUri === 'string' && catalogUri.length > 0 ? { catalogUri } : {}), + catalog, + }, + } satisfies PortableAnimationClipManifestEntry, + ], + }); + const clip = manifest.clips?.[0]; + if (!clip?.streaming?.catalog) { + throw new Error('Portable animation streaming clip bundle creation failed to normalize clip metadata'); + } + + return Object.freeze({ + clip, + catalog: clip.streaming.catalog, + payloads, + resources, + }); +}; \ No newline at end of file diff --git a/web/packages/asset-gltf/src/asset-ir.ts b/web/packages/asset-gltf/src/asset-ir.ts index a889e1ff..d08ae513 100644 --- a/web/packages/asset-gltf/src/asset-ir.ts +++ b/web/packages/asset-gltf/src/asset-ir.ts @@ -1,5 +1,6 @@ import type { Mat4, Quat, Vec2, Vec3, Vec4 } from '@axrone/numeric'; -import type { FilterMode, TextureFormat, WrapMode } from '@axrone/render-webgl2'; +import type { RenderShaderEffectDefinition } from '@axrone/render-core'; +import type { ColorSpace, FilterMode, TextureFormat, WrapMode } from '@axrone/render-webgl2'; export type GltfMeshSemantic = | 'position' @@ -130,6 +131,7 @@ export interface GltfTextureDefinition { readonly format?: TextureFormat; readonly generateMipmaps?: boolean; readonly samplerId?: string; + readonly colorSpace?: ColorSpace; } export type GltfTextureBindingDefinition = @@ -144,6 +146,7 @@ export interface GltfShaderDefinition { readonly id: string; readonly vertexSource: string; readonly fragmentSource: string; + readonly effect?: RenderShaderEffectDefinition; readonly attributes?: Partial>; readonly uniforms?: readonly string[]; readonly depthTest?: boolean; diff --git a/web/packages/asset-gltf/src/importer.ts b/web/packages/asset-gltf/src/importer.ts index 605511eb..14222055 100644 --- a/web/packages/asset-gltf/src/importer.ts +++ b/web/packages/asset-gltf/src/importer.ts @@ -1,4 +1,15 @@ import { FilterMode, TextureFormat, WrapMode } from '@axrone/render-webgl2'; +import type { + AnimationClipCompressionDefinition, + AnimationClipEventDefinition, + AnimationClipStreamingCatalogDefinition, + AnimationClipStreamingDefinition, + AnimationFootContactDefinition, + AnimationLayerDefinition, + AnimationMotionFeatureDefinition, + AnimationParameterDefinition, + AnimationRootMotionDefinition, +} from '@axrone/animation'; import type { AssetImportDiagnostic, AssetImportResult, @@ -23,10 +34,13 @@ import { stripExtension, type NormalizedGltfSource, } from './internal/source-runtime'; +import { deepFreeze, isPlainObject } from '@axrone/utility'; import { buildMeshDefinition, collectPrimitiveDiagnostics } from './internal/mesh-runtime'; import type { GltfAccessorJson, GltfAnimationClipAsset, + GltfAnimationClipMetadata, + GltfAnimationControllerMetadata, GltfAssetSchema, GltfAssetSchemaLike, GltfCameraJson, @@ -40,6 +54,7 @@ import type { GltfMaterialTextureBinding, GltfMeshAsset, GltfMeshJson, + GltfSceneJson, GltfSkinAsset, GltfNodeJson, GltfPunctualLightJson, @@ -59,71 +74,1686 @@ import type { GltfTranscodeStage, } from './types'; -const EMPTY_ARRAY = Object.freeze([]) as readonly never[]; -const DEFAULT_SAMPLER_ID = 'gltf/sampler/default'; -const DEFAULT_MATERIAL_KEY_SUFFIX = 'material/default'; -const DEFAULT_MATERIAL_NAME = 'Default Material'; -const DEFAULT_DOCUMENT_NAME = 'glTF Document'; -const MAX_SCENE_LOCAL_LIGHTS = 4; -const RADIANS_TO_DEGREES = 180 / Math.PI; -const SUPPORTED_GLTF_EXTENSIONS = new Set([ - 'EXT_meshopt_compression', - 'KHR_draco_mesh_compression', - 'KHR_lights_punctual', - 'KHR_materials_emissive_strength', - 'KHR_materials_unlit', - 'KHR_mesh_quantization', - 'KHR_texture_basisu', - 'KHR_texture_transform', -]); +const EMPTY_ARRAY = Object.freeze([]) as readonly never[]; +const DEFAULT_SAMPLER_ID = 'gltf/sampler/default'; +const DEFAULT_MATERIAL_KEY_SUFFIX = 'material/default'; +const DEFAULT_MATERIAL_NAME = 'Default Material'; +const DEFAULT_DOCUMENT_NAME = 'glTF Document'; +const MAX_SCENE_LOCAL_LIGHTS = 4; +const RADIANS_TO_DEGREES = 180 / Math.PI; +const VALID_ANIMATION_PARAMETER_KINDS = new Set(['float', 'int', 'bool', 'trigger']); +const VALID_ANIMATION_LAYER_MODES = new Set(['override', 'additive']); +const VALID_ANIMATION_IK_SOLVERS = new Set(['ccd', 'fabrik']); +const VALID_ANIMATION_CONDITION_KINDS = new Set(['float', 'int', 'bool', 'trigger']); +const VALID_ANIMATION_CONDITION_OPERATORS = new Set(['<', '<=', '>', '>=', '==', '!=']); +const ANIMATION_MANIFEST_RESOURCE_NAMES = Object.freeze([ + 'animation-manifest.json', + 'animations.manifest.json', + 'animation-controller.json', + 'animations.json', +]); +const SUPPORTED_GLTF_EXTENSIONS = new Set([ + 'EXT_meshopt_compression', + 'KHR_draco_mesh_compression', + 'KHR_lights_punctual', + 'KHR_materials_emissive_strength', + 'KHR_materials_unlit', + 'KHR_mesh_quantization', + 'KHR_texture_basisu', + 'KHR_texture_transform', +]); + +interface PrefabBuildResult { + readonly prefab: GltfPrefabDefinition; + readonly rootNodeIds: readonly string[]; + readonly nodeIds: readonly string[]; + readonly meshKeys: readonly string[]; + readonly skinKeys: readonly string[]; + readonly animationKeys: readonly string[]; + readonly materialKeys: readonly string[]; + readonly animationController?: GltfAnimationControllerMetadata; + readonly diagnostics: readonly AssetImportDiagnostic[]; +} + +interface GltfSkinBinding { + readonly jointNodeIds: readonly string[]; + readonly skeletonNodeId?: string; + readonly inverseBindMatrices?: readonly number[] | Float32Array; +} + +interface PortableAnimationManifestSceneEntry { + readonly scene?: number; + readonly sceneName?: string; + readonly controller?: Record; + readonly clips?: readonly Record[]; +} + +interface PortableAnimationManifest { + readonly controller?: Record; + readonly scenes?: readonly PortableAnimationManifestSceneEntry[]; + readonly clips?: readonly Record[]; +} + +interface PortableAnimationFeatureExportDefinition { + readonly rootNodeId?: string; + readonly rootNodeIndex?: number; + readonly sampleInterval?: number; + readonly sampleTimes?: readonly number[]; + readonly forwardAxis?: readonly [number, number, number]; + readonly tags?: readonly string[]; + readonly costBias?: number; +} + +interface GltfAnimationClipMetadataSource extends Omit { + readonly featureExport?: PortableAnimationFeatureExportDefinition; +} + +interface GltfAnimationClipMetadataSourceIndex { + readonly byId: ReadonlyMap; + readonly byAnimationIndex: ReadonlyMap; +} + +const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const isBooleanTuple3 = (value: unknown): value is readonly [boolean, boolean, boolean] => + Array.isArray(value) && value.length === 3 && value.every((entry) => typeof entry === 'boolean'); + +const isNumberTuple3 = (value: unknown): value is readonly [number, number, number] => + Array.isArray(value) && value.length === 3 && value.every((entry) => isFiniteNumber(entry)); + +const isNumberTuple4 = (value: unknown): value is readonly [number, number, number, number] => + Array.isArray(value) && value.length === 4 && value.every((entry) => isFiniteNumber(entry)); + +const maybeFreeze = (value: T, enabled: boolean): T => + (enabled ? (deepFreeze(value) as T) : value); + +const cloneSerializableMetadata = (value: unknown): unknown => { + if ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value; + } + + if (Array.isArray(value)) { + return value.map((entry) => cloneSerializableMetadata(entry)); + } + + if (!isPlainObject(value)) { + return undefined; + } + + const cloned: Record = {}; + for (const [key, entry] of Object.entries(value)) { + const clonedEntry = cloneSerializableMetadata(entry); + if (clonedEntry !== undefined) { + cloned[key] = clonedEntry; + } + } + + return cloned; +}; + +const createAnimationMetadataDiagnostic = ( + sceneIndex: number, + message: string +): AssetImportDiagnostic => + Object.freeze({ + level: 'warning', + code: 'gltf.animation.metadata.invalid', + message: `Scene ${sceneIndex} animation metadata was ignored: ${message}`, + } satisfies AssetImportDiagnostic); + +const resolveSceneAnimationMetadataSource = ( + scene: GltfSceneJson | undefined +): Record | undefined => { + const extras = scene && isPlainObject(scene.extras) ? scene.extras : undefined; + if (!extras) { + return undefined; + } + + const axrone = isPlainObject(extras.axrone) ? extras.axrone : undefined; + const candidates = [ + axrone?.animationController, + axrone?.animation, + extras.animationController, + extras.animation, + ]; + + for (let index = 0; index < candidates.length; index += 1) { + const candidate = candidates[index]; + if (isPlainObject(candidate)) { + return candidate; + } + } + + return undefined; +}; + +const createAnimationManifestDiagnostic = (message: string): AssetImportDiagnostic => + Object.freeze({ + level: 'warning', + code: 'gltf.animation.manifest.invalid', + message, + } satisfies AssetImportDiagnostic); + +const collectAnimationManifestResourceCandidates = ( + normalized: NormalizedGltfSource +): readonly string[] => { + const sourceStem = stripExtension(basenameOfUri(normalized.sourceUri)); + return Object.freeze( + [...new Set([ + ...ANIMATION_MANIFEST_RESOURCE_NAMES, + ...(sourceStem + ? [ + `${sourceStem}.animation-manifest.json`, + `${sourceStem}.animations.json`, + `${sourceStem}.animation-controller.json`, + ] + : []), + ])] + ); +}; + +const resolvePortableAnimationManifest = ( + normalized: NormalizedGltfSource, + diagnostics: AssetImportDiagnostic[] +): PortableAnimationManifest | undefined => { + if (normalized.resources.size === 0) { + return undefined; + } + + const resources = [...new Map([...normalized.resources.values()].map((resource) => [resource.uri, resource])).values()]; + const candidates = collectAnimationManifestResourceCandidates(normalized); + const candidate = + candidates + .map((name) => resources.find((resource) => basenameOfUri(resource.uri) === name)) + .find((resource): resource is (typeof resources)[number] => Boolean(resource)) ?? + resources.find((resource) => { + const name = basenameOfUri(resource.uri)?.toLowerCase(); + return Boolean( + name && + (name.endsWith('.animation-manifest.json') || + name.endsWith('.animations.json') || + name.endsWith('.animation-controller.json')) + ); + }); + + if (!candidate) { + return undefined; + } + + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(candidate.bytes)) as unknown; + } catch (error) { + diagnostics.push( + createAnimationManifestDiagnostic( + `Animation manifest '${candidate.uri}' could not be parsed and was ignored` + ) + ); + return undefined; + } + + if (!isPlainObject(parsed)) { + diagnostics.push( + createAnimationManifestDiagnostic( + `Animation manifest '${candidate.uri}' must contain a JSON object` + ) + ); + return undefined; + } + + const directController = + 'parameters' in parsed || 'layers' in parsed || 'rootMotion' in parsed ? parsed : undefined; + const controller = isPlainObject(parsed.controller) + ? parsed.controller + : isPlainObject(parsed.animationController) + ? parsed.animationController + : directController; + const scenes = Array.isArray(parsed.scenes) + ? Object.freeze( + parsed.scenes + .filter((entry): entry is Record => isPlainObject(entry)) + .map((entry) => + Object.freeze({ + ...(isFiniteNumber(entry.scene) + ? { scene: Math.max(0, Math.trunc(entry.scene)) } + : {}), + ...(typeof entry.sceneName === 'string' + ? { sceneName: entry.sceneName } + : typeof entry.name === 'string' + ? { sceneName: entry.name } + : {}), + ...(isPlainObject(entry.controller) + ? { controller: entry.controller } + : isPlainObject(entry.animationController) + ? { controller: entry.animationController } + : {}), + ...(Array.isArray(entry.clips) + ? { + clips: Object.freeze( + entry.clips.filter( + (clip): clip is Record => isPlainObject(clip) + ) + ), + } + : {}), + } satisfies PortableAnimationManifestSceneEntry) + ) + .filter( + (entry) => + entry.controller !== undefined || + (Array.isArray(entry.clips) && entry.clips.length > 0) + ) + ) + : undefined; + const clips = Array.isArray(parsed.clips) + ? Object.freeze(parsed.clips.filter((entry): entry is Record => isPlainObject(entry))) + : undefined; + + if (!controller && (!scenes || scenes.length === 0) && (!clips || clips.length === 0)) { + diagnostics.push( + createAnimationManifestDiagnostic( + `Animation manifest '${candidate.uri}' did not contain any usable controller or clip metadata` + ) + ); + return undefined; + } + + return Object.freeze({ + ...(controller ? { controller } : {}), + ...(scenes && scenes.length > 0 ? { scenes } : {}), + ...(clips && clips.length > 0 ? { clips } : {}), + }); +}; + +const mergeAnimationMetadataSources = ( + base: Record | undefined, + override: Record | undefined +): Record | undefined => { + if (!base) { + return override; + } + if (!override) { + return base; + } + + return { + ...base, + ...override, + ...(override.parameters !== undefined ? { parameters: override.parameters } : {}), + ...(override.layers !== undefined ? { layers: override.layers } : {}), + ...(override.rootMotion !== undefined ? { rootMotion: override.rootMotion } : {}), + }; +}; + +const resolvePortableAnimationManifestSceneEntry = ( + manifest: PortableAnimationManifest | undefined, + scene: GltfSceneJson | undefined, + sceneIndex: number +): PortableAnimationManifestSceneEntry | undefined => { + if (!manifest) { + return undefined; + } + + const sceneName = typeof scene?.name === 'string' ? scene.name : undefined; + return ( + manifest.scenes?.find((entry) => entry.scene === sceneIndex) ?? + (sceneName ? manifest.scenes?.find((entry) => entry.sceneName === sceneName) : undefined) + ); +}; + +const resolvePortableSceneAnimationMetadataSource = ( + manifest: PortableAnimationManifest | undefined, + scene: GltfSceneJson | undefined, + sceneIndex: number +): Record | undefined => { + if (!manifest) { + return undefined; + } + + const sceneEntry = resolvePortableAnimationManifestSceneEntry(manifest, scene, sceneIndex); + + return mergeAnimationMetadataSources(manifest.controller, sceneEntry?.controller); +}; + +const sanitizeAnimationTags = (value: unknown): readonly string[] | undefined => { + if (!Array.isArray(value)) { + return undefined; + } + + const tags = [...new Set(value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0))]; + return tags.length > 0 ? Object.freeze(tags) : undefined; +}; + +const sanitizeAnimationClipEvents = ( + value: unknown, + diagnostics: AssetImportDiagnostic[], + createDiagnostic: (message: string) => AssetImportDiagnostic, + freeze: boolean +): readonly AnimationClipEventDefinition[] | undefined => { + if (!Array.isArray(value)) { + return undefined; + } + + const events: AnimationClipEventDefinition[] = []; + for (let index = 0; index < value.length; index += 1) { + const entry = value[index]; + if (!isPlainObject(entry) || typeof entry.name !== 'string' || !isFiniteNumber(entry.time)) { + diagnostics.push(createDiagnostic(`event ${index} must provide a valid name and time`)); + continue; + } + + const payload = cloneSerializableMetadata(entry.payload); + events.push( + maybeFreeze( + { + ...(typeof entry.id === 'string' && entry.id.length > 0 ? { id: entry.id } : {}), + name: entry.name, + time: Math.max(0, entry.time), + ...(payload !== undefined ? { payload: payload as Readonly> | null } : {}), + ...(sanitizeAnimationTags(entry.tags) ? { tags: sanitizeAnimationTags(entry.tags) } : {}), + } satisfies AnimationClipEventDefinition, + freeze + ) + ); + } + + return events.length > 0 ? Object.freeze(events.sort((left, right) => left.time - right.time)) : undefined; +}; + +const sanitizeAnimationFootContacts = ( + value: unknown, + diagnostics: AssetImportDiagnostic[], + createDiagnostic: (message: string) => AssetImportDiagnostic, + freeze: boolean +): readonly AnimationFootContactDefinition[] | undefined => { + if (!Array.isArray(value)) { + return undefined; + } + + const contacts: AnimationFootContactDefinition[] = []; + for (let index = 0; index < value.length; index += 1) { + const entry = value[index]; + if ( + !isPlainObject(entry) || + typeof entry.bone !== 'string' || + !isFiniteNumber(entry.startTime) || + !isFiniteNumber(entry.endTime) + ) { + diagnostics.push(createDiagnostic(`foot contact ${index} must provide a valid bone, startTime, and endTime`)); + continue; + } + + const metadata = cloneSerializableMetadata(entry.metadata); + contacts.push( + maybeFreeze( + { + bone: entry.bone, + startTime: Math.max(0, Math.min(entry.startTime, entry.endTime)), + endTime: Math.max(0, Math.max(entry.startTime, entry.endTime)), + ...(isBooleanTuple3(entry.lockTranslationAxes) + ? { lockTranslationAxes: Object.freeze([...entry.lockTranslationAxes]) as readonly [boolean, boolean, boolean] } + : {}), + ...(metadata !== undefined ? { metadata: metadata as Readonly> } : {}), + } satisfies AnimationFootContactDefinition, + freeze + ) + ); + } + + return contacts.length > 0 ? Object.freeze(contacts.sort((left, right) => left.startTime - right.startTime)) : undefined; +}; + +const sanitizeAnimationMotionFeatures = ( + value: unknown, + diagnostics: AssetImportDiagnostic[], + createDiagnostic: (message: string) => AssetImportDiagnostic, + freeze: boolean +): readonly AnimationMotionFeatureDefinition[] | undefined => { + if (!Array.isArray(value)) { + return undefined; + } + + const features: AnimationMotionFeatureDefinition[] = []; + for (let index = 0; index < value.length; index += 1) { + const entry = value[index]; + if (!isPlainObject(entry) || !isFiniteNumber(entry.time)) { + diagnostics.push(createDiagnostic(`motion feature ${index} must provide a valid time`)); + continue; + } + + features.push( + maybeFreeze( + { + time: Math.max(0, entry.time), + ...(isNumberTuple3(entry.trajectoryPosition) + ? { trajectoryPosition: Object.freeze([...entry.trajectoryPosition]) as readonly [number, number, number] } + : {}), + ...(isNumberTuple3(entry.facingDirection) + ? { facingDirection: Object.freeze([...entry.facingDirection]) as readonly [number, number, number] } + : {}), + ...(sanitizeAnimationTags(entry.tags) ? { tags: sanitizeAnimationTags(entry.tags) } : {}), + ...(isFiniteNumber(entry.costBias) ? { costBias: entry.costBias } : {}), + } satisfies AnimationMotionFeatureDefinition, + freeze + ) + ); + } + + return features.length > 0 ? Object.freeze(features.sort((left, right) => left.time - right.time)) : undefined; +}; + +const sanitizeAnimationClipCompression = ( + value: unknown, + diagnostics: AssetImportDiagnostic[], + createDiagnostic: (message: string) => AssetImportDiagnostic, + freeze: boolean +): AnimationClipCompressionDefinition | undefined => { + if (!isPlainObject(value)) { + return undefined; + } + + if ( + value.codec !== undefined && + value.codec !== 'none' && + value.codec !== 'keyframe-reduced' + ) { + diagnostics.push(createDiagnostic('compression codec must be none or keyframe-reduced')); + return undefined; + } + + const compression = maybeFreeze( + { + ...(typeof value.codec === 'string' ? { codec: value.codec as AnimationClipCompressionDefinition['codec'] } : {}), + ...(isFiniteNumber(value.positionTolerance) ? { positionTolerance: value.positionTolerance } : {}), + ...(isFiniteNumber(value.rotationToleranceDegrees) + ? { rotationToleranceDegrees: value.rotationToleranceDegrees } + : {}), + ...(isFiniteNumber(value.scaleTolerance) ? { scaleTolerance: value.scaleTolerance } : {}), + ...(isFiniteNumber(value.curveTolerance) ? { curveTolerance: value.curveTolerance } : {}), + ...(typeof value.preserveStepTracks === 'boolean' + ? { preserveStepTracks: value.preserveStepTracks } + : {}), + } satisfies AnimationClipCompressionDefinition, + freeze + ); + + return Object.keys(compression).length > 0 ? compression : undefined; +}; + +const sanitizeAnimationClipStreaming = ( + value: unknown, + diagnostics: AssetImportDiagnostic[], + createDiagnostic: (message: string) => AssetImportDiagnostic, + freeze: boolean +): AnimationClipStreamingDefinition | undefined => { + if (!isPlainObject(value)) { + return undefined; + } + + if (value.mode !== undefined && value.mode !== 'resident' && value.mode !== 'streamed') { + diagnostics.push(createDiagnostic('streaming mode must be resident or streamed')); + return undefined; + } + + const catalog = isPlainObject(value.catalog) + ? (() => { + const chunks = Array.isArray(value.catalog.chunks) + ? value.catalog.chunks + .filter((entry): entry is Record => isPlainObject(entry)) + .map((entry) => { + if ( + typeof entry.uri !== 'string' || + !isFiniteNumber(entry.startTime) || + !isFiniteNumber(entry.endTime) + ) { + diagnostics.push( + createDiagnostic( + 'streaming catalog chunks must provide uri, startTime, and endTime' + ) + ); + return undefined; + } + + return maybeFreeze( + { + ...(typeof entry.id === 'string' ? { id: entry.id } : {}), + uri: entry.uri, + startTime: Math.max(0, Math.min(entry.startTime, entry.endTime)), + endTime: Math.max(0, Math.max(entry.startTime, entry.endTime)), + ...(isFiniteNumber(entry.byteOffset) + ? { byteOffset: Math.max(0, Math.trunc(entry.byteOffset)) } + : {}), + ...(isFiniteNumber(entry.byteLength) + ? { byteLength: Math.max(0, Math.trunc(entry.byteLength)) } + : {}), + ...(typeof entry.mimeType === 'string' + ? { mimeType: entry.mimeType } + : {}), + }, + freeze + ); + }) + .filter( + ( + entry + ): entry is NonNullable< + AnimationClipStreamingCatalogDefinition['chunks'][number] + > => Boolean(entry) + ) + : []; + + if (chunks.length === 0) { + diagnostics.push(createDiagnostic('streaming catalog must provide at least one valid chunk')); + return undefined; + } + + return maybeFreeze( + { + ...(typeof value.catalog.id === 'string' ? { id: value.catalog.id } : {}), + chunks: Object.freeze(chunks), + } satisfies AnimationClipStreamingCatalogDefinition, + freeze + ); + })() + : undefined; + + const streaming = maybeFreeze( + { + ...(typeof value.mode === 'string' ? { mode: value.mode as AnimationClipStreamingDefinition['mode'] } : {}), + ...(isFiniteNumber(value.chunkDuration) ? { chunkDuration: value.chunkDuration } : {}), + ...(isFiniteNumber(value.preloadWindow) ? { preloadWindow: value.preloadWindow } : {}), + ...(isFiniteNumber(value.priority) ? { priority: Math.trunc(value.priority) } : {}), + ...(typeof value.sourceUri === 'string' ? { sourceUri: value.sourceUri } : {}), + ...(typeof value.catalogUri === 'string' ? { catalogUri: value.catalogUri } : {}), + ...(catalog ? { catalog } : {}), + } satisfies AnimationClipStreamingDefinition, + freeze + ); + + return Object.keys(streaming).length > 0 ? streaming : undefined; +}; + +const sanitizeAnimationFeatureExport = ( + value: unknown, + diagnostics: AssetImportDiagnostic[], + createDiagnostic: (message: string) => AssetImportDiagnostic, + freeze: boolean +): PortableAnimationFeatureExportDefinition | undefined => { + if (!isPlainObject(value)) { + return undefined; + } + + if ( + value.sampleInterval !== undefined && + (!isFiniteNumber(value.sampleInterval) || value.sampleInterval <= 0) + ) { + diagnostics.push(createDiagnostic('featureExport.sampleInterval must be a positive number')); + return undefined; + } + + const sampleTimes = Array.isArray(value.sampleTimes) + ? Object.freeze( + value.sampleTimes + .filter((entry): entry is number => isFiniteNumber(entry)) + .map((entry) => Math.max(0, entry)) + ) + : undefined; + if (value.sampleTimes !== undefined && (!sampleTimes || sampleTimes.length === 0)) { + diagnostics.push(createDiagnostic('featureExport.sampleTimes must contain numeric values')); + return undefined; + } + + const forwardAxis = isNumberTuple3(value.forwardAxis) + ? (Object.freeze([...value.forwardAxis]) as readonly [number, number, number]) + : undefined; + if (value.forwardAxis !== undefined && !forwardAxis) { + diagnostics.push(createDiagnostic('featureExport.forwardAxis must be a numeric vec3')); + return undefined; + } + + const config = maybeFreeze( + { + ...(typeof value.rootNodeId === 'string' ? { rootNodeId: value.rootNodeId } : {}), + ...(isFiniteNumber(value.rootNodeIndex) + ? { rootNodeIndex: Math.max(0, Math.trunc(value.rootNodeIndex)) } + : {}), + ...(isFiniteNumber(value.sampleInterval) ? { sampleInterval: value.sampleInterval } : {}), + ...(sampleTimes && sampleTimes.length > 0 ? { sampleTimes } : {}), + ...(forwardAxis ? { forwardAxis } : {}), + ...(sanitizeAnimationTags(value.tags) ? { tags: sanitizeAnimationTags(value.tags) } : {}), + ...(isFiniteNumber(value.costBias) ? { costBias: value.costBias } : {}), + } satisfies PortableAnimationFeatureExportDefinition, + freeze + ); + + if (Object.keys(config).length === 0) { + diagnostics.push(createDiagnostic('featureExport must contain at least one usable field')); + return undefined; + } + + return config; +}; + +const sanitizeAnimationClipMetadataSource = ( + value: Record, + diagnostics: AssetImportDiagnostic[], + createDiagnostic: (message: string) => AssetImportDiagnostic, + freeze: boolean +): GltfAnimationClipMetadataSource | undefined => { + const events = sanitizeAnimationClipEvents(value.events, diagnostics, createDiagnostic, freeze); + const footContacts = sanitizeAnimationFootContacts(value.footContacts, diagnostics, createDiagnostic, freeze); + const tags = sanitizeAnimationTags(value.tags); + const features = sanitizeAnimationMotionFeatures(value.features, diagnostics, createDiagnostic, freeze); + const compression = sanitizeAnimationClipCompression(value.compression, diagnostics, createDiagnostic, freeze); + const streaming = sanitizeAnimationClipStreaming(value.streaming, diagnostics, createDiagnostic, freeze); + const featureExport = sanitizeAnimationFeatureExport( + value.featureExport, + diagnostics, + createDiagnostic, + freeze + ); + + if (!events && !footContacts && !tags && !features && !compression && !streaming && !featureExport) { + return undefined; + } + + return maybeFreeze( + { + ...(events ? { events } : {}), + ...(footContacts ? { footContacts } : {}), + ...(tags ? { tags } : {}), + ...(features ? { features } : {}), + ...(compression ? { compression } : {}), + ...(streaming ? { streaming } : {}), + ...(featureExport ? { featureExport } : {}), + }, + freeze + ); +}; + +const mergeClipMetadataSources = ( + base: GltfAnimationClipMetadataSource | undefined, + override: GltfAnimationClipMetadataSource | undefined, + freeze: boolean +): GltfAnimationClipMetadataSource | undefined => { + if (!base) { + return override; + } + if (!override) { + return base; + } + + return maybeFreeze( + { + ...(override.events ? { events: override.events } : base.events ? { events: base.events } : {}), + ...(override.footContacts + ? { footContacts: override.footContacts } + : base.footContacts + ? { footContacts: base.footContacts } + : {}), + ...(override.tags ? { tags: override.tags } : base.tags ? { tags: base.tags } : {}), + ...(override.features + ? { features: override.features } + : base.features + ? { features: base.features } + : {}), + ...(override.compression + ? { compression: override.compression } + : base.compression + ? { compression: base.compression } + : {}), + ...(override.streaming + ? { streaming: override.streaming } + : base.streaming + ? { streaming: base.streaming } + : {}), + ...(override.featureExport + ? { featureExport: override.featureExport } + : base.featureExport + ? { featureExport: base.featureExport } + : {}), + } satisfies GltfAnimationClipMetadataSource, + freeze + ); +}; + +const resolveAnimationClipMetadataEntries = ( + entries: readonly Record[] | undefined, + diagnostics: AssetImportDiagnostic[], + createDiagnostic: (message: string) => AssetImportDiagnostic, + freeze: boolean +): GltfAnimationClipMetadataSourceIndex => { + const byId = new Map(); + const byAnimationIndex = new Map(); + + for (let index = 0; index < (entries?.length ?? 0); index += 1) { + const entry = entries![index]!; + const clipId = + typeof entry.id === 'string' + ? entry.id + : typeof entry.clipId === 'string' + ? entry.clipId + : undefined; + const animationIndex = isFiniteNumber(entry.animationIndex) + ? Math.max(0, Math.trunc(entry.animationIndex)) + : undefined; + if (!clipId && animationIndex === undefined) { + diagnostics.push(createDiagnostic(`clip entry ${index} must provide an id, clipId, or animationIndex`)); + continue; + } + + const metadata = sanitizeAnimationClipMetadataSource(entry, diagnostics, createDiagnostic, freeze); + if (!metadata) { + continue; + } + + if (clipId) { + byId.set(clipId, mergeClipMetadataSources(byId.get(clipId), metadata, freeze) ?? metadata); + } + if (animationIndex !== undefined) { + byAnimationIndex.set( + animationIndex, + mergeClipMetadataSources(byAnimationIndex.get(animationIndex), metadata, freeze) ?? metadata + ); + } + } + + return { + byId, + byAnimationIndex, + }; +}; + +const resolvePortableAnimationClipMetadataSources = ( + manifest: PortableAnimationManifest | undefined, + diagnostics: AssetImportDiagnostic[], + freeze: boolean +): GltfAnimationClipMetadataSourceIndex => + resolveAnimationClipMetadataEntries( + manifest?.clips, + diagnostics, + (message) => createAnimationManifestDiagnostic(`Animation manifest ${message}`), + freeze + ); + +const resolveScenePortableAnimationClipMetadataSources = ( + manifest: PortableAnimationManifest | undefined, + scene: GltfSceneJson | undefined, + sceneIndex: number, + diagnostics: AssetImportDiagnostic[], + freeze: boolean +): GltfAnimationClipMetadataSourceIndex => + resolveAnimationClipMetadataEntries( + resolvePortableAnimationManifestSceneEntry(manifest, scene, sceneIndex)?.clips, + diagnostics, + (message) => createAnimationMetadataDiagnostic(sceneIndex, `clip override ${message}`), + freeze + ); + +const resolveClipMetadataSourceForAnimation = ( + sources: GltfAnimationClipMetadataSourceIndex, + animation: Pick +): GltfAnimationClipMetadataSource | undefined => + sources.byId.get(animation.id) ?? sources.byAnimationIndex.get(animation.animationIndex); + +const hasClipMetadata = (clip: GltfAnimationClipAsset): boolean => + Boolean( + clip.events || + clip.footContacts || + clip.tags || + clip.features || + clip.compression || + clip.streaming + ); + +const toClipMetadata = ( + clip: GltfAnimationClipAsset, + freeze: boolean +): GltfAnimationClipMetadata | undefined => { + if (!hasClipMetadata(clip)) { + return undefined; + } + + return maybeFreeze( + { + id: clip.id, + ...(clip.events ? { events: clip.events } : {}), + ...(clip.footContacts ? { footContacts: clip.footContacts } : {}), + ...(clip.tags ? { tags: clip.tags } : {}), + ...(clip.features ? { features: clip.features } : {}), + ...(clip.compression ? { compression: clip.compression } : {}), + ...(clip.streaming ? { streaming: clip.streaming } : {}), + } satisfies GltfAnimationClipMetadata, + freeze + ); +}; + +const mergeClipMetadata = ( + clipId: string, + base: GltfAnimationClipMetadata | undefined, + override: GltfAnimationClipMetadataSource | undefined, + freeze: boolean +): GltfAnimationClipMetadata | undefined => { + if (!base && !override) { + return undefined; + } + + return maybeFreeze( + { + id: clipId, + ...(override?.events ? { events: override.events } : base?.events ? { events: base.events } : {}), + ...(override?.footContacts + ? { footContacts: override.footContacts } + : base?.footContacts + ? { footContacts: base.footContacts } + : {}), + ...(override?.tags ? { tags: override.tags } : base?.tags ? { tags: base.tags } : {}), + ...(override?.features + ? { features: override.features } + : base?.features + ? { features: base.features } + : {}), + ...(override?.compression + ? { compression: override.compression } + : base?.compression + ? { compression: base.compression } + : {}), + ...(override?.streaming + ? { streaming: override.streaming } + : base?.streaming + ? { streaming: base.streaming } + : {}), + } satisfies GltfAnimationClipMetadata, + freeze + ); +}; + +const findAnimationTrackFrameIndex = (times: Float32Array, time: number): number => { + if (times.length <= 1 || time <= times[0]!) { + return 0; + } + + const lastIndex = times.length - 1; + if (time >= times[lastIndex]!) { + return Math.max(0, lastIndex - 1); + } + + let low = 0; + let high = lastIndex; + while (low <= high) { + const mid = (low + high) >> 1; + const start = times[mid]!; + const end = times[mid + 1] ?? Number.POSITIVE_INFINITY; + if (time < start) { + high = mid - 1; + continue; + } + if (time >= end) { + low = mid + 1; + continue; + } + return mid; + } + + return Math.max(0, Math.min(lastIndex - 1, low)); +}; + +const sampleAnimationTrackValues = ( + track: GltfAnimationClipAsset['tracks'][number], + time: number +): readonly number[] => { + const componentCount = track.valueComponentCount; + const frameIndex = findAnimationTrackFrameIndex(track.times, time); + const nextIndex = Math.min(track.keyframeCount - 1, frameIndex + 1); + const startTime = track.times[frameIndex] ?? 0; + const endTime = track.times[nextIndex] ?? startTime; + const duration = Math.max(0, endTime - startTime); + const alpha = duration > 0 ? Math.max(0, Math.min(1, (time - startTime) / duration)) : 0; + + if (track.interpolation === 'STEP' || frameIndex === nextIndex) { + const baseOffset = + frameIndex * track.sampleStride + (track.interpolation === 'CUBICSPLINE' ? componentCount : 0); + return Object.freeze( + Array.from({ length: componentCount }, (_, componentIndex) => + track.values[baseOffset + componentIndex] ?? (componentIndex === 3 ? 1 : 0) + ) + ); + } + + if (track.interpolation === 'CUBICSPLINE') { + const leftBase = frameIndex * track.sampleStride; + const rightBase = nextIndex * track.sampleStride; + const s = alpha; + const s2 = s * s; + const s3 = s2 * s; + const h00 = 2 * s3 - 3 * s2 + 1; + const h10 = s3 - 2 * s2 + s; + const h01 = -2 * s3 + 3 * s2; + const h11 = s3 - s2; + return Object.freeze( + Array.from({ length: componentCount }, (_, componentIndex) => { + const inTangent = track.values[rightBase + componentIndex] ?? 0; + const value0 = track.values[leftBase + componentCount + componentIndex] ?? 0; + const outTangent = track.values[leftBase + componentCount * 2 + componentIndex] ?? 0; + const value1 = track.values[rightBase + componentCount + componentIndex] ?? 0; + return h00 * value0 + h10 * duration * outTangent + h01 * value1 + h11 * duration * inTangent; + }) + ); + } + + const leftOffset = frameIndex * track.sampleStride; + const rightOffset = nextIndex * track.sampleStride; + return Object.freeze( + Array.from({ length: componentCount }, (_, componentIndex) => { + const left = track.values[leftOffset + componentIndex] ?? 0; + const right = track.values[rightOffset + componentIndex] ?? left; + return left + (right - left) * alpha; + }) + ); +}; + +const normalizeVector3Tuple = ( + value: readonly [number, number, number] +): readonly [number, number, number] => { + const length = Math.hypot(value[0], value[1], value[2]); + if (length <= Number.EPSILON) { + return Object.freeze([0, 0, 1]) as readonly [number, number, number]; + } + return Object.freeze([value[0] / length, value[1] / length, value[2] / length]) as readonly [number, number, number]; +}; -interface PrefabBuildResult { - readonly prefab: GltfPrefabDefinition; - readonly rootNodeIds: readonly string[]; - readonly nodeIds: readonly string[]; - readonly meshKeys: readonly string[]; - readonly skinKeys: readonly string[]; - readonly animationKeys: readonly string[]; - readonly materialKeys: readonly string[]; - readonly diagnostics: readonly AssetImportDiagnostic[]; -} +const normalizeQuaternionTuple = ( + value: readonly [number, number, number, number] +): readonly [number, number, number, number] => { + const length = Math.hypot(value[0], value[1], value[2], value[3]); + if (length <= Number.EPSILON) { + return Object.freeze([0, 0, 0, 1]) as readonly [number, number, number, number]; + } + return Object.freeze([ + value[0] / length, + value[1] / length, + value[2] / length, + value[3] / length, + ]) as readonly [number, number, number, number]; +}; -interface GltfSkinBinding { - readonly jointNodeIds: readonly string[]; - readonly skeletonNodeId?: string; - readonly inverseBindMatrices?: readonly number[] | Float32Array; -} +const rotateVectorByQuaternion = ( + vector: readonly [number, number, number], + quaternion: readonly [number, number, number, number] +): readonly [number, number, number] => { + const [x, y, z, w] = normalizeQuaternionTuple(quaternion); + const uvX = y * vector[2] - z * vector[1]; + const uvY = z * vector[0] - x * vector[2]; + const uvZ = x * vector[1] - y * vector[0]; + const uuvX = y * uvZ - z * uvY; + const uuvY = z * uvX - x * uvZ; + const uuvZ = x * uvY - y * uvX; + return normalizeVector3Tuple([ + vector[0] + (uvX * w + uuvX) * 2, + vector[1] + (uvY * w + uuvY) * 2, + vector[2] + (uvZ * w + uuvZ) * 2, + ]); +}; -const isPlainObject = (value: unknown): value is Record => - value !== null && typeof value === 'object' && Array.isArray(value) === false; +const resolveFeatureExportSampleTimes = ( + duration: number, + config: PortableAnimationFeatureExportDefinition +): readonly number[] => { + const explicitTimes = config.sampleTimes + ? [...new Set(config.sampleTimes.map((entry) => Math.max(0, Math.min(duration, entry))))].sort((left, right) => left - right) + : []; + if (explicitTimes.length > 0) { + return Object.freeze(explicitTimes); + } + + const interval = + typeof config.sampleInterval === 'number' && Number.isFinite(config.sampleInterval) && config.sampleInterval > 0 + ? config.sampleInterval + : Math.max(duration, 1e-3); + const times: number[] = []; + for (let time = 0; time < duration; time += interval) { + times.push(Math.max(0, Math.min(duration, time))); + } + if (times.length === 0 || Math.abs((times[times.length - 1] ?? 0) - duration) > 1e-6) { + times.push(duration); + } + return Object.freeze([...new Set(times)].sort((left, right) => left - right)); +}; -const isTypedArray = (value: unknown): value is ArrayBufferView => - ArrayBuffer.isView(value) && (value instanceof DataView === false); +const resolveFeatureExportTarget = ( + tracks: readonly GltfAnimationClipAsset['tracks'][number][], + config: PortableAnimationFeatureExportDefinition +): { readonly targetNodeId: string; readonly targetNodeIndex: number } | undefined => { + const findTrack = (predicate: (track: GltfAnimationClipAsset['tracks'][number]) => boolean) => + tracks.find((track) => (track.path === 'translation' || track.path === 'rotation') && predicate(track)); + + const resolvedTrack = + (typeof config.rootNodeId === 'string' + ? findTrack((track) => track.targetNodeId === config.rootNodeId) + : undefined) ?? + (typeof config.rootNodeIndex === 'number' + ? findTrack((track) => track.targetNodeIndex === config.rootNodeIndex) + : undefined) ?? + tracks.find((track) => track.path === 'translation') ?? + tracks.find((track) => track.path === 'rotation'); + + return resolvedTrack + ? { + targetNodeId: resolvedTrack.targetNodeId, + targetNodeIndex: resolvedTrack.targetNodeIndex, + } + : undefined; +}; -const freezeDeep = (value: T): T => { - if (value === null || typeof value !== 'object') { - return value; +const exportMotionFeaturesFromTracks = ( + clipId: string, + tracks: readonly GltfAnimationClipAsset['tracks'][number][], + duration: number, + config: PortableAnimationFeatureExportDefinition, + diagnostics: AssetImportDiagnostic[], + createDiagnostic: (message: string) => AssetImportDiagnostic, + freeze: boolean +): readonly AnimationMotionFeatureDefinition[] | undefined => { + const target = resolveFeatureExportTarget(tracks, config); + if (!target) { + diagnostics.push(createDiagnostic(`clip '${clipId}' featureExport could not resolve a translation or rotation target`)); + return undefined; } - if (value instanceof ArrayBuffer || isTypedArray(value) || value instanceof DataView) { - return value; + const translationTrack = tracks.find( + (track) => track.path === 'translation' && track.targetNodeId === target.targetNodeId + ); + const rotationTrack = tracks.find( + (track) => track.path === 'rotation' && track.targetNodeId === target.targetNodeId + ); + if (!translationTrack && !rotationTrack) { + diagnostics.push(createDiagnostic(`clip '${clipId}' featureExport target '${target.targetNodeId}' has no usable tracks`)); + return undefined; } - if (Array.isArray(value)) { - for (const item of value) { - freezeDeep(item); + const sampleTimes = resolveFeatureExportSampleTimes(duration, config); + const forwardAxis = normalizeVector3Tuple(config.forwardAxis ?? [0, 0, 1]); + const positions = translationTrack + ? sampleTimes.map((time) => { + const sample = sampleAnimationTrackValues(translationTrack, time); + return Object.freeze([ + sample[0] ?? 0, + sample[1] ?? 0, + sample[2] ?? 0, + ]) as readonly [number, number, number]; + }) + : undefined; + + const fallbackFacing = sampleTimes.map((_, index) => { + if (!positions) { + return forwardAxis; + } + const current = positions[index]!; + const neighbor = positions[index + 1] ?? positions[index - 1] ?? current; + const direction: readonly [number, number, number] = + positions[index + 1] + ? [neighbor[0] - current[0], neighbor[1] - current[1], neighbor[2] - current[2]] + : [current[0] - neighbor[0], current[1] - neighbor[1], current[2] - neighbor[2]]; + return normalizeVector3Tuple(direction); + }); + + const features = sampleTimes + .map((time, index) => { + const rotation = rotationTrack + ? (sampleAnimationTrackValues(rotationTrack, time) as readonly [number, number, number, number]) + : undefined; + const facingDirection = rotation ? rotateVectorByQuaternion(forwardAxis, rotation) : fallbackFacing[index]!; + const trajectoryPosition = positions?.[index]; + if (!trajectoryPosition && !facingDirection) { + return undefined; + } + + return maybeFreeze( + { + time, + ...(trajectoryPosition ? { trajectoryPosition } : {}), + ...(facingDirection ? { facingDirection } : {}), + ...(config.tags ? { tags: config.tags } : {}), + ...(typeof config.costBias === 'number' ? { costBias: config.costBias } : {}), + } satisfies AnimationMotionFeatureDefinition, + freeze + ); + }) + .filter((feature): feature is AnimationMotionFeatureDefinition => Boolean(feature)); + + return features.length > 0 ? Object.freeze(features) : undefined; +}; + +const sanitizeAnimationParameters = ( + value: unknown, + sceneIndex: number, + diagnostics: AssetImportDiagnostic[], + freeze: boolean +): readonly AnimationParameterDefinition[] | undefined => { + if (!Array.isArray(value)) { + return undefined; + } + + const parameters: AnimationParameterDefinition[] = []; + const seenNames = new Set(); + for (let index = 0; index < value.length; index += 1) { + const entry = value[index]; + if (!isPlainObject(entry)) { + diagnostics.push( + createAnimationMetadataDiagnostic(sceneIndex, `parameter ${index} is not an object`) + ); + continue; + } + + const name = typeof entry.name === 'string' ? entry.name : null; + const kind = + typeof entry.kind === 'string' && VALID_ANIMATION_PARAMETER_KINDS.has(entry.kind) + ? (entry.kind as AnimationParameterDefinition['kind']) + : null; + if (!name || !kind || VALID_ANIMATION_PARAMETER_KINDS.has(kind) === false) { + diagnostics.push( + createAnimationMetadataDiagnostic( + sceneIndex, + `parameter ${index} must provide a valid name and kind` + ) + ); + continue; + } + + if (seenNames.has(name)) { + diagnostics.push( + createAnimationMetadataDiagnostic(sceneIndex, `parameter '${name}' is duplicated`) + ); + continue; + } + + const defaultValue = entry.defaultValue; + if ( + defaultValue !== undefined && + ((kind === 'float' || kind === 'int') + ? isFiniteNumber(defaultValue) === false + : typeof defaultValue !== 'boolean') + ) { + diagnostics.push( + createAnimationMetadataDiagnostic( + sceneIndex, + `parameter '${name}' has an invalid defaultValue` + ) + ); + continue; + } + + seenNames.add(name); + switch (kind) { + case 'float': + case 'int': + parameters.push( + maybeFreeze( + { + name, + kind, + ...(typeof defaultValue === 'number' ? { defaultValue } : {}), + } satisfies AnimationParameterDefinition, + freeze + ) + ); + break; + case 'bool': + case 'trigger': + parameters.push( + maybeFreeze( + { + name, + kind, + ...(typeof defaultValue === 'boolean' ? { defaultValue } : {}), + } satisfies AnimationParameterDefinition, + freeze + ) + ); + break; + } + } + + return parameters.length > 0 ? Object.freeze(parameters) : undefined; +}; + +const validateAnimationParameterReference = ( + parameter: unknown, + parameterNames: ReadonlySet +): boolean => typeof parameter === 'string' && parameterNames.has(parameter); + +const validateAnimationConditionMetadata = ( + condition: unknown, + parameterNames: ReadonlySet +): boolean => { + if (!isPlainObject(condition) || typeof condition.kind !== 'string') { + return false; + } + + if (VALID_ANIMATION_CONDITION_KINDS.has(condition.kind) === false) { + return false; + } + + if (!validateAnimationParameterReference(condition.parameter, parameterNames)) { + return false; + } + + switch (condition.kind) { + case 'float': + case 'int': + return ( + typeof condition.operator === 'string' && + VALID_ANIMATION_CONDITION_OPERATORS.has(condition.operator) && + isFiniteNumber(condition.value) + ); + case 'bool': + return typeof condition.value === 'boolean'; + case 'trigger': + return true; + default: + return false; + } +}; + +const validateAnimationMotionMetadata = ( + motion: unknown, + clipIds: ReadonlySet, + parameterNames: ReadonlySet +): boolean => { + if (!isPlainObject(motion) || typeof motion.kind !== 'string') { + return false; + } + + switch (motion.kind) { + case 'clip': + return typeof motion.clipId === 'string' && clipIds.has(motion.clipId); + case 'blend1d': + return ( + validateAnimationParameterReference(motion.parameter, parameterNames) && + Array.isArray(motion.children) && + motion.children.length > 0 && + motion.children.every( + (child) => + isPlainObject(child) && + isFiniteNumber(child.threshold) && + validateAnimationMotionMetadata(child.motion, clipIds, parameterNames) + ) + ); + case 'blend2d': + return ( + validateAnimationParameterReference(motion.parameterX, parameterNames) && + validateAnimationParameterReference(motion.parameterY, parameterNames) && + Array.isArray(motion.children) && + motion.children.length > 0 && + motion.children.every( + (child) => + isPlainObject(child) && + Array.isArray(child.position) && + child.position.length === 2 && + child.position.every((entry) => isFiniteNumber(entry)) && + validateAnimationMotionMetadata(child.motion, clipIds, parameterNames) + ) + ); + case 'direct': + return ( + Array.isArray(motion.children) && + motion.children.length > 0 && + motion.children.every( + (child) => + isPlainObject(child) && + (child.parameter === undefined || + validateAnimationParameterReference(child.parameter, parameterNames)) && + (child.weight === undefined || isFiniteNumber(child.weight)) && + validateAnimationMotionMetadata(child.motion, clipIds, parameterNames) + ) + ); + case 'additive': + return ( + validateAnimationMotionMetadata(motion.base, clipIds, parameterNames) && + validateAnimationMotionMetadata(motion.additive, clipIds, parameterNames) && + (motion.parameter === undefined || + validateAnimationParameterReference(motion.parameter, parameterNames)) && + (motion.weight === undefined || isFiniteNumber(motion.weight)) + ); + default: + return false; + } +}; + +const validateAnimationStateMachineMetadata = ( + stateMachine: unknown, + clipIds: ReadonlySet, + parameterNames: ReadonlySet +): boolean => { + if ( + !isPlainObject(stateMachine) || + typeof stateMachine.entryState !== 'string' || + !Array.isArray(stateMachine.states) + ) { + return false; + } + + const stateIds = new Set(); + for (let index = 0; index < stateMachine.states.length; index += 1) { + const state = stateMachine.states[index]; + if (!isPlainObject(state) || typeof state.id !== 'string') { + return false; + } + stateIds.add(state.id); + } + + if (!stateIds.has(stateMachine.entryState)) { + return false; + } + + const validateTransitions = (transitions: unknown): boolean => + transitions === undefined || + (Array.isArray(transitions) && + transitions.every( + (transition) => + isPlainObject(transition) && + typeof transition.to === 'string' && + stateIds.has(transition.to) && + (transition.duration === undefined || isFiniteNumber(transition.duration)) && + (transition.offset === undefined || isFiniteNumber(transition.offset)) && + (transition.exitTime === undefined || isFiniteNumber(transition.exitTime)) && + (transition.fixedDuration === undefined || typeof transition.fixedDuration === 'boolean') && + (transition.canInterrupt === undefined || typeof transition.canInterrupt === 'boolean') && + (transition.priority === undefined || isFiniteNumber(transition.priority)) && + (transition.conditions === undefined || + (Array.isArray(transition.conditions) && + transition.conditions.every((condition) => + validateAnimationConditionMetadata(condition, parameterNames) + ))) + )); + + return ( + validateTransitions(stateMachine.anyStateTransitions) && + stateMachine.states.every( + (state) => + isPlainObject(state) && + validateAnimationMotionMetadata(state.motion, clipIds, parameterNames) && + (state.speed === undefined || isFiniteNumber(state.speed)) && + (state.loop === undefined || typeof state.loop === 'boolean') && + validateTransitions(state.transitions) + ) + ); +}; + +const validateAnimationIkLayerMetadata = ( + value: unknown, + boneIds: ReadonlySet +): boolean => { + if (!isPlainObject(value) || typeof value.id !== 'string' || !Array.isArray(value.jobs)) { + return false; + } + + return value.jobs.every( + (job) => + isPlainObject(job) && + typeof job.id === 'string' && + typeof job.solver === 'string' && + VALID_ANIMATION_IK_SOLVERS.has(job.solver) && + typeof job.rootBone === 'string' && + boneIds.has(job.rootBone) && + typeof job.tipBone === 'string' && + boneIds.has(job.tipBone) && + (job.targetBone === undefined || + (typeof job.targetBone === 'string' && boneIds.has(job.targetBone))) && + (job.targetPosition === undefined || isNumberTuple3(job.targetPosition)) && + (job.targetRotation === undefined || isNumberTuple4(job.targetRotation)) && + (job.precision === undefined || isFiniteNumber(job.precision)) && + (job.maxIterations === undefined || isFiniteNumber(job.maxIterations)) && + (job.weight === undefined || isFiniteNumber(job.weight)) && + (job.preserveTipRotation === undefined || typeof job.preserveTipRotation === 'boolean') + ); +}; + +const sanitizeAnimationLayers = ( + value: unknown, + sceneIndex: number, + diagnostics: AssetImportDiagnostic[], + clipIds: ReadonlySet, + parameterNames: ReadonlySet, + boneIds: ReadonlySet, + freeze: boolean +): readonly AnimationLayerDefinition[] | undefined => { + if (!Array.isArray(value)) { + return undefined; + } + + const layers: AnimationLayerDefinition[] = []; + const seenLayerIds = new Set(); + for (let index = 0; index < value.length; index += 1) { + const entry = value[index]; + if (!isPlainObject(entry) || typeof entry.id !== 'string') { + diagnostics.push( + createAnimationMetadataDiagnostic(sceneIndex, `layer ${index} must provide an id`) + ); + continue; + } + + if (seenLayerIds.has(entry.id)) { + diagnostics.push( + createAnimationMetadataDiagnostic(sceneIndex, `layer '${entry.id}' is duplicated`) + ); + continue; + } + + if ( + entry.mode !== undefined && + (typeof entry.mode !== 'string' || VALID_ANIMATION_LAYER_MODES.has(entry.mode) === false) + ) { + diagnostics.push( + createAnimationMetadataDiagnostic(sceneIndex, `layer '${entry.id}' has an unsupported mode`) + ); + continue; + } + + if (entry.weight !== undefined && isFiniteNumber(entry.weight) === false) { + diagnostics.push( + createAnimationMetadataDiagnostic(sceneIndex, `layer '${entry.id}' has an invalid weight`) + ); + continue; + } + + if ( + entry.boneMask !== undefined && + (!Array.isArray(entry.boneMask) || + entry.boneMask.some( + (boneId) => typeof boneId !== 'string' || boneIds.has(boneId) === false + )) + ) { + diagnostics.push( + createAnimationMetadataDiagnostic( + sceneIndex, + `layer '${entry.id}' references unknown bone ids in boneMask` + ) + ); + continue; + } + + if (!validateAnimationStateMachineMetadata(entry.stateMachine, clipIds, parameterNames)) { + diagnostics.push( + createAnimationMetadataDiagnostic( + sceneIndex, + `layer '${entry.id}' has an invalid state machine` + ) + ); + continue; + } + + if ( + entry.ikLayers !== undefined && + (!Array.isArray(entry.ikLayers) || + entry.ikLayers.some((ikLayer) => validateAnimationIkLayerMetadata(ikLayer, boneIds) === false)) + ) { + diagnostics.push( + createAnimationMetadataDiagnostic(sceneIndex, `layer '${entry.id}' has invalid IK metadata`) + ); + continue; + } + + const cloned = cloneSerializableMetadata(entry); + if (!isPlainObject(cloned)) { + diagnostics.push( + createAnimationMetadataDiagnostic(sceneIndex, `layer '${entry.id}' could not be cloned`) + ); + continue; } - return Object.freeze(value) as T; + + seenLayerIds.add(entry.id); + layers.push(maybeFreeze(cloned as unknown as AnimationLayerDefinition, freeze)); + } + + return layers.length > 0 ? Object.freeze(layers) : undefined; +}; + +const sanitizeAnimationRootMotion = ( + value: unknown, + sceneIndex: number, + diagnostics: AssetImportDiagnostic[], + boneIds: ReadonlySet, + freeze: boolean +): AnimationRootMotionDefinition | null | undefined => { + if (value === null) { + return null; + } + + if (!isPlainObject(value)) { + return undefined; + } + + if (typeof value.bone !== 'string' || boneIds.has(value.bone) === false) { + diagnostics.push( + createAnimationMetadataDiagnostic( + sceneIndex, + 'rootMotion must reference an imported node id' + ) + ); + return undefined; + } + + if ( + value.projectTranslationAxes !== undefined && + isBooleanTuple3(value.projectTranslationAxes) === false + ) { + diagnostics.push( + createAnimationMetadataDiagnostic( + sceneIndex, + 'rootMotion.projectTranslationAxes must be a boolean tuple of length 3' + ) + ); + return undefined; } - for (const nested of Object.values(value as Record)) { - freezeDeep(nested); + if ( + (value.consume !== undefined && typeof value.consume !== 'boolean') || + (value.extractRotation !== undefined && typeof value.extractRotation !== 'boolean') + ) { + diagnostics.push( + createAnimationMetadataDiagnostic(sceneIndex, 'rootMotion flags must be boolean values') + ); + return undefined; } - return Object.freeze(value); + return maybeFreeze( + { + bone: value.bone, + ...(value.consume !== undefined ? { consume: value.consume } : {}), + ...(value.projectTranslationAxes !== undefined + ? { + projectTranslationAxes: Object.freeze([ + ...value.projectTranslationAxes, + ]) as readonly [boolean, boolean, boolean], + } + : {}), + ...(value.extractRotation !== undefined + ? { extractRotation: value.extractRotation } + : {}), + } satisfies AnimationRootMotionDefinition, + freeze + ); }; -const maybeFreeze = (value: T, enabled: boolean): T => (enabled ? freezeDeep(value) : value); +const resolveSceneAnimationControllerMetadata = ( + scene: GltfSceneJson | undefined, + sceneIndex: number, + clipIds: ReadonlySet, + boneIds: ReadonlySet, + animations: readonly GltfAnimationClipAsset[], + manifest: PortableAnimationManifest | undefined, + diagnostics: AssetImportDiagnostic[], + freeze: boolean +): GltfAnimationControllerMetadata | undefined => { + const source = mergeAnimationMetadataSources( + resolvePortableSceneAnimationMetadataSource(manifest, scene, sceneIndex), + resolveSceneAnimationMetadataSource(scene) + ); + const parameters = source + ? sanitizeAnimationParameters(source.parameters, sceneIndex, diagnostics, freeze) + : undefined; + const parameterNames = new Set(parameters?.map((parameter) => parameter.name) ?? EMPTY_ARRAY); + const layers = source + ? sanitizeAnimationLayers( + source.layers, + sceneIndex, + diagnostics, + clipIds, + parameterNames, + boneIds, + freeze + ) + : undefined; + const rootMotion = source + ? sanitizeAnimationRootMotion( + source.rootMotion, + sceneIndex, + diagnostics, + boneIds, + freeze + ) + : undefined; + const sceneClipMetadataSources = resolveScenePortableAnimationClipMetadataSources( + manifest, + scene, + sceneIndex, + diagnostics, + freeze + ); + const clips = Object.freeze( + animations + .map((animation) => + mergeClipMetadata( + animation.id, + toClipMetadata(animation, freeze), + resolveClipMetadataSourceForAnimation(sceneClipMetadataSources, animation), + freeze + ) + ) + .filter((clip): clip is GltfAnimationClipMetadata => Boolean(clip)) + ); + + if (!parameters && !layers && rootMotion === undefined && clips.length === 0) { + return undefined; + } + + return maybeFreeze( + { + ...(parameters ? { parameters } : {}), + ...(layers ? { layers } : {}), + ...(rootMotion !== undefined ? { rootMotion } : {}), + ...(clips.length > 0 ? { clips } : {}), + } satisfies GltfAnimationControllerMetadata, + freeze + ); +}; const sanitizeName = (value: string | undefined, fallback: string): string => { const trimmed = value?.trim(); @@ -507,7 +2137,8 @@ const createSkinBinding = (skin: GltfSkinAsset | undefined): GltfSkinBinding | u }; const createAnimatorSnapshot = ( - animations: readonly GltfAnimationClipAsset[] + animations: readonly GltfAnimationClipAsset[], + metadata: GltfAnimationControllerMetadata | undefined ): GltfComponentSnapshot | undefined => { type SerializableTrack = Readonly<{ targetNodeId: string; @@ -523,11 +2154,22 @@ const createAnimatorSnapshot = ( type SerializableClip = Readonly<{ id: string; duration: number; + events?: readonly AnimationClipEventDefinition[]; + footContacts?: readonly AnimationFootContactDefinition[]; + tags?: readonly string[]; + features?: readonly AnimationMotionFeatureDefinition[]; + compression?: AnimationClipCompressionDefinition; + streaming?: AnimationClipStreamingDefinition; tracks: readonly SerializableTrack[]; }>; + const clipMetadataById = new Map( + (metadata?.clips ?? EMPTY_ARRAY).map((clip) => [clip.id, clip] as const) + ); + const clips = animations .map((clip) => { + const clipMetadata = clipMetadataById.get(clip.id) ?? clip; const tracks = clip.tracks .map( (track) => @@ -550,6 +2192,12 @@ const createAnimatorSnapshot = ( return Object.freeze({ id: clip.id, duration: clip.duration, + ...(clipMetadata.events ? { events: clipMetadata.events } : {}), + ...(clipMetadata.footContacts ? { footContacts: clipMetadata.footContacts } : {}), + ...(clipMetadata.tags ? { tags: clipMetadata.tags } : {}), + ...(clipMetadata.features ? { features: clipMetadata.features } : {}), + ...(clipMetadata.compression ? { compression: clipMetadata.compression } : {}), + ...(clipMetadata.streaming ? { streaming: clipMetadata.streaming } : {}), tracks: Object.freeze(tracks), } satisfies SerializableClip); }) @@ -564,6 +2212,9 @@ const createAnimatorSnapshot = ( data: encodeGltfValue( Object.freeze({ clips: Object.freeze(clips), + ...(metadata?.parameters ? { parameters: metadata.parameters } : {}), + ...(metadata?.layers ? { layers: metadata.layers } : {}), + ...(metadata && 'rootMotion' in metadata ? { rootMotion: metadata.rootMotion ?? null } : {}), clipId: clips[0]?.id ?? null, playOnStart: true, playing: true, @@ -669,9 +2320,12 @@ const createDefaultMaterialDefinition = ( id: '', shaderId, uniforms: Object.freeze({ - _BaseColorFactor: Object.freeze([1, 1, 1, 1]), - _MetallicFactor: 1, - _RoughnessFactor: 1, + // Missing glTF materials should fall back to a neutral dielectric surface, + // not a fully metallic one, otherwise untextured characters read as black + // and skinning/shading artifacts become dramatically overemphasized. + _BaseColorFactor: Object.freeze([0.84, 0.84, 0.86, 1]), + _MetallicFactor: 0.04, + _RoughnessFactor: 0.94, _EmissiveFactor: Object.freeze([0, 0, 0]), _AlphaMode: 0, _AlphaCutoff: 0.5, @@ -858,7 +2512,25 @@ const createSkinAsset = async ( ); } - inverseBindMatrices = decoded.values; + inverseBindMatrices = new Float32Array(decoded.values.length); + for (let matrixOffset = 0; matrixOffset < decoded.values.length; matrixOffset += 16) { + inverseBindMatrices[matrixOffset + 0] = decoded.values[matrixOffset + 0]!; + inverseBindMatrices[matrixOffset + 1] = decoded.values[matrixOffset + 4]!; + inverseBindMatrices[matrixOffset + 2] = decoded.values[matrixOffset + 8]!; + inverseBindMatrices[matrixOffset + 3] = decoded.values[matrixOffset + 12]!; + inverseBindMatrices[matrixOffset + 4] = decoded.values[matrixOffset + 1]!; + inverseBindMatrices[matrixOffset + 5] = decoded.values[matrixOffset + 5]!; + inverseBindMatrices[matrixOffset + 6] = decoded.values[matrixOffset + 9]!; + inverseBindMatrices[matrixOffset + 7] = decoded.values[matrixOffset + 13]!; + inverseBindMatrices[matrixOffset + 8] = decoded.values[matrixOffset + 2]!; + inverseBindMatrices[matrixOffset + 9] = decoded.values[matrixOffset + 6]!; + inverseBindMatrices[matrixOffset + 10] = decoded.values[matrixOffset + 10]!; + inverseBindMatrices[matrixOffset + 11] = decoded.values[matrixOffset + 14]!; + inverseBindMatrices[matrixOffset + 12] = decoded.values[matrixOffset + 3]!; + inverseBindMatrices[matrixOffset + 13] = decoded.values[matrixOffset + 7]!; + inverseBindMatrices[matrixOffset + 14] = decoded.values[matrixOffset + 11]!; + inverseBindMatrices[matrixOffset + 15] = decoded.values[matrixOffset + 15]!; + } } return maybeFreeze( @@ -883,6 +2555,8 @@ const createAnimationClipAsset = async ( root: GltfRootJson, animationIndex: number, accessors: GltfAccessorRuntime, + clipMetadataSources: GltfAnimationClipMetadataSourceIndex, + diagnostics: AssetImportDiagnostic[], freeze: boolean ): Promise => { const animation = root.animations?.[animationIndex]; @@ -973,11 +2647,42 @@ const createAnimationClipAsset = async ( ); } + const clipId = sanitizeName(animation.name, `Animation ${animationIndex}`); + const clipMetadata = + clipMetadataSources.byId.get(clipId) ?? + clipMetadataSources.byAnimationIndex.get(animationIndex); + const exportedFeatures = clipMetadata?.featureExport + ? exportMotionFeaturesFromTracks( + clipId, + Object.freeze(tracks), + duration, + clipMetadata.featureExport, + diagnostics, + (message) => createAnimationManifestDiagnostic(message), + freeze + ) + : undefined; + const features = + clipMetadata?.features || exportedFeatures + ? Object.freeze( + [ + ...(clipMetadata?.features ?? []), + ...(exportedFeatures ?? []), + ].sort((left, right) => left.time - right.time) + ) + : undefined; + return maybeFreeze( { - id: sanitizeName(animation.name, `Animation ${animationIndex}`), + id: clipId, animationIndex, duration, + ...(clipMetadata?.events ? { events: clipMetadata.events } : {}), + ...(clipMetadata?.footContacts ? { footContacts: clipMetadata.footContacts } : {}), + ...(clipMetadata?.tags ? { tags: clipMetadata.tags } : {}), + ...(features ? { features } : {}), + ...(clipMetadata?.compression ? { compression: clipMetadata.compression } : {}), + ...(clipMetadata?.streaming ? { streaming: clipMetadata.streaming } : {}), tracks: Object.freeze(tracks), } satisfies GltfAnimationClipAsset, freeze @@ -993,7 +2698,8 @@ const buildPrefabDefinition = ( skinsByIndex: readonly (GltfSkinAsset | undefined)[], skinKeysByIndex: readonly string[], animationsByIndex: readonly (GltfAnimationClipAsset | undefined)[], - animationKeysByIndex: readonly string[] + animationKeysByIndex: readonly string[], + manifest: PortableAnimationManifest | undefined ): PrefabBuildResult => { const scene = root.scenes?.[sceneIndex]; if (!scene) { @@ -1203,7 +2909,18 @@ const buildPrefabDefinition = ( }) .filter((animation): animation is GltfAnimationClipAsset => Boolean(animation)); - const animatorComponent = createAnimatorSnapshot(sceneAnimations); + const animationController = resolveSceneAnimationControllerMetadata( + root.scenes?.[sceneIndex], + sceneIndex, + new Set(sceneAnimations.map((animation) => animation.id)), + importedNodeIds, + sceneAnimations, + manifest, + diagnostics, + true + ); + + const animatorComponent = createAnimatorSnapshot(sceneAnimations, animationController); if (animatorComponent) { const firstRootActorIndex = actors.findIndex((actor) => actor.parentNodeId === null); if (firstRootActorIndex >= 0) { @@ -1246,6 +2963,7 @@ const buildPrefabDefinition = ( skinKeys: Object.freeze([...skinKeys]), animationKeys: Object.freeze([...animationKeys]), materialKeys: Object.freeze([...materialKeys]), + ...(animationController ? { animationController } : {}), diagnostics: Object.freeze(diagnostics), }; }; @@ -1462,18 +3180,29 @@ export const createGltfImporter = < const { source, createSubKey } = context; const normalized = normalizeGltfSource(source); assertSupportedRequiredExtensions(normalized.json); - const runtime = new GltfResourceRuntime(normalized, source, options.resourceResolver); + const runtime = new GltfResourceRuntime( + normalized, + source, + options.resourceResolver, + options.dracoDecoder + ); const accessors = new GltfAccessorRuntime(runtime); const diagnostics: AssetImportDiagnostic[] = [ ...collectExtensionDiagnostics(normalized.json), ...collectFeatureDiagnostics(normalized.json), ]; + const animationManifest = resolvePortableAnimationManifest(normalized, diagnostics); const textureUsageMap = collectTextureUsages(normalized.json); const explicitTextures = normalized.json.textures ?? EMPTY_ARRAY; const explicitMaterials = normalized.json.materials ?? EMPTY_ARRAY; const explicitMeshes = normalized.json.meshes ?? EMPTY_ARRAY; const explicitSkins = normalized.json.skins ?? EMPTY_ARRAY; const explicitAnimations = normalized.json.animations ?? EMPTY_ARRAY; + const clipMetadataSources = resolvePortableAnimationClipMetadataSources( + animationManifest, + diagnostics, + freeze + ); const textureKeys = explicitTextures.map((_, index) => String(createSubKey(`texture/${index}`)) ); @@ -1689,6 +3418,8 @@ export const createGltfImporter = < normalized.json, animationIndex, accessors, + clipMetadataSources, + diagnostics, freeze ); animationsByIndex[animationIndex] = asset; @@ -1729,7 +3460,8 @@ export const createGltfImporter = < skinsByIndex, skinKeys, animationsByIndex, - animationKeys + animationKeys, + animationManifest ); diagnostics.push(...built.diagnostics); const key = String(createSubKey(`scene/${sceneIndex}/prefab`)); @@ -1747,6 +3479,9 @@ export const createGltfImporter = < skinKeys: built.skinKeys, animationKeys: built.animationKeys, materialKeys: built.materialKeys, + ...(built.animationController + ? { animationController: built.animationController } + : {}), }, freeze ); @@ -1772,6 +3507,9 @@ export const createGltfImporter = < name: asset.id, prefabKey: key, rootNodeIds: built.rootNodeIds, + ...(built.animationController + ? { animationController: built.animationController } + : {}), } satisfies GltfDocumentSceneAsset, freeze ) diff --git a/web/packages/asset-gltf/src/index.ts b/web/packages/asset-gltf/src/index.ts index 5c780537..85417cbd 100644 --- a/web/packages/asset-gltf/src/index.ts +++ b/web/packages/asset-gltf/src/index.ts @@ -3,8 +3,10 @@ export type { GltfAnimationClipAsset, GltfAnimationChannelJson, GltfAnimationChannelTargetJson, + GltfAnimationClipMetadata, GltfAnimationJson, GltfAnimationSamplerJson, + GltfAnimationControllerMetadata, GltfAnimationTrackAsset, GltfAssetKind, GltfAssetSchema, @@ -15,6 +17,7 @@ export type { GltfCameraOrthographicJson, GltfCameraPerspectiveJson, GltfCompressedTexturePayload, + GltfDracoDecoderOptions, GltfDracoMeshCompressionExtensionJson, GltfDocumentAsset, GltfDocumentSceneAsset, @@ -62,6 +65,19 @@ export type { GltfTextureUsage, GltfTranscodeStage, } from './types'; +export type { + PortableAnimationClipManifestEntry, + PortableAnimationFeatureExportDefinition, + PortableAnimationManifest, + PortableAnimationManifestSceneEntry, +} from './animation-manifest'; +export type { + AnimationStreamingChunkResourceOptions, + PortableAnimationStreamingChunkRangeDefinition, + PortableAnimationStreamingClipBundle, + PortableAnimationStreamingClipBundleOptions, + PortableAnimationStreamingClipSource, +} from './animation-streaming'; export type { AssetDatabase, AssetImportDiagnostic, @@ -97,11 +113,26 @@ export { GltfSchemaError, GltfTopologyError, } from './errors'; +export type { ParsedKtx2Texture } from './internal/ktx2-container'; +export { + inferTextureFormatFromKtx2, + parseKtx2Texture, +} from './internal/ktx2-container'; export { createLoadersBasisGltfTextureTranscoder, resolveLoadersBasisGltfTextureFormats, } from './loaders-texture-transcoder'; +export { + createPortableAnimationManifest, + createPortableAnimationManifestResource, + serializePortableAnimationManifest, +} from './animation-manifest'; +export { + createAnimationStreamingChunkResource, + createPortableAnimationStreamingClipBundle, + DEFAULT_ANIMATION_STREAMING_CHUNK_MIME_TYPE, +} from './animation-streaming'; export { createGltfImporter, createGltfTextureTranscodeStage, diff --git a/web/packages/asset-gltf/src/internal/mesh-runtime.ts b/web/packages/asset-gltf/src/internal/mesh-runtime.ts index c48a83bc..a713decc 100644 --- a/web/packages/asset-gltf/src/internal/mesh-runtime.ts +++ b/web/packages/asset-gltf/src/internal/mesh-runtime.ts @@ -1,7 +1,12 @@ import type { AssetImportDiagnostic } from '../asset-contract'; import { GltfSchemaError, GltfTopologyError } from '../errors'; import type { GltfMeshDefinition } from '../asset-ir'; -import type { GltfAccessorJson, GltfMeshBounds, GltfPrimitiveJson } from '../types'; +import type { + GltfAccessorJson, + GltfDracoDecoderOptions, + GltfMeshBounds, + GltfPrimitiveJson, +} from '../types'; import { type DecodedAccessor, GltfAccessorRuntime } from './accessor-runtime'; import { GltfResourceRuntime } from './source-runtime'; @@ -53,7 +58,13 @@ interface ResolvedPrimitiveGeometry { readonly topologyMode: 0 | 1 | 2 | 3 | 4 | 5 | 6; } +type DracoDecoderModuleFactory = NonNullable; +type DracoDecoderModuleConfig = Parameters[0]; + let dracoDecoderModulePromise: Promise | undefined; +let browserDracoDecoderModuleFactoryPromise: Promise | undefined; +const dracoDecoderModulePromisesByWasmUrl = new Map>(); +const dracoDecoderModulePromisesByFactory = new WeakMap>(); const isSupportedAttributeSemantic = (value: string): value is SupportedAttributeSemantic => (SUPPORTED_ATTRIBUTE_SEMANTICS as readonly string[]).includes(value); @@ -61,11 +72,105 @@ const isSupportedAttributeSemantic = (value: string): value is SupportedAttribut const isSupportedMorphTargetSemantic = (value: string): value is SupportedMorphTargetSemantic => (SUPPORTED_MORPH_TARGET_SEMANTICS as readonly string[]).includes(value); -const loadDracoDecoderModule = async (): Promise => { +const createDracoDecoderModuleConfig = ( + options: GltfDracoDecoderOptions | undefined +): DracoDecoderModuleConfig | undefined => { + if (!options?.wasmUrl) { + return undefined; + } + + return { + locateFile: (path: string, scriptDirectory: string) => + path === 'draco_decoder_gltf.wasm' + ? options.wasmUrl! + : `${scriptDirectory}${path}`, + }; +}; + +const isLikelyHtmlWasmResponseError = (error: unknown): boolean => { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes('WebAssembly.instantiate(): expected magic word 00 61 73 6d') && + message.includes('found 3c 21 64 6f') + ); +}; + +const withDracoDecoderHint = ( + error: unknown, + options: GltfDracoDecoderOptions | undefined +): never => { + if (!options?.wasmUrl && isLikelyHtmlWasmResponseError(error)) { + throw new Error( + 'Failed to initialize the browser Draco decoder because the wasm request resolved to HTML instead of draco_decoder_gltf.wasm. Configure createGltfImporter({ dracoDecoder: { wasmUrl } }) for browser imports that use KHR_draco_mesh_compression.', + { + cause: error instanceof Error ? error : undefined, + } + ); + } + + throw error; +}; + +const loadBrowserDracoDecoderModuleFactory = async (): Promise => { + browserDracoDecoderModuleFactoryPromise ??= import( + 'draco3dgltf/draco_decoder_gltf_nodejs.js' + ).then((module) => { + const candidate = + (module as { default?: unknown }).default ?? + (module as { DracoDecoderModule?: unknown }).DracoDecoderModule; + if (typeof candidate !== 'function') { + throw new Error('draco3dgltf browser decoder module factory could not be resolved'); + } + return candidate as DracoDecoderModuleFactory; + }); + + return browserDracoDecoderModuleFactoryPromise; +}; + +const loadConfiguredDracoDecoderModule = async ( + options: GltfDracoDecoderOptions +): Promise => { + const moduleFactory = options.moduleFactory ?? (await loadBrowserDracoDecoderModuleFactory()); + const cacheKey = options.wasmUrl; + + if (cacheKey) { + const cached = dracoDecoderModulePromisesByWasmUrl.get(cacheKey); + if (cached) { + return cached; + } + } else { + const cached = dracoDecoderModulePromisesByFactory.get(moduleFactory); + if (cached) { + return cached; + } + } + + const promise = Promise.resolve(moduleFactory(createDracoDecoderModuleConfig(options))).catch( + (error) => withDracoDecoderHint(error, options) + ); + + if (cacheKey) { + dracoDecoderModulePromisesByWasmUrl.set(cacheKey, promise); + } else { + dracoDecoderModulePromisesByFactory.set(moduleFactory, promise); + } + + return promise; +}; + +const loadDracoDecoderModule = async (runtime: GltfResourceRuntime): Promise => { + if (runtime.dracoDecoder?.moduleFactory || runtime.dracoDecoder?.wasmUrl) { + return loadConfiguredDracoDecoderModule(runtime.dracoDecoder); + } + dracoDecoderModulePromise ??= import('draco3dgltf').then((module) => module.createDecoderModule({}) ); - return dracoDecoderModulePromise; + try { + return await dracoDecoderModulePromise; + } catch (error) { + return withDracoDecoderHint(error, runtime.dracoDecoder); + } }; const accessorComponentCount = (type: GltfAccessorJson['type']): number => { @@ -288,7 +393,7 @@ const decodeDracoPrimitive = async ( } const compressedBytes = await runtime.resolveBufferView(extension.bufferView); - const decoderModule = await loadDracoDecoderModule(); + const decoderModule = await loadDracoDecoderModule(runtime); const decoderBuffer = new decoderModule.DecoderBuffer(); const decoder = new decoderModule.Decoder(); const mesh = new decoderModule.Mesh(); diff --git a/web/packages/asset-gltf/src/internal/source-runtime.ts b/web/packages/asset-gltf/src/internal/source-runtime.ts index 2e9ab375..3157cefa 100644 --- a/web/packages/asset-gltf/src/internal/source-runtime.ts +++ b/web/packages/asset-gltf/src/internal/source-runtime.ts @@ -1,4 +1,5 @@ import type { AssetImportSource } from '../asset-contract'; +import { isPlainObject } from '@axrone/utility'; import { MeshoptDecoder } from 'meshoptimizer'; import { GltfContainerError, @@ -31,9 +32,6 @@ export interface NormalizedGltfSource { readonly resources: ReadonlyMap; } -const isPlainObject = (value: unknown): value is Record => - value !== null && typeof value === 'object' && Array.isArray(value) === false; - const toUint8Array = (value: string | ArrayBuffer | ArrayBufferView | Uint8Array): Uint8Array => { if (typeof value === 'string') { return new TextEncoder().encode(value); @@ -378,7 +376,8 @@ export class GltfResourceRuntime { constructor( readonly source: NormalizedGltfSource, readonly importSource: AssetImportSource, - readonly resourceResolver: GltfImporterOptions['resourceResolver'] + readonly resourceResolver: GltfImporterOptions['resourceResolver'], + readonly dracoDecoder: GltfImporterOptions['dracoDecoder'] ) {} async resolveBuffer(index: number): Promise { diff --git a/web/packages/asset-gltf/src/types.ts b/web/packages/asset-gltf/src/types.ts index 5896e6e2..4206dfa8 100644 --- a/web/packages/asset-gltf/src/types.ts +++ b/web/packages/asset-gltf/src/types.ts @@ -1,3 +1,13 @@ +import type { + AnimationClipCompressionDefinition, + AnimationClipEventDefinition, + AnimationClipStreamingDefinition, + AnimationFootContactDefinition, + AnimationLayerDefinition, + AnimationMotionFeatureDefinition, + AnimationParameterDefinition, + AnimationRootMotionDefinition, +} from '@axrone/animation'; import type { FilterMode, TextureFormat, WrapMode } from '@axrone/render-webgl2'; import type { AssetCustomSource, @@ -49,6 +59,7 @@ export interface GltfDocumentSceneAsset { readonly name: string; readonly prefabKey: string; readonly rootNodeIds: readonly string[]; + readonly animationController?: GltfAnimationControllerMetadata; } export interface GltfDocumentStats { @@ -110,6 +121,29 @@ export interface GltfAnimationClipAsset { readonly animationIndex: number; readonly duration: number; readonly tracks: readonly GltfAnimationTrackAsset[]; + readonly events?: readonly AnimationClipEventDefinition[]; + readonly footContacts?: readonly AnimationFootContactDefinition[]; + readonly tags?: readonly string[]; + readonly features?: readonly AnimationMotionFeatureDefinition[]; + readonly compression?: AnimationClipCompressionDefinition; + readonly streaming?: AnimationClipStreamingDefinition; +} + +export interface GltfAnimationClipMetadata { + readonly id: string; + readonly events?: readonly AnimationClipEventDefinition[]; + readonly footContacts?: readonly AnimationFootContactDefinition[]; + readonly tags?: readonly string[]; + readonly features?: readonly AnimationMotionFeatureDefinition[]; + readonly compression?: AnimationClipCompressionDefinition; + readonly streaming?: AnimationClipStreamingDefinition; +} + +export interface GltfAnimationControllerMetadata { + readonly parameters?: readonly AnimationParameterDefinition[]; + readonly layers?: readonly AnimationLayerDefinition[]; + readonly rootMotion?: AnimationRootMotionDefinition | null; + readonly clips?: readonly GltfAnimationClipMetadata[]; } export type GltfMaterialAlphaMode = 'OPAQUE' | 'MASK' | 'BLEND'; @@ -222,6 +256,7 @@ export interface GltfPrefabAsset { readonly skinKeys: readonly string[]; readonly animationKeys: readonly string[]; readonly materialKeys: readonly string[]; + readonly animationController?: GltfAnimationControllerMetadata; } export interface GltfAssetSchema extends AssetSchema { @@ -279,9 +314,19 @@ export type GltfResourceResolver = ( request: Readonly ) => GltfResolvedResource | Promise | undefined; +export interface GltfDracoDecoderOptions { + readonly wasmUrl?: string; + readonly moduleFactory?: ( + moduleConfig?: Readonly<{ + locateFile?: (path: string, scriptDirectory: string) => string; + }> + ) => Promise; +} + export interface GltfImporterOptions { readonly id?: string; readonly resourceResolver?: GltfResourceResolver; + readonly dracoDecoder?: GltfDracoDecoderOptions; readonly materialShaderId?: string; readonly textureStageId?: string; readonly defaultSamplerId?: string; @@ -582,6 +627,8 @@ export interface GltfNodeJson { export interface GltfSceneJson { readonly nodes?: readonly number[]; readonly name?: string; + readonly extensions?: Readonly>; + readonly extras?: Readonly>; } export interface GltfRootJson { diff --git a/web/packages/asset-shader/package.json b/web/packages/asset-shader/package.json new file mode 100644 index 00000000..a64edc44 --- /dev/null +++ b/web/packages/asset-shader/package.json @@ -0,0 +1,28 @@ +{ + "name": "@axrone/asset-shader", + "version": "0.1.0", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "rollup -c rollup.config.mjs", + "clean": "rimraf dist", + "test": "vitest run" + }, + "dependencies": { + "@axrone/asset-core": "^0.1.0", + "@axrone/render-core": "^0.1.0", + "@axrone/utility": "^0.0.1" + } +} \ No newline at end of file diff --git a/web/packages/asset-shader/rollup.config.mjs b/web/packages/asset-shader/rollup.config.mjs new file mode 100644 index 00000000..16ce0312 --- /dev/null +++ b/web/packages/asset-shader/rollup.config.mjs @@ -0,0 +1,9 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createPackageConfig } from '../../build/create-package-config.mjs'; + +const packageDir = path.dirname(fileURLToPath(import.meta.url)); + +export default createPackageConfig({ + packageDir, +}); diff --git a/web/packages/asset-shader/src/__tests__/shader-effect-importer.test.ts b/web/packages/asset-shader/src/__tests__/shader-effect-importer.test.ts new file mode 100644 index 00000000..f298b5a3 --- /dev/null +++ b/web/packages/asset-shader/src/__tests__/shader-effect-importer.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { AssetDatabase } from '@axrone/asset-core'; +import { + createAssetShaderImportPipeline, + type AssetShaderImportSchema, +} from '../shader-effect-importer'; + +describe('asset-shader effect import pipeline', () => { + it('imports shorthand effect JSON and derives a canonical shader effect definition', async () => { + const database = new AssetDatabase({ + pipeline: createAssetShaderImportPipeline(), + }); + + const receipt = await database.import({ + kind: 'text', + uri: 'content/hero-tint.effect.json', + mimeType: 'application/json', + data: JSON.stringify({ + attributes: [{ name: 'a_Position', type: 'vec3', location: 0 }], + properties: [ + { + name: 'u_Tint', + type: 'vec4', + stages: ['fragment'], + scope: 'material', + inspector: { + label: 'Tint', + group: 'Surface', + control: 'color', + }, + }, + ], + vertex: { + main: ['gl_Position = vec4(a_Position, 1.0);'], + }, + fragment: { + precision: 'highp', + outputs: [{ name: 'o_Color', type: 'vec4' }], + main: ['o_Color = u_Tint;'], + }, + }), + }); + + expect(receipt.importerId).toBe('asset-shader.effect.json'); + expect(receipt.primary.kind).toBe('shaderEffect'); + expect(receipt.primary.data.format).toBe('axrone.shader/effect'); + expect(receipt.primary.data.version).toBe(1); + expect(receipt.primary.data.id).toBe('hero-tint'); + expect(receipt.primary.data.properties?.[0]?.inspector?.control).toBe('color'); + }); + + it('imports wrapped effect JSON and preserves inspector select options and array uniforms', async () => { + const database = new AssetDatabase({ + pipeline: createAssetShaderImportPipeline(), + }); + + const receipt = await database.import({ + kind: 'json', + uri: 'content/rig.shader.json', + data: { + effect: { + id: 'shader/rig-preview', + attributes: [{ name: 'a_Position', type: 'vec3', location: 0 }], + properties: [ + { + name: 'u_JointMatrices', + type: 'mat4', + arrayLength: 32, + stages: ['vertex'], + scope: 'object', + }, + { + name: 'u_Mode', + type: 'float', + stages: ['fragment'], + scope: 'material', + inspector: { + label: 'Mode', + group: 'Rendering', + control: 'select', + options: [ + { label: 'Opaque', value: 0 }, + { label: 'Blend', value: 2 }, + ], + }, + }, + ], + vertex: { + main: ['gl_Position = vec4(a_Position, 1.0);'], + }, + fragment: { + precision: 'highp', + outputs: [{ name: 'o_Color', type: 'vec4' }], + main: ['o_Color = vec4(vec3(u_Mode / 2.0), 1.0);'], + }, + }, + }, + }); + + expect(receipt.primary.data.id).toBe('shader/rig-preview'); + expect(receipt.primary.data.properties?.[0]?.arrayLength).toBe(32); + expect(receipt.primary.data.properties?.[1]?.inspector?.options).toEqual([ + { label: 'Opaque', value: 0 }, + { label: 'Blend', value: 2 }, + ]); + }); +}); diff --git a/web/packages/asset-shader/src/index.ts b/web/packages/asset-shader/src/index.ts new file mode 100644 index 00000000..1d18f6bb --- /dev/null +++ b/web/packages/asset-shader/src/index.ts @@ -0,0 +1,48 @@ +export const ASSET_SHADER_CAPABILITY_ID = 'asset/shader'; +export const ASSET_SHADER_CAPABILITY_PACKAGE = '@axrone/asset-shader'; +export const ASSET_SHADER_OWNER_PACKAGE = '@axrone/asset-core'; + +const ASSET_SHADER_CAPABILITY = Object.freeze({ + id: ASSET_SHADER_CAPABILITY_ID, + packageName: ASSET_SHADER_CAPABILITY_PACKAGE, + ownerPackage: ASSET_SHADER_OWNER_PACKAGE, +}); + +export type AssetShaderCapability = typeof ASSET_SHADER_CAPABILITY; + +export const getAssetShaderCapability = (): AssetShaderCapability => ASSET_SHADER_CAPABILITY; + +export type { + AssetShaderImportKind, + AssetShaderImportPipelineOptions, + AssetShaderImportResult, + AssetShaderImportSchema, + ShaderEffectJsonSource, +} from './shader-effect-importer'; +export { + createAssetShaderImportPipeline, + createShaderEffectJsonImporter, + normalizeShaderEffectJsonSource, +} from './shader-effect-importer'; + +export type { + CompiledRenderShaderEffect, + RenderShaderAttributeDefinition, + RenderShaderEffectDefinition, + RenderShaderEffectRenderStateDefinition, + RenderShaderInspectorControlDefinition, + RenderShaderInspectorOptionDefinition, + RenderShaderInterfaceDefinition, + RenderShaderLibraryDefinition, + RenderShaderPropertyDefinition, + RenderShaderSerializableValue, + RenderShaderStageDefinition, + RenderShaderStageName, + RenderShaderValueType, +} from '@axrone/render-core'; +export { + cloneRenderShaderEffectDefinition, + compileRenderShaderEffect, +} from '@axrone/render-core'; + +export * from '@axrone/asset-core'; diff --git a/web/packages/asset-shader/src/shader-effect-importer.ts b/web/packages/asset-shader/src/shader-effect-importer.ts new file mode 100644 index 00000000..ec583643 --- /dev/null +++ b/web/packages/asset-shader/src/shader-effect-importer.ts @@ -0,0 +1,576 @@ +import { + AssetImportPipeline, + type AssetImportPipelineOptions, + type AssetImportResult, + type AssetImportSource, + type AssetImporter, +} from '@axrone/asset-core'; +import { isPlainObject } from '@axrone/utility'; +import { + cloneRenderShaderEffectDefinition, + compileRenderShaderEffect, + type RenderShaderAttributeDefinition, + type RenderShaderEffectDefinition, + type RenderShaderInspectorControlDefinition, + type RenderShaderInspectorOptionDefinition, + type RenderShaderInterfaceDefinition, + type RenderShaderLibraryDefinition, + type RenderShaderPropertyDefinition, + type RenderShaderSerializableValue, + type RenderShaderStageDefinition, + type RenderShaderStageName, + type RenderShaderValueType, +} from '@axrone/render-core'; + +export type AssetShaderImportKind = 'shaderEffect'; + +export type AssetShaderImportSchema = { + readonly [key: string]: unknown; + readonly shaderEffect: RenderShaderEffectDefinition; +}; + +export type AssetShaderImportResult = AssetImportResult< + AssetShaderImportSchema, + AssetShaderImportKind +>; + +export interface AssetShaderImportPipelineOptions + extends Omit, 'importers'> { + readonly importers?: readonly AssetImporter[]; +} + +export interface ShaderEffectJsonSource { + readonly format?: RenderShaderEffectDefinition['format']; + readonly version?: RenderShaderEffectDefinition['version']; + readonly id?: string; + readonly attributes?: readonly unknown[]; + readonly varyings?: readonly unknown[]; + readonly properties?: readonly unknown[]; + readonly libraries?: readonly unknown[]; + readonly vertex?: unknown; + readonly fragment?: unknown; + readonly renderState?: unknown; + readonly effect?: ShaderEffectJsonSource; +} + +const SHADER_VALUE_TYPES = new Set([ + 'float', + 'vec2', + 'vec3', + 'vec4', + 'int', + 'ivec2', + 'ivec3', + 'ivec4', + 'uint', + 'uvec2', + 'uvec3', + 'uvec4', + 'bool', + 'bvec2', + 'bvec3', + 'bvec4', + 'mat3', + 'mat4', + 'sampler2D', + 'samplerCube', +]); +const SHADER_STAGE_NAMES = new Set(['vertex', 'fragment']); +const INSPECTOR_CONTROL_TYPES = new Set< + NonNullable +>(['auto', 'color', 'slider', 'texture', 'toggle', 'select']); +const INTERFACE_INTERPOLATIONS = new Set< + NonNullable +>(['flat', 'smooth']); +const INSPECTOR_GROUP_FALLBACK = 'Properties'; + +const isShaderSerializableValue = (value: unknown): value is RenderShaderSerializableValue => { + if ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return true; + } + + if (Array.isArray(value)) { + return value.every((entry) => isShaderSerializableValue(entry)); + } + + if (isPlainObject(value)) { + return Object.values(value).every((entry) => isShaderSerializableValue(entry)); + } + + return false; +}; + +const readJsonLikeSource = (source: AssetImportSource): unknown => { + if (source.kind === 'json') { + return source.data; + } + + if (source.kind === 'text') { + return JSON.parse(source.data) as unknown; + } + + throw new Error(`Unsupported shader effect import source kind: ${source.kind}`); +}; + +const deriveShaderEffectIdFromSource = ( + source: AssetImportSource, + fallback: string = 'shader-effect' +): string => { + const uri = source.uri?.trim(); + if (!uri) { + return fallback; + } + + const leaf = uri.split(/[\\/]/).pop() ?? fallback; + return ( + leaf + .replace(/\.effect\.json$/i, '') + .replace(/\.shader\.json$/i, '') + .replace(/\.json$/i, '') + .replace(/\.[^.]+$/i, '') || fallback + ); +}; + +const ensurePlainObject = (value: unknown, label: string): Record => { + if (!isPlainObject(value)) { + throw new Error(`${label} must be an object`); + } + + return value; +}; + +const ensureString = (value: unknown, label: string): string => { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${label} must be a non-empty string`); + } + + return value; +}; + +const ensureOptionalString = (value: unknown, label: string): string | undefined => { + if (value === undefined) { + return undefined; + } + + return ensureString(value, label); +}; + +const ensureOptionalBoolean = (value: unknown, label: string): boolean | undefined => { + if (value === undefined) { + return undefined; + } + + if (typeof value !== 'boolean') { + throw new Error(`${label} must be a boolean`); + } + + return value; +}; + +const ensureOptionalFiniteNumber = (value: unknown, label: string): number | undefined => { + if (value === undefined) { + return undefined; + } + + if (typeof value !== 'number' || Number.isFinite(value) === false) { + throw new Error(`${label} must be a finite number`); + } + + return value; +}; + +const ensureOptionalPositiveInteger = (value: unknown, label: string): number | undefined => { + if (value === undefined) { + return undefined; + } + + if (typeof value !== 'number' || Number.isInteger(value) === false || value <= 0) { + throw new Error(`${label} must be a positive integer`); + } + + return value; +}; + +const ensureShaderValueType = (value: unknown, label: string): RenderShaderValueType => { + const normalized = ensureString(value, label) as RenderShaderValueType; + if (!SHADER_VALUE_TYPES.has(normalized)) { + throw new Error(`${label} must be a supported shader value type`); + } + + return normalized; +}; + +const ensureOptionalStringArray = ( + value: unknown, + label: string +): readonly string[] | undefined => { + if (value === undefined) { + return undefined; + } + + if (Array.isArray(value) === false) { + throw new Error(`${label} must be an array of strings`); + } + + return value.map((entry, index) => ensureString(entry, `${label}[${index}]`)); +}; + +const ensureStageNames = ( + value: unknown, + label: string +): readonly RenderShaderStageName[] | undefined => { + const stages = ensureOptionalStringArray(value, label); + if (!stages) { + return undefined; + } + + return stages.map((stage, index) => { + const normalized = stage as RenderShaderStageName; + if (!SHADER_STAGE_NAMES.has(normalized)) { + throw new Error(`${label}[${index}] must be 'vertex' or 'fragment'`); + } + return normalized; + }); +}; + +const normalizeInspectorOptions = ( + value: unknown, + label: string +): readonly RenderShaderInspectorOptionDefinition[] | undefined => { + if (value === undefined) { + return undefined; + } + + if (Array.isArray(value) === false) { + throw new Error(`${label} must be an array`); + } + + return value.map((entry, index) => { + const object = ensurePlainObject(entry, `${label}[${index}]`); + const optionValue = object.value; + if ( + typeof optionValue !== 'string' && + typeof optionValue !== 'number' && + typeof optionValue !== 'boolean' + ) { + throw new Error(`${label}[${index}].value must be a string, number, or boolean`); + } + + return { + label: ensureString(object.label, `${label}[${index}].label`), + value: optionValue, + }; + }); +}; + +const normalizeInspector = ( + value: unknown, + label: string +): RenderShaderInspectorControlDefinition | undefined => { + if (value === undefined) { + return undefined; + } + + const object = ensurePlainObject(value, label); + const control = object.control as RenderShaderInspectorControlDefinition['control'] | undefined; + if (control !== undefined && !INSPECTOR_CONTROL_TYPES.has(control)) { + throw new Error(`${label}.control must be a supported inspector control`); + } + + return { + label: ensureOptionalString(object.label, `${label}.label`), + group: ensureOptionalString(object.group, `${label}.group`), + control, + min: ensureOptionalFiniteNumber(object.min, `${label}.min`), + max: ensureOptionalFiniteNumber(object.max, `${label}.max`), + step: ensureOptionalFiniteNumber(object.step, `${label}.step`), + options: normalizeInspectorOptions(object.options, `${label}.options`), + hidden: ensureOptionalBoolean(object.hidden, `${label}.hidden`), + }; +}; + +const normalizeInterfaces = ( + value: unknown, + label: string +): readonly RenderShaderInterfaceDefinition[] | undefined => { + if (value === undefined) { + return undefined; + } + + if (Array.isArray(value) === false) { + throw new Error(`${label} must be an array`); + } + + return value.map((entry, index) => { + const object = ensurePlainObject(entry, `${label}[${index}]`); + const interpolation = object.interpolation as + | RenderShaderInterfaceDefinition['interpolation'] + | undefined; + if (interpolation !== undefined && !INTERFACE_INTERPOLATIONS.has(interpolation)) { + throw new Error(`${label}[${index}].interpolation must be 'flat' or 'smooth'`); + } + + return { + name: ensureString(object.name, `${label}[${index}].name`), + type: ensureShaderValueType(object.type, `${label}[${index}].type`), + interpolation, + }; + }); +}; + +const normalizeAttributes = ( + value: unknown, + label: string +): readonly RenderShaderAttributeDefinition[] | undefined => { + if (value === undefined) { + return undefined; + } + + if (Array.isArray(value) === false) { + throw new Error(`${label} must be an array`); + } + + return value.map((entry, index) => { + const object = ensurePlainObject(entry, `${label}[${index}]`); + const location = object.location; + if ( + location !== undefined && + (typeof location !== 'number' || Number.isInteger(location) === false || location < 0) + ) { + throw new Error(`${label}[${index}].location must be a non-negative integer`); + } + + return { + name: ensureString(object.name, `${label}[${index}].name`), + type: ensureShaderValueType(object.type, `${label}[${index}].type`), + location, + }; + }); +}; + +const normalizeLibraries = ( + value: unknown, + label: string +): readonly RenderShaderLibraryDefinition[] | undefined => { + if (value === undefined) { + return undefined; + } + + if (Array.isArray(value) === false) { + throw new Error(`${label} must be an array`); + } + + return value.map((entry, index) => { + const object = ensurePlainObject(entry, `${label}[${index}]`); + const codeValue = object.code; + let code: string | readonly string[]; + if (typeof codeValue === 'string') { + code = codeValue; + } else if (Array.isArray(codeValue)) { + code = codeValue.map((line, lineIndex) => + ensureString(line, `${label}[${index}].code[${lineIndex}]`) + ); + } else { + throw new Error(`${label}[${index}].code must be a string or string array`); + } + + return { + id: ensureString(object.id, `${label}[${index}].id`), + code, + }; + }); +}; + +const normalizeProperties = ( + value: unknown, + label: string +): readonly RenderShaderPropertyDefinition[] | undefined => { + if (value === undefined) { + return undefined; + } + + if (Array.isArray(value) === false) { + throw new Error(`${label} must be an array`); + } + + return value.map((entry, index) => { + const object = ensurePlainObject(entry, `${label}[${index}]`); + const scope = object.scope as RenderShaderPropertyDefinition['scope'] | undefined; + if ( + scope !== undefined && + !['material', 'object', 'camera', 'frame', 'system', 'internal'].includes(scope) + ) { + throw new Error(`${label}[${index}].scope must be a supported property scope`); + } + + const defaultValue = object.defaultValue; + if (defaultValue !== undefined && !isShaderSerializableValue(defaultValue)) { + throw new Error(`${label}[${index}].defaultValue must be JSON serializable`); + } + + return { + name: ensureString(object.name, `${label}[${index}].name`), + type: ensureShaderValueType(object.type, `${label}[${index}].type`), + arrayLength: ensureOptionalPositiveInteger( + object.arrayLength, + `${label}[${index}].arrayLength` + ), + stages: ensureStageNames(object.stages, `${label}[${index}].stages`), + scope, + defaultValue: defaultValue as RenderShaderSerializableValue | undefined, + inspector: normalizeInspector(object.inspector, `${label}[${index}].inspector`), + }; + }); +}; + +const normalizeDeclarations = ( + value: unknown, + label: string +): readonly (string | readonly string[])[] | undefined => { + if (value === undefined) { + return undefined; + } + + if (Array.isArray(value) === false) { + throw new Error(`${label} must be an array`); + } + + return value.map((entry, index) => { + if (typeof entry === 'string') { + return entry; + } + if (Array.isArray(entry)) { + return entry.map((line, lineIndex) => + ensureString(line, `${label}[${index}][${lineIndex}]`) + ); + } + + throw new Error(`${label}[${index}] must be a string or string array`); + }); +}; + +const normalizeStage = ( + value: unknown, + label: string +): RenderShaderStageDefinition => { + const object = ensurePlainObject(value, label); + const precision = object.precision as RenderShaderStageDefinition['precision'] | undefined; + if (precision !== undefined && !['lowp', 'mediump', 'highp'].includes(precision)) { + throw new Error(`${label}.precision must be 'lowp', 'mediump', or 'highp'`); + } + + return { + version: ensureOptionalString(object.version, `${label}.version`), + precision, + directives: ensureOptionalStringArray(object.directives, `${label}.directives`), + inputs: normalizeInterfaces(object.inputs, `${label}.inputs`), + outputs: normalizeInterfaces(object.outputs, `${label}.outputs`), + declarations: normalizeDeclarations(object.declarations, `${label}.declarations`), + includes: ensureOptionalStringArray(object.includes, `${label}.includes`), + main: ensureOptionalStringArray(object.main, `${label}.main`) ?? [], + }; +}; + +const normalizeRenderState = ( + value: unknown, + label: string +): RenderShaderEffectDefinition['renderState'] => { + if (value === undefined) { + return undefined; + } + + const object = ensurePlainObject(value, label); + return { + depthTest: ensureOptionalBoolean(object.depthTest, `${label}.depthTest`), + cull: ensureOptionalBoolean(object.cull, `${label}.cull`), + blend: ensureOptionalBoolean(object.blend, `${label}.blend`), + }; +}; + +export const normalizeShaderEffectJsonSource = ( + source: AssetImportSource, + payload: unknown +): RenderShaderEffectDefinition => { + const root = ensurePlainObject(payload, 'Shader effect payload'); + const candidate = root.effect + ? ensurePlainObject(root.effect, 'Shader effect payload.effect') + : root; + const format = candidate.format; + if (format !== undefined && format !== 'axrone.shader/effect') { + throw new Error('Shader effect payload.format must be axrone.shader/effect'); + } + + const version = candidate.version; + if (version !== undefined && version !== 1) { + throw new Error('Shader effect payload.version must be 1'); + } + + const definition: RenderShaderEffectDefinition = { + format: 'axrone.shader/effect', + version: 1, + id: + typeof candidate.id === 'string' && candidate.id.trim() !== '' + ? candidate.id + : deriveShaderEffectIdFromSource(source), + attributes: normalizeAttributes(candidate.attributes, 'Shader effect payload.attributes'), + varyings: normalizeInterfaces(candidate.varyings, 'Shader effect payload.varyings'), + properties: normalizeProperties(candidate.properties, 'Shader effect payload.properties'), + libraries: normalizeLibraries(candidate.libraries, 'Shader effect payload.libraries'), + vertex: normalizeStage(candidate.vertex, 'Shader effect payload.vertex'), + fragment: normalizeStage(candidate.fragment, 'Shader effect payload.fragment'), + renderState: normalizeRenderState( + candidate.renderState, + 'Shader effect payload.renderState' + ), + }; + + compileRenderShaderEffect(definition); + return cloneRenderShaderEffectDefinition(definition); +}; + +export const createShaderEffectJsonImporter = (): AssetImporter => ({ + id: 'asset-shader.effect.json', + priority: 20, + sourceKinds: ['json', 'text'], + extensions: ['effect.json', 'shader.json', 'json'], + canImport: ({ source }) => { + try { + normalizeShaderEffectJsonSource(source, readJsonLikeSource(source)); + return true; + } catch { + return false; + } + }, + import: ({ source }) => { + const definition = normalizeShaderEffectJsonSource(source, readJsonLikeSource(source)); + return { + primary: { + kind: 'shaderEffect', + data: definition, + name: definition.id, + metadata: source.uri + ? { + uri: source.uri, + mimeType: source.mimeType, + properties: { + inspectorGroup: INSPECTOR_GROUP_FALLBACK, + }, + } + : undefined, + }, + }; + }, +}); + +export const createAssetShaderImportPipeline = ( + options: AssetShaderImportPipelineOptions = {} +): AssetImportPipeline => + new AssetImportPipeline({ + ...options, + importers: [createShaderEffectJsonImporter(), ...(options.importers ?? [])], + }); diff --git a/web/packages/asset-shader/tsconfig.build.json b/web/packages/asset-shader/tsconfig.build.json new file mode 100644 index 00000000..5dc55682 --- /dev/null +++ b/web/packages/asset-shader/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": false, + "types": ["node"], + "declaration": true, + "declarationMap": false, + "sourceMap": true + }, + "include": ["src/**/*.ts", "../../types/**/*.d.ts"], + "exclude": [ + "**/__tests__/**", + "**/*.test.ts", + "**/*.spec.ts", + "**/*.browser.test.ts", + "**/*.browser.spec.ts", + "dist" + ] +} \ No newline at end of file diff --git a/web/packages/audio/package.json b/web/packages/audio/package.json new file mode 100644 index 00000000..f6cc70d4 --- /dev/null +++ b/web/packages/audio/package.json @@ -0,0 +1,30 @@ +{ + "name": "@axrone/audio", + "version": "0.1.0", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "rollup -c rollup.config.mjs", + "clean": "rimraf dist", + "test": "vitest run" + }, + "dependencies": { + "@axrone/asset-core": "^0.1.0", + "@axrone/ecs-runtime": "^0.1.0", + "@axrone/event": "^0.1.0", + "@axrone/numeric": "^0.0.1", + "@axrone/utility": "^0.0.1" + } +} diff --git a/web/packages/audio/rollup.config.mjs b/web/packages/audio/rollup.config.mjs new file mode 100644 index 00000000..1a663619 --- /dev/null +++ b/web/packages/audio/rollup.config.mjs @@ -0,0 +1,9 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createPackageConfig } from '../../build/create-package-config.mjs'; + +const packageDir = path.dirname(fileURLToPath(import.meta.url)); + +export default createPackageConfig({ + packageDir, +}); \ No newline at end of file diff --git a/web/packages/audio/src/__tests__/audio-internals.test.ts b/web/packages/audio/src/__tests__/audio-internals.test.ts new file mode 100644 index 00000000..38d2e593 --- /dev/null +++ b/web/packages/audio/src/__tests__/audio-internals.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { + attenuationGainForDistance, + cloneSpatialization, + normalizeAttenuation, +} from '../internal/spatial'; +import { hasOwnKeys, withRetry } from '../internal/shared'; + +describe('audio internal helpers', () => { + it('clones spatialization payloads without retaining nested references', () => { + const source = { + mode: '3d' as const, + position: { x: 4, y: 2, z: -1 }, + orientation: { x: 0, y: 0, z: -1 }, + attenuation: { + model: 'linear' as const, + refDistance: 2, + maxDistance: 8, + rolloffFactor: 0.5, + minGain: 0.25, + }, + }; + + const cloned = cloneSpatialization(source); + + expect(cloned).toEqual(source); + expect(cloned).not.toBe(source); + expect(cloned?.position).not.toBe(source.position); + expect(cloned?.orientation).not.toBe(source.orientation); + expect(cloned?.attenuation).not.toBe(source.attenuation); + }); + + it('normalizes attenuation ranges and clamps gain to the configured floor', () => { + const normalized = normalizeAttenuation({ + model: 'linear', + refDistance: -2, + maxDistance: 0, + rolloffFactor: -3, + minGain: 4, + }); + + expect(normalized).toEqual({ + model: 'linear', + refDistance: 0.0001, + maxDistance: 0.0001, + rolloffFactor: 0, + minGain: 1, + }); + expect( + attenuationGainForDistance(128, { + model: 'linear', + refDistance: 1, + maxDistance: 4, + rolloffFactor: 1, + minGain: 0.2, + }) + ).toBe(0.2); + }); + + it('retries operations with zero-allocation guard helpers around partial patches', async () => { + let attempts = 0; + + const result = await withRetry( + { + attempts: 3, + backoffMs: 0, + }, + (attempt) => ({ + operation: 'context.resume' as const, + attempt, + }), + async () => { + attempts += 1; + if (attempts < 3) { + throw new Error(`attempt:${attempts}`); + } + + return 'ready'; + } + ); + + expect(result).toBe('ready'); + expect(attempts).toBe(3); + expect(hasOwnKeys({ volume: 1 })).toBe(true); + expect(hasOwnKeys({})).toBe(false); + }); +}); diff --git a/web/packages/audio/src/__tests__/audio-system.integration.test.ts b/web/packages/audio/src/__tests__/audio-system.integration.test.ts new file mode 100644 index 00000000..721a55d2 --- /dev/null +++ b/web/packages/audio/src/__tests__/audio-system.integration.test.ts @@ -0,0 +1,307 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { createAudioSystem } from '../system'; +import { + FakeAudioBuffer, + FakeAudioContext, + installFakeAudioGlobals, +} from './helpers/fake-audio-context'; + +describe('AudioSystem integration', () => { + beforeAll(() => { + installFakeAudioGlobals(); + }); + + it('syncs listener activation and fallback through the registry layer', () => { + const context = new FakeAudioContext(); + const system = createAudioSystem({ + context: context as unknown as AudioContext, + listeners: [ + { + id: 'main', + active: true, + position: { x: 1, y: 2, z: 3 }, + }, + { + id: 'backup', + position: { x: 9, y: 8, z: 7 }, + }, + ], + }); + + expect(system.activeListener?.id).toBe('main'); + expect(context.listener.positionX.value).toBe(1); + expect(context.listener.positionY.value).toBe(2); + expect(context.listener.positionZ.value).toBe(3); + + system.setActiveListener('backup'); + + expect(system.activeListener?.id).toBe('backup'); + expect(context.listener.positionX.value).toBe(9); + expect(context.listener.positionY.value).toBe(8); + expect(context.listener.positionZ.value).toBe(7); + + expect(system.removeListener('backup')).toBe(true); + expect(system.activeListener?.id).toBe('main'); + expect(context.listener.positionX.value).toBe(1); + expect(context.listener.positionY.value).toBe(2); + expect(context.listener.positionZ.value).toBe(3); + }); + + it('re-routes active playback when a source bus changes', async () => { + const context = new FakeAudioContext(); + const buffer = new FakeAudioBuffer(2, 96000, 48000) as unknown as AudioBuffer; + const system = createAudioSystem({ + context: context as unknown as AudioContext, + }); + + system.upsertBus({ id: 'music' }); + system.upsertBus({ id: 'sfx' }); + system.upsertListener({ id: 'listener', active: true }); + system.upsertSource({ + id: 'laser', + busId: 'music', + clip: { + kind: 'buffer', + buffer, + }, + spatial: { + mode: '2d', + position: { x: 2, y: 0, z: 0 }, + }, + }); + + await system.playSource('laser'); + + const musicBusGain = context.gainNodes[1]; + const sfxBusGain = context.gainNodes[2]; + const playbackPanner = context.stereoPannerNodes.at(-1); + + expect(playbackPanner?.connections[0]).toBe(musicBusGain); + expect(system.getSource('laser')?.busId).toBe('music'); + + system.updateSource('laser', { busId: 'sfx' }); + + expect(playbackPanner?.connections[0]).toBe(sfxBusGain); + expect(system.getSource('laser')?.busId).toBe('sfx'); + }); + + it('restores snapshot playback into a fresh audio context with preserved offsets', async () => { + const sourceContext = new FakeAudioContext(); + const buffer = new FakeAudioBuffer(2, 192000, 48000) as unknown as AudioBuffer; + const sourceSystem = createAudioSystem({ + context: sourceContext as unknown as AudioContext, + listeners: [ + { + id: 'main', + active: true, + position: { x: 4, y: 0, z: 0 }, + }, + ], + buses: [{ id: 'music' }], + sources: [ + { + id: 'theme', + busId: 'music', + clip: { + kind: 'buffer', + buffer, + }, + loop: true, + }, + ], + }); + + await sourceSystem.playSource('theme'); + sourceContext.advance(1.5); + const snapshot = sourceSystem.snapshot(); + + expect(snapshot.sources[0]?.currentOffsetSeconds).toBeCloseTo(1.5, 5); + + const restoredContext = new FakeAudioContext(); + const restoredSystem = createAudioSystem({ + context: restoredContext as unknown as AudioContext, + }); + + await restoredSystem.restore(snapshot, { restorePlayback: true }); + + expect(restoredSystem.activeListener?.id).toBe('main'); + expect(restoredSystem.getBus('music')?.id).toBe('music'); + expect(restoredSystem.getSource('theme')?.playbackState).toBe('playing'); + expect(restoredContext.listener.positionX.value).toBe(4); + + const restoredPlayback = restoredContext.bufferSourceNodes.at(-1); + expect(restoredPlayback?.startCalls[0]?.offset).toBeCloseTo( + snapshot.sources[0]?.currentOffsetSeconds ?? 0, + 5 + ); + }); + + it('captures pause offsets and resumes from the paused position', async () => { + const context = new FakeAudioContext(); + const buffer = new FakeAudioBuffer(2, 192000, 48000) as unknown as AudioBuffer; + const system = createAudioSystem({ + context: context as unknown as AudioContext, + listeners: [{ id: 'main', active: true }], + sources: [ + { + id: 'ambience', + clip: { + kind: 'buffer', + buffer, + }, + }, + ], + }); + + await system.playSource('ambience'); + context.advance(0.75); + + system.pauseSource('ambience'); + context.flush(); + + expect(system.getSource('ambience')?.playbackState).toBe('paused'); + expect(system.getSource('ambience')?.currentOffsetSeconds).toBeCloseTo(0.75, 5); + + await system.resumeSource('ambience'); + + const resumedNode = context.bufferSourceNodes.at(-1); + expect(resumedNode?.startCalls[0]?.offset).toBeCloseTo(0.75, 5); + expect(system.getSource('ambience')?.playbackState).toBe('playing'); + }); + + it('cleans up transient sources after scheduled stop reaches ended state', async () => { + const context = new FakeAudioContext(); + const buffer = new FakeAudioBuffer(2, 96000, 48000) as unknown as AudioBuffer; + const system = createAudioSystem({ + context: context as unknown as AudioContext, + listeners: [{ id: 'main', active: true }], + }); + + const handle = await system.play({ + clip: { + kind: 'buffer', + buffer, + }, + }); + + expect(system.getSource(handle.sourceId)).toBeDefined(); + + handle.stop({ when: context.currentTime + 0.5 }); + context.advance(0.25); + + expect(system.getSource(handle.sourceId)).toBeDefined(); + + context.advance(0.25); + + expect(system.getSource(handle.sourceId)).toBeUndefined(); + }); + + it('transitions non-looping playback to stopped when natural duration elapses', async () => { + const context = new FakeAudioContext(); + const buffer = new FakeAudioBuffer(2, 48000, 48000) as unknown as AudioBuffer; + const system = createAudioSystem({ + context: context as unknown as AudioContext, + listeners: [{ id: 'main', active: true }], + sources: [ + { + id: 'voice', + clip: { + kind: 'buffer', + buffer, + }, + }, + ], + }); + + await system.playSource('voice'); + context.advance(1); + + expect(system.getSource('voice')?.playbackState).toBe('stopped'); + expect(system.getSource('voice')?.currentOffsetSeconds).toBe(0); + }); + + it('emits runtime events for playback commands and lifecycle transitions', async () => { + const context = new FakeAudioContext(); + const buffer = new FakeAudioBuffer(2, 96000, 48000) as unknown as AudioBuffer; + const system = createAudioSystem({ + context: context as unknown as AudioContext, + listeners: [{ id: 'main', active: true }], + }); + const observed: string[] = []; + const playedSourceIds: string[] = []; + const unsubscribeAll = system.events.on('audio:*', (event) => { + observed.push(event.type); + }); + const unsubscribePlayed = system.events.on('source:played', (event) => { + playedSourceIds.push(event.source.id); + }); + + system.resetDiagnostics(); + system.upsertBus({ id: 'music' }); + system.upsertSource({ + id: 'theme', + busId: 'music', + clip: { + kind: 'buffer', + buffer, + }, + }); + await system.playSource('theme'); + context.advance(0.25); + system.pauseSource('theme'); + context.flush(); + await system.resumeSource('theme'); + system.stopSource('theme'); + context.flush(); + await system.suspend(); + await system.resume(); + + unsubscribeAll(); + unsubscribePlayed(); + + expect(playedSourceIds).toEqual(['theme']); + expect(observed).toContain('bus:upserted'); + expect(observed).toContain('source:upserted'); + expect(observed).toContain('source:played'); + expect(observed).toContain('source:paused'); + expect(observed).toContain('source:resumed'); + expect(observed).toContain('source:stopped'); + expect(observed).toContain('source:ended'); + expect(observed).toContain('system:suspended'); + expect(observed).toContain('system:resumed'); + }); + + it('captures diagnostics counters and the last emitted event snapshot', async () => { + const context = new FakeAudioContext(); + const buffer = new FakeAudioBuffer(2, 48000, 48000) as unknown as AudioBuffer; + const system = createAudioSystem({ + context: context as unknown as AudioContext, + listeners: [{ id: 'main', active: true }], + }); + + system.resetDiagnostics(); + system.upsertBus({ id: 'sfx' }); + system.upsertSource({ + id: 'click', + busId: 'sfx', + clip: { + kind: 'buffer', + buffer, + }, + }); + await system.playSource('click'); + context.advance(1); + + const diagnostics = system.getDiagnostics(); + + expect(diagnostics.busCount).toBe(2); + expect(diagnostics.listenerCount).toBe(1); + expect(diagnostics.sourceCount).toBe(1); + expect(diagnostics.activePlaybackCount).toBe(0); + expect(diagnostics.counters.busMutationCount).toBe(1); + expect(diagnostics.counters.sourceMutationCount).toBe(1); + expect(diagnostics.counters.playbackCommandCount).toBe(1); + expect(diagnostics.counters.playbackCompletionCount).toBe(1); + expect(diagnostics.lastEvent?.type).toBe('source:ended'); + }); +}); diff --git a/web/packages/audio/src/__tests__/helpers/fake-audio-context.ts b/web/packages/audio/src/__tests__/helpers/fake-audio-context.ts new file mode 100644 index 00000000..62a21bbd --- /dev/null +++ b/web/packages/audio/src/__tests__/helpers/fake-audio-context.ts @@ -0,0 +1,278 @@ +export class FakeAudioParam { + value: number; + readonly events: Array< + | { readonly type: 'cancel'; readonly atTime: number } + | { readonly type: 'set'; readonly value: number; readonly atTime: number } + | { readonly type: 'ramp'; readonly value: number; readonly atTime: number } + > = []; + + constructor(initialValue = 0) { + this.value = initialValue; + } + + cancelScheduledValues(atTime: number): void { + this.events.push({ type: 'cancel', atTime }); + } + + setValueAtTime(value: number, atTime: number): void { + this.value = value; + this.events.push({ type: 'set', value, atTime }); + } + + linearRampToValueAtTime(value: number, atTime: number): void { + this.value = value; + this.events.push({ type: 'ramp', value, atTime }); + } +} + +class FakeAudioNode { + readonly connections: unknown[] = []; + + constructor(readonly kind: string) {} + + connect(target: unknown): unknown { + this.connections.push(target); + return target; + } + + disconnect(): void { + this.connections.splice(0); + } +} + +export class FakeGainNode extends FakeAudioNode { + readonly gain = new FakeAudioParam(1); + + constructor() { + super('gain'); + } +} + +export class FakeStereoPannerNode extends FakeAudioNode { + readonly pan = new FakeAudioParam(0); + + constructor() { + super('stereo-panner'); + } +} + +export class FakePannerNode extends FakeAudioNode { + readonly positionX = new FakeAudioParam(0); + readonly positionY = new FakeAudioParam(0); + readonly positionZ = new FakeAudioParam(0); + readonly orientationX = new FakeAudioParam(0); + readonly orientationY = new FakeAudioParam(0); + readonly orientationZ = new FakeAudioParam(-1); + + distanceModel: DistanceModelType = 'inverse'; + panningModel: PanningModelType = 'HRTF'; + refDistance = 1; + maxDistance = 1000000; + rolloffFactor = 0; + coneInnerAngle = 360; + coneOuterAngle = 360; + coneOuterGain = 0; + + constructor() { + super('panner'); + } +} + +export class FakeAudioBuffer { + readonly channelData: Float32Array[]; + readonly duration: number; + readonly numberOfChannels: number; + + constructor( + channelCount = 2, + readonly length = 48000, + readonly sampleRate = 48000 + ) { + this.numberOfChannels = channelCount; + this.duration = sampleRate > 0 ? length / sampleRate : 0; + this.channelData = Array.from({ length: channelCount }, () => new Float32Array(length)); + } + + copyToChannel(source: Float32Array, channelNumber: number): void { + this.channelData[channelNumber]?.set(source); + } +} + +type ScheduledTime = number | undefined; + +const minScheduledTime = (left: ScheduledTime, right: ScheduledTime): ScheduledTime => { + if (left === undefined) { + return right; + } + if (right === undefined) { + return left; + } + return Math.min(left, right); +}; + +export class FakeBufferSourceNode extends FakeAudioNode { + buffer: AudioBuffer | null = null; + loop = false; + loopStart = 0; + loopEnd = 0; + readonly playbackRate = new FakeAudioParam(1); + readonly detune = new FakeAudioParam(0); + onended: (() => void) | null = null; + readonly startCalls: Array<{ + readonly when: number; + readonly offset: number; + readonly duration?: number; + }> = []; + readonly stopCalls: number[] = []; + + #scheduledStart?: number; + #scheduledNaturalEnd?: number; + #scheduledStop?: number; + #ended = false; + + constructor(readonly context: FakeAudioContext) { + super('buffer-source'); + } + + start(when = 0, offset = 0, duration?: number): void { + this.startCalls.push({ when, offset, duration }); + this.#scheduledStart = when; + if (this.loop && duration === undefined) { + this.#scheduledNaturalEnd = undefined; + return; + } + + const resolvedDuration = + duration ?? + Math.max(0, (this.buffer?.duration ?? 0) - offset); + this.#scheduledNaturalEnd = when + resolvedDuration; + } + + stop(when = this.context.currentTime): void { + this.stopCalls.push(when); + this.#scheduledStop = when; + } + + emitEnded(): void { + if (this.#ended) { + return; + } + this.#ended = true; + this.onended?.(); + } + + flush(currentTime: number): void { + if (this.#ended) { + return; + } + + const effectiveEnd = minScheduledTime(this.#scheduledNaturalEnd, this.#scheduledStop); + if (effectiveEnd === undefined) { + return; + } + + if (currentTime >= effectiveEnd) { + this.emitEnded(); + } + } +} + +export class FakeAudioListener { + readonly positionX = new FakeAudioParam(0); + readonly positionY = new FakeAudioParam(0); + readonly positionZ = new FakeAudioParam(0); + readonly forwardX = new FakeAudioParam(0); + readonly forwardY = new FakeAudioParam(0); + readonly forwardZ = new FakeAudioParam(-1); + readonly upX = new FakeAudioParam(0); + readonly upY = new FakeAudioParam(1); + readonly upZ = new FakeAudioParam(0); +} + +export class FakeAudioContext { + currentTime = 0; + state: AudioContextState = 'running'; + readonly destination = new FakeAudioNode('destination'); + readonly listener = new FakeAudioListener(); + readonly gainNodes: FakeGainNode[] = []; + readonly stereoPannerNodes: FakeStereoPannerNode[] = []; + readonly pannerNodes: FakePannerNode[] = []; + readonly bufferSourceNodes: FakeBufferSourceNode[] = []; + readonly buffers: FakeAudioBuffer[] = []; + + createGain(): GainNode { + const node = new FakeGainNode(); + this.gainNodes.push(node); + return node as unknown as GainNode; + } + + createStereoPanner(): StereoPannerNode { + const node = new FakeStereoPannerNode(); + this.stereoPannerNodes.push(node); + return node as unknown as StereoPannerNode; + } + + createPanner(): PannerNode { + const node = new FakePannerNode(); + this.pannerNodes.push(node); + return node as unknown as PannerNode; + } + + createBufferSource(): AudioBufferSourceNode { + const node = new FakeBufferSourceNode(this); + this.bufferSourceNodes.push(node); + return node as unknown as AudioBufferSourceNode; + } + + createBuffer(numberOfChannels: number, length: number, sampleRate: number): AudioBuffer { + const buffer = new FakeAudioBuffer(numberOfChannels, length, sampleRate); + this.buffers.push(buffer); + return buffer as unknown as AudioBuffer; + } + + async decodeAudioData(_audioData: ArrayBuffer): Promise { + return this.createBuffer(2, 48000, 48000); + } + + async resume(): Promise { + this.state = 'running'; + } + + async suspend(): Promise { + this.state = 'suspended'; + } + + async close(): Promise { + this.state = 'closed'; + } + + advance(seconds: number): void { + this.currentTime += seconds; + this.flush(); + } + + flush(): void { + for (const node of this.bufferSourceNodes) { + node.flush(this.currentTime); + } + } +} + +let globalsInstalled = false; + +export const installFakeAudioGlobals = (): void => { + if (globalsInstalled) { + return; + } + + const target = globalThis as typeof globalThis & { + AudioBuffer: typeof AudioBuffer; + StereoPannerNode: typeof StereoPannerNode; + PannerNode: typeof PannerNode; + }; + + target.AudioBuffer = FakeAudioBuffer as unknown as typeof AudioBuffer; + target.StereoPannerNode = FakeStereoPannerNode as unknown as typeof StereoPannerNode; + target.PannerNode = FakePannerNode as unknown as typeof PannerNode; + globalsInstalled = true; +}; diff --git a/web/packages/audio/src/asset.ts b/web/packages/audio/src/asset.ts new file mode 100644 index 00000000..10a586a3 --- /dev/null +++ b/web/packages/audio/src/asset.ts @@ -0,0 +1,118 @@ +import type { AssetRecord } from '@axrone/asset-core'; +import { + isAudioClipAssetRecord, + normalizeAudioClipId, +} from './reference'; +import type { + AudioAssetSchema, + AudioClipAssetData, + AudioClipAssetSelector, + AudioClipInput, + AudioClipSelector, + AudioInlineClipSelector, + AudioRegisteredClipSelector, +} from './types'; + +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const hasFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const isFloat32ArrayList = (value: unknown): value is readonly Float32Array[] => + Array.isArray(value) && value.every((entry) => entry instanceof Float32Array); + +export const AUDIO_CLIP_ASSET_KIND = 'audioClip' as const; + +export const isAudioClipAssetData = (value: unknown): value is AudioClipAssetData => { + if (!isObject(value) || typeof value.kind !== 'string') { + return false; + } + + switch (value.kind) { + case 'buffer': + return typeof AudioBuffer !== 'undefined' ? value.buffer instanceof AudioBuffer : 'buffer' in value; + case 'pcm': + return hasFiniteNumber(value.sampleRate) && isFloat32ArrayList(value.channelData); + case 'encoded': + return value.data instanceof ArrayBuffer || ArrayBuffer.isView(value.data); + case 'url': + return typeof value.url === 'string' && value.url.length > 0; + default: + return false; + } +}; + +export const isAudioClipSelector = ( + value: unknown +): value is AudioClipSelector => { + if (!isObject(value) || typeof value.kind !== 'string') { + return false; + } + + switch (value.kind) { + case 'registered': + return typeof value.clipId === 'string'; + case 'asset': + return 'selector' in value; + case 'inline': + return isAudioClipAssetData(value.clip); + default: + return false; + } +}; + +export const createRegisteredAudioClipSelector = ( + clipId: string +): AudioRegisteredClipSelector => ({ + kind: 'registered', + clipId: normalizeAudioClipId(clipId), +}); + +export const createInlineAudioClipSelector = ( + clip: AudioClipAssetData +): AudioInlineClipSelector => ({ + kind: 'inline', + clip, +}); + +export const toAudioClipSelector = ( + value: AudioClipInput | undefined +): AudioClipSelector | undefined => { + if (value === undefined) { + return undefined; + } + + if (typeof AudioBuffer !== 'undefined' && value instanceof AudioBuffer) { + return createInlineAudioClipSelector({ kind: 'buffer', buffer: value }) as AudioClipSelector; + } + + if (isAudioClipSelector(value)) { + return value; + } + + if (isAudioClipAssetData(value)) { + return createInlineAudioClipSelector(value) as AudioClipSelector; + } + + return { + kind: 'asset', + selector: value as AudioClipAssetSelector, + }; +}; + +export const toAudioClipSelectorFromRecord = ( + value: AudioClipAssetSelector | AssetRecord +): AudioClipSelector => { + if (isAudioClipAssetRecord(value)) { + return { + kind: 'asset', + selector: value as AudioClipAssetSelector, + }; + } + + return { + kind: 'asset', + selector: value, + }; +}; \ No newline at end of file diff --git a/web/packages/audio/src/component-binder.ts b/web/packages/audio/src/component-binder.ts new file mode 100644 index 00000000..e57fdd09 --- /dev/null +++ b/web/packages/audio/src/component-binder.ts @@ -0,0 +1,84 @@ +import { AudioListenerComponent, AudioSourceComponent } from './components'; +import { AudioSystem } from './system'; +import type { + AudioAssetSchema, + AudioSourceComponentCommand, +} from './types'; + +export class AudioComponentBinder { + readonly #listeners = new Set(); + readonly #sources = new Set>(); + + constructor(readonly system: AudioSystem) {} + + attachListener(component: AudioListenerComponent): this { + this.#listeners.add(component); + return this; + } + + detachListener(component: AudioListenerComponent): boolean { + return this.#listeners.delete(component); + } + + attachSource(component: AudioSourceComponent): this { + this.#sources.add(component); + return this; + } + + detachSource(component: AudioSourceComponent): boolean { + return this.#sources.delete(component); + } + + clear(): void { + this.#listeners.clear(); + this.#sources.clear(); + } + + async update(): Promise { + for (const listener of this.#listeners) { + this.system.upsertListener(listener.toDescriptor()); + if (listener.active) { + this.system.setActiveListener(listener.listenerId); + } + } + + for (const source of this.#sources) { + const state = this.system.upsertSource(source.toDescriptor()); + source.syncState(state); + const commands = source.consumeCommands(); + for (const command of commands) { + await this.#dispatchSourceCommand(source, command); + } + } + + this.system.refreshSpatialAudio(); + } + + async #dispatchSourceCommand( + component: AudioSourceComponent, + command: AudioSourceComponentCommand + ): Promise { + switch (command.kind) { + case 'play': + component.syncState( + await this.system + .playSource(component.sourceId, command.request) + .then(() => this.system.getSource(component.sourceId)!) + ); + break; + case 'pause': + this.system.pauseSource(component.sourceId); + break; + case 'resume': + component.syncState( + await this.system + .resumeSource(component.sourceId) + .then(() => this.system.getSource(component.sourceId)!) + ); + break; + case 'stop': + this.system.stopSource(component.sourceId, command.options); + break; + } + } +} diff --git a/web/packages/audio/src/components.ts b/web/packages/audio/src/components.ts new file mode 100644 index 00000000..750a4316 --- /dev/null +++ b/web/packages/audio/src/components.ts @@ -0,0 +1,559 @@ +import { Component } from '@axrone/ecs-runtime'; +import type { ComponentConfig } from '@axrone/ecs-runtime'; +import type { Transform } from '@axrone/ecs-runtime'; +import { Vec3 } from '@axrone/numeric'; +import { toAudioClipSelector } from './asset'; +import { + DEFAULT_LISTENER_FORWARD, + DEFAULT_LISTENER_POSITION, + DEFAULT_LISTENER_UP, + DEFAULT_SOURCE_ORIENTATION, + DEFAULT_SOURCE_POSITION, + cloneMetadata, + isFiniteNumber, + normalizeVector3, +} from './internal/shared'; +import { cloneSpatialization } from './internal/spatial'; +import { + MASTER_AUDIO_BUS_ID, + cloneAudioVector3, + normalizeAudioBusId, + normalizeAudioListenerId, + normalizeAudioSourceId, +} from './reference'; +import type { + AudioAssetSchema, + AudioJsonValue, + AudioListenerDescriptor, + AudioListenerId, + AudioSourceComponentCommand, + AudioSourceDefinition, + AudioSourceId, + AudioSourcePlayRequest, + AudioSourceState, + AudioSpatialization, + AudioStopOptions, + AudioVector3, +} from './types'; + +export interface AudioListenerComponentConfig extends ComponentConfig { + readonly listenerId?: AudioListenerId | string; + readonly active?: boolean; + readonly position?: AudioVector3; + readonly forward?: AudioVector3; + readonly up?: AudioVector3; + readonly useTransform?: boolean; + readonly metadata?: Readonly>; +} + +export class AudioListenerComponent extends Component { + private _listenerId: AudioListenerId; + private _active: boolean; + private _position: AudioVector3; + private _forward: AudioVector3; + private _up: AudioVector3; + private _useTransform: boolean; + private _metadata: Readonly>; + + constructor(config: AudioListenerComponentConfig = {}) { + super(config); + this._listenerId = normalizeAudioListenerId(config.listenerId ?? 'default'); + this._active = config.active ?? true; + this._position = normalizeVector3(config.position, DEFAULT_LISTENER_POSITION); + this._forward = normalizeVector3(config.forward, DEFAULT_LISTENER_FORWARD); + this._up = normalizeVector3(config.up, DEFAULT_LISTENER_UP); + this._useTransform = config.useTransform ?? true; + this._metadata = cloneMetadata(config.metadata); + } + + get listenerId(): AudioListenerId { + return this._listenerId; + } + + set listenerId(value: AudioListenerId | string) { + this._listenerId = normalizeAudioListenerId(value); + } + + get active(): boolean { + return this._active; + } + + set active(value: boolean) { + this._active = value; + } + + get useTransform(): boolean { + return this._useTransform; + } + + set useTransform(value: boolean) { + this._useTransform = value; + } + + get position(): AudioVector3 { + return cloneAudioVector3(this._position); + } + + set position(value: AudioVector3) { + this._position = normalizeVector3(value, DEFAULT_LISTENER_POSITION); + } + + get forward(): AudioVector3 { + return cloneAudioVector3(this._forward); + } + + set forward(value: AudioVector3) { + this._forward = normalizeVector3(value, DEFAULT_LISTENER_FORWARD); + } + + get up(): AudioVector3 { + return cloneAudioVector3(this._up); + } + + set up(value: AudioVector3) { + this._up = normalizeVector3(value, DEFAULT_LISTENER_UP); + } + + get metadata(): Readonly> { + return this._metadata; + } + + set metadata(value: Readonly>) { + this._metadata = cloneMetadata(value); + } + + toDescriptor(): AudioListenerDescriptor { + const transform = this._useTransform ? (this.transform as Transform | undefined) : undefined; + if (transform) { + const position = cloneAudioVector3(transform.worldPosition); + const forward = cloneAudioVector3( + transform.worldRotation.rotateVector(Vec3.BACK, Vec3.create()) as AudioVector3 + ); + const up = cloneAudioVector3( + transform.worldRotation.rotateVector(Vec3.UP, Vec3.create()) as AudioVector3 + ); + return { + id: this._listenerId, + active: this._active, + enabled: this.enabled, + position, + forward, + up, + metadata: this._metadata, + }; + } + + return { + id: this._listenerId, + active: this._active, + enabled: this.enabled, + position: cloneAudioVector3(this._position), + forward: cloneAudioVector3(this._forward), + up: cloneAudioVector3(this._up), + metadata: this._metadata, + }; + } + + serialize(): Record { + return { + listenerId: this._listenerId, + active: this._active, + enabled: this.enabled, + position: cloneAudioVector3(this._position), + forward: cloneAudioVector3(this._forward), + up: cloneAudioVector3(this._up), + useTransform: this._useTransform, + metadata: this._metadata, + }; + } + + deserialize(data: Record): void { + if (typeof data.listenerId === 'string') { + this.listenerId = data.listenerId; + } + if (typeof data.active === 'boolean') { + this.active = data.active; + } + if (typeof data.enabled === 'boolean') { + this.enabled = data.enabled; + } + if (data.position) { + this.position = data.position; + } + if (data.forward) { + this.forward = data.forward; + } + if (data.up) { + this.up = data.up; + } + if (typeof data.useTransform === 'boolean') { + this.useTransform = data.useTransform; + } + if (data.metadata && typeof data.metadata === 'object') { + this.metadata = data.metadata; + } + } + + clone(): this { + return new AudioListenerComponent({ + listenerId: this._listenerId, + active: this._active, + enabled: this.enabled, + position: this._position, + forward: this._forward, + up: this._up, + useTransform: this._useTransform, + metadata: this._metadata, + }) as this; + } +} + +export interface AudioSourceComponentConfig + extends ComponentConfig { + readonly sourceId?: AudioSourceId | string; + readonly busId?: string; + readonly clip?: AudioSourceDefinition['clip']; + readonly volume?: number; + readonly muted?: boolean; + readonly loop?: boolean; + readonly autoplay?: boolean; + readonly playbackRate?: number; + readonly detuneCents?: number; + readonly pan?: number; + readonly spatial?: AudioSpatialization; + readonly startOffsetSeconds?: number; + readonly useTransform?: boolean; + readonly metadata?: Readonly>; +} + +export class AudioSourceComponent< + TSchema extends AudioAssetSchema = AudioAssetSchema, +> extends Component> { + private _sourceId: AudioSourceId; + private _busId = MASTER_AUDIO_BUS_ID; + private _clip?: AudioSourceDefinition['clip']; + private _volume: number; + private _muted: boolean; + private _loop: boolean; + private _autoplay: boolean; + private _playbackRate: number; + private _detuneCents: number; + private _pan: number; + private _spatial?: AudioSpatialization; + private _startOffsetSeconds: number; + private _useTransform: boolean; + private _metadata: Readonly>; + private readonly _pendingCommands: AudioSourceComponentCommand[] = []; + private _autoplayPending: boolean; + private _lastKnownState: AudioSourceState['playbackState'] = 'idle'; + + constructor(config: AudioSourceComponentConfig = {}) { + super(config); + this._sourceId = normalizeAudioSourceId(config.sourceId ?? this.id); + this._busId = normalizeAudioBusId(config.busId ?? MASTER_AUDIO_BUS_ID); + this._clip = config.clip; + this._volume = isFiniteNumber(config.volume) ? config.volume : 1; + this._muted = config.muted ?? false; + this._loop = config.loop ?? false; + this._autoplay = config.autoplay ?? false; + this._playbackRate = isFiniteNumber(config.playbackRate) ? config.playbackRate : 1; + this._detuneCents = isFiniteNumber(config.detuneCents) ? config.detuneCents : 0; + this._pan = isFiniteNumber(config.pan) ? config.pan : 0; + this._spatial = cloneSpatialization(config.spatial); + this._startOffsetSeconds = isFiniteNumber(config.startOffsetSeconds) + ? config.startOffsetSeconds + : 0; + this._useTransform = config.useTransform ?? true; + this._metadata = cloneMetadata(config.metadata); + this._autoplayPending = this._autoplay; + } + + get sourceId(): AudioSourceId { + return this._sourceId; + } + + set sourceId(value: AudioSourceId | string) { + this._sourceId = normalizeAudioSourceId(value); + } + + get busId(): string { + return this._busId; + } + + set busId(value: string) { + this._busId = normalizeAudioBusId(value); + } + + get clip(): AudioSourceDefinition['clip'] | undefined { + return this._clip; + } + + set clip(value: AudioSourceDefinition['clip'] | undefined) { + this._clip = value; + } + + get volume(): number { + return this._volume; + } + + set volume(value: number) { + this._volume = value; + } + + get muted(): boolean { + return this._muted; + } + + set muted(value: boolean) { + this._muted = value; + } + + get loop(): boolean { + return this._loop; + } + + set loop(value: boolean) { + this._loop = value; + } + + get autoplay(): boolean { + return this._autoplay; + } + + set autoplay(value: boolean) { + this._autoplay = value; + if (value) { + this._autoplayPending = true; + } + } + + get playbackRate(): number { + return this._playbackRate; + } + + set playbackRate(value: number) { + this._playbackRate = value; + } + + get detuneCents(): number { + return this._detuneCents; + } + + set detuneCents(value: number) { + this._detuneCents = value; + } + + get pan(): number { + return this._pan; + } + + set pan(value: number) { + this._pan = value; + } + + get spatial(): AudioSpatialization | undefined { + return cloneSpatialization(this._spatial); + } + + set spatial(value: AudioSpatialization | undefined) { + this._spatial = cloneSpatialization(value); + } + + get startOffsetSeconds(): number { + return this._startOffsetSeconds; + } + + set startOffsetSeconds(value: number) { + this._startOffsetSeconds = value; + } + + get useTransform(): boolean { + return this._useTransform; + } + + set useTransform(value: boolean) { + this._useTransform = value; + } + + get metadata(): Readonly> { + return this._metadata; + } + + set metadata(value: Readonly>) { + this._metadata = cloneMetadata(value); + } + + get playbackState(): AudioSourceState['playbackState'] { + return this._lastKnownState; + } + + play(request?: AudioSourcePlayRequest): void { + this._pendingCommands.push({ kind: 'play', request }); + } + + pause(): void { + this._pendingCommands.push({ kind: 'pause' }); + } + + resume(): void { + this._pendingCommands.push({ kind: 'resume' }); + } + + stop(options?: AudioStopOptions): void { + this._pendingCommands.push({ kind: 'stop', options }); + } + + override onEnable(): void { + if (this._autoplay) { + this._autoplayPending = true; + } + } + + override onDisable(): void { + this.stop(); + } + + consumeCommands(): readonly AudioSourceComponentCommand[] { + const commands = this._pendingCommands.splice(0); + if (this._autoplayPending) { + commands.unshift({ kind: 'play' }); + this._autoplayPending = false; + } + return Object.freeze(commands); + } + + syncState(state: AudioSourceState): void { + this._lastKnownState = state.playbackState; + } + + toDescriptor(): AudioSourceDefinition { + const transform = this._useTransform ? (this.transform as Transform | undefined) : undefined; + let spatial = cloneSpatialization(this._spatial); + + if (transform) { + const position = cloneAudioVector3(transform.worldPosition); + const orientation = cloneAudioVector3( + transform.worldRotation.rotateVector(Vec3.BACK, Vec3.create()) as AudioVector3 + ); + + if (!spatial) { + spatial = { + mode: '3d', + position, + orientation, + }; + } else if (spatial.mode === '2d') { + spatial = { + ...spatial, + position, + }; + } else { + spatial = { + ...spatial, + position, + orientation, + }; + } + } + + return { + id: this._sourceId, + busId: this._busId, + clip: this._clip, + volume: this._volume, + muted: this._muted || !this.enabled, + loop: this._loop, + autoplay: this._autoplay, + playbackRate: this._playbackRate, + detuneCents: this._detuneCents, + pan: this._pan, + spatial, + startOffsetSeconds: this._startOffsetSeconds, + metadata: this._metadata, + }; + } + + serialize(): Record { + return { + sourceId: this._sourceId, + busId: this._busId, + clip: toAudioClipSelector(this._clip), + volume: this._volume, + muted: this._muted, + loop: this._loop, + autoplay: this._autoplay, + playbackRate: this._playbackRate, + detuneCents: this._detuneCents, + pan: this._pan, + spatial: cloneSpatialization(this._spatial), + startOffsetSeconds: this._startOffsetSeconds, + useTransform: this._useTransform, + metadata: this._metadata, + }; + } + + deserialize(data: Record): void { + if (typeof data.sourceId === 'string') { + this.sourceId = data.sourceId; + } + if (typeof data.busId === 'string') { + this.busId = data.busId; + } + if ('clip' in data) { + this.clip = data.clip; + } + if (isFiniteNumber(data.volume)) { + this.volume = data.volume; + } + if (typeof data.muted === 'boolean') { + this.muted = data.muted; + } + if (typeof data.loop === 'boolean') { + this.loop = data.loop; + } + if (typeof data.autoplay === 'boolean') { + this.autoplay = data.autoplay; + } + if (isFiniteNumber(data.playbackRate)) { + this.playbackRate = data.playbackRate; + } + if (isFiniteNumber(data.detuneCents)) { + this.detuneCents = data.detuneCents; + } + if (isFiniteNumber(data.pan)) { + this.pan = data.pan; + } + if (data.spatial) { + this.spatial = data.spatial; + } + if (isFiniteNumber(data.startOffsetSeconds)) { + this.startOffsetSeconds = data.startOffsetSeconds; + } + if (typeof data.useTransform === 'boolean') { + this.useTransform = data.useTransform; + } + if (data.metadata && typeof data.metadata === 'object') { + this.metadata = data.metadata; + } + } + + clone(): this { + return new AudioSourceComponent({ + sourceId: this._sourceId, + busId: this._busId, + clip: this._clip, + volume: this._volume, + muted: this._muted, + loop: this._loop, + autoplay: this._autoplay, + playbackRate: this._playbackRate, + detuneCents: this._detuneCents, + pan: this._pan, + spatial: this._spatial, + startOffsetSeconds: this._startOffsetSeconds, + useTransform: this._useTransform, + metadata: this._metadata, + enabled: this.enabled, + }) as this; + } +} diff --git a/web/packages/audio/src/errors.ts b/web/packages/audio/src/errors.ts new file mode 100644 index 00000000..0927be28 --- /dev/null +++ b/web/packages/audio/src/errors.ts @@ -0,0 +1,175 @@ +import type { + AudioMessageCode, + AudioMessageDescriptor, + AudioMessageResolver, + AudioRuntimeMessageCode, + AudioValidationMessageCode, +} from './types'; + +const formatUnknown = (value: unknown): string => { + if (value instanceof Error) { + return value.message; + } + + if (typeof value === 'string') { + return value; + } + + try { + return JSON.stringify(value); + } catch { + return String(value); + } +}; + +export const DEFAULT_AUDIO_MESSAGE_RESOLVER: AudioMessageResolver = ( + descriptor: AudioMessageDescriptor +): string | undefined => { + switch (descriptor.code) { + case 'audio.invalid-bus-id': + return `Invalid audio bus id: ${formatUnknown(descriptor.value)}`; + case 'audio.invalid-clip': + return `Invalid audio clip input: ${formatUnknown(descriptor.value)}`; + case 'audio.invalid-context': + return `Invalid audio context: ${formatUnknown(descriptor.value)}`; + case 'audio.invalid-distance': + return `Invalid audio distance value: ${formatUnknown(descriptor.value)}`; + case 'audio.invalid-gain': + return `Invalid audio gain value: ${formatUnknown(descriptor.value)}`; + case 'audio.invalid-listener': + return `Invalid audio listener input: ${formatUnknown(descriptor.value)}`; + case 'audio.invalid-pan': + return `Invalid audio pan value: ${formatUnknown(descriptor.value)}`; + case 'audio.invalid-parent-bus': + return `Invalid audio parent bus: ${formatUnknown(descriptor.value)}`; + case 'audio.invalid-playback-rate': + return `Invalid audio playback rate: ${formatUnknown(descriptor.value)}`; + case 'audio.invalid-source': + return `Invalid audio source input: ${formatUnknown(descriptor.value)}`; + case 'audio.invalid-snapshot': + return `Invalid audio snapshot: ${formatUnknown(descriptor.value)}`; + case 'audio.invalid-time': + return `Invalid audio time value: ${formatUnknown(descriptor.value)}`; + case 'audio.invalid-vector': + return `Invalid audio vector value: ${formatUnknown(descriptor.value)}`; + case 'audio.bus.cycle': + return `Audio bus cycle detected between ${descriptor.busId} and ${descriptor.parentId}`; + case 'audio.asset.resolve-failed': + return `Failed to resolve audio asset ${formatUnknown(descriptor.selector)}: ${formatUnknown(descriptor.reason)}`; + case 'audio.bus.missing': + return `Audio bus not found: ${descriptor.busId}`; + case 'audio.context.resume-failed': + return `Failed to resume audio context: ${formatUnknown(descriptor.reason)}`; + case 'audio.context.suspend-failed': + return `Failed to suspend audio context: ${formatUnknown(descriptor.reason)}`; + case 'audio.disposed': + return 'Audio system has already been disposed'; + case 'audio.listener.missing': + return `Audio listener not found: ${descriptor.listenerId}`; + case 'audio.snapshot.invalid': + return `Audio snapshot is invalid: ${descriptor.reason}`; + case 'audio.source.missing': + return `Audio source not found: ${descriptor.sourceId}`; + case 'audio.source.play-failed': + return `Failed to play audio source ${descriptor.sourceId}: ${formatUnknown(descriptor.reason)}`; + case 'audio.source.resume-failed': + return `Failed to resume audio source ${descriptor.sourceId}: ${formatUnknown(descriptor.reason)}`; + case 'audio.unavailable': + return `Audio system is unavailable: ${descriptor.reason}`; + default: + return undefined; + } +}; + +export const resolveAudioMessage = ( + descriptor: AudioMessageDescriptor, + locale = 'en', + resolver: AudioMessageResolver = DEFAULT_AUDIO_MESSAGE_RESOLVER +): string => resolver(descriptor, locale) ?? DEFAULT_AUDIO_MESSAGE_RESOLVER(descriptor, locale) ?? descriptor.code; + +export class AudioError extends Error { + override readonly name: string; + readonly code: AudioMessageCode; + + constructor(name: string, code: AudioMessageCode, message: string, options?: ErrorOptions) { + super(message, options); + this.name = name; + this.code = code; + Object.setPrototypeOf(this, new.target.prototype); + ( + Error as typeof Error & { captureStackTrace?: (target: object, ctor: Function) => void } + ).captureStackTrace?.(this, this.constructor); + } +} + +export class AudioConfigurationError extends AudioError { + constructor(code: AudioValidationMessageCode, message: string, options?: ErrorOptions) { + super('AudioConfigurationError', code, message, options); + } +} + +export class AudioLifecycleError extends AudioError { + constructor( + code: 'audio.context.resume-failed' | 'audio.context.suspend-failed', + message: string, + options?: ErrorOptions + ) { + super('AudioLifecycleError', code, message, options); + } +} + +export class AudioDisposedError extends AudioError { + constructor(message: string, options?: ErrorOptions) { + super('AudioDisposedError', 'audio.disposed', message, options); + } +} + +export class AudioUnavailableError extends AudioError { + constructor(message: string, options?: ErrorOptions) { + super('AudioUnavailableError', 'audio.unavailable', message, options); + } +} + +export class AudioAssetError extends AudioError { + constructor(message: string, options?: ErrorOptions) { + super('AudioAssetError', 'audio.asset.resolve-failed', message, options); + } +} + +export class AudioBusError extends AudioError { + readonly busId: string; + + constructor(message: string, busId: string, options?: ErrorOptions) { + super('AudioBusError', 'audio.bus.missing', message, options); + this.busId = busId; + } +} + +export class AudioListenerError extends AudioError { + readonly listenerId: string; + + constructor(message: string, listenerId: string, options?: ErrorOptions) { + super('AudioListenerError', 'audio.listener.missing', message, options); + this.listenerId = listenerId; + } +} + +export class AudioSourceError extends AudioError { + readonly sourceId: string; + + constructor( + code: 'audio.source.missing' | 'audio.source.play-failed' | 'audio.source.resume-failed', + message: string, + sourceId: string, + options?: ErrorOptions + ) { + super('AudioSourceError', code, message, options); + this.sourceId = sourceId; + } +} + +export class AudioSnapshotError extends AudioError { + constructor(message: string, options?: ErrorOptions) { + super('AudioSnapshotError', 'audio.snapshot.invalid', message, options); + } +} \ No newline at end of file diff --git a/web/packages/audio/src/index.ts b/web/packages/audio/src/index.ts new file mode 100644 index 00000000..16fb8ed1 --- /dev/null +++ b/web/packages/audio/src/index.ts @@ -0,0 +1,126 @@ +export type { + Audio2DSpatialization, + Audio3DSpatialization, + AudioAssetResolver, + AudioAssetSchema, + AudioBusDefinition, + AudioBusId, + AudioBusPatch, + AudioBusState, + AudioBufferClipAssetData, + AudioClipAssetData, + AudioClipAssetSelector, + AudioClipId, + AudioClipInput, + AudioClipRecord, + AudioClipSelector, + AudioDistanceModel, + AudioDiagnosticsCounters, + AudioDiagnosticsSnapshot, + AudioEventEmitter, + AudioEncodedClipAssetData, + AudioJsonArray, + AudioJsonObject, + AudioJsonPrimitive, + AudioJsonValue, + AudioListenerDescriptor, + AudioListenerId, + AudioListenerPatch, + AudioListenerState, + AudioMessageCode, + AudioMessageDescriptor, + AudioMessageResolver, + AudioMixerSnapshot, + AudioMixerSnapshotBusState, + AudioPatch, + AudioPanningModel, + AudioPcmClipAssetData, + AudioPlaybackHandle, + AudioPlaybackState, + AudioRestoreOptions, + AudioRuntimeEvent, + AudioRuntimeEventChannel, + AudioRuntimeEventMap, + AudioRuntimeEventType, + AudioRetryContext, + AudioRetryPolicy, + AudioSnapshotId, + AudioSnapshotTransitionOptions, + AudioSourceComponentCommand, + AudioSourceDefinition, + AudioSourceId, + AudioSourcePatch, + AudioSourcePlayRequest, + AudioSourceState, + AudioSpatialAttenuation, + AudioSpatialization, + AudioStopOptions, + AudioSystemOptions, + AudioSystemSnapshot, + AudioSystemStatus, + AudioUrlClipAssetData, + AudioValidationMessageCode, + AudioVector3, + AudioRuntimeMessageCode, +} from './types'; + +export { + AUDIO_CLIP_ASSET_KIND, + createInlineAudioClipSelector, + createRegisteredAudioClipSelector, + isAudioClipAssetData, + isAudioClipSelector, + toAudioClipSelector, + toAudioClipSelectorFromRecord, +} from './asset'; + +export { + AudioAssetError, + AudioBusError, + AudioConfigurationError, + AudioDisposedError, + AudioError, + AudioLifecycleError, + AudioListenerError, + AudioSnapshotError, + AudioSourceError, + AudioUnavailableError, + DEFAULT_AUDIO_MESSAGE_RESOLVER, + resolveAudioMessage, +} from './errors'; + +export { + MASTER_AUDIO_BUS_ID, + asAudioBusId, + asAudioClipId, + asAudioListenerId, + asAudioSnapshotId, + asAudioSourceId, + cloneAudioVector3, + createAudioClipAssetReference, + isAudioClipAssetRecord, + isAudioClipAssetReference, + normalizeAudioBusId, + normalizeAudioClipId, + normalizeAudioListenerId, + normalizeAudioSnapshotId, + normalizeAudioSourceId, +} from './reference'; + +export type { + AudioListenerComponentConfig, + AudioSourceComponentConfig, +} from './components'; +export { + AudioListenerComponent, + AudioSourceComponent, +} from './components'; + +export { + AudioSystem, + createAudioSystem, + isAudioMixerSnapshot, + isAudioSystemSnapshot, +} from './system'; + +export { AudioComponentBinder } from './component-binder'; diff --git a/web/packages/audio/src/internal/bus-registry.ts b/web/packages/audio/src/internal/bus-registry.ts new file mode 100644 index 00000000..8f688724 --- /dev/null +++ b/web/packages/audio/src/internal/bus-registry.ts @@ -0,0 +1,337 @@ +import { AudioBusError } from '../errors'; +import { + MASTER_AUDIO_BUS_ID, + normalizeAudioBusId, +} from '../reference'; +import type { + AudioBusDefinition, + AudioBusId, + AudioBusState, + AudioJsonValue, + AudioMessageDescriptor, + AudioMixerSnapshot, + AudioSnapshotTransitionOptions, +} from '../types'; +import type { InternalBus } from './runtime'; +import { + cloneMetadata, + disconnectNode, + setParamValue, +} from './shared'; + +export interface AudioBusRegistryOptions { + readonly context: AudioContext; + readonly destination: AudioNode; + readonly createConfigurationError: (descriptor: AudioMessageDescriptor) => Error; + readonly normalizeGain: ( + value: number, + code: 'audio.invalid-gain' | 'audio.invalid-distance' + ) => number; + readonly normalizePan: (value: number) => number; +} + +export interface AudioBusRemovalResult { + readonly removed: boolean; + readonly fallbackBusId?: AudioBusId; +} + +export class AudioBusRegistry { + readonly #context: AudioContext; + readonly #destination: AudioNode; + readonly #createConfigurationError: AudioBusRegistryOptions['createConfigurationError']; + readonly #normalizeGain: AudioBusRegistryOptions['normalizeGain']; + readonly #normalizePan: AudioBusRegistryOptions['normalizePan']; + readonly #buses = new Map(); + + constructor(options: AudioBusRegistryOptions) { + this.#context = options.context; + this.#destination = options.destination; + this.#createConfigurationError = options.createConfigurationError; + this.#normalizeGain = options.normalizeGain; + this.#normalizePan = options.normalizePan; + + const master = this.#createRuntime({ id: MASTER_AUDIO_BUS_ID }); + this.#buses.set(MASTER_AUDIO_BUS_ID, master); + this.#connect(master); + } + + initialize(definitions: readonly AudioBusDefinition[]): void { + for (const definition of definitions) { + this.upsert({ ...definition, parentId: undefined }); + } + + for (const definition of definitions) { + if (definition.parentId !== undefined) { + this.upsert({ id: definition.id, parentId: definition.parentId }); + } + } + } + + upsert(definition: AudioBusDefinition): AudioBusState { + const id = normalizeAudioBusId(definition.id); + const parentId = + definition.parentId === undefined ? undefined : normalizeAudioBusId(definition.parentId); + + if (id === MASTER_AUDIO_BUS_ID && parentId !== undefined) { + throw this.#createConfigurationError({ + code: 'audio.invalid-parent-bus', + value: parentId, + }); + } + if (parentId === id) { + throw this.#createConfigurationError({ + code: 'audio.bus.cycle', + busId: id, + parentId, + }); + } + if (parentId !== undefined && !this.#buses.has(parentId)) { + throw new AudioBusError(`Audio bus ${parentId} does not exist`, parentId); + } + if (parentId && this.#createsCycle(id, parentId)) { + throw this.#createConfigurationError({ + code: 'audio.bus.cycle', + busId: id, + parentId, + }); + } + + const isNew = !this.#buses.has(id); + let bus = this.#buses.get(id); + if (!bus) { + bus = this.#createRuntime({ id }); + this.#buses.set(id, bus); + } + + if (isNew || bus.parentId !== parentId) { + if (bus.parentId) { + this.#buses.get(bus.parentId)?.childIds.delete(bus.id); + } + bus.parentId = parentId; + if (parentId) { + this.#buses.get(parentId)?.childIds.add(bus.id); + } + this.#connect(bus); + } + + if (definition.volume !== undefined) { + bus.volume = this.#normalizeGain(definition.volume, 'audio.invalid-gain'); + } + if (definition.mute !== undefined) { + bus.mute = definition.mute; + } + if (definition.pan !== undefined) { + bus.pan = this.#normalizePan(definition.pan); + } + if (definition.metadata !== undefined) { + bus.metadata = cloneMetadata(definition.metadata); + } + + this.#applyState(bus); + return this.snapshot(bus.id); + } + + remove(id: AudioBusId | string): AudioBusRemovalResult { + const normalizedId = normalizeAudioBusId(id); + if (normalizedId === MASTER_AUDIO_BUS_ID) { + return { removed: false }; + } + + const bus = this.#buses.get(normalizedId); + if (!bus) { + return { removed: false }; + } + + const fallbackBusId = bus.parentId ?? MASTER_AUDIO_BUS_ID; + const fallbackBus = this.require(fallbackBusId); + + for (const childId of bus.childIds) { + const child = this.#buses.get(childId); + if (!child) { + continue; + } + + child.parentId = fallbackBusId; + fallbackBus.childIds.add(childId); + this.#connect(child); + } + + if (bus.parentId) { + this.#buses.get(bus.parentId)?.childIds.delete(normalizedId); + } + + disconnectNode(bus.outputNode); + disconnectNode(bus.gainNode); + disconnectNode(bus.panNode); + this.#buses.delete(normalizedId); + + return { + removed: true, + fallbackBusId, + }; + } + + require(id: AudioBusId | string): InternalBus { + const normalizedId = normalizeAudioBusId(id); + const bus = this.#buses.get(normalizedId); + if (!bus) { + throw new AudioBusError(`Audio bus ${normalizedId} does not exist`, normalizedId); + } + return bus; + } + + get(id: AudioBusId | string): AudioBusState | undefined { + const bus = this.#buses.get(normalizeAudioBusId(id)); + return bus ? this.snapshot(bus.id) : undefined; + } + + list(): readonly AudioBusState[] { + return Object.freeze([...this.#buses.values()].map((bus) => this.snapshot(bus.id))); + } + + snapshot(id: AudioBusId): AudioBusState { + const bus = this.require(id); + return Object.freeze({ + id: bus.id, + parentId: bus.parentId, + volume: bus.volume, + mute: bus.mute, + pan: bus.pan, + effectiveGain: this.#effectiveGain(bus.id), + childIds: Object.freeze([...bus.childIds]), + metadata: bus.metadata, + }); + } + + captureSnapshot(id?: string): AudioMixerSnapshot { + return Object.freeze({ + id, + buses: Object.freeze( + [...this.#buses.values()].map((bus) => + Object.freeze({ + id: bus.id, + volume: bus.volume, + mute: bus.mute, + pan: bus.pan, + }) + ) + ), + }); + } + + applySnapshot( + snapshot: AudioMixerSnapshot, + options: AudioSnapshotTransitionOptions = {} + ): void { + const atTime = options.atTime ?? this.#context.currentTime; + const durationSeconds = options.durationSeconds ?? 0; + + for (const entry of snapshot.buses) { + const bus = this.#buses.get(normalizeAudioBusId(entry.id)); + if (!bus) { + continue; + } + + if (entry.volume !== undefined) { + bus.volume = this.#normalizeGain(entry.volume, 'audio.invalid-gain'); + } + if (entry.mute !== undefined) { + bus.mute = entry.mute; + } + if (entry.pan !== undefined) { + bus.pan = this.#normalizePan(entry.pan); + } + this.#applyState(bus, { atTime, durationSeconds }); + } + } + + clear(): void { + for (const [id, bus] of [...this.#buses]) { + if (id === MASTER_AUDIO_BUS_ID) { + bus.childIds.clear(); + bus.parentId = undefined; + bus.volume = 1; + bus.mute = false; + bus.pan = 0; + bus.metadata = Object.freeze({}); + this.#connect(bus); + continue; + } + + disconnectNode(bus.outputNode); + disconnectNode(bus.gainNode); + disconnectNode(bus.panNode); + this.#buses.delete(id); + } + } + + #createRuntime(definition: Pick): InternalBus { + const gainNode = this.#context.createGain(); + const panNode = + typeof this.#context.createStereoPanner === 'function' + ? this.#context.createStereoPanner() + : undefined; + let outputNode: AudioNode = gainNode; + if (panNode) { + gainNode.connect(panNode); + outputNode = panNode; + } + + return { + id: normalizeAudioBusId(definition.id), + parentId: definition.parentId ? normalizeAudioBusId(definition.parentId) : undefined, + gainNode, + panNode, + outputNode, + childIds: new Set(), + volume: 1, + mute: false, + pan: 0, + metadata: Object.freeze({}) as Readonly>, + }; + } + + #connect(bus: InternalBus): void { + disconnectNode(bus.outputNode); + const parent = bus.parentId ? this.#buses.get(bus.parentId) : undefined; + bus.outputNode.connect(parent?.gainNode ?? this.#destination); + this.#applyState(bus); + } + + #applyState( + bus: InternalBus, + options: { atTime?: number; durationSeconds?: number } = {} + ): void { + const atTime = options.atTime ?? this.#context.currentTime; + const durationSeconds = options.durationSeconds ?? 0; + setParamValue(bus.gainNode.gain, bus.mute ? 0 : bus.volume, atTime, durationSeconds); + if (bus.panNode) { + setParamValue(bus.panNode.pan, bus.pan, atTime, durationSeconds); + } + } + + #effectiveGain(id: AudioBusId): number { + let gain = 1; + let current: AudioBusId | undefined = id; + while (current) { + const bus = this.#buses.get(current); + if (!bus) { + break; + } + gain *= bus.mute ? 0 : bus.volume; + current = bus.parentId; + } + return gain; + } + + #createsCycle(id: AudioBusId, parentId: AudioBusId): boolean { + let current: AudioBusId | undefined = parentId; + while (current) { + if (current === id) { + return true; + } + current = this.#buses.get(current)?.parentId; + } + return false; + } +} diff --git a/web/packages/audio/src/internal/clip-store.ts b/web/packages/audio/src/internal/clip-store.ts new file mode 100644 index 00000000..0896812a --- /dev/null +++ b/web/packages/audio/src/internal/clip-store.ts @@ -0,0 +1,269 @@ +import type { AssetRecord } from '@axrone/asset-core'; +import { isAudioClipAssetData, toAudioClipSelector } from '../asset'; +import { AudioAssetError } from '../errors'; +import { + isAudioClipAssetRecord, + normalizeAudioClipId, +} from '../reference'; +import type { + AudioAssetResolver, + AudioAssetSchema, + AudioClipAssetData, + AudioClipAssetSelector, + AudioClipId, + AudioClipInput, + AudioClipRecord, + AudioClipSelector, + AudioJsonValue, + AudioRetryPolicy, + AudioSystemOptions, +} from '../types'; +import { cloneMetadata, isObject, withRetry } from './shared'; + +export interface AudioClipStoreOptions { + readonly context: AudioContext; + readonly assetDatabase?: AudioSystemOptions['assetDatabase']; + readonly assetResolver?: AudioAssetResolver; + readonly retryPolicy?: AudioRetryPolicy; +} + +export class AudioClipStore { + readonly #context: AudioContext; + readonly #assetDatabase?: AudioSystemOptions['assetDatabase']; + readonly #assetResolver?: AudioAssetResolver; + readonly #retryPolicy?: AudioRetryPolicy; + readonly #registeredClips = new Map>(); + readonly #clipCache = new Map>>(); + readonly #bufferCacheKeys = new WeakMap(); + + #bufferCacheSequence = 0; + + constructor(options: AudioClipStoreOptions) { + this.#context = options.context; + this.#assetDatabase = options.assetDatabase; + this.#assetResolver = options.assetResolver; + this.#retryPolicy = options.retryPolicy; + } + + register(id: AudioClipId | string, clip: AudioClipInput): AudioClipId { + const normalizedId = normalizeAudioClipId(id); + const selector = toAudioClipSelector(clip); + if (!selector) { + throw new AudioAssetError('Audio clip selector could not be created'); + } + + this.#registeredClips.set(normalizedId, selector); + this.#invalidate({ kind: 'registered', clipId: normalizedId }); + return normalizedId; + } + + unregister(id: AudioClipId | string): boolean { + const normalizedId = normalizeAudioClipId(id); + this.#invalidate({ kind: 'registered', clipId: normalizedId }); + return this.#registeredClips.delete(normalizedId); + } + + async resolve(input: AudioClipInput): Promise> { + const selector = toAudioClipSelector(input); + if (!selector) { + throw new AudioAssetError('Audio clip selector could not be created'); + } + + return this.resolveSelector(selector); + } + + async resolveSelector(selector: AudioClipSelector): Promise> { + const cacheKey = this.#cacheKeyForSelector(selector); + const useCache = cacheKey !== undefined; + + if (useCache) { + const cached = this.#clipCache.get(cacheKey); + if (cached) { + return cached; + } + } + + const pending = withRetry( + this.#retryPolicy, + (attempt) => ({ operation: 'asset.resolve', attempt, clip: selector }), + async () => this.#decodeSelector(selector) + ); + + if (useCache) { + this.#clipCache.set(cacheKey, pending); + } + + try { + return await pending; + } catch (error) { + if (useCache) { + this.#clipCache.delete(cacheKey); + } + throw error; + } + } + + clear(): void { + this.#registeredClips.clear(); + this.#clipCache.clear(); + } + + #invalidate(selector: AudioClipSelector): void { + const cacheKey = this.#cacheKeyForSelector(selector); + if (cacheKey) { + this.#clipCache.delete(cacheKey); + } + } + + async #decodeSelector(selector: AudioClipSelector): Promise> { + switch (selector.kind) { + case 'registered': { + const registered = this.#registeredClips.get(selector.clipId); + if (!registered) { + throw new AudioAssetError(`Registered audio clip ${selector.clipId} does not exist`); + } + + return this.resolveSelector(registered); + } + case 'asset': { + const resolved = await this.#resolveAssetClip(selector.selector); + if (isAudioClipAssetRecord(resolved)) { + if (!isAudioClipAssetData(resolved.data)) { + throw new AudioAssetError('Resolved asset record does not contain audio clip data'); + } + + return this.#decodeClipData(selector, resolved.data, resolved.data.metadata); + } + + return this.#decodeClipData(selector, resolved, resolved.metadata); + } + case 'inline': + return this.#decodeClipData(selector, selector.clip, selector.clip.metadata); + default: + throw new AudioAssetError('Unsupported audio clip selector'); + } + } + + async #resolveAssetClip( + selector: AudioClipAssetSelector + ): Promise | AudioClipAssetData> { + const fromResolver = await this.#assetResolver?.resolveClip(selector); + if (fromResolver) { + return fromResolver as AssetRecord | AudioClipAssetData; + } + + const record = this.#assetDatabase?.get(selector); + if (!record) { + throw new AudioAssetError(`Audio asset could not be resolved for selector ${JSON.stringify(selector)}`); + } + + return record; + } + + async #decodeClipData( + selector: AudioClipSelector, + data: AudioClipAssetData, + metadata?: Readonly> + ): Promise> { + let buffer: AudioBuffer; + switch (data.kind) { + case 'buffer': + buffer = data.buffer; + break; + case 'pcm': + buffer = this.#createBufferFromPcm(data); + break; + case 'encoded': + buffer = await this.#context.decodeAudioData(this.#toArrayBuffer(data.data)); + break; + case 'url': { + const response = await fetch(data.url, { + credentials: data.credentials, + headers: data.headers, + }); + if (!response.ok) { + throw new AudioAssetError(`Failed to fetch audio clip ${data.url}`); + } + buffer = await this.#context.decodeAudioData(await response.arrayBuffer()); + break; + } + default: + throw new AudioAssetError('Unsupported audio clip asset data'); + } + + return Object.freeze({ + id: normalizeAudioClipId(this.#cacheKeyForSelector(selector) ?? `clip:${++this.#bufferCacheSequence}`), + selector, + buffer, + durationSeconds: buffer.duration, + sampleRate: buffer.sampleRate, + channelCount: buffer.numberOfChannels, + loopStartSeconds: data.loopStartSeconds, + loopEndSeconds: data.loopEndSeconds, + metadata: cloneMetadata(metadata), + }); + } + + #createBufferFromPcm(data: Extract): AudioBuffer { + const frameLength = data.channelData[0]?.length ?? 0; + const buffer = this.#context.createBuffer(data.channelData.length, frameLength, data.sampleRate); + data.channelData.forEach((channel, index) => { + buffer.copyToChannel(channel, index); + }); + return buffer; + } + + #toArrayBuffer(value: ArrayBuffer | ArrayBufferView): ArrayBuffer { + if (value instanceof ArrayBuffer) { + return value.slice(0); + } + + const view = new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + return view.slice().buffer; + } + + #cacheKeyForSelector(selector: AudioClipSelector): string | undefined { + switch (selector.kind) { + case 'registered': + return `registered:${selector.clipId}`; + case 'asset': + return this.#assetSelectorCacheKey(selector.selector); + case 'inline': + switch (selector.clip.kind) { + case 'buffer': { + const cached = this.#bufferCacheKeys.get(selector.clip.buffer); + if (cached) { + return cached; + } + + const key = `buffer:${++this.#bufferCacheSequence}`; + this.#bufferCacheKeys.set(selector.clip.buffer, key); + return key; + } + case 'url': + return `url:${selector.clip.url}`; + default: + return undefined; + } + default: + return undefined; + } + } + + #assetSelectorCacheKey(selector: AudioClipAssetSelector): string { + if (typeof selector === 'string') { + return `asset:string:${selector}`; + } + if (isObject(selector) && 'token' in selector && typeof selector.token === 'string') { + return `asset:token:${selector.token}`; + } + if (isObject(selector) && 'id' in selector && 'revision' in selector) { + return `asset:record:${String(selector.id)}:${String(selector.revision)}`; + } + if (isObject(selector) && 'key' in selector && typeof selector.key === 'string') { + return `asset:key:${selector.key}:${'kind' in selector ? String(selector.kind ?? '') : ''}`; + } + + return `asset:json:${JSON.stringify(selector)}`; + } +} diff --git a/web/packages/audio/src/internal/listener-registry.ts b/web/packages/audio/src/internal/listener-registry.ts new file mode 100644 index 00000000..c159e414 --- /dev/null +++ b/web/packages/audio/src/internal/listener-registry.ts @@ -0,0 +1,161 @@ +import { AudioListenerError } from '../errors'; +import { normalizeAudioListenerId } from '../reference'; +import type { + AudioListenerDescriptor, + AudioListenerId, + AudioListenerState, +} from '../types'; +import type { InternalListener } from './runtime'; +import { + DEFAULT_LISTENER_FORWARD, + DEFAULT_LISTENER_POSITION, + DEFAULT_LISTENER_UP, + cloneMetadata, + normalizeVector3, +} from './shared'; +import { syncAudioListenerToContext } from './spatial'; + +export class AudioListenerRegistry { + readonly #listeners = new Map(); + + #activeListenerId?: AudioListenerId; + + get activeListenerId(): AudioListenerId | undefined { + return this.#activeListenerId; + } + + upsert(descriptor: AudioListenerDescriptor): AudioListenerState { + const id = normalizeAudioListenerId(descriptor.id ?? 'default'); + let listener = this.#listeners.get(id); + + if (!listener) { + listener = { + id, + active: descriptor.active ?? this.#listeners.size === 0, + enabled: descriptor.enabled ?? true, + position: normalizeVector3(descriptor.position, DEFAULT_LISTENER_POSITION), + forward: normalizeVector3(descriptor.forward, DEFAULT_LISTENER_FORWARD), + up: normalizeVector3(descriptor.up, DEFAULT_LISTENER_UP), + metadata: cloneMetadata(descriptor.metadata), + }; + this.#listeners.set(id, listener); + } + + if (descriptor.active !== undefined) { + listener.active = descriptor.active; + } + if (descriptor.enabled !== undefined) { + listener.enabled = descriptor.enabled; + } + if (descriptor.position !== undefined) { + listener.position = normalizeVector3(descriptor.position, DEFAULT_LISTENER_POSITION); + } + if (descriptor.forward !== undefined) { + listener.forward = normalizeVector3(descriptor.forward, DEFAULT_LISTENER_FORWARD); + } + if (descriptor.up !== undefined) { + listener.up = normalizeVector3(descriptor.up, DEFAULT_LISTENER_UP); + } + if (descriptor.metadata !== undefined) { + listener.metadata = cloneMetadata(descriptor.metadata); + } + + if (listener.active) { + this.#activate(listener.id); + } else if (this.#activeListenerId === listener.id || !this.#activeListenerId) { + this.#activeListenerId = this.#findFallbackListenerId(); + } + + return this.snapshot(listener.id); + } + + remove(id: AudioListenerId | string): boolean { + const normalizedId = normalizeAudioListenerId(id); + const removed = this.#listeners.delete(normalizedId); + if (!removed) { + return false; + } + + if (this.#activeListenerId === normalizedId) { + this.#activeListenerId = this.#findFallbackListenerId(); + } + return true; + } + + setActive(id: AudioListenerId | string): void { + const normalizedId = normalizeAudioListenerId(id); + const listener = this.#listeners.get(normalizedId); + if (!listener) { + throw new AudioListenerError(`Audio listener ${normalizedId} does not exist`, normalizedId); + } + this.#activate(listener.id); + } + + get(id: AudioListenerId | string): AudioListenerState | undefined { + const listener = this.#listeners.get(normalizeAudioListenerId(id)); + return listener ? this.snapshot(listener.id) : undefined; + } + + list(): readonly AudioListenerState[] { + return Object.freeze([...this.#listeners.values()].map((listener) => this.snapshot(listener.id))); + } + + snapshot(id: AudioListenerId): AudioListenerState { + const listener = this.require(id); + return Object.freeze({ + id: listener.id, + active: listener.active, + enabled: listener.enabled, + position: normalizeVector3(listener.position, DEFAULT_LISTENER_POSITION), + forward: normalizeVector3(listener.forward, DEFAULT_LISTENER_FORWARD), + up: normalizeVector3(listener.up, DEFAULT_LISTENER_UP), + metadata: listener.metadata, + }); + } + + activeRuntime(): InternalListener | undefined { + return this.#activeListenerId ? this.#listeners.get(this.#activeListenerId) : undefined; + } + + audibleRuntime(): InternalListener | undefined { + const listener = this.activeRuntime(); + return listener?.enabled ? listener : undefined; + } + + syncToContext(audioListener: AudioListener): void { + syncAudioListenerToContext(audioListener, this.audibleRuntime()); + } + + require(id: AudioListenerId | string): InternalListener { + const normalizedId = normalizeAudioListenerId(id); + const listener = this.#listeners.get(normalizedId); + if (!listener) { + throw new AudioListenerError(`Audio listener ${normalizedId} does not exist`, normalizedId); + } + return listener; + } + + clear(): void { + this.#listeners.clear(); + this.#activeListenerId = undefined; + } + + #activate(id: AudioListenerId): void { + for (const candidate of this.#listeners.values()) { + candidate.active = candidate.id === id; + } + this.#activeListenerId = id; + } + + #findFallbackListenerId(): AudioListenerId | undefined { + let fallbackId: AudioListenerId | undefined; + for (const listener of this.#listeners.values()) { + const shouldActivate = fallbackId === undefined && listener.enabled; + listener.active = shouldActivate; + if (shouldActivate) { + fallbackId = listener.id; + } + } + return fallbackId; + } +} diff --git a/web/packages/audio/src/internal/observability.ts b/web/packages/audio/src/internal/observability.ts new file mode 100644 index 00000000..45a5f7a5 --- /dev/null +++ b/web/packages/audio/src/internal/observability.ts @@ -0,0 +1,160 @@ +import { EventEmitter } from '@axrone/event'; +import type { + AudioAssetSchema, + AudioDiagnosticsCounters, + AudioDiagnosticsSnapshot, + AudioEventEmitter, + AudioListenerId, + AudioRuntimeEvent, + AudioRuntimeEventBase, +} from '../types'; + +const AUDIO_ALL_EVENTS_CHANNEL = 'audio:*' as const; + +type DistributiveOmit = TValue extends unknown + ? Omit + : never; + +type AudioRuntimeEventInput = DistributiveOmit< + AudioRuntimeEvent, + keyof Pick +>; + +export interface AudioDiagnosticsState { + readonly systemStatus: AudioRuntimeEventBase['systemStatus']; + readonly activeListenerId?: AudioListenerId; + readonly busCount: number; + readonly listenerCount: number; + readonly sourceCount: number; + readonly activePlaybackCount: number; +} + +export class AudioObservabilityRuntime { + readonly events: AudioEventEmitter = new EventEmitter({ + maxListeners: Infinity, + }) as AudioEventEmitter; + + #sequence = 0; + #counters: AudioDiagnosticsCounters = { + emittedEventCount: 0, + busMutationCount: 0, + listenerMutationCount: 0, + sourceMutationCount: 0, + playbackCommandCount: 0, + playbackCompletionCount: 0, + playbackErrorCount: 0, + snapshotOperationCount: 0, + lifecycleTransitionCount: 0, + }; + #lastEvent?: AudioRuntimeEvent; + + emit(event: AudioRuntimeEventInput): AudioRuntimeEvent { + const nextEvent = Object.freeze({ + ...event, + sequence: ++this.#sequence, + timestamp: Date.now(), + }) as AudioRuntimeEvent; + + this.#counters = this.#nextCounters(nextEvent); + this.#lastEvent = nextEvent; + + if (this.events.listenerCountAll() > 0) { + this.events.emitSync(AUDIO_ALL_EVENTS_CHANNEL, nextEvent); + this.events.emitSync(nextEvent.type, nextEvent as never); + } + + return nextEvent; + } + + snapshot(state: AudioDiagnosticsState): AudioDiagnosticsSnapshot { + return Object.freeze({ + capturedAtEpochMs: Date.now(), + systemStatus: state.systemStatus, + activeListenerId: state.activeListenerId, + busCount: state.busCount, + listenerCount: state.listenerCount, + sourceCount: state.sourceCount, + activePlaybackCount: state.activePlaybackCount, + counters: Object.freeze({ ...this.#counters }), + lastEvent: this.#lastEvent, + }); + } + + reset(): void { + this.#sequence = 0; + this.#counters = { + emittedEventCount: 0, + busMutationCount: 0, + listenerMutationCount: 0, + sourceMutationCount: 0, + playbackCommandCount: 0, + playbackCompletionCount: 0, + playbackErrorCount: 0, + snapshotOperationCount: 0, + lifecycleTransitionCount: 0, + }; + this.#lastEvent = undefined; + } + + #nextCounters(event: AudioRuntimeEvent): AudioDiagnosticsCounters { + const base: AudioDiagnosticsCounters = { + ...this.#counters, + emittedEventCount: this.#counters.emittedEventCount + 1, + }; + + switch (event.type) { + case 'bus:upserted': + case 'bus:removed': + return { + ...base, + busMutationCount: base.busMutationCount + 1, + }; + case 'listener:upserted': + case 'listener:removed': + case 'listener:activated': + return { + ...base, + listenerMutationCount: base.listenerMutationCount + 1, + }; + case 'source:upserted': + case 'source:removed': + return { + ...base, + sourceMutationCount: base.sourceMutationCount + 1, + }; + case 'source:played': + case 'source:paused': + case 'source:resumed': + case 'source:stopped': + return { + ...base, + playbackCommandCount: base.playbackCommandCount + 1, + }; + case 'source:ended': + return { + ...base, + playbackCompletionCount: base.playbackCompletionCount + 1, + }; + case 'source:error': + return { + ...base, + playbackErrorCount: base.playbackErrorCount + 1, + }; + case 'snapshot:captured': + case 'snapshot:restored': + return { + ...base, + snapshotOperationCount: base.snapshotOperationCount + 1, + }; + case 'system:suspended': + case 'system:resumed': + case 'system:disposed': + return { + ...base, + lifecycleTransitionCount: base.lifecycleTransitionCount + 1, + }; + default: + return base; + } + } +} diff --git a/web/packages/audio/src/internal/playback-runtime.ts b/web/packages/audio/src/internal/playback-runtime.ts new file mode 100644 index 00000000..f4eefd8d --- /dev/null +++ b/web/packages/audio/src/internal/playback-runtime.ts @@ -0,0 +1,120 @@ +import { syncPlaybackSpatialState } from './spatial'; +import type { + InternalListener, + InternalPlayback, + InternalSource, +} from './runtime'; +import { disconnectNode } from './shared'; +import type { AudioAssetSchema, AudioClipRecord } from '../types'; + +export interface AudioPlaybackStartOptions { + readonly sequence: number; + readonly clip: AudioClipRecord; + readonly busNode: AudioNode; + readonly when: number; + readonly offsetSeconds: number; + readonly durationSeconds?: number; + readonly onEnded: () => void; +} + +export class AudioPlaybackRuntime { + constructor(readonly context: AudioContext) {} + + startPlayback( + source: InternalSource, + options: AudioPlaybackStartOptions + ): InternalPlayback { + const sourceNode = this.context.createBufferSource(); + sourceNode.buffer = options.clip.buffer; + sourceNode.loop = source.loop; + sourceNode.playbackRate.value = source.playbackRate; + sourceNode.detune.value = source.detuneCents; + if (options.clip.loopStartSeconds !== undefined) { + sourceNode.loopStart = options.clip.loopStartSeconds; + } + if (options.clip.loopEndSeconds !== undefined) { + sourceNode.loopEnd = options.clip.loopEndSeconds; + } + + const gainNode = this.context.createGain(); + const attenuationNode = this.context.createGain(); + sourceNode.connect(gainNode); + gainNode.connect(attenuationNode); + + let spatialNode: StereoPannerNode | PannerNode | undefined; + let outputNode: AudioNode = attenuationNode; + + if (source.spatial?.mode === '3d') { + const panner = this.context.createPanner(); + panner.distanceModel = 'inverse'; + panner.refDistance = 1; + panner.maxDistance = 1000000; + panner.rolloffFactor = 0; + attenuationNode.connect(panner); + outputNode = panner; + spatialNode = panner; + } else if (typeof this.context.createStereoPanner === 'function') { + const stereoPanner = this.context.createStereoPanner(); + attenuationNode.connect(stereoPanner); + outputNode = stereoPanner; + spatialNode = stereoPanner; + } + + outputNode.connect(options.busNode); + + const playback: InternalPlayback = { + sequence: options.sequence, + sourceNode, + gainNode, + attenuationNode, + spatialNode, + outputNode, + clip: options.clip, + durationSeconds: options.durationSeconds, + startOffsetSeconds: options.offsetSeconds, + startedAtContextTime: options.when, + control: 'playing', + }; + + sourceNode.onended = options.onEnded; + + try { + if (options.durationSeconds !== undefined && options.durationSeconds > 0) { + sourceNode.start(options.when, options.offsetSeconds, options.durationSeconds); + } else { + sourceNode.start(options.when, options.offsetSeconds); + } + } catch (error) { + this.disposePlayback(playback); + throw error; + } + + return playback; + } + + syncPlayback( + source: InternalSource, + listener: InternalListener | undefined + ): void { + if (!source.active) { + return; + } + + source.active.sourceNode.playbackRate.value = source.playbackRate; + source.active.sourceNode.detune.value = source.detuneCents; + syncPlaybackSpatialState(source.active, source, listener); + } + + reconnectPlayback(playback: InternalPlayback, busNode: AudioNode): void { + disconnectNode(playback.outputNode); + playback.outputNode.connect(busNode); + } + + disposePlayback(playback: InternalPlayback): void { + playback.sourceNode.onended = null; + disconnectNode(playback.sourceNode); + disconnectNode(playback.gainNode); + disconnectNode(playback.attenuationNode); + disconnectNode(playback.spatialNode); + } +} diff --git a/web/packages/audio/src/internal/runtime.ts b/web/packages/audio/src/internal/runtime.ts new file mode 100644 index 00000000..de93e20b --- /dev/null +++ b/web/packages/audio/src/internal/runtime.ts @@ -0,0 +1,69 @@ +import type { + AudioAssetSchema, + AudioBusId, + AudioClipRecord, + AudioJsonValue, + AudioListenerId, + AudioSourceId, + AudioSourceState, + AudioSpatialization, + AudioVector3, +} from '../types'; + +export interface InternalBus { + readonly id: AudioBusId; + parentId?: AudioBusId; + readonly gainNode: GainNode; + readonly panNode?: StereoPannerNode; + readonly outputNode: AudioNode; + readonly childIds: Set; + volume: number; + mute: boolean; + pan: number; + metadata: Readonly>; +} + +export interface InternalListener { + readonly id: AudioListenerId; + active: boolean; + enabled: boolean; + position: AudioVector3; + forward: AudioVector3; + up: AudioVector3; + metadata: Readonly>; +} + +export interface InternalPlayback { + readonly sequence: number; + readonly sourceNode: AudioBufferSourceNode; + readonly gainNode: GainNode; + readonly attenuationNode: GainNode; + readonly spatialNode?: StereoPannerNode | PannerNode; + readonly outputNode: AudioNode; + readonly clip: AudioClipRecord; + readonly durationSeconds?: number; + readonly startOffsetSeconds: number; + readonly startedAtContextTime: number; + control: 'playing' | 'pausing' | 'stopping'; +} + +export interface InternalSource { + readonly id: AudioSourceId; + busId: AudioBusId; + clip?: import('../types').AudioClipSelector; + volume: number; + muted: boolean; + loop: boolean; + autoplay: boolean; + playbackRate: number; + detuneCents: number; + pan: number; + spatial?: AudioSpatialization; + startOffsetSeconds: number; + metadata: Readonly>; + playbackState: AudioSourceState['playbackState']; + currentOffsetSeconds: number; + durationSeconds?: number; + playSequence: number; + active?: InternalPlayback; +} diff --git a/web/packages/audio/src/internal/shared.ts b/web/packages/audio/src/internal/shared.ts new file mode 100644 index 00000000..5cd670ff --- /dev/null +++ b/web/packages/audio/src/internal/shared.ts @@ -0,0 +1,137 @@ +import { AudioUnavailableError } from '../errors'; +import { cloneAudioVector3 } from '../reference'; +import type { + AudioAssetSchema, + AudioJsonValue, + AudioRetryContext, + AudioRetryPolicy, + AudioVector3, +} from '../types'; + +export const DEFAULT_LISTENER_POSITION = Object.freeze({ + x: 0, + y: 0, + z: 0, +} satisfies AudioVector3); + +export const DEFAULT_LISTENER_FORWARD = Object.freeze({ + x: 0, + y: 0, + z: -1, +} satisfies AudioVector3); + +export const DEFAULT_LISTENER_UP = Object.freeze({ + x: 0, + y: 1, + z: 0, +} satisfies AudioVector3); + +export const DEFAULT_SOURCE_POSITION = DEFAULT_LISTENER_POSITION; +export const DEFAULT_SOURCE_ORIENTATION = DEFAULT_LISTENER_FORWARD; + +export const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +export const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +export const clamp = (value: number, min: number, max: number): number => + Math.min(max, Math.max(min, value)); + +export const cloneMetadata = ( + value: Readonly> | undefined +): Readonly> => Object.freeze({ ...(value ?? {}) }); + +export const normalizeVector3 = (value: AudioVector3 | undefined, fallback: AudioVector3): AudioVector3 => { + const next = cloneAudioVector3(value, fallback); + if (!isFiniteNumber(next.x) || !isFiniteNumber(next.y) || !isFiniteNumber(next.z)) { + throw new TypeError('Audio vector values must be finite'); + } + + return next; +}; + +export const effectivePlaybackRate = (playbackRate: number, detuneCents: number): number => + playbackRate * 2 ** (detuneCents / 1200); + +export const hasOwnKeys = (value: object): boolean => Object.keys(value).length > 0; + +export const resolveContextFactory = (): (() => AudioContext) => { + const GlobalAudioContext = + (globalThis as { AudioContext?: typeof AudioContext }).AudioContext ?? + (globalThis as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + + if (!GlobalAudioContext) { + throw new AudioUnavailableError('No AudioContext implementation is available'); + } + + return () => new GlobalAudioContext(); +}; + +export const sleep = async (ms: number): Promise => { + if (ms <= 0) { + return; + } + + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +}; + +export const disconnectNode = (node: AudioNode | undefined): void => { + if (!node) { + return; + } + + try { + node.disconnect(); + } catch {} +}; + +export const setParamValue = ( + param: AudioParam, + value: number, + atTime: number, + durationSeconds = 0 +): void => { + param.cancelScheduledValues(atTime); + if (durationSeconds > 0) { + param.setValueAtTime(param.value, atTime); + param.linearRampToValueAtTime(value, atTime + durationSeconds); + return; + } + + param.setValueAtTime(value, atTime); +}; + +export const withRetry = async < + TResult, + TSchema extends AudioAssetSchema = AudioAssetSchema, +>( + policy: AudioRetryPolicy | undefined, + contextFactory: (attempt: number) => AudioRetryContext, + operation: () => Promise +): Promise => { + const attempts = Math.max(1, policy?.attempts ?? 1); + let lastError: unknown; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return await operation(); + } catch (error) { + lastError = error; + const context = contextFactory(attempt); + const shouldRetry = attempt < attempts && (policy?.shouldRetry?.(error, context) ?? true); + if (!shouldRetry) { + throw error; + } + const backoff = + typeof policy?.backoffMs === 'function' + ? policy.backoffMs(attempt) + : (policy?.backoffMs ?? 0); + await sleep(backoff); + } + } + + throw lastError; +}; diff --git a/web/packages/audio/src/internal/source-registry.ts b/web/packages/audio/src/internal/source-registry.ts new file mode 100644 index 00000000..a7eda223 --- /dev/null +++ b/web/packages/audio/src/internal/source-registry.ts @@ -0,0 +1,253 @@ +import { toAudioClipSelector } from '../asset'; +import { AudioSourceError } from '../errors'; +import { + MASTER_AUDIO_BUS_ID, + normalizeAudioBusId, + normalizeAudioSourceId, +} from '../reference'; +import type { + AudioAssetSchema, + AudioBusId, + AudioSourceDefinition, + AudioSourceId, + AudioSourceState, +} from '../types'; +import type { InternalPlayback, InternalSource } from './runtime'; +import { + cloneMetadata, + isFiniteNumber, +} from './shared'; +import { cloneSpatialization } from './spatial'; + +export interface AudioSourceRegistryOptions { + readonly normalizeGain: ( + value: number, + code: 'audio.invalid-gain' | 'audio.invalid-distance' + ) => number; + readonly normalizePan: (value: number) => number; + readonly normalizePlaybackRate: (value: number) => number; + readonly normalizeTime: (value: number) => number; +} + +export class AudioSourceRegistry { + readonly #normalizeGain: AudioSourceRegistryOptions['normalizeGain']; + readonly #normalizePan: AudioSourceRegistryOptions['normalizePan']; + readonly #normalizePlaybackRate: AudioSourceRegistryOptions['normalizePlaybackRate']; + readonly #normalizeTime: AudioSourceRegistryOptions['normalizeTime']; + readonly #sources = new Map>(); + readonly #transientSources = new Set(); + + #sourceSequence = 0; + #oneShotSequence = 0; + + constructor(options: AudioSourceRegistryOptions) { + this.#normalizeGain = options.normalizeGain; + this.#normalizePan = options.normalizePan; + this.#normalizePlaybackRate = options.normalizePlaybackRate; + this.#normalizeTime = options.normalizeTime; + } + + nextOneShotId(): AudioSourceId { + this.#oneShotSequence += 1; + return normalizeAudioSourceId(`oneshot:${this.#oneShotSequence}`); + } + + markTransient(id: AudioSourceId): void { + this.#transientSources.add(id); + } + + isTransient(id: AudioSourceId): boolean { + return this.#transientSources.has(id); + } + + upsert( + definition: AudioSourceDefinition, + options: { + readonly requireBus: (id: string) => void; + readonly reconnectPlaybackOutput?: ( + playback: InternalPlayback, + nextBusId: string + ) => void; + } + ): InternalSource { + const id = + definition.id !== undefined + ? normalizeAudioSourceId(definition.id) + : this.#nextManagedId(); + let source = this.#sources.get(id); + + if (!source) { + source = { + id, + busId: normalizeAudioBusId(definition.busId ?? MASTER_AUDIO_BUS_ID), + clip: toAudioClipSelector(definition.clip), + volume: this.#normalizeGain(definition.volume ?? 1, 'audio.invalid-gain'), + muted: definition.muted ?? false, + loop: definition.loop ?? false, + autoplay: definition.autoplay ?? false, + playbackRate: this.#normalizePlaybackRate(definition.playbackRate ?? 1), + detuneCents: isFiniteNumber(definition.detuneCents) ? definition.detuneCents : 0, + pan: this.#normalizePan(definition.pan ?? 0), + spatial: cloneSpatialization(definition.spatial), + startOffsetSeconds: this.#normalizeTime(definition.startOffsetSeconds ?? 0), + metadata: cloneMetadata(definition.metadata), + playbackState: 'idle', + currentOffsetSeconds: this.#normalizeTime(definition.startOffsetSeconds ?? 0), + playSequence: 0, + }; + this.#sources.set(id, source); + } + + if (definition.busId !== undefined) { + const nextBusId = normalizeAudioBusId(definition.busId); + options.requireBus(nextBusId); + if (source.busId !== nextBusId) { + source.busId = nextBusId; + if (source.active) { + options.reconnectPlaybackOutput?.(source.active, nextBusId); + } + } + } + if (definition.clip !== undefined) { + source.clip = toAudioClipSelector(definition.clip); + } + if (definition.volume !== undefined) { + source.volume = this.#normalizeGain(definition.volume, 'audio.invalid-gain'); + } + if (definition.muted !== undefined) { + source.muted = definition.muted; + } + if (definition.loop !== undefined) { + source.loop = definition.loop; + } + if (definition.autoplay !== undefined) { + source.autoplay = definition.autoplay; + } + if (definition.playbackRate !== undefined) { + source.playbackRate = this.#normalizePlaybackRate(definition.playbackRate); + } + if (definition.detuneCents !== undefined && isFiniteNumber(definition.detuneCents)) { + source.detuneCents = definition.detuneCents; + } + if (definition.pan !== undefined) { + source.pan = this.#normalizePan(definition.pan); + } + if (definition.spatial !== undefined) { + source.spatial = cloneSpatialization(definition.spatial); + } + if (definition.startOffsetSeconds !== undefined) { + const offset = this.#normalizeTime(definition.startOffsetSeconds); + source.startOffsetSeconds = offset; + if (!source.active) { + source.currentOffsetSeconds = offset; + } + } + if (definition.metadata !== undefined) { + source.metadata = cloneMetadata(definition.metadata); + } + + return source; + } + + reassignBus( + previousBusId: AudioBusId | string, + nextBusId: AudioBusId | string, + reconnectPlaybackOutput: ( + playback: InternalPlayback, + nextBusId: string + ) => void + ): void { + const from = normalizeAudioBusId(previousBusId); + const to = normalizeAudioBusId(nextBusId); + + for (const source of this.#sources.values()) { + if (source.busId !== from) { + continue; + } + + source.busId = to; + if (source.active) { + reconnectPlaybackOutput(source.active, to); + } + } + } + + remove(id: AudioSourceId | string): InternalSource | undefined { + const normalizedId = normalizeAudioSourceId(id); + const source = this.#sources.get(normalizedId); + if (!source) { + return undefined; + } + + this.#transientSources.delete(normalizedId); + this.#sources.delete(normalizedId); + return source; + } + + require(id: AudioSourceId | string): InternalSource { + const normalizedId = normalizeAudioSourceId(id); + const source = this.#sources.get(normalizedId); + if (!source) { + throw new AudioSourceError( + 'audio.source.missing', + `Audio source ${normalizedId} does not exist`, + normalizedId + ); + } + return source; + } + + get(id: AudioSourceId | string): InternalSource | undefined { + return this.#sources.get(normalizeAudioSourceId(id)); + } + + list(): readonly InternalSource[] { + return Object.freeze([...this.#sources.values()]); + } + + values(): IterableIterator> { + return this.#sources.values(); + } + + clear(): readonly InternalSource[] { + const sources = [...this.#sources.values()]; + this.#sources.clear(); + this.#transientSources.clear(); + return Object.freeze(sources); + } + + #nextManagedId(): AudioSourceId { + let nextId: AudioSourceId; + do { + this.#sourceSequence += 1; + nextId = normalizeAudioSourceId(`source:${this.#sourceSequence}`); + } while (this.#sources.has(nextId)); + + return nextId; + } + + snapshot( + source: InternalSource, + resolveCurrentOffset: (source: InternalSource) => number + ): AudioSourceState { + return Object.freeze({ + id: source.id, + busId: source.busId, + clip: source.clip, + volume: source.volume, + muted: source.muted, + loop: source.loop, + autoplay: source.autoplay, + playbackRate: source.playbackRate, + detuneCents: source.detuneCents, + pan: source.pan, + spatial: source.spatial ? cloneSpatialization(source.spatial) : undefined, + startOffsetSeconds: source.startOffsetSeconds, + metadata: source.metadata, + playbackState: source.playbackState, + currentOffsetSeconds: resolveCurrentOffset(source), + durationSeconds: source.durationSeconds, + playSequence: source.playSequence, + }); + } +} diff --git a/web/packages/audio/src/internal/spatial.ts b/web/packages/audio/src/internal/spatial.ts new file mode 100644 index 00000000..2bdd7860 --- /dev/null +++ b/web/packages/audio/src/internal/spatial.ts @@ -0,0 +1,277 @@ +import { cloneAudioVector3 } from '../reference'; +import type { + Audio3DSpatialization, + AudioSpatialAttenuation, + AudioSpatialization, + AudioVector3, +} from '../types'; +import { + clamp, + DEFAULT_LISTENER_FORWARD, + DEFAULT_LISTENER_POSITION, + DEFAULT_LISTENER_UP, + isFiniteNumber, + normalizeVector3, +} from './shared'; + +export interface AudioSpatialListenerState { + readonly enabled: boolean; + readonly position: AudioVector3; + readonly forward: AudioVector3; + readonly up: AudioVector3; +} + +export interface AudioSpatialPlaybackNodes { + readonly gainNode: GainNode; + readonly attenuationNode: GainNode; + readonly spatialNode?: StereoPannerNode | PannerNode; +} + +export interface AudioSpatialSourceState { + readonly muted: boolean; + readonly volume: number; + readonly pan: number; + readonly spatial?: AudioSpatialization; +} + +export const DEFAULT_ATTENUATION = Object.freeze({ + model: 'inverse', + refDistance: 1, + maxDistance: 10000, + rolloffFactor: 1, + minGain: 0, +} satisfies Required); + +const isStereoPannerNode = (value: unknown): value is StereoPannerNode => + typeof StereoPannerNode !== 'undefined' && value instanceof StereoPannerNode; + +const isPannerNode = (value: unknown): value is PannerNode => + typeof PannerNode !== 'undefined' && value instanceof PannerNode; + +export const cloneSpatialization = ( + value: AudioSpatialization | undefined +): AudioSpatialization | undefined => { + if (!value) { + return undefined; + } + + if (value.mode === '2d') { + return { + mode: '2d', + position: value.position ? cloneAudioVector3(value.position) : undefined, + pan: value.pan, + attenuation: value.attenuation ? { ...value.attenuation } : undefined, + }; + } + + return { + mode: '3d', + position: value.position ? cloneAudioVector3(value.position) : undefined, + orientation: value.orientation ? cloneAudioVector3(value.orientation) : undefined, + attenuation: value.attenuation ? { ...value.attenuation } : undefined, + panningModel: value.panningModel, + coneInnerAngle: value.coneInnerAngle, + coneOuterAngle: value.coneOuterAngle, + coneOuterGain: value.coneOuterGain, + }; +}; + +export const normalizeAttenuation = ( + value: AudioSpatialAttenuation | undefined +): Required => ({ + model: value?.model ?? DEFAULT_ATTENUATION.model, + refDistance: isFiniteNumber(value?.refDistance) + ? Math.max(0.0001, value.refDistance) + : DEFAULT_ATTENUATION.refDistance, + maxDistance: isFiniteNumber(value?.maxDistance) + ? Math.max(0.0001, value.maxDistance) + : DEFAULT_ATTENUATION.maxDistance, + rolloffFactor: isFiniteNumber(value?.rolloffFactor) + ? Math.max(0, value.rolloffFactor) + : DEFAULT_ATTENUATION.rolloffFactor, + minGain: isFiniteNumber(value?.minGain) + ? clamp(value.minGain, 0, 1) + : DEFAULT_ATTENUATION.minGain, +}); + +export const attenuationGainForDistance = ( + distance: number, + value: AudioSpatialAttenuation | undefined +): number => { + const attenuation = normalizeAttenuation(value); + const ref = attenuation.refDistance; + const max = Math.max(ref, attenuation.maxDistance); + const rolloff = attenuation.rolloffFactor; + + let gain = 1; + switch (attenuation.model) { + case 'none': + gain = 1; + break; + case 'linear': + if (distance <= ref) { + gain = 1; + } else if (distance >= max) { + gain = attenuation.minGain; + } else { + gain = 1 - rolloff * ((distance - ref) / (max - ref)); + } + break; + case 'exponential': + gain = distance <= ref ? 1 : (distance / ref) ** -rolloff; + break; + case 'inverse': + default: + gain = distance <= ref ? 1 : ref / (ref + rolloff * (distance - ref)); + break; + } + + return clamp(gain, attenuation.minGain, 1); +}; + +const distance2D = (from: AudioVector3, to: AudioVector3): number => { + const dx = from.x - to.x; + const dy = from.y - to.y; + return Math.sqrt(dx * dx + dy * dy); +}; + +const distance3D = (from: AudioVector3, to: AudioVector3): number => { + const dx = from.x - to.x; + const dy = from.y - to.y; + const dz = from.z - to.z; + return Math.sqrt(dx * dx + dy * dy + dz * dz); +}; + +const effectivePanFor2D = ( + source: AudioVector3, + listener: AudioVector3, + pan: number, + attenuation: AudioSpatialAttenuation | undefined +): number => { + const normalized = normalizeAttenuation(attenuation); + const span = Math.max(normalized.refDistance, normalized.maxDistance, 1); + return clamp(pan + (source.x - listener.x) / span, -1, 1); +}; + +export const syncAudioListenerToContext = ( + audioListener: AudioListener, + target?: AudioSpatialListenerState +): void => { + const position = target?.position ?? DEFAULT_LISTENER_POSITION; + const forward = target?.forward ?? DEFAULT_LISTENER_FORWARD; + const up = target?.up ?? DEFAULT_LISTENER_UP; + + if ('positionX' in audioListener) { + audioListener.positionX.value = position.x; + audioListener.positionY.value = position.y; + audioListener.positionZ.value = position.z; + audioListener.forwardX.value = forward.x; + audioListener.forwardY.value = forward.y; + audioListener.forwardZ.value = forward.z; + audioListener.upX.value = up.x; + audioListener.upY.value = up.y; + audioListener.upZ.value = up.z; + return; + } + + (audioListener as AudioListener & { + setPosition?: (x: number, y: number, z: number) => void; + setOrientation?: ( + fx: number, + fy: number, + fz: number, + ux: number, + uy: number, + uz: number + ) => void; + }).setPosition?.(position.x, position.y, position.z); + (audioListener as AudioListener & { + setPosition?: (x: number, y: number, z: number) => void; + setOrientation?: ( + fx: number, + fy: number, + fz: number, + ux: number, + uy: number, + uz: number + ) => void; + }).setOrientation?.(forward.x, forward.y, forward.z, up.x, up.y, up.z); +}; + +export const applyPannerState = ( + panner: PannerNode, + spatial: Audio3DSpatialization, + position: AudioVector3, + orientation: AudioVector3 +): void => { + panner.panningModel = spatial.panningModel ?? 'HRTF'; + panner.distanceModel = 'inverse'; + panner.refDistance = 1; + panner.maxDistance = 1000000; + panner.rolloffFactor = 0; + panner.coneInnerAngle = spatial.coneInnerAngle ?? 360; + panner.coneOuterAngle = spatial.coneOuterAngle ?? 360; + panner.coneOuterGain = spatial.coneOuterGain ?? 0; + + if ('positionX' in panner) { + panner.positionX.value = position.x; + panner.positionY.value = position.y; + panner.positionZ.value = position.z; + panner.orientationX.value = orientation.x; + panner.orientationY.value = orientation.y; + panner.orientationZ.value = orientation.z; + return; + } + + (panner as PannerNode & { + setPosition?: (x: number, y: number, z: number) => void; + setOrientation?: (x: number, y: number, z: number) => void; + }).setPosition?.(position.x, position.y, position.z); + (panner as PannerNode & { + setPosition?: (x: number, y: number, z: number) => void; + setOrientation?: (x: number, y: number, z: number) => void; + }).setOrientation?.(orientation.x, orientation.y, orientation.z); +}; + +export const syncPlaybackSpatialState = ( + playback: AudioSpatialPlaybackNodes, + source: AudioSpatialSourceState, + listener?: AudioSpatialListenerState +): void => { + playback.gainNode.gain.value = source.muted ? 0 : source.volume; + + if (!source.spatial) { + playback.attenuationNode.gain.value = 1; + if (isStereoPannerNode(playback.spatialNode)) { + playback.spatialNode.pan.value = source.pan; + } + return; + } + + if (source.spatial.mode === '2d') { + const position = normalizeVector3(source.spatial.position, DEFAULT_LISTENER_POSITION); + const listenerPosition = listener?.position ?? DEFAULT_LISTENER_POSITION; + playback.attenuationNode.gain.value = listener?.enabled + ? attenuationGainForDistance(distance2D(position, listenerPosition), source.spatial.attenuation) + : 1; + if (isStereoPannerNode(playback.spatialNode)) { + playback.spatialNode.pan.value = effectivePanFor2D( + position, + listenerPosition, + source.pan + (source.spatial.pan ?? 0), + source.spatial.attenuation + ); + } + return; + } + + const position = normalizeVector3(source.spatial.position, DEFAULT_LISTENER_POSITION); + const orientation = normalizeVector3(source.spatial.orientation, DEFAULT_LISTENER_FORWARD); + const listenerPosition = listener?.position ?? DEFAULT_LISTENER_POSITION; + playback.attenuationNode.gain.value = listener?.enabled + ? attenuationGainForDistance(distance3D(position, listenerPosition), source.spatial.attenuation) + : 1; + if (isPannerNode(playback.spatialNode)) { + applyPannerState(playback.spatialNode, source.spatial, position, orientation); + } +}; diff --git a/web/packages/audio/src/reference.ts b/web/packages/audio/src/reference.ts new file mode 100644 index 00000000..ca03c81e --- /dev/null +++ b/web/packages/audio/src/reference.ts @@ -0,0 +1,67 @@ +import { createAssetReference, isAssetReference } from '@axrone/asset-core'; +import type { AssetId, AssetRecord, AssetReference } from '@axrone/asset-core'; +import type { + AudioAssetSchema, + AudioBusId, + AudioClipId, + AudioListenerId, + AudioSnapshotId, + AudioSourceId, + AudioVector3, +} from './types'; + +const normalizeIdentifier = (value: string, label: string): string => { + const normalized = value.trim(); + if (normalized.length === 0) { + throw new TypeError(`${label} cannot be empty`); + } + + return normalized; +}; + +export const asAudioBusId = (value: string): AudioBusId => value as AudioBusId; +export const asAudioClipId = (value: string): AudioClipId => value as AudioClipId; +export const asAudioListenerId = (value: string): AudioListenerId => value as AudioListenerId; +export const asAudioSourceId = (value: string): AudioSourceId => value as AudioSourceId; +export const asAudioSnapshotId = (value: string): AudioSnapshotId => value as AudioSnapshotId; + +export const MASTER_AUDIO_BUS_ID = asAudioBusId('master'); + +export const normalizeAudioBusId = (value: string | AudioBusId): AudioBusId => + asAudioBusId(normalizeIdentifier(String(value), 'Audio bus id')); + +export const normalizeAudioClipId = (value: string | AudioClipId): AudioClipId => + asAudioClipId(normalizeIdentifier(String(value), 'Audio clip id')); + +export const normalizeAudioListenerId = (value: string | AudioListenerId): AudioListenerId => + asAudioListenerId(normalizeIdentifier(String(value), 'Audio listener id')); + +export const normalizeAudioSourceId = (value: string | AudioSourceId): AudioSourceId => + asAudioSourceId(normalizeIdentifier(String(value), 'Audio source id')); + +export const normalizeAudioSnapshotId = (value: string | AudioSnapshotId): AudioSnapshotId => + asAudioSnapshotId(normalizeIdentifier(String(value), 'Audio snapshot id')); + +export const createAudioClipAssetReference = (id: AssetId): AssetReference<'audioClip'> => + createAssetReference('audioClip', id); + +export const isAudioClipAssetReference = (value: unknown): value is AssetReference<'audioClip'> => + isAssetReference(value) && value.kind === 'audioClip'; + +export const isAudioClipAssetRecord = ( + value: unknown +): value is AssetRecord => + typeof value === 'object' && + value !== null && + 'kind' in value && + (value as AssetRecord).kind === 'audioClip' && + 'data' in value; + +export const cloneAudioVector3 = (value: AudioVector3 | undefined, fallback?: AudioVector3): AudioVector3 => { + const source = value ?? fallback ?? { x: 0, y: 0, z: 0 }; + return { + x: Number(source.x) || 0, + y: Number(source.y) || 0, + z: Number(source.z) || 0, + }; +}; \ No newline at end of file diff --git a/web/packages/audio/src/system.ts b/web/packages/audio/src/system.ts new file mode 100644 index 00000000..b39d1109 --- /dev/null +++ b/web/packages/audio/src/system.ts @@ -0,0 +1,997 @@ +import { + AudioConfigurationError, + DEFAULT_AUDIO_MESSAGE_RESOLVER, + AudioDisposedError, + AudioLifecycleError, + AudioSnapshotError, + AudioSourceError, + resolveAudioMessage, +} from './errors'; +import { toAudioClipSelector } from './asset'; +import { AudioBusRegistry } from './internal/bus-registry'; +import { AudioClipStore } from './internal/clip-store'; +import { AudioListenerRegistry } from './internal/listener-registry'; +import { AudioObservabilityRuntime } from './internal/observability'; +import { AudioPlaybackRuntime } from './internal/playback-runtime'; +import type { + InternalPlayback, + InternalSource, +} from './internal/runtime'; +import { AudioSourceRegistry } from './internal/source-registry'; +import { + clamp, + effectivePlaybackRate, + hasOwnKeys, + isFiniteNumber, + isObject, + resolveContextFactory, + withRetry, +} from './internal/shared'; +import { + MASTER_AUDIO_BUS_ID, + normalizeAudioBusId, + normalizeAudioListenerId, + normalizeAudioSnapshotId, + normalizeAudioSourceId, +} from './reference'; +import type { + AudioAssetSchema, + AudioBusDefinition, + AudioBusId, + AudioBusPatch, + AudioBusState, + AudioClipId, + AudioClipInput, + AudioClipRecord, + AudioDiagnosticsSnapshot, + AudioEventEmitter, + AudioListenerDescriptor, + AudioListenerId, + AudioListenerPatch, + AudioListenerState, + AudioMessageDescriptor, + AudioMessageResolver, + AudioMixerSnapshot, + AudioPlaybackHandle, + AudioRestoreOptions, + AudioRetryPolicy, + AudioSnapshotTransitionOptions, + AudioSourceDefinition, + AudioSourceId, + AudioSourcePatch, + AudioSourcePlayRequest, + AudioSourceState, + AudioStopOptions, + AudioSystemOptions, + AudioSystemSnapshot, + AudioSystemStatus, +} from './types'; + +type AudioPatchToDefinition = Partial; + +export class AudioSystem { + readonly context: AudioContext; + readonly destination: AudioNode; + + readonly #messageResolver: AudioMessageResolver; + readonly #locale: string; + readonly #autoResume: boolean; + readonly #resumeRetryPolicy?: AudioRetryPolicy; + readonly #ownsContext: boolean; + readonly #clips: AudioClipStore; + readonly #buses: AudioBusRegistry; + readonly #listeners = new AudioListenerRegistry(); + readonly #observability = new AudioObservabilityRuntime(); + readonly #playbackRuntime: AudioPlaybackRuntime; + readonly #sources: AudioSourceRegistry; + + #playSequence = 0; + #status: AudioSystemStatus = 'idle'; + #disposed = false; + + constructor(options: AudioSystemOptions = {}) { + const ownsContext = !options.context; + const context = options.context ?? (options.createContext ?? resolveContextFactory())(); + this.context = context; + this.destination = options.destination ?? context.destination; + this.#ownsContext = ownsContext; + this.#messageResolver = options.messageResolver ?? DEFAULT_AUDIO_MESSAGE_RESOLVER; + this.#locale = options.locale ?? 'en'; + this.#autoResume = options.autoResume ?? true; + this.#resumeRetryPolicy = options.resumeRetryPolicy; + + this.#clips = new AudioClipStore({ + context, + assetDatabase: options.assetDatabase, + assetResolver: options.assetResolver, + retryPolicy: options.assetRetryPolicy, + }); + this.#playbackRuntime = new AudioPlaybackRuntime(context); + this.#buses = new AudioBusRegistry({ + context, + destination: this.destination, + createConfigurationError: (descriptor) => this.#configurationError(descriptor), + normalizeGain: (value, code) => this.#normalizeGain(value, code), + normalizePan: (value) => this.#normalizePan(value), + }); + this.#sources = new AudioSourceRegistry({ + normalizeGain: (value, code) => this.#normalizeGain(value, code), + normalizePan: (value) => this.#normalizePan(value), + normalizePlaybackRate: (value) => this.#normalizePlaybackRate(value), + normalizeTime: (value) => this.#normalizeTime(value), + }); + + if (options.buses?.length) { + this.#buses.initialize(options.buses); + } + + if (options.listeners) { + for (const listener of options.listeners) { + this.upsertListener(listener); + } + } + + if (options.sources) { + for (const source of options.sources) { + this.upsertSource(source); + } + } + + this.#syncListenerToContext(); + } + + get status(): AudioSystemStatus { + return this.#status; + } + + get isDisposed(): boolean { + return this.#disposed; + } + + get activeListener(): AudioListenerState | undefined { + const activeListenerId = this.#listeners.activeListenerId; + return activeListenerId ? this.#listeners.get(activeListenerId) : undefined; + } + + get events(): AudioEventEmitter { + return this.#observability.events; + } + + getDiagnostics(): AudioDiagnosticsSnapshot { + return this.#observability.snapshot({ + systemStatus: this.#status, + activeListenerId: this.#listeners.activeListenerId, + busCount: this.listBuses().length, + listenerCount: this.listListeners().length, + sourceCount: this.listSources().length, + activePlaybackCount: [...this.#sources.values()].filter((source) => !!source.active).length, + }); + } + + resetDiagnostics(): void { + this.#observability.reset(); + } + + registerClip(id: AudioClipId | string, clip: AudioClipInput): AudioClipId { + this.#assertNotDisposed(); + const selector = toAudioClipSelector(clip); + if (!selector) { + throw this.#configurationError({ code: 'audio.invalid-clip', value: clip }); + } + + return this.#clips.register(id, selector); + } + + unregisterClip(id: AudioClipId | string): boolean { + this.#assertNotDisposed(); + return this.#clips.unregister(id); + } + + async resolveClip(selector: AudioClipInput): Promise> { + this.#assertNotDisposed(); + const normalized = toAudioClipSelector(selector); + if (!normalized) { + throw this.#configurationError({ code: 'audio.invalid-clip', value: selector }); + } + + return this.#clips.resolveSelector(normalized); + } + + upsertBus(definition: AudioBusDefinition): AudioBusState { + this.#assertNotDisposed(); + const state = this.#buses.upsert(definition); + this.#syncAllSources(); + this.#emit({ + type: 'bus:upserted', + contextTime: this.context.currentTime, + systemStatus: this.#status, + bus: state, + }); + return state; + } + + updateBus(id: AudioBusId | string, patch: AudioBusPatch): AudioBusState { + return this.upsertBus({ + id, + ...(patch as AudioPatchToDefinition>), + }); + } + + removeBus(id: AudioBusId | string): boolean { + this.#assertNotDisposed(); + const removed = this.#buses.remove(id); + if (!removed.removed || !removed.fallbackBusId) { + return false; + } + + this.#sources.reassignBus(id, removed.fallbackBusId, (playback, nextBusId) => { + this.#reconnectPlaybackOutput(playback, nextBusId); + }); + this.#syncAllSources(); + this.#emit({ + type: 'bus:removed', + contextTime: this.context.currentTime, + systemStatus: this.#status, + busId: this.#coerceBusId(id), + fallbackBusId: removed.fallbackBusId, + }); + return true; + } + + getBus(id: AudioBusId | string): AudioBusState | undefined { + return this.#buses.get(id); + } + + listBuses(): readonly AudioBusState[] { + return this.#buses.list(); + } + + upsertListener(descriptor: AudioListenerDescriptor): AudioListenerState { + this.#assertNotDisposed(); + const state = this.#listeners.upsert(descriptor); + this.#syncListenerToContext(); + this.#syncAllSources(); + this.#emit({ + type: 'listener:upserted', + contextTime: this.context.currentTime, + systemStatus: this.#status, + listener: state, + }); + return state; + } + + updateListener(id: AudioListenerId | string, patch: AudioListenerPatch): AudioListenerState { + return this.upsertListener({ + id, + ...(patch as AudioPatchToDefinition>), + }); + } + + removeListener(id: AudioListenerId | string): boolean { + this.#assertNotDisposed(); + const removed = this.#listeners.remove(id); + if (!removed) { + return false; + } + + this.#syncListenerToContext(); + this.#syncAllSources(); + this.#emit({ + type: 'listener:removed', + contextTime: this.context.currentTime, + systemStatus: this.#status, + listenerId: this.#coerceListenerId(id), + }); + return true; + } + + setActiveListener(id: AudioListenerId | string): void { + this.#assertNotDisposed(); + this.#listeners.setActive(id); + this.#syncListenerToContext(); + this.#syncAllSources(); + this.#emit({ + type: 'listener:activated', + contextTime: this.context.currentTime, + systemStatus: this.#status, + listener: this.#listeners.get(id)!, + }); + } + + getListener(id: AudioListenerId | string): AudioListenerState | undefined { + return this.#listeners.get(id); + } + + listListeners(): readonly AudioListenerState[] { + return this.#listeners.list(); + } + + upsertSource(definition: AudioSourceDefinition): AudioSourceState { + this.#assertNotDisposed(); + const source = this.#sources.upsert(definition, { + requireBus: (id) => { + this.#buses.require(id); + }, + reconnectPlaybackOutput: (playback, nextBusId) => { + this.#reconnectPlaybackOutput(playback, nextBusId); + }, + }); + + this.#syncSource(source); + if (source.autoplay && source.playbackState === 'idle' && source.clip) { + void this.playSource(source.id).catch(() => undefined); + } + + const state = this.#snapshotSource(source); + this.#emit({ + type: 'source:upserted', + contextTime: this.context.currentTime, + systemStatus: this.#status, + source: state, + }); + return state; + } + + updateSource(id: AudioSourceId | string, patch: AudioSourcePatch): AudioSourceState { + return this.upsertSource({ + id, + ...(patch as AudioPatchToDefinition, 'id'>>), + }); + } + + removeSource(id: AudioSourceId | string): boolean { + this.#assertNotDisposed(); + const source = this.#sources.get(id); + if (!source) { + return false; + } + + this.stopSource(id); + this.#disposePlayback(source); + const removedState = this.#snapshotSource(source); + this.#sources.remove(id); + this.#emit({ + type: 'source:removed', + contextTime: this.context.currentTime, + systemStatus: this.#status, + source: removedState, + }); + return true; + } + + getSource(id: AudioSourceId | string): AudioSourceState | undefined { + const source = this.#sources.get(id); + return source ? this.#snapshotSource(source) : undefined; + } + + listSources(): readonly AudioSourceState[] { + return Object.freeze([...this.#sources.values()].map((source) => this.#snapshotSource(source))); + } + + async playSource( + id: AudioSourceId | string, + request: AudioSourcePlayRequest = {} + ): Promise { + return this.#playSourceInternal(id, request, 'source:played', 'play'); + } + + async #playSourceInternal( + id: AudioSourceId | string, + request: AudioSourcePlayRequest, + successEventType: 'source:played' | 'source:resumed', + operation: 'play' | 'resume' + ): Promise { + this.#assertNotDisposed(); + const sourceId = normalizeAudioSourceId(id); + const source = this.#sources.require(sourceId); + + const { when, offsetSeconds, durationSeconds, replace, ...patch } = request; + if (hasOwnKeys(patch)) { + this.upsertSource({ + id: sourceId, + ...(patch as AudioPatchToDefinition, 'id'>>), + }); + } + + if (!source.clip) { + throw this.#configurationError({ code: 'audio.invalid-clip', value: source.clip }); + } + + if (this.#autoResume && this.context.state === 'suspended') { + await this.resume(); + } + + if (source.active) { + if (replace === false) { + return this.#createPlaybackHandle(sourceId, source.playSequence); + } + this.stopSource(sourceId); + } + + const clip = await this.#clips.resolveSelector(source.clip); + const bus = this.#buses.require(source.busId); + const normalizedOffset = this.#normalizeOffset( + offsetSeconds ?? source.currentOffsetSeconds, + clip, + source.loop + ); + const normalizedWhen = + when !== undefined ? this.#normalizeTime(when) : this.context.currentTime; + const normalizedDuration = + durationSeconds !== undefined ? this.#normalizeTime(durationSeconds) : undefined; + const sequence = ++this.#playSequence; + + try { + source.active = this.#playbackRuntime.startPlayback(source, { + sequence, + clip, + busNode: bus.gainNode, + when: normalizedWhen, + offsetSeconds: normalizedOffset, + durationSeconds: normalizedDuration, + onEnded: () => { + this.#handleEnded(source.id, sequence); + }, + }); + } catch (error) { + source.playbackState = 'stopped'; + this.#emit({ + type: 'source:error', + contextTime: this.context.currentTime, + systemStatus: this.#status, + operation, + sourceId, + reason: error, + source: this.getSource(sourceId), + }); + throw new AudioSourceError( + 'audio.source.play-failed', + `Failed to play audio source ${sourceId}`, + sourceId, + { cause: error instanceof Error ? error : new Error(String(error)) } + ); + } + + source.playSequence = sequence; + source.playbackState = 'playing'; + source.currentOffsetSeconds = normalizedOffset; + source.durationSeconds = clip.durationSeconds; + + this.#syncSource(source); + this.#emit({ + type: successEventType, + contextTime: this.context.currentTime, + systemStatus: 'running', + source: this.#snapshotSource(source), + }); + + this.#status = 'running'; + return this.#createPlaybackHandle(source.id, sequence); + } + + async play( + request: AudioSourceDefinition & AudioSourcePlayRequest + ): Promise { + const id = normalizeAudioSourceId(request.id ?? this.#sources.nextOneShotId()); + this.#sources.markTransient(id); + this.upsertSource({ ...request, id, autoplay: false }); + return this.playSource(id, request); + } + + pauseSource(id: AudioSourceId | string): void { + this.#assertNotDisposed(); + const source = this.#sources.require(id); + if (!source.active) { + source.playbackState = 'paused'; + this.#emit({ + type: 'source:paused', + contextTime: this.context.currentTime, + systemStatus: this.#status, + source: this.#snapshotSource(source), + }); + return; + } + + source.currentOffsetSeconds = this.#currentOffsetForSource(source); + source.playbackState = 'paused'; + source.active.control = 'pausing'; + this.#emit({ + type: 'source:paused', + contextTime: this.context.currentTime, + systemStatus: this.#status, + source: this.#snapshotSource(source), + }); + try { + source.active.sourceNode.stop(); + } catch {} + } + + async resumeSource(id: AudioSourceId | string): Promise { + this.#assertNotDisposed(); + const source = this.#sources.require(id); + if (source.playbackState === 'playing') { + return this.#createPlaybackHandle(source.id, source.playSequence); + } + if (!source.clip) { + throw this.#configurationError({ code: 'audio.invalid-clip', value: source.clip }); + } + + return withRetry( + this.#resumeRetryPolicy, + (attempt) => ({ + operation: 'source.resume', + attempt, + sourceId: source.id, + clip: source.clip, + }), + async () => + this.#playSourceInternal( + source.id, + { + offsetSeconds: source.currentOffsetSeconds, + replace: true, + }, + 'source:resumed', + 'resume' + ) + ); + } + + stopSource(id: AudioSourceId | string, options: AudioStopOptions = {}): void { + this.#assertNotDisposed(); + const source = this.#sources.require(id); + source.currentOffsetSeconds = 0; + source.playbackState = 'stopped'; + this.#emit({ + type: 'source:stopped', + contextTime: this.context.currentTime, + systemStatus: this.#status, + source: this.#snapshotSource(source), + }); + if (!source.active) { + return; + } + + source.active.control = 'stopping'; + try { + if (options.when !== undefined) { + source.active.sourceNode.stop(this.#normalizeTime(options.when)); + } else { + source.active.sourceNode.stop(); + } + } catch {} + } + + captureMixerSnapshot(id?: string): AudioMixerSnapshot { + this.#assertNotDisposed(); + const snapshot = this.#buses.captureSnapshot(id ? normalizeAudioSnapshotId(id) : undefined); + this.#emit({ + type: 'snapshot:captured', + contextTime: this.context.currentTime, + systemStatus: this.#status, + snapshotKind: 'mixer', + snapshotId: snapshot.id ? normalizeAudioSnapshotId(snapshot.id) : undefined, + busCount: snapshot.buses.length, + listenerCount: this.listListeners().length, + sourceCount: this.listSources().length, + }); + return snapshot; + } + + applyMixerSnapshot( + snapshot: AudioMixerSnapshot, + options: AudioSnapshotTransitionOptions = {} + ): void { + this.#assertNotDisposed(); + if (!isAudioMixerSnapshot(snapshot)) { + throw new AudioSnapshotError('Mixer snapshot is invalid'); + } + + const atTime = + options.atTime !== undefined ? this.#normalizeTime(options.atTime) : this.context.currentTime; + const durationSeconds = + options.durationSeconds !== undefined + ? this.#normalizeTime(options.durationSeconds) + : 0; + + this.#buses.applySnapshot(snapshot, { atTime, durationSeconds }); + } + + snapshot(): AudioSystemSnapshot { + this.#assertNotDisposed(); + const snapshot = Object.freeze({ + version: 1, + status: this.#status === 'disposed' ? 'idle' : this.#status, + capturedAtEpochMs: Date.now(), + activeListenerId: this.#listeners.activeListenerId, + buses: Object.freeze(this.listBuses()), + listeners: Object.freeze(this.listListeners()), + sources: Object.freeze(this.listSources()), + }); + this.#emit({ + type: 'snapshot:captured', + contextTime: this.context.currentTime, + systemStatus: this.#status, + snapshotKind: 'system', + snapshotId: undefined, + busCount: snapshot.buses.length, + listenerCount: snapshot.listeners.length, + sourceCount: snapshot.sources.length, + }); + return snapshot; + } + + async restore(snapshot: AudioSystemSnapshot, options: AudioRestoreOptions = {}): Promise { + this.#assertNotDisposed(); + if (!isAudioSystemSnapshot(snapshot)) { + throw new AudioSnapshotError('Audio system snapshot is invalid'); + } + + if (options.clearExisting ?? true) { + this.#clearSources(); + this.#listeners.clear(); + this.#buses.clear(); + } + + const additionalBuses = snapshot.buses.filter((bus) => bus.id !== MASTER_AUDIO_BUS_ID); + for (const bus of additionalBuses) { + this.upsertBus({ + id: bus.id, + volume: bus.volume, + mute: bus.mute, + pan: bus.pan, + metadata: bus.metadata, + }); + } + for (const bus of additionalBuses) { + if (bus.parentId) { + this.upsertBus({ id: bus.id, parentId: bus.parentId }); + } + } + + const master = snapshot.buses.find((bus) => bus.id === MASTER_AUDIO_BUS_ID); + if (master) { + this.upsertBus({ + id: MASTER_AUDIO_BUS_ID, + volume: master.volume, + mute: master.mute, + pan: master.pan, + metadata: master.metadata, + }); + } + + for (const listener of snapshot.listeners) { + this.upsertListener(listener); + } + if (snapshot.activeListenerId) { + this.setActiveListener(snapshot.activeListenerId); + } + + for (const source of snapshot.sources) { + this.upsertSource({ + id: source.id, + busId: source.busId, + clip: source.clip, + volume: source.volume, + muted: source.muted, + loop: source.loop, + autoplay: false, + playbackRate: source.playbackRate, + detuneCents: source.detuneCents, + pan: source.pan, + spatial: source.spatial, + startOffsetSeconds: source.currentOffsetSeconds, + metadata: source.metadata, + }); + + const restored = this.#sources.require(source.id); + restored.playbackState = source.playbackState; + restored.currentOffsetSeconds = source.currentOffsetSeconds; + restored.durationSeconds = source.durationSeconds; + } + + if (options.transition) { + this.applyMixerSnapshot(this.captureMixerSnapshot(), options.transition); + } + + if (options.restorePlayback ?? true) { + for (const source of snapshot.sources) { + if (source.playbackState === 'playing') { + await this.playSource(source.id, { + offsetSeconds: source.currentOffsetSeconds, + replace: true, + }); + } + } + } + + this.#status = snapshot.status; + this.#emit({ + type: 'snapshot:restored', + contextTime: this.context.currentTime, + systemStatus: this.#status, + snapshotId: undefined, + busCount: snapshot.buses.length, + listenerCount: snapshot.listeners.length, + sourceCount: snapshot.sources.length, + restorePlayback: options.restorePlayback ?? true, + }); + } + + async suspend(): Promise { + this.#assertNotDisposed(); + await withRetry( + this.#resumeRetryPolicy, + (attempt) => ({ operation: 'context.suspend', attempt }), + async () => { + try { + await this.context.suspend(); + this.#status = 'suspended'; + this.#emit({ + type: 'system:suspended', + contextTime: this.context.currentTime, + systemStatus: this.#status, + }); + } catch (error) { + throw new AudioLifecycleError( + 'audio.context.suspend-failed', + 'Failed to suspend audio context', + { + cause: error instanceof Error ? error : new Error(String(error)), + } + ); + } + } + ); + } + + async resume(): Promise { + this.#assertNotDisposed(); + await withRetry( + this.#resumeRetryPolicy, + (attempt) => ({ operation: 'context.resume', attempt }), + async () => { + try { + await this.context.resume(); + this.#status = 'running'; + this.#emit({ + type: 'system:resumed', + contextTime: this.context.currentTime, + systemStatus: this.#status, + }); + } catch (error) { + throw new AudioLifecycleError( + 'audio.context.resume-failed', + 'Failed to resume audio context', + { + cause: error instanceof Error ? error : new Error(String(error)), + } + ); + } + } + ); + } + + async dispose(): Promise { + if (this.#disposed) { + return; + } + + this.#clearSources(); + this.#listeners.clear(); + this.#buses.clear(); + this.#clips.clear(); + this.#disposed = true; + this.#status = 'disposed'; + this.#emit({ + type: 'system:disposed', + contextTime: this.context.currentTime, + systemStatus: this.#status, + }); + + if (this.#ownsContext) { + try { + await this.context.close(); + } catch {} + } + } + + refreshSpatialAudio(): void { + this.#assertNotDisposed(); + this.#syncListenerToContext(); + this.#syncAllSources(); + } + + #clearSources(): void { + for (const source of this.#sources.values()) { + this.stopSource(source.id); + this.#disposePlayback(source); + } + this.#sources.clear(); + } + + #syncListenerToContext(): void { + this.#listeners.syncToContext(this.context.listener); + } + + #syncSource(source: InternalSource): void { + this.#playbackRuntime.syncPlayback(source, this.#listeners.activeRuntime()); + } + + #syncAllSources(): void { + for (const source of this.#sources.values()) { + this.#syncSource(source); + } + } + + #reconnectPlaybackOutput(playback: InternalPlayback, nextBusId: string): void { + this.#playbackRuntime.reconnectPlayback(playback, this.#buses.require(nextBusId).gainNode); + } + + #currentOffsetForSource(source: InternalSource): number { + if (!source.active) { + return source.currentOffsetSeconds; + } + + const elapsed = Math.max(0, this.context.currentTime - source.active.startedAtContextTime); + const rate = effectivePlaybackRate(source.playbackRate, source.detuneCents); + const progressed = source.active.startOffsetSeconds + elapsed * rate; + return this.#normalizeOffset(progressed, source.active.clip, source.loop); + } + + #normalizeOffset(offsetSeconds: number, clip: AudioClipRecord, loop: boolean): number { + const duration = Math.max(clip.durationSeconds, 0.0001); + const normalizedOffset = this.#normalizeTime(offsetSeconds); + if (!loop) { + return clamp(normalizedOffset, 0, duration); + } + + const loopStart = clip.loopStartSeconds ?? 0; + const loopEnd = clip.loopEndSeconds ?? duration; + const loopDuration = Math.max(loopEnd - loopStart, 0.0001); + if (normalizedOffset < loopStart) { + return normalizedOffset; + } + return loopStart + ((normalizedOffset - loopStart) % loopDuration); + } + + #handleEnded(id: AudioSourceId, sequence: number): void { + const source = this.#sources.get(id); + if (!source || !source.active || source.active.sequence !== sequence) { + return; + } + + const control = source.active.control; + this.#disposePlayback(source); + if (control === 'pausing') { + source.playbackState = 'paused'; + return; + } + if (control === 'stopping') { + source.playbackState = 'stopped'; + source.currentOffsetSeconds = 0; + this.#emit({ + type: 'source:ended', + contextTime: this.context.currentTime, + systemStatus: this.#status, + source: this.#snapshotSource(source), + }); + if (this.#sources.isTransient(id)) { + this.#sources.remove(id); + } + return; + } + + source.playbackState = 'stopped'; + source.currentOffsetSeconds = source.loop ? source.currentOffsetSeconds : 0; + this.#emit({ + type: 'source:ended', + contextTime: this.context.currentTime, + systemStatus: this.#status, + source: this.#snapshotSource(source), + }); + if (this.#sources.isTransient(id)) { + this.#sources.remove(id); + } + } + + #disposePlayback(source: InternalSource): void { + if (!source.active) { + return; + } + + this.#playbackRuntime.disposePlayback(source.active); + source.active = undefined; + } + + #snapshotSource(source: InternalSource): AudioSourceState { + return this.#sources.snapshot(source, (candidate) => + candidate.active ? this.#currentOffsetForSource(candidate) : candidate.currentOffsetSeconds + ); + } + + #normalizeGain(value: number, code: 'audio.invalid-gain' | 'audio.invalid-distance'): number { + if (!isFiniteNumber(value) || value < 0) { + throw this.#configurationError({ code, value }); + } + return value; + } + + #normalizePan(value: number): number { + if (!isFiniteNumber(value)) { + throw this.#configurationError({ code: 'audio.invalid-pan', value }); + } + return clamp(value, -1, 1); + } + + #normalizePlaybackRate(value: number): number { + if (!isFiniteNumber(value) || value <= 0) { + throw this.#configurationError({ code: 'audio.invalid-playback-rate', value }); + } + return value; + } + + #normalizeTime(value: number): number { + if (!isFiniteNumber(value) || value < 0) { + throw this.#configurationError({ code: 'audio.invalid-time', value }); + } + return value; + } + + #configurationError(descriptor: AudioMessageDescriptor): AudioConfigurationError { + return new AudioConfigurationError( + descriptor.code as never, + resolveAudioMessage(descriptor, this.#locale, this.#messageResolver) + ); + } + + #emit( + event: Parameters['emit']>[0] + ): void { + this.#observability.emit(event); + } + + #coerceBusId(id: AudioBusId | string): AudioBusId { + return normalizeAudioBusId(id); + } + + #coerceListenerId(id: AudioListenerId | string): AudioListenerId { + return normalizeAudioListenerId(id); + } + + #assertNotDisposed(): void { + if (this.#disposed) { + throw new AudioDisposedError('Audio system has already been disposed'); + } + } + + #createPlaybackHandle(sourceId: AudioSourceId, sequence: number): AudioPlaybackHandle { + return Object.freeze({ + sourceId, + sequence, + stop: (options?: AudioStopOptions) => { + this.stopSource(sourceId, options); + }, + pause: () => { + this.pauseSource(sourceId); + }, + resume: () => this.resumeSource(sourceId).then(() => undefined), + }); + } +} + +export const createAudioSystem = ( + options: AudioSystemOptions = {} +): AudioSystem => new AudioSystem(options); + +export const isAudioMixerSnapshot = (value: unknown): value is AudioMixerSnapshot => + isObject(value) && Array.isArray(value.buses); + +export const isAudioSystemSnapshot = ( + value: unknown +): value is AudioSystemSnapshot => + isObject(value) && + value.version === 1 && + Array.isArray(value.buses) && + Array.isArray(value.listeners) && + Array.isArray(value.sources); diff --git a/web/packages/audio/src/types.ts b/web/packages/audio/src/types.ts new file mode 100644 index 00000000..eacee096 --- /dev/null +++ b/web/packages/audio/src/types.ts @@ -0,0 +1,653 @@ +import type { AssetDatabase, AssetRecord, AssetSchema, AssetSelector } from '@axrone/asset-core'; +import type { IEventEmitter } from '@axrone/event'; +import type { IVec3Like } from '@axrone/numeric'; +import type { + Brand, + JsonArray, + JsonObject, + JsonPrimitive, + JsonValue, +} from '@axrone/utility'; + +export type AudioBusId = Brand; +export type AudioClipId = Brand; +export type AudioListenerId = Brand; +export type AudioSourceId = Brand; +export type AudioSnapshotId = Brand; + +export type AudioJsonPrimitive = JsonPrimitive; +export type AudioJsonObject = JsonObject; +export type AudioJsonArray = JsonArray; +export type AudioJsonValue = JsonValue; + +export type AudioPatch = T extends (...args: never[]) => unknown + ? never + : T extends ReadonlyArray + ? readonly TItem[] + : T extends object + ? { readonly [TKey in keyof T]?: AudioPatch } + : T; + +export interface AudioVector3 extends IVec3Like {} + +export type AudioDistanceModel = 'none' | 'linear' | 'inverse' | 'exponential'; +export type AudioPanningModel = 'equalpower' | 'HRTF'; +export type AudioPlaybackState = 'idle' | 'playing' | 'paused' | 'stopped' | 'disposed'; +export type AudioSystemStatus = 'idle' | 'running' | 'suspended' | 'disposed'; + +export interface AudioClipBase { + readonly loopStartSeconds?: number; + readonly loopEndSeconds?: number; + readonly metadata?: Readonly>; +} + +export interface AudioBufferClipAssetData extends AudioClipBase { + readonly kind: 'buffer'; + readonly buffer: AudioBuffer; +} + +export interface AudioPcmClipAssetData extends AudioClipBase { + readonly kind: 'pcm'; + readonly sampleRate: number; + readonly channelData: readonly Float32Array[]; +} + +export interface AudioEncodedClipAssetData extends AudioClipBase { + readonly kind: 'encoded'; + readonly data: ArrayBuffer | ArrayBufferView; + readonly mimeType?: string; +} + +export interface AudioUrlClipAssetData extends AudioClipBase { + readonly kind: 'url'; + readonly url: string; + readonly mimeType?: string; + readonly headers?: Readonly>; + readonly credentials?: RequestCredentials; +} + +export type AudioClipAssetData = + | AudioBufferClipAssetData + | AudioPcmClipAssetData + | AudioEncodedClipAssetData + | AudioUrlClipAssetData; + +export interface AudioAssetSchema extends AssetSchema { + readonly audioClip: AudioClipAssetData; +} + +export type AudioClipAssetSelector = + AssetSelector; + +export interface AudioRegisteredClipSelector { + readonly kind: 'registered'; + readonly clipId: AudioClipId; +} + +export interface AudioAssetClipSelector { + readonly kind: 'asset'; + readonly selector: AudioClipAssetSelector; +} + +export interface AudioInlineClipSelector { + readonly kind: 'inline'; + readonly clip: AudioClipAssetData; +} + +export type AudioClipSelector = + | AudioRegisteredClipSelector + | AudioAssetClipSelector + | AudioInlineClipSelector; + +export type AudioClipInput = + | AudioClipSelector + | AudioClipAssetSelector + | AudioClipAssetData + | AudioBuffer; + +export interface AudioSpatialAttenuation { + readonly model?: AudioDistanceModel; + readonly refDistance?: number; + readonly maxDistance?: number; + readonly rolloffFactor?: number; + readonly minGain?: number; +} + +export interface Audio2DSpatialization { + readonly mode: '2d'; + readonly position?: AudioVector3; + readonly pan?: number; + readonly attenuation?: AudioSpatialAttenuation; +} + +export interface Audio3DSpatialization { + readonly mode: '3d'; + readonly position?: AudioVector3; + readonly orientation?: AudioVector3; + readonly attenuation?: AudioSpatialAttenuation; + readonly panningModel?: AudioPanningModel; + readonly coneInnerAngle?: number; + readonly coneOuterAngle?: number; + readonly coneOuterGain?: number; +} + +export type AudioSpatialization = Audio2DSpatialization | Audio3DSpatialization; + +export interface AudioBusDefinition { + readonly id: AudioBusId | string; + readonly parentId?: AudioBusId | string; + readonly volume?: number; + readonly mute?: boolean; + readonly pan?: number; + readonly metadata?: Readonly>; +} + +export type AudioBusPatch = AudioPatch>; + +export interface AudioBusState { + readonly id: AudioBusId; + readonly parentId?: AudioBusId; + readonly volume: number; + readonly mute: boolean; + readonly pan: number; + readonly effectiveGain: number; + readonly childIds: readonly AudioBusId[]; + readonly metadata: Readonly>; +} + +export interface AudioListenerDescriptor { + readonly id?: AudioListenerId | string; + readonly active?: boolean; + readonly enabled?: boolean; + readonly position?: AudioVector3; + readonly forward?: AudioVector3; + readonly up?: AudioVector3; + readonly metadata?: Readonly>; +} + +export type AudioListenerPatch = AudioPatch>; + +export interface AudioListenerState { + readonly id: AudioListenerId; + readonly active: boolean; + readonly enabled: boolean; + readonly position: AudioVector3; + readonly forward: AudioVector3; + readonly up: AudioVector3; + readonly metadata: Readonly>; +} + +export interface AudioSourceDefinition { + readonly id?: AudioSourceId | string; + readonly busId?: AudioBusId | string; + readonly clip?: AudioClipInput; + readonly volume?: number; + readonly muted?: boolean; + readonly loop?: boolean; + readonly autoplay?: boolean; + readonly playbackRate?: number; + readonly detuneCents?: number; + readonly pan?: number; + readonly spatial?: AudioSpatialization; + readonly startOffsetSeconds?: number; + readonly metadata?: Readonly>; +} + +export type AudioSourcePatch = AudioPatch< + Omit, 'id'> +>; + +export interface AudioSourceState { + readonly id: AudioSourceId; + readonly busId: AudioBusId; + readonly clip?: AudioClipSelector; + readonly volume: number; + readonly muted: boolean; + readonly loop: boolean; + readonly autoplay: boolean; + readonly playbackRate: number; + readonly detuneCents: number; + readonly pan: number; + readonly spatial?: AudioSpatialization; + readonly startOffsetSeconds: number; + readonly metadata: Readonly>; + readonly playbackState: AudioPlaybackState; + readonly currentOffsetSeconds: number; + readonly durationSeconds?: number; + readonly playSequence: number; +} + +export interface AudioSourcePlayRequest + extends AudioSourcePatch { + readonly when?: number; + readonly offsetSeconds?: number; + readonly durationSeconds?: number; + readonly replace?: boolean; +} + +export interface AudioStopOptions { + readonly when?: number; +} + +export interface AudioPlaybackHandle { + readonly sourceId: AudioSourceId; + readonly sequence: number; + stop(options?: AudioStopOptions): void; + pause(): void; + resume(): Promise; +} + +export interface AudioClipRegistration { + readonly id: AudioClipId | string; + readonly clip: AudioClipInput; +} + +export interface AudioClipRecord { + readonly id: AudioClipId; + readonly selector: AudioClipSelector; + readonly buffer: AudioBuffer; + readonly durationSeconds: number; + readonly sampleRate: number; + readonly channelCount: number; + readonly loopStartSeconds?: number; + readonly loopEndSeconds?: number; + readonly metadata: Readonly>; +} + +export interface AudioMixerSnapshotBusState { + readonly id: AudioBusId | string; + readonly volume?: number; + readonly mute?: boolean; + readonly pan?: number; +} + +export interface AudioMixerSnapshot { + readonly id?: AudioSnapshotId | string; + readonly buses: readonly AudioMixerSnapshotBusState[]; +} + +export interface AudioSnapshotTransitionOptions { + readonly durationSeconds?: number; + readonly atTime?: number; +} + +export interface AudioSystemSnapshot { + readonly version: 1; + readonly status: Exclude; + readonly capturedAtEpochMs: number; + readonly activeListenerId?: AudioListenerId; + readonly buses: readonly AudioBusState[]; + readonly listeners: readonly AudioListenerState[]; + readonly sources: readonly AudioSourceState[]; +} + +export interface AudioRestoreOptions { + readonly clearExisting?: boolean; + readonly restorePlayback?: boolean; + readonly transition?: AudioSnapshotTransitionOptions; +} + +export interface AudioRetryContext { + readonly operation: + | 'asset.resolve' + | 'context.resume' + | 'context.suspend' + | 'source.play' + | 'source.resume'; + readonly attempt: number; + readonly sourceId?: AudioSourceId; + readonly clip?: AudioClipSelector; +} + +export interface AudioRetryPolicy { + readonly attempts?: number; + readonly backoffMs?: number | ((attempt: number) => number); + readonly shouldRetry?: ( + error: unknown, + context: Readonly> + ) => boolean; +} + +export interface AudioAssetResolver { + resolveClip( + selector: AudioClipAssetSelector + ): + | Promise | AudioClipAssetData | undefined> + | AssetRecord + | AudioClipAssetData + | undefined; +} + +export interface AudioSystemOptions { + readonly context?: AudioContext; + readonly createContext?: () => AudioContext; + readonly destination?: AudioNode; + readonly locale?: string; + readonly messageResolver?: AudioMessageResolver; + readonly assetDatabase?: AssetDatabase; + readonly assetResolver?: AudioAssetResolver; + readonly buses?: readonly AudioBusDefinition[]; + readonly listeners?: readonly AudioListenerDescriptor[]; + readonly sources?: readonly AudioSourceDefinition[]; + readonly autoResume?: boolean; + readonly resumeRetryPolicy?: AudioRetryPolicy; + readonly assetRetryPolicy?: AudioRetryPolicy; +} + +export type AudioRuntimeEventType = + | 'bus:upserted' + | 'bus:removed' + | 'listener:upserted' + | 'listener:removed' + | 'listener:activated' + | 'source:upserted' + | 'source:removed' + | 'source:played' + | 'source:paused' + | 'source:resumed' + | 'source:stopped' + | 'source:ended' + | 'source:error' + | 'snapshot:captured' + | 'snapshot:restored' + | 'system:suspended' + | 'system:resumed' + | 'system:disposed'; + +export interface AudioRuntimeEventBase { + readonly type: AudioRuntimeEventType; + readonly sequence: number; + readonly timestamp: number; + readonly contextTime: number; + readonly systemStatus: AudioSystemStatus; +} + +export interface AudioBusUpsertedEvent extends AudioRuntimeEventBase { + readonly type: 'bus:upserted'; + readonly bus: AudioBusState; +} + +export interface AudioBusRemovedEvent extends AudioRuntimeEventBase { + readonly type: 'bus:removed'; + readonly busId: AudioBusId; + readonly fallbackBusId?: AudioBusId; +} + +export interface AudioListenerUpsertedEvent extends AudioRuntimeEventBase { + readonly type: 'listener:upserted'; + readonly listener: AudioListenerState; +} + +export interface AudioListenerRemovedEvent extends AudioRuntimeEventBase { + readonly type: 'listener:removed'; + readonly listenerId: AudioListenerId; +} + +export interface AudioListenerActivatedEvent extends AudioRuntimeEventBase { + readonly type: 'listener:activated'; + readonly listener: AudioListenerState; +} + +export interface AudioSourceStateEvent< + TSchema extends AudioAssetSchema = AudioAssetSchema, + TType extends + | 'source:upserted' + | 'source:removed' + | 'source:played' + | 'source:paused' + | 'source:resumed' + | 'source:stopped' + | 'source:ended' = + | 'source:upserted' + | 'source:removed' + | 'source:played' + | 'source:paused' + | 'source:resumed' + | 'source:stopped' + | 'source:ended', +> extends AudioRuntimeEventBase { + readonly type: TType; + readonly source: AudioSourceState; +} + +export interface AudioSourceErrorEvent + extends AudioRuntimeEventBase { + readonly type: 'source:error'; + readonly operation: 'play' | 'resume'; + readonly sourceId: AudioSourceId; + readonly reason: unknown; + readonly source?: AudioSourceState; +} + +export interface AudioSnapshotCapturedEvent extends AudioRuntimeEventBase { + readonly type: 'snapshot:captured'; + readonly snapshotKind: 'mixer' | 'system'; + readonly snapshotId?: AudioSnapshotId; + readonly busCount: number; + readonly listenerCount: number; + readonly sourceCount: number; +} + +export interface AudioSnapshotRestoredEvent extends AudioRuntimeEventBase { + readonly type: 'snapshot:restored'; + readonly snapshotId?: AudioSnapshotId; + readonly busCount: number; + readonly listenerCount: number; + readonly sourceCount: number; + readonly restorePlayback: boolean; +} + +export interface AudioSystemLifecycleEvent extends AudioRuntimeEventBase { + readonly type: 'system:suspended' | 'system:resumed' | 'system:disposed'; +} + +export type AudioRuntimeEvent = + | AudioBusUpsertedEvent + | AudioBusRemovedEvent + | AudioListenerUpsertedEvent + | AudioListenerRemovedEvent + | AudioListenerActivatedEvent + | AudioSourceStateEvent + | AudioSourceErrorEvent + | AudioSnapshotCapturedEvent + | AudioSnapshotRestoredEvent + | AudioSystemLifecycleEvent; + +export type AudioRuntimeEventChannel = 'audio:*' | AudioRuntimeEventType; + +export type AudioRuntimeEventMap = Readonly< + { + 'audio:*': AudioRuntimeEvent; + } & { + [TType in AudioRuntimeEventType]: Extract< + AudioRuntimeEvent, + { readonly type: TType } + >; + } +>; + +export type AudioEventEmitter = IEventEmitter< + AudioRuntimeEventMap +>; + +export interface AudioDiagnosticsCounters { + readonly emittedEventCount: number; + readonly busMutationCount: number; + readonly listenerMutationCount: number; + readonly sourceMutationCount: number; + readonly playbackCommandCount: number; + readonly playbackCompletionCount: number; + readonly playbackErrorCount: number; + readonly snapshotOperationCount: number; + readonly lifecycleTransitionCount: number; +} + +export interface AudioDiagnosticsSnapshot { + readonly capturedAtEpochMs: number; + readonly systemStatus: AudioSystemStatus; + readonly activeListenerId?: AudioListenerId; + readonly busCount: number; + readonly listenerCount: number; + readonly sourceCount: number; + readonly activePlaybackCount: number; + readonly counters: AudioDiagnosticsCounters; + readonly lastEvent?: AudioRuntimeEvent; +} + +export type AudioSourceComponentCommand = + | { + readonly kind: 'play'; + readonly request?: AudioSourcePlayRequest; + } + | { + readonly kind: 'pause'; + } + | { + readonly kind: 'resume'; + } + | { + readonly kind: 'stop'; + readonly options?: AudioStopOptions; + }; + +export type AudioValidationMessageCode = + | `audio.invalid-${ + | 'bus-id' + | 'clip' + | 'context' + | 'distance' + | 'gain' + | 'listener' + | 'pan' + | 'parent-bus' + | 'playback-rate' + | 'source' + | 'snapshot' + | 'time' + | 'vector'}` + | 'audio.bus.cycle'; + +export type AudioRuntimeMessageCode = + | 'audio.asset.resolve-failed' + | 'audio.bus.missing' + | 'audio.context.resume-failed' + | 'audio.context.suspend-failed' + | 'audio.disposed' + | 'audio.listener.missing' + | 'audio.snapshot.invalid' + | 'audio.source.missing' + | 'audio.source.play-failed' + | 'audio.source.resume-failed' + | 'audio.unavailable'; + +export type AudioMessageCode = AudioValidationMessageCode | AudioRuntimeMessageCode; + +export type AudioMessageDescriptor = + | { + readonly code: 'audio.invalid-bus-id'; + readonly value: unknown; + } + | { + readonly code: 'audio.invalid-clip'; + readonly value: unknown; + } + | { + readonly code: 'audio.invalid-context'; + readonly value: unknown; + } + | { + readonly code: 'audio.invalid-distance'; + readonly value: unknown; + } + | { + readonly code: 'audio.invalid-gain'; + readonly value: unknown; + } + | { + readonly code: 'audio.invalid-listener'; + readonly value: unknown; + } + | { + readonly code: 'audio.invalid-pan'; + readonly value: unknown; + } + | { + readonly code: 'audio.invalid-parent-bus'; + readonly value: unknown; + } + | { + readonly code: 'audio.invalid-playback-rate'; + readonly value: unknown; + } + | { + readonly code: 'audio.invalid-source'; + readonly value: unknown; + } + | { + readonly code: 'audio.invalid-snapshot'; + readonly value: unknown; + } + | { + readonly code: 'audio.invalid-time'; + readonly value: unknown; + } + | { + readonly code: 'audio.invalid-vector'; + readonly value: unknown; + } + | { + readonly code: 'audio.bus.cycle'; + readonly busId: string; + readonly parentId: string; + } + | { + readonly code: 'audio.asset.resolve-failed'; + readonly selector: unknown; + readonly reason: unknown; + } + | { + readonly code: 'audio.bus.missing'; + readonly busId: string; + } + | { + readonly code: 'audio.context.resume-failed'; + readonly reason: unknown; + } + | { + readonly code: 'audio.context.suspend-failed'; + readonly reason: unknown; + } + | { + readonly code: 'audio.disposed'; + } + | { + readonly code: 'audio.listener.missing'; + readonly listenerId: string; + } + | { + readonly code: 'audio.snapshot.invalid'; + readonly reason: string; + } + | { + readonly code: 'audio.source.missing'; + readonly sourceId: string; + } + | { + readonly code: 'audio.source.play-failed'; + readonly sourceId: string; + readonly reason: unknown; + } + | { + readonly code: 'audio.source.resume-failed'; + readonly sourceId: string; + readonly reason: unknown; + } + | { + readonly code: 'audio.unavailable'; + readonly reason: string; + }; + +export type AudioMessageResolver = ( + descriptor: AudioMessageDescriptor, + locale: string +) => string | undefined; diff --git a/web/packages/audio/tsconfig.build.json b/web/packages/audio/tsconfig.build.json new file mode 100644 index 00000000..5dc55682 --- /dev/null +++ b/web/packages/audio/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": false, + "types": ["node"], + "declaration": true, + "declarationMap": false, + "sourceMap": true + }, + "include": ["src/**/*.ts", "../../types/**/*.d.ts"], + "exclude": [ + "**/__tests__/**", + "**/*.test.ts", + "**/*.spec.ts", + "**/*.browser.test.ts", + "**/*.browser.spec.ts", + "dist" + ] +} \ No newline at end of file diff --git a/web/packages/ecs-events/package.json b/web/packages/ecs-events/package.json deleted file mode 100644 index 161eba49..00000000 --- a/web/packages/ecs-events/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@axrone/ecs-events", - "version": "0.1.0", - "main": "./dist/index.js", - "module": "./dist/index.mjs", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "sideEffects": false, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", - "require": "./dist/index.js" - }, - "./event": { - "types": "./dist/event.d.ts", - "import": "./dist/event.mjs", - "require": "./dist/event.js" - }, - "./observer": { - "types": "./dist/observer.d.ts", - "import": "./dist/observer.mjs", - "require": "./dist/observer.js" - }, - "./ecs-observer": { - "types": "./dist/ecs-observer.d.ts", - "import": "./dist/ecs-observer.mjs", - "require": "./dist/ecs-observer.js" - }, - "./world-event-runtime": { - "types": "./dist/world-event-runtime.d.ts", - "import": "./dist/world-event-runtime.mjs", - "require": "./dist/world-event-runtime.js" - } - }, - "scripts": { - "build": "rollup -c rollup.config.mjs", - "clean": "rimraf dist", - "test": "vitest run" - } -} \ No newline at end of file diff --git a/web/packages/ecs-events/rollup.config.mjs b/web/packages/ecs-events/rollup.config.mjs deleted file mode 100644 index 8fd76785..00000000 --- a/web/packages/ecs-events/rollup.config.mjs +++ /dev/null @@ -1,31 +0,0 @@ -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { createPackageConfig } from '../../build/create-package-config.mjs'; - -const packageDir = path.dirname(fileURLToPath(import.meta.url)); - -export default [ - ...createPackageConfig({ - packageDir, - }), - ...createPackageConfig({ - packageDir, - inputRelativePath: 'src/event.ts', - outputBasename: 'event', - }), - ...createPackageConfig({ - packageDir, - inputRelativePath: 'src/observer.ts', - outputBasename: 'observer', - }), - ...createPackageConfig({ - packageDir, - inputRelativePath: 'src/ecs-observer.ts', - outputBasename: 'ecs-observer', - }), - ...createPackageConfig({ - packageDir, - inputRelativePath: 'src/world-event-runtime.ts', - outputBasename: 'world-event-runtime', - }), -]; \ No newline at end of file diff --git a/web/packages/ecs-events/src/ecs-observer.ts b/web/packages/ecs-events/src/ecs-observer.ts deleted file mode 100644 index 1845f818..00000000 --- a/web/packages/ecs-events/src/ecs-observer.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { createBehaviorSubject, createSubject, type IObservableSubject } from './observer'; - -export interface ECSObservableActorLike { - readonly name?: string; - readonly tag?: string; - readonly layer?: number; -} - -export interface ECSObservableEntityLifecycleEvent< - TEntity = unknown, - TActor extends ECSObservableActorLike = ECSObservableActorLike, -> { - readonly entity: TEntity; - readonly actor: TActor; -} - -export interface ECSObservableComponentEvent< - TEntity = unknown, - TComponent = unknown, - TActor extends ECSObservableActorLike = ECSObservableActorLike, -> { - readonly entity: TEntity; - readonly component: TComponent; - readonly actor: TActor; -} - -export interface ECSSystemExecutionStartEvent { - readonly systemId: string; - readonly deltaTime: number; -} - -export interface ECSSystemExecutionEndEvent extends ECSSystemExecutionStartEvent { - readonly duration: number; -} - -export interface ECSFrameStartEvent { - readonly frameId: number; - readonly timestamp: number; -} - -export interface ECSFrameEndEvent extends ECSFrameStartEvent { - readonly duration: number; -} - -export type ECSComponentChangeAction = 'added' | 'removed'; -export type ECSEntityLifecycleAction = 'created' | 'destroyed'; - -export type ECSComponentChange = TComponentEvent & { - readonly action: ECSComponentChangeAction; -}; - -export type ECSEntityLifecycleChange< - TEntityLifecycle extends ECSObservableEntityLifecycleEvent, -> = TEntityLifecycle & { - readonly action: ECSEntityLifecycleAction; -}; - -export interface ECSComponentStream { - readonly added: IObservableSubject; - readonly removed: IObservableSubject; - readonly changes: IObservableSubject>; -} - -export interface ECSEntityLifecycleStreams< - TEntityLifecycle extends ECSObservableEntityLifecycleEvent, -> { - readonly all: IObservableSubject>; - byName(name: string): { - readonly created: IObservableSubject; - readonly destroyed: IObservableSubject; - }; - byTag(tag: string): { - readonly created: IObservableSubject; - readonly destroyed: IObservableSubject; - }; - byLayer(layer: number): { - readonly created: IObservableSubject; - readonly destroyed: IObservableSubject; - }; -} - -export class ECSObservables< - TComponentName extends string = string, - TEntityLifecycle extends ECSObservableEntityLifecycleEvent = ECSObservableEntityLifecycleEvent, - TComponentEvent extends ECSObservableComponentEvent = ECSObservableComponentEvent, - TQueryResult = unknown, -> { - readonly entityCreated: IObservableSubject; - readonly entityDestroyed: IObservableSubject; - - private readonly componentObservables = new Map< - string, - { - added: IObservableSubject; - removed: IObservableSubject; - } - >(); - - readonly systemExecutionStart: IObservableSubject; - readonly systemExecutionEnd: IObservableSubject; - - readonly frameStart: IObservableSubject; - readonly frameEnd: IObservableSubject; - - private readonly queryObservables = new Map>(); - - constructor() { - this.entityCreated = createSubject(); - this.entityDestroyed = createSubject(); - this.systemExecutionStart = createSubject(); - this.systemExecutionEnd = createSubject(); - this.frameStart = createSubject(); - this.frameEnd = createSubject(); - } - - getComponentObservables(componentName: TComponentName) { - const key = componentName as string; - - if (!this.componentObservables.has(key)) { - this.componentObservables.set(key, { - added: createSubject(), - removed: createSubject(), - }); - } - - return this.componentObservables.get(key)!; - } - - getQueryObservable(queryKey: string, initialValue: T[] = []): IObservableSubject { - if (!this.queryObservables.has(queryKey)) { - this.queryObservables.set(queryKey, createBehaviorSubject(initialValue) as IObservableSubject); - } - - return this.queryObservables.get(queryKey)! as IObservableSubject; - } - - createEntityFilter( - predicate: (data: TEntityLifecycle) => boolean - ): IObservableSubject { - return this._createEntityFilterFrom(this.entityCreated, predicate); - } - - createComponentStream(componentName: TComponentName): ECSComponentStream { - const observables = this.getComponentObservables(componentName); - - const changes = createSubject>(); - - observables.added.addObserver((data) => { - changes.notify({ ...data, action: 'added' }); - }); - - observables.removed.addObserver((data) => { - changes.notify({ ...data, action: 'removed' }); - }); - - return { - added: observables.added, - removed: observables.removed, - changes, - }; - } - - createDebouncedQuery( - queryKey: string, - debounceMs: number = 100, - initialValue: T[] = [] - ): IObservableSubject { - const queryObservable = this.getQueryObservable(queryKey, initialValue); - const debounced = createSubject(); - - let timeoutId: ReturnType | undefined; - - queryObservable.addObserver((data) => { - if (timeoutId !== undefined) { - clearTimeout(timeoutId); - } - - timeoutId = setTimeout(() => { - debounced.notify(data); - }, debounceMs); - }); - - return debounced; - } - - createThrottledQuery( - queryKey: string, - throttleMs: number = 100, - initialValue: T[] = [] - ): IObservableSubject { - const queryObservable = this.getQueryObservable(queryKey, initialValue); - const throttled = createSubject(); - - let lastExecution = 0; - - queryObservable.addObserver((data) => { - const now = Date.now(); - if (now - lastExecution >= throttleMs) { - throttled.notify(data); - lastExecution = now; - } - }); - - return throttled; - } - - createEntityLifecycle(): ECSEntityLifecycleStreams { - const all = createSubject>(); - - this.entityCreated.addObserver((data) => { - all.notify({ ...data, action: 'created' }); - }); - - this.entityDestroyed.addObserver((data) => { - all.notify({ ...data, action: 'destroyed' }); - }); - - return { - all, - - byName: (name: string) => ({ - created: this._createEntityFilterFrom( - this.entityCreated, - ({ actor }) => actor.name === name - ), - destroyed: this._createEntityFilterFrom( - this.entityDestroyed, - ({ actor }) => actor.name === name - ), - }), - - byTag: (tag: string) => ({ - created: this._createEntityFilterFrom( - this.entityCreated, - ({ actor }) => actor.tag === tag - ), - destroyed: this._createEntityFilterFrom( - this.entityDestroyed, - ({ actor }) => actor.tag === tag - ), - }), - - byLayer: (layer: number) => ({ - created: this._createEntityFilterFrom( - this.entityCreated, - ({ actor }) => actor.layer === layer - ), - destroyed: this._createEntityFilterFrom( - this.entityDestroyed, - ({ actor }) => actor.layer === layer - ), - }), - }; - } - - dispose(): void { - this.entityCreated.dispose(); - this.entityDestroyed.dispose(); - this.systemExecutionStart.dispose(); - this.systemExecutionEnd.dispose(); - this.frameStart.dispose(); - this.frameEnd.dispose(); - - this.componentObservables.forEach(({ added, removed }) => { - added.dispose(); - removed.dispose(); - }); - - this.queryObservables.forEach((observable) => { - observable.dispose(); - }); - - this.componentObservables.clear(); - this.queryObservables.clear(); - } - - private _createEntityFilterFrom( - source: IObservableSubject, - predicate: (data: TEntityLifecycle) => boolean - ): IObservableSubject { - const filtered = createSubject(); - - source.addObserver((data) => { - if (predicate(data)) { - filtered.notify(data); - } - }); - - return filtered; - } -} \ No newline at end of file diff --git a/web/packages/ecs-events/src/event.ts b/web/packages/ecs-events/src/event.ts deleted file mode 100644 index 29b5ecbc..00000000 --- a/web/packages/ecs-events/src/event.ts +++ /dev/null @@ -1,290 +0,0 @@ -export interface EventMap {} -export type EventKey = Extract; - -export interface EventMetrics { - readonly emittedCount: number; - readonly handlerCount: number; - readonly queuedCount: number; - readonly errorCount: number; - readonly lastEmittedAt: number | null; -} - -export interface IEventEmitter { - on>( - event: TEvent, - handler: (data: TEvents[TEvent]) => void | Promise - ): () => void; - once>( - event: TEvent, - handler: (data: TEvents[TEvent]) => void | Promise - ): () => void; - emit>(event: TEvent, data: TEvents[TEvent]): Promise; - emitSync>(event: TEvent, data: TEvents[TEvent]): boolean; - off>( - event: TEvent, - handler?: (data: TEvents[TEvent]) => void | Promise - ): boolean; - getMetrics(event: string): EventMetrics; - eventNames(): string[]; - pause(): void; - resume(): void; - drain(): Promise; - dispose(): void; -} - -type InternalEventHandler = ((data: unknown) => void | Promise) & { - __originalHandler__?: (data: unknown) => void | Promise; -}; - -interface QueuedEvent { - readonly event: string; - readonly data: unknown; -} - -interface MutableEventMetrics { - emittedCount: number; - handlerCount: number; - queuedCount: number; - errorCount: number; - lastEmittedAt: number | null; -} - -const createInitialMetrics = (): MutableEventMetrics => ({ - emittedCount: 0, - handlerCount: 0, - queuedCount: 0, - errorCount: 0, - lastEmittedAt: null, -}); - -class TypedEventEmitter implements IEventEmitter { - private readonly _handlers = new Map>(); - private readonly _metrics = new Map(); - private readonly _queue: QueuedEvent[] = []; - - private _paused = false; - private _disposed = false; - private _drainPromise: Promise | null = null; - - on>( - event: TEvent, - handler: (data: TEvents[TEvent]) => void | Promise - ): () => void { - if (this._disposed) { - return () => {}; - } - - const eventName = event as string; - const handlers = this._getHandlers(eventName); - handlers.add(handler as InternalEventHandler); - this._getMetrics(eventName).handlerCount = handlers.size; - - return () => { - this.off(event, handler); - }; - } - - once>( - event: TEvent, - handler: (data: TEvents[TEvent]) => void | Promise - ): () => void { - let unsubscribe = () => {}; - const wrappedHandler: InternalEventHandler = (async (data: unknown) => { - unsubscribe(); - await handler(data as TEvents[TEvent]); - }) as InternalEventHandler; - wrappedHandler.__originalHandler__ = handler as (data: unknown) => void | Promise; - unsubscribe = this.on(event, wrappedHandler as (data: TEvents[TEvent]) => void | Promise); - return unsubscribe; - } - - async emit>(event: TEvent, data: TEvents[TEvent]): Promise { - if (this._disposed) { - return false; - } - - const eventName = event as string; - if (this._paused) { - this._enqueueEvent(eventName, data); - return true; - } - - return this._dispatchAsync(eventName, data); - } - - emitSync>(event: TEvent, data: TEvents[TEvent]): boolean { - if (this._disposed) { - return false; - } - - const eventName = event as string; - if (this._paused) { - this._enqueueEvent(eventName, data); - return true; - } - - return this._dispatchSync(eventName, data); - } - - off>( - event: TEvent, - handler?: (data: TEvents[TEvent]) => void | Promise - ): boolean { - const eventName = event as string; - const handlers = this._handlers.get(eventName); - if (!handlers) { - return false; - } - - let removed = false; - if (!handler) { - removed = handlers.size > 0; - handlers.clear(); - } else { - for (const registeredHandler of [...handlers]) { - if ( - registeredHandler === handler || - registeredHandler.__originalHandler__ === handler - ) { - handlers.delete(registeredHandler); - removed = true; - } - } - } - - if (handlers.size === 0) { - this._handlers.delete(eventName); - } - - this._getMetrics(eventName).handlerCount = handlers.size; - return removed; - } - - getMetrics(event: string): EventMetrics { - const eventName = event; - const metrics = this._getMetrics(eventName); - metrics.handlerCount = this._handlers.get(eventName)?.size ?? 0; - return { ...metrics }; - } - - eventNames(): string[] { - return [...new Set([...this._handlers.keys(), ...this._metrics.keys()])]; - } - - pause(): void { - if (!this._disposed) { - this._paused = true; - } - } - - resume(): void { - if (this._disposed) { - return; - } - - this._paused = false; - if (this._queue.length > 0) { - void this._flushQueue(); - } - } - - drain(): Promise { - return this._flushQueue(); - } - - dispose(): void { - this._disposed = true; - this._paused = false; - this._handlers.clear(); - this._queue.length = 0; - for (const metrics of this._metrics.values()) { - metrics.handlerCount = 0; - metrics.queuedCount = 0; - } - } - - private _getHandlers(eventName: string): Set { - let handlers = this._handlers.get(eventName); - if (!handlers) { - handlers = new Set(); - this._handlers.set(eventName, handlers); - } - return handlers; - } - - private _getMetrics(eventName: string): MutableEventMetrics { - let metrics = this._metrics.get(eventName); - if (!metrics) { - metrics = createInitialMetrics(); - this._metrics.set(eventName, metrics); - } - return metrics; - } - - private _enqueueEvent(eventName: string, data: unknown): void { - this._queue.push({ event: eventName, data }); - this._getMetrics(eventName).queuedCount += 1; - } - - private async _dispatchAsync(eventName: string, data: unknown): Promise { - const handlers = [...(this._handlers.get(eventName) ?? [])]; - const metrics = this._getMetrics(eventName); - metrics.emittedCount += 1; - metrics.lastEmittedAt = performance.now(); - - let succeeded = true; - for (const handler of handlers) { - try { - await handler(data); - } catch (error) { - metrics.errorCount += 1; - succeeded = false; - console.error(`Event handler failed for ${eventName}:`, error); - } - } - - return succeeded; - } - - private _dispatchSync(eventName: string, data: unknown): boolean { - const handlers = [...(this._handlers.get(eventName) ?? [])]; - const metrics = this._getMetrics(eventName); - metrics.emittedCount += 1; - metrics.lastEmittedAt = performance.now(); - - let succeeded = true; - for (const handler of handlers) { - try { - void handler(data); - } catch (error) { - metrics.errorCount += 1; - succeeded = false; - console.error(`Event handler failed for ${eventName}:`, error); - } - } - - return succeeded; - } - - private _flushQueue(): Promise { - if (this._drainPromise) { - return this._drainPromise; - } - - this._drainPromise = (async () => { - while (!this._paused && this._queue.length > 0) { - const queuedEvent = this._queue.shift()!; - const metrics = this._getMetrics(queuedEvent.event); - metrics.queuedCount = Math.max(0, metrics.queuedCount - 1); - await this._dispatchAsync(queuedEvent.event, queuedEvent.data); - } - })().finally(() => { - this._drainPromise = null; - }); - - return this._drainPromise; - } -} - -export const createTypedEmitter = (): IEventEmitter => - new TypedEventEmitter(); \ No newline at end of file diff --git a/web/packages/ecs-events/src/index.ts b/web/packages/ecs-events/src/index.ts deleted file mode 100644 index 5c70f1a4..00000000 --- a/web/packages/ecs-events/src/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -export type { EventKey, EventMap, EventMetrics, IEventEmitter } from './event'; -export { createTypedEmitter } from './event'; - -export type { IObservableSubject, ObserverCallback, UnobserveFn } from './observer'; -export { createBehaviorSubject, createSubject } from './observer'; - -export type { - ECSComponentChange, - ECSComponentChangeAction, - ECSComponentStream, - ECSFrameEndEvent, - ECSFrameStartEvent, - ECSObservableActorLike, - ECSObservableComponentEvent, - ECSObservableEntityLifecycleEvent, - ECSEntityLifecycleAction, - ECSEntityLifecycleChange, - ECSEntityLifecycleStreams, - ECSSystemExecutionEndEvent, - ECSSystemExecutionStartEvent, -} from './ecs-observer'; -export { ECSObservables } from './ecs-observer'; - -export type { WorldQueryExecutor } from './world-event-runtime'; -export { WorldEventRuntime } from './world-event-runtime'; \ No newline at end of file diff --git a/web/packages/ecs-events/src/observer.ts b/web/packages/ecs-events/src/observer.ts deleted file mode 100644 index dff1ba39..00000000 --- a/web/packages/ecs-events/src/observer.ts +++ /dev/null @@ -1,134 +0,0 @@ -export type ObserverCallback = (data: T) => void | Promise; -export type UnobserveFn = () => void; - -export interface IObservableSubject { - readonly id: string; - addObserver(callback: ObserverCallback, options?: unknown): UnobserveFn; - notify(data: T): Promise; - notifySync(data: T): void; - complete(): void; - error(error: unknown): void; - dispose(): void; -} - -let nextSubjectId = 1; - -class ObservableSubject implements IObservableSubject { - readonly id = `ecs-subject-${nextSubjectId++}`; - - protected readonly _observers = new Set>(); - protected _completed = false; - protected _disposed = false; - - addObserver(callback: ObserverCallback, _options?: unknown): UnobserveFn { - if (this._disposed || this._completed) { - return () => {}; - } - - this._observers.add(callback); - return () => { - this._observers.delete(callback); - }; - } - - async notify(data: T): Promise { - if (this._disposed || this._completed) { - return; - } - - const pendingNotifications: Promise[] = []; - for (const observer of [...this._observers]) { - try { - pendingNotifications.push(Promise.resolve(observer(data)).then(() => {})); - } catch (error) { - console.error('Observer notification failed:', error); - } - } - - await Promise.all(pendingNotifications); - } - - notifySync(data: T): void { - if (this._disposed || this._completed) { - return; - } - - for (const observer of [...this._observers]) { - try { - void observer(data); - } catch (error) { - console.error('Observer notification failed:', error); - } - } - } - - complete(): void { - if (this._disposed) { - return; - } - - this._completed = true; - this._observers.clear(); - } - - error(error: unknown): void { - console.error('Observable subject error:', error); - this.complete(); - } - - dispose(): void { - this._disposed = true; - this._completed = true; - this._observers.clear(); - } -} - -class BehaviorObservableSubject extends ObservableSubject { - private _version = 0; - - constructor(private _currentValue: T) { - super(); - } - - override addObserver(callback: ObserverCallback, options?: unknown): UnobserveFn { - const unsubscribe = super.addObserver(callback, options); - if (!this._disposed && !this._completed) { - const currentValue = this._currentValue; - const replayVersion = this._version; - queueMicrotask(() => { - if ( - this._disposed || - this._completed || - !this._observers.has(callback) || - replayVersion !== this._version - ) { - return; - } - - try { - void callback(currentValue); - } catch (error) { - console.error('Behavior observer replay failed:', error); - } - }); - } - return unsubscribe; - } - - override async notify(data: T): Promise { - this._currentValue = data; - this._version += 1; - await super.notify(data); - } - - override notifySync(data: T): void { - this._currentValue = data; - this._version += 1; - super.notifySync(data); - } -} - -export const createSubject = (): IObservableSubject => new ObservableSubject(); - -export const createBehaviorSubject = (initialValue: T): IObservableSubject => - new BehaviorObservableSubject(initialValue); \ No newline at end of file diff --git a/web/packages/ecs-events/src/world-event-runtime.ts b/web/packages/ecs-events/src/world-event-runtime.ts deleted file mode 100644 index 18620635..00000000 --- a/web/packages/ecs-events/src/world-event-runtime.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { createTypedEmitter, type EventKey, type EventMap, type IEventEmitter } from './event'; -import { ECSObservables, type ECSObservableComponentEvent, type ECSObservableEntityLifecycleEvent } from './ecs-observer'; - -export type WorldQueryExecutor = ( - ...components: readonly TComponentName[] -) => readonly TQueryResult[]; - -export class WorldEventRuntime< - TEvents extends EventMap, - TComponentName extends string = string, - TEntityLifecycle extends ECSObservableEntityLifecycleEvent = ECSObservableEntityLifecycleEvent, - TComponentEvent extends ECSObservableComponentEvent = ECSObservableComponentEvent, - TQueryResult = unknown, -> { - private readonly _eventBus: IEventEmitter = createTypedEmitter(); - private readonly _disposables = new Set<() => void>(); - - constructor( - componentNames: readonly TComponentName[], - private readonly _query: WorldQueryExecutor, - private readonly _observables: ECSObservables< - TComponentName, - TEntityLifecycle, - TComponentEvent, - TQueryResult - > = new ECSObservables() - ) { - this._setupEventObserverBridge(componentNames); - } - - on>(event: T, handler: (data: TEvents[T]) => void): () => void { - return this._trackDisposer(this._eventBus.on(event, handler)); - } - - once>(event: T, handler: (data: TEvents[T]) => void): () => void { - return this._trackDisposer(this._eventBus.once(event, handler)); - } - - emit>(event: T, data: TEvents[T]): Promise { - return this._eventBus.emit(event, data); - } - - emitSync>(event: T, data: TEvents[T]): boolean { - return this._eventBus.emitSync(event, data); - } - - emitSafe>(event: T, data: TEvents[T]): void { - try { - this._eventBus.emitSync(event, data); - } catch (error) { - console.error(`Failed to emit event ${String(event)}:`, error); - } - } - - off>(event: T, handler?: (data: TEvents[T]) => void): boolean { - return this._eventBus.off(event, handler); - } - - getEventMetrics>(event: T) { - return this._eventBus.getMetrics(String(event)); - } - - getAllEventMetrics(): Record { - const allMetrics: Record = {}; - const eventNames = this._eventBus.eventNames(); - - for (let i = 0; i < eventNames.length; i++) { - const eventName = eventNames[i]!; - try { - allMetrics[eventName] = this._eventBus.getMetrics(eventName); - } catch (error) { - console.warn(`Failed to get metrics for event ${eventName}:`, error); - } - } - - return allMetrics; - } - - pause(): void { - this._eventBus.pause(); - } - - resume(): void { - this._eventBus.resume(); - } - - drain(): Promise { - return this._eventBus.drain(); - } - - getObservables(): ECSObservables { - return this._observables; - } - - observeEntityLifecycle() { - return this._observables.createEntityLifecycle(); - } - - observeComponent(componentName: TComponentName) { - return this._observables.createComponentStream(componentName); - } - - createReactiveQuery(...components: Q) { - const queryKey = - components.length === 1 - ? String(components[0]) - : [...components].sort().join(','); - const queryObservable = this._observables.getQueryObservable(queryKey, []); - - const updateQuery = () => { - try { - const results = this._query(...components); - queryObservable.notify([...results]); - } catch (error) { - console.error('Failed to update reactive query:', error); - } - }; - const reactiveQueryHandler = updateQuery as (data: TEvents[EventKey]) => void; - - for (let i = 0; i < components.length; i++) { - const componentName = components[i] as string; - this._trackDisposer( - this._eventBus.on(`${componentName}Added` as EventKey, reactiveQueryHandler) - ); - this._trackDisposer( - this._eventBus.on(`${componentName}Removed` as EventKey, reactiveQueryHandler) - ); - } - - this._trackDisposer(this._eventBus.on('EntityCreated' as EventKey, reactiveQueryHandler)); - this._trackDisposer(this._eventBus.on('EntityDestroyed' as EventKey, reactiveQueryHandler)); - - updateQuery(); - - return queryObservable; - } - - registerComponent(componentName: TComponentName): void { - this._registerComponentEventBridge(componentName); - } - - dispose(): void { - try { - this._eventBus.dispose(); - } catch (error) { - console.error('Failed to dispose event bus:', error); - } - - try { - this._observables.dispose(); - } catch (error) { - console.error('Failed to dispose observables:', error); - } - - for (const dispose of this._disposables) { - try { - dispose(); - } catch (error) { - console.error('Failed to execute disposal task:', error); - } - } - this._disposables.clear(); - } - - private _trackDisposer(unsubscribe: () => void): () => void { - this._disposables.add(unsubscribe); - - return () => { - unsubscribe(); - this._disposables.delete(unsubscribe); - }; - } - - private _setupEventObserverBridge(componentNames: readonly TComponentName[]): void { - this._eventBus.on('EntityCreated' as EventKey, (data) => { - try { - this._observables.entityCreated.notify(data as TEntityLifecycle); - } catch (error) { - console.error('Failed to notify entity created:', error); - } - }); - - this._eventBus.on('EntityDestroyed' as EventKey, (data) => { - try { - this._observables.entityDestroyed.notify(data as TEntityLifecycle); - } catch (error) { - console.error('Failed to notify entity destroyed:', error); - } - }); - - for (let i = 0; i < componentNames.length; i++) { - this._registerComponentEventBridge(componentNames[i]!); - } - } - - private _registerComponentEventBridge(componentName: TComponentName): void { - this._eventBus.on(`${componentName}Added` as EventKey, (data) => { - try { - const observables = this._observables.getComponentObservables(componentName); - observables.added.notify(data as TComponentEvent); - } catch (error) { - console.error(`Failed to notify ${componentName} added:`, error); - } - }); - - this._eventBus.on(`${componentName}Removed` as EventKey, (data) => { - try { - const observables = this._observables.getComponentObservables(componentName); - observables.removed.notify(data as TComponentEvent); - } catch (error) { - console.error(`Failed to notify ${componentName} removed:`, error); - } - }); - } -} \ No newline at end of file diff --git a/web/packages/ecs-runtime/package.json b/web/packages/ecs-runtime/package.json index 33398dc1..d49a4fd4 100644 --- a/web/packages/ecs-runtime/package.json +++ b/web/packages/ecs-runtime/package.json @@ -76,11 +76,12 @@ "test": "vitest run" }, "dependencies": { - "@axrone/ecs-events": "^0.1.0", "@axrone/ecs-query": "^0.1.0", "@axrone/ecs-storage": "^0.1.0", "@axrone/ecs-world-support": "^0.1.0", + "@axrone/event": "^0.1.0", "@axrone/numeric": "^0.0.1", + "@axrone/observer": "^0.1.0", "@axrone/utility": "^0.0.1" } } \ No newline at end of file diff --git a/web/packages/ecs-runtime/src/__tests__/actor.test.ts b/web/packages/ecs-runtime/src/__tests__/actor.test.ts index db478b0d..281fbe29 100644 --- a/web/packages/ecs-runtime/src/__tests__/actor.test.ts +++ b/web/packages/ecs-runtime/src/__tests__/actor.test.ts @@ -144,6 +144,29 @@ describe('Actor', () => { new Actor(null as any); }).toThrow(ActorError); }); + + it('should initialize preloaded components through the fast path', () => { + const preloadedActor = Actor.createWithComponents(world, { autoStart: false }, [ + { + componentType: Transform, + component: new Transform(), + }, + { + componentType: TestComponent, + component: new TestComponent(10, 'preloaded'), + }, + ]); + + expect(preloadedActor.getComponent(Transform)).toBeInstanceOf(Transform); + expect(preloadedActor.getComponent(TestComponent)?.value).toBe(1); + expect(preloadedActor.getComponent(TestComponent)?.name).toBe('preloaded'); + + preloadedActor.start(); + + expect(preloadedActor.getComponent(TestComponent)?.value).toBe(2); + + preloadedActor.destroy(); + }); }); describe('component management', () => { diff --git a/web/packages/ecs-runtime/src/__tests__/component-property.test.ts b/web/packages/ecs-runtime/src/__tests__/component-property.test.ts new file mode 100644 index 00000000..20c3c76a --- /dev/null +++ b/web/packages/ecs-runtime/src/__tests__/component-property.test.ts @@ -0,0 +1,77 @@ +import { + Component, + Transform, + getComponentMetadata, + getComponentPropertyMetadata, + getComponentPropertyMetadataByKey, + property, + script, +} from '@axrone/ecs-runtime'; +import { describe, expect, test } from 'vitest'; + +@script({ allowMultiple: true }) +class RepeatableScriptComponent extends Component { + @property({ label: 'Speed', defaultValue: 5, min: 0, step: 0.5 }) + public speed: number = 5; + + @property({ type: Transform, description: 'Target transform reference' }) + public targetTransform: Transform | null = null; +} + +@script() +class DerivedRepeatableScriptComponent extends RepeatableScriptComponent { + @property({ label: 'Enabled', defaultValue: true }) + public enabledFlag: boolean = true; +} + +describe('Component Property Decorator', () => { + test('surfaces allowMultiple through script metadata', () => { + const metadata = getComponentMetadata(RepeatableScriptComponent); + + expect(metadata).toBeDefined(); + expect(metadata?.scriptName).toBe('RepeatableScriptComponent'); + expect(metadata?.allowMultiple).toBe(true); + }); + + test('collects inherited property metadata in declaration order', () => { + const metadata = getComponentPropertyMetadata(DerivedRepeatableScriptComponent); + + expect(metadata.map((entry) => entry.propertyKey)).toEqual([ + 'speed', + 'targetTransform', + 'enabledFlag', + ]); + expect(metadata[0]).toMatchObject({ + propertyKey: 'speed', + label: 'Speed', + defaultValue: 5, + min: 0, + step: 0.5, + serializable: true, + visible: true, + }); + expect(metadata[1]).toMatchObject({ + propertyKey: 'targetTransform', + description: 'Target transform reference', + type: Transform, + }); + expect(metadata[2]).toMatchObject({ + propertyKey: 'enabledFlag', + label: 'Enabled', + defaultValue: true, + }); + }); + + test('resolves individual property metadata by key', () => { + const metadata = getComponentPropertyMetadataByKey( + RepeatableScriptComponent, + 'targetTransform', + ); + + expect(metadata).toMatchObject({ + propertyKey: 'targetTransform', + type: Transform, + description: 'Target transform reference', + }); + }); +}); diff --git a/web/packages/ecs-runtime/src/__tests__/component.test.ts b/web/packages/ecs-runtime/src/__tests__/component.test.ts index 870b0a83..5d7f7ee1 100644 --- a/web/packages/ecs-runtime/src/__tests__/component.test.ts +++ b/web/packages/ecs-runtime/src/__tests__/component.test.ts @@ -138,6 +138,26 @@ describe('Component', () => { expect(comp.priority).toBe(0); }); + it('should lazily allocate optional support collections', () => { + const comp = new TestComponent(); + const raw = comp as any; + + expect(raw._cleanupTasks).toBeUndefined(); + expect(raw._eventSubscriptions).toBeUndefined(); + expect(raw._dependencies).toBeUndefined(); + expect(raw._dependents).toBeUndefined(); + + const cleanup = vi.fn(); + comp.addCleanupTask(cleanup); + + expect(raw._cleanupTasks).toBeInstanceOf(Set); + expect(raw._eventSubscriptions).toBeUndefined(); + expect(raw._dependencies).toBeUndefined(); + expect(raw._dependents).toBeUndefined(); + + comp._internalDestroy(); + }); + it('should initialize with custom configuration', () => { const comp = new TestComponent(10, 'custom'); diff --git a/web/packages/ecs-runtime/src/component-system/components/hierarchy.ts b/web/packages/ecs-runtime/src/component-system/components/hierarchy.ts index b3bf08bd..9e6d3330 100644 --- a/web/packages/ecs-runtime/src/component-system/components/hierarchy.ts +++ b/web/packages/ecs-runtime/src/component-system/components/hierarchy.ts @@ -2,6 +2,7 @@ import { Component } from '../core/component'; import { script } from '../decorators/script'; import type { Actor } from '../core/actor'; import type { Entity } from '../types/core'; +import type { Transform } from './transform'; @script({ scriptName: 'Hierarchy', @@ -15,6 +16,7 @@ import type { Entity } from '../types/core'; validateDependencies: true, enableMetrics: true, enableCaching: true, + trackInstances: false, }) export class Hierarchy extends Component { private _parent?: Hierarchy; @@ -224,13 +226,13 @@ export class Hierarchy extends Component { }); } - private _getTransform(): any { + private _getTransform(): Transform | undefined { if (!this.actor) { return undefined; } - const TransformClass = - (this.world as any)?.registry?.Transform || (globalThis as any).Transform; + const worldAny = this.world as unknown as { registry?: { Transform?: typeof Transform } } | null; + const TransformClass = worldAny?.registry?.Transform ?? (globalThis as unknown as { Transform?: typeof Transform }).Transform; if (!TransformClass) { return undefined; diff --git a/web/packages/ecs-runtime/src/component-system/components/transform.ts b/web/packages/ecs-runtime/src/component-system/components/transform.ts index d39c0044..87f5c688 100644 --- a/web/packages/ecs-runtime/src/component-system/components/transform.ts +++ b/web/packages/ecs-runtime/src/component-system/components/transform.ts @@ -2,6 +2,7 @@ import { Component } from '../core/component'; import { script } from '../decorators/script'; import { Mat4, Vec3, Quat } from '@axrone/numeric'; import type { Hierarchy } from './hierarchy'; +import type { ComponentType } from '../types/component'; const WORLD_TRANSLATION_EPSILON = 1e-8; @@ -17,6 +18,7 @@ const WORLD_TRANSLATION_EPSILON = 1e-8; validateDependencies: true, enableMetrics: true, enableCaching: true, + trackInstances: false, }) export class Transform extends Component { private _position: Vec3 = Vec3.ZERO.clone(); @@ -298,7 +300,7 @@ export class Transform extends Component { } findChildWithComponent( - componentType: new (...args: any[]) => T + componentType: ComponentType ): Transform | undefined { for (const child of this.children) { if (child.actor?.hasComponent(componentType)) { @@ -310,7 +312,7 @@ export class Transform extends Component { } findChildrenWithComponent( - componentType: new (...args: any[]) => T + componentType: ComponentType ): Transform[] { const results: Transform[] = []; @@ -585,9 +587,6 @@ export class Transform extends Component { private markWorldDirty(): void { this._worldDirty = true; - this._worldPosition = undefined; - this._worldRotation = undefined; - this._worldScale = undefined; for (const child of this.children) { child.markWorldDirty(); diff --git a/web/packages/ecs-runtime/src/component-system/core/actor.ts b/web/packages/ecs-runtime/src/component-system/core/actor.ts index 27e8fbdf..3e36032d 100644 --- a/web/packages/ecs-runtime/src/component-system/core/actor.ts +++ b/web/packages/ecs-runtime/src/component-system/core/actor.ts @@ -8,9 +8,9 @@ import { Transform } from '../components/transform'; const getComponentTypeName = (componentType: ComponentType): string => getComponentMetadata(componentType)?.scriptName ?? componentType.name; -export interface EventBus { - emit(eventType: string, data: any): void; - on(eventType: string, handler: (data: any) => void): () => void; +export interface EventBus { + emit(eventType: T, data: unknown): void; + on(eventType: T, handler: (data: unknown) => void): () => void; } export type ActorState = 'initializing' | 'active' | 'inactive' | 'destroying' | 'destroyed'; @@ -61,6 +61,19 @@ export interface ActorConfig { readonly autoStart?: boolean; } +interface ActorPreloadedComponentEntry { + readonly componentType: ComponentType; + readonly component: Component; + readonly componentName?: string; + readonly metadata?: ComponentMetadata; +} + +interface ActorInternalConfig extends ActorConfig { + readonly entity?: Entity; + readonly skipCoreComponents?: boolean; + readonly preloadedComponents?: readonly ActorPreloadedComponentEntry[]; +} + export class Actor< TWorld extends World = World, TComponents extends readonly ComponentType[] = readonly ComponentType[], @@ -100,8 +113,10 @@ export class Actor< throw new ActorError('Invalid world instance provided', '' as ActorId, 'constructor'); } + const internalConfig = config as ActorInternalConfig; + this.world = world; - this.entity = world.createEntity(); + this.entity = internalConfig.entity ?? world.createEntity(); this.creationTime = performance.now(); this._name = config.name ?? 'Actor'; @@ -118,7 +133,13 @@ export class Actor< try { world.registerActor(this.entity, this); - this._initializeCoreComponents(); + if (internalConfig.preloadedComponents?.length) { + this._initializePreloadedComponents(internalConfig.preloadedComponents); + } + + if (!internalConfig.skipCoreComponents) { + this._initializeCoreComponents(); + } this._state = 'active'; @@ -149,6 +170,30 @@ export class Actor< return Actor._componentMetadataMap.get(componentType); } + static createWithComponents< + TWorld extends World = World, + >( + world: TWorld, + config: ActorConfig, + components: readonly ActorPreloadedComponentEntry[] + ): Actor { + const componentMap: Record = {}; + + for (const entry of components) { + componentMap[entry.componentName ?? getComponentTypeName(entry.componentType)] = + entry.component; + } + + const entity = world.createEntityWithComponents(componentMap); + + return new Actor(world, { + ...config, + entity, + skipCoreComponents: true, + preloadedComponents: components, + } as ActorConfig) as Actor; + } + get name(): string { return this._name; } @@ -314,7 +359,7 @@ export class Actor< throw new ComponentError( 'Invalid component type provided', this.id, - getComponentTypeName((componentType as any) ?? { name: 'unknown' }) + getComponentTypeName((componentType as unknown as ComponentType) ?? { name: 'unknown' }) ); } @@ -648,8 +693,8 @@ export class Actor< const HierarchyClass = this._resolveCoreComponent('Hierarchy', Hierarchy); const TransformClass = this._resolveCoreComponent('Transform', Transform); - this.addComponent(HierarchyClass as any); - this.addComponent(TransformClass as any); + if (HierarchyClass) this.addComponent(HierarchyClass as ComponentType); + if (TransformClass) this.addComponent(TransformClass as ComponentType); } catch (error) { console.error( new ComponentError( @@ -662,6 +707,82 @@ export class Actor< } } + private _initializePreloadedComponents( + entries: readonly ActorPreloadedComponentEntry[] + ): void { + const finalizeEntries: Array<{ + readonly component: Component; + readonly componentName: string; + readonly runAwake: boolean; + readonly runStart: boolean; + readonly runEnable: boolean; + }> = []; + const emitComponentAdded = !!(this._eventBus && typeof this._eventBus.emit === 'function'); + + for (const entry of entries) { + const metadata = entry.metadata ?? this._getComponentMetadata(entry.componentType); + const componentName = entry.componentName ?? getComponentTypeName(entry.componentType); + + if (metadata?.dependencies && metadata.dependencies.length > 0) { + throw new ComponentError( + 'Preloaded component fast path does not support dependencies', + this.id, + componentName + ); + } + + if (this._components.has(entry.componentType)) { + throw new ComponentError( + 'Component already exists and is not singleton', + this.id, + componentName + ); + } + + (entry.component as any).entity = this.entity; + (entry.component as any).actor = this; + (entry.component as any).world = this.world; + + this._components.set(entry.componentType, entry.component); + this._componentPriorities.set(entry.componentType, metadata?.priority ?? 0); + + const runAwake = typeof entry.component.awake === 'function'; + const runStart = this._started && typeof entry.component.start === 'function'; + const runEnable = this._active && typeof entry.component.onEnable === 'function'; + + if (runAwake || runStart || runEnable || emitComponentAdded) { + finalizeEntries.push({ + component: entry.component, + componentName, + runAwake, + runStart, + runEnable, + }); + } + } + + for (const entry of finalizeEntries) { + if (entry.runAwake) { + this._executeComponentLifecycle(entry.component, 'awake'); + } + + if (entry.runStart) { + this._executeComponentLifecycle(entry.component, 'start'); + } + + if (entry.runEnable) { + this._executeComponentLifecycle(entry.component, 'onEnable'); + } + + if (emitComponentAdded) { + this._emitEvent('actor:componentAdded', { + componentType: entry.componentName, + component: entry.component, + }); + } + } + } + private _validateNotDestroyed(operation: string): void { if (this._destroyed) { throw new ActorError( @@ -672,33 +793,34 @@ export class Actor< } } - private _getHierarchyComponent(): any { + private _getHierarchyComponent(): Hierarchy | undefined { const HierarchyClass = this._resolveCoreComponent('Hierarchy', Hierarchy, false); if (!HierarchyClass) { return undefined; } - return this.getComponent(HierarchyClass as any); + return this.getComponent(HierarchyClass as ComponentType); } private _resolveCoreComponent( name: 'Hierarchy' | 'Transform', - fallback: ComponentType, + fallback: ComponentType, ensureRegistered: boolean = true - ): ComponentType | undefined { - const registryComponent = - (this.world as any).registry?.[name] || (globalThis as any)[name] || fallback; + ): ComponentType | undefined { + const worldAny = this.world as unknown as { registry?: Record> }; + const globalAny = globalThis as unknown as { Hierarchy?: ComponentType; Transform?: ComponentType }; + const registryComponent = (worldAny.registry?.[name] ?? globalAny[name] ?? fallback) as ComponentType; if (!registryComponent) { return undefined; } - if (ensureRegistered && !this.world.isComponentRegistered(registryComponent as any)) { - this.world.registerComponentType(registryComponent as any); + if (ensureRegistered && !this.world.isComponentRegistered(registryComponent)) { + this.world.registerComponentType(registryComponent); } - return registryComponent as ComponentType; + return registryComponent; } private _getSortedComponents(): Array<[ComponentType, Component]> { @@ -801,7 +923,7 @@ export class Actor< } } - private _emitEvent(eventType: string, data: any): void { + private _emitEvent(eventType: string, data: Record): void { try { if (this._eventBus && typeof this._eventBus.emit === 'function') { this._eventBus.emit(eventType, { @@ -822,7 +944,7 @@ export class Actor< } } - on(eventType: string, handler: (data: any) => void): () => void { + on(eventType: string, handler: (data: unknown) => void): () => void { if (!this._eventBus || typeof this._eventBus.on !== 'function') { console.warn('Event bus not available'); return () => {}; diff --git a/web/packages/ecs-runtime/src/component-system/core/component.ts b/web/packages/ecs-runtime/src/component-system/core/component.ts index c2bdb62f..cedf1077 100644 --- a/web/packages/ecs-runtime/src/component-system/core/component.ts +++ b/web/packages/ecs-runtime/src/component-system/core/component.ts @@ -2,6 +2,7 @@ import type { Entity } from '../types/core'; import type { ComponentType, ComponentMetadata } from '../types/component'; import type { World } from './world'; import type { Actor } from './actor'; +import type { Transform } from '../components/transform'; import { getComponentMetadata } from '../decorators/script'; const getComponentTypeName = (componentType: ComponentType): string => @@ -97,6 +98,7 @@ export interface ComponentConfig { readonly executeInEditMode?: boolean; readonly enableMetrics?: boolean; readonly enableCaching?: boolean; + readonly trackInstances?: boolean; readonly validateOnUpdate?: boolean; readonly autoSerialize?: boolean; } @@ -131,17 +133,18 @@ export abstract class Component< private _totalUpdateTime: number = 0; private readonly _enableMetrics: boolean; private readonly _enableCaching: boolean; + private readonly _trackInstances: boolean; private readonly _validateOnUpdate: boolean; private readonly _autoSerialize: boolean; private readonly _cache: ComponentCache; private readonly _cacheTimeout: number = 1000; - private readonly _eventSubscriptions = new Set<() => void>(); - private readonly _cleanupTasks = new Set<() => void>(); + private _eventSubscriptions?: Set<() => void>; + private _cleanupTasks?: Set<() => void>; - private readonly _dependencies = new Map(); - private readonly _dependents = new Set>(); + private _dependencies?: Map; + private _dependents?: Set>; constructor(config: TConfig = {} as TConfig) { this._creationTime = performance.now(); @@ -152,6 +155,10 @@ export abstract class Component< this._executeInEditMode = config.executeInEditMode ?? false; this._enableMetrics = config.enableMetrics ?? false; this._enableCaching = config.enableCaching ?? true; + this._trackInstances = + config.trackInstances ?? + getComponentMetadata(this.constructor as ComponentType)?.trackInstances ?? + true; this._validateOnUpdate = config.validateOnUpdate ?? false; this._autoSerialize = config.autoSerialize ?? false; @@ -160,7 +167,9 @@ export abstract class Component< lastCacheUpdate: 0, }; - this._registerInstance(); + if (this._trackInstances) { + this._registerInstance(); + } this._initialize(); } @@ -271,7 +280,7 @@ export abstract class Component< }; } - get transform(): any { + get transform(): Transform | undefined { if (!this._enableCaching) { return this._getTransformDirect(); } @@ -344,10 +353,10 @@ export abstract class Component< const component = this.actor.addComponent(componentType, ...args); - this._dependencies.set(componentType, component); + this._getDependencies().set(componentType, component); if (component instanceof Component) { - component._dependents.add(new WeakRef(this)); + component._getDependents().add(new WeakRef(this)); } this._clearCache(); @@ -362,7 +371,7 @@ export abstract class Component< const component = this.getComponent(componentType); if (component instanceof Component) { - for (const dependentRef of component._dependents) { + for (const dependentRef of component._dependents ?? []) { const dependent = dependentRef.deref(); if (dependent && dependent !== this) { throw new ComponentError( @@ -375,7 +384,7 @@ export abstract class Component< } this.actor.removeComponent(componentType); - this._dependencies.delete(componentType); + this._dependencies?.delete(componentType); this._clearCache(); } @@ -660,8 +669,8 @@ export abstract class Component< entity: this.entity, actor: this.actor?.id, world: this.world ? 'attached' : 'detached', - dependencies: Array.from(this._dependencies.keys()).map((type) => type.name), - dependents: Array.from(this._dependents) + dependencies: Array.from(this._dependencies?.keys() ?? []).map((type) => type.name), + dependents: Array.from(this._dependents ?? []) .map((ref) => ref.deref()?.constructor.name) .filter(Boolean), metrics: this.metrics, @@ -720,6 +729,38 @@ export abstract class Component< return this._cache.componentCache; } + private _getCleanupTasks(): Set<() => void> { + if (!this._cleanupTasks) { + this._cleanupTasks = new Set(); + } + + return this._cleanupTasks; + } + + private _getEventSubscriptions(): Set<() => void> { + if (!this._eventSubscriptions) { + this._eventSubscriptions = new Set(); + } + + return this._eventSubscriptions; + } + + private _getDependencies(): Map { + if (!this._dependencies) { + this._dependencies = new Map(); + } + + return this._dependencies; + } + + private _getDependents(): Set> { + if (!this._dependents) { + this._dependents = new Set(); + } + + return this._dependents; + } + private _getActorCache(): Map[]> { if (!this._cache.actorCache) { this._cache.actorCache = new Map(); @@ -736,13 +777,13 @@ export abstract class Component< } } - private _getTransformDirect(): any { + private _getTransformDirect(): Transform | undefined { if (!this.actor) { return undefined; } - const TransformClass = - (this.world as any)?.registry?.Transform || (globalThis as any).Transform; + const worldAny = this.world as unknown as { registry?: { Transform?: ComponentType } } | null; + const TransformClass = worldAny?.registry?.Transform ?? (globalThis as unknown as { Transform?: ComponentType }).Transform; if (TransformClass) { return this.actor.getComponent(TransformClass); @@ -825,7 +866,7 @@ export abstract class Component< if (typeof lifecycleMethod === 'function') { try { - (lifecycleMethod as any).apply(this, args); + (lifecycleMethod as (...args: any[]) => void).apply(this, args); } catch (error) { throw new ComponentLifecycleError( `Lifecycle method ${String(method)} failed`, @@ -859,11 +900,11 @@ export abstract class Component< size += (this._cache.componentCache?.size ?? 0) * 50; size += (this._cache.actorCache?.size ?? 0) * 100; - size += this._dependencies.size * 50; - size += this._dependents.size * 50; + size += (this._dependencies?.size ?? 0) * 50; + size += (this._dependents?.size ?? 0) * 50; - size += this._eventSubscriptions.size * 30; - size += this._cleanupTasks.size * 30; + size += (this._eventSubscriptions?.size ?? 0) * 30; + size += (this._cleanupTasks?.size ?? 0) * 30; return size; } catch (error) { @@ -882,7 +923,7 @@ export abstract class Component< this._cache.lastCacheUpdate = 0; } - private _emitEvent(eventType: string, data: any): void { + private _emitEvent(eventType: string, data: Record): void { try { const eventBus = (this.world as any)?.eventBus; if (eventBus && typeof eventBus.emit === 'function') { @@ -902,7 +943,7 @@ export abstract class Component< private _cleanup(): void { try { - for (const cleanup of this._cleanupTasks) { + for (const cleanup of this._cleanupTasks ?? []) { try { cleanup(); } catch (error) { @@ -910,7 +951,7 @@ export abstract class Component< } } - for (const unsubscribe of this._eventSubscriptions) { + for (const unsubscribe of this._eventSubscriptions ?? []) { try { unsubscribe(); } catch (error) { @@ -918,20 +959,24 @@ export abstract class Component< } } - this._dependencies.clear(); + this._dependencies?.clear(); - for (const dependentRef of this._dependents) { + for (const dependentRef of this._dependents ?? []) { const dependent = dependentRef.deref(); if (dependent) { - dependent._dependencies.delete(this.constructor as ComponentType); + dependent._dependencies?.delete(this.constructor as ComponentType); } } - this._dependents.clear(); + this._dependents?.clear(); this._clearCache(); - this._cleanupTasks.clear(); - this._eventSubscriptions.clear(); + this._cleanupTasks?.clear(); + this._eventSubscriptions?.clear(); + this._cleanupTasks = undefined; + this._eventSubscriptions = undefined; + this._dependencies = undefined; + this._dependents = undefined; } catch (error) { console.error( `Component cleanup failed for ${this.constructor.name}:${this._id}:`, @@ -952,10 +997,14 @@ export abstract class Component< this._clearCache(); - this._eventSubscriptions.clear(); - this._cleanupTasks.clear(); - this._dependencies.clear(); - this._dependents.clear(); + this._eventSubscriptions?.clear(); + this._cleanupTasks?.clear(); + this._dependencies?.clear(); + this._dependents?.clear(); + this._eventSubscriptions = undefined; + this._cleanupTasks = undefined; + this._dependencies = undefined; + this._dependents = undefined; this.entity = undefined; this.actor = undefined; @@ -966,20 +1015,20 @@ export abstract class Component< addCleanupTask(cleanup: () => void): void { if (typeof cleanup === 'function') { - this._cleanupTasks.add(cleanup); + this._getCleanupTasks().add(cleanup); } } removeCleanupTask(cleanup: () => void): void { - this._cleanupTasks.delete(cleanup); + this._cleanupTasks?.delete(cleanup); } - on(eventType: string, handler: (data: any) => void): () => void { + on(eventType: string, handler: (data: unknown) => void): () => void { try { - const eventBus = (this.world as any)?.eventBus; + const eventBus = (this.world as unknown as { eventBus?: { on: (eventType: string, handler: (data: unknown) => void) => () => void } })?.eventBus; if (eventBus && typeof eventBus.on === 'function') { const unsubscribe = eventBus.on(eventType, handler); - this._eventSubscriptions.add(unsubscribe); + this._getEventSubscriptions().add(unsubscribe); return unsubscribe; } } catch (error) { @@ -1012,7 +1061,7 @@ export abstract class Component< return result; } - static getInstanceCount(this: new (...args: any[]) => T): number { + static getInstanceCount(this: new (...args: unknown[]) => T): number { const instances = Component._componentInstances.get(this as ComponentType); if (!instances) { return 0; @@ -1030,5 +1079,11 @@ export abstract class Component< } export { script, getComponentMetadata, setComponentMetadata } from '../decorators/script'; +export { + property, + getComponentPropertyMetadata, + getComponentPropertyMetadataByKey, + setComponentPropertyMetadata, +} from '../decorators/property'; export type { ComponentMetrics, ComponentCache }; diff --git a/web/packages/ecs-runtime/src/component-system/core/index.ts b/web/packages/ecs-runtime/src/component-system/core/index.ts index e91a6f9e..03859d2f 100644 --- a/web/packages/ecs-runtime/src/component-system/core/index.ts +++ b/web/packages/ecs-runtime/src/component-system/core/index.ts @@ -1,4 +1,10 @@ -export { Component, script, getComponentMetadata } from './component'; +export { + Component, + script, + property, + getComponentMetadata, + getComponentPropertyMetadata, +} from './component'; export * from '../decorators'; export { World } from './world'; export { Actor } from './actor'; diff --git a/web/packages/ecs-runtime/src/component-system/core/world-event-runtime.ts b/web/packages/ecs-runtime/src/component-system/core/world-event-runtime.ts index 93aa19ac..074beac8 100644 --- a/web/packages/ecs-runtime/src/component-system/core/world-event-runtime.ts +++ b/web/packages/ecs-runtime/src/component-system/core/world-event-runtime.ts @@ -1,42 +1,367 @@ -import { WorldEventRuntime as BaseWorldEventRuntime } from '@axrone/ecs-events/world-event-runtime'; -import { ECSObservables, type ECSComponentChangeEvent, type ECSComponentName, type ECSEntityLifecycleEvent, type ECSReactiveQueryResult } from '../observers/ecs-observer'; +import { + createTypedEmitter, + type EventCallback, + type EventKey, + type IEventEmitter, +} from '@axrone/event'; +import type { IObservableSubject } from '@axrone/observer'; +import { + ECSObservables, + type ECSComponentChangeEvent, + type ECSComponentName, + type ECSEntityLifecycleEvent, + type ECSReactiveQueryResult, +} from '../observers/ecs-observer'; import type { ComponentRegistry } from '../types/core'; import type { ECSEventMap } from '../types/events'; -import type { QueryResult } from '../types/system'; + +type ECSComponentAddedEventKey< + R extends ComponentRegistry, + K extends ECSComponentName, +> = Extract<`${string & K}Added`, EventKey>>; + +type ECSComponentRemovedEventKey< + R extends ComponentRegistry, + K extends ECSComponentName, +> = Extract<`${string & K}Removed`, EventKey>>; + +type EntityCreatedEventKey = Extract< + 'EntityCreated', + EventKey> +>; + +type EntityDestroyedEventKey = Extract< + 'EntityDestroyed', + EventKey> +>; type WorldQueryExecutor = ( ...components: readonly ECSComponentName[] ) => readonly ECSReactiveQueryResult[]; -export class WorldEventRuntime extends BaseWorldEventRuntime< - ECSEventMap, - ECSComponentName, - ECSEntityLifecycleEvent, - ECSComponentChangeEvent, - ECSReactiveQueryResult -> { - constructor(componentNames: readonly string[], query: WorldQueryExecutor) { - super( - componentNames as readonly ECSComponentName[], - query as (...components: readonly ECSComponentName[]) => readonly QueryResult< - R, - readonly ECSComponentName[] - >[], - new ECSObservables() - ); +export interface WorldEventMetrics { + readonly emittedCount: number; + readonly handlerCount: number; + readonly queuedCount: number; + readonly errorCount: number; + readonly lastEmittedAt: number | null; +} + +export class WorldEventRuntime { + private readonly _eventBus: IEventEmitter> = createTypedEmitter>(); + private readonly _disposables = new Set<() => void>(); + private readonly _trackedEvents = new Set(); + private readonly _lastEmittedAt = new Map(); + private readonly _bridgedComponents = new Set>(); + + constructor( + componentNames: readonly string[], + private readonly _query: WorldQueryExecutor, + private readonly _observables: ECSObservables = new ECSObservables() + ) { + this._setupEventObserverBridge(componentNames as readonly ECSComponentName[]); } - registerComponent(componentName: string): void { - super.registerComponent(componentName as ECSComponentName); + on>>( + event: T, + handler: EventCallback[T]> + ): () => void { + return this._subscribe(event, handler); + } + + once>>( + event: T, + handler: EventCallback[T]> + ): () => void { + return this._subscribeOnce(event, handler); + } + + async emit>>( + event: T, + data: ECSEventMap[T] + ): Promise { + this._trackedEvents.add(String(event)); + const wasPaused = this._eventBus.isPaused(); + const result = await this._eventBus.emit(event, data); + + if (!wasPaused) { + this._lastEmittedAt.set(String(event), performance.now()); + } + + return result; + } + + emitSync>>(event: T, data: ECSEventMap[T]): boolean { + this._trackedEvents.add(String(event)); + const wasPaused = this._eventBus.isPaused(); + const result = this._eventBus.emitSync(event, data); + + if (!wasPaused) { + this._lastEmittedAt.set(String(event), performance.now()); + } + + return result; + } + + emitSafe>>(event: T, data: ECSEventMap[T]): void { + try { + this.emitSync(event, data); + } catch (error) { + console.error(`Failed to emit event ${String(event)}:`, error); + } + } + + off>>( + event: T, + handler?: EventCallback[T]> + ): boolean { + this._trackedEvents.add(String(event)); + return this._eventBus.off(event, handler); + } + + getEventMetrics>>(event: T): WorldEventMetrics { + this._trackedEvents.add(String(event)); + + const metrics = this._eventBus.getMetrics(event); + const queuedCount = this._eventBus.getPendingCount(event); + + return { + emittedCount: Math.max(0, metrics.emit.count - queuedCount), + handlerCount: this._eventBus.listenerCount(event), + queuedCount, + errorCount: metrics.execution.errors, + lastEmittedAt: this._lastEmittedAt.get(String(event)) ?? null, + }; + } + + getAllEventMetrics(): Record { + const allMetrics: Record = {}; + const eventNames = new Set(this._trackedEvents); + + for (const eventName of this._eventBus.eventNames()) { + eventNames.add(String(eventName)); + } + + for (const eventName of eventNames) { + try { + allMetrics[eventName] = this.getEventMetrics( + eventName as EventKey> + ); + } catch (error) { + console.warn(`Failed to get metrics for event ${eventName}:`, error); + } + } + + return allMetrics; + } + + pause(): void { + this._eventBus.pause(); + } + + resume(): void { + this._eventBus.resume(); + } + + drain(): Promise { + return this._eventBus.drain(); + } + + getObservables(): ECSObservables { + return this._observables; + } + + observeEntityLifecycle() { + return this._observables.createEntityLifecycle(); } observeComponent(componentName: K) { - return super.observeComponent(componentName as unknown as ECSComponentName); + return this._observables.createComponentStream( + componentName as unknown as ECSComponentName + ); } createReactiveQuery(...components: Q) { - return super.createReactiveQuery( - ...(components as unknown as readonly ECSComponentName[]) + const componentNames = components as unknown as readonly ECSComponentName[]; + const queryKey = + componentNames.length === 1 + ? String(componentNames[0]) + : [...componentNames].sort().join(','); + const queryObservable = this._observables.getQueryObservable>( + queryKey, + [] ); + + const updateQuery = () => { + try { + const results = this._query(...componentNames); + this._notifyObservable(queryObservable, [...results], `reactive query ${queryKey}`); + } catch (error) { + console.error('Failed to update reactive query:', error); + } + }; + + const reactiveQueryHandler = () => { + updateQuery(); + }; + + for (const componentName of componentNames) { + this._subscribeReactiveQueryComponent(componentName, reactiveQueryHandler); + } + + this._subscribe(this._getEntityCreatedEventKey(), reactiveQueryHandler); + this._subscribe(this._getEntityDestroyedEventKey(), reactiveQueryHandler); + + updateQuery(); + + return queryObservable; + } + + registerComponent(componentName: string): void { + this._registerComponentEventBridge(componentName as ECSComponentName); + } + + dispose(): void { + try { + this._eventBus.dispose(); + } catch (error) { + console.error('Failed to dispose event bus:', error); + } + + try { + this._observables.dispose(); + } catch (error) { + console.error('Failed to dispose observables:', error); + } + + for (const dispose of this._disposables) { + try { + dispose(); + } catch (error) { + console.error('Failed to execute disposal task:', error); + } + } + + this._disposables.clear(); + this._bridgedComponents.clear(); + this._trackedEvents.clear(); + this._lastEmittedAt.clear(); + } + + private _trackDisposer(unsubscribe: () => unknown): () => void { + const dispose = () => { + unsubscribe(); + this._disposables.delete(dispose); + }; + + this._disposables.add(dispose); + return dispose; + } + + private _subscribe>>( + event: T, + handler: EventCallback[T]> + ): () => void { + this._trackedEvents.add(String(event)); + return this._trackDisposer(this._eventBus.on(event, handler)); + } + + private _subscribeOnce>>( + event: T, + handler: EventCallback[T]> + ): () => void { + this._trackedEvents.add(String(event)); + return this._trackDisposer(this._eventBus.once(event, handler)); + } + + private _getEntityCreatedEventKey(): EntityCreatedEventKey { + return 'EntityCreated' as EntityCreatedEventKey; + } + + private _getEntityDestroyedEventKey(): EntityDestroyedEventKey { + return 'EntityDestroyed' as EntityDestroyedEventKey; + } + + private _getAddedEventKey>( + componentName: K + ): ECSComponentAddedEventKey { + return `${componentName}Added` as ECSComponentAddedEventKey; + } + + private _getRemovedEventKey>( + componentName: K + ): ECSComponentRemovedEventKey { + return `${componentName}Removed` as ECSComponentRemovedEventKey; + } + + private _subscribeReactiveQueryComponent>( + componentName: K, + handler: () => void + ): void { + this._subscribe(this._getAddedEventKey(componentName), handler); + this._subscribe(this._getRemovedEventKey(componentName), handler); + } + + private _setupEventObserverBridge(componentNames: readonly ECSComponentName[]): void { + this._subscribe(this._getEntityCreatedEventKey(), (data) => { + this._notifyObservable( + this._observables.entityCreated, + data as ECSEntityLifecycleEvent, + 'entity created' + ); + }); + + this._subscribe(this._getEntityDestroyedEventKey(), (data) => { + this._notifyObservable( + this._observables.entityDestroyed, + data as ECSEntityLifecycleEvent, + 'entity destroyed' + ); + }); + + for (const componentName of componentNames) { + this._registerComponentEventBridge(componentName); + } + } + + private _registerComponentEventBridge>( + componentName: K + ): void { + if (this._bridgedComponents.has(componentName)) { + return; + } + + this._bridgedComponents.add(componentName); + + const addedEvent = this._getAddedEventKey(componentName); + const removedEvent = this._getRemovedEventKey(componentName); + + this._subscribe(addedEvent, (data) => { + const observables = this._observables.getComponentObservables(componentName); + this._notifyObservable( + observables.added, + data as ECSComponentChangeEvent, + `${componentName} added` + ); + }); + + this._subscribe(removedEvent, (data) => { + const observables = this._observables.getComponentObservables(componentName); + this._notifyObservable( + observables.removed, + data as ECSComponentChangeEvent, + `${componentName} removed` + ); + }); + } + + private _notifyObservable( + observable: IObservableSubject, + data: T, + context: string + ): void { + void observable.notify(data).catch((error) => { + console.error(`Failed to notify ${context}:`, error); + }); } } diff --git a/web/packages/ecs-runtime/src/component-system/core/world-mutation-runtime.ts b/web/packages/ecs-runtime/src/component-system/core/world-mutation-runtime.ts index d2395c64..7b4f83bb 100644 --- a/web/packages/ecs-runtime/src/component-system/core/world-mutation-runtime.ts +++ b/web/packages/ecs-runtime/src/component-system/core/world-mutation-runtime.ts @@ -1,4 +1,4 @@ -import type { EventKey } from '@axrone/ecs-events/event'; +import type { EventKey } from '@axrone/event'; import type { WorldStorageRuntime } from '@axrone/ecs-storage/world-storage-runtime'; import { getComponentMetadata } from '../decorators/script'; import type { @@ -43,6 +43,25 @@ export class WorldMutationRuntime { return entity; } + createEntityWithComponents(components: Record): Entity { + const entries = Object.entries(components).filter(([, component]) => component !== undefined); + + if (entries.length === 0) { + return this.createEntity(); + } + + const resolution = this._options.storage.createEntityWithComponents( + Object.fromEntries(entries) + ); + + if (resolution.createdArchetype) { + this._options.onStructureChange(); + } + + this._options.onMutation(); + return resolution.entity; + } + destroyEntity(entity: Entity): void { const removedEntity = this._options.storage.destroyEntity(entity); if (!removedEntity) { diff --git a/web/packages/ecs-runtime/src/component-system/core/world.ts b/web/packages/ecs-runtime/src/component-system/core/world.ts index c9a989e9..e2837e9b 100644 --- a/web/packages/ecs-runtime/src/component-system/core/world.ts +++ b/web/packages/ecs-runtime/src/component-system/core/world.ts @@ -6,7 +6,7 @@ import type { ComponentConstructor, } from '../types/core'; import type { QueryResult } from '../types/system'; -import type { EventKey } from '@axrone/ecs-events/event'; +import type { EventKey } from '@axrone/event'; import { OptimizedQueryCache } from '@axrone/ecs-query/query-cache'; import { WorldQueryRuntime } from '@axrone/ecs-query/world-query-runtime'; import { WorldStorageRuntime } from '@axrone/ecs-storage/world-storage-runtime'; @@ -207,6 +207,31 @@ export class World { } } + createEntityWithComponents(components: Record): Entity { + this._validateWorldState('createEntityWithComponents'); + + if (this._storage.entityCount >= this._config.maxEntities) { + throw new WorldError( + `Maximum entity limit (${this._config.maxEntities}) reached`, + 'createEntityWithComponents' + ); + } + + for (const componentName of Object.keys(components)) { + this._validateComponentName(componentName as keyof R, 'createEntityWithComponents'); + } + + try { + return this._mutations.createEntityWithComponents(components); + } catch (error) { + throw new WorldError( + 'Failed to create entity with components', + 'createEntityWithComponents', + error instanceof Error ? error : new Error(String(error)) + ); + } + } + destroyEntity(entity: Entity): void { this._validateWorldState('destroyEntity'); this._validateEntity(entity, 'destroyEntity'); diff --git a/web/packages/ecs-runtime/src/component-system/decorators/index.ts b/web/packages/ecs-runtime/src/component-system/decorators/index.ts index 0674dfbd..24dcf54b 100644 --- a/web/packages/ecs-runtime/src/component-system/decorators/index.ts +++ b/web/packages/ecs-runtime/src/component-system/decorators/index.ts @@ -11,4 +11,18 @@ export { __debugScriptSystem, } from './script'; +export { + property, + getComponentPropertyMetadata, + getComponentPropertyMetadataByKey, + setComponentPropertyMetadata, + clearComponentPropertyMetadataCaches, +} from './property'; + export type { ScriptMetadata, ScriptDecoratorOptions, ValidationResult } from './script'; +export type { + PropertyMetadata, + PropertyDecoratorOptions, + PropertyTypeId, + PropertyTypeReference, +} from './property'; diff --git a/web/packages/ecs-runtime/src/component-system/decorators/property.ts b/web/packages/ecs-runtime/src/component-system/decorators/property.ts new file mode 100644 index 00000000..82986424 --- /dev/null +++ b/web/packages/ecs-runtime/src/component-system/decorators/property.ts @@ -0,0 +1,158 @@ +import type { ComponentType } from '../types/component'; +import type { Component } from '../core/component'; + +export type PropertyTypeId = + | 'boolean' + | 'number' + | 'string' + | 'vec2' + | 'vec3' + | 'entity' + | 'transform'; + +export type PropertyTypeReference = PropertyTypeId | string | Function; + +export interface PropertyMetadata { + readonly propertyKey: string; + readonly label?: string; + readonly description?: string; + readonly type?: PropertyTypeReference; + readonly defaultValue?: unknown; + readonly serializable?: boolean; + readonly visible?: boolean; + readonly min?: number; + readonly max?: number; + readonly step?: number; +} + +export interface PropertyDecoratorOptions extends Omit {} + +let explicitPropertyMetadataMap = new WeakMap>(); +let prototypePropertyMetadataMap = new WeakMap>(); + +const normalizePropertyMetadata = ( + propertyKey: string, + metadata: PropertyDecoratorOptions | PropertyMetadata, +): PropertyMetadata => ({ + propertyKey, + label: metadata.label, + description: metadata.description, + type: metadata.type, + defaultValue: metadata.defaultValue, + serializable: metadata.serializable ?? true, + visible: metadata.visible ?? true, + min: metadata.min, + max: metadata.max, + step: metadata.step, +}); + +const getOrCreatePrototypeMetadata = (target: object): Map => { + const existing = prototypePropertyMetadataMap.get(target); + if (existing) { + return existing; + } + + const created = new Map(); + prototypePropertyMetadataMap.set(target, created); + return created; +}; + +const getOrCreateExplicitMetadata = (componentType: ComponentType): Map => { + const existing = explicitPropertyMetadataMap.get(componentType); + if (existing) { + return existing; + } + + const created = new Map(); + explicitPropertyMetadataMap.set(componentType, created); + return created; +}; + +const collectPrototypePropertyMetadata = (componentType: ComponentType): Map => { + const collected = new Map(); + const prototypes: object[] = []; + let current = componentType.prototype; + + while (current && current !== Object.prototype) { + prototypes.unshift(current); + current = Object.getPrototypeOf(current); + } + + for (const prototype of prototypes) { + const metadata = prototypePropertyMetadataMap.get(prototype); + if (!metadata) { + continue; + } + + for (const [propertyKey, propertyMetadata] of metadata.entries()) { + collected.set(propertyKey, propertyMetadata); + } + } + + return collected; +}; + +export function property( + options: PropertyDecoratorOptions = {}, +): (target: object, propertyKey: string | symbol) => void { + return function propertyDecorator(target: object, propertyKey: string | symbol): void { + if (typeof propertyKey !== 'string') { + return; + } + + const metadata = normalizePropertyMetadata(propertyKey, options); + const prototypeMetadata = getOrCreatePrototypeMetadata(target); + prototypeMetadata.set(propertyKey, metadata); + + const componentType = (target as { constructor?: ComponentType }).constructor; + if (typeof componentType === 'function') { + const explicitMetadata = getOrCreateExplicitMetadata(componentType); + explicitMetadata.set(propertyKey, metadata); + } + }; +} + +export function getComponentPropertyMetadata( + componentType: ComponentType, +): readonly PropertyMetadata[] { + const merged = collectPrototypePropertyMetadata(componentType); + const explicitMetadata = explicitPropertyMetadataMap.get(componentType); + + if (explicitMetadata) { + for (const [propertyKey, propertyMetadata] of explicitMetadata.entries()) { + merged.set(propertyKey, propertyMetadata); + } + } + + return [...merged.values()]; +} + +export function getComponentPropertyMetadataByKey( + componentType: ComponentType, + propertyKey: string, +): PropertyMetadata | undefined { + return getComponentPropertyMetadata(componentType).find( + (metadata) => metadata.propertyKey === propertyKey, + ); +} + +export function setComponentPropertyMetadata( + componentType: ComponentType, + metadata: readonly PropertyMetadata[], +): void { + const nextMetadata = new Map(); + + for (const propertyMetadata of metadata) { + nextMetadata.set( + propertyMetadata.propertyKey, + normalizePropertyMetadata(propertyMetadata.propertyKey, propertyMetadata), + ); + } + + explicitPropertyMetadataMap.set(componentType, nextMetadata); +} + +export function clearComponentPropertyMetadataCaches(): void { + explicitPropertyMetadataMap = new WeakMap>(); + prototypePropertyMetadataMap = new WeakMap>(); +} diff --git a/web/packages/ecs-runtime/src/component-system/decorators/script.ts b/web/packages/ecs-runtime/src/component-system/decorators/script.ts index c45b278d..824f6857 100644 --- a/web/packages/ecs-runtime/src/component-system/decorators/script.ts +++ b/web/packages/ecs-runtime/src/component-system/decorators/script.ts @@ -204,6 +204,8 @@ export function script( singleton: options.singleton || false, executeInEditMode: options.executeInEditMode || false, priority: options.priority || 0, + allowMultiple: options.allowMultiple ?? false, + trackInstances: options.trackInstances ?? true, version: options.version, author: options.author, description: options.description, diff --git a/web/packages/ecs-runtime/src/component-system/observers/ecs-observer.ts b/web/packages/ecs-runtime/src/component-system/observers/ecs-observer.ts index d0fa5f7a..1975b1d7 100644 --- a/web/packages/ecs-runtime/src/component-system/observers/ecs-observer.ts +++ b/web/packages/ecs-runtime/src/component-system/observers/ecs-observer.ts @@ -1,4 +1,8 @@ -import { ECSObservables as ECSObservablesBase } from '@axrone/ecs-events/ecs-observer'; +import { + createBehaviorSubject, + createSubject, + type IObservableSubject, +} from '@axrone/observer'; import type { ComponentInstance, ComponentRegistry, Entity } from '../types/core'; import type { QueryResult } from '../types/system'; import type { Actor } from '../core/actor'; @@ -21,9 +25,268 @@ export type ECSReactiveQueryResult = QueryResult< readonly ECSComponentName[] >; -export class ECSObservables extends ECSObservablesBase< - ECSComponentName, - ECSEntityLifecycleEvent, - ECSComponentChangeEvent, - ECSReactiveQueryResult -> {} +export interface ECSSystemExecutionStartEvent { + readonly systemId: string; + readonly deltaTime: number; +} + +export interface ECSSystemExecutionEndEvent extends ECSSystemExecutionStartEvent { + readonly duration: number; +} + +export interface ECSFrameStartEvent { + readonly frameId: number; + readonly timestamp: number; +} + +export interface ECSFrameEndEvent extends ECSFrameStartEvent { + readonly duration: number; +} + +export type ECSComponentChangeAction = 'added' | 'removed'; +export type ECSEntityLifecycleAction = 'created' | 'destroyed'; + +export type ECSComponentChange = TComponentEvent & { + readonly action: ECSComponentChangeAction; +}; + +export type ECSEntityLifecycleChange = TEntityLifecycle & { + readonly action: ECSEntityLifecycleAction; +}; + +export interface ECSComponentStream { + readonly added: IObservableSubject>; + readonly removed: IObservableSubject>; + readonly changes: IObservableSubject>>; +} + +export interface ECSEntityLifecycleStreams { + readonly all: IObservableSubject>; + byName(name: string): { + readonly created: IObservableSubject; + readonly destroyed: IObservableSubject; + }; + byTag(tag: string): { + readonly created: IObservableSubject; + readonly destroyed: IObservableSubject; + }; + byLayer(layer: number): { + readonly created: IObservableSubject; + readonly destroyed: IObservableSubject; + }; +} + +export class ECSObservables { + readonly entityCreated: IObservableSubject; + readonly entityDestroyed: IObservableSubject; + + readonly systemExecutionStart: IObservableSubject; + readonly systemExecutionEnd: IObservableSubject; + + readonly frameStart: IObservableSubject; + readonly frameEnd: IObservableSubject; + + private readonly componentObservables = new Map< + string, + { + added: IObservableSubject>; + removed: IObservableSubject>; + } + >(); + + private readonly queryObservables = new Map>(); + + constructor() { + this.entityCreated = createSubject(); + this.entityDestroyed = createSubject(); + this.systemExecutionStart = createSubject(); + this.systemExecutionEnd = createSubject(); + this.frameStart = createSubject(); + this.frameEnd = createSubject(); + } + + getComponentObservables(componentName: ECSComponentName) { + const key = componentName as string; + + if (!this.componentObservables.has(key)) { + this.componentObservables.set(key, { + added: createSubject>(), + removed: createSubject>(), + }); + } + + return this.componentObservables.get(key)!; + } + + getQueryObservable(queryKey: string, initialValue: T[] = []): IObservableSubject { + if (!this.queryObservables.has(queryKey)) { + this.queryObservables.set( + queryKey, + createBehaviorSubject(initialValue) as IObservableSubject + ); + } + + return this.queryObservables.get(queryKey)! as IObservableSubject; + } + + createEntityFilter( + predicate: (data: ECSEntityLifecycleEvent) => boolean + ): IObservableSubject { + return this._createEntityFilterFrom(this.entityCreated, predicate); + } + + createComponentStream(componentName: ECSComponentName): ECSComponentStream { + const observables = this.getComponentObservables(componentName); + const changes = createSubject>>(); + + observables.added.addObserver((data) => { + this._notifySubject(changes, { ...data, action: 'added' }, `${componentName} changes`); + }); + + observables.removed.addObserver((data) => { + this._notifySubject(changes, { ...data, action: 'removed' }, `${componentName} changes`); + }); + + return { + added: observables.added, + removed: observables.removed, + changes, + }; + } + + createDebouncedQuery( + queryKey: string, + debounceMs: number = 100, + initialValue: T[] = [] + ): IObservableSubject { + const queryObservable = this.getQueryObservable(queryKey, initialValue); + const debounced = createSubject(); + + let timeoutId: ReturnType | undefined; + + queryObservable.addObserver((data) => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + + timeoutId = setTimeout(() => { + this._notifySubject(debounced, data, `debounced query ${queryKey}`); + }, debounceMs); + }); + + return debounced; + } + + createThrottledQuery( + queryKey: string, + throttleMs: number = 100, + initialValue: T[] = [] + ): IObservableSubject { + const queryObservable = this.getQueryObservable(queryKey, initialValue); + const throttled = createSubject(); + + let lastExecution = 0; + + queryObservable.addObserver((data) => { + const now = Date.now(); + if (now - lastExecution >= throttleMs) { + this._notifySubject(throttled, data, `throttled query ${queryKey}`); + lastExecution = now; + } + }); + + return throttled; + } + + createEntityLifecycle(): ECSEntityLifecycleStreams { + const all = createSubject>(); + + this.entityCreated.addObserver((data) => { + this._notifySubject(all, { ...data, action: 'created' }, 'entity lifecycle stream'); + }); + + this.entityDestroyed.addObserver((data) => { + this._notifySubject(all, { ...data, action: 'destroyed' }, 'entity lifecycle stream'); + }); + + return { + all, + byName: (name: string) => ({ + created: this._createEntityFilterFrom( + this.entityCreated, + ({ actor }) => actor.name === name + ), + destroyed: this._createEntityFilterFrom( + this.entityDestroyed, + ({ actor }) => actor.name === name + ), + }), + byTag: (tag: string) => ({ + created: this._createEntityFilterFrom( + this.entityCreated, + ({ actor }) => actor.tag === tag + ), + destroyed: this._createEntityFilterFrom( + this.entityDestroyed, + ({ actor }) => actor.tag === tag + ), + }), + byLayer: (layer: number) => ({ + created: this._createEntityFilterFrom( + this.entityCreated, + ({ actor }) => actor.layer === layer + ), + destroyed: this._createEntityFilterFrom( + this.entityDestroyed, + ({ actor }) => actor.layer === layer + ), + }), + }; + } + + dispose(): void { + this.entityCreated.dispose(); + this.entityDestroyed.dispose(); + this.systemExecutionStart.dispose(); + this.systemExecutionEnd.dispose(); + this.frameStart.dispose(); + this.frameEnd.dispose(); + + this.componentObservables.forEach(({ added, removed }) => { + added.dispose(); + removed.dispose(); + }); + + this.queryObservables.forEach((observable) => { + observable.dispose(); + }); + + this.componentObservables.clear(); + this.queryObservables.clear(); + } + + private _createEntityFilterFrom( + source: IObservableSubject, + predicate: (data: ECSEntityLifecycleEvent) => boolean + ): IObservableSubject { + const filtered = createSubject(); + + source.addObserver((data) => { + if (predicate(data)) { + this._notifySubject(filtered, data, 'filtered entity lifecycle stream'); + } + }); + + return filtered; + } + + private _notifySubject( + subject: IObservableSubject, + data: T, + context: string + ): void { + void subject.notify(data).catch((error) => { + console.error(`Failed to notify ${context}:`, error); + }); + } +} diff --git a/web/packages/ecs-runtime/src/component-system/types/component.ts b/web/packages/ecs-runtime/src/component-system/types/component.ts index 258a52cc..647b4357 100644 --- a/web/packages/ecs-runtime/src/component-system/types/component.ts +++ b/web/packages/ecs-runtime/src/component-system/types/component.ts @@ -1,5 +1,6 @@ import type { Component } from '../core/component'; +// Using any[] for construct signature compatibility - type safety enforced at usage sites via ComponentType export type ComponentType = new (...args: any[]) => T; export type ComponentMetadata = { @@ -8,6 +9,8 @@ export type ComponentMetadata = { readonly singleton?: boolean; readonly executeInEditMode?: boolean; readonly priority?: number; + readonly allowMultiple?: boolean; + readonly trackInstances?: boolean; }; export interface IComponentPool { diff --git a/web/packages/ecs-runtime/src/component-system/types/core.ts b/web/packages/ecs-runtime/src/component-system/types/core.ts index 5f7c7453..5318a7e3 100644 --- a/web/packages/ecs-runtime/src/component-system/types/core.ts +++ b/web/packages/ecs-runtime/src/component-system/types/core.ts @@ -1,3 +1,5 @@ +import type { Component } from '../core/component'; + declare const EntityBrand: unique symbol; declare const ComponentBrand: unique symbol; declare const SystemBrand: unique symbol; @@ -10,7 +12,7 @@ export type SystemId = T & { readonly [SystemBrand]: export type ActorId = T & { readonly [ActorBrand]: true }; export type ArchetypeId = string & { readonly [ArchetypeBrand]: true }; -export type ComponentConstructor = new (...args: any[]) => T; +export type ComponentConstructor = new (...args: any[]) => T; export type ComponentInstance = T extends ComponentConstructor ? U : never; diff --git a/web/packages/ecs-runtime/src/component-system/types/events.ts b/web/packages/ecs-runtime/src/component-system/types/events.ts index a302db54..71afd3ca 100644 --- a/web/packages/ecs-runtime/src/component-system/types/events.ts +++ b/web/packages/ecs-runtime/src/component-system/types/events.ts @@ -1,6 +1,6 @@ import type { ComponentRegistry, ComponentInstance, Entity } from './core'; import type { Actor } from '../core/actor'; -import type { EventMap } from '@axrone/ecs-events/event'; +import type { EventMap } from '@axrone/event'; export type ComponentChangeEvent = { readonly [K in keyof R as `${string & K}Added`]: { diff --git a/web/packages/ecs-runtime/src/index.ts b/web/packages/ecs-runtime/src/index.ts index 2c0ae4a3..6b68e051 100644 --- a/web/packages/ecs-runtime/src/index.ts +++ b/web/packages/ecs-runtime/src/index.ts @@ -3,15 +3,20 @@ export { } from './component-system/core/component'; export { script, + property, getComponentMetadata, + getComponentPropertyMetadata, + getComponentPropertyMetadataByKey, getAllScripts, getDependencyTree, getScriptMetrics, setComponentMetadata, + setComponentPropertyMetadata, validateAllScripts, clearScriptCaches, + clearComponentPropertyMetadataCaches, __debugScriptSystem, -} from './component-system/decorators/script'; +} from './component-system/decorators'; export type { ComponentConfig, ComponentDebug, @@ -24,7 +29,11 @@ export type { ScriptDecoratorOptions, ScriptMetadata, ValidationResult, -} from './component-system/decorators/script'; + PropertyMetadata, + PropertyDecoratorOptions, + PropertyTypeId, + PropertyTypeReference, +} from './component-system/decorators'; export { Actor, ActorError } from './component-system/core/actor'; export type { diff --git a/web/packages/ecs-storage/package.json b/web/packages/ecs-storage/package.json index 664d8829..e1d7305f 100644 --- a/web/packages/ecs-storage/package.json +++ b/web/packages/ecs-storage/package.json @@ -51,6 +51,6 @@ "test": "vitest run" }, "dependencies": { - "@axrone/utility": "^0.0.1" + "@axrone/memory": "^0.0.1" } -} \ No newline at end of file +} diff --git a/web/packages/ecs-storage/src/component-pool.ts b/web/packages/ecs-storage/src/component-pool.ts index 49a3c77d..3d86014a 100644 --- a/web/packages/ecs-storage/src/component-pool.ts +++ b/web/packages/ecs-storage/src/component-pool.ts @@ -1,4 +1,4 @@ -import { ObjectPool, type ObjectPoolOptions } from '@axrone/utility'; +import { ObjectPool, type ObjectPoolOptions } from '@axrone/memory'; import type { StorageComponentConstructor, StorageComponentPool } from './types'; export interface ComponentPoolConfig { @@ -292,4 +292,4 @@ export class ComponentPool } } } -} \ No newline at end of file +} diff --git a/web/packages/ecs-storage/src/world-storage-runtime.ts b/web/packages/ecs-storage/src/world-storage-runtime.ts index 10696b15..df079c91 100644 --- a/web/packages/ecs-storage/src/world-storage-runtime.ts +++ b/web/packages/ecs-storage/src/world-storage-runtime.ts @@ -12,6 +12,15 @@ export interface WorldDestroyedEntity< readonly removedComponents: Record; } +export interface WorldCreatedEntity< + TEntity extends number = number, + TArchetypeId extends string = string, +> { + readonly entity: TEntity; + readonly archetypeId: TArchetypeId; + readonly createdArchetype: boolean; +} + export interface WorldStorageDebugInfo< R extends StorageComponentRegistry, TEntity extends number = number, @@ -68,6 +77,22 @@ export class WorldStorageRuntime< return entity; } + createEntityWithComponents( + components: Record + ): WorldCreatedEntity { + const { entity } = this._entityStore.createEntity(); + const resolution = this._archetypeStore.getOrCreateArchetype(Object.keys(components)); + + resolution.archetype.addEntity(entity, components); + this._entityStore.setEntityArchetype(entity, resolution.archetype.id); + + return { + entity, + archetypeId: resolution.archetype.id, + createdArchetype: resolution.created, + }; + } + destroyEntity(entity: TEntity): WorldDestroyedEntity | undefined { const archetypeId = this._entityStore.getEntityArchetypeId(entity); if (!archetypeId) { diff --git a/web/packages/event/package.json b/web/packages/event/package.json index c7fcaa2d..77027ee0 100644 --- a/web/packages/event/package.json +++ b/web/packages/event/package.json @@ -21,6 +21,6 @@ "test": "vitest run" }, "dependencies": { - "@axrone/utility": "^0.0.1" + "@axrone/memory": "^0.0.1" } -} \ No newline at end of file +} diff --git a/web/packages/event/src/definition.ts b/web/packages/event/src/definition.ts index dbf5e98d..d66408ad 100644 --- a/web/packages/event/src/definition.ts +++ b/web/packages/event/src/definition.ts @@ -1,35 +1,43 @@ -export type EventCallback = (data: T) => void | Promise; +export type EventCallback = (data: T) => void | Promise; export type UnsubscribeFn = () => boolean; -export type EventKey = string & keyof T; -export type EventMap = Record; +export type EventMap = Record; +export type EventKey = Extract; export type EventPriority = 'high' | 'normal' | 'low'; +export type EventDispatchItem = { + [K in EventKey]: { + readonly event: K; + readonly data: T[K]; + readonly priority?: EventPriority; + }; +}[EventKey]; + export type ExtractEventData< TEventMap extends EventMap, - TEventKey extends keyof TEventMap, + TEventKey extends EventKey, > = TEventMap[TEventKey]; -export type EventNames = keyof T & string; +export type EventNames = EventKey; -export type OptionalData = T extends undefined ? T | void : T; +export type OptionalData = [T] extends [undefined] ? T | void : T; export function isValidEventName(eventName: unknown): eventName is string { return typeof eventName === 'string' && eventName.length > 0; } -export function isValidCallback(callback: unknown): callback is EventCallback { +export function isValidCallback(callback: unknown): callback is EventCallback { return typeof callback === 'function'; } export function isValidPriority(priority: unknown): priority is EventPriority { - return typeof priority === 'string' && ['high', 'normal', 'low'].includes(priority); + return priority === 'high' || priority === 'normal' || priority === 'low'; } -export const PRIORITY_VALUES: Record = { +export const PRIORITY_VALUES = Object.freeze({ high: 0, normal: 1, low: 2, -} as const; +} satisfies Readonly>); export const DEFAULT_PRIORITY: EventPriority = 'normal'; @@ -43,7 +51,7 @@ export interface EventOptions { readonly gcIntervalMs?: number; } -export const DEFAULT_OPTIONS: Required = Object.freeze({ +export const DEFAULT_OPTIONS = Object.freeze({ captureRejections: false, maxListeners: 10, weakReferences: false, @@ -51,7 +59,7 @@ export const DEFAULT_OPTIONS: Required = Object.freeze({ concurrencyLimit: Infinity, bufferSize: 1000, gcIntervalMs: 60000, -} as const); +} satisfies Required); export const MEMORY_USAGE_SYMBOLS = Object.freeze({ staticSubscriptions: Symbol('staticSubscriptions'), diff --git a/web/packages/event/src/event-emitter.ts b/web/packages/event/src/event-emitter.ts index e9dd6401..fae37f6c 100644 --- a/web/packages/event/src/event-emitter.ts +++ b/web/packages/event/src/event-emitter.ts @@ -1,4 +1,3 @@ -import { PriorityQueue } from '@axrone/utility'; import { performance } from './performance'; import { EventCallback, @@ -8,8 +7,10 @@ import { UnsubscribeFn, EventOptions, DEFAULT_OPTIONS, + DEFAULT_PRIORITY, PRIORITY_VALUES, MEMORY_USAGE_SYMBOLS, + EventDispatchItem, } from './definition'; import { EventHandlerError, EventQueueFullError } from './errors'; import { @@ -22,7 +23,8 @@ import { QueuedEvent, EventMetrics, } from './interfaces'; -import { EventScheduler } from './event-scheduler'; +import { EventScheduler, TaskPriority } from './event-scheduler'; +import { EVENT_EMITTER_TAP, EventTap, EventTapContext } from './internals'; export interface IEventEmitter extends IEventSubscriber, @@ -50,40 +52,214 @@ export interface IEventEmitter dispose(): void; } +type InternalCallback = EventCallback | WeakRef>; + +interface InternalSubscription { + readonly id: symbol; + readonly event: string; + readonly once: boolean; + readonly priority: EventPriority; + readonly createdAt: number; + readonly weak: boolean; + readonly callback: InternalCallback; + readonly unregisterToken?: object; + lastExecuted?: number; + executionCount: number; +} + +interface ListenerBucket { + readonly high: InternalSubscription[]; + readonly normal: InternalSubscription[]; + readonly low: InternalSubscription[]; + size: number; +} + +interface BufferedBucket { + readonly high: QueuedEvent[]; + readonly normal: QueuedEvent[]; + readonly low: QueuedEvent[]; + size: number; +} + +interface TimingAccumulator { + count: number; + total: number; + min: number; + max: number; +} + +interface MetricsAccumulator { + emit: TimingAccumulator; + execution: TimingAccumulator & { errors: number }; +} + +const PRIORITY_TO_TASK_PRIORITY = Object.freeze({ + high: TaskPriority.HIGH, + normal: TaskPriority.NORMAL, + low: TaskPriority.LOW, +} satisfies Readonly>); + +function normalizeMaxListeners(value: number | undefined, fallback: number): number { + if (value === Infinity) { + return Infinity; + } + + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback; + } + + return Math.max(0, Math.trunc(value)); +} + +function normalizeConcurrency(value: number | undefined, fallback: number): number { + if (value === Infinity || (value === undefined && fallback === Infinity)) { + return value ?? fallback; + } + + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback; + } + + return Math.max(1, Math.trunc(value)); +} + +function normalizeBufferSize(value: number | undefined, fallback: number): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback; + } + + return Math.max(1, Math.trunc(value)); +} + +function normalizeGcInterval(value: number | undefined, fallback: number): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback; + } + + return Math.max(0, Math.trunc(value)); +} + +function normalizeOptions(options: EventOptions): Required { + return { + captureRejections: + typeof options.captureRejections === 'boolean' + ? options.captureRejections + : DEFAULT_OPTIONS.captureRejections, + maxListeners: normalizeMaxListeners(options.maxListeners, DEFAULT_OPTIONS.maxListeners), + weakReferences: + typeof options.weakReferences === 'boolean' + ? options.weakReferences + : DEFAULT_OPTIONS.weakReferences, + immediateDispatch: + typeof options.immediateDispatch === 'boolean' + ? options.immediateDispatch + : DEFAULT_OPTIONS.immediateDispatch, + concurrencyLimit: normalizeConcurrency( + options.concurrencyLimit, + DEFAULT_OPTIONS.concurrencyLimit + ), + bufferSize: normalizeBufferSize(options.bufferSize, DEFAULT_OPTIONS.bufferSize), + gcIntervalMs: normalizeGcInterval(options.gcIntervalMs, DEFAULT_OPTIONS.gcIntervalMs), + }; +} + +function createListenerBucket(): ListenerBucket { + return { + high: [], + normal: [], + low: [], + size: 0, + }; +} + +function createBufferedBucket(): BufferedBucket { + return { + high: [], + normal: [], + low: [], + size: 0, + }; +} + +function createTimingAccumulator(): TimingAccumulator { + return { + count: 0, + total: 0, + min: Number.POSITIVE_INFINITY, + max: 0, + }; +} + +function createMetricsAccumulator(): MetricsAccumulator { + return { + emit: createTimingAccumulator(), + execution: { + ...createTimingAccumulator(), + errors: 0, + }, + }; +} + +function snapshotTiming(timing: TimingAccumulator): EventMetrics['emit']['timing'] { + if (timing.count === 0) { + return { + avg: 0, + max: 0, + min: 0, + total: 0, + }; + } + + return { + avg: timing.total / timing.count, + max: timing.max, + min: Number.isFinite(timing.min) ? timing.min : 0, + total: timing.total, + }; +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + (typeof value === 'object' || typeof value === 'function') && + value !== null && + typeof (value as PromiseLike).then === 'function' + ); +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + export class EventEmitter implements IEventEmitter { - #subscriptions = new Map>>(); + #events = new Map(); + #subscriptionIndex = new Map>(); #options: Required; - #staticSubscriptionStorage = new Map(); - #weakSubscriptionStorage?: WeakMap; - #metrics = new Map< - string, - { - emit: { - count: number; - timing: number[]; - }; - execution: { - count: number; - errors: number; - timing: number[]; - }; - } - >(); + #metrics = new Map(); #scheduler: EventScheduler; - #eventQueues = new Map>(); - #eventIdCounter = 0; + #buffer = new Map(); + #bufferedEventId = 0; + #bufferedEventCount = 0; #isPaused = false; + #isDisposed = false; #gcIntervalId?: ReturnType; - #lastGcTime = Date.now(); + #weakRegistry?: FinalizationRegistry; + #tapListeners = new Set(); + #bufferProcessing: Promise | null = null; constructor(options: EventOptions = {}) { - this.#options = { ...DEFAULT_OPTIONS, ...options }; + this.#options = normalizeOptions(options); - if (this.#options.weakReferences) { - this.#weakSubscriptionStorage = new WeakMap(); + if ( + this.#options.weakReferences && + typeof WeakRef === 'function' && + typeof FinalizationRegistry === 'function' + ) { + this.#weakRegistry = new FinalizationRegistry((subscriptionId) => { + this.offById(subscriptionId); + }); } - this.#scheduler = new EventScheduler({ concurrencyLimit: this.#options.concurrencyLimit }); + this.#scheduler = this.#createScheduler(); if (this.#options.gcIntervalMs > 0) { this.#startGc(); @@ -95,7 +271,7 @@ export class EventEmitter implements IEventEmitte } set maxListeners(value: number) { - if (value < 0 || !Number.isInteger(value)) { + if (value !== Infinity && (value < 0 || !Number.isInteger(value))) { throw new TypeError('maxListeners must be a non-negative integer'); } this.#options = { ...this.#options, maxListeners: value }; @@ -113,11 +289,14 @@ export class EventEmitter implements IEventEmitte callback: EventCallback, options: SubscriptionOptions = {} ): UnsubscribeFn { - return this.#addListener(event, callback, { + this.#ensureRuntime(); + + const id = this.#registerListener(event, callback, { once: false, - priority: 'normal', - ...options, + priority: options.priority ?? DEFAULT_PRIORITY, }); + + return () => this.offById(id); } public once>( @@ -125,11 +304,14 @@ export class EventEmitter implements IEventEmitte callback: EventCallback, options: Omit = {} ): UnsubscribeFn { - return this.#addListener(event, callback, { + this.#ensureRuntime(); + + const id = this.#registerListener(event, callback, { once: true, - priority: 'normal', - ...options, + priority: options.priority ?? DEFAULT_PRIORITY, }); + + return () => this.offById(id); } public pipe>( @@ -137,93 +319,49 @@ export class EventEmitter implements IEventEmitte emitter: IEventPublisher, targetEvent?: string ): UnsubscribeFn { - const actualTargetEvent = targetEvent || event; - return this.on(event, (data) => { - void emitter.emit(actualTargetEvent as any, data); - }); + const actualTargetEvent = targetEvent ?? (event as string); + return this.on(event, (data) => emitter.emit(actualTargetEvent as any, data).then(() => undefined)); } public off>(event: K, callback?: EventCallback): boolean { - if (!this.#subscriptions.has(event)) { + const eventName = String(event); + const bucket = this.#events.get(eventName); + + if (!bucket || bucket.size === 0) { return false; } - const subscriptionMap = this.#subscriptions.get(event)!; - if (!callback) { - for (const id of subscriptionMap.keys()) { - this.#staticSubscriptionStorage.delete(id); - } - this.#subscriptions.delete(event); - return subscriptionMap.size > 0; + this.#clearBucket(eventName, bucket); + return true; } - let found = false; - for (const [id, subscription] of subscriptionMap.entries()) { - if (subscription.callback === callback) { - subscriptionMap.delete(id); - this.#staticSubscriptionStorage.delete(id); - found = true; - } - } + let removed = false; + + for (const priority of ['high', 'normal', 'low'] as const) { + const records = bucket[priority]; + + for (let index = records.length - 1; index >= 0; index--) { + const record = records[index]!; + const currentCallback = this.#resolveCallback(record); - if (subscriptionMap.size === 0) { - this.#subscriptions.delete(event); + if (currentCallback === callback) { + this.#deleteSubscription(record); + removed = true; + } + } } - return found; + return removed; } public offById(subscriptionId: symbol): boolean { - const subscription = this.#staticSubscriptionStorage.get(subscriptionId); + const subscription = this.#subscriptionIndex.get(subscriptionId); if (!subscription) { return false; } - const { event } = subscription; - const subscriptionMap = this.#subscriptions.get(event); - - if (!subscriptionMap) { - this.#staticSubscriptionStorage.delete(subscriptionId); - return false; - } - - const result = subscriptionMap.delete(subscriptionId); - this.#staticSubscriptionStorage.delete(subscriptionId); - - if (subscriptionMap.size === 0) { - this.#subscriptions.delete(event); - } - - return result; - } - - async #handleError(error: Error): Promise { - const errorEvent = 'error' as EventKey; - - if (this.has(errorEvent)) { - try { - await this.emit(errorEvent, error as T[typeof errorEvent]); - } catch (innerError) { - console.error('Error in error handler:', innerError); - } - } else { - throw error; - } - } - - #handleErrorSync(error: Error): void { - const errorEvent = 'error' as EventKey; - - if (this.has(errorEvent)) { - try { - this.emitSync(errorEvent, error as T[typeof errorEvent]); - } catch (innerError) { - console.error('Error in error handler:', innerError); - } - } else { - throw error; - } + return this.#deleteSubscription(subscription); } public async emit>( @@ -231,77 +369,47 @@ export class EventEmitter implements IEventEmitte data: T[K], options: { priority?: EventPriority } = {} ): Promise { - const priority = options.priority || 'normal'; + this.#ensureRuntime(); + + const eventName = String(event); + const priority = options.priority ?? DEFAULT_PRIORITY; const startTime = performance.now(); - try { - if (this.#isPaused) { - this.#addToQueue(event, data, priority); - this.#updateMetrics(event, 'emit', 0); + if (this.#isPaused) { + try { + this.#enqueueBufferedEvent(eventName, data, priority); + this.#recordEmitMetric(eventName, 0); return true; + } catch (error) { + this.#recordEmitMetric(eventName, performance.now() - startTime); + throw error; } + } - const subscriptionMap = this.#subscriptions.get(event); - if (!subscriptionMap || subscriptionMap.size === 0) { - this.#updateMetrics(event, 'emit', performance.now() - startTime); - return false; - } - - const subscriptions = [...subscriptionMap.values()].sort( - (a, b) => PRIORITY_VALUES[a.priority] - PRIORITY_VALUES[b.priority] - ); - - const onceSubscriptions = subscriptions.filter((s) => s.once); - for (const subscription of onceSubscriptions) { - this.offById(subscription.id); - } - - const executionPromises = subscriptions.map((subscription) => - this.#scheduler.schedule(async () => { - const execStartTime = performance.now(); - subscription.executionCount++; - subscription.lastExecuted = Date.now(); - const { callback } = subscription; - - try { - await callback(data); - this.#updateMetrics(event, 'execution', performance.now() - execStartTime); - } catch (error) { - this.#updateMetrics( - event, - 'execution', - performance.now() - execStartTime, - true - ); + const tapContext: Omit = { + event: eventName, + data, + priority, + sync: false, + }; - const shouldCaptureRejections = this.#options.captureRejections === true; + this.#emitTaps({ ...tapContext, phase: 'start' }); - if (shouldCaptureRejections) { - try { - await this.#handleError(new EventHandlerError(event, error)); - return; - } catch (handlerError) { - console.error('Failed to handle error:', handlerError); - return; - } - } else { - throw new EventHandlerError(event, error); - } - } - }) - ); + try { + const snapshot = this.#snapshotListeners(eventName); - if (this.#options.captureRejections === true) { - await Promise.allSettled(executionPromises); - } else { - await Promise.all(executionPromises); + if (snapshot.length === 0) { + return false; } - this.#updateMetrics(event, 'emit', performance.now() - startTime); + this.#removeOnceSubscriptions(snapshot); + await this.#dispatchAsync(eventName, data, snapshot); return true; } catch (error) { - this.#updateMetrics(event, 'emit', performance.now() - startTime); throw error; + } finally { + this.#recordEmitMetric(eventName, performance.now() - startTime); + this.#emitTaps({ ...tapContext, phase: 'end' }); } } @@ -310,168 +418,189 @@ export class EventEmitter implements IEventEmitte data: T[K], options: { priority?: EventPriority } = {} ): boolean { + this.#ensureRuntime(); + + const eventName = String(event); + const priority = options.priority ?? DEFAULT_PRIORITY; const startTime = performance.now(); - try { - if (this.#isPaused) { - this.#addToQueue(event, data, options.priority || 'normal'); - this.#updateMetrics(event, 'emit', 0); + if (this.#isPaused) { + try { + this.#enqueueBufferedEvent(eventName, data, priority); + this.#recordEmitMetric(eventName, 0); return true; + } catch (error) { + this.#recordEmitMetric(eventName, performance.now() - startTime); + throw error; } + } - const subscriptionMap = this.#subscriptions.get(event); - if (!subscriptionMap || subscriptionMap.size === 0) { - this.#updateMetrics(event, 'emit', performance.now() - startTime); - return false; - } + const tapContext: Omit = { + event: eventName, + data, + priority, + sync: true, + }; - const subscriptions = [...subscriptionMap.values()].sort( - (a, b) => PRIORITY_VALUES[a.priority] - PRIORITY_VALUES[b.priority] - ); + this.#emitTaps({ ...tapContext, phase: 'start' }); + + try { + const snapshot = this.#snapshotListeners(eventName); - const onceSubscriptions = subscriptions.filter((s) => s.once); - for (const subscription of onceSubscriptions) { - this.offById(subscription.id); + if (snapshot.length === 0) { + return false; } + this.#removeOnceSubscriptions(snapshot); + let hadAsyncCallbacks = false; - for (const subscription of subscriptions) { + for (const subscription of snapshot) { + const callback = this.#resolveCallback(subscription); + + if (!callback) { + continue; + } + const execStartTime = performance.now(); subscription.executionCount++; subscription.lastExecuted = Date.now(); - const { callback } = subscription; try { const result = callback(data); - if (result instanceof Promise) { + + if (isPromiseLike(result)) { hadAsyncCallbacks = true; - result - .catch((error) => { - this.#updateMetrics( - event, - 'execution', + void Promise.resolve(result).then( + () => { + this.#recordExecutionMetric( + eventName, + performance.now() - execStartTime, + false + ); + }, + (error) => { + this.#recordExecutionMetric( + eventName, performance.now() - execStartTime, true ); - const shouldCaptureRejections = - this.#options.captureRejections === true; + const wrapped = new EventHandlerError(eventName, error); - if (shouldCaptureRejections) { - this.#handleErrorSync(new EventHandlerError(event, error)); + if (this.#options.captureRejections) { + try { + this.#handleCapturedErrorSync(eventName, wrapped); + } catch (handlerError) { + this.#reportAsyncError(handlerError); + } } else { - queueMicrotask(() => { - throw new EventHandlerError(event, error); - }); + this.#reportAsyncError(wrapped); } - }) - .then(() => { - this.#updateMetrics( - event, - 'execution', - performance.now() - execStartTime - ); - }); + } + ); } else { - this.#updateMetrics(event, 'execution', performance.now() - execStartTime); + this.#recordExecutionMetric( + eventName, + performance.now() - execStartTime, + false + ); } } catch (error) { - this.#updateMetrics( - event, - 'execution', + this.#recordExecutionMetric( + eventName, performance.now() - execStartTime, true ); - const shouldCaptureRejections = this.#options.captureRejections === true; + const wrapped = new EventHandlerError(eventName, error); - if (shouldCaptureRejections) { - this.#handleErrorSync(new EventHandlerError(event, error)); + if (this.#options.captureRejections) { + this.#handleCapturedErrorSync(eventName, wrapped); } else { - throw new EventHandlerError(event, error); + throw wrapped; } } } if (hadAsyncCallbacks) { console.warn( - `EventEmitter: Event "${String( - event - )}" was emitted synchronously but had async listeners. Consider using emit() instead.` + `EventEmitter: Event "${eventName}" was emitted synchronously but had async listeners. Consider using emit() instead.` ); } - this.#updateMetrics(event, 'emit', performance.now() - startTime); return true; } catch (error) { - this.#updateMetrics(event, 'emit', performance.now() - startTime); throw error; + } finally { + this.#recordEmitMetric(eventName, performance.now() - startTime); + this.#emitTaps({ ...tapContext, phase: 'end' }); } } - public async emitBatch>( - events: Array<{ event: K; data: T[K]; priority?: EventPriority }> - ): Promise { + public async emitBatch(events: ReadonlyArray>): Promise { if (events.length === 0) return []; - const results: Promise[] = []; + this.#ensureRuntime(); - for (const { event, data, priority } of events) { - results.push(this.emit(event, data, { priority })); + const results = new Array>(events.length); + + for (let index = 0; index < events.length; index++) { + const { event, data, priority } = events[index]!; + results[index] = this.emit( + event as EventKey, + data as T[EventKey], + priority ? { priority } : undefined + ); } return Promise.all(results); } public has>(event: K): boolean { - const subscriptionMap = this.#subscriptions.get(event); - return !!subscriptionMap && subscriptionMap.size > 0; + const bucket = this.#events.get(String(event)); + return bucket !== undefined && bucket.size > 0; } public hasSubscription(subscriptionId: symbol): boolean { - return this.#staticSubscriptionStorage.has(subscriptionId); + return this.#subscriptionIndex.has(subscriptionId); } public listenerCount>(event: K): number { - const subscriptionMap = this.#subscriptions.get(event); - return subscriptionMap ? subscriptionMap.size : 0; + return this.#events.get(String(event))?.size ?? 0; } public listenerCountAll(): number { - let count = 0; - for (const subscriptionMap of this.#subscriptions.values()) { - count += subscriptionMap.size; - } - return count; + return this.#subscriptionIndex.size; } public eventNames(): EventKey[] { - return Array.from(this.#subscriptions.keys()) as EventKey[]; + return Array.from(this.#events.keys()) as EventKey[]; } public getSubscriptions>(event: K): ReadonlyArray> { - const subscriptionMap = this.#subscriptions.get(event); - if (!subscriptionMap) { + const bucket = this.#events.get(String(event)); + if (!bucket || bucket.size === 0) { return []; } - return Array.from(subscriptionMap.values()) as Subscription[]; + + const subscriptions: Subscription[] = []; + this.#appendPublicSubscriptions(bucket.high, subscriptions); + this.#appendPublicSubscriptions(bucket.normal, subscriptions); + this.#appendPublicSubscriptions(bucket.low, subscriptions); + return subscriptions; } public removeAllListeners>(event?: K): this { if (event) { - const subscriptionMap = this.#subscriptions.get(event); - if (subscriptionMap) { - for (const id of subscriptionMap.keys()) { - this.#staticSubscriptionStorage.delete(id); - } - this.#subscriptions.delete(event); + const eventName = String(event); + const bucket = this.#events.get(eventName); + if (bucket) { + this.#clearBucket(eventName, bucket); } } else { - this.#staticSubscriptionStorage.clear(); - this.#subscriptions.clear(); - if (this.#weakSubscriptionStorage) { - this.#weakSubscriptionStorage = new WeakMap(); + for (const [eventName, bucket] of this.#events.entries()) { + this.#clearBucket(eventName, bucket); } } return this; @@ -482,14 +611,20 @@ export class EventEmitter implements IEventEmitte callbacks: ReadonlyArray>, options: SubscriptionOptions = {} ): ReadonlyArray { - const subscriptionIds: symbol[] = []; + if (callbacks.length === 0) { + return []; + } - for (const callback of callbacks) { - const unsubscribe = this.on(event, callback, options); - const subscription = this.getSubscriptions(event).find((s) => s.callback === callback); - if (subscription) { - subscriptionIds.push(subscription.id); - } + this.#ensureRuntime(); + + const subscriptionIds = new Array(callbacks.length); + + for (let index = 0; index < callbacks.length; index++) { + const callback = callbacks[index]!; + subscriptionIds[index] = this.#registerListener(event, callback, { + once: options.once ?? false, + priority: options.priority ?? DEFAULT_PRIORITY, + }); } return subscriptionIds; @@ -505,35 +640,40 @@ export class EventEmitter implements IEventEmitte return count; } - public getQueuedEvents>(event?: K): ReadonlyArray { + public getQueuedEvents>(event: K): ReadonlyArray>; + public getQueuedEvents(): ReadonlyArray]>>; + public getQueuedEvents>(event?: K): ReadonlyArray> { if (event) { - const queue = this.#eventQueues.get(event); - return queue ? queue.toArray() : []; + const bucket = this.#buffer.get(String(event)); + return bucket ? this.#snapshotBufferedBucket(bucket) : []; + } + + if (this.#bufferedEventCount === 0) { + return []; } - const allEvents: QueuedEvent[] = []; - for (const queue of this.#eventQueues.values()) { - allEvents.push(...queue.toArray()); + const allEvents = new Array(this.#bufferedEventCount); + let offset = 0; + + for (const bucket of this.#buffer.values()) { + offset = this.#copyBufferedEntries(bucket.high, allEvents, offset); + offset = this.#copyBufferedEntries(bucket.normal, allEvents, offset); + offset = this.#copyBufferedEntries(bucket.low, allEvents, offset); } return allEvents.sort((a, b) => { const priorityDiff = PRIORITY_VALUES[a.priority] - PRIORITY_VALUES[b.priority]; if (priorityDiff !== 0) return priorityDiff; - return a.timestamp - b.timestamp; + return a.id - b.id; }); } public getPendingCount>(event?: K): number { if (event) { - const queue = this.#eventQueues.get(event); - return queue ? queue.size : 0; + return this.#buffer.get(String(event))?.size ?? 0; } - let total = 0; - for (const queue of this.#eventQueues.values()) { - total += queue.size; - } - return total; + return this.#bufferedEventCount; } public getBufferSize(): number { @@ -542,30 +682,43 @@ export class EventEmitter implements IEventEmitte public clearBuffer>(event?: K): number { if (event) { - const queue = this.#eventQueues.get(event); - if (!queue) return 0; - const size = queue.size; - queue.clear(); + const eventName = String(event); + const bucket = this.#buffer.get(eventName); + if (!bucket) return 0; + const size = bucket.size; + this.#buffer.delete(eventName); + this.#bufferedEventCount -= size; return size; } - let total = 0; - for (const [eventName, queue] of this.#eventQueues.entries()) { - total += queue.size; - queue.clear(); - } - this.#eventQueues.clear(); + const total = this.#bufferedEventCount; + this.#buffer.clear(); + this.#bufferedEventCount = 0; return total; } public pause(): void { + this.#ensureRuntime(); this.#isPaused = true; } public resume(): void { if (!this.#isPaused) return; + + this.#ensureRuntime(); this.#isPaused = false; - this.#processQueues(); + + if (this.#bufferedEventCount === 0 || this.#bufferProcessing) { + return; + } + + const processing = this.#processBufferedEvents().finally(() => { + if (this.#bufferProcessing === processing) { + this.#bufferProcessing = null; + } + }); + + this.#bufferProcessing = processing; } public isPaused(): boolean { @@ -573,19 +726,37 @@ export class EventEmitter implements IEventEmitte } public async drain(): Promise { - await this.#scheduler.drain(); + for (;;) { + const currentBufferProcessing = this.#bufferProcessing; + if (currentBufferProcessing) { + await currentBufferProcessing; + continue; + } + + await this.#scheduler.drain(); - if (!this.#isPaused) { - await this.#processQueues(); + if ( + this.#bufferProcessing === null && + this.#scheduler.activeCount === 0 && + this.#scheduler.queuedCount === 0 + ) { + break; + } } } public async flush>(event: K): Promise { - if (!this.#eventQueues.has(event)) return; + if (this.#bufferProcessing) { + await this.#bufferProcessing; + } + + const eventName = String(event); + const bucket = this.#buffer.get(eventName); + if (!bucket || bucket.size === 0) return; - const queue = this.#eventQueues.get(event)!; - const queuedEvents = queue.toArray(); - queue.clear(); + const queuedEvents = this.#snapshotBufferedBucket(bucket); + this.#buffer.delete(eventName); + this.#bufferedEventCount -= queuedEvents.length; const wasPaused = this.#isPaused; this.#isPaused = false; @@ -602,229 +773,203 @@ export class EventEmitter implements IEventEmitte } public getMetrics>(event: K): EventMetrics { - const metrics = this.#metrics.get(event) || { - emit: { count: 0, timing: [] }, - execution: { count: 0, errors: 0, timing: [] }, - }; + const metrics = this.#metrics.get(String(event)); - const emitTimings = metrics.emit.timing; - const executionTimings = metrics.execution.timing; + if (!metrics) { + return { + emit: { + count: 0, + timing: snapshotTiming(createTimingAccumulator()), + }, + execution: { + count: 0, + errors: 0, + timing: snapshotTiming(createTimingAccumulator()), + }, + }; + } return { emit: { count: metrics.emit.count, - timing: { - avg: emitTimings.length - ? emitTimings.reduce((a, b) => a + b, 0) / emitTimings.length - : 0, - max: emitTimings.length ? Math.max(...emitTimings) : 0, - min: emitTimings.length ? Math.min(...emitTimings) : 0, - total: emitTimings.reduce((a, b) => a + b, 0), - }, + timing: snapshotTiming(metrics.emit), }, execution: { count: metrics.execution.count, errors: metrics.execution.errors, - timing: { - avg: executionTimings.length - ? executionTimings.reduce((a, b) => a + b, 0) / executionTimings.length - : 0, - max: executionTimings.length ? Math.max(...executionTimings) : 0, - min: executionTimings.length ? Math.min(...executionTimings) : 0, - total: executionTimings.reduce((a, b) => a + b, 0), - }, + timing: snapshotTiming(metrics.execution), }, }; } public resetMetrics>(event?: K): void { if (event) { - this.#metrics.delete(event); + this.#metrics.delete(String(event)); } else { this.#metrics.clear(); } } public getMemoryUsage(): Record { - const calcSize = (obj: any): number => { - if (obj === null || obj === undefined) return 0; - - let bytes = 0; - - if (typeof obj === 'object') { - if (obj instanceof Map) { - bytes = 64; - for (const [key, value] of obj.entries()) { - bytes += calcSize(key) + calcSize(value); - } - } else if (obj instanceof Set) { - bytes = 40; - for (const item of obj) { - bytes += calcSize(item); - } - } else if (obj instanceof Array) { - bytes = 40 + 8 * obj.length; - for (const item of obj) { - bytes += calcSize(item); - } - } else if (obj instanceof PriorityQueue) { - bytes = 48; - bytes += calcSize(obj.toArray()); - } else { - bytes = 40; - for (const key in obj) { - if (Object.prototype.hasOwnProperty.call(obj, key)) { - bytes += calcSize(key) + calcSize(obj[key]); - } - } - } - } else if (typeof obj === 'string') { - bytes = 2 * obj.length + 24; - } else if (typeof obj === 'number') { - bytes = 8; - } else if (typeof obj === 'boolean') { - bytes = 4; - } else if (typeof obj === 'symbol') { - bytes = 16; - } - - return bytes; - }; + const staticSubscriptions = this.#subscriptionIndex.size * 112; + const subscriptionMaps = this.#events.size * 80 + this.#subscriptionIndex.size * 24; + const priorityQueues = this.#buffer.size * 72; + const eventBuffer = this.#bufferedEventCount * 64; + const total = + staticSubscriptions + + subscriptionMaps + + priorityQueues + + eventBuffer + + this.#metrics.size * 64 + + this.#tapListeners.size * 16; return { - [MEMORY_USAGE_SYMBOLS.staticSubscriptions]: calcSize(this.#staticSubscriptionStorage), - [MEMORY_USAGE_SYMBOLS.subscriptionMaps]: calcSize(this.#subscriptions), - [MEMORY_USAGE_SYMBOLS.priorityQueues]: calcSize(this.#eventQueues), - [MEMORY_USAGE_SYMBOLS.eventBuffer]: Array.from(this.#eventQueues.values()).reduce( - (total, queue) => total + queue.size, - 0 - ), - total: - calcSize(this.#staticSubscriptionStorage) + - calcSize(this.#subscriptions) + - calcSize(this.#eventQueues) + - calcSize(this.#metrics), + [MEMORY_USAGE_SYMBOLS.staticSubscriptions]: staticSubscriptions, + [MEMORY_USAGE_SYMBOLS.subscriptionMaps]: subscriptionMaps, + [MEMORY_USAGE_SYMBOLS.priorityQueues]: priorityQueues, + [MEMORY_USAGE_SYMBOLS.eventBuffer]: eventBuffer, + total, }; } - #addListener>( + public [EVENT_EMITTER_TAP](tap: EventTap): UnsubscribeFn { + this.#ensureRuntime(); + this.#tapListeners.add(tap); + return () => this.#tapListeners.delete(tap); + } + + #registerListener>( event: K, callback: EventCallback, options: Required - ): UnsubscribeFn { - if (!this.#subscriptions.has(event)) { - this.#subscriptions.set(event, new Map()); - } + ): symbol { + const eventName = String(event); + let bucket = this.#events.get(eventName); - const subscriptionMap = this.#subscriptions.get(event)!; + if (!bucket) { + bucket = createListenerBucket(); + this.#events.set(eventName, bucket); + } if ( this.#options.maxListeners !== Infinity && - subscriptionMap.size >= this.#options.maxListeners + bucket.size >= this.#options.maxListeners ) { console.warn( `MaxListenersExceededWarning: Possible memory leak detected. ${ - subscriptionMap.size - } listeners added to event "${String(event)}".` + bucket.size + } listeners added to event "${eventName}".` ); } - const id = Symbol(); - const subscription: Subscription = { + const id = Symbol(eventName); + let internalCallback: InternalCallback = callback; + let unregisterToken: object | undefined; + let weak = false; + + if (this.#options.weakReferences && this.#weakRegistry) { + unregisterToken = Object.create(null) as object; + this.#weakRegistry.register(callback as EventCallback & object, id, unregisterToken); + internalCallback = new WeakRef(callback as EventCallback & object); + weak = true; + } + + const subscription: InternalSubscription = { id, - event, - callback, + event: eventName, + callback: internalCallback, once: options.once, priority: options.priority, executionCount: 0, createdAt: Date.now(), + unregisterToken, + weak, }; - subscriptionMap.set(id, subscription as Subscription); - this.#staticSubscriptionStorage.set(id, subscription as Subscription); - - if (this.#weakSubscriptionStorage && typeof callback === 'object') { - const existingIds = this.#weakSubscriptionStorage.get(callback) || []; - this.#weakSubscriptionStorage.set(callback, [...existingIds, id]); - } + bucket[options.priority].push(subscription); + bucket.size += 1; + this.#subscriptionIndex.set(id, subscription); - return () => this.offById(id); + return id; } - #addToQueue>(event: K, data: T[K], priority: EventPriority): void { - if (!this.#eventQueues.has(event)) { - this.#eventQueues.set( - event, - PriorityQueue.withComparator((a, b) => a - b) - ); - } + #enqueueBufferedEvent>( + event: K | string, + data: T[K] | T[EventKey], + priority: EventPriority + ): void { + const eventName = String(event); + let bucket = this.#buffer.get(eventName); - const queue = this.#eventQueues.get(event)!; + if (!bucket) { + bucket = createBufferedBucket(); + this.#buffer.set(eventName, bucket); + } - if (queue.size >= this.#options.bufferSize) { - throw new EventQueueFullError(event, this.#options.bufferSize); + if (bucket.size >= this.#options.bufferSize) { + throw new EventQueueFullError(eventName, this.#options.bufferSize); } - const eventId = this.#eventIdCounter++; + const eventId = ++this.#bufferedEventId; const queuedEvent: QueuedEvent = { id: eventId, - event, + event: eventName, data, timestamp: Date.now(), priority, }; - const priorityValue = PRIORITY_VALUES[priority] * 1000000000 + Date.now(); - queue.enqueue(queuedEvent, priorityValue); + bucket[priority].push(queuedEvent); + bucket.size += 1; + this.#bufferedEventCount += 1; } - async #processQueues(): Promise { - if (this.#isPaused) return; - - const allEvents = this.getQueuedEvents(); + async #processBufferedEvents(): Promise { + if (this.#isPaused || this.#bufferedEventCount === 0) { + return; + } + const queuedEvents = this.getQueuedEvents(); this.clearBuffer(); - for (const queuedEvent of allEvents) { + for (const queuedEvent of queuedEvents) { await this.emit(queuedEvent.event as EventKey, queuedEvent.data as T[EventKey], { priority: queuedEvent.priority, }); } } - #updateMetrics>( - event: K, - type: 'emit' | 'execution', - duration: number, - isError = false - ): void { - if (!this.#metrics.has(event)) { - this.#metrics.set(event, { - emit: { count: 0, timing: [] }, - execution: { count: 0, errors: 0, timing: [] }, - }); + async #dispatchAsync>( + event: K | string, + data: T[K] | T[EventKey], + snapshot: ReadonlyArray + ): Promise { + const eventName = String(event); + + if (snapshot.length === 1) { + let scheduled = this.#scheduleDispatch(eventName, snapshot[0]!, data); + + if (this.#options.captureRejections) { + scheduled = scheduled.catch((error) => this.#handleCapturedErrorAsync(eventName, error)); + } + + await scheduled; + return; } - const metrics = this.#metrics.get(event)!; + const scheduled = new Array>(snapshot.length); - if (type === 'emit') { - metrics.emit.count++; - metrics.emit.timing.push(duration); + for (let index = 0; index < snapshot.length; index++) { + let task = this.#scheduleDispatch(eventName, snapshot[index]!, data); - if (metrics.emit.timing.length > 100) { - metrics.emit.timing = metrics.emit.timing.slice(-100); + if (this.#options.captureRejections) { + task = task.catch((error) => this.#handleCapturedErrorAsync(eventName, error)); } - } else { - metrics.execution.count++; - if (isError) { - metrics.execution.errors++; - } - metrics.execution.timing.push(duration); - if (metrics.execution.timing.length > 100) { - metrics.execution.timing = metrics.execution.timing.slice(-100); - } + scheduled[index] = task; } + + await Promise.all(scheduled); } #startGc(): void { @@ -846,22 +991,21 @@ export class EventEmitter implements IEventEmitte } #runGc(): void { - this.#lastGcTime = Date.now(); - if (this.#options.weakReferences) { - return; + for (const subscription of this.#subscriptionIndex.values()) { + this.#resolveCallback(subscription); + } } - const existingEvents = new Set(this.eventNames()); - for (const event of this.#metrics.keys()) { - if (!existingEvents.has(event as any)) { - this.#metrics.delete(event); + for (const [eventName, metrics] of this.#metrics.entries()) { + if (!this.#events.has(eventName) && !this.#buffer.has(eventName)) { + this.#metrics.delete(eventName); } } - for (const [event, queue] of this.#eventQueues.entries()) { - if (queue.size === 0) { - this.#eventQueues.delete(event); + for (const [eventName, bucket] of this.#buffer.entries()) { + if (bucket.size === 0) { + this.#buffer.delete(eventName); } } } @@ -869,10 +1013,306 @@ export class EventEmitter implements IEventEmitte dispose(): void { if (this.#gcIntervalId) { clearInterval(this.#gcIntervalId); + this.#gcIntervalId = undefined; } + this.#scheduler.dispose(); this.removeAllListeners(); this.clearBuffer(); this.#metrics.clear(); + this.#tapListeners.clear(); + this.#bufferProcessing = null; + this.#isPaused = false; + this.#isDisposed = true; + } + + #createScheduler(): EventScheduler { + return new EventScheduler({ + concurrencyLimit: this.#options.concurrencyLimit, + }); + } + + #ensureRuntime(): void { + if (!this.#isDisposed) { + return; + } + + this.#isDisposed = false; + this.#scheduler = this.#createScheduler(); + + if (this.#options.gcIntervalMs > 0) { + this.#startGc(); + } + } + + #scheduleDispatch>( + event: K | string, + subscription: InternalSubscription, + data: T[K] | T[EventKey] + ): Promise { + const eventName = String(event); + + return this.#scheduler.schedule( + async () => { + const callback = this.#resolveCallback(subscription); + + if (!callback) { + return; + } + + const startTime = performance.now(); + subscription.executionCount += 1; + subscription.lastExecuted = Date.now(); + + try { + await callback(data as never); + this.#recordExecutionMetric(eventName, performance.now() - startTime, false); + } catch (error) { + this.#recordExecutionMetric(eventName, performance.now() - startTime, true); + throw new EventHandlerError(eventName, error); + } + }, + PRIORITY_TO_TASK_PRIORITY[subscription.priority] + ); + } + + async #handleCapturedErrorAsync(eventName: string, error: unknown): Promise { + const wrapped = error instanceof EventHandlerError ? error : new EventHandlerError(eventName, error); + + if (eventName === 'error') { + throw wrapped; + } + + const errorEvent = 'error' as EventKey; + + if (!this.has(errorEvent)) { + throw wrapped; + } + + await this.emit(errorEvent, wrapped as T[typeof errorEvent]); + } + + #handleCapturedErrorSync(eventName: string, error: EventHandlerError): void { + if (eventName === 'error') { + throw error; + } + + const errorEvent = 'error' as EventKey; + + if (!this.has(errorEvent)) { + throw error; + } + + this.emitSync(errorEvent, error as T[typeof errorEvent]); + } + + #reportAsyncError(error: unknown): void { + const failure = toError(error); + + if (typeof queueMicrotask === 'function') { + queueMicrotask(() => { + throw failure; + }); + return; + } + + void Promise.resolve().then(() => { + throw failure; + }); + } + + #emitTaps(context: EventTapContext): void { + if (this.#tapListeners.size === 0) { + return; + } + + for (const tap of this.#tapListeners) { + try { + tap(context); + } catch (error) { + this.#reportAsyncError(error); + } + } + } + + #resolveCallback(subscription: InternalSubscription): EventCallback | undefined { + if (!subscription.weak) { + return subscription.callback as EventCallback; + } + + const callback = (subscription.callback as WeakRef>).deref(); + + if (callback) { + return callback; + } + + this.#deleteSubscription(subscription); + return undefined; + } + + #deleteSubscription(subscription: InternalSubscription): boolean { + const bucket = this.#events.get(subscription.event); + this.#subscriptionIndex.delete(subscription.id); + + if (subscription.unregisterToken && this.#weakRegistry) { + this.#weakRegistry.unregister(subscription.unregisterToken); + } + + if (!bucket) { + return false; + } + + const records = bucket[subscription.priority]; + + for (let index = 0; index < records.length; index++) { + if (records[index] === subscription) { + records.splice(index, 1); + bucket.size -= 1; + + if (bucket.size === 0) { + this.#events.delete(subscription.event); + } + + return true; + } + } + + if (bucket.size === 0) { + this.#events.delete(subscription.event); + } + + return false; + } + + #clearBucket(eventName: string, bucket: ListenerBucket): void { + for (const priority of ['high', 'normal', 'low'] as const) { + const records = bucket[priority]; + + for (let index = 0; index < records.length; index++) { + const subscription = records[index]!; + this.#subscriptionIndex.delete(subscription.id); + + if (subscription.unregisterToken && this.#weakRegistry) { + this.#weakRegistry.unregister(subscription.unregisterToken); + } + } + + records.length = 0; + } + + bucket.size = 0; + this.#events.delete(eventName); + } + + #snapshotListeners(eventName: string): InternalSubscription[] { + const bucket = this.#events.get(eventName); + if (!bucket || bucket.size === 0) { + return []; + } + + const snapshot = new Array>(bucket.size); + let offset = 0; + offset = this.#copyLiveSubscriptions(bucket.high, snapshot, offset); + offset = this.#copyLiveSubscriptions(bucket.normal, snapshot, offset); + offset = this.#copyLiveSubscriptions(bucket.low, snapshot, offset); + + snapshot.length = offset; + return snapshot; + } + + #copyLiveSubscriptions( + source: InternalSubscription[], + target: InternalSubscription[], + offset: number + ): number { + for (let index = 0; index < source.length; ) { + const subscription = source[index]!; + + if (!this.#resolveCallback(subscription)) { + continue; + } + + target[offset] = subscription; + offset += 1; + index += 1; + } + + return offset; + } + + #removeOnceSubscriptions(snapshot: ReadonlyArray>): void { + for (let index = 0; index < snapshot.length; index++) { + const subscription = snapshot[index]!; + if (subscription.once) { + this.#deleteSubscription(subscription); + } + } + } + + #appendPublicSubscriptions( + source: InternalSubscription[], + target: Subscription[] + ): void { + for (let index = 0; index < source.length; ) { + const subscription = source[index]!; + const callback = this.#resolveCallback(subscription); + + if (!callback) { + continue; + } + + target.push({ + id: subscription.id, + event: subscription.event, + callback, + once: subscription.once, + priority: subscription.priority, + createdAt: subscription.createdAt, + lastExecuted: subscription.lastExecuted, + executionCount: subscription.executionCount, + }); + index += 1; + } + } + + #copyBufferedEntries(source: ReadonlyArray, target: QueuedEvent[], offset: number): number { + for (let index = 0; index < source.length; index++) { + target[offset] = source[index]!; + offset += 1; + } + + return offset; + } + + #snapshotBufferedBucket(bucket: BufferedBucket): QueuedEvent[] { + const snapshot = new Array(bucket.size); + let offset = 0; + offset = this.#copyBufferedEntries(bucket.high, snapshot, offset); + offset = this.#copyBufferedEntries(bucket.normal, snapshot, offset); + this.#copyBufferedEntries(bucket.low, snapshot, offset); + return snapshot; + } + + #recordEmitMetric(eventName: string, duration: number): void { + const metrics = this.#metrics.get(eventName) ?? createMetricsAccumulator(); + this.#metrics.set(eventName, metrics); + this.#updateTiming(metrics.emit, duration); + } + + #recordExecutionMetric(eventName: string, duration: number, isError: boolean): void { + const metrics = this.#metrics.get(eventName) ?? createMetricsAccumulator(); + this.#metrics.set(eventName, metrics); + this.#updateTiming(metrics.execution, duration); + + if (isError) { + metrics.execution.errors += 1; + } + } + + #updateTiming(timing: TimingAccumulator, duration: number): void { + timing.count += 1; + timing.total += duration; + timing.max = Math.max(timing.max, duration); + timing.min = Math.min(timing.min, duration); } } diff --git a/web/packages/event/src/event-group.ts b/web/packages/event/src/event-group.ts index 6dd7dda3..11b91997 100644 --- a/web/packages/event/src/event-group.ts +++ b/web/packages/event/src/event-group.ts @@ -8,12 +8,18 @@ import { QueuedEvent, } from './interfaces'; +interface TrackedSubscription { + readonly event: string; + readonly callback: EventCallback; +} + export class EventGroup implements IEventEmitter { readonly #emitter: IEventEmitter; readonly #subscriptions: Set = new Set(); + readonly #tracked = new Map(); constructor(baseEmitter?: IEventEmitter) { - this.#emitter = baseEmitter || new EventEmitter(); + this.#emitter = baseEmitter ?? new EventEmitter(); } get maxListeners(): number { @@ -29,22 +35,15 @@ export class EventGroup implements IEventEmitter { callback: EventCallback, options?: SubscriptionOptions ): UnsubscribeFn { - const unsubscribe = this.#emitter.on(event, callback, options); - const subscription = this.#emitter - .getSubscriptions(event) - .find((s) => s.callback === callback); + const [subscriptionId] = this.#emitter.batchSubscribe(event, [callback], options); - if (subscription) { - this.#subscriptions.add(subscription.id); + if (!subscriptionId) { + return () => false; } - return () => { - const result = unsubscribe(); - if (subscription) { - this.#subscriptions.delete(subscription.id); - } - return result; - }; + this.#trackSubscription(subscriptionId, String(event), callback); + + return () => this.#unsubscribeTracked(subscriptionId); } once>( @@ -52,56 +51,47 @@ export class EventGroup implements IEventEmitter { callback: EventCallback, options?: Omit ): UnsubscribeFn { - const unsubscribe = this.#emitter.once(event, callback, options); - const subscription = this.#emitter - .getSubscriptions(event) - .find((s) => s.callback === callback); - - if (subscription) { - this.#subscriptions.add(subscription.id); - } - - const wrappedUnsubscribe = () => { - const result = unsubscribe(); - if (subscription) { - this.#subscriptions.delete(subscription.id); - } - return result; - }; - + let subscriptionId: symbol | undefined; const wrappedCallback: EventCallback = (data) => { - if (subscription) { - this.#subscriptions.delete(subscription.id); + if (subscriptionId) { + this.#untrackSubscription(subscriptionId); } return callback(data); }; - return wrappedUnsubscribe; + const [trackedId] = this.#emitter.batchSubscribe(event, [wrappedCallback], { + ...options, + once: true, + }); + + if (!trackedId) { + return () => false; + } + + subscriptionId = trackedId; + this.#trackSubscription(trackedId, String(event), callback); + + return () => this.#unsubscribeTracked(trackedId); } off>(event: K, callback?: EventCallback): boolean { - if (callback) { - const subscription = this.#emitter - .getSubscriptions(event) - .find((s) => s.callback === callback); - if (subscription) { - this.#subscriptions.delete(subscription.id); - } - } else { - for (const subscription of this.#emitter.getSubscriptions(event)) { - this.#subscriptions.delete(subscription.id); - } + const ids = this.#collectSubscriptionIds(String(event), callback); + + if (ids.length === 0) { + return false; } - return this.#emitter.off(event, callback); + let removed = false; + + for (const subscriptionId of ids) { + removed = this.#unsubscribeTracked(subscriptionId) || removed; + } + + return removed; } offById(subscriptionId: symbol): boolean { - const result = this.#emitter.offById(subscriptionId); - if (result) { - this.#subscriptions.delete(subscriptionId); - } - return result; + return this.#unsubscribeTracked(subscriptionId); } pipe>( @@ -109,9 +99,8 @@ export class EventGroup implements IEventEmitter { emitter: IEventPublisher, targetEvent?: string ): UnsubscribeFn { - return this.on( - event, - (data) => void emitter.emit((targetEvent as any) || (event as any), data) + return this.on(event, (data) => + emitter.emit((targetEvent ?? (event as string)) as any, data).then(() => undefined) ); } @@ -131,36 +120,60 @@ export class EventGroup implements IEventEmitter { return this.#emitter.emitSync(event, data, options); } - emitBatch>( - events: Array<{ event: K; data: T[K]; priority?: EventPriority }> - ): Promise { + emitBatch(events: Parameters['emitBatch']>[0]): Promise { return this.#emitter.emitBatch(events); } has>(event: K): boolean { - return this.#emitter.has(event); + return this.listenerCount(event) > 0; } listenerCount>(event: K): number { - return this.#emitter.listenerCount(event); + return this.#collectSubscriptionIds(String(event)).length; } listenerCountAll(): number { - return this.#emitter.listenerCountAll(); + this.#pruneStaleSubscriptions(); + return this.#subscriptions.size; } eventNames(): EventKey[] { - return this.#emitter.eventNames(); + this.#pruneStaleSubscriptions(); + + const names = new Set>(); + + for (const tracked of this.#tracked.values()) { + names.add(tracked.event as EventKey); + } + + return Array.from(names); } getSubscriptions>(event: K): ReadonlyArray> { - return this.#emitter.getSubscriptions(event).filter((s) => this.#subscriptions.has(s.id)); + const activeIds = new Set(this.#collectSubscriptionIds(String(event))); + if (activeIds.size === 0) { + return []; + } + + return this.#emitter.getSubscriptions(event).flatMap((subscription) => { + if (!activeIds.has(subscription.id)) { + return []; + } + + const tracked = this.#tracked.get(subscription.id); + + return [ + { + ...subscription, + callback: (tracked?.callback ?? subscription.callback) as EventCallback, + }, + ]; + }); } hasSubscription(subscriptionId: symbol): boolean { - return ( - this.#subscriptions.has(subscriptionId) && this.#emitter.hasSubscription(subscriptionId) - ); + this.#pruneStaleSubscriptions(); + return this.#subscriptions.has(subscriptionId); } getMetrics>(event: K): EventMetrics { @@ -171,8 +184,10 @@ export class EventGroup implements IEventEmitter { return this.#emitter.getMemoryUsage(); } - getQueuedEvents>(event?: K): ReadonlyArray { - return this.#emitter.getQueuedEvents(event); + getQueuedEvents>(event: K): ReadonlyArray>; + getQueuedEvents(): ReadonlyArray]>>; + getQueuedEvents>(event?: K): ReadonlyArray> { + return event ? this.#emitter.getQueuedEvents(event) : this.#emitter.getQueuedEvents(); } getPendingCount>(event?: K): number { @@ -200,15 +215,12 @@ export class EventGroup implements IEventEmitter { } removeAllListeners>(event?: K): this { - if (event) { - for (const subscription of this.#emitter.getSubscriptions(event)) { - this.#subscriptions.delete(subscription.id); - } - } else { - this.#subscriptions.clear(); + const ids = this.#collectSubscriptionIds(event ? String(event) : undefined); + + for (const subscriptionId of ids) { + this.#unsubscribeTracked(subscriptionId); } - this.#emitter.removeAllListeners(event); return this; } @@ -219,18 +231,25 @@ export class EventGroup implements IEventEmitter { ): ReadonlyArray { const ids = this.#emitter.batchSubscribe(event, callbacks, options); - for (const id of ids) { - this.#subscriptions.add(id); + for (let index = 0; index < ids.length; index++) { + const id = ids[index]!; + const callback = callbacks[index]; + + if (callback) { + this.#trackSubscription(id, String(event), callback); + } } return ids; } batchUnsubscribe(subscriptionIds: ReadonlyArray): number { - const count = this.#emitter.batchUnsubscribe(subscriptionIds); + let count = 0; - for (const id of subscriptionIds) { - this.#subscriptions.delete(id); + for (const subscriptionId of subscriptionIds) { + if (this.#unsubscribeTracked(subscriptionId)) { + count += 1; + } } return count; @@ -253,9 +272,54 @@ export class EventGroup implements IEventEmitter { } dispose(): void { + this.removeAllListeners(); + } + + #trackSubscription(id: symbol, event: string, callback: EventCallback): void { + this.#subscriptions.add(id); + this.#tracked.set(id, { event, callback }); + } + + #untrackSubscription(id: symbol): void { + this.#subscriptions.delete(id); + this.#tracked.delete(id); + } + + #unsubscribeTracked(id: symbol): boolean { + const wasTracked = this.#subscriptions.has(id); + const removed = wasTracked ? this.#emitter.offById(id) : false; + this.#untrackSubscription(id); + return removed; + } + + #pruneStaleSubscriptions(): void { for (const id of this.#subscriptions) { - this.#emitter.offById(id); + if (!this.#emitter.hasSubscription(id)) { + this.#untrackSubscription(id); + } + } + } + + #collectSubscriptionIds( + event?: string, + callback?: EventCallback + ): symbol[] { + this.#pruneStaleSubscriptions(); + + const ids: symbol[] = []; + + for (const [id, tracked] of this.#tracked.entries()) { + if (event !== undefined && tracked.event !== event) { + continue; + } + + if (callback !== undefined && tracked.callback !== callback) { + continue; + } + + ids.push(id); } - this.#subscriptions.clear(); + + return ids; } } diff --git a/web/packages/event/src/event-scheduler.ts b/web/packages/event/src/event-scheduler.ts index bd1d9acc..87b4ef43 100644 --- a/web/packages/event/src/event-scheduler.ts +++ b/web/packages/event/src/event-scheduler.ts @@ -1,4 +1,5 @@ -import { PriorityQueue } from '@axrone/utility'; +import { Queue } from '@axrone/memory'; +import { performance } from './performance'; declare const __taskBrand: unique symbol; declare const __schedulerBrand: unique symbol; @@ -69,7 +70,62 @@ interface ITask { readonly maxRetries: number; startedAt?: number; timeoutId?: ReturnType; - promise?: Promise; + state: TaskState; +} + +interface MutableTaskMetrics { + id: TaskId; + priority: TaskPriority; + state: TaskState; + queuedAt: number; + startedAt?: number; + completedAt?: number; + executionTime?: number; + retryCount: number; +} + +const TASK_PRIORITY_ORDER = [ + TaskPriority.IMMEDIATE, + TaskPriority.HIGH, + TaskPriority.NORMAL, + TaskPriority.LOW, + TaskPriority.IDLE, +] as const; + +function normalizePositiveInteger(value: number | undefined, fallback: number): number { + if (value === Infinity) { + return Infinity; + } + + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback; + } + + return Math.max(1, Math.trunc(value)); +} + +function normalizeConcurrencyLimit(value: number | undefined, fallback: number): number { + if (value === Infinity || fallback === Infinity) { + return value === undefined ? fallback : value; + } + + return normalizePositiveInteger(value, fallback); +} + +function normalizeDuration(value: number | undefined, fallback: number): number { + if (value === Infinity) { + return 0; + } + + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback; + } + + return Math.max(0, Math.trunc(value)); +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); } export class EventScheduler { @@ -84,11 +140,20 @@ export class EventScheduler { private readonly gcIntervalMs: number; private readonly name: string; - private readonly taskQueue = new PriorityQueue, TaskPriority>(); + private readonly taskQueues: Record>> = { + [TaskPriority.IMMEDIATE]: new Queue>(), + [TaskPriority.HIGH]: new Queue>(), + [TaskPriority.NORMAL]: new Queue>(), + [TaskPriority.LOW]: new Queue>(), + [TaskPriority.IDLE]: new Queue>(), + }; private readonly activeTasks = new Map>(); - private readonly taskMetrics = new Map(); + private readonly taskMetrics = new Map(); + private readonly drainWaiters: Array<() => void> = []; private taskIdCounter = 0; + private queuedCountValue = 0; + private delayedRetryCount = 0; private completedCount = 0; private failedCount = 0; private totalExecutionTime = 0; @@ -101,14 +166,14 @@ export class EventScheduler { constructor(options: ISchedulerOptions = {}) { this.id = `scheduler_${Date.now()}_${Math.random().toString(36).substring(2, 11)}` as SchedulerId; - this.concurrencyLimit = Math.max(1, options.concurrencyLimit ?? Infinity); - this.maxQueueSize = Math.max(1, options.maxQueueSize ?? 10000); + this.concurrencyLimit = normalizeConcurrencyLimit(options.concurrencyLimit, Infinity); + this.maxQueueSize = normalizePositiveInteger(options.maxQueueSize, 10000); this.enableMetrics = options.enableMetrics ?? true; this.enableRetries = options.enableRetries ?? false; - this.maxRetries = Math.max(0, options.maxRetries ?? 3); - this.retryDelay = Math.max(0, options.retryDelay ?? 1000); - this.taskTimeout = Math.max(0, options.taskTimeout ?? 30000); - this.gcIntervalMs = Math.max(1000, options.gcIntervalMs ?? 60000); + this.maxRetries = Math.max(0, Math.trunc(options.maxRetries ?? 3)); + this.retryDelay = normalizeDuration(options.retryDelay, 1000); + this.taskTimeout = normalizeDuration(options.taskTimeout, 30000); + this.gcIntervalMs = Math.max(1000, normalizeDuration(options.gcIntervalMs, 60000)); this.name = options.name ?? `EventScheduler-${this.id}`; if (this.gcIntervalMs > 0) { @@ -121,11 +186,11 @@ export class EventScheduler { } get queuedCount(): number { - return this.taskQueue.size as unknown as number; + return this.queuedCountValue; } get isAtCapacity(): boolean { - return (this.taskQueue.size as unknown as number) >= this.maxQueueSize; + return this.queuedCountValue >= this.maxQueueSize; } get disposed(): boolean { @@ -166,10 +231,9 @@ export class EventScheduler { timeout: timeout ?? this.taskTimeout, retryCount: 0, maxRetries: this.enableRetries ? this.maxRetries : 0, + state: TaskState.PENDING, }; - (task as any).promise = promise; - promise.catch(() => {}); if (this.enableMetrics) { @@ -183,14 +247,17 @@ export class EventScheduler { } try { - this.taskQueue.enqueue(task, priority); + if (this.activeCount < this.concurrencyLimit && this.queuedCountValue === 0) { + this.executeTask(task); + } else { + this.enqueueTask(task); + this.processQueue(); + } } catch (error) { _reject(new Error('Failed to enqueue task')); return promise; } - this.processQueue(); - return promise; } @@ -211,17 +278,13 @@ export class EventScheduler { } async drain(): Promise { - if (this.isDisposed) return; - - while (this.activeCount > 0 || this.queuedCount > 0) { - await new Promise((resolve) => { - if (this.activeCount === 0 && this.queuedCount === 0) { - resolve(void 0); - } else { - setTimeout(resolve, 1); - } - }); + if (this.isDisposed || this.isIdle()) { + return; } + + await new Promise((resolve) => { + this.drainWaiters.push(resolve); + }); } getStats(): ISchedulerStats { @@ -244,11 +307,12 @@ export class EventScheduler { } getTaskMetrics(taskId: TaskId): ITaskMetrics | null { - return this.taskMetrics.get(taskId) ?? null; + const metrics = this.taskMetrics.get(taskId); + return metrics ? { ...metrics } : null; } getAllTaskMetrics(): ReadonlyArray { - return Array.from(this.taskMetrics.values()); + return Array.from(this.taskMetrics.values(), (metrics) => ({ ...metrics })); } clearMetrics(): void { @@ -271,25 +335,35 @@ export class EventScheduler { } this.activeTasks.forEach((task) => { + task.state = TaskState.CANCELLED; if (task.timeoutId) { clearTimeout(task.timeoutId); } try { task.reject(new Error('Scheduler disposed')); - } catch (error) {} + } catch {} }); - while (!this.taskQueue.isEmpty) { - const task = this.taskQueue.tryDequeue(); - if (task) { + for (const priority of TASK_PRIORITY_ORDER) { + const queue = this.taskQueues[priority]; + for (;;) { + const task = queue.tryDequeue(); + if (!task) { + break; + } + + task.state = TaskState.CANCELLED; try { task.reject(new Error('Scheduler disposed')); - } catch (error) {} + } catch {} } } + this.queuedCountValue = 0; + this.delayedRetryCount = 0; this.activeTasks.clear(); this.taskMetrics.clear(); + this.resolveDrainWaiters(); } async gracefulDispose(): Promise { @@ -304,16 +378,19 @@ export class EventScheduler { if (this.isDisposed) return; while (this.activeCount < this.concurrencyLimit) { - const task = this.taskQueue.tryDequeue(); + const task = this.dequeueTask(); if (!task) break; this.executeTask(task); } + + this.resolveDrainWaitersIfIdle(); } private async executeTask(task: ITask): Promise { const now = performance.now(); task.startedAt = now; + task.state = TaskState.RUNNING; this.activeTasks.set(task.id, task); @@ -335,18 +412,24 @@ export class EventScheduler { const result = await task.fn(); this.handleTaskSuccess(task, result, now); } catch (error) { - this.handleTaskError(task, error as Error, now); + this.handleTaskError(task, toError(error), now); } } private handleTaskSuccess(task: ITask, result: T, startTime: number): void { + if (!this.activeTasks.has(task.id) || task.state !== TaskState.RUNNING) { + return; + } + const executionTime = performance.now() - startTime; if (task.timeoutId) { clearTimeout(task.timeoutId); + task.timeoutId = undefined; } this.activeTasks.delete(task.id); + task.state = TaskState.COMPLETED; this.completedCount++; this.totalExecutionTime += executionTime; this.throughputCounter++; @@ -365,40 +448,58 @@ export class EventScheduler { } private handleTaskError(task: ITask, error: Error, startTime: number): void { + if (!this.activeTasks.has(task.id) || task.state !== TaskState.RUNNING) { + return; + } + const executionTime = performance.now() - startTime; if (task.timeoutId) { clearTimeout(task.timeoutId); + task.timeoutId = undefined; } this.activeTasks.delete(task.id); if (this.enableRetries && task.retryCount < task.maxRetries) { task.retryCount++; + task.state = TaskState.PENDING; if (this.enableMetrics) { const metrics = this.taskMetrics.get(task.id); if (metrics) { - (metrics as any).retryCount = task.retryCount; + metrics.retryCount = task.retryCount; + metrics.state = TaskState.PENDING; } } + this.delayedRetryCount++; setTimeout(() => { - if (!this.isDisposed && !this.isAtCapacity) { - try { - this.taskQueue.enqueue(task, task.priority); - this.processQueue(); - } catch { - task.reject(error); - } - } else { + this.delayedRetryCount--; + + if (this.isDisposed) { task.reject(error); + this.resolveDrainWaitersIfIdle(); + return; + } + + if (this.activeCount < this.concurrencyLimit && this.queuedCountValue === 0) { + this.executeTask(task); + return; + } + + if (this.isAtCapacity) { + task.reject(error); + } else { + this.enqueueTask(task); + this.processQueue(); } }, this.retryDelay); return; } + task.state = TaskState.FAILED; this.failedCount++; this.totalExecutionTime += executionTime; this.throughputCounter++; @@ -406,9 +507,9 @@ export class EventScheduler { if (this.enableMetrics) { const metrics = this.taskMetrics.get(task.id); if (metrics) { - (metrics as any).state = TaskState.FAILED; - (metrics as any).completedAt = performance.now(); - (metrics as any).executionTime = executionTime; + metrics.state = TaskState.FAILED; + metrics.completedAt = performance.now(); + metrics.executionTime = executionTime; } } @@ -456,13 +557,52 @@ export class EventScheduler { } private calculateMemoryUsage(): number { - const taskSize = 200; - const metricsSize = 100; + const taskSize = 160; + const metricsSize = 80; return ( this.activeTasks.size * taskSize + - this.taskQueue.size * taskSize + + this.queuedCountValue * taskSize + + this.delayedRetryCount * 32 + this.taskMetrics.size * metricsSize ); } + + private enqueueTask(task: ITask): void { + this.taskQueues[task.priority].enqueue(task); + this.queuedCountValue++; + } + + private dequeueTask(): ITask | undefined { + for (const priority of TASK_PRIORITY_ORDER) { + const task = this.taskQueues[priority].tryDequeue(); + if (task) { + this.queuedCountValue--; + return task; + } + } + + return undefined; + } + + private isIdle(): boolean { + return this.activeTasks.size === 0 && this.queuedCountValue === 0 && this.delayedRetryCount === 0; + } + + private resolveDrainWaitersIfIdle(): void { + if (this.isIdle()) { + this.resolveDrainWaiters(); + } + } + + private resolveDrainWaiters(): void { + if (this.drainWaiters.length === 0) { + return; + } + + const waiters = this.drainWaiters.splice(0, this.drainWaiters.length); + for (const resolve of waiters) { + resolve(); + } + } } diff --git a/web/packages/event/src/extras.ts b/web/packages/event/src/extras.ts index 82d466a2..676aa2a0 100644 --- a/web/packages/event/src/extras.ts +++ b/web/packages/event/src/extras.ts @@ -1,7 +1,16 @@ -import { EventMap, EventOptions, UnsubscribeFn, EventPriority, EventCallback } from './definition'; +import { + EventMap, + EventOptions, + UnsubscribeFn, + EventPriority, + EventCallback, + EventKey, + EventDispatchItem, +} from './definition'; import { EventError } from './errors'; import { IEventEmitter, EventEmitter } from './event-emitter'; -import { SubscriptionOptions } from './interfaces'; +import { SubscriptionOptions, Subscription, QueuedEvent, IEventPublisher } from './interfaces'; +import { EVENT_EMITTER_TAP, hasEventTapSupport } from './internals'; export type EventMapOf = E extends IEventEmitter ? M : EventMap; @@ -20,11 +29,114 @@ export type MergedEventMap = Maps extends [infer First, : {}; export type EventTransformer = { - [K in keyof SrcMap]?: (data: SrcMap[K]) => DestMap[keyof DestMap]; + [K in EventKey]?: (data: SrcMap[K]) => DestMap[EventKey]; }; export type ExcludeEventsMap = Pick>; +type PriorityStacks = Map; + +function bindCleanup(target: IEventEmitter, cleanup: () => void): void { + const baseDispose = target.dispose.bind(target); + let disposed = false; + + target.dispose = () => { + if (disposed) { + return; + } + + disposed = true; + cleanup(); + baseDispose(); + }; +} + +function releaseAll(unsubscribers: Iterable): void { + for (const unsubscribe of unsubscribers) { + unsubscribe(); + } +} + +function toVoid(promise: Promise): Promise { + return promise.then(() => undefined); +} + +function isSchedulerLifecycleError(error: unknown): boolean { + return ( + error instanceof Error && + (error.message === 'Scheduler disposed' || error.message === 'Scheduler has been disposed') + ); +} + +function rethrowAsync(error: unknown): void { + const failure = error instanceof Error ? error : new Error(String(error)); + + if (typeof queueMicrotask === 'function') { + queueMicrotask(() => { + throw failure; + }); + return; + } + + void Promise.resolve().then(() => { + throw failure; + }); +} + +function detachPromise(promise: Promise): void { + void promise.catch((error) => { + if (!isSchedulerLifecycleError(error)) { + rethrowAsync(error); + } + }); +} + +function pushPriority(stacks: PriorityStacks, eventName: string, priority: EventPriority): void { + const stack = stacks.get(eventName); + + if (stack) { + stack.push(priority); + return; + } + + stacks.set(eventName, [priority]); +} + +function popPriority(stacks: PriorityStacks, eventName: string): void { + const stack = stacks.get(eventName); + if (!stack) { + return; + } + + stack.pop(); + + if (stack.length === 0) { + stacks.delete(eventName); + } +} + +function peekPriority(stacks: PriorityStacks, eventName: string): EventPriority | undefined { + const stack = stacks.get(eventName); + return stack ? stack[stack.length - 1] : undefined; +} + +function trackPriorities( + emitter: IEventEmitter, + stacks: PriorityStacks +): UnsubscribeFn | undefined { + if (!hasEventTapSupport(emitter)) { + return undefined; + } + + return emitter[EVENT_EMITTER_TAP]((context) => { + if (context.phase === 'start') { + pushPriority(stacks, context.event, context.priority); + } else { + popPriority(stacks, context.event); + } + }); +} + export function createEmitter( options?: EventOptions ): IEventEmitter { @@ -52,55 +164,54 @@ export function filterEvents( passthroughErrors?: boolean; } ): IEventEmitter> { - const target = new EventEmitter>(); - const unsubscribers: UnsubscribeFn[] = []; - const allowedEventsSet = new Set(allowedEvents); + type TargetMap = FilteredEventMap; + type TargetKey = EventKey; + + const target = new EventEmitter(); + const unsubscribers = new Map(); + const allowedEventsSet = new Set(allowedEvents as ReadonlyArray); if (options?.passthroughErrors && !allowedEventsSet.has('error' as any)) { allowedEventsSet.add('error' as any); } for (const event of allowedEventsSet) { - unsubscribers.push(source.on(event, (data) => void target.emit(event, data))); + unsubscribers.set( + event, + source.on(event as EventKey, (data) => + toVoid(target.emit(event as TargetKey, data as any)) + ) + ); } - const originalEmit = target.emit.bind(target); - target.emit = async function & string>( + const originalEmit = target.emit.bind(target) as IEventEmitter['emit']; + target.emit = async function ( event: E, - data: FilteredEventMap[E], + data: TargetMap[E], options?: { priority?: EventPriority } ): Promise { - if (!allowedEventsSet.has(event as K)) { + if (!allowedEventsSet.has(event)) { return false; } - return originalEmit(event, data, options); + return originalEmit(event as TargetKey, data as TargetMap[TargetKey], options); }; - const originalEmitSync = target.emitSync.bind(target); - target.emitSync = function & string>( + const originalEmitSync = target.emitSync.bind(target) as IEventEmitter['emitSync']; + target.emitSync = function ( event: E, - data: FilteredEventMap[E], + data: TargetMap[E], options?: { priority?: EventPriority } ): boolean { - if (!allowedEventsSet.has(event as K)) { + if (!allowedEventsSet.has(event)) { return false; } - return originalEmitSync(event, data, options); - }; - - const originalRemoveAllListeners = target.removeAllListeners.bind(target); - target.removeAllListeners = function & string>( - event?: E - ): EventEmitter> { - if (event === undefined) { - unsubscribers.forEach((unsub) => unsub()); - } - return originalRemoveAllListeners(event) as EventEmitter>; + return originalEmitSync(event as TargetKey, data as TargetMap[TargetKey], options); }; - (target as any).dispose = () => { - unsubscribers.forEach((unsub) => unsub()); - }; + bindCleanup(target, () => { + releaseAll(unsubscribers.values()); + unsubscribers.clear(); + }); return target; } @@ -109,43 +220,91 @@ export function excludeEvents( source: IEventEmitter, excludedEvents: ReadonlyArray ): IEventEmitter> { - const target = new EventEmitter>(); - const excludedEventsSet = new Set(excludedEvents); - const unsubscribers: UnsubscribeFn[] = []; + type TargetMap = ExcludeEventsMap; + type TargetKey = EventKey; + + const target = new EventEmitter(); + const excludedEventsSet = new Set(excludedEvents as ReadonlyArray); + const unsubscribers = new Map(); const forwardedEvents = new Set(); const setupForwarding = (event: string) => { if (!excludedEventsSet.has(event as any) && !forwardedEvents.has(event)) { forwardedEvents.add(event); - unsubscribers.push( - source.on(event as any, (data) => void target.emit(event as any, data)) + unsubscribers.set( + event, + source.on(event as EventKey, (data) => + toVoid(target.emit(event as TargetKey, data as any)) + ) ); } }; source.eventNames().forEach(setupForwarding); - const originalTargetOn = target.on.bind(target); - target.on = function & string>( + const originalTargetOn = target.on.bind(target) as IEventEmitter['on']; + target.on = function ( event: E, - callback: EventCallback[E]>, - options?: { priority?: EventPriority } + callback: EventCallback, + options?: SubscriptionOptions ): UnsubscribeFn { setupForwarding(event); - return originalTargetOn(event, callback, options); + return originalTargetOn(event as TargetKey, callback as EventCallback, options); }; - (target as any).dispose = () => { - unsubscribers.forEach((unsub) => unsub()); + const originalTargetOnce = target.once.bind(target) as IEventEmitter['once']; + target.once = function ( + event: E, + callback: EventCallback, + options?: Omit + ): UnsubscribeFn { + setupForwarding(event); + return originalTargetOnce( + event as TargetKey, + callback as EventCallback, + options + ); }; + const originalEmit = target.emit.bind(target) as IEventEmitter['emit']; + target.emit = async function ( + event: E, + data: TargetMap[E], + options?: { priority?: EventPriority } + ): Promise { + if (excludedEventsSet.has(event)) { + return false; + } + + return originalEmit(event as TargetKey, data as TargetMap[TargetKey], options); + }; + + const originalEmitSync = target.emitSync.bind(target) as IEventEmitter['emitSync']; + target.emitSync = function ( + event: E, + data: TargetMap[E], + options?: { priority?: EventPriority } + ): boolean { + if (excludedEventsSet.has(event)) { + return false; + } + + return originalEmitSync(event as TargetKey, data as TargetMap[TargetKey], options); + }; + + bindCleanup(target, () => { + releaseAll(unsubscribers.values()); + unsubscribers.clear(); + forwardedEvents.clear(); + }); + return target as IEventEmitter>; } export function createEventProxy( source: IEventEmitter, target: IEventEmitter, - mapping: Readonly>>, + mapping: Readonly, EventKey>>>, transformers?: EventTransformer, options?: { preservePriority?: boolean; @@ -155,30 +314,33 @@ export function createEventProxy(); - const currentPriorities = new Map(); + const sourcePriorities: PriorityStacks = new Map(); + const targetPriorities: PriorityStacks = new Map(); if (options?.preservePriority) { - const originalEmit = source.emit.bind(source); - source.emit = async function ( - event: K, - data: SrcMap[K], - emitOptions?: { priority?: EventPriority } - ): Promise { - const priority = emitOptions?.priority || 'normal'; - currentPriorities.set(event, priority); - try { - return await originalEmit(event, data, emitOptions); - } finally { - setTimeout(() => currentPriorities.delete(event), 0); - } - }; + const sourceTracking = trackPriorities(source, sourcePriorities); + const targetTracking = options.bidirectional + ? trackPriorities(target, targetPriorities) + : undefined; + + if (sourceTracking) { + unsubscribers.push(sourceTracking); + } + + if (targetTracking) { + unsubscribers.push(targetTracking); + } } - for (const sourceEvent of Object.keys(mapping) as Array) { - const targetEvent = mapping[sourceEvent]!; + for (const [sourceEvent, targetEvent] of Object.entries(mapping) as Array< + [EventKey, EventKey | undefined] + >) { + if (!targetEvent) { + continue; + } unsubscribers.push( - source.on(sourceEvent, (data: SrcMap[typeof sourceEvent]) => { + source.on(sourceEvent as EventKey, async (data: SrcMap[typeof sourceEvent]) => { const proxyKey = `src->${sourceEvent}->${targetEvent}`; if (proxyingEvents.has(proxyKey)) { return; @@ -187,17 +349,19 @@ export function createEventProxy] as + | ((data: SrcMap[typeof sourceEvent]) => DestMap[EventKey]) + | undefined; + const transformedData = transform ? transform(data) : data; - void target.emit(targetEvent, data as any, { priority }); + await target.emit( + targetEvent as EventKey, + transformedData as any, + priority ? { priority } : undefined + ); } finally { proxyingEvents.delete(proxyKey); } @@ -213,13 +377,11 @@ export function createEventProxy = {}; - - for (const targetEvent of Object.keys(reverseMapping) as Array) { - const sourceEvent = reverseMapping[targetEvent] as keyof SrcMap & string; + for (const targetEvent of Object.keys(reverseMapping) as Array>) { + const sourceEvent = reverseMapping[targetEvent] as EventKey; unsubscribers.push( - target.on(targetEvent, (data: DestMap[typeof targetEvent]) => { + target.on(targetEvent as EventKey, async (data: DestMap[typeof targetEvent]) => { const proxyKey = `dest->${targetEvent}->${sourceEvent}`; if (proxyingEvents.has(proxyKey)) { return; @@ -228,19 +390,14 @@ export function createEventProxy, + data as any, + priority ? { priority } : undefined + ); } finally { proxyingEvents.delete(proxyKey); } @@ -273,38 +430,69 @@ export function mergeEmitters>>( > { const merged = new EventEmitter(); const unsubscribers: UnsubscribeFn[] = []; + const lazyForwarders = new Map(); + const fallbackEmitters = emitters.filter((emitter) => !hasEventTapSupport(emitter)); for (const emitter of emitters) { - const originalEmit = emitter.emit.bind(emitter); - - (emitter as any).emit = async function (event: any, data: any, options?: any) { - const result = await originalEmit(event, data, options); - void merged.emit(event, data, options); - return result; - }; + if (!hasEventTapSupport(emitter)) { + continue; + } - (emitter as any)._originalEmit = originalEmit; + unsubscribers.push( + emitter[EVENT_EMITTER_TAP]((context) => { + if (context.phase === 'start') { + detachPromise( + merged.emit(context.event as any, context.data as any, { + priority: context.priority, + }) + ); + } + }) + ); } - const originalRemoveAllListeners = merged.removeAllListeners.bind(merged); - merged.removeAllListeners = function (event?: E) { - if (event === undefined) { - unsubscribers.forEach((unsub) => unsub()); + const ensureFallbackForwarding = (eventName: string): void => { + if (fallbackEmitters.length === 0 || lazyForwarders.has(eventName)) { + return; } - return originalRemoveAllListeners(event); + + const eventUnsubscribers = fallbackEmitters.map((emitter) => + emitter.on(eventName as any, (data) => toVoid(merged.emit(eventName as any, data))) + ); + + lazyForwarders.set(eventName, eventUnsubscribers); }; - (merged as any).dispose = () => { - unsubscribers.forEach((unsub) => unsub()); + const originalOn = merged.on.bind(merged); + merged.on = function ( + event: K, + callback: EventCallback, + options?: SubscriptionOptions + ): UnsubscribeFn { + ensureFallbackForwarding(event); + return originalOn(event, callback, options); + }; - for (const emitter of emitters) { - if ((emitter as any)._originalEmit) { - (emitter as any).emit = (emitter as any)._originalEmit; - delete (emitter as any)._originalEmit; - } - } + const originalOnce = merged.once.bind(merged); + merged.once = function ( + event: K, + callback: EventCallback, + options?: Omit + ): UnsubscribeFn { + ensureFallbackForwarding(event); + return originalOnce(event, callback, options); }; + bindCleanup(merged, () => { + releaseAll(unsubscribers); + + for (const eventUnsubscribers of lazyForwarders.values()) { + releaseAll(eventUnsubscribers); + } + + lazyForwarders.clear(); + }); + return merged as unknown as IEventEmitter< MergedEventMap< [ @@ -318,72 +506,207 @@ export function mergeEmitters>>( export function namespaceEvents( prefix: Prefix, - source: IEventEmitter = new EventEmitter() + source?: IEventEmitter ): IEventEmitter> { - const namespaced = new EventEmitter>(); - const unsubscribers: UnsubscribeFn[] = []; + type SourceKey = EventKey; + type NamespacedMap = NamespacedEventMap; + type NamespacedKey = EventKey; - const resolveSourceEvent = & string>( - event: K - ): keyof T & string => { - const prefixStr = `${prefix}:`; - if (!event.startsWith(prefixStr)) { - throw new EventError(`Event "${event}" must start with namespace "${prefixStr}"`); - } - return event.slice(prefixStr.length) as keyof T & string; - }; + const actualSource = source ?? new EventEmitter(); + const ownsSource = source === undefined; + const prefixValue = `${prefix}:`; - const createNamespacedEvent = ( - event: K - ): keyof NamespacedEventMap & string => { - return `${prefix}:${event}` as any; - }; + const resolveSourceEvent = (event: NamespacedKey): SourceKey => { + const eventName = String(event); - const originalOn = namespaced.on.bind(namespaced); - namespaced.on = function & string>( - event: K, - callback: EventCallback[K]>, - options?: SubscriptionOptions - ): UnsubscribeFn { - const sourceEvent = resolveSourceEvent(event); - return source.on(sourceEvent, callback as any, options); + if (!eventName.startsWith(prefixValue)) { + throw new EventError(`Event "${eventName}" must start with namespace "${prefixValue}"`); + } + + return eventName.slice(prefixValue.length) as SourceKey; }; - const originalEmit = namespaced.emit.bind(namespaced); - namespaced.emit = function & string>( - event: K, - data: NamespacedEventMap[K], - options?: { priority?: EventPriority } - ): Promise { - const sourceEvent = resolveSourceEvent(event); - return source.emit(sourceEvent, data as any, options); + const createNamespacedEvent = (event: SourceKey): NamespacedKey => { + return `${prefix}:${event}` as NamespacedKey; }; - for (const event of source.eventNames()) { - const namespacedEvent = createNamespacedEvent(event) as keyof NamespacedEventMap< - Prefix, - T - > & - string; - unsubscribers.push( - source.on(event as any, (data: any) => { - void namespaced.emit(namespacedEvent, data); - }) - ); - } + const namespaced = { + get maxListeners() { + return actualSource.maxListeners; + }, + set maxListeners(value: number) { + actualSource.maxListeners = value; + }, + on( + event: K, + callback: EventCallback, + options?: SubscriptionOptions + ): UnsubscribeFn { + return actualSource.on(resolveSourceEvent(event) as SourceKey, callback as any, options); + }, + once( + event: K, + callback: EventCallback, + options?: Omit + ): UnsubscribeFn { + return actualSource.once(resolveSourceEvent(event) as SourceKey, callback as any, options); + }, + off( + event: K, + callback?: EventCallback + ): boolean { + return actualSource.off(resolveSourceEvent(event) as SourceKey, callback as any); + }, + offById(subscriptionId: symbol): boolean { + return actualSource.offById(subscriptionId); + }, + pipe( + event: K, + emitter: IEventPublisher, + targetEvent?: string + ): UnsubscribeFn { + return actualSource.on(resolveSourceEvent(event) as SourceKey, (data) => { + return toVoid(emitter.emit((targetEvent ?? event) as any, data)); + }); + }, + emit( + event: K, + data: NamespacedMap[K], + options?: { priority?: EventPriority } + ): Promise { + return actualSource.emit(resolveSourceEvent(event) as SourceKey, data as any, options); + }, + emitSync( + event: K, + data: NamespacedMap[K], + options?: { priority?: EventPriority } + ): boolean { + return actualSource.emitSync(resolveSourceEvent(event) as SourceKey, data as any, options); + }, + emitBatch(events: ReadonlyArray>): Promise { + return actualSource.emitBatch( + events.map(({ event, data, priority }) => ({ + event: resolveSourceEvent(event as NamespacedKey) as SourceKey, + data: data as unknown as T[SourceKey], + priority, + })) as unknown as ReadonlyArray> + ); + }, + has(event: K): boolean { + return actualSource.has(resolveSourceEvent(event) as SourceKey); + }, + listenerCount(event: K): number { + return actualSource.listenerCount(resolveSourceEvent(event) as SourceKey); + }, + listenerCountAll(): number { + return actualSource.listenerCountAll(); + }, + eventNames(): NamespacedKey[] { + return actualSource.eventNames().map((event) => createNamespacedEvent(event as SourceKey)); + }, + getSubscriptions( + event: K + ): ReadonlyArray> { + return actualSource + .getSubscriptions(resolveSourceEvent(event) as SourceKey) + .map((subscription) => ({ + ...subscription, + event: createNamespacedEvent(subscription.event as SourceKey), + })) as unknown as ReadonlyArray>; + }, + hasSubscription(subscriptionId: symbol): boolean { + return actualSource.hasSubscription(subscriptionId); + }, + getMetrics(event: K) { + return actualSource.getMetrics(resolveSourceEvent(event) as SourceKey); + }, + getMemoryUsage(): Record { + return actualSource.getMemoryUsage(); + }, + getQueuedEvents( + event?: K + ): ReadonlyArray> { + const queuedEvents = event + ? actualSource.getQueuedEvents(resolveSourceEvent(event) as SourceKey) + : actualSource.getQueuedEvents(); + + return queuedEvents.map((queuedEvent) => ({ + ...queuedEvent, + event: createNamespacedEvent(queuedEvent.event as SourceKey), + })); + }, + getPendingCount(event?: K): number { + return event + ? actualSource.getPendingCount(resolveSourceEvent(event) as SourceKey) + : actualSource.getPendingCount(); + }, + getBufferSize(): number { + return actualSource.getBufferSize(); + }, + clearBuffer(event?: K): number { + return event + ? actualSource.clearBuffer(resolveSourceEvent(event) as SourceKey) + : actualSource.clearBuffer(); + }, + pause(): void { + actualSource.pause(); + }, + resume(): void { + actualSource.resume(); + }, + isPaused(): boolean { + return actualSource.isPaused(); + }, + removeAllListeners(event?: K) { + if (event) { + actualSource.removeAllListeners(resolveSourceEvent(event) as SourceKey); + } else { + actualSource.removeAllListeners(); + } - (namespaced as any).dispose = () => { - unsubscribers.forEach((unsub) => unsub()); - }; + return namespaced; + }, + batchSubscribe( + event: K, + callbacks: ReadonlyArray>, + options?: SubscriptionOptions + ): ReadonlyArray { + return actualSource.batchSubscribe(resolveSourceEvent(event) as SourceKey, callbacks as any, options); + }, + batchUnsubscribe(subscriptionIds: ReadonlyArray): number { + return actualSource.batchUnsubscribe(subscriptionIds); + }, + resetMaxListeners(): void { + actualSource.resetMaxListeners(); + }, + drain(): Promise { + return actualSource.drain(); + }, + flush(event: K): Promise { + return actualSource.flush(resolveSourceEvent(event) as SourceKey); + }, + resetMetrics(event?: K): void { + if (event) { + actualSource.resetMetrics(resolveSourceEvent(event) as SourceKey); + } else { + actualSource.resetMetrics(); + } + }, + dispose(): void { + if (ownsSource) { + actualSource.dispose(); + } + }, + } as unknown as IEventEmitter; return namespaced; } export class TypedEventRegistry { - readonly #registry = new Map(); - readonly #symbolToEvent = new Map(); + readonly #registry = new Map, symbol>(); + readonly #symbolToEvent = new Map>(); - register(event: K): symbol { + register>(event: K): symbol { if (this.#registry.has(event)) { return this.#registry.get(event)!; } @@ -393,15 +716,15 @@ export class TypedEventRegistry { return symbol; } - getSymbol(event: K): symbol | undefined { + getSymbol>(event: K): symbol | undefined { return this.#registry.get(event); } - getEvent(symbol: symbol): (keyof T & string) | undefined { + getEvent(symbol: symbol): EventKey | undefined { return this.#symbolToEvent.get(symbol); } - has(event: K): boolean { + has>(event: K): boolean { return this.#registry.has(event); } @@ -409,7 +732,7 @@ export class TypedEventRegistry { return this.#symbolToEvent.has(symbol); } - events(): Array { + events(): Array> { return Array.from(this.#registry.keys()); } @@ -417,7 +740,7 @@ export class TypedEventRegistry { return Array.from(this.#symbolToEvent.keys()); } - entries(): Array<[keyof T & string, symbol]> { + entries(): Array<[EventKey, symbol]> { return Array.from(this.#registry.entries()); } diff --git a/web/packages/event/src/interfaces.ts b/web/packages/event/src/interfaces.ts index 90df31a4..88d3e341 100644 --- a/web/packages/event/src/interfaces.ts +++ b/web/packages/event/src/interfaces.ts @@ -1,6 +1,20 @@ -import { EventCallback, EventPriority, EventMap, EventKey, UnsubscribeFn } from './definition'; +import { + EventCallback, + EventPriority, + EventMap, + EventKey, + UnsubscribeFn, + EventDispatchItem, +} from './definition'; + +interface TimingSnapshot { + readonly avg: number; + readonly max: number; + readonly min: number; + readonly total: number; +} -export interface Subscription { +export interface Subscription { readonly id: symbol; readonly event: string; readonly callback: EventCallback; @@ -19,26 +33,16 @@ export interface SubscriptionOptions { export interface EventMetrics { readonly emit: { readonly count: number; - readonly timing: { - readonly avg: number; - readonly max: number; - readonly min: number; - readonly total: number; - }; + readonly timing: TimingSnapshot; }; readonly execution: { readonly count: number; readonly errors: number; - readonly timing: { - readonly avg: number; - readonly max: number; - readonly min: number; - readonly total: number; - }; + readonly timing: TimingSnapshot; }; } -export interface QueuedEvent { +export interface QueuedEvent { readonly id: number; readonly event: string; readonly data: T; @@ -83,13 +87,13 @@ export interface IEventPublisher { options?: { priority?: EventPriority } ): boolean; - emitBatch>( - events: Array<{ event: K; data: T[K]; priority?: EventPriority }> - ): Promise; + emitBatch(events: ReadonlyArray>): Promise; } export interface IEventBuffer { - getQueuedEvents>(event?: K): ReadonlyArray; + getQueuedEvents>(event: K): ReadonlyArray>; + + getQueuedEvents(): ReadonlyArray]>>; getPendingCount>(event?: K): number; diff --git a/web/packages/event/src/internals.ts b/web/packages/event/src/internals.ts new file mode 100644 index 00000000..8745513b --- /dev/null +++ b/web/packages/event/src/internals.ts @@ -0,0 +1,25 @@ +import type { EventPriority, UnsubscribeFn } from './definition'; + +export interface EventTapContext { + readonly phase: 'start' | 'end'; + readonly event: string; + readonly data: unknown; + readonly priority: EventPriority; + readonly sync: boolean; +} + +export type EventTap = (context: EventTapContext) => void; + +export const EVENT_EMITTER_TAP = Symbol('axrone.event.tap'); + +export interface EventTapSource { + [EVENT_EMITTER_TAP](tap: EventTap): UnsubscribeFn; +} + +export function hasEventTapSupport(value: unknown): value is EventTapSource { + return ( + value !== null && + typeof value === 'object' && + typeof (value as EventTapSource)[EVENT_EMITTER_TAP] === 'function' + ); +} \ No newline at end of file diff --git a/web/packages/event/src/performance.ts b/web/packages/event/src/performance.ts index e98cc495..1ef3feed 100644 --- a/web/packages/event/src/performance.ts +++ b/web/packages/event/src/performance.ts @@ -2,42 +2,16 @@ interface PerformanceTimer { readonly now: () => number; } -class FallbackPerformanceTimer implements PerformanceTimer { - readonly now = (): number => Date.now(); -} - -class PerformanceProvider { - private static instance?: PerformanceTimer; - - static getInstance(): PerformanceTimer { - if (!PerformanceProvider.instance) { - PerformanceProvider.instance = PerformanceProvider.createTimer(); - } - return PerformanceProvider.instance; - } - - private static createTimer(): PerformanceTimer { - if (typeof window !== 'undefined' && window.performance && 'now' in window.performance) { - return window.performance as PerformanceTimer; - } - - if (typeof global !== 'undefined' && global.performance && 'now' in global.performance) { - return global.performance as PerformanceTimer; - } - - try { - const perfHooks = require('perf_hooks'); - if (perfHooks?.performance && 'now' in perfHooks.performance) { - return perfHooks.performance as PerformanceTimer; - } - } catch {} - - return new FallbackPerformanceTimer(); - } - - static reset(): void { - PerformanceProvider.instance = undefined; - } -} - -export const performance = PerformanceProvider.getInstance(); +const fallbackPerformance: PerformanceTimer = { + now: () => Date.now(), +}; + +const globalPerformance = + typeof globalThis === 'object' && + globalThis !== null && + 'performance' in globalThis && + typeof globalThis.performance?.now === 'function' + ? (globalThis.performance as PerformanceTimer) + : undefined; + +export const performance: PerformanceTimer = globalPerformance ?? fallbackPerformance; diff --git a/web/packages/event/src/utility.ts b/web/packages/event/src/utility.ts index 0938382d..ee74aa23 100644 --- a/web/packages/event/src/utility.ts +++ b/web/packages/event/src/utility.ts @@ -2,6 +2,14 @@ import { EventMap, EventKey, EventCallback, UnsubscribeFn, EventPriority } from import { IEventEmitter, EventEmitter } from './event-emitter'; import { SubscriptionOptions } from './interfaces'; +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + (typeof value === 'object' || typeof value === 'function') && + value !== null && + typeof (value as PromiseLike).then === 'function' + ); +} + export function createHooks(): { on: >( event: K, @@ -58,43 +66,46 @@ export function createHooks(): { export const EventUtils = { createKey: (name: string): EventKey<{ [key: string]: T }> => name as any, - toAsync: (fn: (data: T) => R): ((data: T) => Promise) => { - return async (data: T) => fn(data); - }, + toAsync: (fn: (data: T) => R): ((data: T) => Promise) => (data: T) => + Promise.resolve(fn(data)), debounce: (callback: EventCallback, wait: number): EventCallback => { - let timeout: ReturnType | null = null; + const delay = Math.max(0, wait); + let timeoutId: ReturnType | undefined; let lastData: T; return (data: T) => { lastData = data; - if (timeout !== null) { - clearTimeout(timeout); + if (timeoutId !== undefined) { + clearTimeout(timeoutId); } - timeout = setTimeout(() => { - timeout = null; - callback(lastData); - }, wait); + timeoutId = setTimeout(() => { + timeoutId = undefined; + void callback(lastData); + }, delay); }; }, throttle: (callback: EventCallback, limit: number): EventCallback => { - let inThrottle = false; - let lastResult: Promise | void; + const duration = Math.max(0, limit); + let throttled = false; + let lastResult: void | Promise; return (data: T) => { - if (!inThrottle) { - inThrottle = true; + if (!throttled || duration === 0) { + throttled = duration > 0; lastResult = callback(data); - setTimeout(() => { - inThrottle = false; - }, limit); + if (duration > 0) { + setTimeout(() => { + throttled = false; + }, duration); + } } - return lastResult instanceof Promise ? lastResult : Promise.resolve(lastResult); + return lastResult; }; }, @@ -103,27 +114,37 @@ export const EventUtils = { maxCalls: number, timeWindow: number ): EventCallback => { + const limit = Math.max(0, Math.trunc(maxCalls)); + const windowSize = Math.max(0, Math.trunc(timeWindow)); const calls: number[] = []; + let head = 0; return (data: T) => { + if (limit === 0) { + return; + } + const now = Date.now(); - while (calls.length > 0 && calls[0] <= now - timeWindow) { - calls.shift(); + while (head < calls.length && calls[head] <= now - windowSize) { + head += 1; + } + + if (head > 64 && head * 2 >= calls.length) { + calls.splice(0, head); + head = 0; } - if (calls.length < maxCalls) { + if (calls.length - head < limit) { calls.push(now); return callback(data); } - - return Promise.resolve(); }; }, once: (callback: EventCallback): EventCallback => { let called = false; - let result: any; + let result: void | Promise; return (data: T) => { if (!called) { @@ -135,9 +156,17 @@ export const EventUtils = { }, compose: (...callbacks: EventCallback[]): EventCallback => { + if (callbacks.length === 0) { + return () => undefined; + } + + if (callbacks.length === 1) { + return callbacks[0]!; + } + return async (data: T) => { - for (const callback of callbacks) { - await callback(data); + for (let index = 0; index < callbacks.length; index++) { + await callbacks[index]!(data); } }; }, @@ -152,8 +181,7 @@ export const EventUtils = { map: (transform: (data: T) => U, callback: EventCallback): EventCallback => { return (data: T) => { - const transformed = transform(data); - return callback(transformed); + return callback(transform(data)); }; }, @@ -161,9 +189,17 @@ export const EventUtils = { callback: EventCallback, errorHandler: (error: unknown, data: T) => void ): EventCallback => { - return async (data: T) => { + return (data: T) => { try { - await callback(data); + const result = callback(data); + + if (isPromiseLike(result)) { + return result.catch((error) => { + errorHandler(error, data); + }); + } + + return result; } catch (error) { errorHandler(error, data); } diff --git a/web/packages/game-loop/package.json b/web/packages/game-loop/package.json index d3d4d598..15fd3d8c 100644 --- a/web/packages/game-loop/package.json +++ b/web/packages/game-loop/package.json @@ -19,5 +19,8 @@ "build": "rollup -c rollup.config.mjs", "clean": "rimraf dist", "test": "vitest run" + }, + "dependencies": { + "@axrone/utility": "^0.0.1" } -} \ No newline at end of file +} diff --git a/web/packages/game-loop/src/game-loop.ts b/web/packages/game-loop/src/game-loop.ts index 1ce0f65f..81a847c1 100644 --- a/web/packages/game-loop/src/game-loop.ts +++ b/web/packages/game-loop/src/game-loop.ts @@ -9,10 +9,10 @@ import { type GameLoopSystemRunnerRuntime, } from './game-loop-system-runner'; import { createAnimationFrameScheduler, isGameLoopScheduler } from './scheduler'; +import type { DeepReadonly } from '@axrone/utility'; import type { AfterFrameContext, BeforeUpdateContext, - DeepReadonly, FixedUpdateContext, GameLoopContextBase, GameLoopController, diff --git a/web/packages/game-loop/src/index.ts b/web/packages/game-loop/src/index.ts index 70a8e5b1..324ceccd 100644 --- a/web/packages/game-loop/src/index.ts +++ b/web/packages/game-loop/src/index.ts @@ -1,7 +1,6 @@ export type { AfterFrameContext, BeforeUpdateContext, - DeepReadonly, FixedUpdateContext, GameLoopContextBase, GameLoopController, diff --git a/web/packages/game-loop/src/types.ts b/web/packages/game-loop/src/types.ts index 80ba7dc8..c66a4bd5 100644 --- a/web/packages/game-loop/src/types.ts +++ b/web/packages/game-loop/src/types.ts @@ -1,4 +1,12 @@ import type { GameLoopSystemError } from './errors'; +import type { DeepReadonly, JsonValue } from '@axrone/utility'; + +export type { + JsonArray, + JsonObject, + JsonPrimitive, + JsonValue, +} from '@axrone/utility'; export type GameLoopStatus = 'idle' | 'running' | 'paused' | 'stopped' | 'disposed'; export type GameLoopErrorPolicy = 'throw' | 'pause' | 'stop' | 'continue'; @@ -10,26 +18,6 @@ export type GameLoopFramePhase = | 'after-frame'; export type GameLoopFailurePhase = GameLoopFramePhase | 'dispose'; -export type JsonPrimitive = string | number | boolean | null; - -export interface JsonObject { - readonly [key: string]: JsonValue; -} - -export interface JsonArray extends ReadonlyArray {} - -export type JsonValue = JsonPrimitive | JsonObject | JsonArray; - -export type DeepReadonly = T extends (...args: never[]) => unknown - ? T - : T extends JsonPrimitive - ? T - : T extends ReadonlyArray - ? readonly DeepReadonly[] - : T extends object - ? { readonly [TKey in keyof T]: DeepReadonly } - : T; - export type GameLoopValidationMessageCode = | `loop.invalid-${ | 'fixed-delta' diff --git a/web/packages/geometry/package.json b/web/packages/geometry/package.json index 86b8a1c7..1060c6fa 100644 --- a/web/packages/geometry/package.json +++ b/web/packages/geometry/package.json @@ -21,7 +21,8 @@ "test": "vitest run" }, "dependencies": { + "@axrone/memory": "^0.0.1", "@axrone/numeric": "^0.0.1", "@axrone/utility": "^0.0.1" } -} \ No newline at end of file +} diff --git a/web/packages/geometry/src/__tests__/plane.test.ts b/web/packages/geometry/src/__tests__/plane.test.ts new file mode 100644 index 00000000..07175df1 --- /dev/null +++ b/web/packages/geometry/src/__tests__/plane.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { createCircle, createPlane, createQuad, createRing } from '@axrone/geometry'; + +const readPositions = ( + vertices: { toUint8Array(): Uint8Array }, +): Array<[number, number, number]> => { + const bytes = vertices.toUint8Array(); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const positions: Array<[number, number, number]> = []; + for (let offset = 0; offset < bytes.byteLength; offset += 32) { + positions.push([ + view.getFloat32(offset, false), + view.getFloat32(offset + 4, false), + view.getFloat32(offset + 8, false), + ]); + } + return positions; +}; + +const readIndices = (indices: { toUint8Array(): Uint8Array }): number[] => { + const bytes = indices.toUint8Array(); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const values: number[] = []; + for (let offset = 0; offset < bytes.byteLength; offset += 2) { + values.push(view.getUint16(offset, false)); + } + return values; +}; + +const resolveFaceNormal = ( + positions: Array<[number, number, number]>, + indices: number[], +): [number, number, number] => { + const a = positions[indices[0]!]!; + const b = positions[indices[1]!]!; + const c = positions[indices[2]!]!; + const ab: [number, number, number] = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + const ac: [number, number, number] = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; + return [ + ab[1] * ac[2] - ab[2] * ac[1], + ab[2] * ac[0] - ab[0] * ac[2], + ab[0] * ac[1] - ab[1] * ac[0], + ]; +}; + +describe('plane primitive winding', () => { + it('creates XZ planes with upward-facing front faces', () => { + const geometry = createPlane({ width: 1, height: 1 }); + const normal = resolveFaceNormal(readPositions(geometry.vertices), readIndices(geometry.indices)); + expect(normal[1]).toBeGreaterThan(0); + }); + + it('creates XZ quads with upward-facing front faces', () => { + const geometry = createQuad({ width: 1, height: 1, orientation: 'xz' }); + const normal = resolveFaceNormal(readPositions(geometry.vertices), readIndices(geometry.indices)); + expect(normal[1]).toBeGreaterThan(0); + }); + + it('creates YZ quads with +X-facing front faces', () => { + const geometry = createQuad({ width: 1, height: 1, orientation: 'yz' }); + const normal = resolveFaceNormal(readPositions(geometry.vertices), readIndices(geometry.indices)); + expect(normal[0]).toBeGreaterThan(0); + }); + + it('creates circles with upward-facing front faces', () => { + const geometry = createCircle({ radius: 1, segments: 16 }); + const normal = resolveFaceNormal(readPositions(geometry.vertices), readIndices(geometry.indices)); + expect(normal[1]).toBeGreaterThan(0); + }); + + it('creates rings with upward-facing front faces', () => { + const geometry = createRing({ innerRadius: 0.5, outerRadius: 1, segments: 16 }); + const normal = resolveFaceNormal(readPositions(geometry.vertices), readIndices(geometry.indices)); + expect(normal[1]).toBeGreaterThan(0); + }); +}); \ No newline at end of file diff --git a/web/packages/geometry/src/__tests__/solid-primitives.test.ts b/web/packages/geometry/src/__tests__/solid-primitives.test.ts new file mode 100644 index 00000000..800a66d7 --- /dev/null +++ b/web/packages/geometry/src/__tests__/solid-primitives.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest'; +import { createCapsule, createCylinder, createQuad, createSphere, createTorus } from '@axrone/geometry'; + +const VERTEX_STRIDE = 32; + +const readPositions = ( + vertices: { toUint8Array(): Uint8Array }, +): Array<[number, number, number]> => { + const bytes = vertices.toUint8Array(); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const positions: Array<[number, number, number]> = []; + for (let offset = 0; offset < bytes.byteLength; offset += VERTEX_STRIDE) { + positions.push([ + view.getFloat32(offset, false), + view.getFloat32(offset + 4, false), + view.getFloat32(offset + 8, false), + ]); + } + return positions; +}; + +const readNormals = ( + vertices: { toUint8Array(): Uint8Array }, +): Array<[number, number, number]> => { + const bytes = vertices.toUint8Array(); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const normals: Array<[number, number, number]> = []; + for (let offset = 0; offset < bytes.byteLength; offset += VERTEX_STRIDE) { + normals.push([ + view.getFloat32(offset + 12, false), + view.getFloat32(offset + 16, false), + view.getFloat32(offset + 20, false), + ]); + } + return normals; +}; + +const readIndices = (indices: { toUint8Array(): Uint8Array }): number[] => { + const bytes = indices.toUint8Array(); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const values: number[] = []; + for (let offset = 0; offset < bytes.byteLength; offset += 2) { + values.push(view.getUint16(offset, false)); + } + return values; +}; + +const resolveTriangleNormal = ( + positions: Array<[number, number, number]>, + indices: number[], + triangleIndex: number, +): [number, number, number] => { + const offset = triangleIndex * 3; + const a = positions[indices[offset]!]!; + const b = positions[indices[offset + 1]!]!; + const c = positions[indices[offset + 2]!]!; + const ab: [number, number, number] = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + const ac: [number, number, number] = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; + return [ + ab[1] * ac[2] - ab[2] * ac[1], + ab[2] * ac[0] - ab[0] * ac[2], + ab[0] * ac[1] - ab[1] * ac[0], + ]; +}; + +const resolveTriangleArea = ( + positions: Array<[number, number, number]>, + indices: number[], + triangleIndex: number, +): number => { + const normal = resolveTriangleNormal(positions, indices, triangleIndex); + return Math.hypot(normal[0], normal[1], normal[2]) * 0.5; +}; + +const resolveTriangleCentroid = ( + positions: Array<[number, number, number]>, + indices: number[], + triangleIndex: number, +): [number, number, number] => { + const offset = triangleIndex * 3; + const a = positions[indices[offset]!]!; + const b = positions[indices[offset + 1]!]!; + const c = positions[indices[offset + 2]!]!; + return [ + (a[0] + b[0] + c[0]) / 3, + (a[1] + b[1] + c[1]) / 3, + (a[2] + b[2] + c[2]) / 3, + ]; +}; + +const dot = (a: [number, number, number], b: [number, number, number]): number => + a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + +const hasPosition = ( + positions: Array<[number, number, number]>, + target: [number, number, number], + epsilon: number = 1e-6, +): boolean => + positions.some( + (position) => + Math.abs(position[0] - target[0]) <= epsilon && + Math.abs(position[1] - target[1]) <= epsilon && + Math.abs(position[2] - target[2]) <= epsilon, + ); + +describe('solid primitive generation', () => { + it('builds spheres without degenerate pole triangles', () => { + const geometry = createSphere({ widthSegments: 16, heightSegments: 8 }); + const positions = readPositions(geometry.vertices); + const indices = readIndices(geometry.indices); + const triangleCount = indices.length / 3; + + for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) { + expect(resolveTriangleArea(positions, indices, triangleIndex)).toBeGreaterThan(1e-6); + } + }); + + it('preserves authored sphere normals across the duplicated UV seam', () => { + const widthSegments = 16; + const heightSegments = 8; + const geometry = createSphere({ widthSegments, heightSegments }); + const positions = readPositions(geometry.vertices); + const normals = readNormals(geometry.vertices); + const stride = widthSegments + 1; + + for (let lat = 1; lat < heightSegments; lat += 1) { + const seamStart = lat * stride; + const seamEnd = seamStart + widthSegments; + const leftPosition = positions[seamStart]!; + const rightPosition = positions[seamEnd]!; + const leftNormal = normals[seamStart]!; + const rightNormal = normals[seamEnd]!; + + expect(Math.abs(leftPosition[0] - rightPosition[0])).toBeLessThan(1e-6); + expect(Math.abs(leftPosition[1] - rightPosition[1])).toBeLessThan(1e-6); + expect(Math.abs(leftPosition[2] - rightPosition[2])).toBeLessThan(1e-6); + expect(Math.abs(leftNormal[0] - rightNormal[0])).toBeLessThan(1e-5); + expect(Math.abs(leftNormal[1] - rightNormal[1])).toBeLessThan(1e-5); + expect(Math.abs(leftNormal[2] - rightNormal[2])).toBeLessThan(1e-5); + } + }); + + it('winds cylinder caps outward on both ends', () => { + const radialSegments = 12; + const geometry = createCylinder({ radialSegments, heightSegments: 1 }); + const positions = readPositions(geometry.vertices); + const indices = readIndices(geometry.indices); + const torsoTriangleCount = radialSegments * 2; + const topCapNormal = resolveTriangleNormal(positions, indices, torsoTriangleCount); + const bottomCapNormal = resolveTriangleNormal( + positions, + indices, + torsoTriangleCount + radialSegments, + ); + + expect(topCapNormal[1]).toBeGreaterThan(0); + expect(bottomCapNormal[1]).toBeLessThan(0); + }); + + it('winds capsule pole fans outward on both ends', () => { + const radius = 0.5; + const length = 1; + const radialSegments = 12; + const capSegments = 8; + const geometry = createCapsule({ radius, length, radialSegments, capSegments }); + const positions = readPositions(geometry.vertices); + const indices = readIndices(geometry.indices); + const bodyTriangleCount = radialSegments * 2; + const hemisphereTriangleCount = radialSegments + (capSegments - 1) * radialSegments * 2; + const halfLength = length * 0.5; + + for (let triangleIndex = 0; triangleIndex < bodyTriangleCount; triangleIndex += 1) { + const normal = resolveTriangleNormal(positions, indices, triangleIndex); + const centroid = resolveTriangleCentroid(positions, indices, triangleIndex); + const outward = [centroid[0], 0, centroid[2]] as [number, number, number]; + expect(dot(normal, outward)).toBeGreaterThan(1e-6); + } + + const topPoleNormal = resolveTriangleNormal(positions, indices, bodyTriangleCount); + const bottomPoleNormal = resolveTriangleNormal( + positions, + indices, + bodyTriangleCount + hemisphereTriangleCount, + ); + + expect(hasPosition(positions, [0, halfLength + radius, 0])).toBe(true); + expect(hasPosition(positions, [0, -(halfLength + radius), 0])).toBe(true); + expect(hasPosition(positions, [0, halfLength, 0])).toBe(false); + expect(hasPosition(positions, [0, -halfLength, 0])).toBe(false); + expect(topPoleNormal[1]).toBeGreaterThan(0); + expect(bottomPoleNormal[1]).toBeLessThan(0); + }); + + it('winds torus faces outward across the full ring', () => { + const radius = 1; + const geometry = createTorus({ radius, tube: 0.35, radialSegments: 12, tubularSegments: 24 }); + const positions = readPositions(geometry.vertices); + const indices = readIndices(geometry.indices); + const triangleCount = indices.length / 3; + + for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) { + const normal = resolveTriangleNormal(positions, indices, triangleIndex); + const centroid = resolveTriangleCentroid(positions, indices, triangleIndex); + const centerLength = Math.hypot(centroid[0], centroid[2]); + const center = [ + (centroid[0] / centerLength) * radius, + 0, + (centroid[2] / centerLength) * radius, + ] as [number, number, number]; + const outward = [ + centroid[0] - center[0], + centroid[1] - center[1], + centroid[2] - center[2], + ] as [number, number, number]; + expect(dot(normal, outward)).toBeGreaterThan(1e-6); + } + }); + + it('builds XY quads as single-sided by default', () => { + const geometry = createQuad({ width: 1, height: 1, orientation: 'xy' }); + const positions = readPositions(geometry.vertices); + const indices = readIndices(geometry.indices); + const normals = Array.from({ length: indices.length / 3 }, (_, triangleIndex) => + resolveTriangleNormal(positions, indices, triangleIndex), + ); + + expect(indices).toHaveLength(6); + expect(normals.every((normal) => normal[2] > 0)).toBe(true); + }); + + it('builds XY quads with front and back faces when requested', () => { + const geometry = createQuad({ width: 1, height: 1, orientation: 'xy', doubleSided: true }); + const positions = readPositions(geometry.vertices); + const indices = readIndices(geometry.indices); + const normals = Array.from({ length: indices.length / 3 }, (_, triangleIndex) => + resolveTriangleNormal(positions, indices, triangleIndex), + ); + + expect(normals.some((normal) => normal[2] > 0)).toBe(true); + expect(normals.some((normal) => normal[2] < 0)).toBe(true); + }); +}); \ No newline at end of file diff --git a/web/packages/geometry/src/aabb.ts b/web/packages/geometry/src/aabb.ts index 1f650dd5..a8d017f6 100644 --- a/web/packages/geometry/src/aabb.ts +++ b/web/packages/geometry/src/aabb.ts @@ -1,7 +1,7 @@ import { IVec2Like, IVec3Like, Vec2, Vec3, Mat4, IMat4Like, EPSILON } from '@axrone/numeric'; import { ICloneable, Equatable } from '@axrone/utility'; +import type { Brand } from '@axrone/utility'; -export type Brand = K & { readonly __brand: T }; export type Radians = Brand; export type Degrees = Brand; diff --git a/web/packages/geometry/src/primitives/capsule.ts b/web/packages/geometry/src/primitives/capsule.ts index 18dfedc3..f144a66d 100644 --- a/web/packages/geometry/src/primitives/capsule.ts +++ b/web/packages/geometry/src/primitives/capsule.ts @@ -93,7 +93,7 @@ const generateCylinderBody = ( const c = startVertex + j + radialSegments + 1; const d = startVertex + j + radialSegments + 2; - builder.addQuad(a, b, d, c); + builder.addQuad(a, c, d, b); } }; @@ -107,11 +107,13 @@ const generateHemisphere = ( config: Required ): void => { const startVertex = builder.vertexCount; + const poleY = center.y + radius * (isTop ? 1 : -1); + const polePosition = Vec3.create(center.x, poleY, center.z); - const centerNormal = config.generateNormals ? Vec3.create(0, isTop ? 1 : -1, 0) : undefined; - const centerTexCoord = config.generateTexCoords ? { u: 0.5, v: isTop ? 0 : 1 } : undefined; + const poleNormal = config.generateNormals ? Vec3.create(0, isTop ? 1 : -1, 0) : undefined; + const poleTexCoord = config.generateTexCoords ? { u: 0.5, v: isTop ? 0 : 1 } : undefined; - builder.addVertex(center, centerNormal, centerTexCoord); + builder.addVertex(polePosition, poleNormal, poleTexCoord); for (let i = 1; i <= capSegments; i++) { const phi = (i / capSegments) * Math.PI * 0.5; @@ -145,14 +147,14 @@ const generateHemisphere = ( } for (let j = 0; j < radialSegments; j++) { - const center = startVertex; + const pole = startVertex; const a = startVertex + 1 + j; const b = startVertex + 1 + j + 1; if (isTop) { - builder.addTriangle(center, a, b); + builder.addTriangle(pole, b, a); } else { - builder.addTriangle(center, b, a); + builder.addTriangle(pole, a, b); } } diff --git a/web/packages/geometry/src/primitives/cylinder.ts b/web/packages/geometry/src/primitives/cylinder.ts index 91397557..43b645d9 100644 --- a/web/packages/geometry/src/primitives/cylinder.ts +++ b/web/packages/geometry/src/primitives/cylinder.ts @@ -175,9 +175,9 @@ const generateCylinderGeometry = ( const b = centerIndexStart + 1 + x + 1; if (top) { - builder.addTriangle(c, b, a); - } else { builder.addTriangle(c, a, b); + } else { + builder.addTriangle(c, b, a); } } } diff --git a/web/packages/geometry/src/primitives/geometry-builder.ts b/web/packages/geometry/src/primitives/geometry-builder.ts index 87910e86..455b07ab 100644 --- a/web/packages/geometry/src/primitives/geometry-builder.ts +++ b/web/packages/geometry/src/primitives/geometry-builder.ts @@ -1,5 +1,5 @@ import { Vec3, IVec3Like, EPSILON } from '@axrone/numeric'; -import { ByteBuffer } from '@axrone/utility'; +import { ByteBuffer } from '@axrone/memory'; import { IGeometryBuffers, IGeometryLayout, @@ -173,7 +173,7 @@ export class GeometryBuilder !vertex.normal)) { this.computeNormals(); } diff --git a/web/packages/geometry/src/primitives/plane.ts b/web/packages/geometry/src/primitives/plane.ts index 73087133..a879d727 100644 --- a/web/packages/geometry/src/primitives/plane.ts +++ b/web/packages/geometry/src/primitives/plane.ts @@ -22,12 +22,15 @@ export const createPlane = (config: Partial = {}): IGeometryBuffer }; export const createQuad = ( - config: Partial & { orientation?: 'xy' | 'xz' | 'yz' } = {} + config: Partial & { + orientation?: 'xy' | 'xz' | 'yz'; + doubleSided?: boolean; + } = {} ): IGeometryBuffers => { - const { orientation = 'xy', ...rest } = config; + const { orientation = 'xy', doubleSided = false, ...rest } = config; const finalConfig = { ...DEFAULT_PLANE_CONFIG, ...rest }; const builder = GeometryBuilder.create(finalConfig); - return generateQuadGeometry(builder, finalConfig, orientation); + return generateQuadGeometry(builder, finalConfig, orientation, doubleSided); }; export const createCircle = ( @@ -118,7 +121,7 @@ const generatePlaneGeometry = ( const c = ix + 1 + gridX1 * (iy + 1); const d = ix + 1 + gridX1 * iy; - builder.addQuad(a, b, c, d); + builder.addQuad(a, d, c, b); } } @@ -128,7 +131,8 @@ const generatePlaneGeometry = ( const generateQuadGeometry = ( builder: GeometryBuilder, config: Required, - orientation: 'xy' | 'xz' | 'yz' + orientation: 'xy' | 'xz' | 'yz', + doubleSided: boolean ): IGeometryBuffers => { const { width, height } = config; const halfWidth = width / 2; @@ -182,8 +186,36 @@ const generateQuadGeometry = ( ); } - builder.addTriangle(0, 1, 2); - builder.addTriangle(0, 2, 3); + if (orientation === 'xy') { + builder.addTriangle(0, 1, 2); + builder.addTriangle(0, 2, 3); + } else { + builder.addQuad(0, 3, 2, 1); + } + + if (!doubleSided) { + return builder.build(); + } + + const backNormal = config.generateNormals + ? Vec3.create(-normal.x, -normal.y, -normal.z) + : undefined; + const backStart = builder.vertexCount; + + for (let i = 0; i < 4; i++) { + builder.addVertex( + positions[i], + backNormal, + config.generateTexCoords ? texCoords[i] : undefined + ); + } + + if (orientation === 'xy') { + builder.addTriangle(backStart, backStart + 2, backStart + 1); + builder.addTriangle(backStart, backStart + 3, backStart + 2); + } else { + builder.addQuad(backStart, backStart + 1, backStart + 2, backStart + 3); + } return builder.build(); }; @@ -218,7 +250,7 @@ const generateCircleGeometry = ( } for (let i = 0; i < segments; i++) { - builder.addTriangle(0, i + 1, i + 2); + builder.addTriangle(0, i + 2, i + 1); } return builder.build(); @@ -272,7 +304,7 @@ const generateRingGeometry = ( const c = (i + 1) * 2 + 1; const d = (i + 1) * 2; - builder.addQuad(a, b, c, d); + builder.addQuad(a, d, c, b); } return builder.build(); diff --git a/web/packages/geometry/src/primitives/sphere.ts b/web/packages/geometry/src/primitives/sphere.ts index 9a1df75d..59cfa4e8 100644 --- a/web/packages/geometry/src/primitives/sphere.ts +++ b/web/packages/geometry/src/primitives/sphere.ts @@ -51,6 +51,7 @@ const generateSphereGeometry = ( ): IGeometryBuffers => { const { radius, widthSegments, heightSegments, phiStart, phiLength, thetaStart, thetaLength } = config; + const thetaEnd = thetaStart + thetaLength; const widthStep = 1 / widthSegments; const heightStep = 1 / heightSegments; @@ -106,13 +107,18 @@ const generateSphereGeometry = ( for (let iy = 0; iy < heightSegments; iy++) { for (let ix = 0; ix < widthSegments; ix++) { const stride = widthSegments + 1; - const a = (iy + 1) * stride + ix; - const b = (iy + 1) * stride + ix + 1; - const c = iy * stride + ix + 1; - const d = iy * stride + ix; + const a = iy * stride + ix + 1; + const b = iy * stride + ix; + const c = (iy + 1) * stride + ix; + const d = (iy + 1) * stride + ix + 1; - if (iy !== 0) builder.addTriangle(a, b, d); - if (iy !== heightSegments - 1) builder.addTriangle(b, c, d); + if (iy !== 0 || thetaStart > 0) { + builder.addTriangle(a, b, d); + } + + if (iy !== heightSegments - 1 || thetaEnd < Math.PI) { + builder.addTriangle(b, c, d); + } } } diff --git a/web/packages/geometry/src/primitives/torus.ts b/web/packages/geometry/src/primitives/torus.ts index 8b087c68..bb381c23 100644 --- a/web/packages/geometry/src/primitives/torus.ts +++ b/web/packages/geometry/src/primitives/torus.ts @@ -102,7 +102,7 @@ const generateTorusGeometry = ( const c = (tubularSegments + 1) * (j - 1) + i; const d = (tubularSegments + 1) * j + i; - builder.addQuad(a, b, c, d); + builder.addQuad(a, d, c, b); } } diff --git a/web/packages/geometry/src/primitives/types.ts b/web/packages/geometry/src/primitives/types.ts index 360640a6..2e743b07 100644 --- a/web/packages/geometry/src/primitives/types.ts +++ b/web/packages/geometry/src/primitives/types.ts @@ -1,5 +1,5 @@ import { IVec3Like } from '@axrone/numeric'; -import { ByteBuffer } from '@axrone/utility'; +import { ByteBuffer } from '@axrone/memory'; declare const __geometryBrand: unique symbol; declare const __vertexAttributeBrand: unique symbol; diff --git a/web/packages/input/package.json b/web/packages/input/package.json index 74c31304..6bbe4f88 100644 --- a/web/packages/input/package.json +++ b/web/packages/input/package.json @@ -21,6 +21,7 @@ "test": "vitest run" }, "dependencies": { - "@axrone/event": "^0.1.0" + "@axrone/event": "^0.1.0", + "@axrone/utility": "^0.0.1" } -} \ No newline at end of file +} diff --git a/web/packages/input/src/internal/action-events.ts b/web/packages/input/src/internal/action-events.ts index ad194b7e..055d9696 100644 --- a/web/packages/input/src/internal/action-events.ts +++ b/web/packages/input/src/internal/action-events.ts @@ -1,4 +1,4 @@ -import { EventEmitter } from '@axrone/event'; +import { createTypedEmitter } from '@axrone/event'; import { InputConfigurationError } from '../errors'; import type { AxisStateStore, @@ -38,9 +38,7 @@ export interface InputActionEventsRuntime(): InputActionEventEmitter => - new EventEmitter>({ - maxListeners: Infinity, - }); + createTypedEmitter>(); export const subscribeActionListener = ( runtime: InputActionEventsRuntime, diff --git a/web/packages/input/src/internal/shared.ts b/web/packages/input/src/internal/shared.ts index 50762580..cef03c9a 100644 --- a/web/packages/input/src/internal/shared.ts +++ b/web/packages/input/src/internal/shared.ts @@ -20,6 +20,9 @@ import type { InputVector2, InputVector2State, } from '../types'; +import { isRecord } from '@axrone/utility'; + +export { isRecord }; export const INPUT_SNAPSHOT_VERSION = 1 as const; export const EPSILON = 1e-6; @@ -360,9 +363,6 @@ export interface InternalActionEventDescriptor { readonly context?: InputContextId; } -export const isRecord = (value: unknown): value is Record => - value !== null && typeof value === 'object'; - export const isEventTargetLike = ( value: unknown ): value is Pick => diff --git a/web/packages/input/src/types.ts b/web/packages/input/src/types.ts index 7a70b770..680cea36 100644 --- a/web/packages/input/src/types.ts +++ b/web/packages/input/src/types.ts @@ -1,8 +1,7 @@ import type { IEventEmitter } from '@axrone/event'; +import type { Disposable } from '@axrone/utility'; -export interface IDisposable { - dispose(): void; -} +export type IDisposable = Disposable; export type InputDeviceKind = 'keyboard' | 'mouse' | 'touch' | 'gamepad'; export type InputActionKind = 'button' | 'axis' | 'vector2'; @@ -156,12 +155,10 @@ export type MouseMotionControlPath = `mouse/move/${'x' | 'y'}`; export type MouseWheelControlPath = `mouse/wheel/${'x' | 'y' | 'z'}`; export type MousePositionControlPath = `mouse/position/${'x' | 'y'}`; export type TouchContactControlPath = `touch/contact/${InputTouchSelectorToken}`; -export type TouchPositionControlPath = - `touch/position/${'x' | 'y'}/${InputTouchSelectorToken}`; +export type TouchPositionControlPath = `touch/position/${'x' | 'y'}/${InputTouchSelectorToken}`; export type TouchDeltaControlPath = `touch/delta/${'x' | 'y'}/${InputTouchSelectorToken}`; export type TouchAggregateControlPath = 'touch/pinch' | 'touch/count'; -export type GamepadButtonControlPath = - `gamepad/${InputGamepadSelectorToken}/button/${number}`; +export type GamepadButtonControlPath = `gamepad/${InputGamepadSelectorToken}/button/${number}`; export type GamepadAxisControlPath = `gamepad/${InputGamepadSelectorToken}/axis/${number}`; export type GamepadConnectionControlPath = `gamepad/${InputGamepadSelectorToken}/connected`; export type GamepadSemanticButtonName = @@ -330,15 +327,16 @@ export type InputScalarBinding = InputControlBinding | InputAxisCompositeBinding export type InputVector2Binding = InputDirectionalBinding | InputDualAxisBinding; export type InputBinding = InputScalarBinding | InputVector2Binding; -export type InputBindingSlotFor = TBinding extends InputControlBinding - ? 'control' - : TBinding extends InputAxisCompositeBinding - ? 'negative' | 'positive' - : TBinding extends InputDirectionalBinding - ? 'up' | 'down' | 'left' | 'right' - : TBinding extends InputDualAxisBinding - ? 'x' | 'y' - : never; +export type InputBindingSlotFor = + TBinding extends InputControlBinding + ? 'control' + : TBinding extends InputAxisCompositeBinding + ? 'negative' | 'positive' + : TBinding extends InputDirectionalBinding + ? 'up' | 'down' | 'left' | 'right' + : TBinding extends InputDualAxisBinding + ? 'x' | 'y' + : never; export type InputBindingSlot = InputBindingSlotFor; @@ -491,9 +489,7 @@ export type InputActionValues = { }; export type InputActionStates = { - readonly [TAction in InputActionName]: InputActionStateForDefinition< - TSchema[TAction] - >; + readonly [TAction in InputActionName]: InputActionStateForDefinition; }; export interface InputKeyboardSourceEvent { diff --git a/web/packages/lighting/package.json b/web/packages/lighting/package.json new file mode 100644 index 00000000..97058546 --- /dev/null +++ b/web/packages/lighting/package.json @@ -0,0 +1,42 @@ +{ + "name": "@axrone/lighting", + "version": "0.1.0", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./core": { + "types": "./dist/core/index.d.ts", + "import": "./dist/core/index.mjs", + "require": "./dist/core/index.js" + }, + "./frame": { + "types": "./dist/frame/index.d.ts", + "import": "./dist/frame/index.mjs", + "require": "./dist/frame/index.js" + }, + "./serialization": { + "types": "./dist/serialization/index.d.ts", + "import": "./dist/serialization/index.mjs", + "require": "./dist/serialization/index.js" + } + }, + "scripts": { + "build": "rollup -c rollup.config.mjs", + "clean": "rimraf dist", + "test": "vitest run --config vitest.config.ts" + }, + "dependencies": { + "@axrone/numeric": "^0.0.1", + "@axrone/utility": "^0.0.1" + } +} \ No newline at end of file diff --git a/web/packages/lighting/rollup.config.mjs b/web/packages/lighting/rollup.config.mjs new file mode 100644 index 00000000..d5e3eb9f --- /dev/null +++ b/web/packages/lighting/rollup.config.mjs @@ -0,0 +1,24 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createPackageConfig } from '../../build/create-package-config.mjs'; + +const packageDir = path.dirname(fileURLToPath(import.meta.url)); + +export default [ + ...createPackageConfig({ packageDir }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/core.ts', + outputBasename: 'core/index', + }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/frame.ts', + outputBasename: 'frame/index', + }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/serialization.ts', + outputBasename: 'serialization/index', + }), +]; \ No newline at end of file diff --git a/web/packages/lighting/src/__tests__/lighting-frame-resolver.test.ts b/web/packages/lighting/src/__tests__/lighting-frame-resolver.test.ts new file mode 100644 index 00000000..bd03bfff --- /dev/null +++ b/web/packages/lighting/src/__tests__/lighting-frame-resolver.test.ts @@ -0,0 +1,215 @@ +import { Vec3 } from '@axrone/numeric'; +import { describe, expect, it } from 'vitest'; +import { LightSortMode } from '../constants'; +import { LightingDisposedError } from '../errors'; +import { LightingFrameResolver } from '../frame-resolver'; +import { LightingRig } from '../rig'; +import { + createLightingUniformLayout, + createLightingUniformValueMap, +} from '../uniform-layout'; + +describe('LightingFrameResolver', () => { + it('preserves insertion order under none sort and exposes uniform layout metadata', () => { + const rig = new LightingRig(); + + rig.addPoint({ + id: 'first', + position: [0, 0, 10], + range: 20, + intensity: 1, + }); + rig.addPoint({ + id: 'second', + position: [0, 0, 1], + range: 20, + intensity: 50, + }); + + const resolver = new LightingFrameResolver({ + capacity: { + maxDirectionalLights: 0, + maxPointLights: 1, + maxSpotLights: 0, + maxLocalLights: 1, + }, + sortMode: LightSortMode.None, + }); + const state = resolver.resolve(rig, { + cameraPosition: [0, 0, 0], + }); + const uniforms = createLightingUniformValueMap(state); + const layout = createLightingUniformLayout({ + maxDirectionalLights: 0, + maxPointLights: 1, + maxSpotLights: 0, + maxLocalLights: 1, + }); + + expect(Array.from(state.pointPositions)).toEqual([0, 0, 10]); + expect(state.stats.selectedPointCount).toBe(1); + expect(state.stats.omittedPointCount).toBe(1); + expect(layout.defines.AXRONE_LIGHTING_MAX_LOCAL_LIGHTS).toBe('1'); + expect(uniforms.u_PointLightCount).toBe(1); + expect(uniforms.u_LocalLightCount).toBe(1); + }); + + it('ranks priority mode deterministically and uses insertion order as a tie-breaker', () => { + const rig = new LightingRig(); + + rig.addPoint({ + id: 'first', + position: [1, 0, 0], + range: 10, + intensity: 1, + priority: 2, + }); + rig.addPoint({ + id: 'second', + position: [2, 0, 0], + range: 10, + intensity: 1, + priority: 2, + }); + rig.addPoint({ + id: 'winner', + position: [3, 0, 0], + range: 10, + intensity: 1, + priority: 3, + }); + + const state = new LightingFrameResolver({ + capacity: { + maxDirectionalLights: 0, + maxPointLights: 2, + maxSpotLights: 0, + maxLocalLights: 2, + }, + sortMode: LightSortMode.Priority, + }).resolve(rig); + + expect(Array.from(state.pointPositions)).toEqual([3, 0, 0, 1, 0, 0]); + expect(Array.from(state.localLightKinds)).toEqual([1, 1]); + }); + + it('changes point influence when camera context is present', () => { + const rig = new LightingRig(); + + rig.addPoint({ + id: 'far-strong', + position: [0, 0, 20], + range: 4, + intensity: 11, + }); + rig.addPoint({ + id: 'near-weak', + position: [0, 0, 1], + range: 10, + intensity: 1, + }); + + const resolver = new LightingFrameResolver({ + capacity: { + maxDirectionalLights: 0, + maxPointLights: 1, + maxSpotLights: 0, + maxLocalLights: 1, + }, + sortMode: LightSortMode.Influence, + }); + + const withoutCameraPositions = Array.from(resolver.resolve(rig).pointPositions); + const withTuplePositions = Array.from( + resolver.resolve(rig, { + cameraPosition: [0, 0, 0], + }).pointPositions + ); + const withVecState = resolver.resolve(rig, { + cameraPosition: new Vec3(0, 0, 0), + }); + const withVecPositions = Array.from(withVecState.pointPositions); + const cachedState = resolver.resolve(rig, { + cameraPosition: new Vec3(0, 0, 0), + }); + + expect(withoutCameraPositions).toEqual([0, 0, 20]); + expect(withTuplePositions).toEqual([0, 0, 1]); + expect(withVecPositions).toEqual([0, 0, 1]); + expect(cachedState).toBe(withVecState); + }); + + it('suppresses spot influence outside the cone and re-enables it at the light position', () => { + const rig = new LightingRig(); + + rig.addPoint({ + id: 'point', + position: [0, 0, 1], + range: 10, + intensity: 1, + }); + rig.addSpot({ + id: 'spot', + position: [0, 0, 5], + direction: [0, 1, 0], + range: 10, + intensity: 20, + coneMode: 'cosine', + innerConeCosine: 0.95, + outerConeCosine: 0.8, + }); + + const resolver = new LightingFrameResolver({ + capacity: { + maxDirectionalLights: 0, + maxPointLights: 1, + maxSpotLights: 1, + maxLocalLights: 1, + }, + sortMode: LightSortMode.Influence, + }); + + const outsideConeKinds = Array.from( + resolver.resolve(rig, { + cameraPosition: [0, 0, 0], + }).localLightKinds + ); + const atLightKinds = Array.from( + resolver.resolve(rig, { + cameraPosition: [0, 0, 5], + }).localLightKinds + ); + + expect(outsideConeKinds).toEqual([1]); + expect(atLightKinds).toEqual([2]); + }); + + it('supports zero-capacity resolvers and disposal boundaries', () => { + const rig = new LightingRig(); + + rig.addDirectional({ + direction: [0, -1, 0], + }); + + const resolver = new LightingFrameResolver({ + capacity: { + maxDirectionalLights: 0, + maxPointLights: 0, + maxSpotLights: 0, + maxLocalLights: 0, + }, + }); + const state = resolver.resolve(rig); + + expect(resolver.capacity.maxLocalLights).toBe(0); + expect(resolver.isDisposed).toBe(false); + expect(state.stats.selectedDirectionalCount).toBe(0); + expect(state.stats.omittedDirectionalCount).toBe(1); + + resolver.dispose(); + expect(resolver.isDisposed).toBe(true); + + resolver.dispose(); + expect(() => resolver.resolve(rig)).toThrow(LightingDisposedError); + }); +}); \ No newline at end of file diff --git a/web/packages/lighting/src/__tests__/lighting-guards.test.ts b/web/packages/lighting/src/__tests__/lighting-guards.test.ts new file mode 100644 index 00000000..82a37a96 --- /dev/null +++ b/web/packages/lighting/src/__tests__/lighting-guards.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { LightKind, LightSortMode } from '../constants'; +import { + isDirectionalLightDefinition, + isLightDefinition, + isLightKind, + isLightingDocument, + isLightingMetadata, + isLightSortMode, + isPointLightDefinition, + isReadonlyTuple3, + isSerializedLight, + isSpotLightDefinition, +} from '../guards'; +import { + createDirectionalLightDefinition, + createPointLightDefinition, + createSpotLightDefinition, +} from '../validation'; + +describe('lighting guards', () => { + it('validates primitive discriminators, tuples, and metadata', () => { + expect(isReadonlyTuple3([1, 2, 3])).toBe(true); + expect(isReadonlyTuple3([1, 2])).toBe(false); + expect(isLightKind(LightKind.Point)).toBe(true); + expect(isLightKind('area')).toBe(false); + expect(isLightSortMode(LightSortMode.Priority)).toBe(true); + expect(isLightSortMode('custom')).toBe(false); + expect( + isLightingMetadata({ + enabled: true, + nested: { + tags: ['a'], + }, + }) + ).toBe(true); + expect( + isLightingMetadata({ + invalid: () => true, + }) + ).toBe(false); + }); + + it('validates concrete light definitions', () => { + const directional = createDirectionalLightDefinition( + { + direction: [0, -1, 0], + }, + 'directional' + ); + const point = createPointLightDefinition( + { + position: [1, 2, 3], + range: 4, + }, + 'point' + ); + const spot = createSpotLightDefinition( + { + direction: [0, -1, 0], + coneMode: 'cosine', + innerConeCosine: 0.9, + outerConeCosine: 0.7, + }, + 'spot' + ); + + expect(isDirectionalLightDefinition(directional)).toBe(true); + expect(isPointLightDefinition(point)).toBe(true); + expect(isSpotLightDefinition(spot)).toBe(true); + expect(isLightDefinition(directional)).toBe(true); + expect( + isLightDefinition({ + kind: LightKind.Point, + id: 'broken', + }) + ).toBe(false); + }); + + it('validates serialized lights and documents', () => { + const serializedSpot = { + kind: LightKind.Spot, + id: 'spot', + position: [1, 2, 3], + direction: [0, -1, 0], + range: 4, + attenuation: 2, + innerConeCosine: 0.9, + outerConeCosine: 0.7, + } as const; + + expect(isSerializedLight(serializedSpot)).toBe(true); + expect( + isSerializedLight({ + kind: LightKind.Point, + range: 'broken', + }) + ).toBe(false); + expect( + isLightingDocument({ + version: 1, + rigId: 'rig', + environment: { + ambient: [0.1, 0.2, 0.3], + }, + lights: [serializedSpot], + }) + ).toBe(true); + expect( + isLightingDocument({ + version: 'broken', + }) + ).toBe(false); + expect( + isLightingDocument({ + environment: 'broken', + }) + ).toBe(false); + }); +}); \ No newline at end of file diff --git a/web/packages/lighting/src/__tests__/lighting-rig.test.ts b/web/packages/lighting/src/__tests__/lighting-rig.test.ts new file mode 100644 index 00000000..037189b6 --- /dev/null +++ b/web/packages/lighting/src/__tests__/lighting-rig.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from 'vitest'; +import { LightKind } from '../constants'; +import { LightingDisposedError, LightingValidationError } from '../errors'; +import { LightingRig } from '../rig'; +import type { LightingSelectionState } from '../types'; + +describe('LightingRig', () => { + it('tracks versioned light lifecycle and immutable ordering', () => { + const rig = new LightingRig({ + environment: { + ambient: [0.2, 0.1, 0.05], + }, + }); + const directional = rig.addDirectional({ + id: 'directional', + direction: [0, -1, 0], + }); + const point = rig.addPoint({ + id: 'point', + position: [1, 2, 3], + range: 5, + }); + const spot = rig.addSpot({ + id: 'spot', + position: [3, 2, 1], + direction: [0, -1, 0], + coneMode: 'cosine', + innerConeCosine: 0.9, + outerConeCosine: 0.7, + }); + + expect(Number(rig.version)).toBe(3); + expect(rig.size).toBe(3); + expect(rig.has(point.id)).toBe(true); + expect(rig.get(directional.id)?.kind).toBe(LightKind.Directional); + expect(rig.list().map((light) => String(light.id))).toEqual([ + 'directional', + 'point', + 'spot', + ]); + + rig.setEnvironment({ gamma: 1.8 }); + expect(Number(rig.version)).toBe(4); + expect(rig.environment.gamma).toBe(1.8); + + rig.resetEnvironment(); + expect(Number(rig.version)).toBe(5); + expect(rig.environment.gamma).toBe(2.2); + + expect(rig.remove('missing')).toBe(false); + expect(rig.remove(point.id)).toBe(true); + expect(rig.has(point.id)).toBe(false); + expect(rig.list().map((light) => String(light.id))).toEqual([ + 'directional', + 'spot', + ]); + + const versionBeforeClear = Number(rig.version); + rig.clear(); + expect(rig.size).toBe(0); + expect(Number(rig.version)).toBe(versionBeforeClear + 1); + + rig.clear(); + expect(Number(rig.version)).toBe(versionBeforeClear + 1); + }); + + it('updates lights by kind and rejects duplicate or missing ids', () => { + const rig = new LightingRig(); + const directional = rig.addDirectional({ + id: 'directional', + direction: [0, -1, 0], + }); + const point = rig.addPoint({ + id: 'point', + position: [1, 2, 3], + range: 4, + }); + const spot = rig.addSpot({ + id: 'spot', + direction: [0, -1, 0], + coneMode: 'cosine', + innerConeCosine: 0.85, + outerConeCosine: 0.7, + }); + + const updatedDirectional = rig.update(directional.id, { + ambient: [0.1, 0.2, 0.3], + intensity: 4, + }); + const updatedPoint = rig.update(point.id, { + position: [9, 8, 7], + range: 10, + }); + const updatedSpot = rig.update(spot.id, { + coneMode: 'angle', + innerConeAngle: 0.2, + outerConeAngle: 0.4, + }); + + expect(updatedDirectional.intensity).toBe(4); + expect(updatedPoint.range).toBe(10); + expect(Array.from([updatedPoint.position.x, updatedPoint.position.y, updatedPoint.position.z])).toEqual([ + 9, + 8, + 7, + ]); + expect(updatedSpot.innerConeCosine).toBeCloseTo(Math.cos(0.2)); + expect(rig.list().map((light) => String(light.id))).toEqual([ + 'directional', + 'point', + 'spot', + ]); + + expect(() => + rig.addSpot({ + id: 'point', + direction: [0, -1, 0], + coneMode: 'cosine', + innerConeCosine: 0.9, + outerConeCosine: 0.7, + }) + ).toThrow(LightingValidationError); + expect(() => + rig.update('missing', { + range: 1, + }) + ).toThrow(LightingValidationError); + }); + + it('delegates frame resolution and enforces disposal boundaries', () => { + const rig = new LightingRig(); + const state = {} as LightingSelectionState; + const resolver = { + resolve: vi.fn(() => state), + }; + + expect( + rig.resolveFrame(resolver, { + cameraPosition: [0, 0, 0], + }) + ).toBe(state); + expect(resolver.resolve).toHaveBeenCalledWith(rig, { + cameraPosition: [0, 0, 0], + }); + + rig.dispose(); + expect(rig.isDisposed).toBe(true); + expect(() => rig.version).toThrow(LightingDisposedError); + expect(() => rig.list()).toThrow(LightingDisposedError); + expect(() => rig.get('missing')).toThrow(LightingDisposedError); + expect(() => rig.addPoint({})).toThrow(LightingDisposedError); + + rig.dispose(); + expect(rig.isDisposed).toBe(true); + }); +}); \ No newline at end of file diff --git a/web/packages/lighting/src/__tests__/lighting-serialization.test.ts b/web/packages/lighting/src/__tests__/lighting-serialization.test.ts new file mode 100644 index 00000000..2449f7ed --- /dev/null +++ b/web/packages/lighting/src/__tests__/lighting-serialization.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest'; +import { LightKind } from '../constants'; +import { LightingSerializationError } from '../errors'; +import { LightingRig } from '../rig'; +import { + deserializeLightingRig, + safeDeserializeLightingRig, + serializeLightingRig, +} from '../serialization'; + +describe('lighting serialization', () => { + it('serializes empty rigs with canonical defaults', () => { + const document = serializeLightingRig( + new LightingRig({ + id: 'rig', + }) + ); + + expect(document.version).toBe(1); + expect(document.rigId).toBe('rig'); + expect(document.environment).toEqual({ + ambient: [0.08, 0.08, 0.1], + sky: [0.08, 0.09, 0.11], + ground: [0.04, 0.04, 0.045], + exposure: 1, + gamma: 2.2, + }); + expect(document.lights).toEqual([]); + }); + + it('round-trips rigs with all light kinds', () => { + const rig = new LightingRig({ + id: 'roundtrip', + environment: { + ambient: [0.2, 0.1, 0.05], + exposure: 1.4, + gamma: 2.1, + }, + }); + + rig.addDirectional({ + id: 'sun', + direction: [1, -1, 0], + ambient: [0.01, 0.02, 0.03], + metadata: { + tags: ['key'], + }, + }); + rig.addPoint({ + id: 'fill', + position: [4, 5, 6], + range: 9, + attenuation: 3, + }); + rig.addSpot({ + id: 'lamp', + position: [1, 2, 3], + direction: [0, -1, 0], + range: 5, + coneMode: 'cosine', + innerConeCosine: 0.9, + outerConeCosine: 0.7, + }); + + const parsed = deserializeLightingRig(serializeLightingRig(rig)); + + expect(parsed.size).toBe(3); + expect(parsed.environment.exposure).toBe(1.4); + expect(parsed.environment.gamma).toBe(2.1); + expect(parsed.get('sun')?.kind).toBe(LightKind.Directional); + expect(parsed.get('fill')?.kind).toBe(LightKind.Point); + expect(parsed.get('lamp')?.kind).toBe(LightKind.Spot); + }); + + it('reports malformed headers and entries without discarding valid lights', () => { + const invalidHeaderResult = safeDeserializeLightingRig({ + version: 'broken', + rigId: 42, + environment: 'invalid', + lights: [ + null, + { + kind: LightKind.Directional, + id: 'sun', + direction: [0, -1, 0], + }, + { + kind: 'unknown', + }, + { + kind: LightKind.Point, + id: 'broken-point', + range: 'broken', + }, + { + kind: LightKind.Spot, + id: 'broken-cone', + direction: [0, -1, 0], + innerConeCosine: 0.2, + outerConeCosine: 0.8, + }, + { + kind: LightKind.Point, + id: 'sun', + range: 4, + }, + ], + }); + const unsupportedVersionResult = safeDeserializeLightingRig({ + version: 2, + }); + + expect(invalidHeaderResult.ok).toBe(true); + expect(unsupportedVersionResult.ok).toBe(true); + + if (invalidHeaderResult.ok && unsupportedVersionResult.ok) { + expect(invalidHeaderResult.value.size).toBe(1); + expect(invalidHeaderResult.value.get('sun')?.kind).toBe(LightKind.Directional); + expect(invalidHeaderResult.issues.map((issue) => issue.path)).toEqual( + expect.arrayContaining([ + '$.version', + '$.rigId', + '$.environment', + '$.lights[0]', + '$.lights[2].kind', + '$.lights[3]', + '$.lights[4]', + '$.lights[5]', + ]) + ); + expect( + unsupportedVersionResult.issues.map((issue) => issue.path) + ).toContain('$.version'); + } + }); + + it('throws strict deserialization errors for invalid and partial documents', () => { + expect(() => deserializeLightingRig(null)).toThrow(LightingSerializationError); + + let error: LightingSerializationError | null = null; + + try { + deserializeLightingRig({ + lights: [ + { + kind: LightKind.Point, + id: 'bad', + range: 'broken', + }, + ], + }); + } catch (caught) { + error = caught as LightingSerializationError; + } + + expect(error).toBeInstanceOf(LightingSerializationError); + expect(error?.code).toBe('lighting.serialize.partial'); + expect(error?.details?.issueCount).toBe(1); + }); +}); \ No newline at end of file diff --git a/web/packages/lighting/src/__tests__/lighting-uniform-layout.test.ts b/web/packages/lighting/src/__tests__/lighting-uniform-layout.test.ts new file mode 100644 index 00000000..89df73a0 --- /dev/null +++ b/web/packages/lighting/src/__tests__/lighting-uniform-layout.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { createLightingUniformLayout } from '../uniform-layout'; + +describe('lighting uniform layout', () => { + it('exposes per-kind property metadata for the modern lighting contract', () => { + const layout = createLightingUniformLayout({ + maxDirectionalLights: 1, + maxPointLights: 3, + maxSpotLights: 2, + maxLocalLights: 4, + }); + + expect(layout.names.directionalLightCount).toBe('u_DirectionalLightCount'); + expect(layout.properties.find((property) => property.name === 'u_DirectionalLightDirection')?.arrayLength).toBe(1); + expect(layout.properties.find((property) => property.name === 'u_PointLightPosition')?.arrayLength).toBe(3); + expect(layout.properties.find((property) => property.name === 'u_SpotLightInnerConeCosine')?.arrayLength).toBe(2); + expect(layout.properties.find((property) => property.name === 'u_LocalLightKind')?.type).toBe('int'); + }); +}); \ No newline at end of file diff --git a/web/packages/lighting/src/__tests__/lighting-validation.test.ts b/web/packages/lighting/src/__tests__/lighting-validation.test.ts new file mode 100644 index 00000000..bf245f71 --- /dev/null +++ b/web/packages/lighting/src/__tests__/lighting-validation.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest'; +import { LightKind } from '../constants'; +import { LightingValidationError } from '../errors'; +import { + applyDirectionalLightPatch, + applyLightPatch, + applyPointLightPatch, + applySpotLightPatch, + createDirectionalLightDefinition, + createLightDefinition, + createLightingEnvironment, + createPointLightDefinition, + createSpotLightDefinition, + resolveLightingCapacity, + serializeVec3, + updateLightingEnvironment, +} from '../validation'; + +describe('lighting validation', () => { + it('creates frozen environments and updates them immutably', () => { + const environment = createLightingEnvironment({ + ambient: [0.1, 0.2, 0.3], + exposure: 1.5, + }); + const updated = updateLightingEnvironment(environment, { + gamma: 1.8, + }); + + expect(serializeVec3(environment.ambient)).toEqual([0.1, 0.2, 0.3]); + expect(environment.exposure).toBe(1.5); + expect(environment.gamma).toBe(2.2); + expect(Object.isFrozen(environment)).toBe(true); + expect(Object.isFrozen(environment.ambient)).toBe(true); + expect(updated).not.toBe(environment); + expect(updated.exposure).toBe(1.5); + expect(updated.gamma).toBe(1.8); + }); + + it('resolves capacity and rejects invalid environment or capacity inputs', () => { + expect( + resolveLightingCapacity({ + maxPointLights: 2, + maxSpotLights: 3, + maxLocalLights: 99, + }) + ).toEqual({ + maxDirectionalLights: 1, + maxPointLights: 2, + maxSpotLights: 3, + maxLocalLights: 5, + }); + + expect(() => createLightingEnvironment({ exposure: -1 })).toThrow( + LightingValidationError + ); + expect(() => createLightingEnvironment({ gamma: 0 })).toThrow( + LightingValidationError + ); + expect(() => resolveLightingCapacity({ maxPointLights: 1.5 })).toThrow( + LightingValidationError + ); + expect(() => resolveLightingCapacity({ maxSpotLights: -1 })).toThrow( + LightingValidationError + ); + }); + + it('normalizes directional lights and deep-clones metadata', () => { + const metadata = { + nested: { enabled: true }, + tags: ['a'], + }; + const light = createDirectionalLightDefinition( + { + id: 'sun', + direction: [0, -2, 0], + ambient: [0.1, 0.2, 0.3], + metadata, + }, + 'fallback' + ); + const clonedMetadata = light.metadata as { + nested: { enabled: boolean }; + tags: string[]; + }; + + metadata.nested.enabled = false; + metadata.tags.push('b'); + + expect(light.kind).toBe(LightKind.Directional); + expect(serializeVec3(light.direction)).toEqual([0, -1, 0]); + expect(clonedMetadata.nested.enabled).toBe(true); + expect(clonedMetadata.tags).toEqual(['a']); + expect(Object.isFrozen(light)).toBe(true); + expect(Object.isFrozen(clonedMetadata)).toBe(true); + expect(Object.isFrozen(clonedMetadata.nested)).toBe(true); + expect(() => + createDirectionalLightDefinition( + { + direction: [0, 0, 0], + }, + 'bad-direction' + ) + ).toThrow(LightingValidationError); + }); + + it('validates point and spot definitions with angle and cosine cones', () => { + const point = createPointLightDefinition( + { + range: 5, + attenuation: 1.5, + }, + 'point' + ); + const spotFromAngles = createSpotLightDefinition( + { + direction: [0, -1, 0], + coneMode: 'angle', + innerConeAngle: 0.1, + outerConeAngle: 0.3, + }, + 'spot-angle' + ); + const spotFromCosines = createSpotLightDefinition( + { + direction: [0, -1, 0], + coneMode: 'cosine', + innerConeCosine: 0.9, + outerConeCosine: 0.7, + }, + 'spot-cosine' + ); + + expect(point.range).toBe(5); + expect(point.attenuation).toBe(1.5); + expect(spotFromAngles.innerConeCosine).toBeCloseTo(Math.cos(0.1)); + expect(spotFromAngles.outerConeCosine).toBeCloseTo(Math.cos(0.3)); + expect(spotFromCosines.innerConeCosine).toBeCloseTo(0.9); + expect(spotFromCosines.outerConeCosine).toBeCloseTo(0.7); + + expect(() => createPointLightDefinition({ range: 0 }, 'bad-point')).toThrow( + LightingValidationError + ); + expect(() => + createSpotLightDefinition( + { + direction: [0, -1, 0], + coneMode: 'cosine', + innerConeCosine: 0.4, + outerConeCosine: 0.7, + }, + 'bad-spot-cosine' + ) + ).toThrow(LightingValidationError); + expect(() => + createSpotLightDefinition( + { + direction: [0, -1, 0], + coneMode: 'angle', + innerConeAngle: 0.5, + outerConeAngle: 0.2, + }, + 'bad-spot-angle' + ) + ).toThrow(LightingValidationError); + }); + + it('dispatches generic creation and patch helpers across light kinds', () => { + const directional = createLightDefinition( + LightKind.Directional, + { + direction: [1, -1, 0], + }, + 'directional' + ); + const point = createLightDefinition( + LightKind.Point, + { + position: [1, 2, 3], + range: 4, + }, + 'point' + ); + const spot = createLightDefinition( + LightKind.Spot, + { + direction: [0, -1, 0], + coneMode: 'cosine', + innerConeCosine: 0.8, + outerConeCosine: 0.6, + }, + 'spot' + ); + const nextDirectional = applyDirectionalLightPatch(directional, { + intensity: 5, + }); + const nextPoint = applyPointLightPatch(point, { + position: [3, 2, 1], + range: 9, + }); + const nextSpot = applySpotLightPatch(spot, { + coneMode: 'angle', + innerConeAngle: 0.2, + outerConeAngle: 0.4, + }); + const genericDirectional = applyLightPatch(directional, { + priority: 7, + }); + const genericPoint = applyLightPatch(point, { + attenuation: 5, + }); + const genericSpot = applyLightPatch(spot, { + coneMode: 'cosine', + innerConeCosine: 0.91, + outerConeCosine: 0.81, + }); + + expect(nextDirectional.intensity).toBe(5); + expect(serializeVec3(nextPoint.position)).toEqual([3, 2, 1]); + expect(nextPoint.range).toBe(9); + expect(nextSpot.innerConeCosine).toBeCloseTo(Math.cos(0.2)); + expect(genericDirectional.priority).toBe(7); + expect(genericPoint.attenuation).toBe(5); + expect(genericSpot.innerConeCosine).toBeCloseTo(0.91); + }); +}); \ No newline at end of file diff --git a/web/packages/lighting/src/__tests__/lighting.test.ts b/web/packages/lighting/src/__tests__/lighting.test.ts new file mode 100644 index 00000000..182a683e --- /dev/null +++ b/web/packages/lighting/src/__tests__/lighting.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; +import { LightKind, LightSortMode } from '../constants'; +import { LightingFrameResolver } from '../frame-resolver'; +import { LightingRig } from '../rig'; +import { + deserializeLightingRig, + safeDeserializeLightingRig, + serializeLightingRig, +} from '../serialization'; +import { createLightingUniformValueMap } from '../uniform-layout'; + +describe('lighting', () => { + it('selects the highest influence lights into per-kind and local buffers', () => { + const rig = new LightingRig(); + + rig.addDirectional({ + direction: [0, -1, 0], + ambient: [0.1, 0.1, 0.08], + intensity: 2, + color: [1, 0.9, 0.8], + }); + rig.addPoint({ + id: 'near-red', + position: [0, 0, 2], + range: 6, + intensity: 8, + color: [1, 0, 0], + }); + rig.addPoint({ + id: 'far-green', + position: [0, 0, 9], + range: 4, + intensity: 8, + color: [0, 1, 0], + }); + rig.addSpot({ + id: 'near-blue', + position: [0, 0, 1], + direction: [0, 0, -1], + range: 8, + intensity: 6, + color: [0, 0, 1], + coneMode: 'angle', + innerConeAngle: 0.1, + outerConeAngle: 0.45, + }); + + const resolver = new LightingFrameResolver({ + capacity: { + maxDirectionalLights: 1, + maxPointLights: 1, + maxSpotLights: 1, + maxLocalLights: 2, + }, + sortMode: LightSortMode.Influence, + }); + const state = resolver.resolve(rig, { cameraPosition: [0, 0, 0] }); + const uniforms = createLightingUniformValueMap(state); + + expect(state.stats.selectedDirectionalCount).toBe(1); + expect(state.stats.selectedPointCount).toBe(1); + expect(state.stats.selectedSpotCount).toBe(1); + expect(state.stats.selectedLocalLightCount).toBe(2); + expect(Array.from(state.pointPositions)).toEqual([0, 0, 2]); + expect(Array.from(state.localLightKinds)).toEqual([ + 1, + 2, + ]); + expect(uniforms.u_PointLightCount).toBe(1); + expect(uniforms.u_LocalLightCount).toBe(2); + expect(Array.from(uniforms.u_SpotLightDirection)).toEqual([0, 0, -1]); + }); + + it('round-trips a serialized rig without losing canonical values', () => { + const rig = new LightingRig({ + environment: { + ambient: [0.2, 0.1, 0.05], + exposure: 1.4, + gamma: 2.1, + }, + }); + + rig.addDirectional({ + id: 'sun', + direction: [1, -1, 0], + ambient: [0.01, 0.02, 0.03], + intensity: 3, + }); + rig.addSpot({ + id: 'lamp', + position: [1, 2, 3], + direction: [0, -1, 0], + range: 5, + intensity: 2, + coneMode: 'cosine', + innerConeCosine: 0.9, + outerConeCosine: 0.7, + }); + + const document = serializeLightingRig(rig); + const parsed = deserializeLightingRig(document); + const lamp = parsed.get('lamp'); + + expect(parsed.size).toBe(2); + expect(parsed.environment.exposure).toBe(1.4); + expect(parsed.environment.gamma).toBe(2.1); + expect(lamp?.kind).toBe(LightKind.Spot); + + if (lamp?.kind === LightKind.Spot) { + expect(lamp.innerConeCosine).toBeCloseTo(0.9); + expect(lamp.outerConeCosine).toBeCloseTo(0.7); + } + }); + + it('safe deserialization keeps valid lights and reports malformed ones', () => { + const result = safeDeserializeLightingRig({ + version: 1, + lights: [ + { + kind: 'point', + id: 'ok', + position: [0, 0, 0], + range: 4, + intensity: 1, + }, + { + kind: 'spot', + id: 'bad', + position: [0, 0, 0], + direction: [0, 0, -1], + range: 'broken', + }, + ], + }); + + expect(result.ok).toBe(true); + + if (result.ok) { + expect(result.value.size).toBe(1); + expect(result.issues).toHaveLength(1); + expect(result.value.get('ok')?.kind).toBe(LightKind.Point); + } + }); + + it('rejects invalid updates', () => { + const rig = new LightingRig(); + const light = rig.addPoint({ id: 'point', range: 5 }); + + expect(() => + rig.update(light.id, { + range: -1, + }) + ).toThrow(); + }); +}); \ No newline at end of file diff --git a/web/packages/lighting/src/brands.ts b/web/packages/lighting/src/brands.ts new file mode 100644 index 00000000..5baa1356 --- /dev/null +++ b/web/packages/lighting/src/brands.ts @@ -0,0 +1,12 @@ +import type { Brand } from '@axrone/utility'; +import type { LightKind } from './constants'; + +export type LightingRigId = Brand; +export type LightingVersion = Brand; +export type LightId = Brand; + +export const brandLightingRigId = (value: string): LightingRigId => value as LightingRigId; +export const brandLightingVersion = (value: number): LightingVersion => + value as LightingVersion; +export const brandLightId = (kind: K, value: string): LightId => + value as LightId; \ No newline at end of file diff --git a/web/packages/lighting/src/constants.ts b/web/packages/lighting/src/constants.ts new file mode 100644 index 00000000..ab48cdad --- /dev/null +++ b/web/packages/lighting/src/constants.ts @@ -0,0 +1,32 @@ +export const LightKind = Object.freeze({ + Directional: 'directional', + Point: 'point', + Spot: 'spot', +} as const); + +export type LightKind = (typeof LightKind)[keyof typeof LightKind]; + +export const LightKinds = Object.freeze([ + LightKind.Directional, + LightKind.Point, + LightKind.Spot, +] as const); + +export const LightSortMode = Object.freeze({ + None: 'none', + Priority: 'priority', + Influence: 'influence', +} as const); + +export type LightSortMode = (typeof LightSortMode)[keyof typeof LightSortMode]; + +export const LightTypeCode = Object.freeze({ + [LightKind.Directional]: 0, + [LightKind.Point]: 1, + [LightKind.Spot]: 2, +} as const satisfies Record); + +export type LightTypeCode = (typeof LightTypeCode)[K]; + +export const LightingDocumentVersion = 1 as const; +export type LightingDocumentVersion = typeof LightingDocumentVersion; \ No newline at end of file diff --git a/web/packages/lighting/src/core.ts b/web/packages/lighting/src/core.ts new file mode 100644 index 00000000..c9737651 --- /dev/null +++ b/web/packages/lighting/src/core.ts @@ -0,0 +1,18 @@ +export * from './constants'; +export * from './brands'; +export * from './errors'; +export * from './types'; +export * from './guards'; +export { + DEFAULT_LIGHTING_CAPACITY, + DEFAULT_LIGHTING_ENVIRONMENT, + applyDirectionalLightPatch, + applyPointLightPatch, + applySpotLightPatch, + createDirectionalLightDefinition, + createLightingEnvironment, + createPointLightDefinition, + createSpotLightDefinition, + resolveLightingCapacity, +} from './validation'; +export { LightingRig } from './rig'; \ No newline at end of file diff --git a/web/packages/lighting/src/errors.ts b/web/packages/lighting/src/errors.ts new file mode 100644 index 00000000..44067254 --- /dev/null +++ b/web/packages/lighting/src/errors.ts @@ -0,0 +1,58 @@ +export type LightingErrorCode = + | `lighting.rig.${string}` + | `lighting.light.${string}` + | `lighting.resolve.${string}` + | `lighting.serialize.${string}`; + +export type LightingErrorDetails = Readonly>; + +export class LightingError extends Error { + readonly code: LightingErrorCode; + readonly details?: LightingErrorDetails; + readonly cause?: Error; + + constructor( + code: LightingErrorCode, + message: string, + details?: LightingErrorDetails, + cause?: Error + ) { + super(message); + this.code = code; + this.details = details; + this.cause = cause; + Object.setPrototypeOf(this, new.target.prototype); + ( + Error as typeof Error & { captureStackTrace?: (target: object, ctor: Function) => void } + ).captureStackTrace?.(this, this.constructor); + } +} + +export class LightingValidationError extends LightingError { + constructor(code: `lighting.light.${string}` | `lighting.rig.${string}`, message: string, details?: LightingErrorDetails) { + super(code, message, details); + } +} + +export class LightingResolveError extends LightingError { + constructor(code: `lighting.resolve.${string}`, message: string, details?: LightingErrorDetails) { + super(code, message, details); + } +} + +export class LightingSerializationError extends LightingError { + constructor( + code: `lighting.serialize.${string}`, + message: string, + details?: LightingErrorDetails, + cause?: Error + ) { + super(code, message, details, cause); + } +} + +export class LightingDisposedError extends LightingError { + constructor(resource: string) { + super('lighting.rig.disposed', `${resource} has already been disposed`, { resource }); + } +} \ No newline at end of file diff --git a/web/packages/lighting/src/frame-resolver.ts b/web/packages/lighting/src/frame-resolver.ts new file mode 100644 index 00000000..3bdbe241 --- /dev/null +++ b/web/packages/lighting/src/frame-resolver.ts @@ -0,0 +1,822 @@ +import { Vec3 } from '@axrone/numeric'; +import type { Disposable } from '@axrone/utility'; +import type { ReadonlyTuple3 } from '@axrone/utility'; +import { brandLightingRigId, brandLightingVersion } from './brands'; +import { LightKind, LightSortMode, LightTypeCode } from './constants'; +import type { LightSortMode as LightSortModeType } from './constants'; +import { LightingDisposedError } from './errors'; +import { LIGHTING_RIG_ACCESS, type InternalLightRecord, type LightingRigReadable } from './internal'; +import type { + DirectionalLightDefinition, + LightingCapacity, + LightingEnvironment, + LightingFrameResolverOptions, + LightingSelectionOptions, + LightingSelectionState, + LightingSelectionStats, + PointLightDefinition, + SpotLightDefinition, + Vec3Input, +} from './types'; +import { DEFAULT_LIGHTING_CAPACITY, resolveLightingCapacity } from './validation'; + +type RankedDirectional = InternalLightRecord<'directional'>; +type RankedLocal = InternalLightRecord<'point' | 'spot'>; + +type Mutable = { + -readonly [K in keyof T]: T[K]; +}; + +interface MutableLightingEnvironment { + ambient: Vec3; + sky: Vec3; + ground: Vec3; + exposure: number; + gamma: number; +} + +type MutableLightingSelectionStats = Mutable; + +type MutableLightingSelectionState = Mutable< + Omit +> & { + environment: MutableLightingEnvironment; + stats: MutableLightingSelectionStats; +}; + +const createFloatViews = (source: Float32Array, capacity: number, stride: number): readonly Float32Array[] => { + return Object.freeze( + Array.from({ length: capacity + 1 }, (_, count) => + source.subarray(0, Math.max(1, count * stride)) + ) + ); +}; + +const createIntViews = (source: Int32Array, capacity: number): readonly Int32Array[] => { + return Object.freeze( + Array.from({ length: capacity + 1 }, (_, count) => source.subarray(0, Math.max(1, count))) + ); +}; + +const createStats = (): MutableLightingSelectionStats => ({ + totalLightCount: 0, + totalDirectionalCount: 0, + totalPointCount: 0, + totalSpotCount: 0, + selectedDirectionalCount: 0, + selectedPointCount: 0, + selectedSpotCount: 0, + selectedLocalLightCount: 0, + omittedDirectionalCount: 0, + omittedPointCount: 0, + omittedSpotCount: 0, + omittedLocalLightCount: 0, +}); + +const writeVec3 = (target: Vec3, source: Readonly): void => { + target.x = source.x; + target.y = source.y; + target.z = source.z; +}; + +const isVec3TupleInput = (value: Vec3Input): value is ReadonlyTuple3 => Array.isArray(value); + +const sameVec3Input = (camera: Vec3Input | undefined, lastCamera: Vec3, hasLastCamera: boolean): boolean => { + if (camera === undefined) { + return !hasLastCamera; + } + + if (!hasLastCamera) { + return false; + } + + if (isVec3TupleInput(camera)) { + return camera[0] === lastCamera.x && camera[1] === lastCamera.y && camera[2] === lastCamera.z; + } + + return camera.x === lastCamera.x && camera.y === lastCamera.y && camera.z === lastCamera.z; +}; + +const writeCamera = (camera: Vec3, value: Vec3Input | undefined): boolean => { + if (value === undefined) { + camera.x = 0; + camera.y = 0; + camera.z = 0; + return false; + } + + if (isVec3TupleInput(value)) { + camera.x = value[0]; + camera.y = value[1]; + camera.z = value[2]; + return true; + } + + camera.x = value.x; + camera.y = value.y; + camera.z = value.z; + return true; +}; + +const distanceSquared = (a: Readonly, b: Readonly): number => { + const dx = a.x - b.x; + const dy = a.y - b.y; + const dz = a.z - b.z; + return dx * dx + dy * dy + dz * dz; +}; + +const pointInfluence = (light: PointLightDefinition, camera: Readonly | null): number => { + if (!camera) { + return light.intensity; + } + + const radiusSq = light.range * light.range; + if (radiusSq <= 0) { + return 0; + } + + const normalizedDistance = distanceSquared(light.position, camera) / radiusSq; + return light.intensity / (1 + light.attenuation * normalizedDistance * normalizedDistance); +}; + +const spotInfluence = (light: SpotLightDefinition, camera: Readonly | null): number => { + if (!camera) { + return light.intensity; + } + + const radiusSq = light.range * light.range; + if (radiusSq <= 0) { + return 0; + } + + const offsetX = camera.x - light.position.x; + const offsetY = camera.y - light.position.y; + const offsetZ = camera.z - light.position.z; + const length = Math.hypot(offsetX, offsetY, offsetZ); + + if (length <= 1e-8) { + return light.intensity; + } + + const directionDot = + (offsetX / length) * light.direction.x + + (offsetY / length) * light.direction.y + + (offsetZ / length) * light.direction.z; + + if (directionDot <= light.outerConeCosine) { + return 0; + } + + const normalizedDistance = distanceSquared(light.position, camera) / radiusSq; + const coneFactor = + light.innerConeCosine <= light.outerConeCosine + ? 1 + : Math.min( + 1, + Math.max( + 0, + (directionDot - light.outerConeCosine) / + (light.innerConeCosine - light.outerConeCosine) + ) + ); + + return (light.intensity * coneFactor) / (1 + light.attenuation * normalizedDistance * normalizedDistance); +}; + +const directionalScore = (light: DirectionalLightDefinition): number => light.priority * 1024 + light.intensity; + +const localScore = ( + entry: RankedLocal, + mode: LightSortModeType, + camera: Readonly | null +): number => { + switch (mode) { + case LightSortMode.None: + return 0; + case LightSortMode.Priority: + return entry.definition.priority * 1024 + entry.definition.intensity; + case LightSortMode.Influence: + if (entry.definition.kind === LightKind.Point) { + return entry.definition.priority * 1024 + pointInfluence(entry.definition, camera); + } + + return entry.definition.priority * 1024 + spotInfluence(entry.definition, camera); + default: + return 0; + } +}; + +const isDirectionalRecord = (entry: InternalLightRecord): entry is RankedDirectional => { + return entry.definition.kind === LightKind.Directional; +}; + +const isPointRecord = (entry: InternalLightRecord): entry is InternalLightRecord<'point'> => { + return entry.definition.kind === LightKind.Point; +}; + +const isSpotRecord = (entry: InternalLightRecord): entry is InternalLightRecord<'spot'> => { + return entry.definition.kind === LightKind.Spot; +}; + +const shouldPrecede = (score: number, sequence: number, otherScore: number, otherSequence: number): boolean => { + return score > otherScore || (score === otherScore && sequence < otherSequence); +}; + +const assignStats = ( + stats: MutableLightingSelectionStats, + totalDirectionalCount: number, + totalPointCount: number, + totalSpotCount: number, + selectedDirectionalCount: number, + selectedPointCount: number, + selectedSpotCount: number, + selectedLocalLightCount: number +): void => { + stats.totalDirectionalCount = totalDirectionalCount; + stats.totalPointCount = totalPointCount; + stats.totalSpotCount = totalSpotCount; + stats.totalLightCount = totalDirectionalCount + totalPointCount + totalSpotCount; + stats.selectedDirectionalCount = selectedDirectionalCount; + stats.selectedPointCount = selectedPointCount; + stats.selectedSpotCount = selectedSpotCount; + stats.selectedLocalLightCount = selectedLocalLightCount; + stats.omittedDirectionalCount = Math.max(0, totalDirectionalCount - selectedDirectionalCount); + stats.omittedPointCount = Math.max(0, totalPointCount - selectedPointCount); + stats.omittedSpotCount = Math.max(0, totalSpotCount - selectedSpotCount); + stats.omittedLocalLightCount = Math.max( + 0, + totalPointCount + totalSpotCount - selectedLocalLightCount + ); +}; + +export class LightingFrameResolver implements Disposable { + readonly #capacity: Readonly; + readonly #defaultSortMode: LightSortModeType; + + readonly #directionalDirectionsBase: Float32Array; + readonly #directionalColorsBase: Float32Array; + readonly #directionalAmbientColorsBase: Float32Array; + readonly #directionalIntensitiesBase: Float32Array; + readonly #pointPositionsBase: Float32Array; + readonly #pointColorsBase: Float32Array; + readonly #pointIntensitiesBase: Float32Array; + readonly #pointRangesBase: Float32Array; + readonly #spotPositionsBase: Float32Array; + readonly #spotDirectionsBase: Float32Array; + readonly #spotColorsBase: Float32Array; + readonly #spotIntensitiesBase: Float32Array; + readonly #spotRangesBase: Float32Array; + readonly #spotInnerConeCosinesBase: Float32Array; + readonly #spotOuterConeCosinesBase: Float32Array; + readonly #localLightKindsBase: Int32Array; + readonly #localLightPositionsBase: Float32Array; + readonly #localLightDirectionsBase: Float32Array; + readonly #localLightColorsBase: Float32Array; + readonly #localLightIntensitiesBase: Float32Array; + readonly #localLightRangesBase: Float32Array; + readonly #localLightInnerConeCosinesBase: Float32Array; + readonly #localLightOuterConeCosinesBase: Float32Array; + + readonly #directionalDirectionsViews: readonly Float32Array[]; + readonly #directionalColorsViews: readonly Float32Array[]; + readonly #directionalAmbientColorsViews: readonly Float32Array[]; + readonly #directionalIntensitiesViews: readonly Float32Array[]; + readonly #pointPositionsViews: readonly Float32Array[]; + readonly #pointColorsViews: readonly Float32Array[]; + readonly #pointIntensitiesViews: readonly Float32Array[]; + readonly #pointRangesViews: readonly Float32Array[]; + readonly #spotPositionsViews: readonly Float32Array[]; + readonly #spotDirectionsViews: readonly Float32Array[]; + readonly #spotColorsViews: readonly Float32Array[]; + readonly #spotIntensitiesViews: readonly Float32Array[]; + readonly #spotRangesViews: readonly Float32Array[]; + readonly #spotInnerConeCosinesViews: readonly Float32Array[]; + readonly #spotOuterConeCosinesViews: readonly Float32Array[]; + readonly #localLightKindsViews: readonly Int32Array[]; + readonly #localLightPositionsViews: readonly Float32Array[]; + readonly #localLightDirectionsViews: readonly Float32Array[]; + readonly #localLightColorsViews: readonly Float32Array[]; + readonly #localLightIntensitiesViews: readonly Float32Array[]; + readonly #localLightRangesViews: readonly Float32Array[]; + readonly #localLightInnerConeCosinesViews: readonly Float32Array[]; + readonly #localLightOuterConeCosinesViews: readonly Float32Array[]; + + readonly #state: MutableLightingSelectionState; + readonly #directionalRanked: Array; + readonly #directionalScores: Float64Array; + readonly #directionalSequences: Int32Array; + readonly #pointRanked: Array | null>; + readonly #pointScores: Float64Array; + readonly #pointSequences: Int32Array; + readonly #spotRanked: Array | null>; + readonly #spotScores: Float64Array; + readonly #spotSequences: Int32Array; + readonly #localRanked: Array; + readonly #localScores: Float64Array; + readonly #localSequences: Int32Array; + + readonly #lastCamera = new Vec3(); + + #hasCachedCamera = false; + #cachedRigId: string | null = null; + #cachedVersion = -1; + #cachedSortMode: LightSortModeType; + #isDisposed = false; + + constructor(options: LightingFrameResolverOptions = {}) { + this.#capacity = resolveLightingCapacity(options.capacity ?? DEFAULT_LIGHTING_CAPACITY); + this.#defaultSortMode = options.sortMode ?? LightSortMode.Influence; + this.#cachedSortMode = this.#defaultSortMode; + + this.#directionalDirectionsBase = new Float32Array(Math.max(1, this.#capacity.maxDirectionalLights * 3)); + this.#directionalColorsBase = new Float32Array(Math.max(1, this.#capacity.maxDirectionalLights * 3)); + this.#directionalAmbientColorsBase = new Float32Array(Math.max(1, this.#capacity.maxDirectionalLights * 3)); + this.#directionalIntensitiesBase = new Float32Array(Math.max(1, this.#capacity.maxDirectionalLights)); + this.#pointPositionsBase = new Float32Array(Math.max(1, this.#capacity.maxPointLights * 3)); + this.#pointColorsBase = new Float32Array(Math.max(1, this.#capacity.maxPointLights * 3)); + this.#pointIntensitiesBase = new Float32Array(Math.max(1, this.#capacity.maxPointLights)); + this.#pointRangesBase = new Float32Array(Math.max(1, this.#capacity.maxPointLights)); + this.#spotPositionsBase = new Float32Array(Math.max(1, this.#capacity.maxSpotLights * 3)); + this.#spotDirectionsBase = new Float32Array(Math.max(1, this.#capacity.maxSpotLights * 3)); + this.#spotColorsBase = new Float32Array(Math.max(1, this.#capacity.maxSpotLights * 3)); + this.#spotIntensitiesBase = new Float32Array(Math.max(1, this.#capacity.maxSpotLights)); + this.#spotRangesBase = new Float32Array(Math.max(1, this.#capacity.maxSpotLights)); + this.#spotInnerConeCosinesBase = new Float32Array(Math.max(1, this.#capacity.maxSpotLights)); + this.#spotOuterConeCosinesBase = new Float32Array(Math.max(1, this.#capacity.maxSpotLights)); + this.#localLightKindsBase = new Int32Array(Math.max(1, this.#capacity.maxLocalLights)); + this.#localLightPositionsBase = new Float32Array(Math.max(1, this.#capacity.maxLocalLights * 3)); + this.#localLightDirectionsBase = new Float32Array(Math.max(1, this.#capacity.maxLocalLights * 3)); + this.#localLightColorsBase = new Float32Array(Math.max(1, this.#capacity.maxLocalLights * 3)); + this.#localLightIntensitiesBase = new Float32Array(Math.max(1, this.#capacity.maxLocalLights)); + this.#localLightRangesBase = new Float32Array(Math.max(1, this.#capacity.maxLocalLights)); + this.#localLightInnerConeCosinesBase = new Float32Array(Math.max(1, this.#capacity.maxLocalLights)); + this.#localLightOuterConeCosinesBase = new Float32Array(Math.max(1, this.#capacity.maxLocalLights)); + + this.#directionalDirectionsViews = createFloatViews(this.#directionalDirectionsBase, this.#capacity.maxDirectionalLights, 3); + this.#directionalColorsViews = createFloatViews(this.#directionalColorsBase, this.#capacity.maxDirectionalLights, 3); + this.#directionalAmbientColorsViews = createFloatViews(this.#directionalAmbientColorsBase, this.#capacity.maxDirectionalLights, 3); + this.#directionalIntensitiesViews = createFloatViews(this.#directionalIntensitiesBase, this.#capacity.maxDirectionalLights, 1); + this.#pointPositionsViews = createFloatViews(this.#pointPositionsBase, this.#capacity.maxPointLights, 3); + this.#pointColorsViews = createFloatViews(this.#pointColorsBase, this.#capacity.maxPointLights, 3); + this.#pointIntensitiesViews = createFloatViews(this.#pointIntensitiesBase, this.#capacity.maxPointLights, 1); + this.#pointRangesViews = createFloatViews(this.#pointRangesBase, this.#capacity.maxPointLights, 1); + this.#spotPositionsViews = createFloatViews(this.#spotPositionsBase, this.#capacity.maxSpotLights, 3); + this.#spotDirectionsViews = createFloatViews(this.#spotDirectionsBase, this.#capacity.maxSpotLights, 3); + this.#spotColorsViews = createFloatViews(this.#spotColorsBase, this.#capacity.maxSpotLights, 3); + this.#spotIntensitiesViews = createFloatViews(this.#spotIntensitiesBase, this.#capacity.maxSpotLights, 1); + this.#spotRangesViews = createFloatViews(this.#spotRangesBase, this.#capacity.maxSpotLights, 1); + this.#spotInnerConeCosinesViews = createFloatViews(this.#spotInnerConeCosinesBase, this.#capacity.maxSpotLights, 1); + this.#spotOuterConeCosinesViews = createFloatViews(this.#spotOuterConeCosinesBase, this.#capacity.maxSpotLights, 1); + this.#localLightKindsViews = createIntViews(this.#localLightKindsBase, this.#capacity.maxLocalLights); + this.#localLightPositionsViews = createFloatViews(this.#localLightPositionsBase, this.#capacity.maxLocalLights, 3); + this.#localLightDirectionsViews = createFloatViews(this.#localLightDirectionsBase, this.#capacity.maxLocalLights, 3); + this.#localLightColorsViews = createFloatViews(this.#localLightColorsBase, this.#capacity.maxLocalLights, 3); + this.#localLightIntensitiesViews = createFloatViews(this.#localLightIntensitiesBase, this.#capacity.maxLocalLights, 1); + this.#localLightRangesViews = createFloatViews(this.#localLightRangesBase, this.#capacity.maxLocalLights, 1); + this.#localLightInnerConeCosinesViews = createFloatViews(this.#localLightInnerConeCosinesBase, this.#capacity.maxLocalLights, 1); + this.#localLightOuterConeCosinesViews = createFloatViews(this.#localLightOuterConeCosinesBase, this.#capacity.maxLocalLights, 1); + + this.#directionalRanked = Array.from({ length: this.#capacity.maxDirectionalLights }, () => null); + this.#directionalScores = new Float64Array(this.#capacity.maxDirectionalLights); + this.#directionalSequences = new Int32Array(this.#capacity.maxDirectionalLights); + this.#pointRanked = Array.from({ length: this.#capacity.maxPointLights }, () => null); + this.#pointScores = new Float64Array(this.#capacity.maxPointLights); + this.#pointSequences = new Int32Array(this.#capacity.maxPointLights); + this.#spotRanked = Array.from({ length: this.#capacity.maxSpotLights }, () => null); + this.#spotScores = new Float64Array(this.#capacity.maxSpotLights); + this.#spotSequences = new Int32Array(this.#capacity.maxSpotLights); + this.#localRanked = Array.from({ length: this.#capacity.maxLocalLights }, () => null); + this.#localScores = new Float64Array(this.#capacity.maxLocalLights); + this.#localSequences = new Int32Array(this.#capacity.maxLocalLights); + + const environment: MutableLightingEnvironment = { + ambient: new Vec3(), + sky: new Vec3(), + ground: new Vec3(), + exposure: 1, + gamma: 2.2, + }; + const stats = createStats(); + this.#state = { + rigId: brandLightingRigId(''), + version: brandLightingVersion(0), + sortMode: this.#defaultSortMode, + capacity: this.#capacity, + environment, + stats, + directionalDirections: this.#directionalDirectionsViews[0]!, + directionalColors: this.#directionalColorsViews[0]!, + directionalAmbientColors: this.#directionalAmbientColorsViews[0]!, + directionalIntensities: this.#directionalIntensitiesViews[0]!, + pointPositions: this.#pointPositionsViews[0]!, + pointColors: this.#pointColorsViews[0]!, + pointIntensities: this.#pointIntensitiesViews[0]!, + pointRanges: this.#pointRangesViews[0]!, + spotPositions: this.#spotPositionsViews[0]!, + spotDirections: this.#spotDirectionsViews[0]!, + spotColors: this.#spotColorsViews[0]!, + spotIntensities: this.#spotIntensitiesViews[0]!, + spotRanges: this.#spotRangesViews[0]!, + spotInnerConeCosines: this.#spotInnerConeCosinesViews[0]!, + spotOuterConeCosines: this.#spotOuterConeCosinesViews[0]!, + localLightKinds: this.#localLightKindsViews[0]!, + localLightPositions: this.#localLightPositionsViews[0]!, + localLightDirections: this.#localLightDirectionsViews[0]!, + localLightColors: this.#localLightColorsViews[0]!, + localLightIntensities: this.#localLightIntensitiesViews[0]!, + localLightRanges: this.#localLightRangesViews[0]!, + localLightInnerConeCosines: this.#localLightInnerConeCosinesViews[0]!, + localLightOuterConeCosines: this.#localLightOuterConeCosinesViews[0]!, + }; + } + + get capacity(): Readonly { + return this.#capacity; + } + + get isDisposed(): boolean { + return this.#isDisposed; + } + + resolve(rig: LightingRigReadable, options: LightingSelectionOptions = {}): LightingSelectionState { + this.#assertNotDisposed(); + const snapshot = rig[LIGHTING_RIG_ACCESS](); + const sortMode = options.sortMode ?? this.#defaultSortMode; + const currentVersion = Number(snapshot.version); + + if ( + this.#cachedRigId === String(snapshot.id) && + this.#cachedVersion === currentVersion && + this.#cachedSortMode === sortMode && + (sortMode !== LightSortMode.Influence || sameVec3Input(options.cameraPosition, this.#lastCamera, this.#hasCachedCamera)) + ) { + return this.#state; + } + + const hasCamera = writeCamera(this.#lastCamera, options.cameraPosition); + const camera = hasCamera ? this.#lastCamera : null; + this.#hasCachedCamera = hasCamera; + this.#cachedRigId = String(snapshot.id); + this.#cachedVersion = currentVersion; + this.#cachedSortMode = sortMode; + + this.#clearBuffers(); + this.#syncEnvironment(snapshot.environment); + + let selectedDirectionalCount = 0; + let selectedPointCount = 0; + let selectedSpotCount = 0; + let selectedLocalLightCount = 0; + let totalDirectionalCount = 0; + let totalPointCount = 0; + let totalSpotCount = 0; + + if (sortMode === LightSortMode.None) { + for (const entry of snapshot.entries) { + const { definition } = entry; + + if (!definition.enabled) { + continue; + } + + switch (definition.kind) { + case LightKind.Directional: + totalDirectionalCount += 1; + if (selectedDirectionalCount < this.#capacity.maxDirectionalLights) { + this.#writeDirectional(definition, selectedDirectionalCount); + selectedDirectionalCount += 1; + } + break; + case LightKind.Point: + totalPointCount += 1; + if (selectedPointCount < this.#capacity.maxPointLights) { + this.#writePoint(definition, selectedPointCount); + selectedPointCount += 1; + } + if (selectedLocalLightCount < this.#capacity.maxLocalLights) { + this.#writeLocalPoint(definition, selectedLocalLightCount); + selectedLocalLightCount += 1; + } + break; + case LightKind.Spot: + totalSpotCount += 1; + if (selectedSpotCount < this.#capacity.maxSpotLights) { + this.#writeSpot(definition, selectedSpotCount); + selectedSpotCount += 1; + } + if (selectedLocalLightCount < this.#capacity.maxLocalLights) { + this.#writeLocalSpot(definition, selectedLocalLightCount); + selectedLocalLightCount += 1; + } + break; + } + } + } else { + for (const entry of snapshot.entries) { + if (!entry.definition.enabled) { + continue; + } + + if (isDirectionalRecord(entry)) { + totalDirectionalCount += 1; + selectedDirectionalCount = this.#insertRanked( + this.#directionalRanked, + this.#directionalScores, + this.#directionalSequences, + selectedDirectionalCount, + entry, + directionalScore(entry.definition) + ); + continue; + } + + if (isPointRecord(entry)) { + const score = localScore(entry, sortMode, camera); + totalPointCount += 1; + selectedPointCount = this.#insertRanked( + this.#pointRanked, + this.#pointScores, + this.#pointSequences, + selectedPointCount, + entry, + score + ); + selectedLocalLightCount = this.#insertRanked( + this.#localRanked, + this.#localScores, + this.#localSequences, + selectedLocalLightCount, + entry, + score + ); + continue; + } + + if (isSpotRecord(entry)) { + const score = localScore(entry, sortMode, camera); + totalSpotCount += 1; + selectedSpotCount = this.#insertRanked( + this.#spotRanked, + this.#spotScores, + this.#spotSequences, + selectedSpotCount, + entry, + score + ); + selectedLocalLightCount = this.#insertRanked( + this.#localRanked, + this.#localScores, + this.#localSequences, + selectedLocalLightCount, + entry, + score + ); + } + } + + for (let index = 0; index < selectedDirectionalCount; index += 1) { + const entry = this.#directionalRanked[index]!; + this.#writeDirectional(entry.definition, index); + } + + for (let index = 0; index < selectedPointCount; index += 1) { + const entry = this.#pointRanked[index]!; + this.#writePoint(entry.definition, index); + } + + for (let index = 0; index < selectedSpotCount; index += 1) { + const entry = this.#spotRanked[index]!; + this.#writeSpot(entry.definition, index); + } + + for (let index = 0; index < selectedLocalLightCount; index += 1) { + const entry = this.#localRanked[index]!; + + if (entry.definition.kind === LightKind.Point) { + this.#writeLocalPoint(entry.definition, index); + } else { + this.#writeLocalSpot(entry.definition, index); + } + } + } + + assignStats( + this.#state.stats, + totalDirectionalCount, + totalPointCount, + totalSpotCount, + selectedDirectionalCount, + selectedPointCount, + selectedSpotCount, + selectedLocalLightCount + ); + + this.#state.rigId = snapshot.id; + this.#state.version = snapshot.version; + this.#state.sortMode = sortMode; + this.#state.directionalDirections = this.#directionalDirectionsViews[selectedDirectionalCount]!; + this.#state.directionalColors = this.#directionalColorsViews[selectedDirectionalCount]!; + this.#state.directionalAmbientColors = this.#directionalAmbientColorsViews[selectedDirectionalCount]!; + this.#state.directionalIntensities = this.#directionalIntensitiesViews[selectedDirectionalCount]!; + this.#state.pointPositions = this.#pointPositionsViews[selectedPointCount]!; + this.#state.pointColors = this.#pointColorsViews[selectedPointCount]!; + this.#state.pointIntensities = this.#pointIntensitiesViews[selectedPointCount]!; + this.#state.pointRanges = this.#pointRangesViews[selectedPointCount]!; + this.#state.spotPositions = this.#spotPositionsViews[selectedSpotCount]!; + this.#state.spotDirections = this.#spotDirectionsViews[selectedSpotCount]!; + this.#state.spotColors = this.#spotColorsViews[selectedSpotCount]!; + this.#state.spotIntensities = this.#spotIntensitiesViews[selectedSpotCount]!; + this.#state.spotRanges = this.#spotRangesViews[selectedSpotCount]!; + this.#state.spotInnerConeCosines = this.#spotInnerConeCosinesViews[selectedSpotCount]!; + this.#state.spotOuterConeCosines = this.#spotOuterConeCosinesViews[selectedSpotCount]!; + this.#state.localLightKinds = this.#localLightKindsViews[selectedLocalLightCount]!; + this.#state.localLightPositions = this.#localLightPositionsViews[selectedLocalLightCount]!; + this.#state.localLightDirections = this.#localLightDirectionsViews[selectedLocalLightCount]!; + this.#state.localLightColors = this.#localLightColorsViews[selectedLocalLightCount]!; + this.#state.localLightIntensities = this.#localLightIntensitiesViews[selectedLocalLightCount]!; + this.#state.localLightRanges = this.#localLightRangesViews[selectedLocalLightCount]!; + this.#state.localLightInnerConeCosines = this.#localLightInnerConeCosinesViews[selectedLocalLightCount]!; + this.#state.localLightOuterConeCosines = this.#localLightOuterConeCosinesViews[selectedLocalLightCount]!; + + return this.#state; + } + + dispose(): void { + if (this.#isDisposed) { + return; + } + + this.#isDisposed = true; + this.#cachedRigId = null; + this.#cachedVersion = -1; + } + + #insertRanked( + records: Array, + scores: Float64Array, + sequences: Int32Array, + count: number, + entry: T, + score: number + ): number { + const capacity = records.length; + + if (capacity === 0) { + return 0; + } + + const sequence = entry.sequence; + let insertIndex = count; + + while ( + insertIndex > 0 && + shouldPrecede(score, sequence, scores[insertIndex - 1]!, sequences[insertIndex - 1]!) + ) { + insertIndex -= 1; + } + + if (count < capacity) { + for (let index = count; index > insertIndex; index -= 1) { + records[index] = records[index - 1]; + scores[index] = scores[index - 1]!; + sequences[index] = sequences[index - 1]!; + } + + records[insertIndex] = entry; + scores[insertIndex] = score; + sequences[insertIndex] = sequence; + return count + 1; + } + + if (insertIndex >= capacity) { + return count; + } + + for (let index = capacity - 1; index > insertIndex; index -= 1) { + records[index] = records[index - 1]; + scores[index] = scores[index - 1]!; + sequences[index] = sequences[index - 1]!; + } + + records[insertIndex] = entry; + scores[insertIndex] = score; + sequences[insertIndex] = sequence; + return count; + } + + #syncEnvironment(environment: LightingEnvironment): void { + writeVec3(this.#state.environment.ambient, environment.ambient); + writeVec3(this.#state.environment.sky, environment.sky); + writeVec3(this.#state.environment.ground, environment.ground); + this.#state.environment.exposure = environment.exposure; + this.#state.environment.gamma = environment.gamma; + } + + #clearBuffers(): void { + this.#directionalDirectionsBase.fill(0); + this.#directionalColorsBase.fill(0); + this.#directionalAmbientColorsBase.fill(0); + this.#directionalIntensitiesBase.fill(0); + this.#pointPositionsBase.fill(0); + this.#pointColorsBase.fill(0); + this.#pointIntensitiesBase.fill(0); + this.#pointRangesBase.fill(0); + this.#spotPositionsBase.fill(0); + this.#spotDirectionsBase.fill(0); + this.#spotColorsBase.fill(0); + this.#spotIntensitiesBase.fill(0); + this.#spotRangesBase.fill(0); + this.#spotInnerConeCosinesBase.fill(0); + this.#spotOuterConeCosinesBase.fill(0); + this.#localLightKindsBase.fill(0); + this.#localLightPositionsBase.fill(0); + this.#localLightDirectionsBase.fill(0); + this.#localLightColorsBase.fill(0); + this.#localLightIntensitiesBase.fill(0); + this.#localLightRangesBase.fill(0); + this.#localLightInnerConeCosinesBase.fill(0); + this.#localLightOuterConeCosinesBase.fill(0); + } + + #writeDirectional(light: DirectionalLightDefinition, slot: number): void { + const offset = slot * 3; + this.#directionalDirectionsBase[offset] = light.direction.x; + this.#directionalDirectionsBase[offset + 1] = light.direction.y; + this.#directionalDirectionsBase[offset + 2] = light.direction.z; + this.#directionalColorsBase[offset] = light.color.x; + this.#directionalColorsBase[offset + 1] = light.color.y; + this.#directionalColorsBase[offset + 2] = light.color.z; + this.#directionalAmbientColorsBase[offset] = light.ambient.x; + this.#directionalAmbientColorsBase[offset + 1] = light.ambient.y; + this.#directionalAmbientColorsBase[offset + 2] = light.ambient.z; + this.#directionalIntensitiesBase[slot] = light.intensity; + } + + #writePoint(light: PointLightDefinition, slot: number): void { + const offset = slot * 3; + this.#pointPositionsBase[offset] = light.position.x; + this.#pointPositionsBase[offset + 1] = light.position.y; + this.#pointPositionsBase[offset + 2] = light.position.z; + this.#pointColorsBase[offset] = light.color.x; + this.#pointColorsBase[offset + 1] = light.color.y; + this.#pointColorsBase[offset + 2] = light.color.z; + this.#pointIntensitiesBase[slot] = light.intensity; + this.#pointRangesBase[slot] = light.range; + } + + #writeSpot(light: SpotLightDefinition, slot: number): void { + const offset = slot * 3; + this.#spotPositionsBase[offset] = light.position.x; + this.#spotPositionsBase[offset + 1] = light.position.y; + this.#spotPositionsBase[offset + 2] = light.position.z; + this.#spotDirectionsBase[offset] = light.direction.x; + this.#spotDirectionsBase[offset + 1] = light.direction.y; + this.#spotDirectionsBase[offset + 2] = light.direction.z; + this.#spotColorsBase[offset] = light.color.x; + this.#spotColorsBase[offset + 1] = light.color.y; + this.#spotColorsBase[offset + 2] = light.color.z; + this.#spotIntensitiesBase[slot] = light.intensity; + this.#spotRangesBase[slot] = light.range; + this.#spotInnerConeCosinesBase[slot] = light.innerConeCosine; + this.#spotOuterConeCosinesBase[slot] = light.outerConeCosine; + } + + #writeLocalPoint(light: PointLightDefinition, slot: number): void { + const offset = slot * 3; + this.#localLightKindsBase[slot] = LightTypeCode[LightKind.Point]; + this.#localLightPositionsBase[offset] = light.position.x; + this.#localLightPositionsBase[offset + 1] = light.position.y; + this.#localLightPositionsBase[offset + 2] = light.position.z; + this.#localLightColorsBase[offset] = light.color.x; + this.#localLightColorsBase[offset + 1] = light.color.y; + this.#localLightColorsBase[offset + 2] = light.color.z; + this.#localLightIntensitiesBase[slot] = light.intensity; + this.#localLightRangesBase[slot] = light.range; + this.#localLightInnerConeCosinesBase[slot] = 0; + this.#localLightOuterConeCosinesBase[slot] = 0; + } + + #writeLocalSpot(light: SpotLightDefinition, slot: number): void { + const offset = slot * 3; + this.#localLightKindsBase[slot] = LightTypeCode[LightKind.Spot]; + this.#localLightPositionsBase[offset] = light.position.x; + this.#localLightPositionsBase[offset + 1] = light.position.y; + this.#localLightPositionsBase[offset + 2] = light.position.z; + this.#localLightDirectionsBase[offset] = light.direction.x; + this.#localLightDirectionsBase[offset + 1] = light.direction.y; + this.#localLightDirectionsBase[offset + 2] = light.direction.z; + this.#localLightColorsBase[offset] = light.color.x; + this.#localLightColorsBase[offset + 1] = light.color.y; + this.#localLightColorsBase[offset + 2] = light.color.z; + this.#localLightIntensitiesBase[slot] = light.intensity; + this.#localLightRangesBase[slot] = light.range; + this.#localLightInnerConeCosinesBase[slot] = light.innerConeCosine; + this.#localLightOuterConeCosinesBase[slot] = light.outerConeCosine; + } + + #assertNotDisposed(): void { + if (this.#isDisposed) { + throw new LightingDisposedError('LightingFrameResolver'); + } + } +} \ No newline at end of file diff --git a/web/packages/lighting/src/frame.ts b/web/packages/lighting/src/frame.ts new file mode 100644 index 00000000..93d60570 --- /dev/null +++ b/web/packages/lighting/src/frame.ts @@ -0,0 +1,23 @@ +export type { + LightingCapacity, + LightingFrameResolverOptions, + LightingSelectionOptions, + LightingSelectionState, + LightingSelectionStats, +} from './types'; +export { LightingFrameResolver } from './frame-resolver'; +export { + createLightingUniformLayout, + createLightingUniformValueMap, +} from './uniform-layout'; +export type { + LightingUniformProperty, + LightingUniformPropertyType, + LightingShaderDefines, + LightingUniformField, + LightingUniformLayout, + LightingUniformName, + LightingUniformNames, + LightingUniformValue, + LightingUniformValueMap, +} from './uniform-layout'; diff --git a/web/packages/lighting/src/guards.ts b/web/packages/lighting/src/guards.ts new file mode 100644 index 00000000..7e29d781 --- /dev/null +++ b/web/packages/lighting/src/guards.ts @@ -0,0 +1,228 @@ +import { Vec3 } from '@axrone/numeric'; +import type { JsonValue, ReadonlyTuple3 } from '@axrone/utility'; +import { LightKind, LightKinds, LightSortMode } from './constants'; +import type { + DirectionalLightDefinition, + LightDefinition, + LightingDocument, + LightingMetadata, + PointLightDefinition, + SerializedLight, + SpotLightDefinition, +} from './types'; + +const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const isObjectRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null; +}; + +const isJsonValue = (value: unknown): value is JsonValue => { + if ( + value === null || + typeof value === 'string' || + (typeof value === 'number' && Number.isFinite(value)) || + typeof value === 'boolean' + ) { + return true; + } + + if (Array.isArray(value)) { + return value.every(isJsonValue); + } + + if (!isObjectRecord(value)) { + return false; + } + + return Object.values(value).every(isJsonValue); +}; + +export const isReadonlyTuple3 = (value: unknown): value is ReadonlyTuple3 => { + return Array.isArray(value) && value.length === 3 && value.every(isFiniteNumber); +}; + +export const isLightingMetadata = (value: unknown): value is LightingMetadata => { + return isObjectRecord(value) && Object.values(value).every(isJsonValue); +}; + +export const isLightKind = (value: unknown): value is LightDefinition['kind'] => { + return typeof value === 'string' && (LightKinds as readonly string[]).includes(value); +}; + +export const isLightSortMode = (value: unknown): value is typeof LightSortMode[keyof typeof LightSortMode] => { + return typeof value === 'string' && Object.values(LightSortMode).includes(value as never); +}; + +export const isDirectionalLightDefinition = ( + value: unknown +): value is DirectionalLightDefinition => { + if (!isObjectRecord(value)) { + return false; + } + + return ( + value.kind === LightKind.Directional && + typeof value.id === 'string' && + typeof value.enabled === 'boolean' && + value.color instanceof Vec3 && + isFiniteNumber(value.intensity) && + isFiniteNumber(value.priority) && + value.direction instanceof Vec3 && + value.ambient instanceof Vec3 && + (value.metadata === undefined || isLightingMetadata(value.metadata)) + ); +}; + +export const isPointLightDefinition = (value: unknown): value is PointLightDefinition => { + if (!isObjectRecord(value)) { + return false; + } + + return ( + value.kind === LightKind.Point && + typeof value.id === 'string' && + typeof value.enabled === 'boolean' && + value.color instanceof Vec3 && + isFiniteNumber(value.intensity) && + isFiniteNumber(value.priority) && + value.position instanceof Vec3 && + isFiniteNumber(value.range) && + isFiniteNumber(value.attenuation) && + (value.metadata === undefined || isLightingMetadata(value.metadata)) + ); +}; + +export const isSpotLightDefinition = (value: unknown): value is SpotLightDefinition => { + if (!isObjectRecord(value)) { + return false; + } + + return ( + value.kind === LightKind.Spot && + typeof value.id === 'string' && + typeof value.enabled === 'boolean' && + value.color instanceof Vec3 && + isFiniteNumber(value.intensity) && + isFiniteNumber(value.priority) && + value.position instanceof Vec3 && + value.direction instanceof Vec3 && + isFiniteNumber(value.range) && + isFiniteNumber(value.attenuation) && + isFiniteNumber(value.innerConeCosine) && + isFiniteNumber(value.outerConeCosine) && + (value.metadata === undefined || isLightingMetadata(value.metadata)) + ); +}; + +export const isLightDefinition = (value: unknown): value is LightDefinition => { + return ( + isDirectionalLightDefinition(value) || + isPointLightDefinition(value) || + isSpotLightDefinition(value) + ); +}; + +export const isSerializedLight = (value: unknown): value is SerializedLight => { + if (!isObjectRecord(value) || !isLightKind(value.kind)) { + return false; + } + + if (value.id !== undefined && typeof value.id !== 'string') { + return false; + } + + if (value.color !== undefined && !isReadonlyTuple3(value.color)) { + return false; + } + + if (value.intensity !== undefined && !isFiniteNumber(value.intensity)) { + return false; + } + + if (value.priority !== undefined && !isFiniteNumber(value.priority)) { + return false; + } + + if (value.metadata !== undefined && !isLightingMetadata(value.metadata)) { + return false; + } + + switch (value.kind) { + case LightKind.Directional: + return ( + (value.direction === undefined || isReadonlyTuple3(value.direction)) && + (value.ambient === undefined || isReadonlyTuple3(value.ambient)) + ); + case LightKind.Point: + return ( + (value.position === undefined || isReadonlyTuple3(value.position)) && + (value.range === undefined || isFiniteNumber(value.range)) && + (value.attenuation === undefined || isFiniteNumber(value.attenuation)) + ); + case LightKind.Spot: + return ( + (value.position === undefined || isReadonlyTuple3(value.position)) && + (value.direction === undefined || isReadonlyTuple3(value.direction)) && + (value.range === undefined || isFiniteNumber(value.range)) && + (value.attenuation === undefined || isFiniteNumber(value.attenuation)) && + (value.innerConeCosine === undefined || isFiniteNumber(value.innerConeCosine)) && + (value.outerConeCosine === undefined || isFiniteNumber(value.outerConeCosine)) + ); + } +}; + +export const isLightingDocument = (value: unknown): value is LightingDocument => { + if (!isObjectRecord(value)) { + return false; + } + + if (value.version !== undefined && !isFiniteNumber(value.version)) { + return false; + } + + if (value.rigId !== undefined && typeof value.rigId !== 'string') { + return false; + } + + if (value.environment !== undefined) { + if (!isObjectRecord(value.environment)) { + return false; + } + + const environment = value.environment; + + if (environment.ambient !== undefined && !isReadonlyTuple3(environment.ambient)) { + return false; + } + + if (environment.sky !== undefined && !isReadonlyTuple3(environment.sky)) { + return false; + } + + if (environment.ground !== undefined && !isReadonlyTuple3(environment.ground)) { + return false; + } + + if (environment.exposure !== undefined && !isFiniteNumber(environment.exposure)) { + return false; + } + + if (environment.gamma !== undefined && !isFiniteNumber(environment.gamma)) { + return false; + } + } + + if (value.lights !== undefined) { + if (!Array.isArray(value.lights)) { + return false; + } + + if (!value.lights.every(isSerializedLight)) { + return false; + } + } + + return true; +}; \ No newline at end of file diff --git a/web/packages/lighting/src/index.ts b/web/packages/lighting/src/index.ts new file mode 100644 index 00000000..98548c99 --- /dev/null +++ b/web/packages/lighting/src/index.ts @@ -0,0 +1,3 @@ +export * from './core'; +export * from './frame'; +export * from './serialization'; \ No newline at end of file diff --git a/web/packages/lighting/src/internal.ts b/web/packages/lighting/src/internal.ts new file mode 100644 index 00000000..b3e72f72 --- /dev/null +++ b/web/packages/lighting/src/internal.ts @@ -0,0 +1,21 @@ +import type { LightKind } from './constants'; +import type { LightDefinition, LightingEnvironment } from './types'; +import type { LightingRigId, LightingVersion } from './brands'; + +export interface InternalLightRecord { + readonly definition: LightDefinition; + readonly sequence: number; +} + +export interface LightingRigSnapshot { + readonly id: LightingRigId; + readonly version: LightingVersion; + readonly environment: LightingEnvironment; + readonly entries: readonly InternalLightRecord[]; +} + +export const LIGHTING_RIG_ACCESS: unique symbol = Symbol('AXRONE_LIGHTING_RIG_ACCESS'); + +export interface LightingRigReadable { + readonly [LIGHTING_RIG_ACCESS]: () => LightingRigSnapshot; +} \ No newline at end of file diff --git a/web/packages/lighting/src/rig.ts b/web/packages/lighting/src/rig.ts new file mode 100644 index 00000000..570ff567 --- /dev/null +++ b/web/packages/lighting/src/rig.ts @@ -0,0 +1,291 @@ +import type { Disposable } from '@axrone/utility'; +import { brandLightingRigId, brandLightingVersion } from './brands'; +import { LightKind } from './constants'; +import { LightingDisposedError, LightingValidationError } from './errors'; +import { LIGHTING_RIG_ACCESS, type InternalLightRecord, type LightingRigReadable } from './internal'; +import type { + DirectionalLightCreateInput, + DirectionalLightDefinition, + DirectionalLightPatch, + LightDefinition, + LightingEnvironment, + LightingRigOptions, + LightingSelectionOptions, + LightingSelectionState, + PointLightCreateInput, + PointLightDefinition, + PointLightPatch, + SpotLightCreateInput, + SpotLightDefinition, + SpotLightPatch, +} from './types'; +import { + DEFAULT_LIGHTING_ENVIRONMENT, + applyDirectionalLightPatch, + applyPointLightPatch, + applySpotLightPatch, + createDirectionalLightDefinition, + createLightingEnvironment, + createPointLightDefinition, + createSpotLightDefinition, + updateLightingEnvironment, +} from './validation'; + +let lightingRigOrdinal = 0; + +const createDefaultRigId = (): string => `lighting-rig:${++lightingRigOrdinal}`; + +export class LightingRig implements Disposable, LightingRigReadable { + readonly #id; + + #environment: LightingEnvironment; + #version = 0; + #sequence = 0; + #lightOrdinal = 0; + #isDisposed = false; + #records = new Map(); + #orderedRecords: readonly InternalLightRecord[] = Object.freeze([] as InternalLightRecord[]); + #orderedDefinitions: readonly LightDefinition[] = Object.freeze([] as LightDefinition[]); + + constructor(options: LightingRigOptions = {}) { + this.#id = brandLightingRigId( + typeof options.id === 'string' ? options.id : createDefaultRigId() + ); + this.#environment = options.environment + ? createLightingEnvironment(options.environment) + : DEFAULT_LIGHTING_ENVIRONMENT; + } + + get id() { + return this.#id; + } + + get version() { + this.#assertNotDisposed(); + return brandLightingVersion(this.#version); + } + + get size(): number { + this.#assertNotDisposed(); + return this.#records.size; + } + + get isDisposed(): boolean { + return this.#isDisposed; + } + + get environment(): LightingEnvironment { + this.#assertNotDisposed(); + return this.#environment; + } + + setEnvironment(patch: Partial): this; + setEnvironment(patch: Parameters[1]): this; + setEnvironment(patch: Parameters[1]): this { + this.#assertNotDisposed(); + this.#environment = updateLightingEnvironment(this.#environment, patch); + this.#bumpVersion(); + return this; + } + + resetEnvironment(): this { + this.#assertNotDisposed(); + this.#environment = DEFAULT_LIGHTING_ENVIRONMENT; + this.#bumpVersion(); + return this; + } + + addDirectional(input: DirectionalLightCreateInput): DirectionalLightDefinition { + this.#assertNotDisposed(); + const definition = createDirectionalLightDefinition( + input, + this.#createLightId(LightKind.Directional) + ); + const record = this.#createRecord(definition); + this.#insertRecord(record); + return definition; + } + + addPoint(input: PointLightCreateInput): PointLightDefinition { + this.#assertNotDisposed(); + const definition = createPointLightDefinition(input, this.#createLightId(LightKind.Point)); + const record = this.#createRecord(definition); + this.#insertRecord(record); + return definition; + } + + addSpot(input: SpotLightCreateInput): SpotLightDefinition { + this.#assertNotDisposed(); + const definition = createSpotLightDefinition(input, this.#createLightId(LightKind.Spot)); + const record = this.#createRecord(definition); + this.#insertRecord(record); + return definition; + } + + get(id: string): LightDefinition | null; + get(id: DirectionalLightDefinition['id']): DirectionalLightDefinition | null; + get(id: PointLightDefinition['id']): PointLightDefinition | null; + get(id: SpotLightDefinition['id']): SpotLightDefinition | null; + get(id: string): LightDefinition | null { + this.#assertNotDisposed(); + return this.#records.get(id)?.definition ?? null; + } + + has(id: string): boolean { + this.#assertNotDisposed(); + return this.#records.has(id); + } + + list(): readonly LightDefinition[] { + this.#assertNotDisposed(); + return this.#orderedDefinitions; + } + + update(id: DirectionalLightDefinition['id'], patch: DirectionalLightPatch): DirectionalLightDefinition; + update(id: PointLightDefinition['id'], patch: PointLightPatch): PointLightDefinition; + update(id: SpotLightDefinition['id'], patch: SpotLightPatch): SpotLightDefinition; + update(id: string, patch: DirectionalLightPatch | PointLightPatch | SpotLightPatch): LightDefinition; + update(id: string, patch: DirectionalLightPatch | PointLightPatch | SpotLightPatch): LightDefinition { + this.#assertNotDisposed(); + const current = this.#records.get(id); + + if (!current) { + throw new LightingValidationError('lighting.light.not-found', `No light with id ${id} exists in this rig`, { + id, + }); + } + + let definition: LightDefinition; + + switch (current.definition.kind) { + case LightKind.Directional: + definition = applyDirectionalLightPatch( + current.definition, + patch as DirectionalLightPatch + ); + break; + case LightKind.Point: + definition = applyPointLightPatch(current.definition, patch as PointLightPatch); + break; + case LightKind.Spot: + definition = applySpotLightPatch(current.definition, patch as SpotLightPatch); + break; + } + + const nextRecord: InternalLightRecord = Object.freeze({ + definition, + sequence: current.sequence, + }); + this.#records.set(id, nextRecord); + this.#orderedRecords = Object.freeze( + this.#orderedRecords.map((entry) => (entry.sequence === current.sequence ? nextRecord : entry)) + ); + this.#orderedDefinitions = Object.freeze( + this.#orderedRecords.map((entry) => entry.definition) + ); + this.#bumpVersion(); + return definition; + } + + remove(id: string): boolean { + this.#assertNotDisposed(); + const existing = this.#records.get(id); + + if (!existing) { + return false; + } + + this.#records.delete(id); + this.#orderedRecords = Object.freeze( + this.#orderedRecords.filter((entry) => entry.sequence !== existing.sequence) + ); + this.#orderedDefinitions = Object.freeze( + this.#orderedRecords.map((entry) => entry.definition) + ); + this.#bumpVersion(); + return true; + } + + clear(): this { + this.#assertNotDisposed(); + + if (this.#records.size === 0) { + return this; + } + + this.#records.clear(); + this.#orderedRecords = Object.freeze([] as InternalLightRecord[]); + this.#orderedDefinitions = Object.freeze([] as LightDefinition[]); + this.#bumpVersion(); + return this; + } + + resolveFrame( + resolver: Pick LightingSelectionState }, 'resolve'>, + options?: LightingSelectionOptions + ): LightingSelectionState { + this.#assertNotDisposed(); + return resolver.resolve(this, options); + } + + dispose(): void { + if (this.#isDisposed) { + return; + } + + this.#records.clear(); + this.#orderedRecords = Object.freeze([] as InternalLightRecord[]); + this.#orderedDefinitions = Object.freeze([] as LightDefinition[]); + this.#environment = DEFAULT_LIGHTING_ENVIRONMENT; + this.#isDisposed = true; + } + + readonly [LIGHTING_RIG_ACCESS] = () => { + this.#assertNotDisposed(); + return { + id: this.#id, + version: brandLightingVersion(this.#version), + environment: this.#environment, + entries: this.#orderedRecords, + }; + }; + + #createLightId(kind: string): string { + this.#lightOrdinal += 1; + return `${kind}:${this.#lightOrdinal}`; + } + + #createRecord(definition: LightDefinition): InternalLightRecord { + return Object.freeze({ + definition, + sequence: ++this.#sequence, + }); + } + + #insertRecord(record: InternalLightRecord): void { + const id = String(record.definition.id); + + if (this.#records.has(id)) { + throw new LightingValidationError('lighting.light.duplicate-id', `A light with id ${id} already exists in this rig`, { + id, + }); + } + + this.#records.set(id, record); + this.#orderedRecords = Object.freeze([...this.#orderedRecords, record]); + this.#orderedDefinitions = Object.freeze( + this.#orderedRecords.map((entry) => entry.definition) + ); + this.#bumpVersion(); + } + + #bumpVersion(): void { + this.#version += 1; + } + + #assertNotDisposed(): void { + if (this.#isDisposed) { + throw new LightingDisposedError('LightingRig'); + } + } +} \ No newline at end of file diff --git a/web/packages/lighting/src/serialization.ts b/web/packages/lighting/src/serialization.ts new file mode 100644 index 00000000..b30b3799 --- /dev/null +++ b/web/packages/lighting/src/serialization.ts @@ -0,0 +1,286 @@ +import { LightKind, LightingDocumentVersion } from './constants'; +import { LightingSerializationError } from './errors'; +import { isLightKind, isSerializedLight } from './guards'; +import { LightingRig } from './rig'; +import type { + LightingDocument, + LightingIssue, + SerializedDirectionalLight, + SerializedLight, + SerializedPointLight, + SerializedSpotLight, +} from './types'; +import { serializeVec3 } from './validation'; + +export interface LightingParseSuccess { + readonly ok: true; + readonly value: LightingRig; + readonly issues: readonly LightingIssue[]; +} + +export interface LightingParseFailure { + readonly ok: false; + readonly issues: readonly LightingIssue[]; +} + +export type LightingParseResult = LightingParseSuccess | LightingParseFailure; + +const isObjectRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null; +}; + +const isTuple3 = (value: unknown): value is readonly [number, number, number] => { + return ( + Array.isArray(value) && + value.length === 3 && + value.every((entry) => typeof entry === 'number' && Number.isFinite(entry)) + ); +}; + +const pushIssue = ( + issues: LightingIssue[], + path: string, + code: LightingIssue['code'], + message: string +): void => { + issues.push(Object.freeze({ code, path, message })); +}; + +const serializeLight = (light: ReturnType[number]): SerializedLight => { + switch (light.kind) { + case LightKind.Directional: + return Object.freeze({ + id: String(light.id), + kind: light.kind, + enabled: light.enabled, + color: serializeVec3(light.color), + intensity: light.intensity, + priority: light.priority, + metadata: light.metadata, + direction: serializeVec3(light.direction), + ambient: serializeVec3(light.ambient), + } satisfies SerializedDirectionalLight); + case LightKind.Point: + return Object.freeze({ + id: String(light.id), + kind: light.kind, + enabled: light.enabled, + color: serializeVec3(light.color), + intensity: light.intensity, + priority: light.priority, + metadata: light.metadata, + position: serializeVec3(light.position), + range: light.range, + attenuation: light.attenuation, + } satisfies SerializedPointLight); + case LightKind.Spot: + return Object.freeze({ + id: String(light.id), + kind: light.kind, + enabled: light.enabled, + color: serializeVec3(light.color), + intensity: light.intensity, + priority: light.priority, + metadata: light.metadata, + position: serializeVec3(light.position), + direction: serializeVec3(light.direction), + range: light.range, + attenuation: light.attenuation, + innerConeCosine: light.innerConeCosine, + outerConeCosine: light.outerConeCosine, + } satisfies SerializedSpotLight); + } +}; + +export const serializeLightingRig = (rig: LightingRig): LightingDocument => { + return Object.freeze({ + version: LightingDocumentVersion, + rigId: String(rig.id), + environment: Object.freeze({ + ambient: serializeVec3(rig.environment.ambient), + sky: serializeVec3(rig.environment.sky), + ground: serializeVec3(rig.environment.ground), + exposure: rig.environment.exposure, + gamma: rig.environment.gamma, + }), + lights: Object.freeze(rig.list().map(serializeLight)), + }); +}; + +export const safeDeserializeLightingRig = (input: unknown): LightingParseResult => { + const issues: LightingIssue[] = []; + + if (!isObjectRecord(input)) { + pushIssue(issues, '$', 'lighting.parse.document', 'Lighting documents must be objects'); + return { ok: false, issues: Object.freeze(issues) }; + } + + if (input.version !== undefined) { + if (typeof input.version !== 'number' || !Number.isFinite(input.version)) { + pushIssue(issues, '$.version', 'lighting.parse.version', 'version must be a finite number'); + } else if (input.version !== LightingDocumentVersion) { + pushIssue( + issues, + '$.version', + 'lighting.parse.version', + `Unsupported lighting document version: ${input.version}` + ); + } + } + + if (input.rigId !== undefined && typeof input.rigId !== 'string') { + pushIssue(issues, '$.rigId', 'lighting.parse.rig-id', 'rigId must be a string'); + } + + if (input.environment !== undefined && !isObjectRecord(input.environment)) { + pushIssue(issues, '$.environment', 'lighting.parse.environment', 'environment must be an object'); + } + + const environment = isObjectRecord(input.environment) ? input.environment : undefined; + const rig = new LightingRig({ + id: typeof input.rigId === 'string' ? input.rigId : undefined, + environment: environment + ? { + ambient: isTuple3(environment.ambient) ? environment.ambient : undefined, + sky: isTuple3(environment.sky) ? environment.sky : undefined, + ground: isTuple3(environment.ground) ? environment.ground : undefined, + exposure: + typeof environment.exposure === 'number' && Number.isFinite(environment.exposure) + ? environment.exposure + : undefined, + gamma: + typeof environment.gamma === 'number' && Number.isFinite(environment.gamma) + ? environment.gamma + : undefined, + } + : undefined, + }); + + if (environment) { + if (environment.ambient !== undefined && !isTuple3(environment.ambient)) { + pushIssue(issues, '$.environment.ambient', 'lighting.parse.vector', 'ambient must be a 3-number tuple'); + } + if (environment.sky !== undefined && !isTuple3(environment.sky)) { + pushIssue(issues, '$.environment.sky', 'lighting.parse.vector', 'sky must be a 3-number tuple'); + } + if (environment.ground !== undefined && !isTuple3(environment.ground)) { + pushIssue(issues, '$.environment.ground', 'lighting.parse.vector', 'ground must be a 3-number tuple'); + } + if ( + environment.exposure !== undefined && + !(typeof environment.exposure === 'number' && Number.isFinite(environment.exposure)) + ) { + pushIssue(issues, '$.environment.exposure', 'lighting.parse.number', 'exposure must be a finite number'); + } + if ( + environment.gamma !== undefined && + !(typeof environment.gamma === 'number' && Number.isFinite(environment.gamma)) + ) { + pushIssue(issues, '$.environment.gamma', 'lighting.parse.number', 'gamma must be a finite number'); + } + } + + if (input.lights === undefined) { + return { ok: true, value: rig, issues: Object.freeze(issues) }; + } + + if (!Array.isArray(input.lights)) { + pushIssue(issues, '$.lights', 'lighting.parse.lights', 'lights must be an array'); + return { ok: true, value: rig, issues: Object.freeze(issues) }; + } + + input.lights.forEach((value, index) => { + const basePath = `$.lights[${index}]`; + + if (!isObjectRecord(value)) { + pushIssue(issues, basePath, 'lighting.parse.light', 'light entries must be objects'); + return; + } + + if (!isLightKind(value.kind)) { + pushIssue(issues, `${basePath}.kind`, 'lighting.parse.kind', 'light kind must be directional, point, or spot'); + return; + } + + if (!isSerializedLight(value)) { + pushIssue(issues, basePath, 'lighting.parse.light', 'light entry contains invalid field types'); + return; + } + + try { + switch (value.kind) { + case LightKind.Directional: + rig.addDirectional({ + id: value.id, + enabled: value.enabled, + color: value.color, + intensity: value.intensity, + priority: value.priority, + metadata: value.metadata, + direction: value.direction, + ambient: value.ambient, + }); + break; + case LightKind.Point: + rig.addPoint({ + id: value.id, + enabled: value.enabled, + color: value.color, + intensity: value.intensity, + priority: value.priority, + metadata: value.metadata, + position: value.position, + range: value.range, + attenuation: value.attenuation, + }); + break; + case LightKind.Spot: + rig.addSpot({ + id: value.id, + enabled: value.enabled, + color: value.color, + intensity: value.intensity, + priority: value.priority, + metadata: value.metadata, + position: value.position, + direction: value.direction, + range: value.range, + attenuation: value.attenuation, + coneMode: 'cosine', + innerConeCosine: value.innerConeCosine, + outerConeCosine: value.outerConeCosine, + }); + break; + } + } catch (error) { + pushIssue( + issues, + basePath, + 'lighting.parse.light', + error instanceof Error ? error.message : 'Failed to deserialize light' + ); + } + }); + + return { ok: true, value: rig, issues: Object.freeze(issues) }; +}; + +export const deserializeLightingRig = (input: unknown): LightingRig => { + const result = safeDeserializeLightingRig(input); + + if (!result.ok) { + throw new LightingSerializationError('lighting.serialize.document', 'Unable to deserialize lighting document', { + issueCount: result.issues.length, + issues: result.issues, + }); + } + + if (result.issues.length > 0) { + throw new LightingSerializationError('lighting.serialize.partial', 'Lighting document contains invalid entries', { + issueCount: result.issues.length, + issues: result.issues, + }); + } + + return result.value; +}; \ No newline at end of file diff --git a/web/packages/lighting/src/types.ts b/web/packages/lighting/src/types.ts new file mode 100644 index 00000000..64925d9a --- /dev/null +++ b/web/packages/lighting/src/types.ts @@ -0,0 +1,269 @@ +import type { Vec3 } from '@axrone/numeric'; +import type { JsonValue, ReadonlyTuple3 } from '@axrone/utility'; +import type { LightId, LightingRigId, LightingVersion } from './brands'; +import type { LightKind, LightSortMode, LightingDocumentVersion } from './constants'; + +export type Vec3Input = Readonly | ReadonlyTuple3; +export type LightingMetadata = Readonly>; + +export interface LightingEnvironment { + readonly ambient: Readonly; + readonly sky: Readonly; + readonly ground: Readonly; + readonly exposure: number; + readonly gamma: number; +} + +export interface LightingEnvironmentInput { + readonly ambient?: Vec3Input; + readonly sky?: Vec3Input; + readonly ground?: Vec3Input; + readonly exposure?: number; + readonly gamma?: number; +} + +export interface LightingCapacity { + readonly maxDirectionalLights: number; + readonly maxPointLights: number; + readonly maxSpotLights: number; + readonly maxLocalLights: number; +} + +export interface LightingRigOptions { + readonly id?: LightingRigId | string; + readonly environment?: LightingEnvironmentInput; +} + +export interface LightingFrameResolverOptions { + readonly capacity?: Partial; + readonly sortMode?: LightSortMode; +} + +export interface LightingSelectionOptions { + readonly cameraPosition?: Vec3Input; + readonly sortMode?: LightSortMode; +} + +export interface BaseLightDefinition { + readonly id: LightId; + readonly kind: K; + readonly enabled: boolean; + readonly color: Readonly; + readonly intensity: number; + readonly priority: number; + readonly metadata?: LightingMetadata; +} + +export interface DirectionalLightDefinition extends BaseLightDefinition<'directional'> { + readonly direction: Readonly; + readonly ambient: Readonly; +} + +export interface PointLightDefinition extends BaseLightDefinition<'point'> { + readonly position: Readonly; + readonly range: number; + readonly attenuation: number; +} + +export interface SpotLightDefinition extends BaseLightDefinition<'spot'> { + readonly position: Readonly; + readonly direction: Readonly; + readonly range: number; + readonly attenuation: number; + readonly innerConeCosine: number; + readonly outerConeCosine: number; +} + +export interface BaseLightCreateInput { + readonly id?: LightId | string; + readonly enabled?: boolean; + readonly color?: Vec3Input; + readonly intensity?: number; + readonly priority?: number; + readonly metadata?: LightingMetadata | null; +} + +export interface DirectionalLightCreateInput extends BaseLightCreateInput<'directional'> { + readonly direction?: Vec3Input; + readonly ambient?: Vec3Input; +} + +export interface PointLightCreateInput extends BaseLightCreateInput<'point'> { + readonly position?: Vec3Input; + readonly range?: number; + readonly attenuation?: number; +} + +export type SpotLightConeInput = + | { + readonly coneMode?: 'angle'; + readonly innerConeAngle?: number; + readonly outerConeAngle?: number; + readonly innerConeCosine?: never; + readonly outerConeCosine?: never; + } + | { + readonly coneMode: 'cosine'; + readonly innerConeCosine?: number; + readonly outerConeCosine?: number; + readonly innerConeAngle?: never; + readonly outerConeAngle?: never; + }; + +export type SpotLightCreateInput = BaseLightCreateInput<'spot'> & { + readonly position?: Vec3Input; + readonly direction?: Vec3Input; + readonly range?: number; + readonly attenuation?: number; +} & SpotLightConeInput; + +export interface BaseLightPatch { + readonly enabled?: boolean; + readonly color?: Vec3Input; + readonly intensity?: number; + readonly priority?: number; + readonly metadata?: LightingMetadata | null; +} + +export interface DirectionalLightPatch extends BaseLightPatch { + readonly direction?: Vec3Input; + readonly ambient?: Vec3Input; +} + +export interface PointLightPatch extends BaseLightPatch { + readonly position?: Vec3Input; + readonly range?: number; + readonly attenuation?: number; +} + +export type SpotLightPatch = BaseLightPatch & { + readonly position?: Vec3Input; + readonly direction?: Vec3Input; + readonly range?: number; + readonly attenuation?: number; +} & SpotLightConeInput; + +export interface LightDefinitionMap { + readonly directional: DirectionalLightDefinition; + readonly point: PointLightDefinition; + readonly spot: SpotLightDefinition; +} + +export interface LightCreateInputMap { + readonly directional: DirectionalLightCreateInput; + readonly point: PointLightCreateInput; + readonly spot: SpotLightCreateInput; +} + +export interface LightPatchMap { + readonly directional: DirectionalLightPatch; + readonly point: PointLightPatch; + readonly spot: SpotLightPatch; +} + +export type LightDefinition = LightDefinitionMap[K]; +export type LightCreateInput = LightCreateInputMap[K]; +export type LightPatch = LightPatchMap[K]; + +export interface LightingSelectionStats { + readonly totalLightCount: number; + readonly totalDirectionalCount: number; + readonly totalPointCount: number; + readonly totalSpotCount: number; + readonly selectedDirectionalCount: number; + readonly selectedPointCount: number; + readonly selectedSpotCount: number; + readonly selectedLocalLightCount: number; + readonly omittedDirectionalCount: number; + readonly omittedPointCount: number; + readonly omittedSpotCount: number; + readonly omittedLocalLightCount: number; +} + +export interface LightingSelectionState { + readonly rigId: LightingRigId; + readonly version: LightingVersion; + readonly sortMode: LightSortMode; + readonly capacity: LightingCapacity; + readonly environment: LightingEnvironment; + readonly stats: LightingSelectionStats; + readonly directionalDirections: Float32Array; + readonly directionalColors: Float32Array; + readonly directionalAmbientColors: Float32Array; + readonly directionalIntensities: Float32Array; + readonly pointPositions: Float32Array; + readonly pointColors: Float32Array; + readonly pointIntensities: Float32Array; + readonly pointRanges: Float32Array; + readonly spotPositions: Float32Array; + readonly spotDirections: Float32Array; + readonly spotColors: Float32Array; + readonly spotIntensities: Float32Array; + readonly spotRanges: Float32Array; + readonly spotInnerConeCosines: Float32Array; + readonly spotOuterConeCosines: Float32Array; + readonly localLightKinds: Int32Array; + readonly localLightPositions: Float32Array; + readonly localLightDirections: Float32Array; + readonly localLightColors: Float32Array; + readonly localLightIntensities: Float32Array; + readonly localLightRanges: Float32Array; + readonly localLightInnerConeCosines: Float32Array; + readonly localLightOuterConeCosines: Float32Array; +} + +export interface SerializedLightingEnvironment { + readonly ambient?: ReadonlyTuple3; + readonly sky?: ReadonlyTuple3; + readonly ground?: ReadonlyTuple3; + readonly exposure?: number; + readonly gamma?: number; +} + +export interface SerializedBaseLight { + readonly id?: string; + readonly kind: K; + readonly enabled?: boolean; + readonly color?: ReadonlyTuple3; + readonly intensity?: number; + readonly priority?: number; + readonly metadata?: LightingMetadata; +} + +export interface SerializedDirectionalLight extends SerializedBaseLight<'directional'> { + readonly direction?: ReadonlyTuple3; + readonly ambient?: ReadonlyTuple3; +} + +export interface SerializedPointLight extends SerializedBaseLight<'point'> { + readonly position?: ReadonlyTuple3; + readonly range?: number; + readonly attenuation?: number; +} + +export type SerializedSpotLight = SerializedBaseLight<'spot'> & { + readonly position?: ReadonlyTuple3; + readonly direction?: ReadonlyTuple3; + readonly range?: number; + readonly attenuation?: number; + readonly innerConeCosine?: number; + readonly outerConeCosine?: number; +}; + +export type SerializedLight = + | SerializedDirectionalLight + | SerializedPointLight + | SerializedSpotLight; + +export interface LightingDocument { + readonly version?: LightingDocumentVersion | number; + readonly rigId?: string; + readonly environment?: SerializedLightingEnvironment; + readonly lights?: readonly SerializedLight[]; +} + +export interface LightingIssue { + readonly code: `lighting.parse.${string}`; + readonly path: string; + readonly message: string; +} \ No newline at end of file diff --git a/web/packages/lighting/src/uniform-layout.ts b/web/packages/lighting/src/uniform-layout.ts new file mode 100644 index 00000000..bac70e4c --- /dev/null +++ b/web/packages/lighting/src/uniform-layout.ts @@ -0,0 +1,332 @@ +import type { Vec3 } from '@axrone/numeric'; +import type { LightingCapacity, LightingSelectionState } from './types'; +import { resolveLightingCapacity } from './validation'; + +export type LightingUniformField = + | 'AmbientLight' + | 'SkyLight' + | 'GroundLight' + | 'Exposure' + | 'Gamma' + | 'DirectionalLightCount' + | 'DirectionalLightDirection' + | 'DirectionalLightColor' + | 'DirectionalLightAmbientColor' + | 'DirectionalLightIntensity' + | 'PointLightCount' + | 'PointLightPosition' + | 'PointLightColor' + | 'PointLightIntensity' + | 'PointLightRange' + | 'SpotLightCount' + | 'SpotLightPosition' + | 'SpotLightDirection' + | 'SpotLightColor' + | 'SpotLightIntensity' + | 'SpotLightRange' + | 'SpotLightInnerConeCosine' + | 'SpotLightOuterConeCosine' + | 'LocalLightCount' + | 'LocalLightKind' + | 'LocalLightPosition' + | 'LocalLightDirection' + | 'LocalLightColor' + | 'LocalLightIntensity' + | 'LocalLightRange' + | 'LocalLightInnerConeCosine' + | 'LocalLightOuterConeCosine'; + +export type LightingUniformName = + `u_${TField}`; + +export type LightingUniformNames = { + readonly [TField in LightingUniformField as Uncapitalize]: LightingUniformName; +}; + +export type LightingUniformPropertyType = 'vec3' | 'float' | 'int'; + +export interface LightingUniformProperty { + readonly name: LightingUniformName; + readonly type: LightingUniformPropertyType; + readonly scope: 'frame'; + readonly arrayLength?: number; +} + +export interface LightingShaderDefines { + readonly AXRONE_LIGHTING_MAX_DIRECTIONAL_LIGHTS: string; + readonly AXRONE_LIGHTING_MAX_POINT_LIGHTS: string; + readonly AXRONE_LIGHTING_MAX_SPOT_LIGHTS: string; + readonly AXRONE_LIGHTING_MAX_LOCAL_LIGHTS: string; +} + +export interface LightingUniformLayout { + readonly capacity: Readonly; + readonly names: LightingUniformNames; + readonly defines: LightingShaderDefines; + readonly properties: readonly LightingUniformProperty[]; +} + +export type LightingUniformValue = + TField extends + | 'Exposure' + | 'Gamma' + | 'DirectionalLightCount' + | 'PointLightCount' + | 'SpotLightCount' + | 'LocalLightCount' + ? number + : TField extends 'LocalLightKind' + ? Int32Array + : TField extends 'AmbientLight' | 'SkyLight' | 'GroundLight' + ? Readonly + : Float32Array; + +export type LightingUniformValueMap = { + readonly [TField in LightingUniformField as LightingUniformName]: LightingUniformValue; +}; + +const NAMES: LightingUniformNames = Object.freeze({ + ambientLight: 'u_AmbientLight', + skyLight: 'u_SkyLight', + groundLight: 'u_GroundLight', + exposure: 'u_Exposure', + gamma: 'u_Gamma', + directionalLightCount: 'u_DirectionalLightCount', + directionalLightDirection: 'u_DirectionalLightDirection', + directionalLightColor: 'u_DirectionalLightColor', + directionalLightAmbientColor: 'u_DirectionalLightAmbientColor', + directionalLightIntensity: 'u_DirectionalLightIntensity', + pointLightCount: 'u_PointLightCount', + pointLightPosition: 'u_PointLightPosition', + pointLightColor: 'u_PointLightColor', + pointLightIntensity: 'u_PointLightIntensity', + pointLightRange: 'u_PointLightRange', + spotLightCount: 'u_SpotLightCount', + spotLightPosition: 'u_SpotLightPosition', + spotLightDirection: 'u_SpotLightDirection', + spotLightColor: 'u_SpotLightColor', + spotLightIntensity: 'u_SpotLightIntensity', + spotLightRange: 'u_SpotLightRange', + spotLightInnerConeCosine: 'u_SpotLightInnerConeCosine', + spotLightOuterConeCosine: 'u_SpotLightOuterConeCosine', + localLightCount: 'u_LocalLightCount', + localLightKind: 'u_LocalLightKind', + localLightPosition: 'u_LocalLightPosition', + localLightDirection: 'u_LocalLightDirection', + localLightColor: 'u_LocalLightColor', + localLightIntensity: 'u_LocalLightIntensity', + localLightRange: 'u_LocalLightRange', + localLightInnerConeCosine: 'u_LocalLightInnerConeCosine', + localLightOuterConeCosine: 'u_LocalLightOuterConeCosine', +}); + +const createLightingUniformProperties = ( + capacity: Readonly +): readonly LightingUniformProperty[] => { + return Object.freeze([ + { name: NAMES.ambientLight, type: 'vec3', scope: 'frame' }, + { name: NAMES.skyLight, type: 'vec3', scope: 'frame' }, + { name: NAMES.groundLight, type: 'vec3', scope: 'frame' }, + { name: NAMES.exposure, type: 'float', scope: 'frame' }, + { name: NAMES.gamma, type: 'float', scope: 'frame' }, + { name: NAMES.directionalLightCount, type: 'int', scope: 'frame' }, + { + name: NAMES.directionalLightDirection, + type: 'vec3', + scope: 'frame', + arrayLength: capacity.maxDirectionalLights, + }, + { + name: NAMES.directionalLightColor, + type: 'vec3', + scope: 'frame', + arrayLength: capacity.maxDirectionalLights, + }, + { + name: NAMES.directionalLightAmbientColor, + type: 'vec3', + scope: 'frame', + arrayLength: capacity.maxDirectionalLights, + }, + { + name: NAMES.directionalLightIntensity, + type: 'float', + scope: 'frame', + arrayLength: capacity.maxDirectionalLights, + }, + { name: NAMES.pointLightCount, type: 'int', scope: 'frame' }, + { + name: NAMES.pointLightPosition, + type: 'vec3', + scope: 'frame', + arrayLength: capacity.maxPointLights, + }, + { + name: NAMES.pointLightColor, + type: 'vec3', + scope: 'frame', + arrayLength: capacity.maxPointLights, + }, + { + name: NAMES.pointLightIntensity, + type: 'float', + scope: 'frame', + arrayLength: capacity.maxPointLights, + }, + { + name: NAMES.pointLightRange, + type: 'float', + scope: 'frame', + arrayLength: capacity.maxPointLights, + }, + { name: NAMES.spotLightCount, type: 'int', scope: 'frame' }, + { + name: NAMES.spotLightPosition, + type: 'vec3', + scope: 'frame', + arrayLength: capacity.maxSpotLights, + }, + { + name: NAMES.spotLightDirection, + type: 'vec3', + scope: 'frame', + arrayLength: capacity.maxSpotLights, + }, + { + name: NAMES.spotLightColor, + type: 'vec3', + scope: 'frame', + arrayLength: capacity.maxSpotLights, + }, + { + name: NAMES.spotLightIntensity, + type: 'float', + scope: 'frame', + arrayLength: capacity.maxSpotLights, + }, + { + name: NAMES.spotLightRange, + type: 'float', + scope: 'frame', + arrayLength: capacity.maxSpotLights, + }, + { + name: NAMES.spotLightInnerConeCosine, + type: 'float', + scope: 'frame', + arrayLength: capacity.maxSpotLights, + }, + { + name: NAMES.spotLightOuterConeCosine, + type: 'float', + scope: 'frame', + arrayLength: capacity.maxSpotLights, + }, + { name: NAMES.localLightCount, type: 'int', scope: 'frame' }, + { + name: NAMES.localLightKind, + type: 'int', + scope: 'frame', + arrayLength: capacity.maxLocalLights, + }, + { + name: NAMES.localLightPosition, + type: 'vec3', + scope: 'frame', + arrayLength: capacity.maxLocalLights, + }, + { + name: NAMES.localLightDirection, + type: 'vec3', + scope: 'frame', + arrayLength: capacity.maxLocalLights, + }, + { + name: NAMES.localLightColor, + type: 'vec3', + scope: 'frame', + arrayLength: capacity.maxLocalLights, + }, + { + name: NAMES.localLightIntensity, + type: 'float', + scope: 'frame', + arrayLength: capacity.maxLocalLights, + }, + { + name: NAMES.localLightRange, + type: 'float', + scope: 'frame', + arrayLength: capacity.maxLocalLights, + }, + { + name: NAMES.localLightInnerConeCosine, + type: 'float', + scope: 'frame', + arrayLength: capacity.maxLocalLights, + }, + { + name: NAMES.localLightOuterConeCosine, + type: 'float', + scope: 'frame', + arrayLength: capacity.maxLocalLights, + }, + ]); +}; + +export const createLightingUniformLayout = ( + capacity: Partial = {} +): LightingUniformLayout => { + const resolvedCapacity = resolveLightingCapacity(capacity); + + return Object.freeze({ + capacity: resolvedCapacity, + names: NAMES, + defines: Object.freeze({ + AXRONE_LIGHTING_MAX_DIRECTIONAL_LIGHTS: String(resolvedCapacity.maxDirectionalLights), + AXRONE_LIGHTING_MAX_POINT_LIGHTS: String(resolvedCapacity.maxPointLights), + AXRONE_LIGHTING_MAX_SPOT_LIGHTS: String(resolvedCapacity.maxSpotLights), + AXRONE_LIGHTING_MAX_LOCAL_LIGHTS: String(resolvedCapacity.maxLocalLights), + }), + properties: createLightingUniformProperties(resolvedCapacity), + }); +}; + +export const createLightingUniformValueMap = ( + state: LightingSelectionState +): LightingUniformValueMap => { + return { + u_AmbientLight: state.environment.ambient, + u_SkyLight: state.environment.sky, + u_GroundLight: state.environment.ground, + u_Exposure: state.environment.exposure, + u_Gamma: state.environment.gamma, + u_DirectionalLightCount: state.stats.selectedDirectionalCount, + u_DirectionalLightDirection: state.directionalDirections, + u_DirectionalLightColor: state.directionalColors, + u_DirectionalLightAmbientColor: state.directionalAmbientColors, + u_DirectionalLightIntensity: state.directionalIntensities, + u_PointLightCount: state.stats.selectedPointCount, + u_PointLightPosition: state.pointPositions, + u_PointLightColor: state.pointColors, + u_PointLightIntensity: state.pointIntensities, + u_PointLightRange: state.pointRanges, + u_SpotLightCount: state.stats.selectedSpotCount, + u_SpotLightPosition: state.spotPositions, + u_SpotLightDirection: state.spotDirections, + u_SpotLightColor: state.spotColors, + u_SpotLightIntensity: state.spotIntensities, + u_SpotLightRange: state.spotRanges, + u_SpotLightInnerConeCosine: state.spotInnerConeCosines, + u_SpotLightOuterConeCosine: state.spotOuterConeCosines, + u_LocalLightCount: state.stats.selectedLocalLightCount, + u_LocalLightKind: state.localLightKinds, + u_LocalLightPosition: state.localLightPositions, + u_LocalLightDirection: state.localLightDirections, + u_LocalLightColor: state.localLightColors, + u_LocalLightIntensity: state.localLightIntensities, + u_LocalLightRange: state.localLightRanges, + u_LocalLightInnerConeCosine: state.localLightInnerConeCosines, + u_LocalLightOuterConeCosine: state.localLightOuterConeCosines, + }; +}; \ No newline at end of file diff --git a/web/packages/lighting/src/validation.ts b/web/packages/lighting/src/validation.ts new file mode 100644 index 00000000..b6f372f8 --- /dev/null +++ b/web/packages/lighting/src/validation.ts @@ -0,0 +1,493 @@ +import { Vec3 } from '@axrone/numeric'; +import type { JsonValue, ReadonlyTuple3 } from '@axrone/utility'; +import { brandLightId } from './brands'; +import { LightKind } from './constants'; +import { LightingValidationError } from './errors'; +import type { + DirectionalLightCreateInput, + DirectionalLightDefinition, + DirectionalLightPatch, + LightCreateInput, + LightDefinition, + LightPatch, + LightingCapacity, + LightingEnvironment, + LightingEnvironmentInput, + LightingMetadata, + PointLightCreateInput, + PointLightDefinition, + PointLightPatch, + SpotLightCreateInput, + SpotLightDefinition, + SpotLightPatch, + Vec3Input, +} from './types'; + +const DEFAULT_INNER_CONE_ANGLE = Math.PI / 8; +const DEFAULT_OUTER_CONE_ANGLE = Math.PI / 4; + +const freezeVec3 = (x: number, y: number, z: number): Readonly => + Object.freeze(new Vec3(x, y, z)); + +const cloneJsonValue = (value: JsonValue): JsonValue => { + if (value === null || typeof value !== 'object') { + return value; + } + + if (Array.isArray(value)) { + return Object.freeze(value.map((entry) => cloneJsonValue(entry))) as JsonValue; + } + + const clone: Record = {}; + + for (const [key, entry] of Object.entries(value)) { + clone[key] = cloneJsonValue(entry); + } + + return Object.freeze(clone) as JsonValue; +}; + +const cloneMetadata = (value: LightingMetadata | null | undefined): LightingMetadata | undefined => { + if (value == null) { + return undefined; + } + + const clone: Record = {}; + + for (const [key, entry] of Object.entries(value)) { + clone[key] = cloneJsonValue(entry); + } + + return Object.freeze(clone); +}; + +const assertFiniteNumber = ( + label: string, + value: number, + minimum: number = -Infinity, + maximum: number = Infinity, + inclusiveMinimum: boolean = true, + inclusiveMaximum: boolean = true +): number => { + if (!Number.isFinite(value)) { + throw new LightingValidationError('lighting.light.invalid-number', `${label} must be a finite number`, { + label, + value, + }); + } + + if ((inclusiveMinimum ? value < minimum : value <= minimum) || (inclusiveMaximum ? value > maximum : value >= maximum)) { + throw new LightingValidationError('lighting.light.out-of-range', `${label} is out of range`, { + label, + value, + minimum, + maximum, + }); + } + + return value; +}; + +const assertInteger = (label: string, value: number, minimum: number = 0): number => { + if (!Number.isInteger(value) || value < minimum) { + throw new LightingValidationError('lighting.rig.invalid-capacity', `${label} must be an integer >= ${minimum}`, { + label, + value, + minimum, + }); + } + + return value; +}; + +const toVec3Components = ( + value: Vec3Input | undefined, + fallback: Readonly +): readonly [number, number, number] => { + if (value instanceof Vec3) { + return [value.x, value.y, value.z]; + } + + if (Array.isArray(value)) { + if (value.length !== 3) { + throw new LightingValidationError('lighting.light.invalid-vector', 'Vec3 tuples must contain exactly three numbers', { + value, + }); + } + + return [value[0], value[1], value[2]]; + } + + return [fallback.x, fallback.y, fallback.z]; +}; + +const toFrozenVec3 = (value: Vec3Input | undefined, fallback: Readonly): Readonly => { + const [x, y, z] = toVec3Components(value, fallback); + return freezeVec3( + assertFiniteNumber('x', x), + assertFiniteNumber('y', y), + assertFiniteNumber('z', z) + ); +}; + +const toNormalizedDirection = ( + value: Vec3Input | undefined, + fallback: Readonly +): Readonly => { + const [x, y, z] = toVec3Components(value, fallback); + const length = Math.hypot(x, y, z); + + if (!Number.isFinite(length) || length <= 1e-8) { + throw new LightingValidationError('lighting.light.invalid-direction', 'Light direction must be a non-zero finite vector', { + value, + }); + } + + return freezeVec3(x / length, y / length, z / length); +}; + +const toSpotConeCosines = ( + input: SpotLightCreateInput | SpotLightPatch, + fallbackInnerCosine: number, + fallbackOuterCosine: number +): { readonly innerConeCosine: number; readonly outerConeCosine: number } => { + const hasAngleInput = input.innerConeAngle !== undefined || input.outerConeAngle !== undefined; + const hasCosineInput = input.innerConeCosine !== undefined || input.outerConeCosine !== undefined; + const cosineMode = input.coneMode === 'cosine' || (input.coneMode === undefined && hasCosineInput && !hasAngleInput); + + if (cosineMode) { + const innerConeCosine = assertFiniteNumber( + 'innerConeCosine', + input.innerConeCosine ?? fallbackInnerCosine, + 0, + 1 + ); + const outerConeCosine = assertFiniteNumber( + 'outerConeCosine', + input.outerConeCosine ?? fallbackOuterCosine, + 0, + 1 + ); + + if (innerConeCosine < outerConeCosine) { + throw new LightingValidationError('lighting.light.invalid-cone', 'innerConeCosine must be greater than or equal to outerConeCosine', { + innerConeCosine, + outerConeCosine, + }); + } + + return { innerConeCosine, outerConeCosine }; + } + + const innerConeAngle = assertFiniteNumber( + 'innerConeAngle', + input.innerConeAngle ?? Math.acos(Math.min(1, Math.max(0, fallbackInnerCosine))), + 0, + Math.PI / 2 + ); + const outerConeAngle = assertFiniteNumber( + 'outerConeAngle', + input.outerConeAngle ?? Math.acos(Math.min(1, Math.max(0, fallbackOuterCosine))), + 0, + Math.PI / 2, + false, + true + ); + + if (innerConeAngle > outerConeAngle) { + throw new LightingValidationError('lighting.light.invalid-cone', 'innerConeAngle must be less than or equal to outerConeAngle', { + innerConeAngle, + outerConeAngle, + }); + } + + return { + innerConeCosine: Math.cos(innerConeAngle), + outerConeCosine: Math.cos(outerConeAngle), + }; +}; + +export const DEFAULT_LIGHTING_CAPACITY: Readonly = Object.freeze({ + maxDirectionalLights: 1, + maxPointLights: 8, + maxSpotLights: 8, + maxLocalLights: 12, +}); + +export const DEFAULT_LIGHTING_ENVIRONMENT: LightingEnvironment = Object.freeze({ + ambient: freezeVec3(0.08, 0.08, 0.1), + sky: freezeVec3(0.08, 0.09, 0.11), + ground: freezeVec3(0.04, 0.04, 0.045), + exposure: 1, + gamma: 2.2, +}); + +export const serializeVec3 = (value: Readonly): ReadonlyTuple3 => [ + value.x, + value.y, + value.z, +]; + +export const createLightingEnvironment = ( + input: LightingEnvironmentInput = {} +): LightingEnvironment => { + const ambient = toFrozenVec3(input.ambient, DEFAULT_LIGHTING_ENVIRONMENT.ambient); + const sky = toFrozenVec3(input.sky, DEFAULT_LIGHTING_ENVIRONMENT.sky); + const ground = toFrozenVec3(input.ground, DEFAULT_LIGHTING_ENVIRONMENT.ground); + const exposure = assertFiniteNumber('exposure', input.exposure ?? DEFAULT_LIGHTING_ENVIRONMENT.exposure, 0); + const gamma = assertFiniteNumber('gamma', input.gamma ?? DEFAULT_LIGHTING_ENVIRONMENT.gamma, 0, Infinity, false); + + return Object.freeze({ + ambient, + sky, + ground, + exposure, + gamma, + }); +}; + +export const updateLightingEnvironment = ( + current: LightingEnvironment, + patch: LightingEnvironmentInput +): LightingEnvironment => { + return createLightingEnvironment({ + ambient: patch.ambient ?? current.ambient, + sky: patch.sky ?? current.sky, + ground: patch.ground ?? current.ground, + exposure: patch.exposure ?? current.exposure, + gamma: patch.gamma ?? current.gamma, + }); +}; + +export const resolveLightingCapacity = ( + input: Partial = {} +): Readonly => { + const maxDirectionalLights = assertInteger( + 'maxDirectionalLights', + input.maxDirectionalLights ?? DEFAULT_LIGHTING_CAPACITY.maxDirectionalLights, + 0 + ); + const maxPointLights = assertInteger( + 'maxPointLights', + input.maxPointLights ?? DEFAULT_LIGHTING_CAPACITY.maxPointLights, + 0 + ); + const maxSpotLights = assertInteger( + 'maxSpotLights', + input.maxSpotLights ?? DEFAULT_LIGHTING_CAPACITY.maxSpotLights, + 0 + ); + const maxLocalLights = Math.min( + assertInteger( + 'maxLocalLights', + input.maxLocalLights ?? DEFAULT_LIGHTING_CAPACITY.maxLocalLights, + 0 + ), + maxPointLights + maxSpotLights + ); + + return Object.freeze({ + maxDirectionalLights, + maxPointLights, + maxSpotLights, + maxLocalLights, + }); +}; + +const freezeDefinition = (definition: TDefinition): TDefinition => + Object.freeze(definition); + +export const createDirectionalLightDefinition = ( + input: DirectionalLightCreateInput, + fallbackId: string +): DirectionalLightDefinition => { + const id = brandLightId( + LightKind.Directional, + typeof input.id === 'string' ? input.id : fallbackId + ); + + return freezeDefinition({ + id, + kind: LightKind.Directional, + enabled: input.enabled ?? true, + color: toFrozenVec3(input.color, Vec3.ONE), + intensity: assertFiniteNumber('intensity', input.intensity ?? 1, 0), + priority: assertFiniteNumber('priority', input.priority ?? 0), + metadata: cloneMetadata(input.metadata), + direction: toNormalizedDirection(input.direction, Vec3.DOWN), + ambient: toFrozenVec3(input.ambient, Vec3.ZERO), + }); +}; + +export const createPointLightDefinition = ( + input: PointLightCreateInput, + fallbackId: string +): PointLightDefinition => { + const id = brandLightId(LightKind.Point, typeof input.id === 'string' ? input.id : fallbackId); + + return freezeDefinition({ + id, + kind: LightKind.Point, + enabled: input.enabled ?? true, + color: toFrozenVec3(input.color, Vec3.ONE), + intensity: assertFiniteNumber('intensity', input.intensity ?? 1, 0), + priority: assertFiniteNumber('priority', input.priority ?? 0), + metadata: cloneMetadata(input.metadata), + position: toFrozenVec3(input.position, Vec3.ZERO), + range: assertFiniteNumber('range', input.range ?? 8, 0, Infinity, false), + attenuation: assertFiniteNumber('attenuation', input.attenuation ?? 2, 0), + }); +}; + +export const createSpotLightDefinition = ( + input: SpotLightCreateInput, + fallbackId: string +): SpotLightDefinition => { + const id = brandLightId(LightKind.Spot, typeof input.id === 'string' ? input.id : fallbackId); + const cones = toSpotConeCosines( + input, + Math.cos(DEFAULT_INNER_CONE_ANGLE), + Math.cos(DEFAULT_OUTER_CONE_ANGLE) + ); + + return freezeDefinition({ + id, + kind: LightKind.Spot, + enabled: input.enabled ?? true, + color: toFrozenVec3(input.color, Vec3.ONE), + intensity: assertFiniteNumber('intensity', input.intensity ?? 1, 0), + priority: assertFiniteNumber('priority', input.priority ?? 0), + metadata: cloneMetadata(input.metadata), + position: toFrozenVec3(input.position, Vec3.ZERO), + direction: toNormalizedDirection(input.direction, Vec3.DOWN), + range: assertFiniteNumber('range', input.range ?? 8, 0, Infinity, false), + attenuation: assertFiniteNumber('attenuation', input.attenuation ?? 2, 0), + innerConeCosine: cones.innerConeCosine, + outerConeCosine: cones.outerConeCosine, + }); +}; + +export const createLightDefinition = ( + kind: K, + input: LightCreateInput, + fallbackId: string +): LightDefinition => { + switch (kind) { + case LightKind.Directional: + return createDirectionalLightDefinition( + input as DirectionalLightCreateInput, + fallbackId + ) as LightDefinition; + case LightKind.Point: + return createPointLightDefinition(input as PointLightCreateInput, fallbackId) as LightDefinition; + case LightKind.Spot: + return createSpotLightDefinition(input as SpotLightCreateInput, fallbackId) as LightDefinition; + } +}; + +export const applyDirectionalLightPatch = ( + definition: DirectionalLightDefinition, + patch: DirectionalLightPatch +): DirectionalLightDefinition => { + return createDirectionalLightDefinition( + { + id: definition.id, + enabled: patch.enabled ?? definition.enabled, + color: patch.color ?? definition.color, + intensity: patch.intensity ?? definition.intensity, + priority: patch.priority ?? definition.priority, + metadata: patch.metadata === undefined ? definition.metadata ?? null : patch.metadata, + direction: patch.direction ?? definition.direction, + ambient: patch.ambient ?? definition.ambient, + }, + String(definition.id) + ); +}; + +export const applyPointLightPatch = ( + definition: PointLightDefinition, + patch: PointLightPatch +): PointLightDefinition => { + return createPointLightDefinition( + { + id: definition.id, + enabled: patch.enabled ?? definition.enabled, + color: patch.color ?? definition.color, + intensity: patch.intensity ?? definition.intensity, + priority: patch.priority ?? definition.priority, + metadata: patch.metadata === undefined ? definition.metadata ?? null : patch.metadata, + position: patch.position ?? definition.position, + range: patch.range ?? definition.range, + attenuation: patch.attenuation ?? definition.attenuation, + }, + String(definition.id) + ); +}; + +export const applySpotLightPatch = ( + definition: SpotLightDefinition, + patch: SpotLightPatch +): SpotLightDefinition => { + if (patch.coneMode === 'angle' || patch.innerConeAngle !== undefined || patch.outerConeAngle !== undefined) { + return createSpotLightDefinition( + { + id: definition.id, + enabled: patch.enabled ?? definition.enabled, + color: patch.color ?? definition.color, + intensity: patch.intensity ?? definition.intensity, + priority: patch.priority ?? definition.priority, + metadata: patch.metadata === undefined ? definition.metadata ?? null : patch.metadata, + position: patch.position ?? definition.position, + direction: patch.direction ?? definition.direction, + range: patch.range ?? definition.range, + attenuation: patch.attenuation ?? definition.attenuation, + coneMode: 'angle', + innerConeAngle: patch.innerConeAngle, + outerConeAngle: patch.outerConeAngle, + }, + String(definition.id) + ); + } + + return createSpotLightDefinition( + { + id: definition.id, + enabled: patch.enabled ?? definition.enabled, + color: patch.color ?? definition.color, + intensity: patch.intensity ?? definition.intensity, + priority: patch.priority ?? definition.priority, + metadata: patch.metadata === undefined ? definition.metadata ?? null : patch.metadata, + position: patch.position ?? definition.position, + direction: patch.direction ?? definition.direction, + range: patch.range ?? definition.range, + attenuation: patch.attenuation ?? definition.attenuation, + coneMode: 'cosine', + innerConeCosine: patch.innerConeCosine ?? definition.innerConeCosine, + outerConeCosine: patch.outerConeCosine ?? definition.outerConeCosine, + }, + String(definition.id) + ); +}; + +export const applyLightPatch = ( + definition: LightDefinition, + patch: LightPatch +): LightDefinition => { + switch (definition.kind) { + case LightKind.Directional: + return applyDirectionalLightPatch( + definition as DirectionalLightDefinition, + patch as DirectionalLightPatch + ) as LightDefinition; + case LightKind.Point: + return applyPointLightPatch( + definition as PointLightDefinition, + patch as PointLightPatch + ) as LightDefinition; + case LightKind.Spot: + return applySpotLightPatch( + definition as SpotLightDefinition, + patch as SpotLightPatch + ) as LightDefinition; + } +}; \ No newline at end of file diff --git a/web/packages/lighting/vitest.config.ts b/web/packages/lighting/vitest.config.ts new file mode 100644 index 00000000..db2af9ef --- /dev/null +++ b/web/packages/lighting/vitest.config.ts @@ -0,0 +1,23 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; +import { createWorkspacePackageAliasEntries } from '../../build/workspace-package-aliases.mjs'; + +const packageDir = path.dirname(fileURLToPath(import.meta.url)); +const workspaceDir = path.resolve(packageDir, '../..'); + +export default defineConfig({ + test: { + environment: 'happy-dom', + globals: true, + setupFiles: [path.resolve(workspaceDir, 'vitest.setup.ts')], + include: ['src/**/*.{test,spec}.{js,ts}', 'src/**/__tests__/**/*.{test,spec}.{js,ts}'], + exclude: ['src/**/*.browser.{test,spec}.{js,ts}', 'src/**/renderer/**/*', 'src/**/webgl/**/*'], + }, + resolve: { + alias: createWorkspacePackageAliasEntries(workspaceDir), + }, + esbuild: { + target: 'es2022', + }, +}); \ No newline at end of file diff --git a/web/packages/memory/package.json b/web/packages/memory/package.json new file mode 100644 index 00000000..af952c51 --- /dev/null +++ b/web/packages/memory/package.json @@ -0,0 +1,26 @@ +{ + "name": "@axrone/memory", + "version": "0.0.1", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "rollup -c rollup.config.mjs", + "clean": "rimraf dist", + "test": "vitest run" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "" +} diff --git a/web/packages/memory/rollup.config.mjs b/web/packages/memory/rollup.config.mjs new file mode 100644 index 00000000..16ce0312 --- /dev/null +++ b/web/packages/memory/rollup.config.mjs @@ -0,0 +1,9 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createPackageConfig } from '../../build/create-package-config.mjs'; + +const packageDir = path.dirname(fileURLToPath(import.meta.url)); + +export default createPackageConfig({ + packageDir, +}); diff --git a/web/packages/utility/src/__tests__/buffer/buffer-pool.test.ts b/web/packages/memory/src/__tests__/buffer/buffer-pool.test.ts similarity index 99% rename from web/packages/utility/src/__tests__/buffer/buffer-pool.test.ts rename to web/packages/memory/src/__tests__/buffer/buffer-pool.test.ts index 9b2a83b0..174dd2c3 100644 --- a/web/packages/utility/src/__tests__/buffer/buffer-pool.test.ts +++ b/web/packages/memory/src/__tests__/buffer/buffer-pool.test.ts @@ -1,4 +1,4 @@ -import { BufferPool, BufferPoolOptions } from '../../memory/buffering'; +import { BufferPool, BufferPoolOptions } from '../../buffering'; describe('Professional BufferPool', () => { let pool: BufferPool; diff --git a/web/packages/utility/src/__tests__/buffer/buffer-view.test.ts b/web/packages/memory/src/__tests__/buffer/buffer-view.test.ts similarity index 98% rename from web/packages/utility/src/__tests__/buffer/buffer-view.test.ts rename to web/packages/memory/src/__tests__/buffer/buffer-view.test.ts index a4fb83ae..f4ffcacb 100644 --- a/web/packages/utility/src/__tests__/buffer/buffer-view.test.ts +++ b/web/packages/memory/src/__tests__/buffer/buffer-view.test.ts @@ -1,4 +1,4 @@ -import { ByteBuffer, BufferView } from '../../memory/buffering'; +import { ByteBuffer, BufferView } from '../../buffering'; describe('BufferView — professional tests', () => { it('creates typed views and reports capacity/position/limit correctly', () => { diff --git a/web/packages/utility/src/__tests__/buffer/buffer.test.ts b/web/packages/memory/src/__tests__/buffer/buffer.test.ts similarity index 99% rename from web/packages/utility/src/__tests__/buffer/buffer.test.ts rename to web/packages/memory/src/__tests__/buffer/buffer.test.ts index 39090cd4..58bed9db 100644 --- a/web/packages/utility/src/__tests__/buffer/buffer.test.ts +++ b/web/packages/memory/src/__tests__/buffer/buffer.test.ts @@ -5,7 +5,7 @@ import { ReadOnlyBufferError, BufferAlignmentError, InvalidMarkError, -} from '../../memory/buffering'; +} from '../../buffering'; describe('ByteBuffer core — professional tests', () => { it('allocates with a positive capacity and rounds to power of two', () => { diff --git a/web/packages/memory/src/__tests__/containers/binary-heap-advanced.test.ts b/web/packages/memory/src/__tests__/containers/binary-heap-advanced.test.ts new file mode 100644 index 00000000..bfe717bb --- /dev/null +++ b/web/packages/memory/src/__tests__/containers/binary-heap-advanced.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { BinaryHeap, createBinaryHeap, isBinaryHeap } from '../../containers/queue/binary-heap'; + +describe('BinaryHeap', () => { + it('should build min and max heaps with the expected ordering', () => { + const minHeap = BinaryHeap.min([3, 1, 2]); + const maxHeap = BinaryHeap.max([3, 1, 2]); + + expect(minHeap.peek()).toBe(1); + expect(maxHeap.peek()).toBe(3); + expect(minHeap.toSortedArray()).toEqual([1, 2, 3]); + expect(maxHeap.toSortedArray()).toEqual([3, 2, 1]); + }); + + it('should support push, pop, replaceTop, pushPop, and removeAt', () => { + const heap = createBinaryHeap({ comparator: (left, right) => left - right }); + + heap.pushAll([5, 1, 3]); + + expect(heap.pushPop(2)).toBe(1); + expect(heap.peek()).toBe(2); + expect(heap.replaceTop(4)).toBe(2); + expect(heap.peek()).toBe(3); + expect(heap.removeAt(1)).toBe(5); + expect(heap.pop()).toBe(3); + expect(heap.pop()).toBe(4); + expect(heap.pop()).toBeUndefined(); + }); + + it('should clone and serialize without sharing internal storage', () => { + const heap = BinaryHeap.from([4, 1, 3]); + const clone = heap.clone(); + const snapshot = heap.toJSON(); + const restored = BinaryHeap.deserialize(snapshot); + + clone.push(0); + + expect(heap.peek()).toBe(1); + expect(clone.peek()).toBe(0); + expect(restored.toSortedArray()).toEqual([1, 3, 4]); + expect(isBinaryHeap(heap)).toBe(true); + expect(isBinaryHeap({ peek: () => undefined })).toBe(false); + }); +}); \ No newline at end of file diff --git a/web/packages/utility/src/__tests__/memory/containers/binary-heap.test.ts b/web/packages/memory/src/__tests__/containers/binary-heap.test.ts similarity index 98% rename from web/packages/utility/src/__tests__/memory/containers/binary-heap.test.ts rename to web/packages/memory/src/__tests__/containers/binary-heap.test.ts index ee9b4d42..2ef317eb 100644 --- a/web/packages/utility/src/__tests__/memory/containers/binary-heap.test.ts +++ b/web/packages/memory/src/__tests__/containers/binary-heap.test.ts @@ -1,11 +1,11 @@ -import { BinaryMinHeap } from '../../../memory/containers/queue/binary-heap'; +import { BinaryMinHeap } from '../../containers/queue/binary-heap'; import { createCapacity, createQueueSize, defaultComparator, numericComparator, -} from '../../../memory/containers/queue/utils'; -import { EmptyQueueError } from '../../../memory/containers/queue/errors'; +} from '../../containers/queue/utils'; +import { EmptyQueueError } from '../../containers/queue/errors'; import { beforeEach, describe, expect, it } from 'vitest'; describe('BinaryMinHeap', () => { diff --git a/web/packages/memory/src/__tests__/containers/queue.test.ts b/web/packages/memory/src/__tests__/containers/queue.test.ts new file mode 100644 index 00000000..e48e1872 --- /dev/null +++ b/web/packages/memory/src/__tests__/containers/queue.test.ts @@ -0,0 +1,235 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + Queue, + EmptyQueueError, + InvalidCapacityError, + createCapacity, + createQueueSize, +} from '../../containers/queue'; + +describe('Queue', () => { + describe('Constructor', () => { + it('should create an empty queue with default options', () => { + const queue = new Queue(); + + expect(queue.size).toBe(createQueueSize(0)); + expect(queue.isEmpty).toBe(true); + expect(queue.capacity).toBe(createCapacity(16)); + }); + + it('should honor a custom initial capacity', () => { + const queue = new Queue({ initialCapacity: createCapacity(32) }); + + expect(queue.capacity).toBe(createCapacity(32)); + expect(queue.size).toBe(createQueueSize(0)); + }); + + it('should reject invalid initial capacities', () => { + expect(() => new Queue({ initialCapacity: createCapacity(0) })).toThrow( + InvalidCapacityError + ); + }); + }); + + describe('FIFO Operations', () => { + let queue: Queue; + + beforeEach(() => { + queue = new Queue({ initialCapacity: createCapacity(4) }); + }); + + it('should preserve insertion order across enqueue and dequeue', () => { + queue.enqueue('first'); + queue.enqueue('second'); + queue.enqueue('third'); + + expect(queue.dequeue()).toBe('first'); + expect(queue.dequeue()).toBe('second'); + expect(queue.dequeue()).toBe('third'); + expect(queue.isEmpty).toBe(true); + }); + + it('should support wrap-around without losing order', () => { + queue.enqueue('a'); + queue.enqueue('b'); + queue.enqueue('c'); + + expect(queue.dequeue()).toBe('a'); + expect(queue.dequeue()).toBe('b'); + + queue.enqueue('d'); + queue.enqueue('e'); + queue.enqueue('f'); + + expect(queue.toArray()).toEqual(['c', 'd', 'e', 'f']); + expect(queue.dequeue()).toBe('c'); + expect(queue.dequeue()).toBe('d'); + expect(queue.dequeue()).toBe('e'); + expect(queue.dequeue()).toBe('f'); + }); + + it('should grow capacity when the ring buffer fills up', () => { + const initialCapacity = queue.capacity; + + queue.enqueue('a'); + queue.enqueue('b'); + queue.enqueue('c'); + queue.enqueue('d'); + queue.enqueue('e'); + + expect(queue.capacity).toBeGreaterThan(initialCapacity); + expect(queue.toArray()).toEqual(['a', 'b', 'c', 'd', 'e']); + }); + }); + + describe('Safe Accessors', () => { + let queue: Queue; + + beforeEach(() => { + queue = new Queue(); + }); + + it('should throw when dequeuing from an empty queue', () => { + expect(() => queue.dequeue()).toThrow(EmptyQueueError); + }); + + it('should throw when peeking into an empty queue', () => { + expect(() => queue.peek()).toThrow(EmptyQueueError); + }); + + it('should expose undefined-safe accessors for empty queues', () => { + expect(queue.tryDequeue()).toBeUndefined(); + expect(queue.tryPeek()).toBeUndefined(); + }); + + it('should peek without consuming the head item', () => { + queue.enqueue(10); + queue.enqueue(20); + + expect(queue.peek()).toBe(10); + expect(queue.tryPeek()).toBe(10); + expect(queue.size).toBe(createQueueSize(2)); + }); + }); + + describe('Bulk and Utility Operations', () => { + it('should enqueue ranges from arrays and iterables', () => { + const queue = new Queue(); + + queue.enqueueRange([1, 2, 3]); + queue.enqueueRange(new Set([4, 5])); + + expect(queue.toArray()).toEqual([1, 2, 3, 4, 5]); + }); + + it('should report membership using reference equality', () => { + const queue = new Queue(); + const first = { id: 1 }; + const second = { id: 2 }; + + queue.enqueue(first); + + expect(queue.contains(first)).toBe(true); + expect(queue.contains(second)).toBe(false); + }); + + it('should expose a frozen snapshot with toArray', () => { + const queue = new Queue(); + queue.enqueueRange(['alpha', 'beta', 'gamma']); + + const snapshot = queue.toArray(); + + expect(snapshot).toEqual(['alpha', 'beta', 'gamma']); + expect(Object.isFrozen(snapshot)).toBe(true); + }); + + it('should iterate in FIFO order', () => { + const queue = new Queue(); + queue.enqueueRange([1, 2, 3, 4]); + + expect([...queue]).toEqual([1, 2, 3, 4]); + }); + + it('should clone queue state without sharing storage', () => { + const queue = new Queue({ initialCapacity: createCapacity(8) }); + queue.enqueueRange(['north', 'south', 'west']); + + const clone = queue.clone(); + clone.enqueue('east'); + + expect(queue.toArray()).toEqual(['north', 'south', 'west']); + expect(clone.toArray()).toEqual(['north', 'south', 'west', 'east']); + }); + }); + + describe('Capacity Management', () => { + it('should reserve additional capacity when requested', () => { + const queue = new Queue({ initialCapacity: createCapacity(4) }); + + queue.ensureCapacity(createCapacity(64)); + + expect(queue.capacity).toBeGreaterThanOrEqual(createCapacity(64)); + }); + + it('should reject invalid ensureCapacity calls', () => { + const queue = new Queue(); + + expect(() => queue.ensureCapacity(createCapacity(0))).toThrow(InvalidCapacityError); + }); + + it('should trim excess capacity to active size', () => { + const queue = new Queue({ initialCapacity: createCapacity(2) }); + + for (let index = 0; index < 24; index++) { + queue.enqueue(index); + } + + for (let index = 0; index < 20; index++) { + queue.dequeue(); + } + + const capacityBeforeTrim = queue.capacity; + queue.trimExcess(); + + expect(queue.capacity).toBeLessThanOrEqual(capacityBeforeTrim); + expect(queue.capacity).toBeGreaterThanOrEqual(createCapacity(4)); + expect(queue.toArray()).toEqual([20, 21, 22, 23]); + }); + + it('should auto-trim back toward minimum capacity when enabled', () => { + const queue = new Queue({ + initialCapacity: createCapacity(4), + autoTrim: true, + }); + + for (let index = 0; index < 40; index++) { + queue.enqueue(index); + } + + const expandedCapacity = queue.capacity; + + for (let index = 0; index < 39; index++) { + queue.dequeue(); + } + + expect(queue.capacity).toBeLessThanOrEqual(expandedCapacity); + expect(queue.capacity).toBeGreaterThanOrEqual(createCapacity(4)); + expect(queue.toArray()).toEqual([39]); + }); + + it('should reset to minimum capacity on clear when auto-trim is enabled', () => { + const queue = new Queue({ + initialCapacity: createCapacity(4), + autoTrim: true, + }); + + queue.ensureCapacity(createCapacity(64)); + queue.enqueueRange([1, 2, 3, 4]); + queue.clear(); + + expect(queue.size).toBe(createQueueSize(0)); + expect(queue.capacity).toBe(createCapacity(4)); + expect(queue.isEmpty).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/web/packages/utility/src/__tests__/memory/lazy.test.ts b/web/packages/memory/src/__tests__/lazy.test.ts similarity index 98% rename from web/packages/utility/src/__tests__/memory/lazy.test.ts rename to web/packages/memory/src/__tests__/lazy.test.ts index 908d1070..57793b05 100644 --- a/web/packages/utility/src/__tests__/memory/lazy.test.ts +++ b/web/packages/memory/src/__tests__/lazy.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { create, fromValue, tryLazy, ILazy } from '../../memory/lazy'; +import { create, fromValue, tryLazy, ILazy } from '../lazy'; describe('Lazy', () => { it('should evaluate lazily and cache the result', () => { diff --git a/web/packages/utility/src/__tests__/memory/pool/mempool.test.ts b/web/packages/memory/src/__tests__/pool/mempool.test.ts similarity index 99% rename from web/packages/utility/src/__tests__/memory/pool/mempool.test.ts rename to web/packages/memory/src/__tests__/pool/mempool.test.ts index b0780e10..985da872 100644 --- a/web/packages/utility/src/__tests__/memory/pool/mempool.test.ts +++ b/web/packages/memory/src/__tests__/pool/mempool.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { MemoryPool, MemoryPoolError, PoolableObject } from './../../../memory/pool/mempool'; +import { MemoryPool, MemoryPoolError, PoolableObject } from './../../pool/mempool'; describe('MemoryPool', () => { class TestObject implements PoolableObject { diff --git a/web/packages/utility/src/__tests__/memory/pool/objectpool.test.ts b/web/packages/memory/src/__tests__/pool/objectpool.test.ts similarity index 99% rename from web/packages/utility/src/__tests__/memory/pool/objectpool.test.ts rename to web/packages/memory/src/__tests__/pool/objectpool.test.ts index f9f18dbe..cb251f39 100644 --- a/web/packages/utility/src/__tests__/memory/pool/objectpool.test.ts +++ b/web/packages/memory/src/__tests__/pool/objectpool.test.ts @@ -1,5 +1,5 @@ -import { ObjectPool, ObjectPoolOptions, PoolableWrapper } from '../../../memory/pool/object-pool'; -import { MemoryPoolErrorCode } from '../../../memory/pool/mempool'; +import { ObjectPool, ObjectPoolOptions, PoolableWrapper } from '../../pool/object-pool'; +import { MemoryPoolErrorCode } from '../../pool/mempool'; import { afterEach, describe, expect, it, vi } from 'vitest'; describe('ObjectPool', () => { diff --git a/web/packages/memory/src/__tests__/pq.test.ts b/web/packages/memory/src/__tests__/pq.test.ts new file mode 100644 index 00000000..9eb9d0a9 --- /dev/null +++ b/web/packages/memory/src/__tests__/pq.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest'; +import { + PriorityQueue, + PriorityQueueComparatorError, + PriorityQueueHandleError, + PriorityQueuePriorityError, + PriorityQueueSerializationError, + createPriorityQueue, + isPriorityQueue, +} from '../containers/queue/priority-queue'; + +describe('PriorityQueue', () => { + it('dequeues highest priority items first in default max order', () => { + const queue = new PriorityQueue(); + + queue.enqueue('low', 1); + queue.enqueue('high', 10); + queue.enqueue('medium', 5); + + expect(queue.dequeue()).toBe('high'); + expect(queue.dequeue()).toBe('medium'); + expect(queue.dequeue()).toBe('low'); + }); + + it('supports min order helpers', () => { + const queue = PriorityQueue.min(); + + queue.enqueue('low', 1); + queue.enqueue('high', 10); + + expect(queue.peek()).toBe('low'); + expect(queue.dequeue()).toBe('low'); + expect(queue.dequeue()).toBe('high'); + }); + + it('keeps insertion order stable for equal priorities', () => { + const queue = new PriorityQueue(); + + queue.enqueue('first', 7); + queue.enqueue('second', 7); + queue.enqueue('third', 7); + + expect(queue.drain()).toEqual(['first', 'second', 'third']); + }); + + it('returns handles and snapshots for queued items', () => { + const queue = new PriorityQueue(); + const handle = queue.enqueue('alpha', 3); + + expect(queue.has(handle)).toBe(true); + expect(queue.peekHandle()).toBe(handle); + expect(queue.peekPriority()).toBe(3); + expect(queue.get(handle)).toEqual({ value: 'alpha', priority: 3, handle }); + expect(queue.peekEntry()).toEqual({ value: 'alpha', priority: 3, handle }); + }); + + it('updates and removes entries by handle', () => { + const queue = new PriorityQueue(); + const first = queue.enqueue('first', 1); + const second = queue.enqueue('second', 2); + + expect(queue.updateValue(first, 'first-updated')).toBe(true); + expect(queue.updatePriority(first, 10)).toBe(true); + expect(queue.peek()).toBe('first-updated'); + + const removed = queue.remove(second); + + expect(removed).toEqual({ value: 'second', priority: 2, handle: second }); + expect(queue.has(second)).toBe(false); + expect(queue.contains('second')).toBe(false); + }); + + it('supports replaceHead', () => { + const queue = new PriorityQueue(); + + queue.enqueue('first', 1); + queue.enqueue('second', 2); + + const head = queue.peekEntry(); + const removed = queue.replaceHead('replacement', 3); + + expect(removed).toEqual(head); + expect(queue.peek()).toBe('replacement'); + }); + + it('builds from entries and values', () => { + const fromEntries = PriorityQueue.fromEntries([ + { value: 'c', priority: 3 }, + { value: 'a', priority: 1 }, + { value: 'b', priority: 2 }, + ]); + + const fromValues = PriorityQueue.fromValues(['gamma', 'alpha', 'beta'], { + priority: (value) => value.length, + }); + + expect(fromEntries.drain()).toEqual(['c', 'b', 'a']); + expect(fromValues.drain()).toEqual(['gamma', 'alpha', 'beta']); + }); + + it('serializes and restores queue state', () => { + const queue = PriorityQueue.min(); + + queue.enqueue('b', 2); + queue.enqueue('a', 1); + + const json = queue.toJSON(); + const restored = PriorityQueue.deserialize(json); + + expect(json).toEqual({ + kind: 'PriorityQueue', + version: 1, + order: 'min', + items: [ + { value: 'a', priority: 1 }, + { value: 'b', priority: 2 }, + ], + }); + expect(restored.drain()).toEqual(['a', 'b']); + }); + + it('clones without sharing mutable state', () => { + const queue = new PriorityQueue(); + const handle = queue.enqueue('alpha', 1); + const clone = queue.clone(); + + expect(clone.peek()).toBe('alpha'); + expect(clone.updateValue(handle, 'beta')).toBe(true); + expect(queue.peek()).toBe('alpha'); + expect(clone.peek()).toBe('beta'); + }); + + it('supports iteration and sorted views', () => { + const queue = new PriorityQueue(); + + queue.enqueue('first', 1); + queue.enqueue('second', 2); + + expect([...queue]).toEqual(['second', 'first']); + expect(queue.toSortedArray()).toEqual(['second', 'first']); + expect(queue.toSortedEntries().map((entry) => entry.value)).toEqual(['second', 'first']); + }); + + it('uses priority selectors when values are enqueued without explicit priority', () => { + const queue = new PriorityQueue<{ id: string; score: number }, number>({ + priority: (value) => value.score, + }); + + queue.enqueue({ id: 'low', score: 1 }); + queue.enqueue({ id: 'high', score: 10 }); + + expect(queue.dequeue()?.id).toBe('high'); + }); + + it('reports type helpers and errors', () => { + const queue = new PriorityQueue(); + const handle = queue.enqueue('alpha', 1); + + expect(isPriorityQueue(queue)).toBe(true); + expect(isPriorityQueue(null)).toBe(false); + expect(queue.assertHas(handle)).toEqual({ value: 'alpha', priority: 1, handle }); + expect(() => queue.assertHas(123 as never)).toThrow(PriorityQueueHandleError); + expect(() => new PriorityQueue({ comparator: {} as never })).toThrow( + PriorityQueueComparatorError + ); + expect(() => new PriorityQueue().enqueue('a')).toThrow( + PriorityQueuePriorityError + ); + expect(() => + PriorityQueue.deserialize({ kind: 'Bad', version: 1, items: [] }) + ).toThrow(PriorityQueueSerializationError); + }); +}); + +describe('PriorityQueue factories', () => { + it('createPriorityQueue returns a usable queue', () => { + const queue = createPriorityQueue({ + priority: (value) => value.length, + }); + + queue.enqueue('alpha'); + + expect(queue.peek()).toBe('alpha'); + }); + + it('PriorityQueue.max and PriorityQueue.min use the expected order', () => { + const maxQueue = PriorityQueue.max(); + const minQueue = PriorityQueue.min(); + + maxQueue.enqueue('low', 1); + maxQueue.enqueue('high', 10); + minQueue.enqueue('low', 1); + minQueue.enqueue('high', 10); + + expect(maxQueue.peek()).toBe('high'); + expect(minQueue.peek()).toBe('low'); + }); +}); diff --git a/web/packages/utility/src/memory/buffering/buffer-pool.ts b/web/packages/memory/src/buffering/buffer-pool.ts similarity index 100% rename from web/packages/utility/src/memory/buffering/buffer-pool.ts rename to web/packages/memory/src/buffering/buffer-pool.ts diff --git a/web/packages/utility/src/memory/buffering/buffer-view.ts b/web/packages/memory/src/buffering/buffer-view.ts similarity index 100% rename from web/packages/utility/src/memory/buffering/buffer-view.ts rename to web/packages/memory/src/buffering/buffer-view.ts diff --git a/web/packages/utility/src/memory/buffering/byte-buffer-core.ts b/web/packages/memory/src/buffering/byte-buffer-core.ts similarity index 100% rename from web/packages/utility/src/memory/buffering/byte-buffer-core.ts rename to web/packages/memory/src/buffering/byte-buffer-core.ts diff --git a/web/packages/utility/src/memory/buffering/constants.ts b/web/packages/memory/src/buffering/constants.ts similarity index 100% rename from web/packages/utility/src/memory/buffering/constants.ts rename to web/packages/memory/src/buffering/constants.ts diff --git a/web/packages/utility/src/memory/buffering/errors.ts b/web/packages/memory/src/buffering/errors.ts similarity index 100% rename from web/packages/utility/src/memory/buffering/errors.ts rename to web/packages/memory/src/buffering/errors.ts diff --git a/web/packages/utility/src/memory/buffering/index.ts b/web/packages/memory/src/buffering/index.ts similarity index 100% rename from web/packages/utility/src/memory/buffering/index.ts rename to web/packages/memory/src/buffering/index.ts diff --git a/web/packages/utility/src/memory/buffering/interfaces.ts b/web/packages/memory/src/buffering/interfaces.ts similarity index 100% rename from web/packages/utility/src/memory/buffering/interfaces.ts rename to web/packages/memory/src/buffering/interfaces.ts diff --git a/web/packages/utility/src/memory/buffering/types.ts b/web/packages/memory/src/buffering/types.ts similarity index 100% rename from web/packages/utility/src/memory/buffering/types.ts rename to web/packages/memory/src/buffering/types.ts diff --git a/web/packages/utility/src/memory/buffering/utils.ts b/web/packages/memory/src/buffering/utils.ts similarity index 100% rename from web/packages/utility/src/memory/buffering/utils.ts rename to web/packages/memory/src/buffering/utils.ts diff --git a/web/packages/memory/src/containers/queue/binary-heap.ts b/web/packages/memory/src/containers/queue/binary-heap.ts new file mode 100644 index 00000000..309a140c --- /dev/null +++ b/web/packages/memory/src/containers/queue/binary-heap.ts @@ -0,0 +1,946 @@ +import { EmptyQueueError, InvalidCapacityError } from './errors'; +import { + Capacity, + Comparator, + HeapIndex, + QueueSize, + BinaryHeapOperations, +} from './types'; +import { createCapacity, createQueueSize, defaultComparator } from './utils'; + +export type { Comparator } from './types'; + +export type HeapOrder = 'min' | 'max'; + +export type CompareSign = -1 | 0 | 1; + +export type Equality = (left: T, right: T) => boolean; + +export type HeapPrimitive = number | bigint | string | Date; + +export type HeapSerialized = Readonly<{ + readonly kind: 'BinaryHeap'; + readonly version: 1; + readonly order: HeapOrder; + readonly items: readonly T[]; +}>; + +export type HeapLike = Iterable | ArrayLike; + +export interface BinaryHeapOptions { + readonly order?: O; + readonly comparator?: Comparator; + readonly equality?: Equality; + readonly items?: HeapLike; +} + +export interface ReadonlyBinaryHeap extends Iterable { + readonly size: number; + readonly order: O; + readonly comparator: Comparator; + readonly [Symbol.toStringTag]: 'BinaryHeap'; + isEmpty(): boolean; + peek(): T | undefined; + toArray(): T[]; + toJSON(): HeapSerialized; + clone(): BinaryHeap; + values(): IterableIterator; +} + +export class HeapError extends Error { + public override readonly name: string = 'HeapError'; + + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class HeapComparatorError extends HeapError { + public override readonly name: string = 'HeapComparatorError'; + + constructor(message = 'A valid comparator function is required for this heap.') { + super(message); + } +} + +export class HeapIndexError extends HeapError { + public override readonly name: string = 'HeapIndexError'; + + constructor(message = 'Heap index is out of range.') { + super(message); + } +} + +export class HeapSerializationError extends HeapError { + public override readonly name: string = 'HeapSerializationError'; + + constructor(message = 'Invalid heap serialization payload.') { + super(message); + } +} + +const enum InternalOrder { + Min = 1, + Max = -1, +} + +const isFunction = (value: unknown): value is (...args: readonly unknown[]) => unknown => + typeof value === 'function'; + +const isObject = (value: unknown): value is Record => + value !== null && typeof value === 'object'; + +export const defaultPrimitiveComparator = (left: T, right: T): number => { + if (left === right) return 0; + + if (typeof left === 'number' && typeof right === 'number') { + if (Number.isNaN(left)) return Number.isNaN(right) ? 0 : 1; + if (Number.isNaN(right)) return -1; + return left < right ? -1 : 1; + } + + const leftValue = left instanceof Date ? left.getTime() : left; + const rightValue = right instanceof Date ? right.getTime() : right; + + return leftValue < rightValue ? -1 : 1; +}; + +const defaultEquality = (left: T, right: T): boolean => Object.is(left, right); + +const ensureComparator = (comparator: unknown): Comparator => { + if (!isFunction(comparator)) { + throw new HeapComparatorError(); + } + + return comparator as Comparator; +}; + +const ensureEquality = (equality: Equality | undefined): Equality => + equality ?? defaultEquality; + +const normalizeOrder = (order: HeapOrder | undefined): HeapOrder => (order === 'max' ? 'max' : 'min'); + +const internalOrderOf = (order: HeapOrder): InternalOrder => + order === 'max' ? InternalOrder.Max : InternalOrder.Min; + +const collectToArray = (source: HeapLike | undefined): T[] => { + if (source === undefined) { + return []; + } + + if (Array.isArray(source)) { + return source.slice(); + } + + const length = (source as ArrayLike).length; + + if (typeof length === 'number') { + const result = new Array(length); + + for (let index = 0; index < length; index++) { + result[index] = (source as ArrayLike)[index]!; + } + + return result; + } + + const result: T[] = []; + + for (const value of source as Iterable) { + result.push(value); + } + + return result; +}; + +const compareByOrder = ( + order: InternalOrder, + comparator: Comparator, + left: T, + right: T +): boolean => comparator(left, right) * order < 0; + +const ensureSerializable = (value: unknown): HeapSerialized => { + if (!isObject(value)) { + throw new HeapSerializationError(); + } + + if (value.kind !== 'BinaryHeap' || value.version !== 1) { + throw new HeapSerializationError(); + } + + if (value.order !== 'min' && value.order !== 'max') { + throw new HeapSerializationError(); + } + + if (!Array.isArray(value.items)) { + throw new HeapSerializationError(); + } + + return value as HeapSerialized; +}; + +const siftUpThreshold = (baseLength: number, incoming: number): number => + Math.ceil(baseLength / Math.log2(baseLength + incoming + 1)); + +export class BinaryHeap implements ReadonlyBinaryHeap { + readonly #store: T[]; + readonly #order: O; + readonly #orderFactor: InternalOrder; + readonly #comparator: Comparator; + readonly #equality: Equality; + + static #restore( + store: T[], + order: O, + comparator: Comparator, + equality: Equality + ): BinaryHeap { + const heap = new BinaryHeap({ order, comparator, equality }); + const length = store.length; + + heap.#store.length = length; + + for (let index = 0; index < length; index++) { + heap.#store[index] = store[index]!; + } + + return heap; + } + + public static min(items?: HeapLike): BinaryHeap; + public static min(comparator: Comparator, items?: HeapLike): BinaryHeap; + public static min(arg1?: Comparator | HeapLike, arg2?: HeapLike): BinaryHeap { + if (isFunction(arg1)) { + return new BinaryHeap({ order: 'min', comparator: arg1 as Comparator, items: arg2 }); + } + + return new BinaryHeap({ + order: 'min', + comparator: defaultPrimitiveComparator as unknown as Comparator, + items: arg1 as HeapLike | undefined, + }); + } + + public static max(items?: HeapLike): BinaryHeap; + public static max(comparator: Comparator, items?: HeapLike): BinaryHeap; + public static max(arg1?: Comparator | HeapLike, arg2?: HeapLike): BinaryHeap { + if (isFunction(arg1)) { + return new BinaryHeap({ order: 'max', comparator: arg1 as Comparator, items: arg2 }); + } + + return new BinaryHeap({ + order: 'max', + comparator: defaultPrimitiveComparator as unknown as Comparator, + items: arg1 as HeapLike | undefined, + }); + } + + public static from( + items: HeapLike, + options?: Omit, 'items' | 'comparator'> + ): BinaryHeap; + public static from( + items: HeapLike, + options: Omit, 'items'> & Required, 'comparator'>> + ): BinaryHeap; + public static from( + items: HeapLike, + options?: BinaryHeapOptions + ): BinaryHeap { + const order = normalizeOrder(options?.order) as O; + const comparator = options?.comparator ?? (defaultPrimitiveComparator as unknown as Comparator); + + return new BinaryHeap({ order, comparator, equality: options?.equality, items }); + } + + public static deserialize( + payload: unknown, + options?: Omit, 'items' | 'order' | 'comparator'> + ): BinaryHeap; + public static deserialize( + payload: unknown, + options: Omit, 'items' | 'order'> & Required, 'comparator'>> + ): BinaryHeap; + public static deserialize( + payload: unknown, + options?: BinaryHeapOptions + ): BinaryHeap { + const serialized = ensureSerializable(payload); + + const comparator = options?.comparator ?? (defaultPrimitiveComparator as unknown as Comparator); + + return new BinaryHeap({ + order: serialized.order as O, + comparator, + equality: options?.equality, + items: serialized.items, + }); + } + + public static isHeap(value: unknown): value is ReadonlyBinaryHeap { + return ( + isObject(value) && + value[Symbol.toStringTag] === 'BinaryHeap' && + isFunction((value as { peek?: unknown }).peek) + ); + } + + constructor(options?: BinaryHeapOptions) { + const order = normalizeOrder(options?.order) as O; + + this.#order = order; + this.#orderFactor = internalOrderOf(order); + this.#comparator = ensureComparator( + options?.comparator ?? (defaultPrimitiveComparator as unknown as Comparator) + ); + this.#equality = ensureEquality(options?.equality); + this.#store = collectToArray(options?.items); + + if (this.#store.length > 1) { + this.#heapify(); + } + } + + public get [Symbol.toStringTag](): 'BinaryHeap' { + return 'BinaryHeap'; + } + + public get size(): number { + return this.#store.length; + } + + public get order(): O { + return this.#order; + } + + public get comparator(): Comparator { + return this.#comparator; + } + + public isEmpty(): boolean { + return this.#store.length === 0; + } + + public clear(): this { + this.#store.length = 0; + return this; + } + + public clone(): BinaryHeap { + return BinaryHeap.#restore(this.#store, this.#order, this.#comparator, this.#equality); + } + + public peek(): T | undefined { + return this.#store[0]; + } + + public at(index: number): T | undefined { + return index >= 0 && index < this.#store.length ? this.#store[index] : undefined; + } + + public push(value: T): this { + const store = this.#store; + store.push(value); + + const index = store.length - 1; + + if (index > 0) { + this.#siftUp(index); + } + + return this; + } + + public enqueue(value: T): this { + return this.push(value); + } + + public add(value: T): this { + return this.push(value); + } + + public pushAll(values: HeapLike): this { + const store = this.#store; + + let source: T[]; + + if (!Array.isArray(values)) { + const length = (values as ArrayLike).length; + + if (typeof length === 'number') { + source = new Array(length); + + for (let index = 0; index < length; index++) { + source[index] = (values as ArrayLike)[index]!; + } + } else { + source = []; + + for (const value of values as Iterable) { + source.push(value); + } + } + } else { + source = values; + } + + const incoming = source.length; + + if (incoming === 0) { + return this; + } + + const baseLength = store.length; + + if (baseLength === 0) { + store.length = incoming; + + for (let index = 0; index < incoming; index++) { + store[index] = source[index]!; + } + + if (incoming > 1) { + this.#heapify(); + } + + return this; + } + + const threshold = siftUpThreshold(baseLength, incoming); + + if (incoming <= threshold) { + for (let index = 0; index < incoming; index++) { + store.push(source[index]!); + this.#siftUp(store.length - 1); + } + } else { + const newLength = baseLength + incoming; + store.length = newLength; + + for (let index = 0; index < incoming; index++) { + store[baseLength + index] = source[index]!; + } + + this.#heapify(); + } + + return this; + } + + public merge(other: ReadonlyBinaryHeap | HeapLike): this { + if (other instanceof BinaryHeap) { + return this.pushAll(other.#store); + } + + return this.pushAll(other); + } + + public pop(): T | undefined { + const store = this.#store; + const length = store.length; + + if (length === 0) { + return undefined; + } + + if (length === 1) { + return store.pop(); + } + + const root = store[0]!; + store[0] = store.pop()!; + this.#siftDown(0); + + return root; + } + + public dequeue(): T | undefined { + return this.pop(); + } + + public poll(): T | undefined { + return this.pop(); + } + + public replaceTop(value: T): T | undefined { + const store = this.#store; + + if (store.length === 0) { + store.push(value); + return undefined; + } + + const root = store[0]!; + store[0] = value; + this.#siftDown(0); + + return root; + } + + public pushPop(value: T): T { + const store = this.#store; + + if (store.length === 0) { + return value; + } + + const root = store[0]!; + + if (this.#comesBefore(root, value)) { + store[0] = value; + this.#siftDown(0); + return root; + } + + return value; + } + + public popPush(value: T): T | undefined { + return this.replaceTop(value); + } + + public delete(value: T): boolean { + const index = this.indexOf(value); + + if (index < 0) { + return false; + } + + this.removeAt(index); + return true; + } + + public contains(value: T): boolean { + return this.indexOf(value) >= 0; + } + + public indexOf(value: T): number { + const store = this.#store; + const size = store.length; + + if (size === 0) { + return -1; + } + + if (this.#comesBefore(value, store[0]!)) { + return -1; + } + + const equality = this.#equality; + + for (let index = 0; index < size; index++) { + if (equality(store[index]!, value)) { + return index; + } + } + + return -1; + } + + public updateAt(index: number, value: T): this { + this.#assertIndex(index); + + const store = this.#store; + const previous = store[index]!; + store[index] = value; + + if (this.#comesBefore(value, previous)) { + this.#siftUp(index); + } else if (this.#comesBefore(previous, value)) { + this.#siftDown(index); + } + + return this; + } + + public removeAt(index: number): T { + this.#assertIndex(index); + + const store = this.#store; + const removed = store[index]!; + + if (store.length === 1) { + store.pop(); + return removed; + } + + const tail = store.pop()!; + + if (index < store.length) { + store[index] = tail; + const parent = (index - 1) >> 1; + + if (index > 0 && this.#comesBefore(tail, store[parent]!)) { + this.#siftUp(index); + } else { + this.#siftDown(index); + } + } + + return removed; + } + + public drain(): T[] { + const length = this.#store.length; + + if (length === 0) { + return []; + } + + const result = new Array(length); + + for (let index = 0; index < length; index++) { + result[index] = this.pop()!; + } + + return result; + } + + public toArray(): T[] { + return this.#store.slice(); + } + + public toSortedArray(): T[] { + const clone = this.clone(); + const size = clone.size; + const result = new Array(size); + + for (let index = 0; index < size; index++) { + result[index] = clone.pop()!; + } + + return result; + } + + public values(): IterableIterator { + return this.#store.values(); + } + + public keys(): IterableIterator { + return this.#store.keys(); + } + + public entries(): IterableIterator<[number, T]> { + return this.#store.entries(); + } + + public [Symbol.iterator](): IterableIterator { + return this.values(); + } + + public forEach( + callback: (value: T, index: number, heap: this) => void, + thisArg?: unknown + ): void { + const store = this.#store; + + for (let index = 0, length = store.length; index < length; index++) { + callback.call(thisArg, store[index]!, index, this); + } + } + + public map(callback: (value: T, index: number, heap: this) => U, thisArg?: unknown): U[] { + const store = this.#store; + const result = new Array(store.length); + + for (let index = 0, length = store.length; index < length; index++) { + result[index] = callback.call(thisArg, store[index]!, index, this); + } + + return result; + } + + public filter( + predicate: (value: T, index: number, heap: this) => value is S, + thisArg?: unknown + ): S[]; + public filter( + predicate: (value: T, index: number, heap: this) => boolean, + thisArg?: unknown + ): T[]; + public filter( + predicate: (value: T, index: number, heap: this) => boolean, + thisArg?: unknown + ): T[] { + const store = this.#store; + const result: T[] = []; + + for (let index = 0, length = store.length; index < length; index++) { + const value = store[index]!; + + if (predicate.call(thisArg, value, index, this)) { + result.push(value); + } + } + + return result; + } + + public some(predicate: (value: T, index: number, heap: this) => boolean, thisArg?: unknown): boolean { + const store = this.#store; + + for (let index = 0, length = store.length; index < length; index++) { + if (predicate.call(thisArg, store[index]!, index, this)) { + return true; + } + } + + return false; + } + + public every(predicate: (value: T, index: number, heap: this) => boolean, thisArg?: unknown): boolean { + const store = this.#store; + + for (let index = 0, length = store.length; index < length; index++) { + if (!predicate.call(thisArg, store[index]!, index, this)) { + return false; + } + } + + return true; + } + + public find( + predicate: (value: T, index: number, heap: this) => value is S, + thisArg?: unknown + ): S | undefined; + public find( + predicate: (value: T, index: number, heap: this) => boolean, + thisArg?: unknown + ): T | undefined; + public find( + predicate: (value: T, index: number, heap: this) => boolean, + thisArg?: unknown + ): T | undefined { + const store = this.#store; + + for (let index = 0, length = store.length; index < length; index++) { + const value = store[index]!; + + if (predicate.call(thisArg, value, index, this)) { + return value; + } + } + + return undefined; + } + + public reduce( + callback: (accumulator: U, value: T, index: number, heap: this) => U, + initialValue: U + ): U { + const store = this.#store; + let accumulator = initialValue; + + for (let index = 0, length = store.length; index < length; index++) { + accumulator = callback(accumulator, store[index]!, index, this); + } + + return accumulator; + } + + public toJSON(): HeapSerialized { + return { + kind: 'BinaryHeap', + version: 1, + order: this.#order, + items: this.#store.slice(), + }; + } + + #comesBefore(left: T, right: T): boolean { + return compareByOrder(this.#orderFactor, this.#comparator, left, right); + } + + #assertIndex(index: number): void { + if (!Number.isInteger(index) || index < 0 || index >= this.#store.length) { + throw new HeapIndexError(); + } + } + + #heapify(): void { + const store = this.#store; + + for (let index = (store.length >> 1) - 1; index >= 0; index--) { + this.#siftDown(index); + } + } + + #siftUp(index: number): void { + const store = this.#store; + const item = store[index]!; + + while (index > 0) { + const parentIndex = (index - 1) >> 1; + const parent = store[parentIndex]!; + + if (!this.#comesBefore(item, parent)) { + break; + } + + store[index] = parent; + index = parentIndex; + } + + store[index] = item; + } + + #siftDown(index: number): void { + const store = this.#store; + const length = store.length; + const item = store[index]!; + const half = length >> 1; + + while (index < half) { + let bestIndex = (index << 1) + 1; + let bestChild = store[bestIndex]!; + const rightIndex = bestIndex + 1; + + if (rightIndex < length) { + const right = store[rightIndex]!; + + if (this.#comesBefore(right, bestChild)) { + bestIndex = rightIndex; + bestChild = right; + } + } + + if (!this.#comesBefore(bestChild, item)) { + break; + } + + store[index] = bestChild; + index = bestIndex; + } + + store[index] = item; + } +} + +export class BinaryMinHeap implements BinaryHeapOperations, Iterable { + #heap: BinaryHeap; + #comparator: Comparator; + #capacity: number; + + constructor(comparator: Comparator, initialCapacity?: Capacity) { + this.#comparator = comparator; + this.#capacity = initialCapacity === undefined ? 16 : (initialCapacity as number); + + if (!Number.isInteger(this.#capacity) || this.#capacity < 0) { + throw new InvalidCapacityError(this.#capacity); + } + + this.#heap = new BinaryHeap({ + order: 'min', + comparator, + }); + } + + get size(): QueueSize { + return createQueueSize(this.#heap.size); + } + + get isEmpty(): boolean { + return this.#heap.isEmpty(); + } + + get capacity(): Capacity { + return createCapacity(this.#capacity); + } + + insert(item: T): void { + this.#growToFit(this.#heap.size + 1); + this.#heap.push(item); + } + + extract(): T { + if (this.#heap.isEmpty()) { + throw new EmptyQueueError(); + } + + return this.#heap.pop()!; + } + + peek(): T { + if (this.#heap.isEmpty()) { + throw new EmptyQueueError(); + } + + return this.#heap.peek()!; + } + + clear(): void { + this.#heap.clear(); + } + + ensureCapacity(capacity: Capacity): void { + const required = capacity as number; + + if (required <= this.#capacity) { + return; + } + + this.#growToFit(required); + } + + trimExcess(): void { + const targetCapacity = Math.max(1, this.#heap.size); + + if (targetCapacity < this.#capacity) { + this.#heap = BinaryHeap.from(this.#heap.toArray(), { + order: 'min', + comparator: this.#comparator, + }); + this.#capacity = targetCapacity; + } + } + + contains(item: T): boolean { + const items = this.#heap.toArray(); + + for (let index = 0; index < items.length; index++) { + if (items[index] === item) { + return true; + } + } + + return false; + } + + toArray(): T[] { + return this.#heap.toArray(); + } + + values(): IterableIterator { + return this.#heap.values(); + } + + [Symbol.iterator](): Iterator { + return this.#heap[Symbol.iterator](); + } + + #growToFit(requiredCapacity: number): void { + if (requiredCapacity <= this.#capacity) { + return; + } + + const doubled = this.#capacity > 0 ? this.#capacity * 2 : 1; + this.#capacity = Math.max(requiredCapacity, doubled, this.#capacity + 1, 1); + } +} + +export function createBinaryHeap( + options?: BinaryHeapOptions +): BinaryHeap; +export function createBinaryHeap( + options: BinaryHeapOptions & Required, 'comparator'>> +): BinaryHeap; +export function createBinaryHeap( + options?: BinaryHeapOptions +): BinaryHeap { + return new BinaryHeap(options); +} + +export const isBinaryHeap = BinaryHeap.isHeap; diff --git a/web/packages/utility/src/memory/containers/queue/errors.ts b/web/packages/memory/src/containers/queue/errors.ts similarity index 100% rename from web/packages/utility/src/memory/containers/queue/errors.ts rename to web/packages/memory/src/containers/queue/errors.ts diff --git a/web/packages/memory/src/containers/queue/index.ts b/web/packages/memory/src/containers/queue/index.ts new file mode 100644 index 00000000..377998c8 --- /dev/null +++ b/web/packages/memory/src/containers/queue/index.ts @@ -0,0 +1,4 @@ +export * from './errors'; +export * from './queue'; +export * from './priority-queue'; +export * from './utils'; diff --git a/web/packages/memory/src/containers/queue/priority-queue.ts b/web/packages/memory/src/containers/queue/priority-queue.ts new file mode 100644 index 00000000..7ac1af9f --- /dev/null +++ b/web/packages/memory/src/containers/queue/priority-queue.ts @@ -0,0 +1,1287 @@ +import type { Comparator, Equality, HeapOrder } from './binary-heap'; +import { defaultPrimitiveComparator } from './binary-heap'; + +declare const __priorityQueueHandle: unique symbol; + +export type PriorityOrder = HeapOrder; + +export type PrimitivePriority = number | bigint | string | Date; + +export type PrioritySelector = (value: T) => P; + +export type PriorityQueueHandle = number & { + readonly [__priorityQueueHandle]: true; +}; + +export type PriorityQueueEntry = Readonly<{ + value: T; + priority: P; +}>; + +export type PriorityQueueSnapshotEntry = Readonly<{ + value: T; + priority: P; + handle: PriorityQueueHandle; +}>; + +export type PriorityQueueSerialized = Readonly<{ + kind: 'PriorityQueue'; + version: 1; + order: PriorityOrder; + items: readonly PriorityQueueEntry[]; +}>; + +export type QueueLike = Iterable | ArrayLike; + +export interface PriorityQueueOptions { + readonly order?: O; + readonly comparator?: Comparator

    ; + readonly equality?: Equality; + readonly priority?: PrioritySelector; + readonly items?: QueueLike>; +} + +export interface ReadonlyPriorityQueue + extends Iterable { + readonly size: number; + readonly order: O; + readonly comparator: Comparator

    ; + readonly [Symbol.toStringTag]: 'PriorityQueue'; + readonly prioritySelector?: PrioritySelector; + isEmpty(): boolean; + peek(): T | undefined; + peekEntry(): PriorityQueueSnapshotEntry | undefined; + has(handle: PriorityQueueHandle): boolean; + get(handle: PriorityQueueHandle): PriorityQueueSnapshotEntry | undefined; + contains(value: T): boolean; + toArray(): T[]; + toEntries(): PriorityQueueSnapshotEntry[]; + toJSON(): PriorityQueueSerialized; + clone(): PriorityQueue; + values(): IterableIterator; +} + +export class PriorityQueueError extends Error { + public override readonly name: string = 'PriorityQueueError'; + + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class PriorityQueueComparatorError extends PriorityQueueError { + public override readonly name: string = 'PriorityQueueComparatorError'; + + constructor(message = 'A valid comparator function is required for this priority queue.') { + super(message); + } +} + +export class PriorityQueuePriorityError extends PriorityQueueError { + public override readonly name: string = 'PriorityQueuePriorityError'; + + constructor(message = 'A priority selector or explicit priority value is required for this operation.') { + super(message); + } +} + +export class PriorityQueueHandleError extends PriorityQueueError { + public override readonly name: string = 'PriorityQueueHandleError'; + + constructor(message = 'The provided priority queue handle is invalid or does not exist.') { + super(message); + } +} + +export class PriorityQueueSerializationError extends PriorityQueueError { + public override readonly name: string = 'PriorityQueueSerializationError'; + + constructor(message = 'Invalid priority queue serialization payload.') { + super(message); + } +} + +const enum InternalOrder { + Min = 1, + Max = -1, +} + +type Node = { + value: T; + priority: P; + handle: PriorityQueueHandle; + sequence: number; +}; + +const isFunction = (value: unknown): value is (...args: readonly unknown[]) => unknown => + typeof value === 'function'; + +const isObject = (value: unknown): value is Record => + value !== null && typeof value === 'object'; + +const isEntryLike = (value: unknown): value is PriorityQueueEntry => + isObject(value) && 'value' in value && 'priority' in value; + +const defaultEquality = (left: T, right: T): boolean => Object.is(left, right); + +const ensureComparator =

    (comparator: unknown): Comparator

    => { + if (!isFunction(comparator)) { + throw new PriorityQueueComparatorError(); + } + + return comparator as Comparator

    ; +}; + +const normalizeOrder = (order: PriorityOrder | undefined): PriorityOrder => + order === 'min' ? 'min' : 'max'; + +const internalOrderOf = (order: PriorityOrder): InternalOrder => + order === 'max' ? InternalOrder.Max : InternalOrder.Min; + +const toHandle = (value: number): PriorityQueueHandle => value as PriorityQueueHandle; + +const collectEntries = ( + source: QueueLike> | undefined +): PriorityQueueEntry[] => { + if (source === undefined) { + return []; + } + + if (Array.isArray(source)) { + return source.slice(); + } + + const length = (source as ArrayLike>).length; + + if (typeof length === 'number') { + const result = new Array>(length); + + for (let index = 0; index < length; index++) { + result[index] = (source as ArrayLike>)[index]!; + } + + return result; + } + + const result: PriorityQueueEntry[] = []; + + for (const entry of source as Iterable>) { + result.push(entry); + } + + return result; +}; + +const collectValues = (source: QueueLike): T[] => { + if (Array.isArray(source)) { + return source.slice(); + } + + const length = (source as ArrayLike).length; + + if (typeof length === 'number') { + const result = new Array(length); + + for (let index = 0; index < length; index++) { + result[index] = (source as ArrayLike)[index]!; + } + + return result; + } + + const result: T[] = []; + + for (const value of source as Iterable) { + result.push(value); + } + + return result; +}; + +const ensureSerialized = (value: unknown): PriorityQueueSerialized => { + if (!isObject(value)) { + throw new PriorityQueueSerializationError(); + } + + if (value.kind !== 'PriorityQueue' || value.version !== 1) { + throw new PriorityQueueSerializationError(); + } + + if (value.order !== 'min' && value.order !== 'max') { + throw new PriorityQueueSerializationError(); + } + + if (!Array.isArray(value.items)) { + throw new PriorityQueueSerializationError(); + } + + for (let index = 0; index < value.items.length; index++) { + if (!isEntryLike(value.items[index])) { + throw new PriorityQueueSerializationError(); + } + } + + return value as PriorityQueueSerialized; +}; + +const siftUpThreshold = (baseLength: number, incoming: number): number => + Math.ceil(baseLength / Math.log2(baseLength + incoming + 1)); + +class NodeIterator implements IterableIterator { + readonly #store: Node[]; + readonly #select: (node: Node) => R; + #index = 0; + + constructor(store: Node[], select: (node: Node) => R) { + this.#store = store; + this.#select = select; + } + + next(): IteratorResult { + const index = this.#index; + + if (index >= this.#store.length) { + return { value: undefined as unknown as R, done: true }; + } + + this.#index = index + 1; + return { value: this.#select(this.#store[index]!), done: false }; + } + + [Symbol.iterator](): this { + return this; + } +} + +export class PriorityQueue + implements ReadonlyPriorityQueue +{ + readonly #store: Node[]; + readonly #indexByHandle: Map; + readonly #order: O; + readonly #orderFactor: InternalOrder; + readonly #comparator: Comparator

    ; + readonly #equality: Equality; + readonly #prioritySelector: PrioritySelector | undefined; + #nextHandle: number; + #nextSequence: number; + + static #restore(source: PriorityQueue): PriorityQueue { + const queue = new PriorityQueue({ + order: source.#order, + comparator: source.#comparator, + equality: source.#equality, + priority: source.#prioritySelector, + }); + + const length = source.#store.length; + queue.#store.length = length; + + for (let index = 0; index < length; index++) { + const current = source.#store[index]!; + const node: Node = { + value: current.value, + priority: current.priority, + handle: current.handle, + sequence: current.sequence, + }; + + queue.#store[index] = node; + queue.#indexByHandle.set(node.handle, index); + } + + queue.#nextHandle = source.#nextHandle; + queue.#nextSequence = source.#nextSequence; + return queue; + } + + public static max( + options?: Omit, 'order' | 'comparator'> + ): PriorityQueue; + public static max( + options: Omit, 'order'> & + Required, 'comparator'>> + ): PriorityQueue; + public static max(options?: PriorityQueueOptions): PriorityQueue { + const comparator = + options?.comparator ?? (defaultPrimitiveComparator as unknown as Comparator

    ); + + return new PriorityQueue({ ...options, order: 'max', comparator }); + } + + public static min( + options?: Omit, 'order' | 'comparator'> + ): PriorityQueue; + public static min( + options: Omit, 'order'> & + Required, 'comparator'>> + ): PriorityQueue; + public static min(options?: PriorityQueueOptions): PriorityQueue { + const comparator = + options?.comparator ?? (defaultPrimitiveComparator as unknown as Comparator

    ); + + return new PriorityQueue({ ...options, order: 'min', comparator }); + } + + public static fromEntries( + items: QueueLike>, + options?: Omit, 'items' | 'comparator'> + ): PriorityQueue; + public static fromEntries( + items: QueueLike>, + options: Omit, 'items'> & + Required, 'comparator'>> + ): PriorityQueue; + public static fromEntries( + items: QueueLike>, + options?: PriorityQueueOptions + ): PriorityQueue { + const comparator = + options?.comparator ?? (defaultPrimitiveComparator as unknown as Comparator

    ); + + return new PriorityQueue({ + order: normalizeOrder(options?.order) as O, + comparator, + equality: options?.equality, + priority: options?.priority, + items, + }); + } + + public static fromValues( + values: QueueLike, + options: Omit, 'items' | 'comparator'> & + Required, 'priority'>> + ): PriorityQueue; + public static fromValues( + values: QueueLike, + options: Omit, 'items'> & + Required, 'priority' | 'comparator'>> + ): PriorityQueue; + public static fromValues( + values: QueueLike, + options: PriorityQueueOptions & Required, 'priority'>> + ): PriorityQueue { + const comparator = + options.comparator ?? (defaultPrimitiveComparator as unknown as Comparator

    ); + + const queue = new PriorityQueue({ + order: normalizeOrder(options.order) as O, + comparator, + equality: options.equality, + priority: options.priority, + }); + + queue.enqueueValues(values); + return queue; + } + + public static deserialize( + payload: unknown, + options?: Omit, 'items' | 'order' | 'comparator'> + ): PriorityQueue; + public static deserialize( + payload: unknown, + options: Omit, 'items' | 'order'> & + Required, 'comparator'>> + ): PriorityQueue; + public static deserialize( + payload: unknown, + options?: PriorityQueueOptions + ): PriorityQueue { + const serialized = ensureSerialized(payload); + const comparator = + options?.comparator ?? (defaultPrimitiveComparator as unknown as Comparator

    ); + + return new PriorityQueue({ + order: serialized.order as O, + comparator, + equality: options?.equality, + priority: options?.priority, + items: serialized.items, + }); + } + + public static isPriorityQueue(value: unknown): value is ReadonlyPriorityQueue { + return ( + isObject(value) && + value[Symbol.toStringTag] === 'PriorityQueue' && + isFunction((value as { peek?: unknown }).peek) + ); + } + + public constructor(options?: PriorityQueueOptions) { + const order = normalizeOrder(options?.order) as O; + + this.#order = order; + this.#orderFactor = internalOrderOf(order); + this.#comparator = ensureComparator

    ( + options?.comparator ?? (defaultPrimitiveComparator as unknown as Comparator

    ) + ); + this.#equality = options?.equality ?? defaultEquality; + this.#prioritySelector = options?.priority; + this.#store = []; + this.#indexByHandle = new Map(); + this.#nextHandle = 1; + this.#nextSequence = 1; + + const items = collectEntries(options?.items); + + if (items.length > 0) { + this.#store.length = items.length; + + for (let index = 0; index < items.length; index++) { + const entry = items[index]!; + const handle = this.#allocateHandle(); + const node: Node = { + value: entry.value, + priority: entry.priority, + handle, + sequence: this.#allocateSequence(), + }; + + this.#store[index] = node; + this.#indexByHandle.set(handle, index); + } + + if (items.length > 1) { + this.#heapify(); + } + } + } + + public get [Symbol.toStringTag](): 'PriorityQueue' { + return 'PriorityQueue'; + } + + public get size(): number { + return this.#store.length; + } + + public get order(): O { + return this.#order; + } + + public get comparator(): Comparator

    { + return this.#comparator; + } + + public get prioritySelector(): PrioritySelector | undefined { + return this.#prioritySelector; + } + + public isEmpty(): boolean { + return this.#store.length === 0; + } + + public clear(): this { + this.#store.length = 0; + this.#indexByHandle.clear(); + return this; + } + + public clone(): PriorityQueue { + return PriorityQueue.#restore(this); + } + + public peek(): T | undefined { + return this.#store[0]?.value; + } + + public peekPriority(): P | undefined { + return this.#store[0]?.priority; + } + + public peekHandle(): PriorityQueueHandle | undefined { + return this.#store[0]?.handle; + } + + public peekEntry(): PriorityQueueSnapshotEntry | undefined { + const node = this.#store[0]; + + return node === undefined ? undefined : this.#snapshot(node); + } + + public has(handle: PriorityQueueHandle): boolean { + return this.#indexByHandle.has(handle); + } + + public get(handle: PriorityQueueHandle): PriorityQueueSnapshotEntry | undefined { + const index = this.#indexByHandle.get(handle); + + if (index === undefined) { + return undefined; + } + + const node = this.#store[index]; + return node === undefined ? undefined : this.#snapshot(node); + } + + public assertHas(handle: PriorityQueueHandle): PriorityQueueSnapshotEntry { + const entry = this.get(handle); + + if (entry === undefined) { + throw new PriorityQueueHandleError(); + } + + return entry; + } + + public contains(value: T): boolean { + const store = this.#store; + const equality = this.#equality; + + for (let index = 0; index < store.length; index++) { + if (equality(store[index]!.value, value)) { + return true; + } + } + + return false; + } + + public enqueue(value: T, priority: P): PriorityQueueHandle; + public enqueue(value: T): PriorityQueueHandle; + public enqueue(value: T, priority?: P): PriorityQueueHandle { + return this.#insert( + value, + priority !== undefined ? priority : this.#resolveFromSelector(value) + ); + } + + public offer(value: T, priority: P): PriorityQueueHandle; + public offer(value: T): PriorityQueueHandle; + public offer(value: T, priority?: P): PriorityQueueHandle { + return priority !== undefined ? this.enqueue(value, priority) : this.enqueue(value); + } + + public enqueueEntry(entry: PriorityQueueEntry): PriorityQueueHandle { + return this.#insert(entry.value, entry.priority); + } + + public enqueueEntries(entries: QueueLike>): this { + const items = collectEntries(entries); + const incoming = items.length; + + if (incoming === 0) { + return this; + } + + const store = this.#store; + const baseSize = store.length; + + if (baseSize === 0) { + store.length = incoming; + + for (let index = 0; index < incoming; index++) { + const item = items[index]!; + const handle = this.#allocateHandle(); + const node: Node = { + value: item.value, + priority: item.priority, + handle, + sequence: this.#allocateSequence(), + }; + + store[index] = node; + this.#indexByHandle.set(handle, index); + } + + if (incoming > 1) { + this.#heapify(); + } + + return this; + } + + const threshold = siftUpThreshold(baseSize, incoming); + + if (incoming <= threshold) { + for (let index = 0; index < incoming; index++) { + const item = items[index]!; + this.#insert(item.value, item.priority); + } + } else { + const offset = store.length; + store.length = offset + incoming; + + for (let index = 0; index < incoming; index++) { + const item = items[index]!; + const handle = this.#allocateHandle(); + const node: Node = { + value: item.value, + priority: item.priority, + handle, + sequence: this.#allocateSequence(), + }; + + const targetIndex = offset + index; + store[targetIndex] = node; + this.#indexByHandle.set(handle, targetIndex); + } + + this.#heapify(); + } + + return this; + } + + public enqueueRange(items: QueueLike>): this { + return this.enqueueEntries(items); + } + + public enqueueValues(values: QueueLike): this { + const items = collectValues(values); + const incoming = items.length; + + if (incoming === 0) { + return this; + } + + const store = this.#store; + const baseSize = store.length; + const threshold = baseSize === 0 ? 0 : siftUpThreshold(baseSize, incoming); + + if (baseSize === 0 && incoming > 1) { + const selector = this.#prioritySelector; + + if (selector === undefined) { + throw new PriorityQueuePriorityError(); + } + + store.length = incoming; + + for (let index = 0; index < incoming; index++) { + const value = items[index]!; + const handle = this.#allocateHandle(); + const node: Node = { + value, + priority: selector(value), + handle, + sequence: this.#allocateSequence(), + }; + + store[index] = node; + this.#indexByHandle.set(handle, index); + } + + this.#heapify(); + return this; + } + + if (incoming > threshold && baseSize > 0) { + const selector = this.#prioritySelector; + + if (selector === undefined) { + throw new PriorityQueuePriorityError(); + } + + const offset = store.length; + store.length = offset + incoming; + + for (let index = 0; index < incoming; index++) { + const value = items[index]!; + const handle = this.#allocateHandle(); + const node: Node = { + value, + priority: selector(value), + handle, + sequence: this.#allocateSequence(), + }; + + const targetIndex = offset + index; + store[targetIndex] = node; + this.#indexByHandle.set(handle, targetIndex); + } + + this.#heapify(); + return this; + } + + for (let index = 0; index < incoming; index++) { + this.#insert(items[index]!, this.#resolveFromSelector(items[index]!)); + } + + return this; + } + + public dequeue(): T | undefined { + return this.#extractRoot()?.value; + } + + public tryDequeue(): T | undefined { + return this.dequeue(); + } + + public poll(): T | undefined { + return this.dequeue(); + } + + public dequeueEntry(): PriorityQueueSnapshotEntry | undefined { + const node = this.#extractRoot(); + return node === undefined ? undefined : this.#snapshot(node); + } + + public tryPeek(): T | undefined { + return this.peek(); + } + + public replaceHead(value: T, priority: P): PriorityQueueSnapshotEntry | undefined; + public replaceHead(value: T): PriorityQueueSnapshotEntry | undefined; + public replaceHead(value: T, priority?: P): PriorityQueueSnapshotEntry | undefined { + const resolvedPriority = priority !== undefined ? priority : this.#resolveFromSelector(value); + const store = this.#store; + + if (store.length === 0) { + this.#insert(value, resolvedPriority); + return undefined; + } + + const removed = store[0]!; + this.#indexByHandle.delete(removed.handle); + + const handle = this.#allocateHandle(); + const node: Node = { + value, + priority: resolvedPriority, + handle, + sequence: this.#allocateSequence(), + }; + + store[0] = node; + this.#indexByHandle.set(handle, 0); + this.#siftDown(0); + + return this.#snapshot(removed); + } + + public remove(handle: PriorityQueueHandle): PriorityQueueSnapshotEntry | undefined { + const index = this.#indexByHandle.get(handle); + + if (index === undefined) { + return undefined; + } + + return this.#snapshot(this.#removeAt(index)); + } + + public delete(value: T): boolean { + const store = this.#store; + const equality = this.#equality; + + for (let index = 0; index < store.length; index++) { + if (equality(store[index]!.value, value)) { + this.#removeAt(index); + return true; + } + } + + return false; + } + + public updatePriority(handle: PriorityQueueHandle, priority: P): boolean { + const index = this.#indexByHandle.get(handle); + + if (index === undefined) { + return false; + } + + return this.#updatePriorityAt(index, priority); + } + + public updateValue(handle: PriorityQueueHandle, value: T): boolean { + const index = this.#indexByHandle.get(handle); + + if (index === undefined) { + return false; + } + + this.#store[index]!.value = value; + return true; + } + + public update(handle: PriorityQueueHandle, value: T, priority: P): boolean; + public update(handle: PriorityQueueHandle, value: T): boolean; + public update(handle: PriorityQueueHandle, value: T, priority?: P): boolean { + const index = this.#indexByHandle.get(handle); + + if (index === undefined) { + return false; + } + + const node = this.#store[index]!; + node.value = value; + + if (priority !== undefined) { + return this.#updatePriorityAt(index, priority); + } + + const selector = this.#prioritySelector; + + if (selector !== undefined) { + return this.#updatePriorityAt(index, selector(value)); + } + + return true; + } + + public drain(): T[] { + const length = this.#store.length; + + if (length === 0) { + return []; + } + + const result = new Array(length); + + for (let index = 0; index < length; index++) { + result[index] = this.dequeue()!; + } + + return result; + } + + public dequeueAll(): T[] { + return this.drain(); + } + + public drainEntries(): PriorityQueueSnapshotEntry[] { + const length = this.#store.length; + + if (length === 0) { + return []; + } + + const result = new Array>(length); + + for (let index = 0; index < length; index++) { + result[index] = this.dequeueEntry()!; + } + + return result; + } + + public toArray(): T[] { + const store = this.#store; + const result = new Array(store.length); + + for (let index = 0; index < store.length; index++) { + result[index] = store[index]!.value; + } + + return result; + } + + public toEntries(): PriorityQueueSnapshotEntry[] { + const store = this.#store; + const result = new Array>(store.length); + + for (let index = 0; index < store.length; index++) { + result[index] = this.#snapshot(store[index]!); + } + + return result; + } + + public toSortedArray(): T[] { + const clone = PriorityQueue.#restore(this); + const length = clone.size; + const result = new Array(length); + + for (let index = 0; index < length; index++) { + result[index] = clone.dequeue()!; + } + + return result; + } + + public toSortedEntries(): PriorityQueueSnapshotEntry[] { + const clone = PriorityQueue.#restore(this); + const length = clone.size; + const result = new Array>(length); + + for (let index = 0; index < length; index++) { + result[index] = clone.dequeueEntry()!; + } + + return result; + } + + public toJSON(): PriorityQueueSerialized { + const store = this.#store; + const items = new Array>(store.length); + + for (let index = 0; index < store.length; index++) { + const node = store[index]!; + items[index] = { value: node.value, priority: node.priority }; + } + + return { kind: 'PriorityQueue', version: 1, order: this.#order, items }; + } + + public values(): IterableIterator { + return new NodeIterator(this.#store, (node) => node.value); + } + + public priorities(): IterableIterator

    { + return new NodeIterator(this.#store, (node) => node.priority); + } + + public handles(): IterableIterator { + return new NodeIterator(this.#store, (node) => node.handle); + } + + public entries(): IterableIterator<[PriorityQueueHandle, T]> { + return new NodeIterator(this.#store, (node) => [node.handle, node.value]); + } + + public [Symbol.iterator](): IterableIterator { + return this.values(); + } + + public forEach( + callback: (value: T, handle: PriorityQueueHandle, queue: this) => void, + thisArg?: unknown + ): void { + const store = this.#store; + + for (let index = 0; index < store.length; index++) { + const node = store[index]!; + callback.call(thisArg, node.value, node.handle, this); + } + } + + public map( + callback: (value: T, handle: PriorityQueueHandle, queue: this) => U, + thisArg?: unknown + ): U[] { + const store = this.#store; + const result = new Array(store.length); + + for (let index = 0; index < store.length; index++) { + const node = store[index]!; + result[index] = callback.call(thisArg, node.value, node.handle, this); + } + + return result; + } + + public filter( + predicate: (value: T, handle: PriorityQueueHandle, queue: this) => value is S, + thisArg?: unknown + ): S[]; + public filter( + predicate: (value: T, handle: PriorityQueueHandle, queue: this) => boolean, + thisArg?: unknown + ): T[]; + public filter( + predicate: (value: T, handle: PriorityQueueHandle, queue: this) => boolean, + thisArg?: unknown + ): T[] { + const store = this.#store; + const result: T[] = []; + + for (let index = 0; index < store.length; index++) { + const node = store[index]!; + + if (predicate.call(thisArg, node.value, node.handle, this)) { + result.push(node.value); + } + } + + return result; + } + + public find( + predicate: (value: T, handle: PriorityQueueHandle, queue: this) => value is S, + thisArg?: unknown + ): S | undefined; + public find( + predicate: (value: T, handle: PriorityQueueHandle, queue: this) => boolean, + thisArg?: unknown + ): T | undefined; + public find( + predicate: (value: T, handle: PriorityQueueHandle, queue: this) => boolean, + thisArg?: unknown + ): T | undefined { + const store = this.#store; + + for (let index = 0; index < store.length; index++) { + const node = store[index]!; + + if (predicate.call(thisArg, node.value, node.handle, this)) { + return node.value; + } + } + + return undefined; + } + + public some( + predicate: (value: T, handle: PriorityQueueHandle, queue: this) => boolean, + thisArg?: unknown + ): boolean { + const store = this.#store; + + for (let index = 0; index < store.length; index++) { + const node = store[index]!; + + if (predicate.call(thisArg, node.value, node.handle, this)) { + return true; + } + } + + return false; + } + + public every( + predicate: (value: T, handle: PriorityQueueHandle, queue: this) => boolean, + thisArg?: unknown + ): boolean { + const store = this.#store; + + for (let index = 0; index < store.length; index++) { + const node = store[index]!; + + if (!predicate.call(thisArg, node.value, node.handle, this)) { + return false; + } + } + + return true; + } + + public reduce( + callback: (accumulator: U, value: T, handle: PriorityQueueHandle, queue: this) => U, + initialValue: U + ): U { + const store = this.#store; + let accumulator = initialValue; + + for (let index = 0; index < store.length; index++) { + const node = store[index]!; + accumulator = callback(accumulator, node.value, node.handle, this); + } + + return accumulator; + } + + public tryDequeueEntry(): PriorityQueueSnapshotEntry | undefined { + return this.dequeueEntry(); + } + + public tryPeekEntry(): PriorityQueueSnapshotEntry | undefined { + return this.peekEntry(); + } + + #allocateHandle(): PriorityQueueHandle { + if (this.#nextHandle >= Number.MAX_SAFE_INTEGER) { + this.#nextHandle = 1; + } + + return toHandle(this.#nextHandle++); + } + + #allocateSequence(): number { + if (this.#nextSequence >= Number.MAX_SAFE_INTEGER) { + this.#nextSequence = 1; + } + + return this.#nextSequence++; + } + + #resolveFromSelector(value: T): P { + const selector = this.#prioritySelector; + + if (selector === undefined) { + throw new PriorityQueuePriorityError(); + } + + return selector(value); + } + + #snapshot(node: Node): PriorityQueueSnapshotEntry { + return { value: node.value, priority: node.priority, handle: node.handle }; + } + + #comesBefore(left: Node, right: Node): boolean { + const cmp = this.#comparator(left.priority, right.priority); + + if (cmp !== 0) { + return cmp * this.#orderFactor < 0; + } + + return left.sequence < right.sequence; + } + + #updatePriorityAt(index: number, priority: P): boolean { + const node = this.#store[index]!; + const cmp = this.#comparator(priority, node.priority); + + node.priority = priority; + + if (cmp === 0) { + return true; + } + + if (cmp * this.#orderFactor < 0) { + this.#siftUp(index); + } else { + this.#siftDown(index); + } + + return true; + } + + #insert(value: T, priority: P): PriorityQueueHandle { + const handle = this.#allocateHandle(); + const node: Node = { + value, + priority, + handle, + sequence: this.#allocateSequence(), + }; + + const index = this.#store.length; + this.#store.push(node); + this.#indexByHandle.set(handle, index); + + if (index > 0) { + this.#siftUp(index); + } + + return handle; + } + + #extractRoot(): Node | undefined { + const store = this.#store; + const length = store.length; + + if (length === 0) { + return undefined; + } + + if (length === 1) { + const node = store.pop()!; + this.#indexByHandle.delete(node.handle); + return node; + } + + const root = store[0]!; + const tail = store.pop()!; + + this.#indexByHandle.delete(root.handle); + store[0] = tail; + this.#indexByHandle.set(tail.handle, 0); + this.#siftDown(0); + + return root; + } + + #removeAt(index: number): Node { + const store = this.#store; + const removed = store[index]!; + + this.#indexByHandle.delete(removed.handle); + + if (store.length === 1) { + store.pop(); + return removed; + } + + const tail = store.pop()!; + + if (index < store.length) { + store[index] = tail; + this.#indexByHandle.set(tail.handle, index); + + const parent = (index - 1) >> 1; + + if (index > 0 && this.#comesBefore(tail, store[parent]!)) { + this.#siftUp(index); + } else { + this.#siftDown(index); + } + } + + return removed; + } + + #heapify(): void { + for (let index = (this.#store.length >> 1) - 1; index >= 0; index--) { + this.#siftDown(index); + } + } + + #siftUp(index: number): void { + const store = this.#store; + const item = store[index]!; + + while (index > 0) { + const parentIndex = (index - 1) >> 1; + const parent = store[parentIndex]!; + + if (!this.#comesBefore(item, parent)) { + break; + } + + store[index] = parent; + this.#indexByHandle.set(parent.handle, index); + index = parentIndex; + } + + store[index] = item; + this.#indexByHandle.set(item.handle, index); + } + + #siftDown(index: number): void { + const store = this.#store; + const length = store.length; + const item = store[index]!; + const half = length >> 1; + + while (index < half) { + let bestIndex = (index << 1) + 1; + let bestChild = store[bestIndex]!; + const rightIndex = bestIndex + 1; + + if (rightIndex < length) { + const right = store[rightIndex]!; + + if (this.#comesBefore(right, bestChild)) { + bestIndex = rightIndex; + bestChild = right; + } + } + + if (!this.#comesBefore(bestChild, item)) { + break; + } + + store[index] = bestChild; + this.#indexByHandle.set(bestChild.handle, index); + index = bestIndex; + } + + store[index] = item; + this.#indexByHandle.set(item.handle, index); + } +} + +export function createPriorityQueue( + options?: Omit, 'comparator'> +): PriorityQueue; +export function createPriorityQueue( + options: PriorityQueueOptions & Required, 'comparator'>> +): PriorityQueue; +export function createPriorityQueue( + options?: PriorityQueueOptions +): PriorityQueue { + return new PriorityQueue(options); +} + +export const isPriorityQueue = PriorityQueue.isPriorityQueue; + +export type { Comparator, Equality } from './binary-heap'; +export type { HeapIndex, QueueSize, Capacity } from './types'; \ No newline at end of file diff --git a/web/packages/memory/src/containers/queue/queue.ts b/web/packages/memory/src/containers/queue/queue.ts new file mode 100644 index 00000000..258cc827 --- /dev/null +++ b/web/packages/memory/src/containers/queue/queue.ts @@ -0,0 +1,268 @@ +import { EmptyQueueError, InvalidCapacityError } from './errors'; +import { Capacity, QueueSize } from './types'; +import { createCapacity, createQueueSize } from './utils'; + +export interface QueueOptions { + readonly initialCapacity?: Capacity; + readonly autoTrim?: boolean; + readonly growthFactor?: number; + readonly shrinkThreshold?: number; +} + +const DEFAULT_INITIAL_CAPACITY = 16; +const DEFAULT_GROWTH_FACTOR = 2; +const DEFAULT_SHRINK_THRESHOLD = 0.25; + +function normalizeInitialCapacity(value?: Capacity): number { + const resolved = value === undefined ? DEFAULT_INITIAL_CAPACITY : (value as number); + + if (!Number.isInteger(resolved) || resolved <= 0) { + throw new InvalidCapacityError(resolved); + } + + return resolved; +} + +function normalizeGrowthFactor(value?: number): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 1) { + return DEFAULT_GROWTH_FACTOR; + } + + return value; +} + +function normalizeShrinkThreshold(value?: number): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value >= 1) { + return DEFAULT_SHRINK_THRESHOLD; + } + + return value; +} + +export class Queue implements Iterable { + private storage: Array; + private readonly autoTrimEnabled: boolean; + private readonly growthFactor: number; + private readonly shrinkThreshold: number; + private readonly minimumCapacity: number; + + private _head = 0; + private _size = 0; + private _capacity: number; + + constructor(options: QueueOptions = {}) { + this.minimumCapacity = normalizeInitialCapacity(options.initialCapacity); + this.autoTrimEnabled = options.autoTrim ?? false; + this.growthFactor = normalizeGrowthFactor(options.growthFactor); + this.shrinkThreshold = normalizeShrinkThreshold(options.shrinkThreshold); + this._capacity = this.minimumCapacity; + this.storage = new Array(this._capacity); + } + + get size(): QueueSize { + return createQueueSize(this._size); + } + + get isEmpty(): boolean { + return this._size === 0; + } + + get capacity(): Capacity { + return createCapacity(this._capacity); + } + + enqueue(value: T): void { + this.ensureInternalCapacity(this._size + 1); + this.storage[(this._head + this._size) % this._capacity] = value; + this._size += 1; + } + + enqueueRange(values: ReadonlyArray | Iterable): void { + if (Array.isArray(values)) { + if (values.length === 0) { + return; + } + + this.ensureInternalCapacity(this._size + values.length); + + for (let index = 0; index < values.length; index++) { + this.storage[(this._head + this._size) % this._capacity] = values[index]; + this._size += 1; + } + + return; + } + + for (const value of values) { + this.enqueue(value); + } + } + + dequeue(): T { + const value = this.tryDequeue(); + + if (value === undefined) { + throw new EmptyQueueError(); + } + + return value; + } + + tryDequeue(): T | undefined { + if (this._size === 0) { + return undefined; + } + + const index = this._head; + const value = this.storage[index]; + this.storage[index] = undefined; + this._size -= 1; + + if (this._size === 0) { + this._head = 0; + } else { + this._head = (index + 1) % this._capacity; + } + + if (this.autoTrimEnabled) { + this.trimIfNeeded(); + } + + return value; + } + + peek(): T { + const value = this.tryPeek(); + + if (value === undefined) { + throw new EmptyQueueError(); + } + + return value; + } + + tryPeek(): T | undefined { + return this._size === 0 ? undefined : this.storage[this._head]; + } + + contains(value: T): boolean { + for (let index = 0; index < this._size; index++) { + if (this.storage[(this._head + index) % this._capacity] === value) { + return true; + } + } + + return false; + } + + clear(): void { + if (this._size === 0) { + return; + } + + for (let index = 0; index < this._size; index++) { + this.storage[(this._head + index) % this._capacity] = undefined; + } + + this._head = 0; + this._size = 0; + + if (this.autoTrimEnabled && this._capacity !== this.minimumCapacity) { + this.resize(this.minimumCapacity); + } + } + + ensureCapacity(capacity: Capacity): void { + const required = capacity as number; + + if (!Number.isInteger(required) || required <= 0) { + throw new InvalidCapacityError(required); + } + + this.ensureInternalCapacity(required); + } + + trimExcess(): void { + const targetCapacity = Math.max(this.minimumCapacity, this._size || 1); + if (targetCapacity < this._capacity) { + this.resize(targetCapacity); + } + } + + toArray(): ReadonlyArray { + const result = new Array(this._size); + + for (let index = 0; index < this._size; index++) { + result[index] = this.storage[(this._head + index) % this._capacity]!; + } + + return Object.freeze(result); + } + + clone(): Queue { + const clone = new Queue({ + initialCapacity: createCapacity(Math.max(this.minimumCapacity, this._size || 1)), + autoTrim: this.autoTrimEnabled, + growthFactor: this.growthFactor, + shrinkThreshold: this.shrinkThreshold, + }); + + clone.enqueueRange(this); + return clone; + } + + *[Symbol.iterator](): Iterator { + for (let index = 0; index < this._size; index++) { + yield this.storage[(this._head + index) % this._capacity]!; + } + } + + private ensureInternalCapacity(requiredCapacity: number): void { + if (requiredCapacity <= this._capacity) { + return; + } + + let nextCapacity = this._capacity; + + while (nextCapacity < requiredCapacity) { + nextCapacity = Math.max(nextCapacity + 1, Math.ceil(nextCapacity * this.growthFactor)); + } + + this.resize(nextCapacity); + } + + private trimIfNeeded(): void { + if (this._capacity <= this.minimumCapacity || this._size === 0) { + if (this._size === 0 && this._capacity > this.minimumCapacity) { + this.resize(this.minimumCapacity); + } + + return; + } + + if (this._size > Math.floor(this._capacity * this.shrinkThreshold)) { + return; + } + + const targetCapacity = Math.max(this.minimumCapacity, Math.max(this._size << 1, 1)); + if (targetCapacity < this._capacity) { + this.resize(targetCapacity); + } + } + + private resize(nextCapacity: number): void { + if (!Number.isInteger(nextCapacity) || nextCapacity <= 0) { + throw new InvalidCapacityError(nextCapacity); + } + + const nextStorage = new Array(nextCapacity); + + for (let index = 0; index < this._size; index++) { + nextStorage[index] = this.storage[(this._head + index) % this._capacity]; + } + + this.storage = nextStorage; + this._capacity = nextCapacity; + this._head = 0; + } +} \ No newline at end of file diff --git a/web/packages/memory/src/containers/queue/types.ts b/web/packages/memory/src/containers/queue/types.ts new file mode 100644 index 00000000..8920ef36 --- /dev/null +++ b/web/packages/memory/src/containers/queue/types.ts @@ -0,0 +1,20 @@ +declare const __nominal: unique symbol; + +export type Nominal = T & { readonly [__nominal]: K }; + +export type Comparator = (a: T, b: T) => number; + +export type HeapIndex = Nominal; +export type QueueSize = Nominal; +export type Capacity = Nominal; + +export interface BinaryHeapOperations { + insert(item: T): void; + extract(): T; + + peek(): T; + readonly size: QueueSize; + readonly isEmpty: boolean; + + clear(): void; +} diff --git a/web/packages/utility/src/memory/containers/queue/utils.ts b/web/packages/memory/src/containers/queue/utils.ts similarity index 100% rename from web/packages/utility/src/memory/containers/queue/utils.ts rename to web/packages/memory/src/containers/queue/utils.ts diff --git a/web/packages/utility/src/memory/containers/stack/abstract-stack.ts b/web/packages/memory/src/containers/stack/abstract-stack.ts similarity index 99% rename from web/packages/utility/src/memory/containers/stack/abstract-stack.ts rename to web/packages/memory/src/containers/stack/abstract-stack.ts index 42b8f846..e25e653e 100644 --- a/web/packages/utility/src/memory/containers/stack/abstract-stack.ts +++ b/web/packages/memory/src/containers/stack/abstract-stack.ts @@ -1,7 +1,7 @@ import { StackIntegrityError } from './errors'; import { StackMemoryPool as MemoryPool } from './pool-adapter'; import { StackIterator } from './stack-iterator'; -import { createStackSize, __variance, createStackCapacity } from './stack'; +import { createStackSize, __variance, createStackCapacity } from './stack-core'; import { ReadonlyStackInterface, StackConfiguration } from './interfaces'; import { StackSize, StackNode, StackCapacity, StackResult, NodeId } from './types'; diff --git a/web/packages/utility/src/memory/containers/stack/errors.ts b/web/packages/memory/src/containers/stack/errors.ts similarity index 100% rename from web/packages/utility/src/memory/containers/stack/errors.ts rename to web/packages/memory/src/containers/stack/errors.ts diff --git a/web/packages/memory/src/containers/stack/index.ts b/web/packages/memory/src/containers/stack/index.ts new file mode 100644 index 00000000..27626cb6 --- /dev/null +++ b/web/packages/memory/src/containers/stack/index.ts @@ -0,0 +1,35 @@ +export { + StackCapacityError, + StackIntegrityError, + StackMemoryError, + OptimizedArrayStack, + ImmutableStack, + createStackCapacity, + createStackSize, + createNodeId, +} from './stack'; + +export type { + StackCapacity, + StackSize, + NodeId, + MemoryAddress, + AllocatorId, + PoolIndex, + StackNode, + AlignedStackNode, + StackResult, + ExtractSuccess, + ExtractError, + StackConfiguration, + StackErrorUnion, + ReadonlyStackInterface, + MutableStackInterface, + ImmutableStackInterface, + NonEmptyArray, + EmptyArray, + ArrayWithLength, +} from './stack'; + +export { StackMemoryPool } from './pool-adapter'; +export { StackIterator } from './stack-iterator'; diff --git a/web/packages/utility/src/memory/containers/stack/interfaces.ts b/web/packages/memory/src/containers/stack/interfaces.ts similarity index 100% rename from web/packages/utility/src/memory/containers/stack/interfaces.ts rename to web/packages/memory/src/containers/stack/interfaces.ts diff --git a/web/packages/utility/src/memory/containers/stack/pool-adapter.ts b/web/packages/memory/src/containers/stack/pool-adapter.ts similarity index 100% rename from web/packages/utility/src/memory/containers/stack/pool-adapter.ts rename to web/packages/memory/src/containers/stack/pool-adapter.ts diff --git a/web/packages/memory/src/containers/stack/stack-core.ts b/web/packages/memory/src/containers/stack/stack-core.ts new file mode 100644 index 00000000..c3ec94f1 --- /dev/null +++ b/web/packages/memory/src/containers/stack/stack-core.ts @@ -0,0 +1,28 @@ +import { StackIntegrityError } from './errors'; +import type { NodeId, StackCapacity, StackSize } from './types'; + +export const __variance: unique symbol = Symbol('stack.variance') as typeof __variance; + +const CAPACITY_MASK = 0x7fffffff; +const SIZE_MASK = 0x7fffffff; +const NODE_ID_MASK = 0xffffffff; + +export const createStackCapacity = (value: number): StackCapacity => { + const masked = value & CAPACITY_MASK; + if (masked !== value || value <= 0) { + throw new StackIntegrityError('Invalid capacity value', { value, masked }); + } + return masked as StackCapacity; +}; + +export const createStackSize = (value: number): StackSize => { + const masked = value & SIZE_MASK; + if (masked !== value || value < 0) { + throw new StackIntegrityError('Invalid size value', { value, masked }); + } + return masked as StackSize; +}; + +export const createNodeId = (): NodeId => { + return ((Math.random() * NODE_ID_MASK) | 0) as NodeId; +}; diff --git a/web/packages/utility/src/memory/containers/stack/stack-iterator.ts b/web/packages/memory/src/containers/stack/stack-iterator.ts similarity index 100% rename from web/packages/utility/src/memory/containers/stack/stack-iterator.ts rename to web/packages/memory/src/containers/stack/stack-iterator.ts diff --git a/web/packages/utility/src/memory/containers/stack/stack.ts b/web/packages/memory/src/containers/stack/stack.ts similarity index 92% rename from web/packages/utility/src/memory/containers/stack/stack.ts rename to web/packages/memory/src/containers/stack/stack.ts index 7c76f460..63f7e2d7 100644 --- a/web/packages/utility/src/memory/containers/stack/stack.ts +++ b/web/packages/memory/src/containers/stack/stack.ts @@ -17,8 +17,7 @@ import { ExtractSuccess, ExtractError, } from './types'; - -export declare const __variance: unique symbol; +import { __variance, createNodeId, createStackCapacity, createStackSize } from './stack-core'; import { StackCapacityError, @@ -35,30 +34,6 @@ import { ReadonlyStackInterface, } from './interfaces'; -const CAPACITY_MASK = 0x7fffffff; -const SIZE_MASK = 0x7fffffff; -const NODE_ID_MASK = 0xffffffff; - -const createStackCapacity = (value: number): StackCapacity => { - const masked = value & CAPACITY_MASK; - if (masked !== value || value <= 0) { - throw new StackIntegrityError('Invalid capacity value', { value, masked }); - } - return masked as StackCapacity; -}; - -const createStackSize = (value: number): StackSize => { - const masked = value & SIZE_MASK; - if (masked !== value || value < 0) { - throw new StackIntegrityError('Invalid size value', { value, masked }); - } - return masked as StackSize; -}; - -const createNodeId = (): NodeId => { - return ((Math.random() * NODE_ID_MASK) | 0) as NodeId; -}; - class OptimizedArrayStack extends AbstractStack implements MutableStackInterface { private _disposed = false; readonly [__variance] = undefined as any; diff --git a/web/packages/utility/src/memory/containers/stack/types.ts b/web/packages/memory/src/containers/stack/types.ts similarity index 100% rename from web/packages/utility/src/memory/containers/stack/types.ts rename to web/packages/memory/src/containers/stack/types.ts diff --git a/web/packages/memory/src/index.ts b/web/packages/memory/src/index.ts new file mode 100644 index 00000000..00e035b6 --- /dev/null +++ b/web/packages/memory/src/index.ts @@ -0,0 +1,6 @@ +export * from './pool'; +export * from './buffering'; +export * from './containers/queue'; +export * from './containers/queue/binary-heap'; +export * from './containers/stack'; +export * from './lazy'; diff --git a/web/packages/utility/src/memory/lazy/index.ts b/web/packages/memory/src/lazy/index.ts similarity index 100% rename from web/packages/utility/src/memory/lazy/index.ts rename to web/packages/memory/src/lazy/index.ts diff --git a/web/packages/utility/src/memory/lazy/lazy-core.ts b/web/packages/memory/src/lazy/lazy-core.ts similarity index 100% rename from web/packages/utility/src/memory/lazy/lazy-core.ts rename to web/packages/memory/src/lazy/lazy-core.ts diff --git a/web/packages/utility/src/memory/lazy/lazy-factory.ts b/web/packages/memory/src/lazy/lazy-factory.ts similarity index 100% rename from web/packages/utility/src/memory/lazy/lazy-factory.ts rename to web/packages/memory/src/lazy/lazy-factory.ts diff --git a/web/packages/utility/src/memory/lazy/lazy-impl.ts b/web/packages/memory/src/lazy/lazy-impl.ts similarity index 100% rename from web/packages/utility/src/memory/lazy/lazy-impl.ts rename to web/packages/memory/src/lazy/lazy-impl.ts diff --git a/web/packages/utility/src/memory/lazy/lazy-utils.ts b/web/packages/memory/src/lazy/lazy-utils.ts similarity index 100% rename from web/packages/utility/src/memory/lazy/lazy-utils.ts rename to web/packages/memory/src/lazy/lazy-utils.ts diff --git a/web/packages/utility/src/memory/pool/index.ts b/web/packages/memory/src/pool/index.ts similarity index 100% rename from web/packages/utility/src/memory/pool/index.ts rename to web/packages/memory/src/pool/index.ts diff --git a/web/packages/utility/src/memory/pool/mempool.ts b/web/packages/memory/src/pool/mempool.ts similarity index 100% rename from web/packages/utility/src/memory/pool/mempool.ts rename to web/packages/memory/src/pool/mempool.ts diff --git a/web/packages/utility/src/memory/pool/object-pool.ts b/web/packages/memory/src/pool/object-pool.ts similarity index 100% rename from web/packages/utility/src/memory/pool/object-pool.ts rename to web/packages/memory/src/pool/object-pool.ts diff --git a/web/packages/utility/src/memory/pool/pool-support.ts b/web/packages/memory/src/pool/pool-support.ts similarity index 100% rename from web/packages/utility/src/memory/pool/pool-support.ts rename to web/packages/memory/src/pool/pool-support.ts diff --git a/web/packages/utility/src/memory/pool/typed-array-pool.ts b/web/packages/memory/src/pool/typed-array-pool.ts similarity index 100% rename from web/packages/utility/src/memory/pool/typed-array-pool.ts rename to web/packages/memory/src/pool/typed-array-pool.ts diff --git a/web/packages/memory/tsconfig.build.json b/web/packages/memory/tsconfig.build.json new file mode 100644 index 00000000..42990e9c --- /dev/null +++ b/web/packages/memory/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": false, + "types": ["node"], + "declaration": true, + "declarationMap": false, + "sourceMap": true + }, + "include": ["src/**/*.ts", "../../types/**/*.d.ts"], + "exclude": [ + "**/__tests__/**", + "**/*.test.ts", + "**/*.spec.ts", + "**/*.browser.test.ts", + "**/*.browser.spec.ts", + "dist" + ] +} diff --git a/web/packages/numeric/src/clamp.ts b/web/packages/numeric/src/clamp.ts index 078c4bd9..874773aa 100644 --- a/web/packages/numeric/src/clamp.ts +++ b/web/packages/numeric/src/clamp.ts @@ -1,4 +1,6 @@ -type BrandedNumber = number & { readonly __brand: T }; +import type { Brand } from '@axrone/utility'; + +type BrandedNumber = Brand; type FiniteNumber = BrandedNumber<'finite'>; diff --git a/web/packages/observer/src/definition.ts b/web/packages/observer/src/definition.ts index 9a2ccf4b..dc5c7378 100644 --- a/web/packages/observer/src/definition.ts +++ b/web/packages/observer/src/definition.ts @@ -2,20 +2,21 @@ export type ObserverCallback = ( data: T, subject: IObservableSubject ) => void | Promise; + export type UnobserveFn = () => boolean; export type ObserverId = symbol; export type SubjectId = symbol; export type ObservationPriority = 'high' | 'normal' | 'low'; +export type NotificationType = 'update' | 'complete' | 'error' | 'lifecycle'; +export type ObserverErrorHandling = 'throw' | 'silent' | 'callback'; -export type NotificationData = { +export interface NotificationData { readonly timestamp: number; readonly data: T; readonly type: NotificationType; readonly source: SubjectId; -}; - -export type NotificationType = 'update' | 'complete' | 'error' | 'lifecycle'; +} export type ObserverFilter = (data: T, subject: IObservableSubject) => boolean; @@ -24,109 +25,181 @@ export type ObserverTransform = ( subject: IObservableSubject ) => TOutput | Promise; -export interface ObserverOptions { +export interface ObserverBufferingOptions { + readonly enabled?: boolean; + readonly maxSize?: number; + readonly flushIntervalMs?: number; +} + +export interface ObserverReplayOptions { + readonly enabled?: boolean; + readonly bufferSize?: number; +} + +export interface SubjectMemoryManagementOptions { + readonly enabled?: boolean; + readonly gcIntervalMs?: number; + readonly weakReferences?: boolean; +} + +export interface SubjectReplayOptions { + readonly enabled?: boolean; + readonly bufferSize?: number; +} + +export interface SubjectConcurrencyOptions { + readonly enabled?: boolean; + readonly maxConcurrent?: number; +} + +export interface SubjectValidationOptions { + readonly enabled?: boolean; + readonly validator?: (data: T) => boolean; +} + +export interface ObserverOptions { readonly priority?: ObservationPriority; readonly once?: boolean; - readonly filter?: ObserverFilter; - readonly transform?: ObserverTransform; + readonly filter?: ObserverFilter; + readonly transform?: ObserverTransform; readonly debounceMs?: number; readonly throttleMs?: number; - readonly buffering?: { - readonly enabled: boolean; - readonly maxSize: number; - readonly flushIntervalMs: number; - }; - readonly replay?: { - readonly enabled: boolean; - readonly bufferSize: number; - }; + readonly buffering?: ObserverBufferingOptions; + readonly replay?: ObserverReplayOptions; readonly weakReference?: boolean; - readonly errorHandling?: 'throw' | 'silent' | 'callback'; - readonly onError?: (error: Error, data: any, subject: IObservableSubject) => void; + readonly errorHandling?: ObserverErrorHandling; + readonly onError?: ( + error: Error, + data: TOutput | readonly TOutput[], + subject: IObservableSubject + ) => void; } -export interface SubjectOptions { +export interface SubjectOptions { readonly maxObservers?: number; readonly autoComplete?: boolean; readonly errorPropagation?: boolean; - readonly memoryManagement?: { - readonly enabled: boolean; - readonly gcIntervalMs: number; - readonly weakReferences: boolean; - }; - readonly replay?: { - readonly enabled: boolean; - readonly bufferSize: number; - }; - readonly concurrency?: { - readonly enabled: boolean; - readonly maxConcurrent: number; - }; - readonly validation?: { - readonly enabled: boolean; - readonly validator?: (data: any) => boolean; - }; + readonly memoryManagement?: SubjectMemoryManagementOptions; + readonly replay?: SubjectReplayOptions; + readonly concurrency?: SubjectConcurrencyOptions; + readonly validation?: SubjectValidationOptions; +} + +export interface NormalizedObserverBufferingOptions { + readonly enabled: boolean; + readonly maxSize: number; + readonly flushIntervalMs: number; +} + +export interface NormalizedObserverReplayOptions { + readonly enabled: boolean; + readonly bufferSize: number; } -export const DEFAULT_OBSERVER_OPTIONS: Required< - Omit -> = Object.freeze({ +export interface NormalizedSubjectMemoryManagementOptions { + readonly enabled: boolean; + readonly gcIntervalMs: number; + readonly weakReferences: boolean; +} + +export interface NormalizedSubjectReplayOptions { + readonly enabled: boolean; + readonly bufferSize: number; +} + +export interface NormalizedSubjectConcurrencyOptions { + readonly enabled: boolean; + readonly maxConcurrent: number; +} + +export interface NormalizedSubjectValidationOptions { + readonly enabled: boolean; + readonly validator?: (data: T) => boolean; +} + +export interface NormalizedObserverOptions { + readonly priority: ObservationPriority; + readonly once: boolean; + readonly filter?: ObserverFilter; + readonly transform?: ObserverTransform; + readonly debounceMs: number; + readonly throttleMs: number; + readonly buffering: NormalizedObserverBufferingOptions; + readonly replay: NormalizedObserverReplayOptions; + readonly weakReference: boolean; + readonly errorHandling: ObserverErrorHandling; + readonly onError?: ( + error: Error, + data: TOutput | readonly TOutput[], + subject: IObservableSubject + ) => void; +} + +export interface NormalizedSubjectOptions { + readonly maxObservers: number; + readonly autoComplete: boolean; + readonly errorPropagation: boolean; + readonly memoryManagement: NormalizedSubjectMemoryManagementOptions; + readonly replay: NormalizedSubjectReplayOptions; + readonly concurrency: NormalizedSubjectConcurrencyOptions; + readonly validation: NormalizedSubjectValidationOptions; +} + +export type ObserverEmission< + TInput, + TOptions extends ObserverOptions | undefined, +> = TOptions extends ObserverOptions + ? TOptions extends { buffering: { enabled: true } } + ? TOutput[] + : TOutput + : TInput; + +export const DEFAULT_OBSERVER_OPTIONS: NormalizedObserverOptions = Object.freeze({ priority: 'normal', once: false, debounceMs: 0, throttleMs: 0, - buffering: { + buffering: Object.freeze({ enabled: false, maxSize: 100, flushIntervalMs: 1000, - }, - replay: { + }), + replay: Object.freeze({ enabled: false, bufferSize: 10, - }, + }), weakReference: false, errorHandling: 'throw', -} as const); +}); -export const DEFAULT_SUBJECT_OPTIONS: Required> = Object.freeze({ +export const DEFAULT_SUBJECT_OPTIONS: NormalizedSubjectOptions = Object.freeze({ maxObservers: 100, autoComplete: false, errorPropagation: true, - memoryManagement: { + memoryManagement: Object.freeze({ enabled: true, gcIntervalMs: 60000, weakReferences: false, - }, - replay: { + }), + replay: Object.freeze({ enabled: false, bufferSize: 10, - }, - concurrency: { + }), + concurrency: Object.freeze({ enabled: true, maxConcurrent: 10, - }, - validation: { + }), + validation: Object.freeze({ enabled: false, - }, -} as const); + validator: undefined, + }), +}); -export const PRIORITY_VALUES: Record = { +export const PRIORITY_VALUES: Readonly> = Object.freeze({ high: 0, normal: 1, low: 2, -} as const; - -export function isValidObserver(observer: unknown): observer is ObserverCallback { - return typeof observer === 'function'; -} - -export function isValidPriority(priority: unknown): priority is ObservationPriority { - return typeof priority === 'string' && ['high', 'normal', 'low'].includes(priority); -} - -export function isValidNotificationType(type: unknown): type is NotificationType { - return typeof type === 'string' && ['update', 'complete', 'error', 'lifecycle'].includes(type); -} +}); export const OBSERVER_MEMORY_SYMBOLS = Object.freeze({ observerMap: Symbol('observerMap'), @@ -135,7 +208,7 @@ export const OBSERVER_MEMORY_SYMBOLS = Object.freeze({ observationQueues: Symbol('observationQueues'), filterFunctions: Symbol('filterFunctions'), transformFunctions: Symbol('transformFunctions'), -} as const); +}); export interface IObservableSubject { readonly id: SubjectId; @@ -143,10 +216,13 @@ export interface IObservableSubject { notifySync(data: T): boolean; complete(): Promise; error(error: Error): Promise; - addObserver(observer: ObserverCallback, options?: ObserverOptions): UnobserveFn; - removeObserver(observer: ObserverCallback): boolean; + addObserver | undefined = undefined>( + observer: ObserverCallback>, + options?: TOptions + ): UnobserveFn; + removeObserver(observer: ObserverCallback): boolean; removeObserverById(observerId: ObserverId): boolean; - hasObserver(observer: ObserverCallback): boolean; + hasObserver(observer: ObserverCallback): boolean; getObserverCount(): number; isCompleted(): boolean; isErrored(): boolean; @@ -158,9 +234,228 @@ export interface IObservableSubject { export interface IObserver { readonly id: ObserverId; readonly callback: ObserverCallback; - readonly options: Required; + readonly options: NormalizedObserverOptions; readonly createdAt: number; readonly executionCount: number; readonly lastExecuted?: number; readonly isActive: boolean; } + +const normalizeNonNegativeInteger = (value: unknown, fallback: number): number => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + return fallback; + } + + return Math.floor(value); +}; + +const normalizePositiveInteger = (value: unknown, fallback: number): number => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) { + return fallback; + } + + return Math.floor(value); +}; + +export function createObserverId(description: string = 'Observer'): ObserverId { + return Symbol(description); +} + +export function createSubjectId(description: string = 'Subject'): SubjectId { + return Symbol(description); +} + +export function createNotificationData( + source: SubjectId, + type: NotificationType, + data: T, + timestamp: number = Date.now() +): NotificationData { + return { + timestamp, + data, + type, + source, + }; +} + +export function isValidObserver(observer: unknown): observer is ObserverCallback { + return typeof observer === 'function'; +} + +export function isValidPriority(priority: unknown): priority is ObservationPriority { + return priority === 'high' || priority === 'normal' || priority === 'low'; +} + +export function isValidNotificationType(type: unknown): type is NotificationType { + return type === 'update' || type === 'complete' || type === 'error' || type === 'lifecycle'; +} + +export function mergeObserverOptions( + base: ObserverOptions | NormalizedObserverOptions = {}, + override: ObserverOptions = {} +): ObserverOptions { + return { + ...base, + ...override, + buffering: + base.buffering || override.buffering + ? { + ...(base.buffering ?? {}), + ...(override.buffering ?? {}), + } + : undefined, + replay: + base.replay || override.replay + ? { + ...(base.replay ?? {}), + ...(override.replay ?? {}), + } + : undefined, + }; +} + +export function mergeSubjectOptions( + base: SubjectOptions | NormalizedSubjectOptions = {}, + override: SubjectOptions = {} +): SubjectOptions { + return { + ...base, + ...override, + memoryManagement: + base.memoryManagement || override.memoryManagement + ? { + ...(base.memoryManagement ?? {}), + ...(override.memoryManagement ?? {}), + } + : undefined, + replay: + base.replay || override.replay + ? { + ...(base.replay ?? {}), + ...(override.replay ?? {}), + } + : undefined, + concurrency: + base.concurrency || override.concurrency + ? { + ...(base.concurrency ?? {}), + ...(override.concurrency ?? {}), + } + : undefined, + validation: + base.validation || override.validation + ? { + ...(base.validation ?? {}), + ...(override.validation ?? {}), + } + : undefined, + }; +} + +export function normalizeObserverOptions( + options: ObserverOptions = {} +): NormalizedObserverOptions { + const priority = isValidPriority(options.priority) + ? options.priority + : DEFAULT_OBSERVER_OPTIONS.priority; + const errorHandling: ObserverErrorHandling = + options.errorHandling === 'silent' || + options.errorHandling === 'callback' || + options.errorHandling === 'throw' + ? options.errorHandling + : DEFAULT_OBSERVER_OPTIONS.errorHandling; + + return Object.freeze({ + priority, + once: options.once === true, + filter: options.filter, + transform: options.transform, + debounceMs: normalizeNonNegativeInteger( + options.debounceMs, + DEFAULT_OBSERVER_OPTIONS.debounceMs + ), + throttleMs: normalizeNonNegativeInteger( + options.throttleMs, + DEFAULT_OBSERVER_OPTIONS.throttleMs + ), + buffering: Object.freeze({ + enabled: options.buffering?.enabled === true, + maxSize: normalizePositiveInteger( + options.buffering?.maxSize, + DEFAULT_OBSERVER_OPTIONS.buffering.maxSize + ), + flushIntervalMs: normalizePositiveInteger( + options.buffering?.flushIntervalMs, + DEFAULT_OBSERVER_OPTIONS.buffering.flushIntervalMs + ), + }), + replay: Object.freeze({ + enabled: options.replay?.enabled === true, + bufferSize: normalizePositiveInteger( + options.replay?.bufferSize, + DEFAULT_OBSERVER_OPTIONS.replay.bufferSize + ), + }), + weakReference: options.weakReference === true, + errorHandling, + onError: options.onError, + }); +} + +export function normalizeSubjectOptions( + options: SubjectOptions = {} +): NormalizedSubjectOptions { + return Object.freeze({ + maxObservers: normalizePositiveInteger( + options.maxObservers, + DEFAULT_SUBJECT_OPTIONS.maxObservers + ), + autoComplete: options.autoComplete === true, + errorPropagation: + typeof options.errorPropagation === 'boolean' + ? options.errorPropagation + : DEFAULT_SUBJECT_OPTIONS.errorPropagation, + memoryManagement: Object.freeze({ + enabled: + typeof options.memoryManagement?.enabled === 'boolean' + ? options.memoryManagement.enabled + : DEFAULT_SUBJECT_OPTIONS.memoryManagement.enabled, + gcIntervalMs: normalizeNonNegativeInteger( + options.memoryManagement?.gcIntervalMs, + DEFAULT_SUBJECT_OPTIONS.memoryManagement.gcIntervalMs + ), + weakReferences: + typeof options.memoryManagement?.weakReferences === 'boolean' + ? options.memoryManagement.weakReferences + : DEFAULT_SUBJECT_OPTIONS.memoryManagement.weakReferences, + }), + replay: Object.freeze({ + enabled: + typeof options.replay?.enabled === 'boolean' + ? options.replay.enabled + : DEFAULT_SUBJECT_OPTIONS.replay.enabled, + bufferSize: normalizePositiveInteger( + options.replay?.bufferSize, + DEFAULT_SUBJECT_OPTIONS.replay.bufferSize + ), + }), + concurrency: Object.freeze({ + enabled: + typeof options.concurrency?.enabled === 'boolean' + ? options.concurrency.enabled + : DEFAULT_SUBJECT_OPTIONS.concurrency.enabled, + maxConcurrent: normalizePositiveInteger( + options.concurrency?.maxConcurrent, + DEFAULT_SUBJECT_OPTIONS.concurrency.maxConcurrent + ), + }), + validation: Object.freeze({ + enabled: + typeof options.validation?.enabled === 'boolean' + ? options.validation.enabled + : DEFAULT_SUBJECT_OPTIONS.validation.enabled, + validator: options.validation?.validator, + }), + }); +} diff --git a/web/packages/observer/src/errors.ts b/web/packages/observer/src/errors.ts index f1a76766..ed18fd04 100644 --- a/web/packages/observer/src/errors.ts +++ b/web/packages/observer/src/errors.ts @@ -1,143 +1,148 @@ -import { ObserverCallback, ObserverId, NotificationData } from './definition'; +import { NotificationData, ObserverCallback, ObserverId, SubjectId } from './definition'; + +const captureStack = (target: object, ctor: Function): void => { + ( + Error as typeof Error & { + captureStackTrace?: (instance: object, constructor: Function) => void; + } + ).captureStackTrace?.(target, ctor); +}; + +const normalizeError = (error: unknown): Error => + error instanceof Error ? error : new Error(typeof error === 'string' ? error : String(error)); export class BaseObserverError extends Error { - public readonly code: string; - public readonly timestamp: number; + readonly code: string; + readonly timestamp: number; constructor(message: string, code: string = 'OBSERVER_ERROR') { super(message); - this.name = 'BaseObserverError'; + this.name = new.target.name; this.code = code; this.timestamp = Date.now(); - - ( - Error as typeof Error & { captureStackTrace?: (target: object, ctor: Function) => void } - ).captureStackTrace?.(this, this.constructor); + Object.setPrototypeOf(this, new.target.prototype); + captureStack(this, new.target); } } export class ObserverError extends BaseObserverError { constructor(message: string, code: string = 'OBSERVER_ERROR') { super(message, code); - this.name = 'ObserverError'; } } export class SubjectError extends BaseObserverError { - public readonly subjectId?: symbol; + readonly subjectId?: SubjectId; - constructor(message: string, subjectId?: symbol, code: string = 'SUBJECT_ERROR') { + constructor(message: string, subjectId?: SubjectId, code: string = 'SUBJECT_ERROR') { super(message, code); - this.name = 'SubjectError'; this.subjectId = subjectId; } } export class ObserverNotFoundError extends ObserverError { - public readonly observerId?: ObserverId; + readonly observerId?: ObserverId; constructor(observerId?: ObserverId) { - const message = observerId - ? `Observer with ID ${String(observerId)} not found` - : 'Observer not found'; - super(message, 'OBSERVER_NOT_FOUND'); - this.name = 'ObserverNotFoundError'; + super( + observerId + ? `Observer with ID ${String(observerId)} not found` + : 'Observer not found', + 'OBSERVER_NOT_FOUND' + ); this.observerId = observerId; } } export class SubjectCompletedError extends SubjectError { - constructor(subjectId?: symbol) { + constructor(subjectId?: SubjectId) { super('Cannot operate on completed subject', subjectId, 'SUBJECT_COMPLETED'); - this.name = 'SubjectCompletedError'; } } export class SubjectDisposedError extends SubjectError { - constructor(subjectId?: symbol) { + constructor(subjectId?: SubjectId) { super('Cannot operate on disposed subject', subjectId, 'SUBJECT_DISPOSED'); - this.name = 'SubjectDisposedError'; } } export class MaxObserversExceededError extends SubjectError { - public readonly maxObservers: number; - public readonly currentCount: number; + readonly maxObservers: number; + readonly currentCount: number; - constructor(maxObservers: number, currentCount: number, subjectId?: symbol) { + constructor(maxObservers: number, currentCount: number, subjectId?: SubjectId) { super( `Maximum number of observers exceeded. Max: ${maxObservers}, Current: ${currentCount}`, subjectId, 'MAX_OBSERVERS_EXCEEDED' ); - this.name = 'MaxObserversExceededError'; this.maxObservers = maxObservers; this.currentCount = currentCount; } } export class ObserverExecutionError extends ObserverError { - public readonly observerId: ObserverId; - public readonly originalError: Error; - public readonly notificationData: NotificationData; + readonly observerId: ObserverId; + readonly originalError: Error; + readonly notificationData: NotificationData; constructor(observerId: ObserverId, originalError: Error, notificationData: NotificationData) { super(`Observer execution failed: ${originalError.message}`, 'OBSERVER_EXECUTION_ERROR'); - this.name = 'ObserverExecutionError'; this.observerId = observerId; this.originalError = originalError; this.notificationData = notificationData; + (this as { cause?: Error }).cause = originalError; } } export class ValidationError extends SubjectError { - public readonly invalidData: any; + readonly invalidData: unknown; - constructor(message: string, invalidData: any, subjectId?: symbol) { + constructor(message: string, invalidData: unknown, subjectId?: SubjectId) { super(message, subjectId, 'VALIDATION_ERROR'); - this.name = 'ValidationError'; this.invalidData = invalidData; } } export class ConcurrencyLimitError extends SubjectError { - public readonly limit: number; - public readonly current: number; + readonly limit: number; + readonly current: number; - constructor(limit: number, current: number, subjectId?: symbol) { + constructor(limit: number, current: number, subjectId?: SubjectId) { super( `Concurrency limit exceeded. Limit: ${limit}, Current: ${current}`, subjectId, 'CONCURRENCY_LIMIT_ERROR' ); - this.name = 'ConcurrencyLimitError'; this.limit = limit; this.current = current; } } export class FilterError extends ObserverError { - public readonly filterFunction: Function; - public readonly originalError: Error; + readonly filterFunction: Function; + readonly originalError: Error; - constructor(originalError: Error, filterFunction: Function) { - super(`Observer filter execution failed: ${originalError.message}`, 'FILTER_ERROR'); - this.name = 'FilterError'; + constructor(originalError: unknown, filterFunction: ObserverCallback | Function) { + const normalized = normalizeError(originalError); + super(`Observer filter execution failed: ${normalized.message}`, 'FILTER_ERROR'); this.filterFunction = filterFunction; - this.originalError = originalError; + this.originalError = normalized; + (this as { cause?: Error }).cause = normalized; } } export class TransformError extends ObserverError { - public readonly transformFunction: Function; - public readonly originalError: Error; - public readonly inputData: any; + readonly transformFunction: Function; + readonly originalError: Error; + readonly inputData: unknown; - constructor(originalError: Error, transformFunction: Function, inputData: any) { - super(`Observer transform execution failed: ${originalError.message}`, 'TRANSFORM_ERROR'); - this.name = 'TransformError'; + constructor(originalError: unknown, transformFunction: Function, inputData: unknown) { + const normalized = normalizeError(originalError); + super(`Observer transform execution failed: ${normalized.message}`, 'TRANSFORM_ERROR'); this.transformFunction = transformFunction; - this.originalError = originalError; + this.originalError = normalized; this.inputData = inputData; + (this as { cause?: Error }).cause = normalized; } } diff --git a/web/packages/observer/src/factory.ts b/web/packages/observer/src/factory.ts index 315d742a..7ee03cbf 100644 --- a/web/packages/observer/src/factory.ts +++ b/web/packages/observer/src/factory.ts @@ -1,54 +1,50 @@ import { + mergeObserverOptions, + mergeSubjectOptions, + normalizeObserverOptions, ObserverCallback, - ObserverId, - SubjectId, ObserverOptions, SubjectOptions, IObservableSubject, IObserver, - UnobserveFn, - DEFAULT_OBSERVER_OPTIONS, } from './definition'; -import { IObservableFactory, IObserverRegistry, IMemoryManager } from './interfaces'; -import { Subject } from './subject'; -import { ObserverRegistry } from './registry'; +import { IObservableFactory, IMemoryManager, IObserverRegistry } from './interfaces'; import { MemoryManager } from './memory-manager'; +import { ObserverRegistry } from './registry'; +import { AsyncSubject, BehaviorSubject, ReplaySubject, Subject } from './subject'; class ObserverImpl implements IObserver { - readonly id: ObserverId; + readonly id = Symbol('Observer'); readonly callback: ObserverCallback; - readonly options: Required; - readonly createdAt: number; - readonly executionCount: number; - readonly lastExecuted?: number; - readonly isActive: boolean; - - constructor(callback: ObserverCallback, options: ObserverOptions = {}) { - this.id = Symbol('Observer'); + readonly options: IObserver['options']; + readonly createdAt = Date.now(); + readonly executionCount = 0; + readonly lastExecuted = undefined; + readonly isActive = true; + + constructor(callback: ObserverCallback, options: ObserverOptions = {}) { this.callback = callback; - this.options = { ...DEFAULT_OBSERVER_OPTIONS, ...options } as Required; - this.createdAt = Date.now(); - this.executionCount = 0; - this.isActive = true; + this.options = normalizeObserverOptions(options); } } export class ObservableFactory implements IObservableFactory { readonly #memoryManager: IMemoryManager; - readonly #defaultSubjectOptions: SubjectOptions; - readonly #defaultObserverOptions: ObserverOptions; + readonly #ownsMemoryManager: boolean; + readonly #defaultSubjectOptions: SubjectOptions; + readonly #defaultObserverOptions: ObserverOptions; constructor( options: { - defaultSubjectOptions?: SubjectOptions; - defaultObserverOptions?: ObserverOptions; + defaultSubjectOptions?: SubjectOptions; + defaultObserverOptions?: ObserverOptions; enableMemoryTracking?: boolean; memoryManager?: IMemoryManager; } = {} ) { this.#defaultSubjectOptions = options.defaultSubjectOptions ?? {}; this.#defaultObserverOptions = options.defaultObserverOptions ?? {}; - + this.#ownsMemoryManager = options.memoryManager === undefined; this.#memoryManager = options.memoryManager ?? new MemoryManager({ @@ -56,17 +52,21 @@ export class ObservableFactory implements IObservableFactory { }); } - createSubject(options: SubjectOptions = {}): IObservableSubject { - const mergedOptions = { ...this.#defaultSubjectOptions, ...options }; + createSubject(options: SubjectOptions = {}): IObservableSubject { + const mergedOptions = mergeSubjectOptions( + this.#defaultSubjectOptions as SubjectOptions, + options + ); const subject = new Subject(mergedOptions); - this.#memoryManager.trackSubject(subject); - return subject; } - createObserver(callback: ObserverCallback, options: ObserverOptions = {}): IObserver { - const mergedOptions = { ...this.#defaultObserverOptions, ...options }; + createObserver(callback: ObserverCallback, options: ObserverOptions = {}): IObserver { + const mergedOptions = mergeObserverOptions( + this.#defaultObserverOptions as ObserverOptions, + options + ); return new ObserverImpl(callback, mergedOptions); } @@ -80,34 +80,43 @@ export class ObservableFactory implements IObservableFactory { return new ObserverRegistry({ enableMemoryTracking: options.enableMemoryTracking ?? true, memoryManager: this.#memoryManager, + disposeMemoryManager: false, }); } - createBehaviorSubject(initialValue: T, options: SubjectOptions = {}): BehaviorSubject { - return new BehaviorSubject(initialValue, options); + createBehaviorSubject(initialValue: T, options: SubjectOptions = {}): BehaviorSubject { + return new BehaviorSubject( + initialValue, + mergeSubjectOptions(this.#defaultSubjectOptions as SubjectOptions, options) + ); } createReplaySubject( bufferSize: number = 10, - options: SubjectOptions = {} + options: SubjectOptions = {} ): ReplaySubject { - const replayOptions: SubjectOptions = { - ...options, - replay: { - enabled: true, - bufferSize, - ...options.replay, - }, - }; - return new ReplaySubject(replayOptions); + return new ReplaySubject( + mergeSubjectOptions(this.#defaultSubjectOptions as SubjectOptions, { + ...options, + replay: { + enabled: true, + bufferSize, + ...options.replay, + }, + }) + ); } - createAsyncSubject(options: SubjectOptions = {}): AsyncSubject { - return new AsyncSubject(options); + createAsyncSubject(options: SubjectOptions = {}): AsyncSubject { + return new AsyncSubject( + mergeSubjectOptions(this.#defaultSubjectOptions as SubjectOptions, options) + ); } dispose(): void { - this.#memoryManager.dispose(); + if (this.#ownsMemoryManager) { + this.#memoryManager.dispose(); + } } getMemoryUsage() { @@ -119,114 +128,21 @@ export class ObservableFactory implements IObservableFactory { } } -export class BehaviorSubject extends Subject { - #currentValue: T; - #hasValue = true; - - constructor(initialValue: T, options: SubjectOptions = {}) { - super(options); - this.#currentValue = initialValue; - } - - get value(): T { - if (!this.#hasValue) { - throw new Error('BehaviorSubject has no current value'); - } - return this.#currentValue; - } - - async notify(data: T): Promise { - this.#currentValue = data; - this.#hasValue = true; - return super.notify(data); - } - - notifySync(data: T): boolean { - this.#currentValue = data; - this.#hasValue = true; - return super.notifySync(data); - } - - addObserver(callback: ObserverCallback, options: ObserverOptions = {}) { - const unsubscribe = super.addObserver(callback, options); - - if (this.#hasValue) { - setTimeout(() => { - callback(this.#currentValue, this); - }, 0); - } - - return unsubscribe; - } -} - -export class ReplaySubject extends Subject { - constructor(options: SubjectOptions = {}) { - const replayOptions: SubjectOptions = { - ...options, - replay: { - enabled: true, - bufferSize: 10, - ...options.replay, - }, - }; - super(replayOptions); - } - - addObserver(callback: ObserverCallback, options: ObserverOptions = {}): UnobserveFn { - const replayOptions: ObserverOptions = { - ...options, - replay: { - enabled: true, - bufferSize: this.options.replay.bufferSize, - ...options.replay, - }, - }; - return super.addObserver(callback, replayOptions); - } -} - -export class AsyncSubject extends Subject { - #lastValue?: T; - #hasValue = false; - - async notify(data: T): Promise { - this.#lastValue = data; - this.#hasValue = true; - return true; - } - - notifySync(data: T): boolean { - this.#lastValue = data; - this.#hasValue = true; - return true; - } - - async complete(): Promise { - if (this.#hasValue && this.#lastValue !== undefined) { - await super.notify(this.#lastValue); - } - - (this as any).isCompleted = true; - (this as any).metrics.completedAt = Date.now(); - } -} - export const observableFactory = new ObservableFactory(); -export const createSubject = (options?: SubjectOptions) => +export const createSubject = (options?: SubjectOptions) => observableFactory.createSubject(options); -export const createBehaviorSubject = (initialValue: T, options?: SubjectOptions) => +export const createBehaviorSubject = (initialValue: T, options?: SubjectOptions) => observableFactory.createBehaviorSubject(initialValue, options); -export const createReplaySubject = (bufferSize?: number, options?: SubjectOptions) => +export const createReplaySubject = (bufferSize?: number, options?: SubjectOptions) => observableFactory.createReplaySubject(bufferSize, options); -export const createAsyncSubject = (options?: SubjectOptions) => +export const createAsyncSubject = (options?: SubjectOptions) => observableFactory.createAsyncSubject(options); -export const createObserver = (callback: ObserverCallback, options?: ObserverOptions) => +export const createObserver = (callback: ObserverCallback, options?: ObserverOptions) => observableFactory.createObserver(callback, options); export const createRegistry = (options?: Parameters[0]) => diff --git a/web/packages/observer/src/index.ts b/web/packages/observer/src/index.ts index 40a25b7b..629f2c72 100644 --- a/web/packages/observer/src/index.ts +++ b/web/packages/observer/src/index.ts @@ -1,99 +1,108 @@ -// Core types and interfaces export type { - ObserverCallback, - UnobserveFn, - ObserverId, - SubjectId, - ObservationPriority, NotificationData, NotificationType, + ObservationPriority, + ObserverBufferingOptions, + ObserverCallback, + ObserverEmission, + ObserverErrorHandling, ObserverFilter, - ObserverTransform, + ObserverId, ObserverOptions, + ObserverReplayOptions, + ObserverTransform, + SubjectConcurrencyOptions, + SubjectId, + SubjectMemoryManagementOptions, SubjectOptions, + SubjectReplayOptions, + SubjectValidationOptions, IObservableSubject, IObserver, + UnobserveFn, } from './definition'; export { + createNotificationData, + createObserverId, + createSubjectId, DEFAULT_OBSERVER_OPTIONS, DEFAULT_SUBJECT_OPTIONS, - PRIORITY_VALUES, + mergeObserverOptions, + mergeSubjectOptions, + normalizeObserverOptions, + normalizeSubjectOptions, OBSERVER_MEMORY_SYMBOLS, + PRIORITY_VALUES, + isValidNotificationType, isValidObserver, isValidPriority, - isValidNotificationType, } from './definition'; -// Error classes export { BaseObserverError, + ConcurrencyLimitError, + FilterError, + MaxObserversExceededError, ObserverError, - SubjectError, + ObserverExecutionError, ObserverNotFoundError, SubjectCompletedError, SubjectDisposedError, - MaxObserversExceededError, - ObserverExecutionError, - ValidationError, - ConcurrencyLimitError, - FilterError, + SubjectError, TransformError, + ValidationError, } from './errors'; -// Interfaces export type { - IObserverSubscription, - IObserverRegistry, - ISubjectLifecycle, - IObserverMetrics, - ISubjectMetrics, + IMemoryManager, + IObservableFactory, IObserverBuffer, - IReplayBuffer, - IObserverScheduler, + IObserverChain, + IObserverConnection, IObserverDebouncer, - IObserverThrottler, IObserverFilterEngine, - IMemoryManager, + IObserverMetrics, + IObserverRegistry, + IObserverScheduler, + IObserverSubscription, + IObserverThrottler, IObserverValidator, - IObservableFactory, - IObserverChain, + IReplayBuffer, ISubjectGroup, - IObserverConnection, + ISubjectLifecycle, + ISubjectMetrics, } from './interfaces'; export type { ISubject } from './subject'; -export { Subject } from './subject'; -export { ObserverRegistry } from './registry'; +export { AsyncSubject, BehaviorSubject, ReplaySubject, Subject } from './subject'; export { MemoryManager } from './memory-manager'; +export { ObserverRegistry } from './registry'; export { ObservableFactory, - BehaviorSubject, - ReplaySubject, - AsyncSubject, - observableFactory, - createSubject, - createBehaviorSubject, - createReplaySubject, createAsyncSubject, + createBehaviorSubject, createObserver, createRegistry, + createReplaySubject, + createSubject, + observableFactory, } from './factory'; export { ObserverChain, - SubjectGroup, ObserverConnection, + SubjectGroup, chain, - group, - connect, - pipe, - merge, combineLatest, + connect, + debounce, filter, + group, map, - debounce, + merge, + pipe, throttle, } from './operators'; @@ -101,20 +110,43 @@ import type { IObservableSubject, IObserver, ObserverCallback, - UnobserveFn, ObserverOptions, SubjectOptions, + UnobserveFn, +} from './definition'; +import { + mergeObserverOptions, + mergeSubjectOptions, + normalizeObserverOptions, + normalizeSubjectOptions, + DEFAULT_OBSERVER_OPTIONS, + DEFAULT_SUBJECT_OPTIONS, } from './definition'; import { createSubject } from './factory'; -import { DEFAULT_OBSERVER_OPTIONS, DEFAULT_SUBJECT_OPTIONS } from './definition'; + +const attachCleanup = (subject: IObservableSubject, cleanup: () => void): IObservableSubject => { + const originalDispose = subject.dispose.bind(subject); + let cleaned = false; + + subject.dispose = () => { + if (!cleaned) { + cleaned = true; + cleanup(); + } + + originalDispose(); + }; + + return subject; +}; export function isObservableSubject(value: unknown): value is IObservableSubject { return ( value !== null && typeof value === 'object' && - typeof (value as any).notify === 'function' && - typeof (value as any).addObserver === 'function' && - typeof (value as any).dispose === 'function' && + typeof (value as IObservableSubject).notify === 'function' && + typeof (value as IObservableSubject).addObserver === 'function' && + typeof (value as IObservableSubject).dispose === 'function' && 'id' in value ); } @@ -123,58 +155,68 @@ export function isObserver(value: unknown): value is IObserver { return ( value !== null && typeof value === 'object' && - typeof (value as any).callback === 'function' && + typeof (value as IObserver).callback === 'function' && 'id' in value && 'createdAt' in value ); } export class ObserverUtils { - static createTypedSubject>(): { + static createTypedSubject>(): { [K in keyof T]: IObservableSubject; } { - return new Proxy({} as any, { - get(target, prop) { - if (typeof prop === 'string' && !(prop in target)) { - target[prop] = createSubject(); + return new Proxy({} as { [K in keyof T]?: IObservableSubject }, { + get(target, key: keyof T) { + if (!(key in target)) { + target[key] = createSubject(); } - return target[prop]; + return target[key]; }, - }); + }) as { + [K in keyof T]: IObservableSubject; + }; } static fromPromise(promise: Promise): IObservableSubject { const subject = createSubject(); - promise - .then((value) => { - subject.notify(value); - subject.complete(); + + void promise + .then(async (value) => { + await subject.notify(value); + await subject.complete(); }) - .catch((error) => { - subject.error(error); + .catch(async (error) => { + await subject.error(error instanceof Error ? error : new Error(String(error))); }); + return subject; } - static fromArray(array: T[], intervalMs: number = 0): IObservableSubject { + static fromArray(array: readonly T[], intervalMs: number = 0): IObservableSubject { const subject = createSubject(); - if (intervalMs === 0) { - array.forEach((item) => subject.notifySync(item)); - subject.complete(); - } else { - let index = 0; - const interval = setInterval(() => { - if (index < array.length) { - subject.notify(array[index++]); - } else { - clearInterval(interval); - subject.complete(); - } - }, intervalMs); + if (intervalMs <= 0) { + for (const item of array) { + subject.notifySync(item); + } + void subject.complete(); + return subject; } - return subject; + let index = 0; + const interval = setInterval(() => { + if (index >= array.length) { + clearInterval(interval); + void subject.complete(); + return; + } + + void subject.notify(array[index++]).catch(() => undefined); + }, Math.max(1, Math.floor(intervalMs))); + + return attachCleanup(subject, () => { + clearInterval(interval); + }); } static fromEvent( @@ -183,108 +225,92 @@ export class ObserverUtils { options?: AddEventListenerOptions ): IObservableSubject { const subject = createSubject(); - const handler = (event: Event) => { - subject.notify(event as T); + void subject.notify(event as T).catch(() => undefined); }; target.addEventListener(eventName, handler, options); - const originalDispose = subject.dispose.bind(subject); - subject.dispose = () => { + return attachCleanup(subject, () => { target.removeEventListener(eventName, handler, options); - originalDispose(); - }; - - return subject; + }); } static interval(intervalMs: number): IObservableSubject { const subject = createSubject(); - let count = 0; - - const intervalId = setInterval(() => { - subject.notify(count++); - }, intervalMs); - - const originalDispose = subject.dispose.bind(subject); - subject.dispose = () => { - clearInterval(intervalId); - originalDispose(); - }; + let current = 0; + const interval = setInterval(() => { + void subject.notify(current++).catch(() => undefined); + }, Math.max(1, Math.floor(intervalMs))); - return subject; + return attachCleanup(subject, () => { + clearInterval(interval); + }); } static timer(delayMs: number, intervalMs?: number): IObservableSubject { const subject = createSubject(); - let count = 0; - - const timeoutId = setTimeout(() => { - subject.notify(count++); - - if (intervalMs !== undefined) { - const intervalId = setInterval(() => { - subject.notify(count++); - }, intervalMs); - - const originalDispose = subject.dispose.bind(subject); - subject.dispose = () => { - clearInterval(intervalId); - originalDispose(); - }; - } else { - subject.complete(); + let current = 0; + let interval: ReturnType | undefined; + + const timeout = setTimeout(() => { + void subject.notify(current++).catch(() => undefined); + + if (intervalMs === undefined) { + void subject.complete(); + return; } - }, delayMs); - const originalDispose = subject.dispose.bind(subject); - subject.dispose = () => { - clearTimeout(timeoutId); - originalDispose(); - }; + interval = setInterval(() => { + void subject.notify(current++).catch(() => undefined); + }, Math.max(1, Math.floor(intervalMs))); + }, Math.max(0, Math.floor(delayMs))); - return subject; + return attachCleanup(subject, () => { + clearTimeout(timeout); + if (interval) { + clearInterval(interval); + } + }); } static defer(factory: () => IObservableSubject): IObservableSubject { const subject = createSubject(); let source: IObservableSubject | undefined; - let unsubscribe: UnobserveFn | undefined; + let sourceUnsubscribe: UnobserveFn | undefined; - const originalAddObserver = subject.addObserver.bind(subject); - subject.addObserver = (callback, options) => { - if (!source) { - source = factory(); - unsubscribe = source.addObserver((data) => { - subject.notify(data); - }); - } - return originalAddObserver(callback, options); - }; - - const originalDispose = subject.dispose.bind(subject); - subject.dispose = () => { - if (unsubscribe) { - unsubscribe(); - } + const ensureSource = (): void => { if (source) { - source.dispose(); + return; } - originalDispose(); + + source = factory(); + sourceUnsubscribe = source.addObserver((data) => { + void subject.notify(data).catch(() => undefined); + }); }; - return subject; + const originalAddObserver = subject.addObserver.bind(subject); + subject.addObserver = ((callback: ObserverCallback, options?: ObserverOptions) => { + ensureSource(); + return originalAddObserver(callback, options); + }) as IObservableSubject['addObserver']; + + return attachCleanup(subject, () => { + sourceUnsubscribe?.(); + source?.dispose(); + }); } } export class ObserverConfig { - private static instance: ObserverConfig; + private static instance?: ObserverConfig; + private config = { - defaultObserverOptions: DEFAULT_OBSERVER_OPTIONS, - defaultSubjectOptions: DEFAULT_SUBJECT_OPTIONS, + defaultObserverOptions: normalizeObserverOptions(DEFAULT_OBSERVER_OPTIONS), + defaultSubjectOptions: normalizeSubjectOptions(DEFAULT_SUBJECT_OPTIONS), enableGlobalErrorHandling: true, - globalErrorHandler: (error: Error, context: any) => { + globalErrorHandler: (error: Error, context: unknown) => { console.error('Observer Error:', error, context); }, enableMemoryTracking: true, @@ -295,18 +321,23 @@ export class ObserverConfig { if (!ObserverConfig.instance) { ObserverConfig.instance = new ObserverConfig(); } + return ObserverConfig.instance; } setDefaultObserverOptions(options: Partial): void { - this.config.defaultObserverOptions = { ...this.config.defaultObserverOptions, ...options }; + this.config.defaultObserverOptions = normalizeObserverOptions( + mergeObserverOptions(this.config.defaultObserverOptions, options) + ); } setDefaultSubjectOptions(options: Partial): void { - this.config.defaultSubjectOptions = { ...this.config.defaultSubjectOptions, ...options }; + this.config.defaultSubjectOptions = normalizeSubjectOptions( + mergeSubjectOptions(this.config.defaultSubjectOptions, options) + ); } - setGlobalErrorHandler(handler: (error: Error, context: any) => void): void { + setGlobalErrorHandler(handler: (error: Error, context: unknown) => void): void { this.config.globalErrorHandler = handler; } @@ -323,7 +354,14 @@ export class ObserverConfig { } getConfig() { - return { ...this.config }; + return { + defaultObserverOptions: this.config.defaultObserverOptions, + defaultSubjectOptions: this.config.defaultSubjectOptions, + enableGlobalErrorHandling: this.config.enableGlobalErrorHandling, + globalErrorHandler: this.config.globalErrorHandler, + enableMemoryTracking: this.config.enableMemoryTracking, + enablePerformanceTracking: this.config.enablePerformanceTracking, + }; } } diff --git a/web/packages/observer/src/interfaces.ts b/web/packages/observer/src/interfaces.ts index 6f225047..0e95a2bc 100644 --- a/web/packages/observer/src/interfaces.ts +++ b/web/packages/observer/src/interfaces.ts @@ -1,18 +1,18 @@ import { ObserverCallback, - UnobserveFn, + ObserverEmission, ObserverId, - SubjectId, ObserverOptions, + SubjectId, SubjectOptions, NotificationData, - NotificationType, IObservableSubject, IObserver, + UnobserveFn, } from './definition'; export interface IObserverSubscription extends IObserver { - readonly subject: IObservableSubject; + readonly subject: IObservableSubject; readonly priority: number; readonly isDebounced: boolean; readonly isThrottled: boolean; @@ -23,15 +23,15 @@ export interface IObserverSubscription extends IObserver { } export interface IObserverRegistry { - register( + register | undefined = undefined>( subject: IObservableSubject, - observer: ObserverCallback, - options?: ObserverOptions + observer: ObserverCallback>, + options?: TOptions ): ObserverId; unregister(observerId: ObserverId): boolean; - unregisterByCallback(subject: IObservableSubject, observer: ObserverCallback): boolean; + unregisterByCallback(subject: IObservableSubject, observer: ObserverCallback): boolean; getObserver(observerId: ObserverId): IObserverSubscription | undefined; @@ -100,7 +100,7 @@ export interface IReplayBuffer { getLast(count: number): ReadonlyArray; clear(): void; size(): number; - maxSize: number; + readonly maxSize: number; } export interface IObserverScheduler { @@ -183,13 +183,13 @@ export interface IMemoryManager { export interface IObserverValidator { validateData(data: T, validator?: (data: T) => boolean): boolean; validateObserver(observer: ObserverCallback): boolean; - validateOptions(options: ObserverOptions): boolean; - validateSubjectOptions(options: SubjectOptions): boolean; + validateOptions(options: ObserverOptions): boolean; + validateSubjectOptions(options: SubjectOptions): boolean; } export interface IObservableFactory { - createSubject(options?: SubjectOptions): IObservableSubject; - createObserver(callback: ObserverCallback, options?: ObserverOptions): IObserver; + createSubject(options?: SubjectOptions): IObservableSubject; + createObserver(callback: ObserverCallback, options?: ObserverOptions): IObserver; createRegistry(options?: { maxSubjects?: number; enableMetrics?: boolean; @@ -197,8 +197,6 @@ export interface IObservableFactory { }): IObserverRegistry; } -// Advanced interfaces for enterprise features - export interface IObserverChain { filter(predicate: (data: T, subject: IObservableSubject) => boolean): IObserverChain; map(transform: (data: T, subject: IObservableSubject) => U): IObserverChain; @@ -219,7 +217,7 @@ export interface ISubjectGroup { notifyAllSync(data: T): boolean[]; completeAll(): Promise; disposeAll(): void; - addObserver(observer: ObserverCallback, options?: ObserverOptions): UnobserveFn[]; + addObserver(observer: ObserverCallback, options?: ObserverOptions): UnobserveFn[]; merge(): IObservableSubject; combineLatest(): IObservableSubject; } diff --git a/web/packages/observer/src/memory-manager.ts b/web/packages/observer/src/memory-manager.ts index b7f4911a..bb8a795e 100644 --- a/web/packages/observer/src/memory-manager.ts +++ b/web/packages/observer/src/memory-manager.ts @@ -3,7 +3,7 @@ import { IMemoryManager, IObserverSubscription } from './interfaces'; interface TrackedSubject { readonly id: SubjectId; - readonly subject: WeakRef; + readonly subject: WeakRef>; readonly createdAt: number; lastAccessedAt: number; } @@ -15,11 +15,23 @@ interface TrackedObserver { lastAccessedAt: number; } +const SUBJECT_OVERHEAD_BYTES = 256; +const OBSERVER_OVERHEAD_BYTES = 128; +const BUFFER_ENTRY_BYTES = 32; + +const runtimeGc = (): void => { + const maybeGc = (globalThis as { gc?: () => void }).gc; + if (typeof maybeGc === 'function') { + maybeGc(); + } +}; + export class MemoryManager implements IMemoryManager { readonly #trackedSubjects = new Map(); readonly #trackedObservers = new Map(); readonly #gcIntervalId?: ReturnType; readonly #enableTracking: boolean; + readonly #autoGcThresholdMb: number; #isDisposed = false; constructor( @@ -30,27 +42,27 @@ export class MemoryManager implements IMemoryManager { } = {} ) { this.#enableTracking = options.enableTracking ?? true; + this.#autoGcThresholdMb = options.autoGcThresholdMb ?? 50; - if (this.#enableTracking && (options.gcIntervalMs ?? 60000) > 0) { + if (this.#enableTracking && (options.gcIntervalMs ?? 60000) > 0 && typeof WeakRef === 'function') { this.#gcIntervalId = setInterval(() => { - this.#runAutomaticGc(options.autoGcThresholdMb ?? 50); + void this.#runAutomaticGc(); }, options.gcIntervalMs ?? 60000); } } trackSubject(subject: IObservableSubject): void { - if (!this.#enableTracking || this.#isDisposed) { + if (!this.#enableTracking || this.#isDisposed || typeof WeakRef !== 'function') { return; } - const tracked: TrackedSubject = { + const now = Date.now(); + this.#trackedSubjects.set(subject.id, { id: subject.id, subject: new WeakRef(subject), - createdAt: Date.now(), - lastAccessedAt: Date.now(), - }; - - this.#trackedSubjects.set(subject.id, tracked); + createdAt: now, + lastAccessedAt: now, + }); } untrackSubject(subjectId: SubjectId): void { @@ -58,18 +70,17 @@ export class MemoryManager implements IMemoryManager { } trackObserver(observer: IObserverSubscription): void { - if (!this.#enableTracking || this.#isDisposed) { + if (!this.#enableTracking || this.#isDisposed || typeof WeakRef !== 'function') { return; } - const tracked: TrackedObserver = { + const now = Date.now(); + this.#trackedObservers.set(observer.id, { id: observer.id, observer: new WeakRef(observer), - createdAt: Date.now(), - lastAccessedAt: Date.now(), - }; - - this.#trackedObservers.set(observer.id, tracked); + createdAt: now, + lastAccessedAt: now, + }); } untrackObserver(observerId: ObserverId): void { @@ -83,36 +94,44 @@ export class MemoryManager implements IMemoryManager { observerBuffers: number; totalMemoryBytes: number; } { - let replayBufferSize = 0; - let observerBufferSize = 0; + let replayBuffers = 0; + let observerBuffers = 0; let totalMemoryBytes = 0; for (const tracked of this.#trackedSubjects.values()) { const subject = tracked.subject.deref(); - if (subject) { - tracked.lastAccessedAt = Date.now(); - const usage = subject.getMemoryUsage(); - replayBufferSize += usage[OBSERVER_MEMORY_SYMBOLS.replayBuffers.toString()] ?? 0; - observerBufferSize += - usage[OBSERVER_MEMORY_SYMBOLS.observationQueues.toString()] ?? 0; - - totalMemoryBytes += this.#estimateObjectSize(subject); + if (!subject) { + continue; } + + tracked.lastAccessedAt = Date.now(); + const usage = subject.getMemoryUsage(); + const replayEntries = usage[OBSERVER_MEMORY_SYMBOLS.replayBuffers.toString()] ?? 0; + const observerEntries = usage[OBSERVER_MEMORY_SYMBOLS.observationQueues.toString()] ?? 0; + + replayBuffers += replayEntries; + observerBuffers += observerEntries; + totalMemoryBytes += + SUBJECT_OVERHEAD_BYTES + + replayEntries * BUFFER_ENTRY_BYTES + + observerEntries * BUFFER_ENTRY_BYTES; } for (const tracked of this.#trackedObservers.values()) { const observer = tracked.observer.deref(); - if (observer) { - tracked.lastAccessedAt = Date.now(); - totalMemoryBytes += this.#estimateObjectSize(observer); + if (!observer) { + continue; } + + tracked.lastAccessedAt = Date.now(); + totalMemoryBytes += OBSERVER_OVERHEAD_BYTES; } return { subjects: this.#trackedSubjects.size, observers: this.#trackedObservers.size, - replayBuffers: replayBufferSize, - observerBuffers: observerBufferSize, + replayBuffers, + observerBuffers, totalMemoryBytes, }; } @@ -126,36 +145,34 @@ export class MemoryManager implements IMemoryManager { return { subjectsCleared: 0, observersCleared: 0, memoryFreed: 0 }; } - const initialMemory = this.getMemoryUsage(); + const initialMemory = this.getMemoryUsage().totalMemoryBytes; let subjectsCleared = 0; let observersCleared = 0; + const now = Date.now(); + const idleThresholdMs = 5 * 60 * 1000; - for (const [subjectId, tracked] of this.#trackedSubjects.entries()) { + for (const [subjectId, tracked] of this.#trackedSubjects) { const subject = tracked.subject.deref(); if (!subject) { this.#trackedSubjects.delete(subjectId); subjectsCleared++; - } else { - const now = Date.now(); - const inactiveTime = now - tracked.lastAccessedAt; - const fiveMinutes = 5 * 60 * 1000; - - if ( - (subject.isCompleted() || !subject.getObserverCount()) && - inactiveTime > fiveMinutes - ) { - try { - subject.dispose(); - this.#trackedSubjects.delete(subjectId); - subjectsCleared++; - } catch { - // ignore disposal errors - } - } + continue; + } + + if ( + (subject.isCompleted() || subject.getObserverCount() === 0) && + now - tracked.lastAccessedAt > idleThresholdMs + ) { + try { + subject.dispose(); + } catch {} + + this.#trackedSubjects.delete(subjectId); + subjectsCleared++; } } - for (const [observerId, tracked] of this.#trackedObservers.entries()) { + for (const [observerId, tracked] of this.#trackedObservers) { const observer = tracked.observer.deref(); if (!observer || !observer.isActive) { this.#trackedObservers.delete(observerId); @@ -163,20 +180,14 @@ export class MemoryManager implements IMemoryManager { } } - if (global.gc) { - global.gc(); - } + runtimeGc(); - const finalMemory = this.getMemoryUsage(); - const memoryFreed = Math.max( - 0, - initialMemory.totalMemoryBytes - finalMemory.totalMemoryBytes - ); + const finalMemory = this.getMemoryUsage().totalMemoryBytes; return { subjectsCleared, observersCleared, - memoryFreed, + memoryFreed: Math.max(0, initialMemory - finalMemory), }; } @@ -196,11 +207,11 @@ export class MemoryManager implements IMemoryManager { } getTrackedSubjects(): ReadonlyArray { - return Array.from(this.#trackedSubjects.keys()); + return [...this.#trackedSubjects.keys()]; } getTrackedObservers(): ReadonlyArray { - return Array.from(this.#trackedObservers.keys()); + return [...this.#trackedObservers.keys()]; } getSubjectAccessTime(subjectId: SubjectId): number | undefined { @@ -220,42 +231,39 @@ export class MemoryManager implements IMemoryManager { memoryPressure: 'low' | 'medium' | 'high'; } { let deadSubjects = 0; + let deadObservers = 0; let inactiveSubjects = 0; + let inactiveObservers = 0; const now = Date.now(); - const inactiveThreshold = 10 * 60 * 1000; // 10 minutes + const inactivityThreshold = 10 * 60 * 1000; for (const tracked of this.#trackedSubjects.values()) { const subject = tracked.subject.deref(); if (!subject) { deadSubjects++; - } else if (now - tracked.lastAccessedAt > inactiveThreshold) { + } else if (now - tracked.lastAccessedAt > inactivityThreshold) { inactiveSubjects++; } } - let deadObservers = 0; - let inactiveObservers = 0; - for (const tracked of this.#trackedObservers.values()) { const observer = tracked.observer.deref(); if (!observer) { deadObservers++; - } else if (now - tracked.lastAccessedAt > inactiveThreshold) { + } else if (now - tracked.lastAccessedAt > inactivityThreshold) { inactiveObservers++; } } const totalTracked = this.#trackedSubjects.size + this.#trackedObservers.size; + const usage = this.getMemoryUsage().totalMemoryBytes / (1024 * 1024); const deadRatio = (deadSubjects + deadObservers) / Math.max(1, totalTracked); - - let memoryPressure: 'low' | 'medium' | 'high'; - if (deadRatio > 0.3) { - memoryPressure = 'high'; - } else if (deadRatio > 0.1) { - memoryPressure = 'medium'; - } else { - memoryPressure = 'low'; - } + const memoryPressure: 'low' | 'medium' | 'high' = + usage > this.#autoGcThresholdMb || deadRatio > 0.3 + ? 'high' + : usage > this.#autoGcThresholdMb * 0.5 || deadRatio > 0.1 + ? 'medium' + : 'low'; return { deadSubjects, @@ -267,62 +275,10 @@ export class MemoryManager implements IMemoryManager { }; } - async #runAutomaticGc(thresholdMb: number): Promise { - const usage = this.getMemoryUsage(); - const usageMb = usage.totalMemoryBytes / (1024 * 1024); - - if (usageMb > thresholdMb) { + async #runAutomaticGc(): Promise { + const usageMb = this.getMemoryUsage().totalMemoryBytes / (1024 * 1024); + if (usageMb > this.#autoGcThresholdMb) { await this.runGarbageCollection(); } } - - #estimateObjectSize(obj: any): number { - let size = 0; - - if (obj === null || obj === undefined) { - return 0; - } - - switch (typeof obj) { - case 'boolean': - return 4; - case 'number': - return 8; - case 'string': - return obj.length * 2; - case 'object': - if (obj instanceof Array) { - size = 24; - for (const item of obj) { - size += this.#estimateObjectSize(item); - } - } else if (obj instanceof Map) { - size = 32; - for (const [key, value] of obj) { - size += this.#estimateObjectSize(key); - size += this.#estimateObjectSize(value); - } - } else if (obj instanceof Set) { - size = 32; - for (const item of obj) { - size += this.#estimateObjectSize(item); - } - } else { - size = 16; - for (const key in obj) { - if (obj.hasOwnProperty(key)) { - size += key.length * 2; - size += this.#estimateObjectSize(obj[key]); - } - } - } - break; - case 'function': - return 100; - default: - return 8; - } - - return size; - } } diff --git a/web/packages/observer/src/operators.ts b/web/packages/observer/src/operators.ts index 437378d2..877dc0e5 100644 --- a/web/packages/observer/src/operators.ts +++ b/web/packages/observer/src/operators.ts @@ -1,144 +1,347 @@ import { ObserverCallback, - UnobserveFn, - ObserverId, - SubjectId, ObserverOptions, + SubjectId, IObservableSubject, + UnobserveFn, } from './definition'; -import { IObserverChain, ISubjectGroup, IObserverConnection } from './interfaces'; -import { Subject } from './subject'; import { createSubject } from './factory'; +import { IObserverChain, IObserverConnection, ISubjectGroup } from './interfaces'; + +type TimeoutHandle = ReturnType; + +type FilterOperation = { + readonly type: 'filter'; + readonly predicate: (data: unknown, subject: IObservableSubject) => boolean; +}; + +type MapOperation = { + readonly type: 'map'; + readonly transform: (data: unknown, subject: IObservableSubject) => unknown; +}; + +type DebounceOperation = { + readonly type: 'debounce'; + readonly ms: number; +}; + +type ThrottleOperation = { + readonly type: 'throttle'; + readonly ms: number; +}; + +type BufferOperation = { + readonly type: 'buffer'; + readonly maxSize: number; + readonly flushIntervalMs: number; +}; + +type TakeOperation = { + readonly type: 'take'; + readonly count: number; +}; + +type TakeUntilOperation = { + readonly type: 'takeUntil'; + readonly predicate: (data: unknown, subject: IObservableSubject) => boolean; +}; + +type ChainOperation = + | FilterOperation + | MapOperation + | DebounceOperation + | ThrottleOperation + | BufferOperation + | TakeOperation + | TakeUntilOperation; + +const isPromiseLike = (value: unknown): value is PromiseLike => + typeof value === 'object' && + value !== null && + 'then' in value && + typeof (value as PromiseLike).then === 'function'; + +const attachCleanup = (subject: IObservableSubject, cleanup: () => void): IObservableSubject => { + const originalDispose = subject.dispose.bind(subject); + let cleaned = false; + + subject.dispose = () => { + if (!cleaned) { + cleaned = true; + cleanup(); + } + + originalDispose(); + }; + + return subject; +}; export class ObserverChain implements IObserverChain { - readonly #subject: IObservableSubject; - readonly #operations: Array<{ - type: 'filter' | 'map' | 'debounce' | 'throttle' | 'buffer' | 'take' | 'takeUntil'; - fn?: Function; - value?: any; - }> = []; + readonly #subject: IObservableSubject; + readonly #operations: ChainOperation[] = []; constructor(subject: IObservableSubject) { - this.#subject = subject; + this.#subject = subject as IObservableSubject; } filter(predicate: (data: T, subject: IObservableSubject) => boolean): IObserverChain { - this.#operations.push({ type: 'filter', fn: predicate }); + this.#operations.push({ + type: 'filter', + predicate: predicate as FilterOperation['predicate'], + }); return this; } map(transform: (data: T, subject: IObservableSubject) => U): IObserverChain { - this.#operations.push({ type: 'map', fn: transform }); - return this as any; + this.#operations.push({ + type: 'map', + transform: transform as MapOperation['transform'], + }); + return this as unknown as IObserverChain; } debounce(ms: number): IObserverChain { - this.#operations.push({ type: 'debounce', value: ms }); + this.#operations.push({ + type: 'debounce', + ms: Math.max(0, Math.floor(ms)), + }); return this; } throttle(ms: number): IObserverChain { - this.#operations.push({ type: 'throttle', value: ms }); + this.#operations.push({ + type: 'throttle', + ms: Math.max(0, Math.floor(ms)), + }); return this; } buffer(maxSize: number, flushIntervalMs: number): IObserverChain { - this.#operations.push({ type: 'buffer', value: { maxSize, flushIntervalMs } }); - return this as any; + this.#operations.push({ + type: 'buffer', + maxSize: Math.max(1, Math.floor(maxSize)), + flushIntervalMs: Math.max(1, Math.floor(flushIntervalMs)), + }); + return this as unknown as IObserverChain; } take(count: number): IObserverChain { - this.#operations.push({ type: 'take', value: count }); + this.#operations.push({ + type: 'take', + count: Math.max(0, Math.floor(count)), + }); return this; } takeUntil(predicate: (data: T, subject: IObservableSubject) => boolean): IObserverChain { - this.#operations.push({ type: 'takeUntil', fn: predicate }); + this.#operations.push({ + type: 'takeUntil', + predicate: predicate as TakeUntilOperation['predicate'], + }); return this; } subscribe(callback: ObserverCallback): UnobserveFn { - const options: Partial = {}; - let transformFn: Function | undefined; - let filterFn: Function | undefined; - let takeCount = 0; - let takeUntilFn: Function | undefined; - - for (const op of this.#operations) { - switch (op.type) { - case 'filter': - filterFn = this.#combineFilters(filterFn, op.fn!); - break; - case 'map': - transformFn = this.#combineTransforms(transformFn, op.fn!); - break; - case 'debounce': - (options as any).debounceMs = op.value; - break; - case 'throttle': - (options as any).throttleMs = op.value; - break; - case 'buffer': - (options as any).buffering = { - enabled: true, - maxSize: op.value.maxSize, - flushIntervalMs: op.value.flushIntervalMs, - }; - break; - case 'take': - takeCount = op.value; - break; - case 'takeUntil': - takeUntilFn = op.fn!; - break; + const operations = this.#operations.slice(); + const debounceTimers = new Map(); + const throttleTimes = new Map(); + const takeRemaining = new Map(); + const bufferStates = new Map< + number, + { + values: unknown[]; + stopAfter: boolean; + timer?: TimeoutHandle; } - } + >(); + let active = true; + let unsubscribe: UnobserveFn | undefined; - if (filterFn) { - (options as any).filter = filterFn; + for (let index = 0; index < operations.length; index++) { + const operation = operations[index]; + if (operation?.type === 'take') { + takeRemaining.set(index, operation.count); + } else if (operation?.type === 'buffer') { + bufferStates.set(index, { + values: [], + stopAfter: false, + }); + } } - if (transformFn) { - (options as any).transform = transformFn; - } + const cleanup = (): void => { + for (const timer of debounceTimers.values()) { + clearTimeout(timer); + } - let wrappedCallback = callback; - let callCount = 0; - let unsubscribe: UnobserveFn | undefined; + debounceTimers.clear(); - if (takeCount > 0 || takeUntilFn) { - wrappedCallback = (data: T, subject: IObservableSubject) => { - callCount++; + for (const state of bufferStates.values()) { + if (state.timer) { + clearTimeout(state.timer); + state.timer = undefined; + } + state.values.length = 0; + state.stopAfter = false; + } - if (takeUntilFn && takeUntilFn(data, subject)) { - unsubscribe?.(); + bufferStates.clear(); + throttleTimes.clear(); + }; + + const stop = (): boolean => { + if (!active) { + return false; + } + + active = false; + cleanup(); + const release = unsubscribe; + unsubscribe = undefined; + return release ? release() : false; + }; + + const finalize = ( + value: unknown, + subject: IObservableSubject, + stopAfter: boolean + ): void => { + try { + const result = (callback as ObserverCallback)(value, subject); + if (isPromiseLike(result)) { + void Promise.resolve(result).catch(() => undefined); + } + } finally { + if (stopAfter) { + stop(); + } + } + }; + + const process = ( + index: number, + value: unknown, + subject: IObservableSubject, + stopAfter: boolean + ): void => { + if (!active) { + return; + } + + if (index >= operations.length) { + finalize(value, subject, stopAfter); + return; + } + + const operation = operations[index]; + if (!operation) { + finalize(value, subject, stopAfter); + return; + } + + switch (operation.type) { + case 'filter': + if (!operation.predicate(value, subject)) { + return; + } + process(index + 1, value, subject, stopAfter); + return; + + case 'map': + process(index + 1, operation.transform(value, subject), subject, stopAfter); + return; + + case 'debounce': { + const existingTimer = debounceTimers.get(index); + if (existingTimer) { + clearTimeout(existingTimer); + } + + const timer = setTimeout(() => { + debounceTimers.delete(index); + process(index + 1, value, subject, stopAfter); + }, operation.ms); + + debounceTimers.set(index, timer); return; } - callback(data, subject); + case 'throttle': { + const now = Date.now(); + const lastExecution = throttleTimes.get(index) ?? 0; + if (now - lastExecution < operation.ms) { + return; + } - if (takeCount > 0 && callCount >= takeCount) { - unsubscribe?.(); + throttleTimes.set(index, now); + process(index + 1, value, subject, stopAfter); + return; } - }; - } - unsubscribe = this.#subject.addObserver(wrappedCallback, options); - return unsubscribe; - } + case 'buffer': { + const state = bufferStates.get(index); + if (!state) { + return; + } + + state.values.push(value); + state.stopAfter = state.stopAfter || stopAfter; + + const flush = (): void => { + if (!active || state.values.length === 0) { + return; + } + + const batch = state.values.slice(); + const nextStopAfter = state.stopAfter; + state.values.length = 0; + state.stopAfter = false; + if (state.timer) { + clearTimeout(state.timer); + state.timer = undefined; + } + process(index + 1, batch, subject, nextStopAfter); + }; - #combineFilters(existing: Function | undefined, newFilter: Function): Function { - if (!existing) return newFilter; - return (data: any, subject: any) => existing(data, subject) && newFilter(data, subject); - } + if (state.values.length >= operation.maxSize) { + flush(); + return; + } + + if (!state.timer) { + state.timer = setTimeout(flush, operation.flushIntervalMs); + } + return; + } - #combineTransforms(existing: Function | undefined, newTransform: Function): Function { - if (!existing) return newTransform; - return async (data: any, subject: any) => { - const result = existing(data, subject); - const intermediate = result instanceof Promise ? await result : result; - return newTransform(intermediate, subject); + case 'take': { + const remaining = takeRemaining.get(index) ?? 0; + if (remaining <= 0) { + stop(); + return; + } + + takeRemaining.set(index, remaining - 1); + process(index + 1, value, subject, stopAfter || remaining === 1); + return; + } + + case 'takeUntil': + if (operation.predicate(value, subject)) { + stop(); + return; + } + process(index + 1, value, subject, stopAfter); + return; + } }; + + unsubscribe = this.#subject.addObserver((data, subject) => { + process(0, data, subject, false); + }); + + return stop; } } @@ -146,7 +349,7 @@ export class SubjectGroup implements ISubjectGroup { readonly #subjects = new Map>(); get subjects(): ReadonlyArray> { - return Array.from(this.#subjects.values()); + return [...this.#subjects.values()]; } add(subject: IObservableSubject): void { @@ -162,74 +365,66 @@ export class SubjectGroup implements ISubjectGroup { } async notifyAll(data: T): Promise { - const promises = Array.from(this.#subjects.values()).map((subject) => subject.notify(data)); - return Promise.all(promises); + return Promise.all([...this.#subjects.values()].map((subject) => subject.notify(data))); } notifyAllSync(data: T): boolean[] { - return Array.from(this.#subjects.values()).map((subject) => subject.notifySync(data)); + return [...this.#subjects.values()].map((subject) => subject.notifySync(data)); } async completeAll(): Promise { - const promises = Array.from(this.#subjects.values()).map((subject) => subject.complete()); - await Promise.all(promises); + await Promise.all([...this.#subjects.values()].map((subject) => subject.complete())); } disposeAll(): void { for (const subject of this.#subjects.values()) { subject.dispose(); } + this.#subjects.clear(); } - addObserver(observer: ObserverCallback, options?: ObserverOptions): UnobserveFn[] { - const unsubscribers = Array.from(this.#subjects.values()).map((subject) => - subject.addObserver(observer, options) - ); - - return unsubscribers; + addObserver(observer: ObserverCallback, options?: ObserverOptions): UnobserveFn[] { + return [...this.#subjects.values()].map((subject) => subject.addObserver(observer, options)); } merge(): IObservableSubject { - const mergedSubject = createSubject(); - const unsubscribers: UnobserveFn[] = []; - - for (const subject of this.#subjects.values()) { - const unsubscribe = subject.addObserver((data) => { - mergedSubject.notify(data); - }); - unsubscribers.push(unsubscribe); - } - - const originalDispose = mergedSubject.dispose.bind(mergedSubject); - mergedSubject.dispose = () => { - unsubscribers.forEach((unsub) => unsub()); - originalDispose(); - }; + const merged = createSubject(); + const unsubs = [...this.#subjects.values()].map((subject) => + subject.addObserver((data) => { + void merged.notify(data).catch(() => undefined); + }) + ); - return mergedSubject; + return attachCleanup(merged, () => { + for (const unsubscribe of unsubs) { + unsubscribe(); + } + }); } combineLatest(): IObservableSubject { - const combinedSubject = createSubject(); - const latestValues = new Map(); - const hasEmitted = new Set(); - - for (const subject of this.#subjects.values()) { + const combined = createSubject(); + const subjects = [...this.#subjects.values()]; + const latest = new Map(); + const emitted = new Set(); + const unsubs = subjects.map((subject) => subject.addObserver((data) => { - latestValues.set(subject.id, data); - hasEmitted.add(subject.id); - - if (hasEmitted.size === this.#subjects.size) { - const values = Array.from(this.#subjects.values()).map( - (s) => latestValues.get(s.id)! - ); - combinedSubject.notify(values); + latest.set(subject.id, data); + emitted.add(subject.id); + + if (emitted.size === subjects.length) { + const values = subjects.map((entry) => latest.get(entry.id) as T); + void combined.notify(values).catch(() => undefined); } - }); - } + }) + ); - return combinedSubject; + return attachCleanup(combined, () => { + for (const unsubscribe of unsubs) { + unsubscribe(); + } + }); } } @@ -240,7 +435,7 @@ export class ObserverConnection readonly target: IObservableSubject; readonly transform?: (data: TSource) => TTarget | Promise; #unsubscribe?: UnobserveFn; - #isConnected = false; + #connected = false; constructor( source: IObservableSubject, @@ -253,34 +448,35 @@ export class ObserverConnection } get isConnected(): boolean { - return this.#isConnected; + return this.#connected; } connect(): void { - if (this.#isConnected) { + if (this.#connected) { return; } this.#unsubscribe = this.source.addObserver(async (data) => { try { - const transformedData = this.transform ? await this.transform(data) : (data as any); - await this.target.notify(transformedData); + const next = this.transform ? await this.transform(data) : (data as unknown as TTarget); + await this.target.notify(next); } catch (error) { - await this.target.error(error as Error); + await this.target.error(error instanceof Error ? error : new Error(String(error))); } }); - this.#isConnected = true; + this.#connected = true; } disconnect(): void { - if (!this.#isConnected || !this.#unsubscribe) { + if (!this.#connected) { return; } - this.#unsubscribe(); + this.#connected = false; + const unsubscribe = this.#unsubscribe; this.#unsubscribe = undefined; - this.#isConnected = false; + unsubscribe?.(); } dispose(): void { @@ -313,9 +509,17 @@ export function pipe( transform: (data: T) => U | Promise ): IObservableSubject { const target = createSubject(); - const connection = connect(source, target, transform); - connection.connect(); - return target; + const unsubscribe = source.addObserver(async (data) => { + try { + await target.notify(await transform(data)); + } catch (error) { + await target.error(error instanceof Error ? error : new Error(String(error))); + } + }); + + return attachCleanup(target, () => { + unsubscribe(); + }); } export function merge(...subjects: IObservableSubject[]): IObservableSubject { @@ -330,7 +534,16 @@ export function filter( source: IObservableSubject, predicate: (data: T) => boolean ): IObservableSubject { - return pipe(source, (data) => (predicate(data) ? data : (undefined as any))); + const target = createSubject(); + const unsubscribe = source.addObserver((data) => { + if (predicate(data)) { + void target.notify(data).catch(() => undefined); + } + }); + + return attachCleanup(target, () => { + unsubscribe(); + }); } export function map( @@ -342,29 +555,42 @@ export function map( export function debounce(source: IObservableSubject, ms: number): IObservableSubject { const target = createSubject(); - let timeoutId: ReturnType; + let timer: TimeoutHandle | undefined; + const unsubscribe = source.addObserver((data) => { + if (timer) { + clearTimeout(timer); + } - source.addObserver((data) => { - clearTimeout(timeoutId); - timeoutId = setTimeout(() => { - target.notify(data); - }, ms); + timer = setTimeout(() => { + timer = undefined; + void target.notify(data).catch(() => undefined); + }, Math.max(0, Math.floor(ms))); }); - return target; + return attachCleanup(target, () => { + if (timer) { + clearTimeout(timer); + timer = undefined; + } + unsubscribe(); + }); } export function throttle(source: IObservableSubject, ms: number): IObservableSubject { const target = createSubject(); + const interval = Math.max(0, Math.floor(ms)); let lastEmission = 0; - - source.addObserver((data) => { + const unsubscribe = source.addObserver((data) => { const now = Date.now(); - if (now - lastEmission >= ms) { - lastEmission = now; - target.notify(data); + if (now - lastEmission < interval) { + return; } + + lastEmission = now; + void target.notify(data).catch(() => undefined); }); - return target; + return attachCleanup(target, () => { + unsubscribe(); + }); } diff --git a/web/packages/observer/src/registry.ts b/web/packages/observer/src/registry.ts index f6f13e96..a1c02555 100644 --- a/web/packages/observer/src/registry.ts +++ b/web/packages/observer/src/registry.ts @@ -1,74 +1,81 @@ import { + mergeObserverOptions, + normalizeObserverOptions, ObserverCallback, - UnobserveFn, ObserverId, - SubjectId, ObserverOptions, + PRIORITY_VALUES, + SubjectId, IObservableSubject, OBSERVER_MEMORY_SYMBOLS, } from './definition'; -import { ObserverNotFoundError } from './errors'; import { IObserverRegistry, IObserverSubscription, IMemoryManager } from './interfaces'; interface RegisteredObserver extends IObserverSubscription { - readonly unsubscribe: UnobserveFn; + readonly unsubscribe: () => boolean; } export class ObserverRegistry implements IObserverRegistry { readonly #observers = new Map(); readonly #subjectObservers = new Map>(); readonly #memoryManager?: IMemoryManager; + readonly #disposeMemoryManager: boolean; #isDisposed = false; constructor( options: { enableMemoryTracking?: boolean; memoryManager?: IMemoryManager; + disposeMemoryManager?: boolean; } = {} ) { if (options.enableMemoryTracking && options.memoryManager) { this.#memoryManager = options.memoryManager; } + + this.#disposeMemoryManager = options.disposeMemoryManager === true; } - register( + register | undefined = undefined>( subject: IObservableSubject, - observer: ObserverCallback, - options: ObserverOptions = {} + observer: ObserverCallback, + options?: TOptions ): ObserverId { this.#throwIfDisposed(); const unsubscribe = subject.addObserver(observer, options); + const resolvedOptions = normalizeObserverOptions(mergeObserverOptions({}, options ?? {})); const observerId = Symbol('RegisteredObserver'); - const registeredObserver: RegisteredObserver = { + const registeredObserver: RegisteredObserver = { id: observerId, callback: observer, - options: options as Required, + options: resolvedOptions, createdAt: Date.now(), executionCount: 0, + lastExecuted: undefined, isActive: true, subject, - priority: 1, - isDebounced: (options.debounceMs ?? 0) > 0, - isThrottled: (options.throttleMs ?? 0) > 0, - hasFilter: !!options.filter, - hasTransform: !!options.transform, - bufferSize: options.buffering?.maxSize ?? 0, - replayEnabled: options.replay?.enabled ?? false, + priority: PRIORITY_VALUES[resolvedOptions.priority], + isDebounced: resolvedOptions.debounceMs > 0, + isThrottled: resolvedOptions.throttleMs > 0, + hasFilter: typeof resolvedOptions.filter === 'function', + hasTransform: typeof resolvedOptions.transform === 'function', + bufferSize: resolvedOptions.buffering.enabled ? resolvedOptions.buffering.maxSize : 0, + replayEnabled: resolvedOptions.replay.enabled, unsubscribe, }; this.#observers.set(observerId, registeredObserver); - if (!this.#subjectObservers.has(subject.id)) { - this.#subjectObservers.set(subject.id, new Set()); + let observerIds = this.#subjectObservers.get(subject.id); + if (!observerIds) { + observerIds = new Set(); + this.#subjectObservers.set(subject.id, observerIds); } - this.#subjectObservers.get(subject.id)!.add(observerId); + observerIds.add(observerId); - if (this.#memoryManager) { - this.#memoryManager.trackObserver(registeredObserver); - } + this.#memoryManager?.trackObserver(registeredObserver); return observerId; } @@ -80,7 +87,7 @@ export class ObserverRegistry implements IObserverRegistry { } observer.unsubscribe(); - + (observer as { isActive: boolean }).isActive = false; this.#observers.delete(observerId); const subjectObservers = this.#subjectObservers.get(observer.subject.id); @@ -91,32 +98,26 @@ export class ObserverRegistry implements IObserverRegistry { } } - if (this.#memoryManager) { - this.#memoryManager.untrackObserver(observerId); - } - + this.#memoryManager?.untrackObserver(observerId); return true; } - unregisterByCallback( - subject: IObservableSubject, - observer: ObserverCallback - ): boolean { + unregisterByCallback(subject: IObservableSubject, observer: ObserverCallback): boolean { const subjectObservers = this.#subjectObservers.get(subject.id); - if (!subjectObservers) { + if (!subjectObservers || subjectObservers.size === 0) { return false; } - let unregistered = false; - for (const observerId of subjectObservers) { - const registeredObserver = this.#observers.get(observerId); - if (registeredObserver && registeredObserver.callback === observer) { - this.unregister(observerId); - unregistered = true; + let removed = false; + + for (const observerId of [...subjectObservers]) { + const registered = this.#observers.get(observerId); + if (registered?.callback === observer) { + removed = this.unregister(observerId) || removed; } } - return unregistered; + return removed; } getObserver(observerId: ObserverId): IObserverSubscription | undefined { @@ -130,6 +131,7 @@ export class ObserverRegistry implements IObserverRegistry { } const observers: IObserverSubscription[] = []; + for (const observerId of observerIds) { const observer = this.#observers.get(observerId); if (observer) { @@ -142,11 +144,13 @@ export class ObserverRegistry implements IObserverRegistry { getActiveObserverCount(): number { let activeCount = 0; + for (const observer of this.#observers.values()) { if (observer.isActive) { activeCount++; } } + return activeCount; } @@ -155,8 +159,10 @@ export class ObserverRegistry implements IObserverRegistry { } clear(): void { - for (const observer of this.#observers.values()) { + for (const [observerId, observer] of this.#observers) { observer.unsubscribe(); + this.#memoryManager?.untrackObserver(observerId); + (observer as { isActive: boolean }).isActive = false; } this.#observers.clear(); @@ -171,8 +177,8 @@ export class ObserverRegistry implements IObserverRegistry { this.#isDisposed = true; this.clear(); - if (this.#memoryManager) { - this.#memoryManager.dispose(); + if (this.#disposeMemoryManager) { + this.#memoryManager?.dispose(); } } @@ -184,25 +190,53 @@ export class ObserverRegistry implements IObserverRegistry { } getObserversByPriority(priority: number): ReadonlyArray { - return Array.from(this.#observers.values()).filter( - (observer) => observer.priority === priority - ); + const result: IObserverSubscription[] = []; + for (const observer of this.#observers.values()) { + if (observer.priority === priority) { + result.push(observer); + } + } + return result; } getObserversWithFilters(): ReadonlyArray { - return Array.from(this.#observers.values()).filter((observer) => observer.hasFilter); + const result: IObserverSubscription[] = []; + for (const observer of this.#observers.values()) { + if (observer.hasFilter) { + result.push(observer); + } + } + return result; } getObserversWithTransforms(): ReadonlyArray { - return Array.from(this.#observers.values()).filter((observer) => observer.hasTransform); + const result: IObserverSubscription[] = []; + for (const observer of this.#observers.values()) { + if (observer.hasTransform) { + result.push(observer); + } + } + return result; } getDebounceObservers(): ReadonlyArray { - return Array.from(this.#observers.values()).filter((observer) => observer.isDebounced); + const result: IObserverSubscription[] = []; + for (const observer of this.#observers.values()) { + if (observer.isDebounced) { + result.push(observer); + } + } + return result; } getThrottledObservers(): ReadonlyArray { - return Array.from(this.#observers.values()).filter((observer) => observer.isThrottled); + const result: IObserverSubscription[] = []; + for (const observer of this.#observers.values()) { + if (observer.isThrottled) { + result.push(observer); + } + } + return result; } validateRegistry(): { @@ -213,14 +247,14 @@ export class ObserverRegistry implements IObserverRegistry { inactiveObservers: number; } { const issues: string[] = []; - let activeCount = 0; - let inactiveCount = 0; + let activeObservers = 0; + let inactiveObservers = 0; - for (const [observerId, observer] of this.#observers.entries()) { + for (const [observerId, observer] of this.#observers) { if (observer.isActive) { - activeCount++; + activeObservers++; } else { - inactiveCount++; + inactiveObservers++; issues.push(`Observer ${String(observerId)} is inactive but still registered`); } @@ -229,7 +263,7 @@ export class ObserverRegistry implements IObserverRegistry { } } - for (const [subjectId, observerIds] of this.#subjectObservers.entries()) { + for (const [subjectId, observerIds] of this.#subjectObservers) { for (const observerId of observerIds) { if (!this.#observers.has(observerId)) { issues.push( @@ -243,8 +277,8 @@ export class ObserverRegistry implements IObserverRegistry { isHealthy: issues.length === 0, issues, totalObservers: this.#observers.size, - activeObservers: activeCount, - inactiveObservers: inactiveCount, + activeObservers, + inactiveObservers, }; } diff --git a/web/packages/observer/src/subject.ts b/web/packages/observer/src/subject.ts index c983b965..40cb45f1 100644 --- a/web/packages/observer/src/subject.ts +++ b/web/packages/observer/src/subject.ts @@ -1,127 +1,268 @@ -import { EventEmitter, IEventEmitter } from '@axrone/event'; -import { PriorityQueue } from '@axrone/utility'; import { + createNotificationData, + createObserverId, + createSubjectId, + normalizeObserverOptions, + normalizeSubjectOptions, + NotificationData, ObserverCallback, - UnobserveFn, + ObserverEmission, ObserverId, - SubjectId, ObserverOptions, + PRIORITY_VALUES, + SubjectId, SubjectOptions, - NotificationData, - NotificationType, IObservableSubject, - IObserver, - DEFAULT_OBSERVER_OPTIONS, - DEFAULT_SUBJECT_OPTIONS, - PRIORITY_VALUES, OBSERVER_MEMORY_SYMBOLS, + NormalizedObserverOptions, + NormalizedSubjectOptions, + UnobserveFn, } from './definition'; import { - SubjectError, - SubjectCompletedError, - SubjectDisposedError, - MaxObserversExceededError, - ObserverExecutionError, - ValidationError, ConcurrencyLimitError, FilterError, + MaxObserversExceededError, + ObserverExecutionError, + SubjectCompletedError, + SubjectDisposedError, TransformError, + ValidationError, } from './errors'; import { - IObserverSubscription, - IObserverMetrics, - ISubjectMetrics, - ISubjectLifecycle, IObserverBuffer, + IObserverSubscription, IReplayBuffer, + ISubjectLifecycle, + ISubjectMetrics, } from './interfaces'; -interface InternalObserver extends IObserver { - readonly priority: number; - debounceTimer?: ReturnType; - throttleLastExecution?: number; - buffer?: IObserverBuffer; - readonly filter?: (data: T, subject: IObservableSubject) => boolean; - readonly transform?: (data: T, subject: IObservableSubject) => any | Promise; - executionCount: number; - lastExecuted?: number; - isActive: boolean; - weakRef?: WeakRef>; -} +type TimeoutHandle = ReturnType; + +const performanceNow = (): number => + typeof performance !== 'undefined' && typeof performance.now === 'function' + ? performance.now() + : Date.now(); + +const scheduleTask = + typeof queueMicrotask === 'function' + ? queueMicrotask.bind(globalThis) + : (callback: () => void): void => { + void Promise.resolve().then(callback); + }; + +const isPromiseLike = (value: unknown): value is PromiseLike => + typeof value === 'object' && + value !== null && + 'then' in value && + typeof (value as PromiseLike).then === 'function'; -class ObserverBuffer implements IObserverBuffer { - private readonly buffer: NotificationData[] = []; - private readonly maxSize: number; +const normalizeError = (error: unknown): Error => + error instanceof Error ? error : new Error(typeof error === 'string' ? error : String(error)); - constructor(maxSize: number = 100) { +class RingBuffer { + readonly maxSize: number; + readonly #items: Array; + #start = 0; + #size = 0; + + constructor(maxSize: number) { this.maxSize = maxSize; + this.#items = new Array(maxSize); } - add(data: NotificationData): void { - if (this.buffer.length >= this.maxSize) { - this.buffer.shift(); + add(value: T): void { + if (this.maxSize === 0) { + return; + } + + if (this.#size < this.maxSize) { + this.#items[(this.#start + this.#size) % this.maxSize] = value; + this.#size++; + return; + } + + this.#items[this.#start] = value; + this.#start = (this.#start + 1) % this.maxSize; + } + + toArray(): T[] { + const result = new Array(this.#size); + + for (let index = 0; index < this.#size; index++) { + result[index] = this.#items[(this.#start + index) % this.maxSize] as T; } - this.buffer.push(data); + + return result; + } + + last(count: number): T[] { + if (count <= 0 || this.#size === 0) { + return []; + } + + const size = count >= this.#size ? this.#size : count; + const result = new Array(size); + const offset = this.#size - size; + + for (let index = 0; index < size; index++) { + result[index] = this.#items[ + (this.#start + offset + index) % this.maxSize + ] as T; + } + + return result; + } + + takeAll(): T[] { + const snapshot = this.toArray(); + this.clear(); + return snapshot; + } + + clear(): void { + for (let index = 0; index < this.#size; index++) { + this.#items[(this.#start + index) % this.maxSize] = undefined; + } + + this.#start = 0; + this.#size = 0; + } + + size(): number { + return this.#size; + } + + isFull(): boolean { + return this.#size >= this.maxSize; + } +} + +class NotificationBuffer implements IObserverBuffer { + readonly #buffer: RingBuffer>; + + constructor(maxSize: number) { + this.#buffer = new RingBuffer(maxSize); + } + + add(data: NotificationData): void { + this.#buffer.add(data); } flush(): ReadonlyArray> { - const items = [...this.buffer]; - this.buffer.length = 0; - return items; + return this.#buffer.takeAll(); } clear(): void { - this.buffer.length = 0; + this.#buffer.clear(); } size(): number { - return this.buffer.length; + return this.#buffer.size(); } isFull(): boolean { - return this.buffer.length >= this.maxSize; + return this.#buffer.isFull(); } getAll(): ReadonlyArray> { - return [...this.buffer]; + return this.#buffer.toArray(); } } class ReplayBuffer implements IReplayBuffer { - private readonly buffer: T[] = []; - public readonly maxSize: number; + readonly #buffer: RingBuffer; + readonly maxSize: number; - constructor(maxSize: number = 10) { + constructor(maxSize: number) { this.maxSize = maxSize; + this.#buffer = new RingBuffer(maxSize); } add(data: T): void { - if (this.buffer.length >= this.maxSize) { - this.buffer.shift(); - } - this.buffer.push(data); + this.#buffer.add(data); } getAll(): ReadonlyArray { - return [...this.buffer]; + return this.#buffer.toArray(); } getLast(count: number): ReadonlyArray { - const startIndex = Math.max(0, this.buffer.length - count); - return this.buffer.slice(startIndex); + return this.#buffer.last(count); } clear(): void { - this.buffer.length = 0; + this.#buffer.clear(); } size(): number { - return this.buffer.length; + return this.#buffer.size(); + } +} + +class ObserverRecord implements IObserverSubscription { + readonly id: ObserverId; + readonly createdAt: number; + readonly subject: IObservableSubject; + readonly priority: number; + readonly isDebounced: boolean; + readonly isThrottled: boolean; + readonly hasFilter: boolean; + readonly hasTransform: boolean; + readonly bufferSize: number; + readonly replayEnabled: boolean; + readonly options: NormalizedObserverOptions; + executionCount = 0; + lastExecuted?: number; + isActive = true; + debounceTimer?: TimeoutHandle; + bufferTimer?: TimeoutHandle; + buffer?: RingBuffer; + notificationBuffer?: NotificationBuffer; + throttleLastExecution = 0; + readonly #strongCallback?: ObserverCallback; + readonly #weakCallback?: WeakRef>; + + constructor( + subject: IObservableSubject, + callback: ObserverCallback, + options: NormalizedObserverOptions, + useWeakReference: boolean + ) { + this.id = createObserverId(); + this.createdAt = Date.now(); + this.subject = subject; + this.priority = PRIORITY_VALUES[options.priority]; + this.isDebounced = options.debounceMs > 0; + this.isThrottled = options.throttleMs > 0; + this.hasFilter = typeof options.filter === 'function'; + this.hasTransform = typeof options.transform === 'function'; + this.bufferSize = options.buffering.enabled ? options.buffering.maxSize : 0; + this.replayEnabled = options.replay.enabled; + this.options = options; + + if (useWeakReference && typeof WeakRef === 'function') { + this.#weakCallback = new WeakRef(callback); + } else { + this.#strongCallback = callback; + } + + if (options.buffering.enabled) { + this.buffer = new RingBuffer(options.buffering.maxSize); + this.notificationBuffer = new NotificationBuffer(options.buffering.maxSize); + } + } + + get callback(): ObserverCallback { + return (this.#strongCallback ?? this.#weakCallback?.deref() ?? (() => undefined)) as ObserverCallback; + } + + resolveCallback(): ObserverCallback | undefined { + return this.#strongCallback ?? this.#weakCallback?.deref(); } } export interface ISubject extends IObservableSubject { - readonly options: Required; + readonly options: NormalizedSubjectOptions; readonly metrics: ISubjectMetrics; readonly lifecycle?: ISubjectLifecycle; setLifecycle(lifecycle: ISubjectLifecycle): void; @@ -131,210 +272,173 @@ export interface ISubject extends IObservableSubject { } export class Subject implements ISubject { - readonly #id: SubjectId = Symbol('Subject'); - readonly #observers = new Map>(); - readonly #options: Required; - readonly #eventEmitter: IEventEmitter; - #replayBuffer?: ReplayBuffer; - #isCompleted = false; - #isDisposed = false; - #lastError?: Error; - #lifecycle?: ISubjectLifecycle; - #gcIntervalId?: ReturnType; - #concurrentNotifications = 0; - - #metrics: { - notificationCount: number; - errorCount: number; - createdAt: number; - completedAt?: number; - lastNotificationAt?: number; - notificationTimings: number[]; - }; - - constructor(options: SubjectOptions = {}) { - this.#options = { ...DEFAULT_SUBJECT_OPTIONS, ...options }; - this.#eventEmitter = new EventEmitter(); - - this.#metrics = { - notificationCount: 0, - errorCount: 0, - createdAt: Date.now(), - notificationTimings: [], - }; - - if (this.#options.replay.enabled) { - this.#replayBuffer = new ReplayBuffer(this.#options.replay.bufferSize); + readonly id: SubjectId = createSubjectId(); + protected readonly _options: NormalizedSubjectOptions; + protected readonly _observers = new Map>(); + protected readonly _buckets: [ObserverRecord[], ObserverRecord[], ObserverRecord[]] = + [[], [], []]; + protected _replayBuffer?: ReplayBuffer; + protected _isCompleted = false; + protected _isDisposed = false; + protected _lastError?: Error; + protected _lifecycle?: ISubjectLifecycle; + protected _gcIntervalId?: ReturnType; + protected _concurrentNotifications = 0; + protected _notificationCount = 0; + protected _errorCount = 0; + protected readonly _createdAt = Date.now(); + protected _completedAt?: number; + protected _lastNotificationAt?: number; + protected _totalNotificationTime = 0; + protected _notificationDepth = 0; + protected _needsCompaction = false; + + constructor(options: SubjectOptions = {}) { + this._options = normalizeSubjectOptions(options); + + if (this._options.replay.enabled) { + this._replayBuffer = new ReplayBuffer(this._options.replay.bufferSize); } - if ( - this.#options.memoryManagement.enabled && - this.#options.memoryManagement.gcIntervalMs > 0 - ) { - this.#startGarbageCollection(); + if (this._options.memoryManagement.enabled && this._options.memoryManagement.gcIntervalMs > 0) { + this._gcIntervalId = setInterval(() => { + this._runGarbageCollection(); + }, this._options.memoryManagement.gcIntervalMs); } } - get id(): SubjectId { - return this.#id; - } - - get options(): Required { - return { ...this.#options }; + get options(): NormalizedSubjectOptions { + return this._options; } get metrics(): ISubjectMetrics { - const avgTime = - this.#metrics.notificationTimings.length > 0 - ? this.#metrics.notificationTimings.reduce((a, b) => a + b, 0) / - this.#metrics.notificationTimings.length - : 0; - return { - notificationCount: this.#metrics.notificationCount, - observerCount: this.#observers.size, - errorCount: this.#metrics.errorCount, - completedAt: this.#metrics.completedAt, - createdAt: this.#metrics.createdAt, - averageNotificationTime: avgTime, - totalNotificationTime: this.#metrics.notificationTimings.reduce((a, b) => a + b, 0), - lastNotificationAt: this.#metrics.lastNotificationAt, - replayBufferSize: this.#replayBuffer?.size() ?? 0, - isCompleted: this.#isCompleted, - isErrored: !!this.#lastError, + notificationCount: this._notificationCount, + observerCount: this._observers.size, + errorCount: this._errorCount, + completedAt: this._completedAt, + createdAt: this._createdAt, + averageNotificationTime: + this._notificationCount === 0 + ? 0 + : this._totalNotificationTime / this._notificationCount, + totalNotificationTime: this._totalNotificationTime, + lastNotificationAt: this._lastNotificationAt, + replayBufferSize: this._replayBuffer?.size() ?? 0, + isCompleted: this._isCompleted, + isErrored: this._lastError !== undefined, }; } get lifecycle(): ISubjectLifecycle | undefined { - return this.#lifecycle; + return this._lifecycle; } setLifecycle(lifecycle: ISubjectLifecycle): void { - this.#lifecycle = lifecycle; + this._lifecycle = lifecycle; } async notify(data: T): Promise { - this.#throwIfDisposed(); + this._assertNotDisposed(); - if (this.#isCompleted) { - throw new SubjectCompletedError(this.#id); + if (this._isCompleted) { + throw new SubjectCompletedError(this.id); } - const startTime = performance.now(); + const startedAt = performanceNow(); try { - if (this.#options.validation.enabled && this.#options.validation.validator) { - if (!this.#options.validation.validator(data)) { - throw new ValidationError('Data validation failed', data, this.#id); - } - } + this._validateData(data); + this._enterConcurrencyWindow(); - if (this.#options.concurrency.enabled) { - if (this.#concurrentNotifications >= this.#options.concurrency.maxConcurrent) { - throw new ConcurrencyLimitError( - this.#options.concurrency.maxConcurrent, - this.#concurrentNotifications, - this.#id - ); - } - this.#concurrentNotifications++; - } - - if (this.#lifecycle?.onBeforeNotify) { - const shouldContinue = await this.#lifecycle.onBeforeNotify(data, this); + if (this._lifecycle?.onBeforeNotify) { + const shouldContinue = await this._lifecycle.onBeforeNotify(data, this); if (!shouldContinue) { return false; } } - if (this.#replayBuffer) { - this.#replayBuffer.add(data); - } + this._replayBuffer?.add(data); - const notificationPromises: Promise[] = []; - const observerArray = Array.from(this.#observers.values()); + let pending: Promise[] | undefined; - for (const observer of observerArray) { - if (!observer.isActive) continue; + this._notificationDepth++; - const notificationPromise = this.#notifyObserver(observer, data); - notificationPromises.push(notificationPromise); + try { + this._forEachObserver((observer) => { + const task = this._notifyObserverAsync(observer, data); + if (task) { + (pending ??= []).push(task); + } + }); + } finally { + this._notificationDepth--; + this._compactObserversIfNeeded(); } - await Promise.allSettled(notificationPromises); - - const endTime = performance.now(); - const executionTime = endTime - startTime; - this.#metrics.notificationCount++; - this.#metrics.lastNotificationAt = Date.now(); - this.#metrics.notificationTimings.push(executionTime); - - if (this.#metrics.notificationTimings.length > 100) { - this.#metrics.notificationTimings = this.#metrics.notificationTimings.slice(-100); + if (pending) { + await Promise.allSettled(pending); } - if (this.#lifecycle?.onAfterNotify) { - await this.#lifecycle.onAfterNotify(data, this, true); + this._notificationCount++; + this._lastNotificationAt = Date.now(); + this._totalNotificationTime += performanceNow() - startedAt; + + if (this._lifecycle?.onAfterNotify) { + await this._lifecycle.onAfterNotify(data, this, true); } return true; } catch (error) { - this.#metrics.errorCount++; + this._errorCount++; - if (this.#lifecycle?.onAfterNotify) { - await this.#lifecycle.onAfterNotify(data, this, false); + if (this._lifecycle?.onAfterNotify) { + await this._lifecycle.onAfterNotify(data, this, false); } - if (this.#options.errorPropagation) { + if (this._options.errorPropagation) { throw error; } return false; } finally { - if (this.#options.concurrency.enabled) { - this.#concurrentNotifications--; - } + this._leaveConcurrencyWindow(); } } notifySync(data: T): boolean { - this.#throwIfDisposed(); + this._assertNotDisposed(); - if (this.#isCompleted) { - throw new SubjectCompletedError(this.#id); + if (this._isCompleted) { + throw new SubjectCompletedError(this.id); } - const startTime = performance.now(); + const startedAt = performanceNow(); try { - if (this.#options.validation.enabled && this.#options.validation.validator) { - if (!this.#options.validation.validator(data)) { - throw new ValidationError('Data validation failed', data, this.#id); - } + this._validateData(data); + this._replayBuffer?.add(data); + + this._notificationDepth++; + + try { + this._forEachObserver((observer) => { + this._notifyObserverSync(observer, data); + }); + } finally { + this._notificationDepth--; + this._compactObserversIfNeeded(); } - if (this.#replayBuffer) { - this.#replayBuffer.add(data); - } - - const observerArray = Array.from(this.#observers.values()); - - for (const observer of observerArray) { - if (!observer.isActive) continue; - this.#notifyObserverSync(observer, data); - } - - const endTime = performance.now(); - const executionTime = endTime - startTime; - this.#metrics.notificationCount++; - this.#metrics.lastNotificationAt = Date.now(); - this.#metrics.notificationTimings.push(executionTime); + this._notificationCount++; + this._lastNotificationAt = Date.now(); + this._totalNotificationTime += performanceNow() - startedAt; return true; } catch (error) { - this.#metrics.errorCount++; + this._errorCount++; - if (this.#options.errorPropagation) { + if (this._options.errorPropagation) { throw error; } @@ -343,435 +447,856 @@ export class Subject implements ISubject { } async complete(): Promise { - this.#throwIfDisposed(); + this._assertNotDisposed(); - if (this.#isCompleted) { + if (this._isCompleted) { return; } - this.#isCompleted = true; - this.#metrics.completedAt = Date.now(); + this._isCompleted = true; + this._completedAt = Date.now(); - const notificationData: NotificationData = { - timestamp: Date.now(), - data: undefined, - type: 'complete', - source: this.#id, - }; + this._notificationDepth++; + + try { + this._forEachObserver((observer) => { + const callback = this._resolveObserverCallback(observer); + if (!callback) { + return; + } - for (const observer of this.#observers.values()) { - if (observer.isActive) { try { - await observer.callback(undefined as any, this); - } catch (error) {} - } + const result = callback(undefined as never, this); + if (isPromiseLike(result)) { + void Promise.resolve(result).catch(() => undefined); + } + } catch {} + }); + } finally { + this._notificationDepth--; + this._compactObserversIfNeeded(); } - if (this.#lifecycle?.onComplete) { - await this.#lifecycle.onComplete(this); + if (this._lifecycle?.onComplete) { + await this._lifecycle.onComplete(this); } - if (this.#options.autoComplete) { + if (this._options.autoComplete) { this.dispose(); } } async error(error: Error): Promise { - this.#throwIfDisposed(); + this._assertNotDisposed(); - this.#lastError = error; - this.#metrics.errorCount++; + this._lastError = error; + this._errorCount++; - const notificationData: NotificationData = { - timestamp: Date.now(), - data: error, - type: 'error', - source: this.#id, - }; + this._notificationDepth++; + + try { + this._forEachObserver((observer) => { + const callback = this._resolveObserverCallback(observer); + if (!callback) { + return; + } - for (const observer of this.#observers.values()) { - if (observer.isActive) { try { if (observer.options.errorHandling === 'callback' && observer.options.onError) { - observer.options.onError(error, notificationData.data, this); - } else if (observer.options.errorHandling === 'throw') { - await observer.callback(error as any, this); + observer.options.onError(error, error, this); + return; } - } catch (executionError) {} - } - } - - if (this.#lifecycle?.onError) { - await this.#lifecycle.onError(error, this); - } - } - - addObserver(observer: ObserverCallback, options: ObserverOptions = {}): UnobserveFn { - this.#throwIfDisposed(); - if (this.#isCompleted) { - throw new SubjectCompletedError(this.#id); - } - - if (this.#observers.size >= this.#options.maxObservers) { - throw new MaxObserversExceededError( - this.#options.maxObservers, - this.#observers.size, - this.#id - ); - } - - const observerId = Symbol('Observer'); - const mergedOptions = { ...DEFAULT_OBSERVER_OPTIONS, ...options }; - - const internalObserver: InternalObserver = { - id: observerId, - callback: observer, - options: mergedOptions as Required, - createdAt: Date.now(), - executionCount: 0, - isActive: true, - priority: PRIORITY_VALUES[mergedOptions.priority], - filter: options.filter, - transform: options.transform, - }; - - if (mergedOptions.weakReference) { - internalObserver.weakRef = new WeakRef(observer); - } - - if (mergedOptions.buffering.enabled) { - internalObserver.buffer = new ObserverBuffer(mergedOptions.buffering.maxSize); - } - - this.#observers.set(observerId, internalObserver); - - if (this.#replayBuffer && mergedOptions.replay.enabled) { - const replayData = this.#replayBuffer.getLast(mergedOptions.replay.bufferSize); - for (const data of replayData) { - setTimeout(() => { - if (internalObserver.isActive) { - this.#notifyObserver(internalObserver, data).catch(() => {}); + if (observer.options.errorHandling === 'throw') { + const result = callback(error as never, this); + if (isPromiseLike(result)) { + void Promise.resolve(result).catch(() => undefined); + } } - }, 0); - } + } catch {} + }); + } finally { + this._notificationDepth--; + this._compactObserversIfNeeded(); } - if (this.#lifecycle?.onObserverAdded) { - this.#lifecycle.onObserverAdded(internalObserver as IObserverSubscription, this); + if (this._lifecycle?.onError) { + await this._lifecycle.onError(error, this); } + } - return () => this.removeObserverById(observerId); + addObserver | undefined = undefined>( + observer: ObserverCallback>, + options?: TOptions + ): UnobserveFn { + const record = this._addObserverRecord( + observer as ObserverCallback, + options as ObserverOptions | undefined + ); + return this._createUnobserve(record.id); } - removeObserver(observer: ObserverCallback): boolean { - for (const [id, internalObserver] of this.#observers.entries()) { - if (internalObserver.callback === observer) { - return this.removeObserverById(id); + removeObserver(observer: ObserverCallback): boolean { + for (const record of this._observers.values()) { + const callback = record.resolveCallback(); + if (callback === observer) { + return this.removeObserverById(record.id); } } + return false; } removeObserverById(observerId: ObserverId): boolean { - const observer = this.#observers.get(observerId); - if (!observer) { + const record = this._observers.get(observerId); + if (!record) { return false; } - observer.isActive = false; - - if (observer.debounceTimer) { - clearTimeout(observer.debounceTimer); - } - - this.#observers.delete(observerId); - - if (this.#lifecycle?.onObserverRemoved) { - this.#lifecycle.onObserverRemoved(observerId, this); - } - + this._deactivateObserver(record, true); return true; } - hasObserver(observer: ObserverCallback): boolean { - for (const internalObserver of this.#observers.values()) { - if (internalObserver.callback === observer) { + hasObserver(observer: ObserverCallback): boolean { + for (const record of this._observers.values()) { + if (record.resolveCallback() === observer) { return true; } } + return false; } getObserverCount(): number { - return this.#observers.size; + return this._observers.size; } isCompleted(): boolean { - return this.#isCompleted; + return this._isCompleted; } isErrored(): boolean { - return !!this.#lastError; + return this._lastError !== undefined; } getLastError(): Error | undefined { - return this.#lastError; + return this._lastError; } getReplayBuffer(): ReadonlyArray { - return this.#replayBuffer?.getAll() ?? []; + return this._replayBuffer?.getAll() ?? []; } clearReplayBuffer(): void { - this.#replayBuffer?.clear(); + this._replayBuffer?.clear(); } getMemoryUsage(): Record { - const usage: Record = {}; + let bufferedNotifications = 0; - usage[OBSERVER_MEMORY_SYMBOLS.observerMap.toString()] = this.#observers.size; - usage[OBSERVER_MEMORY_SYMBOLS.replayBuffers.toString()] = this.#replayBuffer?.size() ?? 0; - - let bufferCount = 0; - for (const observer of this.#observers.values()) { - if (observer.buffer) { - bufferCount += observer.buffer.size(); - } + for (const observer of this._observers.values()) { + bufferedNotifications += observer.buffer?.size() ?? 0; } - usage[OBSERVER_MEMORY_SYMBOLS.observationQueues.toString()] = bufferCount; - return usage; + return { + [OBSERVER_MEMORY_SYMBOLS.observerMap.toString()]: this._observers.size, + [OBSERVER_MEMORY_SYMBOLS.replayBuffers.toString()]: this._replayBuffer?.size() ?? 0, + [OBSERVER_MEMORY_SYMBOLS.observationQueues.toString()]: bufferedNotifications, + }; } dispose(): void { - if (this.#isDisposed) { + if (this._isDisposed) { return; } - this.#isDisposed = true; + this._isDisposed = true; + + if (this._gcIntervalId) { + clearInterval(this._gcIntervalId); + this._gcIntervalId = undefined; + } - for (const observer of this.#observers.values()) { + for (const observer of this._observers.values()) { + this._cleanupObserver(observer); observer.isActive = false; - if (observer.debounceTimer) { - clearTimeout(observer.debounceTimer); + } + + this._observers.clear(); + this._buckets[0].length = 0; + this._buckets[1].length = 0; + this._buckets[2].length = 0; + this._replayBuffer?.clear(); + + if (this._lifecycle?.onDispose) { + const result = this._lifecycle.onDispose(this); + if (isPromiseLike(result)) { + void Promise.resolve(result).catch(() => undefined); } } - this.#observers.clear(); + } - this.#replayBuffer?.clear(); + protected _addObserverRecord( + observer: ObserverCallback, + options?: ObserverOptions + ): ObserverRecord { + this._assertNotDisposed(); - if (this.#gcIntervalId) { - clearInterval(this.#gcIntervalId); + if (this._isCompleted) { + throw new SubjectCompletedError(this.id); } - this.#eventEmitter.dispose(); + if (this._observers.size >= this._options.maxObservers) { + throw new MaxObserversExceededError( + this._options.maxObservers, + this._observers.size, + this.id + ); + } - if (this.#lifecycle?.onDispose) { - const result = this.#lifecycle.onDispose(this); - if (result && typeof result.catch === 'function') { - result.catch(() => {}); - } + const normalizedOptions = normalizeObserverOptions(options); + const useWeakReference = + typeof WeakRef === 'function' && + (normalizedOptions.weakReference || this._options.memoryManagement.weakReferences); + const record = new ObserverRecord( + this, + observer, + normalizedOptions, + useWeakReference + ); + + this._observers.set(record.id, record); + this._buckets[record.priority].push(record); + + if (this._lifecycle?.onObserverAdded) { + this._lifecycle.onObserverAdded(record, this); } + + if (record.replayEnabled && this._replayBuffer) { + this._scheduleObserverValues( + record, + this._replayBuffer.getLast(record.options.replay.bufferSize) + ); + } + + return record; } - async #notifyObserver(observer: InternalObserver, data: T): Promise { - if (!observer.isActive) { + protected _createUnobserve(observerId: ObserverId): UnobserveFn { + return () => this.removeObserverById(observerId); + } + + protected _scheduleObserverValues(observer: ObserverRecord, values: ReadonlyArray): void { + if (values.length === 0) { return; } - try { - if (observer.weakRef) { - const callback = observer.weakRef.deref(); - if (!callback) { - observer.isActive = false; + for (const value of values) { + scheduleTask(() => { + if (!observer.isActive || this._isDisposed) { return; } - } - if (observer.filter) { - try { - const shouldNotify = observer.filter(data, this); - if (!shouldNotify) { - return; - } - } catch (error) { - throw new FilterError(error as Error, observer.filter); + const task = this._notifyObserverAsync(observer, value); + if (task) { + void task; } + }); + } + } + + protected _assertNotDisposed(): void { + if (this._isDisposed) { + throw new SubjectDisposedError(this.id); + } + } + + protected _finalizeObserverExecution(observer: ObserverRecord): void { + observer.executionCount++; + observer.lastExecuted = Date.now(); + + if (observer.options.once) { + this._deactivateObserver(observer, true); + } + } + + protected _handleAsyncObserverFailure( + observer: ObserverRecord, + error: unknown, + data: unknown + ): void { + this._errorCount++; + const normalized = normalizeError(error); + + if (observer.options.errorHandling === 'callback' && observer.options.onError) { + try { + observer.options.onError(normalized, data as never, this); + } catch {} + } + } + + protected _handleSyncObserverFailure( + observer: ObserverRecord, + error: unknown, + data: unknown + ): never | void { + const normalized = normalizeError(error); + + if (observer.options.errorHandling === 'callback' && observer.options.onError) { + observer.options.onError(normalized, data as never, this); + return; + } + + if (observer.options.errorHandling === 'silent') { + return; + } + + throw new ObserverExecutionError( + observer.id, + normalized, + createNotificationData(this.id, 'update', data) + ); + } + + protected _notifyObserverAsync(observer: ObserverRecord, data: T): Promise | undefined { + if (!observer.isActive) { + return undefined; + } + + const callback = this._resolveObserverCallback(observer); + if (!callback) { + return undefined; + } + + try { + const filter = observer.options.filter; + if (filter && !filter(data, this)) { + return undefined; } + } catch (error) { + this._handleAsyncObserverFailure( + observer, + new FilterError(error, observer.options.filter as Function), + data + ); + return undefined; + } - let transformedData = data; - if (observer.transform) { - try { - transformedData = await observer.transform(data, this); - } catch (error) { - throw new TransformError(error as Error, observer.transform, data); + if (observer.options.transform) { + try { + const transformed = observer.options.transform(data, this); + + if (isPromiseLike(transformed)) { + return Promise.resolve(transformed).then( + (value) => this._dispatchObserverAsync(observer, value), + (error) => { + this._handleAsyncObserverFailure( + observer, + new TransformError(error, observer.options.transform as Function, data), + data + ); + } + ); } + + return this._dispatchObserverAsync(observer, transformed); + } catch (error) { + this._handleAsyncObserverFailure( + observer, + new TransformError(error, observer.options.transform, data), + data + ); + return undefined; } + } - if (observer.options.debounceMs > 0) { - if (observer.debounceTimer) { - clearTimeout(observer.debounceTimer); - } + return this._dispatchObserverAsync(observer, data); + } - (observer as any).debounceTimer = setTimeout(() => { - this.#executeObserver(observer, transformedData); - }, observer.options.debounceMs); - return; + protected _dispatchObserverAsync( + observer: ObserverRecord, + data: unknown + ): Promise | undefined { + if (!observer.isActive) { + return undefined; + } + + if (observer.buffer) { + this._enqueueBufferedValue(observer, data); + return undefined; + } + + if (observer.options.debounceMs > 0) { + if (observer.debounceTimer) { + clearTimeout(observer.debounceTimer); } - if (observer.options.throttleMs > 0) { - const now = Date.now(); - if ( - observer.throttleLastExecution && - now - observer.throttleLastExecution < observer.options.throttleMs - ) { - return; + observer.debounceTimer = setTimeout(() => { + observer.debounceTimer = undefined; + const task = this._invokeObserverAsync(observer, data); + if (task) { + void task; } - (observer as any).throttleLastExecution = now; + }, observer.options.debounceMs); + + return undefined; + } + + if (observer.options.throttleMs > 0) { + const now = Date.now(); + + if (now - observer.throttleLastExecution < observer.options.throttleMs) { + return undefined; } - await this.#executeObserver(observer, transformedData); - } catch (error) { - throw new ObserverExecutionError(observer.id, error as Error, { - timestamp: Date.now(), - data, - type: 'update', - source: this.#id, - }); + observer.throttleLastExecution = now; } + + return this._invokeObserverAsync(observer, data); } - #notifyObserverSync(observer: InternalObserver, data: T): void { + protected _invokeObserverAsync( + observer: ObserverRecord, + data: unknown + ): Promise | undefined { if (!observer.isActive) { - return; + return undefined; } - try { - if (observer.filter) { - const shouldNotify = observer.filter(data, this); - if (!shouldNotify) { - return; - } - } + const callback = this._resolveObserverCallback(observer); + if (!callback) { + return undefined; + } - let transformedData = data; - if (observer.transform) { - const result = observer.transform(data, this); - if (result instanceof Promise) { - throw new Error('Async transforms not supported in sync notification'); - } - transformedData = result; + try { + const result = callback(data, this); + + if (isPromiseLike(result)) { + return Promise.resolve(result).then( + () => { + this._finalizeObserverExecution(observer); + }, + (error) => { + this._handleAsyncObserverFailure(observer, error, data); + } + ); } - this.#executeObserverSync(observer, transformedData); + this._finalizeObserverExecution(observer); + return undefined; } catch (error) { - throw new ObserverExecutionError(observer.id, error as Error, { - timestamp: Date.now(), - data, - type: 'update', - source: this.#id, - }); + this._handleAsyncObserverFailure(observer, error, data); + return undefined; } } - async #executeObserver(observer: InternalObserver, data: T): Promise { - const startTime = performance.now(); + protected _notifyObserverSync(observer: ObserverRecord, data: T): void { + if (!observer.isActive) { + return; + } - try { - await observer.callback(data, this); - observer.executionCount++; - observer.lastExecuted = Date.now(); + this._resolveObserverCallback(observer); + if (!observer.isActive) { + return; + } - if (observer.options.once) { - observer.isActive = false; - this.#observers.delete(observer.id); + try { + const filter = observer.options.filter; + if (filter && !filter(data, this)) { + return; } } catch (error) { - if (observer.options.errorHandling === 'throw') { - throw error; - } else if (observer.options.errorHandling === 'callback' && observer.options.onError) { - observer.options.onError(error as Error, data, this); + this._handleSyncObserverFailure( + observer, + new FilterError(error, observer.options.filter as Function), + data + ); + return; + } + + let transformed: unknown = data; + + if (observer.options.transform) { + try { + const result = observer.options.transform(data, this); + if (isPromiseLike(result)) { + throw new TransformError( + new Error('Async transforms are not supported in synchronous notifications'), + observer.options.transform, + data + ); + } + + transformed = result; + } catch (error) { + this._handleSyncObserverFailure( + observer, + error instanceof TransformError + ? error + : new TransformError(error, observer.options.transform, data), + data + ); + return; } } - } - #executeObserverSync(observer: InternalObserver, data: T): void { + if (observer.buffer) { + this._enqueueBufferedValue(observer, transformed); + return; + } + if (observer.options.debounceMs > 0) { if (observer.debounceTimer) { clearTimeout(observer.debounceTimer); } - (observer as any).debounceTimer = setTimeout(() => { - this.#executeObserverSyncImmediate(observer, data); + observer.debounceTimer = setTimeout(() => { + observer.debounceTimer = undefined; + + try { + this._invokeObserverSyncImmediate(observer, transformed); + } catch {} }, observer.options.debounceMs); return; } if (observer.options.throttleMs > 0) { const now = Date.now(); - if ( - observer.throttleLastExecution && - now - observer.throttleLastExecution < observer.options.throttleMs - ) { + + if (now - observer.throttleLastExecution < observer.options.throttleMs) { return; } + observer.throttleLastExecution = now; } - this.#executeObserverSyncImmediate(observer, data); + this._invokeObserverSyncImmediate(observer, transformed); } - #executeObserverSyncImmediate(observer: InternalObserver, data: T): void { + protected _invokeObserverSyncImmediate(observer: ObserverRecord, data: unknown): void { + if (!observer.isActive) { + return; + } + + const callback = this._resolveObserverCallback(observer); + if (!callback) { + return; + } + try { - const result = observer.callback(data, this); - if (result instanceof Promise) { - result.catch(() => {}); + const result = callback(data, this); + + if (isPromiseLike(result)) { + void Promise.resolve(result).catch((error) => { + try { + this._handleSyncObserverFailure(observer, error, data); + } catch {} + }); } - observer.executionCount++; - observer.lastExecuted = Date.now(); + this._finalizeObserverExecution(observer); + } catch (error) { + this._handleSyncObserverFailure(observer, error, data); + } + } + + protected _resolveObserverCallback( + observer: ObserverRecord + ): ObserverCallback | undefined { + const callback = observer.resolveCallback(); - if (observer.options.once) { - observer.isActive = false; - this.#observers.delete(observer.id); + if (!callback) { + this._deactivateObserver(observer, false); + return undefined; + } + + return callback; + } + + protected _enqueueBufferedValue(observer: ObserverRecord, data: unknown): void { + observer.buffer?.add(data); + observer.notificationBuffer?.add(createNotificationData(this.id, 'update', data)); + + if (observer.buffer?.isFull()) { + this._flushObserverBuffer(observer); + return; + } + + if (!observer.bufferTimer) { + observer.bufferTimer = setTimeout(() => { + observer.bufferTimer = undefined; + this._flushObserverBuffer(observer); + }, observer.options.buffering.flushIntervalMs); + } + } + + protected _flushObserverBuffer(observer: ObserverRecord): void { + if (!observer.isActive || !observer.buffer || observer.buffer.size() === 0) { + return; + } + + if (observer.bufferTimer) { + clearTimeout(observer.bufferTimer); + observer.bufferTimer = undefined; + } + + const batch = observer.buffer.takeAll(); + observer.notificationBuffer?.flush(); + + const task = this._dispatchObserverAsync(observer, batch); + if (task) { + void task; + } + } + + protected _validateData(data: T): void { + const validator = this._options.validation.validator; + if (this._options.validation.enabled && validator && !validator(data)) { + throw new ValidationError('Data validation failed', data, this.id); + } + } + + protected _enterConcurrencyWindow(): void { + if (!this._options.concurrency.enabled) { + return; + } + + if (this._concurrentNotifications >= this._options.concurrency.maxConcurrent) { + throw new ConcurrencyLimitError( + this._options.concurrency.maxConcurrent, + this._concurrentNotifications, + this.id + ); + } + + this._concurrentNotifications++; + } + + protected _leaveConcurrencyWindow(): void { + if (!this._options.concurrency.enabled || this._concurrentNotifications === 0) { + return; + } + + this._concurrentNotifications--; + } + + protected _forEachObserver(visitor: (observer: ObserverRecord) => void): void { + for (let priority = 0; priority < this._buckets.length; priority++) { + const bucket = this._buckets[priority]; + + for (let index = 0; index < bucket.length; index++) { + const observer = bucket[index]; + + if (!observer || !observer.isActive) { + this._needsCompaction = true; + continue; + } + + visitor(observer); } - } catch (error) { - if (observer.options.errorHandling === 'throw') { - throw error; - } else if (observer.options.errorHandling === 'callback' && observer.options.onError) { - observer.options.onError(error as Error, data, this); + } + } + + protected _deactivateObserver(observer: ObserverRecord, notifyLifecycle: boolean): void { + if (!observer.isActive && !this._observers.has(observer.id)) { + return; + } + + observer.isActive = false; + this._cleanupObserver(observer); + this._observers.delete(observer.id); + + if (this._notificationDepth === 0) { + this._removeFromBucket(observer); + } else { + this._needsCompaction = true; + } + + if (notifyLifecycle && this._lifecycle?.onObserverRemoved) { + this._lifecycle.onObserverRemoved(observer.id, this); + } + } + + protected _cleanupObserver(observer: ObserverRecord): void { + if (observer.debounceTimer) { + clearTimeout(observer.debounceTimer); + observer.debounceTimer = undefined; + } + + if (observer.bufferTimer) { + clearTimeout(observer.bufferTimer); + observer.bufferTimer = undefined; + } + + observer.buffer?.clear(); + observer.notificationBuffer?.clear(); + } + + protected _removeFromBucket(observer: ObserverRecord): void { + const bucket = this._buckets[observer.priority]; + const index = bucket.findIndex((entry) => entry.id === observer.id); + + if (index >= 0) { + bucket.splice(index, 1); + } + } + + protected _compactObserversIfNeeded(): void { + if (this._notificationDepth !== 0 || !this._needsCompaction) { + return; + } + + for (let priority = 0; priority < this._buckets.length; priority++) { + const bucket = this._buckets[priority]; + let writeIndex = 0; + + for (let readIndex = 0; readIndex < bucket.length; readIndex++) { + const observer = bucket[readIndex]; + + if (observer && observer.isActive && this._observers.has(observer.id)) { + bucket[writeIndex++] = observer; + } + } + + bucket.length = writeIndex; + } + + this._needsCompaction = false; + } + + protected _runGarbageCollection(): void { + for (const observer of this._observers.values()) { + if (!observer.isActive || !observer.resolveCallback()) { + this._deactivateObserver(observer, false); } } + + this._compactObserversIfNeeded(); + } +} + +export class BehaviorSubject extends Subject { + #currentValue: T; + + constructor(initialValue: T, options: SubjectOptions = {}) { + super(options); + this.#currentValue = initialValue; } - #startGarbageCollection(): void { - this.#gcIntervalId = setInterval(() => { - this.#runGarbageCollection(); - }, this.#options.memoryManagement.gcIntervalMs); + get value(): T { + return this.#currentValue; } - #runGarbageCollection(): void { - for (const [id, observer] of this.#observers.entries()) { - if (!observer.isActive) { - this.#observers.delete(id); - continue; + override async notify(data: T): Promise { + this.#currentValue = data; + return super.notify(data); + } + + override notifySync(data: T): boolean { + this.#currentValue = data; + return super.notifySync(data); + } + + override addObserver | undefined = undefined>( + observer: ObserverCallback>, + options?: TOptions + ): UnobserveFn { + const record = this._addObserverRecord( + observer as ObserverCallback, + options as ObserverOptions | undefined + ); + scheduleTask(() => { + if (!record.isActive || this._isDisposed) { + return; } - if (observer.weakRef && !observer.weakRef.deref()) { - observer.isActive = false; - this.#observers.delete(id); + const task = this._notifyObserverAsync(record, this.#currentValue); + if (task) { + void task; } + }); + return this._createUnobserve(record.id); + } +} + +export class ReplaySubject extends Subject { + constructor(options: SubjectOptions = {}) { + super({ + ...options, + replay: { + enabled: true, + bufferSize: options.replay?.bufferSize, + }, + }); + } + + override addObserver | undefined = undefined>( + observer: ObserverCallback>, + options?: TOptions + ): UnobserveFn { + const replaySize = this.options.replay.bufferSize; + const mergedOptions = { + ...(options ?? {}), + replay: { + enabled: true, + bufferSize: + (options as ObserverOptions | undefined)?.replay?.bufferSize ?? + replaySize, + }, + } as TOptions; + + return super.addObserver(observer, mergedOptions); + } +} + +export class AsyncSubject extends Subject { + #lastValue?: T; + #hasValue = false; + + override async notify(data: T): Promise { + this._assertNotDisposed(); + + if (this._isCompleted) { + throw new SubjectCompletedError(this.id); } - if (this.#metrics.notificationTimings.length > 100) { - this.#metrics.notificationTimings = this.#metrics.notificationTimings.slice(-50); + this.#lastValue = data; + this.#hasValue = true; + return true; + } + + override notifySync(data: T): boolean { + this._assertNotDisposed(); + + if (this._isCompleted) { + throw new SubjectCompletedError(this.id); } + + this.#lastValue = data; + this.#hasValue = true; + return true; } - #throwIfDisposed(): void { - if (this.#isDisposed) { - throw new SubjectDisposedError(this.#id); + override async complete(): Promise { + this._assertNotDisposed(); + + if (this._isCompleted) { + return; + } + + if (this.#hasValue) { + await super.notify(this.#lastValue as T); + } + + this._isCompleted = true; + this._completedAt = Date.now(); + + if (this._lifecycle?.onComplete) { + await this._lifecycle.onComplete(this); + } + + if (this._options.autoComplete) { + this.dispose(); } } } diff --git a/web/packages/particle-system/package.json b/web/packages/particle-system/package.json index 7f01aec4..80ff5652 100644 --- a/web/packages/particle-system/package.json +++ b/web/packages/particle-system/package.json @@ -23,6 +23,7 @@ "dependencies": { "@axrone/event": "^0.1.0", "@axrone/geometry": "^0.1.0", + "@axrone/memory": "^0.0.1", "@axrone/numeric": "^0.0.1", "@axrone/random": "^0.0.1", "@axrone/utility": "^0.0.1" diff --git a/web/packages/particle-system/src/aligned-arrays.ts b/web/packages/particle-system/src/aligned-arrays.ts index 7e180d11..4e45d4cd 100644 --- a/web/packages/particle-system/src/aligned-arrays.ts +++ b/web/packages/particle-system/src/aligned-arrays.ts @@ -1,9 +1,11 @@ import { ICloneable } from '@axrone/utility'; +import type { + Brand, + NumericTypedArray as TypedArray, + NumericTypedArrayConstructor as TypedArrayConstructor, +} from '@axrone/utility'; import { IDisposable } from './disposable'; -declare const __brand: unique symbol; -export type Brand = T & { [__brand]: K }; - export type Alignment = 16 | 32 | 64; export type ComponentCount = 2 | 3 | 4; export type VectorType = 'vec2' | 'vec3' | 'vec4'; @@ -12,20 +14,6 @@ export type AlignedArrayBuffer = Brand; export type VectorIndex = Brand; export type ComponentIndex = Brand; -export interface TypedArrayConstructor { - new (buffer: ArrayBufferLike, byteOffset?: number, length?: number): TypedArray; - new (length: number): TypedArray; - readonly BYTES_PER_ELEMENT: number; -} - -export interface TypedArray extends ArrayBufferView { - readonly length: number; - [index: number]: number; - set(array: ArrayLike, offset?: number): void; - subarray(begin?: number, end?: number): TypedArray; - fill(value: number, start?: number, end?: number): void; -} - export class AlignedArrayError extends Error { readonly code: string; readonly context?: Record; diff --git a/web/packages/particle-system/src/core/interfaces.ts b/web/packages/particle-system/src/core/interfaces.ts index 19e3ed9f..5924adfe 100644 --- a/web/packages/particle-system/src/core/interfaces.ts +++ b/web/packages/particle-system/src/core/interfaces.ts @@ -1,3 +1,4 @@ +import type { EventMap } from '@axrone/event'; import type { Vec3, IVec3Like } from '@axrone/numeric'; import type { ParticleId, SystemId, ModuleId, EmitterId, TextureId } from '../types'; import type { @@ -189,12 +190,12 @@ export interface IParticleSystem extends ILifecycle, IUpdatable { killParticle(particleId: ParticleId): boolean; killAllParticles(): void; - addEventListener( + addEventListener( type: K, listener: (event: ParticleSystemEventMap[K]) => void ): void; - removeEventListener( + removeEventListener( type: K, listener: (event: ParticleSystemEventMap[K]) => void ): void; @@ -232,12 +233,14 @@ export interface ParticleCollisionEvent extends ParticleEvent { readonly impulse: number; } -export interface ParticleSystemEventMap { +export interface ParticleSystemEventMap extends EventMap { readonly birth: ParticleBirthEvent; readonly death: ParticleDeathEvent; readonly collision: ParticleCollisionEvent; } +export type ParticleSystemEventType = Extract; + export interface IMemoryManager { allocate(size: number, alignment?: number): ArrayBuffer | null; deallocate(buffer: ArrayBuffer): void; diff --git a/web/packages/particle-system/src/core/memory.ts b/web/packages/particle-system/src/core/memory.ts index 7a295e18..ed2a1688 100644 --- a/web/packages/particle-system/src/core/memory.ts +++ b/web/packages/particle-system/src/core/memory.ts @@ -4,7 +4,7 @@ import { TypedArrayPool as UtilityTypedArrayPool, TypedArrayPools, PoolableTypedArray, -} from '@axrone/utility'; +} from '@axrone/memory'; export class ParticleMemoryManager implements IMemoryManager { private readonly _alignedManager: AlignedMemoryManager; diff --git a/web/packages/particle-system/src/disposable.ts b/web/packages/particle-system/src/disposable.ts index 36b2f3fc..45b3f049 100644 --- a/web/packages/particle-system/src/disposable.ts +++ b/web/packages/particle-system/src/disposable.ts @@ -1,4 +1 @@ -export interface IDisposable { - dispose(): void; - readonly isDisposed: boolean; -} +export type { IDisposable } from '@axrone/utility'; diff --git a/web/packages/particle-system/src/interfaces.ts b/web/packages/particle-system/src/interfaces.ts index c1ecd241..13dbc74b 100644 --- a/web/packages/particle-system/src/interfaces.ts +++ b/web/packages/particle-system/src/interfaces.ts @@ -1,6 +1,6 @@ import { Vec3, IVec3Like, Mat4 } from '@axrone/numeric'; import { AABB3D } from '@axrone/geometry'; -import { MemoryPool, PoolableObject } from '@axrone/utility'; +import { MemoryPool, PoolableObject } from '@axrone/memory'; import { ParticleId, SystemId, diff --git a/web/packages/particle-system/src/particle-soa.ts b/web/packages/particle-system/src/particle-soa.ts index d02ee5fb..bf50db09 100644 --- a/web/packages/particle-system/src/particle-soa.ts +++ b/web/packages/particle-system/src/particle-soa.ts @@ -2,7 +2,7 @@ import { Vec3, IVec3Like } from '@axrone/numeric'; import { ParticleId } from './types'; import { IParticleSOA } from './interfaces'; import { ParticleMemoryManager } from './core/memory'; -import { TypedArrayPools, PoolableTypedArray } from '@axrone/utility'; +import { TypedArrayPools, PoolableTypedArray } from '@axrone/memory'; export interface ParticleSOAStats { capacity: number; diff --git a/web/packages/particle-system/src/particle-system.ts b/web/packages/particle-system/src/particle-system.ts index eb102d0c..b81e2844 100644 --- a/web/packages/particle-system/src/particle-system.ts +++ b/web/packages/particle-system/src/particle-system.ts @@ -7,6 +7,7 @@ import type { IParticleData, ISpatialIndex, ParticleSystemEventMap, + ParticleSystemEventType, ParticleBirthEvent, ParticleDeathEvent, ParticleCollisionEvent, @@ -308,14 +309,14 @@ export class ParticleSystem implements IParticleSystem { } } - addEventListener( + addEventListener( type: K, listener: (event: ParticleSystemEventMap[K]) => void ): void { this._eventEmitter.on(type, listener); } - removeEventListener( + removeEventListener( type: K, listener: (event: ParticleSystemEventMap[K]) => void ): void { diff --git a/web/packages/particle-system/src/spatial-grid.ts b/web/packages/particle-system/src/spatial-grid.ts index 6efe1899..014e75a0 100644 --- a/web/packages/particle-system/src/spatial-grid.ts +++ b/web/packages/particle-system/src/spatial-grid.ts @@ -1,5 +1,5 @@ import { AABB3D } from '@axrone/geometry'; -import { MemoryPool } from '@axrone/utility'; +import { MemoryPool } from '@axrone/memory'; import { Vec3, IVec3Like } from '@axrone/numeric'; import { ISpatialCell, ISpatialGrid } from './interfaces'; import { ParticleId } from './types'; diff --git a/web/packages/physics-3d/src/components/character-controller.ts b/web/packages/physics-3d/src/components/character-controller.ts index 9cb3a64d..bf66b988 100644 --- a/web/packages/physics-3d/src/components/character-controller.ts +++ b/web/packages/physics-3d/src/components/character-controller.ts @@ -10,6 +10,7 @@ import type { ShapeId3D, } from '../types'; import type { BodyManager3D, PhysicsWorld3D, ShapeManager3D } from '../core/physics-world-3d'; +import { syncTransformWorldPosition } from '../core/transform-sync'; import type { Rigidbody3D } from './rigidbody3d'; const enum CollisionFlags { @@ -157,7 +158,7 @@ export class CharacterController extends Component { const position = this._bodyManager.getPosition(this._bodyId); const newPos = this._performMove(position, motion); this._bodyManager.setPosition(this._bodyId, newPos); - if (this.transform) this.transform.worldPosition = Vec3.from(newPos); + syncTransformWorldPosition(this.transform, newPos); this._updateGroundState(); return this._collisionFlags; } @@ -196,7 +197,7 @@ export class CharacterController extends Component { } if (newX !== position.x || newY !== position.y || newZ !== position.z) { this._bodyManager.setPosition(this._bodyId, { x: newX, y: newY, z: newZ }); - if (this.transform) this.transform.worldPosition = new Vec3(newX, newY, newZ); + syncTransformWorldPosition(this.transform, { x: newX, y: newY, z: newZ }); this._velocity.x = (newX - position.x) / deltaTime; this._velocity.y = (newY - position.y) / deltaTime; this._velocity.z = (newZ - position.z) / deltaTime; diff --git a/web/packages/physics-3d/src/components/rigidbody3d.ts b/web/packages/physics-3d/src/components/rigidbody3d.ts index 7bea267b..a40d5447 100644 --- a/web/packages/physics-3d/src/components/rigidbody3d.ts +++ b/web/packages/physics-3d/src/components/rigidbody3d.ts @@ -7,6 +7,11 @@ import type { IPhysicsBodyDef3D, } from '../types'; import type { BodyManager3D, PhysicsWorld3D } from '../core/physics-world-3d'; +import { + syncTransformWorldPose, + syncTransformWorldPosition, + syncTransformWorldRotation, +} from '../core/transform-sync'; const enum Rigidbody3DType { Static = 0, @@ -276,9 +281,7 @@ export class Rigidbody3D extends Component { set position(value: IVec3Like) { if (!this._bodyManager || this._bodyId === -1) return; this._bodyManager.setPosition(this._bodyId, value); - if (this.transform) { - this.transform.worldPosition = Vec3.from(value); - } + syncTransformWorldPosition(this.transform, value); } get rotation(): Readonly { @@ -291,9 +294,7 @@ export class Rigidbody3D extends Component { set rotation(value: IQuatLike) { if (!this._bodyManager || this._bodyId === -1) return; this._bodyManager.setRotation(this._bodyId, value); - if (this.transform) { - this.transform.worldRotation = Quat.from(value); - } + syncTransformWorldRotation(this.transform, value); } get centerOfMass(): Readonly { @@ -683,11 +684,9 @@ export class Rigidbody3D extends Component { const alpha = 0.5; const lerpedPos = Vec3.lerp(this._previousPosition, pos, alpha); const slerpedRot = Quat.slerp(this._previousRotation, rot, alpha); - this.transform.worldPosition = Vec3.from(lerpedPos); - this.transform.worldRotation = Quat.from(slerpedRot); + syncTransformWorldPose(this.transform, lerpedPos, slerpedRot); } else { - this.transform.worldPosition = Vec3.from(pos); - this.transform.worldRotation = Quat.from(rot); + syncTransformWorldPose(this.transform, pos, rot); } } diff --git a/web/packages/physics-3d/src/core/transform-sync.ts b/web/packages/physics-3d/src/core/transform-sync.ts new file mode 100644 index 00000000..c40dc0ea --- /dev/null +++ b/web/packages/physics-3d/src/core/transform-sync.ts @@ -0,0 +1,124 @@ +import { Vec3, Quat, type IVec3Like, type IQuatLike } from '@axrone/numeric'; +import type { Transform } from '@axrone/ecs-runtime'; + +const TRANSFORM_SCALE_EPSILON = 1e-8; + +const positionScratch = Vec3.create(); +const rotationScratch = Quat.create(); +const inverseParentRotationScratch = Quat.create(); + +const copyInverseParentRotation = (worldRotation: Readonly): Quat => { + inverseParentRotationScratch.x = worldRotation.x; + inverseParentRotationScratch.y = worldRotation.y; + inverseParentRotationScratch.z = worldRotation.z; + inverseParentRotationScratch.w = worldRotation.w; + inverseParentRotationScratch.inverse(); + return inverseParentRotationScratch; +}; + +export const syncTransformWorldPosition = ( + transform: Transform | undefined, + value: Readonly +): void => { + if (!transform) { + return; + } + + const parent = transform.parent; + if (!parent) { + positionScratch.x = value.x; + positionScratch.y = value.y; + positionScratch.z = value.z; + transform.position = positionScratch; + return; + } + + const inverseParentRotation = copyInverseParentRotation(parent.worldRotation); + Vec3.subtract(value, parent.worldPosition, positionScratch); + inverseParentRotation.rotateVector(positionScratch, positionScratch); + + const parentScale = parent.worldScale; + positionScratch.x = + Math.abs(parentScale.x) > TRANSFORM_SCALE_EPSILON + ? positionScratch.x / parentScale.x + : 0; + positionScratch.y = + Math.abs(parentScale.y) > TRANSFORM_SCALE_EPSILON + ? positionScratch.y / parentScale.y + : 0; + positionScratch.z = + Math.abs(parentScale.z) > TRANSFORM_SCALE_EPSILON + ? positionScratch.z / parentScale.z + : 0; + + transform.position = positionScratch; +}; + +export const syncTransformWorldRotation = ( + transform: Transform | undefined, + value: Readonly +): void => { + if (!transform) { + return; + } + + const parent = transform.parent; + if (!parent) { + rotationScratch.x = value.x; + rotationScratch.y = value.y; + rotationScratch.z = value.z; + rotationScratch.w = value.w; + transform.rotation = rotationScratch; + return; + } + + Quat.multiply(copyInverseParentRotation(parent.worldRotation), value, rotationScratch); + transform.rotation = rotationScratch; +}; + +export const syncTransformWorldPose = ( + transform: Transform | undefined, + position: Readonly, + rotation: Readonly +): void => { + if (!transform) { + return; + } + + const parent = transform.parent; + if (!parent) { + positionScratch.x = position.x; + positionScratch.y = position.y; + positionScratch.z = position.z; + rotationScratch.x = rotation.x; + rotationScratch.y = rotation.y; + rotationScratch.z = rotation.z; + rotationScratch.w = rotation.w; + transform.position = positionScratch; + transform.rotation = rotationScratch; + return; + } + + const inverseParentRotation = copyInverseParentRotation(parent.worldRotation); + Vec3.subtract(position, parent.worldPosition, positionScratch); + inverseParentRotation.rotateVector(positionScratch, positionScratch); + + const parentScale = parent.worldScale; + positionScratch.x = + Math.abs(parentScale.x) > TRANSFORM_SCALE_EPSILON + ? positionScratch.x / parentScale.x + : 0; + positionScratch.y = + Math.abs(parentScale.y) > TRANSFORM_SCALE_EPSILON + ? positionScratch.y / parentScale.y + : 0; + positionScratch.z = + Math.abs(parentScale.z) > TRANSFORM_SCALE_EPSILON + ? positionScratch.z / parentScale.z + : 0; + + Quat.multiply(inverseParentRotation, rotation, rotationScratch); + + transform.position = positionScratch; + transform.rotation = rotationScratch; +}; \ No newline at end of file diff --git a/web/packages/random/src/distributions/bernoulli.ts b/web/packages/random/src/distributions/bernoulli.ts index e16308ba..aaf05dc7 100644 --- a/web/packages/random/src/distributions/bernoulli.ts +++ b/web/packages/random/src/distributions/bernoulli.ts @@ -1,12 +1,27 @@ import { IDistribution, IRandomState, RandomResult, DistributionSample } from '../types'; import { validateProbability } from '../constants'; import { createEngineFactory } from '../engines'; +import { + sampleManyFromDistribution, + sampleManyWithDistributionMetadata, + sampleWithDistributionMetadata, +} from '../internal/distribution-sampling'; export class BernoulliDistribution implements IDistribution { constructor(private readonly p: number = 0.5) { validateProbability(p, 'p'); } + private readonly _createSample = (value: boolean): DistributionSample => ({ + value, + metadata: { + p: this.p, + mean: this.mean(), + variance: this.variance(), + standardDeviation: this.standardDeviation(), + }, + }); + public sample = (state: IRandomState): RandomResult => { const engine = createEngineFactory(state.engine)(); engine.setState(state); @@ -16,58 +31,17 @@ export class BernoulliDistribution implements IDistribution { return [value, engine.getState()]; }; - public sampleMany = (state: IRandomState, count: number): RandomResult => { - if (count <= 0 || !Number.isInteger(count)) { - throw new RangeError('Count must be a positive integer'); - } - - const result: boolean[] = []; - let currentState = state; - - for (let i = 0; i < count; i++) { - const [value, nextState] = this.sample(currentState); - result.push(value); - currentState = nextState; - } - - return [result, currentState]; - }; - - public sampleWithMetadata = ( - state: IRandomState - ): RandomResult> => { - const [value, nextState] = this.sample(state); - - const sample: DistributionSample = { - value, - metadata: { - p: this.p, - mean: this.mean(), - variance: this.variance(), - standardDeviation: this.standardDeviation(), - }, - }; + public sampleMany = (state: IRandomState, count: number): RandomResult => + sampleManyFromDistribution(state, count, this.sample); - return [sample, nextState]; - }; + public sampleWithMetadata = (state: IRandomState): RandomResult> => + sampleWithDistributionMetadata(state, this.sample, this._createSample); public sampleManyWithMetadata = ( state: IRandomState, count: number - ): RandomResult[]> => { - const [values, nextState] = this.sampleMany(state, count); - const samples = values.map((value) => ({ - value, - metadata: { - p: this.p, - mean: this.mean(), - variance: this.variance(), - standardDeviation: this.standardDeviation(), - }, - })); - - return [samples, nextState]; - }; + ): RandomResult[]> => + sampleManyWithDistributionMetadata(state, count, this.sampleMany, this._createSample); public probability = (x: boolean | number): number => { const val = typeof x === 'boolean' ? x : x === 1; diff --git a/web/packages/random/src/distributions/binomial.ts b/web/packages/random/src/distributions/binomial.ts index e52b0be6..f3a77016 100644 --- a/web/packages/random/src/distributions/binomial.ts +++ b/web/packages/random/src/distributions/binomial.ts @@ -1,6 +1,11 @@ import { IDistribution, IRandomState, RandomResult, DistributionSample } from '../types'; import { validateNonNegative, validateInteger, validateProbability } from '../constants'; import { createEngineFactory } from '../engines'; +import { + sampleManyFromDistribution, + sampleManyWithDistributionMetadata, + sampleWithDistributionMetadata, +} from '../internal/distribution-sampling'; import { NormalDistribution } from './normal'; export class BinomialDistribution implements IDistribution { @@ -13,6 +18,17 @@ export class BinomialDistribution implements IDistribution { validateProbability(p, 'p'); } + private readonly _createSample = (value: number): DistributionSample => ({ + value, + metadata: { + n: this.n, + p: this.p, + mean: this.mean(), + variance: this.variance(), + standardDeviation: this.standardDeviation(), + }, + }); + public sample = (state: IRandomState): RandomResult => { const engine = createEngineFactory(state.engine)(); engine.setState(state); @@ -43,58 +59,17 @@ export class BinomialDistribution implements IDistribution { return [value, engine.getState()]; }; - public sampleMany = (state: IRandomState, count: number): RandomResult => { - if (count <= 0 || !Number.isInteger(count)) { - throw new RangeError('Count must be a positive integer'); - } + public sampleMany = (state: IRandomState, count: number): RandomResult => + sampleManyFromDistribution(state, count, this.sample); - const result: number[] = []; - let currentState = state; - - for (let i = 0; i < count; i++) { - const [value, nextState] = this.sample(currentState); - result.push(value); - currentState = nextState; - } - - return [result, currentState]; - }; - - public sampleWithMetadata = (state: IRandomState): RandomResult> => { - const [value, nextState] = this.sample(state); - - const sample: DistributionSample = { - value, - metadata: { - n: this.n, - p: this.p, - mean: this.mean(), - variance: this.variance(), - standardDeviation: this.standardDeviation(), - }, - }; - - return [sample, nextState]; - }; + public sampleWithMetadata = (state: IRandomState): RandomResult> => + sampleWithDistributionMetadata(state, this.sample, this._createSample); public sampleManyWithMetadata = ( state: IRandomState, count: number - ): RandomResult[]> => { - const [values, nextState] = this.sampleMany(state, count); - const samples = values.map((value) => ({ - value, - metadata: { - n: this.n, - p: this.p, - mean: this.mean(), - variance: this.variance(), - standardDeviation: this.standardDeviation(), - }, - })); - - return [samples, nextState]; - }; + ): RandomResult[]> => + sampleManyWithDistributionMetadata(state, count, this.sampleMany, this._createSample); public probability = (k: number | boolean): number => { const val = typeof k === 'boolean' ? (k ? 1 : 0) : k; diff --git a/web/packages/random/src/distributions/exponential.ts b/web/packages/random/src/distributions/exponential.ts index c9be6386..e73f1c83 100644 --- a/web/packages/random/src/distributions/exponential.ts +++ b/web/packages/random/src/distributions/exponential.ts @@ -1,12 +1,27 @@ import { IDistribution, IRandomState, RandomResult, DistributionSample } from '../types'; import { validatePositive } from '../constants'; import { createEngineFactory } from '../engines'; +import { + sampleManyFromDistribution, + sampleManyWithDistributionMetadata, + sampleWithDistributionMetadata, +} from '../internal/distribution-sampling'; export class ExponentialDistribution implements IDistribution { constructor(private readonly lambda: number = 1) { validatePositive(lambda, 'lambda'); } + private readonly _createSample = (value: number): DistributionSample => ({ + value, + metadata: { + lambda: this.lambda, + mean: this.mean(), + variance: this.variance(), + standardDeviation: this.standardDeviation(), + }, + }); + public sample = (state: IRandomState): RandomResult => { const engine = createEngineFactory(state.engine)(); engine.setState(state); @@ -16,56 +31,17 @@ export class ExponentialDistribution implements IDistribution { return [value, engine.getState()]; }; - public sampleMany = (state: IRandomState, count: number): RandomResult => { - if (count <= 0 || !Number.isInteger(count)) { - throw new RangeError('Count must be a positive integer'); - } - - const result: number[] = []; - let currentState = state; - - for (let i = 0; i < count; i++) { - const [value, nextState] = this.sample(currentState); - result.push(value); - currentState = nextState; - } - - return [result, currentState]; - }; - - public sampleWithMetadata = (state: IRandomState): RandomResult> => { - const [value, nextState] = this.sample(state); - - const sample: DistributionSample = { - value, - metadata: { - lambda: this.lambda, - mean: this.mean(), - variance: this.variance(), - standardDeviation: this.standardDeviation(), - }, - }; + public sampleMany = (state: IRandomState, count: number): RandomResult => + sampleManyFromDistribution(state, count, this.sample); - return [sample, nextState]; - }; + public sampleWithMetadata = (state: IRandomState): RandomResult> => + sampleWithDistributionMetadata(state, this.sample, this._createSample); public sampleManyWithMetadata = ( state: IRandomState, count: number - ): RandomResult[]> => { - const [values, nextState] = this.sampleMany(state, count); - const samples = values.map((value) => ({ - value, - metadata: { - lambda: this.lambda, - mean: this.mean(), - variance: this.variance(), - standardDeviation: this.standardDeviation(), - }, - })); - - return [samples, nextState]; - }; + ): RandomResult[]> => + sampleManyWithDistributionMetadata(state, count, this.sampleMany, this._createSample); public probability = (x: number): number => { if (!Number.isFinite(x)) { diff --git a/web/packages/random/src/distributions/geometric.ts b/web/packages/random/src/distributions/geometric.ts index 54730ebb..a72e75c4 100644 --- a/web/packages/random/src/distributions/geometric.ts +++ b/web/packages/random/src/distributions/geometric.ts @@ -1,6 +1,11 @@ import { IDistribution, IRandomState, RandomResult, DistributionSample } from '../types'; import { validateProbability } from '../constants'; import { createEngineFactory } from '../engines'; +import { + sampleManyFromDistribution, + sampleManyWithDistributionMetadata, + sampleWithDistributionMetadata, +} from '../internal/distribution-sampling'; export class GeometricDistribution implements IDistribution { constructor(private readonly p: number) { @@ -11,6 +16,16 @@ export class GeometricDistribution implements IDistribution { } } + private readonly _createSample = (value: number): DistributionSample => ({ + value, + metadata: { + p: this.p, + mean: this.mean(), + variance: this.variance(), + standardDeviation: this.standardDeviation(), + }, + }); + public sample = (state: IRandomState): RandomResult => { const engine = createEngineFactory(state.engine)(); engine.setState(state); @@ -22,56 +37,17 @@ export class GeometricDistribution implements IDistribution { return [value, engine.getState()]; }; - public sampleMany = (state: IRandomState, count: number): RandomResult => { - if (count <= 0 || !Number.isInteger(count)) { - throw new RangeError('Count must be a positive integer'); - } - - const result: number[] = []; - let currentState = state; - - for (let i = 0; i < count; i++) { - const [value, nextState] = this.sample(currentState); - result.push(value); - currentState = nextState; - } - - return [result, currentState]; - }; - - public sampleWithMetadata = (state: IRandomState): RandomResult> => { - const [value, nextState] = this.sample(state); - - const sample: DistributionSample = { - value, - metadata: { - p: this.p, - mean: this.mean(), - variance: this.variance(), - standardDeviation: this.standardDeviation(), - }, - }; + public sampleMany = (state: IRandomState, count: number): RandomResult => + sampleManyFromDistribution(state, count, this.sample); - return [sample, nextState]; - }; + public sampleWithMetadata = (state: IRandomState): RandomResult> => + sampleWithDistributionMetadata(state, this.sample, this._createSample); public sampleManyWithMetadata = ( state: IRandomState, count: number - ): RandomResult[]> => { - const [values, nextState] = this.sampleMany(state, count); - const samples = values.map((value) => ({ - value, - metadata: { - p: this.p, - mean: this.mean(), - variance: this.variance(), - standardDeviation: this.standardDeviation(), - }, - })); - - return [samples, nextState]; - }; + ): RandomResult[]> => + sampleManyWithDistributionMetadata(state, count, this.sampleMany, this._createSample); public probability = (k: number | boolean): number => { const val = typeof k === 'boolean' ? (k ? 1 : 0) : k; diff --git a/web/packages/random/src/distributions/normal.ts b/web/packages/random/src/distributions/normal.ts index e80de55e..06c196e1 100644 --- a/web/packages/random/src/distributions/normal.ts +++ b/web/packages/random/src/distributions/normal.ts @@ -1,5 +1,9 @@ import { IDistribution, IRandomState, RandomResult, DistributionSample } from '../types'; import { createEngineFactory } from '../engines'; +import { + sampleManyWithDistributionMetadata, + sampleWithDistributionMetadata, +} from '../internal/distribution-sampling'; const ZIGGURAT_N = 128; const ZIG_X: Float64Array = new Float64Array(ZIGGURAT_N + 1); @@ -46,6 +50,17 @@ export class NormalDistribution implements IDistribution { } } + private readonly _createSample = (value: number): DistributionSample => ({ + value, + zscore: (value - this._mean) / this._stdDev, + metadata: { + algorithm: this.algorithm, + mean: this._mean, + standardDeviation: this._stdDev, + variance: this._stdDev * this._stdDev, + }, + }); + public sample = (state: IRandomState): RandomResult => { const standardNormal = this.generateStandardNormal(state); const value = this._mean + this._stdDev * standardNormal[0]; @@ -117,42 +132,14 @@ export class NormalDistribution implements IDistribution { return [result, currentState]; }; - public sampleWithMetadata = (state: IRandomState): RandomResult> => { - const [value, nextState] = this.sample(state); - const zscore = (value - this._mean) / this._stdDev; - - const sample: DistributionSample = { - value, - zscore, - metadata: { - algorithm: this.algorithm, - mean: this._mean, - standardDeviation: this._stdDev, - variance: this._stdDev * this._stdDev, - }, - }; - - return [sample, nextState]; - }; + public sampleWithMetadata = (state: IRandomState): RandomResult> => + sampleWithDistributionMetadata(state, this.sample, this._createSample); public sampleManyWithMetadata = ( state: IRandomState, count: number - ): RandomResult[]> => { - const [values, nextState] = this.sampleMany(state, count); - const samples = values.map((value) => ({ - value, - zscore: (value - this._mean) / this._stdDev, - metadata: { - algorithm: this.algorithm, - mean: this._mean, - standardDeviation: this._stdDev, - variance: this._stdDev * this._stdDev, - }, - })); - - return [samples, nextState]; - }; + ): RandomResult[]> => + sampleManyWithDistributionMetadata(state, count, this.sampleMany, this._createSample); public probability = (x: number): number => { if (!Number.isFinite(x)) { diff --git a/web/packages/random/src/distributions/poisson.ts b/web/packages/random/src/distributions/poisson.ts index dcdc039f..791d82c3 100644 --- a/web/packages/random/src/distributions/poisson.ts +++ b/web/packages/random/src/distributions/poisson.ts @@ -1,12 +1,27 @@ import { IDistribution, IRandomState, RandomResult, DistributionSample } from '../types'; import { validatePositive, PI, factorial } from '../constants'; import { createEngineFactory } from '../engines'; +import { + sampleManyFromDistribution, + sampleManyWithDistributionMetadata, + sampleWithDistributionMetadata, +} from '../internal/distribution-sampling'; export class PoissonDistribution implements IDistribution { constructor(private readonly lambda: number) { validatePositive(lambda, 'lambda'); } + private readonly _createSample = (value: number): DistributionSample => ({ + value, + metadata: { + lambda: this.lambda, + mean: this.mean(), + variance: this.variance(), + standardDeviation: this.standardDeviation(), + }, + }); + public sample = (state: IRandomState): RandomResult => { const engine = createEngineFactory(state.engine)(); engine.setState(state); @@ -49,56 +64,17 @@ export class PoissonDistribution implements IDistribution { } }; - public sampleMany = (state: IRandomState, count: number): RandomResult => { - if (count <= 0 || !Number.isInteger(count)) { - throw new RangeError('Count must be a positive integer'); - } - - const result: number[] = []; - let currentState = state; + public sampleMany = (state: IRandomState, count: number): RandomResult => + sampleManyFromDistribution(state, count, this.sample); - for (let i = 0; i < count; i++) { - const [value, nextState] = this.sample(currentState); - result.push(value); - currentState = nextState; - } - - return [result, currentState]; - }; - - public sampleWithMetadata = (state: IRandomState): RandomResult> => { - const [value, nextState] = this.sample(state); - - const sample: DistributionSample = { - value, - metadata: { - lambda: this.lambda, - mean: this.mean(), - variance: this.variance(), - standardDeviation: this.standardDeviation(), - }, - }; - - return [sample, nextState]; - }; + public sampleWithMetadata = (state: IRandomState): RandomResult> => + sampleWithDistributionMetadata(state, this.sample, this._createSample); public sampleManyWithMetadata = ( state: IRandomState, count: number - ): RandomResult[]> => { - const [values, nextState] = this.sampleMany(state, count); - const samples = values.map((value) => ({ - value, - metadata: { - lambda: this.lambda, - mean: this.mean(), - variance: this.variance(), - standardDeviation: this.standardDeviation(), - }, - })); - - return [samples, nextState]; - }; + ): RandomResult[]> => + sampleManyWithDistributionMetadata(state, count, this.sampleMany, this._createSample); public probability = (k: number): number => { if (!Number.isInteger(k) || k < 0) { diff --git a/web/packages/random/src/internal/distribution-sampling.ts b/web/packages/random/src/internal/distribution-sampling.ts new file mode 100644 index 00000000..9d7d1625 --- /dev/null +++ b/web/packages/random/src/internal/distribution-sampling.ts @@ -0,0 +1,45 @@ +import type { DistributionSample, IRandomState, RandomResult } from '../types'; + +const validateSampleCount = (count: number): void => { + if (count <= 0 || !Number.isInteger(count)) { + throw new RangeError('Count must be a positive integer'); + } +}; + +export const sampleManyFromDistribution = ( + state: IRandomState, + count: number, + sample: (state: IRandomState) => RandomResult +): RandomResult => { + validateSampleCount(count); + + const result: T[] = []; + let currentState = state; + + for (let index = 0; index < count; index += 1) { + const [value, nextState] = sample(currentState); + result.push(value); + currentState = nextState; + } + + return [result, currentState]; +}; + +export const sampleWithDistributionMetadata = ( + state: IRandomState, + sample: (state: IRandomState) => RandomResult, + createSample: (value: T) => DistributionSample +): RandomResult> => { + const [value, nextState] = sample(state); + return [createSample(value), nextState]; +}; + +export const sampleManyWithDistributionMetadata = ( + state: IRandomState, + count: number, + sampleMany: (state: IRandomState, count: number) => RandomResult, + createSample: (value: T) => DistributionSample +): RandomResult[]> => { + const [values, nextState] = sampleMany(state, count); + return [values.map((value) => createSample(value)), nextState]; +}; \ No newline at end of file diff --git a/web/packages/render-2d/package.json b/web/packages/render-2d/package.json index 07b87f01..a8068bd8 100644 --- a/web/packages/render-2d/package.json +++ b/web/packages/render-2d/package.json @@ -21,6 +21,7 @@ "test": "vitest run" }, "dependencies": { + "@axrone/numeric": "^0.0.1", "@axrone/render-core": "^0.1.0" } } \ No newline at end of file diff --git a/web/packages/render-2d/src/__tests__/sprite-batch-builder.test.ts b/web/packages/render-2d/src/__tests__/sprite-batch-builder.test.ts new file mode 100644 index 00000000..02d5ec66 --- /dev/null +++ b/web/packages/render-2d/src/__tests__/sprite-batch-builder.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from 'vitest'; +import { Color } from '@axrone/numeric'; +import { Render2DSpriteBatchBuilder } from '../sprite-batch-builder'; + +const identity = Object.freeze([ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, +] as const); + +describe('Render2DSpriteBatchBuilder', () => { + it('batches consecutive sprites that share the same texture source', () => { + const builder = new Render2DSpriteBatchBuilder(); + const result = builder.build([ + { + source: { kind: 'texture', textureId: 'atlas/a' }, + worldMatrix: identity, + size: { width: 2, height: 4 }, + anchor: { x: 0.5, y: 0.5 }, + uvRect: { x: 0, y: 0, width: 1, height: 1 }, + color: new Color(1, 1, 1, 1), + }, + { + source: { kind: 'texture', textureId: 'atlas/a' }, + worldMatrix: identity, + size: { width: 1, height: 1 }, + anchor: { x: 0.5, y: 0.5 }, + uvRect: { x: 0, y: 0, width: 0.5, height: 0.5 }, + color: new Color(1, 0.5, 0.25, 1), + }, + { + source: { kind: 'material', materialId: 'mat/b' }, + worldMatrix: identity, + size: { width: 1, height: 1 }, + anchor: { x: 0, y: 0 }, + uvRect: { x: 0, y: 0, width: 1, height: 1 }, + color: new Color(0.5, 1, 1, 0.5), + }, + ]); + + expect(result.spriteCount).toBe(3); + expect(result.quadCount).toBe(3); + expect(result.indexCount).toBe(18); + expect(result.batches).toHaveLength(2); + expect(result.batches[0]?.quadCount).toBe(2); + expect(result.batches[0]?.key.sourceKey).toBe('texture:atlas/a'); + expect(result.batches[1]?.quadCount).toBe(1); + expect(result.batches[1]?.key.sourceKey).toBe('material:mat/b'); + }); + + it('writes transformed quad vertices using row-major transform data', () => { + const builder = new Render2DSpriteBatchBuilder(); + const translation = [ + 1, 0, 0, 3, + 0, 1, 0, 4, + 0, 0, 1, 2, + 0, 0, 0, 1, + ] as const; + + const result = builder.build([ + { + source: { kind: 'texture', textureId: 'atlas/a' }, + worldMatrix: translation, + size: { width: 2, height: 2 }, + anchor: { x: 0.5, y: 0.5 }, + uvRect: { x: 0, y: 0, width: 1, height: 1 }, + color: Color.WHITE, + }, + ]); + + const view = new Float32Array(result.vertexData.buffer, result.vertexData.byteOffset, result.vertexData.byteLength / 4); + + expect(view[0]).toBe(2); + expect(view[1]).toBe(3); + expect(view[2]).toBe(2); + expect(view[6]).toBe(4); + expect(view[7]).toBe(3); + expect(view[8]).toBe(2); + expect(view[12]).toBe(4); + expect(view[13]).toBe(5); + expect(view[14]).toBe(2); + expect(view[18]).toBe(2); + expect(view[19]).toBe(5); + expect(view[20]).toBe(2); + }); + + it('splits batches when the configured quad limit is reached', () => { + const builder = new Render2DSpriteBatchBuilder({ maxBatchQuads: 1 }); + const result = builder.build([ + { + source: { kind: 'texture', textureId: 'atlas/a' }, + worldMatrix: identity, + size: { width: 1, height: 1 }, + anchor: { x: 0.5, y: 0.5 }, + uvRect: { x: 0, y: 0, width: 1, height: 1 }, + color: Color.WHITE, + }, + { + source: { kind: 'texture', textureId: 'atlas/a' }, + worldMatrix: identity, + size: { width: 1, height: 1 }, + anchor: { x: 0.5, y: 0.5 }, + uvRect: { x: 0, y: 0, width: 1, height: 1 }, + color: Color.WHITE, + }, + ]); + + expect(result.batches).toHaveLength(2); + expect(result.batches[0]?.indexOffset).toBe(0); + expect(result.batches[1]?.indexOffset).toBe(6); + }); + + it('splits batches when clip rect state changes', () => { + const builder = new Render2DSpriteBatchBuilder(); + const result = builder.build([ + { + source: { kind: 'texture', textureId: 'atlas/a' }, + worldMatrix: identity, + size: { width: 1, height: 1 }, + anchor: { x: 0.5, y: 0.5 }, + uvRect: { x: 0, y: 0, width: 1, height: 1 }, + clipRect: { x: 0, y: 0, width: 64, height: 64 }, + color: Color.WHITE, + }, + { + source: { kind: 'texture', textureId: 'atlas/a' }, + worldMatrix: identity, + size: { width: 1, height: 1 }, + anchor: { x: 0.5, y: 0.5 }, + uvRect: { x: 0, y: 0, width: 1, height: 1 }, + clipRect: { x: 0, y: 0, width: 64, height: 64 }, + color: Color.WHITE, + }, + { + source: { kind: 'texture', textureId: 'atlas/a' }, + worldMatrix: identity, + size: { width: 1, height: 1 }, + anchor: { x: 0.5, y: 0.5 }, + uvRect: { x: 0, y: 0, width: 1, height: 1 }, + clipRect: { x: 32, y: 0, width: 64, height: 64 }, + color: Color.WHITE, + }, + ]); + + expect(result.batches).toHaveLength(2); + expect(result.batches[0]?.quadCount).toBe(2); + expect(result.batches[0]?.key.clipRect).toEqual({ + x: 0, + y: 0, + width: 64, + height: 64, + }); + expect(result.batches[1]?.quadCount).toBe(1); + }); + + it('splits batches when mask state changes', () => { + const builder = new Render2DSpriteBatchBuilder(); + const identityMask = { + shape: 'circle' as const, + inverseWorldMatrix: [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + ], + size: { width: 32, height: 32 }, + anchor: { x: 0.5, y: 0.5 }, + }; + + const shiftedMask = { + ...identityMask, + inverseWorldMatrix: [ + 1, 0, 0, 4, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + ], + }; + + const result = builder.build([ + { + source: { kind: 'texture', textureId: 'atlas/a' }, + worldMatrix: identity, + size: { width: 1, height: 1 }, + anchor: { x: 0.5, y: 0.5 }, + uvRect: { x: 0, y: 0, width: 1, height: 1 }, + mask: identityMask, + color: Color.WHITE, + }, + { + source: { kind: 'texture', textureId: 'atlas/a' }, + worldMatrix: identity, + size: { width: 1, height: 1 }, + anchor: { x: 0.5, y: 0.5 }, + uvRect: { x: 0, y: 0, width: 1, height: 1 }, + mask: identityMask, + color: Color.WHITE, + }, + { + source: { kind: 'texture', textureId: 'atlas/a' }, + worldMatrix: identity, + size: { width: 1, height: 1 }, + anchor: { x: 0.5, y: 0.5 }, + uvRect: { x: 0, y: 0, width: 1, height: 1 }, + mask: shiftedMask, + color: Color.WHITE, + }, + ]); + + expect(result.batches).toHaveLength(2); + expect(result.batches[0]?.quadCount).toBe(2); + expect(result.batches[0]?.key.mask?.shape).toBe('circle'); + expect(result.batches[1]?.quadCount).toBe(1); + }); + + it('emits nine-slice quads from a single sprite submission', () => { + const builder = new Render2DSpriteBatchBuilder(); + const result = builder.build([ + { + source: { kind: 'texture', textureId: 'atlas/panel' }, + worldMatrix: identity, + size: { width: 30, height: 18 }, + anchor: { x: 0, y: 0 }, + uvRect: { x: 0.25, y: 0.125, width: 0.5, height: 0.5 }, + color: Color.WHITE, + slice: { + sourceSize: { width: 10, height: 6 }, + border: { left: 2, right: 2, top: 1, bottom: 1 }, + }, + }, + ]); + + expect(result.spriteCount).toBe(1); + expect(result.quadCount).toBe(9); + expect(result.vertexCount).toBe(36); + expect(result.indexCount).toBe(54); + expect(result.batches[0]?.quadCount).toBe(9); + + const view = new Float32Array( + result.vertexData.buffer, + result.vertexData.byteOffset, + result.vertexData.byteLength / 4 + ); + + expect(view[0]).toBe(0); + expect(view[6]).toBe(6); + }); +}); \ No newline at end of file diff --git a/web/packages/render-2d/src/errors.ts b/web/packages/render-2d/src/errors.ts new file mode 100644 index 00000000..346f5e56 --- /dev/null +++ b/web/packages/render-2d/src/errors.ts @@ -0,0 +1,30 @@ +export class Render2DError extends Error { + constructor( + message: string, + readonly code: string, + readonly cause?: unknown + ) { + super(message); + this.name = 'Render2DError'; + Object.setPrototypeOf(this, new.target.prototype); + ( + Error as typeof Error & { + captureStackTrace?: (target: object, ctor: Function) => void; + } + ).captureStackTrace?.(this, this.constructor); + } +} + +export class Render2DValidationError extends Render2DError { + constructor(message: string, cause?: unknown) { + super(message, 'RENDER_2D_VALIDATION_ERROR', cause); + this.name = 'Render2DValidationError'; + } +} + +export class Render2DCapacityError extends Render2DError { + constructor(message: string, cause?: unknown) { + super(message, 'RENDER_2D_CAPACITY_ERROR', cause); + this.name = 'Render2DCapacityError'; + } +} \ No newline at end of file diff --git a/web/packages/render-2d/src/index.ts b/web/packages/render-2d/src/index.ts index 8c741360..36bab768 100644 --- a/web/packages/render-2d/src/index.ts +++ b/web/packages/render-2d/src/index.ts @@ -12,6 +12,55 @@ export type Render2DCapability = typeof RENDER_2D_CAPABILITY; export const getRender2DCapability = (): Render2DCapability => RENDER_2D_CAPABILITY; +export type { + PackedRender2DColor, + Render2DBorderLike, + Render2DColorLike, + Render2DMaterialReference, + Render2DReadonlyMat4Like, + Render2DRectLike, + Render2DSizeLike, + Render2DSpriteMask, + Render2DSpriteMaskShape, + Render2DSpriteBatchBuildResult, + Render2DSpriteBatchBuilderOptions, + Render2DSpriteBatchKey, + Render2DSpriteBatchRange, + Render2DSpriteMaterialSource, + Render2DSpriteSlice, + Render2DSpriteSource, + Render2DSpriteSourceKey, + Render2DSpriteSubmission, + Render2DSpriteTextureSource, + Render2DTextureReference, + Render2DVec2Like, +} from './types'; +export { + asRender2DMaterialReference, + asRender2DTextureReference, + getRender2DSpriteSourceKey, + isRender2DSpriteMaterialSource, + isRender2DSpriteTextureSource, +} from './types'; +export { + RENDER_2D_DEFAULT_SPRITE_SHADER_ID, + RENDER_2D_SPRITE_ATTRIBUTE_NAMES, + RENDER_2D_SPRITE_EFFECT, + RENDER_2D_SPRITE_FLOAT_STRIDE, + RENDER_2D_SPRITE_FRAGMENT_SOURCE, + RENDER_2D_SPRITE_INDICES_PER_QUAD, + RENDER_2D_SPRITE_UNIFORM_NAMES, + RENDER_2D_SPRITE_VERTEX_SOURCE, + RENDER_2D_SPRITE_VERTEX_STRIDE, + RENDER_2D_SPRITE_VERTICES_PER_QUAD, +} from './sprite-shader'; +export { + Render2DCapacityError, + Render2DError, + Render2DValidationError, +} from './errors'; +export { Render2DSpriteBatchBuilder } from './sprite-batch-builder'; + export type { ReadonlyRenderResourceRegistry, RenderClearState, diff --git a/web/packages/render-2d/src/sprite-batch-builder.ts b/web/packages/render-2d/src/sprite-batch-builder.ts new file mode 100644 index 00000000..b7bc1d59 --- /dev/null +++ b/web/packages/render-2d/src/sprite-batch-builder.ts @@ -0,0 +1,784 @@ +import { clamp01 } from '@axrone/numeric'; +import { + RENDER_2D_SPRITE_INDICES_PER_QUAD, + RENDER_2D_SPRITE_VERTEX_STRIDE, + RENDER_2D_SPRITE_VERTICES_PER_QUAD, +} from './sprite-shader'; +import { Render2DCapacityError, Render2DValidationError } from './errors'; +import { + type PackedRender2DColor, + type Render2DColorLike, + type Render2DRectLike, + type Render2DSpriteMask, + type Render2DSpriteBatchBuildResult, + type Render2DSpriteBatchBuilderOptions, + type Render2DSpriteBatchKey, + type Render2DSpriteBatchRange, + type Render2DSpriteSlice, + type Render2DSpriteSource, + type Render2DSpriteSubmission, + getRender2DSpriteSourceKey, + isRender2DSpriteMaterialSource, + isRender2DSpriteTextureSource, +} from './types'; + +interface MutableRender2DSpriteBatchRange { + key: Render2DSpriteBatchKey; + spriteOffset: number; + spriteCount: number; + quadCount: number; + indexOffset: number; + indexCount: number; +} + +interface MutableRender2DSpriteBatchBuildResult { + vertexStride: number; + vertexByteLength: number; + vertexData: Uint8Array; + indexData: Uint16Array | Uint32Array; + batches: readonly Render2DSpriteBatchRange[]; + spriteCount: number; + quadCount: number; + vertexCount: number; + indexCount: number; +} + +const DEFAULT_MAX_BATCH_QUADS = 2048; +const MIN_QUAD_EXTENT = 1e-6; + +const growCapacity = (current: number, required: number): number => { + let next = Math.max(256, current); + while (next < required) { + next *= 2; + } + + return next; +}; + +const packColor = (color: Render2DColorLike): PackedRender2DColor => { + const red = Math.round(clamp01(color.r) * 255) & 0xff; + const green = Math.round(clamp01(color.g) * 255) & 0xff; + const blue = Math.round(clamp01(color.b) * 255) & 0xff; + const alpha = Math.round(clamp01(color.a ?? 1) * 255) & 0xff; + return (red | (green << 8) | (blue << 16) | (alpha << 24)) as PackedRender2DColor; +}; + +const cloneSource = (source: TSource): TSource => { + if (isRender2DSpriteTextureSource(source)) { + return { + kind: 'texture', + textureId: source.textureId, + } as TSource; + } + + return { + kind: 'material', + materialId: isRender2DSpriteMaterialSource(source) ? source.materialId : '', + } as TSource; +}; + +const cloneRect = ( + value: Render2DRectLike | null | undefined +): Readonly | null => + value + ? Object.freeze({ + x: value.x, + y: value.y, + width: value.width, + height: value.height, + }) + : null; + +const cloneMask = ( + value: Render2DSpriteMask | null | undefined +): Readonly | null => + value + ? Object.freeze({ + shape: value.shape, + inverseWorldMatrix: Object.freeze(Array.from(value.inverseWorldMatrix, (entry) => Number(entry ?? 0))), + size: Object.freeze({ + width: value.size.width, + height: value.size.height, + }), + anchor: Object.freeze({ + x: value.anchor.x, + y: value.anchor.y, + }), + ...(value.cornerRadius !== undefined ? { cornerRadius: value.cornerRadius } : {}), + }) + : null; + +const areSourcesEqual = ( + left: Render2DSpriteSource, + right: Render2DSpriteSource +): boolean => { + if (left.kind !== right.kind) { + return false; + } + + return isRender2DSpriteTextureSource(left) + ? left.textureId === (right as typeof left).textureId + : (left as Extract).materialId === + (right as Extract).materialId; +}; + +const areRectsEqual = ( + left: Render2DRectLike | null | undefined, + right: Render2DRectLike | null | undefined +): boolean => { + if (!left || !right) { + return left == null && right == null; + } + + return ( + left.x === right.x && + left.y === right.y && + left.width === right.width && + left.height === right.height + ); +}; + +const areMaskMatricesEqual = ( + left: Render2DSpriteMask['inverseWorldMatrix'] | null | undefined, + right: Render2DSpriteMask['inverseWorldMatrix'] | null | undefined +): boolean => { + if (!left || !right) { + return left == null && right == null; + } + + if (left.length !== right.length) { + return false; + } + + for (let index = 0; index < left.length; index += 1) { + if ((left[index] ?? 0) !== (right[index] ?? 0)) { + return false; + } + } + + return true; +}; + +const areMasksEqual = ( + left: Render2DSpriteMask | null | undefined, + right: Render2DSpriteMask | null | undefined +): boolean => { + if (!left || !right) { + return left == null && right == null; + } + + return ( + left.shape === right.shape && + areMaskMatricesEqual(left.inverseWorldMatrix, right.inverseWorldMatrix) && + left.size.width === right.size.width && + left.size.height === right.size.height && + left.anchor.x === right.anchor.x && + left.anchor.y === right.anchor.y && + (left.cornerRadius ?? 0) === (right.cornerRadius ?? 0) + ); +}; + +const assertFinite = (label: string, value: number): void => { + if (!Number.isFinite(value)) { + throw new Render2DValidationError(`${label} must be a finite number`); + } +}; + +const validateSubmission = (submission: Render2DSpriteSubmission): void => { + if (submission.worldMatrix.length < 16) { + throw new Render2DValidationError('Sprite worldMatrix must expose at least 16 values'); + } + + assertFinite('Sprite width', submission.size.width); + assertFinite('Sprite height', submission.size.height); + assertFinite('Sprite anchor.x', submission.anchor.x); + assertFinite('Sprite anchor.y', submission.anchor.y); + assertFinite('Sprite uvRect.x', submission.uvRect.x); + assertFinite('Sprite uvRect.y', submission.uvRect.y); + assertFinite('Sprite uvRect.width', submission.uvRect.width); + assertFinite('Sprite uvRect.height', submission.uvRect.height); + assertFinite('Sprite color.r', submission.color.r); + assertFinite('Sprite color.g', submission.color.g); + assertFinite('Sprite color.b', submission.color.b); + assertFinite('Sprite color.a', submission.color.a ?? 1); + + if (submission.clipRect) { + assertFinite('Sprite clipRect.x', submission.clipRect.x); + assertFinite('Sprite clipRect.y', submission.clipRect.y); + assertFinite('Sprite clipRect.width', submission.clipRect.width); + assertFinite('Sprite clipRect.height', submission.clipRect.height); + } + + if (submission.slice) { + assertFinite('Sprite slice source width', submission.slice.sourceSize.width); + assertFinite('Sprite slice source height', submission.slice.sourceSize.height); + assertFinite('Sprite slice border.left', submission.slice.border.left); + assertFinite('Sprite slice border.right', submission.slice.border.right); + assertFinite('Sprite slice border.top', submission.slice.border.top); + assertFinite('Sprite slice border.bottom', submission.slice.border.bottom); + + if (submission.slice.sourceSize.width <= 0 || submission.slice.sourceSize.height <= 0) { + throw new Render2DValidationError( + 'Sprite slice sourceSize must be greater than zero' + ); + } + + if ( + submission.slice.border.left < 0 || + submission.slice.border.right < 0 || + submission.slice.border.top < 0 || + submission.slice.border.bottom < 0 + ) { + throw new Render2DValidationError( + 'Sprite slice borders must be zero or greater' + ); + } + + if ( + submission.slice.border.left + submission.slice.border.right > + submission.slice.sourceSize.width || + submission.slice.border.top + submission.slice.border.bottom > + submission.slice.sourceSize.height + ) { + throw new Render2DValidationError( + 'Sprite slice borders must fit inside the source size' + ); + } + } + + if (submission.mask) { + assertFinite('Sprite mask size.width', submission.mask.size.width); + assertFinite('Sprite mask size.height', submission.mask.size.height); + assertFinite('Sprite mask anchor.x', submission.mask.anchor.x); + assertFinite('Sprite mask anchor.y', submission.mask.anchor.y); + + if (submission.mask.size.width <= 0 || submission.mask.size.height <= 0) { + throw new Render2DValidationError('Sprite mask size must be greater than zero'); + } + + if (submission.mask.inverseWorldMatrix.length < 16) { + throw new Render2DValidationError('Sprite mask inverse matrix must have 16 values'); + } + + if (submission.mask.shape === 'rounded-rect') { + assertFinite('Sprite mask cornerRadius', submission.mask.cornerRadius ?? 0); + } + } + + for (let index = 0; index < 16; index += 1) { + assertFinite(`Sprite worldMatrix[${index}]`, submission.worldMatrix[index] ?? NaN); + } +}; + +const isRenderableSubmission = (submission: Render2DSpriteSubmission): boolean => { + if (submission.visible === false) { + return false; + } + + if (submission.size.width === 0 || submission.size.height === 0) { + return false; + } + + if (submission.clipRect && (submission.clipRect.width === 0 || submission.clipRect.height === 0)) { + return false; + } + + return isRender2DSpriteTextureSource(submission.source) + ? submission.source.textureId.length > 0 + : submission.source.materialId.length > 0; +}; + +const writeVertex = ( + floatView: Float32Array, + uintView: Uint32Array, + baseVertex: number, + x: number, + y: number, + z: number, + u: number, + v: number, + color: PackedRender2DColor +): void => { + const offset = baseVertex * (RENDER_2D_SPRITE_VERTEX_STRIDE / 4); + floatView[offset] = x; + floatView[offset + 1] = y; + floatView[offset + 2] = z; + floatView[offset + 3] = u; + floatView[offset + 4] = v; + uintView[offset + 5] = color; +}; + +const transformPoint = ( + matrix: ArrayLike, + localX: number, + localY: number, + out: Float32Array +): Float32Array => { + out[0] = (matrix[0] ?? 0) * localX + (matrix[1] ?? 0) * localY + (matrix[3] ?? 0); + out[1] = (matrix[4] ?? 0) * localX + (matrix[5] ?? 0) * localY + (matrix[7] ?? 0); + out[2] = (matrix[8] ?? 0) * localX + (matrix[9] ?? 0) * localY + (matrix[11] ?? 0); + return out; +}; + +export class Render2DSpriteBatchBuilder { + private readonly _maxBatchQuads: number; + private readonly _batches: MutableRender2DSpriteBatchRange[] = []; + private readonly _renderableSubmissions: Render2DSpriteSubmission[] = []; + private readonly _submissionQuadCounts: number[] = []; + private _vertexBuffer = new ArrayBuffer(0); + private _vertexBytes = new Uint8Array(0); + private _vertexFloatView = new Float32Array(0); + private _vertexUintView = new Uint32Array(0); + private _indexData16 = new Uint16Array(0); + private _indexData32 = new Uint32Array(0); + private readonly _pointScratch = new Float32Array(3); + private readonly _xEdges = new Float32Array(4); + private readonly _yEdges = new Float32Array(4); + private readonly _uEdges = new Float32Array(4); + private readonly _vEdges = new Float32Array(4); + private readonly _result: MutableRender2DSpriteBatchBuildResult = { + vertexStride: RENDER_2D_SPRITE_VERTEX_STRIDE, + vertexByteLength: 0, + vertexData: this._vertexBytes, + indexData: this._indexData16, + batches: this._batches, + spriteCount: 0, + quadCount: 0, + vertexCount: 0, + indexCount: 0, + }; + + constructor(options: Render2DSpriteBatchBuilderOptions = {}) { + this._maxBatchQuads = options.maxBatchQuads ?? DEFAULT_MAX_BATCH_QUADS; + + if (!Number.isInteger(this._maxBatchQuads) || this._maxBatchQuads <= 0) { + throw new Render2DValidationError('maxBatchQuads must be a positive integer'); + } + } + + build( + submissions: readonly Render2DSpriteSubmission[] + ): Render2DSpriteBatchBuildResult { + this._renderableSubmissions.length = 0; + this._submissionQuadCounts.length = 0; + + let spriteCount = 0; + let quadCount = 0; + + for (const submission of submissions) { + validateSubmission(submission); + if (!isRenderableSubmission(submission)) { + continue; + } + + const submissionQuadCount = this._measureQuadCount(submission); + if (submissionQuadCount > this._maxBatchQuads) { + throw new Render2DCapacityError( + 'Sprite submission exceeds the configured maxBatchQuads limit' + ); + } + + spriteCount += 1; + quadCount += submissionQuadCount; + this._renderableSubmissions.push(submission); + this._submissionQuadCounts.push(submissionQuadCount); + } + + const vertexCount = quadCount * RENDER_2D_SPRITE_VERTICES_PER_QUAD; + const indexCount = quadCount * RENDER_2D_SPRITE_INDICES_PER_QUAD; + const useUint32 = vertexCount > 0xffff; + + if (vertexCount > 0xffffffff) { + throw new Render2DCapacityError('Sprite vertex count exceeds Uint32 draw capacity'); + } + + this._ensureVertexCapacity(vertexCount); + this._ensureIndexCapacity(indexCount, useUint32); + this._batches.length = 0; + + const indexTarget = useUint32 ? this._indexData32 : this._indexData16; + let quadIndex = 0; + let spriteOffset = 0; + let batchIndex = -1; + let lastSource: Render2DSpriteSource | null = null; + let lastClipRect: Render2DRectLike | null = null; + let lastMask: Render2DSpriteMask | null = null; + + for ( + let submissionIndex = 0; + submissionIndex < this._renderableSubmissions.length; + submissionIndex += 1 + ) { + const submission = this._renderableSubmissions[submissionIndex]!; + const submissionQuadCount = this._submissionQuadCounts[submissionIndex]!; + const submissionClipRect = submission.clipRect ?? null; + + if ( + !lastSource || + !areSourcesEqual(lastSource, submission.source) || + !areRectsEqual(lastClipRect, submissionClipRect) || + !areMasksEqual(lastMask, submission.mask ?? null) || + this._batches[batchIndex]!.quadCount + submissionQuadCount > this._maxBatchQuads + ) { + batchIndex += 1; + const key = { + source: cloneSource(submission.source), + sourceKey: getRender2DSpriteSourceKey(submission.source), + clipRect: cloneRect(submissionClipRect), + mask: cloneMask(submission.mask), + } satisfies Render2DSpriteBatchKey; + this._batches[batchIndex] = { + key, + spriteOffset, + spriteCount: 0, + quadCount: 0, + indexOffset: quadIndex * RENDER_2D_SPRITE_INDICES_PER_QUAD, + indexCount: 0, + }; + lastSource = submission.source; + lastClipRect = submissionClipRect; + lastMask = submission.mask ?? null; + } + + const currentBatch = this._batches[batchIndex]!; + const quadsWritten = this._writeSubmission(submission, quadIndex, indexTarget); + currentBatch.spriteCount += 1; + currentBatch.quadCount += quadsWritten; + currentBatch.indexCount += quadsWritten * RENDER_2D_SPRITE_INDICES_PER_QUAD; + quadIndex += quadsWritten; + spriteOffset += 1; + } + + this._result.vertexByteLength = vertexCount * RENDER_2D_SPRITE_VERTEX_STRIDE; + this._result.vertexData = this._vertexBytes.subarray(0, this._result.vertexByteLength); + this._result.indexData = useUint32 + ? this._indexData32.subarray(0, indexCount) + : this._indexData16.subarray(0, indexCount); + this._result.batches = this._batches; + this._result.spriteCount = spriteCount; + this._result.quadCount = quadCount; + this._result.vertexCount = vertexCount; + this._result.indexCount = indexCount; + return this._result; + } + + private _ensureVertexCapacity(vertexCount: number): void { + const requiredBytes = vertexCount * RENDER_2D_SPRITE_VERTEX_STRIDE; + if (this._vertexBuffer.byteLength >= requiredBytes) { + return; + } + + const nextByteLength = growCapacity(this._vertexBuffer.byteLength, requiredBytes); + this._vertexBuffer = new ArrayBuffer(nextByteLength); + this._vertexBytes = new Uint8Array(this._vertexBuffer); + this._vertexFloatView = new Float32Array(this._vertexBuffer); + this._vertexUintView = new Uint32Array(this._vertexBuffer); + } + + private _ensureIndexCapacity(indexCount: number, useUint32: boolean): void { + if (useUint32) { + if (this._indexData32.length >= indexCount) { + return; + } + + this._indexData32 = new Uint32Array( + growCapacity(this._indexData32.length, indexCount) + ); + return; + } + + if (this._indexData16.length >= indexCount) { + return; + } + + this._indexData16 = new Uint16Array( + growCapacity(this._indexData16.length, indexCount) + ); + } + + private _measureQuadCount(submission: Render2DSpriteSubmission): number { + if (!submission.slice) { + return 1; + } + + this._resolveSliceLocalEdges(submission, this._xEdges, this._yEdges); + + let count = 0; + for (let row = 0; row < 3; row += 1) { + const height = this._yEdges[row + 1]! - this._yEdges[row]!; + if (height <= MIN_QUAD_EXTENT) { + continue; + } + + for (let column = 0; column < 3; column += 1) { + const width = this._xEdges[column + 1]! - this._xEdges[column]!; + if (width > MIN_QUAD_EXTENT) { + count += 1; + } + } + } + + return Math.max(1, count); + } + + private _writeSubmission( + submission: Render2DSpriteSubmission, + quadIndex: number, + indexTarget: Uint16Array | Uint32Array + ): number { + const color = packColor(submission.color); + const minX = -submission.anchor.x * submission.size.width; + const minY = -submission.anchor.y * submission.size.height; + const maxX = minX + submission.size.width; + const maxY = minY + submission.size.height; + + if (!submission.slice) { + let u0 = submission.uvRect.x; + let v0 = submission.uvRect.y; + let u1 = submission.uvRect.x + submission.uvRect.width; + let v1 = submission.uvRect.y + submission.uvRect.height; + + if (submission.flipX) { + [u0, u1] = [u1, u0]; + } + + if (submission.flipY) { + [v0, v1] = [v1, v0]; + } + + this._writeTransformedQuad( + submission, + quadIndex, + indexTarget, + minX, + minY, + maxX, + maxY, + u0, + v0, + u1, + v1, + color + ); + return 1; + } + + this._resolveSliceLocalEdges(submission, this._xEdges, this._yEdges); + this._resolveSliceUVEdges(submission, this._uEdges, this._vEdges); + + let written = 0; + for (let row = 0; row < 3; row += 1) { + const localMinY = this._yEdges[row]!; + const localMaxY = this._yEdges[row + 1]!; + if (localMaxY - localMinY <= MIN_QUAD_EXTENT) { + continue; + } + + for (let column = 0; column < 3; column += 1) { + const localMinX = this._xEdges[column]!; + const localMaxX = this._xEdges[column + 1]!; + if (localMaxX - localMinX <= MIN_QUAD_EXTENT) { + continue; + } + + this._writeTransformedQuad( + submission, + quadIndex + written, + indexTarget, + localMinX, + localMinY, + localMaxX, + localMaxY, + this._uEdges[column]!, + this._vEdges[row + 1]!, + this._uEdges[column + 1]!, + this._vEdges[row]!, + color + ); + written += 1; + } + } + + return Math.max(1, written); + } + + private _resolveSliceLocalEdges( + submission: Render2DSpriteSubmission, + xEdges: Float32Array, + yEdges: Float32Array + ): void { + const slice = submission.slice as Render2DSpriteSlice; + const minX = -submission.anchor.x * submission.size.width; + const minY = -submission.anchor.y * submission.size.height; + + this._resolveDisplayAxisEdges( + submission.size.width, + slice.sourceSize.width, + slice.border.left, + slice.border.right, + minX, + xEdges + ); + this._resolveDisplayAxisEdges( + submission.size.height, + slice.sourceSize.height, + slice.border.bottom, + slice.border.top, + minY, + yEdges + ); + } + + private _resolveSliceUVEdges( + submission: Render2DSpriteSubmission, + uEdges: Float32Array, + vEdges: Float32Array + ): void { + const slice = submission.slice as Render2DSpriteSlice; + const uvLeft = submission.uvRect.x; + const uvTop = submission.uvRect.y; + const uvRight = submission.uvRect.x + submission.uvRect.width; + const uvBottom = submission.uvRect.y + submission.uvRect.height; + const leftInner = + uvLeft + submission.uvRect.width * (slice.border.left / slice.sourceSize.width); + const rightInner = + uvLeft + + submission.uvRect.width * + ((slice.sourceSize.width - slice.border.right) / slice.sourceSize.width); + const topInner = + uvTop + submission.uvRect.height * (slice.border.top / slice.sourceSize.height); + const bottomInner = + uvTop + + submission.uvRect.height * + ((slice.sourceSize.height - slice.border.bottom) / + slice.sourceSize.height); + + uEdges[0] = uvLeft; + uEdges[1] = leftInner; + uEdges[2] = rightInner; + uEdges[3] = uvRight; + if (submission.flipX) { + this._reverseEdges(uEdges); + } + + vEdges[0] = uvBottom; + vEdges[1] = bottomInner; + vEdges[2] = topInner; + vEdges[3] = uvTop; + if (submission.flipY) { + this._reverseEdges(vEdges); + } + } + + private _resolveDisplayAxisEdges( + targetSize: number, + sourceSize: number, + startBorder: number, + endBorder: number, + offset: number, + out: Float32Array + ): void { + let startSize = (startBorder / sourceSize) * targetSize; + let endSize = (endBorder / sourceSize) * targetSize; + const totalBorderSize = startSize + endSize; + + if (totalBorderSize > targetSize && totalBorderSize > 0) { + const scale = targetSize / totalBorderSize; + startSize *= scale; + endSize *= scale; + } + + const centerSize = Math.max(0, targetSize - startSize - endSize); + + out[0] = offset; + out[1] = offset + startSize; + out[2] = offset + startSize + centerSize; + out[3] = offset + targetSize; + } + + private _reverseEdges(edges: Float32Array): void { + const first = edges[0]!; + const second = edges[1]!; + edges[0] = edges[3]!; + edges[1] = edges[2]!; + edges[2] = second; + edges[3] = first; + } + + private _writeTransformedQuad( + submission: Render2DSpriteSubmission, + quadIndex: number, + indexTarget: Uint16Array | Uint32Array, + minX: number, + minY: number, + maxX: number, + maxY: number, + uLeft: number, + vTop: number, + uRight: number, + vBottom: number, + color: PackedRender2DColor + ): void { + const vertexBase = quadIndex * RENDER_2D_SPRITE_VERTICES_PER_QUAD; + const indexBase = quadIndex * RENDER_2D_SPRITE_INDICES_PER_QUAD; + + const point = transformPoint(submission.worldMatrix, minX, minY, this._pointScratch); + writeVertex( + this._vertexFloatView, + this._vertexUintView, + vertexBase, + point[0]!, + point[1]!, + point[2]!, + uLeft, + vBottom, + color + ); + + transformPoint(submission.worldMatrix, maxX, minY, this._pointScratch); + writeVertex( + this._vertexFloatView, + this._vertexUintView, + vertexBase + 1, + this._pointScratch[0]!, + this._pointScratch[1]!, + this._pointScratch[2]!, + uRight, + vBottom, + color + ); + + transformPoint(submission.worldMatrix, maxX, maxY, this._pointScratch); + writeVertex( + this._vertexFloatView, + this._vertexUintView, + vertexBase + 2, + this._pointScratch[0]!, + this._pointScratch[1]!, + this._pointScratch[2]!, + uRight, + vTop, + color + ); + + transformPoint(submission.worldMatrix, minX, maxY, this._pointScratch); + writeVertex( + this._vertexFloatView, + this._vertexUintView, + vertexBase + 3, + this._pointScratch[0]!, + this._pointScratch[1]!, + this._pointScratch[2]!, + uLeft, + vTop, + color + ); + + indexTarget[indexBase] = vertexBase; + indexTarget[indexBase + 1] = vertexBase + 1; + indexTarget[indexBase + 2] = vertexBase + 2; + indexTarget[indexBase + 3] = vertexBase; + indexTarget[indexBase + 4] = vertexBase + 2; + indexTarget[indexBase + 5] = vertexBase + 3; + } +} \ No newline at end of file diff --git a/web/packages/render-2d/src/sprite-shader.ts b/web/packages/render-2d/src/sprite-shader.ts new file mode 100644 index 00000000..610ece9f --- /dev/null +++ b/web/packages/render-2d/src/sprite-shader.ts @@ -0,0 +1,181 @@ +import { + compileRenderShaderEffect, + type RenderShaderEffectDefinition, +} from '@axrone/render-core'; + +export const RENDER_2D_DEFAULT_SPRITE_SHADER_ID = 'Render2D/Sprite'; + +export const RENDER_2D_SPRITE_ATTRIBUTE_NAMES = Object.freeze({ + position: 'a_Position', + uv0: 'a_UV0', + color0: 'a_Color0', +} as const); + +export const RENDER_2D_SPRITE_EFFECT = { + format: 'axrone.shader/effect', + version: 1, + id: RENDER_2D_DEFAULT_SPRITE_SHADER_ID, + attributes: [ + { + name: RENDER_2D_SPRITE_ATTRIBUTE_NAMES.position, + type: 'vec3', + location: 0, + }, + { + name: RENDER_2D_SPRITE_ATTRIBUTE_NAMES.uv0, + type: 'vec2', + location: 2, + }, + { + name: RENDER_2D_SPRITE_ATTRIBUTE_NAMES.color0, + type: 'vec4', + location: 3, + }, + ], + varyings: [ + { name: 'v_UV0', type: 'vec2' }, + { name: 'v_Color0', type: 'vec4' }, + { name: 'v_WorldPosition', type: 'vec3' }, + ], + properties: [ + { + name: 'u_ViewProjection', + type: 'mat4', + stages: ['vertex'], + scope: 'camera', + }, + { + name: 'u_MainTex', + type: 'sampler2D', + stages: ['fragment'], + scope: 'material', + inspector: { + label: 'Main Texture', + group: 'Surface', + control: 'texture', + }, + }, + { + name: 'u_MaskShape', + type: 'int', + stages: ['fragment'], + scope: 'internal', + inspector: { hidden: true }, + }, + { + name: 'u_MaskWorldToLocal', + type: 'mat4', + stages: ['fragment'], + scope: 'internal', + inspector: { hidden: true }, + }, + { + name: 'u_MaskSize', + type: 'vec2', + stages: ['fragment'], + scope: 'internal', + inspector: { hidden: true }, + }, + { + name: 'u_MaskAnchor', + type: 'vec2', + stages: ['fragment'], + scope: 'internal', + inspector: { hidden: true }, + }, + { + name: 'u_MaskCornerRadius', + type: 'float', + stages: ['fragment'], + scope: 'internal', + inspector: { hidden: true }, + }, + ], + libraries: [ + { + id: 'sprite.mask', + code: [ + 'float evaluateMaskCircle(vec2 localPosition, vec2 maskSize, vec2 maskAnchor) {', + ' vec2 maskMin = -maskAnchor * maskSize;', + ' vec2 maskCenter = maskMin + maskSize * 0.5;', + ' vec2 radius = max(maskSize * 0.5, vec2(0.000001));', + ' vec2 normalized = (localPosition - maskCenter) / radius;', + ' return step(length(normalized), 1.0);', + '}', + '', + 'float evaluateMaskRoundedRect(vec2 localPosition, vec2 maskSize, vec2 maskAnchor, float cornerRadius) {', + ' vec2 maskMin = -maskAnchor * maskSize;', + ' vec2 maskCenter = maskMin + maskSize * 0.5;', + ' vec2 halfSize = maskSize * 0.5;', + ' float radius = clamp(cornerRadius, 0.0, min(halfSize.x, halfSize.y));', + ' vec2 local = abs(localPosition - maskCenter);', + ' vec2 inner = max(halfSize - vec2(radius), vec2(0.0));', + ' vec2 delta = local - inner;', + ' vec2 maxDelta = max(delta, vec2(0.0));', + ' float outsideDistance = length(maxDelta) + min(max(delta.x, delta.y), 0.0) - radius;', + ' return step(outsideDistance, 0.0);', + '}', + '', + 'float evaluateMask(vec3 worldPosition) {', + ' if (u_MaskShape == 0) {', + ' return 1.0;', + ' }', + '', + ' vec2 localPosition = (u_MaskWorldToLocal * vec4(worldPosition, 1.0)).xy;', + '', + ' if (u_MaskShape == 1) {', + ' return evaluateMaskCircle(localPosition, u_MaskSize, u_MaskAnchor);', + ' }', + '', + ' if (u_MaskShape == 2) {', + ' return evaluateMaskRoundedRect(localPosition, u_MaskSize, u_MaskAnchor, u_MaskCornerRadius);', + ' }', + '', + ' return 1.0;', + '}', + ], + }, + ], + vertex: { + main: [ + 'v_UV0 = a_UV0;', + 'v_Color0 = a_Color0;', + 'v_WorldPosition = a_Position;', + 'gl_Position = u_ViewProjection * vec4(a_Position, 1.0);', + ], + }, + fragment: { + precision: 'highp', + outputs: [{ name: 'o_Color', type: 'vec4' }], + includes: ['sprite.mask'], + main: [ + 'float mask = evaluateMask(v_WorldPosition);', + 'if (mask <= 0.0) {', + ' discard;', + '}', + '', + 'o_Color = texture(u_MainTex, v_UV0) * v_Color0;', + ], + }, + renderState: { + depthTest: false, + cull: false, + blend: true, + }, +} as const satisfies RenderShaderEffectDefinition; + +const COMPILED_RENDER_2D_SPRITE_EFFECT = compileRenderShaderEffect(RENDER_2D_SPRITE_EFFECT); + +export const RENDER_2D_SPRITE_UNIFORM_NAMES = Object.freeze([ + ...COMPILED_RENDER_2D_SPRITE_EFFECT.uniformNames, +]); + +export const RENDER_2D_SPRITE_VERTEX_STRIDE = 24; +export const RENDER_2D_SPRITE_FLOAT_STRIDE = + RENDER_2D_SPRITE_VERTEX_STRIDE / Float32Array.BYTES_PER_ELEMENT; +export const RENDER_2D_SPRITE_VERTICES_PER_QUAD = 4; +export const RENDER_2D_SPRITE_INDICES_PER_QUAD = 6; + +export const RENDER_2D_SPRITE_VERTEX_SOURCE = COMPILED_RENDER_2D_SPRITE_EFFECT.vertexSource; + +export const RENDER_2D_SPRITE_FRAGMENT_SOURCE = COMPILED_RENDER_2D_SPRITE_EFFECT.fragmentSource; \ No newline at end of file diff --git a/web/packages/render-2d/src/types.ts b/web/packages/render-2d/src/types.ts new file mode 100644 index 00000000..b6fb72eb --- /dev/null +++ b/web/packages/render-2d/src/types.ts @@ -0,0 +1,156 @@ +import type { IColorLike } from '@axrone/numeric'; + +declare const __render2DColorBrand: unique symbol; +declare const __render2DTextureReferenceBrand: unique symbol; +declare const __render2DMaterialReferenceBrand: unique symbol; + +export type PackedRender2DColor = number & { readonly [__render2DColorBrand]: true }; +export type Render2DTextureReference = string & { + readonly [__render2DTextureReferenceBrand]: true; +}; +export type Render2DMaterialReference = string & { + readonly [__render2DMaterialReferenceBrand]: true; +}; + +export interface Render2DVec2Like { + readonly x: number; + readonly y: number; +} + +export interface Render2DSizeLike { + readonly width: number; + readonly height: number; +} + +export interface Render2DRectLike extends Render2DVec2Like, Render2DSizeLike {} + +export interface Render2DBorderLike { + readonly left: number; + readonly right: number; + readonly top: number; + readonly bottom: number; +} + +export type Render2DSpriteMaskShape = 'circle' | 'rounded-rect'; + +export type Render2DColorLike = Readonly; +export type Render2DReadonlyMat4Like = ArrayLike; + +export interface Render2DSpriteSlice { + readonly sourceSize: Render2DSizeLike; + readonly border: Render2DBorderLike; +} + +export interface Render2DSpriteMask { + readonly shape: Render2DSpriteMaskShape; + readonly inverseWorldMatrix: Render2DReadonlyMat4Like; + readonly size: Render2DSizeLike; + readonly anchor: Render2DVec2Like; + readonly cornerRadius?: number; +} + +export interface Render2DSpriteTextureSource { + readonly kind: 'texture'; + readonly textureId: string; +} + +export interface Render2DSpriteMaterialSource { + readonly kind: 'material'; + readonly materialId: string; +} + +export type Render2DSpriteSource = + | Render2DSpriteTextureSource + | Render2DSpriteMaterialSource; + +type Render2DSpriteSourceId = + TSource extends Render2DSpriteTextureSource + ? TSource['textureId'] + : TSource extends Render2DSpriteMaterialSource + ? TSource['materialId'] + : never; + +export type Render2DSpriteSourceKey< + TSource extends Render2DSpriteSource = Render2DSpriteSource, +> = `${TSource['kind']}:${Render2DSpriteSourceId}`; + +export interface Render2DSpriteSubmission< + TSource extends Render2DSpriteSource = Render2DSpriteSource, +> { + readonly source: TSource; + readonly worldMatrix: Render2DReadonlyMat4Like; + readonly size: Render2DSizeLike; + readonly anchor: Render2DVec2Like; + readonly uvRect: Render2DRectLike; + readonly color: Render2DColorLike; + readonly clipRect?: Render2DRectLike | null; + readonly slice?: Render2DSpriteSlice | null; + readonly mask?: Render2DSpriteMask | null; + readonly visible?: boolean; + readonly flipX?: boolean; + readonly flipY?: boolean; +} + +export interface Render2DSpriteBatchKey< + TSource extends Render2DSpriteSource = Render2DSpriteSource, +> { + readonly source: TSource; + readonly sourceKey: Render2DSpriteSourceKey; + readonly clipRect: Readonly | null; + readonly mask: Readonly | null; +} + +export interface Render2DSpriteBatchRange< + TSource extends Render2DSpriteSource = Render2DSpriteSource, +> { + readonly key: Render2DSpriteBatchKey; + readonly spriteOffset: number; + readonly spriteCount: number; + readonly quadCount: number; + readonly indexOffset: number; + readonly indexCount: number; +} + +export interface Render2DSpriteBatchBuildResult { + readonly vertexStride: number; + readonly vertexByteLength: number; + readonly vertexData: Uint8Array; + readonly indexData: Uint16Array | Uint32Array; + readonly batches: readonly Render2DSpriteBatchRange[]; + readonly spriteCount: number; + readonly quadCount: number; + readonly vertexCount: number; + readonly indexCount: number; +} + +export interface Render2DSpriteBatchBuilderOptions { + readonly maxBatchQuads?: number; +} + +export const asRender2DTextureReference = ( + value: string +): Render2DTextureReference => value as Render2DTextureReference; + +export const asRender2DMaterialReference = ( + value: string +): Render2DMaterialReference => value as Render2DMaterialReference; + +export const isRender2DSpriteTextureSource = ( + source: Render2DSpriteSource +): source is Render2DSpriteTextureSource => source.kind === 'texture'; + +export const isRender2DSpriteMaterialSource = ( + source: Render2DSpriteSource +): source is Render2DSpriteMaterialSource => source.kind === 'material'; + +export const getRender2DSpriteSourceKey = < + TSource extends Render2DSpriteSource, +>( + source: TSource +): Render2DSpriteSourceKey => { + if (isRender2DSpriteTextureSource(source)) { + return `texture:${source.textureId}` as Render2DSpriteSourceKey; + } + + return `material:${source.materialId}` as Render2DSpriteSourceKey; +}; \ No newline at end of file diff --git a/web/packages/render-core/src/__tests__/shader-effect.test.ts b/web/packages/render-core/src/__tests__/shader-effect.test.ts new file mode 100644 index 00000000..ba66d691 --- /dev/null +++ b/web/packages/render-core/src/__tests__/shader-effect.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { + cloneRenderShaderEffectDefinition, + compileRenderShaderEffect, + type RenderShaderEffectDefinition, +} from '@axrone/render-core'; + +const createEffect = (): RenderShaderEffectDefinition => ({ + format: 'axrone.shader/effect', + version: 1, + id: 'effect/test', + attributes: [ + { name: 'a_Position', type: 'vec3', location: 0 }, + { name: 'a_UV0', type: 'vec2', location: 2 }, + ], + varyings: [{ name: 'v_UV0', type: 'vec2' }], + properties: [ + { + name: 'u_ViewProjection', + type: 'mat4', + stages: ['vertex'], + scope: 'camera', + }, + { + name: 'u_LightColors', + type: 'vec3', + arrayLength: 4, + stages: ['fragment'], + scope: 'frame', + }, + { + name: 'u_MainTex', + type: 'sampler2D', + stages: ['fragment'], + scope: 'material', + inspector: { + control: 'texture', + options: [{ label: 'Main Texture', value: 'u_MainTex' }], + }, + }, + ], + libraries: [ + { + id: 'sample', + code: [ + 'vec4 sampleMainTex(vec2 uv) {', + ' return texture(u_MainTex, uv);', + '}', + ], + }, + ], + vertex: { + outputs: [{ name: 'v_Position', type: 'vec3' }], + main: [ + 'v_UV0 = a_UV0;', + 'v_Position = a_Position;', + 'gl_Position = u_ViewProjection * vec4(a_Position, 1.0);', + ], + }, + fragment: { + precision: 'highp', + inputs: [{ name: 'v_Position', type: 'vec3' }], + outputs: [{ name: 'o_Color', type: 'vec4' }], + includes: ['sample'], + main: ['o_Color = sampleMainTex(v_UV0) + vec4(v_Position, 0.0);'], + }, + renderState: { + depthTest: false, + cull: false, + blend: true, + }, +}); + +describe('render shader effect compiler', () => { + it('builds vertex and fragment sources from a structured effect definition', () => { + const compiled = compileRenderShaderEffect(createEffect()); + + expect(compiled.uniformNames).toEqual([ + 'u_ViewProjection', + 'u_LightColors', + 'u_MainTex', + ]); + expect(compiled.vertexSource).toContain('layout(location = 0) in vec3 a_Position;'); + expect(compiled.vertexSource).toContain('out vec2 v_UV0;'); + expect(compiled.vertexSource).toContain('out vec3 v_Position;'); + expect(compiled.fragmentSource).toContain('precision highp float;'); + expect(compiled.fragmentSource).toContain('uniform vec3 u_LightColors[4];'); + expect(compiled.fragmentSource).toContain('uniform sampler2D u_MainTex;'); + expect(compiled.fragmentSource).toContain('vec4 sampleMainTex(vec2 uv) {'); + expect(compiled.fragmentSource).toContain('out vec4 o_Color;'); + }); + + it('clones effect definitions without leaking nested mutations', () => { + const effect = createEffect(); + const cloned = cloneRenderShaderEffectDefinition(effect); + const mutableEffect = effect as any; + + mutableEffect.attributes?.push({ name: 'a_Color0', type: 'vec4', location: 3 }); + mutableEffect.properties?.[0]?.stages?.push('fragment'); + if (Array.isArray(mutableEffect.libraries?.[0]?.code)) { + mutableEffect.libraries[0].code.push('vec4 broken() { return vec4(0.0); }'); + } + mutableEffect.vertex.main.push('gl_Position = vec4(0.0);'); + + expect(cloned.attributes).toHaveLength(2); + expect(cloned.properties?.[0]?.stages).toEqual(['vertex']); + expect(cloned.properties?.[2]?.inspector?.options).toEqual([ + { label: 'Main Texture', value: 'u_MainTex' }, + ]); + expect(cloned.vertex.main).toHaveLength(3); + expect(Array.isArray(cloned.libraries?.[0]?.code) ? cloned.libraries[0].code : []).toHaveLength(3); + }); +}); \ No newline at end of file diff --git a/web/packages/render-core/src/index.ts b/web/packages/render-core/src/index.ts index f78c72fe..aab425d9 100644 --- a/web/packages/render-core/src/index.ts +++ b/web/packages/render-core/src/index.ts @@ -4,3 +4,4 @@ export * from './memory'; export * from './graph'; export * from './post-process'; export * from './pipeline'; +export * from './shader-effect'; diff --git a/web/packages/render-core/src/shader-effect.ts b/web/packages/render-core/src/shader-effect.ts new file mode 100644 index 00000000..c254edca --- /dev/null +++ b/web/packages/render-core/src/shader-effect.ts @@ -0,0 +1,476 @@ +import { RenderValidationError } from './errors'; + +export type RenderShaderStageName = 'vertex' | 'fragment'; +export type RenderShaderValueType = + | 'float' + | 'vec2' + | 'vec3' + | 'vec4' + | 'int' + | 'ivec2' + | 'ivec3' + | 'ivec4' + | 'uint' + | 'uvec2' + | 'uvec3' + | 'uvec4' + | 'bool' + | 'bvec2' + | 'bvec3' + | 'bvec4' + | 'mat3' + | 'mat4' + | 'sampler2D' + | 'samplerCube'; + +export type RenderShaderSerializableValue = + | string + | number + | boolean + | null + | readonly RenderShaderSerializableValue[] + | { readonly [key: string]: RenderShaderSerializableValue }; + +export interface RenderShaderInspectorOptionDefinition { + readonly label: string; + readonly value: string | number | boolean; +} + +export interface RenderShaderInspectorControlDefinition { + readonly label?: string; + readonly group?: string; + readonly control?: 'auto' | 'color' | 'slider' | 'texture' | 'toggle' | 'select'; + readonly min?: number; + readonly max?: number; + readonly step?: number; + readonly options?: readonly RenderShaderInspectorOptionDefinition[]; + readonly hidden?: boolean; +} + +export interface RenderShaderPropertyDefinition { + readonly name: string; + readonly type: RenderShaderValueType; + readonly arrayLength?: number; + readonly stages?: readonly RenderShaderStageName[]; + readonly scope?: 'material' | 'object' | 'camera' | 'frame' | 'system' | 'internal'; + readonly defaultValue?: RenderShaderSerializableValue; + readonly inspector?: RenderShaderInspectorControlDefinition; +} + +export interface RenderShaderInterfaceDefinition { + readonly name: string; + readonly type: RenderShaderValueType; + readonly interpolation?: 'flat' | 'smooth'; +} + +export interface RenderShaderAttributeDefinition { + readonly name: string; + readonly type: RenderShaderValueType; + readonly location?: number; +} + +export interface RenderShaderLibraryDefinition { + readonly id: string; + readonly code: string | readonly string[]; +} + +export interface RenderShaderStageDefinition { + readonly version?: string; + readonly precision?: 'lowp' | 'mediump' | 'highp'; + readonly directives?: readonly string[]; + readonly inputs?: readonly RenderShaderInterfaceDefinition[]; + readonly outputs?: readonly RenderShaderInterfaceDefinition[]; + readonly declarations?: readonly (string | readonly string[])[]; + readonly includes?: readonly string[]; + readonly main: readonly string[]; +} + +export interface RenderShaderEffectRenderStateDefinition { + readonly depthTest?: boolean; + readonly cull?: boolean; + readonly blend?: boolean; +} + +export interface RenderShaderEffectDefinition { + readonly format: 'axrone.shader/effect'; + readonly version: 1; + readonly id: string; + readonly attributes?: readonly RenderShaderAttributeDefinition[]; + readonly varyings?: readonly RenderShaderInterfaceDefinition[]; + readonly properties?: readonly RenderShaderPropertyDefinition[]; + readonly libraries?: readonly RenderShaderLibraryDefinition[]; + readonly vertex: RenderShaderStageDefinition; + readonly fragment: RenderShaderStageDefinition; + readonly renderState?: RenderShaderEffectRenderStateDefinition; +} + +export interface CompiledRenderShaderEffect { + readonly id: string; + readonly vertexSource: string; + readonly fragmentSource: string; + readonly uniformNames: readonly string[]; +} + +const DEFAULT_SHADER_VERSION = '300 es'; +const SHADER_STAGES = Object.freeze(['vertex', 'fragment'] as const); + +const toArray = ( + value: string | readonly string[] | undefined +): readonly string[] | undefined => { + if (typeof value === 'string') { + return [value]; + } + + return value ? [...value] : undefined; +}; + +const flattenDeclarations = ( + declarations: readonly (string | readonly string[])[] | undefined +): string[] => { + if (!declarations) { + return []; + } + + const lines: string[] = []; + for (const declaration of declarations) { + if (typeof declaration === 'string') { + lines.push(declaration); + continue; + } + + lines.push(...declaration); + } + + return lines; +}; + +const cloneInspector = ( + value: RenderShaderInspectorControlDefinition | undefined +): RenderShaderInspectorControlDefinition | undefined => + value + ? { + label: value.label, + group: value.group, + control: value.control, + min: value.min, + max: value.max, + step: value.step, + options: value.options + ? value.options.map((option) => ({ + label: option.label, + value: option.value, + })) + : undefined, + hidden: value.hidden, + } + : undefined; + +const cloneInterfaces = ( + value: readonly RenderShaderInterfaceDefinition[] | undefined +): readonly RenderShaderInterfaceDefinition[] | undefined => + value?.map((entry) => ({ + name: entry.name, + type: entry.type, + interpolation: entry.interpolation, + })); + +const cloneAttributes = ( + value: readonly RenderShaderAttributeDefinition[] | undefined +): readonly RenderShaderAttributeDefinition[] | undefined => + value?.map((entry) => ({ + name: entry.name, + type: entry.type, + location: entry.location, + })); + +const cloneProperties = ( + value: readonly RenderShaderPropertyDefinition[] | undefined +): readonly RenderShaderPropertyDefinition[] | undefined => + value?.map((entry) => ({ + name: entry.name, + type: entry.type, + arrayLength: entry.arrayLength, + stages: entry.stages ? [...entry.stages] : undefined, + scope: entry.scope, + defaultValue: entry.defaultValue, + inspector: cloneInspector(entry.inspector), + })); + +const cloneLibraries = ( + value: readonly RenderShaderLibraryDefinition[] | undefined +): readonly RenderShaderLibraryDefinition[] | undefined => + value?.map((entry) => ({ + id: entry.id, + code: typeof entry.code === 'string' ? entry.code : [...entry.code], + })); + +const cloneStage = (stage: RenderShaderStageDefinition): RenderShaderStageDefinition => ({ + version: stage.version, + precision: stage.precision, + directives: stage.directives ? [...stage.directives] : undefined, + inputs: cloneInterfaces(stage.inputs), + outputs: cloneInterfaces(stage.outputs), + declarations: stage.declarations + ? stage.declarations.map((entry) => + typeof entry === 'string' ? entry : [...entry] + ) + : undefined, + includes: stage.includes ? [...stage.includes] : undefined, + main: [...stage.main], +}); + +const formatInterfaceLine = ( + direction: 'in' | 'out', + definition: RenderShaderInterfaceDefinition +): string => { + const interpolation = + definition.interpolation && definition.interpolation !== 'smooth' + ? `${definition.interpolation} ` + : ''; + + return `${interpolation}${direction} ${definition.type} ${definition.name};`; +}; + +const toStageSet = ( + stages: readonly RenderShaderStageName[] | undefined +): ReadonlySet => + new Set( + stages && stages.length > 0 ? stages : SHADER_STAGES + ); + +const collectLibraries = ( + effect: RenderShaderEffectDefinition, + includeIds: readonly string[] | undefined +): string[] => { + if (!includeIds || includeIds.length === 0) { + return []; + } + + const libraryMap = new Map(); + for (const library of effect.libraries ?? []) { + if (libraryMap.has(library.id)) { + throw new RenderValidationError('INVALID_EFFECT', 'en', { + effectId: effect.id, + libraryId: library.id, + reason: 'duplicate-library', + }); + } + + libraryMap.set(library.id, library.code); + } + + const lines: string[] = []; + for (const includeId of includeIds) { + const libraryCode = libraryMap.get(includeId); + if (!libraryCode) { + throw new RenderValidationError('INVALID_EFFECT', 'en', { + effectId: effect.id, + libraryId: includeId, + reason: 'missing-library', + }); + } + + if (typeof libraryCode === 'string') { + lines.push(libraryCode); + continue; + } + + lines.push(...libraryCode); + } + + return lines; +}; + +const collectUniformDeclarations = ( + effect: RenderShaderEffectDefinition, + stage: RenderShaderStageName +): string[] => { + const declarations = new Map(); + + for (const property of effect.properties ?? []) { + const stageSet = toStageSet(property.stages); + if (!stageSet.has(stage)) { + continue; + } + + const arraySuffix = + property.arrayLength !== undefined + ? (() => { + if ( + Number.isInteger(property.arrayLength) === false || + property.arrayLength <= 0 + ) { + throw new RenderValidationError('INVALID_EFFECT', 'en', { + effectId: effect.id, + property: property.name, + reason: 'invalid-uniform-array-length', + }); + } + + return `[${property.arrayLength}]`; + })() + : ''; + const line = `uniform ${property.type} ${property.name}${arraySuffix};`; + const previous = declarations.get(property.name); + if (previous && previous !== line) { + throw new RenderValidationError('INVALID_EFFECT', 'en', { + effectId: effect.id, + property: property.name, + reason: 'conflicting-uniform', + }); + } + + declarations.set(property.name, line); + } + + return [...declarations.values()]; +}; + +const collectInterfaceDeclarations = ( + effect: RenderShaderEffectDefinition, + stage: RenderShaderStageName, + direction: 'in' | 'out' +): string[] => { + const definitions = new Map(); + const sourceDefinitions: RenderShaderInterfaceDefinition[] = []; + + if (stage === 'vertex' && direction === 'out') { + sourceDefinitions.push(...(effect.varyings ?? [])); + } + + if (stage === 'fragment' && direction === 'in') { + sourceDefinitions.push(...(effect.varyings ?? [])); + } + + const stageDefinitions = + direction === 'in' + ? effect[stage].inputs ?? [] + : effect[stage].outputs ?? []; + sourceDefinitions.push(...stageDefinitions); + + for (const definition of sourceDefinitions) { + const line = formatInterfaceLine(direction, definition); + const previous = definitions.get(definition.name); + if (previous && previous !== line) { + throw new RenderValidationError('INVALID_EFFECT', 'en', { + effectId: effect.id, + interfaceName: definition.name, + direction, + stage, + reason: 'conflicting-interface', + }); + } + + definitions.set(definition.name, line); + } + + return [...definitions.values()]; +}; + +const collectAttributeDeclarations = (effect: RenderShaderEffectDefinition): string[] => { + const declarations = new Map(); + + for (const attribute of effect.attributes ?? []) { + const prefix = + attribute.location !== undefined + ? `layout(location = ${attribute.location}) ` + : ''; + const line = `${prefix}in ${attribute.type} ${attribute.name};`; + const previous = declarations.get(attribute.name); + if (previous && previous !== line) { + throw new RenderValidationError('INVALID_EFFECT', 'en', { + effectId: effect.id, + attribute: attribute.name, + reason: 'conflicting-attribute', + }); + } + + declarations.set(attribute.name, line); + } + + return [...declarations.values()]; +}; + +const buildStageSource = ( + effect: RenderShaderEffectDefinition, + stageName: RenderShaderStageName +): string => { + const stage = effect[stageName]; + const sections: string[] = []; + + sections.push(`#version ${stage.version ?? DEFAULT_SHADER_VERSION}`); + + if (stage.directives?.length) { + sections.push(stage.directives.join('\n')); + } + + if (stage.precision) { + sections.push(`precision ${stage.precision} float;`); + } + + if (stageName === 'vertex') { + const attributeLines = collectAttributeDeclarations(effect); + if (attributeLines.length > 0) { + sections.push(attributeLines.join('\n')); + } + } + + const inputLines = collectInterfaceDeclarations(effect, stageName, 'in'); + if (inputLines.length > 0) { + sections.push(inputLines.join('\n')); + } + + const outputLines = collectInterfaceDeclarations(effect, stageName, 'out'); + if (outputLines.length > 0) { + sections.push(outputLines.join('\n')); + } + + const uniformLines = collectUniformDeclarations(effect, stageName); + if (uniformLines.length > 0) { + sections.push(uniformLines.join('\n')); + } + + const declarationLines = flattenDeclarations(stage.declarations); + if (declarationLines.length > 0) { + sections.push(declarationLines.join('\n')); + } + + const libraryLines = collectLibraries(effect, stage.includes); + if (libraryLines.length > 0) { + sections.push(libraryLines.join('\n')); + } + + sections.push(`void main() {\n${stage.main.map((line) => ` ${line}`).join('\n')}\n}`); + + return sections.join('\n\n'); +}; + +export const cloneRenderShaderEffectDefinition = ( + effect: RenderShaderEffectDefinition +): RenderShaderEffectDefinition => ({ + format: effect.format, + version: effect.version, + id: effect.id, + attributes: cloneAttributes(effect.attributes), + varyings: cloneInterfaces(effect.varyings), + properties: cloneProperties(effect.properties), + libraries: cloneLibraries(effect.libraries), + vertex: cloneStage(effect.vertex), + fragment: cloneStage(effect.fragment), + renderState: effect.renderState + ? { + depthTest: effect.renderState.depthTest, + cull: effect.renderState.cull, + blend: effect.renderState.blend, + } + : undefined, +}); + +export const compileRenderShaderEffect = ( + effect: RenderShaderEffectDefinition +): CompiledRenderShaderEffect => ({ + id: effect.id, + vertexSource: buildStageSource(effect, 'vertex'), + fragmentSource: buildStageSource(effect, 'fragment'), + uniformNames: (effect.properties ?? []).map((property) => property.name), +}); \ No newline at end of file diff --git a/web/packages/render-webgl2/package.json b/web/packages/render-webgl2/package.json index 48e785c8..377f1e3e 100644 --- a/web/packages/render-webgl2/package.json +++ b/web/packages/render-webgl2/package.json @@ -58,6 +58,7 @@ "dependencies": { "@axrone/ecs-runtime": "^0.1.0", "@axrone/geometry": "^0.1.0", + "@axrone/memory": "^0.0.1", "@axrone/numeric": "^0.0.1", "@axrone/utility": "^0.0.1" } diff --git a/web/packages/render-webgl2/src/__tests__/texture-color-space.test.ts b/web/packages/render-webgl2/src/__tests__/texture-color-space.test.ts new file mode 100644 index 00000000..40227a8b --- /dev/null +++ b/web/packages/render-webgl2/src/__tests__/texture-color-space.test.ts @@ -0,0 +1,59 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { ColorSpace, TextureFormat } from '../texture/interfaces'; + +const webglConstantStub = new Proxy( + { + RGBA8: 0x8058, + RGB8: 0x8051, + SRGB8: 0x8c41, + SRGB8_ALPHA8: 0x8c43, + RGBA: 0x1908, + RGB: 0x1907, + UNSIGNED_BYTE: 0x1401, + }, + { + get: (target, property) => { + if (typeof property === 'string' && property in target) { + return target[property as keyof typeof target]; + } + + return 0; + }, + }, +); + +let textureUtils: typeof import('../texture/utils'); + +beforeAll(async () => { + Object.assign(globalThis, { + WebGL2RenderingContext: webglConstantStub, + WebGLRenderingContext: webglConstantStub, + }); + + textureUtils = await import('../texture/utils'); +}); + +describe('TextureFormatInfo color space overrides', () => { + it('uses sRGB internal formats for 8-bit color textures when requested', () => { + const rgbaInfo = textureUtils.TextureFormatInfo.getFormatInfo( + TextureFormat.RGBA8, + ColorSpace.SRGB, + ); + const rgbInfo = textureUtils.TextureFormatInfo.getFormatInfo( + TextureFormat.RGB8, + ColorSpace.SRGB, + ); + + expect(rgbaInfo.internalFormat).toBe(webglConstantStub.SRGB8_ALPHA8); + expect(rgbaInfo.srgb).toBe(true); + expect(rgbInfo.internalFormat).toBe(webglConstantStub.SRGB8); + expect(rgbInfo.srgb).toBe(true); + }); + + it('keeps linear internal formats when no sRGB color space is requested', () => { + const rgbaInfo = textureUtils.TextureFormatInfo.getFormatInfo(TextureFormat.RGBA8); + + expect(rgbaInfo.internalFormat).toBe(webglConstantStub.RGBA8); + expect(rgbaInfo.srgb).toBe(false); + }); +}); \ No newline at end of file diff --git a/web/packages/render-webgl2/src/batch/batch-group.ts b/web/packages/render-webgl2/src/batch/batch-group.ts index 04046cdd..2fd054f1 100644 --- a/web/packages/render-webgl2/src/batch/batch-group.ts +++ b/web/packages/render-webgl2/src/batch/batch-group.ts @@ -1,5 +1,5 @@ import { Mat4 } from '@axrone/numeric'; -import { ObjectPool } from '@axrone/utility'; +import { ObjectPool } from '@axrone/memory'; import { IBatchable, IBatchGroup } from './interfaces'; import { IMaterialInstance } from '../shader/interfaces'; import { IBuffer, createBufferFactory } from '../buffer'; diff --git a/web/packages/render-webgl2/src/batch/batch-renderer.ts b/web/packages/render-webgl2/src/batch/batch-renderer.ts index 9fcf90e8..5a9cc0a5 100644 --- a/web/packages/render-webgl2/src/batch/batch-renderer.ts +++ b/web/packages/render-webgl2/src/batch/batch-renderer.ts @@ -1,5 +1,5 @@ import { Mat4 } from '@axrone/numeric'; -import { PriorityQueue } from '@axrone/utility'; +import { PriorityQueue } from '@axrone/memory'; import { IBatchable, IBatchRenderer, BatchConfiguration } from './interfaces'; import { IMaterialInstance } from '../shader/interfaces'; import { BatchGroup } from './batch-group'; @@ -110,8 +110,8 @@ export class BatchRenderer implements IBatchRenderer { this.buildRenderQueue(viewMatrix); - while (!this.renderQueue.isEmpty) { - const job = this.renderQueue.tryDequeue(); + while (!this.renderQueue.isEmpty()) { + const job = this.renderQueue.dequeue(); if (!job) break; job.group.render(viewMatrix, projectionMatrix); diff --git a/web/packages/render-webgl2/src/buffer.ts b/web/packages/render-webgl2/src/buffer.ts index 81b70bd4..12ca7d70 100644 --- a/web/packages/render-webgl2/src/buffer.ts +++ b/web/packages/render-webgl2/src/buffer.ts @@ -1,8 +1,7 @@ import type { IDisposable } from './disposable'; import type { IBindableTarget } from './interfaces'; import { BufferPool, ResourceTracker } from './internal/buffer-management'; - -type Nominal = T & { readonly __brand: K }; +import type { Brand } from '@axrone/utility'; export type GLBufferTarget = | WebGL2RenderingContext['ARRAY_BUFFER'] @@ -25,7 +24,7 @@ export type GLBufferUsage = | WebGL2RenderingContext['DYNAMIC_COPY'] | WebGL2RenderingContext['STREAM_COPY']; -export type BufferId = Nominal; +export type BufferId = Brand; export interface BufferOptions { readonly initialData?: BufferSource | null; diff --git a/web/packages/render-webgl2/src/disposable.ts b/web/packages/render-webgl2/src/disposable.ts index 9318a614..45b3f049 100644 --- a/web/packages/render-webgl2/src/disposable.ts +++ b/web/packages/render-webgl2/src/disposable.ts @@ -1,4 +1 @@ -export interface IDisposable { - dispose(): void; - readonly isDisposed: boolean; -} \ No newline at end of file +export type { IDisposable } from '@axrone/utility'; diff --git a/web/packages/render-webgl2/src/framebuffer.ts b/web/packages/render-webgl2/src/framebuffer.ts index 6b567f10..bb6944dd 100644 --- a/web/packages/render-webgl2/src/framebuffer.ts +++ b/web/packages/render-webgl2/src/framebuffer.ts @@ -1,11 +1,10 @@ import type { IDisposable } from './disposable'; import type { IBindableTarget } from './interfaces'; +import type { Brand } from '@axrone/utility'; -type Nominal = T & { readonly __brand: K }; - -export type FramebufferId = Nominal; -export type RenderbufferId = Nominal; -export type TextureId = Nominal; +export type FramebufferId = Brand; +export type RenderbufferId = Brand; +export type TextureId = Brand; export type GLTextureTarget = | WebGL2RenderingContext['TEXTURE_2D'] diff --git a/web/packages/render-webgl2/src/mesh/index-buffer.ts b/web/packages/render-webgl2/src/mesh/index-buffer.ts index 1b6c49af..7dbaa02b 100644 --- a/web/packages/render-webgl2/src/mesh/index-buffer.ts +++ b/web/packages/render-webgl2/src/mesh/index-buffer.ts @@ -6,7 +6,7 @@ import { IndexType, BufferUsage, } from './interfaces'; -import { ByteBuffer } from '@axrone/utility'; +import { ByteBuffer } from '@axrone/memory'; export class WebGLIndexBuffer implements IIndexBuffer { private readonly gl: WebGL2RenderingContext; diff --git a/web/packages/render-webgl2/src/mesh/interfaces.ts b/web/packages/render-webgl2/src/mesh/interfaces.ts index 9597a3e9..7d3743b4 100644 --- a/web/packages/render-webgl2/src/mesh/interfaces.ts +++ b/web/packages/render-webgl2/src/mesh/interfaces.ts @@ -1,5 +1,5 @@ import { Vec2, Vec3, Vec4, Mat4 } from '@axrone/numeric'; -import { ByteBuffer } from '@axrone/utility'; +import { ByteBuffer } from '@axrone/memory'; import type { IBindableTarget } from '../interfaces'; export const enum VertexAttributeType { diff --git a/web/packages/render-webgl2/src/mesh/manager.ts b/web/packages/render-webgl2/src/mesh/manager.ts index 13605c3e..1f86328d 100644 --- a/web/packages/render-webgl2/src/mesh/manager.ts +++ b/web/packages/render-webgl2/src/mesh/manager.ts @@ -4,7 +4,9 @@ import { createSphere, createBox, createPlane, + createQuad, createCylinder, + createCone, createCapsule, createTorus, } from '@axrone/geometry'; @@ -68,7 +70,7 @@ export class MeshManager { heightSegments: segments, generateNormals: true, generateTexCoords: true, - generateTangents: false, + generateTangents: true, }); return this.createMeshFromGeometry(id, geometryBuffers); } @@ -101,10 +103,105 @@ export class MeshManager { return this.createMeshFromGeometry(id, geometryBuffers); } + public createQuadMesh( + id: string, + width: number = 1, + height: number = 1, + orientation: 'xy' | 'xz' | 'yz' = 'xy' + ): IMeshData { + const geometryBuffers = createQuad({ + width, + height, + orientation, + generateNormals: true, + generateTexCoords: true, + generateTangents: false, + }); + return this.createMeshFromGeometry(id, geometryBuffers); + } + public getMesh(id: string): IMeshData | null { return this.meshCache.get(id) || null; } + public createCylinderMesh( + id: string, + radiusTop: number = 0.5, + radiusBottom: number = 0.5, + height: number = 1, + radialSegments: number = 24, + heightSegments: number = 1 + ): IMeshData { + const geometryBuffers = createCylinder({ + radiusTop, + radiusBottom, + height, + radialSegments, + heightSegments, + generateNormals: true, + generateTexCoords: true, + generateTangents: false, + }); + return this.createMeshFromGeometry(id, geometryBuffers); + } + + public createConeMesh( + id: string, + radius: number = 0.5, + height: number = 1, + radialSegments: number = 24, + heightSegments: number = 1 + ): IMeshData { + const geometryBuffers = createCone({ + radius, + height, + radialSegments, + heightSegments, + generateNormals: true, + generateTexCoords: true, + generateTangents: false, + }); + return this.createMeshFromGeometry(id, geometryBuffers); + } + + public createCapsuleMesh( + id: string, + radius: number = 0.5, + length: number = 1, + capSegments: number = 12, + radialSegments: number = 24 + ): IMeshData { + const geometryBuffers = createCapsule({ + radius, + length, + capSegments, + radialSegments, + generateNormals: true, + generateTexCoords: true, + generateTangents: false, + }); + return this.createMeshFromGeometry(id, geometryBuffers); + } + + public createTorusMesh( + id: string, + radius: number = 0.56, + tube: number = 0.18, + radialSegments: number = 20, + tubularSegments: number = 32 + ): IMeshData { + const geometryBuffers = createTorus({ + radius, + tube, + radialSegments, + tubularSegments, + generateNormals: true, + generateTexCoords: true, + generateTangents: false, + }); + return this.createMeshFromGeometry(id, geometryBuffers); + } + public renderMesh(mesh: IMeshData): void { mesh.vertexBuffer.bind(); diff --git a/web/packages/render-webgl2/src/shader/compiler.ts b/web/packages/render-webgl2/src/shader/compiler.ts index 9025fa96..09bb6799 100644 --- a/web/packages/render-webgl2/src/shader/compiler.ts +++ b/web/packages/render-webgl2/src/shader/compiler.ts @@ -30,7 +30,7 @@ import { SHADER_KEYWORDS, } from './utils'; -import { ByteBuffer } from '@axrone/utility'; +import { ByteBuffer } from '@axrone/memory'; class ShaderSourceGenerator { private readonly includeCache = new Map(); diff --git a/web/packages/render-webgl2/src/shader/instance.ts b/web/packages/render-webgl2/src/shader/instance.ts index 34660924..18d05d06 100644 --- a/web/packages/render-webgl2/src/shader/instance.ts +++ b/web/packages/render-webgl2/src/shader/instance.ts @@ -8,7 +8,7 @@ import { } from './interfaces'; import { getWebGLType, getShaderDataTypeComponentCount } from './utils'; -import { ByteBuffer } from '@axrone/utility'; +import { ByteBuffer } from '@axrone/memory'; import { Mat4, Vec2, Vec3, Vec4 } from '@axrone/numeric'; class UniformUploader { @@ -333,6 +333,42 @@ export class ShaderInstance implements IShaderInstance { } } + if (state.stencilTest !== undefined) { + if (state.stencilTest) { + gl.enable(gl.STENCIL_TEST); + } else { + gl.disable(gl.STENCIL_TEST); + } + } + + if (state.stencilMask !== undefined) { + gl.stencilMask(state.stencilMask); + } + + if ( + state.stencilFunc !== undefined || + state.stencilRef !== undefined || + state.stencilMask !== undefined + ) { + gl.stencilFunc( + state.stencilFunc ?? gl.ALWAYS, + state.stencilRef ?? 0, + state.stencilMask ?? 0xff + ); + } + + if ( + state.stencilFail !== undefined || + state.stencilZFail !== undefined || + state.stencilZPass !== undefined + ) { + gl.stencilOp( + state.stencilFail ?? gl.KEEP, + state.stencilZFail ?? gl.KEEP, + state.stencilZPass ?? gl.KEEP + ); + } + if (state.colorWrite) { gl.colorMask( state.colorWrite[0], diff --git a/web/packages/render-webgl2/src/shader/interfaces.ts b/web/packages/render-webgl2/src/shader/interfaces.ts index f8083124..eec82ffa 100644 --- a/web/packages/render-webgl2/src/shader/interfaces.ts +++ b/web/packages/render-webgl2/src/shader/interfaces.ts @@ -1,5 +1,5 @@ import { Mat4, Vec2, Vec3, Vec4 } from '@axrone/numeric'; -import { ByteBuffer } from '@axrone/utility'; +import { ByteBuffer } from '@axrone/memory'; export const enum ShaderDataType { FLOAT = 'float', diff --git a/web/packages/render-webgl2/src/shader/manager.ts b/web/packages/render-webgl2/src/shader/manager.ts index e83d6bd9..8b7a0b9b 100644 --- a/web/packages/render-webgl2/src/shader/manager.ts +++ b/web/packages/render-webgl2/src/shader/manager.ts @@ -1,4 +1,4 @@ -import { ObjectPool } from '@axrone/utility'; +import { ObjectPool } from '@axrone/memory'; import { IShaderManager, IShaderConfiguration, diff --git a/web/packages/render-webgl2/src/texture/interfaces.ts b/web/packages/render-webgl2/src/texture/interfaces.ts index c95c2aa4..9bc53fa6 100644 --- a/web/packages/render-webgl2/src/texture/interfaces.ts +++ b/web/packages/render-webgl2/src/texture/interfaces.ts @@ -1,5 +1,5 @@ import { Vec2, Vec3, Vec4 } from '@axrone/numeric'; -import { ByteBuffer } from '@axrone/utility'; +import { ByteBuffer } from '@axrone/memory'; import type { IBindableTarget } from '../interfaces'; export const enum TextureDimension { diff --git a/web/packages/render-webgl2/src/texture/texture.ts b/web/packages/render-webgl2/src/texture/texture.ts index ba057aad..66372e37 100644 --- a/web/packages/render-webgl2/src/texture/texture.ts +++ b/web/packages/render-webgl2/src/texture/texture.ts @@ -1,5 +1,5 @@ import { Vec2, Vec3, Vec4 } from '@axrone/numeric'; -import { ByteBuffer } from '@axrone/utility'; +import { ByteBuffer } from '@axrone/memory'; import { ITexture, ITextureCreateOptions, @@ -62,7 +62,7 @@ export class WebGLTexture implements ITexture { this.colorSpace = options.colorSpace || ColorSpace.LINEAR; this.label = options.label || null; - const formatInfo = TextureFormatInfo.getFormatInfo(this.format); + const formatInfo = TextureFormatInfo.getFormatInfo(this.format, this.colorSpace); this.isCompressed = formatInfo.compressed; this.bytesPerPixel = formatInfo.bytesPerPixel; this.totalMemoryUsage = TextureUtils.calculateMemoryUsage( @@ -295,7 +295,10 @@ export class WebGLTexture implements ITexture { private _initializeStorageWithOptions(options: ITextureCreateOptions): void { this.bind(); - const formatInfo = TextureFormatInfo.getFormatInfo(options.format); + const formatInfo = TextureFormatInfo.getFormatInfo( + options.format, + options.colorSpace ?? ColorSpace.LINEAR + ); try { switch (options.dimension) { @@ -449,7 +452,7 @@ export class WebGLTexture implements ITexture { height: number, depth: number ): void { - const formatInfo = TextureFormatInfo.getFormatInfo(this.format); + const formatInfo = TextureFormatInfo.getFormatInfo(this.format, this.colorSpace); if (data === null) { return; diff --git a/web/packages/render-webgl2/src/texture/utils.ts b/web/packages/render-webgl2/src/texture/utils.ts index 6f8302d9..453ff539 100644 --- a/web/packages/render-webgl2/src/texture/utils.ts +++ b/web/packages/render-webgl2/src/texture/utils.ts @@ -446,7 +446,15 @@ export class TextureFormatInfo { ...COMPRESSED_FORMAT_DATABASE, ]); - public static getFormatInfo(format: TextureFormat): FormatInfo { + private static readonly SRGB_INTERNAL_FORMAT_OVERRIDES = new Map([ + [TextureFormat.RGB8, WebGL2RenderingContext.SRGB8], + [TextureFormat.RGBA8, WebGL2RenderingContext.SRGB8_ALPHA8], + ]); + + public static getFormatInfo( + format: TextureFormat, + colorSpace: ColorSpace = ColorSpace.LINEAR + ): FormatInfo { const info = this.formatDatabase.get(format); if (!info) { throw new TextureError( @@ -454,7 +462,21 @@ export class TextureFormatInfo { TextureErrorCode.UNSUPPORTED_FORMAT ); } - return info; + + if (colorSpace !== ColorSpace.SRGB || info.compressed || info.depth || info.integer) { + return info; + } + + const srgbInternalFormat = this.SRGB_INTERNAL_FORMAT_OVERRIDES.get(format); + if (srgbInternalFormat === undefined) { + return info; + } + + return { + ...info, + internalFormat: srgbInternalFormat, + srgb: true, + }; } public static getBytesPerPixel(format: TextureFormat): number { diff --git a/web/packages/render-webgl2/src/vao.ts b/web/packages/render-webgl2/src/vao.ts index 5d9396b2..98f81976 100644 --- a/web/packages/render-webgl2/src/vao.ts +++ b/web/packages/render-webgl2/src/vao.ts @@ -1,15 +1,8 @@ -declare const __brand: unique symbol; -declare const __nominal: unique symbol; -declare const __phantom: unique symbol; - import { IVec2Like, IVec3Like, IVec4Like } from '@axrone/numeric'; -import { TypedArray, ObjectPool, ByteBuffer } from '@axrone/utility'; +import { ObjectPool, ByteBuffer } from '@axrone/memory'; +import type { Brand, TypedArray } from '@axrone/utility'; import { createBufferFactory, IBuffer, IBufferFactory } from './buffer'; -type Brand = T & { readonly [__brand]: K }; -type Nominal = T & { readonly [__nominal]: K }; -type Phantom = T & { readonly [__phantom]: K }; - type GLContext = Brand; type GLBuffer = IBuffer; type GLVAO = Brand; diff --git a/web/packages/scene-2d/src/__tests__/scene-2d.test.ts b/web/packages/scene-2d/src/__tests__/scene-2d.test.ts new file mode 100644 index 00000000..f993c898 --- /dev/null +++ b/web/packages/scene-2d/src/__tests__/scene-2d.test.ts @@ -0,0 +1,246 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { Transform } from '@axrone/ecs-runtime'; +import { Vec3 } from '@axrone/numeric'; +import { + createSceneOptions, + installWebGL2Constants, + ManualScheduler, +} from './test-harness'; + +let Scene2D: typeof import('@axrone/scene-2d').Scene2D; +let Camera: typeof import('@axrone/scene-2d').Camera; +let Color: typeof import('@axrone/scene-2d').Color; +let SpriteAnimator: typeof import('@axrone/scene-2d').SpriteAnimator; +let SpriteMask: typeof import('@axrone/scene-2d').SpriteMask; +let SpriteRenderer: typeof import('@axrone/scene-2d').SpriteRenderer; +let SceneCapabilityError: typeof import('@axrone/scene-2d').SceneCapabilityError; +let createSpriteAtlas: typeof import('@axrone/scene-2d').createSpriteAtlas; +let get2DSceneRuntimeProfile: typeof import('@axrone/scene-2d').get2DSceneRuntimeProfile; +let getCoreSceneRuntimeProfile: typeof import('@axrone/scene-2d').getCoreSceneRuntimeProfile; + +describe('Scene2D', () => { + let scheduler: ManualScheduler; + + beforeAll(async () => { + installWebGL2Constants(); + const sceneModule = await import('@axrone/scene-2d'); + Scene2D = sceneModule.Scene2D; + Camera = sceneModule.Camera; + Color = sceneModule.Color; + SpriteAnimator = sceneModule.SpriteAnimator; + SpriteMask = sceneModule.SpriteMask; + SpriteRenderer = sceneModule.SpriteRenderer; + SceneCapabilityError = sceneModule.SceneCapabilityError; + createSpriteAtlas = sceneModule.createSpriteAtlas; + get2DSceneRuntimeProfile = sceneModule.get2DSceneRuntimeProfile; + getCoreSceneRuntimeProfile = sceneModule.getCoreSceneRuntimeProfile; + }); + + beforeEach(() => { + scheduler = new ManualScheduler(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('maps the 2d runtime profile to camera and sprite defaults', () => { + const registry = get2DSceneRuntimeProfile().resolveRegistry({}); + + expect(registry.Camera).toBeDefined(); + expect(registry.SpriteRenderer).toBeDefined(); + expect(registry.SpriteAnimator).toBeDefined(); + expect(registry.SpriteMask).toBeDefined(); + expect('MeshRenderer' in registry).toBe(false); + }); + + it('creates orthographic cameras and sprite actors through the 2d facade', () => { + const canvas = document.createElement('canvas'); + const scene = new Scene2D(createSceneOptions(scheduler, canvas)); + + const cameraActor = scene.createCameraActor({ name: 'Camera' }, { primary: true }); + const spriteActor = scene.createSpriteActor( + { name: 'Sprite' }, + { + textureId: 'hero', + size: [2, 3], + anchor: [0.25, 0.75], + color: Color.fromHex('#ff8040cc'), + } + ); + + const camera = cameraActor.getComponent(Camera); + const cameraTransform = cameraActor.getComponent(Transform); + const sprite = spriteActor.getComponent(SpriteRenderer); + + expect(camera?.orthographic).toBe(true); + expect(camera?.near).toBe(-1000); + expect(camera?.far).toBe(1000); + expect(cameraTransform?.position.z).toBe(1); + expect(sprite?.textureId).toBe('hero'); + expect(sprite?.color).toBeInstanceOf(Color); + expect(sprite?.color.r).toBeCloseTo(1); + expect(sprite?.color.g).toBeCloseTo(0.5019607843137255); + expect(sprite?.color.b).toBeCloseTo(0.25098039215686274); + expect(sprite?.color.a).toBeCloseTo(0.8); + expect(sprite?.size.x).toBe(2); + expect(sprite?.anchor.y).toBe(0.75); + + scene.dispose(); + }); + + it('creates animated sprites that resolve atlas frames through the 2d facade', () => { + const canvas = document.createElement('canvas'); + const scene = new Scene2D(createSceneOptions(scheduler, canvas)); + const atlas = createSpriteAtlas({ + id: 'atlas/hero', + textureId: 'hero', + textureSize: { width: 32, height: 16 }, + frames: [ + { + id: 'hero/idle-0', + region: { x: 0, y: 0, width: 16, height: 16 }, + sourceSize: { width: 2, height: 2 }, + pivot: { x: 0.25, y: 0.75 }, + }, + { + id: 'hero/idle-1', + region: { x: 16, y: 0, width: 16, height: 16 }, + sourceSize: { width: 2, height: 2 }, + }, + ], + animations: [ + { + id: 'idle', + frames: [ + { frameId: 'hero/idle-0', durationMs: 100 }, + { frameId: 'hero/idle-1', durationMs: 100 }, + ], + }, + ], + }); + + scene.createCameraActor({ name: 'Camera' }, { primary: true }); + const actor = scene.createAnimatedSpriteActor( + { name: 'Hero' }, + { color: Color.WHITE }, + { atlas, clipId: 'idle' } + ); + + const sprite = actor.getComponent(SpriteRenderer); + const animator = actor.getComponent(SpriteAnimator); + + expect(animator).toBeInstanceOf(SpriteAnimator); + expect(sprite?.textureId).toBe('hero'); + expect(sprite?.size.x).toBe(2); + expect(sprite?.anchor.x).toBe(0.25); + expect(sprite?.uvRect.x).toBe(0); + + scene.start(0); + scheduler.flush(16); + scheduler.flush(140); + + expect(sprite?.uvRect.x).toBeCloseTo(0.5); + + scene.dispose(); + }); + + it('batches texture-backed sprites into a single draw call', async () => { + const canvas = document.createElement('canvas'); + const scene = new Scene2D(createSceneOptions(scheduler, canvas)); + const gl = scene.gl as any; + + await scene.registerTexture({ + id: 'hero', + source: { + kind: 'data', + width: 1, + height: 1, + channels: 4, + data: [255, 255, 255, 255], + }, + generateMipmaps: false, + }); + + scene.createCameraActor({ name: 'Camera' }, { primary: true }); + scene.createSpriteActor({ name: 'HeroA' }, { textureId: 'hero', size: [1, 1] }); + scene.createSpriteActor({ name: 'HeroB' }, { textureId: 'hero', size: [2, 1] }); + + scene.start(0); + scheduler.flush(16); + + expect(gl.drawElements).toHaveBeenCalledTimes(1); + expect(scene.renderStats.drawCalls).toBe(1); + expect(scene.renderStats.trianglesSubmitted).toBe(4); + + scene.dispose(); + }); + + it('splits masked sprite batches by scissor state', async () => { + const canvas = document.createElement('canvas'); + const scene = new Scene2D(createSceneOptions(scheduler, canvas)); + const gl = scene.gl as any; + + await scene.registerTexture({ + id: 'hero', + source: { + kind: 'data', + width: 1, + height: 1, + channels: 4, + data: [255, 255, 255, 255], + }, + generateMipmaps: false, + }); + + scene.createCameraActor({ name: 'Camera' }, { primary: true }); + + const leftMask = scene.createMaskActor( + { name: 'LeftMask' }, + { size: [2, 2], shape: 'rounded-rect', cornerRadius: 0.35 } + ); + leftMask.getComponent(Transform)!.position = new Vec3(-2, 0, 0); + const rightMask = scene.createMaskActor( + { name: 'RightMask' }, + { size: [2, 2], shape: 'circle' } + ); + rightMask.getComponent(Transform)!.position = new Vec3(2, 0, 0); + + const leftSprite = scene.createSpriteActor({ name: 'LeftSprite' }, { textureId: 'hero' }); + leftSprite.setParent(leftMask); + const rightSprite = scene.createSpriteActor( + { name: 'RightSprite' }, + { textureId: 'hero' } + ); + rightSprite.setParent(rightMask); + + expect(leftMask.getComponent(SpriteMask)).toBeInstanceOf(SpriteMask); + expect(leftMask.getComponent(SpriteMask)?.shape).toBe('rounded-rect'); + expect(rightMask.getComponent(SpriteMask)?.shape).toBe('circle'); + + scene.start(0); + scheduler.flush(16); + + expect(gl.drawElements).toHaveBeenCalledTimes(2); + expect(gl.enable).toHaveBeenCalledWith(gl.SCISSOR_TEST); + expect(gl.scissor).toHaveBeenCalledTimes(2); + + scene.dispose(); + }); + + it('throws capability errors when sprite helpers are used outside the 2d profile', () => { + const canvas = document.createElement('canvas'); + const scene = new Scene2D({ + ...createSceneOptions(scheduler, canvas), + profile: getCoreSceneRuntimeProfile(), + }); + + try { + expect(() => scene.createSpriteActor({ name: 'Sprite' })).toThrow( + SceneCapabilityError + ); + } finally { + scene.dispose(); + } + }); +}); \ No newline at end of file diff --git a/web/packages/scene-2d/src/__tests__/test-harness.ts b/web/packages/scene-2d/src/__tests__/test-harness.ts new file mode 100644 index 00000000..3c201ec2 --- /dev/null +++ b/web/packages/scene-2d/src/__tests__/test-harness.ts @@ -0,0 +1,281 @@ +import { vi } from 'vitest'; +import type { SceneOptions } from '@axrone/scene-2d'; + +export const installWebGL2Constants = (): void => { + const root = globalThis as typeof globalThis & { + WebGL2RenderingContext?: typeof WebGL2RenderingContext; + }; + + if (root.WebGL2RenderingContext) { + return; + } + + let nextConstant = 0x2000; + root.WebGL2RenderingContext = new Proxy(class WebGL2RenderingContext {}, { + get(target, property, receiver) { + if (typeof property === 'string' && !(property in target)) { + Reflect.set(target, property, nextConstant++); + } + + return Reflect.get(target, property, receiver); + }, + }) as typeof WebGL2RenderingContext; +}; + +export class ManualScheduler { + readonly kind = 'manual'; + private _now = 0; + private _nextHandle = 1; + private readonly _callbacks = new Map void>(); + + now(): number { + return this._now; + } + + request(callback: (timestamp: number) => void): number { + const handle = this._nextHandle++; + this._callbacks.set(handle, callback); + return handle; + } + + cancel(handle: number): void { + this._callbacks.delete(handle); + } + + flush(timestamp: number): void { + this._now = timestamp; + const callbacks = [...this._callbacks.values()]; + this._callbacks.clear(); + + for (const callback of callbacks) { + callback(timestamp); + } + } +} + +export const createMockGL = (canvas: HTMLCanvasElement) => { + const shaders = new Set(); + const programs = new Set(); + const buffers = new Set(); + const vertexArrays = new Set(); + const textures = new Set(); + const samplers = new Set(); + + const gl = { + canvas, + ARRAY_BUFFER: 0x8892, + ELEMENT_ARRAY_BUFFER: 0x8893, + STATIC_DRAW: 0x88e4, + DYNAMIC_DRAW: 0x88e8, + FLOAT: 0x1406, + FLOAT_VEC2: 0x8b50, + FLOAT_VEC3: 0x8b51, + FLOAT_VEC4: 0x8b52, + FLOAT_MAT4: 0x8b5c, + INT: 0x1404, + INT_VEC2: 0x8b53, + INT_VEC3: 0x8b54, + INT_VEC4: 0x8b55, + BOOL: 0x8b56, + BOOL_VEC2: 0x8b57, + BOOL_VEC3: 0x8b58, + BOOL_VEC4: 0x8b59, + UNSIGNED_BYTE: 0x1401, + UNSIGNED_SHORT: 0x1403, + UNSIGNED_INT: 0x1405, + UNSIGNED_INT_VEC2: 0x8dc6, + UNSIGNED_INT_VEC3: 0x8dc7, + UNSIGNED_INT_VEC4: 0x8dc8, + TRIANGLES: 0x0004, + LINES: 0x0001, + POINTS: 0x0000, + VERTEX_SHADER: 0x8b31, + FRAGMENT_SHADER: 0x8b30, + COMPILE_STATUS: 0x8b81, + LINK_STATUS: 0x8b82, + COLOR_BUFFER_BIT: 0x4000, + DEPTH_BUFFER_BIT: 0x0100, + DEPTH_TEST: 0x0b71, + CULL_FACE: 0x0b44, + BLEND: 0x0be2, + SCISSOR_TEST: 0x0c11, + BACK: 0x0405, + SRC_ALPHA: 0x0302, + ONE_MINUS_SRC_ALPHA: 0x0303, + TEXTURE_2D: 0x0de1, + TEXTURE_3D: 0x806f, + TEXTURE_CUBE_MAP: 0x8513, + TEXTURE_2D_ARRAY: 0x8c1a, + TEXTURE_CUBE_MAP_POSITIVE_X: 0x8515, + TEXTURE_CUBE_MAP_NEGATIVE_X: 0x8516, + TEXTURE_CUBE_MAP_POSITIVE_Y: 0x8517, + TEXTURE_CUBE_MAP_NEGATIVE_Y: 0x8518, + TEXTURE_CUBE_MAP_POSITIVE_Z: 0x8519, + TEXTURE_CUBE_MAP_NEGATIVE_Z: 0x851a, + TEXTURE0: 0x84c0, + TEXTURE_MIN_FILTER: 0x2801, + TEXTURE_MAG_FILTER: 0x2800, + TEXTURE_WRAP_S: 0x2802, + TEXTURE_WRAP_T: 0x2803, + TEXTURE_WRAP_R: 0x8072, + TEXTURE_COMPARE_MODE: 0x884c, + TEXTURE_COMPARE_FUNC: 0x884d, + TEXTURE_MIN_LOD: 0x813a, + TEXTURE_MAX_LOD: 0x813b, + COMPARE_REF_TO_TEXTURE: 0x884e, + NONE: 0, + REPEAT: 0x2901, + CLAMP_TO_EDGE: 0x812f, + CLAMP_TO_BORDER: 0x812d, + MIRRORED_REPEAT: 0x8370, + NEAREST: 0x2600, + LINEAR: 0x2601, + NEAREST_MIPMAP_NEAREST: 0x2700, + LINEAR_MIPMAP_NEAREST: 0x2701, + NEAREST_MIPMAP_LINEAR: 0x2702, + LINEAR_MIPMAP_LINEAR: 0x2703, + NEVER: 0x0200, + LESS: 0x0201, + EQUAL: 0x0202, + LEQUAL: 0x0203, + GREATER: 0x0204, + NOTEQUAL: 0x0205, + GEQUAL: 0x0206, + ALWAYS: 0x0207, + createShader: vi.fn((type: number) => { + const shader = { type }; + shaders.add(shader); + return shader as unknown as WebGLShader; + }), + shaderSource: vi.fn(), + compileShader: vi.fn(), + getShaderParameter: vi.fn(() => true), + getShaderInfoLog: vi.fn(() => ''), + deleteShader: vi.fn((shader: object) => { + shaders.delete(shader); + }), + createProgram: vi.fn(() => { + const program = {}; + programs.add(program); + return program as WebGLProgram; + }), + bindAttribLocation: vi.fn(), + attachShader: vi.fn(), + linkProgram: vi.fn(), + getProgramParameter: vi.fn(() => true), + getProgramInfoLog: vi.fn(() => ''), + deleteProgram: vi.fn((program: object) => { + programs.delete(program); + }), + getUniformLocation: vi.fn( + (_: WebGLProgram, name: string) => ({ name }) as WebGLUniformLocation + ), + useProgram: vi.fn(), + createVertexArray: vi.fn(() => { + const vao = {}; + vertexArrays.add(vao); + return vao as WebGLVertexArrayObject; + }), + bindVertexArray: vi.fn(), + deleteVertexArray: vi.fn((vao: object) => { + vertexArrays.delete(vao); + }), + createBuffer: vi.fn(() => { + const buffer = {}; + buffers.add(buffer); + return buffer as WebGLBuffer; + }), + bindBuffer: vi.fn(), + bufferData: vi.fn(), + deleteBuffer: vi.fn((buffer: object) => { + buffers.delete(buffer); + }), + enableVertexAttribArray: vi.fn(), + vertexAttribPointer: vi.fn(), + vertexAttribIPointer: vi.fn(), + vertexAttribI4ui: vi.fn(), + viewport: vi.fn(), + clearColor: vi.fn(), + clearDepth: vi.fn(), + clear: vi.fn(), + enable: vi.fn(), + disable: vi.fn(), + cullFace: vi.fn(), + blendFunc: vi.fn(), + scissor: vi.fn(), + depthMask: vi.fn(), + uniformMatrix4fv: vi.fn(), + uniform4f: vi.fn(), + uniform3f: vi.fn(), + uniform2f: vi.fn(), + uniform1f: vi.fn(), + uniform1i: vi.fn(), + uniform4fv: vi.fn(), + uniform3fv: vi.fn(), + uniform2fv: vi.fn(), + uniform1fv: vi.fn(), + uniform4iv: vi.fn(), + uniform3iv: vi.fn(), + uniform2iv: vi.fn(), + uniform1iv: vi.fn(), + uniform4uiv: vi.fn(), + uniform3uiv: vi.fn(), + uniform2uiv: vi.fn(), + uniform1uiv: vi.fn(), + drawArrays: vi.fn(), + drawElements: vi.fn(), + createTexture: vi.fn(() => { + const texture = {}; + textures.add(texture); + return texture as WebGLTexture; + }), + bindTexture: vi.fn(), + activeTexture: vi.fn(), + deleteTexture: vi.fn((texture: object) => { + textures.delete(texture); + }), + texImage2D: vi.fn(), + texImage3D: vi.fn(), + compressedTexImage2D: vi.fn(), + texSubImage2D: vi.fn(), + texSubImage3D: vi.fn(), + generateMipmap: vi.fn(), + createSampler: vi.fn(() => { + const sampler = {}; + samplers.add(sampler); + return sampler as WebGLSampler; + }), + bindSampler: vi.fn(), + deleteSampler: vi.fn((sampler: object) => { + samplers.delete(sampler); + }), + samplerParameteri: vi.fn(), + samplerParameterf: vi.fn(), + getExtension: vi.fn(() => null), + getParameter: vi.fn(() => 1), + }; + + return gl as unknown as WebGL2RenderingContext; +}; + +export const createSceneOptions = ( + scheduler: ManualScheduler, + canvas: HTMLCanvasElement, + registry: SceneOptions['registry'] = {} +): SceneOptions => { + const gl = createMockGL(canvas); + Object.defineProperty(canvas, 'getContext', { + value: vi.fn(() => gl), + configurable: true, + }); + + return { + registry, + scheduler: scheduler as any, + autoStart: false, + createCanvas: () => canvas, + width: 640, + height: 360, + fixedDelta: 16, + }; +}; \ No newline at end of file diff --git a/web/packages/scene-2d/src/index.ts b/web/packages/scene-2d/src/index.ts index 41aad01d..a09e70fa 100644 --- a/web/packages/scene-2d/src/index.ts +++ b/web/packages/scene-2d/src/index.ts @@ -1,5 +1,6 @@ export * from '@axrone/scene-runtime'; export * from '@axrone/scene-runtime/scene-facade'; +export * from '@axrone/scene-runtime/scene-2d-support'; export { SCENE_2D_BUILT_IN_MANIFEST, diff --git a/web/packages/scene-2d/src/scene-2d-actor-runtime.ts b/web/packages/scene-2d/src/scene-2d-actor-runtime.ts new file mode 100644 index 00000000..02476536 --- /dev/null +++ b/web/packages/scene-2d/src/scene-2d-actor-runtime.ts @@ -0,0 +1,119 @@ +import { Transform, type Actor, type ActorConfig, type ComponentConstructor } from '@axrone/ecs-runtime'; +import type { World } from '@axrone/ecs-runtime'; +import type { ComponentRegistry } from '@axrone/ecs-runtime'; +import { Vec3 } from '@axrone/numeric'; +import { + SceneCapabilityError, + type SceneActorRuntime, + type SceneRegistry, +} from '@axrone/scene-runtime'; +import { Camera, type CameraConfig } from '@axrone/scene-runtime/scene-facade'; +import { + SpriteAnimator, + type SpriteAnimatorConfig, + SpriteMask, + type SpriteMaskConfig, + SpriteRenderer, + type SpriteRendererConfig, +} from '@axrone/scene-runtime/scene-2d-support'; + +export interface Scene2DActorRuntimeOptions< + R extends ComponentRegistry = Record, +> { + readonly actors: SceneActorRuntime; +} + +export class Scene2DActorRuntime> { + private readonly _actors: SceneActorRuntime; + + constructor(options: Scene2DActorRuntimeOptions) { + this._actors = options.actors; + } + + createCameraActor( + actorConfig: ActorConfig = {}, + cameraConfig: CameraConfig = {} + ): Actor>> { + this._requireRegisteredComponent( + Camera, + 'camera actor creation requires the 2D scene capability/profile' + ); + const isOrthographic = cameraConfig.orthographic ?? true; + const actor = this._actors.createActor(actorConfig); + actor.addComponent(Camera, { + ...cameraConfig, + orthographic: isOrthographic, + ...(isOrthographic + ? { + near: cameraConfig.near ?? -1000, + far: cameraConfig.far ?? 1000, + } + : {}), + }); + + if (isOrthographic) { + const transform = actor.getComponent(Transform); + if (transform) { + transform.position = new Vec3(0, 0, 1); + } + } + + return actor; + } + + createSpriteActor( + actorConfig: ActorConfig = {}, + spriteConfig: SpriteRendererConfig = {} + ): Actor>> { + this._requireRegisteredComponent( + SpriteRenderer, + 'sprite actor creation requires the 2D scene capability/profile' + ); + const actor = this._actors.createActor(actorConfig); + actor.addComponent(SpriteRenderer, spriteConfig); + return actor; + } + + createAnimatedSpriteActor( + actorConfig: ActorConfig = {}, + spriteConfig: SpriteRendererConfig = {}, + animatorConfig: SpriteAnimatorConfig = {} + ): Actor>> { + this._requireRegisteredComponent( + SpriteRenderer, + 'animated sprite creation requires the 2D scene capability/profile' + ); + this._requireRegisteredComponent( + SpriteAnimator, + 'animated sprite creation requires the 2D scene capability/profile' + ); + const actor = this._actors.createActor(actorConfig); + actor.addComponent(SpriteRenderer, spriteConfig); + actor.addComponent(SpriteAnimator, animatorConfig); + return actor; + } + + createMaskActor( + actorConfig: ActorConfig = {}, + maskConfig: SpriteMaskConfig = {} + ): Actor>> { + this._requireRegisteredComponent( + SpriteMask, + 'mask actor creation requires the 2D scene capability/profile' + ); + const actor = this._actors.createActor(actorConfig); + actor.addComponent(SpriteMask, maskConfig); + return actor; + } + + private _requireRegisteredComponent( + componentType: ComponentConstructor, + message: string + ): void { + if (this._actors.isComponentRegistered(componentType)) { + return; + } + + throw new SceneCapabilityError(message); + } +} \ No newline at end of file diff --git a/web/packages/scene-2d/src/scene-2d.ts b/web/packages/scene-2d/src/scene-2d.ts index 42e5e53a..c1df8349 100644 --- a/web/packages/scene-2d/src/scene-2d.ts +++ b/web/packages/scene-2d/src/scene-2d.ts @@ -1,13 +1,63 @@ import type { ComponentRegistry } from '@axrone/ecs-runtime'; +import { type ActorConfig } from '@axrone/ecs-runtime'; import type { SceneOptions } from '@axrone/scene-runtime'; import { get2DSceneRuntimeProfile } from '@axrone/scene-runtime/scene-profile'; import { SceneAssetFacade } from '@axrone/scene-runtime/scene-facade'; +import { type CameraConfig } from '@axrone/scene-runtime/scene-facade'; +import { + type SpriteAnimatorConfig, + type SpriteMaskConfig, + type SpriteRendererConfig, +} from '@axrone/scene-runtime/scene-2d-support'; +import { Scene2DActorRuntime } from './scene-2d-actor-runtime'; export class Scene2D> extends SceneAssetFacade { + private readonly _actors2d: Scene2DActorRuntime; + constructor(options: SceneOptions = {}) { super({ ...options, profile: options.profile ?? get2DSceneRuntimeProfile(), }); + this._actors2d = new Scene2DActorRuntime({ + actors: this._kernel.actors, + }); + } + + createCameraActor( + actorConfig: ActorConfig = {}, + cameraConfig: CameraConfig = {} + ) { + this.assertNotDisposed(); + return this._actors2d.createCameraActor(actorConfig, cameraConfig); + } + + createSpriteActor( + actorConfig: ActorConfig = {}, + spriteConfig: SpriteRendererConfig = {} + ) { + this.assertNotDisposed(); + return this._actors2d.createSpriteActor(actorConfig, spriteConfig); + } + + createAnimatedSpriteActor( + actorConfig: ActorConfig = {}, + spriteConfig: SpriteRendererConfig = {}, + animatorConfig: SpriteAnimatorConfig = {} + ) { + this.assertNotDisposed(); + return this._actors2d.createAnimatedSpriteActor( + actorConfig, + spriteConfig, + animatorConfig + ); + } + + createMaskActor( + actorConfig: ActorConfig = {}, + maskConfig: SpriteMaskConfig = {} + ) { + this.assertNotDisposed(); + return this._actors2d.createMaskActor(actorConfig, maskConfig); } } \ No newline at end of file diff --git a/web/packages/scene-3d/src/__tests__/camera-frame-state.test.ts b/web/packages/scene-3d/src/__tests__/camera-frame-state.test.ts index 7ac1bc8b..e844f0d1 100644 --- a/web/packages/scene-3d/src/__tests__/camera-frame-state.test.ts +++ b/web/packages/scene-3d/src/__tests__/camera-frame-state.test.ts @@ -44,4 +44,28 @@ describe('SceneCameraFrameStateCollector', () => { expect(collector.collect(undefined, 1280, 720)).toBeNull(); }); + + it('uses horizontal field of view when requested by the camera', () => { + const world = new World(createSceneRegistry()); + const collector = new SceneCameraFrameStateCollector(); + const horizontalActor = new Actor(world); + const verticalActor = new Actor(world); + const horizontalCamera = horizontalActor.addComponent(Camera, { + fieldOfView: 90, + fieldOfViewAxis: 'horizontal', + }); + const verticalCamera = verticalActor.addComponent(Camera, { + fieldOfView: 90, + fieldOfViewAxis: 'vertical', + }); + + const horizontalState = collector.collect(horizontalCamera, 1920, 1080); + + expect(horizontalState?.projectionMatrix.equals(horizontalCamera.getProjectionMatrix(1920 / 1080))).toBe( + true + ); + expect( + horizontalCamera.getProjectionMatrix(1920 / 1080).equals(verticalCamera.getProjectionMatrix(1920 / 1080)) + ).toBe(false); + }); }); diff --git a/web/packages/scene-3d/src/__tests__/follow-camera-controller.test.ts b/web/packages/scene-3d/src/__tests__/follow-camera-controller.test.ts new file mode 100644 index 00000000..5f3aa845 --- /dev/null +++ b/web/packages/scene-3d/src/__tests__/follow-camera-controller.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { Transform } from '@axrone/ecs-runtime'; +import { Quat, Vec3 } from '@axrone/numeric'; +import { + createSceneOptions, + installWebGL2Constants, + ManualScheduler, +} from './test-harness'; + +let Scene: typeof import('@axrone/scene-3d').Scene; +let FollowCameraController: typeof import('@axrone/scene-3d').FollowCameraController; + +describe('FollowCameraController', () => { + let scheduler: ManualScheduler; + + beforeAll(async () => { + installWebGL2Constants(); + const sceneModule = await import('@axrone/scene-3d'); + Scene = sceneModule.Scene; + FollowCameraController = sceneModule.FollowCameraController; + }); + + beforeEach(() => { + scheduler = new ManualScheduler(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('snaps to the target orbit and tracks target changes through the scene loop', () => { + const canvas = document.createElement('canvas'); + const scene = new Scene(createSceneOptions(scheduler, canvas)); + + try { + const target = scene.createActor({ name: 'Target' }); + const targetTransform = target.requireComponent(Transform); + targetTransform.position = new Vec3(2, 1, 3); + + const camera = scene.createCameraActor({ name: 'Camera' }, { primary: true }); + const cameraTransform = camera.requireComponent(Transform); + const controller = camera.addComponent(FollowCameraController, { + distance: 6, + azimuth: 0, + elevation: 0, + targetOffset: [0, 1, 0], + }); + + controller.setTarget(targetTransform); + + scene.start(0); + scheduler.flush(16); + + expect(cameraTransform.position.x).toBeCloseTo(2, 5); + expect(cameraTransform.position.y).toBeCloseTo(2, 5); + expect(cameraTransform.position.z).toBeCloseTo(9, 5); + expect(Quat.rotateVector(cameraTransform.rotation, Vec3.BACK).x).toBeCloseTo(0, 5); + expect(Quat.rotateVector(cameraTransform.rotation, Vec3.BACK).y).toBeCloseTo(0, 5); + expect(Quat.rotateVector(cameraTransform.rotation, Vec3.BACK).z).toBeCloseTo(-1, 5); + + controller.orbit(Math.PI * 0.5, 0).zoom(-2).snap(); + scheduler.flush(32); + + expect(cameraTransform.position.x).toBeCloseTo(6, 5); + expect(cameraTransform.position.y).toBeCloseTo(2, 5); + expect(cameraTransform.position.z).toBeCloseTo(3, 5); + expect(Quat.rotateVector(cameraTransform.rotation, Vec3.BACK).x).toBeCloseTo(-1, 5); + expect(Quat.rotateVector(cameraTransform.rotation, Vec3.BACK).y).toBeCloseTo(0, 5); + expect(Quat.rotateVector(cameraTransform.rotation, Vec3.BACK).z).toBeCloseTo(0, 5); + + targetTransform.position = new Vec3(-1, 1.5, 0.5); + controller.snap(); + scheduler.flush(48); + + expect(cameraTransform.position.x).toBeCloseTo(3, 5); + expect(cameraTransform.position.y).toBeCloseTo(2.5, 5); + expect(cameraTransform.position.z).toBeCloseTo(0.5, 5); + } finally { + scene.dispose(); + } + }); +}); \ No newline at end of file diff --git a/web/packages/scene-3d/src/__tests__/lighting-uniform-binder.test.ts b/web/packages/scene-3d/src/__tests__/lighting-uniform-binder.test.ts index bd5a793b..a1ad70e4 100644 --- a/web/packages/scene-3d/src/__tests__/lighting-uniform-binder.test.ts +++ b/web/packages/scene-3d/src/__tests__/lighting-uniform-binder.test.ts @@ -14,6 +14,8 @@ describe('SceneLightingUniformBinder', () => { const shader = {} as SceneShaderResource; const lighting = { ambient: new Vec3(0.4, 0.3, 0.2), + skyLight: new Vec3(0.5, 0.45, 0.4), + groundLight: new Vec3(0.1, 0.08, 0.06), hasDirectional: true, directionalDirection: new Vec3(0, -1, 0), directionalColor: new Vec3(1, 0.8, 0.6), @@ -46,6 +48,8 @@ describe('SceneLightingUniformBinder', () => { expect(writer.write).toHaveBeenCalledWith(shader, 'u_ReceiveLighting', false); expect(writer.write).toHaveBeenCalledWith(shader, 'u_AmbientLight', Vec3.ZERO); + expect(writer.write).toHaveBeenCalledWith(shader, 'u_SkyLight', Vec3.ZERO); + expect(writer.write).toHaveBeenCalledWith(shader, 'u_GroundLight', Vec3.ZERO); expect(writer.write).toHaveBeenCalledWith(shader, 'u_LightColor', Vec3.ZERO); expect(writer.write).toHaveBeenCalledWith(shader, 'u_LightIntensity', 0); expect(writer.write).toHaveBeenCalledWith(shader, 'u_PointLightCount', 0); diff --git a/web/packages/scene-3d/src/__tests__/material-registry.test.ts b/web/packages/scene-3d/src/__tests__/material-registry.test.ts index 349edcf0..3cc91b59 100644 --- a/web/packages/scene-3d/src/__tests__/material-registry.test.ts +++ b/web/packages/scene-3d/src/__tests__/material-registry.test.ts @@ -24,6 +24,7 @@ describe('SceneMaterialRegistry', () => { id: 'mat/basic', shaderId: 'shader/basic', textureBindings: ['u_MainTex'], + passIds: [], }); expect(registry.get('mat/basic')?.textureBindings.get('u_MainTex')).toEqual({ textureId: 'checker', @@ -62,6 +63,7 @@ describe('SceneMaterialRegistry', () => { id: 'mat/basic', shaderId: 'shader/basic', textureBindings: ['u_MainTex'], + passIds: [], }); expect(registry.getTextureSlots('mat/basic')).toEqual([ { @@ -101,6 +103,45 @@ describe('SceneMaterialRegistry', () => { expect(cloned.textures?.u_MainTex).toBe('checker'); }); + it('clones and exposes material pass definitions through handles', () => { + const registry = new SceneMaterialRegistry(); + const definition = { + id: 'mat/passes', + shaderId: 'shader/basic', + passes: [ + { + id: 'main', + primitive: 'triangle-list' as const, + rasterizerState: { + cullMode: 'back' as const, + }, + blendState: { + blendColor: [0.1, 0.2, 0.3, 0.4] as const, + targets: [ + { + blend: true, + colorWriteMask: [true, false, true, false] as const, + }, + ], + }, + }, + ], + }; + + const handle = registry.create(definition); + const storedPass = registry.get('mat/passes')?.passes[0]; + definition.passes[0]!.blendState!.blendColor = [1, 1, 1, 1]; + + expect(handle.passIds).toEqual(['main']); + expect(storedPass?.blendState?.blendColor).toEqual([0.1, 0.2, 0.3, 0.4]); + expect(storedPass?.blendState?.targets?.[0]?.colorWriteMask).toEqual([ + true, + false, + true, + false, + ]); + }); + it('clears stored materials', () => { const registry = new SceneMaterialRegistry(); registry.create({ diff --git a/web/packages/scene-3d/src/__tests__/render-pass-preparer.test.ts b/web/packages/scene-3d/src/__tests__/render-pass-preparer.test.ts index ed987445..03044b49 100644 --- a/web/packages/scene-3d/src/__tests__/render-pass-preparer.test.ts +++ b/web/packages/scene-3d/src/__tests__/render-pass-preparer.test.ts @@ -68,4 +68,36 @@ describe('SceneRenderPassPreparer', () => { expect(gl.clearColor).toHaveBeenCalledWith(0.8, 0.7, 0.6, 1); expect(gl.clearDepth).toHaveBeenCalledWith(0.5); }); + + it('narrows pass clear flags using camera clear settings', () => { + const gl = { + COLOR_BUFFER_BIT: 1, + DEPTH_BUFFER_BIT: 2, + clearColor: vi.fn(), + clearDepth: vi.fn(), + clear: vi.fn(), + } as unknown as WebGL2RenderingContext; + + const preparer = new SceneRenderPassPreparer(gl, new Vec4(0.1, 0.2, 0.3, 1)); + preparer.prepare( + { + id: 'main', + order: 0, + rendererPassId: 'main', + enabled: true, + clearFlags: ['color', 'depth'], + clearColor: null, + clearDepth: null, + }, + { + clearFlags: ['depth'], + clearDepth: 0.25, + clearColor: new Vec4(0.8, 0.7, 0.6, 1), + } as any + ); + + expect(gl.clearColor).not.toHaveBeenCalled(); + expect(gl.clearDepth).toHaveBeenCalledWith(0.25); + expect(gl.clear).toHaveBeenCalledWith(2); + }); }); diff --git a/web/packages/scene-3d/src/__tests__/render-pass-registry.test.ts b/web/packages/scene-3d/src/__tests__/render-pass-registry.test.ts index b492ba6e..6c5d2209 100644 --- a/web/packages/scene-3d/src/__tests__/render-pass-registry.test.ts +++ b/web/packages/scene-3d/src/__tests__/render-pass-registry.test.ts @@ -31,12 +31,14 @@ describe('SceneRenderPassRegistry', () => { id: 'main', order: 0, rendererPassId: 'main', + materialPassId: null, enabled: true, }); expect(overlay).toEqual({ id: 'overlay', order: 1, rendererPassId: 'overlay', + materialPassId: null, enabled: true, }); expect(mainResource?.clearFlags).toEqual(['color', 'depth']); @@ -70,6 +72,7 @@ describe('SceneRenderPassRegistry', () => { expect(handles.map((handle) => handle.id)).toEqual(['earlier', 'later']); expect(handles[0]?.rendererPassId).toBe('custom'); + expect(handles[0]?.materialPassId).toBeNull(); expect(handles[0]?.enabled).toBe(false); expect(definitions.map((definition) => definition.id)).toEqual(['earlier', 'later']); expect(definitions[1]?.clearFlags).toEqual([]); @@ -132,4 +135,20 @@ describe('SceneRenderPassRegistry', () => { expect(toVec4Tuple(cloned.clearColor)).toEqual([0.2, 0.3, 0.4, 1]); expect(cloned.clearFlags).toEqual(['color', 'depth']); }); + + it('stores material pass selectors on render pass resources and handles', () => { + const registry = new SceneRenderPassRegistry({ + defaultPassId: 'main', + defaultClearColor: new Vec4(0, 0, 0, 1), + }); + + const handle = registry.register({ + id: 'shadow', + rendererPassId: 'shadow', + materialPassId: 'shadow-caster', + }); + + expect(handle.materialPassId).toBe('shadow-caster'); + expect(registry.get('shadow')?.materialPassId).toBe('shadow-caster'); + }); }); diff --git a/web/packages/scene-3d/src/__tests__/render-state-applier.test.ts b/web/packages/scene-3d/src/__tests__/render-state-applier.test.ts index d5735a87..1399ccbd 100644 --- a/web/packages/scene-3d/src/__tests__/render-state-applier.test.ts +++ b/web/packages/scene-3d/src/__tests__/render-state-applier.test.ts @@ -9,16 +9,33 @@ describe('SceneRenderStateApplier', () => { DEPTH_TEST: 1, CULL_FACE: 2, BLEND: 3, - CCW: 4, - BACK: 5, - SRC_ALPHA: 6, - ONE_MINUS_SRC_ALPHA: 7, + STENCIL_TEST: 4, + FRONT: 5, + BACK: 6, + NONE: 0, + CCW: 7, + CW: 8, + LESS: 9, + ALWAYS: 10, + KEEP: 11, + SRC_ALPHA: 12, + ONE_MINUS_SRC_ALPHA: 13, + ONE: 14, + FUNC_ADD: 15, enable: vi.fn(), disable: vi.fn(), frontFace: vi.fn(), cullFace: vi.fn(), - blendFunc: vi.fn(), + blendEquationSeparate: vi.fn(), + blendFuncSeparate: vi.fn(), + blendColor: vi.fn(), + colorMask: vi.fn(), depthMask: vi.fn(), + depthFunc: vi.fn(), + stencilFuncSeparate: vi.fn(), + stencilMaskSeparate: vi.fn(), + stencilOpSeparate: vi.fn(), + lineWidth: vi.fn(), } as unknown as WebGL2RenderingContext; const applier = new SceneRenderStateApplier(gl); @@ -35,11 +52,15 @@ describe('SceneRenderStateApplier', () => { expect(gl.enable).toHaveBeenCalledTimes(2); expect(gl.enable).toHaveBeenNthCalledWith(1, 1); expect(gl.enable).toHaveBeenNthCalledWith(2, 2); - expect(gl.disable).toHaveBeenCalledTimes(1); + expect(gl.disable).toHaveBeenCalledTimes(2); expect(gl.disable).toHaveBeenNthCalledWith(1, 3); + expect(gl.disable).toHaveBeenNthCalledWith(2, 4); expect(gl.frontFace).toHaveBeenCalledTimes(1); expect(gl.cullFace).toHaveBeenCalledTimes(1); expect(gl.depthMask).toHaveBeenCalledTimes(1); + expect(gl.depthFunc).toHaveBeenCalledTimes(1); + expect(gl.colorMask).toHaveBeenCalledTimes(1); + expect(gl.lineWidth).toHaveBeenCalledTimes(1); applier.apply( { @@ -50,11 +71,13 @@ describe('SceneRenderStateApplier', () => { renderPass ); - expect(gl.disable).toHaveBeenCalledTimes(3); - expect(gl.disable).toHaveBeenNthCalledWith(2, 1); - expect(gl.disable).toHaveBeenNthCalledWith(3, 2); + expect(gl.disable).toHaveBeenCalledTimes(4); + expect(gl.disable).toHaveBeenNthCalledWith(3, 1); + expect(gl.disable).toHaveBeenNthCalledWith(4, 2); expect(gl.enable).toHaveBeenCalledTimes(3); expect(gl.enable).toHaveBeenNthCalledWith(3, 3); - expect(gl.blendFunc).toHaveBeenCalledTimes(1); + expect(gl.blendFuncSeparate).toHaveBeenCalledTimes(1); + expect(gl.blendEquationSeparate).toHaveBeenCalledTimes(1); + expect(gl.blendColor).toHaveBeenCalledTimes(1); }); }); diff --git a/web/packages/scene-3d/src/__tests__/scene-3d-actor-runtime.test.ts b/web/packages/scene-3d/src/__tests__/scene-3d-actor-runtime.test.ts index a0980eeb..de2f6967 100644 --- a/web/packages/scene-3d/src/__tests__/scene-3d-actor-runtime.test.ts +++ b/web/packages/scene-3d/src/__tests__/scene-3d-actor-runtime.test.ts @@ -7,6 +7,7 @@ import { SceneComponentCatalog } from '@axrone/scene-3d'; import { Camera } from '@axrone/scene-3d'; import { MeshRenderer } from '@axrone/scene-3d'; import { SceneCapabilityError } from '@axrone/scene-3d'; +import { Transform } from '@axrone/ecs-runtime'; const create3DActorRuntime = (registry = createSceneRegistry()) => { const world = new World(registry); @@ -35,6 +36,29 @@ describe('Scene3DActorRuntime', () => { expect(renderableActor.getComponent(MeshRenderer)?.materialId).toBe('material'); }); + it('creates batched renderable actors with hot transform and renderer references', () => { + const runtime = create3DActorRuntime(); + + const created = runtime.createRenderableActors([ + { + actorConfig: { name: 'RenderableA' }, + rendererConfig: { meshId: 'mesh-a', materialId: 'material-a' }, + }, + { + actorConfig: { name: 'RenderableB' }, + rendererConfig: { meshId: 'mesh-b', materialId: 'material-b' }, + }, + ]); + + expect(created).toHaveLength(2); + expect(created[0]?.actor.name).toBe('RenderableA'); + expect(created[0]?.renderer.meshId).toBe('mesh-a'); + expect(created[0]?.renderer).toBe(created[0]?.actor.getComponent(MeshRenderer)); + expect(created[0]?.transform).toBe(created[0]?.actor.getComponent(Transform)); + expect(created[1]?.actor.name).toBe('RenderableB'); + expect(created[1]?.renderer.materialId).toBe('material-b'); + }); + it('fails fast when 3d helpers are used without the 3d capability set', () => { const runtime = create3DActorRuntime( createSceneRegistry({ @@ -49,5 +73,13 @@ describe('Scene3DActorRuntime', () => { { meshId: 'mesh', materialId: 'material' } ) ).toThrow(SceneCapabilityError); + expect(() => + runtime.createRenderableActors([ + { + actorConfig: { name: 'Renderable' }, + rendererConfig: { meshId: 'mesh', materialId: 'material' }, + }, + ]) + ).toThrow(SceneCapabilityError); }); }); diff --git a/web/packages/scene-3d/src/__tests__/scene-asset-runtime.test.ts b/web/packages/scene-3d/src/__tests__/scene-asset-runtime.test.ts index d77b6143..5190c0e1 100644 --- a/web/packages/scene-3d/src/__tests__/scene-asset-runtime.test.ts +++ b/web/packages/scene-3d/src/__tests__/scene-asset-runtime.test.ts @@ -90,4 +90,28 @@ describe('SceneAssetRuntime', () => { expect(runtime.getTexture('texture')).toBeNull(); expect(runtime.getRenderPasses()).toHaveLength(0); }); + + it('registers built-in primitive mesh helpers beyond box plane and sphere', () => { + const canvas = document.createElement('canvas'); + const gl = createMockGL(canvas); + const runtime = new SceneAssetRuntime({ + gl, + defaultPassId: 'main', + defaultClearColor: new Vec4(0.1, 0.2, 0.3, 1), + releaseBaseMesh: vi.fn(), + clearRenderRuntime: vi.fn(), + }); + + runtime.createCapsuleMesh('capsule'); + runtime.createConeMesh('cone'); + runtime.createCylinderMesh('cylinder'); + runtime.createQuadMesh('quad'); + runtime.createTorusMesh('torus'); + + expect(runtime.getMesh('capsule')).not.toBeNull(); + expect(runtime.getMesh('cone')).not.toBeNull(); + expect(runtime.getMesh('cylinder')).not.toBeNull(); + expect(runtime.getMesh('quad')).not.toBeNull(); + expect(runtime.getMesh('torus')).not.toBeNull(); + }); }); diff --git a/web/packages/scene-3d/src/__tests__/scene-profile-manifest.test.ts b/web/packages/scene-3d/src/__tests__/scene-profile-manifest.test.ts index 2f79e94a..f79a7f8b 100644 --- a/web/packages/scene-3d/src/__tests__/scene-profile-manifest.test.ts +++ b/web/packages/scene-3d/src/__tests__/scene-profile-manifest.test.ts @@ -43,6 +43,7 @@ describe('Scene runtime profile manifests', () => { expect(profile.id).toBe(SCENE_2D_RUNTIME_PROFILE_ID); expect(registry.Animator).toBeDefined(); expect(registry.Camera).toBeDefined(); + expect(registry.SpriteRenderer).toBeDefined(); expect('MeshRenderer' in registry).toBe(false); expect('DirectionalLight' in registry).toBe(false); expect('OrbitCameraController' in registry).toBe(false); diff --git a/web/packages/scene-3d/src/__tests__/scene-registry-manifest.test.ts b/web/packages/scene-3d/src/__tests__/scene-registry-manifest.test.ts index 28c32067..652b4db5 100644 --- a/web/packages/scene-3d/src/__tests__/scene-registry-manifest.test.ts +++ b/web/packages/scene-3d/src/__tests__/scene-registry-manifest.test.ts @@ -26,6 +26,7 @@ describe('SceneRegistry manifests', () => { 'PointLight', 'SpotLight', 'OrbitCameraController', + 'FollowCameraController', 'Animator', ]); }); diff --git a/web/packages/scene-3d/src/__tests__/scene-render-runtime.test.ts b/web/packages/scene-3d/src/__tests__/scene-render-runtime.test.ts index d0d4415a..55d14750 100644 --- a/web/packages/scene-3d/src/__tests__/scene-render-runtime.test.ts +++ b/web/packages/scene-3d/src/__tests__/scene-render-runtime.test.ts @@ -16,6 +16,8 @@ const createRenderRuntime = (renderPasses: readonly SceneRenderPassResource[]) = }, } as any, ambientLight: new Vec3(0.05, 0.06, 0.07), + skyLight: new Vec3(0.11, 0.12, 0.13), + groundLight: new Vec3(0.02, 0.03, 0.04), defaultClearColor: new Vec4(0.1, 0.2, 0.3, 1), getActors: () => [], createMeshResource: vi.fn(), diff --git a/web/packages/scene-3d/src/__tests__/scene-snapshot-loader.test.ts b/web/packages/scene-3d/src/__tests__/scene-snapshot-loader.test.ts index 417924fc..632f548b 100644 --- a/web/packages/scene-3d/src/__tests__/scene-snapshot-loader.test.ts +++ b/web/packages/scene-3d/src/__tests__/scene-snapshot-loader.test.ts @@ -89,4 +89,51 @@ describe('SceneSnapshotLoader', () => { } as any) ).rejects.toThrowError(SceneLifecycleError); }); + + it('starts independent texture registrations concurrently before material creation', async () => { + const calls: string[] = []; + let slowTextureStarted = false; + const loader = new SceneSnapshotLoader({ + defaultRenderPassId: 'main', + defaultClearColor: new Vec4(0, 0, 0, 1), + clearExisting: vi.fn(), + clearRenderPasses: vi.fn(), + registerShader: vi.fn(), + registerMesh: vi.fn(), + registerSampler: vi.fn(), + registerTexture: vi.fn(async (texture) => { + calls.push(`start:${texture.id}`); + if (texture.id === 'slow') { + slowTextureStarted = true; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + if (texture.id === 'fast') { + expect(slowTextureStarted).toBe(true); + } + calls.push(`done:${texture.id}`); + }), + registerRenderPass: vi.fn(), + createMaterial: (material) => { + calls.push(`material:${material.id}`); + }, + instantiatePrefab: vi.fn(() => []), + }); + + await loader.load({ + version: 1, + prefab: { id: 'prefab', actors: [] }, + shaders: [], + meshes: [], + samplers: [], + textures: [ + { id: 'slow', source: { kind: 'color', color: [1, 1, 1, 1] } }, + { id: 'fast', source: { kind: 'color', color: [1, 1, 1, 1] } }, + ], + renderPasses: [], + materials: [{ id: 'material', shaderId: 'shader' }], + } as any); + + expect(calls.slice(0, 2)).toEqual(['start:slow', 'start:fast']); + expect(calls.at(-1)).toBe('material:material'); + }); }); diff --git a/web/packages/scene-3d/src/__tests__/scene.test.ts b/web/packages/scene-3d/src/__tests__/scene.test.ts index d02b379b..d89f0186 100644 --- a/web/packages/scene-3d/src/__tests__/scene.test.ts +++ b/web/packages/scene-3d/src/__tests__/scene.test.ts @@ -109,6 +109,32 @@ describe('Scene', () => { scene.dispose(); }); + it('reports compressed texture formats supported by the active preview context', () => { + const canvas = document.createElement('canvas'); + const scene = new Scene(createSceneOptions(scheduler, canvas)); + const gl = scene.gl as unknown as ReturnType; + const getExtension = gl.getExtension; + + gl.getExtension = vi.fn((name: string) => + name === 'WEBGL_compressed_texture_astc' || name === 'EXT_texture_compression_bptc' + ? {} + : null, + ) as typeof gl.getExtension; + + try { + expect( + scene.getSupportedCompressedTextureFormats([ + TextureFormat.ASTC_4x4, + TextureFormat.BC7_RGBA, + ]) + ).toEqual([TextureFormat.ASTC_4x4, TextureFormat.BC7_RGBA]); + } finally { + gl.getExtension = getExtension; + } + + scene.dispose(); + }); + it('builds the default scene registry and merges custom components', () => { const registry = createSceneRegistry({ registry: { diff --git a/web/packages/scene-3d/src/__tests__/shader-registry.test.ts b/web/packages/scene-3d/src/__tests__/shader-registry.test.ts index b3e0b369..863dc1e1 100644 --- a/web/packages/scene-3d/src/__tests__/shader-registry.test.ts +++ b/web/packages/scene-3d/src/__tests__/shader-registry.test.ts @@ -120,4 +120,41 @@ describe('SceneShaderRegistry', () => { expect(cloned.uniforms).toEqual(['u_Model']); expect(cloned.attributes?.position).toBe('a_Position'); }); + + it('clones shader effect metadata without leaking nested mutations', () => { + const definition = { + id: 'effect-basic', + effect: { + format: 'axrone.shader/effect' as const, + version: 1 as const, + id: 'effect-basic', + properties: [ + { + name: 'u_Color', + type: 'vec4' as const, + scope: 'material' as const, + inspector: { + control: 'color' as const, + }, + }, + ], + vertex: { + main: ['gl_Position = vec4(1.0);'], + }, + fragment: { + precision: 'highp' as const, + outputs: [{ name: 'o_Color', type: 'vec4' as const }], + main: ['o_Color = u_Color;'], + }, + }, + }; + + const cloned = cloneSceneShaderDefinition(definition); + const mutableDefinition = definition as any; + mutableDefinition.effect.properties[0].inspector.control = 'slider'; + mutableDefinition.effect.vertex.main.push('gl_Position = vec4(0.0);'); + + expect(cloned.effect?.properties?.[0]?.inspector?.control).toBe('color'); + expect(cloned.effect?.vertex.main).toEqual(['gl_Position = vec4(1.0);']); + }); }); diff --git a/web/packages/scene-3d/src/__tests__/test-harness.ts b/web/packages/scene-3d/src/__tests__/test-harness.ts index 09d0232f..b01e81f4 100644 --- a/web/packages/scene-3d/src/__tests__/test-harness.ts +++ b/web/packages/scene-3d/src/__tests__/test-harness.ts @@ -98,7 +98,39 @@ export const createMockGL = (canvas: HTMLCanvasElement) => { DEPTH_TEST: 0x0b71, CULL_FACE: 0x0b44, BLEND: 0x0be2, + STENCIL_TEST: 0x0b90, BACK: 0x0405, + FRONT: 0x0404, + CCW: 0x0901, + CW: 0x0900, + KEEP: 0x1e00, + ZERO: 0, + REPLACE: 0x1e01, + INVERT: 0x150a, + INCR: 0x1e02, + INCR_WRAP: 0x8507, + DECR: 0x1e03, + DECR_WRAP: 0x8508, + ONE: 1, + SRC_COLOR: 0x0300, + ONE_MINUS_SRC_COLOR: 0x0301, + DST_COLOR: 0x0306, + ONE_MINUS_DST_COLOR: 0x0307, + DST_ALPHA: 0x0304, + ONE_MINUS_DST_ALPHA: 0x0305, + CONSTANT_COLOR: 0x8001, + ONE_MINUS_CONSTANT_COLOR: 0x8002, + CONSTANT_ALPHA: 0x8003, + ONE_MINUS_CONSTANT_ALPHA: 0x8004, + SRC_ALPHA_SATURATE: 0x0308, + FUNC_ADD: 0x8006, + FUNC_SUBTRACT: 0x800a, + FUNC_REVERSE_SUBTRACT: 0x800b, + MIN: 0x8007, + MAX: 0x8008, + POLYGON_OFFSET_FILL: 0x8037, + SAMPLE_ALPHA_TO_COVERAGE: 0x809e, + RASTERIZER_DISCARD: 0x8c89, SRC_ALPHA: 0x0302, ONE_MINUS_SRC_ALPHA: 0x0303, TEXTURE_2D: 0x0de1, @@ -199,9 +231,20 @@ export const createMockGL = (canvas: HTMLCanvasElement) => { clear: vi.fn(), enable: vi.fn(), disable: vi.fn(), + frontFace: vi.fn(), cullFace: vi.fn(), blendFunc: vi.fn(), + blendEquationSeparate: vi.fn(), + blendFuncSeparate: vi.fn(), + blendColor: vi.fn(), + colorMask: vi.fn(), depthMask: vi.fn(), + depthFunc: vi.fn(), + stencilFuncSeparate: vi.fn(), + stencilMaskSeparate: vi.fn(), + stencilOpSeparate: vi.fn(), + polygonOffset: vi.fn(), + lineWidth: vi.fn(), uniformMatrix4fv: vi.fn(), uniform4f: vi.fn(), uniform3f: vi.fn(), @@ -278,4 +321,4 @@ export const createSceneOptions = ( height: 360, fixedDelta: 16, }; -}; \ No newline at end of file +}; diff --git a/web/packages/scene-3d/src/index.ts b/web/packages/scene-3d/src/index.ts index 6e502b84..c30f4bdd 100644 --- a/web/packages/scene-3d/src/index.ts +++ b/web/packages/scene-3d/src/index.ts @@ -21,7 +21,11 @@ export { resolveSceneRegistryFromProfile, } from '@axrone/scene-runtime/scene-profile'; -export type { Scene3DActorRuntimeOptions } from './scene-3d-actor-runtime'; +export type { + Scene3DActorRuntimeOptions, + SceneRenderableActorCreateOptions, + SceneRenderableActorInstance, +} from './scene-3d-actor-runtime'; export { Scene3DActorRuntime } from './scene-3d-actor-runtime'; export { createScene } from './scene-factory'; export { diff --git a/web/packages/scene-3d/src/scene-3d-actor-runtime.ts b/web/packages/scene-3d/src/scene-3d-actor-runtime.ts index 0a057f42..d195d8e9 100644 --- a/web/packages/scene-3d/src/scene-3d-actor-runtime.ts +++ b/web/packages/scene-3d/src/scene-3d-actor-runtime.ts @@ -1,4 +1,4 @@ -import type { Actor, ActorConfig } from '@axrone/ecs-runtime'; +import { Transform, type Actor, type ActorConfig } from '@axrone/ecs-runtime'; import type { World } from '@axrone/ecs-runtime'; import type { ComponentRegistry } from '@axrone/ecs-runtime'; import { @@ -15,6 +15,19 @@ export interface Scene3DActorRuntimeOptions< readonly actors: SceneActorRuntime; } +export interface SceneRenderableActorCreateOptions { + readonly actorConfig?: ActorConfig; + readonly rendererConfig?: MeshRendererConfig; +} + +export interface SceneRenderableActorInstance< + R extends ComponentRegistry = Record, +> { + readonly actor: Actor>>; + readonly transform: Transform; + readonly renderer: MeshRenderer; +} + export class Scene3DActorRuntime> { private readonly _actors: SceneActorRuntime; @@ -48,6 +61,46 @@ export class Scene3DActorRuntime + ): readonly SceneRenderableActorInstance[] { + this._requireRegisteredComponent( + MeshRenderer, + 'renderable actor creation requires the 3D scene capability/profile' + ); + + return this._actors.runInStructureBatch(() => { + const actors = this._actors.createActorsWithComponents( + configs.map((config) => ({ + actorConfig: config.actorConfig ?? {}, + components: [ + { + type: MeshRenderer, + args: [config.rendererConfig ?? {}], + }, + ], + })), + profiling + ); + + const startedAt = profiling ? performance.now() : 0; + const created = actors.map((actor) => { + const renderer = actor.requireComponent(MeshRenderer); + const transform = actor.requireComponent(Transform); + + return { actor, transform, renderer }; + }); + + if (profiling) { + profiling.resolveHotRefsMs = + (profiling.resolveHotRefsMs ?? 0) + (performance.now() - startedAt); + } + + return created; + }); + } + private _requireRegisteredComponent( componentType: typeof Camera | typeof MeshRenderer, message: string diff --git a/web/packages/scene-3d/src/scene-default-shaders.ts b/web/packages/scene-3d/src/scene-default-shaders.ts index 6f01dd6d..f8e11483 100644 --- a/web/packages/scene-3d/src/scene-default-shaders.ts +++ b/web/packages/scene-3d/src/scene-default-shaders.ts @@ -1,30 +1,71 @@ -import type { SceneShaderDefinition } from '@axrone/scene-runtime'; +import { + createSceneShaderDefinitionFromEffect, + type RenderShaderEffectDefinition, + type SceneShaderDefinition, +} from '@axrone/scene-runtime'; + +const UNLIT_COLOR_SHADER_EFFECT = { + format: 'axrone.shader/effect', + version: 1, + id: 'Scene/UnlitColor', + attributes: [{ name: 'a_Position', type: 'vec3', location: 0 }], + properties: [ + { + name: 'u_Model', + type: 'mat4', + stages: ['vertex'], + scope: 'object', + }, + { + name: 'u_View', + type: 'mat4', + stages: ['vertex'], + scope: 'camera', + }, + { + name: 'u_Projection', + type: 'mat4', + stages: ['vertex'], + scope: 'camera', + }, + { + name: 'u_Color', + type: 'vec4', + stages: ['fragment'], + scope: 'material', + inspector: { + label: 'Color', + group: 'Surface', + control: 'color', + }, + }, + ], + vertex: { + main: ['gl_Position = u_Projection * u_View * u_Model * vec4(a_Position, 1.0);'], + }, + fragment: { + precision: 'highp', + outputs: [{ name: 'o_Color', type: 'vec4' }], + main: ['o_Color = u_Color;'], + }, + renderState: { + depthTest: true, + cull: true, + blend: false, + }, +} as const satisfies RenderShaderEffectDefinition; export const createUnlitColorShaderDefinition = ( id: string = 'Scene/UnlitColor' -): SceneShaderDefinition => ({ - id, - vertexSource: `#version 300 es -layout(location = 0) in vec3 a_Position; -layout(location = 2) in vec2 a_UV0; -uniform mat4 u_Model; -uniform mat4 u_View; -uniform mat4 u_Projection; -out vec2 v_UV0; -void main() { - v_UV0 = a_UV0; - gl_Position = u_Projection * u_View * u_Model * vec4(a_Position, 1.0); -}`, - fragmentSource: `#version 300 es -precision highp float; -uniform vec4 u_Color; -in vec2 v_UV0; -out vec4 o_Color; -void main() { - o_Color = u_Color; -}`, - uniforms: ['u_Model', 'u_View', 'u_Projection', 'u_Color'], - depthTest: true, - cull: true, - blend: false, -}); +): SceneShaderDefinition => + createSceneShaderDefinitionFromEffect( + { + ...UNLIT_COLOR_SHADER_EFFECT, + id, + }, + { + attributes: { + position: 'a_Position', + }, + } + ); diff --git a/web/packages/scene-3d/src/scene.ts b/web/packages/scene-3d/src/scene.ts index 5e67c1f8..6848bd1b 100644 --- a/web/packages/scene-3d/src/scene.ts +++ b/web/packages/scene-3d/src/scene.ts @@ -1,6 +1,7 @@ import { Actor, type ActorConfig } from '@axrone/ecs-runtime'; import type { ComponentRegistry } from '@axrone/ecs-runtime'; import type { World } from '@axrone/ecs-runtime'; +import type { TextureFormat } from '@axrone/render-webgl2'; import type { SceneOptions, SceneRegistry } from '@axrone/scene-runtime'; import { getDefaultSceneRuntimeProfile } from '@axrone/scene-runtime/scene-profile'; import { @@ -8,7 +9,11 @@ import { type CameraConfig, } from '@axrone/scene-runtime/scene-facade'; import { type MeshRendererConfig } from '@axrone/scene-runtime/scene-3d-support'; -import { Scene3DActorRuntime } from './scene-3d-actor-runtime'; +import { + Scene3DActorRuntime, + type SceneRenderableActorCreateOptions, + type SceneRenderableActorInstance, +} from './scene-3d-actor-runtime'; export class Scene> extends SceneAssetFacade { private readonly _actors3d: Scene3DActorRuntime; @@ -38,4 +43,18 @@ export class Scene> extends this.assertNotDisposed(); return this._actors3d.createRenderableActor(actorConfig, rendererConfig); } + + createRenderableActors( + configs: readonly SceneRenderableActorCreateOptions[], + profiling?: Record + ): readonly SceneRenderableActorInstance[] { + this.assertNotDisposed(); + return this._actors3d.createRenderableActors(configs, profiling); + } + + getSupportedCompressedTextureFormats( + preferredFormats?: readonly TextureFormat[] + ): readonly TextureFormat[] { + return super.getSupportedCompressedTextureFormats(preferredFormats); + } } \ No newline at end of file diff --git a/web/packages/scene-runtime-gltf/package.json b/web/packages/scene-runtime-gltf/package.json index 0f6e738d..b9d4504f 100644 --- a/web/packages/scene-runtime-gltf/package.json +++ b/web/packages/scene-runtime-gltf/package.json @@ -23,6 +23,7 @@ "dependencies": { "@axrone/asset-gltf": "^0.1.0", "@axrone/ecs-runtime": "^0.1.0", + "@axrone/lighting": "^0.1.0", "@axrone/numeric": "^0.0.1", "@axrone/render-webgl2": "^0.1.0", "@axrone/scene-3d": "^0.1.0", diff --git a/web/packages/scene-runtime-gltf/src/__tests__/gltf-runtime-smoke.test.ts b/web/packages/scene-runtime-gltf/src/__tests__/gltf-runtime-smoke.test.ts index eb139772..3a5f14c1 100644 --- a/web/packages/scene-runtime-gltf/src/__tests__/gltf-runtime-smoke.test.ts +++ b/web/packages/scene-runtime-gltf/src/__tests__/gltf-runtime-smoke.test.ts @@ -67,7 +67,7 @@ const createRigBinaryBlob = (): Uint8Array => { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, - 0, 0, 0, 1, + -0.5, 0.25, 0, 1, ]); const animationTimes = new Float32Array([0, 1]); const animationTranslations = new Float32Array([ @@ -101,7 +101,11 @@ const createRigBinaryBlob = (): Uint8Array => { return bytes; }; -const createRigJson = (): GltfRootJson => ({ +const createRigJson = ( + options: { + readonly material?: NonNullable[number]; + } = {} +): GltfRootJson => ({ asset: { version: '2.0', generator: 'vitest', @@ -207,10 +211,12 @@ const createRigJson = (): GltfRootJson => ({ WEIGHTS_0: 2, }, indices: 3, + material: options.material ? 0 : undefined, }, ], }, ], + materials: options.material ? [options.material] : undefined, nodes: [ { name: 'Joint Root', @@ -258,6 +264,79 @@ const createRigJson = (): GltfRootJson => ({ scene: 0, }); +const createRigJsonWithAnimationMetadata = (): GltfRootJson => { + const base = createRigJson(); + return { + ...base, + nodes: [ + { + ...base.nodes![0]!, + children: [1], + }, + { + ...base.nodes![1]!, + }, + ], + scenes: [ + { + ...base.scenes![0]!, + extras: { + axrone: { + animation: { + parameters: [ + { + name: 'speed', + kind: 'float', + defaultValue: 0.25, + }, + ], + layers: [ + { + id: 'base', + weight: 1, + stateMachine: { + entryState: 'move', + states: [ + { + id: 'move', + motion: { + kind: 'clip', + clipId: 'Move', + }, + loop: true, + }, + ], + }, + ikLayers: [ + { + id: 'reach', + jobs: [ + { + id: 'aim', + solver: 'ccd', + rootBone: 'node/1', + tipBone: 'node/1', + targetPosition: [1, 0, 0], + maxIterations: 8, + }, + ], + }, + ], + }, + ], + rootMotion: { + bone: 'node/1', + consume: true, + projectTranslationAxes: [true, false, false], + }, + }, + }, + }, + }, + ], + }; +}; + const createMorphBinaryBlob = (): Uint8Array => { const morphPositions = new Float32Array([ 1, 0, 0, @@ -517,6 +596,12 @@ describe('glTF runtime smoke', () => { expect(skinningCall?.[1]).toBe(1); expect(jointPaletteCall?.[2].length).toBe(16); + expect(Array.from(jointPaletteCall?.[2] ?? [])).toEqual([ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + -0.5, 0.25, 0, 1, + ]); expect((gl.drawElements as unknown as { mock: { calls: readonly unknown[][] } }).mock.calls.length).toBeGreaterThan(0); } finally { scene.dispose(); @@ -600,4 +685,142 @@ describe('glTF runtime smoke', () => { scene.dispose(); } }); + + it('maps double-sided blend materials onto runtime shader variants', async () => { + const database = new AssetDatabase({ + importers: [createGltfImporter()], + }); + const receipt = await database.import({ + kind: 'bytes', + data: createGlb( + createRigJson({ + material: { + name: 'PreviewMaterial', + alphaMode: 'BLEND', + doubleSided: true, + pbrMetallicRoughness: { + baseColorFactor: [1, 1, 1, 0.5], + metallicFactor: 0, + roughnessFactor: 1, + }, + }, + }), + createRigBinaryBlob() + ), + uri: 'models/rig-material.glb', + mimeType: 'model/gltf-binary', + }); + + const canvas = document.createElement('canvas'); + const scene = new Scene(createSceneOptions(scheduler, canvas)); + + try { + const load = await loadGltfSceneIntoScene(scene, database, receipt.primary.reference, { + namePrefix: 'Variant ', + }); + + expect(load.snapshot.materials[0]?.shaderId).toBe('gltf/pbr/blend/double-sided'); + } finally { + scene.dispose(); + } + }); + + it('preserves animation controller metadata through the glTF runtime bridge', async () => { + const database = new AssetDatabase({ + importers: [createGltfImporter()], + }); + const receipt = await database.import({ + kind: 'bytes', + data: createGlb(createRigJsonWithAnimationMetadata(), createRigBinaryBlob()), + uri: 'models/rig-animation-metadata.glb', + mimeType: 'model/gltf-binary', + }); + + const canvas = document.createElement('canvas'); + const scene = new Scene(createSceneOptions(scheduler, canvas)); + + try { + const load = await loadGltfSceneIntoScene(scene, database, receipt.primary.reference, { + namePrefix: 'Meta ', + }); + const camera = scene.createCameraActor({ name: 'Camera' }, { primary: true }); + camera.requireComponent(Transform).position = new Vec3(0, 0, 5); + + const rootActor = load.actors.find((actor) => actor.name === 'Meta Joint Root'); + const animator = rootActor?.getComponent(Animator) ?? null; + + expect(load.scene.animationController).toMatchObject({ + parameters: [ + expect.objectContaining({ + name: 'speed', + kind: 'float', + defaultValue: 0.25, + }), + ], + layers: [ + expect.objectContaining({ + id: 'base', + stateMachine: expect.objectContaining({ + entryState: 'move', + }), + ikLayers: [ + expect.objectContaining({ + id: 'reach', + jobs: [ + expect.objectContaining({ + id: 'aim', + solver: 'ccd', + rootBone: 'node/1', + tipBone: 'node/1', + }), + ], + }), + ], + }), + ], + rootMotion: expect.objectContaining({ + bone: 'node/1', + consume: true, + projectTranslationAxes: [true, false, false], + }), + }); + expect(load.prefab.data.animationController).toMatchObject( + load.scene.animationController ?? {} + ); + expect(animator?.clipId).toBe('Move'); + expect(animator?.serialize()).toMatchObject({ + parameters: [ + expect.objectContaining({ + name: 'speed', + kind: 'float', + defaultValue: 0.25, + }), + ], + layers: [ + expect.objectContaining({ + id: 'base', + stateMachine: expect.objectContaining({ + entryState: 'move', + }), + ikLayers: [ + expect.objectContaining({ + id: 'reach', + }), + ], + }), + ], + rootMotion: expect.objectContaining({ + bone: 'node/1', + consume: true, + }), + }); + + scene.start(0); + scheduler.flush(500); + + expect(rootActor?.requireComponent(Transform).position.x).toBeGreaterThan(0); + } finally { + scene.dispose(); + } + }); }); diff --git a/web/packages/scene-runtime-gltf/src/__tests__/runtime-scene-assets.test.ts b/web/packages/scene-runtime-gltf/src/__tests__/runtime-scene-assets.test.ts new file mode 100644 index 00000000..34baa86c --- /dev/null +++ b/web/packages/scene-runtime-gltf/src/__tests__/runtime-scene-assets.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import type { GltfTextureAsset, GltfTextureUsage } from '@axrone/asset-gltf'; +import { ColorSpace, FilterMode, TextureFormat, WrapMode } from '@axrone/render-webgl2'; +import { createGltfTextureDefinitionFromTextureAsset } from '../internal/runtime-scene-assets'; + +const createTextureAsset = (usageHints: readonly GltfTextureUsage[]): GltfTextureAsset => ({ + id: 'Texture', + textureIndex: 0, + imageIndex: 0, + sampler: { + id: 'sampler/default', + minFilter: FilterMode.LINEAR, + magFilter: FilterMode.LINEAR, + wrapS: WrapMode.REPEAT, + wrapT: WrapMode.REPEAT, + }, + payload: { + kind: 'raw', + bytes: new Uint8Array([255, 255, 255, 255]), + mimeType: 'image/png', + width: 1, + height: 1, + }, + usageHints, + runtimeFormat: TextureFormat.RGBA8, + transcode: { + status: 'source', + }, +}); + +describe('scene-runtime glTF runtime scene assets', () => { + it('marks base color textures as sRGB for runtime upload', () => { + const built = createGltfTextureDefinitionFromTextureAsset( + 'texture/albedo', + createTextureAsset(['baseColor']), + ); + + expect(built.definition.colorSpace).toBe(ColorSpace.SRGB); + }); + + it('keeps non-color data textures in linear space', () => { + const built = createGltfTextureDefinitionFromTextureAsset( + 'texture/normal', + createTextureAsset(['normal']), + ); + + expect(built.definition.colorSpace).toBe(ColorSpace.LINEAR); + }); +}); \ No newline at end of file diff --git a/web/packages/scene-runtime-gltf/src/__tests__/runtime-shaders.test.ts b/web/packages/scene-runtime-gltf/src/__tests__/runtime-shaders.test.ts new file mode 100644 index 00000000..d7cf5114 --- /dev/null +++ b/web/packages/scene-runtime-gltf/src/__tests__/runtime-shaders.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { + GLTF_PBR_SHADER_EFFECT, + GLTF_UNLIT_SHADER_EFFECT, + createGltfPbrShaderDefinition, + createGltfRuntimeSurfaceDefinition, + createGltfUnlitShaderDefinition, + resolveGltfRuntimeShaderId, +} from '@axrone/scene-runtime-gltf'; + +describe('scene-runtime glTF shader effects', () => { + it('builds the built-in unlit shader from structured effect metadata', () => { + const definition = createGltfUnlitShaderDefinition(); + + expect(definition.effect?.id).toBe('gltf/unlit'); + expect(definition.uniforms).toContain('_BaseColorFactor'); + expect( + definition.effect?.properties?.find((property) => property.name === '_AlphaMode')?.inspector + ?.control + ).toBe('select'); + expect(definition.fragmentSource).toContain('uniform vec4 _BaseColorFactor;'); + expect(definition.fragmentSource).toContain('vec3 linearToSrgb(vec3 color)'); + expect(definition.fragmentSource).toContain('o_Color = vec4(linearToSrgb(baseColor.rgb), baseColor.a);'); + }); + + it('builds the built-in pbr shader from structured effect metadata with uniform arrays', () => { + const definition = createGltfPbrShaderDefinition(); + + expect(definition.effect?.id).toBe('gltf/pbr'); + expect( + definition.effect?.properties?.find((property) => property.name === 'u_JointMatrices') + ?.arrayLength + ).toBe(128); + expect( + definition.effect?.properties?.find((property) => property.name === 'u_DirectionalLightDirection') + ?.arrayLength + ).toBe(1); + expect( + definition.effect?.properties?.find((property) => property.name === 'u_PointLightPosition') + ?.arrayLength + ).toBe(4); + expect( + definition.effect?.properties?.find((property) => property.name === 'u_SpotLightInnerConeCosine') + ?.arrayLength + ).toBe(4); + expect( + definition.effect?.properties?.find((property) => property.name === 'u_PointLightCount') + ?.type + ).toBe('int'); + expect(definition.effect?.properties?.some((property) => property.name === 'u_LocalLightType')).toBe(false); + expect(GLTF_PBR_SHADER_EFFECT.properties?.some((property) => property.name === '_MetallicFactor')).toBe(true); + expect(GLTF_UNLIT_SHADER_EFFECT.properties?.some((property) => property.name === '_BaseColorTexture')).toBe(true); + expect(definition.fragmentSource).toContain('uniform vec3 u_DirectionalLightDirection[1];'); + expect(definition.fragmentSource).toContain('uniform int u_PointLightCount;'); + expect(definition.fragmentSource).toContain('uniform float u_SpotLightInnerConeCosine[4];'); + expect(definition.fragmentSource).not.toContain('u_LocalLightType'); + expect(definition.cull).toBe(true); + expect(definition.blend).toBe(false); + }); + + it('derives shader variants from glTF material uniforms', () => { + expect(resolveGltfRuntimeShaderId('gltf/pbr')).toBe('gltf/pbr'); + expect(resolveGltfRuntimeShaderId('gltf/pbr', { _DoubleSided: 1 })).toBe('gltf/pbr/double-sided'); + expect(resolveGltfRuntimeShaderId('gltf/pbr', { _AlphaMode: 2 })).toBe('gltf/pbr/blend'); + expect(resolveGltfRuntimeShaderId('gltf/unlit', { _AlphaMode: 2, _DoubleSided: 1 })).toBe( + 'gltf/unlit/blend/double-sided' + ); + + const variant = createGltfPbrShaderDefinition('gltf/pbr/blend/double-sided', { + _AlphaMode: 2, + _DoubleSided: 1, + }); + + expect(variant.cull).toBe(false); + expect(variant.blend).toBe(true); + }); + + it('derives a runtime surface contract from glTF uniforms', () => { + const surface = createGltfRuntimeSurfaceDefinition('gltf/pbr', { + _AlphaMode: 1, + _AlphaCutoff: 0.33, + _DoubleSided: 1, + _BaseColorFactor: [0.8, 0.7, 0.6, 1], + _BaseColorTexture_TexCoord: 1, + _MetallicFactor: 0.4, + _RoughnessFactor: 0.2, + _NormalTexture_TexCoord: 0, + _NormalTexture_Scale: 1.5, + _OcclusionTexture_TexCoord: 0, + _OcclusionTexture_Strength: 0.75, + _EmissiveFactor: [0.1, 0.2, 0.3], + _EmissiveTexture_TexCoord: 0, + }); + + expect(surface).toMatchObject({ + shadingModel: 'pbr', + alphaMode: 'mask', + alphaCutoff: 0.33, + metallic: 0.4, + roughness: 0.2, + normalScale: 1.5, + occlusion: 0.75, + emissive: [0.1, 0.2, 0.3], + features: { + useTwoSided: true, + useAlbedoMap: true, + useNormalMap: true, + useOcclusionMap: true, + useEmissiveMap: true, + useAlphaTest: true, + hasSecondUv: true, + }, + }); + }); +}); diff --git a/web/packages/scene-runtime-gltf/src/index.ts b/web/packages/scene-runtime-gltf/src/index.ts index 8f9b16d8..80f2036e 100644 --- a/web/packages/scene-runtime-gltf/src/index.ts +++ b/web/packages/scene-runtime-gltf/src/index.ts @@ -1,6 +1,12 @@ export { + GLTF_PBR_SHADER_EFFECT, + GLTF_UNLIT_SHADER_EFFECT, createGltfPbrShaderDefinition, + createGltfRuntimeMaterialPasses, + createGltfRuntimeSurfaceDefinition, + createGltfRuntimeSurfaceFeatures, createGltfUnlitShaderDefinition, + resolveGltfRuntimeShaderId, } from './internal/runtime-shaders'; export type { @@ -13,4 +19,4 @@ export type { LoadGltfSceneIntoSceneOptions, LoadGltfSceneIntoSceneResult, } from './scene-runtime-adapter'; -export { loadGltfSceneIntoScene } from './scene-runtime-adapter'; \ No newline at end of file +export { loadGltfSceneIntoScene } from './scene-runtime-adapter'; diff --git a/web/packages/scene-runtime-gltf/src/internal/ktx2-container.ts b/web/packages/scene-runtime-gltf/src/internal/ktx2-container.ts deleted file mode 100644 index 2961b2b1..00000000 --- a/web/packages/scene-runtime-gltf/src/internal/ktx2-container.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { GltfTextureMipLevel } from '@axrone/asset-gltf'; -import { GltfContainerError } from '@axrone/asset-gltf'; -import { TextureFormat } from '@axrone/render-webgl2'; - -const KTX2_IDENTIFIER = new Uint8Array([ - 0xab, - 0x4b, - 0x54, - 0x58, - 0x20, - 0x32, - 0x30, - 0xbb, - 0x0d, - 0x0a, - 0x1a, - 0x0a, -]); -const KTX2_HEADER_LENGTH = 80; -const KTX2_LEVEL_INDEX_ENTRY_LENGTH = 24; - -const VULKAN_FORMAT_TO_TEXTURE_FORMAT = new Map([ - [131, TextureFormat.BC1_RGB], - [133, TextureFormat.BC1_RGBA], - [135, TextureFormat.BC2_RGBA], - [137, TextureFormat.BC3_RGBA], - [139, TextureFormat.BC4_R], - [141, TextureFormat.BC5_RG], - [143, TextureFormat.BC6H_RGB_UF16], - [144, TextureFormat.BC6H_RGB_SF16], - [145, TextureFormat.BC7_RGBA], - [157, TextureFormat.ASTC_4x4], - [159, TextureFormat.ASTC_5x4], - [161, TextureFormat.ASTC_5x5], - [163, TextureFormat.ASTC_6x5], - [165, TextureFormat.ASTC_6x6], - [167, TextureFormat.ASTC_8x5], - [169, TextureFormat.ASTC_8x6], - [171, TextureFormat.ASTC_8x8], - [173, TextureFormat.ASTC_10x5], - [175, TextureFormat.ASTC_10x6], - [177, TextureFormat.ASTC_10x8], - [179, TextureFormat.ASTC_10x10], - [181, TextureFormat.ASTC_12x10], - [183, TextureFormat.ASTC_12x12], -]); - -export interface ParsedKtx2Texture { - readonly vkFormat: number; - readonly width: number; - readonly height: number; - readonly levelCount: number; - readonly supercompressionScheme: number; - readonly levels: readonly GltfTextureMipLevel[]; -} - -const readUint64 = (view: DataView, byteOffset: number): number => { - const low = view.getUint32(byteOffset, true); - const high = view.getUint32(byteOffset + 4, true); - return low + high * 0x1_0000_0000; -}; - -const assertKtx2Identifier = (bytes: Uint8Array): void => { - if (bytes.byteLength < KTX2_HEADER_LENGTH) { - throw new GltfContainerError('KTX2 payload is too small'); - } - - for (let index = 0; index < KTX2_IDENTIFIER.length; index += 1) { - if (bytes[index] !== KTX2_IDENTIFIER[index]) { - throw new GltfContainerError('KTX2 identifier is invalid'); - } - } -}; - -export const parseKtx2Texture = (bytes: Uint8Array): ParsedKtx2Texture => { - assertKtx2Identifier(bytes); - - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - const vkFormat = view.getUint32(12, true); - const width = view.getUint32(20, true); - const height = view.getUint32(24, true); - const depth = view.getUint32(28, true); - const layerCount = view.getUint32(32, true); - const faceCount = view.getUint32(36, true); - const levelCount = Math.max(1, view.getUint32(40, true)); - const supercompressionScheme = view.getUint32(44, true); - - if (width <= 0 || height <= 0) { - throw new GltfContainerError('KTX2 texture dimensions must be positive'); - } - - if (depth > 1) { - throw new GltfContainerError('KTX2 3D textures are not supported by the glTF scene bridge'); - } - - if (layerCount > 1) { - throw new GltfContainerError('KTX2 texture arrays are not supported by the glTF scene bridge'); - } - - if (faceCount > 1) { - throw new GltfContainerError('KTX2 cubemaps are not supported by the glTF scene bridge'); - } - - const levelIndexOffset = KTX2_HEADER_LENGTH; - const levelIndexLength = levelCount * KTX2_LEVEL_INDEX_ENTRY_LENGTH; - if (levelIndexOffset + levelIndexLength > bytes.byteLength) { - throw new GltfContainerError('KTX2 level index exceeds the payload length'); - } - - const levels: GltfTextureMipLevel[] = []; - for (let level = 0; level < levelCount; level += 1) { - const entryOffset = levelIndexOffset + level * KTX2_LEVEL_INDEX_ENTRY_LENGTH; - const byteOffset = readUint64(view, entryOffset); - const byteLength = readUint64(view, entryOffset + 8); - - if (byteOffset < 0 || byteLength <= 0 || byteOffset + byteLength > bytes.byteLength) { - throw new GltfContainerError(`KTX2 mip level ${level} exceeds the payload length`); - } - - levels.push( - Object.freeze({ - level, - width: Math.max(1, width >> level), - height: Math.max(1, height >> level), - byteOffset, - byteLength, - }) - ); - } - - return Object.freeze({ - vkFormat, - width, - height, - levelCount, - supercompressionScheme, - levels: Object.freeze(levels), - }); -}; - -export const inferTextureFormatFromKtx2 = (bytes: Uint8Array): TextureFormat | undefined => { - const parsed = parseKtx2Texture(bytes); - return VULKAN_FORMAT_TO_TEXTURE_FORMAT.get(parsed.vkFormat); -}; \ No newline at end of file diff --git a/web/packages/scene-runtime-gltf/src/internal/runtime-scene-assets.ts b/web/packages/scene-runtime-gltf/src/internal/runtime-scene-assets.ts index 8490c653..d27aa73e 100644 --- a/web/packages/scene-runtime-gltf/src/internal/runtime-scene-assets.ts +++ b/web/packages/scene-runtime-gltf/src/internal/runtime-scene-assets.ts @@ -1,5 +1,5 @@ import { Vec3 } from '@axrone/numeric'; -import { TextureFormat } from '@axrone/render-webgl2'; +import { ColorSpace, TextureFormat } from '@axrone/render-webgl2'; import type { AssetImportDiagnostic, GltfMaterialAsset, @@ -11,10 +11,17 @@ import type { GltfTextureUsage, GltfUniformValue, } from '@axrone/asset-gltf'; +import type { + SceneMaterialDefinition, + SceneMaterialSurfaceDefinition, + SceneMaterialSurfaceTextureBindingDefinition, +} from '@axrone/scene-runtime'; import { inferTextureFormatFromKtx2, parseKtx2Texture, -} from './ktx2-container'; +} from '@axrone/asset-gltf'; +import { resolveGltfRuntimeShaderId } from './runtime-shaders'; +import { createGltfRuntimeMaterialPasses } from './runtime-shaders'; interface GltfTextureUniformSpec { readonly usage: GltfTextureUsage; @@ -149,6 +156,13 @@ const createFallbackTextureSource = ( }; }; +const resolveRuntimeTextureColorSpace = ( + usageHints: readonly GltfTextureUsage[] +): ColorSpace => + usageHints.some((usage) => usage === 'baseColor' || usage === 'emissive') + ? ColorSpace.SRGB + : ColorSpace.LINEAR; + const createCompressedRuntimeTextureDefinition = ( key: string, asset: GltfTextureAsset @@ -192,6 +206,7 @@ const createCompressedRuntimeTextureDefinition = ( samplerId: asset.sampler.id, format: TextureFormat.RGBA8, generateMipmaps: false, + colorSpace: resolveRuntimeTextureColorSpace(asset.usageHints), source: createFallbackTextureSource(asset.usageHints), }, diagnostics: [ @@ -243,6 +258,7 @@ const createCompressedRuntimeTextureDefinition = ( samplerId: asset.sampler.id, format: TextureFormat.RGBA8, generateMipmaps: false, + colorSpace: resolveRuntimeTextureColorSpace(asset.usageHints), source: createFallbackTextureSource(asset.usageHints), }, diagnostics: [ @@ -262,6 +278,7 @@ const createCompressedRuntimeTextureDefinition = ( samplerId: asset.sampler.id, format: runtimeFormat, generateMipmaps: false, + colorSpace: resolveRuntimeTextureColorSpace(asset.usageHints), source: { kind: 'compressed', bytes, @@ -290,10 +307,96 @@ const cloneUniformValue = (value: GltfUniformValue): GltfUniformValue => { return value; }; +const toRuntimeSurfaceTextureBinding = ( + binding: GltfMaterialAsset['textures'][GltfTextureUsage] | undefined +): SceneMaterialSurfaceTextureBindingDefinition | undefined => + binding + ? { + textureId: binding.textureKey, + texCoord: binding.transform?.texCoord === 1 || binding.texCoord === 1 ? 1 : 0, + scale: binding.transform?.scale ?? [1, 1], + offset: binding.transform?.offset ?? [0, 0], + rotation: binding.transform?.rotation ?? 0, + } + : undefined; + +const createRuntimeSurfaceDefinition = ( + asset: GltfMaterialAsset, + uniforms: Readonly> +): SceneMaterialSurfaceDefinition => { + const baseColor = uniforms._BaseColorFactor; + const emissive = uniforms._EmissiveFactor; + const albedoMap = toRuntimeSurfaceTextureBinding(asset.textures.baseColor); + const metallicRoughnessMap = toRuntimeSurfaceTextureBinding(asset.textures.metallicRoughness); + const normalMap = toRuntimeSurfaceTextureBinding(asset.textures.normal); + const occlusionMap = toRuntimeSurfaceTextureBinding(asset.textures.occlusion); + const emissiveMap = toRuntimeSurfaceTextureBinding(asset.textures.emissive); + + return { + shadingModel: asset.unlit ? 'unlit' : 'pbr', + alphaMode: + asset.alphaMode === 'BLEND' + ? 'blend' + : asset.alphaMode === 'MASK' + ? 'mask' + : 'opaque', + alphaCutoff: asset.alphaCutoff, + pbrUvSet: 0, + features: { + useVertexColor: false, + hasSecondUv: Object.values(asset.textures).some((binding) => binding?.texCoord === 1), + useNormalMap: Boolean(normalMap?.textureId), + useTwoSided: asset.doubleSided, + useAlbedoMap: Boolean(albedoMap?.textureId), + usePbrMap: false, + useMetallicRoughnessMap: Boolean(metallicRoughnessMap?.textureId), + useOcclusionMap: Boolean(occlusionMap?.textureId), + useEmissiveMap: Boolean(emissiveMap?.textureId), + useAlphaTest: asset.alphaMode === 'MASK', + }, + tilingOffset: [1, 1, 0, 0], + albedo: + Array.isArray(baseColor) && baseColor.length >= 4 + ? [ + Number(baseColor[0] ?? 1), + Number(baseColor[1] ?? 1), + Number(baseColor[2] ?? 1), + Number(baseColor[3] ?? 1), + ] + : [1, 1, 1, 1], + albedoScale: [1, 1, 1], + normalScale: + typeof uniforms._NormalTexture_Scale === 'number' ? uniforms._NormalTexture_Scale : 1, + occlusion: + typeof uniforms._OcclusionTexture_Strength === 'number' + ? uniforms._OcclusionTexture_Strength + : 1, + roughness: + typeof uniforms._RoughnessFactor === 'number' ? uniforms._RoughnessFactor : 1, + metallic: + typeof uniforms._MetallicFactor === 'number' ? uniforms._MetallicFactor : 1, + specularIntensity: 1, + emissive: + Array.isArray(emissive) && emissive.length >= 3 + ? [ + Number(emissive[0] ?? 0), + Number(emissive[1] ?? 0), + Number(emissive[2] ?? 0), + ] + : [0, 0, 0], + emissiveScale: [1, 1, 1], + albedoMap, + normalMap, + metallicRoughnessMap, + occlusionMap, + emissiveMap, + }; +}; + export const normalizeGltfMaterialDefinition = ( asset: GltfMaterialAsset, key: string -): GltfMaterialDefinition => { +): GltfMaterialDefinition & Pick => { const uniforms: Record = Object.fromEntries( Object.entries(asset.definition.uniforms ?? {}).map(([name, value]) => [ name, @@ -327,7 +430,10 @@ export const normalizeGltfMaterialDefinition = ( return { ...asset.definition, id: key, + shaderId: resolveGltfRuntimeShaderId(asset.definition.shaderId, uniforms), uniforms, + surface: createRuntimeSurfaceDefinition(asset, uniforms), + passes: createGltfRuntimeMaterialPasses(uniforms), textures: asset.definition.textures ? { ...asset.definition.textures } : undefined, }; }; @@ -349,6 +455,7 @@ export const createGltfTextureDefinitionFromTextureAsset = ( id: key, samplerId: asset.sampler.id, format: asset.runtimeFormat ?? TextureFormat.RGBA8, + colorSpace: resolveRuntimeTextureColorSpace(asset.usageHints), source: { kind: 'bytes', bytes: new Uint8Array(asset.payload.bytes), @@ -366,6 +473,7 @@ export const createGltfTextureDefinitionFromTextureAsset = ( id: key, samplerId: asset.sampler.id, format: asset.runtimeFormat ?? TextureFormat.RGBA8, + colorSpace: resolveRuntimeTextureColorSpace(asset.usageHints), source: { kind: 'url', url: asset.payload.uri, @@ -381,6 +489,7 @@ export const createGltfTextureDefinitionFromTextureAsset = ( samplerId: asset.sampler.id, format: TextureFormat.RGBA8, generateMipmaps: false, + colorSpace: resolveRuntimeTextureColorSpace(asset.usageHints), source: createFallbackTextureSource(asset.usageHints), }, diagnostics: [ @@ -391,4 +500,4 @@ export const createGltfTextureDefinitionFromTextureAsset = ( }, ], }; -}; \ No newline at end of file +}; diff --git a/web/packages/scene-runtime-gltf/src/internal/runtime-shaders.ts b/web/packages/scene-runtime-gltf/src/internal/runtime-shaders.ts index 7de4a08a..035e87d7 100644 --- a/web/packages/scene-runtime-gltf/src/internal/runtime-shaders.ts +++ b/web/packages/scene-runtime-gltf/src/internal/runtime-shaders.ts @@ -1,308 +1,928 @@ -import type { GltfShaderDefinition } from '@axrone/asset-gltf'; +import type { GltfMeshSemantic, GltfShaderDefinition } from '@axrone/asset-gltf'; +import { createLightingUniformLayout } from '@axrone/lighting'; +import { + createSceneShaderDefinitionFromEffect, + type RenderShaderEffectDefinition, + type RenderShaderPropertyDefinition, +} from '@axrone/scene-runtime'; +import type { + SceneMaterialPassDefinition, + SceneMaterialSurfaceDefinition, + SceneMaterialSurfaceFeaturesDefinition, +} from '@axrone/scene-runtime'; const GLTF_SHADER_PBR_ID = 'gltf/pbr'; const GLTF_SHADER_UNLIT_ID = 'gltf/unlit'; +const GLTF_SHADER_DOUBLE_SIDED_SUFFIX = '/double-sided'; +const GLTF_SHADER_BLEND_SUFFIX = '/blend'; const MAX_GLTF_SKIN_JOINTS = 128; -const MAX_GLTF_LOCAL_LIGHTS = 4; +const GLTF_LIGHTING_LAYOUT = createLightingUniformLayout({ + maxDirectionalLights: 1, + maxPointLights: 4, + maxSpotLights: 4, + maxLocalLights: 4, +}); +const GLTF_LIGHTING_UNIFORMS = GLTF_LIGHTING_LAYOUT.names; +const MAX_GLTF_DIRECTIONAL_LIGHTS = GLTF_LIGHTING_LAYOUT.capacity.maxDirectionalLights; +const MAX_GLTF_POINT_LIGHTS = GLTF_LIGHTING_LAYOUT.capacity.maxPointLights; +const MAX_GLTF_SPOT_LIGHTS = GLTF_LIGHTING_LAYOUT.capacity.maxSpotLights; -export const createGltfUnlitShaderDefinition = ( - id: string = GLTF_SHADER_UNLIT_ID -): GltfShaderDefinition => ({ - id, - vertexSource: `#version 300 es -layout(location = 0) in vec3 a_Position; -layout(location = 2) in vec2 a_UV0; -layout(location = 5) in vec2 a_UV1; -layout(location = 9) in uvec4 a_Joints0; -layout(location = 10) in vec4 a_Weights0; -uniform mat4 u_Model; -uniform mat4 u_View; -uniform mat4 u_Projection; -uniform bool u_Skinning; -uniform int u_SkinJointCount; -uniform mat4 u_JointMatrices[${MAX_GLTF_SKIN_JOINTS}]; -out vec2 v_UV0; -out vec2 v_UV1; -mat4 resolveSkinMatrix() { - if (!u_Skinning || u_SkinJointCount <= 0) { - return mat4(1.0); - } - mat4 skin = mat4(0.0); - skin += u_JointMatrices[int(a_Joints0.x)] * a_Weights0.x; - skin += u_JointMatrices[int(a_Joints0.y)] * a_Weights0.y; - skin += u_JointMatrices[int(a_Joints0.z)] * a_Weights0.z; - skin += u_JointMatrices[int(a_Joints0.w)] * a_Weights0.w; - return skin; -} -void main() { - v_UV0 = a_UV0; - v_UV1 = a_UV1; - vec4 localPosition = vec4(a_Position, 1.0); - if (u_Skinning && u_SkinJointCount > 0) { - localPosition = resolveSkinMatrix() * localPosition; - } - gl_Position = u_Projection * u_View * u_Model * localPosition; -}`, - fragmentSource: `#version 300 es -precision highp float; -uniform vec4 _BaseColorFactor; -uniform sampler2D _BaseColorTexture; -uniform vec4 _BaseColorTexture_ST; -uniform float _BaseColorTexture_Rotation; -uniform int _BaseColorTexture_TexCoord; -uniform float _AlphaMode; -uniform float _AlphaCutoff; -in vec2 v_UV0; -in vec2 v_UV1; -out vec4 o_Color; -vec2 selectUV(int texCoord) { - return texCoord == 1 ? v_UV1 : v_UV0; -} -vec2 transformUV(vec2 uv, vec4 st, float rotation) { - vec2 scaled = uv * st.xy; - float c = cos(rotation); - float s = sin(rotation); - vec2 rotated = vec2(c * scaled.x - s * scaled.y, s * scaled.x + c * scaled.y); - return rotated + st.zw; -} -void main() { - vec4 baseColor = _BaseColorFactor; - if (_BaseColorTexture_TexCoord >= 0) { - vec2 uv = transformUV(selectUV(_BaseColorTexture_TexCoord), _BaseColorTexture_ST, _BaseColorTexture_Rotation); - baseColor *= texture(_BaseColorTexture, uv); +type GltfMaterialUniformMap = Readonly>; + +const HIDDEN_INSPECTOR = Object.freeze({ hidden: true } as const); +const GLTF_ALPHA_MODE_OPTIONS = Object.freeze([ + { label: 'Opaque', value: 0 }, + { label: 'Mask', value: 1 }, + { label: 'Blend', value: 2 }, +] as const); + +const createLightingProperties = (): readonly RenderShaderPropertyDefinition[] => + GLTF_LIGHTING_LAYOUT.properties + .filter( + (property) => + property.name !== 'u_Exposure' && + property.name !== 'u_Gamma' && + !property.name.startsWith('u_Local') + ) + .map((property) => ({ + name: property.name, + type: property.type, + ...(property.arrayLength !== undefined ? { arrayLength: property.arrayLength } : {}), + stages: ['fragment'], + scope: property.scope, + inspector: HIDDEN_INSPECTOR, + })); + +const GLTF_UNLIT_ATTRIBUTES = Object.freeze({ + position: 'a_Position', + uv0: 'a_UV0', + uv1: 'a_UV1', + joints0: 'a_Joints0', + weights0: 'a_Weights0', +} satisfies Partial>); + +const GLTF_PBR_ATTRIBUTES = Object.freeze({ + position: 'a_Position', + normal: 'a_Normal', + uv0: 'a_UV0', + tangent: 'a_Tangent', + uv1: 'a_UV1', + joints0: 'a_Joints0', + weights0: 'a_Weights0', +} satisfies Partial>); + +const createSurfaceTextureProperties = ( + uniformName: string, + label: string, + group: string, + options: { + readonly scale?: { + readonly label: string; + readonly min: number; + readonly max: number; + readonly step?: number; + readonly defaultValue: number; + }; + readonly strength?: { + readonly label: string; + readonly min: number; + readonly max: number; + readonly step?: number; + readonly defaultValue: number; + }; + } = {} +): readonly RenderShaderPropertyDefinition[] => { + const properties: RenderShaderPropertyDefinition[] = [ + { + name: uniformName, + type: 'sampler2D', + stages: ['fragment'], + scope: 'material', + inspector: { + label, + group, + control: 'texture', + }, + }, + { + name: `${uniformName}_ST`, + type: 'vec4', + stages: ['fragment'], + scope: 'material', + defaultValue: [1, 1, 0, 0], + inspector: HIDDEN_INSPECTOR, + }, + { + name: `${uniformName}_Rotation`, + type: 'float', + stages: ['fragment'], + scope: 'material', + defaultValue: 0, + inspector: HIDDEN_INSPECTOR, + }, + { + name: `${uniformName}_TexCoord`, + type: 'int', + stages: ['fragment'], + scope: 'material', + defaultValue: -1, + inspector: HIDDEN_INSPECTOR, + }, + ]; + + if (options.scale) { + properties.push({ + name: `${uniformName}_Scale`, + type: 'float', + stages: ['fragment'], + scope: 'material', + defaultValue: options.scale.defaultValue, + inspector: { + label: options.scale.label, + group, + control: 'slider', + min: options.scale.min, + max: options.scale.max, + step: options.scale.step, + }, + }); } - int alphaMode = int(_AlphaMode + 0.5); - if (alphaMode == 1 && baseColor.a < _AlphaCutoff) { - discard; + + if (options.strength) { + properties.push({ + name: `${uniformName}_Strength`, + type: 'float', + stages: ['fragment'], + scope: 'material', + defaultValue: options.strength.defaultValue, + inspector: { + label: options.strength.label, + group, + control: 'slider', + min: options.strength.min, + max: options.strength.max, + step: options.strength.step, + }, + }); } - if (alphaMode == 0 || alphaMode == 1) { - baseColor.a = 1.0; + + return properties; +}; + +const createSharedObjectProperties = (): readonly RenderShaderPropertyDefinition[] => [ + { + name: 'u_Model', + type: 'mat4', + stages: ['vertex'], + scope: 'object', + }, + { + name: 'u_View', + type: 'mat4', + stages: ['vertex'], + scope: 'camera', + }, + { + name: 'u_Projection', + type: 'mat4', + stages: ['vertex'], + scope: 'camera', + }, + { + name: 'u_Skinning', + type: 'bool', + stages: ['vertex'], + scope: 'object', + inspector: HIDDEN_INSPECTOR, + }, + { + name: 'u_SkinJointCount', + type: 'int', + stages: ['vertex'], + scope: 'object', + inspector: HIDDEN_INSPECTOR, + }, + { + name: 'u_JointMatrices', + type: 'mat4', + arrayLength: MAX_GLTF_SKIN_JOINTS, + stages: ['vertex'], + scope: 'object', + inspector: HIDDEN_INSPECTOR, + }, +]; + +const createSharedAlphaProperties = (): readonly RenderShaderPropertyDefinition[] => [ + { + name: '_AlphaMode', + type: 'float', + stages: ['fragment'], + scope: 'material', + defaultValue: 0, + inspector: { + label: 'Alpha Mode', + group: 'Surface', + control: 'select', + options: GLTF_ALPHA_MODE_OPTIONS, + }, + }, + { + name: '_AlphaCutoff', + type: 'float', + stages: ['fragment'], + scope: 'material', + defaultValue: 0.5, + inspector: { + label: 'Alpha Cutoff', + group: 'Surface', + control: 'slider', + min: 0, + max: 1, + step: 0.01, + }, + }, + { + name: '_DoubleSided', + type: 'float', + stages: ['fragment'], + scope: 'material', + defaultValue: 0, + inspector: { + label: 'Double Sided', + group: 'Surface', + control: 'toggle', + }, + }, +]; + +const GLTF_SHADER_LIBRARIES = Object.freeze([ + { + id: 'gltf.skinning', + code: [ + 'mat4 resolveSkinMatrix() {', + ' if (!u_Skinning || u_SkinJointCount <= 0) {', + ' return mat4(1.0);', + ' }', + ' mat4 skin = mat4(0.0);', + ' skin += u_JointMatrices[int(a_Joints0.x)] * a_Weights0.x;', + ' skin += u_JointMatrices[int(a_Joints0.y)] * a_Weights0.y;', + ' skin += u_JointMatrices[int(a_Joints0.z)] * a_Weights0.z;', + ' skin += u_JointMatrices[int(a_Joints0.w)] * a_Weights0.w;', + ' return skin;', + '}', + ], + }, + { + id: 'gltf.uv', + code: [ + 'vec2 selectUV(int texCoord) {', + ' return texCoord == 1 ? v_UV1 : v_UV0;', + '}', + '', + 'vec2 transformUV(vec2 uv, vec4 st, float rotation) {', + ' vec2 scaled = uv * st.xy;', + ' float c = cos(rotation);', + ' float s = sin(rotation);', + ' vec2 rotated = vec2(c * scaled.x - s * scaled.y, s * scaled.x + c * scaled.y);', + ' return rotated + st.zw;', + '}', + ], + }, + { + id: 'gltf.color-space', + code: [ + 'vec3 linearToSrgb(vec3 color) {', + ' vec3 clamped = clamp(color, vec3(0.0), vec3(1.0));', + ' vec3 cutoff = step(vec3(0.0031308), clamped);', + ' vec3 lower = clamped * 12.92;', + ' vec3 higher = 1.055 * pow(clamped, vec3(1.0 / 2.4)) - 0.055;', + ' return mix(lower, higher, cutoff);', + '}', + ], + }, + { + id: 'gltf.pbr-lighting', + code: [ + 'vec3 resolveNormal() {', + ' vec3 normal = length(v_WorldNormal) > 0.0001 ? normalize(v_WorldNormal) : vec3(0.0, 0.0, 1.0);', + ' if (_NormalTexture_TexCoord < 0 || length(v_WorldTangent.xyz) <= 0.0001) {', + ' return normal;', + ' }', + ' vec2 uv = transformUV(selectUV(_NormalTexture_TexCoord), _NormalTexture_ST, _NormalTexture_Rotation);', + ' vec3 tangentNormal = texture(_NormalTexture, uv).xyz * 2.0 - 1.0;', + ' tangentNormal.xy *= _NormalTexture_Scale;', + ' vec3 tangent = normalize(v_WorldTangent.xyz);', + ' vec3 bitangent = normalize(cross(normal, tangent)) * (v_WorldTangent.w == 0.0 ? 1.0 : v_WorldTangent.w);', + ' mat3 tbn = mat3(tangent, bitangent, normal);', + ' return normalize(tbn * tangentNormal);', + '}', + '', + 'float rangeAttenuation(float distanceToLight, float range) {', + ' if (range <= 0.0) {', + ' return 1.0;', + ' }', + ' float atten = clamp(1.0 - distanceToLight / range, 0.0, 1.0);', + ' return atten * atten;', + '}', + '', + 'float spotAttenuation(vec3 lightDir, vec3 spotDir, float innerConeCosine, float outerConeCosine) {', + ' float cd = dot(normalize(-lightDir), normalize(spotDir));', + ' return smoothstep(outerConeCosine, innerConeCosine, cd);', + '}', + '', + 'vec3 evaluateLight(vec3 normal, vec3 viewDir, vec3 albedo, float metallic, float roughness, vec3 lightDir, vec3 lightColor, float intensity) {', + ' float ndl = max(dot(normal, lightDir), 0.0);', + ' if (ndl <= 0.0) {', + ' return vec3(0.0);', + ' }', + ' vec3 halfDir = normalize(lightDir + viewDir);', + ' float ndh = max(dot(normal, halfDir), 0.0);', + ' float specPower = mix(128.0, 4.0, clamp(roughness, 0.0, 1.0));', + ' float specular = pow(ndh, specPower);', + ' vec3 diffuse = albedo * (1.0 - metallic) * ndl;', + ' vec3 specColor = mix(vec3(0.04), albedo, metallic) * specular * ndl;', + ' return (diffuse + specColor) * lightColor * intensity;', + '}', + ], + }, +] as const); + +const createGltfShaderDefinitionFromEffect = ( + effect: RenderShaderEffectDefinition, + attributes: Partial> +): GltfShaderDefinition => { + const definition = createSceneShaderDefinitionFromEffect(effect); + + return { + id: definition.id, + vertexSource: definition.vertexSource ?? '', + fragmentSource: definition.fragmentSource ?? '', + effect: definition.effect, + attributes: { ...attributes }, + uniforms: definition.uniforms ? [...definition.uniforms] : undefined, + depthTest: definition.depthTest, + cull: definition.cull, + blend: definition.blend, + }; +}; + +const normalizeNumericUniform = (value: unknown, fallback: number): number => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; } - o_Color = baseColor; -}`, - depthTest: true, - cull: false, - blend: true, + + return fallback; +}; + +const isGltfBlendAlphaMode = (uniforms: GltfMaterialUniformMap | undefined): boolean => + normalizeNumericUniform(uniforms?._AlphaMode, 0) >= 1.5; + +const isGltfDoubleSided = (uniforms: GltfMaterialUniformMap | undefined): boolean => + normalizeNumericUniform(uniforms?._DoubleSided, 0) >= 0.5; + +export const createGltfRuntimeSurfaceFeatures = ( + shaderId: string, + uniforms?: GltfMaterialUniformMap +): SceneMaterialSurfaceFeaturesDefinition => { + const unlit = shaderId.startsWith(GLTF_SHADER_UNLIT_ID); + + return { + useVertexColor: false, + hasSecondUv: normalizeNumericUniform(uniforms?._BaseColorTexture_TexCoord, 0) === 1 || + normalizeNumericUniform(uniforms?._MetallicRoughnessTexture_TexCoord, 0) === 1 || + normalizeNumericUniform(uniforms?._NormalTexture_TexCoord, 0) === 1 || + normalizeNumericUniform(uniforms?._OcclusionTexture_TexCoord, 0) === 1 || + normalizeNumericUniform(uniforms?._EmissiveTexture_TexCoord, 0) === 1, + useNormalMap: !unlit && normalizeNumericUniform(uniforms?._NormalTexture_TexCoord, -1) >= 0, + useTwoSided: isGltfDoubleSided(uniforms), + useAlbedoMap: normalizeNumericUniform(uniforms?._BaseColorTexture_TexCoord, -1) >= 0, + usePbrMap: false, + useMetallicRoughnessMap: + !unlit && normalizeNumericUniform(uniforms?._MetallicRoughnessTexture_TexCoord, -1) >= 0, + useOcclusionMap: !unlit && normalizeNumericUniform(uniforms?._OcclusionTexture_TexCoord, -1) >= 0, + useEmissiveMap: !unlit && normalizeNumericUniform(uniforms?._EmissiveTexture_TexCoord, -1) >= 0, + useAlphaTest: normalizeNumericUniform(uniforms?._AlphaMode, 0) >= 0.5 && normalizeNumericUniform(uniforms?._AlphaMode, 0) < 1.5, + }; +}; + +export const createGltfRuntimeSurfaceDefinition = ( + shaderId: string, + uniforms?: GltfMaterialUniformMap +): SceneMaterialSurfaceDefinition => ({ + shadingModel: shaderId.startsWith(GLTF_SHADER_UNLIT_ID) ? 'unlit' : 'pbr', + alphaMode: + normalizeNumericUniform(uniforms?._AlphaMode, 0) >= 1.5 + ? 'blend' + : normalizeNumericUniform(uniforms?._AlphaMode, 0) >= 0.5 + ? 'mask' + : 'opaque', + alphaCutoff: normalizeNumericUniform(uniforms?._AlphaCutoff, 0.5), + pbrUvSet: 0, + features: createGltfRuntimeSurfaceFeatures(shaderId, uniforms), + tilingOffset: [1, 1, 0, 0], + albedo: Array.isArray(uniforms?._BaseColorFactor) + ? [ + Number((uniforms?._BaseColorFactor as readonly unknown[])[0] ?? 1), + Number((uniforms?._BaseColorFactor as readonly unknown[])[1] ?? 1), + Number((uniforms?._BaseColorFactor as readonly unknown[])[2] ?? 1), + Number((uniforms?._BaseColorFactor as readonly unknown[])[3] ?? 1), + ] + : [1, 1, 1, 1], + normalScale: normalizeNumericUniform(uniforms?._NormalTexture_Scale, 1), + occlusion: normalizeNumericUniform(uniforms?._OcclusionTexture_Strength, 1), + roughness: normalizeNumericUniform(uniforms?._RoughnessFactor, 1), + metallic: normalizeNumericUniform(uniforms?._MetallicFactor, 1), + specularIntensity: 1, + emissive: Array.isArray(uniforms?._EmissiveFactor) + ? [ + Number((uniforms?._EmissiveFactor as readonly unknown[])[0] ?? 0), + Number((uniforms?._EmissiveFactor as readonly unknown[])[1] ?? 0), + Number((uniforms?._EmissiveFactor as readonly unknown[])[2] ?? 0), + ] + : [0, 0, 0], + emissiveScale: [1, 1, 1], }); -export const createGltfPbrShaderDefinition = ( - id: string = GLTF_SHADER_PBR_ID -): GltfShaderDefinition => ({ - id, - vertexSource: `#version 300 es -layout(location = 0) in vec3 a_Position; -layout(location = 1) in vec3 a_Normal; -layout(location = 2) in vec2 a_UV0; -layout(location = 4) in vec4 a_Tangent; -layout(location = 5) in vec2 a_UV1; -layout(location = 9) in uvec4 a_Joints0; -layout(location = 10) in vec4 a_Weights0; -uniform mat4 u_Model; -uniform mat4 u_View; -uniform mat4 u_Projection; -uniform bool u_Skinning; -uniform int u_SkinJointCount; -uniform mat4 u_JointMatrices[${MAX_GLTF_SKIN_JOINTS}]; -out vec2 v_UV0; -out vec2 v_UV1; -out vec3 v_WorldPosition; -out vec3 v_WorldNormal; -out vec4 v_WorldTangent; -mat4 resolveSkinMatrix() { - if (!u_Skinning || u_SkinJointCount <= 0) { - return mat4(1.0); - } - mat4 skin = mat4(0.0); - skin += u_JointMatrices[int(a_Joints0.x)] * a_Weights0.x; - skin += u_JointMatrices[int(a_Joints0.y)] * a_Weights0.y; - skin += u_JointMatrices[int(a_Joints0.z)] * a_Weights0.z; - skin += u_JointMatrices[int(a_Joints0.w)] * a_Weights0.w; - return skin; -} -void main() { - v_UV0 = a_UV0; - v_UV1 = a_UV1; - mat4 skin = resolveSkinMatrix(); - vec4 localPosition = vec4(a_Position, 1.0); - vec3 localNormal = a_Normal; - vec3 localTangent = a_Tangent.xyz; - if (u_Skinning && u_SkinJointCount > 0) { - localPosition = skin * localPosition; - localNormal = mat3(skin) * localNormal; - localTangent = mat3(skin) * localTangent; - } - vec4 worldPosition = u_Model * localPosition; - v_WorldPosition = worldPosition.xyz; - v_WorldNormal = normalize(mat3(u_Model) * localNormal); - v_WorldTangent = vec4(normalize(mat3(u_Model) * localTangent), a_Tangent.w); - gl_Position = u_Projection * u_View * worldPosition; -}`, - fragmentSource: `#version 300 es -precision highp float; -const int MAX_LOCAL_LIGHTS = ${MAX_GLTF_LOCAL_LIGHTS}; -uniform bool u_ReceiveLighting; -uniform vec3 u_AmbientLight; -uniform vec3 u_LightDirection; -uniform vec3 u_LightColor; -uniform float u_LightIntensity; -uniform int u_LocalLightCount; -uniform int u_LocalLightType[MAX_LOCAL_LIGHTS]; -uniform vec3 u_LocalLightPosition[MAX_LOCAL_LIGHTS]; -uniform vec3 u_LocalLightDirection[MAX_LOCAL_LIGHTS]; -uniform vec3 u_LocalLightColor[MAX_LOCAL_LIGHTS]; -uniform float u_LocalLightIntensity[MAX_LOCAL_LIGHTS]; -uniform float u_LocalLightRange[MAX_LOCAL_LIGHTS]; -uniform float u_LocalLightInnerCone[MAX_LOCAL_LIGHTS]; -uniform float u_LocalLightOuterCone[MAX_LOCAL_LIGHTS]; -uniform vec3 u_CameraPosition; -uniform vec4 _BaseColorFactor; -uniform sampler2D _BaseColorTexture; -uniform vec4 _BaseColorTexture_ST; -uniform float _BaseColorTexture_Rotation; -uniform int _BaseColorTexture_TexCoord; -uniform float _MetallicFactor; -uniform float _RoughnessFactor; -uniform sampler2D _MetallicRoughnessTexture; -uniform vec4 _MetallicRoughnessTexture_ST; -uniform float _MetallicRoughnessTexture_Rotation; -uniform int _MetallicRoughnessTexture_TexCoord; -uniform sampler2D _NormalTexture; -uniform vec4 _NormalTexture_ST; -uniform float _NormalTexture_Rotation; -uniform int _NormalTexture_TexCoord; -uniform float _NormalTexture_Scale; -uniform sampler2D _OcclusionTexture; -uniform vec4 _OcclusionTexture_ST; -uniform float _OcclusionTexture_Rotation; -uniform int _OcclusionTexture_TexCoord; -uniform float _OcclusionTexture_Strength; -uniform vec3 _EmissiveFactor; -uniform sampler2D _EmissiveTexture; -uniform vec4 _EmissiveTexture_ST; -uniform float _EmissiveTexture_Rotation; -uniform int _EmissiveTexture_TexCoord; -uniform float _AlphaMode; -uniform float _AlphaCutoff; -in vec2 v_UV0; -in vec2 v_UV1; -in vec3 v_WorldPosition; -in vec3 v_WorldNormal; -in vec4 v_WorldTangent; -out vec4 o_Color; -vec2 selectUV(int texCoord) { - return texCoord == 1 ? v_UV1 : v_UV0; -} -vec2 transformUV(vec2 uv, vec4 st, float rotation) { - vec2 scaled = uv * st.xy; - float c = cos(rotation); - float s = sin(rotation); - vec2 rotated = vec2(c * scaled.x - s * scaled.y, s * scaled.x + c * scaled.y); - return rotated + st.zw; -} -vec3 resolveNormal() { - vec3 normal = length(v_WorldNormal) > 0.0001 ? normalize(v_WorldNormal) : vec3(0.0, 0.0, 1.0); - if (_NormalTexture_TexCoord < 0 || length(v_WorldTangent.xyz) <= 0.0001) { - return normal; - } - vec2 uv = transformUV(selectUV(_NormalTexture_TexCoord), _NormalTexture_ST, _NormalTexture_Rotation); - vec3 tangentNormal = texture(_NormalTexture, uv).xyz * 2.0 - 1.0; - tangentNormal.xy *= _NormalTexture_Scale; - vec3 tangent = normalize(v_WorldTangent.xyz); - vec3 bitangent = normalize(cross(normal, tangent)) * (v_WorldTangent.w == 0.0 ? 1.0 : v_WorldTangent.w); - mat3 tbn = mat3(tangent, bitangent, normal); - return normalize(tbn * tangentNormal); -} -float rangeAttenuation(float distanceToLight, float range) { - if (range <= 0.0) { - return 1.0; - } - float atten = clamp(1.0 - distanceToLight / range, 0.0, 1.0); - return atten * atten; -} -float spotAttenuation(vec3 lightDir, vec3 spotDir, float innerCone, float outerCone) { - float cd = dot(normalize(-lightDir), normalize(spotDir)); - float inner = cos(innerCone); - float outer = cos(outerCone); - return smoothstep(outer, inner, cd); -} -vec3 evaluateLight(vec3 normal, vec3 viewDir, vec3 albedo, float metallic, float roughness, vec3 lightDir, vec3 lightColor, float intensity) { - float ndl = max(dot(normal, lightDir), 0.0); - if (ndl <= 0.0) { - return vec3(0.0); +export const createGltfRuntimeMaterialPasses = ( + uniforms?: GltfMaterialUniformMap +): readonly SceneMaterialPassDefinition[] => { + const alphaMode = normalizeNumericUniform(uniforms?._AlphaMode, 0); + const blendEnabled = alphaMode >= 1.5; + const alphaTestEnabled = alphaMode >= 0.5 && alphaMode < 1.5; + const doubleSided = isGltfDoubleSided(uniforms); + const cullMode = doubleSided ? 'none' : 'back'; + + return Object.freeze([ + { + id: 'main', + phase: 'default', + primitive: 'triangle-list', + rasterizerState: { + cullMode, + frontFace: 'ccw', + }, + depthStencilState: { + depthTest: true, + depthWrite: !blendEnabled, + depthFunc: 'less', + }, + blendState: { + targets: [ + { + blend: blendEnabled, + srcColorFactor: 'src-alpha', + dstColorFactor: 'one-minus-src-alpha', + colorOp: 'add', + srcAlphaFactor: 'one', + dstAlphaFactor: 'one-minus-src-alpha', + alphaOp: 'add', + }, + ], + }, + }, + { + id: 'forward-add', + phase: 'forward-add', + primitive: 'triangle-list', + rasterizerState: { + cullMode, + frontFace: 'ccw', + }, + depthStencilState: { + depthTest: true, + depthWrite: false, + depthFunc: 'lequal', + }, + blendState: { + targets: [ + { + blend: true, + srcColorFactor: 'one', + dstColorFactor: 'one', + colorOp: 'add', + srcAlphaFactor: 'one', + dstAlphaFactor: 'one', + alphaOp: 'add', + }, + ], + }, + }, + { + id: 'shadow-caster', + phase: 'shadow-caster', + primitive: 'triangle-list', + rasterizerState: { + cullMode, + frontFace: 'ccw', + }, + depthStencilState: { + depthTest: true, + depthWrite: true, + depthFunc: 'less', + }, + blendState: { + targets: [ + { + blend: false, + }, + ], + }, + ...(alphaTestEnabled + ? { + priority: 1, + } + : {}), + }, + ]); +}; + +const createVariantShaderId = ( + baseId: string, + options: { + readonly blend: boolean; + readonly doubleSided: boolean; } - vec3 halfDir = normalize(lightDir + viewDir); - float ndh = max(dot(normal, halfDir), 0.0); - float specPower = mix(128.0, 4.0, clamp(roughness, 0.0, 1.0)); - float specular = pow(ndh, specPower); - vec3 diffuse = albedo * (1.0 - metallic) * ndl; - vec3 specColor = mix(vec3(0.04), albedo, metallic) * specular * ndl; - return (diffuse + specColor) * lightColor * intensity; -} -void main() { - vec4 baseColor = _BaseColorFactor; - if (_BaseColorTexture_TexCoord >= 0) { - vec2 uv = transformUV(selectUV(_BaseColorTexture_TexCoord), _BaseColorTexture_ST, _BaseColorTexture_Rotation); - baseColor *= texture(_BaseColorTexture, uv); +): string => { + let variantId = baseId; + if (options.blend) { + variantId += GLTF_SHADER_BLEND_SUFFIX; } - int alphaMode = int(_AlphaMode + 0.5); - if (alphaMode == 1 && baseColor.a < _AlphaCutoff) { - discard; + if (options.doubleSided) { + variantId += GLTF_SHADER_DOUBLE_SIDED_SUFFIX; } + return variantId; +}; - vec3 normal = resolveNormal(); - vec3 viewDir = normalize(u_CameraPosition - v_WorldPosition); - vec2 mrUv = transformUV(selectUV(max(_MetallicRoughnessTexture_TexCoord, 0)), _MetallicRoughnessTexture_ST, _MetallicRoughnessTexture_Rotation); - vec4 mrSample = _MetallicRoughnessTexture_TexCoord >= 0 ? texture(_MetallicRoughnessTexture, mrUv) : vec4(1.0); - float roughness = clamp(_RoughnessFactor * mrSample.g, 0.04, 1.0); - float metallic = clamp(_MetallicFactor * mrSample.b, 0.0, 1.0); - vec3 lighting = baseColor.rgb * u_AmbientLight; - - if (u_ReceiveLighting) { - lighting += evaluateLight(normal, viewDir, baseColor.rgb, metallic, roughness, normalize(-u_LightDirection), u_LightColor, u_LightIntensity); - for (int index = 0; index < MAX_LOCAL_LIGHTS; index += 1) { - if (index >= u_LocalLightCount) { - break; - } - vec3 toLight = u_LocalLightPosition[index] - v_WorldPosition; - float distanceToLight = length(toLight); - vec3 lightDir = distanceToLight > 0.0 ? toLight / distanceToLight : vec3(0.0, 1.0, 0.0); - float attenuation = rangeAttenuation(distanceToLight, u_LocalLightRange[index]); - if (u_LocalLightType[index] == 1) { - attenuation *= spotAttenuation(lightDir, u_LocalLightDirection[index], u_LocalLightInnerCone[index], u_LocalLightOuterCone[index]); - } - lighting += evaluateLight(normal, viewDir, baseColor.rgb, metallic, roughness, lightDir, u_LocalLightColor[index], u_LocalLightIntensity[index] * attenuation); - } - } +const resolveVariantRenderState = (uniforms: GltfMaterialUniformMap | undefined): { + readonly blend: boolean; + readonly cull: boolean; +} => { + const blend = isGltfBlendAlphaMode(uniforms); + const doubleSided = isGltfDoubleSided(uniforms); + return { + blend, + cull: !doubleSided, + }; +}; - if (_OcclusionTexture_TexCoord >= 0) { - vec2 uv = transformUV(selectUV(_OcclusionTexture_TexCoord), _OcclusionTexture_ST, _OcclusionTexture_Rotation); - float occlusion = texture(_OcclusionTexture, uv).r; - lighting *= mix(1.0, occlusion, clamp(_OcclusionTexture_Strength, 0.0, 1.0)); - } +const createGltfUnlitShaderEffect = (id: string): RenderShaderEffectDefinition => ({ + format: 'axrone.shader/effect', + version: 1, + id, + attributes: [ + { name: 'a_Position', type: 'vec3', location: 0 }, + { name: 'a_UV0', type: 'vec2', location: 2 }, + { name: 'a_UV1', type: 'vec2', location: 5 }, + { name: 'a_Joints0', type: 'uvec4', location: 9 }, + { name: 'a_Weights0', type: 'vec4', location: 10 }, + ], + varyings: [ + { name: 'v_UV0', type: 'vec2' }, + { name: 'v_UV1', type: 'vec2' }, + ], + properties: [ + ...createSharedObjectProperties(), + { + name: '_BaseColorFactor', + type: 'vec4', + stages: ['fragment'], + scope: 'material', + defaultValue: [1, 1, 1, 1], + inspector: { + label: 'Base Color', + group: 'Surface', + control: 'color', + }, + }, + ...createSurfaceTextureProperties('_BaseColorTexture', 'Base Color Map', 'Maps'), + ...createSharedAlphaProperties(), + ], + libraries: GLTF_SHADER_LIBRARIES, + vertex: { + includes: ['gltf.skinning'], + main: [ + 'v_UV0 = a_UV0;', + 'v_UV1 = a_UV1;', + 'vec4 localPosition = vec4(a_Position, 1.0);', + 'if (u_Skinning && u_SkinJointCount > 0) {', + ' localPosition = resolveSkinMatrix() * localPosition;', + '}', + 'gl_Position = u_Projection * u_View * u_Model * localPosition;', + ], + }, + fragment: { + precision: 'highp', + outputs: [{ name: 'o_Color', type: 'vec4' }], + includes: ['gltf.uv', 'gltf.color-space'], + main: [ + 'vec4 baseColor = _BaseColorFactor;', + 'if (_BaseColorTexture_TexCoord >= 0) {', + ' vec2 uv = transformUV(selectUV(_BaseColorTexture_TexCoord), _BaseColorTexture_ST, _BaseColorTexture_Rotation);', + ' baseColor *= texture(_BaseColorTexture, uv);', + '}', + 'int alphaMode = int(_AlphaMode + 0.5);', + 'if (alphaMode == 1 && baseColor.a < _AlphaCutoff) {', + ' discard;', + '}', + 'if (alphaMode == 0 || alphaMode == 1) {', + ' baseColor.a = 1.0;', + '}', + 'o_Color = vec4(linearToSrgb(baseColor.rgb), baseColor.a);', + ], + }, + renderState: { + depthTest: true, + cull: true, + blend: false, + }, +}); + +const createGltfPbrShaderEffect = (id: string): RenderShaderEffectDefinition => ({ + format: 'axrone.shader/effect', + version: 1, + id, + attributes: [ + { name: 'a_Position', type: 'vec3', location: 0 }, + { name: 'a_Normal', type: 'vec3', location: 1 }, + { name: 'a_UV0', type: 'vec2', location: 2 }, + { name: 'a_Tangent', type: 'vec4', location: 4 }, + { name: 'a_UV1', type: 'vec2', location: 5 }, + { name: 'a_Joints0', type: 'uvec4', location: 9 }, + { name: 'a_Weights0', type: 'vec4', location: 10 }, + ], + varyings: [ + { name: 'v_UV0', type: 'vec2' }, + { name: 'v_UV1', type: 'vec2' }, + { name: 'v_WorldPosition', type: 'vec3' }, + { name: 'v_WorldNormal', type: 'vec3' }, + { name: 'v_WorldTangent', type: 'vec4' }, + ], + properties: [ + ...createSharedObjectProperties(), + { + name: 'u_ReceiveLighting', + type: 'bool', + stages: ['fragment'], + scope: 'system', + inspector: HIDDEN_INSPECTOR, + }, + ...createLightingProperties(), + { + name: 'u_CameraPosition', + type: 'vec3', + stages: ['fragment'], + scope: 'camera', + inspector: HIDDEN_INSPECTOR, + }, + { + name: '_BaseColorFactor', + type: 'vec4', + stages: ['fragment'], + scope: 'material', + defaultValue: [1, 1, 1, 1], + inspector: { + label: 'Base Color', + group: 'Surface', + control: 'color', + }, + }, + ...createSurfaceTextureProperties('_BaseColorTexture', 'Base Color Map', 'Maps'), + { + name: '_MetallicFactor', + type: 'float', + stages: ['fragment'], + scope: 'material', + defaultValue: 1, + inspector: { + label: 'Metallic', + group: 'Surface', + control: 'slider', + min: 0, + max: 1, + step: 0.01, + }, + }, + { + name: '_RoughnessFactor', + type: 'float', + stages: ['fragment'], + scope: 'material', + defaultValue: 1, + inspector: { + label: 'Roughness', + group: 'Surface', + control: 'slider', + min: 0, + max: 1, + step: 0.01, + }, + }, + ...createSurfaceTextureProperties( + '_MetallicRoughnessTexture', + 'Metallic Roughness Map', + 'Maps' + ), + ...createSurfaceTextureProperties('_NormalTexture', 'Normal Map', 'Maps', { + scale: { + label: 'Normal Scale', + min: 0, + max: 2, + step: 0.01, + defaultValue: 1, + }, + }), + ...createSurfaceTextureProperties('_OcclusionTexture', 'Occlusion Map', 'Maps', { + strength: { + label: 'Occlusion Strength', + min: 0, + max: 1, + step: 0.01, + defaultValue: 1, + }, + }), + { + name: '_EmissiveFactor', + type: 'vec3', + stages: ['fragment'], + scope: 'material', + defaultValue: [0, 0, 0], + inspector: { + label: 'Emissive', + group: 'Emission', + control: 'color', + }, + }, + ...createSurfaceTextureProperties('_EmissiveTexture', 'Emissive Map', 'Emission'), + ...createSharedAlphaProperties(), + ], + libraries: GLTF_SHADER_LIBRARIES, + vertex: { + includes: ['gltf.skinning'], + main: [ + 'v_UV0 = a_UV0;', + 'v_UV1 = a_UV1;', + 'mat4 skin = resolveSkinMatrix();', + 'vec4 localPosition = vec4(a_Position, 1.0);', + 'vec3 localNormal = a_Normal;', + 'vec3 localTangent = a_Tangent.xyz;', + 'if (u_Skinning && u_SkinJointCount > 0) {', + ' localPosition = skin * localPosition;', + ' localNormal = mat3(skin) * localNormal;', + ' localTangent = mat3(skin) * localTangent;', + '}', + 'vec4 worldPosition = u_Model * localPosition;', + 'v_WorldPosition = worldPosition.xyz;', + 'v_WorldNormal = normalize(mat3(u_Model) * localNormal);', + 'v_WorldTangent = vec4(normalize(mat3(u_Model) * localTangent), a_Tangent.w);', + 'gl_Position = u_Projection * u_View * worldPosition;', + ], + }, + fragment: { + precision: 'highp', + outputs: [{ name: 'o_Color', type: 'vec4' }], + includes: ['gltf.uv', 'gltf.pbr-lighting'], + main: [ + 'vec4 baseColor = _BaseColorFactor;', + 'if (_BaseColorTexture_TexCoord >= 0) {', + ' vec2 uv = transformUV(selectUV(_BaseColorTexture_TexCoord), _BaseColorTexture_ST, _BaseColorTexture_Rotation);', + ' baseColor *= texture(_BaseColorTexture, uv);', + '}', + 'int alphaMode = int(_AlphaMode + 0.5);', + 'if (alphaMode == 1 && baseColor.a < _AlphaCutoff) {', + ' discard;', + '}', + '', + 'vec3 normal = resolveNormal();', + 'vec3 viewDir = normalize(u_CameraPosition - v_WorldPosition);', + 'vec2 mrUv = transformUV(selectUV(max(_MetallicRoughnessTexture_TexCoord, 0)), _MetallicRoughnessTexture_ST, _MetallicRoughnessTexture_Rotation);', + 'vec4 mrSample = _MetallicRoughnessTexture_TexCoord >= 0 ? texture(_MetallicRoughnessTexture, mrUv) : vec4(1.0);', + 'float roughness = clamp(_RoughnessFactor * mrSample.g, 0.04, 1.0);', + 'float metallic = clamp(_MetallicFactor * mrSample.b, 0.0, 1.0);', + 'float hemiFactor = clamp(normal.y * 0.5 + 0.5, 0.0, 1.0);', + `vec3 ambient = mix(${GLTF_LIGHTING_UNIFORMS.groundLight}, ${GLTF_LIGHTING_UNIFORMS.skyLight}, hemiFactor) + (${GLTF_LIGHTING_UNIFORMS.ambientLight} * 0.45);`, + 'vec3 lighting = vec3(0.0);', + '', + 'if (u_ReceiveLighting) {', + ` for (int index = 0; index < ${MAX_GLTF_DIRECTIONAL_LIGHTS}; index += 1) {`, + ` if (index >= ${GLTF_LIGHTING_UNIFORMS.directionalLightCount}) {`, + ' break;', + ' }', + ` ambient += ${GLTF_LIGHTING_UNIFORMS.directionalLightAmbientColor}[index];`, + ` lighting += evaluateLight(normal, viewDir, baseColor.rgb, metallic, roughness, normalize(-${GLTF_LIGHTING_UNIFORMS.directionalLightDirection}[index]), ${GLTF_LIGHTING_UNIFORMS.directionalLightColor}[index], ${GLTF_LIGHTING_UNIFORMS.directionalLightIntensity}[index]);`, + ' }', + ' lighting += baseColor.rgb * ambient;', + ` for (int index = 0; index < ${MAX_GLTF_POINT_LIGHTS}; index += 1) {`, + ` if (index >= ${GLTF_LIGHTING_UNIFORMS.pointLightCount}) {`, + ' break;', + ' }', + ` vec3 toLight = ${GLTF_LIGHTING_UNIFORMS.pointLightPosition}[index] - v_WorldPosition;`, + ' float distanceToLight = length(toLight);', + ' vec3 lightDir = distanceToLight > 0.0 ? toLight / distanceToLight : vec3(0.0, 1.0, 0.0);', + ` float attenuation = rangeAttenuation(distanceToLight, ${GLTF_LIGHTING_UNIFORMS.pointLightRange}[index]);`, + ` lighting += evaluateLight(normal, viewDir, baseColor.rgb, metallic, roughness, lightDir, ${GLTF_LIGHTING_UNIFORMS.pointLightColor}[index], ${GLTF_LIGHTING_UNIFORMS.pointLightIntensity}[index] * attenuation);`, + ' }', + ` for (int index = 0; index < ${MAX_GLTF_SPOT_LIGHTS}; index += 1) {`, + ` if (index >= ${GLTF_LIGHTING_UNIFORMS.spotLightCount}) {`, + ' break;', + ' }', + ` vec3 toLight = ${GLTF_LIGHTING_UNIFORMS.spotLightPosition}[index] - v_WorldPosition;`, + ' float distanceToLight = length(toLight);', + ' vec3 lightDir = distanceToLight > 0.0 ? toLight / distanceToLight : vec3(0.0, 1.0, 0.0);', + ` float attenuation = rangeAttenuation(distanceToLight, ${GLTF_LIGHTING_UNIFORMS.spotLightRange}[index]);`, + ` attenuation *= spotAttenuation(lightDir, ${GLTF_LIGHTING_UNIFORMS.spotLightDirection}[index], ${GLTF_LIGHTING_UNIFORMS.spotLightInnerConeCosine}[index], ${GLTF_LIGHTING_UNIFORMS.spotLightOuterConeCosine}[index]);`, + ` lighting += evaluateLight(normal, viewDir, baseColor.rgb, metallic, roughness, lightDir, ${GLTF_LIGHTING_UNIFORMS.spotLightColor}[index], ${GLTF_LIGHTING_UNIFORMS.spotLightIntensity}[index] * attenuation);`, + ' }', + '} else {', + ' lighting = baseColor.rgb * ambient;', + '}', + '', + 'if (_OcclusionTexture_TexCoord >= 0) {', + ' vec2 uv = transformUV(selectUV(_OcclusionTexture_TexCoord), _OcclusionTexture_ST, _OcclusionTexture_Rotation);', + ' float occlusion = texture(_OcclusionTexture, uv).r;', + ' lighting *= mix(1.0, occlusion, clamp(_OcclusionTexture_Strength, 0.0, 1.0));', + '}', + '', + 'vec3 emissive = _EmissiveFactor;', + 'if (_EmissiveTexture_TexCoord >= 0) {', + ' vec2 uv = transformUV(selectUV(_EmissiveTexture_TexCoord), _EmissiveTexture_ST, _EmissiveTexture_Rotation);', + ' emissive *= texture(_EmissiveTexture, uv).rgb;', + '}', + '', + 'float alpha = alphaMode == 2 ? baseColor.a : 1.0;', + 'o_Color = vec4(lighting + emissive, alpha);', + ], + }, + renderState: { + depthTest: true, + cull: true, + blend: false, + }, +}); + +export const GLTF_UNLIT_SHADER_EFFECT = createGltfUnlitShaderEffect(GLTF_SHADER_UNLIT_ID); +export const GLTF_PBR_SHADER_EFFECT = createGltfPbrShaderEffect(GLTF_SHADER_PBR_ID); - vec3 emissive = _EmissiveFactor; - if (_EmissiveTexture_TexCoord >= 0) { - vec2 uv = transformUV(selectUV(_EmissiveTexture_TexCoord), _EmissiveTexture_ST, _EmissiveTexture_Rotation); - emissive *= texture(_EmissiveTexture, uv).rgb; +export const createGltfUnlitShaderDefinition = ( + id: string = GLTF_SHADER_UNLIT_ID, + uniforms?: GltfMaterialUniformMap +): GltfShaderDefinition => { + const definition = createGltfShaderDefinitionFromEffect( + id === GLTF_SHADER_UNLIT_ID ? GLTF_UNLIT_SHADER_EFFECT : createGltfUnlitShaderEffect(id), + GLTF_UNLIT_ATTRIBUTES + ); + const renderState = resolveVariantRenderState(uniforms); + return { + ...definition, + cull: renderState.cull, + blend: renderState.blend, + }; +}; + +export const createGltfPbrShaderDefinition = ( + id: string = GLTF_SHADER_PBR_ID, + uniforms?: GltfMaterialUniformMap +): GltfShaderDefinition => { + const definition = createGltfShaderDefinitionFromEffect( + id === GLTF_SHADER_PBR_ID ? GLTF_PBR_SHADER_EFFECT : createGltfPbrShaderEffect(id), + GLTF_PBR_ATTRIBUTES + ); + const renderState = resolveVariantRenderState(uniforms); + return { + ...definition, + cull: renderState.cull, + blend: renderState.blend, + }; +}; + +export const resolveGltfRuntimeShaderId = ( + shaderId: string, + uniforms?: GltfMaterialUniformMap +): string => { + if (shaderId !== GLTF_SHADER_PBR_ID && shaderId !== GLTF_SHADER_UNLIT_ID) { + return shaderId; } - float alpha = alphaMode == 2 ? baseColor.a : 1.0; - o_Color = vec4(lighting + emissive, alpha); -}`, - depthTest: true, - cull: false, - blend: true, -}); + return createVariantShaderId(shaderId, { + blend: isGltfBlendAlphaMode(uniforms), + doubleSided: isGltfDoubleSided(uniforms), + }); +}; export const resolveGltfShaderDefinition = ( shaderId: string, resolveShaderDefinition?: (shaderId: string) => GltfShaderDefinition | undefined ): GltfShaderDefinition | undefined => { - if (shaderId === GLTF_SHADER_PBR_ID) { - return createGltfPbrShaderDefinition(shaderId); + if (shaderId.startsWith(GLTF_SHADER_PBR_ID)) { + return createGltfPbrShaderDefinition(shaderId, { + _AlphaMode: shaderId.includes(GLTF_SHADER_BLEND_SUFFIX) ? 2 : 0, + _DoubleSided: shaderId.includes(GLTF_SHADER_DOUBLE_SIDED_SUFFIX) ? 1 : 0, + }); } - if (shaderId === GLTF_SHADER_UNLIT_ID) { - return createGltfUnlitShaderDefinition(shaderId); + if (shaderId.startsWith(GLTF_SHADER_UNLIT_ID)) { + return createGltfUnlitShaderDefinition(shaderId, { + _AlphaMode: shaderId.includes(GLTF_SHADER_BLEND_SUFFIX) ? 2 : 0, + _DoubleSided: shaderId.includes(GLTF_SHADER_DOUBLE_SIDED_SUFFIX) ? 1 : 0, + }); } return resolveShaderDefinition?.(shaderId); -}; \ No newline at end of file +}; diff --git a/web/packages/scene-runtime-gltf/src/scene-definition-adapter.ts b/web/packages/scene-runtime-gltf/src/scene-definition-adapter.ts index d2c4ddae..d711e106 100644 --- a/web/packages/scene-runtime-gltf/src/scene-definition-adapter.ts +++ b/web/packages/scene-runtime-gltf/src/scene-definition-adapter.ts @@ -54,11 +54,17 @@ export const adaptGltfMeshDefinitionToScene = ( export const adaptGltfMaterialDefinitionToScene = ( definition: GltfMaterialDefinition -): SceneMaterialDefinition => ({ - ...definition, - uniforms: definition.uniforms ? { ...definition.uniforms } : undefined, - textures: definition.textures ? { ...definition.textures } : undefined, -}); +): SceneMaterialDefinition => { + const material = definition as GltfMaterialDefinition & Partial; + + return { + ...definition, + uniforms: definition.uniforms ? { ...definition.uniforms } : undefined, + textures: definition.textures ? { ...definition.textures } : undefined, + ...(material.surface ? { surface: material.surface } : {}), + ...(material.passes ? { passes: material.passes } : {}), + }; +}; export const adaptGltfTextureDefinitionToScene = ( definition: GltfTextureDefinition @@ -83,6 +89,7 @@ export const adaptGltfShaderDefinitionToScene = ( definition: GltfShaderDefinition ): SceneShaderDefinition => ({ ...definition, + effect: definition.effect, attributes: definition.attributes ? { ...definition.attributes } : undefined, uniforms: definition.uniforms ? [...definition.uniforms] : undefined, -}); \ No newline at end of file +}); diff --git a/web/packages/scene-runtime-gltf/src/scene-snapshot-adapter.ts b/web/packages/scene-runtime-gltf/src/scene-snapshot-adapter.ts index 79416443..a49eb0e2 100644 --- a/web/packages/scene-runtime-gltf/src/scene-snapshot-adapter.ts +++ b/web/packages/scene-runtime-gltf/src/scene-snapshot-adapter.ts @@ -60,19 +60,16 @@ export const createGltfSceneSnapshot = ( for (const materialKey of prefab.data.materialKeys) { const material = database.require({ key: materialKey, kind: 'gltf.material' }); - materials.push( - adaptGltfMaterialDefinitionToScene( - normalizeGltfMaterialDefinition(material.data, material.key) - ) - ); + const runtimeMaterialDefinition = normalizeGltfMaterialDefinition(material.data, material.key); + materials.push(adaptGltfMaterialDefinitionToScene(runtimeMaterialDefinition)); const shaderDefinition = resolveGltfShaderDefinition( - material.data.definition.shaderId, + runtimeMaterialDefinition.shaderId, options.resolveShaderDefinition ); if (!shaderDefinition) { throw new Error( - `glTF runtime bridge cannot resolve shader '${material.data.definition.shaderId}' for material '${materialKey}'` + `glTF runtime bridge cannot resolve shader '${runtimeMaterialDefinition.shaderId}' for material '${materialKey}'` ); } shaderDefinitions.set( diff --git a/web/packages/scene-runtime/package.json b/web/packages/scene-runtime/package.json index df7a5679..44cb8463 100644 --- a/web/packages/scene-runtime/package.json +++ b/web/packages/scene-runtime/package.json @@ -29,6 +29,16 @@ "import": "./dist/scene-facade.mjs", "require": "./dist/scene-facade.js" }, + "./prefab": { + "types": "./dist/prefab.d.ts", + "import": "./dist/prefab.mjs", + "require": "./dist/prefab.js" + }, + "./scene-2d-support": { + "types": "./dist/scene-2d-support.d.ts", + "import": "./dist/scene-2d-support.mjs", + "require": "./dist/scene-2d-support.js" + }, "./scene-3d-support": { "types": "./dist/scene-3d-support.d.ts", "import": "./dist/scene-3d-support.mjs", @@ -41,10 +51,15 @@ "test": "vitest run" }, "dependencies": { + "@axrone/animation": "^0.1.0", + "@axrone/asset-2d": "^0.1.0", "@axrone/ecs-runtime": "^0.1.0", "@axrone/game-loop": "^0.1.0", "@axrone/geometry": "^0.1.0", + "@axrone/lighting": "^0.1.0", "@axrone/numeric": "^0.0.1", + "@axrone/render-2d": "^0.1.0", + "@axrone/render-core": "^0.1.0", "@axrone/random": "^0.0.1", "@axrone/render-webgl2": "^0.1.0", "@axrone/utility": "^0.0.1" diff --git a/web/packages/scene-runtime/rollup.config.mjs b/web/packages/scene-runtime/rollup.config.mjs index 0ef863d9..139aad58 100644 --- a/web/packages/scene-runtime/rollup.config.mjs +++ b/web/packages/scene-runtime/rollup.config.mjs @@ -23,9 +23,19 @@ export default [ inputRelativePath: 'src/scene-facade.ts', outputBasename: 'scene-facade', }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/prefab.ts', + outputBasename: 'prefab', + }), ...createPackageConfig({ packageDir, inputRelativePath: 'src/scene-3d-support.ts', outputBasename: 'scene-3d-support', }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/scene-2d-support.ts', + outputBasename: 'scene-2d-support', + }), ]; \ No newline at end of file diff --git a/web/packages/scene-runtime/src/__tests__/lighting-collector.test.ts b/web/packages/scene-runtime/src/__tests__/lighting-collector.test.ts new file mode 100644 index 00000000..be4df3e4 --- /dev/null +++ b/web/packages/scene-runtime/src/__tests__/lighting-collector.test.ts @@ -0,0 +1,164 @@ +import { Actor, Transform, World } from '@axrone/ecs-runtime'; +import { Vec3 } from '@axrone/numeric'; +import { describe, expect, it } from 'vitest'; +import { DirectionalLight } from '../components/directional-light'; +import { PointLight } from '../components/point-light'; +import { SpotLight } from '../components/spot-light'; +import { SceneLightingCollector } from '../lighting-collector'; +import { createSceneRegistry } from '../scene-registry'; + +const createWorld = (): World => new World(createSceneRegistry()); + +describe('SceneLightingCollector', () => { + it('reuses the cached modern lighting selection while preserving per-kind light buffers', () => { + const world = createWorld(); + const collector = new SceneLightingCollector(4); + + const directionalActor = new Actor(world); + directionalActor.addComponent(DirectionalLight, { + color: [0.8, 0.7, 0.6], + ambientColor: [0.05, 0.04, 0.03], + intensity: 2, + primary: true, + }); + + const pointActor = new Actor(world); + pointActor.addComponent(PointLight, { + color: [1, 0.5, 0.25], + intensity: 3, + range: 9, + }); + pointActor.requireComponent(Transform).position = new Vec3(1, 2, 3); + + const spotActor = new Actor(world); + spotActor.addComponent(SpotLight, { + color: [0.2, 0.4, 1], + intensity: 8, + range: 18, + innerConeAngle: 0.15, + outerConeAngle: 0.5, + }); + spotActor.requireComponent(Transform).position = new Vec3(-1, 5, 2); + + const first = collector.collect( + world.getAllActors(), + new Vec3(0.1, 0.2, 0.3), + Vec3.ZERO, + Vec3.ZERO, + new Vec3(0, 2, 0) + ); + const second = collector.collect( + world.getAllActors(), + new Vec3(0.1, 0.2, 0.3), + Vec3.ZERO, + Vec3.ZERO, + new Vec3(0, 2, 0) + ); + + expect(second).toBe(first); + expect(second.pointPositions).toBe(first.pointPositions); + expect(second.localLightKinds).toBe(first.localLightKinds); + expect(second.environment.ambient.x).toBeCloseTo(0.1); + expect(second.environment.ambient.y).toBeCloseTo(0.2); + expect(second.environment.ambient.z).toBeCloseTo(0.3); + expect(second.stats.selectedDirectionalCount).toBe(1); + expect(second.stats.selectedPointCount).toBe(1); + expect(second.stats.selectedSpotCount).toBe(1); + expect(second.stats.selectedLocalLightCount).toBe(2); + expect(second.directionalAmbientColors[0]).toBeCloseTo(0.05); + expect(second.directionalAmbientColors[1]).toBeCloseTo(0.04); + expect(second.directionalAmbientColors[2]).toBeCloseTo(0.03); + expect(second.pointRanges[0]).toBe(9); + expect(second.spotOuterConeCosines[0]).toBeCloseTo(Math.cos(0.5)); + }); + + it('prefers primary directional lights over fallback directional lights', () => { + const world = createWorld(); + const collector = new SceneLightingCollector(4); + + const fallback = new Actor(world); + fallback.addComponent(DirectionalLight, { + color: [1, 0, 0], + intensity: 1, + primary: false, + }); + + const primary = new Actor(world); + primary.addComponent(DirectionalLight, { + color: [0, 1, 0], + intensity: 4, + primary: true, + }); + + const lighting = collector.collect(world.getAllActors(), Vec3.ZERO); + + expect(lighting.stats.selectedDirectionalCount).toBe(1); + expect(lighting.directionalColors[0]).toBe(0); + expect(lighting.directionalColors[1]).toBe(1); + expect(lighting.directionalIntensities[0]).toBe(4); + }); + + it('uses camera influence when choosing modern local and point-light selections', () => { + const world = createWorld(); + const collector = new SceneLightingCollector(1); + + const farPointActor = new Actor(world); + farPointActor.addComponent(PointLight, { + color: [1, 0, 0], + intensity: 8, + range: 5, + }); + farPointActor.requireComponent(Transform).position = new Vec3(20, 0, 0); + + const nearPointActor = new Actor(world); + nearPointActor.addComponent(PointLight, { + color: [0, 1, 0], + intensity: 2, + range: 8, + }); + nearPointActor.requireComponent(Transform).position = new Vec3(1, 0, 0); + + const lighting = collector.collect(world.getAllActors(), Vec3.ZERO, Vec3.ZERO, Vec3.ZERO, Vec3.ZERO); + + expect(lighting.stats.selectedLocalLightCount).toBe(1); + expect(lighting.stats.selectedPointCount).toBe(1); + expect([...lighting.localLightPositions.slice(0, 3)]).toEqual([1, 0, 0]); + expect([...lighting.pointPositions.slice(0, 3)]).toEqual([1, 0, 0]); + expect(lighting.pointColors[0]).toBe(0); + expect(lighting.pointColors[1]).toBe(1); + }); + + it('keeps cosine spot cones and removes stale lights when components disable', () => { + const world = createWorld(); + const collector = new SceneLightingCollector(2); + + const spotActor = new Actor(world); + const spotLight = spotActor.addComponent(SpotLight, { + color: [0.25, 0.5, 1], + intensity: 6, + range: 12, + innerConeAngle: 0.2, + outerConeAngle: 0.6, + }); + spotActor.requireComponent(Transform).position = new Vec3(4, 0, 0); + + const first = collector.collect(world.getAllActors(), Vec3.ZERO); + + expect(first.stats.selectedLocalLightCount).toBe(1); + expect(first.stats.selectedSpotCount).toBe(1); + expect(first.localLightInnerConeCosines[0]).toBeCloseTo(Math.cos(0.2)); + expect(first.localLightOuterConeCosines[0]).toBeCloseTo(Math.cos(0.6)); + expect(first.spotInnerConeCosines[0]).toBeCloseTo(Math.cos(0.2)); + expect(first.spotOuterConeCosines[0]).toBeCloseTo(Math.cos(0.6)); + + spotLight.enabled = false; + + const second = collector.collect(world.getAllActors(), Vec3.ZERO); + + expect(second.stats.totalSpotCount).toBe(0); + expect(second.stats.selectedLocalLightCount).toBe(0); + expect(second.stats.selectedSpotCount).toBe(0); + expect(second.spotIntensities[0]).toBe(0); + expect(second.spotOuterConeCosines[0]).toBe(0); + }); +}); \ No newline at end of file diff --git a/web/packages/scene-runtime/src/__tests__/lighting-components.test.ts b/web/packages/scene-runtime/src/__tests__/lighting-components.test.ts new file mode 100644 index 00000000..c1749429 --- /dev/null +++ b/web/packages/scene-runtime/src/__tests__/lighting-components.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { DirectionalLight } from '../components/directional-light'; +import { PointLight } from '../components/point-light'; +import { SpotLight } from '../components/spot-light'; + +describe('scene-runtime light components', () => { + it('validates point light ranges through the lighting package rules', () => { + expect(() => new PointLight({ range: 0 })).toThrow(); + + const light = new PointLight({ + color: [0.5, 0.25, 1], + intensity: 2, + range: 9, + }); + + expect(light.range).toBe(9); + expect(() => light.deserialize({ range: -1 })).toThrow(); + expect(light.range).toBe(9); + }); + + it('validates directional light intensity updates without corrupting state', () => { + const light = new DirectionalLight({ + ambientColor: [0.1, 0.2, 0.3], + intensity: 2, + primary: true, + }); + + expect(light.intensity).toBe(2); + expect(light.primary).toBe(true); + expect(() => { + light.intensity = -1; + }).toThrow(); + expect(light.intensity).toBe(2); + expect(light.primary).toBe(true); + }); + + it('validates spot cone ordering and preserves the last valid cone state', () => { + const light = new SpotLight({ + innerConeAngle: 0.2, + outerConeAngle: 0.6, + }); + + expect(light.innerConeAngle).toBeCloseTo(0.2); + expect(light.outerConeAngle).toBeCloseTo(0.6); + + expect(() => { + light.innerConeAngle = 0.8; + }).toThrow(); + expect(light.innerConeAngle).toBeCloseTo(0.2); + expect(light.outerConeAngle).toBeCloseTo(0.6); + + expect(() => { + light.deserialize({ innerConeAngle: 0.7, outerConeAngle: 0.3 }); + }).toThrow(); + expect(light.innerConeAngle).toBeCloseTo(0.2); + expect(light.outerConeAngle).toBeCloseTo(0.6); + }); +}); \ No newline at end of file diff --git a/web/packages/scene-runtime/src/__tests__/material-inspector.test.ts b/web/packages/scene-runtime/src/__tests__/material-inspector.test.ts new file mode 100644 index 00000000..09138e15 --- /dev/null +++ b/web/packages/scene-runtime/src/__tests__/material-inspector.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { createSceneShaderDefinitionFromEffect } from '../shader-effect'; +import { + createSceneMaterialInspectorControls, + createSceneMaterialInspectorSections, +} from '../material-inspector'; + +describe('scene material inspector metadata', () => { + it('derives grouped material controls from shader effect metadata', () => { + const shader = createSceneShaderDefinitionFromEffect({ + format: 'axrone.shader/effect', + version: 1, + id: 'shader/test-material', + properties: [ + { + name: 'u_Tint', + type: 'vec4', + stages: ['fragment'], + scope: 'material', + defaultValue: [1, 1, 1, 1], + inspector: { + label: 'Tint', + group: 'Surface', + control: 'color', + }, + }, + { + name: 'u_Metallic', + type: 'float', + stages: ['fragment'], + scope: 'material', + defaultValue: 1, + inspector: { + label: 'Metallic', + group: 'Surface', + control: 'slider', + min: 0, + max: 1, + step: 0.01, + }, + }, + { + name: 'u_MainTex', + type: 'sampler2D', + stages: ['fragment'], + scope: 'material', + inspector: { + label: 'Main Texture', + group: 'Maps', + control: 'texture', + }, + }, + { + name: 'u_Mode', + type: 'float', + stages: ['fragment'], + scope: 'material', + defaultValue: 0, + inspector: { + label: 'Mode', + group: 'Flags', + control: 'select', + options: [ + { label: 'Opaque', value: 0 }, + { label: 'Blend', value: 2 }, + ], + }, + }, + { + name: 'u_Enabled', + type: 'bool', + stages: ['fragment'], + scope: 'material', + defaultValue: false, + inspector: { + label: 'Enabled', + group: 'Flags', + control: 'toggle', + }, + }, + { + name: 'u_Internal', + type: 'float', + stages: ['fragment'], + scope: 'material', + inspector: { hidden: true }, + }, + ], + vertex: { + main: ['gl_Position = vec4(0.0);'], + }, + fragment: { + precision: 'highp', + outputs: [{ name: 'o_Color', type: 'vec4' }], + main: ['o_Color = vec4(1.0);'], + }, + }); + + const controls = createSceneMaterialInspectorControls(shader, { + id: 'material/test', + shaderId: shader.id, + uniforms: { + u_Tint: [0.8, 0.4, 0.2, 1], + u_Metallic: 0.35, + u_Mode: 2, + u_Enabled: true, + }, + textures: { + u_MainTex: 'textures/test/albedo.ktx2', + }, + }); + const sections = createSceneMaterialInspectorSections(shader, { + id: 'material/test', + shaderId: shader.id, + uniforms: { + u_Tint: [0.8, 0.4, 0.2, 1], + u_Metallic: 0.35, + u_Mode: 2, + u_Enabled: true, + }, + textures: { + u_MainTex: 'textures/test/albedo.ktx2', + }, + }); + + expect(controls).toHaveLength(5); + expect(controls.find((entry) => entry.name === 'u_Tint')).toMatchObject({ + control: 'color', + group: 'Surface', + value: [0.8, 0.4, 0.2, 1], + }); + expect(controls.find((entry) => entry.name === 'u_MainTex')).toMatchObject({ + control: 'texture', + value: 'textures/test/albedo.ktx2', + }); + expect(controls.find((entry) => entry.name === 'u_Mode')?.options).toEqual([ + { label: 'Opaque', value: 0 }, + { label: 'Blend', value: 2 }, + ]); + expect(sections.map((entry) => entry.title)).toEqual(['Surface', 'Maps', 'Flags']); + }); +}); diff --git a/web/packages/scene-runtime/src/__tests__/prefab-animation-integration.test.ts b/web/packages/scene-runtime/src/__tests__/prefab-animation-integration.test.ts new file mode 100644 index 00000000..ccfe383f --- /dev/null +++ b/web/packages/scene-runtime/src/__tests__/prefab-animation-integration.test.ts @@ -0,0 +1,1169 @@ +import { encodeAnimationClipStreamingChunkPayload } from '@axrone/animation'; +import { World, Transform } from '@axrone/ecs-runtime'; +import { Vec3 } from '@axrone/numeric'; +import { describe, expect, it, vi } from 'vitest'; +import { + bindAnimationStreamingBridge, + createFetchAnimationStreamingResolver, +} from '../animation-streaming-bridge'; +import { SceneActorLifecycleRunner } from '../actor-lifecycle-runner'; +import { SceneActorRuntime } from '../scene-actor-runtime'; +import { createSceneRegistry } from '../scene-registry'; +import { encodeSceneValue } from '../serialization'; +import type { ScenePrefabDefinition } from '../types'; +import { Animator } from '../components/animator'; +import { MeshRenderer } from '../components/mesh-renderer'; +import { SceneComponentCatalog } from '../component-catalog'; + +const createPrefabComponent = (type: string, data: unknown) => ({ + type, + data: encodeSceneValue(data), +}); + +const createAnimatedRigPrefab = ( + animatorData: Record +): ScenePrefabDefinition => ({ + id: 'prefab/animated-rig', + actors: [ + { + nodeId: 'node/0', + parentNodeId: null, + name: 'Rig Root', + layer: 0, + tag: 'default', + active: true, + persistent: false, + pooled: false, + components: [ + createPrefabComponent('Transform', { + position: [0, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }), + createPrefabComponent('Animator', animatorData), + ], + }, + { + nodeId: 'node/1', + parentNodeId: 'node/0', + name: 'Hip', + layer: 0, + tag: 'default', + active: true, + persistent: false, + pooled: false, + components: [ + createPrefabComponent('Transform', { + position: [1, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }), + ], + }, + { + nodeId: 'node/2', + parentNodeId: 'node/1', + name: 'Tip', + layer: 0, + tag: 'default', + active: true, + persistent: false, + pooled: false, + components: [ + createPrefabComponent('Transform', { + position: [1, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }), + ], + }, + { + nodeId: 'node/3', + parentNodeId: 'node/0', + name: 'Skinned Mesh', + layer: 0, + tag: 'default', + active: true, + persistent: false, + pooled: false, + components: [ + createPrefabComponent('Transform', { + position: [0, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }), + createPrefabComponent('MeshRenderer', { + meshId: 'mesh/test', + materialId: 'material/test', + visible: true, + renderOrder: 0, + passId: 'main', + receiveLighting: true, + uniformOverrides: {}, + skin: { + jointNodeIds: ['node/1', 'node/2'], + inverseBindMatrices: new Float32Array([ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + ]), + }, + }), + ], + }, + ], +}); + +const createSiblingAnimatorPrefab = (): ScenePrefabDefinition => ({ + id: 'prefab/sibling-animators', + actors: [ + { + nodeId: 'character-a', + parentNodeId: null, + name: 'Character A', + layer: 0, + tag: 'default', + active: true, + persistent: false, + pooled: false, + components: [ + createPrefabComponent('Transform', { + position: [-1, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }), + createPrefabComponent('Animator', { + clipId: 'Idle', + playOnStart: true, + playing: true, + loop: true, + clips: [ + { + id: 'Idle', + duration: 1, + tracks: [ + { + targetNodeId: 'character-a/bone', + path: 'translation', + times: [0, 1], + values: [0, 0, 0, 0, 2, 0], + }, + ], + }, + ], + }), + ], + }, + { + nodeId: 'character-a/bone', + parentNodeId: 'character-a', + name: 'Character A Bone', + layer: 0, + tag: 'default', + active: true, + persistent: false, + pooled: false, + components: [ + createPrefabComponent('Transform', { + position: [0, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }), + ], + }, + { + nodeId: 'character-b', + parentNodeId: null, + name: 'Character B', + layer: 0, + tag: 'default', + active: true, + persistent: false, + pooled: false, + components: [ + createPrefabComponent('Transform', { + position: [1, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }), + createPrefabComponent('Animator', { + clipId: 'Idle', + playOnStart: true, + playing: true, + loop: true, + clips: [ + { + id: 'Idle', + duration: 1, + tracks: [ + { + targetNodeId: 'character-b/bone', + path: 'translation', + times: [0, 1], + values: [0, 0, 0, 0, 2, 0], + }, + ], + }, + ], + }), + ], + }, + { + nodeId: 'character-b/bone', + parentNodeId: 'character-b', + name: 'Character B Bone', + layer: 0, + tag: 'default', + active: true, + persistent: false, + pooled: false, + components: [ + createPrefabComponent('Transform', { + position: [0, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }), + ], + }, + ], +}); + +const createWrappedChildRootAnimatorPrefab = (): ScenePrefabDefinition => ({ + id: 'prefab/wrapped-child-root-animator', + actors: [ + { + nodeId: 'wrapper', + parentNodeId: null, + name: 'Wrapper', + layer: 0, + tag: 'default', + active: true, + persistent: false, + pooled: false, + components: [ + createPrefabComponent('Transform', { + position: [0, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }), + ], + }, + { + nodeId: 'armature', + parentNodeId: 'wrapper', + name: 'Armature', + layer: 0, + tag: 'default', + active: true, + persistent: false, + pooled: false, + components: [ + createPrefabComponent('Transform', { + position: [0, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }), + createPrefabComponent('Animator', { + clipId: 'Idle', + playOnStart: true, + playing: true, + loop: true, + clips: [ + { + id: 'Idle', + duration: 1, + tracks: [ + { + targetNodeId: 'armature/bone', + path: 'translation', + times: [0, 1], + values: [0, 0, 0, 0, 2, 0], + }, + ], + }, + ], + }), + ], + }, + { + nodeId: 'armature/bone', + parentNodeId: 'armature', + name: 'Armature Bone', + layer: 0, + tag: 'default', + active: true, + persistent: false, + pooled: false, + components: [ + createPrefabComponent('Transform', { + position: [0, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }), + ], + }, + ], +}); + +const createPrefabHarness = () => { + const registry = createSceneRegistry(); + const world = new World(registry); + const actors = new SceneActorRuntime({ + world, + componentCatalog: new SceneComponentCatalog(registry), + }); + const lifecycle = new SceneActorLifecycleRunner({ + getActors: () => world.getAllActors(), + }); + + return { + actors, + lifecycle, + world, + }; +}; + +describe('scene-runtime prefab animation integration', () => { + it('instantiates prefab animators with custom parameters, layers, and root motion', () => { + const harness = createPrefabHarness(); + const prefab = createAnimatedRigPrefab({ + clips: [ + { + id: 'Idle', + duration: 1, + tracks: [ + { + targetNodeId: 'node/1', + path: 'translation', + times: [0, 1], + values: [1, 0, 0, 1, 0, 0], + }, + ], + }, + { + id: 'Run', + duration: 1, + tracks: [ + { + targetNodeId: 'node/1', + path: 'translation', + times: [0, 1], + values: [1, 0, 0, 3, 0, 0], + }, + ], + }, + ], + parameters: [{ name: 'speed', kind: 'float', defaultValue: 0 }], + layers: [ + { + id: 'base', + weight: 1, + mode: 'override', + stateMachine: { + entryState: 'locomotion', + states: [ + { + id: 'locomotion', + motion: { + kind: 'blend1d', + parameter: 'speed', + children: [ + { + threshold: 0, + motion: { kind: 'clip', clipId: 'Idle' }, + }, + { + threshold: 1, + motion: { kind: 'clip', clipId: 'Run' }, + }, + ], + }, + }, + ], + }, + }, + ], + rootMotion: { + bone: 'node/1', + consume: true, + projectTranslationAxes: [true, false, false], + }, + clipId: 'Idle', + playOnStart: true, + playing: true, + loop: true, + speed: 1, + time: 0, + applyRootMotion: true, + updateMode: 'Animate Physics', + cullingMode: 'Cull Update Transforms', + }); + + const firstInstance = harness.actors.instantiatePrefab(prefab, { namePrefix: 'A ' }); + const secondInstance = harness.actors.instantiatePrefab(prefab, { namePrefix: 'B ' }); + + const firstRoot = firstInstance.find((actor) => actor.name === 'A Rig Root'); + const firstHip = firstInstance.find((actor) => actor.name === 'A Hip'); + const firstMesh = firstInstance.find((actor) => actor.name === 'A Skinned Mesh'); + const secondRoot = secondInstance.find((actor) => actor.name === 'B Rig Root'); + const secondHip = secondInstance.find((actor) => actor.name === 'B Hip'); + + const firstAnimator = firstRoot?.getComponent(Animator) ?? null; + const secondAnimator = secondRoot?.getComponent(Animator) ?? null; + + firstAnimator?.setFloat('speed', 0.5); + secondAnimator?.setFloat('speed', 0); + + harness.lifecycle.fixedUpdate(500); + + expect(firstRoot?.requireComponent(Transform).position.x).toBeCloseTo(0.5, 5); + expect(secondRoot?.requireComponent(Transform).position.x).toBeCloseTo(0, 5); + expect(firstHip?.requireComponent(Transform).position.x).toBeCloseTo(1, 5); + expect(secondHip?.requireComponent(Transform).position.x).toBeCloseTo(1, 5); + expect(firstMesh?.getComponent(MeshRenderer)?.getSkinJointMatrixPalette()).not.toBeNull(); + expect(firstMesh?.getComponent(MeshRenderer)?.skinJointCount).toBe(2); + expect(firstAnimator?.serialize()).toMatchObject({ + applyRootMotion: true, + updateMode: 'Animate Physics', + cullingMode: 'Cull Update Transforms', + }); + }); + + it('keeps sibling animators in the same prefab instance scoped to their own subtree', () => { + const harness = createPrefabHarness(); + const actors = harness.actors.instantiatePrefab(createSiblingAnimatorPrefab()); + + harness.lifecycle.update(16); + harness.lifecycle.update(500); + + const characterABone = actors.find((actor) => actor.name === 'Character A Bone'); + const characterBBone = actors.find((actor) => actor.name === 'Character B Bone'); + const characterABoneY = characterABone?.requireComponent(Transform).position.y ?? 0; + const characterBBoneY = characterBBone?.requireComponent(Transform).position.y ?? 0; + + expect(characterABoneY).toBeGreaterThan(0.9); + expect(characterBBoneY).toBeGreaterThan(0.9); + expect(characterABoneY).toBeCloseTo(characterBBoneY, 5); + }); + + it('supports animators mounted on child roots without inheriting wrapper parents into the rig', () => { + const harness = createPrefabHarness(); + const actors = harness.actors.instantiatePrefab(createWrappedChildRootAnimatorPrefab()); + + harness.lifecycle.update(16); + harness.lifecycle.update(500); + + const armatureBone = actors.find((actor) => actor.name === 'Armature Bone'); + expect(armatureBone?.requireComponent(Transform).position.y).toBeGreaterThan(0.9); + }); + + it('keeps root motion disabled while preserving runtime animator metadata when applyRootMotion is false', () => { + const harness = createPrefabHarness(); + const prefab = createAnimatedRigPrefab({ + clips: [ + { + id: 'Walk', + duration: 1, + tracks: [ + { + targetNodeId: 'node/1', + path: 'translation', + times: [0, 1], + values: [1, 0, 0, 3, 0, 0], + }, + ], + }, + ], + rootMotion: { + bone: 'node/1', + consume: true, + projectTranslationAxes: [true, false, false], + }, + clipId: 'Walk', + playOnStart: true, + playing: true, + loop: true, + speed: 1, + time: 0, + applyRootMotion: false, + updateMode: 'Unscaled Time', + cullingMode: 'Cull Completely', + }); + + const actors = harness.actors.instantiatePrefab(prefab); + const root = actors.find((actor) => actor.name === 'Rig Root'); + const hip = actors.find((actor) => actor.name === 'Hip'); + const animator = root?.getComponent(Animator) ?? null; + + harness.lifecycle.update(500); + + expect(root?.requireComponent(Transform).position.x).toBeCloseTo(0, 5); + expect(hip?.requireComponent(Transform).position.x).toBeCloseTo(2, 5); + expect(animator?.serialize()).toMatchObject({ + applyRootMotion: false, + updateMode: 'Unscaled Time', + cullingMode: 'Cull Completely', + }); + expect(animator?.getDebugInfo()).toEqual( + expect.objectContaining({ + applyRootMotion: false, + updateMode: 'Unscaled Time', + cullingMode: 'Cull Completely', + }) + ); + }); + + it('updates animate-physics animators only during fixedUpdate', () => { + const harness = createPrefabHarness(); + const prefab = createAnimatedRigPrefab({ + clips: [ + { + id: 'Walk', + duration: 1, + tracks: [ + { + targetNodeId: 'node/1', + path: 'translation', + times: [0, 1], + values: [1, 0, 0, 3, 0, 0], + }, + ], + }, + ], + clipId: 'Walk', + playOnStart: true, + playing: true, + loop: true, + speed: 1, + time: 0, + updateMode: 'Animate Physics', + cullingMode: 'Always Animate', + }); + + const actors = harness.actors.instantiatePrefab(prefab); + const root = actors.find((actor) => actor.name === 'Rig Root'); + const hip = actors.find((actor) => actor.name === 'Hip'); + const animator = root?.getComponent(Animator) ?? null; + + harness.lifecycle.update(500); + + expect(animator?.time).toBeCloseTo(0, 5); + expect(hip?.requireComponent(Transform).position.x).toBeCloseTo(1, 5); + + harness.lifecycle.fixedUpdate(500); + + expect(animator?.time).toBeCloseTo(0.5, 5); + expect(hip?.requireComponent(Transform).position.x).toBeCloseTo(2, 5); + }); + + it('skips animator advancement completely when cull completely has no visible renderer', () => { + const harness = createPrefabHarness(); + const prefab = createAnimatedRigPrefab({ + clips: [ + { + id: 'Walk', + duration: 1, + tracks: [ + { + targetNodeId: 'node/1', + path: 'translation', + times: [0, 1], + values: [1, 0, 0, 3, 0, 0], + }, + ], + }, + ], + clipId: 'Walk', + playOnStart: true, + playing: true, + loop: false, + speed: 1, + time: 0, + cullingMode: 'Cull Completely', + }); + + const actors = harness.actors.instantiatePrefab(prefab); + const root = actors.find((actor) => actor.name === 'Rig Root'); + const hip = actors.find((actor) => actor.name === 'Hip'); + const mesh = actors.find((actor) => actor.name === 'Skinned Mesh'); + const animator = root?.getComponent(Animator) ?? null; + const renderer = mesh?.getComponent(MeshRenderer) ?? null; + + expect(renderer).not.toBeNull(); + renderer!.visible = false; + + harness.lifecycle.update(500); + + expect(animator?.time).toBeCloseTo(0, 5); + expect(hip?.requireComponent(Transform).position.x).toBeCloseTo(1, 5); + + renderer!.visible = true; + harness.lifecycle.update(500); + + expect(animator?.time).toBeCloseTo(0.5, 5); + expect(hip?.requireComponent(Transform).position.x).toBeCloseTo(2, 5); + }); + + it('continues animator time without writing transforms when cull update transforms has no visible renderer', () => { + const harness = createPrefabHarness(); + const prefab = createAnimatedRigPrefab({ + clips: [ + { + id: 'Walk', + duration: 1, + tracks: [ + { + targetNodeId: 'node/1', + path: 'translation', + times: [0, 1], + values: [1, 0, 0, 3, 0, 0], + }, + ], + }, + ], + clipId: 'Walk', + playOnStart: true, + playing: true, + loop: false, + speed: 1, + time: 0, + cullingMode: 'Cull Update Transforms', + }); + + const actors = harness.actors.instantiatePrefab(prefab); + const root = actors.find((actor) => actor.name === 'Rig Root'); + const hip = actors.find((actor) => actor.name === 'Hip'); + const mesh = actors.find((actor) => actor.name === 'Skinned Mesh'); + const animator = root?.getComponent(Animator) ?? null; + const renderer = mesh?.getComponent(MeshRenderer) ?? null; + + expect(renderer).not.toBeNull(); + renderer!.visible = false; + + harness.lifecycle.update(500); + + expect(animator?.time).toBeCloseTo(0.5, 5); + expect(hip?.requireComponent(Transform).position.x).toBeCloseTo(1, 5); + + renderer!.visible = true; + harness.lifecycle.update(500); + + expect(animator?.time).toBeCloseTo(1, 5); + expect(hip?.requireComponent(Transform).position.x).toBeCloseTo(3, 5); + }); + + it('refreshes cached joint world matrices before computing the skin palette', () => { + const harness = createPrefabHarness(); + const prefab = createAnimatedRigPrefab({ + clips: [], + playOnStart: false, + playing: false, + loop: true, + speed: 1, + time: 0, + }); + + const actors = harness.actors.instantiatePrefab(prefab); + const hip = actors.find((actor) => actor.name === 'Hip'); + const mesh = actors.find((actor) => actor.name === 'Skinned Mesh'); + const hipTransform = hip?.getComponent(Transform) ?? null; + const renderer = mesh?.getComponent(MeshRenderer) ?? null; + + const initialPalette = renderer?.getSkinJointMatrixPalette(); + expect(initialPalette).not.toBeNull(); + const initialSnapshot = Array.from(initialPalette ?? []); + + expect(hipTransform).not.toBeNull(); + hipTransform!.position = new Vec3(2, 0, 0); + + const updatedPalette = renderer?.getSkinJointMatrixPalette(); + expect(updatedPalette).not.toBeNull(); + expect(Array.from(updatedPalette ?? [])).not.toEqual(initialSnapshot); + }); + + it('applies prefab-authored IK layer metadata through instantiated animators', () => { + const harness = createPrefabHarness(); + const prefab = createAnimatedRigPrefab({ + clips: [ + { + id: 'Pose', + duration: 1, + tracks: [ + { + targetNodeId: 'node/2', + path: 'translation', + times: [0, 1], + values: [1, 0, 0, 1, 0, 0], + }, + ], + }, + ], + layers: [ + { + id: 'base', + weight: 1, + mode: 'override', + stateMachine: { + entryState: 'pose', + states: [ + { + id: 'pose', + motion: { kind: 'clip', clipId: 'Pose' }, + loop: true, + }, + ], + }, + ikLayers: [ + { + id: 'reach', + jobs: [ + { + id: 'aim', + solver: 'ccd', + rootBone: 'node/0', + tipBone: 'node/2', + targetPosition: [1, 1, 0], + precision: 1e-4, + maxIterations: 24, + }, + ], + }, + ], + }, + ], + clipId: 'Pose', + playOnStart: true, + playing: true, + loop: true, + speed: 1, + time: 0, + }); + + const actors = harness.actors.instantiatePrefab(prefab); + const tip = actors.find((actor) => actor.name === 'Tip'); + + harness.lifecycle.update(16); + + expect(tip?.requireComponent(Transform).worldPosition.x).toBeCloseTo(1, 3); + expect(tip?.requireComponent(Transform).worldPosition.y).toBeCloseTo(1, 3); + expect(tip?.requireComponent(Transform).worldPosition.z).toBeCloseTo(0, 3); + }); + + it('emits animation notify events and exposes controller profile debug info', () => { + const harness = createPrefabHarness(); + const prefab = createAnimatedRigPrefab({ + clips: [ + { + id: 'Attack', + duration: 1, + events: [ + { + id: 'swing', + name: 'attack:swing', + time: 0.5, + payload: { damage: 18 }, + tags: ['combat'], + }, + ], + tracks: [ + { + targetNodeId: 'node/1', + path: 'translation', + times: [0, 1], + values: [1, 0, 0, 2, 0, 0], + }, + ], + }, + ], + clipId: 'Attack', + playOnStart: true, + playing: true, + loop: true, + speed: 1, + time: 0, + }); + const received: Record[] = []; + const unsubscribe = harness.world.on('animation:notify', (event) => { + received.push(event as Record); + }); + + const actors = harness.actors.instantiatePrefab(prefab); + const root = actors.find((actor) => actor.name === 'Rig Root'); + const animator = root?.getComponent(Animator) ?? null; + + harness.lifecycle.update(750); + + expect(received).toEqual([ + expect.objectContaining({ + clipId: 'Attack', + layerId: 'base', + stateId: 'Attack', + name: 'attack:swing', + id: 'swing', + payload: { damage: 18 }, + tags: ['combat'], + }), + ]); + expect(animator?.getDebugInfo()).toEqual( + expect.objectContaining({ + clipId: 'Attack', + profile: expect.objectContaining({ + emittedEventCount: 1, + }), + pendingEvents: [ + expect.objectContaining({ + clipId: 'Attack', + name: 'attack:swing', + }), + ], + }) + ); + + unsubscribe(); + }); + + it('requests streamed animation chunks and blocks playback until the active chunk is loaded', () => { + const harness = createPrefabHarness(); + const prefab = createAnimatedRigPrefab({ + clips: [ + { + id: 'Walk', + duration: 1, + streaming: { + mode: 'streamed', + sourceUri: 'clips/walk.anim', + chunkDuration: 1, + preloadWindow: 0.25, + }, + tracks: [ + { + targetNodeId: 'node/1', + path: 'translation', + times: [0, 1], + values: [1, 0, 0, 3, 0, 0], + }, + ], + }, + ], + clipId: 'Walk', + playOnStart: true, + playing: true, + loop: false, + speed: 1, + time: 0, + }); + const received: Record[] = []; + const unsubscribe = harness.world.on('animation:streaming-request', (event) => { + received.push(event as Record); + }); + + const actors = harness.actors.instantiatePrefab(prefab); + const root = actors.find((actor) => actor.name === 'Rig Root'); + const hip = actors.find((actor) => actor.name === 'Hip'); + const animator = root?.getComponent(Animator) ?? null; + + harness.lifecycle.update(16); + + expect(received).toEqual([ + expect.objectContaining({ + clipId: 'Walk', + chunkId: 'Walk:virtual:0', + reason: 'active', + }), + ]); + expect(hip?.requireComponent(Transform).position.x).toBeCloseTo(1, 5); + expect(animator?.getDebugInfo()).toEqual( + expect.objectContaining({ + streaming: expect.objectContaining({ + ready: false, + }), + pendingStreamingRequests: [ + expect.objectContaining({ + clipId: 'Walk', + }), + ], + }) + ); + + animator?.markStreamingChunkLoaded('Walk', 'Walk:virtual:0'); + harness.lifecycle.update(500); + + expect(hip?.requireComponent(Transform).position.x).toBeCloseTo(2, 5); + + unsubscribe(); + }); + + it('updates animator clip state when cross-fading between prefab-authored clips', () => { + const harness = createPrefabHarness(); + const prefab = createAnimatedRigPrefab({ + clips: [ + { + id: 'Idle', + duration: 1, + tracks: [ + { + targetNodeId: 'node/1', + path: 'translation', + times: [0, 1], + values: [1, 0, 0, 1, 0, 0], + }, + ], + }, + { + id: 'Run', + duration: 1, + tracks: [ + { + targetNodeId: 'node/1', + path: 'translation', + times: [0, 1], + values: [1, 0, 0, 4, 0, 0], + }, + ], + }, + ], + clipId: 'Idle', + playOnStart: true, + playing: true, + loop: true, + speed: 1, + time: 0, + }); + + const actors = harness.actors.instantiatePrefab(prefab); + const root = actors.find((actor) => actor.name === 'Rig Root'); + const animator = root?.getComponent(Animator) ?? null; + + expect(animator?.clipId).toBe('Idle'); + + animator?.crossFade('Run', 0.15); + + expect(animator?.clipId).toBe('Run'); + expect(animator?.getDebugInfo()).toEqual( + expect.objectContaining({ + clipId: 'Run', + }) + ); + }); + + it('bridges streamed animation requests through async chunk resolvers', async () => { + const harness = createPrefabHarness(); + const loaded: Record[] = []; + const received: Record[] = []; + const unsubscribe = harness.world.on('animation:streaming-loaded', (event) => { + received.push(event as Record); + }); + const bridge = bindAnimationStreamingBridge(harness.world, { + resolver: async (request) => ({ + bytes: new Uint8Array([1, 2, 3, 4]), + mimeType: request.mimeType ?? 'application/octet-stream', + }), + onChunkLoaded: async (chunk) => { + loaded.push({ + clipId: chunk.request.clipId, + chunkId: chunk.request.chunkId, + byteLength: chunk.bytes.byteLength, + }); + }, + }); + const prefab = createAnimatedRigPrefab({ + clips: [ + { + id: 'Walk', + duration: 1, + streaming: { + mode: 'streamed', + sourceUri: 'clips/walk.anim', + chunkDuration: 1, + preloadWindow: 0.25, + }, + tracks: [ + { + targetNodeId: 'node/1', + path: 'translation', + times: [0, 1], + values: [1, 0, 0, 3, 0, 0], + }, + ], + }, + ], + clipId: 'Walk', + playOnStart: true, + playing: true, + loop: false, + speed: 1, + time: 0, + }); + + const actors = harness.actors.instantiatePrefab(prefab); + const hip = actors.find((actor) => actor.name === 'Hip'); + + harness.lifecycle.update(16); + await bridge.waitForIdle(); + harness.lifecycle.update(500); + + expect(loaded).toEqual([ + { + clipId: 'Walk', + chunkId: 'Walk:virtual:0', + byteLength: 4, + }, + ]); + expect(received).toEqual([ + expect.objectContaining({ + clipId: 'Walk', + chunkId: 'Walk:virtual:0', + byteLength: 4, + }), + ]); + expect(hip?.requireComponent(Transform).position.x).toBeCloseTo(2, 5); + + bridge.dispose(); + unsubscribe(); + }); + + it('supports fetch-backed animation chunk loading with byte-range catalogs', async () => { + const harness = createPrefabHarness(); + const loadedBytes: Uint8Array[] = []; + const fetch = vi.fn(async () => ({ + ok: true, + status: 200, + headers: { + get(name: string) { + return name.toLowerCase() === 'content-type' + ? 'application/octet-stream' + : null; + }, + }, + async arrayBuffer() { + return new Uint8Array([10, 20, 30, 40, 50, 60]).buffer; + }, + })); + const bridge = bindAnimationStreamingBridge(harness.world, { + resolver: createFetchAnimationStreamingResolver({ fetch }), + onChunkLoaded: (chunk) => { + loadedBytes.push(chunk.bytes); + }, + }); + const prefab = createAnimatedRigPrefab({ + clips: [ + { + id: 'Walk', + duration: 1, + streaming: { + mode: 'streamed', + sourceUri: 'https://cdn.local/clips/walk.anim', + preloadWindow: 0.25, + catalog: { + id: 'walk-catalog', + chunks: [ + { + id: 'walk:chunk:0', + uri: 'https://cdn.local/clips/walk.anim', + startTime: 0, + endTime: 1, + byteOffset: 2, + byteLength: 3, + }, + ], + }, + }, + tracks: [ + { + targetNodeId: 'node/1', + path: 'translation', + times: [0, 1], + values: [1, 0, 0, 3, 0, 0], + }, + ], + }, + ], + clipId: 'Walk', + playOnStart: true, + playing: true, + loop: false, + speed: 1, + time: 0, + }); + + const actors = harness.actors.instantiatePrefab(prefab); + const hip = actors.find((actor) => actor.name === 'Hip'); + + harness.lifecycle.update(16); + await bridge.waitForIdle(); + harness.lifecycle.update(500); + + expect(fetch).toHaveBeenCalledWith( + 'https://cdn.local/clips/walk.anim', + expect.objectContaining({ + headers: expect.objectContaining({ + Range: 'bytes=2-4', + }), + }) + ); + expect(loadedBytes).toEqual([new Uint8Array([30, 40, 50])]); + expect(hip?.requireComponent(Transform).position.x).toBeCloseTo(2, 5); + + bridge.dispose(); + }); + + it('decodes and applies streamed chunk bytes through the default bridge pipeline', async () => { + const harness = createPrefabHarness(); + const bridge = bindAnimationStreamingBridge(harness.world, { + resolver: async () => + encodeAnimationClipStreamingChunkPayload({ + version: 1, + clipId: 'Walk', + startTime: 0, + endTime: 1, + tracks: [ + { + target: 'node/1', + path: 'translation', + times: [0, 1], + values: [1, 0, 0, 3, 0, 0], + }, + ], + }), + }); + const prefab = createAnimatedRigPrefab({ + clips: [ + { + id: 'Walk', + duration: 1, + streaming: { + mode: 'streamed', + sourceUri: 'clips/walk.anim', + chunkDuration: 1, + preloadWindow: 0.25, + }, + tracks: [], + }, + ], + clipId: 'Walk', + playOnStart: true, + playing: true, + loop: false, + speed: 1, + time: 0, + }); + + const actors = harness.actors.instantiatePrefab(prefab); + const hip = actors.find((actor) => actor.name === 'Hip'); + + harness.lifecycle.update(16); + expect(hip?.requireComponent(Transform).position.x).toBeCloseTo(1, 5); + + await bridge.waitForIdle(); + harness.lifecycle.update(500); + + expect(hip?.requireComponent(Transform).position.x).toBeCloseTo(2, 5); + + bridge.dispose(); + }); +}); \ No newline at end of file diff --git a/web/packages/scene-runtime/src/__tests__/render-item-collector.test.ts b/web/packages/scene-runtime/src/__tests__/render-item-collector.test.ts new file mode 100644 index 00000000..548de050 --- /dev/null +++ b/web/packages/scene-runtime/src/__tests__/render-item-collector.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { Vec3 } from '@axrone/numeric'; +import type { Actor } from '@axrone/ecs-runtime'; +import { Transform } from '@axrone/ecs-runtime'; +import { MeshRenderer } from '../components/mesh-renderer'; +import { SceneRenderItemCollector } from '../render-item-collector'; + +const createMockActor = (transform: Transform, renderer: MeshRenderer): Actor => + ({ + active: true, + getComponent(componentType: unknown) { + if (componentType === Transform) { + return transform; + } + if (componentType === MeshRenderer) { + return renderer; + } + return undefined; + }, + } as unknown as Actor); + +describe('SceneRenderItemCollector', () => { + it('draws blended renderers back-to-front after opaque renderers at the same render order', () => { + const opaqueTransform = new Transform(); + opaqueTransform.position = new Vec3(0, 0, 1); + const opaqueRenderer = new MeshRenderer({ + materialId: 'opaque', + renderOrder: 0, + passId: 'main', + }); + + const farBlendTransform = new Transform(); + farBlendTransform.position = new Vec3(0, 0, 8); + const farBlendRenderer = new MeshRenderer({ + materialId: 'blend-far', + renderOrder: 0, + passId: 'main', + }); + + const nearBlendTransform = new Transform(); + nearBlendTransform.position = new Vec3(0, 0, 2); + const nearBlendRenderer = new MeshRenderer({ + materialId: 'blend-near', + renderOrder: 0, + passId: 'main', + }); + + const collector = new SceneRenderItemCollector(); + const renderItems = collector.collect( + [ + createMockActor(nearBlendTransform, nearBlendRenderer), + createMockActor(opaqueTransform, opaqueRenderer), + createMockActor(farBlendTransform, farBlendRenderer), + ], + 'main', + { + cameraPosition: new Vec3(0, 0, 0), + isBlended: (renderer) => renderer.materialId?.startsWith('blend') ?? false, + } + ); + + expect(renderItems.map((item) => item.renderer.materialId)).toEqual([ + 'opaque', + 'blend-far', + 'blend-near', + ]); + }); +}); \ No newline at end of file diff --git a/web/packages/scene-runtime/src/__tests__/scene-prefab-runtime-property-hydration.test.ts b/web/packages/scene-runtime/src/__tests__/scene-prefab-runtime-property-hydration.test.ts new file mode 100644 index 00000000..d33bd9ba --- /dev/null +++ b/web/packages/scene-runtime/src/__tests__/scene-prefab-runtime-property-hydration.test.ts @@ -0,0 +1,158 @@ +import { + Actor, + Component, + Transform, + World, + getComponentPropertyMetadata, + property, + script, +} from '@axrone/ecs-runtime'; +import { Vec3 } from '@axrone/numeric'; +import { describe, expect, it } from 'vitest'; +import { SceneComponentCatalog } from '../component-catalog'; +import { createSceneRegistry } from '../scene-registry'; +import { encodeSceneValue } from '../serialization'; +import { SceneActorRuntime } from '../scene-actor-runtime'; +import type { ScenePrefabDefinition } from '../types'; + +@script({ + scriptName: 'HydratedFollower', +}) +class HydratedFollower extends Component { + @property({ type: Actor }) + public targetActor: Actor | null = null; + + @property({ type: Transform }) + public targetTransform: Transform | null = null; + + @property({ type: 'vec3' }) + public offset = new Vec3(0, 0, 0); + + @property({ type: 'number' }) + public speed = 0; + + @property({ type: 'boolean' }) + public enabledFlag = false; + + @property({ type: 'string' }) + public tintHex = '#ffffff'; +} + +const createPrefabComponent = (type: string, data: unknown) => ({ + type, + data: encodeSceneValue(data), +}); + +const createPrefabHarness = () => { + const registry = createSceneRegistry({ + registry: { + HydratedFollower, + }, + }); + const world = new World(registry); + const actors = new SceneActorRuntime({ + world, + componentCatalog: new SceneComponentCatalog(registry), + }); + + actors.registerComponent(HydratedFollower); + + return { + actors, + world, + }; +}; + +const createHydrationPrefab = (): ScenePrefabDefinition => ({ + id: 'prefab/property-hydration', + actors: [ + { + nodeId: 'node/source', + parentNodeId: null, + name: 'Source', + layer: 0, + tag: 'default', + active: true, + persistent: false, + pooled: false, + components: [ + createPrefabComponent('Transform', { + position: [0, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }), + createPrefabComponent('HydratedFollower', { + scriptPath: 'Scripts/hydrated-follower.ts', + className: 'HydratedFollower', + scriptName: 'HydratedFollower', + executeInEditMode: false, + propertyValues: { + targetActor: { + kind: 'entity', + target: 'node/target', + }, + targetTransform: { + kind: 'entity', + target: 'node/target', + }, + offset: { + x: 1, + y: 2, + z: 3, + }, + speed: '4.5', + enabledFlag: true, + tintHex: '#ff00aa', + }, + }), + ], + }, + { + nodeId: 'node/target', + parentNodeId: null, + name: 'Target', + layer: 0, + tag: 'default', + active: true, + persistent: false, + pooled: false, + components: [ + createPrefabComponent('Transform', { + position: [5, 1, 2], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }), + ], + }, + ], +}); + +describe('ScenePrefabRuntime property hydration', () => { + it('hydrates editor propertyValues into live script instances', () => { + expect(getComponentPropertyMetadata(HydratedFollower).map((entry) => entry.propertyKey)).toEqual([ + 'targetActor', + 'targetTransform', + 'offset', + 'speed', + 'enabledFlag', + 'tintHex', + ]); + + const harness = createPrefabHarness(); + const actors = harness.actors.instantiatePrefab(createHydrationPrefab()); + const sourceActor = actors.find((actor) => actor.name === 'Source'); + const targetActor = actors.find((actor) => actor.name === 'Target'); + const component = sourceActor?.getComponent(HydratedFollower); + + expect(sourceActor).toBeDefined(); + expect(targetActor).toBeDefined(); + expect(component).toBeDefined(); + expect(component?.offset).toBeInstanceOf(Vec3); + expect([component?.offset.x, component?.offset.y, component?.offset.z]).toEqual([1, 2, 3]); + expect(component?.speed).toBe(4.5); + expect(component?.enabledFlag).toBe(true); + expect(component?.tintHex).toBe('#ff00aa'); + expect(component?.targetActor).toBe(targetActor); + expect(component?.targetTransform).toBe(targetActor?.getComponent(Transform)); + }); +}); \ No newline at end of file diff --git a/web/packages/scene-runtime/src/__tests__/scene-prefab-workflow.test.ts b/web/packages/scene-runtime/src/__tests__/scene-prefab-workflow.test.ts new file mode 100644 index 00000000..dc01ff6f --- /dev/null +++ b/web/packages/scene-runtime/src/__tests__/scene-prefab-workflow.test.ts @@ -0,0 +1,382 @@ +import { Hierarchy, Transform, World } from '@axrone/ecs-runtime'; +import { describe, expect, it } from 'vitest'; +import { SceneComponentCatalog } from '../component-catalog'; +import { PrefabNodeBinding } from '../components/prefab-node-binding'; +import { + createScenePrefabScopedNodeId, + createScenePrefabWorkflow, + diffScenePrefabDefinitions, + mergeScenePrefabDefinitions, +} from '../prefab'; +import { createSceneRegistry } from '../scene-registry'; +import { SceneActorRuntime } from '../scene-actor-runtime'; +import { encodeSceneValue } from '../serialization'; +import type { ScenePrefabDefinition } from '../types'; + +const createPrefabHarness = () => { + const registry = createSceneRegistry(); + const world = new World(registry); + const actors = new SceneActorRuntime({ + world, + componentCatalog: new SceneComponentCatalog(registry), + }); + + return { + actors, + world, + }; +}; + +const createPrefabComponent = (type: string, data: unknown, id?: string) => ({ + ...(id ? { id } : {}), + type, + data: encodeSceneValue(data), +}); + +const createTransformComponent = ( + position: readonly [number, number, number], + id?: string, +) => + createPrefabComponent( + 'Transform', + { + position, + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }, + id, + ); + +const createActorSnapshot = ( + nodeId: string, + name: string, + components: readonly ReturnType[], + parentNodeId: string | null = null, +) => ({ + nodeId, + parentNodeId, + name, + layer: 0, + tag: 'default', + active: true, + persistent: false, + pooled: false, + components, +}); + +const getActorPosition = (actor: { getComponent(type: typeof Transform): Transform | null } | undefined) => { + const transform = actor?.getComponent(Transform); + return transform ? [transform.position.x, transform.position.y, transform.position.z] : null; +}; + +describe('ScenePrefabWorkflow', () => { + it('resolves nested prefabs, variants, and live overrides during instantiation', () => { + const weaponPrefab: ScenePrefabDefinition = { + id: 'prefab/weapon', + kind: 'prefab', + actors: [ + createActorSnapshot('weapon-root', 'Weapon', [ + createTransformComponent([1, 0, 0], 'cmp/weapon-transform'), + ]), + createActorSnapshot( + 'muzzle', + 'Muzzle', + [createTransformComponent([0, 0, 1], 'cmp/muzzle-transform')], + 'weapon-root', + ), + ], + }; + + const characterPrefab: ScenePrefabDefinition = { + id: 'prefab/character', + kind: 'prefab', + actors: [ + createActorSnapshot('character-root', 'Character', [ + createTransformComponent([0, 0, 0], 'cmp/character-transform'), + ]), + createActorSnapshot( + 'hand-socket', + 'Hand Socket', + [createTransformComponent([0.5, 1, 0], 'cmp/hand-transform')], + 'character-root', + ), + ], + nested: [ + { + instanceId: 'weapon-slot', + reference: { + kind: 'registry', + prefabId: 'prefab/weapon', + }, + parentNodeId: 'hand-socket', + overrides: [ + { + kind: 'set-component-property', + nodeId: 'weapon-root', + selector: { + kind: 'id', + componentId: 'cmp/weapon-transform', + type: 'Transform', + }, + path: ['position'], + value: encodeSceneValue([2, 0, 0]), + }, + ], + }, + ], + }; + + const eliteVariant: ScenePrefabDefinition = { + id: 'prefab/character-elite', + kind: 'variant', + base: { + kind: 'registry', + prefabId: 'prefab/character', + }, + actors: [], + overrides: [ + { + kind: 'set-actor-field', + nodeId: 'character-root', + field: 'name', + value: 'Elite Character', + }, + { + kind: 'set-component-property', + nodeId: createScenePrefabScopedNodeId('weapon-slot', 'muzzle'), + selector: { + kind: 'id', + componentId: 'cmp/muzzle-transform', + type: 'Transform', + }, + path: ['position'], + value: encodeSceneValue([0, 1, 2]), + }, + ], + }; + + const workflow = createScenePrefabWorkflow({ + prefabs: [weaponPrefab, characterPrefab, eliteVariant], + }); + const harness = createPrefabHarness(); + const actors = harness.actors.instantiatePrefab(eliteVariant, { + prefabResolver: workflow, + liveOverrides: [ + { + kind: 'set-actor-field', + nodeId: createScenePrefabScopedNodeId('weapon-slot', 'weapon-root'), + field: 'name', + value: 'Runtime Weapon', + }, + ], + }); + + const findByNodeId = (nodeId: string) => + actors.find((actor) => actor.getComponent(PrefabNodeBinding)?.nodeId === nodeId); + + const characterRoot = findByNodeId('character-root'); + const handSocket = findByNodeId('hand-socket'); + const weaponRoot = findByNodeId(createScenePrefabScopedNodeId('weapon-slot', 'weapon-root')); + const muzzle = findByNodeId(createScenePrefabScopedNodeId('weapon-slot', 'muzzle')); + + expect(actors).toHaveLength(4); + expect(characterRoot?.name).toBe('Elite Character'); + expect(weaponRoot?.name).toBe('Runtime Weapon'); + expect(weaponRoot?.getComponent(Hierarchy)?.parentActor).toBe(handSocket); + expect(muzzle?.getComponent(Hierarchy)?.parentActor).toBe(weaponRoot); + expect(getActorPosition(weaponRoot)).toEqual([2, 0, 0]); + expect(getActorPosition(muzzle)).toEqual([0, 1, 2]); + }); + + it('produces granular override operations for actor and component deltas', () => { + const base: ScenePrefabDefinition = { + id: 'prefab/base', + kind: 'prefab', + actors: [ + createActorSnapshot('hero', 'Hero', [ + createTransformComponent([0, 0, 0], 'cmp/transform'), + createPrefabComponent( + 'Stats', + { + damage: 10, + flags: { + elite: false, + }, + }, + 'cmp/stats', + ), + ]), + ], + }; + + const target: ScenePrefabDefinition = { + id: 'prefab/target', + kind: 'prefab', + actors: [ + createActorSnapshot('hero', 'Hero Prime', [ + createTransformComponent([1, 2, 3], 'cmp/transform'), + createPrefabComponent( + 'Stats', + { + damage: 12, + flags: { + elite: true, + }, + }, + 'cmp/stats', + ), + ]), + createActorSnapshot( + 'pet', + 'Pet', + [createTransformComponent([0, 1, 0], 'cmp/pet-transform')], + 'hero', + ), + ], + }; + + const diff = diffScenePrefabDefinitions(base, target); + + expect(diff.overrides).toEqual( + expect.arrayContaining([ + { + kind: 'set-actor-field', + nodeId: 'hero', + field: 'name', + value: 'Hero Prime', + }, + { + kind: 'set-component-property', + nodeId: 'hero', + selector: { + kind: 'id', + componentId: 'cmp/transform', + type: 'Transform', + }, + path: ['position'], + value: encodeSceneValue([1, 2, 3]), + }, + { + kind: 'set-component-property', + nodeId: 'hero', + selector: { + kind: 'id', + componentId: 'cmp/stats', + type: 'Stats', + }, + path: ['damage'], + value: encodeSceneValue(12), + }, + { + kind: 'set-component-property', + nodeId: 'hero', + selector: { + kind: 'id', + componentId: 'cmp/stats', + type: 'Stats', + }, + path: ['flags', 'elite'], + value: encodeSceneValue(true), + }, + { + kind: 'add-actor', + actor: expect.objectContaining({ + nodeId: 'pet', + parentNodeId: 'hero', + }), + }, + ]), + ); + }); + + it('merges conflicting override layers with deterministic policies', () => { + const base: ScenePrefabDefinition = { + id: 'prefab/base', + kind: 'prefab', + actors: [ + createActorSnapshot('hero', 'Hero', [ + createPrefabComponent( + 'Stats', + { + damage: 10, + recoil: { + kick: 1, + }, + enabled: true, + }, + 'cmp/stats', + ), + ]), + ], + }; + + const local: ScenePrefabDefinition = { + id: 'prefab/local', + kind: 'prefab', + actors: [ + createActorSnapshot('hero', 'Hero', [ + createPrefabComponent( + 'Stats', + { + damage: 20, + recoil: { + kick: 2, + }, + enabled: true, + }, + 'cmp/stats', + ), + ]), + ], + }; + + const incoming: ScenePrefabDefinition = { + id: 'prefab/incoming', + kind: 'prefab', + actors: [ + createActorSnapshot('hero', 'Hero', [ + createPrefabComponent( + 'Stats', + { + damage: 25, + recoil: { + kick: 1, + }, + enabled: false, + }, + 'cmp/stats', + ), + ]), + ], + }; + + const manualMerge = mergeScenePrefabDefinitions(base, local, incoming); + const preferLocalMerge = mergeScenePrefabDefinitions(base, local, incoming, { + conflictPolicy: 'prefer-local', + }); + + expect(manualMerge.resolved).toBe(false); + expect(manualMerge.conflicts).toHaveLength(1); + expect(manualMerge.definition.actors[0]?.components[0]?.data).toEqual( + encodeSceneValue({ + damage: 10, + recoil: { + kick: 2, + }, + enabled: false, + }), + ); + + expect(preferLocalMerge.resolved).toBe(true); + expect(preferLocalMerge.conflicts).toHaveLength(0); + expect(preferLocalMerge.definition.actors[0]?.components[0]?.data).toEqual( + encodeSceneValue({ + damage: 20, + recoil: { + kick: 2, + }, + enabled: false, + }), + ); + }); +}); \ No newline at end of file diff --git a/web/packages/scene-runtime/src/__tests__/scene-render-runtime-lighting.test.ts b/web/packages/scene-runtime/src/__tests__/scene-render-runtime-lighting.test.ts new file mode 100644 index 00000000..560d81bf --- /dev/null +++ b/web/packages/scene-runtime/src/__tests__/scene-render-runtime-lighting.test.ts @@ -0,0 +1,308 @@ +import { Actor, Transform, World } from '@axrone/ecs-runtime'; +import { Vec3, Vec4 } from '@axrone/numeric'; +import { describe, expect, it, vi } from 'vitest'; +import { Camera } from '../components/camera'; +import { DirectionalLight } from '../components/directional-light'; +import { MeshRenderer } from '../components/mesh-renderer'; +import { PointLight } from '../components/point-light'; +import type { SceneMaterialResource } from '../material-registry'; +import type { SceneMeshResource } from '../mesh-registry'; +import type { SceneRenderPassResource } from '../render-pass-registry'; +import { SceneRenderRuntime } from '../scene-render-runtime'; +import { createSceneRegistry } from '../scene-registry'; +import type { SceneResourceRuntime } from '../scene-resource-runtime'; +import type { SceneShaderResource } from '../shader-registry'; +import type { SceneMeshDefinition } from '../types'; + +const createMockGL = () => { + const uniformWrites = new Map(); + const gl = { + ARRAY_BUFFER: 0x8892, + DYNAMIC_DRAW: 0x88e8, + FLOAT: 0x1406, + FLOAT_VEC3: 0x8b51, + FLOAT_MAT4: 0x8b5c, + INT: 0x1404, + BOOL: 0x8b56, + TRIANGLES: 0x0004, + LINES: 0x0001, + POINTS: 0x0000, + COLOR_BUFFER_BIT: 0x4000, + DEPTH_BUFFER_BIT: 0x0100, + DEPTH_TEST: 0x0b71, + CULL_FACE: 0x0b44, + BLEND: 0x0be2, + STENCIL_TEST: 0x0b90, + BACK: 0x0405, + FRONT: 0x0404, + CCW: 0x0901, + CW: 0x0900, + KEEP: 0x1e00, + ZERO: 0, + REPLACE: 0x1e01, + INVERT: 0x150a, + INCR: 0x1e02, + INCR_WRAP: 0x8507, + DECR: 0x1e03, + DECR_WRAP: 0x8508, + ONE: 1, + SRC_ALPHA: 0x0302, + ONE_MINUS_SRC_ALPHA: 0x0303, + FUNC_ADD: 0x8006, + POLYGON_OFFSET_FILL: 0x8037, + SAMPLE_ALPHA_TO_COVERAGE: 0x809e, + RASTERIZER_DISCARD: 0x8c89, + NONE: 0, + LESS: 0x0201, + TEXTURE0: 0x84c0, + TEXTURE_2D: 0x0de1, + viewport: vi.fn(), + clearColor: vi.fn(), + clearDepth: vi.fn(), + clear: vi.fn(), + enable: vi.fn(), + disable: vi.fn(), + frontFace: vi.fn(), + cullFace: vi.fn(), + blendEquationSeparate: vi.fn(), + blendFuncSeparate: vi.fn(), + blendColor: vi.fn(), + colorMask: vi.fn(), + depthMask: vi.fn(), + depthFunc: vi.fn(), + stencilFuncSeparate: vi.fn(), + stencilMaskSeparate: vi.fn(), + stencilOpSeparate: vi.fn(), + polygonOffset: vi.fn(), + lineWidth: vi.fn(), + useProgram: vi.fn(), + bindVertexArray: vi.fn(), + bindBuffer: vi.fn(), + bufferData: vi.fn(), + bindSampler: vi.fn(), + activeTexture: vi.fn(), + bindTexture: vi.fn(), + uniformMatrix4fv: vi.fn((location: WebGLUniformLocation, _transpose: boolean, value: Float32Array) => { + uniformWrites.set((location as { name: string }).name, Array.from(value)); + }), + uniform3f: vi.fn((location: WebGLUniformLocation, x: number, y: number, z: number) => { + uniformWrites.set((location as { name: string }).name, [x, y, z]); + }), + uniform1f: vi.fn((location: WebGLUniformLocation, value: number) => { + uniformWrites.set((location as { name: string }).name, value); + }), + uniform1i: vi.fn((location: WebGLUniformLocation, value: number) => { + uniformWrites.set((location as { name: string }).name, value); + }), + uniform3fv: vi.fn((location: WebGLUniformLocation, value: Float32Array) => { + uniformWrites.set((location as { name: string }).name, Array.from(value)); + }), + uniform1fv: vi.fn((location: WebGLUniformLocation, value: Float32Array) => { + uniformWrites.set((location as { name: string }).name, Array.from(value)); + }), + uniform1iv: vi.fn((location: WebGLUniformLocation, value: Int32Array) => { + uniformWrites.set((location as { name: string }).name, Array.from(value)); + }), + drawArrays: vi.fn(), + drawElements: vi.fn(), + }; + + return { + gl: gl as unknown as WebGL2RenderingContext, + uniformWrites, + }; +}; + +const createSceneShader = ( + gl: WebGL2RenderingContext, + uniformNames: readonly string[] +): SceneShaderResource => { + const locationEntries = uniformNames.map((name) => [ + name, + { name } as unknown as WebGLUniformLocation, + ] as const); + + return { + id: 'shader', + program: {} as WebGLProgram, + uniformLocations: new Map(locationEntries), + uniformTypes: new Map([ + ['u_ReceiveLighting', gl.BOOL], + ['u_DirectionalLightCount', gl.INT], + ['u_DirectionalLightColor', gl.FLOAT_VEC3], + ['u_DirectionalLightIntensity', gl.FLOAT], + ['u_PointLightCount', gl.INT], + ['u_PointLightPosition', gl.FLOAT_VEC3], + ]), + uniformNames: [...uniformNames], + attributeNames: { position: 'a_Position' }, + depthTest: false, + cull: false, + blend: false, + }; +}; + +describe('SceneRenderRuntime lighting integration', () => { + it('uses the primary camera when ordering local lights through the render path', () => { + const { gl, uniformWrites } = createMockGL(); + const world = new World(createSceneRegistry()); + + const fallbackCameraActor = new Actor(world); + fallbackCameraActor.addComponent(Camera, { primary: false }); + fallbackCameraActor.requireComponent(Transform).position = new Vec3(20, 0, 0); + + const primaryCameraActor = new Actor(world); + primaryCameraActor.addComponent(Camera, { primary: true }); + primaryCameraActor.requireComponent(Transform).position = Vec3.ZERO.clone(); + + const farLightActor = new Actor(world); + farLightActor.addComponent(PointLight, { + color: [1, 0, 0], + intensity: 8, + range: 5, + }); + farLightActor.requireComponent(Transform).position = new Vec3(20, 0, 0); + + const nearLightActor = new Actor(world); + nearLightActor.addComponent(PointLight, { + color: [0, 1, 0], + intensity: 2, + range: 8, + }); + nearLightActor.requireComponent(Transform).position = new Vec3(1, 0, 0); + + const directionalLightActor = new Actor(world); + directionalLightActor.addComponent(DirectionalLight, { + color: [0.9, 0.8, 0.7], + intensity: 3, + primary: true, + }); + directionalLightActor.requireComponent(Transform); + + const rendererActor = new Actor(world); + rendererActor.addComponent(MeshRenderer, { + meshId: 'mesh', + materialId: 'material', + passId: 'main', + receiveLighting: true, + }); + rendererActor.requireComponent(Transform); + + const mesh: SceneMeshResource = { + id: 'mesh', + vertexArray: {} as WebGLVertexArrayObject, + vertexBuffer: {} as WebGLBuffer, + indexBuffer: null, + vertexCount: 3, + indexCount: 0, + indexType: null, + topology: 'triangles', + mode: gl.TRIANGLES, + attributes: new Set(['position']), + }; + const meshDefinition: SceneMeshDefinition = { + id: 'mesh', + vertices: new Float32Array([ + 0, 0, 0, + 1, 0, 0, + 0, 1, 0, + ]), + attributes: [ + { + semantic: 'position', + componentCount: 3, + offset: 0, + stride: 12, + }, + ], + vertexCount: 3, + topology: 'triangles', + }; + const material: SceneMaterialResource = { + id: 'material', + shaderId: 'shader', + uniforms: new Map(), + textureBindings: new Map(), + surface: null, + passes: Object.freeze([]), + }; + const renderPass: SceneRenderPassResource = { + id: 'main', + order: 0, + rendererPassId: 'main', + materialPassId: null, + enabled: true, + clearFlags: [], + clearColor: null, + clearDepth: null, + }; + const shader = createSceneShader(gl, [ + 'u_ReceiveLighting', + 'u_DirectionalLightCount', + 'u_DirectionalLightColor', + 'u_DirectionalLightIntensity', + 'u_PointLightCount', + 'u_PointLightPosition', + ]); + const resources = { + materials: { + get: (id: string) => (id === material.id ? material : undefined), + getTextureSlots: () => Object.freeze([]), + }, + meshes: { + get: (id: string) => (id === mesh.id ? mesh : undefined), + getDefinition: (id: string) => (id === mesh.id ? meshDefinition : undefined), + }, + shaders: { + get: (id: string) => (id === shader.id ? shader : undefined), + }, + textures: { + get: () => undefined, + }, + renderPasses: { + getEnabledResources: () => [renderPass], + }, + resolveSampler: () => ({ + bind: vi.fn(), + nativeHandle: null, + }), + } as unknown as SceneResourceRuntime; + const runtime = new SceneRenderRuntime({ + gl, + resources, + ambientLight: Vec3.ZERO.clone(), + skyLight: Vec3.ZERO.clone(), + groundLight: Vec3.ZERO.clone(), + defaultClearColor: new Vec4(0, 0, 0, 1), + getActors: () => world.getAllActors(), + createMeshResource: vi.fn(() => mesh), + disposeMesh: vi.fn(), + applyMissingVertexAttributeDefaults: vi.fn(), + }); + + runtime.render({ + frame: 1, + elapsedSeconds: 1, + deltaSeconds: 1 / 60, + viewportWidth: 640, + viewportHeight: 360, + }); + + const directionalLightColor = uniformWrites.get('u_DirectionalLightColor') as number[]; + + expect(uniformWrites.get('u_ReceiveLighting')).toBe(1); + expect(uniformWrites.get('u_DirectionalLightCount')).toBe(1); + expect(directionalLightColor[0]).toBeCloseTo(0.9); + expect(directionalLightColor[1]).toBeCloseTo(0.8); + expect(directionalLightColor[2]).toBeCloseTo(0.7); + expect(uniformWrites.get('u_DirectionalLightIntensity')).toEqual([3]); + expect(uniformWrites.get('u_PointLightCount')).toBe(2); + expect((uniformWrites.get('u_PointLightPosition') as number[]).slice(0, 6)).toEqual([ + 1, 0, 0, + 20, 0, 0, + ]); + expect((gl.drawArrays as unknown as { mock: { calls: unknown[] } }).mock.calls).toHaveLength(1); + expect(runtime.stats.drawCalls).toBe(1); + expect(runtime.stats.trianglesSubmitted).toBe(1); + }); +}); \ No newline at end of file diff --git a/web/packages/scene-runtime/src/__tests__/scene-texture-factory.test.ts b/web/packages/scene-runtime/src/__tests__/scene-texture-factory.test.ts new file mode 100644 index 00000000..e5c380c4 --- /dev/null +++ b/web/packages/scene-runtime/src/__tests__/scene-texture-factory.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ColorSpace, TextureFormat } from '@axrone/render-webgl2'; +import { SceneTextureFactory } from '../scene-texture-factory'; + +describe('scene texture factory', () => { + it('forwards the declared texture color space to the texture manager', async () => { + const createTexture = vi.fn().mockReturnValue({ + width: 1, + height: 1, + mipLevels: 1, + isCompressed: false, + generateMipmaps: vi.fn(), + setData: vi.fn(), + nativeHandle: {}, + }); + const factory = new SceneTextureFactory({ + textureManager: { + createTexture, + } as any, + }); + + await factory.create({ + id: 'texture/albedo', + format: TextureFormat.RGBA8, + colorSpace: ColorSpace.SRGB, + source: { + kind: 'data', + width: 1, + height: 1, + channels: 4, + data: [255, 255, 255, 255], + }, + }); + + expect(createTexture).toHaveBeenCalledWith( + expect.objectContaining({ + colorSpace: ColorSpace.SRGB, + }), + expect.any(Uint8Array), + ); + }); +}); \ No newline at end of file diff --git a/web/packages/scene-runtime/src/__tests__/shader-effect.test.ts b/web/packages/scene-runtime/src/__tests__/shader-effect.test.ts new file mode 100644 index 00000000..9c54dfe1 --- /dev/null +++ b/web/packages/scene-runtime/src/__tests__/shader-effect.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { createSceneShaderDefinitionFromEffect } from '../shader-effect'; +import type { RenderShaderEffectDefinition } from '@axrone/render-core'; + +const createEffect = (): RenderShaderEffectDefinition => ({ + format: 'axrone.shader/effect', + version: 1, + id: 'scene/effect-test', + attributes: [{ name: 'a_Position', type: 'vec3', location: 0 }], + properties: [ + { + name: 'u_Model', + type: 'mat4', + stages: ['vertex'], + scope: 'object', + }, + { + name: 'u_Color', + type: 'vec4', + stages: ['fragment'], + scope: 'material', + inspector: { control: 'color' }, + }, + ], + vertex: { + main: ['gl_Position = u_Model * vec4(a_Position, 1.0);'], + }, + fragment: { + precision: 'highp', + outputs: [{ name: 'o_Color', type: 'vec4' }], + main: ['o_Color = u_Color;'], + }, + renderState: { + depthTest: false, + cull: false, + blend: true, + }, +}); + +describe('scene shader effect helper', () => { + it('creates scene shader definitions from structured effect data', () => { + const definition = createSceneShaderDefinitionFromEffect(createEffect(), { + id: 'scene/effect-runtime', + attributes: { + position: 'a_Position', + }, + }); + + expect(definition.id).toBe('scene/effect-runtime'); + expect(definition.effect?.id).toBe('scene/effect-runtime'); + expect(definition.uniforms).toEqual(['u_Model', 'u_Color']); + expect(definition.vertexSource).toContain('uniform mat4 u_Model;'); + expect(definition.fragmentSource).toContain('uniform vec4 u_Color;'); + expect(definition.attributes?.position).toBe('a_Position'); + expect(definition.depthTest).toBe(false); + expect(definition.cull).toBe(false); + expect(definition.blend).toBe(true); + }); +}); \ No newline at end of file diff --git a/web/packages/scene-runtime/src/animation-streaming-bridge.ts b/web/packages/scene-runtime/src/animation-streaming-bridge.ts new file mode 100644 index 00000000..21fcb752 --- /dev/null +++ b/web/packages/scene-runtime/src/animation-streaming-bridge.ts @@ -0,0 +1,488 @@ +import type { AnimationClipStreamingRequest } from '@axrone/animation'; +import type { Actor, Entity } from '@axrone/ecs-runtime'; +import { isRecord } from '@axrone/utility'; +import { Animator } from './components/animator'; + +export interface AnimationStreamingBridgeWorld { + readonly on?: (event: string, handler: (data: Record) => void) => () => void; + readonly emitSync?: (event: string, data: Record) => boolean; + readonly getActor?: (entity: Entity) => Actor | undefined; + readonly getAllActors?: () => readonly Actor[]; +} + +export interface AnimationStreamingRequestEvent extends AnimationClipStreamingRequest { + readonly entity?: Entity; + readonly actorId?: string; +} + +export interface AnimationStreamingChunkResolveResult { + readonly bytes: Uint8Array | ArrayBuffer | ArrayBufferView; + readonly mimeType?: string; +} + +export interface ResolvedAnimationStreamingChunk { + readonly actor?: Actor; + readonly actorId?: string; + readonly animator: Animator; + readonly entity: Entity; + readonly request: AnimationStreamingRequestEvent; + readonly bytes: Uint8Array; + readonly mimeType?: string; +} + +export interface FailedAnimationStreamingChunk { + readonly actor?: Actor; + readonly actorId?: string; + readonly animator?: Animator; + readonly entity?: Entity; + readonly request: AnimationStreamingRequestEvent; + readonly error: Error; +} + +export interface AnimationStreamingResolveContext { + readonly world: AnimationStreamingBridgeWorld; + readonly actor?: Actor; + readonly animator: Animator; + readonly entity: Entity; + readonly request: AnimationStreamingRequestEvent; + readonly signal: AbortSignal; +} + +export type AnimationStreamingChunkResolver = ( + request: Readonly, + context: Readonly +) => + | AnimationStreamingChunkResolveResult + | Uint8Array + | ArrayBuffer + | ArrayBufferView + | Promise< + | AnimationStreamingChunkResolveResult + | Uint8Array + | ArrayBuffer + | ArrayBufferView + | undefined + > + | undefined; + +interface AnimationStreamingFetchResponse { + readonly ok: boolean; + readonly status: number; + readonly statusText?: string; + readonly headers?: { + get(name: string): string | null; + }; + arrayBuffer(): Promise; +} + +interface AnimationStreamingFetchInit { + readonly headers?: Record; + readonly signal?: AbortSignal; +} + +type AnimationStreamingFetch = ( + input: string, + init?: AnimationStreamingFetchInit +) => Promise; + +export interface FetchAnimationStreamingResolverOptions { + readonly fetch?: AnimationStreamingFetch; + readonly headers?: + | Record + | ((request: Readonly) => Record | undefined); +} + +export interface AnimationStreamingBridgeOptions { + readonly resolver?: AnimationStreamingChunkResolver; + readonly applyToAnimator?: boolean; + readonly onChunkLoaded?: ( + chunk: Readonly + ) => void | Promise; + readonly onChunkFailed?: ( + failure: Readonly + ) => void | Promise; +} + +const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const isAbortError = (error: unknown): boolean => + error instanceof Error && error.name === 'AbortError'; + +const toUint8Array = ( + value: Uint8Array | ArrayBuffer | ArrayBufferView +): Uint8Array => { + if (value instanceof Uint8Array) { + return new Uint8Array(value); + } + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)); + } + return new Uint8Array(value.slice(0)); +}; + +const toError = (error: unknown): Error => + error instanceof Error ? error : new Error(String(error)); + +const buildRequestKey = (request: Readonly): string => + `${String(request.entity ?? '')}:${request.clipId}:${request.chunkId}`; + +const getGlobalFetch = (): AnimationStreamingFetch | undefined => { + const candidate = globalThis as { fetch?: AnimationStreamingFetch }; + return typeof candidate.fetch === 'function' ? candidate.fetch.bind(globalThis) : undefined; +}; + +const cloneRequest = (request: AnimationStreamingRequestEvent): AnimationStreamingRequestEvent => + Object.freeze({ + ...(request.entity !== undefined ? { entity: request.entity } : {}), + ...(typeof request.actorId === 'string' ? { actorId: request.actorId } : {}), + clipId: request.clipId, + chunkId: request.chunkId, + uri: request.uri, + startTime: request.startTime, + endTime: request.endTime, + reason: request.reason, + priority: request.priority, + weight: request.weight, + ...(typeof request.mimeType === 'string' ? { mimeType: request.mimeType } : {}), + ...(typeof request.byteOffset === 'number' ? { byteOffset: request.byteOffset } : {}), + ...(typeof request.byteLength === 'number' ? { byteLength: request.byteLength } : {}), + }); + +const parseStreamingRequestEvent = (value: unknown): AnimationStreamingRequestEvent | null => { + if (!isRecord(value)) { + return null; + } + if ( + typeof value.clipId !== 'string' || + value.clipId.length === 0 || + typeof value.chunkId !== 'string' || + value.chunkId.length === 0 || + typeof value.uri !== 'string' || + value.uri.length === 0 || + !isFiniteNumber(value.startTime) || + !isFiniteNumber(value.endTime) || + (value.reason !== 'active' && value.reason !== 'preload') + ) { + return null; + } + + return cloneRequest({ + clipId: value.clipId, + chunkId: value.chunkId, + uri: value.uri, + startTime: value.startTime, + endTime: value.endTime, + reason: value.reason, + priority: isFiniteNumber(value.priority) ? Math.trunc(value.priority) : 0, + weight: isFiniteNumber(value.weight) ? value.weight : 0, + ...(typeof value.mimeType === 'string' ? { mimeType: value.mimeType } : {}), + ...(isFiniteNumber(value.byteOffset) ? { byteOffset: Math.max(0, Math.trunc(value.byteOffset)) } : {}), + ...(isFiniteNumber(value.byteLength) ? { byteLength: Math.max(0, Math.trunc(value.byteLength)) } : {}), + ...(value.entity !== undefined ? { entity: value.entity as Entity } : {}), + ...(typeof value.actorId === 'string' ? { actorId: value.actorId } : {}), + }); +}; + +const normalizeResolvedChunk = ( + value: + | AnimationStreamingChunkResolveResult + | Uint8Array + | ArrayBuffer + | ArrayBufferView, + request: Readonly +): { readonly bytes: Uint8Array; readonly mimeType?: string } => { + if (isRecord(value) && 'bytes' in value) { + const bytes = (value as AnimationStreamingChunkResolveResult).bytes; + return Object.freeze({ + bytes: toUint8Array(bytes), + ...(typeof (value as AnimationStreamingChunkResolveResult).mimeType === 'string' + ? { mimeType: (value as AnimationStreamingChunkResolveResult).mimeType } + : typeof request.mimeType === 'string' + ? { mimeType: request.mimeType } + : {}), + }); + } + + return Object.freeze({ + bytes: toUint8Array(value as Uint8Array | ArrayBuffer | ArrayBufferView), + ...(typeof request.mimeType === 'string' ? { mimeType: request.mimeType } : {}), + }); +}; + +export const createFetchAnimationStreamingResolver = ( + options: FetchAnimationStreamingResolverOptions = {} +): AnimationStreamingChunkResolver => { + const fetchImpl = options.fetch ?? getGlobalFetch(); + + return async (request, context) => { + if (!fetchImpl) { + throw new Error('Animation streaming fetch resolver requires a fetch implementation'); + } + + const headers = { + ...(typeof options.headers === 'function' ? options.headers(request) : options.headers), + } as Record; + const hasByteRange = + typeof request.byteOffset === 'number' && + Number.isFinite(request.byteOffset) && + request.byteOffset >= 0 && + typeof request.byteLength === 'number' && + Number.isFinite(request.byteLength) && + request.byteLength > 0; + + if (hasByteRange) { + const end = request.byteOffset! + request.byteLength! - 1; + headers.Range = `bytes=${request.byteOffset}-${end}`; + } + + const response = await fetchImpl(request.uri, { + headers, + signal: context.signal, + }); + + if (!response.ok) { + throw new Error( + `Animation streaming fetch failed for '${request.uri}' with status ${response.status}${response.statusText ? ` ${response.statusText}` : ''}` + ); + } + + let bytes = new Uint8Array(await response.arrayBuffer()); + if (hasByteRange && response.status !== 206) { + const start = request.byteOffset!; + const end = Math.min(bytes.byteLength, start + request.byteLength!); + bytes = bytes.slice(start, end); + } + + return Object.freeze({ + bytes, + mimeType: response.headers?.get('content-type') ?? request.mimeType, + }); + }; +}; + +export class AnimationStreamingBridge { + private readonly _resolver: AnimationStreamingChunkResolver; + private _unsubscribe: (() => void) | null = null; + private readonly _inFlight = new Map(); + private readonly _inFlightTasks = new Set>(); + private readonly _idleResolvers = new Set<() => void>(); + private _disposed = false; + + constructor( + private readonly _world: AnimationStreamingBridgeWorld, + private readonly _options: AnimationStreamingBridgeOptions = {} + ) { + this._resolver = _options.resolver ?? createFetchAnimationStreamingResolver(); + } + + get inFlightRequestCount(): number { + return this._inFlight.size; + } + + start(): this { + if (this._disposed) { + throw new Error('AnimationStreamingBridge has been disposed'); + } + if (this._unsubscribe) { + return this; + } + if (typeof this._world.on !== 'function') { + throw new Error('AnimationStreamingBridge requires a world with an on(event, handler) API'); + } + + this._unsubscribe = this._world.on('animation:streaming-request', (payload) => { + this._queueRequest(payload); + }); + return this; + } + + async waitForIdle(): Promise { + if (this._inFlightTasks.size === 0) { + return; + } + await new Promise((resolve) => { + this._idleResolvers.add(resolve); + }); + } + + dispose(): void { + if (this._disposed) { + return; + } + this._disposed = true; + this._unsubscribe?.(); + this._unsubscribe = null; + + for (const controller of this._inFlight.values()) { + controller.abort(); + } + this._inFlight.clear(); + this._resolveIdleWaiters(); + } + + private _queueRequest(payload: unknown): void { + const request = parseStreamingRequestEvent(payload); + if (!request) { + return; + } + const key = buildRequestKey(request); + if (this._inFlight.has(key)) { + return; + } + + const controller = new AbortController(); + this._inFlight.set(key, controller); + + const task = Promise.resolve() + .then(async () => { + const actor = this._resolveActor(request); + const animator = actor?.getComponent(Animator); + const entity = actor?.entity ?? request.entity; + + if (!animator || entity === undefined) { + const error = new Error( + `Animation streaming request '${request.chunkId}' could not resolve an Animator instance` + ); + await this._handleFailure(request, error, actor, animator, entity); + return; + } + + try { + const resolved = await this._resolver( + request, + Object.freeze({ + world: this._world, + actor, + animator, + entity, + request, + signal: controller.signal, + }) + ); + + if (controller.signal.aborted || this._disposed) { + return; + } + + if (!resolved) { + throw new Error( + `Animation streaming resolver returned no data for '${request.uri}'` + ); + } + + const chunk = normalizeResolvedChunk(resolved, request); + const payload = Object.freeze({ + actor, + animator, + entity, + request, + bytes: chunk.bytes, + ...(typeof request.actorId === 'string' + ? { actorId: request.actorId } + : actor + ? { actorId: String(actor.id) } + : {}), + ...(typeof chunk.mimeType === 'string' ? { mimeType: chunk.mimeType } : {}), + } satisfies ResolvedAnimationStreamingChunk); + + const applyToAnimator = + this._options.applyToAnimator ?? this._options.onChunkLoaded === undefined; + if (applyToAnimator) { + animator.applyStreamingChunkBytes(request.clipId, payload.bytes, { + startTime: request.startTime, + endTime: request.endTime, + }); + } + + await this._options.onChunkLoaded?.(payload); + if (controller.signal.aborted || this._disposed) { + return; + } + + this._world.emitSync?.('animation:streaming-loaded', { + actorId: payload.actorId, + entity, + clipId: request.clipId, + chunkId: request.chunkId, + uri: request.uri, + byteLength: payload.bytes.byteLength, + ...(payload.mimeType ? { mimeType: payload.mimeType } : {}), + }); + animator.markStreamingChunkLoaded(request.clipId, request.chunkId); + } catch (error) { + if (controller.signal.aborted || isAbortError(error) || this._disposed) { + return; + } + await this._handleFailure(request, toError(error), actor, animator, entity); + } + }) + .finally(() => { + this._inFlight.delete(key); + this._inFlightTasks.delete(task); + this._resolveIdleWaiters(); + }); + + this._inFlightTasks.add(task); + } + + private _resolveActor(request: Readonly): Actor | undefined { + if (request.entity !== undefined) { + const actor = this._world.getActor?.(request.entity); + if (actor) { + return actor; + } + } + if (typeof request.actorId === 'string') { + return this._world.getAllActors?.().find((actor) => String(actor.id) === request.actorId); + } + return undefined; + } + + private async _handleFailure( + request: Readonly, + error: Error, + actor: Actor | undefined, + animator: Animator | undefined, + entity: Entity | undefined + ): Promise { + const failure = Object.freeze({ + actor, + ...(typeof request.actorId === 'string' ? { actorId: request.actorId } : {}), + ...(animator ? { animator } : {}), + ...(entity !== undefined ? { entity } : {}), + request, + error, + } satisfies FailedAnimationStreamingChunk); + + await this._options.onChunkFailed?.(failure); + if (this._disposed) { + return; + } + + this._world.emitSync?.('animation:streaming-failed', { + actorId: request.actorId, + entity, + clipId: request.clipId, + chunkId: request.chunkId, + uri: request.uri, + error: error.message, + }); + animator?.markStreamingChunkFailed(request.clipId, request.chunkId, error.message); + } + + private _resolveIdleWaiters(): void { + if (this._inFlightTasks.size !== 0) { + return; + } + for (const resolve of this._idleResolvers) { + resolve(); + } + this._idleResolvers.clear(); + } +} + +export const bindAnimationStreamingBridge = ( + world: AnimationStreamingBridgeWorld, + options: AnimationStreamingBridgeOptions = {} +): AnimationStreamingBridge => new AnimationStreamingBridge(world, options).start(); \ No newline at end of file diff --git a/web/packages/scene-runtime/src/camera-frame-state.ts b/web/packages/scene-runtime/src/camera-frame-state.ts index b4905745..9ff14150 100644 --- a/web/packages/scene-runtime/src/camera-frame-state.ts +++ b/web/packages/scene-runtime/src/camera-frame-state.ts @@ -1,6 +1,6 @@ import { Mat4, Quat, Vec3 } from '@axrone/numeric'; import { Transform } from '@axrone/ecs-runtime'; -import { Camera } from './components/camera'; +import { Camera, resolveCameraVerticalFieldOfViewRadians } from './components/camera'; interface MutableSceneCameraFrameState { camera: Camera; @@ -86,7 +86,11 @@ export class SceneCameraFrameStateCollector { ); } else { Mat4.perspective( - (camera.fieldOfView * Math.PI) / 180, + resolveCameraVerticalFieldOfViewRadians( + camera.fieldOfView, + camera.fieldOfViewAxis, + aspectRatio + ), aspectRatio, camera.near, camera.far, diff --git a/web/packages/scene-runtime/src/components/animator.ts b/web/packages/scene-runtime/src/components/animator.ts index 4263f80d..765ca9da 100644 --- a/web/packages/scene-runtime/src/components/animator.ts +++ b/web/packages/scene-runtime/src/components/animator.ts @@ -1,4 +1,28 @@ +import { + AnimationController, + applyAnimationClipStreamingChunkDefinition, + decodeAnimationClipStreamingChunkPayload, + type AnimationClipDefinition, + type AnimationControllerEvent, + type AnimationClipStreamingChunkApplicationOptions, + type AnimationClipStreamingChunkPayload, + type AnimationStreamingSnapshot, + type AnimationClipEventDefinition, + type AnimationClipStreamingDefinition, + type AnimationClipStreamingRequest, + type AnimationFrame, + type AnimationFootContactDefinition, + type AnimationLayerDefinition, + type AnimationMotionFeatureDefinition, + type AnimationParameterDefinition, + type AnimationClipCompressionDefinition, + AnimationClipStreamingScheduler, + type AnimationRootMotionDefinition, + type AnimationRootMotionDelta, + type AnimationTrackDefinition, +} from '@axrone/animation'; import { Quat, Vec3 } from '@axrone/numeric'; +import { cloneSerializable, isRecord } from '@axrone/utility'; import { Transform } from '@axrone/ecs-runtime'; import { Component } from '@axrone/ecs-runtime'; import { script } from '@axrone/ecs-runtime'; @@ -6,7 +30,8 @@ import { MeshRenderer } from './mesh-renderer'; import { PrefabNodeBinding } from './prefab-node-binding'; export interface AnimatorTrackConfig { - readonly targetNodeId: string; + readonly targetNodeId?: string; + readonly target?: string; readonly path: 'translation' | 'rotation' | 'scale' | 'weights'; readonly interpolation?: 'LINEAR' | 'STEP' | 'CUBICSPLINE'; readonly keyframeCount?: number; @@ -20,81 +45,112 @@ export interface AnimatorClipConfig { readonly id: string; readonly duration?: number; readonly tracks: readonly AnimatorTrackConfig[]; + readonly events?: readonly AnimationClipEventDefinition[]; + readonly footContacts?: readonly AnimationFootContactDefinition[]; + readonly tags?: readonly string[]; + readonly features?: readonly AnimationMotionFeatureDefinition[]; + readonly compression?: AnimationClipCompressionDefinition; + readonly streaming?: AnimationClipStreamingDefinition; } +export type AnimatorUpdateMode = 'Normal' | 'Animate Physics' | 'Unscaled Time'; +export type AnimatorCullingMode = 'Always Animate' | 'Cull Update Transforms' | 'Cull Completely'; + +const ANIMATOR_UPDATE_MODES = new Set([ + 'Normal', + 'Animate Physics', + 'Unscaled Time', +]); + +const ANIMATOR_CULLING_MODES = new Set([ + 'Always Animate', + 'Cull Update Transforms', + 'Cull Completely', +]); + +const normalizeAnimatorUpdateMode = (value: unknown): AnimatorUpdateMode => + typeof value === 'string' && ANIMATOR_UPDATE_MODES.has(value as AnimatorUpdateMode) + ? (value as AnimatorUpdateMode) + : 'Normal'; + +const normalizeAnimatorCullingMode = (value: unknown): AnimatorCullingMode => + typeof value === 'string' && ANIMATOR_CULLING_MODES.has(value as AnimatorCullingMode) + ? (value as AnimatorCullingMode) + : 'Cull Update Transforms'; + export interface AnimatorConfig { readonly clips?: readonly AnimatorClipConfig[]; + readonly parameters?: readonly AnimationParameterDefinition[]; + readonly layers?: readonly AnimationLayerDefinition[]; + readonly rootMotion?: AnimationRootMotionDefinition | null; readonly clipId?: string | null; readonly playOnStart?: boolean; readonly playing?: boolean; readonly loop?: boolean; readonly speed?: number; readonly time?: number; -} - -interface AnimatorTrackState { - readonly targetNodeId: string; - readonly path: AnimatorTrackConfig['path']; - readonly interpolation: NonNullable; - readonly keyframeCount: number; - readonly valueComponentCount: number; - readonly sampleStride: number; - readonly times: Float32Array; - readonly values: Float32Array; -} - -interface AnimatorClipState { - readonly id: string; - readonly duration: number; - readonly tracks: readonly AnimatorTrackState[]; + readonly applyRootMotion?: boolean; + readonly updateMode?: AnimatorUpdateMode; + readonly cullingMode?: AnimatorCullingMode; } interface AnimatorResolvedTarget { readonly transform?: Transform; readonly meshRenderer?: MeshRenderer; + readonly parentNodeId?: string | null; } -const toFloat32Array = (value: readonly number[] | Float32Array): Float32Array => - value instanceof Float32Array ? new Float32Array(value) : new Float32Array(value); - -const wrapTime = (time: number, duration: number): number => { - if (duration <= 0) { - return 0; - } - - const wrapped = time % duration; - return wrapped < 0 ? wrapped + duration : wrapped; +type AnimatorEvaluationMode = 'apply' | 'update-only' | 'skip'; + +const toTrackDefinition = (track: AnimatorTrackConfig): AnimationTrackDefinition => { + const target = + typeof track.targetNodeId === 'string' + ? track.targetNodeId + : typeof track.target === 'string' + ? track.target + : ''; + return Object.freeze({ + target, + path: track.path, + interpolation: track.interpolation, + keyframeCount: track.keyframeCount, + valueComponentCount: track.valueComponentCount, + sampleStride: track.sampleStride, + times: track.times instanceof Float32Array ? new Float32Array(track.times) : [...track.times], + values: + track.values instanceof Float32Array ? new Float32Array(track.values) : [...track.values], + }); }; -const findFrameIndex = (times: Float32Array, time: number): number => { - if (times.length <= 1 || time <= times[0]!) { - return 0; - } - - const lastIndex = times.length - 1; - if (time >= times[lastIndex]!) { - return Math.max(0, lastIndex - 1); - } - - let low = 0; - let high = lastIndex; - while (low <= high) { - const mid = (low + high) >> 1; - const start = times[mid]!; - const end = times[mid + 1] ?? Number.POSITIVE_INFINITY; - if (time < start) { - high = mid - 1; - continue; - } - if (time >= end) { - low = mid + 1; - continue; - } - return mid; - } - - return Math.max(0, Math.min(lastIndex - 1, low)); -}; +const normalizeClipDefinitions = ( + clips: readonly AnimatorClipConfig[] | undefined +): readonly AnimationClipDefinition[] => + Object.freeze( + (clips ?? []) + .filter((clip): clip is AnimatorClipConfig => + Boolean(clip && typeof clip.id === 'string' && Array.isArray(clip.tracks)) + ) + .map((clip) => + Object.freeze({ + id: clip.id, + duration: clip.duration, + ...(Array.isArray(clip.events) ? { events: cloneSerializable(clip.events) } : {}), + ...(Array.isArray(clip.footContacts) + ? { footContacts: cloneSerializable(clip.footContacts) } + : {}), + ...(Array.isArray(clip.tags) ? { tags: [...clip.tags] } : {}), + ...(Array.isArray(clip.features) ? { features: cloneSerializable(clip.features) } : {}), + ...(clip.compression ? { compression: cloneSerializable(clip.compression) } : {}), + ...(clip.streaming ? { streaming: cloneSerializable(clip.streaming) } : {}), + tracks: Object.freeze( + clip.tracks + .map((track) => toTrackDefinition(track)) + .filter((track) => typeof track.target === 'string' && track.target.length > 0) + ), + } satisfies AnimationClipDefinition) + ) + .filter((clip) => clip.tracks.length > 0 || clip.streaming?.mode === 'streamed') + ); @script({ scriptName: 'Animator', @@ -103,16 +159,26 @@ const findFrameIndex = (times: Float32Array, time: number): number => { singleton: false, }) export class Animator extends Component { - private readonly _clips = new Map(); - private readonly _clipOrder: string[] = []; + private _clipDefinitions: readonly AnimationClipDefinition[] = Object.freeze([]); + private _parameterDefinitions: readonly AnimationParameterDefinition[] = Object.freeze([]); + private _layerDefinitions: readonly AnimationLayerDefinition[] | null = null; + private _rootMotion: AnimationRootMotionDefinition | null = null; private readonly _resolvedTargets = new Map(); private _resolvedInstanceId: string | null = null; + private _controller: AnimationController | null = null; + private _streamingScheduler: AnimationClipStreamingScheduler | null = null; + private _streamingSnapshot: AnimationStreamingSnapshot | null = null; + private _pendingStreamingRequests: readonly AnimationClipStreamingRequest[] = Object.freeze([]); + private _controllerDirty = true; private _currentClipId: string | null = null; private _playOnStart: boolean; private _playing: boolean; private _loop: boolean; private _speed: number; private _time: number; + private _applyRootMotionEnabled: boolean; + private _updateMode: AnimatorUpdateMode; + private _cullingMode: AnimatorCullingMode; private readonly _tempVec3 = new Vec3(); private readonly _tempQuat = new Quat(); @@ -121,10 +187,12 @@ export class Animator extends Component { this._playOnStart = config.playOnStart ?? true; this._playing = config.playing ?? false; this._loop = config.loop ?? true; - this._speed = config.speed ?? 1; - this._time = config.time ?? 0; - this._setClips(config.clips ?? []); - this._currentClipId = config.clipId ?? this._clipOrder[0] ?? null; + this._speed = Number.isFinite(config.speed ?? 1) ? config.speed ?? 1 : 1; + this._time = Number.isFinite(config.time ?? 0) ? Math.max(0, config.time ?? 0) : 0; + this._applyRootMotionEnabled = config.applyRootMotion ?? true; + this._updateMode = normalizeAnimatorUpdateMode(config.updateMode); + this._cullingMode = normalizeAnimatorCullingMode(config.cullingMode); + this._applyConfig(config); } get clipId(): string | null { @@ -132,7 +200,20 @@ export class Animator extends Component { } set clipId(value: string | null) { - this._currentClipId = value && this._clips.has(value) ? value : this._clipOrder[0] ?? null; + const fallback = this._clipDefinitions[0]?.id ?? null; + this._currentClipId = + typeof value === 'string' && this._clipDefinitions.some((clip) => clip.id === value) + ? value + : fallback; + if (this._controller && this._currentClipId) { + try { + this._controller.play(this._currentClipId); + const streaming = this._syncStreamingState(this._controller); + if (!this._isStreamingBlocked(streaming)) { + this._applyFrame(this._controller.currentFrame); + } + } catch {} + } } get playing(): boolean { @@ -148,7 +229,12 @@ export class Animator extends Component { } set loop(value: boolean) { - this._loop = value; + if (this._loop !== value) { + this._loop = value; + if (this._layerDefinitions === null) { + this._controllerDirty = true; + } + } } get speed(): number { @@ -164,18 +250,77 @@ export class Animator extends Component { } set time(value: number) { - this._time = Number.isFinite(value) ? value : 0; - this._applyCurrentClip(); + this._time = Number.isFinite(value) ? Math.max(0, value) : 0; + const controller = this._ensureController(); + if (controller) { + controller.seek(this._time); + const streaming = this._syncStreamingState(controller); + if (!this._isStreamingBlocked(streaming)) { + this._applyFrame(controller.currentFrame); + } + } } - play(clipId: string | null = this._currentClipId ?? this._clipOrder[0] ?? null): this { - if (!clipId || !this._clips.has(clipId)) { - return this; + get applyRootMotion(): boolean { + return this._applyRootMotionEnabled; + } + + set applyRootMotion(value: boolean) { + if (this._applyRootMotionEnabled === value) { + return; } + this._applyRootMotionEnabled = value; + this._controllerDirty = true; + this._controller = null; + this._streamingScheduler = null; + this._streamingSnapshot = null; + this._pendingStreamingRequests = Object.freeze([]); + } + + get updateMode(): AnimatorUpdateMode { + return this._updateMode; + } + + set updateMode(value: AnimatorUpdateMode) { + this._updateMode = normalizeAnimatorUpdateMode(value); + } + + get cullingMode(): AnimatorCullingMode { + return this._cullingMode; + } + + set cullingMode(value: AnimatorCullingMode) { + this._cullingMode = normalizeAnimatorCullingMode(value); + } + + get streaming(): AnimationStreamingSnapshot | null { + return this._streamingSnapshot; + } + + get pendingStreamingRequests(): readonly AnimationClipStreamingRequest[] { + return this._pendingStreamingRequests; + } + play(clipId: string | null = this._currentClipId ?? this._clipDefinitions[0]?.id ?? null): this { + if (!clipId) { + return this; + } + const controller = this._ensureController(); + if (!controller) { + return this; + } this._currentClipId = clipId; this._playing = true; - this._applyCurrentClip(); + try { + controller.play(clipId); + } catch {} + if (this._time > 0) { + controller.seek(this._time); + } + const streaming = this._syncStreamingState(controller); + if (!this._isStreamingBlocked(streaming)) { + this._applyFrame(controller.currentFrame); + } return this; } @@ -188,384 +333,859 @@ export class Animator extends Component { this._playing = false; if (resetTime) { this._time = 0; - this._applyCurrentClip(); + const controller = this._ensureController(); + if (controller) { + if (this._currentClipId) { + try { + controller.play(this._currentClipId); + } catch {} + } + controller.seek(0); + const streaming = this._syncStreamingState(controller); + if (!this._isStreamingBlocked(streaming)) { + this._applyFrame(controller.currentFrame); + } + } } return this; } seek(time: number): this { - const clip = this._getCurrentClip(); - if (!clip) { - this._time = 0; + this.time = time; + return this; + } + + markStreamingChunkRequested(clipId: string, chunkIdOrUri: string): this { + if (this._streamingScheduler?.markChunkRequested(clipId, chunkIdOrUri)) { + this._syncStreamingState(this._controller); + } + return this; + } + + markStreamingChunkLoaded(clipId: string, chunkIdOrUri: string): this { + if (this._streamingScheduler?.markChunkLoaded(clipId, chunkIdOrUri)) { + const streaming = this._syncStreamingState(this._controller); + if (this._controller && !this._isStreamingBlocked(streaming)) { + this._applyFrame(this._controller.evaluate()); + } + } + return this; + } + + applyStreamingChunkBytes( + clipId: string, + bytes: string | Uint8Array | ArrayBuffer | ArrayBufferView, + options: AnimationClipStreamingChunkApplicationOptions = {} + ): this { + return this.applyStreamingChunkPayload( + clipId, + decodeAnimationClipStreamingChunkPayload(bytes), + options + ); + } + + applyStreamingChunkPayload( + clipId: string, + payload: AnimationClipStreamingChunkPayload, + options: AnimationClipStreamingChunkApplicationOptions = {} + ): this { + const clipIndex = this._clipDefinitions.findIndex((clip) => clip.id === clipId); + if (clipIndex < 0) { return this; } - this._time = this._loop ? wrapTime(time, clip.duration) : Math.max(0, Math.min(time, clip.duration)); - this._applyCurrentClip(); + const appliedDefinition = applyAnimationClipStreamingChunkDefinition( + this._clipDefinitions[clipIndex]!, + payload, + { + clipId, + ...options, + } + ); + const definitions = [...this._clipDefinitions]; + definitions[clipIndex] = appliedDefinition; + this._clipDefinitions = Object.freeze(definitions); + + const runtimeClip = this._controller?.clips.get(clipId); + if (runtimeClip) { + runtimeClip.applyStreamingChunk(payload, { + clipId, + ...options, + }); + } return this; } - override start(): void { - if (!this._currentClipId && this._clipOrder.length > 0) { - this._currentClipId = this._clipOrder[0]!; + markStreamingChunkFailed(clipId: string, chunkIdOrUri: string, error?: string): this { + if (this._streamingScheduler?.markChunkFailed(clipId, chunkIdOrUri, error)) { + this._syncStreamingState(this._controller); } + return this; + } - if (this._playOnStart && this._currentClipId) { - this._playing = true; + resetStreaming(clipId?: string): this { + this._streamingScheduler?.reset(clipId); + this._streamingSnapshot = null; + this._pendingStreamingRequests = Object.freeze([]); + return this; + } + + setFloat(name: string, value: number): this { + const controller = this._ensureController(); + if (controller) { + controller.parameters.setFloat(name, value); + const frame = controller.evaluate(); + const streaming = this._syncStreamingState(controller); + if (!this._isStreamingBlocked(streaming)) { + this._applyFrame(frame); + } } + return this; + } - if (this._currentClipId) { - this._applyCurrentClip(); + setInt(name: string, value: number): this { + const controller = this._ensureController(); + if (controller) { + controller.parameters.setInt(name, value); + const frame = controller.evaluate(); + const streaming = this._syncStreamingState(controller); + if (!this._isStreamingBlocked(streaming)) { + this._applyFrame(frame); + } } + return this; } - override update(deltaTime: number): void { - if (!this._playing) { - return; + setBool(name: string, value: boolean): this { + const controller = this._ensureController(); + if (controller) { + controller.parameters.setBool(name, value); + const frame = controller.evaluate(); + const streaming = this._syncStreamingState(controller); + if (!this._isStreamingBlocked(streaming)) { + this._applyFrame(frame); + } + } + return this; + } + + setTrigger(name: string): this { + const controller = this._ensureController(); + if (controller) { + controller.parameters.setTrigger(name); + } + return this; + } + + crossFade(stateId: string, durationSeconds: number): this { + const controller = this._ensureController(); + if (controller) { + this._currentClipId = stateId; + controller.crossFade(stateId, durationSeconds); + const streaming = this._syncStreamingState(controller); + if (!this._isStreamingBlocked(streaming)) { + this._applyFrame(controller.currentFrame); + } } + return this; + } - const clip = this._getCurrentClip(); - if (!clip) { + override start(): void { + if (!this._currentClipId && this._clipDefinitions.length > 0) { + this._currentClipId = this._clipDefinitions[0]!.id; + } + if (this._playOnStart && this._currentClipId) { + this._playing = true; + } + const controller = this._ensureController(); + if (!controller) { return; } + if (this._currentClipId) { + try { + controller.play(this._currentClipId); + } catch {} + } + if (this._time > 0) { + controller.seek(this._time); + } + const streaming = this._syncStreamingState(controller); + if (!this._isStreamingBlocked(streaming)) { + this._applyFrame(controller.currentFrame); + } + } - if (clip.duration <= 0) { - this._time = 0; - this._applyCurrentClip(); + override update(deltaTime: number): void { + if (this._updateMode === 'Animate Physics') { return; } - const deltaSeconds = (deltaTime / 1000) * this._speed; - const nextTime = this._time + deltaSeconds; - if (this._loop) { - this._time = wrapTime(nextTime, clip.duration); - } else { - const clamped = Math.max(0, Math.min(nextTime, clip.duration)); - this._time = clamped; - if (clamped !== nextTime) { - this._playing = false; - } + this._stepAnimation(deltaTime); + } + + override fixedUpdate(deltaTime: number): void { + if (this._updateMode !== 'Animate Physics') { + return; } - this._applyCurrentClip(); + this._stepAnimation(deltaTime); } override serialize(): Record { return { - clips: this._clipOrder - .map((clipId) => this._clips.get(clipId)) - .filter((clip): clip is AnimatorClipState => Boolean(clip)) - .map((clip) => ({ - id: clip.id, - duration: clip.duration, - tracks: clip.tracks.map((track) => ({ - targetNodeId: track.targetNodeId, - path: track.path, - interpolation: track.interpolation, - keyframeCount: track.keyframeCount, - valueComponentCount: track.valueComponentCount, - sampleStride: track.sampleStride, - times: track.times, - values: track.values, - })), + clips: this._clipDefinitions.map((clip) => ({ + id: clip.id, + duration: clip.duration, + ...(clip.events ? { events: cloneSerializable(clip.events) } : {}), + ...(clip.footContacts ? { footContacts: cloneSerializable(clip.footContacts) } : {}), + ...(clip.tags ? { tags: [...clip.tags] } : {}), + ...(clip.features ? { features: cloneSerializable(clip.features) } : {}), + ...(clip.compression ? { compression: cloneSerializable(clip.compression) } : {}), + ...(clip.streaming ? { streaming: cloneSerializable(clip.streaming) } : {}), + tracks: clip.tracks.map((track) => ({ + targetNodeId: track.target, + path: track.path, + interpolation: track.interpolation, + keyframeCount: track.keyframeCount, + valueComponentCount: track.valueComponentCount, + sampleStride: track.sampleStride, + times: cloneSerializable(track.times), + values: cloneSerializable(track.values), })), + })), + parameters: cloneSerializable(this._parameterDefinitions), + layers: this._layerDefinitions ? cloneSerializable(this._layerDefinitions) : null, + rootMotion: this._rootMotion ? cloneSerializable(this._rootMotion) : null, clipId: this._currentClipId, playOnStart: this._playOnStart, playing: this._playing, loop: this._loop, speed: this._speed, time: this._time, + applyRootMotion: this._applyRootMotionEnabled, + updateMode: this._updateMode, + cullingMode: this._cullingMode, + }; + } + + override getDebugInfo(): Record { + const controller = this._ensureController(); + return { + clipId: this._currentClipId, + playing: this._playing, + loop: this._loop, + speed: this._speed, + time: this._time, + applyRootMotion: this._applyRootMotionEnabled, + updateMode: this._updateMode, + cullingMode: this._cullingMode, + profile: controller?.profile ?? null, + pendingEvents: controller?.events ?? [], + activeClips: controller?.activeClips ?? [], + streaming: this._streamingSnapshot, + pendingStreamingRequests: this._pendingStreamingRequests, }; } override deserialize(data: Record): void { - this._setClips(Array.isArray(data.clips) ? data.clips : []); + this._applyConfig({ + clips: Array.isArray(data.clips) ? data.clips : [], + parameters: Array.isArray(data.parameters) ? data.parameters : [], + layers: Array.isArray(data.layers) ? data.layers : undefined, + rootMotion: isRecord(data.rootMotion) && typeof data.rootMotion.bone === 'string' + ? (data.rootMotion as unknown as AnimationRootMotionDefinition) + : data.rootMotion === null + ? null + : undefined, + clipId: typeof data.clipId === 'string' || data.clipId === null ? data.clipId : undefined, + playOnStart: typeof data.playOnStart === 'boolean' ? data.playOnStart : undefined, + playing: typeof data.playing === 'boolean' ? data.playing : undefined, + loop: typeof data.loop === 'boolean' ? data.loop : undefined, + speed: typeof data.speed === 'number' ? data.speed : undefined, + time: typeof data.time === 'number' ? data.time : undefined, + applyRootMotion: typeof data.applyRootMotion === 'boolean' ? data.applyRootMotion : undefined, + updateMode: typeof data.updateMode === 'string' ? normalizeAnimatorUpdateMode(data.updateMode) : undefined, + cullingMode: + typeof data.cullingMode === 'string' + ? normalizeAnimatorCullingMode(data.cullingMode) + : undefined, + }); + } + + private _applyConfig(config: AnimatorConfig): void { + this._clipDefinitions = normalizeClipDefinitions(config.clips); + this._parameterDefinitions = Object.freeze( + (config.parameters ?? []).filter( + (entry): entry is AnimationParameterDefinition => + Boolean(entry && typeof entry.name === 'string' && typeof entry.kind === 'string') + ) + ); + this._layerDefinitions = Array.isArray(config.layers) + ? (Object.freeze(config.layers.map((layer) => cloneSerializable(layer))) as readonly AnimationLayerDefinition[]) + : null; + this._rootMotion = config.rootMotion ?? null; + this._playOnStart = config.playOnStart ?? this._playOnStart; + this._playing = config.playing ?? this._playing; + this._loop = config.loop ?? this._loop; + this._speed = Number.isFinite(config.speed ?? this._speed) ? config.speed ?? this._speed : 1; + this._time = Number.isFinite(config.time ?? this._time) ? Math.max(0, config.time ?? this._time) : 0; + this._applyRootMotionEnabled = config.applyRootMotion ?? this._applyRootMotionEnabled; + this._updateMode = + config.updateMode !== undefined + ? normalizeAnimatorUpdateMode(config.updateMode) + : this._updateMode; + this._cullingMode = + config.cullingMode !== undefined + ? normalizeAnimatorCullingMode(config.cullingMode) + : this._cullingMode; + const fallbackClipId = this._clipDefinitions[0]?.id ?? null; this._currentClipId = - typeof data.clipId === 'string' && this._clips.has(data.clipId) - ? data.clipId - : this._clipOrder[0] ?? null; - if (typeof data.playOnStart === 'boolean') { - this._playOnStart = data.playOnStart; + typeof config.clipId === 'string' && this._clipDefinitions.some((clip) => clip.id === config.clipId) + ? config.clipId + : config.clipId === null + ? null + : this._currentClipId ?? fallbackClipId; + if (!this._currentClipId && fallbackClipId) { + this._currentClipId = fallbackClipId; } - if (typeof data.playing === 'boolean') { - this._playing = data.playing; + this._controllerDirty = true; + this._controller = null; + this._streamingScheduler = null; + this._streamingSnapshot = null; + this._pendingStreamingRequests = Object.freeze([]); + this._resolvedTargets.clear(); + this._resolvedInstanceId = null; + } + + private _ensureController(): AnimationController | null { + const instanceId = this.actor?.getComponent(PrefabNodeBinding)?.instanceId ?? null; + if (this._controller && !this._controllerDirty && this._resolvedInstanceId === instanceId) { + return this._controller; } - if (typeof data.loop === 'boolean') { - this._loop = data.loop; + + this._rebuildTargetMap(instanceId); + if (this._clipDefinitions.length === 0 || this._resolvedTargets.size === 0) { + this._controller = null; + this._controllerDirty = this._clipDefinitions.length > 0; + return null; } - if (typeof data.speed === 'number' && Number.isFinite(data.speed)) { - this._speed = data.speed; + + if ( + this._getRequiredRigTargetNodeIds().some( + (targetNodeId) => !this._resolvedTargets.has(targetNodeId) + ) + ) { + this._controller = null; + this._controllerDirty = true; + return null; } - if (typeof data.time === 'number' && Number.isFinite(data.time)) { - this._time = data.time; + + const bones = [...this._resolvedTargets.entries()] + .filter(([, target]) => Boolean(target.transform)) + .map(([nodeId, target]) => ({ + name: nodeId, + parent: target.parentNodeId ?? null, + translation: [ + target.transform!.position.x, + target.transform!.position.y, + target.transform!.position.z, + ] as const, + rotation: [ + target.transform!.rotation.x, + target.transform!.rotation.y, + target.transform!.rotation.z, + target.transform!.rotation.w, + ] as const, + scale: [ + target.transform!.scale.x, + target.transform!.scale.y, + target.transform!.scale.z, + ] as const, + })); + + if (bones.length === 0) { + this._controller = null; + this._controllerDirty = false; + return null; } - this._resolvedTargets.clear(); - this._resolvedInstanceId = null; + + const layers = this._layerDefinitions ?? this._buildDefaultLayers(); + this._controller = new AnimationController({ + rig: { bones }, + clips: this._clipDefinitions, + parameters: this._parameterDefinitions, + layers, + rootMotion: this._applyRootMotionEnabled ? this._rootMotion : null, + }); + if (this._currentClipId) { + try { + this._controller.play(this._currentClipId); + } catch {} + } + if (this._time > 0) { + this._controller.seek(this._time); + } + this._streamingScheduler = new AnimationClipStreamingScheduler(this._controller.clips); + this._streamingSnapshot = null; + this._pendingStreamingRequests = Object.freeze([]); + this._controllerDirty = false; + const streaming = this._syncStreamingState(this._controller); + if (!this._isStreamingBlocked(streaming)) { + this._applyFrame(this._controller.currentFrame); + } + return this._controller; } - private _setClips(clips: readonly AnimatorClipConfig[]): void { - this._clips.clear(); - this._clipOrder.length = 0; + private _syncStreamingState(controller: AnimationController | null): AnimationStreamingSnapshot | null { + if (!controller || !this._streamingScheduler) { + this._streamingSnapshot = null; + this._pendingStreamingRequests = Object.freeze([]); + return null; + } + const snapshot = this._streamingScheduler.update(controller.activeClips); + this._streamingSnapshot = snapshot; + this._pendingStreamingRequests = this._collectPendingStreamingRequests(snapshot); + if (snapshot.pendingRequests.length > 0) { + this._emitStreamingRequests(snapshot.pendingRequests); + } + return snapshot; + } - for (const clip of clips) { - if (!clip || typeof clip.id !== 'string' || clip.id.length === 0) { - continue; - } + private _collectPendingStreamingRequests( + snapshot: AnimationStreamingSnapshot + ): readonly AnimationClipStreamingRequest[] { + return Object.freeze( + snapshot.clips.flatMap((clip) => + clip.chunks + .filter((chunk) => chunk.status === 'requested') + .map((chunk) => + Object.freeze({ + clipId: chunk.clipId, + chunkId: chunk.chunkId, + uri: chunk.uri, + startTime: chunk.startTime, + endTime: chunk.endTime, + reason: chunk.lastRequestReason ?? (chunk.active ? 'active' : 'preload'), + priority: clip.priority, + weight: chunk.weight, + ...(chunk.mimeType ? { mimeType: chunk.mimeType } : {}), + ...(typeof chunk.byteOffset === 'number' ? { byteOffset: chunk.byteOffset } : {}), + ...(typeof chunk.byteLength === 'number' ? { byteLength: chunk.byteLength } : {}), + } satisfies AnimationClipStreamingRequest) + ) + ) + ); + } - const tracks = (clip.tracks ?? []) - .map((track) => this._normalizeTrack(track)) - .filter((track): track is AnimatorTrackState => Boolean(track)); - const duration = - typeof clip.duration === 'number' && Number.isFinite(clip.duration) - ? clip.duration - : tracks.reduce( - (maxDuration, track) => - Math.max(maxDuration, track.times[track.times.length - 1] ?? 0), - 0 - ); - const normalized: AnimatorClipState = { - id: clip.id, - duration, - tracks: Object.freeze(tracks), - }; + private _isStreamingBlocked(snapshot: AnimationStreamingSnapshot | null): boolean { + if (!snapshot) { + return false; + } + return snapshot.clips.some((clip) => clip.activeWeight > 0 && clip.ready === false); + } + + private _buildDefaultLayers(): readonly AnimationLayerDefinition[] { + const entryState = this._currentClipId ?? this._clipDefinitions[0]!.id; + return Object.freeze([ + Object.freeze({ + id: 'base', + weight: 1, + mode: 'override', + stateMachine: { + entryState, + states: Object.freeze( + this._clipDefinitions.map((clip) => + Object.freeze({ + id: clip.id, + motion: Object.freeze({ + kind: 'clip', + clipId: clip.id, + }), + loop: this._loop, + }) + ) + ), + }, + } satisfies AnimationLayerDefinition), + ]); + } + + private _stepAnimation(deltaTime: number): void { + const controller = this._ensureController(); + if (!controller || !this._playing) { + return; + } + + const evaluationMode = this._resolveEvaluationMode(); + if (evaluationMode === 'skip') { + return; + } + + const streaming = this._syncStreamingState(controller); + if (this._isStreamingBlocked(streaming)) { + return; + } - this._clips.set(clip.id, normalized); - this._clipOrder.push(clip.id); + const deltaSeconds = Math.max(0, deltaTime / 1000) * this._speed; + const result = controller.update(deltaSeconds); + this._time += deltaSeconds; + + if (evaluationMode === 'apply') { + this._applyFrame(result.frame); + if (this._applyRootMotionEnabled) { + this._applyRootMotion(result.rootMotion); + } } + + this._emitAnimationEvents(result.events); + this._syncStreamingState(controller); } - private _normalizeTrack(track: AnimatorTrackConfig): AnimatorTrackState | undefined { - if (!track || typeof track.targetNodeId !== 'string') { - return undefined; + private _resolveEvaluationMode(): AnimatorEvaluationMode { + if (this._cullingMode === 'Always Animate') { + return 'apply'; } - const times = toFloat32Array(track.times ?? []); - const values = toFloat32Array(track.values ?? []); - const keyframeCount = track.keyframeCount ?? times.length; - if (keyframeCount <= 0 || times.length === 0) { - return undefined; + if (this._hasVisibleRendererInHierarchy()) { + return 'apply'; } - const sampleStride = - track.sampleStride ?? - (keyframeCount > 0 ? values.length / keyframeCount : track.valueComponentCount ?? 0); - const valueComponentCount = - track.valueComponentCount ?? - (track.interpolation === 'CUBICSPLINE' ? sampleStride / 3 : sampleStride); - if ( - !Number.isFinite(sampleStride) || - !Number.isFinite(valueComponentCount) || - sampleStride <= 0 || - valueComponentCount <= 0 || - Math.floor(sampleStride) !== sampleStride || - Math.floor(valueComponentCount) !== valueComponentCount - ) { - return undefined; + return this._cullingMode === 'Cull Completely' ? 'skip' : 'update-only'; + } + + private _hasVisibleRendererInHierarchy(): boolean { + const rootActor = this.actor; + if (!rootActor) { + return true; } - return { - targetNodeId: track.targetNodeId, - path: track.path, - interpolation: track.interpolation ?? 'LINEAR', - keyframeCount, - valueComponentCount, - sampleStride, - times, - values, - }; + const stack = [rootActor]; + let hasRenderer = false; + + while (stack.length > 0) { + const actor = stack.pop(); + if (!actor) { + continue; + } + + const meshRenderer = actor.getComponent(MeshRenderer) as MeshRenderer | undefined; + if (meshRenderer) { + hasRenderer = true; + if (meshRenderer.visible) { + return true; + } + } + + for (let index = 0; index < actor.children.length; index += 1) { + stack.push(actor.children[index]!); + } + } + + return hasRenderer === false; } - private _getCurrentClip(): AnimatorClipState | undefined { - return this._currentClipId ? this._clips.get(this._currentClipId) : undefined; + private _emitAnimationEvents(events: readonly AnimationControllerEvent[]): void { + if (events.length === 0) { + return; + } + const world = this.world as + | { + emitSync?: (event: string, data: Record) => boolean; + } + | undefined; + for (let index = 0; index < events.length; index += 1) { + const event = events[index]!; + world?.emitSync?.('animation:notify', { + actorId: this.actor?.id, + entity: this.entity, + clipId: event.clipId, + layerId: event.layerId, + stateId: event.stateId, + name: event.name, + time: event.time, + normalizedTime: event.normalizedTime, + motionWeight: event.motionWeight, + layerWeight: event.layerWeight, + ...(event.id ? { id: event.id } : {}), + ...(event.payload !== undefined ? { payload: event.payload } : {}), + ...(event.tags ? { tags: event.tags } : {}), + }); + } } - private _applyCurrentClip(): void { - const clip = this._getCurrentClip(); - if (!clip) { + private _emitStreamingRequests(requests: readonly AnimationClipStreamingRequest[]): void { + if (requests.length === 0) { return; } + const world = this.world as + | { + emitSync?: (event: string, data: Record) => boolean; + } + | undefined; + for (let index = 0; index < requests.length; index += 1) { + const request = requests[index]!; + world?.emitSync?.('animation:streaming-request', { + actorId: this.actor?.id, + entity: this.entity, + clipId: request.clipId, + chunkId: request.chunkId, + uri: request.uri, + reason: request.reason, + priority: request.priority, + weight: request.weight, + startTime: request.startTime, + endTime: request.endTime, + ...(request.mimeType ? { mimeType: request.mimeType } : {}), + ...(typeof request.byteOffset === 'number' ? { byteOffset: request.byteOffset } : {}), + ...(typeof request.byteLength === 'number' ? { byteLength: request.byteLength } : {}), + }); + } + } - const instanceId = this.actor?.getComponent(PrefabNodeBinding)?.instanceId ?? null; - const requiredTargetCount = new Set(clip.tracks.map((track) => track.targetNodeId)).size; - if ( - this._resolvedInstanceId !== instanceId || - this._resolvedTargets.size < requiredTargetCount - ) { - this._rebuildTargetMap(instanceId); + private _getRequiredTargetNodeIds(): readonly string[] { + const nodeIds = new Set(); + + for (const clip of this._clipDefinitions) { + for (const track of clip.tracks) { + if (typeof track.target === 'string' && track.target.length > 0) { + nodeIds.add(track.target); + } + } } - for (const track of clip.tracks) { - const target = this._resolvedTargets.get(track.targetNodeId); - if (!target) { - continue; + if (typeof this._rootMotion?.bone === 'string' && this._rootMotion.bone.length > 0) { + nodeIds.add(this._rootMotion.bone); + } + + for (const layer of this._layerDefinitions ?? []) { + for (const boneName of layer.boneMask ?? []) { + if (typeof boneName === 'string' && boneName.length > 0) { + nodeIds.add(boneName); + } } - switch (track.path) { - case 'translation': - if (!target.transform) { - break; - } - this._sampleVec3(track, this._time, this._tempVec3); - target.transform.position = this._tempVec3; - break; - case 'scale': - if (!target.transform) { - break; - } - this._sampleVec3(track, this._time, this._tempVec3); - target.transform.scale = this._tempVec3; - break; - case 'rotation': - if (!target.transform) { - break; - } - this._sampleQuat(track, this._time, this._tempQuat); - target.transform.rotation = this._tempQuat; - break; - case 'weights': - if (!target.meshRenderer) { - break; + for (const ikLayer of layer.ikLayers ?? []) { + for (const job of ikLayer.jobs ?? []) { + nodeIds.add(job.rootBone); + nodeIds.add(job.tipBone); + if (typeof job.targetBone === 'string' && job.targetBone.length > 0) { + nodeIds.add(job.targetBone); } - target.meshRenderer.setMorphWeights( - this._sampleComponents(track, this._time, track.valueComponentCount) - ); - break; + } } } + + return Object.freeze([...nodeIds]); } - private _rebuildTargetMap(instanceId: string | null): void { - this._resolvedTargets.clear(); - this._resolvedInstanceId = instanceId; - const actors = (this.world as { getAllActors?: () => readonly { getComponent: (type: any) => any }[] } | undefined)?.getAllActors?.() ?? []; + private _getRequiredRigTargetNodeIds(): readonly string[] { + const nodeIds = new Set(); + + for (const clip of this._clipDefinitions) { + for (const track of clip.tracks) { + if ( + track.path !== 'weights' && + typeof track.target === 'string' && + track.target.length > 0 + ) { + nodeIds.add(track.target); + } + } + } - for (const actor of actors) { - const binding = actor.getComponent(PrefabNodeBinding) as PrefabNodeBinding | undefined; - if (!binding || binding.nodeId === null) { - continue; + if (typeof this._rootMotion?.bone === 'string' && this._rootMotion.bone.length > 0) { + nodeIds.add(this._rootMotion.bone); + } + + for (const layer of this._layerDefinitions ?? []) { + for (const boneName of layer.boneMask ?? []) { + if (typeof boneName === 'string' && boneName.length > 0) { + nodeIds.add(boneName); + } } - if (instanceId && binding.instanceId !== instanceId) { - continue; + + for (const ikLayer of layer.ikLayers ?? []) { + for (const job of ikLayer.jobs ?? []) { + nodeIds.add(job.rootBone); + nodeIds.add(job.tipBone); + if (typeof job.targetBone === 'string' && job.targetBone.length > 0) { + nodeIds.add(job.targetBone); + } + } } + } - const transform = actor.getComponent(Transform) as Transform | undefined; - const meshRenderer = actor.getComponent(MeshRenderer) as MeshRenderer | undefined; - if (transform || meshRenderer) { - this._resolvedTargets.set( - binding.nodeId, + return Object.freeze([...nodeIds]); + } + + private _rebuildTargetMap(instanceId: string | null): void { + this._resolvedTargets.clear(); + this._resolvedInstanceId = instanceId; + type TargetActor = { + parent?: { getComponent: (type: unknown) => unknown } | null; + children: readonly TargetActor[]; + getComponent: (type: unknown) => unknown; + }; + const collectTargets = ( + actors: readonly TargetActor[], + ): Array<{ + readonly actor: { + parent?: { getComponent: (type: unknown) => unknown } | null; + children: readonly unknown[]; + getComponent: (type: unknown) => unknown; + }; + readonly nodeId: string; + readonly binding: PrefabNodeBinding; + readonly transform?: Transform; + readonly meshRenderer?: MeshRenderer; + }> => { + const targets: Array<{ + readonly actor: TargetActor; + readonly nodeId: string; + readonly binding: PrefabNodeBinding; + readonly transform?: Transform; + readonly meshRenderer?: MeshRenderer; + }> = []; + const stack = [...actors]; + + while (stack.length > 0) { + const actor = stack.pop()!; + for (let childIndex = 0; childIndex < actor.children.length; childIndex += 1) { + stack.push(actor.children[childIndex]!); + } + + const binding = actor.getComponent(PrefabNodeBinding) as PrefabNodeBinding | undefined; + if (!binding || binding.nodeId === null) { + continue; + } + if (instanceId && binding.instanceId !== instanceId) { + continue; + } + const transform = actor.getComponent(Transform) as Transform | undefined; + const meshRenderer = actor.getComponent(MeshRenderer) as MeshRenderer | undefined; + if (!transform && !meshRenderer) { + continue; + } + targets.push( Object.freeze({ + actor, + nodeId: binding.nodeId, + binding, ...(transform ? { transform } : {}), ...(meshRenderer ? { meshRenderer } : {}), }) ); } - } - } - private _sampleVec3(track: AnimatorTrackState, time: number, out: Vec3): void { - const sampled = this._sampleComponents(track, time, 3); - out.x = sampled[0]; - out.y = sampled[1]; - out.z = sampled[2]; + return targets; + }; + const rootActor = this.actor as TargetActor | undefined; + const allActors = + (this.world as { getAllActors?: () => readonly TargetActor[] } | undefined) + ?.getAllActors?.() ?? []; + const rootTargets = rootActor ? collectTargets([rootActor]) : []; + const rootTargetNodeIds = new Set(rootTargets.map((target) => target.nodeId)); + const requiresInstanceFallback = + Boolean(instanceId) && + rootTargets.length > 0 && + this._getRequiredTargetNodeIds().some((targetNodeId) => !rootTargetNodeIds.has(targetNodeId)); + const resolvedTargets = + requiresInstanceFallback || rootTargets.length === 0 + ? collectTargets(allActors) + : rootTargets; + + const resolvedNodeIds = new Set( + resolvedTargets.map((entry) => entry.binding.nodeId).filter((nodeId): nodeId is string => Boolean(nodeId)) + ); + + for (let targetIndex = 0; targetIndex < resolvedTargets.length; targetIndex += 1) { + const target = resolvedTargets[targetIndex]!; + const parentBinding = target.actor.parent?.getComponent(PrefabNodeBinding) as + | PrefabNodeBinding + | undefined; + const parentNodeId = + parentBinding && + resolvedNodeIds.has(parentBinding.nodeId ?? '') && + (!instanceId || parentBinding.instanceId === instanceId) + ? parentBinding.nodeId + : null; + this._resolvedTargets.set( + target.nodeId, + Object.freeze({ + ...(target.transform ? { transform: target.transform } : {}), + ...(target.meshRenderer ? { meshRenderer: target.meshRenderer } : {}), + parentNodeId, + }) + ); + } } - private _sampleQuat(track: AnimatorTrackState, time: number, out: Quat): void { - const frameIndex = findFrameIndex(track.times, time); - const nextIndex = Math.min(track.keyframeCount - 1, frameIndex + 1); - const startTime = track.times[frameIndex] ?? 0; - const endTime = track.times[nextIndex] ?? startTime; - const duration = Math.max(endTime - startTime, 0); - const alpha = duration > 0 ? (time - startTime) / duration : 0; - - if (track.interpolation === 'STEP' || frameIndex === nextIndex) { - const offset = frameIndex * track.sampleStride; - out.x = track.values[offset] ?? 0; - out.y = track.values[offset + 1] ?? 0; - out.z = track.values[offset + 2] ?? 0; - out.w = track.values[offset + 3] ?? 1; + private _applyFrame(frame: AnimationFrame): void { + const controller = this._controller; + if (!controller) { return; } - if (track.interpolation === 'CUBICSPLINE') { - const sampled = this._sampleComponents(track, time, 4); - out.x = sampled[0]; - out.y = sampled[1]; - out.z = sampled[2]; - out.w = sampled[3]; - } else { - const leftOffset = frameIndex * track.sampleStride; - const rightOffset = nextIndex * track.sampleStride; - const left = { - x: track.values[leftOffset] ?? 0, - y: track.values[leftOffset + 1] ?? 0, - z: track.values[leftOffset + 2] ?? 0, - w: track.values[leftOffset + 3] ?? 1, - }; - const right = { - x: track.values[rightOffset] ?? 0, - y: track.values[rightOffset + 1] ?? 0, - z: track.values[rightOffset + 2] ?? 0, - w: track.values[rightOffset + 3] ?? 1, - }; - Quat.slerp(left, right, alpha, out); + for (let boneIndex = 0; boneIndex < controller.rig.boneCount; boneIndex += 1) { + const target = this._resolvedTargets.get(controller.rig.boneNames[boneIndex]!); + if (!target?.transform) { + continue; + } + const translationOffset = boneIndex * 3; + const rotationOffset = boneIndex * 4; + this._tempVec3.x = frame.pose.translations[translationOffset]!; + this._tempVec3.y = frame.pose.translations[translationOffset + 1]!; + this._tempVec3.z = frame.pose.translations[translationOffset + 2]!; + target.transform.position = this._tempVec3; + this._tempQuat.x = frame.pose.rotations[rotationOffset]!; + this._tempQuat.y = frame.pose.rotations[rotationOffset + 1]!; + this._tempQuat.z = frame.pose.rotations[rotationOffset + 2]!; + this._tempQuat.w = frame.pose.rotations[rotationOffset + 3]!; + target.transform.rotation = this._tempQuat; + this._tempVec3.x = frame.pose.scales[translationOffset]!; + this._tempVec3.y = frame.pose.scales[translationOffset + 1]!; + this._tempVec3.z = frame.pose.scales[translationOffset + 2]!; + target.transform.scale = this._tempVec3; } - Quat.normalize(out, out); - } - - private _sampleComponents(track: AnimatorTrackState, time: number, componentCount: number): Float32Array { - const sampled = new Float32Array(componentCount); - const frameIndex = findFrameIndex(track.times, time); - const nextIndex = Math.min(track.keyframeCount - 1, frameIndex + 1); - const startTime = track.times[frameIndex] ?? 0; - const endTime = track.times[nextIndex] ?? startTime; - const duration = Math.max(endTime - startTime, 0); - const alpha = duration > 0 ? (time - startTime) / duration : 0; - - if (track.interpolation === 'STEP' || frameIndex === nextIndex) { - const baseOffset = frameIndex * track.sampleStride + (track.interpolation === 'CUBICSPLINE' ? track.valueComponentCount : 0); - for (let component = 0; component < componentCount; component += 1) { - sampled[component] = track.values[baseOffset + component] ?? (component === 3 ? 1 : 0); - } - return sampled; - } - - if (track.interpolation === 'CUBICSPLINE') { - const leftBase = frameIndex * track.sampleStride; - const rightBase = nextIndex * track.sampleStride; - const s = Math.max(0, Math.min(alpha, 1)); - const s2 = s * s; - const s3 = s2 * s; - const h00 = 2 * s3 - 3 * s2 + 1; - const h10 = s3 - 2 * s2 + s; - const h01 = -2 * s3 + 3 * s2; - const h11 = s3 - s2; - - for (let component = 0; component < componentCount; component += 1) { - const inTangent = track.values[rightBase + component] ?? 0; - const value0 = track.values[leftBase + track.valueComponentCount + component] ?? 0; - const outTangent = - track.values[leftBase + track.valueComponentCount * 2 + component] ?? 0; - const value1 = - track.values[rightBase + track.valueComponentCount + component] ?? 0; - sampled[component] = - h00 * value0 + - h10 * duration * outTangent + - h01 * value1 + - h11 * duration * inTangent; + for (let bindingIndex = 0; bindingIndex < controller.curveLayout.bindings.length; bindingIndex += 1) { + const binding = controller.curveLayout.bindings[bindingIndex]!; + const target = this._resolvedTargets.get(binding.id); + if (!target?.meshRenderer) { + continue; } - - return sampled; + target.meshRenderer.setMorphWeights( + frame.curves.values.subarray(binding.offset, binding.offset + binding.componentCount) + ); } + } - const leftOffset = frameIndex * track.sampleStride; - const rightOffset = nextIndex * track.sampleStride; - for (let component = 0; component < componentCount; component += 1) { - const left = track.values[leftOffset + component] ?? 0; - const right = track.values[rightOffset + component] ?? left; - sampled[component] = left + (right - left) * Math.max(0, Math.min(alpha, 1)); + private _applyRootMotion(rootMotion: AnimationRootMotionDelta): void { + const transform = this.transform as Transform | undefined; + if (!transform) { + return; + } + if ( + rootMotion.translation[0] !== 0 || + rootMotion.translation[1] !== 0 || + rootMotion.translation[2] !== 0 + ) { + this._tempVec3.x = rootMotion.translation[0]; + this._tempVec3.y = rootMotion.translation[1]; + this._tempVec3.z = rootMotion.translation[2]; + transform.translate(this._tempVec3, 'local'); + } + if ( + rootMotion.rotation[0] !== 0 || + rootMotion.rotation[1] !== 0 || + rootMotion.rotation[2] !== 0 || + rootMotion.rotation[3] !== 1 + ) { + this._tempQuat.x = rootMotion.rotation[0]; + this._tempQuat.y = rootMotion.rotation[1]; + this._tempQuat.z = rootMotion.rotation[2]; + this._tempQuat.w = rootMotion.rotation[3]; + transform.rotate(this._tempQuat, 'local'); } - - return sampled; } -} \ No newline at end of file +} diff --git a/web/packages/scene-runtime/src/components/camera.ts b/web/packages/scene-runtime/src/components/camera.ts index 70ab080d..6c7dca75 100644 --- a/web/packages/scene-runtime/src/components/camera.ts +++ b/web/packages/scene-runtime/src/components/camera.ts @@ -2,35 +2,73 @@ import { Mat4, Vec3, Vec4 } from '@axrone/numeric'; import { Transform } from '@axrone/ecs-runtime'; import { Component } from '@axrone/ecs-runtime'; import { script } from '@axrone/ecs-runtime'; +import type { SceneClearFlag } from '../types'; + +export type CameraFieldOfViewAxis = 'vertical' | 'horizontal'; export interface CameraConfig { readonly primary?: boolean; readonly near?: number; readonly far?: number; readonly fieldOfView?: number; + readonly fieldOfViewAxis?: CameraFieldOfViewAxis; readonly orthographic?: boolean; readonly orthographicSize?: number; + readonly clearFlags?: readonly SceneClearFlag[]; readonly clearDepth?: number; readonly clearColor?: Vec4 | readonly [number, number, number, number]; } const DEFAULT_CLEAR_COLOR = new Vec4(0.08, 0.09, 0.11, 1); +const DEFAULT_CLEAR_FLAGS = Object.freeze([ + 'color', + 'depth', +] as const satisfies readonly SceneClearFlag[]); + +const normalizeFieldOfViewAxis = (value: unknown): CameraFieldOfViewAxis => + typeof value === 'string' && value.trim().toLowerCase() === 'horizontal' + ? 'horizontal' + : 'vertical'; + +const cloneClearFlags = (value?: readonly SceneClearFlag[]): SceneClearFlag[] => { + if (!Array.isArray(value) || value.length === 0) { + return [...DEFAULT_CLEAR_FLAGS]; + } + + const flags: SceneClearFlag[] = []; + for (const flag of value) { + if ((flag === 'color' || flag === 'depth') && !flags.includes(flag)) { + flags.push(flag); + } + } + + return flags; +}; + +export const resolveCameraVerticalFieldOfViewRadians = ( + fieldOfViewDegrees: number, + fieldOfViewAxis: CameraFieldOfViewAxis, + aspectRatio: number +): number => { + const fieldOfViewRadians = (fieldOfViewDegrees * Math.PI) / 180; + if (fieldOfViewAxis === 'vertical') { + return fieldOfViewRadians; + } + + const safeAspectRatio = Math.max(aspectRatio, 0.001); + return Math.atan(Math.tan(fieldOfViewRadians / 2) / safeAspectRatio) * 2; +}; const toVec4 = (value?: Vec4 | readonly [number, number, number, number]): Vec4 => { if (value instanceof Vec4) { - return new Vec4(value.x, value.y, value.z, value.w); + return Vec4.from(value); } if (Array.isArray(value) && value.length === 4) { - return new Vec4(value[0], value[1], value[2], value[3]); + return Vec4.fromArray(value); } - return new Vec4( - DEFAULT_CLEAR_COLOR.x, - DEFAULT_CLEAR_COLOR.y, - DEFAULT_CLEAR_COLOR.z, - DEFAULT_CLEAR_COLOR.w - ); + return Vec4.from(DEFAULT_CLEAR_COLOR); }; @script({ @@ -44,8 +82,10 @@ export class Camera extends Component { private _near: number; private _far: number; private _fieldOfView: number; + private _fieldOfViewAxis: CameraFieldOfViewAxis; private _orthographic: boolean; private _orthographicSize: number; + private _clearFlags: SceneClearFlag[]; private _clearDepth: number; private _clearColor: Vec4; @@ -55,8 +95,10 @@ export class Camera extends Component { this._near = config.near ?? 0.1; this._far = config.far ?? 1000; this._fieldOfView = config.fieldOfView ?? 60; + this._fieldOfViewAxis = normalizeFieldOfViewAxis(config.fieldOfViewAxis); this._orthographic = config.orthographic ?? false; this._orthographicSize = config.orthographicSize ?? 5; + this._clearFlags = cloneClearFlags(config.clearFlags); this._clearDepth = config.clearDepth ?? 1; this._clearColor = toVec4(config.clearColor); } @@ -93,6 +135,14 @@ export class Camera extends Component { this._fieldOfView = value; } + get fieldOfViewAxis(): CameraFieldOfViewAxis { + return this._fieldOfViewAxis; + } + + set fieldOfViewAxis(value: CameraFieldOfViewAxis) { + this._fieldOfViewAxis = normalizeFieldOfViewAxis(value); + } + get orthographic(): boolean { return this._orthographic; } @@ -109,6 +159,14 @@ export class Camera extends Component { this._orthographicSize = value; } + get clearFlags(): readonly SceneClearFlag[] { + return this._clearFlags; + } + + set clearFlags(value: readonly SceneClearFlag[]) { + this._clearFlags = cloneClearFlags(value); + } + get clearDepth(): number { return this._clearDepth; } @@ -157,7 +215,11 @@ export class Camera extends Component { } return Mat4.perspective( - (this._fieldOfView * Math.PI) / 180, + resolveCameraVerticalFieldOfViewRadians( + this._fieldOfView, + this._fieldOfViewAxis, + aspectRatio + ), aspectRatio, this._near, this._far @@ -183,8 +245,10 @@ export class Camera extends Component { near: this._near, far: this._far, fieldOfView: this._fieldOfView, + fieldOfViewAxis: this._fieldOfViewAxis, orthographic: this._orthographic, orthographicSize: this._orthographicSize, + clearFlags: [...this._clearFlags], clearDepth: this._clearDepth, clearColor: [ this._clearColor.x, @@ -208,22 +272,23 @@ export class Camera extends Component { if (typeof data.fieldOfView === 'number') { this._fieldOfView = data.fieldOfView; } + if (typeof data.fieldOfViewAxis === 'string') { + this._fieldOfViewAxis = normalizeFieldOfViewAxis(data.fieldOfViewAxis); + } if (typeof data.orthographic === 'boolean') { this._orthographic = data.orthographic; } if (typeof data.orthographicSize === 'number') { this._orthographicSize = data.orthographicSize; } + if (Array.isArray(data.clearFlags)) { + this._clearFlags = cloneClearFlags(data.clearFlags); + } if (typeof data.clearDepth === 'number') { this._clearDepth = data.clearDepth; } if (Array.isArray(data.clearColor) && data.clearColor.length === 4) { - this._clearColor = new Vec4( - Number(data.clearColor[0]), - Number(data.clearColor[1]), - Number(data.clearColor[2]), - Number(data.clearColor[3]) - ); + this._clearColor = Vec4.fromArray(data.clearColor); } } -} \ No newline at end of file +} diff --git a/web/packages/scene-runtime/src/components/directional-light.ts b/web/packages/scene-runtime/src/components/directional-light.ts index 5cacc75d..c30e5498 100644 --- a/web/packages/scene-runtime/src/components/directional-light.ts +++ b/web/packages/scene-runtime/src/components/directional-light.ts @@ -1,4 +1,5 @@ import { Quat, Vec3 } from '@axrone/numeric'; +import { createDirectionalLightDefinition } from '@axrone/lighting'; import { Transform } from '@axrone/ecs-runtime'; import { Component } from '@axrone/ecs-runtime'; import { script } from '@axrone/ecs-runtime'; @@ -15,16 +16,18 @@ const toVec3 = ( fallback: Vec3 = Vec3.ONE ): Vec3 => { if (value instanceof Vec3) { - return new Vec3(value.x, value.y, value.z); + return Vec3.from(value); } if (Array.isArray(value) && value.length === 3) { - return new Vec3(value[0], value[1], value[2]); + return Vec3.fromArray(value); } return fallback.clone(); }; +const DEFAULT_AMBIENT_COLOR = Object.freeze(new Vec3(0.06, 0.06, 0.08)); + @script({ scriptName: 'DirectionalLight', priority: 700, @@ -39,10 +42,11 @@ export class DirectionalLight extends Component { constructor(config: DirectionalLightConfig = {}) { super(); - this._color = toVec3(config.color, Vec3.ONE); - this._ambientColor = toVec3(config.ambientColor, new Vec3(0.06, 0.06, 0.08)); - this._intensity = config.intensity ?? 1; - this._primary = config.primary ?? false; + this._color = Vec3.ONE.clone(); + this._ambientColor = Vec3.from(DEFAULT_AMBIENT_COLOR); + this._intensity = 1; + this._primary = false; + this._applyConfig(config); } get color(): Vec3 { @@ -50,7 +54,7 @@ export class DirectionalLight extends Component { } set color(value: Vec3 | readonly [number, number, number]) { - this._color = toVec3(value, Vec3.ONE); + this._applyConfig({ color: value }); } get ambientColor(): Vec3 { @@ -58,7 +62,7 @@ export class DirectionalLight extends Component { } set ambientColor(value: Vec3 | readonly [number, number, number]) { - this._ambientColor = toVec3(value, new Vec3(0.06, 0.06, 0.08)); + this._applyConfig({ ambientColor: value }); } get intensity(): number { @@ -66,7 +70,7 @@ export class DirectionalLight extends Component { } set intensity(value: number) { - this._intensity = value; + this._applyConfig({ intensity: value }); } get primary(): boolean { @@ -74,7 +78,7 @@ export class DirectionalLight extends Component { } set primary(value: boolean) { - this._primary = value; + this._applyConfig({ primary: value }); } getDirection(): Vec3 { @@ -97,25 +101,37 @@ export class DirectionalLight extends Component { } override deserialize(data: Record): void { - if (Array.isArray(data.color) && data.color.length === 3) { - this._color = new Vec3( - Number(data.color[0]), - Number(data.color[1]), - Number(data.color[2]) - ); - } - if (Array.isArray(data.ambientColor) && data.ambientColor.length === 3) { - this._ambientColor = new Vec3( - Number(data.ambientColor[0]), - Number(data.ambientColor[1]), - Number(data.ambientColor[2]) - ); - } - if (typeof data.intensity === 'number') { - this._intensity = data.intensity; - } - if (typeof data.primary === 'boolean') { - this._primary = data.primary; - } + const color = + Array.isArray(data.color) && data.color.length === 3 + ? ([data.color[0], data.color[1], data.color[2]] as const) + : undefined; + const ambientColor = + Array.isArray(data.ambientColor) && data.ambientColor.length === 3 + ? ([data.ambientColor[0], data.ambientColor[1], data.ambientColor[2]] as const) + : undefined; + const patch: DirectionalLightConfig = { + ...(color ? { color } : {}), + ...(ambientColor ? { ambientColor } : {}), + ...(typeof data.intensity === 'number' ? { intensity: data.intensity } : {}), + ...(typeof data.primary === 'boolean' ? { primary: data.primary } : {}), + }; + + this._applyConfig(patch); } -} \ No newline at end of file + + private _applyConfig(config: DirectionalLightConfig): void { + const definition = createDirectionalLightDefinition( + { + color: config.color ?? this._color, + ambient: config.ambientColor ?? this._ambientColor, + intensity: config.intensity ?? this._intensity, + }, + 'scene-runtime:directional-light' + ); + + this._color = Vec3.from(definition.color); + this._ambientColor = Vec3.from(definition.ambient); + this._intensity = definition.intensity; + this._primary = config.primary ?? this._primary; + } +} diff --git a/web/packages/scene-runtime/src/components/follow-camera-controller.ts b/web/packages/scene-runtime/src/components/follow-camera-controller.ts new file mode 100644 index 00000000..56268300 --- /dev/null +++ b/web/packages/scene-runtime/src/components/follow-camera-controller.ts @@ -0,0 +1,325 @@ +import { Quat, Vec3 } from '@axrone/numeric'; +import { Transform } from '@axrone/ecs-runtime'; +import { Component } from '@axrone/ecs-runtime'; +import { script } from '@axrone/ecs-runtime'; + +export interface FollowCameraControllerConfig { + readonly target?: Vec3 | readonly [number, number, number]; + readonly targetOffset?: Vec3 | readonly [number, number, number]; + readonly up?: Vec3 | readonly [number, number, number]; + readonly distance?: number; + readonly minDistance?: number; + readonly maxDistance?: number; + readonly azimuth?: number; + readonly elevation?: number; + readonly positionDamping?: number; + readonly targetDamping?: number; +} + +const FOLLOW_MIN_DISTANCE = 1e-4; +const FOLLOW_PARALLEL_DOT_THRESHOLD = 0.999; +const FOLLOW_ELEVATION_LIMIT = Math.PI * 0.49; +const DEFAULT_TARGET_OFFSET = new Vec3(0, 1.5, 0); + +const toVec3 = ( + value?: Vec3 | readonly [number, number, number], + fallback: Vec3 = Vec3.ZERO +): Vec3 => { + if (value instanceof Vec3) { + return Vec3.from(value); + } + + if (Array.isArray(value) && value.length === 3) { + return Vec3.fromArray(value); + } + + return fallback.clone(); +}; + +const normalizeUpVector = (value: Vec3): Vec3 => { + if (value.lengthSquared() <= FOLLOW_MIN_DISTANCE * FOLLOW_MIN_DISTANCE) { + return Vec3.UP.clone(); + } + + return value.normalize(); +}; + +const clampElevation = (value: number): number => + Math.min(FOLLOW_ELEVATION_LIMIT, Math.max(-FOLLOW_ELEVATION_LIMIT, value)); + +const computeSmoothingFactor = (damping: number, deltaSeconds: number): number => { + if (damping <= 0 || deltaSeconds <= 0) { + return 1; + } + + return 1 - Math.exp(-damping * deltaSeconds); +}; + +const copyVec3 = (source: Readonly, target: Vec3): Vec3 => { + target.x = source.x; + target.y = source.y; + target.z = source.z; + return target; +}; + +@script({ + scriptName: 'FollowCameraController', + priority: 790, + executeInEditMode: true, + singleton: false, +}) +export class FollowCameraController extends Component { + private _target = Vec3.ZERO.clone(); + private _targetTransform: Transform | null = null; + private _targetOffset = DEFAULT_TARGET_OFFSET.clone(); + private _up: Vec3; + private _distance: number; + private _minDistance: number; + private _maxDistance: number; + private _azimuth: number; + private _elevation: number; + private _positionDamping: number; + private _targetDamping: number; + private readonly _smoothedTarget = new Vec3(); + private readonly _desiredTarget = new Vec3(); + private readonly _desiredPosition = new Vec3(); + private readonly _tempForward = new Vec3(); + private readonly _tempUp = new Vec3(); + private readonly _tempBackward = new Vec3(); + private readonly _tempRotation = new Quat(); + private _initialized = false; + + constructor(config: FollowCameraControllerConfig = {}) { + super(); + this._target = toVec3(config.target); + this._targetOffset = toVec3(config.targetOffset, DEFAULT_TARGET_OFFSET); + this._up = normalizeUpVector(toVec3(config.up, Vec3.UP)); + this._minDistance = Math.max(FOLLOW_MIN_DISTANCE, config.minDistance ?? 1.5); + this._maxDistance = Math.max(this._minDistance, config.maxDistance ?? 24); + this._distance = Math.min( + this._maxDistance, + Math.max(this._minDistance, config.distance ?? 7) + ); + this._azimuth = config.azimuth ?? 0; + this._elevation = clampElevation(config.elevation ?? 0.45); + this._positionDamping = Math.max(0, config.positionDamping ?? 10); + this._targetDamping = Math.max(0, config.targetDamping ?? 14); + copyVec3(this._target, this._smoothedTarget); + } + + get target(): Vec3 { + return this._target; + } + + set target(value: Vec3 | readonly [number, number, number]) { + this._target = toVec3(value); + } + + get targetOffset(): Vec3 { + return this._targetOffset; + } + + set targetOffset(value: Vec3 | readonly [number, number, number]) { + this._targetOffset = toVec3(value, DEFAULT_TARGET_OFFSET); + } + + get distance(): number { + return this._distance; + } + + set distance(value: number) { + this._distance = Math.min(this._maxDistance, Math.max(this._minDistance, value)); + } + + get azimuth(): number { + return this._azimuth; + } + + set azimuth(value: number) { + this._azimuth = value; + } + + get elevation(): number { + return this._elevation; + } + + set elevation(value: number) { + this._elevation = clampElevation(value); + } + + get positionDamping(): number { + return this._positionDamping; + } + + set positionDamping(value: number) { + this._positionDamping = Math.max(0, value); + } + + get targetDamping(): number { + return this._targetDamping; + } + + set targetDamping(value: number) { + this._targetDamping = Math.max(0, value); + } + + setTarget(target: Transform | null | undefined, snap: boolean = true): this { + this._targetTransform = target ?? null; + if (snap) { + this.snap(); + } + return this; + } + + orbit(deltaAzimuth: number, deltaElevation: number): this { + this.azimuth = this._azimuth + deltaAzimuth; + this.elevation = this._elevation + deltaElevation; + return this; + } + + zoom(deltaDistance: number): this { + this.distance = this._distance + deltaDistance; + return this; + } + + snap(): this { + this._initialized = false; + return this; + } + + lateUpdate(deltaTime: number): void { + const transform = this.transform as Transform | undefined; + if (!transform) { + return; + } + + const desiredTarget = this._resolveDesiredTarget(this._desiredTarget); + const deltaSeconds = Math.max(0, deltaTime / 1000); + + if (!this._initialized) { + copyVec3(desiredTarget, this._smoothedTarget); + this._composeDesiredPosition(this._smoothedTarget, this._desiredPosition); + this._applyCameraTransform(transform, this._desiredPosition, this._smoothedTarget); + this._initialized = true; + return; + } + + Vec3.lerp( + this._smoothedTarget, + desiredTarget, + computeSmoothingFactor(this._targetDamping, deltaSeconds), + this._smoothedTarget + ); + this._composeDesiredPosition(this._smoothedTarget, this._desiredPosition); + Vec3.lerp( + transform.position, + this._desiredPosition, + computeSmoothingFactor(this._positionDamping, deltaSeconds), + this._desiredPosition + ); + this._applyCameraTransform(transform, this._desiredPosition, this._smoothedTarget); + } + + override serialize(): Record { + return { + target: [this._target.x, this._target.y, this._target.z], + targetOffset: [this._targetOffset.x, this._targetOffset.y, this._targetOffset.z], + up: [this._up.x, this._up.y, this._up.z], + distance: this._distance, + minDistance: this._minDistance, + maxDistance: this._maxDistance, + azimuth: this._azimuth, + elevation: this._elevation, + positionDamping: this._positionDamping, + targetDamping: this._targetDamping, + }; + } + + override deserialize(data: Record): void { + if (Array.isArray(data.target) && data.target.length === 3) { + this._target = Vec3.fromArray(data.target); + } + + if (Array.isArray(data.targetOffset) && data.targetOffset.length === 3) { + this._targetOffset = Vec3.fromArray(data.targetOffset); + } + + if (Array.isArray(data.up) && data.up.length === 3) { + this._up = normalizeUpVector(Vec3.fromArray(data.up)); + } + + if (typeof data.minDistance === 'number') { + this._minDistance = Math.max(FOLLOW_MIN_DISTANCE, data.minDistance); + } + if (typeof data.maxDistance === 'number') { + this._maxDistance = Math.max(this._minDistance, data.maxDistance); + } + if (typeof data.distance === 'number') { + this.distance = data.distance; + } + if (typeof data.azimuth === 'number') { + this._azimuth = data.azimuth; + } + if (typeof data.elevation === 'number') { + this.elevation = data.elevation; + } + if (typeof data.positionDamping === 'number') { + this._positionDamping = Math.max(0, data.positionDamping); + } + if (typeof data.targetDamping === 'number') { + this._targetDamping = Math.max(0, data.targetDamping); + } + + this._initialized = false; + } + + private _resolveDesiredTarget(out: Vec3): Vec3 { + const source = this._targetTransform?.worldPosition ?? this._target; + out.x = source.x + this._targetOffset.x; + out.y = source.y + this._targetOffset.y; + out.z = source.z + this._targetOffset.z; + return out; + } + + private _composeDesiredPosition(target: Vec3, out: Vec3): Vec3 { + const cosElevation = Math.cos(this._elevation); + out.x = target.x + Math.sin(this._azimuth) * cosElevation * this._distance; + out.y = target.y + Math.sin(this._elevation) * this._distance; + out.z = target.z + Math.cos(this._azimuth) * cosElevation * this._distance; + return out; + } + + private _resolveUp(position: Vec3): Vec3 { + Vec3.subtract(this._smoothedTarget, position, this._tempForward); + if (this._tempForward.lengthSquared() <= FOLLOW_MIN_DISTANCE * FOLLOW_MIN_DISTANCE) { + return this._up; + } + + this._tempForward.normalize(); + if (Math.abs(Vec3.dot(this._tempForward, this._up)) < FOLLOW_PARALLEL_DOT_THRESHOLD) { + return this._up; + } + + if (Math.abs(this._tempForward.y) < FOLLOW_PARALLEL_DOT_THRESHOLD) { + return copyVec3(Vec3.UP, this._tempUp); + } + + return copyVec3(Vec3.FORWARD, this._tempUp); + } + + private _applyCameraTransform(transform: Transform, position: Vec3, target: Vec3): void { + transform.position = position; + + Vec3.subtract(target, position, this._tempForward); + if (this._tempForward.lengthSquared() <= FOLLOW_MIN_DISTANCE * FOLLOW_MIN_DISTANCE) { + return; + } + + this._tempForward.normalize(); + const up = this._resolveUp(position); + this._tempBackward.x = -this._tempForward.x; + this._tempBackward.y = -this._tempForward.y; + this._tempBackward.z = -this._tempForward.z; + transform.rotation = Quat.lookRotation(this._tempBackward, up, this._tempRotation); + } +} diff --git a/web/packages/scene-runtime/src/components/mesh-renderer.ts b/web/packages/scene-runtime/src/components/mesh-renderer.ts index b69aa4b7..a0b5521f 100644 --- a/web/packages/scene-runtime/src/components/mesh-renderer.ts +++ b/web/packages/scene-runtime/src/components/mesh-renderer.ts @@ -1,4 +1,4 @@ -import { Mat4 } from '@axrone/numeric'; +import { computeSkinningPalette } from '@axrone/animation'; import { Transform } from '@axrone/ecs-runtime'; import { Component } from '@axrone/ecs-runtime'; import { script } from '@axrone/ecs-runtime'; @@ -103,6 +103,7 @@ const areEqualWeights = ( priority: 100, executeInEditMode: true, singleton: false, + trackInstances: false, }) export class MeshRenderer extends Component { private _meshId: string | null; @@ -117,6 +118,8 @@ export class MeshRenderer extends Component { private _skin: MeshRendererSkinState | null; private _resolvedSkinInstanceId: string | null = null; private _resolvedJointTransforms: readonly (Transform | null)[] | null = null; + private _resolvedJointWorldMatrices: ArrayLike[] | null = null; + private _skinPaletteCache: Float32Array | null = null; constructor(config: MeshRendererConfig = {}) { super(); @@ -233,6 +236,8 @@ export class MeshRenderer extends Component { this._skin = normalizeSkin(value); this._resolvedSkinInstanceId = null; this._resolvedJointTransforms = null; + this._resolvedJointWorldMatrices = null; + this._skinPaletteCache = null; } get hasSkin(): boolean { @@ -258,24 +263,18 @@ export class MeshRenderer extends Component { return null; } - const meshInverse = Mat4.invert(meshTransform.worldMatrix); - const palette = new Float32Array(jointTransforms.length * 16); + const jointWorldMatrices = this._refreshResolvedJointWorldMatrices(jointTransforms); - for (let jointIndex = 0; jointIndex < jointTransforms.length; jointIndex += 1) { - const jointTransform = jointTransforms[jointIndex]!; - let jointMatrix = Mat4.multiply(meshInverse, jointTransform.worldMatrix); - - if (this._skin.inverseBindMatrices) { - jointMatrix = Mat4.multiply( - jointMatrix, - Mat4.fromArray(this._skin.inverseBindMatrices, jointIndex * 16) - ); - } - - palette.set(jointMatrix.data, jointIndex * 16); + if (!this._skinPaletteCache || this._skinPaletteCache.length !== jointTransforms.length * 16) { + this._skinPaletteCache = new Float32Array(jointTransforms.length * 16); } - return palette; + return computeSkinningPalette({ + meshWorldMatrix: meshTransform.worldMatrix.data, + jointWorldMatrices: jointWorldMatrices, + inverseBindMatrices: this._skin.inverseBindMatrices ?? null, + out: this._skinPaletteCache, + }); } setUniform(name: string, value: SceneUniformValue): this { @@ -291,6 +290,12 @@ export class MeshRenderer extends Component { this._uniformOverrides.clear(); } + forEachUniformEntry(visitor: (name: string, value: SceneUniformValue) => void): void { + for (const [name, value] of this._uniformOverrides) { + visitor(name, value); + } + } + getUniformEntries(): readonly (readonly [string, SceneUniformValue])[] { return [...this._uniformOverrides.entries()]; } @@ -420,9 +425,28 @@ export class MeshRenderer extends Component { ); if (this._resolvedJointTransforms.some((entry) => entry === null)) { + this._resolvedJointWorldMatrices = null; return null; } + this._resolvedJointWorldMatrices = (this._resolvedJointTransforms as readonly Transform[]).map( + (transform) => transform.worldMatrix.data + ); + return this._resolvedJointTransforms as readonly Transform[]; } + + private _refreshResolvedJointWorldMatrices( + jointTransforms: readonly Transform[] + ): readonly ArrayLike[] { + if (!this._resolvedJointWorldMatrices || this._resolvedJointWorldMatrices.length !== jointTransforms.length) { + this._resolvedJointWorldMatrices = new Array>(jointTransforms.length); + } + + for (let index = 0; index < jointTransforms.length; index += 1) { + this._resolvedJointWorldMatrices[index] = jointTransforms[index]!.worldMatrix.data; + } + + return this._resolvedJointWorldMatrices; + } } \ No newline at end of file diff --git a/web/packages/scene-runtime/src/components/orbit-camera-controller.ts b/web/packages/scene-runtime/src/components/orbit-camera-controller.ts index 730936b2..a4965935 100644 --- a/web/packages/scene-runtime/src/components/orbit-camera-controller.ts +++ b/web/packages/scene-runtime/src/components/orbit-camera-controller.ts @@ -23,11 +23,11 @@ const toVec3 = ( fallback: Vec3 = Vec3.ZERO ): Vec3 => { if (value instanceof Vec3) { - return new Vec3(value.x, value.y, value.z); + return Vec3.from(value); } if (Array.isArray(value) && value.length === 3) { - return new Vec3(value[0], value[1], value[2]); + return Vec3.fromArray(value); } return fallback.clone(); @@ -66,7 +66,10 @@ export class OrbitCameraController extends Component { this._up = normalizeUpVector(toVec3(config.up, Vec3.UP)); this._minDistance = Math.max(ORBIT_MIN_DISTANCE, config.minDistance ?? 1); this._maxDistance = Math.max(this._minDistance, config.maxDistance ?? 64); - this._distance = Math.min(this._maxDistance, Math.max(this._minDistance, config.distance ?? 6)); + this._distance = Math.min( + this._maxDistance, + Math.max(this._minDistance, config.distance ?? 6) + ); this._azimuth = config.azimuth ?? 0; this._elevation = clampElevation(config.elevation ?? 0.35); this._autoRotateSpeed = config.autoRotateSpeed ?? 0; @@ -139,16 +142,13 @@ export class OrbitCameraController extends Component { } const normalizedForward = Vec3.normalize(forward, new Vec3()); - const up = Math.abs(Vec3.dot(normalizedForward, this._up)) >= ORBIT_PARALLEL_DOT_THRESHOLD - ? Math.abs(normalizedForward.y) < ORBIT_PARALLEL_DOT_THRESHOLD - ? Vec3.UP - : Vec3.FORWARD - : this._up; - const backward = new Vec3( - -normalizedForward.x, - -normalizedForward.y, - -normalizedForward.z - ); + const up = + Math.abs(Vec3.dot(normalizedForward, this._up)) >= ORBIT_PARALLEL_DOT_THRESHOLD + ? Math.abs(normalizedForward.y) < ORBIT_PARALLEL_DOT_THRESHOLD + ? Vec3.UP + : Vec3.FORWARD + : this._up; + const backward = new Vec3(-normalizedForward.x, -normalizedForward.y, -normalizedForward.z); transform.position = position; transform.rotation = Quat.lookRotation(backward, up, new Quat()); @@ -169,17 +169,11 @@ export class OrbitCameraController extends Component { override deserialize(data: Record): void { if (Array.isArray(data.target) && data.target.length === 3) { - this._target = new Vec3( - Number(data.target[0]), - Number(data.target[1]), - Number(data.target[2]) - ); + this._target = Vec3.fromArray(data.target); } if (Array.isArray(data.up) && data.up.length === 3) { - this._up = normalizeUpVector( - new Vec3(Number(data.up[0]), Number(data.up[1]), Number(data.up[2])) - ); + this._up = normalizeUpVector(Vec3.fromArray(data.up)); } if (typeof data.minDistance === 'number') { @@ -201,4 +195,4 @@ export class OrbitCameraController extends Component { this._autoRotateSpeed = data.autoRotateSpeed; } } -} \ No newline at end of file +} diff --git a/web/packages/scene-runtime/src/components/point-light.ts b/web/packages/scene-runtime/src/components/point-light.ts index 964ba6ab..7baa3879 100644 --- a/web/packages/scene-runtime/src/components/point-light.ts +++ b/web/packages/scene-runtime/src/components/point-light.ts @@ -1,4 +1,5 @@ import { Vec3 } from '@axrone/numeric'; +import { createPointLightDefinition } from '@axrone/lighting'; import { Transform } from '@axrone/ecs-runtime'; import { Component } from '@axrone/ecs-runtime'; import { script } from '@axrone/ecs-runtime'; @@ -14,11 +15,11 @@ const toVec3 = ( fallback: Vec3 = Vec3.ONE ): Vec3 => { if (value instanceof Vec3) { - return new Vec3(value.x, value.y, value.z); + return Vec3.from(value); } if (Array.isArray(value) && value.length === 3) { - return new Vec3(value[0], value[1], value[2]); + return Vec3.fromArray(value); } return fallback.clone(); @@ -37,9 +38,10 @@ export class PointLight extends Component { constructor(config: PointLightConfig = {}) { super(); - this._color = toVec3(config.color, Vec3.ONE); - this._intensity = config.intensity ?? 1; - this._range = config.range ?? 8; + this._color = Vec3.ONE.clone(); + this._intensity = 1; + this._range = 8; + this._applyConfig(config); } get color(): Vec3 { @@ -47,7 +49,7 @@ export class PointLight extends Component { } set color(value: Vec3 | readonly [number, number, number]) { - this._color = toVec3(value, Vec3.ONE); + this._applyConfig({ color: value }); } get intensity(): number { @@ -55,7 +57,7 @@ export class PointLight extends Component { } set intensity(value: number) { - this._intensity = value; + this._applyConfig({ intensity: value }); } get range(): number { @@ -63,7 +65,7 @@ export class PointLight extends Component { } set range(value: number) { - this._range = value; + this._applyConfig({ range: value }); } getWorldPosition(): Vec3 { @@ -84,18 +86,31 @@ export class PointLight extends Component { } override deserialize(data: Record): void { - if (Array.isArray(data.color) && data.color.length === 3) { - this._color = new Vec3( - Number(data.color[0]), - Number(data.color[1]), - Number(data.color[2]) - ); - } - if (typeof data.intensity === 'number') { - this._intensity = data.intensity; - } - if (typeof data.range === 'number') { - this._range = data.range; - } + const color = + Array.isArray(data.color) && data.color.length === 3 + ? ([data.color[0], data.color[1], data.color[2]] as const) + : undefined; + const patch: PointLightConfig = { + ...(color ? { color } : {}), + ...(typeof data.intensity === 'number' ? { intensity: data.intensity } : {}), + ...(typeof data.range === 'number' ? { range: data.range } : {}), + }; + + this._applyConfig(patch); + } + + private _applyConfig(config: PointLightConfig): void { + const definition = createPointLightDefinition( + { + color: config.color ?? this._color, + intensity: config.intensity ?? this._intensity, + range: config.range ?? this._range, + }, + 'scene-runtime:point-light' + ); + + this._color = Vec3.from(definition.color); + this._intensity = definition.intensity; + this._range = definition.range; } -} \ No newline at end of file +} diff --git a/web/packages/scene-runtime/src/components/spot-light.ts b/web/packages/scene-runtime/src/components/spot-light.ts index 02a7a179..699d2219 100644 --- a/web/packages/scene-runtime/src/components/spot-light.ts +++ b/web/packages/scene-runtime/src/components/spot-light.ts @@ -1,4 +1,5 @@ import { Quat, Vec3 } from '@axrone/numeric'; +import { createSpotLightDefinition } from '@axrone/lighting'; import { Transform } from '@axrone/ecs-runtime'; import { Component } from '@axrone/ecs-runtime'; import { script } from '@axrone/ecs-runtime'; @@ -16,16 +17,18 @@ const toVec3 = ( fallback: Vec3 = Vec3.ONE ): Vec3 => { if (value instanceof Vec3) { - return new Vec3(value.x, value.y, value.z); + return Vec3.from(value); } if (Array.isArray(value) && value.length === 3) { - return new Vec3(value[0], value[1], value[2]); + return Vec3.fromArray(value); } return fallback.clone(); }; +const clampCosine = (value: number): number => Math.min(1, Math.max(-1, value)); + @script({ scriptName: 'SpotLight', priority: 675, @@ -41,11 +44,12 @@ export class SpotLight extends Component { constructor(config: SpotLightConfig = {}) { super(); - this._color = toVec3(config.color, Vec3.ONE); - this._intensity = config.intensity ?? 1; - this._range = config.range ?? 8; - this._innerConeAngle = config.innerConeAngle ?? Math.PI / 8; - this._outerConeAngle = config.outerConeAngle ?? Math.PI / 4; + this._color = Vec3.ONE.clone(); + this._intensity = 1; + this._range = 8; + this._innerConeAngle = Math.PI / 8; + this._outerConeAngle = Math.PI / 4; + this._applyConfig(config); } get color(): Vec3 { @@ -53,7 +57,7 @@ export class SpotLight extends Component { } set color(value: Vec3 | readonly [number, number, number]) { - this._color = toVec3(value, Vec3.ONE); + this._applyConfig({ color: value }); } get intensity(): number { @@ -61,7 +65,7 @@ export class SpotLight extends Component { } set intensity(value: number) { - this._intensity = value; + this._applyConfig({ intensity: value }); } get range(): number { @@ -69,7 +73,7 @@ export class SpotLight extends Component { } set range(value: number) { - this._range = value; + this._applyConfig({ range: value }); } get innerConeAngle(): number { @@ -77,7 +81,7 @@ export class SpotLight extends Component { } set innerConeAngle(value: number) { - this._innerConeAngle = value; + this._applyConfig({ innerConeAngle: value }); } get outerConeAngle(): number { @@ -85,7 +89,7 @@ export class SpotLight extends Component { } set outerConeAngle(value: number) { - this._outerConeAngle = value; + this._applyConfig({ outerConeAngle: value }); } getWorldPosition(): Vec3 { @@ -118,24 +122,38 @@ export class SpotLight extends Component { } override deserialize(data: Record): void { - if (Array.isArray(data.color) && data.color.length === 3) { - this._color = new Vec3( - Number(data.color[0]), - Number(data.color[1]), - Number(data.color[2]) - ); - } - if (typeof data.intensity === 'number') { - this._intensity = data.intensity; - } - if (typeof data.range === 'number') { - this._range = data.range; - } - if (typeof data.innerConeAngle === 'number') { - this._innerConeAngle = data.innerConeAngle; - } - if (typeof data.outerConeAngle === 'number') { - this._outerConeAngle = data.outerConeAngle; - } + const color = + Array.isArray(data.color) && data.color.length === 3 + ? ([data.color[0], data.color[1], data.color[2]] as const) + : undefined; + const patch: SpotLightConfig = { + ...(color ? { color } : {}), + ...(typeof data.intensity === 'number' ? { intensity: data.intensity } : {}), + ...(typeof data.range === 'number' ? { range: data.range } : {}), + ...(typeof data.innerConeAngle === 'number' ? { innerConeAngle: data.innerConeAngle } : {}), + ...(typeof data.outerConeAngle === 'number' ? { outerConeAngle: data.outerConeAngle } : {}), + }; + + this._applyConfig(patch); + } + + private _applyConfig(config: SpotLightConfig): void { + const definition = createSpotLightDefinition( + { + color: config.color ?? this._color, + intensity: config.intensity ?? this._intensity, + range: config.range ?? this._range, + coneMode: 'angle', + innerConeAngle: config.innerConeAngle ?? this._innerConeAngle, + outerConeAngle: config.outerConeAngle ?? this._outerConeAngle, + }, + 'scene-runtime:spot-light' + ); + + this._color = Vec3.from(definition.color); + this._intensity = definition.intensity; + this._range = definition.range; + this._innerConeAngle = Math.acos(clampCosine(definition.innerConeCosine)); + this._outerConeAngle = Math.acos(clampCosine(definition.outerConeCosine)); } -} \ No newline at end of file +} diff --git a/web/packages/scene-runtime/src/components/sprite-animator.ts b/web/packages/scene-runtime/src/components/sprite-animator.ts new file mode 100644 index 00000000..12ab1b21 --- /dev/null +++ b/web/packages/scene-runtime/src/components/sprite-animator.ts @@ -0,0 +1,299 @@ +import { + createSpriteAtlas, + serializeSpriteAtlasDefinition, + type SpriteAnimationClip, + type SpriteAtlas, + type SpriteAtlasDefinition, + type SpriteAtlasFrame, +} from '@axrone/asset-2d'; +import { Component, script } from '@axrone/ecs-runtime'; +import { SpriteRenderer } from './sprite-renderer'; + +export interface SpriteAnimatorConfig { + readonly atlas?: SpriteAtlas | SpriteAtlasDefinition | null; + readonly clipId?: string | null; + readonly playing?: boolean; + readonly loop?: boolean | null; + readonly speed?: number; + readonly preserveSize?: boolean; + readonly preserveAnchor?: boolean; + readonly preserveSourceSize?: boolean; + readonly preserveSliceBorder?: boolean; +} + +const toSpriteAtlas = ( + value: SpriteAtlas | SpriteAtlasDefinition | null | undefined +): SpriteAtlas | null => { + if (!value) { + return null; + } + + if (typeof (value as SpriteAtlas).getFrame === 'function') { + return value as SpriteAtlas; + } + + return createSpriteAtlas(value as SpriteAtlasDefinition); +}; + +const resolveDefaultClipId = (atlas: SpriteAtlas | null): string | null => + atlas?.animations[0]?.id ?? null; + +@script({ + scriptName: 'SpriteAnimator', + priority: 110, + executeInEditMode: true, + singleton: false, +}) +export class SpriteAnimator extends Component { + private _atlas: SpriteAtlas | null; + private _clipId: string | null; + private _playing: boolean; + private _loop: boolean | null; + private _speed: number; + private _frameIndex: number; + private _frameElapsedMs: number; + private readonly _preserveSize: boolean; + private readonly _preserveAnchor: boolean; + private readonly _preserveSourceSize: boolean; + private readonly _preserveSliceBorder: boolean; + + constructor(config: SpriteAnimatorConfig = {}) { + super(); + this._atlas = toSpriteAtlas(config.atlas); + this._clipId = config.clipId ?? resolveDefaultClipId(this._atlas); + this._playing = config.playing ?? true; + this._loop = config.loop ?? null; + this._speed = Number.isFinite(config.speed) ? Math.max(0, config.speed ?? 1) : 1; + this._frameIndex = 0; + this._frameElapsedMs = 0; + this._preserveSize = config.preserveSize ?? false; + this._preserveAnchor = config.preserveAnchor ?? false; + this._preserveSourceSize = config.preserveSourceSize ?? false; + this._preserveSliceBorder = config.preserveSliceBorder ?? false; + } + + get atlas(): SpriteAtlas | null { + return this._atlas; + } + + set atlas(value: SpriteAtlas | SpriteAtlasDefinition | null) { + this._atlas = toSpriteAtlas(value); + if (!this._clipId || !this.currentClip) { + this._clipId = resolveDefaultClipId(this._atlas); + } + this._frameIndex = 0; + this._frameElapsedMs = 0; + this._applyCurrentFrame(); + } + + get clipId(): string | null { + return this._clipId; + } + + set clipId(value: string | null) { + this._clipId = value; + this._frameIndex = 0; + this._frameElapsedMs = 0; + this._applyCurrentFrame(); + } + + get playing(): boolean { + return this._playing; + } + + set playing(value: boolean) { + this._playing = value; + } + + get loop(): boolean | null { + return this._loop; + } + + set loop(value: boolean | null) { + this._loop = value; + } + + get speed(): number { + return this._speed; + } + + set speed(value: number) { + this._speed = Number.isFinite(value) ? Math.max(0, value) : 1; + } + + get currentClip(): SpriteAnimationClip | null { + if (!this._atlas || !this._clipId) { + return null; + } + + return this._atlas.getAnimation(this._clipId) ?? null; + } + + get currentFrame(): SpriteAtlasFrame | null { + const clip = this.currentClip; + if (clip && clip.frames.length > 0) { + const frameIndex = Math.min(this._frameIndex, clip.frames.length - 1); + return clip.frames[frameIndex]!.frame; + } + + return this._atlas?.frames[0] ?? null; + } + + override awake(): void { + this._applyCurrentFrame(); + } + + override start(): void { + this._applyCurrentFrame(); + } + + override update(deltaTime: number): void { + const clip = this.currentClip; + if (!clip || clip.frames.length === 0) { + this._applyCurrentFrame(); + return; + } + + this._applyCurrentFrame(); + + if (!this._playing || this._speed <= 0 || clip.frames.length === 1 || deltaTime <= 0) { + return; + } + + const shouldLoop = this._loop ?? clip.loop; + let remainingMs = Math.max(0, deltaTime * this._speed); + if (shouldLoop && clip.durationMs > 0) { + remainingMs %= clip.durationMs; + } + + while (remainingMs > 0) { + const currentFrame = clip.frames[this._frameIndex] ?? clip.frames[0]!; + const remainingFrameMs = currentFrame.durationMs - this._frameElapsedMs; + + if (remainingMs < remainingFrameMs) { + this._frameElapsedMs += remainingMs; + break; + } + + remainingMs -= remainingFrameMs; + this._frameElapsedMs = 0; + + if (this._frameIndex < clip.frames.length - 1) { + this._frameIndex += 1; + this._applyCurrentFrame(); + continue; + } + + if (shouldLoop) { + this._frameIndex = 0; + this._applyCurrentFrame(); + continue; + } + + this._playing = false; + this._frameIndex = clip.frames.length - 1; + this._applyCurrentFrame(); + break; + } + } + + play(clipId: string | null = this._clipId): this { + this._clipId = clipId ?? resolveDefaultClipId(this._atlas); + this._frameIndex = 0; + this._frameElapsedMs = 0; + this._playing = true; + this._applyCurrentFrame(); + return this; + } + + pause(): this { + this._playing = false; + return this; + } + + resume(): this { + this._playing = true; + return this; + } + + stop(resetToFirstFrame: boolean = true): this { + this._playing = false; + this._frameElapsedMs = 0; + if (resetToFirstFrame) { + this._frameIndex = 0; + this._applyCurrentFrame(); + } + return this; + } + + override serialize(): Record { + return { + atlas: this._atlas ? serializeSpriteAtlasDefinition(this._atlas) : null, + clipId: this._clipId, + playing: this._playing, + loop: this._loop, + speed: this._speed, + frameIndex: this._frameIndex, + frameElapsedMs: this._frameElapsedMs, + preserveSize: this._preserveSize, + preserveAnchor: this._preserveAnchor, + preserveSourceSize: this._preserveSourceSize, + preserveSliceBorder: this._preserveSliceBorder, + }; + } + + override deserialize(data: Record): void { + if (data.atlas === null) { + this._atlas = null; + } else if (typeof data.atlas === 'object') { + this._atlas = toSpriteAtlas(data.atlas as SpriteAtlasDefinition); + } + + if (typeof data.clipId === 'string' || data.clipId === null) { + this._clipId = data.clipId; + } else if (!this._clipId) { + this._clipId = resolveDefaultClipId(this._atlas); + } + + if (typeof data.playing === 'boolean') { + this._playing = data.playing; + } + + if (typeof data.loop === 'boolean' || data.loop === null) { + this._loop = data.loop; + } + + if (typeof data.speed === 'number') { + this._speed = Math.max(0, data.speed); + } + + if (typeof data.frameIndex === 'number') { + this._frameIndex = Math.max(0, Math.floor(data.frameIndex)); + } + + if (typeof data.frameElapsedMs === 'number') { + this._frameElapsedMs = Math.max(0, data.frameElapsedMs); + } + + this._applyCurrentFrame(); + } + + private _applyCurrentFrame(): void { + const frame = this.currentFrame; + if (!frame) { + return; + } + + const renderer = this.getComponent(SpriteRenderer); + if (!renderer) { + return; + } + + renderer.applyFrame(frame, { + preserveSize: this._preserveSize, + preserveAnchor: this._preserveAnchor, + preserveSourceSize: this._preserveSourceSize, + preserveSliceBorder: this._preserveSliceBorder, + }); + } +} \ No newline at end of file diff --git a/web/packages/scene-runtime/src/components/sprite-mask.ts b/web/packages/scene-runtime/src/components/sprite-mask.ts new file mode 100644 index 00000000..0644efb3 --- /dev/null +++ b/web/packages/scene-runtime/src/components/sprite-mask.ts @@ -0,0 +1,151 @@ +import { Vec2 } from '@axrone/numeric'; +import { Component, script } from '@axrone/ecs-runtime'; +import type { Render2DSizeLike, Render2DVec2Like } from '@axrone/render-2d'; + +export type SpriteMaskVec2Input = Vec2 | Render2DVec2Like | readonly [number, number]; +export type SpriteMaskSizeInput = Vec2 | Render2DSizeLike | readonly [number, number]; +export type SpriteMaskShape = 'rect' | 'circle' | 'rounded-rect'; + +export interface SpriteMaskConfig { + readonly size?: SpriteMaskSizeInput; + readonly anchor?: SpriteMaskVec2Input; + readonly shape?: SpriteMaskShape; + readonly cornerRadius?: number | null; +} + +const isTuple2 = (value: unknown): value is readonly [number, number] => + Array.isArray(value) && value.length >= 2; + +const toVec2 = ( + value: SpriteMaskVec2Input | SpriteMaskSizeInput | undefined, + fallbackX: number, + fallbackY: number +): Vec2 => { + if (!value) { + return new Vec2(fallbackX, fallbackY); + } + + if (value instanceof Vec2) { + return new Vec2(value.x, value.y); + } + + if (isTuple2(value)) { + return new Vec2(Number(value[0] ?? fallbackX), Number(value[1] ?? fallbackY)); + } + + if ('width' in value && 'height' in value) { + return new Vec2(Number(value.width), Number(value.height)); + } + + return new Vec2(Number(value.x), Number(value.y)); +}; + +const toShape = (value: SpriteMaskShape | undefined): SpriteMaskShape => value ?? 'rect'; + +@script({ + scriptName: 'SpriteMask', + priority: 105, + executeInEditMode: true, + singleton: false, +}) +export class SpriteMask extends Component { + private readonly _size: Vec2; + private readonly _anchor: Vec2; + private _shape: SpriteMaskShape; + private _cornerRadius: number | null; + + constructor(config: SpriteMaskConfig = {}) { + super(); + this._size = toVec2(config.size, 1, 1); + this._anchor = toVec2(config.anchor, 0.5, 0.5); + this._shape = toShape(config.shape); + this._cornerRadius = config.cornerRadius ?? null; + } + + get size(): Vec2 { + return this._size; + } + + set size(value: SpriteMaskSizeInput) { + const next = toVec2(value, this._size.x, this._size.y); + this._size.x = next.x; + this._size.y = next.y; + } + + get anchor(): Vec2 { + return this._anchor; + } + + set anchor(value: SpriteMaskVec2Input) { + const next = toVec2(value, this._anchor.x, this._anchor.y); + this._anchor.x = next.x; + this._anchor.y = next.y; + } + + get shape(): SpriteMaskShape { + return this._shape; + } + + set shape(value: SpriteMaskShape) { + this._shape = value; + } + + get cornerRadius(): number | null { + return this._cornerRadius; + } + + set cornerRadius(value: number | null) { + this._cornerRadius = value; + } + + setSize(width: number, height: number): this { + this._size.x = width; + this._size.y = height; + return this; + } + + setAnchor(x: number, y: number): this { + this._anchor.x = x; + this._anchor.y = y; + return this; + } + + setShape(shape: SpriteMaskShape): this { + this._shape = shape; + return this; + } + + setCornerRadius(cornerRadius: number | null): this { + this._cornerRadius = cornerRadius; + return this; + } + + override serialize(): Record { + return { + size: [this._size.x, this._size.y], + anchor: [this._anchor.x, this._anchor.y], + shape: this._shape, + cornerRadius: this._cornerRadius, + }; + } + + override deserialize(data: Record): void { + if (Array.isArray(data.size) && data.size.length >= 2) { + this.setSize(Number(data.size[0]), Number(data.size[1])); + } + + if (Array.isArray(data.anchor) && data.anchor.length >= 2) { + this.setAnchor(Number(data.anchor[0]), Number(data.anchor[1])); + } + + if (typeof data.shape === 'string') { + this.shape = data.shape as SpriteMaskShape; + } + + if (typeof data.cornerRadius === 'number') { + this.cornerRadius = data.cornerRadius; + } else if (data.cornerRadius === null) { + this.cornerRadius = null; + } + } +} \ No newline at end of file diff --git a/web/packages/scene-runtime/src/components/sprite-renderer.ts b/web/packages/scene-runtime/src/components/sprite-renderer.ts new file mode 100644 index 00000000..738ee846 --- /dev/null +++ b/web/packages/scene-runtime/src/components/sprite-renderer.ts @@ -0,0 +1,514 @@ +import type { Asset2DBorderLike, SpriteAtlasFrame } from '@axrone/asset-2d'; +import { Component, script } from '@axrone/ecs-runtime'; +import { Color, Vec2 } from '@axrone/numeric'; +import type { IColorLike } from '@axrone/numeric'; +import type { + Render2DRectLike, + Render2DSizeLike, + Render2DVec2Like, +} from '@axrone/render-2d'; + +export type SpriteRendererVec2Input = + | Vec2 + | Render2DVec2Like + | readonly [number, number]; +export type SpriteRendererSizeInput = + | Vec2 + | Render2DSizeLike + | readonly [number, number]; +export type SpriteRendererRectInput = + | Render2DRectLike + | readonly [number, number, number, number]; +export type SpriteRendererBorderInput = + | Asset2DBorderLike + | readonly [number, number, number, number] + | null; +export type SpriteRendererColorInput = Color | Readonly; + +export interface SpriteRendererRectState { + x: number; + y: number; + width: number; + height: number; +} + +export interface SpriteRendererBorderState { + left: number; + right: number; + top: number; + bottom: number; +} + +export interface SpriteRendererFrameApplyOptions { + readonly preserveSize?: boolean; + readonly preserveAnchor?: boolean; + readonly preserveSourceSize?: boolean; + readonly preserveSliceBorder?: boolean; +} + +export interface SpriteRendererConfig { + readonly textureId?: string | null; + readonly materialId?: string | null; + readonly visible?: boolean; + readonly renderOrder?: number; + readonly sortingLayer?: number; + readonly passId?: string; + readonly size?: SpriteRendererSizeInput; + readonly sourceSize?: SpriteRendererSizeInput; + readonly anchor?: SpriteRendererVec2Input; + readonly uvRect?: SpriteRendererRectInput; + readonly color?: SpriteRendererColorInput; + readonly sliceBorder?: SpriteRendererBorderInput; + readonly frame?: SpriteAtlasFrame | null; + readonly flipX?: boolean; + readonly flipY?: boolean; +} + +const isTuple2 = (value: unknown): value is readonly [number, number] => + Array.isArray(value) && value.length >= 2; + +const isTuple4 = ( + value: unknown +): value is readonly [number, number, number, number] => + Array.isArray(value) && value.length >= 4; + +const toVec2 = ( + value: SpriteRendererVec2Input | SpriteRendererSizeInput | undefined, + fallbackX: number, + fallbackY: number +): Vec2 => { + if (!value) { + return new Vec2(fallbackX, fallbackY); + } + + if (value instanceof Vec2) { + return new Vec2(value.x, value.y); + } + + if (isTuple2(value)) { + return new Vec2(Number(value[0] ?? fallbackX), Number(value[1] ?? fallbackY)); + } + + if ('width' in value && 'height' in value) { + return new Vec2(Number(value.width), Number(value.height)); + } + + return new Vec2(Number(value.x), Number(value.y)); +}; + +const toColor = ( + value: SpriteRendererColorInput | readonly number[] | undefined, + fallback: Color = Color.WHITE +): Color => { + if (!value) { + return fallback.clone(); + } + + if (Array.isArray(value)) { + return Color.fromArray(value); + } + + return Color.from(value as Readonly); +}; + +const toRect = ( + value: SpriteRendererRectInput | undefined, + fallback: SpriteRendererRectState = { x: 0, y: 0, width: 1, height: 1 } +): SpriteRendererRectState => { + if (!value) { + return { ...fallback }; + } + + if (isTuple4(value)) { + return { + x: Number(value[0] ?? fallback.x), + y: Number(value[1] ?? fallback.y), + width: Number(value[2] ?? fallback.width), + height: Number(value[3] ?? fallback.height), + }; + } + + return { + x: Number(value.x), + y: Number(value.y), + width: Number(value.width), + height: Number(value.height), + }; +}; + +const toBorder = ( + value: SpriteRendererBorderInput | undefined, + fallback: SpriteRendererBorderState | null = null +): SpriteRendererBorderState | null => { + if (value == null) { + return fallback + ? { + left: fallback.left, + right: fallback.right, + top: fallback.top, + bottom: fallback.bottom, + } + : null; + } + + if (isTuple4(value)) { + return { + left: Number(value[0] ?? 0), + right: Number(value[1] ?? 0), + top: Number(value[2] ?? 0), + bottom: Number(value[3] ?? 0), + }; + } + + return { + left: Number(value.left), + right: Number(value.right), + top: Number(value.top), + bottom: Number(value.bottom), + }; +}; + +@script({ + scriptName: 'SpriteRenderer', + priority: 100, + executeInEditMode: true, + singleton: false, +}) +export class SpriteRenderer extends Component { + private _textureId: string | null; + private _materialId: string | null; + private _visible: boolean; + private _renderOrder: number; + private _sortingLayer: number; + private _passId: string; + private readonly _size: Vec2; + private readonly _sourceSize: Vec2; + private readonly _anchor: Vec2; + private readonly _color: Color; + private readonly _uvRect: SpriteRendererRectState; + private _sliceBorder: SpriteRendererBorderState | null; + private _flipX: boolean; + private _flipY: boolean; + + constructor(config: SpriteRendererConfig = {}) { + super(); + this._textureId = config.textureId ?? null; + this._materialId = config.materialId ?? null; + this._visible = config.visible ?? true; + this._renderOrder = config.renderOrder ?? 0; + this._sortingLayer = config.sortingLayer ?? 0; + this._passId = config.passId ?? 'main'; + this._size = toVec2(config.size, 1, 1); + this._sourceSize = toVec2(config.sourceSize ?? config.size, this._size.x, this._size.y); + this._anchor = toVec2(config.anchor, 0.5, 0.5); + this._color = toColor(config.color); + this._uvRect = toRect(config.uvRect); + this._sliceBorder = toBorder(config.sliceBorder); + this._flipX = config.flipX ?? false; + this._flipY = config.flipY ?? false; + + if (config.frame) { + this.applyFrame(config.frame, { + preserveSize: config.size !== undefined, + preserveAnchor: config.anchor !== undefined, + preserveSourceSize: config.sourceSize !== undefined, + preserveSliceBorder: config.sliceBorder !== undefined, + }); + } + } + + get textureId(): string | null { + return this._textureId; + } + + set textureId(value: string | null) { + this._textureId = value; + } + + get materialId(): string | null { + return this._materialId; + } + + set materialId(value: string | null) { + this._materialId = value; + } + + get visible(): boolean { + return this._visible; + } + + set visible(value: boolean) { + this._visible = value; + } + + get renderOrder(): number { + return this._renderOrder; + } + + set renderOrder(value: number) { + this._renderOrder = value; + } + + get sortingLayer(): number { + return this._sortingLayer; + } + + set sortingLayer(value: number) { + this._sortingLayer = value; + } + + get passId(): string { + return this._passId; + } + + set passId(value: string) { + this._passId = value; + } + + get size(): Vec2 { + return this._size; + } + + set size(value: SpriteRendererSizeInput) { + const next = toVec2(value, this._size.x, this._size.y); + this._size.x = next.x; + this._size.y = next.y; + } + + get sourceSize(): Vec2 { + return this._sourceSize; + } + + set sourceSize(value: SpriteRendererSizeInput) { + const next = toVec2(value, this._sourceSize.x, this._sourceSize.y); + this._sourceSize.x = next.x; + this._sourceSize.y = next.y; + } + + get anchor(): Vec2 { + return this._anchor; + } + + set anchor(value: SpriteRendererVec2Input) { + const next = toVec2(value, this._anchor.x, this._anchor.y); + this._anchor.x = next.x; + this._anchor.y = next.y; + } + + get color(): Color { + return this._color; + } + + set color(value: SpriteRendererColorInput) { + const next = toColor(value, this._color); + this._color.r = next.r; + this._color.g = next.g; + this._color.b = next.b; + this._color.a = next.a; + } + + get uvRect(): SpriteRendererRectState { + return this._uvRect; + } + + set uvRect(value: SpriteRendererRectInput) { + const next = toRect(value, this._uvRect); + this._uvRect.x = next.x; + this._uvRect.y = next.y; + this._uvRect.width = next.width; + this._uvRect.height = next.height; + } + + get sliceBorder(): SpriteRendererBorderState | null { + return this._sliceBorder; + } + + set sliceBorder(value: SpriteRendererBorderInput) { + this._sliceBorder = toBorder(value); + } + + get flipX(): boolean { + return this._flipX; + } + + set flipX(value: boolean) { + this._flipX = value; + } + + get flipY(): boolean { + return this._flipY; + } + + set flipY(value: boolean) { + this._flipY = value; + } + + get hasRenderableSource(): boolean { + return Boolean(this._materialId || this._textureId); + } + + get isNineSlice(): boolean { + return this._sliceBorder !== null; + } + + setSize(width: number, height: number): this { + this._size.x = width; + this._size.y = height; + return this; + } + + setSourceSize(width: number, height: number): this { + this._sourceSize.x = width; + this._sourceSize.y = height; + return this; + } + + setAnchor(x: number, y: number): this { + this._anchor.x = x; + this._anchor.y = y; + return this; + } + + setColor(value: SpriteRendererColorInput): this { + const next = toColor(value, this._color); + this._color.r = next.r; + this._color.g = next.g; + this._color.b = next.b; + this._color.a = next.a; + return this; + } + + setUVRect(x: number, y: number, width: number, height: number): this { + this._uvRect.x = x; + this._uvRect.y = y; + this._uvRect.width = width; + this._uvRect.height = height; + return this; + } + + setSliceBorder(left: number, right: number, top: number, bottom: number): this { + this._sliceBorder = { left, right, top, bottom }; + return this; + } + + clearSliceBorder(): this { + this._sliceBorder = null; + return this; + } + + applyFrame( + frame: SpriteAtlasFrame, + options: SpriteRendererFrameApplyOptions = {} + ): this { + this._textureId = frame.textureId; + this.setUVRect(frame.uvRect.x, frame.uvRect.y, frame.uvRect.width, frame.uvRect.height); + + if (!options.preserveSize) { + this.setSize(frame.sourceSize.width, frame.sourceSize.height); + } + + if (!options.preserveSourceSize) { + this.setSourceSize(frame.sourceSize.width, frame.sourceSize.height); + } + + if (!options.preserveAnchor) { + this.setAnchor(frame.pivot.x, frame.pivot.y); + } + + if (!options.preserveSliceBorder) { + this._sliceBorder = toBorder(frame.sliceBorder); + } + + return this; + } + + override serialize(): Record { + return { + textureId: this._textureId, + materialId: this._materialId, + visible: this._visible, + renderOrder: this._renderOrder, + sortingLayer: this._sortingLayer, + passId: this._passId, + size: [this._size.x, this._size.y], + sourceSize: [this._sourceSize.x, this._sourceSize.y], + anchor: [this._anchor.x, this._anchor.y], + color: [this._color.r, this._color.g, this._color.b, this._color.a], + uvRect: [ + this._uvRect.x, + this._uvRect.y, + this._uvRect.width, + this._uvRect.height, + ], + sliceBorder: this._sliceBorder + ? [ + this._sliceBorder.left, + this._sliceBorder.right, + this._sliceBorder.top, + this._sliceBorder.bottom, + ] + : null, + flipX: this._flipX, + flipY: this._flipY, + }; + } + + override deserialize(data: Record): void { + if (typeof data.textureId === 'string' || data.textureId === null) { + this._textureId = data.textureId; + } + if (typeof data.materialId === 'string' || data.materialId === null) { + this._materialId = data.materialId; + } + if (typeof data.visible === 'boolean') { + this._visible = data.visible; + } + if (typeof data.renderOrder === 'number') { + this._renderOrder = data.renderOrder; + } + if (typeof data.sortingLayer === 'number') { + this._sortingLayer = data.sortingLayer; + } + if (typeof data.passId === 'string') { + this._passId = data.passId; + } + if (Array.isArray(data.size) && data.size.length >= 2) { + this.setSize(Number(data.size[0]), Number(data.size[1])); + } + if (Array.isArray(data.sourceSize) && data.sourceSize.length >= 2) { + this.setSourceSize(Number(data.sourceSize[0]), Number(data.sourceSize[1])); + } + if (Array.isArray(data.anchor) && data.anchor.length >= 2) { + this.setAnchor(Number(data.anchor[0]), Number(data.anchor[1])); + } + if (data.color) { + this.setColor(data.color); + } + if (Array.isArray(data.uvRect) && data.uvRect.length >= 4) { + this.setUVRect( + Number(data.uvRect[0]), + Number(data.uvRect[1]), + Number(data.uvRect[2]), + Number(data.uvRect[3]) + ); + } + if (data.sliceBorder === null) { + this.clearSliceBorder(); + } else if (Array.isArray(data.sliceBorder) && data.sliceBorder.length >= 4) { + this.setSliceBorder( + Number(data.sliceBorder[0]), + Number(data.sliceBorder[1]), + Number(data.sliceBorder[2]), + Number(data.sliceBorder[3]) + ); + } else if (data.sliceBorder && typeof data.sliceBorder === 'object') { + this.sliceBorder = data.sliceBorder as Asset2DBorderLike; + } + if (typeof data.flipX === 'boolean') { + this._flipX = data.flipX; + } + if (typeof data.flipY === 'boolean') { + this._flipY = data.flipY; + } + } +} \ No newline at end of file diff --git a/web/packages/scene-runtime/src/draw-executor.ts b/web/packages/scene-runtime/src/draw-executor.ts index 6317adc3..65b1e92f 100644 --- a/web/packages/scene-runtime/src/draw-executor.ts +++ b/web/packages/scene-runtime/src/draw-executor.ts @@ -1,6 +1,9 @@ import type { SceneCameraFrameState } from './camera-frame-state'; import type { SceneLightingState } from './lighting-collector'; -import type { SceneMaterialResource } from './material-registry'; +import { + resolveSceneMaterialPass, + type SceneMaterialResource, +} from './material-registry'; import type { SceneMeshResource } from './mesh-registry'; import type { SceneMorphMeshRuntime } from './morph-mesh-runtime'; import type { SceneRenderFrameState } from './render-frame-state'; @@ -58,7 +61,10 @@ interface SceneDrawExecutorDependencies { readonly gl: WebGL2RenderingContext; readonly resources: SceneDrawExecutorResources; readonly morphMeshRuntime: Pick; - readonly renderStateApplier: Pick; + readonly renderStateApplier: Pick< + SceneRenderStateApplier, + 'apply' | 'resolvePrimitiveMode' | 'resolvePrimitiveTopology' + >; readonly frameUniformBinder: Pick; readonly lightingUniformBinder: Pick; readonly skinningUniformBinder: Pick; @@ -90,6 +96,11 @@ export class SceneDrawExecutor { return; } + const materialPass = resolveSceneMaterialPass(material, context.renderPass.materialPassId); + if (context.renderPass.materialPassId != null && !materialPass) { + return; + } + frameState.markActiveRenderer(item.renderer.id); const shader = this._dependencies.resources.shaders.get(material.shaderId); @@ -97,7 +108,7 @@ export class SceneDrawExecutor { return; } - this._dependencies.renderStateApplier.apply(shader, context.renderPass); + this._dependencies.renderStateApplier.apply(shader, context.renderPass, materialPass); this._dependencies.gl.useProgram(shader.program); this._dependencies.gl.bindVertexArray(mesh.vertexArray); this._dependencies.applyMissingVertexAttributeDefaults(mesh); @@ -128,21 +139,35 @@ export class SceneDrawExecutor { this._dependencies.uniformWriter.write(shader, name, value); } - for (const [name, value] of item.renderer.getUniformEntries()) { + item.renderer.forEachUniformEntry((name, value) => { this._dependencies.uniformWriter.write( shader, name, value as SceneUniformValue | null | undefined ); - } + }); + + const primitiveMode = + this._dependencies.renderStateApplier.resolvePrimitiveMode?.( + mesh.mode, + materialPass + ) ?? mesh.mode; if (mesh.indexBuffer && mesh.indexType !== null && mesh.indexCount > 0) { - this._dependencies.gl.drawElements(mesh.mode, mesh.indexCount, mesh.indexType, 0); + this._dependencies.gl.drawElements(primitiveMode, mesh.indexCount, mesh.indexType, 0); } else { - this._dependencies.gl.drawArrays(mesh.mode, 0, mesh.vertexCount); + this._dependencies.gl.drawArrays(primitiveMode, 0, mesh.vertexCount); } - frameState.recordDraw(mesh); + frameState.recordDraw({ + topology: materialPass?.primitive + ? (this._dependencies.renderStateApplier.resolvePrimitiveTopology?.( + materialPass.primitive + ) ?? mesh.topology) + : mesh.topology, + indexCount: mesh.indexCount, + vertexCount: mesh.vertexCount, + }); this._dependencies.materialTextureBinder.unbind(); } } diff --git a/web/packages/scene-runtime/src/errors.ts b/web/packages/scene-runtime/src/errors.ts index 38a2f71a..afc9edd0 100644 --- a/web/packages/scene-runtime/src/errors.ts +++ b/web/packages/scene-runtime/src/errors.ts @@ -54,3 +54,31 @@ export class SceneCapabilityError extends SceneError { this.name = 'SceneCapabilityError'; } } + +export class ScenePrefabError extends SceneError { + constructor(message: string, code = 'SCENE_PREFAB_ERROR', cause?: unknown) { + super(message, code, cause); + this.name = 'ScenePrefabError'; + } +} + +export class ScenePrefabValidationError extends ScenePrefabError { + constructor(message: string, cause?: unknown) { + super(message, 'SCENE_PREFAB_VALIDATION_ERROR', cause); + this.name = 'ScenePrefabValidationError'; + } +} + +export class ScenePrefabResolutionError extends ScenePrefabError { + constructor(message: string, cause?: unknown) { + super(message, 'SCENE_PREFAB_RESOLUTION_ERROR', cause); + this.name = 'ScenePrefabResolutionError'; + } +} + +export class ScenePrefabConflictError extends ScenePrefabError { + constructor(message: string, cause?: unknown) { + super(message, 'SCENE_PREFAB_CONFLICT_ERROR', cause); + this.name = 'ScenePrefabConflictError'; + } +} diff --git a/web/packages/scene-runtime/src/index.ts b/web/packages/scene-runtime/src/index.ts index 3b376bbd..06ad2181 100644 --- a/web/packages/scene-runtime/src/index.ts +++ b/web/packages/scene-runtime/src/index.ts @@ -56,14 +56,34 @@ export { } from './scene-profile'; export type { SceneActorRuntimeOptions } from './scene-actor-runtime'; export { SceneActorRuntime } from './scene-actor-runtime'; +export type { + AnimationStreamingBridgeOptions, + AnimationStreamingBridgeWorld, + AnimationStreamingChunkResolveResult, + AnimationStreamingChunkResolver, + AnimationStreamingRequestEvent, + AnimationStreamingResolveContext, + FailedAnimationStreamingChunk, + FetchAnimationStreamingResolverOptions, + ResolvedAnimationStreamingChunk, +} from './animation-streaming-bridge'; +export { + AnimationStreamingBridge, + bindAnimationStreamingBridge, + createFetchAnimationStreamingResolver, +} from './animation-streaming-bridge'; export { DEFAULT_SCENE_AMBIENT_LIGHT, DEFAULT_SCENE_CLEAR_COLOR, + DEFAULT_SCENE_GROUND_LIGHT, DEFAULT_SCENE_HEIGHT, DEFAULT_SCENE_RENDER_PASS_ID, + DEFAULT_SCENE_SKY_LIGHT, DEFAULT_SCENE_WIDTH, resolveSceneAmbientLight, resolveSceneClearColor, + resolveSceneGroundLight, + resolveSceneSkyLight, } from './scene-runtime-defaults'; export type { SceneSnapshotActorHost, @@ -72,13 +92,75 @@ export type { } from './scene-snapshot-runtime'; export { SceneSnapshotRuntime } from './scene-snapshot-runtime'; +export type { + CompiledRenderShaderEffect, + RenderShaderAttributeDefinition, + RenderShaderEffectDefinition, + RenderShaderEffectRenderStateDefinition, + RenderShaderInspectorControlDefinition, + RenderShaderInspectorOptionDefinition, + RenderShaderInterfaceDefinition, + RenderShaderLibraryDefinition, + RenderShaderPropertyDefinition, + RenderShaderSerializableValue, + RenderShaderStageDefinition, + RenderShaderStageName, + RenderShaderValueType, +} from '@axrone/render-core'; +export { + cloneRenderShaderEffectDefinition, + compileRenderShaderEffect, +} from '@axrone/render-core'; +export type { SceneShaderDefinitionFromEffectOptions } from './shader-effect'; +export { createSceneShaderDefinitionFromEffect } from './shader-effect'; +export type { + SceneMaterialInspectorControlDefinition, + SceneMaterialInspectorControlKind, + SceneMaterialInspectorSection, +} from './material-inspector'; +export { + createSceneMaterialInspectorControls, + createSceneMaterialInspectorSections, +} from './material-inspector'; +export type { + ResolveScenePrefabOptions, + ScenePrefabWorkflowOptions, +} from './scene-prefab-workflow'; +export { + createScenePrefabWorkflow, + resolveScenePrefab, + ScenePrefabWorkflow, +} from './scene-prefab-workflow'; +export { + applyScenePrefabOverrides, +} from './scene-prefab-operations'; +export { + diffScenePrefabDefinitions, + mergeScenePrefabDefinitions, +} from './scene-prefab-diff'; +export { + createScenePrefabComponentSelector, + createScenePrefabScopedNodeId, + findScenePrefabComponentIndex, + getScenePrefabComponentSelectorKey, + hasScenePrefabComposition, + serializeScenePrefabPropertyPath, +} from './scene-prefab-internals'; + export type { SceneBuiltInRegistry, SceneCanvasOptions, SceneClearFlag, SceneLoopState, + SceneMaterialAlphaMode, SceneMaterialDefinition, SceneMaterialHandle, + SceneMaterialPassDefinition, + SceneMaterialPassPrimitive, + SceneMaterialSurfaceDefinition, + SceneMaterialSurfaceFeaturesDefinition, + SceneMaterialSurfaceTextureBindingDefinition, + SceneMaterialShadingModel, SceneMaterialTextureBindingHandle, SceneMeshDefinition, SceneMeshHandle, @@ -88,8 +170,37 @@ export type { SceneMeshSemantic, SceneMeshTopology, SceneOptions, + ScenePrefabActorField, + ScenePrefabActorFieldValue, + ScenePrefabComponentId, + ScenePrefabComponentSelector, + ScenePrefabConflict, + ScenePrefabConflictBaseValue, + ScenePrefabConflictPolicy, + ScenePrefabConflictResolution, + ScenePrefabConflictResolver, ScenePrefabDefinition, + ScenePrefabDiffResult, + ScenePrefabId, ScenePrefabInstantiateOptions, + ScenePrefabInstanceId, + ScenePrefabMergeDefinitionResult, + ScenePrefabMergeOptions, + ScenePrefabMergeResult, + ScenePrefabMetadata, + ScenePrefabNestedInstance, + ScenePrefabNodeId, + ScenePrefabNodeSource, + ScenePrefabOverrideOperation, + ScenePrefabPropertyPath, + ScenePrefabPropertyPathSegment, + ScenePrefabPropertyPathString, + ScenePrefabReference, + ScenePrefabRegistrySource, + ScenePrefabResolveOptions, + ScenePrefabResolvedDefinition, + ScenePrefabResolutionResult, + ScenePrefabResolver, SceneRegistry, SceneRenderPassDefinition, SceneRenderPassHandle, @@ -115,4 +226,8 @@ export { SceneCapabilityError, SceneError, SceneLifecycleError, + ScenePrefabConflictError, + ScenePrefabError, + ScenePrefabResolutionError, + ScenePrefabValidationError, } from './errors'; diff --git a/web/packages/scene-runtime/src/lighting-collector.ts b/web/packages/scene-runtime/src/lighting-collector.ts index 7238214d..8dd0e926 100644 --- a/web/packages/scene-runtime/src/lighting-collector.ts +++ b/web/packages/scene-runtime/src/lighting-collector.ts @@ -1,41 +1,22 @@ import { Quat, Vec3 } from '@axrone/numeric'; -import type { Actor } from '@axrone/ecs-runtime'; -import type { Transform } from '@axrone/ecs-runtime'; +import { + LightKind as LightingLightKind, + LightingFrameResolver, + LightingRig, + LightSortMode, +} from '@axrone/lighting'; +import type { LightingSelectionState } from '@axrone/lighting'; +import type { Actor, Transform } from '@axrone/ecs-runtime'; import { DirectionalLight } from './components/directional-light'; import { PointLight } from './components/point-light'; import { SpotLight } from './components/spot-light'; const DEFAULT_LIGHT_DIRECTION = Object.freeze(new Vec3(0, -1, 0)); +const DEFAULT_LIGHT_ATTENUATION = 2; +const PRIMARY_DIRECTIONAL_PRIORITY = 1_000_000; +const EPSILON = 1e-6; -export interface SceneLightingState { - readonly ambient: Vec3; - hasDirectional: boolean; - readonly directionalDirection: Vec3; - readonly directionalColor: Vec3; - directionalIntensity: number; - readonly pointLightPosition: Vec3; - readonly pointLightColor: Vec3; - pointLightIntensity: number; - pointLightRange: number; - readonly spotLightPosition: Vec3; - readonly spotLightDirection: Vec3; - readonly spotLightColor: Vec3; - spotLightIntensity: number; - spotLightRange: number; - spotLightInnerCone: number; - spotLightOuterCone: number; - pointCount: number; - spotCount: number; - localLightCount: number; - localLightTypes: Int32Array; - localLightPositions: Float32Array; - localLightDirections: Float32Array; - localLightColors: Float32Array; - localLightIntensities: Float32Array; - localLightRanges: Float32Array; - localLightInnerCones: Float32Array; - localLightOuterCones: Float32Array; -} +export type SceneLightingState = LightingSelectionState; const resetVec3 = (vector: Vec3, x: number, y: number, z: number): void => { vector.x = x; @@ -43,86 +24,49 @@ const resetVec3 = (vector: Vec3, x: number, y: number, z: number): void => { vector.z = z; }; -const copyVec3 = (target: Vec3, source: Vec3): void => { - target.x = source.x; - target.y = source.y; - target.z = source.z; +const sameNumber = (left: number, right: number): boolean => Math.abs(left - right) <= EPSILON; + +const sameVec3 = (left: Readonly, right: Readonly): boolean => { + return ( + sameNumber(left.x, right.x) && + sameNumber(left.y, right.y) && + sameNumber(left.z, right.z) + ); }; +const buildLightId = ( + kind: (typeof LightingLightKind)[keyof typeof LightingLightKind], + componentId: string +): string => `${kind}:${componentId}`; + export class SceneLightingCollector { - private readonly _maxLocalLights: number; + private readonly _rig = new LightingRig(); + private readonly _resolver: LightingFrameResolver; private readonly _directionScratch = new Vec3(0, -1, 0); - private readonly _localLightTypesBase: Int32Array; - private readonly _localLightPositionsBase: Float32Array; - private readonly _localLightDirectionsBase: Float32Array; - private readonly _localLightColorsBase: Float32Array; - private readonly _localLightIntensitiesBase: Float32Array; - private readonly _localLightRangesBase: Float32Array; - private readonly _localLightInnerConesBase: Float32Array; - private readonly _localLightOuterConesBase: Float32Array; - private readonly _localLightTypesViews: readonly Int32Array[]; - private readonly _localLightPositionsViews: readonly Float32Array[]; - private readonly _localLightDirectionsViews: readonly Float32Array[]; - private readonly _localLightColorsViews: readonly Float32Array[]; - private readonly _localLightIntensitiesViews: readonly Float32Array[]; - private readonly _localLightRangesViews: readonly Float32Array[]; - private readonly _localLightInnerConesViews: readonly Float32Array[]; - private readonly _localLightOuterConesViews: readonly Float32Array[]; - private readonly _state: SceneLightingState; + private readonly _seenLightIds = new Set(); constructor(maxLocalLights: number) { - this._maxLocalLights = Math.max(1, maxLocalLights); - this._localLightTypesBase = new Int32Array(this._maxLocalLights); - this._localLightPositionsBase = new Float32Array(this._maxLocalLights * 3); - this._localLightDirectionsBase = new Float32Array(this._maxLocalLights * 3); - this._localLightColorsBase = new Float32Array(this._maxLocalLights * 3); - this._localLightIntensitiesBase = new Float32Array(this._maxLocalLights); - this._localLightRangesBase = new Float32Array(this._maxLocalLights); - this._localLightInnerConesBase = new Float32Array(this._maxLocalLights); - this._localLightOuterConesBase = new Float32Array(this._maxLocalLights); - this._localLightTypesViews = this._createIntViews(this._localLightTypesBase); - this._localLightPositionsViews = this._createFloatViews(this._localLightPositionsBase, 3); - this._localLightDirectionsViews = this._createFloatViews(this._localLightDirectionsBase, 3); - this._localLightColorsViews = this._createFloatViews(this._localLightColorsBase, 3); - this._localLightIntensitiesViews = this._createFloatViews(this._localLightIntensitiesBase); - this._localLightRangesViews = this._createFloatViews(this._localLightRangesBase); - this._localLightInnerConesViews = this._createFloatViews(this._localLightInnerConesBase); - this._localLightOuterConesViews = this._createFloatViews(this._localLightOuterConesBase); - this._state = { - ambient: new Vec3(), - hasDirectional: false, - directionalDirection: new Vec3(0, -1, 0), - directionalColor: new Vec3(), - directionalIntensity: 0, - pointLightPosition: new Vec3(), - pointLightColor: new Vec3(), - pointLightIntensity: 0, - pointLightRange: 0, - spotLightPosition: new Vec3(), - spotLightDirection: new Vec3(0, -1, 0), - spotLightColor: new Vec3(), - spotLightIntensity: 0, - spotLightRange: 0, - spotLightInnerCone: 0, - spotLightOuterCone: 0, - pointCount: 0, - spotCount: 0, - localLightCount: 0, - localLightTypes: this._localLightTypesViews[0]!, - localLightPositions: this._localLightPositionsViews[0]!, - localLightDirections: this._localLightDirectionsViews[0]!, - localLightColors: this._localLightColorsViews[0]!, - localLightIntensities: this._localLightIntensitiesViews[0]!, - localLightRanges: this._localLightRangesViews[0]!, - localLightInnerCones: this._localLightInnerConesViews[0]!, - localLightOuterCones: this._localLightOuterConesViews[0]!, - }; + const resolvedCapacity = Math.max(0, Math.trunc(maxLocalLights)); + + this._resolver = new LightingFrameResolver({ + capacity: { + maxDirectionalLights: 1, + maxPointLights: resolvedCapacity, + maxSpotLights: resolvedCapacity, + maxLocalLights: resolvedCapacity, + }, + sortMode: LightSortMode.Influence, + }); } - collect(actors: readonly Actor[], ambientBase: Readonly): SceneLightingState { - const state = this._state; - this._resetState(ambientBase); - let hasFallbackDirectional = false; + collect( + actors: readonly Actor[], + ambientBase: Readonly, + skyLightBase: Readonly = Vec3.ZERO, + groundLightBase: Readonly = Vec3.ZERO, + cameraPosition?: Readonly + ): SceneLightingState { + this._seenLightIds.clear(); for (const actor of actors) { if (!actor.active) { @@ -131,158 +75,166 @@ export class SceneLightingCollector { const directional = actor.getComponent(DirectionalLight); if (directional && directional.enabled) { - state.ambient.x += directional.ambientColor.x; - state.ambient.y += directional.ambientColor.y; - state.ambient.z += directional.ambientColor.z; - - const target = - directional.primary || !hasFallbackDirectional || !state.hasDirectional - ? state.directionalDirection - : null; - - if (target) { - this._writeDirection(directional.transform as Transform | undefined, target); - copyVec3(state.directionalColor, directional.color); - state.directionalIntensity = directional.intensity; - state.hasDirectional = true; - hasFallbackDirectional = hasFallbackDirectional || !directional.primary; - } - } - - if (state.localLightCount >= this._maxLocalLights) { - continue; + this._syncDirectionalLight(directional); } const point = actor.getComponent(PointLight); if (point && point.enabled) { - this._appendPointLight(point, state.localLightCount); - state.localLightCount += 1; - state.pointCount += 1; + this._syncPointLight(point); continue; } const spot = actor.getComponent(SpotLight); if (spot && spot.enabled) { - this._appendSpotLight(spot, state.localLightCount); - state.localLightCount += 1; - state.spotCount += 1; + this._syncSpotLight(spot); } } - this._applyViews(state.localLightCount); + this._removeStaleLights(); + this._syncEnvironment(ambientBase, skyLightBase, groundLightBase); - return state; + return this._resolver.resolve(this._rig, { + cameraPosition, + }); } - private _resetState(ambientBase: Readonly): void { - const state = this._state; - state.ambient.x = ambientBase.x; - state.ambient.y = ambientBase.y; - state.ambient.z = ambientBase.z; - state.hasDirectional = false; - resetVec3( - state.directionalDirection, - DEFAULT_LIGHT_DIRECTION.x, - DEFAULT_LIGHT_DIRECTION.y, - DEFAULT_LIGHT_DIRECTION.z - ); - resetVec3(state.directionalColor, 0, 0, 0); - state.directionalIntensity = 0; - resetVec3(state.pointLightPosition, 0, 0, 0); - resetVec3(state.pointLightColor, 0, 0, 0); - state.pointLightIntensity = 0; - state.pointLightRange = 0; - resetVec3(state.spotLightPosition, 0, 0, 0); - resetVec3( - state.spotLightDirection, - DEFAULT_LIGHT_DIRECTION.x, - DEFAULT_LIGHT_DIRECTION.y, - DEFAULT_LIGHT_DIRECTION.z - ); - resetVec3(state.spotLightColor, 0, 0, 0); - state.spotLightIntensity = 0; - state.spotLightRange = 0; - state.spotLightInnerCone = 0; - state.spotLightOuterCone = 0; - state.pointCount = 0; - state.spotCount = 0; - state.localLightCount = 0; - this._localLightTypesBase.fill(0); - this._localLightPositionsBase.fill(0); - this._localLightDirectionsBase.fill(0); - this._localLightColorsBase.fill(0); - this._localLightIntensitiesBase.fill(0); - this._localLightRangesBase.fill(0); - this._localLightInnerConesBase.fill(0); - this._localLightOuterConesBase.fill(0); - } + private _syncDirectionalLight(light: DirectionalLight): void { + const lightId = buildLightId(LightingLightKind.Directional, String(light.id)); + const priority = light.primary ? PRIMARY_DIRECTIONAL_PRIORITY : 0; + const existing = this._rig.get(lightId); + + this._seenLightIds.add(lightId); + this._writeDirection(light.transform as Transform | undefined, this._directionScratch); + + if (existing?.kind === LightingLightKind.Directional) { + if ( + sameVec3(existing.color, light.color) && + sameVec3(existing.ambient, light.ambientColor) && + sameVec3(existing.direction, this._directionScratch) && + sameNumber(existing.intensity, light.intensity) && + sameNumber(existing.priority, priority) + ) { + return; + } - private _appendPointLight(light: PointLight, slot: number): void { - const state = this._state; - const offset = slot * 3; - const transform = light.transform as Transform | undefined; - const position = transform?.worldPosition; + this._rig.update(lightId, { + color: light.color, + ambient: light.ambientColor, + intensity: light.intensity, + priority, + direction: this._directionScratch, + }); + return; + } + + if (existing) { + this._rig.remove(lightId); + } - if (position) { - this._localLightPositionsBase[offset] = position.x; - this._localLightPositionsBase[offset + 1] = position.y; - this._localLightPositionsBase[offset + 2] = position.z; + this._rig.addDirectional({ + id: lightId, + color: light.color, + ambient: light.ambientColor, + intensity: light.intensity, + priority, + direction: this._directionScratch, + }); + } - if (state.pointCount === 0) { - copyVec3(state.pointLightPosition, position); + private _syncPointLight(light: PointLight): void { + const lightId = buildLightId(LightingLightKind.Point, String(light.id)); + const transform = light.transform as Transform | undefined; + const position = transform?.worldPosition ?? Vec3.ZERO; + const existing = this._rig.get(lightId); + + this._seenLightIds.add(lightId); + + if (existing?.kind === LightingLightKind.Point) { + if ( + sameVec3(existing.color, light.color) && + sameVec3(existing.position, position) && + sameNumber(existing.intensity, light.intensity) && + sameNumber(existing.range, light.range) + ) { + return; } - } - this._localLightColorsBase[offset] = light.color.x; - this._localLightColorsBase[offset + 1] = light.color.y; - this._localLightColorsBase[offset + 2] = light.color.z; - this._localLightIntensitiesBase[slot] = light.intensity; - this._localLightRangesBase[slot] = light.range; + this._rig.update(lightId, { + color: light.color, + intensity: light.intensity, + range: light.range, + attenuation: DEFAULT_LIGHT_ATTENUATION, + position, + }); + return; + } - if (state.pointCount === 0) { - copyVec3(state.pointLightColor, light.color); - state.pointLightIntensity = light.intensity; - state.pointLightRange = light.range; + if (existing) { + this._rig.remove(lightId); } + + this._rig.addPoint({ + id: lightId, + color: light.color, + intensity: light.intensity, + range: light.range, + attenuation: DEFAULT_LIGHT_ATTENUATION, + position, + }); } - private _appendSpotLight(light: SpotLight, slot: number): void { - const state = this._state; - const offset = slot * 3; + private _syncSpotLight(light: SpotLight): void { + const lightId = buildLightId(LightingLightKind.Spot, String(light.id)); const transform = light.transform as Transform | undefined; - const position = transform?.worldPosition; + const position = transform?.worldPosition ?? Vec3.ZERO; + const existing = this._rig.get(lightId); - if (position) { - this._localLightPositionsBase[offset] = position.x; - this._localLightPositionsBase[offset + 1] = position.y; - this._localLightPositionsBase[offset + 2] = position.z; + this._seenLightIds.add(lightId); + this._writeDirection(transform, this._directionScratch); - if (state.spotCount === 0) { - copyVec3(state.spotLightPosition, position); + if (existing?.kind === LightingLightKind.Spot) { + if ( + sameVec3(existing.color, light.color) && + sameVec3(existing.position, position) && + sameVec3(existing.direction, this._directionScratch) && + sameNumber(existing.intensity, light.intensity) && + sameNumber(existing.range, light.range) && + sameNumber(existing.innerConeCosine, Math.cos(light.innerConeAngle)) && + sameNumber(existing.outerConeCosine, Math.cos(light.outerConeAngle)) + ) { + return; } + + this._rig.update(lightId, { + color: light.color, + intensity: light.intensity, + range: light.range, + attenuation: DEFAULT_LIGHT_ATTENUATION, + position, + direction: this._directionScratch, + coneMode: 'angle', + innerConeAngle: light.innerConeAngle, + outerConeAngle: light.outerConeAngle, + }); + return; } - this._writeDirection(transform, this._directionScratch); - this._localLightTypesBase[slot] = 1; - this._localLightDirectionsBase[offset] = this._directionScratch.x; - this._localLightDirectionsBase[offset + 1] = this._directionScratch.y; - this._localLightDirectionsBase[offset + 2] = this._directionScratch.z; - this._localLightColorsBase[offset] = light.color.x; - this._localLightColorsBase[offset + 1] = light.color.y; - this._localLightColorsBase[offset + 2] = light.color.z; - this._localLightIntensitiesBase[slot] = light.intensity; - this._localLightRangesBase[slot] = light.range; - this._localLightInnerConesBase[slot] = light.innerConeAngle; - this._localLightOuterConesBase[slot] = light.outerConeAngle; - - if (state.spotCount === 0) { - copyVec3(state.spotLightDirection, this._directionScratch); - copyVec3(state.spotLightColor, light.color); - state.spotLightIntensity = light.intensity; - state.spotLightRange = light.range; - state.spotLightInnerCone = light.innerConeAngle; - state.spotLightOuterCone = light.outerConeAngle; + if (existing) { + this._rig.remove(lightId); } + + this._rig.addSpot({ + id: lightId, + color: light.color, + intensity: light.intensity, + range: light.range, + attenuation: DEFAULT_LIGHT_ATTENUATION, + position, + direction: this._directionScratch, + coneMode: 'angle', + innerConeAngle: light.innerConeAngle, + outerConeAngle: light.outerConeAngle, + }); } private _writeDirection(transform: Transform | undefined, target: Vec3): void { @@ -300,30 +252,41 @@ export class SceneLightingCollector { Vec3.normalize(this._directionScratch, target); } - private _createIntViews(source: Int32Array): readonly Int32Array[] { - return Object.freeze( - Array.from({ length: this._maxLocalLights + 1 }, (_, count) => - source.subarray(0, Math.max(1, count)) - ) - ); - } + private _removeStaleLights(): void { + const staleIds: string[] = []; - private _createFloatViews(source: Float32Array, stride: number = 1): readonly Float32Array[] { - return Object.freeze( - Array.from({ length: this._maxLocalLights + 1 }, (_, count) => - source.subarray(0, Math.max(1, count) * stride) - ) - ); + for (const light of this._rig.list()) { + const lightId = String(light.id); + + if (!this._seenLightIds.has(lightId)) { + staleIds.push(lightId); + } + } + + for (const lightId of staleIds) { + this._rig.remove(lightId); + } } - private _applyViews(localLightCount: number): void { - this._state.localLightTypes = this._localLightTypesViews[localLightCount]!; - this._state.localLightPositions = this._localLightPositionsViews[localLightCount]!; - this._state.localLightDirections = this._localLightDirectionsViews[localLightCount]!; - this._state.localLightColors = this._localLightColorsViews[localLightCount]!; - this._state.localLightIntensities = this._localLightIntensitiesViews[localLightCount]!; - this._state.localLightRanges = this._localLightRangesViews[localLightCount]!; - this._state.localLightInnerCones = this._localLightInnerConesViews[localLightCount]!; - this._state.localLightOuterCones = this._localLightOuterConesViews[localLightCount]!; + private _syncEnvironment( + ambientBase: Readonly, + skyLightBase: Readonly, + groundLightBase: Readonly + ): void { + const environment = this._rig.environment; + + if ( + sameVec3(environment.ambient, ambientBase) && + sameVec3(environment.sky, skyLightBase) && + sameVec3(environment.ground, groundLightBase) + ) { + return; + } + + this._rig.setEnvironment({ + ambient: ambientBase, + sky: skyLightBase, + ground: groundLightBase, + }); } } diff --git a/web/packages/scene-runtime/src/lighting-uniform-binder.ts b/web/packages/scene-runtime/src/lighting-uniform-binder.ts index 34639a12..b753e174 100644 --- a/web/packages/scene-runtime/src/lighting-uniform-binder.ts +++ b/web/packages/scene-runtime/src/lighting-uniform-binder.ts @@ -1,3 +1,4 @@ +import { createLightingUniformValueMap } from '@axrone/lighting'; import { Vec3 } from '@axrone/numeric'; import type { MeshRenderer } from './components/mesh-renderer'; import type { SceneLightingState } from './lighting-collector'; @@ -12,82 +13,22 @@ export class SceneLightingUniformBinder { renderer: Pick, lighting: SceneLightingState ): void { - const receiveLighting = renderer.receiveLighting; + const values = createLightingUniformValueMap(lighting); - this._writer.write(shader, 'u_ReceiveLighting', receiveLighting); - this._writer.write( - shader, - 'u_AmbientLight', - receiveLighting ? lighting.ambient : Vec3.ZERO - ); - this._writer.write(shader, 'u_LightDirection', lighting.directionalDirection); - this._writer.write( - shader, - 'u_LightColor', - receiveLighting && lighting.hasDirectional ? lighting.directionalColor : Vec3.ZERO - ); - this._writer.write( - shader, - 'u_LightIntensity', - receiveLighting && lighting.hasDirectional ? lighting.directionalIntensity : 0 - ); - this._writer.write(shader, 'u_PointLightCount', receiveLighting ? lighting.pointCount : 0); - this._writer.write(shader, 'u_PointLightPosition', lighting.pointLightPosition); - this._writer.write( - shader, - 'u_PointLightColor', - receiveLighting && lighting.pointCount > 0 ? lighting.pointLightColor : Vec3.ZERO - ); - this._writer.write( - shader, - 'u_PointLightIntensity', - receiveLighting && lighting.pointCount > 0 ? lighting.pointLightIntensity : 0 - ); - this._writer.write( - shader, - 'u_PointLightRange', - receiveLighting && lighting.pointCount > 0 ? lighting.pointLightRange : 0 - ); - this._writer.write(shader, 'u_SpotLightCount', receiveLighting ? lighting.spotCount : 0); - this._writer.write(shader, 'u_SpotLightPosition', lighting.spotLightPosition); - this._writer.write(shader, 'u_SpotLightDirection', lighting.spotLightDirection); - this._writer.write( - shader, - 'u_SpotLightColor', - receiveLighting && lighting.spotCount > 0 ? lighting.spotLightColor : Vec3.ZERO - ); - this._writer.write( - shader, - 'u_SpotLightIntensity', - receiveLighting && lighting.spotCount > 0 ? lighting.spotLightIntensity : 0 - ); - this._writer.write( - shader, - 'u_SpotLightRange', - receiveLighting && lighting.spotCount > 0 ? lighting.spotLightRange : 0 - ); - this._writer.write( - shader, - 'u_SpotLightInnerCone', - receiveLighting && lighting.spotCount > 0 ? lighting.spotLightInnerCone : 0 - ); - this._writer.write( - shader, - 'u_SpotLightOuterCone', - receiveLighting && lighting.spotCount > 0 ? lighting.spotLightOuterCone : 0 - ); - this._writer.write( - shader, - 'u_LocalLightCount', - receiveLighting ? lighting.localLightCount : 0 - ); - this._writer.write(shader, 'u_LocalLightType', lighting.localLightTypes); - this._writer.write(shader, 'u_LocalLightPosition', lighting.localLightPositions); - this._writer.write(shader, 'u_LocalLightDirection', lighting.localLightDirections); - this._writer.write(shader, 'u_LocalLightColor', lighting.localLightColors); - this._writer.write(shader, 'u_LocalLightIntensity', lighting.localLightIntensities); - this._writer.write(shader, 'u_LocalLightRange', lighting.localLightRanges); - this._writer.write(shader, 'u_LocalLightInnerCone', lighting.localLightInnerCones); - this._writer.write(shader, 'u_LocalLightOuterCone', lighting.localLightOuterCones); + this._writer.write(shader, 'u_ReceiveLighting', renderer.receiveLighting); + + for (const [name, value] of Object.entries(values)) { + this._writer.write(shader, name, value); + } + + if (!renderer.receiveLighting) { + this._writer.write(shader, 'u_AmbientLight', Vec3.ZERO); + this._writer.write(shader, 'u_SkyLight', Vec3.ZERO); + this._writer.write(shader, 'u_GroundLight', Vec3.ZERO); + this._writer.write(shader, 'u_DirectionalLightCount', 0); + this._writer.write(shader, 'u_PointLightCount', 0); + this._writer.write(shader, 'u_SpotLightCount', 0); + this._writer.write(shader, 'u_LocalLightCount', 0); + } } } diff --git a/web/packages/scene-runtime/src/material-inspector.ts b/web/packages/scene-runtime/src/material-inspector.ts new file mode 100644 index 00000000..08a0a642 --- /dev/null +++ b/web/packages/scene-runtime/src/material-inspector.ts @@ -0,0 +1,186 @@ +import type { + RenderShaderInspectorOptionDefinition, + RenderShaderPropertyDefinition, + RenderShaderSerializableValue, +} from '@axrone/render-core'; +import type { + SceneMaterialDefinition, + SceneShaderDefinition, + SceneTextureBindingDefinition, + SceneUniformValue, +} from './types'; + +export type SceneMaterialInspectorControlKind = + | 'number' + | 'slider' + | 'color' + | 'texture' + | 'toggle' + | 'select'; + +export interface SceneMaterialInspectorControlDefinition { + readonly name: string; + readonly label: string; + readonly group: string; + readonly control: SceneMaterialInspectorControlKind; + readonly valueType: RenderShaderPropertyDefinition['type']; + readonly value?: RenderShaderSerializableValue | SceneTextureBindingDefinition; + readonly defaultValue?: RenderShaderSerializableValue; + readonly min?: number; + readonly max?: number; + readonly step?: number; + readonly options?: readonly RenderShaderInspectorOptionDefinition[]; +} + +export interface SceneMaterialInspectorSection { + readonly id: string; + readonly title: string; + readonly controls: readonly SceneMaterialInspectorControlDefinition[]; +} + +const MATERIAL_SCOPE = 'material'; +const HIDDEN_GROUP = 'Hidden'; + +const isSamplerType = (type: RenderShaderPropertyDefinition['type']): boolean => + type === 'sampler2D' || type === 'samplerCube'; + +const isBooleanType = (type: RenderShaderPropertyDefinition['type']): boolean => + type === 'bool' || type === 'bvec2' || type === 'bvec3' || type === 'bvec4'; + +const humanizePropertyName = (value: string): string => + value + .replace(/^_+/, '') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .trim() + .replace(/\b\w/g, (match) => match.toUpperCase()); + +const slugify = (value: string): string => + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'properties'; + +const normalizeUniformValue = ( + value: SceneUniformValue | undefined +): RenderShaderSerializableValue | undefined => { + if (value === undefined) { + return undefined; + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (Array.isArray(value)) { + return [...value]; + } + + if (ArrayBuffer.isView(value)) { + return Array.from(value as ArrayLike); + } + + const candidate = value as Partial>; + const vectorValues = [candidate.x, candidate.y, candidate.z, candidate.w].filter( + (entry): entry is number => typeof entry === 'number' + ); + if (vectorValues.length > 0) { + return vectorValues; + } + + return undefined; +}; + +const resolveControlKind = ( + property: RenderShaderPropertyDefinition +): SceneMaterialInspectorControlKind => { + const explicitControl = property.inspector?.control; + if (explicitControl && explicitControl !== 'auto') { + switch (explicitControl) { + case 'color': + case 'slider': + case 'texture': + case 'toggle': + case 'select': + return explicitControl; + default: + break; + } + } + + if (property.inspector?.options?.length) { + return 'select'; + } + + if (isSamplerType(property.type)) { + return 'texture'; + } + + if (isBooleanType(property.type)) { + return 'toggle'; + } + + if ( + property.inspector?.min !== undefined || + property.inspector?.max !== undefined || + property.inspector?.step !== undefined + ) { + return 'slider'; + } + + return 'number'; +}; + +export const createSceneMaterialInspectorControls = ( + shader: SceneShaderDefinition, + material?: SceneMaterialDefinition +): readonly SceneMaterialInspectorControlDefinition[] => { + const properties = shader.effect?.properties ?? []; + + return properties + .filter( + (property) => + property.scope === MATERIAL_SCOPE && + property.inspector?.hidden !== true && + property.inspector?.group !== HIDDEN_GROUP + ) + .map((property) => ({ + name: property.name, + label: property.inspector?.label ?? humanizePropertyName(property.name), + group: property.inspector?.group ?? 'Properties', + control: resolveControlKind(property), + valueType: property.type, + value: isSamplerType(property.type) + ? material?.textures?.[property.name] + : normalizeUniformValue(material?.uniforms?.[property.name]), + defaultValue: property.defaultValue, + min: property.inspector?.min, + max: property.inspector?.max, + step: property.inspector?.step, + options: property.inspector?.options ? [...property.inspector.options] : undefined, + })); +}; + +export const createSceneMaterialInspectorSections = ( + shader: SceneShaderDefinition, + material?: SceneMaterialDefinition +): readonly SceneMaterialInspectorSection[] => { + const sections = new Map(); + + for (const control of createSceneMaterialInspectorControls(shader, material)) { + const bucket = sections.get(control.group); + if (bucket) { + bucket.push(control); + continue; + } + + sections.set(control.group, [control]); + } + + return [...sections.entries()].map(([title, controls]) => ({ + id: slugify(title), + title, + controls, + })); +}; diff --git a/web/packages/scene-runtime/src/material-registry.ts b/web/packages/scene-runtime/src/material-registry.ts index 1f00af0b..87035c05 100644 --- a/web/packages/scene-runtime/src/material-registry.ts +++ b/web/packages/scene-runtime/src/material-registry.ts @@ -1,7 +1,16 @@ import { cloneTextureBinding, decodeSceneValue, encodeSceneValue } from './serialization'; import type { + SceneMaterialBlendStateDefinition, + SceneMaterialBlendTargetStateDefinition, SceneMaterialDefinition, + SceneMaterialDepthStencilStateDefinition, SceneMaterialHandle, + SceneMaterialPassDefinition, + SceneMaterialRasterizerStateDefinition, + SceneMaterialSurfaceDefinition, + SceneMaterialSurfaceFeaturesDefinition, + SceneMaterialSurfaceTextureBindingDefinition, + SceneMaterialStencilFaceStateDefinition, SceneTextureBindingDefinition, SceneUniformValue, } from './types'; @@ -23,10 +32,138 @@ export interface SceneMaterialResource { readonly shaderId: string; readonly uniforms: Map; readonly textureBindings: Map; + readonly surface: SceneMaterialSurfaceDefinition | null; + readonly passes: readonly SceneMaterialPassDefinition[]; } const cloneSceneValue = (value: T): T => decodeSceneValue(encodeSceneValue(value)) as T; +const cloneSurfaceFeaturesDefinition = ( + definition: SceneMaterialSurfaceFeaturesDefinition | undefined +): SceneMaterialSurfaceFeaturesDefinition | undefined => + definition + ? { + ...definition, + } + : undefined; + +const cloneSurfaceTextureBindingDefinition = ( + definition: SceneMaterialSurfaceTextureBindingDefinition | undefined +): SceneMaterialSurfaceTextureBindingDefinition | undefined => + definition + ? { + ...definition, + scale: definition.scale + ? ([...definition.scale] as readonly [number, number]) + : undefined, + offset: definition.offset + ? ([...definition.offset] as readonly [number, number]) + : undefined, + } + : undefined; + +export const cloneSceneMaterialSurfaceDefinition = ( + definition: SceneMaterialSurfaceDefinition | undefined +): SceneMaterialSurfaceDefinition | undefined => + definition + ? { + ...definition, + features: cloneSurfaceFeaturesDefinition(definition.features), + tilingOffset: definition.tilingOffset + ? ([...definition.tilingOffset] as readonly [number, number, number, number]) + : undefined, + albedo: definition.albedo + ? ([...definition.albedo] as readonly [number, number, number, number]) + : undefined, + albedoScale: definition.albedoScale + ? ([...definition.albedoScale] as readonly [number, number, number]) + : undefined, + emissive: definition.emissive + ? ([...definition.emissive] as readonly [number, number, number]) + : undefined, + emissiveScale: definition.emissiveScale + ? ([...definition.emissiveScale] as readonly [number, number, number]) + : undefined, + albedoMap: cloneSurfaceTextureBindingDefinition(definition.albedoMap), + normalMap: cloneSurfaceTextureBindingDefinition(definition.normalMap), + pbrMap: cloneSurfaceTextureBindingDefinition(definition.pbrMap), + metallicRoughnessMap: cloneSurfaceTextureBindingDefinition( + definition.metallicRoughnessMap + ), + occlusionMap: cloneSurfaceTextureBindingDefinition(definition.occlusionMap), + emissiveMap: cloneSurfaceTextureBindingDefinition(definition.emissiveMap), + } + : undefined; + +const cloneStencilFaceStateDefinition = ( + definition: SceneMaterialStencilFaceStateDefinition | undefined +): SceneMaterialStencilFaceStateDefinition | undefined => + definition + ? { + ...definition, + } + : undefined; + +const cloneRasterizerStateDefinition = ( + definition: SceneMaterialRasterizerStateDefinition | undefined +): SceneMaterialRasterizerStateDefinition | undefined => + definition + ? { + ...definition, + } + : undefined; + +const cloneDepthStencilStateDefinition = ( + definition: SceneMaterialDepthStencilStateDefinition | undefined +): SceneMaterialDepthStencilStateDefinition | undefined => + definition + ? { + ...definition, + front: cloneStencilFaceStateDefinition(definition.front), + back: cloneStencilFaceStateDefinition(definition.back), + } + : undefined; + +const cloneBlendTargetStateDefinition = ( + definition: SceneMaterialBlendTargetStateDefinition +): SceneMaterialBlendTargetStateDefinition => ({ + ...definition, + colorWriteMask: definition.colorWriteMask + ? ([...definition.colorWriteMask] as readonly [boolean, boolean, boolean, boolean]) + : undefined, +}); + +const cloneBlendStateDefinition = ( + definition: SceneMaterialBlendStateDefinition | undefined +): SceneMaterialBlendStateDefinition | undefined => + definition + ? { + ...definition, + blendColor: definition.blendColor + ? ([...definition.blendColor] as readonly [number, number, number, number]) + : undefined, + targets: definition.targets + ? Object.freeze(definition.targets.map(cloneBlendTargetStateDefinition)) + : undefined, + } + : undefined; + +const cloneSceneMaterialPassDefinition = ( + definition: SceneMaterialPassDefinition +): SceneMaterialPassDefinition => ({ + ...definition, + rasterizerState: cloneRasterizerStateDefinition(definition.rasterizerState), + depthStencilState: cloneDepthStencilStateDefinition(definition.depthStencilState), + blendState: cloneBlendStateDefinition(definition.blendState), +}); + +const cloneSceneMaterialPassDefinitions = ( + definitions: readonly SceneMaterialPassDefinition[] | undefined +): readonly SceneMaterialPassDefinition[] | undefined => + definitions + ? Object.freeze(definitions.map(cloneSceneMaterialPassDefinition)) + : undefined; + const compareTextureBindings = ( left: readonly [string, SceneMaterialTextureBinding], right: readonly [string, SceneMaterialTextureBinding] @@ -40,6 +177,7 @@ const toHandle = (material: SceneMaterialResource): SceneMaterialHandle => ({ id: material.id, shaderId: material.shaderId, textureBindings: [...material.textureBindings.keys()], + passIds: material.passes.map((pass) => pass.id), }); const createTextureSlots = ( @@ -110,8 +248,23 @@ export const cloneSceneMaterialDefinition = ( ]) ) : undefined, + surface: cloneSceneMaterialSurfaceDefinition(definition.surface), + passes: cloneSceneMaterialPassDefinitions(definition.passes), }); +export const resolveSceneMaterialPass = ( + material: Pick, + materialPassId: string | null | undefined +): SceneMaterialPassDefinition | null => { + const passes = material.passes ?? []; + + if (materialPassId === null || materialPassId === undefined) { + return passes[0] ?? null; + } + + return passes.find((pass) => pass.id === materialPassId) ?? null; +}; + export class SceneMaterialRegistry { private readonly _resources = new Map(); private readonly _definitions = new Map(); @@ -133,6 +286,8 @@ export class SceneMaterialRegistry { normalizeSceneTextureBinding(binding), ]) ), + surface: cloneSceneMaterialSurfaceDefinition(definition.surface) ?? null, + passes: cloneSceneMaterialPassDefinitions(definition.passes) ?? Object.freeze([]), }; this._resources.set(resource.id, resource); diff --git a/web/packages/scene-runtime/src/prefab.ts b/web/packages/scene-runtime/src/prefab.ts new file mode 100644 index 00000000..e4572534 --- /dev/null +++ b/web/packages/scene-runtime/src/prefab.ts @@ -0,0 +1,61 @@ +export type { + ScenePrefabActorField, + ScenePrefabActorFieldValue, + ScenePrefabComponentId, + ScenePrefabComponentSelector, + ScenePrefabConflict, + ScenePrefabConflictBaseValue, + ScenePrefabConflictPolicy, + ScenePrefabConflictResolution, + ScenePrefabConflictResolver, + ScenePrefabDefinition, + ScenePrefabDiffResult, + ScenePrefabId, + ScenePrefabInstanceId, + ScenePrefabMergeDefinitionResult, + ScenePrefabMergeOptions, + ScenePrefabMergeResult, + ScenePrefabMetadata, + ScenePrefabNestedInstance, + ScenePrefabNodeId, + ScenePrefabNodeSource, + ScenePrefabOverrideOperation, + ScenePrefabPropertyPath, + ScenePrefabPropertyPathSegment, + ScenePrefabPropertyPathString, + ScenePrefabReference, + ScenePrefabRegistrySource, + ScenePrefabResolveOptions, + ScenePrefabResolvedDefinition, + ScenePrefabResolutionResult, + ScenePrefabResolver, +} from './types'; +export { + createScenePrefabComponentSelector, + createScenePrefabScopedNodeId, + findScenePrefabComponentIndex, + getScenePrefabComponentSelectorKey, + hasScenePrefabComposition, + isScenePrefabReference, + serializeScenePrefabPropertyPath, +} from './scene-prefab-internals'; +export { applyScenePrefabOverrides } from './scene-prefab-operations'; +export { + diffScenePrefabDefinitions, + mergeScenePrefabDefinitions, +} from './scene-prefab-diff'; +export type { + ResolveScenePrefabOptions, + ScenePrefabWorkflowOptions, +} from './scene-prefab-workflow'; +export { + createScenePrefabWorkflow, + resolveScenePrefab, + ScenePrefabWorkflow, +} from './scene-prefab-workflow'; +export { + ScenePrefabConflictError, + ScenePrefabError, + ScenePrefabResolutionError, + ScenePrefabValidationError, +} from './errors'; \ No newline at end of file diff --git a/web/packages/scene-runtime/src/render-frame-state.ts b/web/packages/scene-runtime/src/render-frame-state.ts index 72483779..385a45b9 100644 --- a/web/packages/scene-runtime/src/render-frame-state.ts +++ b/web/packages/scene-runtime/src/render-frame-state.ts @@ -33,6 +33,11 @@ export class SceneRenderFrameState { this._trianglesSubmitted += Math.floor(mesh.vertexCount / 3); } + recordTriangles(triangleCount: number, drawCalls: number = 1): void { + this._drawCalls += Math.max(0, Math.floor(drawCalls)); + this._trianglesSubmitted += Math.max(0, Math.floor(triangleCount)); + } + get frame(): number { return this._frame; } diff --git a/web/packages/scene-runtime/src/render-item-collector.ts b/web/packages/scene-runtime/src/render-item-collector.ts index f805ad34..ee88973a 100644 --- a/web/packages/scene-runtime/src/render-item-collector.ts +++ b/web/packages/scene-runtime/src/render-item-collector.ts @@ -7,10 +7,34 @@ export interface SceneRenderItem { renderer: MeshRenderer; } +export interface SceneRenderItemSortOptions { + readonly cameraPosition?: { + readonly x: number; + readonly y: number; + readonly z: number; + }; + readonly isBlended?: (renderer: MeshRenderer) => boolean; +} + +const distanceSquaredToCamera = ( + transform: Transform, + cameraPosition: NonNullable +): number => { + const worldPosition = transform.worldPosition; + const dx = worldPosition.x - cameraPosition.x; + const dy = worldPosition.y - cameraPosition.y; + const dz = worldPosition.z - cameraPosition.z; + return dx * dx + dy * dy + dz * dz; +}; + export class SceneRenderItemCollector { private readonly _items: SceneRenderItem[] = []; - collect(actors: readonly Actor[], passId: string): readonly SceneRenderItem[] { + collect( + actors: readonly Actor[], + passId: string, + sortOptions: SceneRenderItemSortOptions = {} + ): readonly SceneRenderItem[] { let count = 0; for (const actor of actors) { @@ -39,7 +63,27 @@ export class SceneRenderItemCollector { } this._items.length = count; - this._items.sort((left, right) => left.renderer.renderOrder - right.renderer.renderOrder); + this._items.sort((left, right) => { + const renderOrderDelta = left.renderer.renderOrder - right.renderer.renderOrder; + if (renderOrderDelta !== 0) { + return renderOrderDelta; + } + + const leftBlended = sortOptions.isBlended?.(left.renderer) ?? false; + const rightBlended = sortOptions.isBlended?.(right.renderer) ?? false; + if (leftBlended !== rightBlended) { + return leftBlended ? 1 : -1; + } + + if (!leftBlended || !sortOptions.cameraPosition) { + return 0; + } + + return ( + distanceSquaredToCamera(right.transform, sortOptions.cameraPosition) - + distanceSquaredToCamera(left.transform, sortOptions.cameraPosition) + ); + }); return this._items; } } diff --git a/web/packages/scene-runtime/src/render-pass-preparer.ts b/web/packages/scene-runtime/src/render-pass-preparer.ts index 4591d33d..d3c38b62 100644 --- a/web/packages/scene-runtime/src/render-pass-preparer.ts +++ b/web/packages/scene-runtime/src/render-pass-preparer.ts @@ -1,6 +1,7 @@ import { Vec4 } from '@axrone/numeric'; import type { Camera } from './components/camera'; import type { SceneRenderPassResource } from './render-pass-registry'; +import type { SceneClearFlag } from './types'; const isSameVec4 = ( left: Vec4 | null | undefined, @@ -16,6 +17,12 @@ const isSameVec4 = ( left.z === right.z && left.w === right.w); +const resolveClearFlags = ( + renderPassClearFlags: readonly SceneClearFlag[], + cameraClearFlags?: readonly SceneClearFlag[] +): readonly SceneClearFlag[] => + cameraClearFlags ? renderPassClearFlags.filter((flag) => cameraClearFlags.includes(flag)) : renderPassClearFlags; + export class SceneRenderPassPreparer { private _clearColor: Vec4 | null = null; private _clearDepth: number | null = null; @@ -26,7 +33,7 @@ export class SceneRenderPassPreparer { ) {} prepare(renderPass: SceneRenderPassResource, camera?: Camera): void { - const clearFlags = renderPass.clearFlags; + const clearFlags = resolveClearFlags(renderPass.clearFlags, camera?.clearFlags); let mask = 0; if (clearFlags.includes('color')) { diff --git a/web/packages/scene-runtime/src/render-pass-registry.ts b/web/packages/scene-runtime/src/render-pass-registry.ts index 6537b227..df2c5481 100644 --- a/web/packages/scene-runtime/src/render-pass-registry.ts +++ b/web/packages/scene-runtime/src/render-pass-registry.ts @@ -1,14 +1,11 @@ import { Vec4 } from '@axrone/numeric'; -import type { - SceneClearFlag, - SceneRenderPassDefinition, - SceneRenderPassHandle, -} from './types'; +import type { SceneClearFlag, SceneRenderPassDefinition, SceneRenderPassHandle } from './types'; export interface SceneRenderPassResource { readonly id: string; readonly order: number; readonly rendererPassId: string; + readonly materialPassId: string | null; readonly enabled: boolean; readonly clearFlags: readonly SceneClearFlag[]; readonly clearColor: Vec4 | null; @@ -16,6 +13,13 @@ export interface SceneRenderPassResource { readonly depthTest?: boolean; readonly cull?: boolean; readonly blend?: boolean; + readonly stencilTest?: boolean; + readonly stencilFunc?: number; + readonly stencilRef?: number; + readonly stencilMask?: number; + readonly stencilFail?: number; + readonly stencilZFail?: number; + readonly stencilZPass?: number; } export interface SceneRenderPassRegistryOptions { @@ -24,14 +28,13 @@ export interface SceneRenderPassRegistryOptions { } const cloneVec4 = (value: Vec4 | readonly [number, number, number, number]): Vec4 => - value instanceof Vec4 - ? new Vec4(value.x, value.y, value.z, value.w) - : new Vec4(value[0], value[1], value[2], value[3]); + value instanceof Vec4 ? Vec4.from(value) : Vec4.fromArray(value); const toHandle = (renderPass: SceneRenderPassResource): SceneRenderPassHandle => ({ id: renderPass.id, order: renderPass.order, rendererPassId: renderPass.rendererPassId, + materialPassId: renderPass.materialPassId, enabled: renderPass.enabled, }); @@ -82,6 +85,7 @@ export class SceneRenderPassRegistry { id: definition.id, order: definition.order ?? this._resources.size, rendererPassId: definition.rendererPassId ?? definition.id, + materialPassId: definition.materialPassId ?? null, enabled: definition.enabled ?? true, clearFlags: definition.clearFlags ?? @@ -100,6 +104,13 @@ export class SceneRenderPassRegistry { depthTest: definition.depthTest, cull: definition.cull, blend: definition.blend, + stencilTest: definition.stencilTest, + stencilFunc: definition.stencilFunc, + stencilRef: definition.stencilRef, + stencilMask: definition.stencilMask, + stencilFail: definition.stencilFail, + stencilZFail: definition.stencilZFail, + stencilZPass: definition.stencilZPass, }; this._resources.set(definition.id, resource); @@ -158,9 +169,7 @@ export class SceneRenderPassRegistry { this._definitionsCache = Object.freeze( [...this._definitions.values()] .sort(compareRenderPassDefinitions) - .map((definition) => - Object.freeze(cloneSceneRenderPassDefinition(definition)) - ) + .map((definition) => Object.freeze(cloneSceneRenderPassDefinition(definition))) ); } diff --git a/web/packages/scene-runtime/src/render-state-applier.ts b/web/packages/scene-runtime/src/render-state-applier.ts index ac72d50a..231a4949 100644 --- a/web/packages/scene-runtime/src/render-state-applier.ts +++ b/web/packages/scene-runtime/src/render-state-applier.ts @@ -1,59 +1,524 @@ +import type { + SceneMaterialBlendFactor, + SceneMaterialBlendOperation, + SceneMaterialBlendTargetStateDefinition, + SceneMaterialCompareFunction, + SceneMaterialCullMode, + SceneMaterialFrontFace, + SceneMaterialPassDefinition, + SceneMaterialPassPrimitive, + SceneMaterialStencilFaceStateDefinition, + SceneMaterialStencilOperation, +} from './types'; import type { SceneRenderPassResource } from './render-pass-registry'; import type { SceneShaderResource } from './shader-registry'; +interface SceneResolvedStencilState { + readonly enabled: boolean; + readonly func: number; + readonly ref: number; + readonly readMask: number; + readonly writeMask: number; + readonly failOp: number; + readonly zFailOp: number; + readonly passOp: number; +} + +const DEFAULT_STENCIL_MASK = 0xff; +const DEFAULT_BLEND_COLOR = Object.freeze([0, 0, 0, 0] as const); +const DEFAULT_COLOR_WRITE_MASK = Object.freeze([true, true, true, true] as const); + +const isTupleEqual = ( + left: readonly number[] | null, + right: readonly number[] +): boolean => { + if (!left || left.length !== right.length) { + return false; + } + + for (let index = 0; index < right.length; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + + return true; +}; + +const isBooleanTupleEqual = ( + left: readonly boolean[] | null, + right: readonly boolean[] +): boolean => { + if (!left || left.length !== right.length) { + return false; + } + + for (let index = 0; index < right.length; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + + return true; +}; + export class SceneRenderStateApplier { private _depthTest: boolean | null = null; - private _cull: boolean | null = null; - private _blend: boolean | null = null; private _depthMask: boolean | null = null; + private _depthFunc: number | null = null; + private _cullEnabled: boolean | null = null; + private _cullFace: number | null = null; + private _frontFace: number | null = null; + private _blend: boolean | null = null; + private _blendEquation: readonly number[] | null = null; + private _blendFunc: readonly number[] | null = null; + private _blendColor: readonly number[] | null = null; + private _colorMask: readonly boolean[] | null = null; + private _stencilTest: boolean | null = null; + private _frontStencilState: SceneResolvedStencilState | null = null; + private _backStencilState: SceneResolvedStencilState | null = null; + private _alphaToCoverage: boolean | null = null; + private _rasterizerDiscard: boolean | null = null; + private _polygonOffsetEnabled: boolean | null = null; + private _polygonOffset: readonly number[] | null = null; + private _lineWidth: number | null = null; constructor(private readonly _gl: WebGL2RenderingContext) {} - apply(shader: SceneShaderResource, renderPass: SceneRenderPassResource): void { - const depthTest = renderPass.depthTest ?? shader.depthTest; - const cull = renderPass.cull ?? shader.cull; - const blend = renderPass.blend ?? shader.blend; + resolvePrimitiveTopology(primitive: SceneMaterialPassPrimitive): 'triangles' | 'lines' | 'points' { + switch (primitive) { + case 'line-list': + return 'lines'; + case 'point-list': + return 'points'; + default: + return 'triangles'; + } + } - if (this._depthTest !== depthTest) { - if (depthTest) { - this._gl.enable?.(this._gl.DEPTH_TEST); - } else { - this._gl.disable?.(this._gl.DEPTH_TEST); - } - this._depthTest = depthTest; + resolvePrimitiveMode( + fallbackMode: number, + materialPass: SceneMaterialPassDefinition | null | undefined + ): number { + switch (materialPass?.primitive) { + case 'line-list': + return this._gl.LINES; + case 'point-list': + return this._gl.POINTS; + case 'triangle-list': + return this._gl.TRIANGLES; + default: + return fallbackMode; + } + } + + resolveBlendEnabled( + shader: SceneShaderResource, + renderPass: SceneRenderPassResource, + materialPass: SceneMaterialPassDefinition | null | undefined + ): boolean { + const blendTarget = materialPass?.blendState?.targets?.[0]; + return blendTarget?.blend ?? renderPass.blend ?? shader.blend; + } + + apply( + shader: SceneShaderResource, + renderPass: SceneRenderPassResource, + materialPass: SceneMaterialPassDefinition | null | undefined = null + ): void { + const depthState = materialPass?.depthStencilState; + const rasterizerState = materialPass?.rasterizerState; + const blendState = materialPass?.blendState; + const blendTarget = blendState?.targets?.[0]; + + const depthTest = depthState?.depthTest ?? renderPass.depthTest ?? shader.depthTest; + const depthWrite = depthState?.depthWrite ?? true; + const depthFunc = this._mapCompareFunction(depthState?.depthFunc ?? 'less') ?? this._gl.LESS; + const cullMode = this._resolveCullMode(shader, renderPass, rasterizerState?.cullMode); + const frontFace = this._mapFrontFace(rasterizerState?.frontFace ?? 'ccw'); + const blend = this.resolveBlendEnabled(shader, renderPass, materialPass); + const blendColor = blendState?.blendColor ?? DEFAULT_BLEND_COLOR; + const colorWriteMask = blendTarget?.colorWriteMask ?? DEFAULT_COLOR_WRITE_MASK; + const frontStencil = this._resolveFrontStencilState(renderPass, depthState?.front); + const backStencil = this._resolveBackStencilState(renderPass, depthState?.back); + const stencilEnabled = frontStencil.enabled || backStencil.enabled; + const alphaToCoverage = blendState?.alphaToCoverage ?? false; + const rasterizerDiscard = rasterizerState?.discard ?? false; + const polygonOffsetFactor = + rasterizerState?.depthBiasSlopeScale ?? rasterizerState?.depthBias ?? 0; + const polygonOffsetUnits = rasterizerState?.depthBias ?? 0; + const polygonOffsetEnabled = polygonOffsetFactor !== 0 || polygonOffsetUnits !== 0; + const lineWidth = rasterizerState?.lineWidth ?? 1; + + this._applyCapability(this._gl.DEPTH_TEST, depthTest, '_depthTest'); + + if (this._depthMask !== depthWrite) { + this._gl.depthMask?.(depthWrite); + this._depthMask = depthWrite; + } + + if (this._depthFunc !== depthFunc) { + this._gl.depthFunc?.(depthFunc); + this._depthFunc = depthFunc; } - if (this._cull !== cull) { - if (cull) { - this._gl.enable?.(this._gl.CULL_FACE); - this._gl.frontFace?.(this._gl.CCW); - this._gl.cullFace?.(this._gl.BACK); - } else { - this._gl.disable?.(this._gl.CULL_FACE); + if (cullMode === this._gl.NONE) { + this._applyCapability(this._gl.CULL_FACE, false, '_cullEnabled'); + this._cullFace = null; + } else { + this._applyCapability(this._gl.CULL_FACE, true, '_cullEnabled'); + if (this._cullFace !== cullMode) { + this._gl.cullFace?.(cullMode); + this._cullFace = cullMode; } - this._cull = cull; } - if (this._blend !== blend) { - if (blend) { - this._gl.enable?.(this._gl.BLEND); - this._gl.blendFunc?.(this._gl.SRC_ALPHA, this._gl.ONE_MINUS_SRC_ALPHA); - } else { - this._gl.disable?.(this._gl.BLEND); + if (this._frontFace !== frontFace) { + this._gl.frontFace?.(frontFace); + this._frontFace = frontFace; + } + + this._applyCapability(this._gl.BLEND, blend, '_blend'); + if (blend) { + const blendEquation = [ + this._mapBlendOperation(blendTarget?.colorOp ?? 'add'), + this._mapBlendOperation(blendTarget?.alphaOp ?? 'add'), + ] as const; + if (!isTupleEqual(this._blendEquation, blendEquation)) { + this._gl.blendEquationSeparate?.(blendEquation[0], blendEquation[1]); + this._blendEquation = blendEquation; } - this._blend = blend; + + const blendFunc = [ + this._mapBlendFactor(blendTarget?.srcColorFactor ?? 'src-alpha'), + this._mapBlendFactor(blendTarget?.dstColorFactor ?? 'one-minus-src-alpha'), + this._mapBlendFactor(blendTarget?.srcAlphaFactor ?? 'one'), + this._mapBlendFactor(blendTarget?.dstAlphaFactor ?? 'one-minus-src-alpha'), + ] as const; + if (!isTupleEqual(this._blendFunc, blendFunc)) { + this._gl.blendFuncSeparate?.( + blendFunc[0], + blendFunc[1], + blendFunc[2], + blendFunc[3] + ); + this._blendFunc = blendFunc; + } + + if (!isTupleEqual(this._blendColor, blendColor)) { + this._gl.blendColor?.( + blendColor[0], + blendColor[1], + blendColor[2], + blendColor[3] + ); + this._blendColor = [...blendColor]; + } + } else { + this._blendEquation = null; + this._blendFunc = null; + this._blendColor = null; + } + + if (!isBooleanTupleEqual(this._colorMask, colorWriteMask)) { + this._gl.colorMask?.( + colorWriteMask[0], + colorWriteMask[1], + colorWriteMask[2], + colorWriteMask[3] + ); + this._colorMask = [...colorWriteMask]; + } + + this._applyCapability(this._gl.STENCIL_TEST, stencilEnabled, '_stencilTest'); + if (stencilEnabled) { + this._applyStencilFaceState(this._gl.FRONT, frontStencil, '_frontStencilState'); + this._applyStencilFaceState(this._gl.BACK, backStencil, '_backStencilState'); + } else { + this._frontStencilState = null; + this._backStencilState = null; + } + + if ('SAMPLE_ALPHA_TO_COVERAGE' in this._gl) { + this._applyCapability( + (this._gl as WebGL2RenderingContext & { SAMPLE_ALPHA_TO_COVERAGE: number }) + .SAMPLE_ALPHA_TO_COVERAGE, + alphaToCoverage, + '_alphaToCoverage' + ); } - if (this._depthMask !== true) { - this._gl.depthMask?.(true); - this._depthMask = true; + if ('RASTERIZER_DISCARD' in this._gl) { + this._applyCapability( + (this._gl as WebGL2RenderingContext & { RASTERIZER_DISCARD: number }) + .RASTERIZER_DISCARD, + rasterizerDiscard, + '_rasterizerDiscard' + ); + } + + if ('POLYGON_OFFSET_FILL' in this._gl) { + this._applyCapability( + (this._gl as WebGL2RenderingContext & { POLYGON_OFFSET_FILL: number }) + .POLYGON_OFFSET_FILL, + polygonOffsetEnabled, + '_polygonOffsetEnabled' + ); + } + + const polygonOffset = [polygonOffsetFactor, polygonOffsetUnits] as const; + if (polygonOffsetEnabled && !isTupleEqual(this._polygonOffset, polygonOffset)) { + this._gl.polygonOffset?.(polygonOffsetFactor, polygonOffsetUnits); + this._polygonOffset = polygonOffset; + } else if (!polygonOffsetEnabled) { + this._polygonOffset = null; + } + + if (this._lineWidth !== lineWidth) { + this._gl.lineWidth?.(lineWidth); + this._lineWidth = lineWidth; } } reset(): void { this._depthTest = null; - this._cull = null; - this._blend = null; this._depthMask = null; + this._depthFunc = null; + this._cullEnabled = null; + this._cullFace = null; + this._frontFace = null; + this._blend = null; + this._blendEquation = null; + this._blendFunc = null; + this._blendColor = null; + this._colorMask = null; + this._stencilTest = null; + this._frontStencilState = null; + this._backStencilState = null; + this._alphaToCoverage = null; + this._rasterizerDiscard = null; + this._polygonOffsetEnabled = null; + this._polygonOffset = null; + this._lineWidth = null; + } + + private _applyCapability( + capability: number, + enabled: boolean, + key: + | '_depthTest' + | '_cullEnabled' + | '_blend' + | '_stencilTest' + | '_alphaToCoverage' + | '_rasterizerDiscard' + | '_polygonOffsetEnabled' + ): void { + if (this[key] === enabled) { + return; + } + + if (enabled) { + this._gl.enable?.(capability); + } else { + this._gl.disable?.(capability); + } + this[key] = enabled as never; + } + + private _resolveCullMode( + shader: SceneShaderResource, + renderPass: SceneRenderPassResource, + materialCullMode: SceneMaterialCullMode | undefined + ): number { + if (materialCullMode) { + return this._mapCullMode(materialCullMode); + } + + const cullEnabled = renderPass.cull ?? shader.cull; + return cullEnabled ? this._gl.BACK : this._gl.NONE; + } + + private _resolveFrontStencilState( + renderPass: SceneRenderPassResource, + face: SceneMaterialStencilFaceStateDefinition | undefined + ): SceneResolvedStencilState { + return { + enabled: face?.stencilTest ?? renderPass.stencilTest ?? false, + func: this._mapCompareFunction(face?.stencilFunc) ?? renderPass.stencilFunc ?? this._gl.ALWAYS, + ref: face?.stencilRef ?? renderPass.stencilRef ?? 0, + readMask: face?.stencilReadMask ?? renderPass.stencilMask ?? DEFAULT_STENCIL_MASK, + writeMask: face?.stencilWriteMask ?? renderPass.stencilMask ?? DEFAULT_STENCIL_MASK, + failOp: this._mapStencilOperation(face?.stencilFailOp) ?? renderPass.stencilFail ?? this._gl.KEEP, + zFailOp: + this._mapStencilOperation(face?.stencilZFailOp) ?? + renderPass.stencilZFail ?? + this._gl.KEEP, + passOp: + this._mapStencilOperation(face?.stencilPassOp) ?? + renderPass.stencilZPass ?? + this._gl.KEEP, + }; + } + + private _resolveBackStencilState( + renderPass: SceneRenderPassResource, + face: SceneMaterialStencilFaceStateDefinition | undefined + ): SceneResolvedStencilState { + return { + enabled: face?.stencilTest ?? renderPass.stencilTest ?? false, + func: this._mapCompareFunction(face?.stencilFunc) ?? renderPass.stencilFunc ?? this._gl.ALWAYS, + ref: face?.stencilRef ?? renderPass.stencilRef ?? 0, + readMask: face?.stencilReadMask ?? renderPass.stencilMask ?? DEFAULT_STENCIL_MASK, + writeMask: face?.stencilWriteMask ?? renderPass.stencilMask ?? DEFAULT_STENCIL_MASK, + failOp: this._mapStencilOperation(face?.stencilFailOp) ?? renderPass.stencilFail ?? this._gl.KEEP, + zFailOp: + this._mapStencilOperation(face?.stencilZFailOp) ?? + renderPass.stencilZFail ?? + this._gl.KEEP, + passOp: + this._mapStencilOperation(face?.stencilPassOp) ?? + renderPass.stencilZPass ?? + this._gl.KEEP, + }; + } + + private _applyStencilFaceState( + face: number, + state: SceneResolvedStencilState, + key: '_frontStencilState' | '_backStencilState' + ): void { + const current = this[key]; + if ( + current && + current.enabled === state.enabled && + current.func === state.func && + current.ref === state.ref && + current.readMask === state.readMask && + current.writeMask === state.writeMask && + current.failOp === state.failOp && + current.zFailOp === state.zFailOp && + current.passOp === state.passOp + ) { + return; + } + + this._gl.stencilFuncSeparate?.(face, state.func, state.ref, state.readMask); + this._gl.stencilMaskSeparate?.(face, state.writeMask); + this._gl.stencilOpSeparate?.(face, state.failOp, state.zFailOp, state.passOp); + this[key] = state; + } + + private _mapCullMode(cullMode: SceneMaterialCullMode): number { + switch (cullMode) { + case 'front': + return this._gl.FRONT; + case 'none': + return this._gl.NONE; + default: + return this._gl.BACK; + } + } + + private _mapFrontFace(frontFace: SceneMaterialFrontFace): number { + return frontFace === 'cw' ? this._gl.CW : this._gl.CCW; + } + + private _mapCompareFunction(compareFunction: SceneMaterialCompareFunction | undefined): number | undefined { + switch (compareFunction) { + case 'never': + return this._gl.NEVER; + case 'less': + return this._gl.LESS; + case 'equal': + return this._gl.EQUAL; + case 'lequal': + return this._gl.LEQUAL; + case 'greater': + return this._gl.GREATER; + case 'notequal': + return this._gl.NOTEQUAL; + case 'gequal': + return this._gl.GEQUAL; + case 'always': + return this._gl.ALWAYS; + default: + return undefined; + } + } + + private _mapStencilOperation(operation: SceneMaterialStencilOperation | undefined): number | undefined { + switch (operation) { + case 'keep': + return this._gl.KEEP; + case 'zero': + return this._gl.ZERO; + case 'replace': + return this._gl.REPLACE; + case 'invert': + return this._gl.INVERT; + case 'incr': + return this._gl.INCR; + case 'incr-wrap': + return this._gl.INCR_WRAP; + case 'decr': + return this._gl.DECR; + case 'decr-wrap': + return this._gl.DECR_WRAP; + default: + return undefined; + } + } + + private _mapBlendFactor(factor: SceneMaterialBlendFactor): number { + switch (factor) { + case 'zero': + return this._gl.ZERO; + case 'one': + return this._gl.ONE; + case 'src-color': + return this._gl.SRC_COLOR; + case 'one-minus-src-color': + return this._gl.ONE_MINUS_SRC_COLOR; + case 'dst-color': + return this._gl.DST_COLOR; + case 'one-minus-dst-color': + return this._gl.ONE_MINUS_DST_COLOR; + case 'src-alpha': + return this._gl.SRC_ALPHA; + case 'one-minus-src-alpha': + return this._gl.ONE_MINUS_SRC_ALPHA; + case 'dst-alpha': + return this._gl.DST_ALPHA; + case 'one-minus-dst-alpha': + return this._gl.ONE_MINUS_DST_ALPHA; + case 'constant-color': + return this._gl.CONSTANT_COLOR; + case 'one-minus-constant-color': + return this._gl.ONE_MINUS_CONSTANT_COLOR; + case 'constant-alpha': + return this._gl.CONSTANT_ALPHA; + case 'one-minus-constant-alpha': + return this._gl.ONE_MINUS_CONSTANT_ALPHA; + case 'src-alpha-saturate': + return this._gl.SRC_ALPHA_SATURATE; + default: + return this._gl.ONE; + } + } + + private _mapBlendOperation(operation: SceneMaterialBlendOperation): number { + switch (operation) { + case 'subtract': + return this._gl.FUNC_SUBTRACT; + case 'reverse-subtract': + return this._gl.FUNC_REVERSE_SUBTRACT; + case 'min': + return this._gl.MIN; + case 'max': + return this._gl.MAX; + default: + return this._gl.FUNC_ADD; + } } } diff --git a/web/packages/scene-runtime/src/scene-2d-support.ts b/web/packages/scene-runtime/src/scene-2d-support.ts new file mode 100644 index 00000000..5e65d9b1 --- /dev/null +++ b/web/packages/scene-runtime/src/scene-2d-support.ts @@ -0,0 +1,52 @@ +export type { + Asset2DBorderLike, + Asset2DImportKind, + Asset2DImportPipelineOptions, + Asset2DImportResult, + Asset2DImportSchema, + SpriteAnimationClip, + SpriteAnimationClipDefinition, + SpriteAnimationFrame, + SpriteAnimationFrameDefinition, + SpriteAtlas, + SpriteAtlasDefinition, + SpriteAtlasFrame, + SpriteAtlasFrameDefinition, +} from '@axrone/asset-2d'; +export { + createAsset2DImportPipeline, + createSpriteAtlas, + createSpriteAtlasJsonImporter, + getSpriteAnimationClip, + getSpriteAtlasFrame, + createTexturePackerSpriteAtlasImporter, + serializeSpriteAtlasDefinition, +} from '@axrone/asset-2d'; +export type { + SpriteAnimatorConfig, +} from './components/sprite-animator'; +export { SpriteAnimator } from './components/sprite-animator'; +export type { + SpriteMaskConfig, + SpriteMaskShape, + SpriteMaskSizeInput, + SpriteMaskVec2Input, +} from './components/sprite-mask'; +export { SpriteMask } from './components/sprite-mask'; +export type { + SpriteRendererBorderInput, + SpriteRendererBorderState, + SpriteRendererColorInput, + SpriteRendererConfig, + SpriteRendererFrameApplyOptions, + SpriteRendererRectInput, + SpriteRendererRectState, + SpriteRendererSizeInput, + SpriteRendererVec2Input, +} from './components/sprite-renderer'; +export { SpriteRenderer } from './components/sprite-renderer'; +export { Color } from '@axrone/numeric'; +export { + DEFAULT_SCENE_2D_SPRITE_SHADER_ID, + createSprite2DShaderDefinition, +} from './sprite-2d-shader'; \ No newline at end of file diff --git a/web/packages/scene-runtime/src/scene-3d-support.ts b/web/packages/scene-runtime/src/scene-3d-support.ts index 89ea4fbf..6b7192b3 100644 --- a/web/packages/scene-runtime/src/scene-3d-support.ts +++ b/web/packages/scene-runtime/src/scene-3d-support.ts @@ -26,6 +26,7 @@ export type { SceneMaterialTextureSlot, } from './material-registry'; export { + cloneSceneMaterialSurfaceDefinition, cloneSceneMaterialDefinition, normalizeSceneTextureBinding, SceneMaterialRegistry, @@ -134,6 +135,8 @@ export { SceneRenderRuntime } from './scene-render-runtime'; export type { DirectionalLightConfig } from './components/directional-light'; export { DirectionalLight } from './components/directional-light'; +export type { FollowCameraControllerConfig } from './components/follow-camera-controller'; +export { FollowCameraController } from './components/follow-camera-controller'; export type { MeshRendererConfig, MeshRendererMorphConfig, @@ -149,4 +152,4 @@ export { PointLight } from './components/point-light'; export type { SpotLightConfig } from './components/spot-light'; export { SpotLight } from './components/spot-light'; -export { decodeSceneValue, encodeSceneValue } from './serialization'; \ No newline at end of file +export { decodeSceneValue, encodeSceneValue } from './serialization'; diff --git a/web/packages/scene-runtime/src/scene-actor-runtime.ts b/web/packages/scene-runtime/src/scene-actor-runtime.ts index 44625b0f..bb638768 100644 --- a/web/packages/scene-runtime/src/scene-actor-runtime.ts +++ b/web/packages/scene-runtime/src/scene-actor-runtime.ts @@ -1,4 +1,12 @@ -import { Actor, type ActorConfig } from '@axrone/ecs-runtime'; +import { + Actor, + Component, + Hierarchy, + Transform, + getComponentMetadata, + type ActorConfig, + type ComponentMetadata, +} from '@axrone/ecs-runtime'; import { World } from '@axrone/ecs-runtime'; import type { ComponentConstructor, @@ -19,6 +27,47 @@ export interface SceneActorRuntimeOptions< readonly componentCatalog: SceneComponentCatalog; } +interface SceneActorComponentCreateEntry { + readonly type: ComponentConstructor; + readonly args?: readonly unknown[]; +} + +interface SceneActorCreateWithComponentsConfig { + readonly actorConfig?: ActorConfig; + readonly components?: readonly SceneActorComponentCreateEntry[]; +} + +interface PreparedActorComponentType { + readonly type: ComponentConstructor; + readonly componentName: string; + readonly metadata?: ComponentMetadata; +} + +interface PreparedActorComponentEntry { + readonly preparedType: PreparedActorComponentType; + readonly args?: readonly unknown[]; +} + +type SceneActorBatchProfiling = Record; + +const captureProfilePhase = ( + profiling: SceneActorBatchProfiling | undefined, + phaseName: string, + action: () => T +): T => { + if (!profiling) { + return action(); + } + + const startedAt = performance.now(); + + try { + return action(); + } finally { + profiling[phaseName] = (profiling[phaseName] ?? 0) + (performance.now() - startedAt); + } +}; + export class SceneActorRuntime> { private readonly _world: World>; private readonly _componentCatalog: SceneComponentCatalog; @@ -47,10 +96,101 @@ export class SceneActorRuntime(callback: () => T): T { + return this._world.batchStructureChanges(callback); + } + createActor(config: ActorConfig = {}): Actor>> { return new Actor(this._world, config); } + createActorsWithComponents( + configs: readonly SceneActorCreateWithComponentsConfig[], + profiling?: SceneActorBatchProfiling + ): readonly Actor>>[] { + if (configs.length === 0) { + return []; + } + + const worldRegistry = this._world.registry as Record; + const hierarchyType = (worldRegistry.Hierarchy ?? Hierarchy) as ComponentConstructor; + const transformType = (worldRegistry.Transform ?? Transform) as ComponentConstructor; + const preparedTypes = new Map(); + const prepareType = (componentType: ComponentConstructor): PreparedActorComponentType => { + const existing = preparedTypes.get(componentType); + if (existing) { + return existing; + } + + if (!this._world.isComponentRegistered(componentType)) { + this.registerComponent(componentType); + } + + const prepared = { + type: componentType, + componentName: getComponentMetadata(componentType)?.scriptName ?? componentType.name, + metadata: getComponentMetadata(componentType), + } satisfies PreparedActorComponentType; + preparedTypes.set(componentType, prepared); + return prepared; + }; + + const preparedConfigs = captureProfilePhase(profiling, 'prepareComponentTypesMs', () => { + const hierarchyPrepared = prepareType(hierarchyType); + const transformPrepared = prepareType(transformType); + + return configs.map((config) => ({ + actorConfig: config.actorConfig ?? {}, + preparedEntries: [ + { + preparedType: hierarchyPrepared, + args: undefined, + }, + { + preparedType: transformPrepared, + args: undefined, + }, + ...(config.components ?? []).map((entry) => ({ + preparedType: prepareType(entry.type), + args: entry.args, + })), + ] satisfies readonly PreparedActorComponentEntry[], + })); + }); + + return preparedConfigs.map((config) => { + const preloadedEntries = captureProfilePhase(profiling, 'componentInstantiateMs', () => + config.preparedEntries.map((entry) => ({ + componentType: entry.preparedType.type as any, + componentName: entry.preparedType.componentName, + metadata: entry.preparedType.metadata, + component: new (entry.preparedType.type as new (...args: any[]) => Component)( + ...(entry.args ?? []) + ), + })) + ); + + return captureProfilePhase(profiling, 'actorCreateMs', () => + Actor.createWithComponents(this._world, config.actorConfig, preloadedEntries) + ); + }); + } + + createActorWithComponents( + config: ActorConfig = {}, + components: readonly { + readonly type: ComponentConstructor; + readonly args?: readonly unknown[]; + }[] = [] + ): Actor>> { + return this.createActorsWithComponents([ + { + actorConfig: config, + components, + }, + ])[0]!; + } + createPrefab( id: string, actors: readonly Actor[] = this._world.getAllActors() @@ -68,4 +208,4 @@ export class SceneActorRuntime + !!value && + typeof value === 'object' && + 'name' in value && + 'layer' in value && + 'tag' in value && + 'active' in value && + 'persistent' in value && + 'pooled' in value && + 'components' in value && + Array.isArray(value.components); + +const isComponentSnapshotValue = (value: unknown): value is SceneComponentSnapshot => + !!value && typeof value === 'object' && 'type' in value && 'data' in value; + +const indexComponents = ( + components: readonly SceneComponentSnapshot[], +): Map => { + const entries = new Map(); + + for (let index = 0; index < components.length; index += 1) { + const component = components[index]!; + const selector = createScenePrefabComponentSelector(components, index); + entries.set(getScenePrefabComponentSelectorKey(selector), { + selector, + component, + index, + }); + } + + return entries; +}; + +const topologicallyOrderAddedActors = ( + actors: readonly SceneActorSnapshot[], + pendingActorIds: ReadonlySet, +): readonly SceneActorSnapshot[] => { + const actorIndex = new Map(); + for (const actor of actors) { + if (actor.nodeId) { + actorIndex.set(actor.nodeId, actor); + } + } + + const ordered: SceneActorSnapshot[] = []; + const visited = new Set(); + + const visit = (actor: SceneActorSnapshot): void => { + const nodeId = actor.nodeId; + if (!nodeId || visited.has(nodeId)) { + return; + } + + if (actor.parentNodeId && pendingActorIds.has(actor.parentNodeId)) { + const parentActor = actorIndex.get(actor.parentNodeId); + if (parentActor) { + visit(parentActor); + } + } + + visited.add(nodeId); + ordered.push(actor); + }; + + for (const actor of actors) { + if (actor.nodeId && pendingActorIds.has(actor.nodeId)) { + visit(actor); + } + } + + return ordered; +}; + +const diffComponentValue = ( + nodeId: string, + selector: ScenePrefabComponentSelector, + baseValue: SceneSerializedValue, + targetValue: SceneSerializedValue, + path: readonly (string | number)[], + overrides: ScenePrefabOverrideOperation[], +): void => { + if (deepEqualSceneSerializedValue(baseValue, targetValue)) { + return; + } + + if (isSceneSerializedStructuredValue(baseValue) && isSceneSerializedStructuredValue(targetValue)) { + const keys = new Set([...Object.keys(baseValue), ...Object.keys(targetValue)]); + for (const key of keys) { + if (!(key in targetValue)) { + overrides.push({ + kind: 'unset-component-property', + nodeId, + selector, + path: [...path, key], + }); + continue; + } + + if (!(key in baseValue)) { + overrides.push({ + kind: 'set-component-property', + nodeId, + selector, + path: [...path, key], + value: cloneSceneSerializedValue(targetValue[key]!), + }); + continue; + } + + diffComponentValue( + nodeId, + selector, + baseValue[key]!, + targetValue[key]!, + [...path, key], + overrides, + ); + } + + return; + } + + overrides.push({ + kind: 'set-component-property', + nodeId, + selector, + path: [...path], + value: cloneSceneSerializedValue(targetValue), + }); +}; + +const cloneConflictBaseValue = ( + value: SceneSerializedValue | SceneActorSnapshot | SceneComponentSnapshot | string | number | boolean | null, +): + | SceneSerializedValue + | SceneActorSnapshot + | SceneComponentSnapshot + | string + | number + | boolean + | null => { + if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (isActorSnapshotValue(value)) { + return cloneSceneActorSnapshot(value); + } + + if (isComponentSnapshotValue(value)) { + return cloneSceneComponentSnapshot(value); + } + + if (Array.isArray(value) || isSceneSerializedObjectValue(value)) { + return cloneSceneSerializedValue(value); + } + + return null; +}; + +const isSameComponentSnapshot = ( + left: SceneComponentSnapshot, + right: SceneComponentSnapshot, +): boolean => + left.id === right.id && + left.type === right.type && + deepEqualSceneSerializedValue(left.data, right.data); + +const isSameOverrideOperation = ( + left: ScenePrefabOverrideOperation, + right: ScenePrefabOverrideOperation, +): boolean => { + if (left.kind !== right.kind) { + return false; + } + + switch (left.kind) { + case 'add-actor': + return ( + right.kind === 'add-actor' && + left.afterNodeId === right.afterNodeId && + JSON.stringify(left.actor) === JSON.stringify(right.actor) + ); + case 'remove-actor': + return right.kind === 'remove-actor' && left.nodeId === right.nodeId; + case 'reparent-actor': + return ( + right.kind === 'reparent-actor' && + left.nodeId === right.nodeId && + left.parentNodeId === right.parentNodeId + ); + case 'set-actor-field': + return ( + right.kind === 'set-actor-field' && + left.nodeId === right.nodeId && + left.field === right.field && + left.value === right.value + ); + case 'add-component': + return ( + right.kind === 'add-component' && + left.nodeId === right.nodeId && + left.index === right.index && + isSameComponentSnapshot(left.component, right.component) + ); + case 'remove-component': + return ( + right.kind === 'remove-component' && + left.nodeId === right.nodeId && + JSON.stringify(left.selector) === JSON.stringify(right.selector) + ); + case 'replace-component': + return ( + right.kind === 'replace-component' && + left.nodeId === right.nodeId && + JSON.stringify(left.selector) === JSON.stringify(right.selector) && + isSameComponentSnapshot(left.component, right.component) + ); + case 'set-component-property': + return ( + right.kind === 'set-component-property' && + left.nodeId === right.nodeId && + JSON.stringify(left.selector) === JSON.stringify(right.selector) && + JSON.stringify(left.path) === JSON.stringify(right.path) && + deepEqualSceneSerializedValue(left.value, right.value) + ); + case 'unset-component-property': + return ( + right.kind === 'unset-component-property' && + left.nodeId === right.nodeId && + JSON.stringify(left.selector) === JSON.stringify(right.selector) && + JSON.stringify(left.path) === JSON.stringify(right.path) + ); + } +}; + +const describeOperation = (operation: ScenePrefabOverrideOperation): OperationDescriptor => { + switch (operation.kind) { + case 'add-actor': + return { + actorId: operation.actor.nodeId ?? '', + scope: 'actor-add', + }; + case 'remove-actor': + return { + actorId: operation.nodeId, + scope: 'actor-remove', + }; + case 'reparent-actor': + return { + actorId: operation.nodeId, + scope: 'actor-field', + fieldKey: 'parentNodeId', + }; + case 'set-actor-field': + return { + actorId: operation.nodeId, + scope: 'actor-field', + fieldKey: operation.field, + }; + case 'add-component': + return { + actorId: operation.nodeId, + scope: 'component-add', + componentKey: operation.component.id + ? `id:${operation.component.id}` + : `type:${operation.component.type}`, + }; + case 'remove-component': + return { + actorId: operation.nodeId, + scope: 'component-remove', + componentKey: getScenePrefabComponentSelectorKey(operation.selector), + }; + case 'replace-component': + return { + actorId: operation.nodeId, + scope: 'component-replace', + componentKey: getScenePrefabComponentSelectorKey(operation.selector), + }; + case 'set-component-property': + return { + actorId: operation.nodeId, + scope: 'component-property', + componentKey: getScenePrefabComponentSelectorKey(operation.selector), + path: operation.path, + }; + case 'unset-component-property': + return { + actorId: operation.nodeId, + scope: 'component-property', + componentKey: getScenePrefabComponentSelectorKey(operation.selector), + path: operation.path, + }; + } +}; + +const operationsConflict = ( + left: ScenePrefabOverrideOperation, + right: ScenePrefabOverrideOperation, +): boolean => { + const leftDescriptor = describeOperation(left); + const rightDescriptor = describeOperation(right); + + if (leftDescriptor.actorId !== rightDescriptor.actorId) { + return false; + } + + if (leftDescriptor.scope === 'actor-remove' || rightDescriptor.scope === 'actor-remove') { + return true; + } + + if (leftDescriptor.scope === 'actor-add' || rightDescriptor.scope === 'actor-add') { + return true; + } + + if (!leftDescriptor.componentKey && !rightDescriptor.componentKey) { + if (!leftDescriptor.fieldKey || !rightDescriptor.fieldKey) { + return true; + } + + return leftDescriptor.fieldKey === rightDescriptor.fieldKey; + } + + if (!leftDescriptor.componentKey || !rightDescriptor.componentKey) { + return false; + } + + if (leftDescriptor.componentKey !== rightDescriptor.componentKey) { + return false; + } + + if ( + leftDescriptor.scope === 'component-add' || + leftDescriptor.scope === 'component-remove' || + leftDescriptor.scope === 'component-replace' || + rightDescriptor.scope === 'component-add' || + rightDescriptor.scope === 'component-remove' || + rightDescriptor.scope === 'component-replace' + ) { + return true; + } + + if (!leftDescriptor.path || !rightDescriptor.path) { + return true; + } + + return ( + isScenePrefabPropertyPathAncestor(leftDescriptor.path, rightDescriptor.path) || + isScenePrefabPropertyPathAncestor(rightDescriptor.path, leftDescriptor.path) + ); +}; + +const createConflictKey = (left: OperationDescriptor, right: OperationDescriptor): string => { + const componentKey = left.componentKey ?? right.componentKey; + if (!componentKey) { + return left.fieldKey === right.fieldKey && left.fieldKey + ? `actor:${left.actorId}:field:${left.fieldKey}` + : `actor:${left.actorId}`; + } + + const path = left.path ?? right.path; + return path && path.length > 0 + ? `component:${left.actorId}:${componentKey}:path:${serializeScenePrefabPropertyPath(path)}` + : `component:${left.actorId}:${componentKey}`; +}; + +const createConflict = ( + base: ScenePrefabDefinition, + local: ScenePrefabOverrideOperation, + incoming: ScenePrefabOverrideOperation, +): ScenePrefabConflict => { + const baseState = createScenePrefabState(base); + const localDescriptor = describeOperation(local); + const incomingDescriptor = describeOperation(incoming); + const actor = baseState.actorIndex.get(localDescriptor.actorId); + + let baseValue: + | SceneSerializedValue + | SceneActorSnapshot + | SceneComponentSnapshot + | string + | number + | boolean + | null = null; + + if (!actor) { + baseValue = null; + } else if (!localDescriptor.componentKey) { + if (!localDescriptor.fieldKey) { + baseValue = cloneSceneActorSnapshot({ + nodeId: actor.nodeId, + parentNodeId: actor.parentNodeId, + name: actor.name, + layer: actor.layer, + tag: actor.tag, + active: actor.active, + persistent: actor.persistent, + pooled: actor.pooled, + ...(actor.source ? { source: actor.source } : {}), + components: actor.components.map((component) => ({ + ...(component.id ? { id: component.id } : {}), + type: component.type, + data: component.data, + })), + }); + } else { + switch (localDescriptor.fieldKey) { + case 'parentNodeId': + baseValue = actor.parentNodeId; + break; + case 'name': + baseValue = actor.name; + break; + case 'layer': + baseValue = actor.layer; + break; + case 'tag': + baseValue = actor.tag; + break; + case 'active': + baseValue = actor.active; + break; + case 'persistent': + baseValue = actor.persistent; + break; + case 'pooled': + baseValue = actor.pooled; + break; + } + } + } else { + const componentEntry = indexComponents( + actor.components.map((component) => ({ + ...(component.id ? { id: component.id } : {}), + type: component.type, + data: component.data, + })), + ).get(localDescriptor.componentKey); + + if (!componentEntry) { + baseValue = null; + } else if (!localDescriptor.path || localDescriptor.path.length === 0) { + baseValue = cloneSceneComponentSnapshot(componentEntry.component); + } else { + baseValue = readSceneSerializedValueAtPath(componentEntry.component.data, localDescriptor.path); + } + } + + return { + key: createConflictKey(localDescriptor, incomingDescriptor), + local: cloneScenePrefabOverrideOperation(local), + incoming: cloneScenePrefabOverrideOperation(incoming), + baseValue: cloneConflictBaseValue(baseValue), + }; +}; + +const resolveConflictChoice = ( + conflict: ScenePrefabConflict, + options: ScenePrefabMergeOptions, +): 'manual' | 'local' | 'incoming' | 'base' => { + if (options.conflictResolver) { + return options.conflictResolver(conflict); + } + + switch (options.conflictPolicy ?? 'manual') { + case 'prefer-local': + return 'local'; + case 'prefer-incoming': + return 'incoming'; + case 'prefer-base': + return 'base'; + default: + return 'manual'; + } +}; + +export const diffScenePrefabDefinitions = ( + base: ScenePrefabDefinition, + target: ScenePrefabDefinition, +): ScenePrefabDiffResult => { + const baseState = createScenePrefabState(base); + const targetState = createScenePrefabState(target); + const overrides: ScenePrefabOverrideOperation[] = []; + + const removedActorIds = new Set(); + for (const actor of baseState.actors) { + if (!targetState.actorIndex.has(actor.nodeId)) { + removedActorIds.add(actor.nodeId); + } + } + + for (const actor of baseState.actors) { + if (!removedActorIds.has(actor.nodeId)) { + continue; + } + + if (actor.parentNodeId && removedActorIds.has(actor.parentNodeId)) { + continue; + } + + overrides.push({ + kind: 'remove-actor', + nodeId: actor.nodeId, + }); + } + + const addedActorIds = new Set(); + for (const actor of targetState.actors) { + if (!baseState.actorIndex.has(actor.nodeId)) { + addedActorIds.add(actor.nodeId); + } + } + + for (const actor of topologicallyOrderAddedActors( + targetState.actors.map((entry) => ({ + nodeId: entry.nodeId, + parentNodeId: entry.parentNodeId, + name: entry.name, + layer: entry.layer, + tag: entry.tag, + active: entry.active, + persistent: entry.persistent, + pooled: entry.pooled, + ...(entry.source ? { source: entry.source } : {}), + components: entry.components.map((component) => ({ + ...(component.id ? { id: component.id } : {}), + type: component.type, + data: component.data, + })), + })), + addedActorIds, + )) { + overrides.push({ + kind: 'add-actor', + actor: cloneSceneActorSnapshot(actor), + }); + } + + for (const baseActor of baseState.actors) { + const targetActor = targetState.actorIndex.get(baseActor.nodeId); + if (!targetActor) { + continue; + } + + if (baseActor.parentNodeId !== targetActor.parentNodeId) { + overrides.push({ + kind: 'reparent-actor', + nodeId: baseActor.nodeId, + parentNodeId: targetActor.parentNodeId, + }); + } + + if (baseActor.name !== targetActor.name) { + overrides.push({ + kind: 'set-actor-field', + nodeId: baseActor.nodeId, + field: 'name', + value: targetActor.name, + }); + } + + if (baseActor.layer !== targetActor.layer) { + overrides.push({ + kind: 'set-actor-field', + nodeId: baseActor.nodeId, + field: 'layer', + value: targetActor.layer, + }); + } + + if (baseActor.tag !== targetActor.tag) { + overrides.push({ + kind: 'set-actor-field', + nodeId: baseActor.nodeId, + field: 'tag', + value: targetActor.tag, + }); + } + + if (baseActor.active !== targetActor.active) { + overrides.push({ + kind: 'set-actor-field', + nodeId: baseActor.nodeId, + field: 'active', + value: targetActor.active, + }); + } + + if (baseActor.persistent !== targetActor.persistent) { + overrides.push({ + kind: 'set-actor-field', + nodeId: baseActor.nodeId, + field: 'persistent', + value: targetActor.persistent, + }); + } + + if (baseActor.pooled !== targetActor.pooled) { + overrides.push({ + kind: 'set-actor-field', + nodeId: baseActor.nodeId, + field: 'pooled', + value: targetActor.pooled, + }); + } + + const baseComponents = indexComponents( + baseActor.components.map((component) => ({ + ...(component.id ? { id: component.id } : {}), + type: component.type, + data: component.data, + })), + ); + const targetComponents = indexComponents( + targetActor.components.map((component) => ({ + ...(component.id ? { id: component.id } : {}), + type: component.type, + data: component.data, + })), + ); + + for (const [key, entry] of baseComponents) { + if (!targetComponents.has(key)) { + overrides.push({ + kind: 'remove-component', + nodeId: baseActor.nodeId, + selector: entry.selector, + }); + } + } + + for (const [key, entry] of targetComponents) { + if (!baseComponents.has(key)) { + overrides.push({ + kind: 'add-component', + nodeId: baseActor.nodeId, + component: cloneSceneComponentSnapshot(entry.component), + index: entry.index, + }); + } + } + + for (const [key, baseEntry] of baseComponents) { + const targetEntry = targetComponents.get(key); + if (!targetEntry) { + continue; + } + + if (baseEntry.component.type !== targetEntry.component.type) { + overrides.push({ + kind: 'replace-component', + nodeId: baseActor.nodeId, + selector: baseEntry.selector, + component: cloneSceneComponentSnapshot(targetEntry.component), + }); + continue; + } + + diffComponentValue( + baseActor.nodeId, + baseEntry.selector, + baseEntry.component.data, + targetEntry.component.data, + [], + overrides, + ); + } + } + + return { + basePrefabId: base.id, + targetPrefabId: target.id, + overrides, + }; +}; + +export const mergeScenePrefabDefinitions = ( + base: ScenePrefabDefinition, + local: ScenePrefabDefinition, + incoming: ScenePrefabDefinition, + options: ScenePrefabMergeOptions = {}, +): ScenePrefabMergeDefinitionResult => { + const localOverrides = diffScenePrefabDefinitions(base, local).overrides.map((operation) => + cloneScenePrefabOverrideOperation(operation), + ); + const incomingOverrides = diffScenePrefabDefinitions(base, incoming).overrides; + const mergedOverrides = [...localOverrides]; + const conflicts: ScenePrefabConflict[] = []; + + for (const incomingOperation of incomingOverrides) { + if (mergedOverrides.some((existing) => isSameOverrideOperation(existing, incomingOperation))) { + continue; + } + + const conflictIndexes: number[] = []; + for (let index = 0; index < mergedOverrides.length; index += 1) { + if (operationsConflict(mergedOverrides[index]!, incomingOperation)) { + conflictIndexes.push(index); + } + } + + if (conflictIndexes.length === 0) { + mergedOverrides.push(cloneScenePrefabOverrideOperation(incomingOperation)); + continue; + } + + const firstConflict = createConflict(base, mergedOverrides[conflictIndexes[0]!]!, incomingOperation); + const choice = resolveConflictChoice(firstConflict, options); + + if (choice === 'manual') { + for (const index of [...conflictIndexes].sort((left, right) => right - left)) { + mergedOverrides.splice(index, 1); + } + + for (const index of conflictIndexes) { + conflicts.push(createConflict(base, localOverrides[index]!, incomingOperation)); + } + + continue; + } + + if (choice === 'local') { + continue; + } + + for (const index of [...conflictIndexes].sort((left, right) => right - left)) { + mergedOverrides.splice(index, 1); + } + + if (choice === 'incoming') { + mergedOverrides.push(cloneScenePrefabOverrideOperation(incomingOperation)); + } + } + + return { + overrides: mergedOverrides, + conflicts, + resolved: conflicts.length === 0, + definition: applyScenePrefabOverrides(base, mergedOverrides), + }; +}; \ No newline at end of file diff --git a/web/packages/scene-runtime/src/scene-prefab-internals.ts b/web/packages/scene-runtime/src/scene-prefab-internals.ts new file mode 100644 index 00000000..1737940c --- /dev/null +++ b/web/packages/scene-runtime/src/scene-prefab-internals.ts @@ -0,0 +1,437 @@ +import { ScenePrefabValidationError } from './errors'; +import type { + SceneActorSnapshot, + SceneComponentSnapshot, + ScenePrefabComponentSelector, + ScenePrefabDefinition, + ScenePrefabMetadata, + ScenePrefabNestedInstance, + ScenePrefabNodeSource, + ScenePrefabOverrideOperation, + ScenePrefabPropertyPath, + ScenePrefabReference, + SceneSerializedValue, +} from './types'; + +const hasOwn = (value: object, key: string): boolean => Object.prototype.hasOwnProperty.call(value, key); + +const cloneStringArray = (value: readonly string[] | undefined): readonly string[] | undefined => + value ? value.map((entry) => entry) : undefined; + +export const isSceneSerializedObjectValue = ( + value: SceneSerializedValue, +): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +export const isSceneSerializedStructuredValue = ( + value: SceneSerializedValue, +): value is Record => + isSceneSerializedObjectValue(value) && !(hasOwn(value, '$type') && hasOwn(value, 'value')); + +export const cloneSceneSerializedValue = (value: SceneSerializedValue): SceneSerializedValue => { + if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (Array.isArray(value)) { + return value.map((entry) => cloneSceneSerializedValue(entry)); + } + + const clone: Record = {}; + for (const [key, entry] of Object.entries(value)) { + clone[key] = cloneSceneSerializedValue(entry); + } + return clone; +}; + +export const deepEqualSceneSerializedValue = ( + left: SceneSerializedValue, + right: SceneSerializedValue, +): boolean => { + if (left === right) { + return true; + } + + if (left === null || right === null) { + return left === right; + } + + if (typeof left !== typeof right) { + return false; + } + + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + + for (let index = 0; index < left.length; index += 1) { + if (!deepEqualSceneSerializedValue(left[index]!, right[index]!)) { + return false; + } + } + + return true; + } + + if (!isSceneSerializedObjectValue(left) || !isSceneSerializedObjectValue(right)) { + return false; + } + + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) { + return false; + } + + for (const key of leftKeys) { + if (!hasOwn(right, key) || !deepEqualSceneSerializedValue(left[key]!, right[key]!)) { + return false; + } + } + + return true; +}; + +export const cloneScenePrefabNodeSource = ( + source: ScenePrefabNodeSource | undefined, +): ScenePrefabNodeSource | undefined => + source + ? { + prefabId: source.prefabId, + nodeId: source.nodeId, + ...(source.instancePath ? { instancePath: cloneStringArray(source.instancePath) } : {}), + ...(source.lineage ? { lineage: cloneStringArray(source.lineage) } : {}), + } + : undefined; + +export const cloneScenePrefabMetadata = ( + metadata: ScenePrefabMetadata | undefined, +): ScenePrefabMetadata | undefined => + metadata + ? { + ...(metadata.revision ? { revision: metadata.revision } : {}), + ...(metadata.locale ? { locale: metadata.locale } : {}), + ...(metadata.updatedAt ? { updatedAt: metadata.updatedAt } : {}), + ...(metadata.timeZone ? { timeZone: metadata.timeZone } : {}), + ...(metadata.tags ? { tags: cloneStringArray(metadata.tags) } : {}), + } + : undefined; + +export const cloneScenePrefabComponentSelector = ( + selector: ScenePrefabComponentSelector, +): ScenePrefabComponentSelector => + selector.kind === 'id' + ? { + kind: 'id', + componentId: selector.componentId, + ...(selector.type ? { type: selector.type } : {}), + } + : { + kind: 'type', + type: selector.type, + ...(selector.occurrence !== undefined ? { occurrence: selector.occurrence } : {}), + }; + +export const cloneSceneComponentSnapshot = ( + component: SceneComponentSnapshot, +): SceneComponentSnapshot => ({ + ...(component.id ? { id: component.id } : {}), + type: component.type, + data: cloneSceneSerializedValue(component.data), +}); + +export const cloneSceneActorSnapshot = (actor: SceneActorSnapshot): SceneActorSnapshot => ({ + ...(actor.nodeId ? { nodeId: actor.nodeId } : {}), + ...(actor.parentNodeId !== undefined ? { parentNodeId: actor.parentNodeId ?? null } : {}), + name: actor.name, + layer: actor.layer, + tag: actor.tag, + active: actor.active, + persistent: actor.persistent, + pooled: actor.pooled, + ...(actor.source ? { source: cloneScenePrefabNodeSource(actor.source) } : {}), + components: actor.components.map((component) => cloneSceneComponentSnapshot(component)), +}); + +export const cloneScenePrefabOverrideOperation = ( + operation: ScenePrefabOverrideOperation, +): ScenePrefabOverrideOperation => { + switch (operation.kind) { + case 'add-actor': + return { + kind: 'add-actor', + actor: cloneSceneActorSnapshot(operation.actor), + ...(operation.afterNodeId ? { afterNodeId: operation.afterNodeId } : {}), + }; + case 'remove-actor': + return { + kind: 'remove-actor', + nodeId: operation.nodeId, + }; + case 'reparent-actor': + return { + kind: 'reparent-actor', + nodeId: operation.nodeId, + ...(operation.parentNodeId !== undefined + ? { parentNodeId: operation.parentNodeId ?? null } + : {}), + }; + case 'set-actor-field': + return { + kind: 'set-actor-field', + nodeId: operation.nodeId, + field: operation.field, + value: operation.value, + }; + case 'add-component': + return { + kind: 'add-component', + nodeId: operation.nodeId, + component: cloneSceneComponentSnapshot(operation.component), + ...(operation.index !== undefined ? { index: operation.index } : {}), + }; + case 'remove-component': + return { + kind: 'remove-component', + nodeId: operation.nodeId, + selector: cloneScenePrefabComponentSelector(operation.selector), + }; + case 'replace-component': + return { + kind: 'replace-component', + nodeId: operation.nodeId, + selector: cloneScenePrefabComponentSelector(operation.selector), + component: cloneSceneComponentSnapshot(operation.component), + }; + case 'set-component-property': + return { + kind: 'set-component-property', + nodeId: operation.nodeId, + selector: cloneScenePrefabComponentSelector(operation.selector), + path: [...operation.path], + value: cloneSceneSerializedValue(operation.value), + }; + case 'unset-component-property': + return { + kind: 'unset-component-property', + nodeId: operation.nodeId, + selector: cloneScenePrefabComponentSelector(operation.selector), + path: [...operation.path], + }; + } +}; + +const cloneScenePrefabNestedInstance = ( + nested: ScenePrefabNestedInstance, + seen: WeakMap, +): ScenePrefabNestedInstance => ({ + instanceId: nested.instanceId, + reference: cloneScenePrefabReference(nested.reference, seen), + ...(nested.parentNodeId !== undefined ? { parentNodeId: nested.parentNodeId ?? null } : {}), + ...(nested.namePrefix ? { namePrefix: nested.namePrefix } : {}), + ...(nested.overrides + ? { + overrides: nested.overrides.map((operation) => + cloneScenePrefabOverrideOperation(operation), + ), + } + : {}), +}); + +export const cloneScenePrefabReference = ( + reference: ScenePrefabReference, + seen: WeakMap = new WeakMap(), +): ScenePrefabReference => + reference.kind === 'inline' + ? { + kind: 'inline', + prefab: cloneScenePrefabDefinition(reference.prefab, seen), + } + : { + kind: 'registry', + prefabId: reference.prefabId, + ...(reference.revision ? { revision: reference.revision } : {}), + }; + +export const cloneScenePrefabDefinition = ( + definition: ScenePrefabDefinition, + seen: WeakMap = new WeakMap(), +): ScenePrefabDefinition => { + const cached = seen.get(definition); + if (cached) { + return cached; + } + + const clone: { + id: string; + actors: readonly SceneActorSnapshot[]; + kind?: ScenePrefabDefinition['kind']; + base?: ScenePrefabReference; + nested?: readonly ScenePrefabNestedInstance[]; + overrides?: readonly ScenePrefabOverrideOperation[]; + metadata?: ScenePrefabMetadata; + lineage?: readonly string[]; + } = { + id: definition.id, + actors: [], + }; + + seen.set(definition, clone as ScenePrefabDefinition); + + if (definition.kind) { + clone.kind = definition.kind; + } + + clone.actors = definition.actors.map((actor) => cloneSceneActorSnapshot(actor)); + + if ('lineage' in definition && Array.isArray(definition.lineage)) { + clone.lineage = cloneStringArray(definition.lineage); + } + + if (definition.base) { + clone.base = cloneScenePrefabReference(definition.base, seen); + } + + if (definition.nested) { + clone.nested = definition.nested.map((nested) => cloneScenePrefabNestedInstance(nested, seen)); + } + + if (definition.overrides) { + clone.overrides = definition.overrides.map((operation) => + cloneScenePrefabOverrideOperation(operation), + ); + } + + if (definition.metadata) { + clone.metadata = cloneScenePrefabMetadata(definition.metadata); + } + + return clone as ScenePrefabDefinition; +}; + +export const isScenePrefabReference = (value: unknown): value is ScenePrefabReference => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + + const reference = value as Partial; + return ( + (reference.kind === 'inline' && !!reference.prefab) || + (reference.kind === 'registry' && typeof reference.prefabId === 'string') + ); +}; + +export const isInlineScenePrefabReference = ( + reference: ScenePrefabReference, +): reference is Extract => reference.kind === 'inline'; + +export const createScenePrefabScopedNodeId = ( + instanceId: string, + nodeId: string, +): string => `${instanceId}::${nodeId}`; + +export const serializeScenePrefabPropertyPath = (path: ScenePrefabPropertyPath): string => + path + .map((segment) => + typeof segment === 'number' + ? `[${segment}]` + : String(segment).replace(/[.[\]\\]/g, '\\$&'), + ) + .join('.'); + +export const isScenePrefabPropertyPathAncestor = ( + ancestor: ScenePrefabPropertyPath, + descendant: ScenePrefabPropertyPath, +): boolean => + ancestor.length <= descendant.length && ancestor.every((segment, index) => segment === descendant[index]); + +export const createScenePrefabComponentSelector = ( + components: readonly SceneComponentSnapshot[], + index: number, +): ScenePrefabComponentSelector => { + const component = components[index]; + if (!component) { + throw new ScenePrefabValidationError(`Cannot create component selector for index ${index}`); + } + + if (component.id) { + return { + kind: 'id', + componentId: component.id, + ...(component.type ? { type: component.type } : {}), + }; + } + + let occurrence = 0; + for (let cursor = 0; cursor < index; cursor += 1) { + if (components[cursor]?.type === component.type) { + occurrence += 1; + } + } + + return { + kind: 'type', + type: component.type, + ...(occurrence > 0 ? { occurrence } : {}), + }; +}; + +export const getScenePrefabComponentSelectorKey = ( + selector: ScenePrefabComponentSelector, +): string => + selector.kind === 'id' + ? `id:${selector.componentId}` + : `type:${selector.type}#${selector.occurrence ?? 0}`; + +export const findScenePrefabComponentIndex = ( + components: readonly SceneComponentSnapshot[], + selector: ScenePrefabComponentSelector, +): number => { + if (selector.kind === 'id') { + const directIndex = components.findIndex((component) => component.id === selector.componentId); + if (directIndex >= 0) { + return directIndex; + } + } + + const typeName = selector.kind === 'type' ? selector.type : selector.type; + if (!typeName) { + return -1; + } + + const targetOccurrence = selector.kind === 'type' ? selector.occurrence ?? 0 : 0; + let occurrence = 0; + + for (let index = 0; index < components.length; index += 1) { + if (components[index]?.type !== typeName) { + continue; + } + + if (occurrence === targetOccurrence) { + return index; + } + + occurrence += 1; + } + + return -1; +}; + +export const ensureScenePrefabNodeId = ( + actor: SceneActorSnapshot, + prefabId: string, + index: number, +): string => { + const nodeId = actor.nodeId?.trim(); + if (nodeId) { + return nodeId; + } + + throw new ScenePrefabValidationError( + `Prefab '${prefabId}' actor at index ${index} is missing a stable nodeId`, + ); +}; + +export const hasScenePrefabComposition = (definition: ScenePrefabDefinition): boolean => + !!definition.base || (definition.nested?.length ?? 0) > 0 || (definition.overrides?.length ?? 0) > 0; \ No newline at end of file diff --git a/web/packages/scene-runtime/src/scene-prefab-operations.ts b/web/packages/scene-runtime/src/scene-prefab-operations.ts new file mode 100644 index 00000000..fe17a82d --- /dev/null +++ b/web/packages/scene-runtime/src/scene-prefab-operations.ts @@ -0,0 +1,686 @@ +import { ScenePrefabValidationError } from './errors'; +import { + cloneSceneActorSnapshot, + cloneSceneComponentSnapshot, + cloneScenePrefabDefinition, + cloneScenePrefabMetadata, + cloneScenePrefabNodeSource, + cloneSceneSerializedValue, + createScenePrefabScopedNodeId, + ensureScenePrefabNodeId, + findScenePrefabComponentIndex, + isSceneSerializedObjectValue, +} from './scene-prefab-internals'; +import type { + SceneActorSnapshot, + SceneComponentSnapshot, + ScenePrefabDefinition, + ScenePrefabNestedInstance, + ScenePrefabOverrideOperation, + ScenePrefabPropertyPath, + ScenePrefabResolvedDefinition, + ScenePrefabNodeSource, + SceneSerializedValue, +} from './types'; + +interface MutableSceneComponentSnapshot { + id?: string; + type: string; + data: SceneSerializedValue; +} + +interface MutableSceneActorSnapshot { + nodeId: string; + parentNodeId: string | null; + name: string; + layer: number; + tag: string; + active: boolean; + persistent: boolean; + pooled: boolean; + source?: ScenePrefabNodeSource; + components: MutableSceneComponentSnapshot[]; +} + +export interface ScenePrefabState { + id: string; + actors: MutableSceneActorSnapshot[]; + actorIndex: Map; +} + +const toMutableComponent = (component: SceneComponentSnapshot): MutableSceneComponentSnapshot => ({ + ...(component.id ? { id: component.id } : {}), + type: component.type, + data: cloneSceneSerializedValue(component.data), +}); + +const createActorSource = ( + actor: SceneActorSnapshot, + nodeId: string, + sourcePrefabId: string, + lineage: readonly string[], +): ScenePrefabNodeSource => + cloneScenePrefabNodeSource(actor.source) ?? { + prefabId: sourcePrefabId, + nodeId, + ...(lineage.length > 0 ? { lineage: [...lineage] } : {}), + }; + +const toMutableActor = ( + actor: SceneActorSnapshot, + index: number, + sourcePrefabId: string, + lineage: readonly string[], +): MutableSceneActorSnapshot => { + const nodeId = ensureScenePrefabNodeId(actor, sourcePrefabId, index); + return { + nodeId, + parentNodeId: actor.parentNodeId ?? null, + name: actor.name, + layer: actor.layer, + tag: actor.tag, + active: actor.active, + persistent: actor.persistent, + pooled: actor.pooled, + source: createActorSource(actor, nodeId, sourcePrefabId, lineage), + components: actor.components.map((component) => toMutableComponent(component)), + }; +}; + +const materializeComponent = (component: MutableSceneComponentSnapshot): SceneComponentSnapshot => ({ + ...(component.id ? { id: component.id } : {}), + type: component.type, + data: cloneSceneSerializedValue(component.data), +}); + +const materializeActor = (actor: MutableSceneActorSnapshot): SceneActorSnapshot => ({ + nodeId: actor.nodeId, + parentNodeId: actor.parentNodeId, + name: actor.name, + layer: actor.layer, + tag: actor.tag, + active: actor.active, + persistent: actor.persistent, + pooled: actor.pooled, + ...(actor.source ? { source: cloneScenePrefabNodeSource(actor.source) } : {}), + components: actor.components.map((component) => materializeComponent(component)), +}); + +const rebuildActorIndex = (state: ScenePrefabState): void => { + state.actorIndex.clear(); + for (const actor of state.actors) { + if (state.actorIndex.has(actor.nodeId)) { + throw new ScenePrefabValidationError( + `Prefab '${state.id}' contains duplicate actor nodeId '${actor.nodeId}'`, + ); + } + state.actorIndex.set(actor.nodeId, actor); + } +}; + +const getScenePrefabLineage = (definition: ScenePrefabDefinition): readonly string[] => + definition.kind === 'resolved' && 'lineage' in definition && Array.isArray(definition.lineage) + ? definition.lineage + : [definition.id]; + +export const createScenePrefabState = ( + definition: Pick, + sourcePrefabId = definition.id, + lineage: readonly string[] = [sourcePrefabId], +): ScenePrefabState => { + const actors = definition.actors.map((actor, index) => + toMutableActor(actor, index, sourcePrefabId, lineage), + ); + const state: ScenePrefabState = { + id: definition.id, + actors, + actorIndex: new Map(), + }; + rebuildActorIndex(state); + return state; +}; + +export const materializeScenePrefabActors = ( + state: ScenePrefabState, +): readonly SceneActorSnapshot[] => state.actors.map((actor) => materializeActor(actor)); + +export const materializeScenePrefabDefinition = ( + definition: ScenePrefabDefinition, + state: ScenePrefabState, +): ScenePrefabDefinition => { + const clone = cloneScenePrefabDefinition(definition); + return { + ...clone, + actors: materializeScenePrefabActors(state), + }; +}; + +export const materializeScenePrefabResolvedDefinition = ( + definition: ScenePrefabDefinition, + state: ScenePrefabState, + lineage: readonly string[], +): ScenePrefabResolvedDefinition => ({ + id: definition.id, + kind: 'resolved', + actors: materializeScenePrefabActors(state), + lineage: [...lineage], + ...(definition.metadata ? { metadata: cloneScenePrefabMetadata(definition.metadata) } : {}), +}); + +export const mergeScenePrefabActors = ( + state: ScenePrefabState, + actors: readonly SceneActorSnapshot[], + sourcePrefabId: string, + lineage: readonly string[] = [sourcePrefabId], +): void => { + for (let index = 0; index < actors.length; index += 1) { + const actor = toMutableActor(actors[index]!, index, sourcePrefabId, lineage); + const existingActor = state.actorIndex.get(actor.nodeId); + if (existingActor) { + const actorIndex = state.actors.indexOf(existingActor); + state.actors.splice(actorIndex, 1, actor); + } else { + state.actors.push(actor); + } + + state.actorIndex.set(actor.nodeId, actor); + } +}; + +export const validateScenePrefabState = (state: ScenePrefabState): void => { + rebuildActorIndex(state); + + for (const actor of state.actors) { + if (actor.parentNodeId !== null && !state.actorIndex.has(actor.parentNodeId)) { + throw new ScenePrefabValidationError( + `Prefab '${state.id}' actor '${actor.nodeId}' references missing parent '${actor.parentNodeId}'`, + ); + } + } + + const visited = new Set(); + const visiting = new Set(); + + const visit = (nodeId: string): void => { + if (visited.has(nodeId)) { + return; + } + + if (visiting.has(nodeId)) { + throw new ScenePrefabValidationError( + `Prefab '${state.id}' contains a hierarchy cycle at '${nodeId}'`, + ); + } + + visiting.add(nodeId); + + const parentNodeId = state.actorIndex.get(nodeId)?.parentNodeId; + if (parentNodeId !== null && parentNodeId !== undefined) { + visit(parentNodeId); + } + + visiting.delete(nodeId); + visited.add(nodeId); + }; + + for (const actor of state.actors) { + visit(actor.nodeId); + } +}; + +const ensureActor = (state: ScenePrefabState, nodeId: string): MutableSceneActorSnapshot => { + const actor = state.actorIndex.get(nodeId); + if (!actor) { + throw new ScenePrefabValidationError( + `Prefab '${state.id}' does not contain actor '${nodeId}'`, + ); + } + return actor; +}; + +const ensureComponentIndex = ( + state: ScenePrefabState, + actor: MutableSceneActorSnapshot, + operation: ScenePrefabOverrideOperation, +): number => { + if ( + operation.kind !== 'remove-component' && + operation.kind !== 'replace-component' && + operation.kind !== 'set-component-property' && + operation.kind !== 'unset-component-property' + ) { + throw new ScenePrefabValidationError('Invalid component operation'); + } + + const componentIndex = findScenePrefabComponentIndex(actor.components, operation.selector); + if (componentIndex >= 0) { + return componentIndex; + } + + throw new ScenePrefabValidationError( + `Prefab '${state.id}' actor '${actor.nodeId}' is missing component selector`, + ); +}; + +const collectSubtreeIds = (state: ScenePrefabState, rootNodeId: string): Set => { + const collected = new Set(); + const pending = [rootNodeId]; + + while (pending.length > 0) { + const currentNodeId = pending.pop()!; + if (collected.has(currentNodeId)) { + continue; + } + + collected.add(currentNodeId); + + for (const actor of state.actors) { + if (actor.parentNodeId === currentNodeId) { + pending.push(actor.nodeId); + } + } + } + + return collected; +}; + +const normalizeComponentInsertIndex = (index: number | undefined, length: number): number => { + if (index === undefined) { + return length; + } + + if (!Number.isInteger(index)) { + throw new ScenePrefabValidationError(`Invalid component insertion index '${index}'`); + } + + return Math.min(Math.max(index, 0), length); +}; + +const validateActorFieldValue = ( + operation: Extract, +): void => { + switch (operation.field) { + case 'name': + case 'tag': + if (typeof operation.value !== 'string') { + throw new ScenePrefabValidationError( + `Actor field '${operation.field}' expects a string value`, + ); + } + return; + case 'layer': + if (typeof operation.value !== 'number' || !Number.isFinite(operation.value)) { + throw new ScenePrefabValidationError("Actor field 'layer' expects a finite number"); + } + return; + case 'active': + case 'persistent': + case 'pooled': + if (typeof operation.value !== 'boolean') { + throw new ScenePrefabValidationError( + `Actor field '${operation.field}' expects a boolean value`, + ); + } + return; + } +}; + +const setSceneSerializedValueAtPath = ( + source: SceneSerializedValue, + path: ScenePrefabPropertyPath, + value: SceneSerializedValue, +): SceneSerializedValue => { + if (path.length === 0) { + return cloneSceneSerializedValue(value); + } + + const [head, ...tail] = path; + if (typeof head === 'number') { + const nextArray = Array.isArray(source) ? [...source] : []; + const currentValue = nextArray[head] ?? null; + nextArray[head] = setSceneSerializedValueAtPath(currentValue, tail, value); + return nextArray; + } + + const nextObject = isSceneSerializedObjectValue(source) ? { ...source } : {}; + nextObject[head] = setSceneSerializedValueAtPath(nextObject[head] ?? null, tail, value); + return nextObject; +}; + +const unsetSceneSerializedValueAtPath = ( + source: SceneSerializedValue, + path: ScenePrefabPropertyPath, +): SceneSerializedValue => { + if (path.length === 0) { + return null; + } + + const [head, ...tail] = path; + if (typeof head === 'number') { + const nextArray = Array.isArray(source) ? [...source] : []; + if (tail.length === 0) { + nextArray.splice(head, 1); + return nextArray; + } + + nextArray[head] = unsetSceneSerializedValueAtPath(nextArray[head] ?? null, tail); + return nextArray; + } + + const nextObject = isSceneSerializedObjectValue(source) ? { ...source } : {}; + if (tail.length === 0) { + delete nextObject[head]; + return nextObject; + } + + nextObject[head] = unsetSceneSerializedValueAtPath(nextObject[head] ?? null, tail); + return nextObject; +}; + +export const readSceneSerializedValueAtPath = ( + source: SceneSerializedValue, + path: ScenePrefabPropertyPath, +): SceneSerializedValue | null => { + if (path.length === 0) { + return cloneSceneSerializedValue(source); + } + + const [head, ...tail] = path; + if (typeof head === 'number') { + if (!Array.isArray(source) || head < 0 || head >= source.length) { + return null; + } + return readSceneSerializedValueAtPath(source[head]!, tail); + } + + if (!isSceneSerializedObjectValue(source) || !(head in source)) { + return null; + } + + return readSceneSerializedValueAtPath(source[head]!, tail); +}; + +const applyScenePrefabOverrideOperation = ( + state: ScenePrefabState, + operation: ScenePrefabOverrideOperation, +): void => { + switch (operation.kind) { + case 'add-actor': { + const actor = toMutableActor(operation.actor, 0, state.id, [state.id]); + if (state.actorIndex.has(actor.nodeId)) { + throw new ScenePrefabValidationError( + `Prefab '${state.id}' already contains actor '${actor.nodeId}'`, + ); + } + + const insertIndex = operation.afterNodeId + ? state.actors.findIndex((entry) => entry.nodeId === operation.afterNodeId) + : -1; + + if (insertIndex >= 0) { + state.actors.splice(insertIndex + 1, 0, actor); + } else { + state.actors.push(actor); + } + + state.actorIndex.set(actor.nodeId, actor); + return; + } + case 'remove-actor': { + ensureActor(state, operation.nodeId); + const subtreeIds = collectSubtreeIds(state, operation.nodeId); + state.actors = state.actors.filter((actor) => !subtreeIds.has(actor.nodeId)); + for (const nodeId of subtreeIds) { + state.actorIndex.delete(nodeId); + } + return; + } + case 'reparent-actor': { + const actor = ensureActor(state, operation.nodeId); + if ( + operation.parentNodeId !== undefined && + operation.parentNodeId !== null && + !state.actorIndex.has(operation.parentNodeId) + ) { + throw new ScenePrefabValidationError( + `Prefab '${state.id}' cannot reparent '${operation.nodeId}' to missing parent '${operation.parentNodeId}'`, + ); + } + + actor.parentNodeId = operation.parentNodeId ?? null; + return; + } + case 'set-actor-field': { + validateActorFieldValue(operation); + const actor = ensureActor(state, operation.nodeId); + switch (operation.field) { + case 'name': { + const nextValue = operation.value; + if (typeof nextValue !== 'string') { + throw new ScenePrefabValidationError("Actor field 'name' expects a string value"); + } + actor.name = nextValue; + return; + } + case 'layer': { + const nextValue = operation.value; + if (typeof nextValue !== 'number') { + throw new ScenePrefabValidationError("Actor field 'layer' expects a number value"); + } + actor.layer = nextValue; + return; + } + case 'tag': { + const nextValue = operation.value; + if (typeof nextValue !== 'string') { + throw new ScenePrefabValidationError("Actor field 'tag' expects a string value"); + } + actor.tag = nextValue; + return; + } + case 'active': { + const nextValue = operation.value; + if (typeof nextValue !== 'boolean') { + throw new ScenePrefabValidationError("Actor field 'active' expects a boolean value"); + } + actor.active = nextValue; + return; + } + case 'persistent': { + const nextValue = operation.value; + if (typeof nextValue !== 'boolean') { + throw new ScenePrefabValidationError( + "Actor field 'persistent' expects a boolean value", + ); + } + actor.persistent = nextValue; + return; + } + case 'pooled': { + const nextValue = operation.value; + if (typeof nextValue !== 'boolean') { + throw new ScenePrefabValidationError("Actor field 'pooled' expects a boolean value"); + } + actor.pooled = nextValue; + return; + } + } + return; + } + case 'add-component': { + const actor = ensureActor(state, operation.nodeId); + if ( + operation.component.id && + actor.components.some((component) => component.id === operation.component.id) + ) { + throw new ScenePrefabValidationError( + `Prefab '${state.id}' actor '${actor.nodeId}' already contains component '${operation.component.id}'`, + ); + } + + const insertIndex = normalizeComponentInsertIndex( + operation.index, + actor.components.length, + ); + actor.components.splice(insertIndex, 0, toMutableComponent(operation.component)); + return; + } + case 'remove-component': { + const actor = ensureActor(state, operation.nodeId); + const componentIndex = ensureComponentIndex(state, actor, operation); + actor.components.splice(componentIndex, 1); + return; + } + case 'replace-component': { + const actor = ensureActor(state, operation.nodeId); + const componentIndex = ensureComponentIndex(state, actor, operation); + actor.components.splice(componentIndex, 1, toMutableComponent(operation.component)); + return; + } + case 'set-component-property': { + const actor = ensureActor(state, operation.nodeId); + const componentIndex = ensureComponentIndex(state, actor, operation); + const component = actor.components[componentIndex]!; + component.data = setSceneSerializedValueAtPath( + component.data, + operation.path, + operation.value, + ); + return; + } + case 'unset-component-property': { + const actor = ensureActor(state, operation.nodeId); + const componentIndex = ensureComponentIndex(state, actor, operation); + const component = actor.components[componentIndex]!; + component.data = unsetSceneSerializedValueAtPath(component.data, operation.path); + return; + } + } +}; + +export const applyScenePrefabOverrideOperations = ( + state: ScenePrefabState, + operations: readonly ScenePrefabOverrideOperation[], +): void => { + for (const operation of operations) { + applyScenePrefabOverrideOperation(state, operation); + } + + validateScenePrefabState(state); +}; + +export const scopeScenePrefabActors = ( + actors: readonly SceneActorSnapshot[], + nested: ScenePrefabNestedInstance, + sourcePrefabId: string, +): readonly SceneActorSnapshot[] => + actors.map((actor, index) => { + const originalNodeId = ensureScenePrefabNodeId(actor, sourcePrefabId, index); + const scopedActor = cloneSceneActorSnapshot(actor); + const source = cloneScenePrefabNodeSource(actor.source) ?? { + prefabId: sourcePrefabId, + nodeId: originalNodeId, + }; + + return { + ...scopedActor, + nodeId: createScenePrefabScopedNodeId(nested.instanceId, originalNodeId), + parentNodeId: actor.parentNodeId + ? createScenePrefabScopedNodeId(nested.instanceId, actor.parentNodeId) + : nested.parentNodeId ?? null, + name: nested.namePrefix ? `${nested.namePrefix}${actor.name}` : actor.name, + source: { + ...source, + instancePath: [...(source.instancePath ?? []), nested.instanceId], + }, + }; + }); + +export const scopeScenePrefabOverrideOperations = ( + operations: readonly ScenePrefabOverrideOperation[], + nested: ScenePrefabNestedInstance, + sourcePrefabId: string, +): readonly ScenePrefabOverrideOperation[] => + operations.map((operation) => { + const scopeNodeId = (nodeId: string): string => + createScenePrefabScopedNodeId(nested.instanceId, nodeId); + + switch (operation.kind) { + case 'add-actor': { + const scopedActor = scopeScenePrefabActors([operation.actor], nested, sourcePrefabId)[0]!; + return { + kind: 'add-actor', + actor: scopedActor, + ...(operation.afterNodeId ? { afterNodeId: scopeNodeId(operation.afterNodeId) } : {}), + }; + } + case 'remove-actor': + return { + kind: 'remove-actor', + nodeId: scopeNodeId(operation.nodeId), + }; + case 'reparent-actor': + return { + kind: 'reparent-actor', + nodeId: scopeNodeId(operation.nodeId), + ...(operation.parentNodeId !== undefined + ? { + parentNodeId: operation.parentNodeId + ? scopeNodeId(operation.parentNodeId) + : nested.parentNodeId ?? null, + } + : {}), + }; + case 'set-actor-field': + return { + kind: 'set-actor-field', + nodeId: scopeNodeId(operation.nodeId), + field: operation.field, + value: operation.value, + }; + case 'add-component': + return { + kind: 'add-component', + nodeId: scopeNodeId(operation.nodeId), + component: cloneSceneComponentSnapshot(operation.component), + ...(operation.index !== undefined ? { index: operation.index } : {}), + }; + case 'remove-component': + return { + kind: 'remove-component', + nodeId: scopeNodeId(operation.nodeId), + selector: operation.selector, + }; + case 'replace-component': + return { + kind: 'replace-component', + nodeId: scopeNodeId(operation.nodeId), + selector: operation.selector, + component: cloneSceneComponentSnapshot(operation.component), + }; + case 'set-component-property': + return { + kind: 'set-component-property', + nodeId: scopeNodeId(operation.nodeId), + selector: operation.selector, + path: [...operation.path], + value: cloneSceneSerializedValue(operation.value), + }; + case 'unset-component-property': + return { + kind: 'unset-component-property', + nodeId: scopeNodeId(operation.nodeId), + selector: operation.selector, + path: [...operation.path], + }; + } + }); + +export const applyScenePrefabOverrides = ( + definition: ScenePrefabDefinition, + operations: readonly ScenePrefabOverrideOperation[], +): ScenePrefabDefinition => { + const state = createScenePrefabState(definition, definition.id, getScenePrefabLineage(definition)); + applyScenePrefabOverrideOperations(state, operations); + return materializeScenePrefabDefinition(definition, state); +}; \ No newline at end of file diff --git a/web/packages/scene-runtime/src/scene-prefab-runtime.ts b/web/packages/scene-runtime/src/scene-prefab-runtime.ts index 4ccf8f9f..f83d7f0a 100644 --- a/web/packages/scene-runtime/src/scene-prefab-runtime.ts +++ b/web/packages/scene-runtime/src/scene-prefab-runtime.ts @@ -2,9 +2,14 @@ import { Hierarchy } from '@axrone/ecs-runtime'; import { Actor, type ActorConfig } from '@axrone/ecs-runtime'; import { Component } from '@axrone/ecs-runtime'; import type { ComponentConstructor } from '@axrone/ecs-runtime'; +import { Transform, getComponentPropertyMetadata } from '@axrone/ecs-runtime'; +import type { PropertyMetadata, PropertyTypeId, PropertyTypeReference } from '@axrone/ecs-runtime'; +import { Vec2, Vec3 } from '@axrone/numeric'; import { PrefabNodeBinding } from './components/prefab-node-binding'; import type { SceneComponentTypeResolver } from './component-catalog'; import { SceneLifecycleError } from './errors'; +import { hasScenePrefabComposition } from './scene-prefab-internals'; +import { resolveScenePrefab } from './scene-prefab-workflow'; import { decodeSceneValue, encodeSceneValue } from './serialization'; import type { SceneActorSnapshot, @@ -19,6 +24,208 @@ interface ScenePrefabHost { getAllActors(): readonly Actor[]; } +const EDITOR_SCRIPT_METADATA_KEYS = new Set([ + 'scriptPath', + 'className', + 'scriptName', + 'executeInEditMode', + 'propertyValues', +]); + +const hasOwn = (value: object, key: string): boolean => + Object.prototype.hasOwnProperty.call(value, key); + +const asRecord = (value: unknown): Record => + value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; + +const asString = (value: unknown, fallback = ''): string => + typeof value === 'string' ? value : fallback; + +const asNumber = (value: unknown, fallback = 0): number => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number.parseFloat(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + return fallback; +}; + +const asBoolean = (value: unknown, fallback = false): boolean => { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + if (value === 'true') { + return true; + } + + if (value === 'false') { + return false; + } + } + + return fallback; +}; + +const resolveVec2Fallback = (value: unknown): readonly [number, number] => { + if (value instanceof Vec2) { + return [value.x, value.y]; + } + + if (Array.isArray(value)) { + return [asNumber(value[0], 0), asNumber(value[1], 0)]; + } + + const objectValue = asRecord(value); + return [asNumber(objectValue.x, 0), asNumber(objectValue.y, 0)]; +}; + +const resolveVec3Fallback = (value: unknown): readonly [number, number, number] => { + if (value instanceof Vec3) { + return [value.x, value.y, value.z]; + } + + if (Array.isArray(value)) { + return [asNumber(value[0], 0), asNumber(value[1], 0), asNumber(value[2], 0)]; + } + + const objectValue = asRecord(value); + return [asNumber(objectValue.x, 0), asNumber(objectValue.y, 0), asNumber(objectValue.z, 0)]; +}; + +const toVec2 = (value: unknown, fallback: unknown): Vec2 => { + if (value instanceof Vec2) { + return value; + } + + const [fallbackX, fallbackY] = resolveVec2Fallback(fallback); + if (Array.isArray(value)) { + return new Vec2(asNumber(value[0], fallbackX), asNumber(value[1], fallbackY)); + } + + const objectValue = asRecord(value); + return new Vec2(asNumber(objectValue.x, fallbackX), asNumber(objectValue.y, fallbackY)); +}; + +const toVec3 = (value: unknown, fallback: unknown): Vec3 => { + if (value instanceof Vec3) { + return value; + } + + const [fallbackX, fallbackY, fallbackZ] = resolveVec3Fallback(fallback); + if (Array.isArray(value)) { + return new Vec3( + asNumber(value[0], fallbackX), + asNumber(value[1], fallbackY), + asNumber(value[2], fallbackZ), + ); + } + + const objectValue = asRecord(value); + return new Vec3( + asNumber(objectValue.x, fallbackX), + asNumber(objectValue.y, fallbackY), + asNumber(objectValue.z, fallbackZ), + ); +}; + +const normalizePropertyTypeId = ( + type: PropertyTypeReference | undefined, +): PropertyTypeId | undefined => { + if (!type) { + return undefined; + } + + if (type === Actor) { + return 'entity'; + } + + if (type === Transform) { + return 'transform'; + } + + if (type === Boolean) { + return 'boolean'; + } + + if (type === Number) { + return 'number'; + } + + if (type === String) { + return 'string'; + } + + if (type === Vec2) { + return 'vec2'; + } + + if (type === Vec3) { + return 'vec3'; + } + + if (typeof type === 'function') { + const name = type.name.toLowerCase(); + if (name === 'actor' || name === 'entity') { + return 'entity'; + } + + if (name === 'transform') { + return 'transform'; + } + + if (name === 'vec2') { + return 'vec2'; + } + + if (name === 'vec3') { + return 'vec3'; + } + } + + if (typeof type !== 'string') { + return undefined; + } + + switch (type.toLowerCase()) { + case 'boolean': + return 'boolean'; + case 'number': + return 'number'; + case 'string': + case 'color': + return 'string'; + case 'vec2': + case 'vector2': + return 'vec2'; + case 'vec3': + case 'vector3': + return 'vec3'; + case 'actor': + case 'entity': + return 'entity'; + case 'transform': + return 'transform'; + default: + return undefined; + } +}; + +const isReferenceLike = (value: unknown): boolean => { + const objectValue = asRecord(value); + const kind = asString(objectValue.kind); + return (kind === 'entity' || kind === 'component') && asString(objectValue.target).length > 0; +}; + let prefabInstanceSequence = 1; const createPrefabInstanceId = (): string => `prefab-instance-${prefabInstanceSequence++}`; @@ -36,7 +243,8 @@ export class ScenePrefabRuntime { ): ScenePrefabDefinition { return { id, - actors: actors.map((actor) => this._createActorSnapshot(actor)), + kind: 'prefab', + actors: actors.map((actor) => this._createActorSnapshot(actor, id)), }; } @@ -44,6 +252,7 @@ export class ScenePrefabRuntime { prefab: ScenePrefabDefinition, options: ScenePrefabInstantiateOptions = {} ): readonly Actor[] { + const resolvedPrefab = this._resolvePrefab(prefab, options); const createdActors: Actor[] = []; const createdByNodeId = new Map(); const instanceId = createPrefabInstanceId(); @@ -56,7 +265,7 @@ export class ScenePrefabRuntime { readonly parentNodeId?: string | null; }> = []; - for (const actorSnapshot of prefab.actors) { + for (const actorSnapshot of resolvedPrefab.actors) { const actor = this._host.createActor({ name: `${options.namePrefix ?? ''}${actorSnapshot.name}`, layer: actorSnapshot.layer as any, @@ -101,13 +310,19 @@ export class ScenePrefabRuntime { for (const pendingHydration of pendingComponentHydration) { for (const componentSnapshot of pendingHydration.components) { - this._hydrateComponent(pendingHydration.actor, componentSnapshot, options); + this._hydrateComponent( + pendingHydration.actor, + componentSnapshot, + options, + createdByNodeId, + createdActors, + ); } } - for (let index = 0; index < prefab.actors.length; index += 1) { + for (let index = 0; index < resolvedPrefab.actors.length; index += 1) { const actor = createdActors[index]!; - const actorSnapshot = prefab.actors[index]!; + const actorSnapshot = resolvedPrefab.actors[index]!; actor.start(); actor.active = actorSnapshot.active; @@ -123,7 +338,7 @@ export class ScenePrefabRuntime { } } - private _createActorSnapshot(actor: Actor): SceneActorSnapshot { + private _createActorSnapshot(actor: Actor, prefabId: string): SceneActorSnapshot { const binding = actor.getComponent(PrefabNodeBinding); const hierarchy = actor.getComponent(Hierarchy); const components = actor @@ -147,6 +362,11 @@ export class ScenePrefabRuntime { active: actor.active, persistent: actor.persistent, pooled: actor.pooled, + source: { + prefabId, + nodeId: binding?.nodeId ?? actor.id, + lineage: [prefabId], + }, components, }; } @@ -156,15 +376,37 @@ export class ScenePrefabRuntime { const data = typeof serialize === 'function' ? (serialize.call(component) ?? {}) : {}; return { + id: component.id, type: this._host.componentCatalog.getName(component.constructor as ComponentConstructor), data: encodeSceneValue(data), }; } + private _resolvePrefab( + prefab: ScenePrefabDefinition, + options: ScenePrefabInstantiateOptions, + ): ScenePrefabDefinition { + if (options.prefabResolver) { + return options.prefabResolver.resolvePrefab(prefab, { + liveOverrides: options.liveOverrides, + }).definition; + } + + if (hasScenePrefabComposition(prefab) || (options.liveOverrides?.length ?? 0) > 0) { + return resolveScenePrefab(prefab, { + liveOverrides: options.liveOverrides, + }).definition; + } + + return prefab; + } + private _hydrateComponent( actor: Actor, snapshot: SceneComponentSnapshot, - options: ScenePrefabInstantiateOptions + options: ScenePrefabInstantiateOptions, + createdByNodeId: ReadonlyMap, + createdActors: readonly Actor[] ): void { const componentType = this._host.componentCatalog.get(snapshot.type); if (!componentType) { @@ -184,20 +426,292 @@ export class ScenePrefabRuntime { ); const decoded = decodeSceneValue(snapshot.data); - if ( - typeof (component as { deserialize?: (data: Record) => void }) - .deserialize === 'function' - ) { - (component as { deserialize(data: Record): void }).deserialize( - (decoded && typeof decoded === 'object' && !Array.isArray(decoded) - ? decoded - : {}) as Record - ); + const normalized = + decoded && typeof decoded === 'object' && !Array.isArray(decoded) + ? this._normalizeComponentData( + componentType, + component, + decoded as Record, + createdByNodeId, + createdActors, + ) + : {}; + const deserialize = (component as { + deserialize?: (data: Record) => void; + }).deserialize; + const hasCustomDeserialize = + typeof deserialize === 'function' && deserialize !== Component.prototype.deserialize; + + if (hasCustomDeserialize) { + deserialize.call(component, normalized as Record); return; } - if (decoded && typeof decoded === 'object' && !Array.isArray(decoded)) { - Object.assign(component as object, decoded); + if (normalized && typeof normalized === 'object' && !Array.isArray(normalized)) { + this._assignHydratedProperties(component, normalized); } } + + private _assignHydratedProperties( + component: Component, + values: Record + ): void { + const target = component as unknown as Record; + + for (const [propertyKey, value] of Object.entries(values)) { + if (propertyKey === 'id') { + continue; + } + + const descriptor = + this._findPropertyDescriptor(component, propertyKey) ?? + Object.getOwnPropertyDescriptor(target, propertyKey); + if ( + descriptor && + ('writable' in descriptor + ? descriptor.writable === false + : descriptor.set === undefined) + ) { + continue; + } + + target[propertyKey] = value; + } + } + + private _findPropertyDescriptor( + component: Component, + propertyKey: string + ): PropertyDescriptor | undefined { + let prototype = Object.getPrototypeOf(component); + + while (prototype && prototype !== Object.prototype) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, propertyKey); + if (descriptor) { + return descriptor; + } + prototype = Object.getPrototypeOf(prototype); + } + + return undefined; + } + + private _normalizeComponentData( + componentType: ComponentConstructor, + component: Component, + decoded: Record, + createdByNodeId: ReadonlyMap, + createdActors: readonly Actor[], + ): Record { + const propertyMetadata = getComponentPropertyMetadata(componentType as any); + if (propertyMetadata.length === 0) { + return decoded; + } + + const hasPropertyValues = hasOwn(decoded, 'propertyValues') && decoded.propertyValues !== null; + const sourceValues = hasPropertyValues ? asRecord(decoded.propertyValues) : decoded; + const normalized = hasPropertyValues + ? this._stripEditorScriptMetadata(decoded) + : { ...decoded }; + const resolvedPropertyKeys = new Set(); + + for (const metadata of propertyMetadata) { + if (metadata.serializable === false || !hasOwn(sourceValues, metadata.propertyKey)) { + continue; + } + + normalized[metadata.propertyKey] = this._resolvePropertyValue( + component, + metadata, + sourceValues[metadata.propertyKey], + createdByNodeId, + createdActors, + ); + resolvedPropertyKeys.add(metadata.propertyKey); + } + + if (hasPropertyValues) { + for (const [propertyKey, value] of Object.entries(sourceValues)) { + if (resolvedPropertyKeys.has(propertyKey)) { + continue; + } + + normalized[propertyKey] = this._resolveFallbackPropertyValue( + component, + propertyKey, + value, + createdByNodeId, + createdActors, + ); + } + } + + return normalized; + } + + private _stripEditorScriptMetadata(decoded: Record): Record { + const normalized: Record = {}; + + for (const [key, value] of Object.entries(decoded)) { + if (!EDITOR_SCRIPT_METADATA_KEYS.has(key)) { + normalized[key] = value; + } + } + + return normalized; + } + + private _resolvePropertyValue( + component: Component, + metadata: PropertyMetadata, + value: unknown, + createdByNodeId: ReadonlyMap, + createdActors: readonly Actor[], + ): unknown { + const currentValue = (component as unknown as Record)[metadata.propertyKey]; + const fallbackValue = currentValue ?? metadata.defaultValue; + + switch (normalizePropertyTypeId(metadata.type)) { + case 'boolean': + return asBoolean(value, asBoolean(fallbackValue, false)); + case 'number': + return asNumber(value, asNumber(fallbackValue, 0)); + case 'string': + return asString(value, asString(fallbackValue, '')); + case 'vec2': + return toVec2(value, fallbackValue); + case 'vec3': + return toVec3(value, fallbackValue); + case 'entity': + return this._resolveActorReference(value, createdByNodeId, createdActors) ?? null; + case 'transform': { + const targetActor = this._resolveActorReference(value, createdByNodeId, createdActors); + return targetActor?.getComponent(Transform) ?? null; + } + default: + return value; + } + } + + private _resolveFallbackPropertyValue( + component: Component, + propertyKey: string, + value: unknown, + createdByNodeId: ReadonlyMap, + createdActors: readonly Actor[], + ): unknown { + const currentValue = (component as unknown as Record)[propertyKey]; + const normalizedKey = propertyKey.toLowerCase(); + + if (currentValue instanceof Vec2) { + return toVec2(value, currentValue); + } + + if (currentValue instanceof Vec3) { + return toVec3(value, currentValue); + } + + if (typeof currentValue === 'number') { + return asNumber(value, currentValue); + } + + if (typeof currentValue === 'boolean') { + return asBoolean(value, currentValue); + } + + if (typeof currentValue === 'string') { + return asString(value, currentValue); + } + + if (normalizedKey.includes('transform')) { + return ( + this._resolveActorReference(value, createdByNodeId, createdActors)?.getComponent( + Transform, + ) ?? null + ); + } + + if (isReferenceLike(value) || normalizedKey.includes('actor') || normalizedKey.includes('entity')) { + return this._resolveActorReference(value, createdByNodeId, createdActors) ?? null; + } + + const objectValue = asRecord(value); + if (hasOwn(objectValue, 'x') && hasOwn(objectValue, 'y') && hasOwn(objectValue, 'z')) { + return toVec3(value, currentValue); + } + + if (hasOwn(objectValue, 'x') && hasOwn(objectValue, 'y')) { + return toVec2(value, currentValue); + } + + return value; + } + + private _resolveActorReference( + value: unknown, + createdByNodeId: ReadonlyMap, + createdActors: readonly Actor[], + ): Actor | undefined { + if (value instanceof Actor) { + return value; + } + + if (value instanceof Transform) { + return this._findActorByComponentId(value.id, createdActors); + } + + if (typeof value === 'string') { + return this._findActorByReferenceTarget(value, createdByNodeId, createdActors); + } + + const referenceObject = asRecord(value); + const referenceKind = asString(referenceObject.kind); + const referenceTarget = asString(referenceObject.target); + + if (!referenceTarget) { + return undefined; + } + + if (referenceKind === 'component') { + return this._findActorByComponentId(referenceTarget, createdActors); + } + + return this._findActorByReferenceTarget(referenceTarget, createdByNodeId, createdActors); + } + + private _findActorByReferenceTarget( + target: string, + createdByNodeId: ReadonlyMap, + createdActors: readonly Actor[], + ): Actor | undefined { + return ( + createdByNodeId.get(target) ?? + createdActors.find((actor) => actor.id === target) ?? + this._host + .getAllActors() + .find( + (actor) => + actor.id === target || + actor.getComponent(PrefabNodeBinding)?.nodeId === target, + ) + ); + } + + private _findActorByComponentId( + componentId: string, + createdActors: readonly Actor[], + ): Actor | undefined { + const actorSets = [createdActors, this._host.getAllActors()]; + + for (const actors of actorSets) { + const actor = actors.find((candidate) => + candidate.getAllComponents().some((component) => component.id === componentId), + ); + if (actor) { + return actor; + } + } + + return undefined; + } } diff --git a/web/packages/scene-runtime/src/scene-prefab-workflow.ts b/web/packages/scene-runtime/src/scene-prefab-workflow.ts new file mode 100644 index 00000000..0fe02fc5 --- /dev/null +++ b/web/packages/scene-runtime/src/scene-prefab-workflow.ts @@ -0,0 +1,275 @@ +import { ScenePrefabResolutionError } from './errors'; +import { isInlineScenePrefabReference, isScenePrefabReference } from './scene-prefab-internals'; +import { + applyScenePrefabOverrideOperations, + createScenePrefabState, + materializeScenePrefabResolvedDefinition, + mergeScenePrefabActors, + scopeScenePrefabActors, + scopeScenePrefabOverrideOperations, + validateScenePrefabState, +} from './scene-prefab-operations'; +import { diffScenePrefabDefinitions, mergeScenePrefabDefinitions } from './scene-prefab-diff'; +import type { + ScenePrefabDefinition, + ScenePrefabDiffResult, + ScenePrefabMergeDefinitionResult, + ScenePrefabMergeOptions, + ScenePrefabOverrideOperation, + ScenePrefabReference, + ScenePrefabRegistrySource, + ScenePrefabResolveOptions, + ScenePrefabResolutionResult, + ScenePrefabResolvedDefinition, + ScenePrefabResolver, +} from './types'; + +export interface ScenePrefabWorkflowOptions { + readonly prefabs?: readonly ScenePrefabDefinition[]; + readonly registry?: ScenePrefabRegistrySource; + readonly enableCache?: boolean; +} + +export interface ResolveScenePrefabOptions extends ScenePrefabResolveOptions { + readonly registry?: ScenePrefabRegistrySource; +} + +const getDefinitionLineage = (definition: ScenePrefabDefinition): readonly string[] => + definition.kind === 'resolved' && 'lineage' in definition && Array.isArray(definition.lineage) + ? definition.lineage + : [definition.id]; + +export class ScenePrefabWorkflow implements ScenePrefabResolver, ScenePrefabRegistrySource { + private readonly _definitions = new Map(); + private readonly _externalRegistry: ScenePrefabRegistrySource | undefined; + private readonly _resolutionCache = new Map(); + private readonly _enableCache: boolean; + private _revision = 0; + + constructor(options: ScenePrefabWorkflowOptions = {}) { + this._externalRegistry = options.registry; + this._enableCache = options.enableCache !== false; + this.registerAll(options.prefabs ?? []); + } + + register(prefab: ScenePrefabDefinition): this { + this._definitions.set(prefab.id, prefab); + this._revision += 1; + this._resolutionCache.clear(); + return this; + } + + registerAll(prefabs: readonly ScenePrefabDefinition[]): this { + for (const prefab of prefabs) { + this._definitions.set(prefab.id, prefab); + } + + if (prefabs.length > 0) { + this._revision += 1; + this._resolutionCache.clear(); + } + + return this; + } + + unregister(prefabId: string): boolean { + const deleted = this._definitions.delete(prefabId); + if (deleted) { + this._revision += 1; + this._resolutionCache.clear(); + } + return deleted; + } + + clear(): void { + if (this._definitions.size === 0) { + return; + } + + this._definitions.clear(); + this._revision += 1; + this._resolutionCache.clear(); + } + + getPrefab(prefabId: string): ScenePrefabDefinition | undefined { + return this._definitions.get(prefabId) ?? this._externalRegistry?.getPrefab(prefabId); + } + + resolvePrefab( + prefab: ScenePrefabDefinition | ScenePrefabReference, + options: ScenePrefabResolveOptions = {}, + ): ScenePrefabResolutionResult { + const cacheKey = this._createCacheKey(prefab, options); + if (cacheKey) { + const cached = this._resolutionCache.get(cacheKey); + if (cached) { + return { + definition: cached, + conflicts: [], + cacheHit: true, + }; + } + } + + const definition = isScenePrefabReference(prefab) + ? this._resolveReference(prefab, []) + : this._resolveDefinition(prefab, []); + const resolvedDefinition = + options.liveOverrides && options.liveOverrides.length > 0 + ? this._applyLiveOverrides(definition, options.liveOverrides) + : definition; + + if (cacheKey) { + this._resolutionCache.set(cacheKey, resolvedDefinition); + } + + return { + definition: resolvedDefinition, + conflicts: [], + cacheHit: false, + }; + } + + applyOverrides( + prefab: ScenePrefabDefinition | ScenePrefabReference, + overrides: readonly ScenePrefabOverrideOperation[], + options: ScenePrefabResolveOptions = {}, + ): ScenePrefabResolvedDefinition { + const resolvedDefinition = this.resolvePrefab(prefab, options).definition; + return this._applyLiveOverrides(resolvedDefinition, overrides); + } + + diff( + base: ScenePrefabDefinition | ScenePrefabReference, + target: ScenePrefabDefinition | ScenePrefabReference, + ): ScenePrefabDiffResult { + const resolvedBase = this.resolvePrefab(base).definition; + const resolvedTarget = this.resolvePrefab(target).definition; + return diffScenePrefabDefinitions(resolvedBase, resolvedTarget); + } + + merge( + base: ScenePrefabDefinition | ScenePrefabReference, + local: ScenePrefabDefinition | ScenePrefabReference, + incoming: ScenePrefabDefinition | ScenePrefabReference, + options: ScenePrefabMergeOptions = {}, + ): ScenePrefabMergeDefinitionResult { + const resolvedBase = this.resolvePrefab(base).definition; + const resolvedLocal = this.resolvePrefab(local).definition; + const resolvedIncoming = this.resolvePrefab(incoming).definition; + return mergeScenePrefabDefinitions(resolvedBase, resolvedLocal, resolvedIncoming, options); + } + + private _createCacheKey( + prefab: ScenePrefabDefinition | ScenePrefabReference, + options: ScenePrefabResolveOptions, + ): string | undefined { + if (!this._enableCache || this._externalRegistry || (options.liveOverrides?.length ?? 0) > 0) { + return undefined; + } + + if (isScenePrefabReference(prefab)) { + if (prefab.kind !== 'registry' || !this._definitions.has(prefab.prefabId)) { + return undefined; + } + + return `${this._revision}:ref:${prefab.prefabId}`; + } + + if (this._definitions.get(prefab.id) !== prefab) { + return undefined; + } + + return `${this._revision}:def:${prefab.id}`; + } + + private _resolveReference( + reference: ScenePrefabReference, + stack: readonly string[], + ): ScenePrefabResolvedDefinition { + if (isInlineScenePrefabReference(reference)) { + return this._resolveDefinition(reference.prefab, stack); + } + + const definition = this.getPrefab(reference.prefabId); + if (!definition) { + throw new ScenePrefabResolutionError( + `Cannot resolve prefab '${reference.prefabId}' from registry`, + ); + } + + return this._resolveDefinition(definition, stack); + } + + private _resolveDefinition( + definition: ScenePrefabDefinition, + stack: readonly string[], + ): ScenePrefabResolvedDefinition { + if (stack.includes(definition.id)) { + throw new ScenePrefabResolutionError( + `Prefab resolution cycle detected: ${[...stack, definition.id].join(' -> ')}`, + ); + } + + const nextStack = [...stack, definition.id]; + const baseDefinition = definition.base ? this._resolveReference(definition.base, nextStack) : undefined; + const lineage = baseDefinition ? [...baseDefinition.lineage, definition.id] : getDefinitionLineage(definition); + const state = baseDefinition + ? createScenePrefabState(baseDefinition, baseDefinition.id, baseDefinition.lineage) + : createScenePrefabState(definition, definition.id, lineage); + + if (baseDefinition) { + mergeScenePrefabActors(state, definition.actors, definition.id, [definition.id]); + } + + for (const nested of definition.nested ?? []) { + const resolvedNested = this._resolveReference(nested.reference, nextStack); + mergeScenePrefabActors( + state, + scopeScenePrefabActors(resolvedNested.actors, nested, resolvedNested.id), + definition.id, + [definition.id], + ); + + if (nested.overrides && nested.overrides.length > 0) { + applyScenePrefabOverrideOperations( + state, + scopeScenePrefabOverrideOperations( + nested.overrides, + nested, + resolvedNested.id, + ), + ); + } + } + + if (definition.overrides && definition.overrides.length > 0) { + applyScenePrefabOverrideOperations(state, definition.overrides); + } + + validateScenePrefabState(state); + return materializeScenePrefabResolvedDefinition(definition, state, lineage); + } + + private _applyLiveOverrides( + definition: ScenePrefabResolvedDefinition, + overrides: readonly ScenePrefabOverrideOperation[], + ): ScenePrefabResolvedDefinition { + const state = createScenePrefabState(definition, definition.id, definition.lineage); + applyScenePrefabOverrideOperations(state, overrides); + return materializeScenePrefabResolvedDefinition(definition, state, definition.lineage); + } +} + +export const createScenePrefabWorkflow = ( + options: ScenePrefabWorkflowOptions = {}, +): ScenePrefabWorkflow => new ScenePrefabWorkflow(options); + +export const resolveScenePrefab = ( + prefab: ScenePrefabDefinition | ScenePrefabReference, + options: ResolveScenePrefabOptions = {}, +): ScenePrefabResolutionResult => + new ScenePrefabWorkflow({ + registry: options.registry, + enableCache: false, + }).resolvePrefab(prefab, options); \ No newline at end of file diff --git a/web/packages/scene-runtime/src/scene-registry.ts b/web/packages/scene-runtime/src/scene-registry.ts index 80852174..58ce4ce1 100644 --- a/web/packages/scene-runtime/src/scene-registry.ts +++ b/web/packages/scene-runtime/src/scene-registry.ts @@ -4,10 +4,14 @@ import type { ComponentConstructor, ComponentRegistry } from '@axrone/ecs-runtim import { Animator } from './components/animator'; import { Camera } from './components/camera'; import { DirectionalLight } from './components/directional-light'; +import { FollowCameraController } from './components/follow-camera-controller'; import { MeshRenderer } from './components/mesh-renderer'; import { OrbitCameraController } from './components/orbit-camera-controller'; import { PrefabNodeBinding } from './components/prefab-node-binding'; import { PointLight } from './components/point-light'; +import { SpriteAnimator } from './components/sprite-animator'; +import { SpriteMask } from './components/sprite-mask'; +import { SpriteRenderer } from './components/sprite-renderer'; import { SpotLight } from './components/spot-light'; import type { SceneBuiltInRegistry, SceneRegistry } from './types'; @@ -46,11 +50,15 @@ const DEFAULT_SCENE_BUILT_IN_REGISTRY: SceneBuiltInRegistry = Object.freeze({ PrefabNodeBinding, Animator, Camera, + SpriteRenderer, + SpriteAnimator, + SpriteMask, MeshRenderer, DirectionalLight, PointLight, SpotLight, OrbitCameraController, + FollowCameraController, }); export const createSceneBuiltInManifest = < @@ -75,7 +83,7 @@ export const SCENE_ANIMATION_BUILT_IN_MANIFEST = createSceneBuiltInManifest({ export const SCENE_2D_BUILT_IN_MANIFEST = createSceneBuiltInManifest({ id: 'scene/2d', - builtIns: ['Camera'] as const, + builtIns: ['Camera', 'SpriteRenderer', 'SpriteAnimator', 'SpriteMask'] as const, }); export const SCENE_3D_BUILT_IN_MANIFEST = createSceneBuiltInManifest({ @@ -87,6 +95,7 @@ export const SCENE_3D_BUILT_IN_MANIFEST = createSceneBuiltInManifest({ 'PointLight', 'SpotLight', 'OrbitCameraController', + 'FollowCameraController', ] as const, }); diff --git a/web/packages/scene-runtime/src/scene-render-runtime.ts b/web/packages/scene-runtime/src/scene-render-runtime.ts index e27ba095..3571ac54 100644 --- a/web/packages/scene-runtime/src/scene-render-runtime.ts +++ b/web/packages/scene-runtime/src/scene-render-runtime.ts @@ -1,5 +1,5 @@ import { Vec3, Vec4 } from '@axrone/numeric'; -import type { Actor } from '@axrone/ecs-runtime'; +import type { Actor, Transform } from '@axrone/ecs-runtime'; import { selectSceneCamera } from './camera-selector'; import { SceneCameraFrameStateCollector } from './camera-frame-state'; import { SceneDrawExecutionContextCache } from './draw-execution-context'; @@ -7,6 +7,7 @@ import { SceneDrawExecutor } from './draw-executor'; import { SceneFrameUniformBinder } from './frame-uniform-binder'; import { SceneLightingCollector } from './lighting-collector'; import { SceneLightingUniformBinder } from './lighting-uniform-binder'; +import { resolveSceneMaterialPass } from './material-registry'; import { SceneMaterialTextureBinder } from './material-texture-binder'; import { SceneMorphMeshRuntime } from './morph-mesh-runtime'; import { SceneRenderFrameState } from './render-frame-state'; @@ -15,6 +16,7 @@ import { SceneRenderPassPreparer } from './render-pass-preparer'; import { SceneRenderStateApplier } from './render-state-applier'; import type { SceneResourceRuntime } from './scene-resource-runtime'; import { SceneSkinningUniformBinder } from './skinning-uniform-binder'; +import { SceneSpriteBatchRuntime } from './sprite-batch-runtime'; import type { SceneMeshResource } from './mesh-registry'; import type { SceneMeshDefinition, SceneRenderStats, SceneUniformValue } from './types'; import { SceneUniformWriter } from './uniform-writer'; @@ -23,6 +25,8 @@ export interface SceneRenderRuntimeOptions { readonly gl: WebGL2RenderingContext; readonly resources: SceneResourceRuntime; readonly ambientLight: Vec3; + readonly skyLight: Vec3; + readonly groundLight: Vec3; readonly defaultClearColor: Vec4; readonly getActors: () => readonly Actor[]; readonly createMeshResource: (definition: SceneMeshDefinition) => SceneMeshResource; @@ -53,6 +57,7 @@ export class SceneRenderRuntime { private readonly _skinningUniformBinder: SceneSkinningUniformBinder; private readonly _morphMeshRuntime: SceneMorphMeshRuntime; private readonly _drawExecutor: SceneDrawExecutor; + private readonly _spriteBatchRuntime: SceneSpriteBatchRuntime; private readonly _textureUniformSetter = ( shader: Parameters[0], name: string, @@ -91,6 +96,14 @@ export class SceneRenderRuntime { textureUniformSetter: this._textureUniformSetter, applyMissingVertexAttributeDefaults: _options.applyMissingVertexAttributeDefaults, }); + this._spriteBatchRuntime = new SceneSpriteBatchRuntime({ + gl: _options.gl, + resources: _options.resources, + renderStateApplier: this._renderStateApplier, + uniformWriter: this._uniformWriter, + materialTextureBinder: this._materialTextureBinder, + textureUniformSetter: this._textureUniformSetter, + }); } get stats(): SceneRenderStats { @@ -101,11 +114,40 @@ export class SceneRenderRuntime { }; } + private _isBlendedRenderer(renderer: import('./components/mesh-renderer').MeshRenderer, renderPass: import('./render-pass-registry').SceneRenderPassResource): boolean { + if (renderer.materialId === null) { + return false; + } + + const material = this._options.resources.materials.get(renderer.materialId); + if (!material) { + return false; + } + + const materialPass = resolveSceneMaterialPass(material, renderPass.materialPassId); + if (renderPass.materialPassId !== null && !materialPass) { + return false; + } + + const shader = this._options.resources.shaders.get(material.shaderId); + if (!shader) { + return false; + } + + return this._renderStateApplier.resolveBlendEnabled(shader, renderPass, materialPass); + } + render(params: SceneRenderRuntimeParams): void { const renderFrame = this._renderFrameState.begin(params.frame); const actors = this._options.getActors(); const camera = selectSceneCamera(actors); - const lighting = this._lightingCollector.collect(actors, this._options.ambientLight); + const lighting = this._lightingCollector.collect( + actors, + this._options.ambientLight, + this._options.skyLight, + this._options.groundLight, + (camera?.transform as Transform | undefined)?.worldPosition + ); const renderPasses = this._options.resources.renderPasses.getEnabledResources(); if (renderPasses.length === 0) { @@ -139,11 +181,24 @@ export class SceneRenderRuntime { const renderItems = this._renderItemCollector.collect( actors, - renderPass.rendererPassId + renderPass.rendererPassId, + { + cameraPosition: cameraFrame.position, + isBlended: (renderer) => this._isBlendedRenderer(renderer, renderPass), + } ); for (const item of renderItems) { this._drawExecutor.execute(item, drawContext, renderFrame); } + + this._spriteBatchRuntime.render({ + actors, + cameraFrame, + renderPass, + frameState: renderFrame, + viewportWidth: params.viewportWidth, + viewportHeight: params.viewportHeight, + }); } this._options.gl.bindVertexArray(null); @@ -156,5 +211,6 @@ export class SceneRenderRuntime { clear(): void { this._morphMeshRuntime.clear(); + this._spriteBatchRuntime.clear(); } } diff --git a/web/packages/scene-runtime/src/scene-runtime-defaults.ts b/web/packages/scene-runtime/src/scene-runtime-defaults.ts index a0efd686..ba03b7b3 100644 --- a/web/packages/scene-runtime/src/scene-runtime-defaults.ts +++ b/web/packages/scene-runtime/src/scene-runtime-defaults.ts @@ -2,6 +2,8 @@ import { Vec3, Vec4 } from '@axrone/numeric'; export const DEFAULT_SCENE_CLEAR_COLOR = new Vec4(0.08, 0.09, 0.11, 1); export const DEFAULT_SCENE_AMBIENT_LIGHT = new Vec3(0.08, 0.08, 0.1); +export const DEFAULT_SCENE_SKY_LIGHT = new Vec3(0.08, 0.09, 0.11); +export const DEFAULT_SCENE_GROUND_LIGHT = new Vec3(0.04, 0.04, 0.045); export const DEFAULT_SCENE_WIDTH = 1280; export const DEFAULT_SCENE_HEIGHT = 720; export const DEFAULT_SCENE_RENDER_PASS_ID = 'main'; @@ -11,14 +13,14 @@ export const resolveSceneClearColor = ( fallback: Vec4 = DEFAULT_SCENE_CLEAR_COLOR ): Vec4 => { if (value instanceof Vec4) { - return new Vec4(value.x, value.y, value.z, value.w); + return Vec4.from(value); } if (Array.isArray(value) && value.length === 4) { - return new Vec4(value[0], value[1], value[2], value[3]); + return Vec4.fromArray(value); } - return new Vec4(fallback.x, fallback.y, fallback.z, fallback.w); + return Vec4.from(fallback); }; export const resolveSceneAmbientLight = ( @@ -26,12 +28,42 @@ export const resolveSceneAmbientLight = ( fallback: Vec3 = DEFAULT_SCENE_AMBIENT_LIGHT ): Vec3 => { if (value instanceof Vec3) { - return new Vec3(value.x, value.y, value.z); + return Vec3.from(value); } if (Array.isArray(value) && value.length === 3) { - return new Vec3(value[0], value[1], value[2]); + return Vec3.fromArray(value); } - return new Vec3(fallback.x, fallback.y, fallback.z); -}; \ No newline at end of file + return Vec3.from(fallback); +}; + +export const resolveSceneSkyLight = ( + value?: Vec3 | readonly [number, number, number] | null, + fallback: Vec3 = DEFAULT_SCENE_SKY_LIGHT +): Vec3 => { + if (value instanceof Vec3) { + return Vec3.from(value); + } + + if (Array.isArray(value) && value.length === 3) { + return Vec3.fromArray(value); + } + + return Vec3.from(fallback); +}; + +export const resolveSceneGroundLight = ( + value?: Vec3 | readonly [number, number, number] | null, + fallback: Vec3 = DEFAULT_SCENE_GROUND_LIGHT +): Vec3 => { + if (value instanceof Vec3) { + return Vec3.from(value); + } + + if (Array.isArray(value) && value.length === 3) { + return Vec3.fromArray(value); + } + + return Vec3.from(fallback); +}; diff --git a/web/packages/scene-runtime/src/scene-runtime-kernel.ts b/web/packages/scene-runtime/src/scene-runtime-kernel.ts index ba342aee..b81fcdee 100644 --- a/web/packages/scene-runtime/src/scene-runtime-kernel.ts +++ b/web/packages/scene-runtime/src/scene-runtime-kernel.ts @@ -18,6 +18,8 @@ import { DEFAULT_SCENE_WIDTH, resolveSceneAmbientLight, resolveSceneClearColor, + resolveSceneGroundLight, + resolveSceneSkyLight, } from './scene-runtime-defaults'; import { SceneSnapshotRuntime } from './scene-snapshot-runtime'; @@ -52,6 +54,8 @@ export class SceneRuntimeKernel; @@ -83,6 +87,8 @@ export class SceneRuntimeKernel this.world.getAllActors(), createMeshResource: (definition) => this.assets.createMeshResource(definition), diff --git a/web/packages/scene-runtime/src/scene-shader-factory.ts b/web/packages/scene-runtime/src/scene-shader-factory.ts index 6382ff4a..c2262a2a 100644 --- a/web/packages/scene-runtime/src/scene-shader-factory.ts +++ b/web/packages/scene-runtime/src/scene-shader-factory.ts @@ -1,3 +1,4 @@ +import { compileRenderShaderEffect } from '@axrone/render-core'; import type { SceneMeshSemantic, SceneShaderDefinition } from './types'; import { SceneShaderError } from './errors'; import type { SceneShaderResource } from './shader-registry'; @@ -106,6 +107,17 @@ export class SceneShaderFactory { constructor(private readonly _options: SceneShaderFactoryOptions) {} create(definition: SceneShaderDefinition): SceneShaderResource { + const compiledEffect = definition.effect + ? compileRenderShaderEffect(definition.effect) + : null; + const vertexSource = definition.vertexSource ?? compiledEffect?.vertexSource; + const fragmentSource = definition.fragmentSource ?? compiledEffect?.fragmentSource; + if (!vertexSource || !fragmentSource) { + throw new SceneShaderError( + `Shader definition '${definition.id}' must provide shader sources or an effect definition` + ); + } + const program = this._options.gl.createProgram(); if (!program) { throw new SceneShaderError(`Failed to create shader program '${definition.id}'`); @@ -118,11 +130,11 @@ export class SceneShaderFactory { const vertexShader = this._compileShader( this._options.gl.VERTEX_SHADER, - definition.vertexSource + vertexSource ); const fragmentShader = this._compileShader( this._options.gl.FRAGMENT_SHADER, - definition.fragmentSource + fragmentSource ); try { @@ -149,7 +161,8 @@ export class SceneShaderFactory { const uniformNames = Array.from( new Set( definition.uniforms ?? - extractUniformNames(definition.vertexSource, definition.fragmentSource) + compiledEffect?.uniformNames ?? + extractUniformNames(vertexSource, fragmentSource) ) ); @@ -183,8 +196,8 @@ export class SceneShaderFactory { for (const [uniformName, uniformType] of extractUniformTypeHints( this._options.gl, - definition.vertexSource, - definition.fragmentSource + vertexSource, + fragmentSource )) { if (!uniformTypes.has(uniformName)) { uniformTypes.set(uniformName, uniformType); @@ -198,9 +211,10 @@ export class SceneShaderFactory { uniformTypes, uniformNames, attributeNames, - depthTest: definition.depthTest ?? true, - cull: definition.cull ?? true, - blend: definition.blend ?? false, + depthTest: + definition.depthTest ?? definition.effect?.renderState?.depthTest ?? true, + cull: definition.cull ?? definition.effect?.renderState?.cull ?? true, + blend: definition.blend ?? definition.effect?.renderState?.blend ?? false, }; } finally { this._options.gl.deleteShader(vertexShader); diff --git a/web/packages/scene-runtime/src/scene-snapshot-loader.ts b/web/packages/scene-runtime/src/scene-snapshot-loader.ts index e8ad16d7..aa086703 100644 --- a/web/packages/scene-runtime/src/scene-snapshot-loader.ts +++ b/web/packages/scene-runtime/src/scene-snapshot-loader.ts @@ -54,9 +54,7 @@ export class SceneSnapshotLoader { this._options.registerSampler(snapshot.samplers[index]!); } - for (let index = 0; index < snapshot.textures.length; index += 1) { - await this._options.registerTexture(snapshot.textures[index]!); - } + await Promise.all(snapshot.textures.map((texture) => this._options.registerTexture(texture))); if (options.clearExisting !== false) { this._options.clearRenderPasses(); diff --git a/web/packages/scene-runtime/src/scene-texture-factory.ts b/web/packages/scene-runtime/src/scene-texture-factory.ts index cc5662cb..22f004ac 100644 --- a/web/packages/scene-runtime/src/scene-texture-factory.ts +++ b/web/packages/scene-runtime/src/scene-texture-factory.ts @@ -151,6 +151,7 @@ export class SceneTextureFactory { width, height, format, + colorSpace: definition.colorSpace, dimension: TextureDimension.TEXTURE_2D, usage: TextureUsage.STATIC, mipLevels: mipLevelsFor(width, height), @@ -166,6 +167,7 @@ export class SceneTextureFactory { width: size, height: size, format, + colorSpace: definition.colorSpace, dimension: TextureDimension.TEXTURE_2D, usage: TextureUsage.STATIC, mipLevels: mipLevelsFor(size, size), @@ -184,6 +186,7 @@ export class SceneTextureFactory { width: definition.source.width, height: definition.source.height, format, + colorSpace: definition.colorSpace, dimension: TextureDimension.TEXTURE_2D, usage: TextureUsage.STATIC, mipLevels: mipLevelsFor( @@ -211,6 +214,7 @@ export class SceneTextureFactory { width: image.width, height: image.height, format, + colorSpace: definition.colorSpace, dimension: TextureDimension.TEXTURE_2D, usage: TextureUsage.STATIC, mipLevels: mipLevelsFor(image.width, image.height), @@ -220,22 +224,29 @@ export class SceneTextureFactory { break; } case 'bytes': { - const image = await this._loadImageFromBytes( + const image = await this._loadImageSourceFromBytes( definition.source.bytes, definition.source.mimeType, definition.source.uri ); - texture = this._options.textureManager.createTexture( - { - width: image.width, - height: image.height, - format, - dimension: TextureDimension.TEXTURE_2D, - usage: TextureUsage.STATIC, - mipLevels: mipLevelsFor(image.width, image.height), - }, - image - ); + try { + texture = this._options.textureManager.createTexture( + { + width: image.width, + height: image.height, + format, + colorSpace: definition.colorSpace, + dimension: TextureDimension.TEXTURE_2D, + usage: TextureUsage.STATIC, + mipLevels: mipLevelsFor(image.width, image.height), + }, + image + ); + } finally { + if (typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap) { + image.close(); + } + } break; } case 'compressed': { @@ -268,6 +279,7 @@ export class SceneTextureFactory { width: topLevel.width, height: topLevel.height, format, + colorSpace: definition.colorSpace, dimension: TextureDimension.TEXTURE_2D, usage: TextureUsage.STATIC, mipLevels: mipLevelCount, @@ -334,13 +346,12 @@ export class SceneTextureFactory { } const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); - const blobBytes = new Uint8Array(data); - const blob = new Blob([blobBytes.buffer], { type: mimeType }); + const blob = new Blob([data], { type: mimeType }); const canCreateObjectUrl = typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function'; const objectUrl = canCreateObjectUrl ? URL.createObjectURL(blob) - : `data:${mimeType};base64,${encodeBase64(blobBytes)}`; + : `data:${mimeType};base64,${encodeBase64(data)}`; try { return await this._loadImage(objectUrl); @@ -350,4 +361,23 @@ export class SceneTextureFactory { } } } + + private async _loadImageSourceFromBytes( + bytes: readonly number[] | Uint8Array, + mimeType: string, + uri?: string + ): Promise { + if (mimeType.startsWith('image/') === false) { + throw new SceneMaterialError( + `Cannot decode texture bytes${uri ? ` for '${uri}'` : ''} because mime type '${mimeType}' is not an image` + ); + } + + const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + if (typeof createImageBitmap === 'function') { + return await createImageBitmap(new Blob([data], { type: mimeType })); + } + + return await this._loadImageFromBytes(data, mimeType, uri); + } } diff --git a/web/packages/scene-runtime/src/serialization.ts b/web/packages/scene-runtime/src/serialization.ts index b68c6209..e21541a0 100644 --- a/web/packages/scene-runtime/src/serialization.ts +++ b/web/packages/scene-runtime/src/serialization.ts @@ -110,27 +110,13 @@ export const decodeSceneValue = (value: SceneSerializedValue): unknown => { switch (encodedType) { case 'Vec2': - return new Vec2(Number(encodedValue[0]), Number(encodedValue[1])); + return Vec2.fromArray(encodedValue); case 'Vec3': - return new Vec3( - Number(encodedValue[0]), - Number(encodedValue[1]), - Number(encodedValue[2]) - ); + return Vec3.fromArray(encodedValue); case 'Vec4': - return new Vec4( - Number(encodedValue[0]), - Number(encodedValue[1]), - Number(encodedValue[2]), - Number(encodedValue[3]) - ); + return Vec4.fromArray(encodedValue); case 'Quat': - return new Quat( - Number(encodedValue[0]), - Number(encodedValue[1]), - Number(encodedValue[2]), - Number(encodedValue[3]) - ); + return Quat.fromArray(encodedValue); case 'Mat4': return new Mat4(encodedValue.map((entry) => Number(entry))); case 'Float32Array': @@ -280,38 +266,38 @@ export const deserializeMeshDefinition = (value: SceneSerializedValue): SceneMes ? Object.freeze( targetObject.attributes.map( (attribute: SceneSerializedValue) => { - if ( - attribute === null || - Array.isArray(attribute) || - typeof attribute !== 'object' - ) { - throw new Error( - 'Invalid serialized mesh morph target attribute' - ); + if ( + attribute === null || + Array.isArray(attribute) || + typeof attribute !== 'object' + ) { + throw new Error( + 'Invalid serialized mesh morph target attribute' + ); + } + + const attributeObject = attribute as Record< + string, + SceneSerializedValue + >; + + return { + semantic: String( + attributeObject.semantic + ) as NonNullable< + SceneMeshDefinition['morphTargets'] + >[number]['attributes'][number]['semantic'], + componentCount: 3 as const, + values: new Float32Array( + Array.isArray(attributeObject.values) + ? attributeObject.values.map( + (entry: SceneSerializedValue) => + Number(entry) + ) + : [] + ), + }; } - - const attributeObject = attribute as Record< - string, - SceneSerializedValue - >; - - return { - semantic: String( - attributeObject.semantic - ) as NonNullable< - SceneMeshDefinition['morphTargets'] - >[number]['attributes'][number]['semantic'], - componentCount: 3 as const, - values: new Float32Array( - Array.isArray(attributeObject.values) - ? attributeObject.values.map( - (entry: SceneSerializedValue) => - Number(entry) - ) - : [] - ), - }; - } ) ) : Object.freeze([]), @@ -347,9 +333,7 @@ export const deserializeMeshDefinition = (value: SceneSerializedValue): SceneMes ? attribute.normalized : undefined, integer: - typeof attribute.integer === 'boolean' - ? attribute.integer - : undefined, + typeof attribute.integer === 'boolean' ? attribute.integer : undefined, }; }) : [], diff --git a/web/packages/scene-runtime/src/shader-effect.ts b/web/packages/scene-runtime/src/shader-effect.ts new file mode 100644 index 00000000..19ea0e63 --- /dev/null +++ b/web/packages/scene-runtime/src/shader-effect.ts @@ -0,0 +1,38 @@ +import { + cloneRenderShaderEffectDefinition, + compileRenderShaderEffect, + type RenderShaderEffectDefinition, +} from '@axrone/render-core'; +import type { SceneMeshSemantic, SceneShaderDefinition } from './types'; + +export interface SceneShaderDefinitionFromEffectOptions { + readonly id?: string; + readonly attributes?: Partial>; + readonly uniforms?: readonly string[]; + readonly depthTest?: boolean; + readonly cull?: boolean; + readonly blend?: boolean; +} + +export const createSceneShaderDefinitionFromEffect = ( + effect: RenderShaderEffectDefinition, + options: SceneShaderDefinitionFromEffectOptions = {} +): SceneShaderDefinition => { + const normalizedEffect = cloneRenderShaderEffectDefinition({ + ...effect, + id: options.id ?? effect.id, + }); + const compiledEffect = compileRenderShaderEffect(normalizedEffect); + + return { + id: normalizedEffect.id, + effect: normalizedEffect, + vertexSource: compiledEffect.vertexSource, + fragmentSource: compiledEffect.fragmentSource, + attributes: options.attributes ? { ...options.attributes } : undefined, + uniforms: options.uniforms ? [...options.uniforms] : [...compiledEffect.uniformNames], + depthTest: options.depthTest ?? normalizedEffect.renderState?.depthTest, + cull: options.cull ?? normalizedEffect.renderState?.cull, + blend: options.blend ?? normalizedEffect.renderState?.blend, + }; +}; \ No newline at end of file diff --git a/web/packages/scene-runtime/src/shader-registry.ts b/web/packages/scene-runtime/src/shader-registry.ts index 162b85c4..278565b4 100644 --- a/web/packages/scene-runtime/src/shader-registry.ts +++ b/web/packages/scene-runtime/src/shader-registry.ts @@ -3,6 +3,7 @@ import type { SceneShaderDefinition, SceneShaderHandle, } from './types'; +import { cloneRenderShaderEffectDefinition } from '@axrone/render-core'; export interface SceneShaderResource { readonly id: string; @@ -32,6 +33,9 @@ export const cloneSceneShaderDefinition = ( ...definition, uniforms: definition.uniforms ? [...definition.uniforms] : undefined, attributes: definition.attributes ? { ...definition.attributes } : undefined, + effect: definition.effect + ? cloneRenderShaderEffectDefinition(definition.effect) + : undefined, }); export class SceneShaderRegistry { diff --git a/web/packages/scene-runtime/src/sprite-2d-shader.ts b/web/packages/scene-runtime/src/sprite-2d-shader.ts new file mode 100644 index 00000000..7cbd15a0 --- /dev/null +++ b/web/packages/scene-runtime/src/sprite-2d-shader.ts @@ -0,0 +1,26 @@ +import { + RENDER_2D_DEFAULT_SPRITE_SHADER_ID, + RENDER_2D_SPRITE_ATTRIBUTE_NAMES, + RENDER_2D_SPRITE_EFFECT, +} from '@axrone/render-2d'; +import type { SceneShaderDefinition } from './types'; +import { createSceneShaderDefinitionFromEffect } from './shader-effect'; + +export const DEFAULT_SCENE_2D_SPRITE_SHADER_ID = RENDER_2D_DEFAULT_SPRITE_SHADER_ID; + +export const createSprite2DShaderDefinition = ( + id: string = DEFAULT_SCENE_2D_SPRITE_SHADER_ID +): SceneShaderDefinition => + createSceneShaderDefinitionFromEffect( + { + ...RENDER_2D_SPRITE_EFFECT, + id, + }, + { + attributes: { + position: RENDER_2D_SPRITE_ATTRIBUTE_NAMES.position, + uv0: RENDER_2D_SPRITE_ATTRIBUTE_NAMES.uv0, + color0: RENDER_2D_SPRITE_ATTRIBUTE_NAMES.color0, + }, + } + ); \ No newline at end of file diff --git a/web/packages/scene-runtime/src/sprite-batch-runtime.ts b/web/packages/scene-runtime/src/sprite-batch-runtime.ts new file mode 100644 index 00000000..5797f520 --- /dev/null +++ b/web/packages/scene-runtime/src/sprite-batch-runtime.ts @@ -0,0 +1,751 @@ +import { Transform, type Actor } from '@axrone/ecs-runtime'; +import { + RENDER_2D_SPRITE_VERTEX_STRIDE, + Render2DSpriteBatchBuilder, + type Render2DRectLike, + type Render2DSpriteMask, + type Render2DSpriteBatchBuildResult, + type Render2DSpriteBatchRange, + type Render2DSpriteSource, + type Render2DSpriteSubmission, +} from '@axrone/render-2d'; +import type { SceneCameraFrameState } from './camera-frame-state'; +import { SpriteMask } from './components/sprite-mask'; +import { SceneMeshError } from './errors'; +import { resolveSceneMaterialPass } from './material-registry'; +import type { SceneMaterialTextureUniformSetter } from './material-texture-binder'; +import type { SceneRenderFrameState } from './render-frame-state'; +import type { SceneRenderPassResource } from './render-pass-registry'; +import type { SceneRenderStateApplier } from './render-state-applier'; +import type { SceneResourceRuntime } from './scene-resource-runtime'; +import { SceneShaderFactory } from './scene-shader-factory'; +import type { SceneShaderResource } from './shader-registry'; +import { createSprite2DShaderDefinition } from './sprite-2d-shader'; +import { SceneSpriteRenderItemCollector } from './sprite-render-item-collector'; +import type { SceneUniformWriteTarget } from './uniform-writer'; + +const MIN_CLIP_W = 1e-6; + +const areClipRectsEqual = ( + left: Render2DRectLike | null, + right: Render2DRectLike | null +): boolean => { + if (!left || !right) { + return left == null && right == null; + } + + return ( + left.x === right.x && + left.y === right.y && + left.width === right.width && + left.height === right.height + ); +}; + +const intersectClipRects = ( + left: Render2DRectLike, + right: Render2DRectLike +): Render2DRectLike | null => { + const x = Math.max(left.x, right.x); + const y = Math.max(left.y, right.y); + const maxX = Math.min(left.x + left.width, right.x + right.width); + const maxY = Math.min(left.y + left.height, right.y + right.height); + const width = maxX - x; + const height = maxY - y; + + if (width <= 0 || height <= 0) { + return null; + } + + return { x, y, width, height }; +}; + +const transformWorldPoint = ( + matrix: ArrayLike, + localX: number, + localY: number, + out: Float32Array +): Float32Array => { + out[0] = (matrix[0] ?? 0) * localX + (matrix[1] ?? 0) * localY + (matrix[3] ?? 0); + out[1] = (matrix[4] ?? 0) * localX + (matrix[5] ?? 0) * localY + (matrix[7] ?? 0); + out[2] = (matrix[8] ?? 0) * localX + (matrix[9] ?? 0) * localY + (matrix[11] ?? 0); + return out; +}; + +const projectWorldPoint = ( + matrix: ArrayLike, + worldX: number, + worldY: number, + worldZ: number, + viewportWidth: number, + viewportHeight: number, + out: Float32Array +): Float32Array => { + const clipX = + (matrix[0] ?? 0) * worldX + + (matrix[1] ?? 0) * worldY + + (matrix[2] ?? 0) * worldZ + + (matrix[3] ?? 0); + const clipY = + (matrix[4] ?? 0) * worldX + + (matrix[5] ?? 0) * worldY + + (matrix[6] ?? 0) * worldZ + + (matrix[7] ?? 0); + const clipW = + (matrix[12] ?? 0) * worldX + + (matrix[13] ?? 0) * worldY + + (matrix[14] ?? 0) * worldZ + + (matrix[15] ?? 0); + + if (!Number.isFinite(clipW) || Math.abs(clipW) <= MIN_CLIP_W) { + out[0] = NaN; + out[1] = NaN; + return out; + } + + const ndcX = clipX / clipW; + const ndcY = clipY / clipW; + out[0] = (ndcX * 0.5 + 0.5) * viewportWidth; + out[1] = (ndcY * 0.5 + 0.5) * viewportHeight; + return out; +}; + +interface SceneResolvedSpriteMaskState { + readonly clipRect: Render2DRectLike | null; + readonly mask: Render2DSpriteMask | null; +} + +export interface SceneSpriteBatchRuntimeOptions { + readonly gl: WebGL2RenderingContext; + readonly resources: SceneResourceRuntime; + readonly renderStateApplier: Pick< + SceneRenderStateApplier, + 'apply' | 'resolvePrimitiveMode' | 'resolvePrimitiveTopology' + >; + readonly uniformWriter: SceneUniformWriteTarget; + readonly materialTextureBinder: Pick< + import('./material-texture-binder').SceneMaterialTextureBinder, + 'bind' | 'unbind' + >; + readonly textureUniformSetter: SceneMaterialTextureUniformSetter; +} + +export interface SceneSpriteBatchRuntimeRenderParams { + readonly actors: readonly Actor[]; + readonly cameraFrame: SceneCameraFrameState; + readonly renderPass: SceneRenderPassResource; + readonly frameState: SceneRenderFrameState; + readonly viewportWidth: number; + readonly viewportHeight: number; +} + +export class SceneSpriteBatchRuntime { + private readonly _collector = new SceneSpriteRenderItemCollector(); + private readonly _builder = new Render2DSpriteBatchBuilder(); + private readonly _shaderFactory: SceneShaderFactory; + private readonly _submissions: Render2DSpriteSubmission[] = []; + private readonly _worldPointScratch = new Float32Array(3); + private readonly _screenPointScratch = new Float32Array(2); + private _defaultShader: SceneShaderResource | null = null; + private _vertexArray: WebGLVertexArrayObject | null = null; + private _vertexBuffer: WebGLBuffer | null = null; + private _indexBuffer: WebGLBuffer | null = null; + private _scissorEnabled = false; + private _activeClipRect: Render2DRectLike | null = null; + private _activeMask: Render2DSpriteMask | null = null; + + constructor(private readonly _options: SceneSpriteBatchRuntimeOptions) { + this._shaderFactory = new SceneShaderFactory({ gl: _options.gl }); + } + + render(params: SceneSpriteBatchRuntimeRenderParams): void { + const items = this._collector.collect(params.actors, params.renderPass.rendererPassId); + if (items.length === 0) { + return; + } + + this._ensureResources(); + + this._submissions.length = 0; + for (const item of items) { + const source = this._resolveSource(item.renderer); + if (!source) { + continue; + } + + const maskState = this._resolveMaskState( + item.actor, + params.cameraFrame, + params.viewportWidth, + params.viewportHeight + ); + if (maskState === null) { + continue; + } + + params.frameState.markActiveRenderer(item.renderer.id); + this._submissions.push({ + source, + worldMatrix: item.transform.worldMatrix.data, + size: { + width: item.renderer.size.x, + height: item.renderer.size.y, + }, + anchor: item.renderer.anchor, + uvRect: item.renderer.uvRect, + color: item.renderer.color, + clipRect: maskState.clipRect ?? undefined, + mask: maskState.mask ?? undefined, + slice: item.renderer.sliceBorder + ? { + sourceSize: { + width: item.renderer.sourceSize.x, + height: item.renderer.sourceSize.y, + }, + border: item.renderer.sliceBorder, + } + : undefined, + visible: item.renderer.visible, + flipX: item.renderer.flipX, + flipY: item.renderer.flipY, + }); + } + + if (this._submissions.length === 0) { + return; + } + + const buildResult = this._builder.build(this._submissions); + if (buildResult.indexCount === 0) { + return; + } + + const indexType = + buildResult.indexData instanceof Uint32Array + ? this._options.gl.UNSIGNED_INT + : this._options.gl.UNSIGNED_SHORT; + + this._upload(buildResult); + + this._options.gl.bindVertexArray(this._vertexArray); + for (const batch of buildResult.batches) { + this._applyClipRect(batch.key.clipRect, params.viewportWidth, params.viewportHeight); + this._drawBatch(batch, indexType, params); + } + this._options.gl.bindVertexArray(null); + this._resetClipRect(); + this._resetMaskState(); + } + + clear(): void { + if (this._defaultShader) { + this._shaderFactory.delete(this._defaultShader); + this._defaultShader = null; + } + + if (this._vertexArray) { + this._options.gl.deleteVertexArray(this._vertexArray); + this._vertexArray = null; + } + + if (this._vertexBuffer) { + this._options.gl.deleteBuffer(this._vertexBuffer); + this._vertexBuffer = null; + } + + if (this._indexBuffer) { + this._options.gl.deleteBuffer(this._indexBuffer); + this._indexBuffer = null; + } + + this._submissions.length = 0; + this._resetClipRect(); + this._resetMaskState(); + } + + private _resolveSource( + renderer: import('./components/sprite-renderer').SpriteRenderer + ): Render2DSpriteSource | null { + if (renderer.materialId) { + return { + kind: 'material', + materialId: renderer.materialId, + }; + } + + if (renderer.textureId) { + return { + kind: 'texture', + textureId: renderer.textureId, + }; + } + + return null; + } + + private _ensureResources(): void { + if (!this._defaultShader) { + this._defaultShader = this._shaderFactory.create( + createSprite2DShaderDefinition('__scene/runtime-sprite-2d') + ); + } + + if (!this._vertexArray) { + this._vertexArray = this._options.gl.createVertexArray(); + if (!this._vertexArray) { + throw new SceneMeshError('Failed to create 2D sprite vertex array'); + } + } + + if (!this._vertexBuffer) { + this._vertexBuffer = this._options.gl.createBuffer(); + if (!this._vertexBuffer) { + throw new SceneMeshError('Failed to create 2D sprite vertex buffer'); + } + } + + if (!this._indexBuffer) { + this._indexBuffer = this._options.gl.createBuffer(); + if (!this._indexBuffer) { + throw new SceneMeshError('Failed to create 2D sprite index buffer'); + } + } + + this._options.gl.bindVertexArray(this._vertexArray); + this._options.gl.bindBuffer(this._options.gl.ARRAY_BUFFER, this._vertexBuffer); + this._options.gl.bindBuffer( + this._options.gl.ELEMENT_ARRAY_BUFFER, + this._indexBuffer + ); + this._options.gl.enableVertexAttribArray(0); + this._options.gl.vertexAttribPointer( + 0, + 3, + this._options.gl.FLOAT, + false, + RENDER_2D_SPRITE_VERTEX_STRIDE, + 0 + ); + this._options.gl.enableVertexAttribArray(2); + this._options.gl.vertexAttribPointer( + 2, + 2, + this._options.gl.FLOAT, + false, + RENDER_2D_SPRITE_VERTEX_STRIDE, + 12 + ); + this._options.gl.enableVertexAttribArray(3); + this._options.gl.vertexAttribPointer( + 3, + 4, + this._options.gl.UNSIGNED_BYTE, + true, + RENDER_2D_SPRITE_VERTEX_STRIDE, + 20 + ); + this._options.gl.bindVertexArray(null); + } + + private _upload(buildResult: Render2DSpriteBatchBuildResult): void { + this._options.gl.bindVertexArray(this._vertexArray); + this._options.gl.bindBuffer(this._options.gl.ARRAY_BUFFER, this._vertexBuffer); + this._options.gl.bufferData( + this._options.gl.ARRAY_BUFFER, + buildResult.vertexData, + this._options.gl.DYNAMIC_DRAW + ); + this._options.gl.bindBuffer( + this._options.gl.ELEMENT_ARRAY_BUFFER, + this._indexBuffer + ); + this._options.gl.bufferData( + this._options.gl.ELEMENT_ARRAY_BUFFER, + buildResult.indexData, + this._options.gl.DYNAMIC_DRAW + ); + } + + private _drawBatch( + batch: Render2DSpriteBatchRange, + indexType: number, + params: SceneSpriteBatchRuntimeRenderParams + ): void { + if (batch.key.source.kind === 'material') { + this._drawMaterialBatch(batch, indexType, params); + return; + } + + this._drawTextureBatch(batch, indexType, params); + } + + private _drawMaterialBatch( + batch: Render2DSpriteBatchRange, + indexType: number, + params: SceneSpriteBatchRuntimeRenderParams + ): void { + if (batch.key.source.kind !== 'material') { + return; + } + + const material = this._options.resources.materials.get(batch.key.source.materialId); + if (!material) { + return; + } + + const materialPass = resolveSceneMaterialPass(material, params.renderPass.materialPassId); + if (params.renderPass.materialPassId !== null && !materialPass) { + return; + } + + const shader = this._options.resources.shaders.get(material.shaderId); + if (!shader || !this._isSpriteShader(shader)) { + return; + } + + this._options.renderStateApplier.apply(shader, params.renderPass, materialPass); + this._options.gl.useProgram(shader.program); + this._options.uniformWriter.write( + shader, + 'u_ViewProjection', + params.cameraFrame.viewProjectionMatrix + ); + this._applyMaskUniforms(shader, batch.key.mask); + this._options.materialTextureBinder.bind( + shader, + material, + this._options.resources, + this._options.textureUniformSetter + ); + + for (const [name, value] of material.uniforms) { + this._options.uniformWriter.write(shader, name, value); + } + + this._options.gl.drawElements( + this._options.renderStateApplier.resolvePrimitiveMode( + this._options.gl.TRIANGLES, + materialPass + ), + batch.indexCount, + indexType, + batch.indexOffset * this._resolveIndexByteSize(indexType) + ); + params.frameState.recordDraw({ + topology: materialPass?.primitive + ? this._options.renderStateApplier.resolvePrimitiveTopology( + materialPass.primitive + ) + : 'triangles', + indexCount: batch.indexCount, + vertexCount: 0, + }); + this._options.materialTextureBinder.unbind(); + } + + private _drawTextureBatch( + batch: Render2DSpriteBatchRange, + indexType: number, + params: SceneSpriteBatchRuntimeRenderParams + ): void { + if (batch.key.source.kind !== 'texture') { + return; + } + + const texture = this._options.resources.textures.get(batch.key.source.textureId); + const shader = this._defaultShader; + if (!texture || !shader) { + return; + } + + this._options.renderStateApplier.apply(shader, params.renderPass, null); + this._options.gl.useProgram(shader.program); + this._options.uniformWriter.write( + shader, + 'u_ViewProjection', + params.cameraFrame.viewProjectionMatrix + ); + this._applyMaskUniforms(shader, batch.key.mask); + texture.texture.bind(0); + this._options.resources.resolveSampler(texture.samplerId).bind(0); + this._options.uniformWriter.write(shader, 'u_MainTex', 0); + this._options.gl.drawElements( + this._options.gl.TRIANGLES, + batch.indexCount, + indexType, + batch.indexOffset * this._resolveIndexByteSize(indexType) + ); + params.frameState.recordDraw({ + topology: 'triangles', + indexCount: batch.indexCount, + vertexCount: 0, + }); + this._options.gl.bindSampler(0, null); + this._options.gl.activeTexture(this._options.gl.TEXTURE0); + this._options.gl.bindTexture(this._options.gl.TEXTURE_2D, null); + } + + private _resolveMaskState( + actor: Actor, + cameraFrame: SceneCameraFrameState, + viewportWidth: number, + viewportHeight: number + ): SceneResolvedSpriteMaskState | null { + let clipRect: Render2DRectLike | undefined; + let shapeMask: Render2DSpriteMask | undefined; + let current: Actor | undefined = actor; + + while (current) { + const mask = current.getComponent(SpriteMask); + if (mask?.enabled) { + const transform = current.getComponent(Transform); + if (!transform) { + return null; + } + + const maskClipRect = this._projectMaskClipRect( + transform, + mask, + cameraFrame, + viewportWidth, + viewportHeight + ); + if (!maskClipRect) { + return null; + } + + const nextClipRect = clipRect + ? intersectClipRects(clipRect, maskClipRect) + : maskClipRect; + if (!nextClipRect) { + return null; + } + + clipRect = nextClipRect; + + if (!shapeMask && mask.shape !== 'rect') { + shapeMask = this._createMaskState(transform, mask); + if (!shapeMask) { + return null; + } + } + } + + current = current.parent; + } + + return { + clipRect: clipRect ?? null, + mask: shapeMask ?? null, + }; + } + + private _createMaskState( + transform: Transform, + mask: SpriteMask + ): Render2DSpriteMask | undefined { + if (mask.size.x <= 0 || mask.size.y <= 0) { + return undefined; + } + + const inverseWorldMatrix = transform.worldMatrix.clone().invert().data; + + return { + shape: mask.shape === 'circle' ? 'circle' : 'rounded-rect', + inverseWorldMatrix: Array.from(inverseWorldMatrix, (entry) => Number(entry ?? 0)), + size: { + width: mask.size.x, + height: mask.size.y, + }, + anchor: { + x: mask.anchor.x, + y: mask.anchor.y, + }, + ...(mask.cornerRadius !== null && mask.cornerRadius !== undefined + ? { cornerRadius: mask.cornerRadius } + : { + cornerRadius: + mask.shape === 'rounded-rect' + ? Math.min(mask.size.x, mask.size.y) * 0.125 + : undefined, + }), + }; + } + + private _projectMaskClipRect( + transform: Transform, + mask: SpriteMask, + cameraFrame: SceneCameraFrameState, + viewportWidth: number, + viewportHeight: number + ): Render2DRectLike | null { + if (mask.size.x <= 0 || mask.size.y <= 0) { + return null; + } + + const worldMatrix = transform.worldMatrix.data; + const viewProjectionMatrix = cameraFrame.viewProjectionMatrix.data; + const minX = -mask.anchor.x * mask.size.x; + const minY = -mask.anchor.y * mask.size.y; + const maxX = minX + mask.size.x; + const maxY = minY + mask.size.y; + + let screenMinX = Number.POSITIVE_INFINITY; + let screenMinY = Number.POSITIVE_INFINITY; + let screenMaxX = Number.NEGATIVE_INFINITY; + let screenMaxY = Number.NEGATIVE_INFINITY; + + const corners = [ + [minX, minY], + [maxX, minY], + [maxX, maxY], + [minX, maxY], + ] as const; + + for (let index = 0; index < corners.length; index += 1) { + const corner = corners[index]!; + const worldPoint = transformWorldPoint( + worldMatrix, + corner[0], + corner[1], + this._worldPointScratch + ); + const screenPoint = projectWorldPoint( + viewProjectionMatrix, + worldPoint[0]!, + worldPoint[1]!, + worldPoint[2]!, + viewportWidth, + viewportHeight, + this._screenPointScratch + ); + + if (!Number.isFinite(screenPoint[0]) || !Number.isFinite(screenPoint[1])) { + return null; + } + + screenMinX = Math.min(screenMinX, screenPoint[0]!); + screenMinY = Math.min(screenMinY, screenPoint[1]!); + screenMaxX = Math.max(screenMaxX, screenPoint[0]!); + screenMaxY = Math.max(screenMaxY, screenPoint[1]!); + } + + const clipX = Math.max(0, Math.floor(screenMinX)); + const clipY = Math.max(0, Math.floor(screenMinY)); + const clipMaxX = Math.min(viewportWidth, Math.ceil(screenMaxX)); + const clipMaxY = Math.min(viewportHeight, Math.ceil(screenMaxY)); + const width = clipMaxX - clipX; + const height = clipMaxY - clipY; + + if (width <= 0 || height <= 0) { + return null; + } + + return { x: clipX, y: clipY, width, height }; + } + + private _applyClipRect( + clipRect: Render2DRectLike | null, + viewportWidth: number, + viewportHeight: number + ): void { + if (!clipRect) { + if (this._scissorEnabled) { + this._options.gl.disable?.(this._options.gl.SCISSOR_TEST); + this._scissorEnabled = false; + this._activeClipRect = null; + } + return; + } + + if (!this._scissorEnabled) { + this._options.gl.enable?.(this._options.gl.SCISSOR_TEST); + this._scissorEnabled = true; + } + + const clampedClipRect = { + x: Math.max(0, Math.floor(clipRect.x)), + y: Math.max(0, Math.floor(clipRect.y)), + width: 0, + height: 0, + }; + const clipMaxX = Math.min(viewportWidth, Math.ceil(clipRect.x + clipRect.width)); + const clipMaxY = Math.min(viewportHeight, Math.ceil(clipRect.y + clipRect.height)); + clampedClipRect.width = Math.max(0, clipMaxX - clampedClipRect.x); + clampedClipRect.height = Math.max(0, clipMaxY - clampedClipRect.y); + + if (clampedClipRect.width === 0 || clampedClipRect.height === 0) { + if (this._scissorEnabled) { + this._options.gl.disable?.(this._options.gl.SCISSOR_TEST); + this._scissorEnabled = false; + } + this._activeClipRect = null; + return; + } + + if (areClipRectsEqual(this._activeClipRect, clampedClipRect)) { + return; + } + + this._options.gl.scissor?.( + clampedClipRect.x, + clampedClipRect.y, + clampedClipRect.width, + clampedClipRect.height + ); + this._activeClipRect = clampedClipRect; + } + + private _applyMaskUniforms( + shader: SceneShaderResource, + mask: Render2DSpriteMask | null | undefined + ): void { + if (!mask) { + this._options.uniformWriter.write(shader, 'u_MaskShape', 0); + this._activeMask = null; + return; + } + + this._options.uniformWriter.write( + shader, + 'u_MaskShape', + mask.shape === 'circle' ? 1 : 2 + ); + this._options.uniformWriter.write( + shader, + 'u_MaskWorldToLocal', + Array.from(mask.inverseWorldMatrix, (entry) => Number(entry ?? 0)) + ); + this._options.uniformWriter.write(shader, 'u_MaskSize', [mask.size.width, mask.size.height]); + this._options.uniformWriter.write(shader, 'u_MaskAnchor', [mask.anchor.x, mask.anchor.y]); + this._options.uniformWriter.write( + shader, + 'u_MaskCornerRadius', + mask.cornerRadius ?? 0 + ); + this._activeMask = mask; + } + + private _resetClipRect(): void { + if (this._scissorEnabled) { + this._options.gl.disable?.(this._options.gl.SCISSOR_TEST); + this._scissorEnabled = false; + } + + this._activeClipRect = null; + } + + private _resetMaskState(): void { + this._activeMask = null; + } + + private _isSpriteShader(shader: SceneShaderResource): boolean { + return shader.uniformNames.includes('u_ViewProjection'); + } + + private _resolveIndexByteSize(indexType: number): number { + return indexType === this._options.gl.UNSIGNED_INT + ? Uint32Array.BYTES_PER_ELEMENT + : Uint16Array.BYTES_PER_ELEMENT; + } +} diff --git a/web/packages/scene-runtime/src/sprite-render-item-collector.ts b/web/packages/scene-runtime/src/sprite-render-item-collector.ts new file mode 100644 index 00000000..34539f7c --- /dev/null +++ b/web/packages/scene-runtime/src/sprite-render-item-collector.ts @@ -0,0 +1,75 @@ +import type { Actor } from '@axrone/ecs-runtime'; +import { Transform } from '@axrone/ecs-runtime'; +import { SpriteRenderer } from './components/sprite-renderer'; + +export interface SceneSpriteRenderItem { + actor: Actor; + transform: Transform; + renderer: SpriteRenderer; + sequence: number; + depth: number; +} + +export class SceneSpriteRenderItemCollector { + private readonly _items: SceneSpriteRenderItem[] = []; + + collect(actors: readonly Actor[], passId: string): readonly SceneSpriteRenderItem[] { + let count = 0; + + for (const actor of actors) { + if (!actor.active) { + continue; + } + + const transform = actor.getComponent(Transform); + const renderer = actor.getComponent(SpriteRenderer); + + if ( + !transform || + !renderer || + !renderer.enabled || + !renderer.visible || + !renderer.hasRenderableSource || + renderer.passId !== passId + ) { + continue; + } + + const item = this._items[count] ?? { + actor, + transform, + renderer, + sequence: count, + depth: transform.worldPosition.z, + }; + item.actor = actor; + item.transform = transform; + item.renderer = renderer; + item.sequence = count; + item.depth = transform.worldPosition.z; + this._items[count] = item; + count += 1; + } + + this._items.length = count; + this._items.sort((left, right) => { + const sortingLayerDelta = left.renderer.sortingLayer - right.renderer.sortingLayer; + if (sortingLayerDelta !== 0) { + return sortingLayerDelta; + } + + const renderOrderDelta = left.renderer.renderOrder - right.renderer.renderOrder; + if (renderOrderDelta !== 0) { + return renderOrderDelta; + } + + const depthDelta = left.depth - right.depth; + if (depthDelta !== 0) { + return depthDelta; + } + + return left.sequence - right.sequence; + }); + return this._items; + } +} \ No newline at end of file diff --git a/web/packages/scene-runtime/src/types.ts b/web/packages/scene-runtime/src/types.ts index 5feab2dc..e92b8a40 100644 --- a/web/packages/scene-runtime/src/types.ts +++ b/web/packages/scene-runtime/src/types.ts @@ -4,17 +4,20 @@ import type { World } from '@axrone/ecs-runtime'; import type { ComponentConstructor, ComponentRegistry } from '@axrone/ecs-runtime'; import type { System, SystemQuery } from '@axrone/ecs-runtime'; import type { GameLoopScheduler, GameLoopStatus } from '@axrone/game-loop'; +import type { RenderShaderEffectDefinition } from '@axrone/render-core'; import type { Camera } from './components/camera'; import type { Animator } from './components/animator'; import type { DirectionalLight } from './components/directional-light'; +import type { FollowCameraController } from './components/follow-camera-controller'; import type { MeshRenderer } from './components/mesh-renderer'; import type { OrbitCameraController } from './components/orbit-camera-controller'; import type { PrefabNodeBinding } from './components/prefab-node-binding'; import type { PointLight } from './components/point-light'; +import type { SpriteRenderer } from './components/sprite-renderer'; import type { SpotLight } from './components/spot-light'; import type { Hierarchy } from '@axrone/ecs-runtime'; import type { Transform } from '@axrone/ecs-runtime'; -import type { FilterMode, TextureFormat, WrapMode } from '@axrone/render-webgl2'; +import type { ColorSpace, FilterMode, TextureFormat, WrapMode } from '@axrone/render-webgl2'; import type { SceneRuntimeProfile } from './scene-profile'; export type SceneMeshSemantic = @@ -146,6 +149,7 @@ export interface SceneTextureDefinition { readonly format?: TextureFormat; readonly generateMipmaps?: boolean; readonly samplerId?: string; + readonly colorSpace?: ColorSpace; } export type SceneTextureBindingDefinition = @@ -156,10 +160,170 @@ export type SceneTextureBindingDefinition = readonly unit?: number; }; +export type SceneMaterialShadingModel = 'unlit' | 'pbr'; +export type SceneMaterialAlphaMode = 'opaque' | 'mask' | 'blend'; +export type SceneMaterialUvSet = 0 | 1; + +export interface SceneMaterialSurfaceFeaturesDefinition { + readonly useVertexColor?: boolean; + readonly hasSecondUv?: boolean; + readonly useNormalMap?: boolean; + readonly useTwoSided?: boolean; + readonly useAlbedoMap?: boolean; + readonly usePbrMap?: boolean; + readonly useMetallicRoughnessMap?: boolean; + readonly useOcclusionMap?: boolean; + readonly useEmissiveMap?: boolean; + readonly useAlphaTest?: boolean; +} + +export interface SceneMaterialSurfaceTextureBindingDefinition { + readonly textureId?: string | null; + readonly samplerId?: string; + readonly unit?: number; + readonly texCoord?: SceneMaterialUvSet; + readonly scale?: readonly [number, number]; + readonly offset?: readonly [number, number]; + readonly rotation?: number; +} + +export interface SceneMaterialSurfaceDefinition { + readonly shadingModel?: SceneMaterialShadingModel; + readonly alphaMode?: SceneMaterialAlphaMode; + readonly alphaCutoff?: number; + readonly pbrUvSet?: SceneMaterialUvSet; + readonly features?: SceneMaterialSurfaceFeaturesDefinition; + readonly tilingOffset?: readonly [number, number, number, number]; + readonly albedo?: readonly [number, number, number, number]; + readonly albedoScale?: readonly [number, number, number]; + readonly normalScale?: number; + readonly occlusion?: number; + readonly roughness?: number; + readonly metallic?: number; + readonly specularIntensity?: number; + readonly emissive?: readonly [number, number, number]; + readonly emissiveScale?: readonly [number, number, number]; + readonly albedoMap?: SceneMaterialSurfaceTextureBindingDefinition; + readonly normalMap?: SceneMaterialSurfaceTextureBindingDefinition; + readonly pbrMap?: SceneMaterialSurfaceTextureBindingDefinition; + readonly metallicRoughnessMap?: SceneMaterialSurfaceTextureBindingDefinition; + readonly occlusionMap?: SceneMaterialSurfaceTextureBindingDefinition; + readonly emissiveMap?: SceneMaterialSurfaceTextureBindingDefinition; +} + +export type SceneMaterialPassPrimitive = 'triangle-list' | 'line-list' | 'point-list'; +export type SceneMaterialPolygonMode = 'fill' | 'line' | 'point'; +export type SceneMaterialShadeModel = 'gouraud' | 'flat'; +export type SceneMaterialCullMode = 'none' | 'front' | 'back'; +export type SceneMaterialFrontFace = 'ccw' | 'cw'; +export type SceneMaterialCompareFunction = + | 'never' + | 'less' + | 'equal' + | 'lequal' + | 'greater' + | 'notequal' + | 'gequal' + | 'always'; +export type SceneMaterialStencilOperation = + | 'keep' + | 'zero' + | 'replace' + | 'invert' + | 'incr' + | 'incr-wrap' + | 'decr' + | 'decr-wrap'; +export type SceneMaterialBlendFactor = + | 'zero' + | 'one' + | 'src-color' + | 'one-minus-src-color' + | 'dst-color' + | 'one-minus-dst-color' + | 'src-alpha' + | 'one-minus-src-alpha' + | 'dst-alpha' + | 'one-minus-dst-alpha' + | 'constant-color' + | 'one-minus-constant-color' + | 'constant-alpha' + | 'one-minus-constant-alpha' + | 'src-alpha-saturate'; +export type SceneMaterialBlendOperation = + | 'add' + | 'subtract' + | 'reverse-subtract' + | 'min' + | 'max'; + +export interface SceneMaterialStencilFaceStateDefinition { + readonly stencilTest?: boolean; + readonly stencilFunc?: SceneMaterialCompareFunction; + readonly stencilReadMask?: number; + readonly stencilWriteMask?: number; + readonly stencilFailOp?: SceneMaterialStencilOperation; + readonly stencilZFailOp?: SceneMaterialStencilOperation; + readonly stencilPassOp?: SceneMaterialStencilOperation; + readonly stencilRef?: number; +} + +export interface SceneMaterialRasterizerStateDefinition { + readonly discard?: boolean; + readonly polygonMode?: SceneMaterialPolygonMode; + readonly shadeModel?: SceneMaterialShadeModel; + readonly cullMode?: SceneMaterialCullMode; + readonly frontFace?: SceneMaterialFrontFace; + readonly depthBias?: number; + readonly depthBiasClamp?: number; + readonly depthBiasSlopeScale?: number; + readonly depthClip?: boolean; + readonly multisample?: boolean; + readonly lineWidth?: number; +} + +export interface SceneMaterialDepthStencilStateDefinition { + readonly depthTest?: boolean; + readonly depthWrite?: boolean; + readonly depthFunc?: SceneMaterialCompareFunction; + readonly front?: SceneMaterialStencilFaceStateDefinition; + readonly back?: SceneMaterialStencilFaceStateDefinition; +} + +export interface SceneMaterialBlendTargetStateDefinition { + readonly blend?: boolean; + readonly srcColorFactor?: SceneMaterialBlendFactor; + readonly dstColorFactor?: SceneMaterialBlendFactor; + readonly colorOp?: SceneMaterialBlendOperation; + readonly srcAlphaFactor?: SceneMaterialBlendFactor; + readonly dstAlphaFactor?: SceneMaterialBlendFactor; + readonly alphaOp?: SceneMaterialBlendOperation; + readonly colorWriteMask?: readonly [boolean, boolean, boolean, boolean]; +} + +export interface SceneMaterialBlendStateDefinition { + readonly alphaToCoverage?: boolean; + readonly independentBlend?: boolean; + readonly blendColor?: readonly [number, number, number, number]; + readonly targets?: readonly SceneMaterialBlendTargetStateDefinition[]; +} + +export interface SceneMaterialPassDefinition { + readonly id: string; + readonly phase?: string; + readonly priority?: number; + readonly primitive?: SceneMaterialPassPrimitive; + readonly stage?: string; + readonly rasterizerState?: SceneMaterialRasterizerStateDefinition; + readonly depthStencilState?: SceneMaterialDepthStencilStateDefinition; + readonly blendState?: SceneMaterialBlendStateDefinition; +} + export interface SceneShaderDefinition { readonly id: string; - readonly vertexSource: string; - readonly fragmentSource: string; + readonly vertexSource?: string; + readonly fragmentSource?: string; + readonly effect?: RenderShaderEffectDefinition; readonly attributes?: Partial>; readonly uniforms?: readonly string[]; readonly depthTest?: boolean; @@ -172,6 +336,8 @@ export interface SceneMaterialDefinition { readonly shaderId: string; readonly uniforms?: Readonly>; readonly textures?: Readonly>; + readonly surface?: SceneMaterialSurfaceDefinition; + readonly passes?: readonly SceneMaterialPassDefinition[]; } export interface SceneRenderPassDefinition { @@ -179,12 +345,20 @@ export interface SceneRenderPassDefinition { readonly order?: number; readonly enabled?: boolean; readonly rendererPassId?: string; + readonly materialPassId?: string; readonly clearFlags?: readonly SceneClearFlag[]; readonly clearColor?: Vec4 | readonly [number, number, number, number] | null; readonly clearDepth?: number | null; readonly depthTest?: boolean; readonly cull?: boolean; readonly blend?: boolean; + readonly stencilTest?: boolean; + readonly stencilFunc?: number; + readonly stencilRef?: number; + readonly stencilMask?: number; + readonly stencilFail?: number; + readonly stencilZFail?: number; + readonly stencilZPass?: number; } export interface SceneCanvasOptions { @@ -218,6 +392,8 @@ export interface SceneOptions = TValue & { + readonly __scenePrefabBrand: TBrand; +}; + +export type ScenePrefabId = ScenePrefabBrand; +export type ScenePrefabNodeId = ScenePrefabBrand; +export type ScenePrefabInstanceId = ScenePrefabBrand; +export type ScenePrefabComponentId = ScenePrefabBrand; +export type ScenePrefabPropertyPathSegment = string | number; +export type ScenePrefabPropertyPath = readonly ScenePrefabPropertyPathSegment[]; +export type ScenePrefabPropertyPathToken< + TSegment extends ScenePrefabPropertyPathSegment, +> = TSegment extends number ? `[${TSegment}]` : TSegment; +export type ScenePrefabPropertyPathString< + TSegments extends ScenePrefabPropertyPath = ScenePrefabPropertyPath, +> = TSegments extends readonly [ + infer THead extends ScenePrefabPropertyPathSegment, + ...infer TTail extends readonly ScenePrefabPropertyPathSegment[], +] + ? `${ScenePrefabPropertyPathToken}${TTail['length'] extends 0 + ? '' + : `.${ScenePrefabPropertyPathString}`}` + : ''; + +export interface ScenePrefabNodeSource { + readonly prefabId: string; + readonly nodeId: string; + readonly instancePath?: readonly string[]; + readonly lineage?: readonly string[]; +} + +export interface ScenePrefabMetadata { + readonly revision?: string; + readonly locale?: string; + readonly updatedAt?: string; + readonly timeZone?: string; + readonly tags?: readonly string[]; +} + +export type ScenePrefabComponentSelector = + | { + readonly kind: 'id'; + readonly componentId: string; + readonly type?: string; + } + | { + readonly kind: 'type'; + readonly type: string; + readonly occurrence?: number; + }; + +export type ScenePrefabActorField = + | 'name' + | 'layer' + | 'tag' + | 'active' + | 'persistent' + | 'pooled'; + +export type ScenePrefabActorFieldValue< + TField extends ScenePrefabActorField = ScenePrefabActorField, +> = TField extends 'name' | 'tag' + ? string + : TField extends 'layer' + ? number + : boolean; + export interface SceneComponentSnapshot { + readonly id?: string; readonly type: string; readonly data: SceneSerializedValue; } @@ -288,12 +534,159 @@ export interface SceneActorSnapshot { readonly active: boolean; readonly persistent: boolean; readonly pooled: boolean; + readonly source?: ScenePrefabNodeSource; readonly components: readonly SceneComponentSnapshot[]; } +export type ScenePrefabReference = + | { + readonly kind: 'inline'; + readonly prefab: ScenePrefabDefinition; + } + | { + readonly kind: 'registry'; + readonly prefabId: string; + readonly revision?: string; + }; + +export type ScenePrefabOverrideOperation = + | { + readonly kind: 'add-actor'; + readonly actor: SceneActorSnapshot; + readonly afterNodeId?: string; + } + | { + readonly kind: 'remove-actor'; + readonly nodeId: string; + } + | { + readonly kind: 'reparent-actor'; + readonly nodeId: string; + readonly parentNodeId?: string | null; + } + | { + readonly kind: 'set-actor-field'; + readonly nodeId: string; + readonly field: ScenePrefabActorField; + readonly value: ScenePrefabActorFieldValue; + } + | { + readonly kind: 'add-component'; + readonly nodeId: string; + readonly component: SceneComponentSnapshot; + readonly index?: number; + } + | { + readonly kind: 'remove-component'; + readonly nodeId: string; + readonly selector: ScenePrefabComponentSelector; + } + | { + readonly kind: 'replace-component'; + readonly nodeId: string; + readonly selector: ScenePrefabComponentSelector; + readonly component: SceneComponentSnapshot; + } + | { + readonly kind: 'set-component-property'; + readonly nodeId: string; + readonly selector: ScenePrefabComponentSelector; + readonly path: ScenePrefabPropertyPath; + readonly value: SceneSerializedValue; + } + | { + readonly kind: 'unset-component-property'; + readonly nodeId: string; + readonly selector: ScenePrefabComponentSelector; + readonly path: ScenePrefabPropertyPath; + }; + +export interface ScenePrefabNestedInstance { + readonly instanceId: string; + readonly reference: ScenePrefabReference; + readonly parentNodeId?: string | null; + readonly namePrefix?: string; + readonly overrides?: readonly ScenePrefabOverrideOperation[]; +} + export interface ScenePrefabDefinition { readonly id: string; + readonly kind?: 'prefab' | 'variant' | 'resolved'; readonly actors: readonly SceneActorSnapshot[]; + readonly base?: ScenePrefabReference; + readonly nested?: readonly ScenePrefabNestedInstance[]; + readonly overrides?: readonly ScenePrefabOverrideOperation[]; + readonly metadata?: ScenePrefabMetadata; +} + +export type ScenePrefabConflictPolicy = 'manual' | 'prefer-local' | 'prefer-incoming' | 'prefer-base'; +export type ScenePrefabConflictResolution = 'local' | 'incoming' | 'base'; +export type ScenePrefabConflictBaseValue = + | SceneSerializedValue + | SceneActorSnapshot + | SceneComponentSnapshot + | ScenePrefabActorFieldValue + | null; + +export interface ScenePrefabConflict { + readonly key: string; + readonly local: ScenePrefabOverrideOperation; + readonly incoming: ScenePrefabOverrideOperation; + readonly baseValue: ScenePrefabConflictBaseValue; +} + +export type ScenePrefabConflictResolver = ( + conflict: ScenePrefabConflict +) => ScenePrefabConflictResolution; + +export interface ScenePrefabMergeOptions { + readonly conflictPolicy?: ScenePrefabConflictPolicy; + readonly conflictResolver?: ScenePrefabConflictResolver; +} + +export interface ScenePrefabDiffResult { + readonly basePrefabId: string; + readonly targetPrefabId: string; + readonly overrides: readonly ScenePrefabOverrideOperation[]; +} + +export interface ScenePrefabMergeResult { + readonly overrides: readonly ScenePrefabOverrideOperation[]; + readonly conflicts: readonly ScenePrefabConflict[]; + readonly resolved: boolean; +} + +export interface ScenePrefabMergeDefinitionResult extends ScenePrefabMergeResult { + readonly definition: ScenePrefabDefinition; +} + +export interface ScenePrefabResolvedDefinition extends ScenePrefabDefinition { + readonly kind: 'resolved'; + readonly base?: undefined; + readonly nested?: undefined; + readonly overrides?: undefined; + readonly lineage: readonly string[]; +} + +export interface ScenePrefabResolveOptions { + readonly liveOverrides?: readonly ScenePrefabOverrideOperation[]; +} + +export interface ScenePrefabResolutionResult { + readonly definition: ScenePrefabResolvedDefinition; + readonly conflicts: readonly ScenePrefabConflict[]; + readonly cacheHit: boolean; +} + +export interface ScenePrefabRegistrySource { + getPrefab(prefabId: string): ScenePrefabDefinition | undefined; +} + +export interface ScenePrefabResolver { + resolvePrefab( + prefab: ScenePrefabDefinition | ScenePrefabReference, + options?: ScenePrefabResolveOptions + ): ScenePrefabResolutionResult; } export interface SceneSnapshot { @@ -313,6 +706,8 @@ export interface ScenePrefabInstantiateOptions { componentName: string, data: SceneSerializedValue ) => readonly unknown[] | undefined; + readonly prefabResolver?: ScenePrefabResolver; + readonly liveOverrides?: readonly ScenePrefabOverrideOperation[]; } export interface SceneSnapshotLoadOptions extends ScenePrefabInstantiateOptions { @@ -325,11 +720,15 @@ export type SceneBuiltInRegistry = { readonly PrefabNodeBinding: typeof PrefabNodeBinding; readonly Animator: typeof Animator; readonly Camera: typeof Camera; + readonly SpriteRenderer: typeof SpriteRenderer; + readonly SpriteAnimator: typeof import('./components/sprite-animator').SpriteAnimator; + readonly SpriteMask: typeof import('./components/sprite-mask').SpriteMask; readonly MeshRenderer: typeof MeshRenderer; readonly DirectionalLight: typeof DirectionalLight; readonly PointLight: typeof PointLight; readonly SpotLight: typeof SpotLight; readonly OrbitCameraController: typeof OrbitCameraController; + readonly FollowCameraController: typeof FollowCameraController; }; export type SceneRegistry> = R & diff --git a/web/packages/shapes-2d/package.json b/web/packages/shapes-2d/package.json new file mode 100644 index 00000000..c0903015 --- /dev/null +++ b/web/packages/shapes-2d/package.json @@ -0,0 +1,66 @@ +{ + "name": "@axrone/shapes-2d", + "version": "0.1.0", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./types": { + "types": "./dist/types.d.ts", + "import": "./dist/types.mjs", + "require": "./dist/types.js" + }, + "./errors": { + "types": "./dist/errors.d.ts", + "import": "./dist/errors.mjs", + "require": "./dist/errors.js" + }, + "./paint": { + "types": "./dist/paint.d.ts", + "import": "./dist/paint.mjs", + "require": "./dist/paint.js" + }, + "./shape": { + "types": "./dist/shape.d.ts", + "import": "./dist/shape.mjs", + "require": "./dist/shape.js" + }, + "./queries": { + "types": "./dist/queries.d.ts", + "import": "./dist/queries.mjs", + "require": "./dist/queries.js" + }, + "./mesh": { + "types": "./dist/mesh.d.ts", + "import": "./dist/mesh.mjs", + "require": "./dist/mesh.js" + }, + "./serialization": { + "types": "./dist/serialization.d.ts", + "import": "./dist/serialization.mjs", + "require": "./dist/serialization.js" + }, + "./registry": { + "types": "./dist/registry.d.ts", + "import": "./dist/registry.mjs", + "require": "./dist/registry.js" + } + }, + "scripts": { + "build": "rollup -c rollup.config.mjs", + "clean": "rimraf dist", + "test": "vitest run" + }, + "dependencies": { + "@axrone/numeric": "^0.0.1" + } +} diff --git a/web/packages/shapes-2d/rollup.config.mjs b/web/packages/shapes-2d/rollup.config.mjs new file mode 100644 index 00000000..81f212aa --- /dev/null +++ b/web/packages/shapes-2d/rollup.config.mjs @@ -0,0 +1,49 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createPackageConfig } from '../../build/create-package-config.mjs'; + +const packageDir = path.dirname(fileURLToPath(import.meta.url)); + +export default [ + ...createPackageConfig({ packageDir }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/types.ts', + outputBasename: 'types', + }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/errors.ts', + outputBasename: 'errors', + }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/paint.ts', + outputBasename: 'paint', + }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/shape.ts', + outputBasename: 'shape', + }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/queries.ts', + outputBasename: 'queries', + }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/mesh.ts', + outputBasename: 'mesh', + }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/serialization.ts', + outputBasename: 'serialization', + }), + ...createPackageConfig({ + packageDir, + inputRelativePath: 'src/registry.ts', + outputBasename: 'registry', + }), +]; diff --git a/web/packages/shapes-2d/src/__tests__/geometry-and-mesh.test.ts b/web/packages/shapes-2d/src/__tests__/geometry-and-mesh.test.ts new file mode 100644 index 00000000..294ede57 --- /dev/null +++ b/web/packages/shapes-2d/src/__tests__/geometry-and-mesh.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { + buildFillMesh, + buildStrokeMesh, + compileShape, + containsPoint, + createRectangleShape, + getShapeArea, + getShapeBounds, + getShapeCentroid, + getShapePerimeter, + hitTestShape, +} from '../index'; + +describe('@axrone/shapes-2d geometry and mesh', () => { + it('computes geometry and hit testing for rectangles', () => { + const shape = createRectangleShape({ + x: 10, + y: 20, + width: 100, + height: 50, + fill: '#ff0000', + stroke: { + paint: '#000000', + width: 10, + }, + }); + + expect(getShapeArea(shape)).toBe(5000); + expect(getShapePerimeter(shape)).toBe(300); + expect(getShapeCentroid(shape)).toEqual({ x: 60, y: 45 }); + expect(containsPoint(shape, [60, 45])).toBe(true); + expect(hitTestShape(shape, [60, 45])).toBe('fill'); + expect(hitTestShape(shape, [10, 25])).toBe('stroke'); + expect(hitTestShape(shape, [0, 0])).toBe('none'); + + const bounds = getShapeBounds(shape); + expect(bounds).toMatchObject({ + minX: 5, + minY: 15, + maxX: 115, + maxY: 75, + }); + }); + + it('builds meshes and compiled snapshots', () => { + const shape = createRectangleShape({ + x: 10, + y: 20, + width: 100, + height: 50, + fill: '#ff0000', + stroke: { + paint: '#000000', + width: 10, + }, + }); + + const fillMesh = buildFillMesh(shape); + const strokeMesh = buildStrokeMesh(shape); + const compiled = compileShape(shape); + + expect(fillMesh?.vertexCount).toBe(4); + expect(fillMesh?.indexCount).toBe(6); + expect(strokeMesh?.vertexCount).toBe(8); + expect(strokeMesh?.indexCount).toBe(24); + expect(compiled.fingerprint.startsWith('rectangle:')).toBe(true); + expect(compiled.fillMesh?.vertexCount).toBe(4); + expect(compiled.strokeMesh?.vertexCount).toBe(8); + }); +}); diff --git a/web/packages/shapes-2d/src/__tests__/paint-and-serialization.test.ts b/web/packages/shapes-2d/src/__tests__/paint-and-serialization.test.ts new file mode 100644 index 00000000..51343019 --- /dev/null +++ b/web/packages/shapes-2d/src/__tests__/paint-and-serialization.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + createLinearGradientPaint, + createRectangleShape, + deserializeShape, + sampleShapePaint, + serializeShape, +} from '../index'; + +describe('@axrone/shapes-2d paint and serialization', () => { + it('samples linear gradients relative to shape bounds', () => { + const fill = createLinearGradientPaint({ + start: [0, 0.5], + end: [1, 0.5], + units: 'shape-bounds', + stops: [ + { offset: 0, color: '#ff0000' }, + { offset: 1, color: '#0000ff' }, + ], + }); + + const shape = createRectangleShape({ + x: 0, + y: 0, + width: 100, + height: 100, + fill, + }); + + const left = sampleShapePaint(shape, 'fill', [0, 50]); + const center = sampleShapePaint(shape, 'fill', [50, 50]); + const right = sampleShapePaint(shape, 'fill', [100, 50]); + + expect(left?.r ?? 0).toBeGreaterThan(0.9); + expect(right?.b ?? 0).toBeGreaterThan(0.9); + expect(center?.r ?? 0).toBeCloseTo(center?.b ?? 0, 1); + }); + + it('round-trips serialized shapes', () => { + const shape = createRectangleShape({ + x: 4, + y: 8, + width: 32, + height: 16, + fill: '#00ff00', + stroke: { + paint: '#000000', + width: 2, + alignment: 'inside', + }, + opacity: 0.75, + name: 'hud-card', + }); + + const serialized = serializeShape(shape); + const restored = deserializeShape(serialized); + const restoredSerialized = serializeShape(restored); + + expect(restoredSerialized).toEqual(serialized); + }); +}); diff --git a/web/packages/shapes-2d/src/__tests__/primitive-shapes.test.ts b/web/packages/shapes-2d/src/__tests__/primitive-shapes.test.ts new file mode 100644 index 00000000..97558853 --- /dev/null +++ b/web/packages/shapes-2d/src/__tests__/primitive-shapes.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { + createCircleShape, + createLinearGradientPaint, + createRectangleShape, + createSolidPaint, +} from '../index'; + +describe('@axrone/shapes-2d primitives', () => { + it('creates rectangle shapes with appearance data', () => { + const shape = createRectangleShape({ + x: 10, + y: 20, + width: 100, + height: 50, + fill: '#ff0000', + stroke: { + paint: '#000000', + width: 10, + }, + }); + + expect(shape.kind).toBe('rectangle'); + expect(shape.fill?.kind).toBe('solid'); + expect(shape.stroke?.paint.kind).toBe('solid'); + expect(shape.opacity).toBe(1); + }); + + it('creates circles and paint descriptors', () => { + const fill = createLinearGradientPaint({ + start: [0, 0], + end: [1, 0], + stops: [ + { offset: 0, color: '#ff0000' }, + { offset: 1, color: '#0000ff' }, + ], + }); + + const shape = createCircleShape({ + cx: 0, + cy: 0, + radius: 24, + fill, + stroke: { + paint: '#ffffff', + width: 6, + }, + }); + + expect(shape.kind).toBe('circle'); + expect(shape.fill?.kind).toBe('linear-gradient'); + expect(shape.stroke?.paint.kind).toBe('solid'); + expect(createSolidPaint('#ffffff').kind).toBe('solid'); + }); +}); diff --git a/web/packages/shapes-2d/src/__tests__/registry.test.ts b/web/packages/shapes-2d/src/__tests__/registry.test.ts new file mode 100644 index 00000000..f56e9952 --- /dev/null +++ b/web/packages/shapes-2d/src/__tests__/registry.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { ShapeRegistry, createEllipseShape } from '../index'; + +describe('@axrone/shapes-2d registry', () => { + it('interns identical shapes and reuses compiled entries', () => { + const registry = new ShapeRegistry({ + maxShapes: 8, + maxCompiledEntries: 4, + }); + + const shapeA = createEllipseShape({ + cx: 16, + cy: 24, + radiusX: 12, + radiusY: 8, + fill: '#3366ff', + stroke: { + paint: '#ffffff', + width: 3, + }, + }); + + const shapeB = createEllipseShape({ + cx: 16, + cy: 24, + radiusX: 12, + radiusY: 8, + fill: '#3366ff', + stroke: { + paint: '#ffffff', + width: 3, + }, + }); + + const idA = registry.register(shapeA); + const idB = registry.register(shapeB); + + expect(idA).toBe(idB); + + const compiledA = registry.compile(idA); + const compiledB = registry.compile(shapeB); + + expect(compiledA).toBe(compiledB); + + registry.dispose(); + expect(registry.stats.disposed).toBe(true); + }); +}); diff --git a/web/packages/shapes-2d/src/common.ts b/web/packages/shapes-2d/src/common.ts new file mode 100644 index 00000000..ba299681 --- /dev/null +++ b/web/packages/shapes-2d/src/common.ts @@ -0,0 +1,281 @@ +import type { IVec2Like } from '@axrone/numeric'; +import type { + GradientSpread, + ShapeApproximationOptions, + ShapeBounds, + ShapeFingerprint, + ShapePointInput, +} from './types'; +import { PaintValidationError, ShapeValidationError } from './errors'; + +export const EPSILON = 1e-9; +export const TAU = Math.PI * 2; +export const DEFAULT_CURVE_TOLERANCE = 0.25; +export const DEFAULT_MIN_CURVE_SEGMENTS = 16; +export const DEFAULT_MAX_CURVE_SEGMENTS = 128; +export const DEFAULT_GRADIENT_LOOKUP_SIZE = 256; +export const DEFAULT_REGISTRY_MAX_SHAPES = 2048; +export const DEFAULT_REGISTRY_MAX_COMPILED = 4096; + +export const clamp = (value: number, min: number, max: number): number => + value < min ? min : value > max ? max : value; + +export const clamp01 = (value: number): number => clamp(value, 0, 1); + +export const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +export const normalizeNumberKey = (value: number): string => + Object.is(value, -0) ? '0' : Number.isInteger(value) ? `${value}` : `${value}`; + +export const assertFiniteNumber = (value: unknown, name: string): number => { + if (!isFiniteNumber(value)) { + throw new ShapeValidationError(`${name} must be a finite number`); + } + return value; +}; + +export const assertPositiveNumber = (value: unknown, name: string): number => { + const normalized = assertFiniteNumber(value, name); + if (normalized <= 0) { + throw new ShapeValidationError(`${name} must be greater than 0`); + } + return normalized; +}; + +export const assertNonNegativeNumber = (value: unknown, name: string): number => { + const normalized = assertFiniteNumber(value, name); + if (normalized < 0) { + throw new ShapeValidationError(`${name} must be greater than or equal to 0`); + } + return normalized; +}; + +export const toPoint = (value: ShapePointInput, name: string): Readonly => { + if (Array.isArray(value)) { + if (value.length < 2) { + throw new ShapeValidationError(`${name} must have at least two numeric values`); + } + + return Object.freeze({ + x: assertFiniteNumber(value[0], `${name}[0]`), + y: assertFiniteNumber(value[1], `${name}[1]`), + }); + } + + if (value && typeof value === 'object' && 'x' in value && 'y' in value) { + return Object.freeze({ + x: assertFiniteNumber(value.x, `${name}.x`), + y: assertFiniteNumber(value.y, `${name}.y`), + }); + } + + throw new ShapeValidationError(`${name} must be a point-like value`); +}; + +export const createBounds = ( + minX: number, + minY: number, + maxX: number, + maxY: number +): ShapeBounds => { + const safeMinX = Math.min(minX, maxX); + const safeMaxX = Math.max(minX, maxX); + const safeMinY = Math.min(minY, maxY); + const safeMaxY = Math.max(minY, maxY); + + return Object.freeze({ + minX: safeMinX, + minY: safeMinY, + maxX: safeMaxX, + maxY: safeMaxY, + width: safeMaxX - safeMinX, + height: safeMaxY - safeMinY, + centerX: (safeMinX + safeMaxX) * 0.5, + centerY: (safeMinY + safeMaxY) * 0.5, + }); +}; + +export const expandBounds = (bounds: ShapeBounds, amount: number): ShapeBounds => + createBounds( + bounds.minX - amount, + bounds.minY - amount, + bounds.maxX + amount, + bounds.maxY + amount + ); + +export const pointInBounds = (bounds: ShapeBounds, point: Readonly): boolean => + point.x >= bounds.minX - EPSILON && + point.x <= bounds.maxX + EPSILON && + point.y >= bounds.minY - EPSILON && + point.y <= bounds.maxY + EPSILON; + +export const distanceSquared = ( + ax: number, + ay: number, + bx: number, + by: number +): number => { + const dx = bx - ax; + const dy = by - ay; + return dx * dx + dy * dy; +}; + +export const distance = (ax: number, ay: number, bx: number, by: number): number => + Math.sqrt(distanceSquared(ax, ay, bx, by)); + +export const distanceToSegmentSquared = ( + px: number, + py: number, + ax: number, + ay: number, + bx: number, + by: number +): number => { + const abx = bx - ax; + const aby = by - ay; + const abLengthSquared = abx * abx + aby * aby; + + if (abLengthSquared <= EPSILON) { + return distanceSquared(px, py, ax, ay); + } + + const t = clamp(((px - ax) * abx + (py - ay) * aby) / abLengthSquared, 0, 1); + const cx = ax + abx * t; + const cy = ay + aby * t; + return distanceSquared(px, py, cx, cy); +}; + +export const polygonSignedArea = (points: ArrayLike): number => { + const count = Math.floor(points.length / 2); + let area = 0; + + for (let index = 0; index < count; index++) { + const current = index * 2; + const next = ((index + 1) % count) * 2; + area += points[current] * points[next + 1] - points[current + 1] * points[next]; + } + + return area * 0.5; +}; + +export const pointInConvexPolygon = ( + points: ArrayLike, + point: Readonly +): boolean => { + const count = Math.floor(points.length / 2); + if (count < 3) { + return false; + } + + const winding = polygonSignedArea(points) >= 0 ? 1 : -1; + + for (let index = 0; index < count; index++) { + const current = index * 2; + const next = ((index + 1) % count) * 2; + const edgeX = points[next] - points[current]; + const edgeY = points[next + 1] - points[current + 1]; + const pointX = point.x - points[current]; + const pointY = point.y - points[current + 1]; + const cross = edgeX * pointY - edgeY * pointX; + + if (cross * winding < -EPSILON) { + return false; + } + } + + return true; +}; + +export const normalizeContourOrientation = ( + contour: Float32Array, + ccw: boolean = true +): Float32Array => { + const area = polygonSignedArea(contour); + const isCcw = area >= 0; + if (isCcw === ccw) { + return contour; + } + + const reversed = new Float32Array(contour.length); + const count = contour.length / 2; + + for (let index = 0; index < count; index++) { + const source = ((count - index) % count) * 2; + const target = index * 2; + reversed[target] = contour[source]; + reversed[target + 1] = contour[source + 1]; + } + + return reversed; +}; + +export const toIndexArray = ( + indices: readonly number[], + vertexCount: number +): Uint16Array | Uint32Array => + vertexCount <= 65535 ? new Uint16Array(indices) : new Uint32Array(indices); + +export const approximateCurveSegments = ( + radiusX: number, + radiusY: number, + options: ShapeApproximationOptions = {} +): number => { + const tolerance = Math.max(options.curveTolerance ?? DEFAULT_CURVE_TOLERANCE, EPSILON); + const minSegments = Math.max(3, Math.floor(options.minCurveSegments ?? DEFAULT_MIN_CURVE_SEGMENTS)); + const maxSegments = Math.max( + minSegments, + Math.floor(options.maxCurveSegments ?? DEFAULT_MAX_CURVE_SEGMENTS) + ); + const radius = Math.max(Math.abs(radiusX), Math.abs(radiusY)); + + if (radius <= EPSILON) { + return minSegments; + } + + const ratio = clamp(1 - tolerance / radius, -1, 1); + const theta = Math.max(EPSILON, 2 * Math.acos(ratio)); + const segments = Math.ceil(TAU / theta); + return clamp(segments, minSegments, maxSegments); +}; + +export const applyGradientSpread = (value: number, spread: GradientSpread): number => { + if (!Number.isFinite(value)) { + throw new PaintValidationError('Gradient sample value must be finite'); + } + + switch (spread) { + case 'pad': + return clamp01(value); + case 'repeat': { + const normalized = value % 1; + return normalized < 0 ? normalized + 1 : normalized; + } + case 'reflect': { + const wrapped = Math.abs(value % 2); + return wrapped > 1 ? 2 - wrapped : wrapped; + } + default: + return clamp01(value); + } +}; + +export const hashString = (value: string): string => { + let hash = 2166136261; + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, '0'); +}; + +export const formatPointKey = (point: Readonly): string => + `${normalizeNumberKey(point.x)},${normalizeNumberKey(point.y)}`; + +export const formatBoundsKey = (bounds: ShapeBounds): string => + `${normalizeNumberKey(bounds.minX)},${normalizeNumberKey(bounds.minY)},${normalizeNumberKey(bounds.maxX)},${normalizeNumberKey(bounds.maxY)}`; + +export const withFingerprintPrefix = ( + prefix: K, + value: string +): `${K}:${string}` => `${prefix}:${value}`; diff --git a/web/packages/shapes-2d/src/errors.ts b/web/packages/shapes-2d/src/errors.ts new file mode 100644 index 00000000..de21488b --- /dev/null +++ b/web/packages/shapes-2d/src/errors.ts @@ -0,0 +1,68 @@ +export const SHAPES_2D_ERROR_CODE = { + INVALID_NUMBER: 'INVALID_NUMBER', + INVALID_POINT: 'INVALID_POINT', + INVALID_COLOR: 'INVALID_COLOR', + INVALID_PAINT: 'INVALID_PAINT', + INVALID_GRADIENT: 'INVALID_GRADIENT', + INVALID_STROKE: 'INVALID_STROKE', + INVALID_SHAPE: 'INVALID_SHAPE', + INVALID_SERIALIZED_PAYLOAD: 'INVALID_SERIALIZED_PAYLOAD', + REGISTRY_DISPOSED: 'REGISTRY_DISPOSED', + SHAPE_NOT_FOUND: 'SHAPE_NOT_FOUND', + CAPACITY_EXCEEDED: 'CAPACITY_EXCEEDED', +} as const; + +export type Shapes2DErrorCode = + (typeof SHAPES_2D_ERROR_CODE)[keyof typeof SHAPES_2D_ERROR_CODE]; + +export interface Shapes2DErrorOptions { + readonly cause?: unknown; + readonly details?: Record; +} + +export class Shapes2DError extends Error { + readonly code: Shapes2DErrorCode; + readonly details?: Readonly>; + override readonly cause?: unknown; + + constructor( + code: Shapes2DErrorCode, + message: string, + options: Shapes2DErrorOptions = {} + ) { + super(message); + this.name = 'Shapes2DError'; + this.code = code; + this.details = options.details; + this.cause = options.cause; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class ShapeValidationError extends Shapes2DError { + constructor(message: string, options: Shapes2DErrorOptions = {}) { + super(SHAPES_2D_ERROR_CODE.INVALID_SHAPE, message, options); + this.name = 'ShapeValidationError'; + } +} + +export class PaintValidationError extends Shapes2DError { + constructor(message: string, options: Shapes2DErrorOptions = {}) { + super(SHAPES_2D_ERROR_CODE.INVALID_PAINT, message, options); + this.name = 'PaintValidationError'; + } +} + +export class SerializationError extends Shapes2DError { + constructor(message: string, options: Shapes2DErrorOptions = {}) { + super(SHAPES_2D_ERROR_CODE.INVALID_SERIALIZED_PAYLOAD, message, options); + this.name = 'SerializationError'; + } +} + +export class ShapeRegistryError extends Shapes2DError { + constructor(code: Shapes2DErrorCode, message: string, options: Shapes2DErrorOptions = {}) { + super(code, message, options); + this.name = 'ShapeRegistryError'; + } +} diff --git a/web/packages/shapes-2d/src/geometry.ts b/web/packages/shapes-2d/src/geometry.ts new file mode 100644 index 00000000..241c6775 --- /dev/null +++ b/web/packages/shapes-2d/src/geometry.ts @@ -0,0 +1,516 @@ +import type { IVec2Like } from '@axrone/numeric'; +import { + EPSILON, + TAU, + approximateCurveSegments, + createBounds, + distance, + distanceSquared, + distanceToSegmentSquared, + expandBounds, + normalizeContourOrientation, + pointInConvexPolygon, + pointInBounds, + polygonSignedArea, + toIndexArray, +} from './common'; +import type { + CircleShape, + EllipseShape, + LineShape, + RectangleShape, + Shape2D, + ShapeApproximationOptions, + ShapeBounds, + ShapeMesh2D, + ShapeStroke, + TriangleShape, +} from './types'; + +const getContourBounds = (contour: Float32Array): ShapeBounds => { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (let index = 0; index < contour.length; index += 2) { + const x = contour[index]!; + const y = contour[index + 1]!; + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + + return createBounds(minX, minY, maxX, maxY); +}; + +const createMesh = ( + positions: Float32Array, + indices: readonly number[] +): ShapeMesh2D => ({ + positions, + indices: toIndexArray(indices, positions.length / 2), + vertexCount: positions.length / 2, + indexCount: indices.length, + bounds: getContourBounds(positions), +}); + +const buildConvexFanMesh = (contour: Float32Array): ShapeMesh2D => { + const indices: number[] = []; + const count = contour.length / 2; + + for (let index = 1; index < count - 1; index++) { + indices.push(0, index, index + 1); + } + + return createMesh(contour.slice(), indices); +}; + +const buildRingMesh = (outer: Float32Array, inner: Float32Array | null): ShapeMesh2D => { + if (!inner || Math.abs(polygonSignedArea(inner)) <= EPSILON) { + return buildConvexFanMesh(outer); + } + + const count = outer.length / 2; + const positions = new Float32Array(outer.length + inner.length); + positions.set(outer, 0); + positions.set(inner, outer.length); + + const indices: number[] = []; + + for (let index = 0; index < count; index++) { + const next = (index + 1) % count; + const innerIndex = index + count; + const innerNext = next + count; + indices.push(index, next, innerNext, index, innerNext, innerIndex); + } + + return createMesh(positions, indices); +}; + +const getStrokeOffsets = ( + stroke: ShapeStroke +): { readonly outer: number; readonly inner: number } => { + switch (stroke.alignment) { + case 'inside': + return { outer: 0, inner: stroke.width }; + case 'outside': + return { outer: stroke.width, inner: 0 }; + default: + return { outer: stroke.width * 0.5, inner: stroke.width * 0.5 }; + } +}; + +const normalizeEdge = ( + fromX: number, + fromY: number, + toX: number, + toY: number +): readonly [number, number] => { + const dx = toX - fromX; + const dy = toY - fromY; + const length = Math.sqrt(dx * dx + dy * dy); + if (length <= EPSILON) { + return [0, 0]; + } + return [dx / length, dy / length]; +}; + +const offsetConvexContour = (contour: Float32Array, distanceValue: number): Float32Array => { + const count = contour.length / 2; + const area = polygonSignedArea(contour); + const winding = area >= 0 ? 1 : -1; + const offset = new Float32Array(contour.length); + + for (let index = 0; index < count; index++) { + const previous = (index + count - 1) % count; + const next = (index + 1) % count; + + const px = contour[index * 2]!; + const py = contour[index * 2 + 1]!; + const prevX = contour[previous * 2]!; + const prevY = contour[previous * 2 + 1]!; + const nextX = contour[next * 2]!; + const nextY = contour[next * 2 + 1]!; + + const [prevDirX, prevDirY] = normalizeEdge(prevX, prevY, px, py); + const [nextDirX, nextDirY] = normalizeEdge(px, py, nextX, nextY); + + const prevNormalX = winding >= 0 ? prevDirY : -prevDirY; + const prevNormalY = winding >= 0 ? -prevDirX : prevDirX; + const nextNormalX = winding >= 0 ? nextDirY : -nextDirY; + const nextNormalY = winding >= 0 ? -nextDirX : nextDirX; + + const miterX = prevNormalX + nextNormalX; + const miterY = prevNormalY + nextNormalY; + const miterLength = Math.sqrt(miterX * miterX + miterY * miterY); + + if (miterLength <= EPSILON) { + offset[index * 2] = px + prevNormalX * distanceValue; + offset[index * 2 + 1] = py + prevNormalY * distanceValue; + continue; + } + + const normalizedMiterX = miterX / miterLength; + const normalizedMiterY = miterY / miterLength; + const projection = normalizedMiterX * prevNormalX + normalizedMiterY * prevNormalY; + + if (Math.abs(projection) <= EPSILON) { + offset[index * 2] = px + prevNormalX * distanceValue; + offset[index * 2 + 1] = py + prevNormalY * distanceValue; + continue; + } + + const scale = distanceValue / projection; + offset[index * 2] = px + normalizedMiterX * scale; + offset[index * 2 + 1] = py + normalizedMiterY * scale; + } + + return offset; +}; + +const isValidContour = (contour: Float32Array | null): contour is Float32Array => + !!contour && + contour.length >= 6 && + contour.every((value) => Number.isFinite(value)) && + Math.abs(polygonSignedArea(contour)) > EPSILON; + +const createEllipseContour = ( + cx: number, + cy: number, + radiusX: number, + radiusY: number, + options: ShapeApproximationOptions = {} +): Float32Array => { + const segments = approximateCurveSegments(radiusX, radiusY, options); + const contour = new Float32Array(segments * 2); + + for (let index = 0; index < segments; index++) { + const angle = (index / segments) * TAU; + contour[index * 2] = cx + Math.cos(angle) * radiusX; + contour[index * 2 + 1] = cy + Math.sin(angle) * radiusY; + } + + return contour; +}; + +const createRectangleContour = (shape: RectangleShape): Float32Array => + new Float32Array([ + shape.x, + shape.y, + shape.x + shape.width, + shape.y, + shape.x + shape.width, + shape.y + shape.height, + shape.x, + shape.y + shape.height, + ]); + +const createTriangleContour = (shape: TriangleShape): Float32Array => + normalizeContourOrientation( + new Float32Array([ + shape.a.x, + shape.a.y, + shape.b.x, + shape.b.y, + shape.c.x, + shape.c.y, + ]) + ); + +export const getShapeContour = ( + shape: Shape2D, + options: ShapeApproximationOptions = {} +): Float32Array => { + switch (shape.kind) { + case 'rectangle': + return createRectangleContour(shape); + case 'circle': + return createEllipseContour(shape.cx, shape.cy, shape.radius, shape.radius, options); + case 'ellipse': + return createEllipseContour(shape.cx, shape.cy, shape.radiusX, shape.radiusY, options); + case 'triangle': + return createTriangleContour(shape); + case 'line': + return new Float32Array([shape.start.x, shape.start.y, shape.end.x, shape.end.y]); + } +}; + +export const getGeometryBounds = (shape: Shape2D): ShapeBounds => { + switch (shape.kind) { + case 'rectangle': + return createBounds(shape.x, shape.y, shape.x + shape.width, shape.y + shape.height); + case 'circle': + return createBounds( + shape.cx - shape.radius, + shape.cy - shape.radius, + shape.cx + shape.radius, + shape.cy + shape.radius + ); + case 'ellipse': + return createBounds( + shape.cx - shape.radiusX, + shape.cy - shape.radiusY, + shape.cx + shape.radiusX, + shape.cy + shape.radiusY + ); + case 'triangle': + return createBounds( + Math.min(shape.a.x, shape.b.x, shape.c.x), + Math.min(shape.a.y, shape.b.y, shape.c.y), + Math.max(shape.a.x, shape.b.x, shape.c.x), + Math.max(shape.a.y, shape.b.y, shape.c.y) + ); + case 'line': + return createBounds(shape.start.x, shape.start.y, shape.end.x, shape.end.y); + } +}; + +export const getShapeBounds = ( + shape: Shape2D, + options: ShapeApproximationOptions = {} +): ShapeBounds => { + const geometryBounds = getGeometryBounds(shape); + if (!shape.stroke) { + return geometryBounds; + } + + const { outer } = getStrokeOffsets(shape.stroke); + if (outer <= EPSILON) { + return geometryBounds; + } + + switch (shape.kind) { + case 'rectangle': + case 'circle': + case 'ellipse': + return expandBounds(geometryBounds, outer); + case 'line': + return expandBounds(geometryBounds, shape.stroke.width * 0.5); + case 'triangle': { + const contour = getShapeContour(shape, options); + return getContourBounds(offsetConvexContour(contour, outer)); + } + } +}; + +export const getShapeArea = (shape: Shape2D): number => { + switch (shape.kind) { + case 'rectangle': + return shape.width * shape.height; + case 'circle': + return Math.PI * shape.radius * shape.radius; + case 'ellipse': + return Math.PI * shape.radiusX * shape.radiusY; + case 'triangle': + return ( + Math.abs( + shape.a.x * (shape.b.y - shape.c.y) + + shape.b.x * (shape.c.y - shape.a.y) + + shape.c.x * (shape.a.y - shape.b.y) + ) * 0.5 + ); + case 'line': + return 0; + } +}; + +export const getShapePerimeter = (shape: Shape2D): number => { + switch (shape.kind) { + case 'rectangle': + return (shape.width + shape.height) * 2; + case 'circle': + return TAU * shape.radius; + case 'ellipse': { + const a = Math.max(shape.radiusX, shape.radiusY); + const b = Math.min(shape.radiusX, shape.radiusY); + return Math.PI * (3 * (a + b) - Math.sqrt((3 * a + b) * (a + 3 * b))); + } + case 'triangle': + return ( + distance(shape.a.x, shape.a.y, shape.b.x, shape.b.y) + + distance(shape.b.x, shape.b.y, shape.c.x, shape.c.y) + + distance(shape.c.x, shape.c.y, shape.a.x, shape.a.y) + ); + case 'line': + return distance(shape.start.x, shape.start.y, shape.end.x, shape.end.y); + } +}; + +export const getShapeCentroid = (shape: Shape2D): Readonly => { + switch (shape.kind) { + case 'rectangle': + return Object.freeze({ + x: shape.x + shape.width * 0.5, + y: shape.y + shape.height * 0.5, + }); + case 'circle': + case 'ellipse': + return Object.freeze({ x: shape.cx, y: shape.cy }); + case 'triangle': + return Object.freeze({ + x: (shape.a.x + shape.b.x + shape.c.x) / 3, + y: (shape.a.y + shape.b.y + shape.c.y) / 3, + }); + case 'line': + return Object.freeze({ + x: (shape.start.x + shape.end.x) * 0.5, + y: (shape.start.y + shape.end.y) * 0.5, + }); + } +}; + +const containsPointInTriangle = (shape: TriangleShape, point: Readonly): boolean => { + const d1 = + (point.x - shape.b.x) * (shape.a.y - shape.b.y) - + (shape.a.x - shape.b.x) * (point.y - shape.b.y); + const d2 = + (point.x - shape.c.x) * (shape.b.y - shape.c.y) - + (shape.b.x - shape.c.x) * (point.y - shape.c.y); + const d3 = + (point.x - shape.a.x) * (shape.c.y - shape.a.y) - + (shape.c.x - shape.a.x) * (point.y - shape.a.y); + const hasNegative = d1 < -EPSILON || d2 < -EPSILON || d3 < -EPSILON; + const hasPositive = d1 > EPSILON || d2 > EPSILON || d3 > EPSILON; + return !(hasNegative && hasPositive); +}; + +export const containsGeometryPoint = ( + shape: Shape2D, + point: Readonly +): boolean => { + switch (shape.kind) { + case 'rectangle': + return pointInBounds(getGeometryBounds(shape), point); + case 'circle': + return ( + distanceSquared(point.x, point.y, shape.cx, shape.cy) <= + shape.radius * shape.radius + EPSILON + ); + case 'ellipse': { + const dx = (point.x - shape.cx) / shape.radiusX; + const dy = (point.y - shape.cy) / shape.radiusY; + return dx * dx + dy * dy <= 1 + EPSILON; + } + case 'triangle': + return containsPointInTriangle(shape, point); + case 'line': + return false; + } +}; + +export const containsStrokePoint = ( + shape: Shape2D, + point: Readonly, + options: ShapeApproximationOptions = {} +): boolean => { + if (!shape.stroke) { + return false; + } + + if (shape.kind === 'line') { + return ( + distanceToSegmentSquared( + point.x, + point.y, + shape.start.x, + shape.start.y, + shape.end.x, + shape.end.y + ) <= + (shape.stroke.width * 0.5) * (shape.stroke.width * 0.5) + EPSILON + ); + } + + const contour = getShapeContour(shape, options); + const { outer, inner } = getStrokeOffsets(shape.stroke); + const outerContour = outer <= EPSILON ? contour : offsetConvexContour(contour, outer); + if (!pointInConvexPolygon(outerContour, point)) { + return false; + } + + if (inner <= EPSILON) { + return true; + } + + const innerContour = offsetConvexContour(contour, -inner); + if (!isValidContour(innerContour)) { + return true; + } + + return !pointInConvexPolygon(innerContour, point); +}; + +const buildLineStrokeMesh = (shape: LineShape): ShapeMesh2D | null => { + if (!shape.stroke) { + return null; + } + + const halfWidth = shape.stroke.width * 0.5; + const dx = shape.end.x - shape.start.x; + const dy = shape.end.y - shape.start.y; + const length = Math.sqrt(dx * dx + dy * dy); + + if (length <= EPSILON) { + return buildConvexFanMesh( + new Float32Array([ + shape.start.x - halfWidth, + shape.start.y - halfWidth, + shape.start.x + halfWidth, + shape.start.y - halfWidth, + shape.start.x + halfWidth, + shape.start.y + halfWidth, + shape.start.x - halfWidth, + shape.start.y + halfWidth, + ]) + ); + } + + const normalX = (-dy / length) * halfWidth; + const normalY = (dx / length) * halfWidth; + + return createMesh( + new Float32Array([ + shape.start.x + normalX, + shape.start.y + normalY, + shape.end.x + normalX, + shape.end.y + normalY, + shape.end.x - normalX, + shape.end.y - normalY, + shape.start.x - normalX, + shape.start.y - normalY, + ]), + [0, 1, 2, 0, 2, 3] + ); +}; + +export const buildFillMeshInternal = ( + shape: Shape2D, + options: ShapeApproximationOptions = {} +): ShapeMesh2D | null => { + if (shape.kind === 'line') { + return null; + } + + return buildConvexFanMesh(getShapeContour(shape, options)); +}; + +export const buildStrokeMeshInternal = ( + shape: Shape2D, + options: ShapeApproximationOptions = {} +): ShapeMesh2D | null => { + if (!shape.stroke) { + return null; + } + + if (shape.kind === 'line') { + return buildLineStrokeMesh(shape); + } + + const contour = getShapeContour(shape, options); + const { outer, inner } = getStrokeOffsets(shape.stroke); + const outerContour = outer <= EPSILON ? contour : offsetConvexContour(contour, outer); + const innerContour = inner <= EPSILON ? null : offsetConvexContour(contour, -inner); + return buildRingMesh(outerContour, isValidContour(innerContour) ? innerContour : null); +}; diff --git a/web/packages/shapes-2d/src/index.ts b/web/packages/shapes-2d/src/index.ts new file mode 100644 index 00000000..4b54f3c8 --- /dev/null +++ b/web/packages/shapes-2d/src/index.ts @@ -0,0 +1,122 @@ +export type { IColorLike, IVec2Like } from '@axrone/numeric'; +export type { + CircleShape, + CircleShapeInput, + CompiledShape2D, + EllipseShape, + EllipseShapeInput, + GradientColorSpace, + GradientSpread, + GradientStop, + GradientStopInput, + GradientUnits, + LineShape, + LineShapeInput, + LinearGradientPaint, + LinearGradientPaintInput, + RadialGradientPaint, + RadialGradientPaintInput, + RectangleShape, + RectangleShapeInput, + Shape2D, + ShapeAppearance, + ShapeAppearanceInput, + ShapeApproximationOptions, + ShapeBounds, + ShapeCompileOptions, + ShapeColorInput, + ShapeId, + ShapeKind, + ShapeFingerprint, + ShapeHitTarget, + ShapePaint, + ShapePaintInput, + ShapePointInput, + ShapeMesh2D, + ShapeRegistryOptions, + ShapeRegistryStats, + ShapeStroke, + ShapeStrokeAlignment, + ShapeStrokeInput, + TriangleShape, + TriangleShapeInput, +} from './types'; +export { + PaintValidationError, + SHAPES_2D_ERROR_CODE, + SerializationError, + ShapeRegistryError, + Shapes2DError, + ShapeValidationError, +} from './errors'; +export { + createColor, + createGradientStop, + createLinearGradientPaint, + createPaint, + createRadialGradientPaint, + createSolidPaint, + createStroke, + isLinearGradientPaint, + isRadialGradientPaint, + isGradientPaint, + isShapePaint, + isSolidPaint, + createGradientLookupTable, + createPaintFingerprint, + modulatePaintAlpha, + samplePaint, +} from './paint'; +export { + createCircleShape, + createEllipseShape, + createLineShape, + createRectangleShape, + createTriangleShape, + isCircleShape, + isEllipseShape, + isLineShape, + isRectangleShape, + isShape2D, + isTriangleShape, + matchShape, +} from './shape'; +export { + buildFillMesh, + buildStrokeMesh, + compileShape, +} from './mesh'; +export { + containsPoint, + getShapeArea, + getShapeBounds, + getShapeCentroid, + getShapePerimeter, + hitTestShape, + sampleShapePaint, +} from './queries'; +export type { + SerializedCircleShape, + SerializedEllipseShape, + SerializedLineShape, + SerializedLinearGradientPaint, + SerializedPaint, + SerializedRadialGradientPaint, + SerializedRectangleShape, + SerializedShape, + SerializedSolidPaint, + SerializedStroke, + SerializedTriangleShape, +} from './serialization'; +export { + createShapeFingerprint, + deserializePaint, + deserializeShape, + deserializeStroke, + serializePaint, + serializeShape, + serializeStroke, + stringifyPaint, + stringifyShape, +} from './serialization'; +export { ShapeRegistry } from './registry'; diff --git a/web/packages/shapes-2d/src/mesh.ts b/web/packages/shapes-2d/src/mesh.ts new file mode 100644 index 00000000..eeec896c --- /dev/null +++ b/web/packages/shapes-2d/src/mesh.ts @@ -0,0 +1,42 @@ +import { + buildFillMeshInternal, + buildStrokeMeshInternal, + getGeometryBounds, + getShapeBounds, + getShapeContour, + getShapePerimeter, + getShapeArea, +} from './geometry'; +import { createShapeFingerprint } from './serialization'; +import type { + CompiledShape2D, + Shape2D, + ShapeCompileOptions, + ShapeMesh2D, +} from './types'; + +export const buildFillMesh = ( + shape: Shape2D, + options: ShapeCompileOptions = {} +): ShapeMesh2D | null => buildFillMeshInternal(shape, options); + +export const buildStrokeMesh = ( + shape: Shape2D, + options: ShapeCompileOptions = {} +): ShapeMesh2D | null => buildStrokeMeshInternal(shape, options); + +export const compileShape = ( + shape: TShape, + options: ShapeCompileOptions = {} +): CompiledShape2D => ({ + shape, + fingerprint: createShapeFingerprint(shape), + geometryBounds: getGeometryBounds(shape), + bounds: getShapeBounds(shape, options), + area: getShapeArea(shape), + perimeter: getShapePerimeter(shape), + contour: getShapeContour(shape, options), + fillMesh: options.includeFillMesh === false ? null : buildFillMeshInternal(shape, options), + strokeMesh: + options.includeStrokeMesh === false ? null : buildStrokeMeshInternal(shape, options), +}); diff --git a/web/packages/shapes-2d/src/paint.ts b/web/packages/shapes-2d/src/paint.ts new file mode 100644 index 00000000..b610c6d3 --- /dev/null +++ b/web/packages/shapes-2d/src/paint.ts @@ -0,0 +1,472 @@ +import { Color } from '@axrone/numeric'; +import type { IColorLike, IVec2Like } from '@axrone/numeric'; +import { + DEFAULT_GRADIENT_LOOKUP_SIZE, + EPSILON, + applyGradientSpread, + assertFiniteNumber, + assertPositiveNumber, + clamp01, + formatPointKey, + hashString, + normalizeNumberKey, + toPoint, +} from './common'; +import { PaintValidationError } from './errors'; +import type { + GradientColorSpace, + GradientStop, + GradientStopInput, + LinearGradientPaint, + LinearGradientPaintInput, + RadialGradientPaint, + RadialGradientPaintInput, + ResolvedColor, + ShapeBounds, + ShapeColorInput, + ShapePaint, + ShapePaintInput, + ShapePointInput, + ShapeStroke, + ShapeStrokeInput, + SolidPaint, +} from './types'; + +const gradientLookupCache = new WeakMap< + LinearGradientPaint | RadialGradientPaint, + Map +>(); + +const isColorLikeObject = (value: unknown): value is ShapeColorInput => + typeof value === 'string' || + Array.isArray(value) || + !!( + value && + typeof value === 'object' && + 'r' in value && + 'g' in value && + 'b' in value && + !('kind' in value) + ); + +const asResolvedColor = (color: Color): ResolvedColor => + Object.freeze({ + r: color.r, + g: color.g, + b: color.b, + a: color.a, + }); + +const normalizeColorSpace = (value: GradientColorSpace | undefined): GradientColorSpace => + value ?? 'srgb'; + +const resolveBoundsPoint = ( + point: Readonly, + bounds: ShapeBounds, + units: 'local' | 'shape-bounds' +): Readonly => + units === 'local' + ? point + : { + x: bounds.minX + bounds.width * point.x, + y: bounds.minY + bounds.height * point.y, + }; + +const resolveBoundsRadius = ( + radius: number, + bounds: ShapeBounds, + units: 'local' | 'shape-bounds' +): number => (units === 'local' ? radius : Math.max(bounds.width, bounds.height) * radius); + +const interpolateLinearSrgb = ( + left: ResolvedColor, + right: ResolvedColor, + factor: number +): ResolvedColor => { + const toLinear = (value: number): number => + value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + const toSrgb = (value: number): number => + value <= 0.0031308 ? value * 12.92 : 1.055 * value ** (1 / 2.4) - 0.055; + const lR = toLinear(left.r); + const lG = toLinear(left.g); + const lB = toLinear(left.b); + const rR = toLinear(right.r); + const rG = toLinear(right.g); + const rB = toLinear(right.b); + + return { + r: clamp01(toSrgb(lR + (rR - lR) * factor)), + g: clamp01(toSrgb(lG + (rG - lG) * factor)), + b: clamp01(toSrgb(lB + (rB - lB) * factor)), + a: clamp01(left.a + (right.a - left.a) * factor), + }; +}; + +const interpolateColor = ( + left: ResolvedColor, + right: ResolvedColor, + factor: number, + colorSpace: GradientColorSpace +): ResolvedColor => { + switch (colorSpace) { + case 'linear-srgb': + return interpolateLinearSrgb(left, right, factor); + case 'hsl': + return Color.lerpHSL(left, right, factor); + case 'lab': + return Color.lerpLab(left, right, factor); + default: + return Color.lerp(left, right, factor); + } +}; + +const normalizeGradientStop = (input: GradientStopInput): GradientStop => { + const offset = assertFiniteNumber(input.offset, 'gradient stop offset'); + if (offset < 0 || offset > 1) { + throw new PaintValidationError('Gradient stop offset must be between 0 and 1'); + } + + return Object.freeze({ + offset, + color: createColor(input.color), + }); +}; + +const normalizeStops = (stops: readonly GradientStopInput[]): readonly GradientStop[] => { + if (stops.length === 0) { + throw new PaintValidationError('Gradient must have at least one color stop'); + } + + return Object.freeze( + stops + .map(normalizeGradientStop) + .slice() + .sort((left, right) => left.offset - right.offset) + ); +}; + +const sampleStops = ( + stops: readonly GradientStop[], + sample: number, + colorSpace: GradientColorSpace +): ResolvedColor => { + if (stops.length === 1) { + const single = stops[0]; + return { + r: single.color.r, + g: single.color.g, + b: single.color.b, + a: single.color.a, + }; + } + + if (sample <= stops[0]!.offset) { + const first = stops[0]!; + return { + r: first.color.r, + g: first.color.g, + b: first.color.b, + a: first.color.a, + }; + } + + const last = stops[stops.length - 1]!; + if (sample >= last.offset) { + return { + r: last.color.r, + g: last.color.g, + b: last.color.b, + a: last.color.a, + }; + } + + for (let index = 0; index < stops.length - 1; index++) { + const left = stops[index]!; + const right = stops[index + 1]!; + if (sample >= left.offset && sample <= right.offset) { + const range = right.offset - left.offset; + const factor = range <= EPSILON ? 0 : (sample - left.offset) / range; + return interpolateColor(left.color, right.color, factor, colorSpace); + } + } + + return { + r: last.color.r, + g: last.color.g, + b: last.color.b, + a: last.color.a, + }; +}; + +const createGradientCacheKey = ( + paint: LinearGradientPaint | RadialGradientPaint, + size: number +): number => { + if (size < 2 || !Number.isFinite(size)) { + throw new PaintValidationError('Gradient lookup table size must be at least 2'); + } + return Math.floor(size); +}; + +export const createColor = (value: ShapeColorInput): ResolvedColor => { + if (typeof value === 'string') { + const normalized = value.trim(); + if (normalized.length === 0) { + throw new PaintValidationError('Color string must not be empty'); + } + + try { + if (normalized.startsWith('#')) { + return asResolvedColor(Color.fromHex(normalized)); + } + return asResolvedColor(Color.fromNamedColor(normalized)); + } catch (error) { + throw new PaintValidationError(`Unsupported color string: ${normalized}`, { + cause: error, + }); + } + } + + if (Array.isArray(value)) { + if (value.length < 3) { + throw new PaintValidationError('Color tuple must include at least three channels'); + } + + const r = assertFiniteNumber(value[0], 'color[0]'); + const g = assertFiniteNumber(value[1], 'color[1]'); + const b = assertFiniteNumber(value[2], 'color[2]'); + const a = value.length > 3 ? assertFiniteNumber(value[3], 'color[3]') : 1; + return asResolvedColor(Color.fromRGB(r, g, b, a)); + } + + if (value && typeof value === 'object' && 'r' in value && 'g' in value && 'b' in value) { + const alpha = 'a' in value ? value.a : 1; + return asResolvedColor( + Color.fromRGB( + assertFiniteNumber(value.r, 'color.r'), + assertFiniteNumber(value.g, 'color.g'), + assertFiniteNumber(value.b, 'color.b'), + assertFiniteNumber(alpha, 'color.a') + ) + ); + } + + throw new PaintValidationError('Unsupported color input'); +}; + +export const isSolidPaint = (value: unknown): value is SolidPaint => + !!value && typeof value === 'object' && 'kind' in value && value.kind === 'solid'; + +export const isLinearGradientPaint = (value: unknown): value is LinearGradientPaint => + !!value && typeof value === 'object' && 'kind' in value && value.kind === 'linear-gradient'; + +export const isRadialGradientPaint = (value: unknown): value is RadialGradientPaint => + !!value && typeof value === 'object' && 'kind' in value && value.kind === 'radial-gradient'; + +export const isGradientPaint = ( + value: unknown +): value is LinearGradientPaint | RadialGradientPaint => + isLinearGradientPaint(value) || isRadialGradientPaint(value); + +export const isShapePaint = (value: unknown): value is ShapePaint => + isSolidPaint(value) || isLinearGradientPaint(value) || isRadialGradientPaint(value); + +export const createGradientStop = (offset: number, color: ShapeColorInput): GradientStop => + normalizeGradientStop({ offset, color }); + +export const createSolidPaint = (color: ShapeColorInput): SolidPaint => + Object.freeze({ + kind: 'solid', + color: createColor(color), + }); + +export const createLinearGradientPaint = (input: LinearGradientPaintInput): LinearGradientPaint => + Object.freeze({ + kind: 'linear-gradient', + start: toPoint(input.start, 'linearGradient.start'), + end: toPoint(input.end, 'linearGradient.end'), + stops: normalizeStops(input.stops), + spread: input.spread ?? 'pad', + colorSpace: normalizeColorSpace(input.colorSpace), + units: input.units ?? 'shape-bounds', + }); + +export const createRadialGradientPaint = (input: RadialGradientPaintInput): RadialGradientPaint => + Object.freeze({ + kind: 'radial-gradient', + center: toPoint(input.center, 'radialGradient.center'), + radius: assertPositiveNumber(input.radius, 'radialGradient.radius'), + stops: normalizeStops(input.stops), + spread: input.spread ?? 'pad', + colorSpace: normalizeColorSpace(input.colorSpace), + units: input.units ?? 'shape-bounds', + }); + +export const createPaint = (input: ShapePaintInput): ShapePaint => { + if (isSolidPaint(input)) { + return createSolidPaint(input.color); + } + + if (isLinearGradientPaint(input)) { + return createLinearGradientPaint(input); + } + + if (isRadialGradientPaint(input)) { + return createRadialGradientPaint(input); + } + + if (isColorLikeObject(input)) { + return createSolidPaint(input); + } + + if (input && typeof input === 'object' && 'start' in input && 'end' in input) { + return createLinearGradientPaint(input as LinearGradientPaintInput); + } + + if (input && typeof input === 'object' && 'center' in input && 'radius' in input) { + return createRadialGradientPaint(input as RadialGradientPaintInput); + } + + throw new PaintValidationError('Unsupported paint input'); +}; + +export const createStroke = (input: ShapeStrokeInput): ShapeStroke => + Object.freeze({ + paint: createPaint(input.paint), + width: assertPositiveNumber(input.width, 'stroke.width'), + alignment: input.alignment ?? 'center', + }); + +export const createGradientLookupTable = ( + paint: LinearGradientPaint | RadialGradientPaint, + size: number = DEFAULT_GRADIENT_LOOKUP_SIZE +): Float32Array => { + const cacheKey = createGradientCacheKey(paint, size); + let cacheBySize = gradientLookupCache.get(paint); + if (!cacheBySize) { + cacheBySize = new Map(); + gradientLookupCache.set(paint, cacheBySize); + } + + const cached = cacheBySize.get(cacheKey); + if (cached) { + return cached; + } + + const table = new Float32Array(cacheKey * 4); + + for (let index = 0; index < cacheKey; index++) { + const time = cacheKey === 1 ? 0 : index / (cacheKey - 1); + const sampled = sampleStops(paint.stops, time, paint.colorSpace); + const offset = index * 4; + table[offset] = sampled.r; + table[offset + 1] = sampled.g; + table[offset + 2] = sampled.b; + table[offset + 3] = sampled.a; + } + + cacheBySize.set(cacheKey, table); + return table; +}; + +const sampleLookupTable = ( + table: Float32Array, + sample: number +): ResolvedColor => { + const position = clamp01(sample) * (table.length / 4 - 1); + const leftIndex = Math.floor(position); + const rightIndex = Math.min(leftIndex + 1, table.length / 4 - 1); + const factor = position - leftIndex; + const leftOffset = leftIndex * 4; + const rightOffset = rightIndex * 4; + + return { + r: table[leftOffset] + (table[rightOffset] - table[leftOffset]) * factor, + g: table[leftOffset + 1] + (table[rightOffset + 1] - table[leftOffset + 1]) * factor, + b: table[leftOffset + 2] + (table[rightOffset + 2] - table[leftOffset + 2]) * factor, + a: table[leftOffset + 3] + (table[rightOffset + 3] - table[leftOffset + 3]) * factor, + }; +}; + +export const samplePaint = ( + paint: ShapePaint, + point: ShapePointInput, + bounds?: ShapeBounds, + lookupTableSize: number = DEFAULT_GRADIENT_LOOKUP_SIZE +): ResolvedColor => { + if (paint.kind === 'solid') { + return { + r: paint.color.r, + g: paint.color.g, + b: paint.color.b, + a: paint.color.a, + }; + } + + const resolvedPoint = toPoint(point, 'paint sample point'); + + if (!bounds && paint.units === 'shape-bounds') { + throw new PaintValidationError('Bounds are required when sampling a bounds-relative paint'); + } + + if (paint.kind === 'linear-gradient') { + const start = bounds ? resolveBoundsPoint(paint.start, bounds, paint.units) : paint.start; + const end = bounds ? resolveBoundsPoint(paint.end, bounds, paint.units) : paint.end; + const dirX = end.x - start.x; + const dirY = end.y - start.y; + const lengthSquared = dirX * dirX + dirY * dirY; + const rawSample = + lengthSquared <= EPSILON + ? 0 + : ((resolvedPoint.x - start.x) * dirX + (resolvedPoint.y - start.y) * dirY) / + lengthSquared; + const sample = applyGradientSpread(rawSample, paint.spread); + const table = createGradientLookupTable(paint, lookupTableSize); + return sampleLookupTable(table, sample); + } + + const center = bounds ? resolveBoundsPoint(paint.center, bounds, paint.units) : paint.center; + const radius = bounds + ? resolveBoundsRadius(paint.radius, bounds, paint.units) + : paint.radius; + const safeRadius = Math.max(radius, EPSILON); + const rawSample = + Math.sqrt( + (resolvedPoint.x - center.x) * (resolvedPoint.x - center.x) + + (resolvedPoint.y - center.y) * (resolvedPoint.y - center.y) + ) / safeRadius; + const sample = applyGradientSpread(rawSample, paint.spread); + const table = createGradientLookupTable(paint, lookupTableSize); + return sampleLookupTable(table, sample); +}; + +export const modulatePaintAlpha = (color: Readonly, opacity: number): ResolvedColor => ({ + r: color.r, + g: color.g, + b: color.b, + a: clamp01((color.a ?? 1) * clamp01(opacity)), +}); + +export const createPaintFingerprint = (paint: ShapePaint): string => { + if (paint.kind === 'solid') { + return `solid:${normalizeNumberKey(paint.color.r)}:${normalizeNumberKey(paint.color.g)}:${normalizeNumberKey(paint.color.b)}:${normalizeNumberKey(paint.color.a)}`; + } + + if (paint.kind === 'linear-gradient') { + const stops = paint.stops + .map( + (stop) => + `${normalizeNumberKey(stop.offset)}:${normalizeNumberKey(stop.color.r)}:${normalizeNumberKey(stop.color.g)}:${normalizeNumberKey(stop.color.b)}:${normalizeNumberKey(stop.color.a)}` + ) + .join('|'); + return `linear:${formatPointKey(paint.start)}:${formatPointKey(paint.end)}:${paint.units}:${paint.spread}:${paint.colorSpace}:${hashString(stops)}`; + } + + const stops = paint.stops + .map( + (stop) => + `${normalizeNumberKey(stop.offset)}:${normalizeNumberKey(stop.color.r)}:${normalizeNumberKey(stop.color.g)}:${normalizeNumberKey(stop.color.b)}:${normalizeNumberKey(stop.color.a)}` + ) + .join('|'); + return `radial:${formatPointKey(paint.center)}:${normalizeNumberKey(paint.radius)}:${paint.units}:${paint.spread}:${paint.colorSpace}:${hashString(stops)}`; +}; diff --git a/web/packages/shapes-2d/src/queries.ts b/web/packages/shapes-2d/src/queries.ts new file mode 100644 index 00000000..93e938e8 --- /dev/null +++ b/web/packages/shapes-2d/src/queries.ts @@ -0,0 +1,96 @@ +import type { IVec2Like } from '@axrone/numeric'; +import { createBounds, toPoint } from './common'; +import { modulatePaintAlpha, samplePaint } from './paint'; +import { + containsGeometryPoint, + containsStrokePoint, + getGeometryBounds, + getShapeArea as getShapeAreaInternal, + getShapeBounds as getShapeBoundsInternal, + getShapeCentroid as getShapeCentroidInternal, + getShapePerimeter as getShapePerimeterInternal, +} from './geometry'; +import type { + Shape2D, + ShapeApproximationOptions, + ShapeBounds, + ResolvedColor, + ShapeHitTarget, + ShapePointInput, +} from './types'; + +export interface ShapeBoundsQueryOptions extends ShapeApproximationOptions { + readonly includeStroke?: boolean; +} + +export const getShapeBounds = ( + shape: Shape2D, + options: ShapeBoundsQueryOptions = {} +): ShapeBounds => + options.includeStroke === false + ? getGeometryBounds(shape) + : getShapeBoundsInternal(shape, options); + +export const getShapeArea = (shape: Shape2D): number => getShapeAreaInternal(shape); + +export const getShapePerimeter = (shape: Shape2D): number => getShapePerimeterInternal(shape); + +export const getShapeCentroid = (shape: Shape2D): Readonly => + getShapeCentroidInternal(shape); + +export const containsPoint = (shape: Shape2D, point: ShapePointInput): boolean => + containsGeometryPoint(shape, toPoint(point, 'shape point')); + +export const hitTestShape = ( + shape: Shape2D, + point: ShapePointInput, + options: ShapeApproximationOptions = {} +): ShapeHitTarget => { + if (!shape.visible || shape.opacity <= 0) { + return 'none'; + } + + const normalizedPoint = toPoint(point, 'shape point'); + + if (shape.stroke && containsStrokePoint(shape, normalizedPoint, options)) { + return 'stroke'; + } + + if (shape.fill && containsGeometryPoint(shape, normalizedPoint)) { + return 'fill'; + } + + return 'none'; +}; + +export const sampleShapePaint = ( + shape: Shape2D, + target: Exclude, + point: ShapePointInput, + options: ShapeApproximationOptions = {} +): ResolvedColor | null => { + if (!shape.visible || shape.opacity <= 0) { + return null; + } + + const normalizedPoint = toPoint(point, 'shape paint point'); + if (target === 'fill') { + if (!shape.fill || !containsGeometryPoint(shape, normalizedPoint)) { + return null; + } + + return modulatePaintAlpha( + samplePaint(shape.fill, normalizedPoint, getGeometryBounds(shape)), + shape.opacity + ); + } + + if (!shape.stroke || !containsStrokePoint(shape, normalizedPoint, options)) { + return null; + } + + return modulatePaintAlpha( + samplePaint(shape.stroke.paint, normalizedPoint, getShapeBoundsInternal(shape, options)), + shape.opacity + ); +}; diff --git a/web/packages/shapes-2d/src/registry.ts b/web/packages/shapes-2d/src/registry.ts new file mode 100644 index 00000000..e15fc808 --- /dev/null +++ b/web/packages/shapes-2d/src/registry.ts @@ -0,0 +1,187 @@ +import { + DEFAULT_MAX_CURVE_SEGMENTS, + DEFAULT_MIN_CURVE_SEGMENTS, + DEFAULT_REGISTRY_MAX_COMPILED, + DEFAULT_REGISTRY_MAX_SHAPES, + DEFAULT_CURVE_TOLERANCE, +} from './common'; +import { ShapeRegistryError, SHAPES_2D_ERROR_CODE } from './errors'; +import { compileShape } from './mesh'; +import { serializeShape, createShapeFingerprint } from './serialization'; +import type { + CompiledShape2D, + Shape2D, + ShapeCompileOptions, + ShapeFingerprint, + ShapeId, + ShapeRegistryOptions, + ShapeRegistryStats, +} from './types'; + +const createCompileCacheKey = ( + fingerprint: string, + options: ShapeCompileOptions +): string => + `${fingerprint}|${options.curveTolerance ?? DEFAULT_CURVE_TOLERANCE}|${options.minCurveSegments ?? DEFAULT_MIN_CURVE_SEGMENTS}|${options.maxCurveSegments ?? DEFAULT_MAX_CURVE_SEGMENTS}|${options.includeFillMesh === false ? 0 : 1}|${options.includeStrokeMesh === false ? 0 : 1}`; + +export class ShapeRegistry implements Disposable { + private readonly _maxShapes: number; + private readonly _maxCompiledEntries: number; + private readonly _defaultCompileOptions: ShapeCompileOptions; + private readonly _shapesById = new Map(); + private readonly _fingerprintsById = new Map(); + private readonly _idsByFingerprint = new Map(); + private readonly _compiledByKey = new Map(); + private _disposed = false; + private _nextId = 1; + + constructor(options: ShapeRegistryOptions = {}) { + this._maxShapes = Math.max(1, Math.floor(options.maxShapes ?? DEFAULT_REGISTRY_MAX_SHAPES)); + this._maxCompiledEntries = Math.max( + 1, + Math.floor(options.maxCompiledEntries ?? DEFAULT_REGISTRY_MAX_COMPILED) + ); + this._defaultCompileOptions = { + curveTolerance: options.curveTolerance ?? DEFAULT_CURVE_TOLERANCE, + minCurveSegments: options.minCurveSegments ?? DEFAULT_MIN_CURVE_SEGMENTS, + maxCurveSegments: options.maxCurveSegments ?? DEFAULT_MAX_CURVE_SEGMENTS, + }; + } + + get stats(): ShapeRegistryStats { + return { + shapeCount: this._shapesById.size, + fingerprintCount: this._idsByFingerprint.size, + compiledCount: this._compiledByKey.size, + disposed: this._disposed, + }; + } + + has(id: ShapeId): boolean { + return this._shapesById.has(id); + } + + get(id: ShapeId): Shape2D | null { + this.ensureActive(); + return this._shapesById.get(id) ?? null; + } + + register(shape: Shape2D): ShapeId { + this.ensureActive(); + const fingerprint = createShapeFingerprint(shape); + const existingId = this._idsByFingerprint.get(fingerprint); + if (existingId) { + return existingId; + } + + if (this._shapesById.size >= this._maxShapes) { + throw new ShapeRegistryError( + SHAPES_2D_ERROR_CODE.CAPACITY_EXCEEDED, + `Shape registry capacity of ${this._maxShapes} entries exceeded` + ); + } + + const id = `shape_${this._nextId++}` as ShapeId; + this._shapesById.set(id, shape); + this._fingerprintsById.set(id, fingerprint); + this._idsByFingerprint.set(fingerprint, id); + return id; + } + + unregister(id: ShapeId): boolean { + this.ensureActive(); + const fingerprint = this._fingerprintsById.get(id); + if (!fingerprint) { + return false; + } + + this._fingerprintsById.delete(id); + this._idsByFingerprint.delete(fingerprint); + this._shapesById.delete(id); + + for (const key of this._compiledByKey.keys()) { + if (key.startsWith(`${fingerprint}|`)) { + this._compiledByKey.delete(key); + } + } + + return true; + } + + compile(target: ShapeId | Shape2D, options: ShapeCompileOptions = {}): CompiledShape2D { + this.ensureActive(); + const shape = typeof target === 'string' ? this.resolveShape(target) : target; + const fingerprint = createShapeFingerprint(shape); + const resolvedOptions = { + ...this._defaultCompileOptions, + ...options, + }; + const cacheKey = createCompileCacheKey(fingerprint, resolvedOptions); + const cached = this._compiledByKey.get(cacheKey); + if (cached) { + this._compiledByKey.delete(cacheKey); + this._compiledByKey.set(cacheKey, cached); + return cached; + } + + const compiled = compileShape(shape, resolvedOptions); + this._compiledByKey.set(cacheKey, compiled); + this.trimCompiledCache(); + return compiled; + } + + serialize(target: ShapeId | Shape2D) { + this.ensureActive(); + const shape = typeof target === 'string' ? this.resolveShape(target) : target; + return serializeShape(shape); + } + + clear(): void { + this._shapesById.clear(); + this._fingerprintsById.clear(); + this._idsByFingerprint.clear(); + this._compiledByKey.clear(); + } + + dispose(): void { + if (this._disposed) { + return; + } + this.clear(); + this._disposed = true; + } + + [Symbol.dispose](): void { + this.dispose(); + } + + private resolveShape(id: ShapeId): Shape2D { + const shape = this._shapesById.get(id); + if (!shape) { + throw new ShapeRegistryError( + SHAPES_2D_ERROR_CODE.SHAPE_NOT_FOUND, + `Shape ${id} is not registered` + ); + } + return shape; + } + + private trimCompiledCache(): void { + while (this._compiledByKey.size > this._maxCompiledEntries) { + const oldestKey = this._compiledByKey.keys().next().value as string | undefined; + if (!oldestKey) { + return; + } + this._compiledByKey.delete(oldestKey); + } + } + + private ensureActive(): void { + if (this._disposed) { + throw new ShapeRegistryError( + SHAPES_2D_ERROR_CODE.REGISTRY_DISPOSED, + 'Shape registry has been disposed' + ); + } + } +} diff --git a/web/packages/shapes-2d/src/serialization.ts b/web/packages/shapes-2d/src/serialization.ts new file mode 100644 index 00000000..355b6e6e --- /dev/null +++ b/web/packages/shapes-2d/src/serialization.ts @@ -0,0 +1,331 @@ +import { hashString } from './common'; +import { SerializationError } from './errors'; +import { createPaint, createStroke } from './paint'; +import { + createCircleShape, + createEllipseShape, + createLineShape, + createRectangleShape, + createTriangleShape, +} from './shape'; +import type { + GradientStopInput, + ResolvedColor, + Shape2D, + ShapeFingerprint, + ShapePaint, + ShapeStroke, + ShapeStrokeAlignment, +} from './types'; + +export interface SerializedSolidPaint { + readonly type: 'paint/solid'; + readonly color: readonly [number, number, number, number]; +} + +export interface SerializedLinearGradientPaint { + readonly type: 'paint/linear-gradient'; + readonly start: readonly [number, number]; + readonly end: readonly [number, number]; + readonly stops: readonly { + readonly offset: number; + readonly color: readonly [number, number, number, number]; + }[]; + readonly spread: 'pad' | 'repeat' | 'reflect'; + readonly colorSpace: 'srgb' | 'linear-srgb' | 'hsl' | 'lab'; + readonly units: 'local' | 'shape-bounds'; +} + +export interface SerializedRadialGradientPaint { + readonly type: 'paint/radial-gradient'; + readonly center: readonly [number, number]; + readonly radius: number; + readonly stops: readonly { + readonly offset: number; + readonly color: readonly [number, number, number, number]; + }[]; + readonly spread: 'pad' | 'repeat' | 'reflect'; + readonly colorSpace: 'srgb' | 'linear-srgb' | 'hsl' | 'lab'; + readonly units: 'local' | 'shape-bounds'; +} + +export type SerializedPaint = + | SerializedSolidPaint + | SerializedLinearGradientPaint + | SerializedRadialGradientPaint; + +export interface SerializedStroke { + readonly width: number; + readonly alignment: ShapeStrokeAlignment; + readonly paint: SerializedPaint; +} + +interface SerializedAppearance { + readonly fill: SerializedPaint | null; + readonly stroke: SerializedStroke | null; + readonly opacity: number; + readonly visible: boolean; + readonly name?: string; +} + +export interface SerializedRectangleShape extends SerializedAppearance { + readonly type: 'shape/rectangle'; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface SerializedCircleShape extends SerializedAppearance { + readonly type: 'shape/circle'; + readonly cx: number; + readonly cy: number; + readonly radius: number; +} + +export interface SerializedEllipseShape extends SerializedAppearance { + readonly type: 'shape/ellipse'; + readonly cx: number; + readonly cy: number; + readonly radiusX: number; + readonly radiusY: number; +} + +export interface SerializedTriangleShape extends SerializedAppearance { + readonly type: 'shape/triangle'; + readonly a: readonly [number, number]; + readonly b: readonly [number, number]; + readonly c: readonly [number, number]; +} + +export interface SerializedLineShape extends SerializedAppearance { + readonly type: 'shape/line'; + readonly start: readonly [number, number]; + readonly end: readonly [number, number]; +} + +export type SerializedShape = + | SerializedRectangleShape + | SerializedCircleShape + | SerializedEllipseShape + | SerializedTriangleShape + | SerializedLineShape; + +const serializeColor = (color: ResolvedColor): readonly [number, number, number, number] => [ + color.r, + color.g, + color.b, + color.a, +] as const; + +const serializeStops = ( + stops: readonly { + readonly offset: number; + readonly color: ResolvedColor; + }[] +): readonly { + readonly offset: number; + readonly color: readonly [number, number, number, number]; +}[] => + stops.map((stop) => ({ + offset: stop.offset, + color: serializeColor(stop.color), + })) as readonly { + readonly offset: number; + readonly color: readonly [number, number, number, number]; + }[]; + +export const serializePaint = (paint: ShapePaint): SerializedPaint => { + switch (paint.kind) { + case 'solid': + return { + type: 'paint/solid', + color: serializeColor(paint.color), + }; + case 'linear-gradient': + return { + type: 'paint/linear-gradient', + start: [paint.start.x, paint.start.y] as const, + end: [paint.end.x, paint.end.y] as const, + stops: serializeStops(paint.stops), + spread: paint.spread, + colorSpace: paint.colorSpace, + units: paint.units, + }; + case 'radial-gradient': + return { + type: 'paint/radial-gradient', + center: [paint.center.x, paint.center.y] as const, + radius: paint.radius, + stops: serializeStops(paint.stops), + spread: paint.spread, + colorSpace: paint.colorSpace, + units: paint.units, + }; + } +}; + +export const serializeStroke = (stroke: ShapeStroke): SerializedStroke => ({ + width: stroke.width, + alignment: stroke.alignment, + paint: serializePaint(stroke.paint), +}); + +export const serializeShape = (shape: Shape2D): SerializedShape => { + const appearance = { + fill: shape.fill ? serializePaint(shape.fill) : null, + stroke: shape.stroke ? serializeStroke(shape.stroke) : null, + opacity: shape.opacity, + visible: shape.visible, + ...(shape.name ? { name: shape.name } : {}), + }; + + switch (shape.kind) { + case 'rectangle': + return { + type: 'shape/rectangle', + x: shape.x, + y: shape.y, + width: shape.width, + height: shape.height, + ...appearance, + }; + case 'circle': + return { + type: 'shape/circle', + cx: shape.cx, + cy: shape.cy, + radius: shape.radius, + ...appearance, + }; + case 'ellipse': + return { + type: 'shape/ellipse', + cx: shape.cx, + cy: shape.cy, + radiusX: shape.radiusX, + radiusY: shape.radiusY, + ...appearance, + }; + case 'triangle': + return { + type: 'shape/triangle', + a: [shape.a.x, shape.a.y] as const, + b: [shape.b.x, shape.b.y] as const, + c: [shape.c.x, shape.c.y] as const, + ...appearance, + }; + case 'line': + return { + type: 'shape/line', + start: [shape.start.x, shape.start.y] as const, + end: [shape.end.x, shape.end.y] as const, + ...appearance, + }; + } +}; + +const asStops = ( + stops: readonly { + readonly offset: number; + readonly color: readonly [number, number, number, number]; + }[] +): readonly GradientStopInput[] => + stops.map((stop) => ({ + offset: stop.offset, + color: stop.color, + })); + +export const deserializePaint = (paint: SerializedPaint): ShapePaint => { + switch (paint.type) { + case 'paint/solid': + return createPaint(paint.color); + case 'paint/linear-gradient': + return createPaint({ + start: paint.start, + end: paint.end, + stops: asStops(paint.stops), + spread: paint.spread, + colorSpace: paint.colorSpace, + units: paint.units, + }); + case 'paint/radial-gradient': + return createPaint({ + center: paint.center, + radius: paint.radius, + stops: asStops(paint.stops), + spread: paint.spread, + colorSpace: paint.colorSpace, + units: paint.units, + }); + default: + throw new SerializationError('Unsupported serialized paint type'); + } +}; + +export const deserializeStroke = (stroke: SerializedStroke): ShapeStroke => + createStroke({ + width: stroke.width, + alignment: stroke.alignment, + paint: deserializePaint(stroke.paint), + }); + +export const deserializeShape = (shape: SerializedShape): Shape2D => { + const appearance = { + fill: shape.fill ? deserializePaint(shape.fill) : null, + stroke: shape.stroke ? deserializeStroke(shape.stroke) : null, + opacity: shape.opacity, + visible: shape.visible, + name: shape.name, + }; + + switch (shape.type) { + case 'shape/rectangle': + return createRectangleShape({ + x: shape.x, + y: shape.y, + width: shape.width, + height: shape.height, + ...appearance, + }); + case 'shape/circle': + return createCircleShape({ + cx: shape.cx, + cy: shape.cy, + radius: shape.radius, + ...appearance, + }); + case 'shape/ellipse': + return createEllipseShape({ + cx: shape.cx, + cy: shape.cy, + radiusX: shape.radiusX, + radiusY: shape.radiusY, + ...appearance, + }); + case 'shape/triangle': + return createTriangleShape({ + a: shape.a, + b: shape.b, + c: shape.c, + ...appearance, + }); + case 'shape/line': + return createLineShape({ + start: shape.start, + end: shape.end, + ...appearance, + }); + default: + throw new SerializationError('Unsupported serialized shape type'); + } +}; + +export const stringifyPaint = (paint: ShapePaint): string => JSON.stringify(serializePaint(paint)); + +export const stringifyShape = (shape: Shape2D): string => JSON.stringify(serializeShape(shape)); + +export const createShapeFingerprint = ( + shape: TShape +): ShapeFingerprint => + `${shape.kind}:${hashString(stringifyShape(shape))}` as ShapeFingerprint; diff --git a/web/packages/shapes-2d/src/shape.ts b/web/packages/shapes-2d/src/shape.ts new file mode 100644 index 00000000..b00437f1 --- /dev/null +++ b/web/packages/shapes-2d/src/shape.ts @@ -0,0 +1,165 @@ +import { + EPSILON, + assertFiniteNumber, + assertPositiveNumber, + clamp01, + distanceSquared, + toPoint, +} from './common'; +import { ShapeValidationError } from './errors'; +import { createPaint, createStroke } from './paint'; +import type { + CircleShape, + CircleShapeInput, + EllipseShape, + EllipseShapeInput, + LineShape, + LineShapeInput, + RectangleShape, + RectangleShapeInput, + Shape2D, + ShapeAppearance, + ShapeAppearanceInput, + ShapeKind, + TriangleShape, + TriangleShapeInput, +} from './types'; + +const normalizeAppearance = ( + input: ShapeAppearanceInput = {}, + allowFill: boolean = true +): ShapeAppearance => { + const fill = input.fill === undefined || input.fill === null ? null : createPaint(input.fill); + if (!allowFill && fill) { + throw new ShapeValidationError('Line shapes do not support fill paint'); + } + + const stroke = + input.stroke === undefined || input.stroke === null ? null : createStroke(input.stroke); + const opacity = clamp01(input.opacity ?? 1); + const visible = input.visible ?? true; + const name = input.name?.trim() || undefined; + + return { + fill, + stroke, + opacity, + visible, + name, + }; +}; + +export const createRectangleShape = (input: RectangleShapeInput): RectangleShape => { + const width = assertPositiveNumber(input.width, 'rectangle.width'); + const height = assertPositiveNumber(input.height, 'rectangle.height'); + + return Object.freeze({ + kind: 'rectangle', + x: assertFiniteNumber(input.x, 'rectangle.x'), + y: assertFiniteNumber(input.y, 'rectangle.y'), + width, + height, + ...normalizeAppearance(input), + }); +}; + +export const createCircleShape = (input: CircleShapeInput): CircleShape => + Object.freeze({ + kind: 'circle', + cx: assertFiniteNumber(input.cx, 'circle.cx'), + cy: assertFiniteNumber(input.cy, 'circle.cy'), + radius: assertPositiveNumber(input.radius, 'circle.radius'), + ...normalizeAppearance(input), + }); + +export const createEllipseShape = (input: EllipseShapeInput): EllipseShape => + Object.freeze({ + kind: 'ellipse', + cx: assertFiniteNumber(input.cx, 'ellipse.cx'), + cy: assertFiniteNumber(input.cy, 'ellipse.cy'), + radiusX: assertPositiveNumber(input.radiusX, 'ellipse.radiusX'), + radiusY: assertPositiveNumber(input.radiusY, 'ellipse.radiusY'), + ...normalizeAppearance(input), + }); + +export const createTriangleShape = (input: TriangleShapeInput): TriangleShape => { + const a = toPoint(input.a, 'triangle.a'); + const b = toPoint(input.b, 'triangle.b'); + const c = toPoint(input.c, 'triangle.c'); + const doubledArea = + a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y); + + if (Math.abs(doubledArea) <= EPSILON) { + throw new ShapeValidationError('Triangle points must not be collinear'); + } + + return Object.freeze({ + kind: 'triangle', + a, + b, + c, + ...normalizeAppearance(input), + }); +}; + +export const createLineShape = (input: LineShapeInput): LineShape => { + const start = toPoint(input.start, 'line.start'); + const end = toPoint(input.end, 'line.end'); + + if (distanceSquared(start.x, start.y, end.x, end.y) <= EPSILON && !input.stroke) { + throw new ShapeValidationError('Zero-length lines require a stroke'); + } + + return Object.freeze({ + kind: 'line', + start, + end, + ...normalizeAppearance(input, false), + }); +}; + +export const isRectangleShape = (value: unknown): value is RectangleShape => + !!value && typeof value === 'object' && 'kind' in value && value.kind === 'rectangle'; + +export const isCircleShape = (value: unknown): value is CircleShape => + !!value && typeof value === 'object' && 'kind' in value && value.kind === 'circle'; + +export const isEllipseShape = (value: unknown): value is EllipseShape => + !!value && typeof value === 'object' && 'kind' in value && value.kind === 'ellipse'; + +export const isTriangleShape = (value: unknown): value is TriangleShape => + !!value && typeof value === 'object' && 'kind' in value && value.kind === 'triangle'; + +export const isLineShape = (value: unknown): value is LineShape => + !!value && typeof value === 'object' && 'kind' in value && value.kind === 'line'; + +export const isShape2D = (value: unknown): value is Shape2D => + isRectangleShape(value) || + isCircleShape(value) || + isEllipseShape(value) || + isTriangleShape(value) || + isLineShape(value); + +export const matchShape = ( + shape: Shape2D, + matcher: { + readonly rectangle: (shape: RectangleShape) => TResult; + readonly circle: (shape: CircleShape) => TResult; + readonly ellipse: (shape: EllipseShape) => TResult; + readonly triangle: (shape: TriangleShape) => TResult; + readonly line: (shape: LineShape) => TResult; + } +): TResult => { + switch (shape.kind) { + case 'rectangle': + return matcher.rectangle(shape); + case 'circle': + return matcher.circle(shape); + case 'ellipse': + return matcher.ellipse(shape); + case 'triangle': + return matcher.triangle(shape); + case 'line': + return matcher.line(shape); + } +}; diff --git a/web/packages/shapes-2d/src/types.ts b/web/packages/shapes-2d/src/types.ts new file mode 100644 index 00000000..7e641120 --- /dev/null +++ b/web/packages/shapes-2d/src/types.ts @@ -0,0 +1,247 @@ +import type { IColorLike, IVec2Like } from '@axrone/numeric'; + +export type ShapeId = `shape_${number}`; + +export type ShapeVec2Tuple = readonly [number, number]; +export type ShapePointInput = Readonly | ShapeVec2Tuple; + +export type ShapeColorTuple = + | readonly [number, number, number] + | readonly [number, number, number, number]; + +export type ShapeColorInput = string | Readonly | ShapeColorTuple; +export type ResolvedColor = Readonly>; + +export interface GradientStop { + readonly offset: number; + readonly color: ResolvedColor; +} + +export interface GradientStopInput { + readonly offset: number; + readonly color: ShapeColorInput; +} + +export type GradientSpread = 'pad' | 'repeat' | 'reflect'; +export type GradientColorSpace = 'srgb' | 'linear-srgb' | 'hsl' | 'lab'; +export type GradientUnits = 'local' | 'shape-bounds'; + +export interface SolidPaint { + readonly kind: 'solid'; + readonly color: ResolvedColor; +} + +export interface LinearGradientPaint { + readonly kind: 'linear-gradient'; + readonly start: Readonly; + readonly end: Readonly; + readonly stops: readonly GradientStop[]; + readonly spread: GradientSpread; + readonly colorSpace: GradientColorSpace; + readonly units: GradientUnits; +} + +export interface RadialGradientPaint { + readonly kind: 'radial-gradient'; + readonly center: Readonly; + readonly radius: number; + readonly stops: readonly GradientStop[]; + readonly spread: GradientSpread; + readonly colorSpace: GradientColorSpace; + readonly units: GradientUnits; +} + +export type ShapePaint = SolidPaint | LinearGradientPaint | RadialGradientPaint; + +export interface LinearGradientPaintInput { + readonly start: ShapePointInput; + readonly end: ShapePointInput; + readonly stops: readonly GradientStopInput[]; + readonly spread?: GradientSpread; + readonly colorSpace?: GradientColorSpace; + readonly units?: GradientUnits; +} + +export interface RadialGradientPaintInput { + readonly center: ShapePointInput; + readonly radius: number; + readonly stops: readonly GradientStopInput[]; + readonly spread?: GradientSpread; + readonly colorSpace?: GradientColorSpace; + readonly units?: GradientUnits; +} + +export type ShapePaintInput = + | ShapePaint + | ShapeColorInput + | LinearGradientPaintInput + | RadialGradientPaintInput; + +export type ShapeStrokeAlignment = 'center' | 'inside' | 'outside'; + +export interface ShapeStroke { + readonly paint: ShapePaint; + readonly width: number; + readonly alignment: ShapeStrokeAlignment; +} + +export interface ShapeStrokeInput { + readonly paint: ShapePaintInput; + readonly width: number; + readonly alignment?: ShapeStrokeAlignment; +} + +export interface ShapeAppearance { + readonly fill: ShapePaint | null; + readonly stroke: ShapeStroke | null; + readonly opacity: number; + readonly visible: boolean; + readonly name?: string; +} + +export interface ShapeAppearanceInput { + readonly fill?: ShapePaintInput | null; + readonly stroke?: ShapeStrokeInput | null; + readonly opacity?: number; + readonly visible?: boolean; + readonly name?: string | null; +} + +interface BaseShape extends ShapeAppearance { + readonly kind: K; +} + +export interface RectangleShape extends BaseShape<'rectangle'> { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface CircleShape extends BaseShape<'circle'> { + readonly cx: number; + readonly cy: number; + readonly radius: number; +} + +export interface EllipseShape extends BaseShape<'ellipse'> { + readonly cx: number; + readonly cy: number; + readonly radiusX: number; + readonly radiusY: number; +} + +export interface TriangleShape extends BaseShape<'triangle'> { + readonly a: Readonly; + readonly b: Readonly; + readonly c: Readonly; +} + +export interface LineShape extends BaseShape<'line'> { + readonly start: Readonly; + readonly end: Readonly; +} + +export interface RectangleShapeInput extends ShapeAppearanceInput { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface CircleShapeInput extends ShapeAppearanceInput { + readonly cx: number; + readonly cy: number; + readonly radius: number; +} + +export interface EllipseShapeInput extends ShapeAppearanceInput { + readonly cx: number; + readonly cy: number; + readonly radiusX: number; + readonly radiusY: number; +} + +export interface TriangleShapeInput extends ShapeAppearanceInput { + readonly a: ShapePointInput; + readonly b: ShapePointInput; + readonly c: ShapePointInput; +} + +export interface LineShapeInput extends ShapeAppearanceInput { + readonly start: ShapePointInput; + readonly end: ShapePointInput; +} + +export interface ShapeKindMap { + readonly rectangle: RectangleShape; + readonly circle: CircleShape; + readonly ellipse: EllipseShape; + readonly triangle: TriangleShape; + readonly line: LineShape; +} + +export type ShapeKind = keyof ShapeKindMap; +export type Shape2D = ShapeKindMap[K]; +export type ShapePaintKind = ShapePaint['kind']; +export type ShapeFingerprint = `${K}:${string}`; +export type SerializedShapeType = `shape/${K}`; +export type SerializedPaintType = `paint/${K}`; +export type ShapeHitTarget = 'none' | 'fill' | 'stroke'; + +export interface ShapeBounds { + readonly minX: number; + readonly minY: number; + readonly maxX: number; + readonly maxY: number; + readonly width: number; + readonly height: number; + readonly centerX: number; + readonly centerY: number; +} + +export interface ShapeApproximationOptions { + readonly curveTolerance?: number; + readonly minCurveSegments?: number; + readonly maxCurveSegments?: number; +} + +export interface ShapeCompileOptions extends ShapeApproximationOptions { + readonly includeFillMesh?: boolean; + readonly includeStrokeMesh?: boolean; +} + +export interface ShapeMesh2D { + readonly positions: Float32Array; + readonly indices: Uint16Array | Uint32Array; + readonly vertexCount: number; + readonly indexCount: number; + readonly bounds: ShapeBounds; +} + +export interface CompiledShape2D { + readonly shape: TShape; + readonly fingerprint: ShapeFingerprint; + readonly geometryBounds: ShapeBounds; + readonly bounds: ShapeBounds; + readonly area: number; + readonly perimeter: number; + readonly contour: Float32Array; + readonly fillMesh: ShapeMesh2D | null; + readonly strokeMesh: ShapeMesh2D | null; +} + +export interface ShapeRegistryOptions { + readonly maxShapes?: number; + readonly maxCompiledEntries?: number; + readonly curveTolerance?: number; + readonly minCurveSegments?: number; + readonly maxCurveSegments?: number; +} + +export interface ShapeRegistryStats { + readonly shapeCount: number; + readonly fingerprintCount: number; + readonly compiledCount: number; + readonly disposed: boolean; +} diff --git a/web/packages/shapes-2d/tsconfig.build.json b/web/packages/shapes-2d/tsconfig.build.json new file mode 100644 index 00000000..42990e9c --- /dev/null +++ b/web/packages/shapes-2d/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": false, + "types": ["node"], + "declaration": true, + "declarationMap": false, + "sourceMap": true + }, + "include": ["src/**/*.ts", "../../types/**/*.d.ts"], + "exclude": [ + "**/__tests__/**", + "**/*.test.ts", + "**/*.spec.ts", + "**/*.browser.test.ts", + "**/*.browser.spec.ts", + "dist" + ] +} diff --git a/web/packages/tween/src/runtime-utils.ts b/web/packages/tween/src/runtime-utils.ts index fea9f59d..2b29726b 100644 --- a/web/packages/tween/src/runtime-utils.ts +++ b/web/packages/tween/src/runtime-utils.ts @@ -1,10 +1,7 @@ -import type { TypedArray } from '@axrone/utility'; +import type { NumericTypedArray, NumericTypedArrayConstructor } from '@axrone/utility'; -export type TweenTypedArray = Exclude; - -export type TweenTypedArrayConstructor = new ( - source: number | ArrayLike -) => TweenTypedArray; +export type TweenTypedArray = NumericTypedArray; +export type TweenTypedArrayConstructor = NumericTypedArrayConstructor; export const isTweenTypedArray = (value: unknown): value is TweenTypedArray => ArrayBuffer.isView(value) && @@ -80,4 +77,4 @@ export const deepCloneTweenValue = (source: T): T => { } return result as T; -}; \ No newline at end of file +}; diff --git a/web/packages/ui-webgl2/src/__tests__/ui-webgl2.test.ts b/web/packages/ui-webgl2/src/__tests__/ui-webgl2.test.ts index 1b1dc53d..769157a7 100644 --- a/web/packages/ui-webgl2/src/__tests__/ui-webgl2.test.ts +++ b/web/packages/ui-webgl2/src/__tests__/ui-webgl2.test.ts @@ -24,8 +24,6 @@ const createGlyphEntry = (): GlyphAtlasEntry => ({ faceId: 1 as GlyphAtlasEntry['faceId'], page: 1 as GlyphAtlasEntry['page'], pageWidth: 64, - width: 14, - height: 16, pageHeight: 64, codePoint: 65, x: 4, @@ -146,6 +144,20 @@ const createFrame = (): UIFrame<{ readonly kind: 'pulse' }> => { const createMockWebGL2Context = () => { let handleId = 0; const makeHandle = (kind: string) => ({ kind, id: ++handleId }); + const state = { + enabled: new Set(), + viewport: [0, 0, 0, 0], + scissorBox: [0, 0, 0, 0], + framebuffer: null as WebGLFramebuffer | null, + currentProgram: null as WebGLProgram | null, + vertexArray: null as WebGLVertexArrayObject | null, + arrayBuffer: null as WebGLBuffer | null, + unpackAlignment: 4, + activeTexture: 0x84c0, + textureBindings: new Map(), + samplerBindings: new Map(), + blendFunc: [0x0302, 0x0303, 0x0302, 0x0303] as [number, number, number, number], + }; return { VERTEX_SHADER: 0x8b31, FRAGMENT_SHADER: 0x8b30, @@ -164,10 +176,26 @@ const createMockWebGL2Context = () => { SCISSOR_TEST: 0x0c11, TEXTURE_2D: 0x0de1, TEXTURE0: 0x84c0, + TEXTURE1: 0x84c1, TEXTURE_MIN_FILTER: 0x2801, TEXTURE_MAG_FILTER: 0x2800, TEXTURE_WRAP_S: 0x2802, TEXTURE_WRAP_T: 0x2803, + VIEWPORT: 0x0ba2, + SCISSOR_BOX: 0x0c10, + CURRENT_PROGRAM: 0x8b8d, + VERTEX_ARRAY_BINDING: 0x85b5, + ARRAY_BUFFER_BINDING: 0x8894, + ACTIVE_TEXTURE: 0x84e0, + TEXTURE_BINDING_2D: 0x8069, + SAMPLER_BINDING: 0x8919, + FRAMEBUFFER: 0x8d40, + FRAMEBUFFER_BINDING: 0x8ca6, + UNPACK_ALIGNMENT: 0x0cf5, + BLEND_SRC_RGB: 0x80c9, + BLEND_DST_RGB: 0x80c8, + BLEND_SRC_ALPHA: 0x80cb, + BLEND_DST_ALPHA: 0x80ca, CLAMP_TO_EDGE: 0x812f, LINEAR: 0x2601, NEAREST: 0x2600, @@ -192,32 +220,106 @@ const createMockWebGL2Context = () => { getUniformLocation: vi.fn((program, name) => ({ program, name })), createBuffer: vi.fn(() => makeHandle('buffer')), deleteBuffer: vi.fn(), - bindBuffer: vi.fn(), + bindBuffer: vi.fn((target, buffer) => { + if (target === 0x8892) { + state.arrayBuffer = buffer as WebGLBuffer | null; + } + }), bufferData: vi.fn(), enableVertexAttribArray: vi.fn(), vertexAttribPointer: vi.fn(), vertexAttribDivisor: vi.fn(), createVertexArray: vi.fn(() => makeHandle('vao')), deleteVertexArray: vi.fn(), - bindVertexArray: vi.fn(), + bindVertexArray: vi.fn((vao) => { + state.vertexArray = vao as WebGLVertexArrayObject | null; + }), createTexture: vi.fn(() => makeHandle('texture')), deleteTexture: vi.fn(), - bindTexture: vi.fn(), - bindSampler: vi.fn(), + bindTexture: vi.fn((_target, texture) => { + state.textureBindings.set(state.activeTexture, texture as WebGLTexture | null); + }), + bindSampler: vi.fn((unit, sampler) => { + state.samplerBindings.set(unit, sampler as WebGLSampler | null); + }), texParameteri: vi.fn(), - pixelStorei: vi.fn(), + pixelStorei: vi.fn((parameter, value) => { + if (parameter === 0x0cf5) { + state.unpackAlignment = value as number; + } + }), texImage2D: vi.fn(), texSubImage2D: vi.fn(), - viewport: vi.fn(), - disable: vi.fn(), - enable: vi.fn(), - blendFunc: vi.fn(), - useProgram: vi.fn(), + viewport: vi.fn((x, y, width, height) => { + state.viewport = [x as number, y as number, width as number, height as number]; + }), + disable: vi.fn((capability) => { + state.enabled.delete(capability as number); + }), + enable: vi.fn((capability) => { + state.enabled.add(capability as number); + }), + blendFunc: vi.fn((src, dst) => { + state.blendFunc = [src as number, dst as number, src as number, dst as number]; + }), + blendFuncSeparate: vi.fn((srcRgb, dstRgb, srcAlpha, dstAlpha) => { + state.blendFunc = [ + srcRgb as number, + dstRgb as number, + srcAlpha as number, + dstAlpha as number, + ]; + }), + useProgram: vi.fn((program) => { + state.currentProgram = program as WebGLProgram | null; + }), uniform2f: vi.fn(), uniform1i: vi.fn(), - activeTexture: vi.fn(), + activeTexture: vi.fn((textureUnit) => { + state.activeTexture = textureUnit as number; + }), drawArraysInstanced: vi.fn(), - scissor: vi.fn(), + scissor: vi.fn((x, y, width, height) => { + state.scissorBox = [x as number, y as number, width as number, height as number]; + }), + bindFramebuffer: vi.fn((_target, framebuffer) => { + state.framebuffer = framebuffer as WebGLFramebuffer | null; + }), + getParameter: vi.fn((parameter) => { + switch (parameter) { + case 0x0ba2: + return state.viewport; + case 0x0c10: + return state.scissorBox; + case 0x8b8d: + return state.currentProgram; + case 0x85b5: + return state.vertexArray; + case 0x8894: + return state.arrayBuffer; + case 0x84e0: + return state.activeTexture; + case 0x8069: + return state.textureBindings.get(state.activeTexture) ?? null; + case 0x8919: + return state.samplerBindings.get(state.activeTexture - 0x84c0) ?? null; + case 0x8ca6: + return state.framebuffer; + case 0x0cf5: + return state.unpackAlignment; + case 0x80c9: + return state.blendFunc[0]; + case 0x80c8: + return state.blendFunc[1]; + case 0x80cb: + return state.blendFunc[2]; + case 0x80ca: + return state.blendFunc[3]; + default: + return null; + } + }), + isEnabled: vi.fn((capability) => state.enabled.has(capability as number)), } as unknown as WebGL2RenderingContext; }; @@ -255,6 +357,56 @@ describe('@axrone/ui-webgl2', () => { renderer.dispose(); }); + it('restores the previous WebGL state after rendering a UI frame', () => { + const gl = createMockWebGL2Context(); + const previousProgram = { id: 'previous-program' } as unknown as WebGLProgram; + const previousVao = { id: 'previous-vao' } as unknown as WebGLVertexArrayObject; + const previousBuffer = { id: 'previous-buffer' } as unknown as WebGLBuffer; + const previousTexture = { id: 'previous-texture' } as unknown as WebGLTexture; + const previousSampler = { id: 'previous-sampler' } as unknown as WebGLSampler; + const previousFramebuffer = { id: 'previous-framebuffer' } as unknown as WebGLFramebuffer; + const renderer = new WebGL2UIRenderer({ gl }); + + gl.bindFramebuffer(gl.FRAMEBUFFER, previousFramebuffer); + gl.viewport(3, 4, 320, 180); + gl.enable(gl.CULL_FACE); + gl.enable(gl.DEPTH_TEST); + gl.disable(gl.BLEND); + gl.enable(gl.SCISSOR_TEST); + gl.scissor(5, 6, 70, 80); + gl.useProgram(previousProgram); + gl.bindVertexArray(previousVao); + gl.bindBuffer(gl.ARRAY_BUFFER, previousBuffer); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4); + gl.blendFuncSeparate(gl.ONE_MINUS_SRC_ALPHA, gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.SRC_ALPHA); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, previousTexture); + gl.bindSampler(0, previousSampler); + gl.activeTexture(gl.TEXTURE1); + + renderer.render(createFrame()); + + expect(gl.getParameter(gl.FRAMEBUFFER_BINDING)).toBe(previousFramebuffer); + expect(gl.getParameter(gl.CURRENT_PROGRAM)).toBe(previousProgram); + expect(gl.getParameter(gl.VERTEX_ARRAY_BINDING)).toBe(previousVao); + expect(gl.getParameter(gl.ARRAY_BUFFER_BINDING)).toBe(previousBuffer); + expect(gl.getParameter(gl.ACTIVE_TEXTURE)).toBe(gl.TEXTURE1); + gl.activeTexture(gl.TEXTURE0); + expect(gl.getParameter(gl.TEXTURE_BINDING_2D)).toBe(previousTexture); + expect(gl.getParameter(gl.SAMPLER_BINDING)).toBe(previousSampler); + expect(gl.getParameter(gl.VIEWPORT)).toEqual([3, 4, 320, 180]); + expect(gl.getParameter(gl.SCISSOR_BOX)).toEqual([5, 6, 70, 80]); + expect(gl.isEnabled(gl.CULL_FACE)).toBe(true); + expect(gl.isEnabled(gl.DEPTH_TEST)).toBe(true); + expect(gl.isEnabled(gl.BLEND)).toBe(false); + expect(gl.isEnabled(gl.SCISSOR_TEST)).toBe(true); + expect(gl.getParameter(gl.UNPACK_ALIGNMENT)).toBe(4); + expect(gl.getParameter(gl.BLEND_SRC_RGB)).toBe(gl.ONE_MINUS_SRC_ALPHA); + expect(gl.getParameter(gl.BLEND_DST_RGB)).toBe(gl.SRC_ALPHA); + expect(gl.getParameter(gl.BLEND_SRC_ALPHA)).toBe(gl.ONE_MINUS_SRC_ALPHA); + expect(gl.getParameter(gl.BLEND_DST_ALPHA)).toBe(gl.SRC_ALPHA); + }); + it('decorates the pipeline backend and renders UI after the base backend ends the frame', async () => { const order: string[] = []; const frame = createFrame(); @@ -598,12 +750,156 @@ describe('@axrone/ui-webgl2', () => { const dynamicFloatUploads = gl.bufferData.mock.calls .map((call) => call[1]) - .filter((value): value is Float32Array => value instanceof Float32Array && value.length === 20); + .filter((value): value is Float32Array => value instanceof Float32Array && value.length === 26); expect(dynamicFloatUploads).toHaveLength(1); expect(dynamicFloatUploads[0]?.[16]).toBe(1); expect(dynamicFloatUploads[0]?.[17]).toBe(6); expect(dynamicFloatUploads[0]?.[18]).toBe(1.5); expect(dynamicFloatUploads[0]?.[19]).toBe(1.25); + expect(Array.from(dynamicFloatUploads[0]?.slice(20, 26) ?? [])).toEqual([1, 0, 0, 0, 1, 0]); }); -}); \ No newline at end of file + + it('uploads distinct raster sizes for the same glyph code point on a shared atlas page', () => { + const gl = createMockWebGL2Context(); + const renderer = new WebGL2UIRenderer({ gl }); + const smallGlyph: GlyphAtlasEntry = { + ...createGlyphEntry(), + rasterSize: 18, + x: 4, + y: 6, + width: 12, + height: 16, + rowStride: 12, + u0: 4 / 64, + v0: 6 / 64, + u1: 16 / 64, + v1: 22 / 64, + data: new Uint8Array(12 * 16).fill(80), + }; + const largeGlyph: GlyphAtlasEntry = { + ...createGlyphEntry(), + rasterSize: 32, + x: 20, + y: 6, + width: 20, + height: 24, + rowStride: 20, + u0: 20 / 64, + v0: 6 / 64, + u1: 40 / 64, + v1: 30 / 64, + data: new Uint8Array(20 * 24).fill(160), + }; + const frame: UIFrame = { + viewportWidth: 160, + viewportHeight: 96, + metrics: { + ...createMetrics(), + renderCount: 1, + textCommandCount: 1, + glyphCount: 2, + customCommandCount: 0, + }, + commands: [ + { + kind: 'text', + widget: 1 as WidgetId, + x: 12, + y: 20, + zIndex: 0, + color: { r: 1, g: 1, b: 1, a: 1 }, + outlineColor: { r: 0, g: 0, b: 0, a: 0 }, + outlineWidth: 0, + edgeSoftness: 1, + opacity: 1, + clip: null, + layout: { + faceId: smallGlyph.faceId, + width: 32, + height: 24, + lineHeight: 24, + baseline: 18, + lines: [ + { + index: 0, + start: 0, + end: 2, + x: 0, + y: 0, + width: 32, + height: 24, + ascent: 18, + descent: 6, + gapCount: 0, + }, + ], + clusters: [ + { + index: 0, + line: 0, + x: 0, + y: 0, + width: 12, + height: 24, + text: 'A', + whitespace: false, + newline: false, + }, + { + index: 1, + line: 0, + x: 12, + y: 0, + width: 20, + height: 24, + text: 'A', + whitespace: false, + newline: false, + }, + ], + carets: [ + { index: 0, line: 0, x: 0, y: 0, height: 24 }, + { index: 1, line: 0, x: 12, y: 0, height: 24 }, + { index: 2, line: 0, x: 32, y: 0, height: 24 }, + ], + glyphs: [ + { + codePoint: 65, + clusterIndex: 0, + x: 0, + y: 4, + advance: 12, + width: 12, + height: 16, + line: 0, + text: 'A', + atlasEntry: smallGlyph, + }, + { + codePoint: 65, + clusterIndex: 1, + x: 12, + y: 0, + advance: 20, + width: 20, + height: 24, + line: 0, + text: 'A', + atlasEntry: largeGlyph, + }, + ], + truncated: false, + direction: 'ltr', + text: 'AA', + }, + }, + ], + }; + + renderer.render(frame); + + expect(gl.texSubImage2D).toHaveBeenCalledTimes(2); + expect(renderer.getStats().uploadedGlyphCount).toBe(2); + }); +}); diff --git a/web/packages/ui-webgl2/src/renderer.ts b/web/packages/ui-webgl2/src/renderer.ts index c2f7aaf2..1a35cf99 100644 --- a/web/packages/ui-webgl2/src/renderer.ts +++ b/web/packages/ui-webgl2/src/renderer.ts @@ -19,9 +19,9 @@ import type { WebGL2UIRendererStatistics, } from './types'; -const QUAD_FLOATS_PER_INSTANCE = 19; -const IMAGE_FLOATS_PER_INSTANCE = 16; -const TEXT_FLOATS_PER_INSTANCE = 20; +const QUAD_FLOATS_PER_INSTANCE = 23; +const IMAGE_FLOATS_PER_INSTANCE = 22; +const TEXT_FLOATS_PER_INSTANCE = 26; const QUAD_VERTEX_SOURCE = `#version 300 es precision mediump float; @@ -31,6 +31,8 @@ layout(location = 2) in vec4 a_FillColor; layout(location = 3) in vec4 a_BorderColor; layout(location = 4) in vec4 a_Radius; layout(location = 5) in float a_BorderWidth; +layout(location = 6) in vec3 a_TransformRow0; +layout(location = 7) in vec3 a_TransformRow1; uniform vec2 u_Viewport; out vec2 v_Local; out vec2 v_Size; @@ -40,6 +42,10 @@ out vec4 v_Radius; out float v_BorderWidth; void main() { vec2 pixel = a_Rect.xy + a_Unit * a_Rect.zw; + pixel = vec2( + a_TransformRow0.x * pixel.x + a_TransformRow0.y * pixel.y + a_TransformRow0.z, + a_TransformRow1.x * pixel.x + a_TransformRow1.y * pixel.y + a_TransformRow1.z + ); vec2 ndc = vec2((pixel.x / u_Viewport.x) * 2.0 - 1.0, 1.0 - (pixel.y / u_Viewport.y) * 2.0); gl_Position = vec4(ndc, 0.0, 1.0); v_Local = a_Unit * a_Rect.zw; @@ -105,6 +111,8 @@ layout(location = 2) in vec4 a_UvRect; layout(location = 3) in vec4 a_Color; layout(location = 4) in vec4 a_OutlineColor; layout(location = 5) in vec4 a_SdfParams; +layout(location = 6) in vec3 a_TransformRow0; +layout(location = 7) in vec3 a_TransformRow1; uniform vec2 u_Viewport; out vec2 v_Uv; out vec4 v_Color; @@ -112,6 +120,10 @@ out vec4 v_OutlineColor; out vec4 v_SdfParams; void main() { vec2 pixel = a_Rect.xy + a_Unit * a_Rect.zw; + pixel = vec2( + a_TransformRow0.x * pixel.x + a_TransformRow0.y * pixel.y + a_TransformRow0.z, + a_TransformRow1.x * pixel.x + a_TransformRow1.y * pixel.y + a_TransformRow1.z + ); vec2 ndc = vec2((pixel.x / u_Viewport.x) * 2.0 - 1.0, 1.0 - (pixel.y / u_Viewport.y) * 2.0); gl_Position = vec4(ndc, 0.0, 1.0); v_Uv = a_UvRect.xy + a_Unit * a_UvRect.zw; @@ -156,6 +168,8 @@ layout(location = 1) in vec4 a_Rect; layout(location = 2) in vec4 a_UvRect; layout(location = 3) in vec4 a_Tint; layout(location = 4) in vec4 a_Radius; +layout(location = 5) in vec3 a_TransformRow0; +layout(location = 6) in vec3 a_TransformRow1; uniform vec2 u_Viewport; out vec2 v_Local; out vec2 v_Size; @@ -164,6 +178,10 @@ out vec4 v_Tint; out vec4 v_Radius; void main() { vec2 pixel = a_Rect.xy + a_Unit * a_Rect.zw; + pixel = vec2( + a_TransformRow0.x * pixel.x + a_TransformRow0.y * pixel.y + a_TransformRow0.z, + a_TransformRow1.x * pixel.x + a_TransformRow1.y * pixel.y + a_TransformRow1.z + ); vec2 ndc = vec2((pixel.x / u_Viewport.x) * 2.0 - 1.0, 1.0 - (pixel.y / u_Viewport.y) * 2.0); gl_Position = vec4(ndc, 0.0, 1.0); v_Local = a_Unit * a_Rect.zw; @@ -221,7 +239,30 @@ interface TexturePage { readonly width: number; readonly height: number; readonly format: FontGlyphBitmapFormat; - readonly uploadedGlyphs: Set; + readonly uploadedGlyphs: Set; +} + +interface WebGL2UICapturedTextureUnitState { + readonly textureBinding2D: WebGLTexture | null | undefined; + readonly samplerBinding: WebGLSampler | null | undefined; +} + +interface WebGL2UIRenderStateSnapshot { + readonly viewport: readonly [number, number, number, number] | null; + readonly scissorBox: readonly [number, number, number, number] | null; + readonly framebufferBinding: WebGLFramebuffer | null | undefined; + readonly currentProgram: WebGLProgram | null | undefined; + readonly vertexArrayBinding: WebGLVertexArrayObject | null | undefined; + readonly arrayBufferBinding: WebGLBuffer | null | undefined; + readonly unpackAlignment: number | undefined; + readonly cullFaceEnabled: boolean | undefined; + readonly depthTestEnabled: boolean | undefined; + readonly blendEnabled: boolean | undefined; + readonly scissorTestEnabled: boolean | undefined; + readonly blendFunc: readonly [number, number, number, number] | null; + readonly activeTexture: number | undefined; + readonly unit0: WebGL2UICapturedTextureUnitState; + readonly activeUnit: WebGL2UICapturedTextureUnitState; } const UNIT_QUAD = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]); @@ -239,6 +280,8 @@ const toUint8Array = (value: ArrayBuffer | ArrayBufferView): Uint8Array => { return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); }; +const createUploadedGlyphKey = (entry: GlyphAtlasEntry): string => `${entry.codePoint}:${entry.rasterSize ?? 0}`; + const multiplyAlpha = (alpha: number, opacity: number): number => alpha * opacity; const blendColor = ( @@ -246,6 +289,8 @@ const blendColor = ( opacity: number ): readonly [number, number, number, number] => [color.r, color.g, color.b, multiplyAlpha(color.a, opacity)]; +const IDENTITY_TRANSFORM = [1, 0, 0, 1, 0, 0] as const; + const sameClip = (left: ClipState | null, right: RectLike | null): boolean => { if (left === null || right === null) { return left === null && right === null; @@ -258,6 +303,232 @@ const sameClip = (left: ClipState | null, right: RectLike | null): boolean => { ); }; +function readGLParameter( + gl: WebGL2RenderingContext, + parameter: number, + fallback: undefined +): TValue | undefined; +function readGLParameter( + gl: WebGL2RenderingContext, + parameter: number, + fallback: null +): TValue | null; +function readGLParameter( + gl: WebGL2RenderingContext, + parameter: number, + fallback: TValue +): TValue; +function readGLParameter( + gl: WebGL2RenderingContext, + parameter: number, + fallback: TValue | null | undefined +): TValue | null | undefined { + if (typeof gl.getParameter !== 'function') { + return fallback; + } + + try { + return (gl.getParameter(parameter) as TValue | null | undefined) ?? fallback; + } catch { + return fallback; + } +} + +const readGLEnabled = ( + gl: WebGL2RenderingContext, + capability: number, + fallback: boolean | undefined = undefined +): boolean | undefined => { + if (typeof gl.isEnabled !== 'function') { + return fallback; + } + + try { + return gl.isEnabled(capability); + } catch { + return fallback; + } +}; + +const captureGLTextureUnitState = ( + gl: WebGL2RenderingContext, + unit: number +): WebGL2UICapturedTextureUnitState => { + if (typeof gl.activeTexture !== 'function') { + return { + textureBinding2D: undefined, + samplerBinding: undefined, + }; + } + + const previousUnit = readGLParameter(gl, gl.ACTIVE_TEXTURE, gl.TEXTURE0); + gl.activeTexture(unit); + const snapshot = { + textureBinding2D: readGLParameter( + gl, + gl.TEXTURE_BINDING_2D, + undefined, + ), + samplerBinding: readGLParameter( + gl, + gl.SAMPLER_BINDING, + undefined, + ), + }; + gl.activeTexture(previousUnit); + return snapshot; +}; + +const captureGLState = (gl: WebGL2RenderingContext): WebGL2UIRenderStateSnapshot => { + const activeTexture = readGLParameter(gl, gl.ACTIVE_TEXTURE, undefined); + const viewport = readGLParameter(gl, gl.VIEWPORT, null); + const scissorBox = readGLParameter(gl, gl.SCISSOR_BOX, null); + const blendSrcRgb = readGLParameter(gl, gl.BLEND_SRC_RGB, undefined); + const blendDstRgb = readGLParameter(gl, gl.BLEND_DST_RGB, undefined); + const blendSrcAlpha = readGLParameter(gl, gl.BLEND_SRC_ALPHA, undefined); + const blendDstAlpha = readGLParameter(gl, gl.BLEND_DST_ALPHA, undefined); + + return { + viewport: + viewport && viewport.length >= 4 + ? [viewport[0] ?? 0, viewport[1] ?? 0, viewport[2] ?? 0, viewport[3] ?? 0] + : null, + scissorBox: + scissorBox && scissorBox.length >= 4 + ? [scissorBox[0] ?? 0, scissorBox[1] ?? 0, scissorBox[2] ?? 0, scissorBox[3] ?? 0] + : null, + framebufferBinding: readGLParameter( + gl, + gl.FRAMEBUFFER_BINDING, + undefined, + ), + currentProgram: readGLParameter( + gl, + gl.CURRENT_PROGRAM, + undefined, + ), + vertexArrayBinding: readGLParameter( + gl, + gl.VERTEX_ARRAY_BINDING, + undefined, + ), + arrayBufferBinding: readGLParameter( + gl, + gl.ARRAY_BUFFER_BINDING, + undefined, + ), + unpackAlignment: readGLParameter(gl, gl.UNPACK_ALIGNMENT, undefined), + cullFaceEnabled: readGLEnabled(gl, gl.CULL_FACE), + depthTestEnabled: readGLEnabled(gl, gl.DEPTH_TEST), + blendEnabled: readGLEnabled(gl, gl.BLEND), + scissorTestEnabled: readGLEnabled(gl, gl.SCISSOR_TEST), + blendFunc: + blendSrcRgb !== undefined && + blendDstRgb !== undefined && + blendSrcAlpha !== undefined && + blendDstAlpha !== undefined + ? [blendSrcRgb, blendDstRgb, blendSrcAlpha, blendDstAlpha] + : null, + activeTexture, + unit0: captureGLTextureUnitState(gl, gl.TEXTURE0), + activeUnit: + activeTexture !== undefined ? captureGLTextureUnitState(gl, activeTexture) : captureGLTextureUnitState(gl, gl.TEXTURE0), + }; +}; + +const restoreGLEnableState = ( + gl: WebGL2RenderingContext, + capability: number, + enabled: boolean | undefined +): void => { + if (enabled === undefined) { + return; + } + if (enabled) { + gl.enable(capability); + return; + } + gl.disable(capability); +}; + +const restoreGLTextureUnitState = ( + gl: WebGL2RenderingContext, + unit: number, + snapshot: WebGL2UICapturedTextureUnitState +): void => { + if (typeof gl.activeTexture !== 'function') { + return; + } + + gl.activeTexture(unit); + if (snapshot.textureBinding2D !== undefined) { + gl.bindTexture(gl.TEXTURE_2D, snapshot.textureBinding2D ?? null); + } + if (snapshot.samplerBinding !== undefined) { + gl.bindSampler?.(unit - gl.TEXTURE0, snapshot.samplerBinding ?? null); + } +}; + +const restoreGLState = ( + gl: WebGL2RenderingContext, + snapshot: WebGL2UIRenderStateSnapshot +): void => { + if (snapshot.framebufferBinding !== undefined) { + gl.bindFramebuffer(gl.FRAMEBUFFER, snapshot.framebufferBinding ?? null); + } + if (snapshot.viewport !== null) { + gl.viewport( + snapshot.viewport[0], + snapshot.viewport[1], + snapshot.viewport[2], + snapshot.viewport[3] + ); + } + restoreGLEnableState(gl, gl.CULL_FACE, snapshot.cullFaceEnabled); + restoreGLEnableState(gl, gl.DEPTH_TEST, snapshot.depthTestEnabled); + restoreGLEnableState(gl, gl.BLEND, snapshot.blendEnabled); + restoreGLEnableState(gl, gl.SCISSOR_TEST, snapshot.scissorTestEnabled); + if (snapshot.scissorTestEnabled && snapshot.scissorBox !== null) { + gl.scissor( + snapshot.scissorBox[0], + snapshot.scissorBox[1], + snapshot.scissorBox[2], + snapshot.scissorBox[3] + ); + } + if (snapshot.blendFunc !== null) { + if (typeof gl.blendFuncSeparate === 'function') { + gl.blendFuncSeparate( + snapshot.blendFunc[0], + snapshot.blendFunc[1], + snapshot.blendFunc[2], + snapshot.blendFunc[3] + ); + } else { + gl.blendFunc(snapshot.blendFunc[0], snapshot.blendFunc[1]); + } + } + if (snapshot.currentProgram !== undefined) { + gl.useProgram(snapshot.currentProgram ?? null); + } + if (snapshot.vertexArrayBinding !== undefined) { + gl.bindVertexArray(snapshot.vertexArrayBinding ?? null); + } + if (snapshot.arrayBufferBinding !== undefined) { + gl.bindBuffer(gl.ARRAY_BUFFER, snapshot.arrayBufferBinding ?? null); + } + if (snapshot.unpackAlignment !== undefined) { + gl.pixelStorei?.(gl.UNPACK_ALIGNMENT, snapshot.unpackAlignment); + } + restoreGLTextureUnitState(gl, gl.TEXTURE0, snapshot.unit0); + if (snapshot.activeTexture !== undefined && snapshot.activeTexture !== gl.TEXTURE0) { + restoreGLTextureUnitState(gl, snapshot.activeTexture, snapshot.activeUnit); + gl.activeTexture(snapshot.activeTexture); + } else if (snapshot.activeTexture !== undefined) { + gl.activeTexture(snapshot.activeTexture); + } +}; + const createShader = (gl: WebGL2RenderingContext, type: number, source: string): WebGLShader => { const shader = gl.createShader(type); if (!shader) { @@ -389,6 +660,7 @@ export class WebGL2UIRenderer implements UIFrameSink>): void { this.ensureActive(); + const previousState = captureGLState(this.gl); this.currentFrame = frame as UIFrame; this.statisticsState.drawCalls = 0; this.statisticsState.quadCount = 0; @@ -407,52 +679,56 @@ export class WebGL2UIRenderer implements UIFrameSink, { + gl: this.gl, + frame, + clip: command.clip, + viewport: { + width: frame.viewportWidth, + height: frame.viewportHeight, + }, + }); + } } + this.flushQuadBatch(frame.viewportHeight); this.flushImageBatch(frame.viewportHeight); this.flushTextBatch(frame.viewportHeight); - if (this.customCommandRenderer) { - this.statisticsState.customCommandCount += 1; - this.customCommandRenderer(command as CustomRenderCommand, { - gl: this.gl, - frame, - clip: command.clip, - viewport: { - width: frame.viewportWidth, - height: frame.viewportHeight, - }, - }); - } + } finally { + this.currentFrame = null; + restoreGLState(this.gl, previousState); } - - this.flushQuadBatch(frame.viewportHeight); - this.flushImageBatch(frame.viewportHeight); - this.flushTextBatch(frame.viewportHeight); - this.currentFrame = null; } dispose(): void { @@ -505,6 +781,12 @@ export class WebGL2UIRenderer implements UIFrameSink implements UIFrameSink implements UIFrameSink implements UIFrameSink implements UIFrameSink implements UIFrameSink implements UIFrameSink(), + uploadedGlyphs: new Set(), }; this.pages.set(key, page); } - if (!page.uploadedGlyphs.has(entry.codePoint)) { + const glyphKey = createUploadedGlyphKey(entry); + if (!page.uploadedGlyphs.has(glyphKey)) { if (!entry.data) { return null; } @@ -864,7 +1178,7 @@ export class WebGL2UIRenderer implements UIFrameSink implements UIFrameSink { + it('normalizes widget config into immutable-by-contract runtime records', () => { + const pointerDown = vi.fn(); + const source = { + props: { label: 'Play' }, + layout: { width: 120, anchor: { x: 0.5 } }, + style: { background: '#112233ff' }, + handlers: { pointerDown }, + }; + + const record = normalizeWidgetRecord(source); + + expect(record.role).toBe('container'); + expect(record.controller).toBeNull(); + expect(record.enabled).toBe(true); + expect(record.interactive).toBe(false); + expect(record.props).toEqual({ label: 'Play' }); + expect(record.layoutInput).toEqual({ width: 120, anchor: { x: 0.5 } }); + expect(record.styleInput).toEqual({ background: '#112233ff' }); + expect(record.handlers?.pointerDown).toBe(pointerDown); + expect(record.props).not.toBe(source.props); + expect(record.layoutInput).not.toBe(source.layout); + }); + + it('compiles style, text, image, and focus inputs with clamped runtime defaults', () => { + const style = compileWidgetStyle({ + opacity: 2, + borderWidth: -4, + color: '#336699cc', + radius: 6, + }); + const text = compileWidgetText( + { + value: 'Axrone', + size: 0, + weight: 'bold', + maxLines: 0, + caretIndex: 4.8, + selectionStart: Number.POSITIVE_INFINITY, + }, + { + defaultFamily: 'Inter', + locale: 'tr', + fallbackColor: style.color, + } + ); + const image = compileWidgetImage({ + source: { kind: 'texture', resourceId: 'hero', width: 0, height: -5 }, + alignX: 4, + alignY: -2, + uvRect: { x: 0.25, y: 0.25, width: 1, height: 1 }, + }); + const focus = compileWidgetFocus({}, true); + + expect(style.opacity).toBe(1); + expect(style.borderWidth).toBe(0); + expect(style.radius.topLeft).toBe(6); + expect(text?.family).toBe('Inter'); + expect(text?.locale).toBe('tr'); + expect(text?.size).toBe(1); + expect(text?.weight).toBe(700); + expect(text?.maxLines).toBe(1); + expect(text?.caretIndex).toBe(4); + expect(text?.selectionStart).toBeNull(); + expect(image?.source.width).toBe(1); + expect(image?.source.height).toBe(1); + expect(image?.alignX).toBe(1); + expect(image?.alignY).toBe(0); + expect(image?.uvRect.width).toBe(0.75); + expect(image?.uvRect.height).toBe(0.75); + expect(focus.focusable).toBe(true); + }); +}); diff --git a/web/packages/ui/src/__tests__/test-font.ts b/web/packages/ui/src/__tests__/test-font.ts new file mode 100644 index 00000000..c76f8f24 --- /dev/null +++ b/web/packages/ui/src/__tests__/test-font.ts @@ -0,0 +1,23 @@ +const TEST_FONT_CODE_POINTS = [ + ...Array.from({ length: 95 }, (_, index) => 32 + index), + 8230, +]; + +export const createTestFontAsset = (family = 'TestSans') => ({ + family, + face: 'Regular', + style: 'normal' as const, + weight: 400 as const, + ascent: 800, + descent: 200, + lineGap: 0, + unitsPerEm: 1000, + defaultAdvance: 500, + fallbackCodePoint: 63, + glyphs: TEST_FONT_CODE_POINTS.map((codePoint) => ({ + codePoint, + advance: codePoint === 32 ? 250 : 500, + width: codePoint === 32 ? 1 : 480, + height: codePoint === 32 ? 1 : 720, + })), +}); \ No newline at end of file diff --git a/web/packages/ui/src/__tests__/ui-controls.test.ts b/web/packages/ui/src/__tests__/ui-controls.test.ts new file mode 100644 index 00000000..344a60b7 --- /dev/null +++ b/web/packages/ui/src/__tests__/ui-controls.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from 'vitest'; +import { + AXRONE_DEFAULT_UI_FONT_FAMILY, + UIRuntime, + createUIButton, + createUICanvas, + createUIEditBox, + createUILayout, + createUIPageView, + createUIProgressBar, + createUIRichText, + createUIScrollView, + createUISlider, + createUIToggle, + createUIWidget, +} from '../index'; +import { createTestFontAsset } from './test-font'; + +const prepareRuntime = () => { + const runtime = new UIRuntime({ width: 480, height: 240 }); + runtime.fonts.registerFace(createTestFontAsset(AXRONE_DEFAULT_UI_FONT_FAMILY)); + return runtime; +}; + +describe('@axrone/ui controls', () => { + it('builds a professional widget hierarchy on top of canvas and layout helpers', () => { + const runtime = prepareRuntime(); + const canvas = createUICanvas(runtime, { style: { background: '#0b1323ff' } }); + const layout = createUILayout(runtime, { + parent: canvas, + layout: { + position: 'absolute', + anchor: 'center', + width: 320, + height: 'content', + padding: 18, + gap: 12, + }, + style: { + background: '#111827ee', + borderColor: '#67e8f9aa', + borderWidth: 1, + radius: 18, + }, + }); + + createUIRichText(runtime, { + parent: layout, + value: 'Axrone UI Studio', + text: { + size: 18, + underline: true, + underlineColor: '#67e8f9ff', + }, + }); + createUIButton(runtime, { + parent: layout, + label: 'Launch', + variant: 'primary', + }); + createUIProgressBar(runtime, { + parent: layout, + label: 'Loading', + value: 0.72, + min: 0, + max: 1, + }); + + const frame = runtime.commit(); + + expect(runtime.getWidgetCount()).toBeGreaterThanOrEqual(8); + expect(frame.commands.filter((command) => command.kind === 'text').length).toBeGreaterThan(1); + expect(frame.commands.filter((command) => command.kind === 'quad').length).toBeGreaterThan(4); + }); + + it('handles button presses and toggle state changes through the retained runtime', () => { + const runtime = prepareRuntime(); + let pressed = 0; + const button = createUIButton(runtime, { + label: 'Play', + variant: 'primary', + onPress: () => { + pressed += 1; + }, + }); + const toggle = createUIToggle(runtime, { + parent: button.root, + label: 'Enabled', + checked: false, + }); + + runtime.commit(); + + const buttonBox = runtime.getLayoutBox(button.root); + runtime.dispatchInput({ + type: 'pointer', + phase: 'move', + x: buttonBox.x + 4, + y: buttonBox.y + 4, + }); + runtime.dispatchInput({ + type: 'pointer', + phase: 'down', + x: buttonBox.x + 8, + y: buttonBox.y + 8, + }); + runtime.dispatchInput({ + type: 'pointer', + phase: 'up', + x: buttonBox.x + 8, + y: buttonBox.y + 8, + }); + + const toggleBox = runtime.getLayoutBox(toggle.root); + runtime.dispatchInput({ + type: 'pointer', + phase: 'down', + x: toggleBox.x + 8, + y: toggleBox.y + 8, + }); + runtime.dispatchInput({ + type: 'pointer', + phase: 'up', + x: toggleBox.x + 8, + y: toggleBox.y + 8, + }); + + expect(pressed).toBe(1); + expect(toggle.isChecked()).toBe(true); + }); + + it('supports edit-box text entry, caret movement, slider keyboard input and content scrolling', () => { + const runtime = prepareRuntime(); + const layout = createUILayout(runtime, { + layout: { + width: 340, + padding: 12, + gap: 14, + }, + }); + const input = createUIEditBox(runtime, { + parent: layout, + value: '', + placeholder: 'Search', + }); + const slider = createUISlider(runtime, { + parent: layout, + label: 'Opacity', + min: 0, + max: 100, + step: 5, + value: 25, + }); + const scroll = createUIScrollView(runtime, { + parent: layout, + layout: { + width: 180, + height: 72, + }, + }); + const item = createUIWidget(runtime, { + parent: scroll, + layout: { + width: '100%', + height: 48, + }, + style: { + background: '#1f2937ff', + }, + }); + createUIWidget(runtime, { + parent: scroll, + layout: { + width: '100%', + height: 48, + }, + style: { + background: '#273449ff', + }, + }); + + runtime.commit(); + runtime.setFocus(input.root); + runtime.dispatchInput({ type: 'text', text: 'abc' }); + runtime.dispatchInput({ type: 'key', phase: 'down', key: 'ArrowLeft' }); + runtime.dispatchInput({ type: 'text', text: 'Z' }); + runtime.dispatchInput({ type: 'key', phase: 'down', key: 'Backspace' }); + + const editingFrame = runtime.commit(); + const caretQuad = editingFrame.commands.find( + (command) => command.kind === 'quad' && command.width === 2, + ); + expect(caretQuad).toBeDefined(); + + runtime.setFocus(slider.root); + runtime.dispatchInput({ type: 'key', phase: 'down', key: 'ArrowRight' }); + runtime.dispatchInput({ type: 'key', phase: 'down', key: 'PageUp' }); + + const beforeScroll = runtime.getLayoutBox(item.root); + scroll.setScroll(0, 18); + runtime.commit(); + const afterScroll = runtime.getLayoutBox(item.root); + + expect(input.getValue()).toBe('abc'); + expect(slider.getValue()).toBe(80); + expect(afterScroll.y).toBeLessThan(beforeScroll.y); + }); + + it('switches page visibility cleanly in the page-view container', () => { + const runtime = prepareRuntime(); + const pageView = createUIPageView(runtime, { + layout: { + width: 220, + height: 96, + }, + }); + const firstPage = createUIWidget(runtime, { + layout: { + width: '100%', + height: '100%', + }, + style: { + background: '#0f766eff', + }, + }); + const secondPage = createUIWidget(runtime, { + layout: { + width: '100%', + height: '100%', + }, + style: { + background: '#7c3aedff', + }, + }); + + pageView.addPage(firstPage.root); + pageView.addPage(secondPage.root); + runtime.commit(); + + pageView.setPage(1); + const frame = runtime.commit(); + + expect(pageView.getPage()).toBe(1); + expect(frame.commands.some((command) => command.widget === secondPage.root)).toBe(true); + expect(frame.commands.some((command) => command.widget === firstPage.root)).toBe(false); + }); +}); \ No newline at end of file diff --git a/web/packages/ui/src/__tests__/ui-runtime.test.ts b/web/packages/ui/src/__tests__/ui-runtime.test.ts index 5df8f42d..83b5bd20 100644 --- a/web/packages/ui/src/__tests__/ui-runtime.test.ts +++ b/web/packages/ui/src/__tests__/ui-runtime.test.ts @@ -1,26 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { FontRegistry, UIRuntime, createRuntimeFrameSource, renderUIFrame } from '../index'; - -const createFontAsset = (family = 'TestSans') => ({ - family, - face: 'Regular', - style: 'normal' as const, - weight: 400 as const, - ascent: 800, - descent: 200, - lineGap: 0, - unitsPerEm: 1000, - defaultAdvance: 500, - fallbackCodePoint: 63, - glyphs: [32, 63, 65, 66, 67, 68, 69, 72, 76, 79, 87, 97, 100, 101, 103, 104, 108, 111, 114, 116, 8230].map( - (codePoint) => ({ - codePoint, - advance: codePoint === 32 ? 250 : 500, - width: codePoint === 32 ? 1 : 480, - height: codePoint === 32 ? 1 : 720, - }) - ), -}); +import { createTestFontAsset as createFontAsset } from './test-font'; describe('@axrone/ui runtime', () => { it('lays out stacked widgets and emits quad and text commands', () => { @@ -319,6 +299,120 @@ describe('@axrone/ui runtime', () => { fonts.dispose(); }); + it('calls URL font fetches with a stable global context', async () => { + const runtimeFactory = { + create: vi.fn(async () => ({ + info: { + family: 'ContextSans', + face: 'Regular', + style: 'normal' as const, + weight: 400 as const, + locale: '', + ascent: 800, + descent: 200, + lineGap: 0, + unitsPerEm: 1000, + defaultAdvance: 500, + fallbackCodePoint: 63, + }, + measureGlyph: () => null, + rasterizeGlyph: () => null, + getKerning: () => 0, + dispose: vi.fn(), + })), + }; + const fetchImpl = vi.fn(function (this: typeof globalThis, url: string) { + expect(this).toBe(globalThis); + expect(url).toBe('https://example.com/font.ttf'); + return Promise.resolve({ + ok: true, + status: 200, + headers: { + get(name: string) { + return name.toLowerCase() === 'content-type' ? 'font/ttf' : null; + }, + }, + arrayBuffer: async () => + new Uint8Array([0x00, 0x01, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00]).buffer, + } as Response); + }) as typeof fetch; + const fonts = new FontRegistry({ + fetch: fetchImpl, + dynamicRuntimeFactory: runtimeFactory, + }); + + const faceId = await fonts.load({ + kind: 'url', + url: 'https://example.com/font.ttf', + family: 'ContextSans', + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(runtimeFactory.create).toHaveBeenCalledTimes(1); + expect(fonts.getFaceInfo(faceId)?.family).toBe('ContextSans'); + fonts.dispose(); + }); + + it('loads binary font sources through the dynamic runtime pipeline and caches rasterized glyph sizes', async () => { + const rasterizeGlyph = vi.fn((codePoint: number, rasterSize: number) => ({ + codePoint, + rasterSize, + width: Math.max(1, rasterSize), + height: Math.max(1, Math.ceil(rasterSize * 1.2)), + data: new Uint8Array(Math.max(1, rasterSize) * Math.max(1, Math.ceil(rasterSize * 1.2))).fill(255), + format: 'alpha8' as const, + rowStride: Math.max(1, rasterSize), + })); + const runtimeFactory = { + create: vi.fn(async () => ({ + info: { + family: 'VectorSans', + face: 'Regular', + style: 'normal' as const, + weight: 400 as const, + locale: '', + ascent: 800, + descent: 200, + lineGap: 0, + unitsPerEm: 1000, + defaultAdvance: 500, + fallbackCodePoint: 63, + }, + measureGlyph: (codePoint: number) => ({ + codePoint, + advance: codePoint === 32 ? 250 : 500, + width: codePoint === 32 ? 1 : 480, + height: codePoint === 32 ? 1 : 720, + }), + rasterizeGlyph, + getKerning: (leftCodePoint: number, rightCodePoint: number) => + leftCodePoint === 65 && rightCodePoint === 86 ? -40 : 0, + dispose: vi.fn(), + })), + }; + const fonts = new FontRegistry({ + dynamicRuntimeFactory: runtimeFactory, + }); + + const faceId = await fonts.load({ + kind: 'buffer', + data: new Uint8Array([0x00, 0x01, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00]), + contentType: 'font/ttf', + family: 'VectorSans', + }); + const first = fonts.measureGlyph(faceId, 65, 18, 86); + const second = fonts.measureGlyph(faceId, 65, 18, 86); + + expect(runtimeFactory.create).toHaveBeenCalledTimes(1); + expect(fonts.getFaceInfo(faceId)?.family).toBe('VectorSans'); + expect(first.advance).toBeCloseTo((500 - 40) * 0.018); + expect(first.atlasEntry).not.toBeNull(); + expect(first.atlasEntry?.rasterSize).toBe(18); + expect(second.atlasEntry?.page).toBe(first.atlasEntry?.page); + expect(rasterizeGlyph).toHaveBeenCalledTimes(1); + fonts.dispose(); + }); + it('resolves runtime frames through the renderer seam helpers', () => { const runtime = new UIRuntime({ width: 96, height: 48 }); const box = runtime.createWidget({ @@ -382,4 +476,4 @@ describe('@axrone/ui runtime', () => { expect(restored.getWidgetCount()).toBe(1); expect(restoredFrame.commands.some((command) => command.kind === 'custom')).toBe(true); }); -}); \ No newline at end of file +}); diff --git a/web/packages/ui/src/controls.ts b/web/packages/ui/src/controls.ts new file mode 100644 index 00000000..4e404625 --- /dev/null +++ b/web/packages/ui/src/controls.ts @@ -0,0 +1,11 @@ +export * from './controls/types'; +export * from './controls/theme'; +export * from './controls/layout-factories'; +export * from './controls/primitives'; +export * from './controls/button'; +export * from './controls/toggle'; +export * from './controls/progress-bar'; +export * from './controls/slider'; +export * from './controls/edit-box'; +export * from './controls/scroll-view'; +export * from './controls/page-view'; \ No newline at end of file diff --git a/web/packages/ui/src/controls/button.ts b/web/packages/ui/src/controls/button.ts new file mode 100644 index 00000000..8e25f3c9 --- /dev/null +++ b/web/packages/ui/src/controls/button.ts @@ -0,0 +1,180 @@ +import type { UIRuntime } from '../runtime'; +import type { UIButtonHandle, UIButtonOptions } from './types'; +import { attachToParent, createTextBlock, disposeWidget, isPointInside } from './internals'; +import { resolveTheme, resolveVariantPalette } from './theme'; + +export const createUIButton = ( + runtime: UIRuntime, + options: UIButtonOptions = {} +): UIButtonHandle => { + const theme = resolveTheme(options.theme); + const baseStyle = options.style ?? {}; + const baseText = options.text ?? {}; + const palette = () => resolveVariantPalette(theme, state.variant); + const state = { + label: options.label ?? 'Button', + disabled: options.disabled ?? false, + hovered: false, + pressed: false, + focused: false, + variant: options.variant ?? 'primary', + onPress: options.onPress, + }; + + let handle: UIButtonHandle; + + const root = runtime.createWidget({ + role: 'button', + key: options.key, + enabled: !state.disabled, + interactive: !state.disabled, + focus: { + focusable: !state.disabled, + ...(options.focus ?? {}), + }, + layout: { + width: 'content', + height: 'content', + minWidth: Math.max(88, Math.round(theme.controlHeight * 2.1)), + minHeight: theme.controlHeight, + padding: [12, 20], + ...(options.layout ?? {}), + }, + handlers: { + pointerEnter: () => { + if (state.disabled) { + return false; + } + state.hovered = true; + apply(); + return true; + }, + pointerLeave: () => { + if (state.disabled) { + return false; + } + state.hovered = false; + apply(); + return true; + }, + pointerDown: () => { + if (state.disabled) { + return false; + } + state.pressed = true; + runtime.setFocus(root, 'pointer'); + apply(); + return true; + }, + pointerUp: (event) => { + if (state.disabled) { + return false; + } + const shouldPress = state.pressed && isPointInside(runtime, root, event.x, event.y); + state.pressed = false; + apply(); + if (shouldPress) { + state.onPress?.(handle); + } + return true; + }, + keyDown: (event) => { + if (state.disabled) { + return false; + } + if ((event.key === 'Enter' || event.key === ' ') && !event.repeat) { + state.onPress?.(handle); + return true; + } + return false; + }, + focus: () => { + state.focused = true; + apply(); + }, + blur: () => { + state.focused = false; + state.pressed = false; + apply(); + }, + }, + }); + + attachToParent(runtime, options.parent, root); + + const apply = (): void => { + const currentPalette = palette(); + const background = state.disabled + ? theme.surfaceDisabledColor + : state.pressed && state.hovered + ? currentPalette.pressed + : state.hovered + ? currentPalette.hover + : currentPalette.idle; + const borderColor = state.focused ? theme.focusColor : currentPalette.border; + const textColor = state.disabled ? theme.textMutedColor : baseText.color ?? currentPalette.text; + + runtime.updateWidget(root, { + enabled: !state.disabled, + interactive: !state.disabled, + focus: { + focusable: !state.disabled, + ...(options.focus ?? {}), + }, + style: { + ...baseStyle, + background, + borderColor, + borderWidth: state.focused ? theme.borderWidth + 1 : baseStyle.borderWidth ?? theme.borderWidth, + radius: baseStyle.radius ?? theme.controlRadius, + color: textColor, + }, + text: createTextBlock(runtime, state.label, theme, { + align: 'center', + wrap: 'none', + overflow: 'ellipsis', + weight: baseText.weight ?? 'medium', + ...(baseText ?? {}), + color: textColor, + }, textColor), + }); + }; + + handle = { + root, + getLabel() { + return state.label; + }, + setLabel(value) { + state.label = value; + apply(); + }, + isDisabled() { + return state.disabled; + }, + setDisabled(disabled) { + state.disabled = disabled; + state.pressed = false; + state.hovered = false; + apply(); + }, + setVariant(variant) { + state.variant = variant; + apply(); + }, + setOnPress(handler) { + state.onPress = handler; + }, + press() { + if (!state.disabled) { + state.onPress?.(handle); + } + }, + dispose() { + disposeWidget(runtime, root); + }, + }; + + apply(); + return handle; +}; \ No newline at end of file diff --git a/web/packages/ui/src/controls/edit-box.ts b/web/packages/ui/src/controls/edit-box.ts new file mode 100644 index 00000000..79f4cff9 --- /dev/null +++ b/web/packages/ui/src/controls/edit-box.ts @@ -0,0 +1,310 @@ +import type { UIRuntime } from '../runtime'; +import type { UIKeyEvent, UIPointerEvent } from '../types'; +import type { UIEditBoxHandle, UIEditBoxOptions } from './types'; +import { DEFAULT_SELECTION_COLOR, attachToParent, clampIndex, createTextBlock, disposeWidget } from './internals'; +import { resolveTheme } from './theme'; + +export const createUIEditBox = ( + runtime: UIRuntime, + options: UIEditBoxOptions = {} +): UIEditBoxHandle => { + const theme = resolveTheme(options.theme); + const textOverrides = options.text ?? {}; + const state = { + value: options.value ?? '', + placeholder: options.placeholder ?? 'Type here', + multiline: options.multiline ?? false, + password: options.password ?? false, + readOnly: options.readOnly ?? false, + disabled: options.disabled ?? false, + hovered: false, + focused: false, + caretIndex: 0, + anchorIndex: null as number | null, + onChange: options.onChange, + onSubmit: options.onSubmit, + }; + state.caretIndex = state.value.length; + + let handle: UIEditBoxHandle; + + const getSelectionBounds = (): readonly [number, number] | null => { + if (state.anchorIndex === null || state.anchorIndex === state.caretIndex) { + return null; + } + return state.anchorIndex < state.caretIndex + ? [state.anchorIndex, state.caretIndex] + : [state.caretIndex, state.anchorIndex]; + }; + + const clearSelection = (): void => { + state.anchorIndex = null; + }; + + const commitTextChange = (): void => { + state.onChange?.(state.value, handle); + apply(); + }; + + const replaceSelection = (text: string): void => { + const bounds = getSelectionBounds(); + const start = bounds ? bounds[0] : state.caretIndex; + const end = bounds ? bounds[1] : state.caretIndex; + state.value = `${state.value.slice(0, start)}${text}${state.value.slice(end)}`; + state.caretIndex = start + text.length; + clearSelection(); + commitTextChange(); + }; + + const setCaretInternal = (index: number, extend = false): void => { + const nextIndex = clampIndex(index, state.value.length); + if (extend) { + state.anchorIndex ??= state.caretIndex; + } else { + clearSelection(); + } + state.caretIndex = nextIndex; + apply(); + }; + + const deleteBackward = (): void => { + if (state.readOnly || state.disabled) { + return; + } + const bounds = getSelectionBounds(); + if (bounds) { + replaceSelection(''); + return; + } + if (state.caretIndex <= 0) { + return; + } + state.value = `${state.value.slice(0, state.caretIndex - 1)}${state.value.slice(state.caretIndex)}`; + state.caretIndex -= 1; + commitTextChange(); + }; + + const deleteForward = (): void => { + if (state.readOnly || state.disabled) { + return; + } + const bounds = getSelectionBounds(); + if (bounds) { + replaceSelection(''); + return; + } + if (state.caretIndex >= state.value.length) { + return; + } + state.value = `${state.value.slice(0, state.caretIndex)}${state.value.slice(state.caretIndex + 1)}`; + commitTextChange(); + }; + + const resolveDisplayValue = (): string => { + if (state.password) { + return '*'.repeat(state.value.length); + } + return state.value; + }; + + const setCaretFromPointer = (event: Readonly, extend = false): void => { + const displayValue = resolveDisplayValue(); + if (displayValue.length === 0) { + setCaretInternal(0, extend); + return; + } + const layout = runtime.getTextLayout(root); + const box = runtime.getLayoutBox(root); + if (!layout || layout.carets.length === 0) { + setCaretInternal(displayValue.length, extend); + return; + } + + let bestIndex = layout.carets[0].index; + let bestDistance = Number.POSITIVE_INFINITY; + for (const caret of layout.carets) { + const dx = box.contentX + caret.x - event.x; + const dy = box.contentY + caret.y + caret.height * 0.5 - event.y; + const distance = dx * dx + dy * dy; + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = caret.index; + } + } + setCaretInternal(bestIndex, extend); + }; + + const root = runtime.createWidget({ + role: 'input', + key: options.key, + enabled: !state.disabled, + interactive: !state.disabled, + focus: { + focusable: !state.disabled, + ...(options.focus ?? {}), + }, + layout: { + width: 260, + height: state.multiline ? 96 : theme.controlHeight, + padding: [10, 12], + ...(options.layout ?? {}), + }, + handlers: { + pointerEnter: () => { + if (state.disabled) { + return false; + } + state.hovered = true; + apply(); + return true; + }, + pointerLeave: () => { + if (state.disabled) { + return false; + } + state.hovered = false; + apply(); + return true; + }, + pointerDown: (event) => { + if (state.disabled) { + return false; + } + runtime.setFocus(root, 'pointer'); + setCaretFromPointer(event, Boolean(event.shiftKey)); + return true; + }, + keyDown: (event: Readonly) => { + if (state.disabled) { + return false; + } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { + state.anchorIndex = 0; + state.caretIndex = state.value.length; + apply(); + return true; + } + switch (event.key) { + case 'ArrowLeft': + setCaretInternal(state.caretIndex - 1, Boolean(event.shiftKey)); + return true; + case 'ArrowRight': + setCaretInternal(state.caretIndex + 1, Boolean(event.shiftKey)); + return true; + case 'Home': + setCaretInternal(0, Boolean(event.shiftKey)); + return true; + case 'End': + setCaretInternal(state.value.length, Boolean(event.shiftKey)); + return true; + case 'Backspace': + deleteBackward(); + return true; + case 'Delete': + deleteForward(); + return true; + case 'Enter': + if (state.multiline) { + replaceSelection('\n'); + } else { + state.onSubmit?.(state.value, handle); + } + return true; + default: + return false; + } + }, + textInput: (event) => { + if (state.readOnly || state.disabled) { + return true; + } + replaceSelection(event.text); + return true; + }, + focus: () => { + state.focused = true; + apply(); + }, + blur: () => { + state.focused = false; + clearSelection(); + apply(); + }, + }, + }); + + attachToParent(runtime, options.parent, root); + + const apply = (): void => { + const displayValue = resolveDisplayValue(); + const placeholderVisible = displayValue.length === 0; + const selection = state.focused && !placeholderVisible ? getSelectionBounds() : null; + const textColor = placeholderVisible ? theme.placeholderColor : textOverrides.color ?? theme.textColor; + + runtime.updateWidget(root, { + enabled: !state.disabled, + interactive: !state.disabled, + focus: { + focusable: !state.disabled, + ...(options.focus ?? {}), + }, + style: { + ...(options.style ?? {}), + background: state.disabled ? theme.surfaceDisabledColor : options.style?.background ?? theme.surfaceColor, + borderColor: state.focused ? theme.focusColor : state.hovered ? theme.borderColor : theme.borderMutedColor, + borderWidth: state.focused ? theme.borderWidth + 1 : options.style?.borderWidth ?? theme.borderWidth, + radius: options.style?.radius ?? theme.controlRadius, + clip: true, + color: textColor, + }, + text: createTextBlock(runtime, placeholderVisible ? state.placeholder : displayValue, theme, { + wrap: state.multiline ? 'word' : 'none', + overflow: state.multiline ? 'clip' : 'ellipsis', + ...(textOverrides ?? {}), + color: textColor, + selectionStart: selection?.[0], + selectionEnd: selection?.[1], + selectionColor: textOverrides.selectionColor ?? DEFAULT_SELECTION_COLOR, + caretIndex: state.focused && !placeholderVisible ? state.caretIndex : undefined, + caretColor: theme.textColor, + caretWidth: textOverrides.caretWidth ?? 2, + }, textColor), + }); + }; + + handle = { + root, + getValue() { + return state.value; + }, + setValue(value) { + state.value = value; + state.caretIndex = clampIndex(state.caretIndex, state.value.length); + clearSelection(); + apply(); + }, + setDisabled(disabled) { + state.disabled = disabled; + state.hovered = false; + apply(); + }, + setReadOnly(readOnly) { + state.readOnly = readOnly; + apply(); + }, + setSelection(start, end) { + state.anchorIndex = clampIndex(start, state.value.length); + state.caretIndex = clampIndex(end, state.value.length); + apply(); + }, + setCaret(index) { + setCaretInternal(index, false); + }, + dispose() { + disposeWidget(runtime, root); + }, + }; + + apply(); + return handle; +}; \ No newline at end of file diff --git a/web/packages/ui/src/controls/internals.ts b/web/packages/ui/src/controls/internals.ts new file mode 100644 index 00000000..eb466bcc --- /dev/null +++ b/web/packages/ui/src/controls/internals.ts @@ -0,0 +1,131 @@ +import type { UIRuntime } from '../runtime'; +import type { ColorInput, TextBlockInput, WidgetId } from '../types'; +import type { UIControlTheme, UIHandle, UIParentTarget, UISlotHandle } from './types'; + +export const DEFAULT_SELECTION_COLOR = '#2563eb55'; + +export const clamp = (value: number, min: number, max: number): number => { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; +}; + +export const resolveParentWidget = (runtime: UIRuntime, parent: UIParentTarget): WidgetId => { + if (parent === null || parent === undefined) { + return runtime.root; + } + if (typeof parent === 'number') { + return parent as WidgetId; + } + if ('content' in parent) { + return parent.content; + } + return parent.root; +}; + +export const attachToParent = (runtime: UIRuntime, parent: UIParentTarget, widget: WidgetId): void => { + runtime.appendChild(resolveParentWidget(runtime, parent), widget); +}; + +export const disposeWidget = (runtime: UIRuntime, widget: WidgetId): void => { + try { + runtime.removeWidget(widget); + } catch { + return; + } +}; + +export const resolveFontFamily = ( + runtime: UIRuntime, + theme: UIControlTheme, + override?: string +): string => override ?? theme.fontFamily ?? runtime.fonts.getDefaultFamily() ?? ''; + +export const countStepDecimals = (step: number): number => { + if (!Number.isFinite(step) || step <= 0) { + return 0; + } + const parts = step.toString().split('.'); + return parts[1]?.length ?? 0; +}; + +export const formatNumericValue = (value: number, step = 0.1): string => { + const decimals = clamp(countStepDecimals(step), 0, 3); + const rounded = Number.parseFloat(value.toFixed(decimals)); + return Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toString(); +}; + +export const clampIndex = (value: number, max: number): number => clamp(Math.floor(value), 0, max); + +export const normalizeRange = (min: number, max: number): { readonly min: number; readonly max: number } => { + if (max >= min) { + return { min, max }; + } + return { min: max, max: min }; +}; + +export const normalizeSteppedValue = (value: number, min: number, max: number, step: number): number => { + const normalizedStep = Number.isFinite(step) && step > 0 ? step : 1; + const clamped = clamp(value, min, max); + const snapped = Math.round((clamped - min) / normalizedStep) * normalizedStep + min; + return clamp(Number.parseFloat(snapped.toFixed(6)), min, max); +}; + +export const createTextBlock = ( + runtime: UIRuntime, + value: string, + theme: UIControlTheme, + override: Partial> | undefined, + fallbackColor: ColorInput +): TextBlockInput => ({ + value, + family: resolveFontFamily(runtime, theme, override?.family), + size: override?.size ?? theme.fontSize, + color: override?.color ?? fallbackColor, + wrap: override?.wrap ?? 'word', + overflow: override?.overflow ?? 'ellipsis', + align: override?.align ?? 'start', + maxLines: override?.maxLines, + lineHeight: override?.lineHeight, + letterSpacing: override?.letterSpacing, + weight: override?.weight, + style: override?.style, + locale: override?.locale, + direction: override?.direction, + outlineColor: override?.outlineColor, + outlineWidth: override?.outlineWidth, + edgeSoftness: override?.edgeSoftness, + shadowColor: override?.shadowColor, + shadowOffsetX: override?.shadowOffsetX, + shadowOffsetY: override?.shadowOffsetY, + underline: override?.underline, + underlineColor: override?.underlineColor, + underlineThickness: override?.underlineThickness, + underlineOffset: override?.underlineOffset, + strikeThrough: override?.strikeThrough, + strikeThroughColor: override?.strikeThroughColor, + strikeThroughThickness: override?.strikeThroughThickness, + selectionStart: override?.selectionStart, + selectionEnd: override?.selectionEnd, + selectionColor: override?.selectionColor, + caretIndex: override?.caretIndex, + caretColor: override?.caretColor, + caretWidth: override?.caretWidth, + caretInset: override?.caretInset, +}); + +export const isPointInside = (runtime: UIRuntime, widget: WidgetId, x: number, y: number): boolean => { + const box = runtime.getLayoutBox(widget); + return x >= box.x && y >= box.y && x <= box.x + box.width && y <= box.y + box.height; +}; + +export const createBaseHandle = (runtime: UIRuntime, root: WidgetId): UIHandle => ({ + root, + dispose() { + disposeWidget(runtime, root); + }, +}); diff --git a/web/packages/ui/src/controls/layout-factories.ts b/web/packages/ui/src/controls/layout-factories.ts new file mode 100644 index 00000000..5fb02dd5 --- /dev/null +++ b/web/packages/ui/src/controls/layout-factories.ts @@ -0,0 +1,26 @@ +import type { AnchorInput, WidgetLayoutInput } from '../types'; + +export const createStackLayout = ( + direction: 'row' | 'column' = 'column', + gap = 0, + overrides: WidgetLayoutInput = {} +): WidgetLayoutInput => ({ + display: 'stack', + direction, + gap, + ...overrides, +}); + +export const createOverlayLayout = (overrides: WidgetLayoutInput = {}): WidgetLayoutInput => ({ + display: 'overlay', + ...overrides, +}); + +export const createAnchoredLayout = ( + anchor: AnchorInput = 'top-left', + overrides: WidgetLayoutInput = {} +): WidgetLayoutInput => ({ + position: 'absolute', + anchor, + ...overrides, +}); \ No newline at end of file diff --git a/web/packages/ui/src/controls/page-view.ts b/web/packages/ui/src/controls/page-view.ts new file mode 100644 index 00000000..604f8ba5 --- /dev/null +++ b/web/packages/ui/src/controls/page-view.ts @@ -0,0 +1,178 @@ +import type { UIRuntime } from '../runtime'; +import type { WidgetId } from '../types'; +import type { UIPageViewHandle, UIPageViewOptions } from './types'; +import { attachToParent, clamp, disposeWidget } from './internals'; +import { resolveTheme } from './theme'; + +export const createUIPageView = ( + runtime: UIRuntime, + options: UIPageViewOptions = {} +): UIPageViewHandle => { + const theme = resolveTheme(options.theme); + const indicatorSize = Math.max(8, Math.round(theme.controlHeight * 0.24)); + const state = { + page: Math.max(0, options.page ?? 0), + disabled: options.disabled ?? false, + showIndicators: options.showIndicators ?? true, + }; + const pages: WidgetId[] = []; + const indicatorDots: WidgetId[] = []; + + const root = runtime.createWidget({ + role: 'custom:page-view', + key: options.key, + enabled: !state.disabled, + interactive: !state.disabled, + focus: { + focusable: !state.disabled, + ...(options.focus ?? {}), + }, + layout: { + display: 'overlay', + width: 320, + height: 200, + ...(options.layout ?? {}), + }, + style: { + background: options.style?.background ?? theme.panelColor, + borderColor: options.style?.borderColor ?? theme.borderColor, + borderWidth: options.style?.borderWidth ?? theme.borderWidth, + radius: options.style?.radius ?? theme.controlRadius, + clip: true, + ...(options.style ?? {}), + }, + handlers: { + keyDown: (event) => { + if (state.disabled || pages.length === 0) { + return false; + } + if (event.key === 'ArrowLeft' || event.key === 'PageUp') { + setPageInternal(state.page - 1); + return true; + } + if (event.key === 'ArrowRight' || event.key === 'PageDown') { + setPageInternal(state.page + 1); + return true; + } + return false; + }, + }, + }); + const content = runtime.createWidget({ + role: 'container:page-view-content', + layout: { + position: 'absolute', + anchor: 'stretch', + display: 'overlay', + }, + }); + const indicators = runtime.createWidget({ + role: 'container:page-view-indicators', + layout: { + position: 'absolute', + anchor: 'bottom', + inset: { bottom: Math.max(10, Math.round(theme.controlHeight * 0.28)) }, + display: 'stack', + direction: 'row', + gap: Math.max(6, Math.round(theme.controlHeight * 0.18)), + justifyContent: 'center', + alignItems: 'center', + width: 'content', + height: 'content', + }, + }); + + attachToParent(runtime, options.parent, root); + runtime.appendChild(root, content); + if (state.showIndicators) { + runtime.appendChild(root, indicators); + } + + const updateIndicators = (): void => { + if (!state.showIndicators) { + return; + } + while (indicatorDots.length < pages.length) { + const dotIndex = indicatorDots.length; + const dot = runtime.createWidget({ + role: 'custom:page-view-dot', + interactive: true, + focus: { focusable: false }, + layout: { + width: indicatorSize, + height: indicatorSize, + shrink: 0, + }, + handlers: { + pointerUp: () => { + setPageInternal(dotIndex); + return true; + }, + }, + }); + indicatorDots.push(dot); + runtime.appendChild(indicators, dot); + } + for (let index = 0; index < indicatorDots.length; index += 1) { + runtime.updateWidget(indicatorDots[index], { + style: { + background: index === state.page ? theme.accentColor : theme.surfaceRaisedColor, + borderColor: index === state.page ? theme.focusColor : theme.borderColor, + borderWidth: 1, + radius: 999, + opacity: index === state.page ? 1 : 0.85, + }, + }); + } + }; + + const setPageInternal = (index: number): void => { + if (pages.length === 0) { + state.page = 0; + return; + } + state.page = clamp(index, 0, pages.length - 1); + for (let pageIndex = 0; pageIndex < pages.length; pageIndex += 1) { + runtime.updateWidget(pages[pageIndex], { + layout: { + position: 'absolute', + anchor: 'stretch', + }, + style: { + visible: pageIndex === state.page, + }, + }); + } + updateIndicators(); + }; + + updateIndicators(); + + return { + root, + content, + getPage() { + return state.page; + }, + setPage(index) { + setPageInternal(index); + }, + addPage(page) { + pages.push(page); + runtime.appendChild(content, page); + setPageInternal(state.page); + return pages.length - 1; + }, + next() { + setPageInternal(state.page + 1); + return state.page; + }, + previous() { + setPageInternal(state.page - 1); + return state.page; + }, + dispose() { + disposeWidget(runtime, root); + }, + }; +}; \ No newline at end of file diff --git a/web/packages/ui/src/controls/primitives.ts b/web/packages/ui/src/controls/primitives.ts new file mode 100644 index 00000000..1991bdc3 --- /dev/null +++ b/web/packages/ui/src/controls/primitives.ts @@ -0,0 +1,151 @@ +import type { UIRuntime } from '../runtime'; +import type { TextBlockInput } from '../types'; +import type { + UICanvasHandle, + UICanvasOptions, + UIBaseOptions, + UILayoutHandle, + UILayoutOptions, + UIRichTextHandle, + UIRichTextOptions, + UIWidgetHandle, +} from './types'; +import { attachToParent, createTextBlock, disposeWidget } from './internals'; +import { resolveTheme } from './theme'; + +export const createUIWidget = ( + runtime: UIRuntime, + options: UIBaseOptions = {} +): UIWidgetHandle => { + const root = runtime.createWidget({ + role: options.role ?? 'container:widget', + key: options.key, + layout: options.layout, + style: options.style, + enabled: options.enabled, + interactive: options.interactive, + focus: options.focus, + }); + + attachToParent(runtime, options.parent, root); + + return { + root, + content: root, + update(patch) { + runtime.updateWidget(root, patch); + }, + dispose() { + disposeWidget(runtime, root); + }, + }; +}; + +export const createUILayout = ( + runtime: UIRuntime, + options: UILayoutOptions = {} +): UILayoutHandle => { + const widget = createUIWidget(runtime, { + ...options, + role: options.role ?? 'container:layout', + layout: { + display: 'stack', + direction: 'column', + gap: 12, + ...(options.layout ?? {}), + }, + }); + + return widget; +}; + +export const createUICanvas = ( + runtime: UIRuntime, + options: UICanvasOptions = {} +): UICanvasHandle => { + const theme = resolveTheme(options.theme); + const widget = createUIWidget(runtime, { + ...options, + role: options.role ?? 'container:canvas', + focus: { + scope: true, + cycle: true, + ...(options.focus ?? {}), + }, + layout: { + display: 'overlay', + width: '100%', + height: '100%', + ...(options.layout ?? {}), + }, + style: { + background: options.style?.background ?? theme.canvasColor, + ...(options.style ?? {}), + }, + }); + + return widget; +}; + +export const createUIRichText = ( + runtime: UIRuntime, + options: UIRichTextOptions = {} +): UIRichTextHandle => { + const theme = resolveTheme(undefined); + let textStyle: Partial> = { ...(options.text ?? {}) }; + let value = options.value ?? ''; + + const root = runtime.createWidget({ + role: options.role ?? 'text:rich', + key: options.key, + layout: { + width: 'content', + height: 'content', + ...(options.layout ?? {}), + }, + style: { + color: options.style?.color ?? theme.textColor, + ...(options.style ?? {}), + }, + text: createTextBlock( + runtime, + value, + theme, + textStyle, + options.style?.color ?? theme.textColor + ), + enabled: options.enabled, + interactive: options.interactive, + focus: options.focus, + }); + + attachToParent(runtime, options.parent, root); + + const apply = (): void => { + runtime.updateWidget(root, { + text: createTextBlock(runtime, value, theme, textStyle, options.style?.color ?? theme.textColor), + style: { + ...(options.style ?? {}), + color: options.style?.color ?? theme.textColor, + }, + }); + }; + + return { + root, + getText() { + return value; + }, + setText(nextValue) { + value = nextValue; + apply(); + }, + updateText(patch) { + textStyle = { ...textStyle, ...patch }; + apply(); + }, + dispose() { + disposeWidget(runtime, root); + }, + }; +}; \ No newline at end of file diff --git a/web/packages/ui/src/controls/progress-bar.ts b/web/packages/ui/src/controls/progress-bar.ts new file mode 100644 index 00000000..9ccb9dce --- /dev/null +++ b/web/packages/ui/src/controls/progress-bar.ts @@ -0,0 +1,135 @@ +import type { UIRuntime } from '../runtime'; +import type { PercentageString } from '../types'; +import type { UIProgressBarHandle, UIProgressBarOptions } from './types'; +import { attachToParent, clamp, createTextBlock, disposeWidget, formatNumericValue, normalizeRange } from './internals'; +import { resolveTheme, resolveVariantPalette } from './theme'; + +export const createUIProgressBar = ( + runtime: UIRuntime, + options: UIProgressBarOptions = {} +): UIProgressBarHandle => { + const theme = resolveTheme(options.theme); + const trackHeight = Math.max(8, Math.round(theme.controlHeight * 0.34)); + const range = normalizeRange(options.min ?? 0, options.max ?? 1); + const state = { + label: options.label ?? 'Progress', + min: range.min, + max: range.max, + value: clamp(options.value ?? range.min, range.min, range.max), + showValue: options.showValue ?? true, + variant: options.variant ?? 'primary', + }; + const root = runtime.createWidget({ + role: 'custom:progress-bar', + key: options.key, + layout: { + display: 'stack', + direction: 'column', + gap: 8, + width: 260, + ...(options.layout ?? {}), + }, + style: { + background: options.style?.background ?? '#00000000', + ...(options.style ?? {}), + }, + }); + const labelWidget = runtime.createWidget({ + role: 'text', + layout: { + width: 'content', + height: 'content', + }, + style: { + color: theme.textColor, + }, + }); + const track = runtime.createWidget({ + role: 'custom:progress-track', + layout: { + width: '100%', + height: trackHeight, + display: 'overlay', + }, + }); + const fill = runtime.createWidget({ + role: 'custom:progress-fill', + layout: { + position: 'absolute', + anchor: 'left', + inset: { left: 0, top: 0, bottom: 0 }, + width: '0%', + }, + }); + + attachToParent(runtime, options.parent, root); + if (state.showValue || state.label) { + runtime.appendChild(root, labelWidget); + } + runtime.appendChild(root, track); + runtime.appendChild(track, fill); + + const apply = (): void => { + const palette = resolveVariantPalette(theme, state.variant); + const percent = state.max === state.min ? 0 : (state.value - state.min) / (state.max - state.min); + const percentString = `${clamp(percent * 100, 0, 100)}%` as PercentageString; + const valueText = state.showValue ? `${formatNumericValue(state.value, 0.01)} / ${formatNumericValue(state.max, 0.01)}` : ''; + const labelText = state.label && state.showValue ? `${state.label} ${valueText}` : state.label || valueText; + + if (state.showValue || state.label) { + runtime.updateWidget(labelWidget, { + text: createTextBlock(runtime, labelText, theme, { wrap: 'none' }, theme.textColor), + }); + } + + runtime.updateWidget(track, { + style: { + background: theme.trackColor, + borderColor: theme.borderMutedColor, + borderWidth: theme.borderWidth, + radius: 999, + }, + }); + runtime.updateWidget(fill, { + layout: { + position: 'absolute', + anchor: 'left', + inset: { left: 0, top: 0, bottom: 0 }, + width: percentString, + }, + style: { + background: palette.idle, + borderColor: '#00000000', + borderWidth: 0, + radius: 999, + }, + }); + }; + + apply(); + + return { + root, + getValue() { + return state.value; + }, + setValue(value) { + state.value = clamp(value, state.min, state.max); + apply(); + }, + setRange(min, max) { + const next = normalizeRange(min, max); + state.min = next.min; + state.max = next.max; + state.value = clamp(state.value, state.min, state.max); + apply(); + }, + setLabel(label) { + state.label = label; + apply(); + }, + dispose() { + disposeWidget(runtime, root); + }, + }; +}; \ No newline at end of file diff --git a/web/packages/ui/src/controls/scroll-view.ts b/web/packages/ui/src/controls/scroll-view.ts new file mode 100644 index 00000000..216850eb --- /dev/null +++ b/web/packages/ui/src/controls/scroll-view.ts @@ -0,0 +1,162 @@ +import type { UIRuntime } from '../runtime'; +import type { UIScrollViewHandle, UIScrollViewOptions } from './types'; +import { attachToParent, clamp, disposeWidget } from './internals'; +import { resolveTheme } from './theme'; + +export const createUIScrollView = ( + runtime: UIRuntime, + options: UIScrollViewOptions = {} +): UIScrollViewHandle => { + const theme = resolveTheme(options.theme); + const state = { + scrollX: Math.max(0, options.scrollX ?? 0), + scrollY: Math.max(0, options.scrollY ?? 0), + disabled: options.disabled ?? false, + }; + + const root = runtime.createWidget({ + role: 'custom:scroll-view', + key: options.key, + enabled: !state.disabled, + interactive: !state.disabled, + focus: { + focusable: !state.disabled, + ...(options.focus ?? {}), + }, + layout: { + display: 'overlay', + width: 320, + height: 200, + ...(options.layout ?? {}), + }, + style: { + background: options.style?.background ?? theme.panelColor, + borderColor: options.style?.borderColor ?? theme.borderColor, + borderWidth: options.style?.borderWidth ?? theme.borderWidth, + radius: options.style?.radius ?? theme.controlRadius, + clip: true, + ...(options.style ?? {}), + }, + handlers: { + wheel: (event) => { + if (state.disabled) { + return false; + } + state.scrollX = Math.max(0, state.scrollX + (event.deltaX ?? 0)); + state.scrollY = Math.max(0, state.scrollY + (event.deltaY ?? 0)); + applyOffsets(); + return true; + }, + keyDown: (event) => { + if (state.disabled) { + return false; + } + switch (event.key) { + case 'PageDown': + state.scrollY = Math.max(0, state.scrollY + 64); + applyOffsets(); + return true; + case 'PageUp': + state.scrollY = Math.max(0, state.scrollY - 64); + applyOffsets(); + return true; + case 'Home': + state.scrollY = 0; + state.scrollX = 0; + applyOffsets(); + return true; + case 'End': + clampToBoundsInternal(); + applyOffsets(); + return true; + default: + return false; + } + }, + }, + }); + const content = runtime.createWidget({ + role: 'container:scroll-content', + layout: { + position: 'absolute', + inset: { top: 0, left: 0 }, + width: '100%', + height: 'content', + display: 'stack', + direction: 'column', + gap: 10, + ...(options.contentLayout ?? {}), + contentOffsetX: state.scrollX, + contentOffsetY: state.scrollY, + }, + style: { + background: options.contentStyle?.background ?? '#00000000', + ...(options.contentStyle ?? {}), + }, + }); + + attachToParent(runtime, options.parent, root); + runtime.appendChild(root, content); + + const applyOffsets = (): void => { + runtime.updateWidget(root, { + enabled: !state.disabled, + interactive: !state.disabled, + focus: { + focusable: !state.disabled, + ...(options.focus ?? {}), + }, + }); + runtime.updateWidget(content, { + layout: { + ...(options.contentLayout ?? {}), + position: 'absolute', + inset: { top: 0, left: 0 }, + width: options.contentLayout?.width ?? '100%', + height: options.contentLayout?.height ?? 'content', + display: options.contentLayout?.display ?? 'stack', + direction: options.contentLayout?.direction ?? 'column', + gap: options.contentLayout?.gap ?? 10, + contentOffsetX: state.scrollX, + contentOffsetY: state.scrollY, + }, + }); + }; + + const clampToBoundsInternal = (): void => { + const viewport = runtime.getLayoutBox(root); + const contentBox = runtime.getLayoutBox(content); + if (viewport.contentWidth <= 0 || viewport.contentHeight <= 0) { + return; + } + state.scrollX = clamp(state.scrollX, 0, Math.max(0, contentBox.width - viewport.contentWidth)); + state.scrollY = clamp(state.scrollY, 0, Math.max(0, contentBox.height - viewport.contentHeight)); + }; + + applyOffsets(); + + return { + root, + content, + getScroll() { + return { x: state.scrollX, y: state.scrollY }; + }, + setScroll(x, y) { + state.scrollX = Math.max(0, x); + state.scrollY = Math.max(0, y); + applyOffsets(); + }, + scrollBy(deltaX, deltaY) { + state.scrollX = Math.max(0, state.scrollX + deltaX); + state.scrollY = Math.max(0, state.scrollY + deltaY); + applyOffsets(); + }, + clampToBounds() { + clampToBoundsInternal(); + applyOffsets(); + }, + dispose() { + disposeWidget(runtime, root); + }, + }; +}; \ No newline at end of file diff --git a/web/packages/ui/src/controls/slider.ts b/web/packages/ui/src/controls/slider.ts new file mode 100644 index 00000000..6bd9a417 --- /dev/null +++ b/web/packages/ui/src/controls/slider.ts @@ -0,0 +1,314 @@ +import type { UIRuntime } from '../runtime'; +import type { UIKeyEvent, UIPointerEvent } from '../types'; +import type { UISliderHandle, UISliderOptions } from './types'; +import { + attachToParent, + clamp, + createTextBlock, + disposeWidget, + formatNumericValue, + isPointInside, + normalizeRange, + normalizeSteppedValue, +} from './internals'; +import { resolveTheme, resolveVariantPalette } from './theme'; + +export const createUISlider = ( + runtime: UIRuntime, + options: UISliderOptions = {} +): UISliderHandle => { + const theme = resolveTheme(options.theme); + const trackHeight = Math.max(12, Math.round(theme.controlHeight * 0.48)); + const thumbSize = Math.max(14, Math.round(trackHeight - 2)); + const range = normalizeRange(options.min ?? 0, options.max ?? 1); + const state = { + label: options.label ?? '', + min: range.min, + max: range.max, + step: options.step ?? 0.01, + value: normalizeSteppedValue(options.value ?? range.min, range.min, range.max, options.step ?? 0.01), + showValue: options.showValue ?? true, + disabled: options.disabled ?? false, + dragging: false, + focused: false, + variant: options.variant ?? 'primary', + onChange: options.onChange, + }; + + const root = runtime.createWidget({ + role: 'custom:slider', + key: options.key, + enabled: !state.disabled, + interactive: !state.disabled, + focus: { + focusable: !state.disabled, + ...(options.focus ?? {}), + }, + layout: { + display: 'stack', + direction: 'column', + gap: 8, + width: 260, + ...(options.layout ?? {}), + }, + style: { + background: options.style?.background ?? '#00000000', + ...(options.style ?? {}), + }, + }); + const header = runtime.createWidget({ + role: 'text', + }); + const track = runtime.createWidget({ + role: 'custom:slider-track', + layout: { + width: '100%', + height: trackHeight, + display: 'overlay', + }, + }); + const rail = runtime.createWidget({ + role: 'custom:slider-rail', + layout: { + position: 'absolute', + anchor: { + x: 0, + y: 0.5, + pivotX: 0, + pivotY: 0.5, + }, + inset: { left: 0, right: 0 }, + height: 6, + }, + }); + const fill = runtime.createWidget({ + role: 'custom:slider-fill', + layout: { + position: 'absolute', + anchor: { + x: 0, + y: 0.5, + pivotX: 0, + pivotY: 0.5, + }, + inset: { left: 0 }, + width: '0%', + height: Math.max(4, Math.round(trackHeight * 0.34)), + }, + }); + const thumb = runtime.createWidget({ + role: 'custom:slider-thumb', + layout: { + position: 'absolute', + width: thumbSize, + height: thumbSize, + }, + }); + + attachToParent(runtime, options.parent, root); + if (state.label || state.showValue) { + runtime.appendChild(root, header); + } + runtime.appendChild(root, track); + runtime.appendChild(track, rail); + runtime.appendChild(track, fill); + runtime.appendChild(track, thumb); + + let handle: UISliderHandle; + + const setValueInternal = (value: number, emit: boolean): void => { + const nextValue = normalizeSteppedValue(value, state.min, state.max, state.step); + if (nextValue === state.value) { + apply(); + return; + } + state.value = nextValue; + if (emit) { + state.onChange?.(state.value, handle); + } + apply(); + }; + + const updateFromPointer = (event: Readonly): void => { + const box = runtime.getLayoutBox(track); + const ratio = clamp((event.x - box.x) / Math.max(box.width, 1), 0, 1); + setValueInternal(state.min + (state.max - state.min) * ratio, true); + }; + + const apply = (): void => { + const palette = resolveVariantPalette(theme, state.variant); + const ratio = state.max === state.min ? 0 : (state.value - state.min) / (state.max - state.min); + const percent = clamp(ratio * 100, 0, 100); + const headerText = state.label && state.showValue + ? `${state.label}: ${formatNumericValue(state.value, state.step)}` + : state.label || (state.showValue ? formatNumericValue(state.value, state.step) : ''); + + runtime.updateWidget(root, { + enabled: !state.disabled, + interactive: !state.disabled, + focus: { + focusable: !state.disabled, + ...(options.focus ?? {}), + }, + handlers: { + pointerDown: (event) => { + if (state.disabled) { + return false; + } + runtime.setFocus(root, 'pointer'); + if (isPointInside(runtime, track, event.x, event.y)) { + state.dragging = true; + updateFromPointer(event); + } + return true; + }, + pointerMove: (event) => { + if (!state.dragging || state.disabled) { + return false; + } + updateFromPointer(event); + return true; + }, + pointerUp: (event) => { + if (!state.dragging || state.disabled) { + return false; + } + updateFromPointer(event); + state.dragging = false; + return true; + }, + keyDown: (event: Readonly) => { + if (state.disabled) { + return false; + } + switch (event.key) { + case 'ArrowLeft': + case 'ArrowDown': + setValueInternal(state.value - state.step, true); + return true; + case 'ArrowRight': + case 'ArrowUp': + setValueInternal(state.value + state.step, true); + return true; + case 'Home': + setValueInternal(state.min, true); + return true; + case 'End': + setValueInternal(state.max, true); + return true; + case 'PageDown': + setValueInternal(state.value - state.step * 10, true); + return true; + case 'PageUp': + setValueInternal(state.value + state.step * 10, true); + return true; + default: + return false; + } + }, + focus: () => { + state.focused = true; + apply(); + }, + blur: () => { + state.focused = false; + state.dragging = false; + apply(); + }, + }, + }); + + if (state.label || state.showValue) { + runtime.updateWidget(header, { + text: createTextBlock(runtime, headerText, theme, { wrap: 'none' }, state.disabled ? theme.textMutedColor : theme.textColor), + style: { + color: state.disabled ? theme.textMutedColor : theme.textColor, + }, + }); + } + + runtime.updateWidget(track, { + style: { + background: '#00000000', + borderColor: '#00000000', + borderWidth: 0, + radius: 0, + }, + }); + runtime.updateWidget(rail, { + style: { + background: theme.trackColor, + borderColor: state.focused ? theme.focusColor : theme.borderMutedColor, + borderWidth: state.focused ? theme.borderWidth + 1 : theme.borderWidth, + radius: 999, + }, + }); + runtime.updateWidget(fill, { + layout: { + position: 'absolute', + anchor: { + x: 0, + y: 0.5, + pivotX: 0, + pivotY: 0.5, + }, + inset: { left: 0 }, + width: `${percent}%`, + height: Math.max(4, Math.round(trackHeight * 0.34)), + }, + style: { + background: state.disabled ? theme.surfaceDisabledColor : palette.idle, + borderColor: '#00000000', + borderWidth: 0, + radius: 999, + }, + }); + runtime.updateWidget(thumb, { + layout: { + position: 'absolute', + width: thumbSize, + height: thumbSize, + anchor: { + x: percent / 100, + y: 0.5, + pivotX: 0.5, + pivotY: 0.5, + }, + }, + style: { + background: state.disabled ? theme.textMutedColor : theme.thumbColor, + borderColor: state.focused ? theme.focusColor : palette.border, + borderWidth: theme.borderWidth, + radius: 999, + }, + }); + }; + + handle = { + root, + getValue() { + return state.value; + }, + setValue(value) { + setValueInternal(value, false); + }, + setRange(min, max) { + const next = normalizeRange(min, max); + state.min = next.min; + state.max = next.max; + state.value = normalizeSteppedValue(state.value, state.min, state.max, state.step); + apply(); + }, + setDisabled(disabled) { + state.disabled = disabled; + state.dragging = false; + apply(); + }, + dispose() { + disposeWidget(runtime, root); + }, + }; + + apply(); + return handle; +}; \ No newline at end of file diff --git a/web/packages/ui/src/controls/theme.ts b/web/packages/ui/src/controls/theme.ts new file mode 100644 index 00000000..a2180dc1 --- /dev/null +++ b/web/packages/ui/src/controls/theme.ts @@ -0,0 +1,108 @@ +import { + AXRONE_DEFAULT_UI_FONT_FAMILY, + createDefaultUIFontAsset, + ensureDefaultUIFont, +} from '../font'; +import type { ColorInput } from '../types'; +import type { UIControlTheme, UIControlVariant } from './types'; + +export const AXRONE_FALLBACK_UI_FONT_FAMILY = AXRONE_DEFAULT_UI_FONT_FAMILY; +export const createFallbackUIFontAsset = createDefaultUIFontAsset; +export const ensureFallbackUIFont = ensureDefaultUIFont; + +export const defaultUIControlTheme: Readonly = Object.freeze({ + fontSize: 15, + controlHeight: 44, + controlRadius: 14, + borderWidth: 1, + canvasColor: '#07101dcc', + panelColor: '#0d1728dd', + surfaceColor: '#132133f2', + surfaceRaisedColor: '#1c2d45ff', + surfaceHoverColor: '#263d5cff', + surfacePressedColor: '#101a2bff', + surfaceDisabledColor: '#132033b8', + borderColor: '#dbe7ff26', + borderMutedColor: '#dbe7ff16', + focusColor: '#60a5faff', + textColor: '#f8fbffff', + textMutedColor: '#8fa1bbff', + placeholderColor: '#72839dff', + accentColor: '#2563ebff', + accentHoverColor: '#3b82f6ff', + accentPressedColor: '#1d4ed8ff', + successColor: '#22c55eff', + successHoverColor: '#4ade80ff', + successPressedColor: '#16a34aff', + warningColor: '#f59e0bff', + warningHoverColor: '#fbbf24ff', + warningPressedColor: '#d97706ff', + dangerColor: '#ef4444ff', + dangerHoverColor: '#f87171ff', + dangerPressedColor: '#dc2626ff', + thumbColor: '#f8fbffff', + trackColor: '#0b1220ff', +}); + +export const resolveTheme = (theme: Partial | undefined): UIControlTheme => ({ + ...defaultUIControlTheme, + ...(theme ?? {}), +}); + +export const resolveThemeScale = (theme: UIControlTheme): number => + Math.max(theme.controlHeight / defaultUIControlTheme.controlHeight, 0.5); + +export const resolveVariantPalette = ( + theme: UIControlTheme, + variant: UIControlVariant +): Readonly<{ + idle: ColorInput; + hover: ColorInput; + pressed: ColorInput; + text: ColorInput; + border: ColorInput; +}> => { + switch (variant) { + case 'primary': + return { + idle: theme.accentColor, + hover: theme.accentHoverColor, + pressed: theme.accentPressedColor, + text: '#f8fbffff', + border: '#93c5fd88', + }; + case 'success': + return { + idle: theme.successColor, + hover: theme.successHoverColor, + pressed: theme.successPressedColor, + text: '#f8fbffff', + border: '#86efacaa', + }; + case 'warning': + return { + idle: theme.warningColor, + hover: theme.warningHoverColor, + pressed: theme.warningPressedColor, + text: '#140d03ff', + border: '#fcd34daa', + }; + case 'danger': + return { + idle: theme.dangerColor, + hover: theme.dangerHoverColor, + pressed: theme.dangerPressedColor, + text: '#f8fbffff', + border: '#fca5a5aa', + }; + case 'neutral': + default: + return { + idle: theme.surfaceRaisedColor, + hover: theme.surfaceHoverColor, + pressed: theme.surfacePressedColor, + text: theme.textColor, + border: theme.borderColor, + }; + } +}; diff --git a/web/packages/ui/src/controls/toggle.ts b/web/packages/ui/src/controls/toggle.ts new file mode 100644 index 00000000..8d6016b1 --- /dev/null +++ b/web/packages/ui/src/controls/toggle.ts @@ -0,0 +1,233 @@ +import type { UIRuntime } from '../runtime'; +import type { UIToggleHandle, UIToggleOptions } from './types'; +import { attachToParent, createTextBlock, disposeWidget, isPointInside } from './internals'; +import { resolveTheme, resolveThemeScale, resolveVariantPalette } from './theme'; + +export const createUIToggle = ( + runtime: UIRuntime, + options: UIToggleOptions = {} +): UIToggleHandle => { + const theme = resolveTheme(options.theme); + const themeScale = resolveThemeScale(theme); + const state = { + checked: options.checked ?? false, + disabled: options.disabled ?? false, + hovered: false, + pressed: false, + focused: false, + label: options.label ?? 'Toggle', + onChange: options.onChange, + variant: options.variant ?? 'primary', + }; + const labelPlacement = options.labelPlacement ?? 'right'; + const trackHeight = Math.max(20, Math.round(theme.controlHeight * 0.68)); + const trackWidth = Math.max(Math.round(theme.controlHeight * 1.75), Math.round(48 * themeScale)); + const thumbSize = Math.max(16, Math.round(trackHeight - 6)); + + let handle: UIToggleHandle; + + const root = runtime.createWidget({ + role: 'custom:toggle', + key: options.key, + enabled: !state.disabled, + interactive: !state.disabled, + focus: { + focusable: !state.disabled, + ...(options.focus ?? {}), + }, + layout: { + display: 'stack', + direction: 'row', + alignItems: 'center', + gap: Math.max(8, Math.round(theme.controlHeight * 0.24)), + width: 'content', + height: 'content', + ...(options.layout ?? {}), + }, + style: { + background: options.style?.background ?? '#00000000', + ...(options.style ?? {}), + }, + handlers: { + pointerEnter: () => { + if (state.disabled) { + return false; + } + state.hovered = true; + apply(); + return true; + }, + pointerLeave: () => { + if (state.disabled) { + return false; + } + state.hovered = false; + apply(); + return true; + }, + pointerDown: () => { + if (state.disabled) { + return false; + } + state.pressed = true; + runtime.setFocus(root, 'pointer'); + apply(); + return true; + }, + pointerUp: (event) => { + if (state.disabled) { + return false; + } + const shouldToggle = state.pressed && isPointInside(runtime, root, event.x, event.y); + state.pressed = false; + if (shouldToggle) { + state.checked = !state.checked; + state.onChange?.(state.checked, handle); + } + apply(); + return true; + }, + keyDown: (event) => { + if (state.disabled) { + return false; + } + if ((event.key === 'Enter' || event.key === ' ') && !event.repeat) { + state.checked = !state.checked; + state.onChange?.(state.checked, handle); + apply(); + return true; + } + return false; + }, + focus: () => { + state.focused = true; + apply(); + }, + blur: () => { + state.focused = false; + state.pressed = false; + apply(); + }, + }, + }); + + const track = runtime.createWidget({ + role: 'custom:toggle-track', + layout: { + width: trackWidth, + height: trackHeight, + display: 'overlay', + shrink: 0, + }, + }); + const thumb = runtime.createWidget({ + role: 'custom:toggle-thumb', + layout: { + position: 'absolute', + width: thumbSize, + height: thumbSize, + }, + }); + const label = runtime.createWidget({ + role: 'text', + layout: { + width: 'content', + height: 'content', + }, + }); + + attachToParent(runtime, options.parent, root); + if (labelPlacement === 'left') { + runtime.appendChild(root, label); + runtime.appendChild(root, track); + } else { + runtime.appendChild(root, track); + runtime.appendChild(root, label); + } + runtime.appendChild(track, thumb); + + const apply = (): void => { + const palette = resolveVariantPalette(theme, state.variant); + const activeColor = state.checked ? palette.idle : theme.surfaceColor; + const hoverColor = state.checked ? palette.hover : theme.surfaceHoverColor; + const currentColor = state.disabled + ? theme.surfaceDisabledColor + : state.hovered || state.focused + ? hoverColor + : activeColor; + + runtime.updateWidget(root, { + enabled: !state.disabled, + interactive: !state.disabled, + focus: { + focusable: !state.disabled, + ...(options.focus ?? {}), + }, + }); + runtime.updateWidget(track, { + style: { + background: currentColor, + borderColor: state.focused ? theme.focusColor : state.checked ? palette.border : theme.borderColor, + borderWidth: state.focused ? theme.borderWidth + 1 : theme.borderWidth, + radius: 999, + }, + }); + runtime.updateWidget(thumb, { + layout: { + position: 'absolute', + width: thumbSize, + height: thumbSize, + anchor: { + x: state.checked ? 1 : 0, + y: 0.5, + pivotX: state.checked ? 1 : 0, + pivotY: 0.5, + offsetX: state.checked ? -3 : 3, + offsetY: 0, + }, + }, + style: { + background: state.disabled ? theme.textMutedColor : theme.thumbColor, + borderColor: '#00000018', + borderWidth: 1, + radius: 999, + }, + }); + runtime.updateWidget(label, { + text: createTextBlock(runtime, state.label, theme, { wrap: 'none' }, state.disabled ? theme.textMutedColor : theme.textColor), + style: { + color: state.disabled ? theme.textMutedColor : theme.textColor, + }, + }); + }; + + handle = { + root, + isChecked() { + return state.checked; + }, + setChecked(checked) { + state.checked = checked; + apply(); + }, + toggle() { + if (!state.disabled) { + state.checked = !state.checked; + state.onChange?.(state.checked, handle); + apply(); + } + }, + setDisabled(disabled) { + state.disabled = disabled; + state.pressed = false; + state.hovered = false; + apply(); + }, + dispose() { + disposeWidget(runtime, root); + }, + }; + + apply(); + return handle; +}; \ No newline at end of file diff --git a/web/packages/ui/src/controls/types.ts b/web/packages/ui/src/controls/types.ts new file mode 100644 index 00000000..34a4a14a --- /dev/null +++ b/web/packages/ui/src/controls/types.ts @@ -0,0 +1,226 @@ +import type { + ColorInput, + TextBlockInput, + WidgetFocusPolicyInput, + WidgetId, + WidgetKey, + WidgetLayoutInput, + WidgetRole, + WidgetStyleInput, +} from '../types'; + +export interface UIControlTheme { + readonly fontFamily?: string; + readonly fontSize: number; + readonly controlHeight: number; + readonly controlRadius: number; + readonly borderWidth: number; + readonly canvasColor: ColorInput; + readonly panelColor: ColorInput; + readonly surfaceColor: ColorInput; + readonly surfaceRaisedColor: ColorInput; + readonly surfaceHoverColor: ColorInput; + readonly surfacePressedColor: ColorInput; + readonly surfaceDisabledColor: ColorInput; + readonly borderColor: ColorInput; + readonly borderMutedColor: ColorInput; + readonly focusColor: ColorInput; + readonly textColor: ColorInput; + readonly textMutedColor: ColorInput; + readonly placeholderColor: ColorInput; + readonly accentColor: ColorInput; + readonly accentHoverColor: ColorInput; + readonly accentPressedColor: ColorInput; + readonly successColor: ColorInput; + readonly successHoverColor: ColorInput; + readonly successPressedColor: ColorInput; + readonly warningColor: ColorInput; + readonly warningHoverColor: ColorInput; + readonly warningPressedColor: ColorInput; + readonly dangerColor: ColorInput; + readonly dangerHoverColor: ColorInput; + readonly dangerPressedColor: ColorInput; + readonly thumbColor: ColorInput; + readonly trackColor: ColorInput; +} + +export type UIControlVariant = 'neutral' | 'primary' | 'success' | 'warning' | 'danger'; +export type UIParentTarget = WidgetId | UIHandle | UISlotHandle | null | undefined; + +export interface UIHandle { + readonly root: WidgetId; + dispose(): void; +} + +export interface UISlotHandle extends UIHandle { + readonly content: WidgetId; +} + +export interface UIWidgetPatch { + readonly layout?: WidgetLayoutInput; + readonly style?: WidgetStyleInput; + readonly text?: TextBlockInput | null; + readonly enabled?: boolean; + readonly interactive?: boolean; + readonly focus?: WidgetFocusPolicyInput; +} + +export interface UIWidgetHandle extends UISlotHandle { + update(patch: UIWidgetPatch): void; +} + +export interface UIBaseOptions { + readonly parent?: UIParentTarget; + readonly key?: WidgetKey; + readonly role?: WidgetRole; + readonly layout?: WidgetLayoutInput; + readonly style?: WidgetStyleInput; + readonly enabled?: boolean; + readonly interactive?: boolean; + readonly focus?: WidgetFocusPolicyInput; +} + +export interface UIRichTextOptions extends UIBaseOptions { + readonly value?: string; + readonly text?: Partial>; +} + +export interface UIRichTextHandle extends UIHandle { + getText(): string; + setText(value: string): void; + updateText(patch: Partial>): void; +} + +export interface UIButtonOptions extends UIBaseOptions { + readonly label?: string; + readonly disabled?: boolean; + readonly variant?: UIControlVariant; + readonly theme?: Partial; + readonly text?: Partial>; + readonly onPress?: (handle: UIButtonHandle) => void; +} + +export interface UIButtonHandle extends UIHandle { + getLabel(): string; + setLabel(value: string): void; + isDisabled(): boolean; + setDisabled(disabled: boolean): void; + setVariant(variant: UIControlVariant): void; + setOnPress(handler: UIButtonOptions['onPress']): void; + press(): void; +} + +export interface UIToggleOptions extends UIBaseOptions { + readonly label?: string; + readonly checked?: boolean; + readonly disabled?: boolean; + readonly labelPlacement?: 'left' | 'right'; + readonly variant?: UIControlVariant; + readonly theme?: Partial; + readonly onChange?: (checked: boolean, handle: UIToggleHandle) => void; +} + +export interface UIToggleHandle extends UIHandle { + isChecked(): boolean; + setChecked(checked: boolean): void; + toggle(): void; + setDisabled(disabled: boolean): void; +} + +export interface UIProgressBarOptions extends UIBaseOptions { + readonly label?: string; + readonly value?: number; + readonly min?: number; + readonly max?: number; + readonly showValue?: boolean; + readonly variant?: UIControlVariant; + readonly theme?: Partial; +} + +export interface UIProgressBarHandle extends UIHandle { + getValue(): number; + setValue(value: number): void; + setRange(min: number, max: number): void; + setLabel(label: string): void; +} + +export interface UISliderOptions extends UIBaseOptions { + readonly label?: string; + readonly value?: number; + readonly min?: number; + readonly max?: number; + readonly step?: number; + readonly showValue?: boolean; + readonly disabled?: boolean; + readonly variant?: UIControlVariant; + readonly theme?: Partial; + readonly onChange?: (value: number, handle: UISliderHandle) => void; +} + +export interface UISliderHandle extends UIHandle { + getValue(): number; + setValue(value: number): void; + setRange(min: number, max: number): void; + setDisabled(disabled: boolean): void; +} + +export interface UIEditBoxOptions extends UIBaseOptions { + readonly value?: string; + readonly placeholder?: string; + readonly multiline?: boolean; + readonly password?: boolean; + readonly readOnly?: boolean; + readonly disabled?: boolean; + readonly theme?: Partial; + readonly text?: Partial>; + readonly onChange?: (value: string, handle: UIEditBoxHandle) => void; + readonly onSubmit?: (value: string, handle: UIEditBoxHandle) => void; +} + +export interface UIEditBoxHandle extends UIHandle { + getValue(): string; + setValue(value: string): void; + setDisabled(disabled: boolean): void; + setReadOnly(readOnly: boolean): void; + setSelection(start: number, end: number): void; + setCaret(index: number): void; +} + +export interface UIScrollViewOptions extends UIBaseOptions { + readonly scrollX?: number; + readonly scrollY?: number; + readonly disabled?: boolean; + readonly theme?: Partial; + readonly contentLayout?: WidgetLayoutInput; + readonly contentStyle?: WidgetStyleInput; +} + +export interface UIScrollViewHandle extends UISlotHandle { + getScroll(): Readonly<{ x: number; y: number }>; + setScroll(x: number, y: number): void; + scrollBy(deltaX: number, deltaY: number): void; + clampToBounds(): void; +} + +export interface UIPageViewOptions extends UIBaseOptions { + readonly page?: number; + readonly showIndicators?: boolean; + readonly disabled?: boolean; + readonly theme?: Partial; +} + +export interface UIPageViewHandle extends UISlotHandle { + getPage(): number; + setPage(index: number): void; + addPage(page: WidgetId): number; + next(): number; + previous(): number; +} + +export interface UICanvasOptions extends UIBaseOptions { + readonly theme?: Partial; +} + +export interface UICanvasHandle extends UISlotHandle {} +export interface UILayoutOptions extends UIBaseOptions {} +export interface UILayoutHandle extends UISlotHandle {} diff --git a/web/packages/ui/src/font-runtime.ts b/web/packages/ui/src/font-runtime.ts new file mode 100644 index 00000000..e6a11cc8 --- /dev/null +++ b/web/packages/ui/src/font-runtime.ts @@ -0,0 +1,436 @@ +import { FontLoadError } from './errors'; +import { + METRIC_EM_SIZE, + buildCanvasFont, + codePointToString, + createCanvasContext, + createRuntimeInfo, + getBoundingHeight, + getBoundingWidth, + normalizeStyleToken, + normalizeWeightToken, + quantizeRasterSize, + quoteFontFamilyToken, + resizeCanvas, +} from './font-runtime/internals'; +import type { CanvasLike, CanvasRenderingContext2DLike } from './font-runtime/internals'; +import type { + DynamicFontFaceRuntime, + DynamicFontGlyphRaster, + DynamicFontRuntimeFactory, + DynamicFontRuntimeInfo, + DynamicFontRuntimeSource, + FontGlyphMetric, + FontStyle, + FontWeight, +} from './types'; + +let nextRuntimeId = 1; + +export interface BrowserSystemFontFaceRuntimeOptions { + readonly family: string; + readonly cssFamily?: string; + readonly face?: string; + readonly style?: FontStyle; + readonly weight?: FontWeight; + readonly locale?: string; + readonly fallbackCodePoint?: number; + readonly atlas?: DynamicFontRuntimeInfo['atlas']; +} + +interface CachedGlyphMetric { + readonly metric: FontGlyphMetric; + readonly character: string; +} + +class BrowserDynamicFontFaceRuntime implements DynamicFontFaceRuntime { + readonly info: DynamicFontRuntimeInfo; + + private readonly familyName: string; + private readonly fontFace: FontFace; + private readonly metricContext: CanvasRenderingContext2DLike; + private readonly rasterCanvas: CanvasLike; + private readonly rasterContext: CanvasRenderingContext2DLike; + private readonly glyphs = new Map(); + private readonly kerningCache = new Map(); + private disposed = false; + + private constructor( + familyName: string, + fontFace: FontFace, + info: DynamicFontRuntimeInfo, + metricContext: CanvasRenderingContext2DLike, + rasterCanvas: CanvasLike, + rasterContext: CanvasRenderingContext2DLike + ) { + this.familyName = familyName; + this.fontFace = fontFace; + this.info = info; + this.metricContext = metricContext; + this.rasterCanvas = rasterCanvas; + this.rasterContext = rasterContext; + } + + static async create(source: DynamicFontRuntimeSource): Promise { + if (typeof FontFace === 'undefined') { + throw new FontLoadError('The current runtime does not expose the FontFace API.'); + } + const familyBase = source.source.family?.trim() || `AxroneDynamicFont${nextRuntimeId}`; + const familyName = `${familyBase}-${nextRuntimeId++}`; + const descriptors: FontFaceDescriptors = { + style: normalizeStyleToken(source.source.style), + weight: normalizeWeightToken(source.source.weight), + }; + const fontFace = new FontFace(familyName, source.bytes, descriptors); + await fontFace.load(); + if (typeof document !== 'undefined' && 'fonts' in document && document.fonts) { + document.fonts.add(fontFace); + } + const metricPair = createCanvasContext(); + const rasterPair = createCanvasContext(); + const metricContext = metricPair.context; + metricContext.textBaseline = 'alphabetic'; + metricContext.textAlign = 'left'; + const info = createRuntimeInfo(metricContext, quoteFontFamilyToken(familyName), { + family: source.source.family ?? familyBase, + face: source.source.face, + style: source.source.style, + weight: source.source.weight, + locale: source.source.locale, + fallbackCodePoint: source.source.fallbackCodePoint, + atlas: source.source.atlas, + }); + return new BrowserDynamicFontFaceRuntime( + familyName, + fontFace, + info, + metricContext, + rasterPair.canvas, + rasterPair.context + ); + } + + measureGlyph(codePoint: number): FontGlyphMetric | null { + this.ensureActive(); + const existing = this.glyphs.get(codePoint); + if (existing) { + return existing.metric; + } + const character = codePointToString(codePoint); + const unitsPerEm = this.info.unitsPerEm ?? METRIC_EM_SIZE; + const defaultAdvance = this.info.defaultAdvance ?? unitsPerEm * 0.5; + this.metricContext.font = buildCanvasFont( + unitsPerEm, + quoteFontFamilyToken(this.familyName), + this.info.style, + this.info.weight, + ); + const metrics = this.metricContext.measureText(character); + const advance = Math.max(1, Math.ceil(metrics.width || defaultAdvance)); + const glyphMetric: FontGlyphMetric = { + codePoint, + advance, + bearingX: metrics.actualBoundingBoxLeft ?? 0, + bearingY: metrics.actualBoundingBoxAscent ?? this.info.ascent, + width: Math.max(1, Math.ceil(getBoundingWidth(metrics, advance))), + height: Math.max(1, Math.ceil(getBoundingHeight(metrics, this.info.ascent + this.info.descent))), + }; + this.glyphs.set(codePoint, { metric: glyphMetric, character }); + return glyphMetric; + } + + rasterizeGlyph(codePoint: number, pixelSize: number): DynamicFontGlyphRaster | null { + this.ensureActive(); + const cached = this.glyphs.get(codePoint) ?? (() => { + const metric = this.measureGlyph(codePoint); + return metric ? this.glyphs.get(codePoint) ?? null : null; + })(); + if (!cached) { + return null; + } + const rasterSize = quantizeRasterSize(pixelSize); + const isWhitespace = /^\s$/u.test(cached.character); + if (isWhitespace) { + return { + codePoint, + rasterSize, + width: 1, + height: 1, + }; + } + const padding = Math.max(2, Math.ceil(rasterSize * 0.125)); + this.rasterContext.font = buildCanvasFont( + rasterSize, + quoteFontFamilyToken(this.familyName), + this.info.style, + this.info.weight, + ); + this.rasterContext.textBaseline = 'alphabetic'; + this.rasterContext.textAlign = 'left'; + const metrics = this.rasterContext.measureText(cached.character); + const drawWidth = Math.max(1, Math.ceil(getBoundingWidth(metrics, metrics.width || rasterSize * 0.5))); + const drawHeight = Math.max(1, Math.ceil(getBoundingHeight(metrics, rasterSize))); + const width = drawWidth + padding * 2; + const height = drawHeight + padding * 2; + resizeCanvas(this.rasterCanvas, width, height); + this.rasterContext.clearRect(0, 0, width, height); + this.rasterContext.font = buildCanvasFont( + rasterSize, + quoteFontFamilyToken(this.familyName), + this.info.style, + this.info.weight, + ); + this.rasterContext.textBaseline = 'alphabetic'; + this.rasterContext.textAlign = 'left'; + this.rasterContext.fillStyle = 'rgba(255, 255, 255, 1)'; + const originX = padding + (metrics.actualBoundingBoxLeft ?? 0); + const originY = padding + (metrics.actualBoundingBoxAscent ?? rasterSize * 0.8); + this.rasterContext.fillText(cached.character, originX, originY); + const image = this.rasterContext.getImageData(0, 0, width, height); + const alpha = new Uint8Array(width * height); + for (let index = 0, offset = 3; index < alpha.length; index += 1, offset += 4) { + alpha[index] = image.data[offset] ?? 0; + } + return { + codePoint, + rasterSize, + width, + height, + data: alpha, + format: 'alpha8', + rowStride: width, + }; + } + + getKerning(leftCodePoint: number, rightCodePoint: number): number { + this.ensureActive(); + const key = `${leftCodePoint}:${rightCodePoint}`; + const cached = this.kerningCache.get(key); + if (cached !== undefined) { + return cached; + } + const left = this.measureGlyph(leftCodePoint); + const right = this.measureGlyph(rightCodePoint); + if (!left || !right) { + this.kerningCache.set(key, 0); + return 0; + } + const pairText = `${codePointToString(leftCodePoint)}${codePointToString(rightCodePoint)}`; + this.metricContext.font = buildCanvasFont( + this.info.unitsPerEm ?? METRIC_EM_SIZE, + quoteFontFamilyToken(this.familyName), + this.info.style, + this.info.weight, + ); + const pairAdvance = this.metricContext.measureText(pairText).width; + const kerning = Math.round(pairAdvance - left.advance - right.advance); + this.kerningCache.set(key, kerning); + return kerning; + } + + dispose(): void { + if (!this.disposed) { + if (typeof document !== 'undefined' && 'fonts' in document && document.fonts && typeof document.fonts.delete === 'function') { + document.fonts.delete(this.fontFace); + } + this.glyphs.clear(); + this.kerningCache.clear(); + this.disposed = true; + } + } + + [Symbol.dispose](): void { + this.dispose(); + } + + private ensureActive(): void { + if (this.disposed) { + throw new FontLoadError('The dynamic font runtime has already been disposed.'); + } + } +} + +class BrowserSystemFontFaceRuntime implements DynamicFontFaceRuntime { + readonly info: DynamicFontRuntimeInfo; + + private readonly familyToken: string; + private readonly metricContext: CanvasRenderingContext2DLike; + private readonly rasterCanvas: CanvasLike; + private readonly rasterContext: CanvasRenderingContext2DLike; + private readonly glyphs = new Map(); + private readonly kerningCache = new Map(); + private disposed = false; + + private constructor( + familyToken: string, + info: DynamicFontRuntimeInfo, + metricContext: CanvasRenderingContext2DLike, + rasterCanvas: CanvasLike, + rasterContext: CanvasRenderingContext2DLike, + ) { + this.familyToken = familyToken; + this.info = info; + this.metricContext = metricContext; + this.rasterCanvas = rasterCanvas; + this.rasterContext = rasterContext; + } + + static create(options: BrowserSystemFontFaceRuntimeOptions): BrowserSystemFontFaceRuntime { + const metricPair = createCanvasContext(); + const rasterPair = createCanvasContext(); + const metricContext = metricPair.context; + metricContext.textBaseline = 'alphabetic'; + metricContext.textAlign = 'left'; + + const familyToken = quoteFontFamilyToken(options.cssFamily ?? options.family); + const info = createRuntimeInfo(metricContext, familyToken, options); + + return new BrowserSystemFontFaceRuntime( + familyToken, + info, + metricContext, + rasterPair.canvas, + rasterPair.context, + ); + } + + measureGlyph(codePoint: number): FontGlyphMetric | null { + this.ensureActive(); + const existing = this.glyphs.get(codePoint); + if (existing) { + return existing.metric; + } + + const character = codePointToString(codePoint); + const unitsPerEm = this.info.unitsPerEm ?? METRIC_EM_SIZE; + const defaultAdvance = this.info.defaultAdvance ?? unitsPerEm * 0.5; + this.metricContext.font = buildCanvasFont(unitsPerEm, this.familyToken, this.info.style, this.info.weight); + const metrics = this.metricContext.measureText(character); + const glyphMetric: FontGlyphMetric = { + codePoint, + advance: Math.max(1, Math.ceil(metrics.width || defaultAdvance)), + bearingX: metrics.actualBoundingBoxLeft ?? 0, + bearingY: metrics.actualBoundingBoxAscent ?? this.info.ascent, + width: Math.max(1, Math.ceil(getBoundingWidth(metrics, metrics.width || defaultAdvance))), + height: Math.max(1, Math.ceil(getBoundingHeight(metrics, this.info.ascent + this.info.descent))), + }; + + this.glyphs.set(codePoint, { metric: glyphMetric, character }); + return glyphMetric; + } + + rasterizeGlyph(codePoint: number, pixelSize: number): DynamicFontGlyphRaster | null { + this.ensureActive(); + const cached = this.glyphs.get(codePoint) ?? (() => { + const metric = this.measureGlyph(codePoint); + return metric ? this.glyphs.get(codePoint) ?? null : null; + })(); + if (!cached) { + return null; + } + + const rasterSize = quantizeRasterSize(pixelSize); + const isWhitespace = /^\s$/u.test(cached.character); + if (isWhitespace) { + return { + codePoint, + rasterSize, + width: 1, + height: 1, + }; + } + + const padding = Math.max(2, Math.ceil(rasterSize * 0.125)); + this.rasterContext.font = buildCanvasFont(rasterSize, this.familyToken, this.info.style, this.info.weight); + this.rasterContext.textBaseline = 'alphabetic'; + this.rasterContext.textAlign = 'left'; + const metrics = this.rasterContext.measureText(cached.character); + const drawWidth = Math.max(1, Math.ceil(getBoundingWidth(metrics, metrics.width || rasterSize * 0.5))); + const drawHeight = Math.max(1, Math.ceil(getBoundingHeight(metrics, rasterSize))); + const width = drawWidth + padding * 2; + const height = drawHeight + padding * 2; + + resizeCanvas(this.rasterCanvas, width, height); + this.rasterContext.clearRect(0, 0, width, height); + this.rasterContext.font = buildCanvasFont(rasterSize, this.familyToken, this.info.style, this.info.weight); + this.rasterContext.textBaseline = 'alphabetic'; + this.rasterContext.textAlign = 'left'; + this.rasterContext.fillStyle = 'rgba(255, 255, 255, 1)'; + + const originX = padding + (metrics.actualBoundingBoxLeft ?? 0); + const originY = padding + (metrics.actualBoundingBoxAscent ?? rasterSize * 0.8); + this.rasterContext.fillText(cached.character, originX, originY); + + const image = this.rasterContext.getImageData(0, 0, width, height); + const alpha = new Uint8Array(width * height); + for (let index = 0, offset = 3; index < alpha.length; index += 1, offset += 4) { + alpha[index] = image.data[offset] ?? 0; + } + + return { + codePoint, + rasterSize, + width, + height, + data: alpha, + format: 'alpha8', + rowStride: width, + }; + } + + getKerning(leftCodePoint: number, rightCodePoint: number): number { + this.ensureActive(); + const key = `${leftCodePoint}:${rightCodePoint}`; + const cached = this.kerningCache.get(key); + if (cached !== undefined) { + return cached; + } + + const left = this.measureGlyph(leftCodePoint); + const right = this.measureGlyph(rightCodePoint); + if (!left || !right) { + this.kerningCache.set(key, 0); + return 0; + } + + const pairText = `${codePointToString(leftCodePoint)}${codePointToString(rightCodePoint)}`; + this.metricContext.font = buildCanvasFont( + this.info.unitsPerEm ?? METRIC_EM_SIZE, + this.familyToken, + this.info.style, + this.info.weight, + ); + const pairAdvance = this.metricContext.measureText(pairText).width; + const kerning = Math.round(pairAdvance - left.advance - right.advance); + this.kerningCache.set(key, kerning); + return kerning; + } + + dispose(): void { + if (!this.disposed) { + this.glyphs.clear(); + this.kerningCache.clear(); + this.disposed = true; + } + } + + [Symbol.dispose](): void { + this.dispose(); + } + + private ensureActive(): void { + if (this.disposed) { + throw new FontLoadError('The system font runtime has already been disposed.'); + } + } +} + +export const createBrowserDynamicFontRuntimeFactory = (): DynamicFontRuntimeFactory => ({ + async create(source: DynamicFontRuntimeSource): Promise { + return BrowserDynamicFontFaceRuntime.create(source); + }, +}); + +export const createBrowserSystemFontFaceRuntime = ( + options: BrowserSystemFontFaceRuntimeOptions, +): DynamicFontFaceRuntime => BrowserSystemFontFaceRuntime.create(options); diff --git a/web/packages/ui/src/font-runtime/internals.ts b/web/packages/ui/src/font-runtime/internals.ts new file mode 100644 index 00000000..f94ac847 --- /dev/null +++ b/web/packages/ui/src/font-runtime/internals.ts @@ -0,0 +1,123 @@ +import { FontLoadError } from '../errors'; +import type { DynamicFontRuntimeInfo, FontStyle, FontWeight } from '../types'; + +export const METRIC_EM_SIZE = 1000; +const MIN_RASTER_SIZE = 8; +const MAX_RASTER_SIZE = 256; + +export type CanvasLike = HTMLCanvasElement | OffscreenCanvas; +export type CanvasRenderingContext2DLike = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; + +export const normalizeWeightToken = (weight: FontWeight | undefined): string => { + if (weight === undefined) { + return '400'; + } + return String(weight); +}; + +export const normalizeStyleToken = (style: FontStyle | undefined): string => style ?? 'normal'; + +export const createCanvasContext = (): { canvas: CanvasLike; context: CanvasRenderingContext2DLike } => { + if (typeof OffscreenCanvas !== 'undefined') { + const canvas = new OffscreenCanvas(1, 1); + const context = canvas.getContext('2d'); + if (context) { + return { canvas, context }; + } + } + if (typeof document !== 'undefined' && typeof document.createElement === 'function') { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (context) { + return { canvas, context }; + } + } + throw new FontLoadError('No 2D canvas implementation is available for dynamic font rasterization.'); +}; + +export const resizeCanvas = (canvas: CanvasLike, width: number, height: number): void => { + canvas.width = Math.max(1, Math.ceil(width)); + canvas.height = Math.max(1, Math.ceil(height)); +}; + +export const codePointToString = (codePoint: number): string => String.fromCodePoint(codePoint); + +export const getBoundingWidth = (metrics: TextMetrics, fallbackAdvance: number): number => { + const left = metrics.actualBoundingBoxLeft ?? 0; + const right = metrics.actualBoundingBoxRight ?? 0; + const bounded = left + right; + return bounded > 0 ? bounded : Math.max(1, fallbackAdvance); +}; + +export const getBoundingHeight = (metrics: TextMetrics, fallbackSize: number): number => { + const ascent = metrics.actualBoundingBoxAscent ?? 0; + const descent = metrics.actualBoundingBoxDescent ?? 0; + const bounded = ascent + descent; + return bounded > 0 ? bounded : Math.max(1, fallbackSize); +}; + +export const quantizeRasterSize = (fontSize: number): number => + Math.max(MIN_RASTER_SIZE, Math.min(MAX_RASTER_SIZE, Math.round(fontSize))); + +export const quoteFontFamilyToken = (family: string): string => { + const trimmed = family.trim(); + if (trimmed.length === 0) { + return 'sans-serif'; + } + if (trimmed.includes(',') || trimmed.includes('"') || trimmed.includes("'")) { + return trimmed; + } + return /\s/u.test(trimmed) ? `"${trimmed}"` : trimmed; +}; + +export const buildCanvasFont = ( + fontSize: number, + familyToken: string, + style: FontStyle | undefined, + weight: FontWeight | undefined, +): string => + `${normalizeStyleToken(style)} ${normalizeWeightToken(weight)} ${fontSize}px ${familyToken}`; + +export const createRuntimeInfo = ( + metricContext: CanvasRenderingContext2DLike, + familyToken: string, + source: { + readonly family: string; + readonly face?: string; + readonly style?: FontStyle; + readonly weight?: FontWeight; + readonly locale?: string; + readonly fallbackCodePoint?: number; + readonly atlas?: DynamicFontRuntimeInfo['atlas']; + }, +): DynamicFontRuntimeInfo => { + const sample = 'Hg'; + metricContext.font = buildCanvasFont(METRIC_EM_SIZE, familyToken, source.style, source.weight); + const sampleMetrics = metricContext.measureText(sample); + const ascent = Math.max(1, Math.ceil(sampleMetrics.actualBoundingBoxAscent ?? METRIC_EM_SIZE * 0.8)); + const descent = Math.max(1, Math.ceil(sampleMetrics.actualBoundingBoxDescent ?? METRIC_EM_SIZE * 0.2)); + const lineGap = Math.max( + 0, + Math.ceil( + (sampleMetrics.fontBoundingBoxAscent ?? ascent) + + (sampleMetrics.fontBoundingBoxDescent ?? descent) - + ascent - + descent, + ), + ); + + return { + family: source.family, + face: source.face ?? 'Regular', + style: source.style ?? 'normal', + weight: source.weight ?? 400, + locale: source.locale ?? '', + ascent, + descent, + lineGap, + unitsPerEm: METRIC_EM_SIZE, + defaultAdvance: Math.max(1, Math.ceil(sampleMetrics.width / Math.max(1, sample.length))), + fallbackCodePoint: source.fallbackCodePoint ?? 63, + atlas: source.atlas, + }; +}; diff --git a/web/packages/ui/src/font.ts b/web/packages/ui/src/font.ts index ee308e4f..fa3480db 100644 --- a/web/packages/ui/src/font.ts +++ b/web/packages/ui/src/font.ts @@ -1,10 +1,25 @@ +import { DisposedUIError, FontFaceNotFoundError, FontLoadError } from './errors'; +import { createBrowserDynamicFontRuntimeFactory, createBrowserSystemFontFaceRuntime } from './font-runtime'; +import { GlyphAtlas } from './font/atlas'; +import type { GlyphAtlasSource } from './font/atlas'; +import { BinaryFontLoader, DescriptorFontLoader, JsonFontLoader } from './font/loaders'; import { - DisposedUIError, - FontFaceNotFoundError, - FontLoadError, -} from './errors'; + applyRetryDelay, + buildSourceKey, + isDynamicFontFaceAsset, + normalizeGlyphMap, + normalizeKerningMap, + normalizeStyle, + normalizeWeight, + wait, +} from './font/source'; import type { + DynamicFontFaceAsset, + DynamicFontFaceRuntime, + DynamicFontGlyphRaster, + DynamicFontRuntimeFactory, FontAssetSource, + FontBinaryFormat, FontFaceAsset, FontFaceId, FontFaceInfo, @@ -22,64 +37,28 @@ import type { FontStyle, FontWeight, GlyphAtlasEntry, - GlyphAtlasPageId, GlyphAtlasPageSnapshot, KerningPairKey, RetryPolicy, + StaticFontFaceAsset, } from './types'; -const normalizeWeight = (weight: FontWeight | undefined): number => { - switch (weight) { - case 'thin': - return 100; - case 'extralight': - return 200; - case 'light': - return 300; - case 'normal': - return 400; - case 'medium': - return 500; - case 'semibold': - return 600; - case 'bold': - return 700; - case 'extrabold': - return 800; - case 'black': - return 900; - case undefined: - return 400; - default: - return weight; - } -}; - -const normalizeStyle = (style: FontStyle | undefined): FontStyle => style ?? 'normal'; - -const toByteArray = (value: ArrayBuffer | ArrayBufferView): Uint8Array => { - if (value instanceof ArrayBuffer) { - return new Uint8Array(value); - } - return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); -}; +export const AXRONE_DEFAULT_UI_FONT_FAMILY = 'Roboto, "Segoe UI", "Helvetica Neue", Arial, sans-serif'; -const wait = async (delayMs: number): Promise => - new Promise((resolve) => { - setTimeout(resolve, delayMs); - }); +export interface SystemFontFaceAssetOptions { + readonly family: string; + readonly cssFamily?: string; + readonly face?: string; + readonly style?: FontStyle; + readonly weight?: FontWeight; + readonly locale?: string; + readonly fallbackCodePoint?: number; +} -const applyRetryDelay = (policy: RetryPolicy | undefined, attempt: number): number => { - const base = policy?.baseDelayMs ?? 16; - const max = policy?.maxDelayMs ?? 250; - const jitter = policy?.jitter ?? 0; - const exponential = Math.min(max, base * 2 ** Math.max(0, attempt - 1)); - if (jitter <= 0) { - return exponential; - } - const factor = 1 + (Math.random() * 2 - 1) * jitter; - return Math.max(0, Math.round(exponential * factor)); -}; +interface ResolvedGlyphMetric { + readonly codePoint: number; + readonly metric: FontGlyphMetric; +} interface InternalFontFace { readonly id: FontFaceId; @@ -87,6 +66,7 @@ interface InternalFontFace { readonly glyphs: Map; readonly kernings: Map; readonly atlas: GlyphAtlas; + readonly runtime?: DynamicFontFaceRuntime; } interface InternalFamily { @@ -96,270 +76,6 @@ interface InternalFamily { readonly faces: FontFaceId[]; } -interface AtlasPage { - readonly id: GlyphAtlasPageId; - readonly width: number; - readonly height: number; - cursorX: number; - cursorY: number; - rowHeight: number; - readonly entries: Map; -} - -class GlyphAtlas { - private readonly faceId: FontFaceId; - private readonly width: number; - private readonly height: number; - private readonly padding: number; - private readonly pages: AtlasPage[] = []; - private readonly entries = new Map(); - private nextPageId = 1; - - constructor(faceId: FontFaceId, width: number, height: number, padding: number) { - this.faceId = faceId; - this.width = Math.max(8, Math.floor(width)); - this.height = Math.max(8, Math.floor(height)); - this.padding = Math.max(0, Math.floor(padding)); - } - - ensure(metric: FontGlyphMetric): GlyphAtlasEntry { - const existing = this.entries.get(metric.codePoint); - if (existing) { - return existing; - } - const width = Math.max(1, Math.ceil(metric.width ?? metric.advance)); - const height = Math.max(1, Math.ceil(metric.height ?? width)); - const paddedWidth = width + this.padding * 2; - const paddedHeight = height + this.padding * 2; - let page = this.pages[this.pages.length - 1]; - if (!page) { - page = this.createPage(); - } - if (page.cursorX + paddedWidth > page.width) { - page.cursorX = 0; - page.cursorY += page.rowHeight; - page.rowHeight = 0; - } - if (page.cursorY + paddedHeight > page.height) { - page = this.createPage(); - } - const x = page.cursorX + this.padding; - const y = page.cursorY + this.padding; - const format: FontGlyphBitmapFormat = metric.format ?? 'alpha8'; - const rowStride = metric.rowStride ?? width * (format === 'rgba8' ? 4 : 1); - const entry: GlyphAtlasEntry = { - faceId: this.faceId, - page: page.id, - pageWidth: page.width, - pageHeight: page.height, - codePoint: metric.codePoint, - x, - y, - width, - height, - format, - rowStride, - distanceRange: metric.distanceRange ?? 1, - u0: x / page.width, - v0: y / page.height, - u1: (x + width) / page.width, - v1: (y + height) / page.height, - data: metric.data ?? null, - }; - page.entries.set(metric.codePoint, entry); - this.entries.set(metric.codePoint, entry); - page.cursorX += paddedWidth; - page.rowHeight = Math.max(page.rowHeight, paddedHeight); - return entry; - } - - snapshot(): readonly GlyphAtlasPageSnapshot[] { - return this.pages.map((page) => ({ - id: page.id as number, - width: page.width, - height: page.height, - entries: [...page.entries.values()], - })); - } - - restore(pages: readonly GlyphAtlasPageSnapshot[]): void { - this.pages.length = 0; - this.entries.clear(); - let maxPageId = 0; - for (const pageSnapshot of pages) { - const page: AtlasPage = { - id: pageSnapshot.id as GlyphAtlasPageId, - width: pageSnapshot.width, - height: pageSnapshot.height, - cursorX: 0, - cursorY: 0, - rowHeight: 0, - entries: new Map(), - }; - for (const entry of pageSnapshot.entries) { - page.entries.set(entry.codePoint, entry); - this.entries.set(entry.codePoint, entry); - page.cursorX = Math.max(page.cursorX, entry.x + entry.width + this.padding); - page.cursorY = Math.max(page.cursorY, entry.y); - page.rowHeight = Math.max(page.rowHeight, entry.height + this.padding * 2); - } - this.pages.push(page); - maxPageId = Math.max(maxPageId, pageSnapshot.id); - } - this.nextPageId = maxPageId + 1; - } - - clear(): void { - this.pages.length = 0; - this.entries.clear(); - this.nextPageId = 1; - } - - private createPage(): AtlasPage { - const page: AtlasPage = { - id: this.nextPageId as GlyphAtlasPageId, - width: this.width, - height: this.height, - cursorX: 0, - cursorY: 0, - rowHeight: 0, - entries: new Map(), - }; - this.nextPageId += 1; - this.pages.push(page); - return page; - } -} - -class DescriptorFontLoader implements FontLoader { - readonly id = 'descriptor'; - - canLoad(source: FontAssetSource): boolean { - return source.kind === 'descriptor'; - } - - async load(source: FontAssetSource): Promise { - if (source.kind !== 'descriptor') { - throw new FontLoadError('DescriptorFontLoader only accepts descriptor sources.'); - } - return source.asset; - } -} - -class JsonFontLoader implements FontLoader { - readonly id = 'json'; - private readonly fetchImpl?: typeof globalThis.fetch; - - constructor(fetchImpl?: typeof globalThis.fetch) { - this.fetchImpl = fetchImpl; - } - - canLoad(source: FontAssetSource): boolean { - return source.kind === 'buffer' || source.kind === 'url'; - } - - async load(source: FontAssetSource, signal?: AbortSignal): Promise { - if (source.kind === 'buffer') { - const text = new TextDecoder().decode(toByteArray(source.data)); - return this.normalizeParsedAsset(JSON.parse(text) as Record); - } - if (source.kind !== 'url') { - throw new FontLoadError('JsonFontLoader only accepts buffer or url sources.'); - } - if (!this.fetchImpl) { - throw new FontLoadError('No fetch implementation is available for URL font sources.'); - } - const response = await this.fetchImpl(source.url, { - headers: source.headers, - signal, - }); - if (!response.ok) { - throw new FontLoadError(`Font request failed with status ${response.status}.`, { - url: source.url, - status: response.status, - }); - } - const payload = (await response.json()) as Record; - return this.normalizeParsedAsset(payload); - } - - private normalizeParsedAsset(payload: Record): FontFaceAsset { - const glyphsValue = payload.glyphs; - const glyphs = Array.isArray(glyphsValue) - ? (glyphsValue as FontGlyphMetric[]) - : typeof glyphsValue === 'object' && glyphsValue !== null - ? Object.values(glyphsValue as Record) - : []; - const kerningsValue = payload.kernings; - const kernings = kerningsValue instanceof Map - ? kerningsValue - : typeof kerningsValue === 'object' && kerningsValue !== null - ? (kerningsValue as Record) - : undefined; - return { - family: String(payload.family ?? ''), - face: String(payload.face ?? 'Regular'), - style: normalizeStyle((payload.style as FontStyle | undefined) ?? 'normal'), - weight: normalizeWeight(payload.weight as FontWeight | undefined) as FontFaceAsset['weight'], - locale: String(payload.locale ?? ''), - ascent: Number(payload.ascent ?? 0), - descent: Number(payload.descent ?? 0), - lineGap: Number(payload.lineGap ?? 0), - unitsPerEm: Number(payload.unitsPerEm ?? 1000), - defaultAdvance: Number(payload.defaultAdvance ?? 500), - fallbackCodePoint: Number(payload.fallbackCodePoint ?? 63), - glyphs, - kernings, - atlas: typeof payload.atlas === 'object' && payload.atlas !== null - ? { - width: Number((payload.atlas as Record).width ?? 1024), - height: Number((payload.atlas as Record).height ?? 1024), - padding: Number((payload.atlas as Record).padding ?? 1), - } - : undefined, - }; - } -} - -const buildSourceKey = (source: FontAssetSource): string => { - switch (source.kind) { - case 'descriptor': - return `descriptor:${source.asset.family}:${source.asset.face ?? 'Regular'}:${normalizeWeight(source.asset.weight)}`; - case 'buffer': - return source.cacheKey ?? `buffer:${toByteArray(source.data).byteLength}:${source.contentType ?? 'application/json'}`; - case 'url': - return source.cacheKey ?? `url:${source.url}`; - default: - return 'unknown'; - } -}; - -const normalizeGlyphMap = ( - glyphs: FontFaceAsset['glyphs'] -): Map => { - if (glyphs instanceof Map) { - return new Map(glyphs); - } - if (Array.isArray(glyphs)) { - return new Map(glyphs.map((metric) => [metric.codePoint, metric])); - } - return new Map( - Object.values(glyphs).map((metric) => [metric.codePoint, metric]) - ); -}; - -const normalizeKerningMap = ( - kernings: FontFaceAsset['kernings'] -): Map => { - if (!kernings) { - return new Map(); - } - if (kernings instanceof Map) { - return new Map(kernings); - } - return new Map(Object.entries(kernings) as [KerningPairKey, number][]); -}; - export class FontRegistry implements Disposable { private readonly familiesByName = new Map(); private readonly facesById = new Map(); @@ -382,7 +98,9 @@ export class FontRegistry implements Disposable { fetch: options.fetch, }; this.defaultFamily = options.defaultFamily ?? null; + const dynamicRuntimeFactory = options.dynamicRuntimeFactory ?? createBrowserDynamicFontRuntimeFactory(); this.registerLoader(new DescriptorFontLoader()); + this.registerLoader(new BinaryFontLoader(options.fetch ?? globalThis.fetch, dynamicRuntimeFactory)); this.registerLoader(new JsonFontLoader(options.fetch ?? globalThis.fetch)); } @@ -414,27 +132,28 @@ export class FontRegistry implements Disposable { registerFace(asset: FontFaceAsset): FontFaceId { this.ensureActive(); - const familyId = this.registerFamily({ name: asset.family }); - const family = this.familiesByName.get(asset.family)!; + const infoSource = isDynamicFontFaceAsset(asset) ? asset.runtime.info : asset; + const familyId = this.registerFamily({ name: infoSource.family }); + const family = this.familiesByName.get(infoSource.family)!; const info: FontFaceInfo = { id: this.nextFaceId as FontFaceId, - family: asset.family, - face: asset.face ?? 'Regular', - style: normalizeStyle(asset.style), - weight: normalizeWeight(asset.weight), - locale: asset.locale ?? '', - ascent: asset.ascent, - descent: asset.descent, - lineGap: asset.lineGap ?? 0, - unitsPerEm: asset.unitsPerEm ?? 1000, - defaultAdvance: asset.defaultAdvance ?? 500, - fallbackCodePoint: asset.fallbackCodePoint ?? 63, + family: infoSource.family, + face: infoSource.face ?? 'Regular', + style: normalizeStyle(infoSource.style), + weight: normalizeWeight(infoSource.weight), + locale: infoSource.locale ?? '', + ascent: infoSource.ascent, + descent: infoSource.descent, + lineGap: infoSource.lineGap ?? 0, + unitsPerEm: infoSource.unitsPerEm ?? 1000, + defaultAdvance: infoSource.defaultAdvance ?? 500, + fallbackCodePoint: infoSource.fallbackCodePoint ?? 63, }; const atlas = new GlyphAtlas( info.id, - asset.atlas?.width ?? this.options.atlasWidth, - asset.atlas?.height ?? this.options.atlasHeight, - asset.atlas?.padding ?? this.options.atlasPadding + infoSource.atlas?.width ?? this.options.atlasWidth, + infoSource.atlas?.height ?? this.options.atlasHeight, + infoSource.atlas?.padding ?? this.options.atlasPadding ); const face: InternalFontFace = { id: info.id, @@ -442,12 +161,13 @@ export class FontRegistry implements Disposable { glyphs: normalizeGlyphMap(asset.glyphs), kernings: normalizeKerningMap(asset.kernings), atlas, + runtime: isDynamicFontFaceAsset(asset) ? asset.runtime : undefined, }; this.nextFaceId += 1; this.facesById.set(info.id as number, face); family.faces.push(info.id); if (!this.defaultFamily) { - this.defaultFamily = asset.family; + this.defaultFamily = infoSource.family; } void familyId; return info.id; @@ -516,17 +236,13 @@ export class FontRegistry implements Disposable { return this.facesById.get(faceId as number)?.info ?? null; } - ensureGlyph(faceId: FontFaceId, codePoint: number): GlyphAtlasEntry | null { + ensureGlyph(faceId: FontFaceId, codePoint: number, fontSize?: number): GlyphAtlasEntry | null { this.ensureActive(); const face = this.facesById.get(faceId as number); if (!face) { throw new FontFaceNotFoundError({ faceId }); } - const metric = face.glyphs.get(codePoint) ?? face.glyphs.get(face.info.fallbackCodePoint); - if (!metric) { - return null; - } - return face.atlas.ensure(metric); + return this.ensureGlyphEntry(face, codePoint, fontSize); } measureGlyph( @@ -551,20 +267,24 @@ export class FontRegistry implements Disposable { if (!face) { throw new FontFaceNotFoundError({ faceId }); } - const metric = face.glyphs.get(codePoint) ?? face.glyphs.get(face.info.fallbackCodePoint) ?? null; - const kerningKey = nextCodePoint === undefined ? null : `${codePoint}:${nextCodePoint}`; - const kerning = kerningKey ? face.kernings.get(kerningKey as KerningPairKey) ?? 0 : 0; + const resolved = this.resolveMetricWithFallback(face, codePoint); + const nextResolved = + nextCodePoint === undefined ? null : this.resolveMetricWithFallback(face, nextCodePoint); + const kerning = + nextResolved && resolved + ? this.resolveKerning(face, resolved.codePoint, nextResolved.codePoint) + : 0; const scale = fontSize / face.info.unitsPerEm; - const width = (metric?.width ?? metric?.advance ?? face.info.defaultAdvance) * scale; - const height = (metric?.height ?? face.info.ascent + face.info.descent) * scale; + const width = (resolved?.metric.width ?? resolved?.metric.advance ?? face.info.defaultAdvance) * scale; + const height = (resolved?.metric.height ?? face.info.ascent + face.info.descent) * scale; return { faceId, codePoint, - advance: ((metric?.advance ?? face.info.defaultAdvance) + kerning) * scale, + advance: ((resolved?.metric.advance ?? face.info.defaultAdvance) + kerning) * scale, width, height, - metric, - atlasEntry: metric ? face.atlas.ensure(metric) : null, + metric: resolved?.metric ?? null, + atlasEntry: resolved ? this.ensureGlyphEntry(face, resolved.codePoint, fontSize) : null, }; } @@ -612,7 +332,7 @@ export class FontRegistry implements Disposable { family: faceSnapshot.family, face: faceSnapshot.face, style: faceSnapshot.style, - weight: faceSnapshot.weight as FontFaceAsset['weight'], + weight: faceSnapshot.weight as StaticFontFaceAsset['weight'], locale: faceSnapshot.locale, ascent: faceSnapshot.ascent, descent: faceSnapshot.descent, @@ -631,6 +351,10 @@ export class FontRegistry implements Disposable { } clear(): void { + for (const face of this.facesById.values()) { + face.runtime?.dispose?.(); + face.atlas.clear(); + } this.familiesByName.clear(); this.facesById.clear(); this.pendingLoads.clear(); @@ -656,6 +380,91 @@ export class FontRegistry implements Disposable { } } + private resolveMetric(face: InternalFontFace, codePoint: number): FontGlyphMetric | null { + const existing = face.glyphs.get(codePoint); + if (existing) { + return existing; + } + const runtimeMetric = face.runtime?.measureGlyph(codePoint) ?? null; + if (runtimeMetric) { + face.glyphs.set(codePoint, runtimeMetric); + } + return runtimeMetric; + } + + private resolveMetricWithFallback(face: InternalFontFace, codePoint: number): ResolvedGlyphMetric | null { + const metric = this.resolveMetric(face, codePoint); + if (metric) { + return { codePoint, metric }; + } + if (codePoint !== face.info.fallbackCodePoint) { + const fallbackMetric = this.resolveMetric(face, face.info.fallbackCodePoint); + if (fallbackMetric) { + return { + codePoint: face.info.fallbackCodePoint, + metric: fallbackMetric, + }; + } + } + return null; + } + + private resolveKerning(face: InternalFontFace, leftCodePoint: number, rightCodePoint: number): number { + const key = `${leftCodePoint}:${rightCodePoint}` as KerningPairKey; + const existing = face.kernings.get(key); + if (existing !== undefined) { + return existing; + } + const value = face.runtime?.getKerning?.(leftCodePoint, rightCodePoint) ?? 0; + face.kernings.set(key, value); + return value; + } + + private ensureGlyphEntry(face: InternalFontFace, codePoint: number, fontSize?: number): GlyphAtlasEntry | null { + const resolved = this.resolveMetricWithFallback(face, codePoint); + if (!resolved) { + return null; + } + if (!face.runtime) { + const cached = face.atlas.get(resolved.codePoint); + if (cached) { + return cached; + } + return face.atlas.ensure({ + codePoint: resolved.codePoint, + width: resolved.metric.width ?? resolved.metric.advance, + height: resolved.metric.height ?? resolved.metric.width ?? resolved.metric.advance, + data: resolved.metric.data ?? null, + format: resolved.metric.format, + rowStride: resolved.metric.rowStride, + distanceRange: resolved.metric.distanceRange, + }); + } + const rasterSize = Math.max(1, Math.round(fontSize ?? 16)); + const cached = face.atlas.get(resolved.codePoint, rasterSize); + if (cached) { + return cached; + } + const raster = face.runtime.rasterizeGlyph(resolved.codePoint, rasterSize); + if (!raster) { + return null; + } + return face.atlas.ensure(this.toAtlasGlyph(raster)); + } + + private toAtlasGlyph(raster: DynamicFontGlyphRaster): GlyphAtlasSource { + return { + codePoint: raster.codePoint, + rasterSize: raster.rasterSize, + width: raster.width, + height: raster.height, + data: raster.data ?? null, + format: raster.format, + rowStride: raster.rowStride, + distanceRange: raster.distanceRange, + }; + } + private async loadInternal(source: FontAssetSource, options: FontLoadOptions): Promise { const loader = this.loaders.find((candidate) => candidate.canLoad(source)); if (!loader) { @@ -683,8 +492,55 @@ export class FontRegistry implements Disposable { } } +export const createSystemFontFaceAsset = (options: SystemFontFaceAssetOptions): DynamicFontFaceAsset => ({ + kind: 'dynamic', + runtime: createBrowserSystemFontFaceRuntime({ + family: options.family, + cssFamily: options.cssFamily ?? options.family, + face: options.face, + style: options.style, + weight: options.weight, + locale: options.locale, + fallbackCodePoint: options.fallbackCodePoint, + }), +}); + +export const createDefaultUIFontAsset = ( + family = AXRONE_DEFAULT_UI_FONT_FAMILY, +): DynamicFontFaceAsset => + createSystemFontFaceAsset({ + family, + cssFamily: family, + }); + +export const ensureSystemUIFont = ( + fonts: Pick, + family: string, + cssFamily = family, +): string => { + if (!fonts.resolveFace({ family })) { + fonts.registerFace( + createSystemFontFaceAsset({ + family, + cssFamily, + }), + ); + } + return fonts.getDefaultFamily() ?? family; +}; + +export const ensureDefaultUIFont = ( + fonts: Pick, + family = AXRONE_DEFAULT_UI_FONT_FAMILY, +): string => ensureSystemUIFont(fonts, family, family); + export type { + DynamicFontFaceAsset, + DynamicFontFaceRuntime, + DynamicFontGlyphRaster, + DynamicFontRuntimeFactory, FontAssetSource, + FontBinaryFormat, FontFaceAsset, FontFaceId, FontFaceInfo, @@ -702,4 +558,7 @@ export type { GlyphAtlasPageSnapshot, KerningPairKey, RetryPolicy, -}; \ No newline at end of file + StaticFontFaceAsset, +}; + +export { createBrowserDynamicFontRuntimeFactory, createBrowserSystemFontFaceRuntime }; diff --git a/web/packages/ui/src/font/atlas.ts b/web/packages/ui/src/font/atlas.ts new file mode 100644 index 00000000..1305c69b --- /dev/null +++ b/web/packages/ui/src/font/atlas.ts @@ -0,0 +1,161 @@ +import type { + FontFaceId, + FontGlyphBitmapFormat, + GlyphAtlasEntry, + GlyphAtlasPageId, + GlyphAtlasPageSnapshot, +} from '../types'; +import { createAtlasEntryKey } from './source'; + +export interface GlyphAtlasSource { + readonly codePoint: number; + readonly rasterSize?: number; + readonly width: number; + readonly height: number; + readonly data?: ArrayBuffer | ArrayBufferView | null; + readonly format?: FontGlyphBitmapFormat; + readonly rowStride?: number; + readonly distanceRange?: number; +} + +interface AtlasPage { + readonly id: GlyphAtlasPageId; + readonly width: number; + readonly height: number; + cursorX: number; + cursorY: number; + rowHeight: number; + readonly entries: Map; +} + +export class GlyphAtlas { + private readonly faceId: FontFaceId; + private readonly width: number; + private readonly height: number; + private readonly padding: number; + private readonly pages: AtlasPage[] = []; + private readonly entries = new Map(); + private nextPageId = 1; + + constructor(faceId: FontFaceId, width: number, height: number, padding: number) { + this.faceId = faceId; + this.width = Math.max(8, Math.floor(width)); + this.height = Math.max(8, Math.floor(height)); + this.padding = Math.max(0, Math.floor(padding)); + } + + get(codePoint: number, rasterSize?: number): GlyphAtlasEntry | null { + return this.entries.get(createAtlasEntryKey(codePoint, rasterSize)) ?? null; + } + + ensure(glyph: GlyphAtlasSource): GlyphAtlasEntry { + const key = createAtlasEntryKey(glyph.codePoint, glyph.rasterSize); + const existing = this.entries.get(key); + if (existing) { + return existing; + } + const width = Math.max(1, Math.ceil(glyph.width)); + const height = Math.max(1, Math.ceil(glyph.height)); + const paddedWidth = width + this.padding * 2; + const paddedHeight = height + this.padding * 2; + let page = this.pages[this.pages.length - 1]; + if (!page) { + page = this.createPage(); + } + if (page.cursorX + paddedWidth > page.width) { + page.cursorX = 0; + page.cursorY += page.rowHeight; + page.rowHeight = 0; + } + if (page.cursorY + paddedHeight > page.height) { + page = this.createPage(); + } + const x = page.cursorX + this.padding; + const y = page.cursorY + this.padding; + const format: FontGlyphBitmapFormat = glyph.format ?? 'alpha8'; + const rowStride = glyph.rowStride ?? width * (format === 'rgba8' ? 4 : 1); + const entry: GlyphAtlasEntry = { + faceId: this.faceId, + page: page.id, + pageWidth: page.width, + pageHeight: page.height, + codePoint: glyph.codePoint, + rasterSize: glyph.rasterSize, + x, + y, + width, + height, + format, + rowStride, + distanceRange: glyph.distanceRange ?? 1, + u0: x / page.width, + v0: y / page.height, + u1: (x + width) / page.width, + v1: (y + height) / page.height, + data: glyph.data ?? null, + }; + page.entries.set(key, entry); + this.entries.set(key, entry); + page.cursorX += paddedWidth; + page.rowHeight = Math.max(page.rowHeight, paddedHeight); + return entry; + } + + snapshot(): readonly GlyphAtlasPageSnapshot[] { + return this.pages.map((page) => ({ + id: page.id as number, + width: page.width, + height: page.height, + entries: [...page.entries.values()], + })); + } + + restore(pages: readonly GlyphAtlasPageSnapshot[]): void { + this.pages.length = 0; + this.entries.clear(); + let maxPageId = 0; + for (const pageSnapshot of pages) { + const page: AtlasPage = { + id: pageSnapshot.id as GlyphAtlasPageId, + width: pageSnapshot.width, + height: pageSnapshot.height, + cursorX: 0, + cursorY: 0, + rowHeight: 0, + entries: new Map(), + }; + for (const entry of pageSnapshot.entries) { + const key = createAtlasEntryKey(entry.codePoint, entry.rasterSize); + page.entries.set(key, entry); + this.entries.set(key, entry); + page.cursorX = Math.max(page.cursorX, entry.x + entry.width + this.padding); + page.cursorY = Math.max(page.cursorY, entry.y); + page.rowHeight = Math.max(page.rowHeight, entry.height + this.padding * 2); + } + this.pages.push(page); + maxPageId = Math.max(maxPageId, pageSnapshot.id); + } + this.nextPageId = maxPageId + 1; + } + + clear(): void { + this.pages.length = 0; + this.entries.clear(); + this.nextPageId = 1; + } + + private createPage(): AtlasPage { + const page: AtlasPage = { + id: this.nextPageId as GlyphAtlasPageId, + width: this.width, + height: this.height, + cursorX: 0, + cursorY: 0, + rowHeight: 0, + entries: new Map(), + }; + this.nextPageId += 1; + this.pages.push(page); + return page; + } +} \ No newline at end of file diff --git a/web/packages/ui/src/font/loaders.ts b/web/packages/ui/src/font/loaders.ts new file mode 100644 index 00000000..eed2d65a --- /dev/null +++ b/web/packages/ui/src/font/loaders.ts @@ -0,0 +1,178 @@ +import { FontLoadError } from '../errors'; +import type { + DynamicFontRuntimeFactory, + FontAssetSource, + FontFaceAsset, + FontGlyphMetric, + FontLoader, + FontStyle, + FontWeight, + KerningPairKey, + StaticFontFaceAsset, +} from '../types'; +import { + buildSourceKey, + detectBinaryFormatFromBuffer, + detectBinaryFormatFromContentType, + detectBinaryFormatFromUrl, + detectSourceBinaryFormat, + normalizeStyle, + normalizeWeight, + toByteArray, + toOwnedArrayBuffer, +} from './source'; + +export class DescriptorFontLoader implements FontLoader { + readonly id = 'descriptor'; + + canLoad(source: FontAssetSource): boolean { + return source.kind === 'descriptor'; + } + + async load(source: FontAssetSource): Promise { + if (source.kind !== 'descriptor') { + throw new FontLoadError('DescriptorFontLoader only accepts descriptor sources.'); + } + return source.asset; + } +} + +export class BinaryFontLoader implements FontLoader { + readonly id = 'binary'; + + private readonly fetchImpl?: typeof globalThis.fetch; + private readonly runtimeFactory: DynamicFontRuntimeFactory; + + constructor(fetchImpl: typeof globalThis.fetch | undefined, runtimeFactory: DynamicFontRuntimeFactory) { + this.fetchImpl = fetchImpl; + this.runtimeFactory = runtimeFactory; + } + + canLoad(source: FontAssetSource): boolean { + return source.kind !== 'descriptor' && detectSourceBinaryFormat(source) !== null; + } + + async load(source: FontAssetSource, signal?: AbortSignal): Promise { + if (source.kind === 'descriptor') { + throw new FontLoadError('BinaryFontLoader only accepts buffer or url sources.'); + } + + let bytes: ArrayBuffer; + let format = detectSourceBinaryFormat(source); + + if (source.kind === 'buffer') { + bytes = toOwnedArrayBuffer(source.data); + format ??= detectBinaryFormatFromBuffer(new Uint8Array(bytes)); + } else { + if (!this.fetchImpl) { + throw new FontLoadError('No fetch implementation is available for URL font sources.'); + } + const response = await this.fetchImpl.call(globalThis, source.url, { + headers: source.headers, + signal, + }); + if (!response.ok) { + throw new FontLoadError(`Font request failed with status ${response.status}.`, { + url: source.url, + status: response.status, + }); + } + bytes = await response.arrayBuffer(); + const responseContentType = + typeof response.headers?.get === 'function' ? response.headers.get('content-type') ?? undefined : undefined; + format ??= detectBinaryFormatFromContentType(responseContentType); + format ??= detectBinaryFormatFromUrl(source.url); + format ??= detectBinaryFormatFromBuffer(new Uint8Array(bytes)); + } + + if (!format) { + throw new FontLoadError('Unable to determine the binary font format.', { source }); + } + + return { + kind: 'dynamic', + runtime: await this.runtimeFactory.create({ + source, + bytes, + format, + cacheKey: buildSourceKey(source), + }), + }; + } +} + +export class JsonFontLoader implements FontLoader { + readonly id = 'json'; + private readonly fetchImpl?: typeof globalThis.fetch; + + constructor(fetchImpl?: typeof globalThis.fetch) { + this.fetchImpl = fetchImpl; + } + + canLoad(source: FontAssetSource): boolean { + return source.kind !== 'descriptor' && detectSourceBinaryFormat(source) === null; + } + + async load(source: FontAssetSource, signal?: AbortSignal): Promise { + if (source.kind === 'buffer') { + const text = new TextDecoder().decode(toByteArray(source.data)); + return this.normalizeParsedAsset(JSON.parse(text) as Record); + } + if (source.kind !== 'url') { + throw new FontLoadError('JsonFontLoader only accepts buffer or url sources.'); + } + if (!this.fetchImpl) { + throw new FontLoadError('No fetch implementation is available for URL font sources.'); + } + const response = await this.fetchImpl.call(globalThis, source.url, { + headers: source.headers, + signal, + }); + if (!response.ok) { + throw new FontLoadError(`Font request failed with status ${response.status}.`, { + url: source.url, + status: response.status, + }); + } + const payload = (await response.json()) as Record; + return this.normalizeParsedAsset(payload); + } + + private normalizeParsedAsset(payload: Record): StaticFontFaceAsset { + const glyphsValue = payload.glyphs; + const glyphs = Array.isArray(glyphsValue) + ? (glyphsValue as FontGlyphMetric[]) + : typeof glyphsValue === 'object' && glyphsValue !== null + ? Object.values(glyphsValue as Record) + : []; + const kerningsValue = payload.kernings; + const kernings = kerningsValue instanceof Map + ? kerningsValue + : typeof kerningsValue === 'object' && kerningsValue !== null + ? (kerningsValue as Record) + : undefined; + return { + family: String(payload.family ?? ''), + face: String(payload.face ?? 'Regular'), + style: normalizeStyle((payload.style as FontStyle | undefined) ?? 'normal'), + weight: normalizeWeight(payload.weight as FontWeight | undefined) as StaticFontFaceAsset['weight'], + locale: String(payload.locale ?? ''), + ascent: Number(payload.ascent ?? 0), + descent: Number(payload.descent ?? 0), + lineGap: Number(payload.lineGap ?? 0), + unitsPerEm: Number(payload.unitsPerEm ?? 1000), + defaultAdvance: Number(payload.defaultAdvance ?? 500), + fallbackCodePoint: Number(payload.fallbackCodePoint ?? 63), + glyphs, + kernings, + atlas: + typeof payload.atlas === 'object' && payload.atlas !== null + ? { + width: Number((payload.atlas as Record).width ?? 1024), + height: Number((payload.atlas as Record).height ?? 1024), + padding: Number((payload.atlas as Record).padding ?? 1), + } + : undefined, + }; + } +} diff --git a/web/packages/ui/src/font/source.ts b/web/packages/ui/src/font/source.ts new file mode 100644 index 00000000..4eda1834 --- /dev/null +++ b/web/packages/ui/src/font/source.ts @@ -0,0 +1,194 @@ +import type { + DynamicFontFaceAsset, + FontAssetSource, + FontBinaryFormat, + FontFaceAsset, + FontGlyphMetric, + FontStyle, + FontWeight, + KerningPairKey, + RetryPolicy, + StaticFontFaceAsset, +} from '../types'; + +export const normalizeWeight = (weight: FontWeight | undefined): number => { + switch (weight) { + case 'thin': + return 100; + case 'extralight': + return 200; + case 'light': + return 300; + case 'normal': + return 400; + case 'medium': + return 500; + case 'semibold': + return 600; + case 'bold': + return 700; + case 'extrabold': + return 800; + case 'black': + return 900; + case undefined: + return 400; + default: + return weight; + } +}; + +export const normalizeStyle = (style: FontStyle | undefined): FontStyle => style ?? 'normal'; + +export const toByteArray = (value: ArrayBuffer | ArrayBufferView): Uint8Array => { + if (value instanceof ArrayBuffer) { + return new Uint8Array(value); + } + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); +}; + +export const toOwnedArrayBuffer = (value: ArrayBuffer | ArrayBufferView): ArrayBuffer => { + const bytes = toByteArray(value); + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +}; + +export const wait = async (delayMs: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); + +export const applyRetryDelay = (policy: RetryPolicy | undefined, attempt: number): number => { + const base = policy?.baseDelayMs ?? 16; + const max = policy?.maxDelayMs ?? 250; + const jitter = policy?.jitter ?? 0; + const exponential = Math.min(max, base * 2 ** Math.max(0, attempt - 1)); + if (jitter <= 0) { + return exponential; + } + const factor = 1 + (Math.random() * 2 - 1) * jitter; + return Math.max(0, Math.round(exponential * factor)); +}; + +export const isDynamicFontFaceAsset = (asset: FontFaceAsset): asset is DynamicFontFaceAsset => asset.kind === 'dynamic'; + +export const createAtlasEntryKey = (codePoint: number, rasterSize?: number): string => `${codePoint}:${rasterSize ?? 0}`; + +export const detectBinaryFormatFromContentType = (contentType: string | undefined): FontBinaryFormat | null => { + if (!contentType) { + return null; + } + const normalized = contentType.toLowerCase(); + if (normalized.includes('woff2')) { + return 'woff2'; + } + if (normalized.includes('woff')) { + return 'woff'; + } + if (normalized.includes('font/otf') || normalized.includes('opentype')) { + return 'otf'; + } + if (normalized.includes('font/ttf') || normalized.includes('truetype') || normalized.includes('font/sfnt')) { + return 'ttf'; + } + return null; +}; + +export const detectBinaryFormatFromUrl = (url: string): FontBinaryFormat | null => { + const normalized = url.toLowerCase().split('#')[0]!.split('?')[0]!; + if (normalized.endsWith('.woff2')) { + return 'woff2'; + } + if (normalized.endsWith('.woff')) { + return 'woff'; + } + if (normalized.endsWith('.otf')) { + return 'otf'; + } + if (normalized.endsWith('.ttf')) { + return 'ttf'; + } + return null; +}; + +export const detectBinaryFormatFromBuffer = (bytes: Uint8Array): FontBinaryFormat | null => { + if (bytes.byteLength < 4) { + return null; + } + const tag = String.fromCharCode(bytes[0] ?? 0, bytes[1] ?? 0, bytes[2] ?? 0, bytes[3] ?? 0); + if (tag === 'wOF2') { + return 'woff2'; + } + if (tag === 'wOFF') { + return 'woff'; + } + if (tag === 'OTTO') { + return 'otf'; + } + const sfnt = + (bytes[0] === 0x00 && bytes[1] === 0x01 && bytes[2] === 0x00 && bytes[3] === 0x00) || + tag === 'true' || + tag === 'typ1'; + return sfnt ? 'ttf' : null; +}; + +export const detectSourceBinaryFormat = (source: FontAssetSource): FontBinaryFormat | null => { + if (source.kind === 'descriptor') { + return null; + } + if (source.contentType) { + return detectBinaryFormatFromContentType(source.contentType); + } + if (source.kind === 'url') { + return detectBinaryFormatFromUrl(source.url); + } + return detectBinaryFormatFromBuffer(toByteArray(source.data)); +}; + +export const normalizeGlyphMap = ( + glyphs: StaticFontFaceAsset['glyphs'] | DynamicFontFaceAsset['glyphs'] | undefined +): Map => { + if (!glyphs) { + return new Map(); + } + if (glyphs instanceof Map) { + return new Map(glyphs); + } + if (Array.isArray(glyphs)) { + return new Map(glyphs.map((metric) => [metric.codePoint, metric])); + } + return new Map(Object.values(glyphs).map((metric) => [metric.codePoint, metric])); +}; + +export const normalizeKerningMap = ( + kernings: StaticFontFaceAsset['kernings'] | DynamicFontFaceAsset['kernings'] | undefined +): Map => { + if (!kernings) { + return new Map(); + } + if (kernings instanceof Map) { + return new Map(kernings); + } + return new Map(Object.entries(kernings) as [KerningPairKey, number][]); +}; + +export const buildSourceKey = (source: FontAssetSource): string => { + const metadata = [ + source.kind !== 'descriptor' ? source.family ?? '' : '', + source.kind !== 'descriptor' ? source.face ?? '' : '', + source.kind !== 'descriptor' ? normalizeStyle(source.style) : '', + source.kind !== 'descriptor' ? normalizeWeight(source.weight) : '', + source.kind !== 'descriptor' ? source.locale ?? '' : '', + ].join(':'); + switch (source.kind) { + case 'descriptor': + return `descriptor:${source.asset.kind ?? 'static'}:${source.asset.kind === 'dynamic' ? source.asset.runtime.info.family : source.asset.family}:${source.asset.kind === 'dynamic' ? source.asset.runtime.info.face ?? 'Regular' : source.asset.face ?? 'Regular'}:${source.asset.kind === 'dynamic' ? normalizeWeight(source.asset.runtime.info.weight) : normalizeWeight(source.asset.weight)}`; + case 'buffer': + return source.cacheKey ?? `buffer:${toByteArray(source.data).byteLength}:${source.contentType ?? 'application/octet-stream'}:${metadata}`; + case 'url': + return source.cacheKey ?? `url:${source.url}:${source.contentType ?? ''}:${metadata}`; + default: + return 'unknown'; + } +}; \ No newline at end of file diff --git a/web/packages/ui/src/index.ts b/web/packages/ui/src/index.ts index b0cac452..105a0b5f 100644 --- a/web/packages/ui/src/index.ts +++ b/web/packages/ui/src/index.ts @@ -1,8 +1,29 @@ export * from './types'; export * from './errors'; export * from './widget'; -export * from './layout'; +export { + UILayoutEngine, + compileLength, + compileLayoutInput, + normalizeAnchor, + normalizeCorners, + normalizeEdges, + resolveLength, +} from './layout'; +export type { LayoutTreeAdapter } from './layout'; export * from './render'; -export * from './font'; +export { + AXRONE_DEFAULT_UI_FONT_FAMILY, + FontRegistry, + createBrowserDynamicFontRuntimeFactory, + createBrowserSystemFontFaceRuntime, + createDefaultUIFontAsset, + createSystemFontFaceAsset, + ensureDefaultUIFont, + ensureSystemUIFont, +} from './font'; +export type { SystemFontFaceAssetOptions } from './font'; +export * from './font-runtime'; export * from './text'; -export * from './runtime'; \ No newline at end of file +export * from './runtime'; +export * from './controls'; diff --git a/web/packages/ui/src/layout.ts b/web/packages/ui/src/layout.ts index d2c32366..d8d828d2 100644 --- a/web/packages/ui/src/layout.ts +++ b/web/packages/ui/src/layout.ts @@ -1,233 +1,21 @@ -import { UIError, UIErrorCode } from './errors'; +import { clamp, resolveLength } from './layout/normalization'; import type { AlignMode, - Anchor, - AnchorInput, - CornerInput, - CornerRadii, - EdgeInput, EdgeInsets, LayoutBox, ReadonlyColor, ResolvedLayout, - ResolvedLength, SizeLike, - WidgetLayoutInput, } from './types'; -const ZERO_EDGES: EdgeInsets = Object.freeze({ top: 0, right: 0, bottom: 0, left: 0 }); -const ZERO_CORNERS: CornerRadii = Object.freeze({ topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 }); -const DEFAULT_ANCHOR: Anchor = Object.freeze({ - x: 0, - y: 0, - pivotX: 0, - pivotY: 0, - offsetX: 0, - offsetY: 0, - stretch: false, -}); -const AUTO_LENGTH: ResolvedLength = Object.freeze({ kind: 'auto', value: 0 }); -const CONTENT_LENGTH: ResolvedLength = Object.freeze({ kind: 'content', value: 0 }); - -const clamp = (value: number, min: number, max: number): number => { - if (value < min) { - return min; - } - if (value > max) { - return max; - } - return value; -}; - -const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); - -const isEdgeRecord = ( - value: EdgeInput -): value is Readonly>> => - typeof value === 'object' && value !== null && !Array.isArray(value); - -const createLength = (kind: ResolvedLength['kind'], value: number): ResolvedLength => ({ kind, value }); - -export const compileLength = (input: number | string | undefined): ResolvedLength => { - if (input === undefined || input === 'auto') { - return AUTO_LENGTH; - } - if (input === 'content') { - return CONTENT_LENGTH; - } - if (typeof input === 'number') { - if (!Number.isFinite(input)) { - throw new UIError(UIErrorCode.InvalidArgument, 'Length numbers must be finite.', { input }); - } - return createLength('px', input); - } - if (input.endsWith('%')) { - const value = Number.parseFloat(input.slice(0, -1)); - if (!Number.isFinite(value)) { - throw new UIError(UIErrorCode.InvalidArgument, 'Percent lengths must contain a valid number.', { - input, - }); - } - return createLength('percent', value / 100); - } - if (input.startsWith('stretch:')) { - const value = Number.parseFloat(input.slice('stretch:'.length)); - if (!Number.isFinite(value) || value <= 0) { - throw new UIError(UIErrorCode.InvalidArgument, 'Stretch lengths must be greater than zero.', { input }); - } - return createLength('stretch', value); - } - if (input.startsWith('viewport:')) { - const value = Number.parseFloat(input.slice('viewport:'.length)); - if (!Number.isFinite(value)) { - throw new UIError(UIErrorCode.InvalidArgument, 'Viewport lengths must contain a valid number.', { - input, - }); - } - return createLength('viewport', value); - } - throw new UIError(UIErrorCode.InvalidArgument, 'Unsupported length input.', { input }); -}; - -export const normalizeEdges = (input: EdgeInput | undefined): EdgeInsets => { - if (input === undefined) { - return ZERO_EDGES; - } - if (typeof input === 'number') { - return { top: input, right: input, bottom: input, left: input }; - } - if (Array.isArray(input)) { - if (input.length === 2) { - return { top: input[0], right: input[1], bottom: input[0], left: input[1] }; - } - if (input.length === 4) { - return { - top: input[0], - right: input[1], - bottom: input[2], - left: input[3], - }; - } - } - if (!isEdgeRecord(input)) { - return ZERO_EDGES; - } - return { - top: input.top ?? 0, - right: input.right ?? 0, - bottom: input.bottom ?? 0, - left: input.left ?? 0, - }; -}; - -export const normalizeCorners = (input: CornerInput | undefined): CornerRadii => { - if (input === undefined) { - return ZERO_CORNERS; - } - if (typeof input === 'number') { - return { topLeft: input, topRight: input, bottomRight: input, bottomLeft: input }; - } - return { - topLeft: input[0], - topRight: input[1], - bottomRight: input[2], - bottomLeft: input[3], - }; -}; - -export const normalizeAnchor = (input: AnchorInput | undefined): Anchor => { - if (input === undefined) { - return DEFAULT_ANCHOR; - } - if (typeof input === 'string') { - switch (input) { - case 'top-left': - return { ...DEFAULT_ANCHOR }; - case 'top': - return { ...DEFAULT_ANCHOR, x: 0.5, pivotX: 0.5 }; - case 'top-right': - return { ...DEFAULT_ANCHOR, x: 1, pivotX: 1 }; - case 'left': - return { ...DEFAULT_ANCHOR, y: 0.5, pivotY: 0.5 }; - case 'center': - return { ...DEFAULT_ANCHOR, x: 0.5, y: 0.5, pivotX: 0.5, pivotY: 0.5 }; - case 'right': - return { ...DEFAULT_ANCHOR, x: 1, y: 0.5, pivotX: 1, pivotY: 0.5 }; - case 'bottom-left': - return { ...DEFAULT_ANCHOR, y: 1, pivotY: 1 }; - case 'bottom': - return { ...DEFAULT_ANCHOR, x: 0.5, y: 1, pivotX: 0.5, pivotY: 1 }; - case 'bottom-right': - return { ...DEFAULT_ANCHOR, x: 1, y: 1, pivotX: 1, pivotY: 1 }; - case 'stretch': - return { ...DEFAULT_ANCHOR, stretch: true }; - default: - return DEFAULT_ANCHOR; - } - } - return { - x: input.x ?? 0, - y: input.y ?? 0, - pivotX: input.pivotX ?? input.x ?? 0, - pivotY: input.pivotY ?? input.y ?? 0, - offsetX: input.offsetX ?? 0, - offsetY: input.offsetY ?? 0, - stretch: input.stretch ?? false, - }; -}; - -export const resolveLength = ( - length: ResolvedLength, - available: number, - content: number, - viewport: number = available -): number => { - switch (length.kind) { - case 'px': - return length.value; - case 'percent': - return Number.isFinite(available) ? available * length.value : content; - case 'stretch': - return Number.isFinite(available) ? available * length.value : content; - case 'viewport': - return Number.isFinite(viewport) ? viewport * length.value : content; - case 'content': - case 'auto': - default: - return content; - } -}; - -export const compileLayoutInput = (input: WidgetLayoutInput | undefined): ResolvedLayout => { - const inset = input?.inset; - return { - display: input?.display ?? 'stack', - direction: input?.direction ?? 'column', - gap: input?.gap ?? 0, - padding: normalizeEdges(input?.padding), - margin: normalizeEdges(input?.margin), - width: compileLength(input?.width), - height: compileLength(input?.height), - minWidth: input?.minWidth ?? 0, - minHeight: input?.minHeight ?? 0, - maxWidth: input?.maxWidth ?? Number.POSITIVE_INFINITY, - maxHeight: input?.maxHeight ?? Number.POSITIVE_INFINITY, - grow: Math.max(0, input?.grow ?? 0), - shrink: Math.max(0, input?.shrink ?? 1), - basis: compileLength(input?.basis), - alignItems: input?.alignItems ?? 'start', - alignSelf: input?.alignSelf ?? 'auto', - justifyContent: input?.justifyContent ?? 'start', - position: input?.position ?? 'flow', - insetTop: inset?.top === undefined ? undefined : compileLength(inset.top), - insetRight: inset?.right === undefined ? undefined : compileLength(inset.right), - insetBottom: inset?.bottom === undefined ? undefined : compileLength(inset.bottom), - insetLeft: inset?.left === undefined ? undefined : compileLength(inset.left), - anchor: normalizeAnchor(input?.anchor), - aspectRatio: input?.aspectRatio ?? 0, - zIndex: input?.zIndex ?? 0, - }; -}; +export { + compileLength, + compileLayoutInput, + normalizeAnchor, + normalizeCorners, + normalizeEdges, + resolveLength, +} from './layout/normalization'; export interface LayoutTreeAdapter { readonly root: TNode; @@ -257,14 +45,16 @@ const createBox = ( y: number, width: number, height: number, - padding: EdgeInsets + padding: EdgeInsets, + contentOffsetX: number, + contentOffsetY: number ): LayoutBox => ({ x, y, width, height, - contentX: x + padding.left, - contentY: y + padding.top, + contentX: x + padding.left - contentOffsetX, + contentY: y + padding.top - contentOffsetY, contentWidth: Math.max(0, width - sumHorizontal(padding)), contentHeight: Math.max(0, height - sumVertical(padding)), }); @@ -409,7 +199,15 @@ export class UILayoutEngine { width: forcedWidth ?? this.measureNode(adapter, node, availableWidth, availableHeight).width, height: forcedHeight ?? this.measureNode(adapter, node, availableWidth, availableHeight).height, }; - const box = createBox(x, y, measured.width, measured.height, layout.padding); + const box = createBox( + x, + y, + measured.width, + measured.height, + layout.padding, + layout.contentOffsetX, + layout.contentOffsetY + ); adapter.setBox(node, box); if (adapter.getFirstChild(node) !== null) { if (layout.display === 'overlay') { @@ -641,13 +439,13 @@ export class UILayoutEngine { } export type { + Anchor, + CornerRadii, + EdgeInsets, LayoutBox, - WidgetLayoutInput, + ReadonlyColor, ResolvedLayout, ResolvedLength, - EdgeInsets, - CornerRadii, - Anchor, SizeLike, - ReadonlyColor, -}; \ No newline at end of file + WidgetLayoutInput, +} from './types'; diff --git a/web/packages/ui/src/layout/normalization.ts b/web/packages/ui/src/layout/normalization.ts new file mode 100644 index 00000000..18f11a4c --- /dev/null +++ b/web/packages/ui/src/layout/normalization.ts @@ -0,0 +1,226 @@ +import { UIError, UIErrorCode } from '../errors'; +import type { + Anchor, + AnchorInput, + CornerInput, + CornerRadii, + EdgeInput, + EdgeInsets, + ResolvedLayout, + ResolvedLength, + WidgetLayoutInput, +} from '../types'; + +const ZERO_EDGES: EdgeInsets = Object.freeze({ top: 0, right: 0, bottom: 0, left: 0 }); +const ZERO_CORNERS: CornerRadii = Object.freeze({ topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 }); +const DEFAULT_ANCHOR: Anchor = Object.freeze({ + x: 0, + y: 0, + pivotX: 0, + pivotY: 0, + offsetX: 0, + offsetY: 0, + stretch: false, +}); +const AUTO_LENGTH: ResolvedLength = Object.freeze({ kind: 'auto', value: 0 }); +const CONTENT_LENGTH: ResolvedLength = Object.freeze({ kind: 'content', value: 0 }); + +export const clamp = (value: number, min: number, max: number): number => { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; +}; + +const isEdgeRecord = ( + value: EdgeInput +): value is Readonly>> => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const createLength = (kind: ResolvedLength['kind'], value: number): ResolvedLength => ({ kind, value }); + +export const compileLength = (input: number | string | undefined): ResolvedLength => { + if (input === undefined || input === 'auto') { + return AUTO_LENGTH; + } + if (input === 'content') { + return CONTENT_LENGTH; + } + if (typeof input === 'number') { + if (!Number.isFinite(input)) { + throw new UIError(UIErrorCode.InvalidArgument, 'Length numbers must be finite.', { input }); + } + return createLength('px', input); + } + if (input.endsWith('%')) { + const value = Number.parseFloat(input.slice(0, -1)); + if (!Number.isFinite(value)) { + throw new UIError(UIErrorCode.InvalidArgument, 'Percent lengths must contain a valid number.', { + input, + }); + } + return createLength('percent', value / 100); + } + if (input.startsWith('stretch:')) { + const value = Number.parseFloat(input.slice('stretch:'.length)); + if (!Number.isFinite(value) || value <= 0) { + throw new UIError(UIErrorCode.InvalidArgument, 'Stretch lengths must be greater than zero.', { input }); + } + return createLength('stretch', value); + } + if (input.startsWith('viewport:')) { + const value = Number.parseFloat(input.slice('viewport:'.length)); + if (!Number.isFinite(value)) { + throw new UIError(UIErrorCode.InvalidArgument, 'Viewport lengths must contain a valid number.', { + input, + }); + } + return createLength('viewport', value); + } + throw new UIError(UIErrorCode.InvalidArgument, 'Unsupported length input.', { input }); +}; + +export const normalizeEdges = (input: EdgeInput | undefined): EdgeInsets => { + if (input === undefined) { + return ZERO_EDGES; + } + if (typeof input === 'number') { + return { top: input, right: input, bottom: input, left: input }; + } + if (Array.isArray(input)) { + if (input.length === 2) { + return { top: input[0], right: input[1], bottom: input[0], left: input[1] }; + } + if (input.length === 4) { + return { + top: input[0], + right: input[1], + bottom: input[2], + left: input[3], + }; + } + } + if (!isEdgeRecord(input)) { + return ZERO_EDGES; + } + return { + top: input.top ?? 0, + right: input.right ?? 0, + bottom: input.bottom ?? 0, + left: input.left ?? 0, + }; +}; + +export const normalizeCorners = (input: CornerInput | undefined): CornerRadii => { + if (input === undefined) { + return ZERO_CORNERS; + } + if (typeof input === 'number') { + return { topLeft: input, topRight: input, bottomRight: input, bottomLeft: input }; + } + return { + topLeft: input[0], + topRight: input[1], + bottomRight: input[2], + bottomLeft: input[3], + }; +}; + +export const normalizeAnchor = (input: AnchorInput | undefined): Anchor => { + if (input === undefined) { + return DEFAULT_ANCHOR; + } + if (typeof input === 'string') { + switch (input) { + case 'top-left': + return { ...DEFAULT_ANCHOR }; + case 'top': + return { ...DEFAULT_ANCHOR, x: 0.5, pivotX: 0.5 }; + case 'top-right': + return { ...DEFAULT_ANCHOR, x: 1, pivotX: 1 }; + case 'left': + return { ...DEFAULT_ANCHOR, y: 0.5, pivotY: 0.5 }; + case 'center': + return { ...DEFAULT_ANCHOR, x: 0.5, y: 0.5, pivotX: 0.5, pivotY: 0.5 }; + case 'right': + return { ...DEFAULT_ANCHOR, x: 1, y: 0.5, pivotX: 1, pivotY: 0.5 }; + case 'bottom-left': + return { ...DEFAULT_ANCHOR, y: 1, pivotY: 1 }; + case 'bottom': + return { ...DEFAULT_ANCHOR, x: 0.5, y: 1, pivotX: 0.5, pivotY: 1 }; + case 'bottom-right': + return { ...DEFAULT_ANCHOR, x: 1, y: 1, pivotX: 1, pivotY: 1 }; + case 'stretch': + return { ...DEFAULT_ANCHOR, stretch: true }; + default: + return DEFAULT_ANCHOR; + } + } + return { + x: input.x ?? 0, + y: input.y ?? 0, + pivotX: input.pivotX ?? input.x ?? 0, + pivotY: input.pivotY ?? input.y ?? 0, + offsetX: input.offsetX ?? 0, + offsetY: input.offsetY ?? 0, + stretch: input.stretch ?? false, + }; +}; + +export const resolveLength = ( + length: ResolvedLength, + available: number, + content: number, + viewport: number = available +): number => { + switch (length.kind) { + case 'px': + return length.value; + case 'percent': + return Number.isFinite(available) ? available * length.value : content; + case 'stretch': + return Number.isFinite(available) ? available * length.value : content; + case 'viewport': + return Number.isFinite(viewport) ? viewport * length.value : content; + case 'content': + case 'auto': + default: + return content; + } +}; + +export const compileLayoutInput = (input: WidgetLayoutInput | undefined): ResolvedLayout => { + const inset = input?.inset; + return { + display: input?.display ?? 'stack', + direction: input?.direction ?? 'column', + gap: input?.gap ?? 0, + padding: normalizeEdges(input?.padding), + contentOffsetX: input?.contentOffsetX ?? 0, + contentOffsetY: input?.contentOffsetY ?? 0, + margin: normalizeEdges(input?.margin), + width: compileLength(input?.width), + height: compileLength(input?.height), + minWidth: input?.minWidth ?? 0, + minHeight: input?.minHeight ?? 0, + maxWidth: input?.maxWidth ?? Number.POSITIVE_INFINITY, + maxHeight: input?.maxHeight ?? Number.POSITIVE_INFINITY, + grow: Math.max(0, input?.grow ?? 0), + shrink: Math.max(0, input?.shrink ?? 1), + basis: compileLength(input?.basis), + alignItems: input?.alignItems ?? 'start', + alignSelf: input?.alignSelf ?? 'auto', + justifyContent: input?.justifyContent ?? 'start', + position: input?.position ?? 'flow', + insetTop: inset?.top === undefined ? undefined : compileLength(inset.top), + insetRight: inset?.right === undefined ? undefined : compileLength(inset.right), + insetBottom: inset?.bottom === undefined ? undefined : compileLength(inset.bottom), + insetLeft: inset?.left === undefined ? undefined : compileLength(inset.left), + anchor: normalizeAnchor(input?.anchor), + aspectRatio: input?.aspectRatio ?? 0, + zIndex: input?.zIndex ?? 0, + }; +}; diff --git a/web/packages/ui/src/runtime.ts b/web/packages/ui/src/runtime.ts index 583fd1b8..4d7feee3 100644 --- a/web/packages/ui/src/runtime.ts +++ b/web/packages/ui/src/runtime.ts @@ -5,8 +5,34 @@ import { WidgetNotFoundError, WidgetTreeIntegrityError, } from './errors'; -import { FontRegistry } from './font'; -import { UILayoutEngine, compileLayoutInput, normalizeCorners } from './layout'; +import { FontRegistry, ensureDefaultUIFont } from './font'; +import { UILayoutEngine, compileLayoutInput } from './layout'; +import { NodeFlag } from './runtime/node-flags'; +import { + type StoredWidgetRecord, + compileWidgetFocus, + compileWidgetImage, + compileWidgetStyle, + compileWidgetText, + normalizeWidgetRecord, +} from './runtime/records'; +import { + EMPTY_FOCUS_INPUT, + EMPTY_LAYOUT_INPUT, + EMPTY_RECORD_OBJECT, + EMPTY_STYLE_INPUT, + TRANSPARENT, + cloneData, + intersectRect, + intersectsPoint, + mergeFocusInput, + mergeHandlers, + mergeImageInput, + mergeLayoutInput, + mergeProps, + mergeStyleInput, + mergeTextInput, +} from './runtime/internals'; import { TextLayoutEngine } from './text'; import { WidgetRegistry } from './widget'; import type { @@ -18,7 +44,6 @@ import type { ImageRenderCommand, LayoutBox, QuadRenderCommand, - ReadonlyColor, RenderCommand, ResolvedFocusPolicy, ResolvedWidgetImage, @@ -26,7 +51,6 @@ import type { ResolvedTextBlock, ResolvedWidgetStyle, SizeLike, - TextBlockInput, TextLayoutResult, TextRenderCommand, UIFrame, @@ -39,13 +63,10 @@ import type { WidgetEventContext, WidgetEventHandlers, WidgetFocusChangeEvent, - WidgetFocusPolicyInput, - WidgetImageInput, WidgetId, WidgetKey, WidgetLayoutInput, WidgetPatch, - WidgetRole, WidgetSerializableKey, WidgetSnapshot, WidgetStyleInput, @@ -63,295 +84,13 @@ export interface UIRuntimeOptions { readonly textCacheSize?: number; } -interface StoredWidgetRecord { - readonly role: WidgetRole; - readonly controller: string | null; - readonly key?: WidgetKey; - readonly props: Readonly>; - readonly enabled: boolean; - readonly interactive: boolean; - readonly layoutInput: WidgetLayoutInput; - readonly styleInput: WidgetStyleInput; - readonly textInput: TextBlockInput | null; - readonly imageInput: WidgetImageInput | null; - readonly focusInput: WidgetFocusPolicyInput; - readonly handlers: WidgetEventHandlers, UIRuntime> | null; -} - -const EMPTY_RECORD_OBJECT: Readonly> = Object.freeze({}); -const EMPTY_LAYOUT_INPUT: WidgetLayoutInput = Object.freeze({}); -const EMPTY_STYLE_INPUT: WidgetStyleInput = Object.freeze({}); -const EMPTY_FOCUS_INPUT: WidgetFocusPolicyInput = Object.freeze({}); -const TRANSPARENT: ReadonlyColor = Object.freeze({ r: 0, g: 0, b: 0, a: 0 }); -const BLACK: ReadonlyColor = Object.freeze({ r: 0, g: 0, b: 0, a: 1 }); -const WHITE: ReadonlyColor = Object.freeze({ r: 1, g: 1, b: 1, a: 1 }); - -const enum NodeFlag { - Allocated = 1 << 0, - Visible = 1 << 1, - Interactive = 1 << 2, - Enabled = 1 << 3, - Focusable = 1 << 4, - TextDirty = 1 << 5, -} - -const clamp = (value: number, min: number, max: number): number => { - if (value < min) { - return min; - } - if (value > max) { - return max; - } - return value; -}; - -const isPlainObject = (value: unknown): value is Record => { - if (value === null || typeof value !== 'object') { - return false; - } - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -}; - -const isColorLike = ( - value: ColorInput -): value is { readonly r: number; readonly g: number; readonly b: number; readonly a?: number } => - typeof value === 'object' && value !== null && !Array.isArray(value); - -const cloneData = (value: TValue): TValue => { - if (value === null || value === undefined) { - return value; - } - if (Array.isArray(value)) { - return value.map((entry) => cloneData(entry)) as TValue; - } - if (isPlainObject(value)) { - const clone: Record = {}; - for (const [key, entry] of Object.entries(value)) { - if (typeof entry === 'function' || typeof entry === 'symbol') { - continue; - } - clone[key] = cloneData(entry); - } - return clone as TValue; - } - if (typeof value === 'function' || typeof value === 'symbol') { - return undefined as TValue; - } - return value; -}; - -const colorFromNumber = (value: number): ReadonlyColor => ({ - r: ((value >>> 24) & 0xff) / 255, - g: ((value >>> 16) & 0xff) / 255, - b: ((value >>> 8) & 0xff) / 255, - a: (value & 0xff) / 255, -}); - -const colorFromHex = (value: string): ReadonlyColor => { - const hex = value.replace('#', '').trim(); - if (hex.length === 3) { - return { - r: Number.parseInt(hex[0] + hex[0], 16) / 255, - g: Number.parseInt(hex[1] + hex[1], 16) / 255, - b: Number.parseInt(hex[2] + hex[2], 16) / 255, - a: 1, - }; - } - if (hex.length === 6 || hex.length === 8) { - return { - r: Number.parseInt(hex.slice(0, 2), 16) / 255, - g: Number.parseInt(hex.slice(2, 4), 16) / 255, - b: Number.parseInt(hex.slice(4, 6), 16) / 255, - a: hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1, - }; - } - return TRANSPARENT; -}; - -const normalizeColor = (input: ColorInput | undefined, fallback: ReadonlyColor): ReadonlyColor => { - if (input === undefined) { - return fallback; - } - if (typeof input === 'number') { - return colorFromNumber(input >>> 0); - } - if (typeof input === 'string') { - return colorFromHex(input); - } - if (Array.isArray(input)) { - if (input.length === 3) { - return { r: input[0], g: input[1], b: input[2], a: 1 }; - } - return { r: input[0], g: input[1], b: input[2], a: input[3] }; - } - if (!isColorLike(input)) { - return fallback; - } - return { - r: input.r, - g: input.g, - b: input.b, - a: input.a ?? 1, - }; -}; - -const normalizeWeight = (value: ResolvedTextBlock['weight'] | TextBlockInput['weight']): number => { - switch (value) { - case 'thin': - return 100; - case 'extralight': - return 200; - case 'light': - return 300; - case 'normal': - return 400; - case 'medium': - return 500; - case 'semibold': - return 600; - case 'bold': - return 700; - case 'extrabold': - return 800; - case 'black': - return 900; - case undefined: - return 400; - default: - return value; - } -}; - -const mergeLayoutInput = ( - base: WidgetLayoutInput, - patch: WidgetPatch['layout'] | undefined -): WidgetLayoutInput => { - if (!patch) { - return base; - } - return { - ...base, - ...patch, - inset: patch.inset ? { ...(base.inset ?? {}), ...patch.inset } : base.inset, - anchor: - patch.anchor && isPlainObject(patch.anchor) && isPlainObject(base.anchor) - ? { ...base.anchor, ...patch.anchor } - : patch.anchor ?? base.anchor, - } as WidgetLayoutInput; -}; - -const mergeStyleInput = ( - base: WidgetStyleInput, - patch: WidgetPatch['style'] | undefined -): WidgetStyleInput => ({ ...(base ?? {}), ...(patch ?? {}) }) as WidgetStyleInput; - -const mergeTextInput = ( - base: TextBlockInput | null, - patch: WidgetPatch['text'] | undefined -): TextBlockInput | null => { - if (patch === undefined) { - return base; - } - if (patch === null) { - return null; - } - return { ...(base ?? { value: '' }), ...patch } as TextBlockInput; -}; - -const mergeImageInput = ( - base: WidgetImageInput | null, - patch: WidgetPatch['image'] | undefined -): WidgetImageInput | null => { - if (patch === undefined) { - return base; - } - if (patch === null) { - return null; - } - const next = patch as WidgetImageInput; - return { - ...(base ?? {}), - ...next, - uvRect: next.uvRect ? { ...(base?.uvRect ?? {}), ...next.uvRect } : base?.uvRect, - } as WidgetImageInput; -}; - -const mergeFocusInput = ( - base: WidgetFocusPolicyInput, - patch: WidgetPatch['focus'] | undefined -): WidgetFocusPolicyInput => ({ ...(base ?? {}), ...(patch ?? {}) }); - -const mergeHandlers = ( - base: WidgetEventHandlers, UIRuntime> | null, - patch: WidgetEventHandlers, UIRuntime> | undefined -): WidgetEventHandlers, UIRuntime> | null => { - if (patch === undefined) { - return base; - } - return { ...(base ?? {}), ...patch }; -}; - -const mergeProps = ( - base: Readonly>, - patch: Readonly> | undefined -): Readonly> => ({ ...(base ?? EMPTY_RECORD_OBJECT), ...(patch ?? {}) }); - -const normalizeIndex = (value: number | undefined): number | null => { - if (value === undefined || value === null) { - return null; - } - if (!Number.isFinite(value)) { - return null; - } - return Math.max(0, Math.floor(value)); -}; - -const normalizeUvRect = (input: WidgetImageInput['uvRect']): { readonly x: number; readonly y: number; readonly width: number; readonly height: number } => { - const x = clamp(input?.x ?? 0, 0, 1); - const y = clamp(input?.y ?? 0, 0, 1); - const width = clamp(input?.width ?? 1, 0, 1 - x); - const height = clamp(input?.height ?? 1, 0, 1 - y); - return { x, y, width, height }; -}; - -const intersectsPoint = (box: LayoutBox | null, x: number, y: number): boolean => { - if (!box) { - return false; - } - return x >= box.x && y >= box.y && x <= box.x + box.width && y <= box.y + box.height; -}; - -const intersectRect = (left: LayoutBox | null, right: LayoutBox): LayoutBox | null => { - if (!left) { - return right; - } - const x = Math.max(left.x, right.x); - const y = Math.max(left.y, right.y); - const maxX = Math.min(left.x + left.width, right.x + right.width); - const maxY = Math.min(left.y + left.height, right.y + right.height); - if (maxX <= x || maxY <= y) { - return null; - } - return { - x, - y, - width: maxX - x, - height: maxY - y, - contentX: x, - contentY: y, - contentWidth: maxX - x, - contentHeight: maxY - y, - }; -}; - export class UIRuntime implements Disposable { readonly fonts: FontRegistry; readonly textEngine: TextLayoutEngine; readonly registry: WidgetRegistry, TPayload>; private readonly layoutEngine = new UILayoutEngine(); - private records: Array | null> = []; + private records: Array> | null> = []; private layouts: Array = []; private styles: Array = []; private texts: Array = []; @@ -398,13 +137,26 @@ export class UIRuntime implements Disposable { this.viewportWidth = Math.max(0, options.width ?? 0); this.viewportHeight = Math.max(0, options.height ?? 0); this.fonts = options.fonts ?? new FontRegistry(options.fontOptions); + if (this.fonts.getDefaultFamily() === null) { + try { + ensureDefaultUIFont(this.fonts); + } catch (error) { + if ( + !(error instanceof UIError) || + error.code !== UIErrorCode.FontLoadFailed || + !error.message.includes('No 2D canvas implementation is available') + ) { + throw error; + } + } + } this.textEngine = options.textEngine ?? new TextLayoutEngine(this.fonts, { cacheSize: options.textCacheSize, locale: this.locale }); this.registry = options.registry ?? new WidgetRegistry, TPayload>(); const rootId = this.allocate(); this.rootId = rootId as WidgetId; - this.records[rootId] = this.normalizeRecord({ + this.records[rootId] = normalizeWidgetRecord({ role: 'root', layout: { display: 'overlay', @@ -447,7 +199,7 @@ export class UIRuntime implements Disposable { ): WidgetId { this.ensureActive(); const id = this.allocate(); - this.records[id] = this.normalizeRecord(config as WidgetConfig, UIRuntime>); + this.records[id] = normalizeWidgetRecord(config as WidgetConfig, UIRuntime>); this.applyRecord(id, null, null, true); return id as WidgetId; } @@ -521,7 +273,7 @@ export class UIRuntime implements Disposable { } const previousController = current.controller; const previousProps = current.props; - const merged: StoredWidgetRecord = { + const merged: StoredWidgetRecord> = { role: patch.role ?? current.role, controller: patch.controller ?? current.controller, key: patch.key ?? current.key, @@ -593,6 +345,22 @@ export class UIRuntime implements Disposable { return Math.max(0, this.liveCount - 1); } + collectSubtreeWidgetIds(widget: WidgetId): WidgetId[] { + const index = this.requireWidget(widget); + const widgets: WidgetId[] = []; + const stack = [index]; + + while (stack.length > 0) { + const current = stack.pop()!; + widgets.push(current as WidgetId); + for (let child = this.lastChild[current]; child !== 0; child = this.previousSibling[child]) { + stack.push(child); + } + } + + return widgets; + } + commit(viewport?: Partial): UIFrame { this.ensureActive(); if (viewport) { @@ -708,7 +476,7 @@ export class UIRuntime implements Disposable { this.locale = snapshot.locale; this.clear(); const rootSnapshot = snapshot.root; - this.records[this.rootId] = this.normalizeRecord({ + this.records[this.rootId] = normalizeWidgetRecord({ role: rootSnapshot.role, controller: rootSnapshot.controller, key: rootSnapshot.key ?? undefined, @@ -748,25 +516,6 @@ export class UIRuntime implements Disposable { } } - private normalizeRecord( - config: WidgetConfig, UIRuntime> - ): StoredWidgetRecord { - return { - role: config.role ?? 'container', - controller: config.controller ?? null, - key: config.key, - props: cloneData(config.props ?? EMPTY_RECORD_OBJECT), - enabled: config.enabled ?? true, - interactive: config.interactive ?? false, - layoutInput: cloneData(config.layout ?? EMPTY_LAYOUT_INPUT), - styleInput: cloneData(config.style ?? EMPTY_STYLE_INPUT), - textInput: cloneData(config.text ?? null), - imageInput: cloneData(config.image ?? null), - focusInput: cloneData(config.focus ?? EMPTY_FOCUS_INPUT), - handlers: (config.handlers as WidgetEventHandlers, UIRuntime>) ?? null, - }; - } - private applyRecord( index: number, previousProps: Readonly> | null, @@ -784,10 +533,14 @@ export class UIRuntime implements Disposable { this.states[index] = undefined; } this.layouts[index] = compileLayoutInput(record.layoutInput); - this.styles[index] = this.compileStyle(record.styleInput); - this.texts[index] = this.compileText(record.textInput, this.styles[index]!.color); - this.images[index] = this.compileImage(record.imageInput); - this.focuses[index] = this.compileFocus(record.focusInput, record.interactive); + this.styles[index] = compileWidgetStyle(record.styleInput); + this.texts[index] = compileWidgetText(record.textInput, { + defaultFamily: this.fonts.getDefaultFamily(), + locale: this.locale, + fallbackColor: this.styles[index]!.color, + }); + this.images[index] = compileWidgetImage(record.imageInput); + this.focuses[index] = compileWidgetFocus(record.focusInput, record.interactive); this.textLayouts[index] = null; this.textLayoutWidths[index] = Number.NaN; this.updateFlags(index); @@ -801,100 +554,6 @@ export class UIRuntime implements Disposable { this.focusDirty = true; } - private compileStyle(input: WidgetStyleInput): ResolvedWidgetStyle { - return { - visible: input.visible ?? true, - opacity: clamp(input.opacity ?? 1, 0, 1), - clip: input.clip ?? false, - background: normalizeColor(input.background, TRANSPARENT), - borderColor: normalizeColor(input.borderColor, TRANSPARENT), - borderWidth: Math.max(0, input.borderWidth ?? 0), - radius: normalizeCorners(input.radius), - color: normalizeColor(input.color, BLACK), - }; - } - - private compileText(input: TextBlockInput | null, fallbackColor: ReadonlyColor): ResolvedTextBlock | null { - if (!input) { - return null; - } - return { - value: input.value, - family: input.family ?? this.fonts.getDefaultFamily() ?? '', - size: Math.max(1, input.size ?? 16), - weight: normalizeWeight(input.weight), - style: input.style ?? 'normal', - locale: input.locale ?? this.locale, - direction: input.direction ?? 'auto', - lineHeight: Math.max(0, input.lineHeight ?? 0), - letterSpacing: input.letterSpacing ?? 0, - wrap: input.wrap ?? 'word', - overflow: input.overflow ?? 'clip', - maxLines: Math.max(1, Math.floor(input.maxLines ?? Number.MAX_SAFE_INTEGER)), - align: input.align ?? 'start', - color: normalizeColor(input.color, fallbackColor), - outlineColor: normalizeColor(input.outlineColor, TRANSPARENT), - outlineWidth: Math.max(0, input.outlineWidth ?? 0), - edgeSoftness: Math.max(0.5, input.edgeSoftness ?? 1), - shadowColor: normalizeColor(input.shadowColor, TRANSPARENT), - shadowOffsetX: input.shadowOffsetX ?? 0, - shadowOffsetY: input.shadowOffsetY ?? 0, - underline: input.underline ?? false, - underlineColor: normalizeColor(input.underlineColor, TRANSPARENT), - underlineThickness: Math.max(1, input.underlineThickness ?? 1), - underlineOffset: input.underlineOffset ?? 1, - strikeThrough: input.strikeThrough ?? false, - strikeThroughColor: normalizeColor(input.strikeThroughColor, TRANSPARENT), - strikeThroughThickness: Math.max(1, input.strikeThroughThickness ?? 1), - selectionStart: normalizeIndex(input.selectionStart), - selectionEnd: normalizeIndex(input.selectionEnd), - selectionColor: normalizeColor(input.selectionColor, TRANSPARENT), - caretIndex: normalizeIndex(input.caretIndex), - caretColor: normalizeColor(input.caretColor, TRANSPARENT), - caretWidth: Math.max(1, input.caretWidth ?? 1), - caretInset: Math.max(0, input.caretInset ?? 1), - }; - } - - private compileImage(input: WidgetImageInput | null): ResolvedWidgetImage | null { - if (!input) { - return null; - } - const source = input.source.kind === 'material' - ? { - kind: 'material' as const, - materialId: input.source.materialId, - textureBinding: input.source.textureBinding, - width: Math.max(1, input.source.width), - height: Math.max(1, input.source.height), - } - : { - kind: 'texture' as const, - resourceId: input.source.resourceId, - width: Math.max(1, input.source.width), - height: Math.max(1, input.source.height), - }; - return { - source, - fit: input.fit ?? 'fill', - alignX: clamp(input.alignX ?? 0.5, 0, 1), - alignY: clamp(input.alignY ?? 0.5, 0, 1), - sampling: input.sampling ?? 'linear', - tint: normalizeColor(input.tint, WHITE), - uvRect: normalizeUvRect(input.uvRect), - }; - } - - private compileFocus(input: WidgetFocusPolicyInput, interactive: boolean): ResolvedFocusPolicy { - return { - focusable: input.focusable ?? interactive, - tabIndex: input.tabIndex ?? 0, - scope: input.scope ?? false, - cycle: input.cycle ?? false, - order: input.order ?? 0, - }; - } - private updateFlags(index: number): void { const style = this.styles[index]!; const focus = this.focuses[index]!; @@ -1507,6 +1166,12 @@ export class UIRuntime implements Disposable { const target = this.hitTest(event.x, event.y); if (event.phase === 'move') { this.updateHover(target, event); + if (this.pressed) { + if (target && this.pressed !== target) { + return this.bubbleEvent(this.pressed as number, event) || this.bubbleEvent(target as number, event); + } + return this.bubbleEvent((target ?? this.pressed) as number, event); + } if (target) { return this.bubbleEvent(target as number, event); } @@ -1534,6 +1199,9 @@ export class UIRuntime implements Disposable { } private dispatchKey(event: Readonly): boolean { + if (this.focused && this.bubbleEvent(this.focused as number, event)) { + return true; + } if (event.phase === 'down') { if (event.key === 'Tab') { const direction: FocusMoveDirection = event.shiftKey ? 'backward' : 'forward'; @@ -1552,7 +1220,7 @@ export class UIRuntime implements Disposable { return this.moveFocus('down') !== null; } } - return this.focused ? this.bubbleEvent(this.focused as number, event) : false; + return false; } private dispatchText(event: Readonly): boolean { @@ -1934,4 +1602,4 @@ export type { WidgetSnapshot, WidgetStyleInput, UIRuntimeSnapshot, -}; \ No newline at end of file +}; diff --git a/web/packages/ui/src/runtime/internals.ts b/web/packages/ui/src/runtime/internals.ts new file mode 100644 index 00000000..0b42267c --- /dev/null +++ b/web/packages/ui/src/runtime/internals.ts @@ -0,0 +1,266 @@ +import type { + ColorInput, + LayoutBox, + ReadonlyColor, + ResolvedTextBlock, + TextBlockInput, + WidgetEventHandlers, + WidgetFocusPolicyInput, + WidgetImageInput, + WidgetLayoutInput, + WidgetPatch, + WidgetStyleInput, +} from '../types'; +import { isPlainObject } from '@axrone/utility'; + +export const EMPTY_RECORD_OBJECT: Readonly> = Object.freeze({}); +export const EMPTY_LAYOUT_INPUT: WidgetLayoutInput = Object.freeze({}); +export const EMPTY_STYLE_INPUT: WidgetStyleInput = Object.freeze({}); +export const EMPTY_FOCUS_INPUT: WidgetFocusPolicyInput = Object.freeze({}); +export const TRANSPARENT: ReadonlyColor = Object.freeze({ r: 0, g: 0, b: 0, a: 0 }); +export const BLACK: ReadonlyColor = Object.freeze({ r: 0, g: 0, b: 0, a: 1 }); +export const WHITE: ReadonlyColor = Object.freeze({ r: 1, g: 1, b: 1, a: 1 }); + +export const clamp = (value: number, min: number, max: number): number => { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; +}; + +const isColorLike = ( + value: ColorInput +): value is { readonly r: number; readonly g: number; readonly b: number; readonly a?: number } => + typeof value === 'object' && value !== null && !Array.isArray(value); + +export const cloneData = (value: TValue): TValue => { + if (value === null || value === undefined) { + return value; + } + if (Array.isArray(value)) { + return value.map((entry) => cloneData(entry)) as TValue; + } + if (isPlainObject(value)) { + const clone: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === 'function' || typeof entry === 'symbol') { + continue; + } + clone[key] = cloneData(entry); + } + return clone as TValue; + } + if (typeof value === 'function' || typeof value === 'symbol') { + return undefined as TValue; + } + return value; +}; + +const colorFromNumber = (value: number): ReadonlyColor => ({ + r: ((value >>> 24) & 0xff) / 255, + g: ((value >>> 16) & 0xff) / 255, + b: ((value >>> 8) & 0xff) / 255, + a: (value & 0xff) / 255, +}); + +const colorFromHex = (value: string): ReadonlyColor => { + const hex = value.replace('#', '').trim(); + if (hex.length === 3) { + return { + r: Number.parseInt(hex[0] + hex[0], 16) / 255, + g: Number.parseInt(hex[1] + hex[1], 16) / 255, + b: Number.parseInt(hex[2] + hex[2], 16) / 255, + a: 1, + }; + } + if (hex.length === 6 || hex.length === 8) { + return { + r: Number.parseInt(hex.slice(0, 2), 16) / 255, + g: Number.parseInt(hex.slice(2, 4), 16) / 255, + b: Number.parseInt(hex.slice(4, 6), 16) / 255, + a: hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1, + }; + } + return TRANSPARENT; +}; + +export const normalizeColor = (input: ColorInput | undefined, fallback: ReadonlyColor): ReadonlyColor => { + if (input === undefined) { + return fallback; + } + if (typeof input === 'number') { + return colorFromNumber(input >>> 0); + } + if (typeof input === 'string') { + return colorFromHex(input); + } + if (Array.isArray(input)) { + if (input.length === 3) { + return { r: input[0], g: input[1], b: input[2], a: 1 }; + } + return { r: input[0], g: input[1], b: input[2], a: input[3] }; + } + if (!isColorLike(input)) { + return fallback; + } + return { + r: input.r, + g: input.g, + b: input.b, + a: input.a ?? 1, + }; +}; + +export const normalizeWeight = (value: ResolvedTextBlock['weight'] | TextBlockInput['weight']): number => { + switch (value) { + case 'thin': + return 100; + case 'extralight': + return 200; + case 'light': + return 300; + case 'normal': + return 400; + case 'medium': + return 500; + case 'semibold': + return 600; + case 'bold': + return 700; + case 'extrabold': + return 800; + case 'black': + return 900; + case undefined: + return 400; + default: + return value; + } +}; + +export const mergeLayoutInput = ( + base: WidgetLayoutInput, + patch: WidgetPatch['layout'] | undefined +): WidgetLayoutInput => { + if (!patch) { + return base; + } + return { + ...base, + ...patch, + inset: patch.inset ? { ...(base.inset ?? {}), ...patch.inset } : base.inset, + anchor: + patch.anchor && isPlainObject(patch.anchor) && isPlainObject(base.anchor) + ? { ...base.anchor, ...patch.anchor } + : patch.anchor ?? base.anchor, + } as WidgetLayoutInput; +}; + +export const mergeStyleInput = ( + base: WidgetStyleInput, + patch: WidgetPatch['style'] | undefined +): WidgetStyleInput => ({ ...(base ?? {}), ...(patch ?? {}) }) as WidgetStyleInput; + +export const mergeTextInput = ( + base: TextBlockInput | null, + patch: WidgetPatch['text'] | undefined +): TextBlockInput | null => { + if (patch === undefined) { + return base; + } + if (patch === null) { + return null; + } + return { ...(base ?? { value: '' }), ...patch } as TextBlockInput; +}; + +export const mergeImageInput = ( + base: WidgetImageInput | null, + patch: WidgetPatch['image'] | undefined +): WidgetImageInput | null => { + if (patch === undefined) { + return base; + } + if (patch === null) { + return null; + } + const next = patch as WidgetImageInput; + return { + ...(base ?? {}), + ...next, + uvRect: next.uvRect ? { ...(base?.uvRect ?? {}), ...next.uvRect } : base?.uvRect, + } as WidgetImageInput; +}; + +export const mergeFocusInput = ( + base: WidgetFocusPolicyInput, + patch: WidgetPatch['focus'] | undefined +): WidgetFocusPolicyInput => ({ ...(base ?? {}), ...(patch ?? {}) }); + +export const mergeHandlers = ( + base: WidgetEventHandlers, TRuntime> | null, + patch: WidgetEventHandlers, TRuntime> | undefined +): WidgetEventHandlers, TRuntime> | null => { + if (patch === undefined) { + return base; + } + return { ...(base ?? {}), ...patch }; +}; + +export const mergeProps = ( + base: Readonly>, + patch: Readonly> | undefined +): Readonly> => ({ ...(base ?? EMPTY_RECORD_OBJECT), ...(patch ?? {}) }); + +export const normalizeIndex = (value: number | undefined): number | null => { + if (value === undefined || value === null) { + return null; + } + if (!Number.isFinite(value)) { + return null; + } + return Math.max(0, Math.floor(value)); +}; + +export const normalizeUvRect = ( + input: WidgetImageInput['uvRect'] +): { readonly x: number; readonly y: number; readonly width: number; readonly height: number } => { + const x = clamp(input?.x ?? 0, 0, 1); + const y = clamp(input?.y ?? 0, 0, 1); + const width = clamp(input?.width ?? 1, 0, 1 - x); + const height = clamp(input?.height ?? 1, 0, 1 - y); + return { x, y, width, height }; +}; + +export const intersectsPoint = (box: LayoutBox | null, x: number, y: number): boolean => { + if (!box) { + return false; + } + return x >= box.x && y >= box.y && x <= box.x + box.width && y <= box.y + box.height; +}; + +export const intersectRect = (left: LayoutBox | null, right: LayoutBox): LayoutBox | null => { + if (!left) { + return right; + } + const x = Math.max(left.x, right.x); + const y = Math.max(left.y, right.y); + const maxX = Math.min(left.x + left.width, right.x + right.width); + const maxY = Math.min(left.y + left.height, right.y + right.height); + if (maxX <= x || maxY <= y) { + return null; + } + return { + x, + y, + width: maxX - x, + height: maxY - y, + contentX: x, + contentY: y, + contentWidth: maxX - x, + contentHeight: maxY - y, + }; +}; diff --git a/web/packages/ui/src/runtime/lifecycle/WidgetLifecycleManager.ts b/web/packages/ui/src/runtime/lifecycle/WidgetLifecycleManager.ts new file mode 100644 index 00000000..1a3d3563 --- /dev/null +++ b/web/packages/ui/src/runtime/lifecycle/WidgetLifecycleManager.ts @@ -0,0 +1,163 @@ +import type { + LayoutBox, + ReadonlyColor, + ResolvedFocusPolicy, + ResolvedTextBlock, + ResolvedWidgetImage, + ResolvedWidgetStyle, + SizeLike, + UIFrame, + WidgetConfig, + WidgetId, +} from '../../types'; +import type { StoredWidgetRecord } from '../records'; + +export interface WidgetLifecycleHost { + allocate(): WidgetId; + requireWidget(widget: WidgetId | null): number; + isAncestor(ancestor: number, candidate: number): boolean; + detachNode(index: number): void; + refreshDepths(index: number, depth: number): void; + markTreeChanged(index: number): void; + destroyNode(index: number): void; + normalizeRecord(config: WidgetConfig, TRuntime>): StoredWidgetRecord; + applyRecord( + index: number, + previousProps: Readonly> | null, + previousController: string | null, + initial: boolean + ): void; + updateFlags(index: number): void; + compileStyle(input: StoredWidgetRecord['styleInput']): ResolvedWidgetStyle; + compileText( + input: StoredWidgetRecord['textInput'], + fallbackColor: ReadonlyColor + ): ResolvedTextBlock | null; + compileImage(input: StoredWidgetRecord['imageInput']): ResolvedWidgetImage | null; + compileFocus(input: StoredWidgetRecord['focusInput'], interactive: boolean): ResolvedFocusPolicy; + createControllerContext(index: number): unknown; + measureContent(index: number, constraints: Readonly): SizeLike; + measureImageContent(image: ResolvedWidgetImage, constraints: Readonly): SizeLike; + writeBox(index: number, box: LayoutBox): void; + readBox(index: number): LayoutBox; + renderFrame(): UIFrame; + resolveImageCommand( + index: number, + box: LayoutBox, + image: ResolvedWidgetImage, + style: ResolvedWidgetStyle, + clip: LayoutBox | null, + zIndex: number + ): UIFrame['commands'][number] | null; +} + +/** + * Thin lifecycle facade for runtimes that expose widget allocation, tree + * mutation, record application, and frame rendering as a single host contract. + */ +export class WidgetLifecycleManager { + private readonly runtime: WidgetLifecycleHost; + + constructor(runtime: WidgetLifecycleHost) { + this.runtime = runtime; + } + + allocate(): WidgetId { + return this.runtime.allocate(); + } + + requireWidget(widget: WidgetId | null): number { + return this.runtime.requireWidget(widget); + } + + isAncestor(ancestor: number, candidate: number): boolean { + return this.runtime.isAncestor(ancestor, candidate); + } + + detachNode(index: number): void { + this.runtime.detachNode(index); + } + + refreshDepths(index: number, depth: number): void { + this.runtime.refreshDepths(index, depth); + } + + markTreeChanged(index: number): void { + this.runtime.markTreeChanged(index); + } + + destroyNode(index: number): void { + this.runtime.destroyNode(index); + } + + normalizeRecord(config: WidgetConfig, TRuntime>): StoredWidgetRecord { + return this.runtime.normalizeRecord(config); + } + + applyRecord( + index: number, + previousProps: Readonly> | null, + previousController: string | null, + initial: boolean + ): void { + this.runtime.applyRecord(index, previousProps, previousController, initial); + } + + updateFlags(index: number): void { + this.runtime.updateFlags(index); + } + + compileStyle(input: StoredWidgetRecord['styleInput']): ResolvedWidgetStyle { + return this.runtime.compileStyle(input); + } + + compileText( + input: StoredWidgetRecord['textInput'], + fallbackColor: ReadonlyColor + ): ResolvedTextBlock | null { + return this.runtime.compileText(input, fallbackColor); + } + + compileImage(input: StoredWidgetRecord['imageInput']): ResolvedWidgetImage | null { + return this.runtime.compileImage(input); + } + + compileFocus(input: StoredWidgetRecord['focusInput'], interactive: boolean): ResolvedFocusPolicy { + return this.runtime.compileFocus(input, interactive); + } + + createControllerContext(index: number): unknown { + return this.runtime.createControllerContext(index); + } + + measureContent(index: number, constraints: Readonly): SizeLike { + return this.runtime.measureContent(index, constraints); + } + + measureImageContent(image: ResolvedWidgetImage, constraints: Readonly): SizeLike { + return this.runtime.measureImageContent(image, constraints); + } + + writeBox(index: number, box: LayoutBox): void { + this.runtime.writeBox(index, box); + } + + readBox(index: number): LayoutBox { + return this.runtime.readBox(index); + } + + renderFrame(): UIFrame { + return this.runtime.renderFrame(); + } + + resolveImageCommand( + index: number, + box: LayoutBox, + image: ResolvedWidgetImage, + style: ResolvedWidgetStyle, + clip: LayoutBox | null, + zIndex: number + ): UIFrame['commands'][number] | null { + return this.runtime.resolveImageCommand(index, box, image, style, clip, zIndex); + } +} diff --git a/web/packages/ui/src/runtime/node-flags.ts b/web/packages/ui/src/runtime/node-flags.ts new file mode 100644 index 00000000..4c05bd00 --- /dev/null +++ b/web/packages/ui/src/runtime/node-flags.ts @@ -0,0 +1,8 @@ +export const enum NodeFlag { + Allocated = 1 << 0, + Visible = 1 << 1, + Interactive = 1 << 2, + Enabled = 1 << 3, + Focusable = 1 << 4, + TextDirty = 1 << 5, +} diff --git a/web/packages/ui/src/runtime/records.ts b/web/packages/ui/src/runtime/records.ts new file mode 100644 index 00000000..5a9c2e00 --- /dev/null +++ b/web/packages/ui/src/runtime/records.ts @@ -0,0 +1,167 @@ +import { normalizeCorners } from '../layout'; +import type { + ReadonlyColor, + ResolvedFocusPolicy, + ResolvedTextBlock, + ResolvedWidgetImage, + ResolvedWidgetStyle, + TextBlockInput, + WidgetConfig, + WidgetEventHandlers, + WidgetFocusPolicyInput, + WidgetImageInput, + WidgetKey, + WidgetLayoutInput, + WidgetRole, + WidgetStyleInput, +} from '../types'; +import { + BLACK, + EMPTY_FOCUS_INPUT, + EMPTY_LAYOUT_INPUT, + EMPTY_RECORD_OBJECT, + EMPTY_STYLE_INPUT, + TRANSPARENT, + WHITE, + clamp, + cloneData, + normalizeColor, + normalizeIndex, + normalizeUvRect, + normalizeWeight, +} from './internals'; + +export interface StoredWidgetRecord { + readonly role: WidgetRole; + readonly controller: string | null; + readonly key?: WidgetKey; + readonly props: Readonly>; + readonly enabled: boolean; + readonly interactive: boolean; + readonly layoutInput: WidgetLayoutInput; + readonly styleInput: WidgetStyleInput; + readonly textInput: TextBlockInput | null; + readonly imageInput: WidgetImageInput | null; + readonly focusInput: WidgetFocusPolicyInput; + readonly handlers: WidgetEventHandlers, TRuntime> | null; +} + +export interface TextCompileContext { + readonly defaultFamily: string | null; + readonly locale: string; + readonly fallbackColor: ReadonlyColor; +} + +export const normalizeWidgetRecord = ( + config: WidgetConfig, TRuntime> +): StoredWidgetRecord => ({ + role: config.role ?? 'container', + controller: config.controller ?? null, + key: config.key, + props: cloneData(config.props ?? EMPTY_RECORD_OBJECT), + enabled: config.enabled ?? true, + interactive: config.interactive ?? false, + layoutInput: cloneData(config.layout ?? EMPTY_LAYOUT_INPUT), + styleInput: cloneData(config.style ?? EMPTY_STYLE_INPUT), + textInput: cloneData(config.text ?? null), + imageInput: cloneData(config.image ?? null), + focusInput: cloneData(config.focus ?? EMPTY_FOCUS_INPUT), + handlers: (config.handlers as WidgetEventHandlers, TRuntime>) ?? null, +}); + +export const compileWidgetStyle = (input: WidgetStyleInput): ResolvedWidgetStyle => ({ + visible: input.visible ?? true, + opacity: clamp(input.opacity ?? 1, 0, 1), + clip: input.clip ?? false, + background: normalizeColor(input.background, TRANSPARENT), + borderColor: normalizeColor(input.borderColor, TRANSPARENT), + borderWidth: Math.max(0, input.borderWidth ?? 0), + radius: normalizeCorners(input.radius), + color: normalizeColor(input.color, BLACK), +}); + +export const compileWidgetText = ( + input: TextBlockInput | null, + context: TextCompileContext +): ResolvedTextBlock | null => { + if (!input) { + return null; + } + return { + value: input.value, + family: input.family ?? context.defaultFamily ?? '', + size: Math.max(1, input.size ?? 16), + weight: normalizeWeight(input.weight), + style: input.style ?? 'normal', + locale: input.locale ?? context.locale, + direction: input.direction ?? 'auto', + lineHeight: Math.max(0, input.lineHeight ?? 0), + letterSpacing: input.letterSpacing ?? 0, + wrap: input.wrap ?? 'word', + overflow: input.overflow ?? 'clip', + maxLines: Math.max(1, Math.floor(input.maxLines ?? Number.MAX_SAFE_INTEGER)), + align: input.align ?? 'start', + color: normalizeColor(input.color, context.fallbackColor), + outlineColor: normalizeColor(input.outlineColor, TRANSPARENT), + outlineWidth: Math.max(0, input.outlineWidth ?? 0), + edgeSoftness: Math.max(0.5, input.edgeSoftness ?? 1), + shadowColor: normalizeColor(input.shadowColor, TRANSPARENT), + shadowOffsetX: input.shadowOffsetX ?? 0, + shadowOffsetY: input.shadowOffsetY ?? 0, + underline: input.underline ?? false, + underlineColor: normalizeColor(input.underlineColor, TRANSPARENT), + underlineThickness: Math.max(1, input.underlineThickness ?? 1), + underlineOffset: input.underlineOffset ?? 1, + strikeThrough: input.strikeThrough ?? false, + strikeThroughColor: normalizeColor(input.strikeThroughColor, TRANSPARENT), + strikeThroughThickness: Math.max(1, input.strikeThroughThickness ?? 1), + selectionStart: normalizeIndex(input.selectionStart), + selectionEnd: normalizeIndex(input.selectionEnd), + selectionColor: normalizeColor(input.selectionColor, TRANSPARENT), + caretIndex: normalizeIndex(input.caretIndex), + caretColor: normalizeColor(input.caretColor, TRANSPARENT), + caretWidth: Math.max(1, input.caretWidth ?? 1), + caretInset: Math.max(0, input.caretInset ?? 1), + }; +}; + +export const compileWidgetImage = (input: WidgetImageInput | null): ResolvedWidgetImage | null => { + if (!input) { + return null; + } + const source = + input.source.kind === 'material' + ? { + kind: 'material' as const, + materialId: input.source.materialId, + textureBinding: input.source.textureBinding, + width: Math.max(1, input.source.width), + height: Math.max(1, input.source.height), + } + : { + kind: 'texture' as const, + resourceId: input.source.resourceId, + width: Math.max(1, input.source.width), + height: Math.max(1, input.source.height), + }; + return { + source, + fit: input.fit ?? 'fill', + alignX: clamp(input.alignX ?? 0.5, 0, 1), + alignY: clamp(input.alignY ?? 0.5, 0, 1), + sampling: input.sampling ?? 'linear', + tint: normalizeColor(input.tint, WHITE), + uvRect: normalizeUvRect(input.uvRect), + }; +}; + +export const compileWidgetFocus = ( + input: WidgetFocusPolicyInput, + interactive: boolean +): ResolvedFocusPolicy => ({ + focusable: input.focusable ?? interactive, + tabIndex: input.tabIndex ?? 0, + scope: input.scope ?? false, + cycle: input.cycle ?? false, + order: input.order ?? 0, +}); diff --git a/web/packages/ui/src/text.ts b/web/packages/ui/src/text.ts index 529229ac..f5792303 100644 --- a/web/packages/ui/src/text.ts +++ b/web/packages/ui/src/text.ts @@ -1,5 +1,6 @@ import { DisposedUIError } from './errors'; import { FontRegistry } from './font'; +import { LruCache, createCacheKey, createGraphemeSegments, detectDirection, isWhitespace } from './text/internals'; import type { FontFaceId, FontFaceInfo, @@ -44,82 +45,6 @@ interface ClusterLine { gapCount: number; } -class LruCache { - private readonly limit: number; - private readonly entries = new Map(); - - constructor(limit: number) { - this.limit = Math.max(1, limit); - } - - get(key: TKey): TValue | undefined { - const value = this.entries.get(key); - if (value === undefined) { - return undefined; - } - this.entries.delete(key); - this.entries.set(key, value); - return value; - } - - set(key: TKey, value: TValue): void { - if (this.entries.has(key)) { - this.entries.delete(key); - } - this.entries.set(key, value); - if (this.entries.size > this.limit) { - const firstKey = this.entries.keys().next().value as TKey; - this.entries.delete(firstKey); - } - } - - clear(): void { - this.entries.clear(); - } -} - -const createGraphemeSegments = (value: string, locale: string): string[] => { - if (typeof Intl !== 'undefined' && typeof Intl.Segmenter !== 'undefined') { - const segmenter = new Intl.Segmenter(locale || undefined, { granularity: 'grapheme' }); - return [...segmenter.segment(value)].map((segment) => segment.segment); - } - return Array.from(value); -}; - -const detectDirection = (value: string, requested: ResolvedTextBlock['direction']): ResolvedTextDirection => { - if (requested === 'ltr' || requested === 'rtl') { - return requested; - } - return /[\u0590-\u08FF]/u.test(value) ? 'rtl' : 'ltr'; -}; - -const isWhitespace = (value: string): boolean => /^\s+$/u.test(value) && value !== '\n'; - -const createCacheKey = ( - block: ResolvedTextBlock, - faceId: FontFaceId | null, - width: number, - height: number -): string => - [ - faceId ?? 'none', - block.family, - block.size, - block.weight, - block.style, - block.locale, - block.direction, - block.letterSpacing, - block.lineHeight, - block.wrap, - block.overflow, - block.maxLines, - block.align, - width, - height, - block.value, - ].join('|'); - export class TextLayoutEngine implements Disposable { private readonly fonts: FontRegistry; private readonly cache: LruCache; diff --git a/web/packages/ui/src/text/internals.ts b/web/packages/ui/src/text/internals.ts new file mode 100644 index 00000000..39d9c3a1 --- /dev/null +++ b/web/packages/ui/src/text/internals.ts @@ -0,0 +1,77 @@ +import type { FontFaceId, ResolvedTextBlock, ResolvedTextDirection } from '../types'; + +export class LruCache { + private readonly limit: number; + private readonly entries = new Map(); + + constructor(limit: number) { + this.limit = Math.max(1, limit); + } + + get(key: TKey): TValue | undefined { + const value = this.entries.get(key); + if (value === undefined) { + return undefined; + } + this.entries.delete(key); + this.entries.set(key, value); + return value; + } + + set(key: TKey, value: TValue): void { + if (this.entries.has(key)) { + this.entries.delete(key); + } + this.entries.set(key, value); + if (this.entries.size > this.limit) { + const firstKey = this.entries.keys().next().value as TKey; + this.entries.delete(firstKey); + } + } + + clear(): void { + this.entries.clear(); + } +} + +export const createGraphemeSegments = (value: string, locale: string): string[] => { + if (typeof Intl !== 'undefined' && typeof Intl.Segmenter !== 'undefined') { + const segmenter = new Intl.Segmenter(locale || undefined, { granularity: 'grapheme' }); + return [...segmenter.segment(value)].map((segment) => segment.segment); + } + return Array.from(value); +}; + +export const detectDirection = (value: string, requested: ResolvedTextBlock['direction']): ResolvedTextDirection => { + if (requested === 'ltr' || requested === 'rtl') { + return requested; + } + return /[\u0590-\u08FF]/u.test(value) ? 'rtl' : 'ltr'; +}; + +export const isWhitespace = (value: string): boolean => /^\s+$/u.test(value) && value !== '\n'; + +export const createCacheKey = ( + block: ResolvedTextBlock, + faceId: FontFaceId | null, + width: number, + height: number +): string => + [ + faceId ?? 'none', + block.family, + block.size, + block.weight, + block.style, + block.locale, + block.direction, + block.letterSpacing, + block.lineHeight, + block.wrap, + block.overflow, + block.maxLines, + block.align, + width, + height, + block.value, + ].join('|'); \ No newline at end of file diff --git a/web/packages/ui/src/types.ts b/web/packages/ui/src/types.ts index 6c373363..40087387 100644 --- a/web/packages/ui/src/types.ts +++ b/web/packages/ui/src/types.ts @@ -1,817 +1,5 @@ -export type Brand = TValue & { readonly __brand: TBrand }; - -export type WidgetId = Brand; -export type FontFaceId = Brand; -export type FontFamilyId = Brand; -export type GlyphAtlasPageId = Brand; - -export type WidgetKey = string | number | symbol; -export type WidgetSerializableKey = string | number | null; -export type Axis = 'row' | 'column'; -export type DisplayMode = 'stack' | 'overlay'; -export type PositionMode = 'flow' | 'absolute'; -export type AlignMode = 'start' | 'center' | 'end' | 'stretch'; -export type AlignSelfMode = AlignMode | 'auto'; -export type JustifyMode = - | 'start' - | 'center' - | 'end' - | 'space-between' - | 'space-around' - | 'space-evenly'; -export type LengthKind = 'auto' | 'px' | 'percent' | 'content' | 'stretch' | 'viewport'; -export type TextWrapMode = 'none' | 'word' | 'grapheme'; -export type TextOverflowMode = 'clip' | 'ellipsis'; -export type TextAlignMode = 'start' | 'center' | 'end' | 'justify'; -export type TextDirectionMode = 'auto' | 'ltr' | 'rtl'; -export type ResolvedTextDirection = 'ltr' | 'rtl'; -export type UIImageFitMode = 'fill' | 'contain' | 'cover' | 'none' | 'scale-down'; -export type UIImageSamplingMode = 'linear' | 'nearest'; -export type FontStyle = 'normal' | 'italic' | 'oblique'; -export type FontWeight = - | 100 - | 200 - | 300 - | 400 - | 500 - | 600 - | 700 - | 800 - | 900 - | 'thin' - | 'extralight' - | 'light' - | 'normal' - | 'medium' - | 'semibold' - | 'bold' - | 'extrabold' - | 'black'; -export type FocusMoveDirection = 'forward' | 'backward' | 'left' | 'right' | 'up' | 'down'; -export type WidgetRoleBase = 'root' | 'container' | 'text' | 'button' | 'input' | 'custom'; -export type WidgetRole = WidgetRoleBase | `${WidgetRoleBase}:${string}`; -export type PercentageString = `${number}%`; -export type StretchString = `stretch:${number}`; -export type ViewportString = `viewport:${number}`; -export type ColorHexString = `#${string}`; -export type KerningPairKey = `${number}:${number}`; -export type UILengthInput = number | 'auto' | 'content' | PercentageString | StretchString | ViewportString; -export type FontGlyphBitmapFormat = 'alpha8' | 'rgba8' | 'sdf8'; - -export interface Vec2Like { - readonly x: number; - readonly y: number; -} - -export interface SizeLike { - readonly width: number; - readonly height: number; -} - -export interface RectLike extends Vec2Like, SizeLike {} - -export interface UVRect { - readonly x: number; - readonly y: number; - readonly width: number; - readonly height: number; -} - -export interface EdgeInsets { - readonly top: number; - readonly right: number; - readonly bottom: number; - readonly left: number; -} - -export interface CornerRadii { - readonly topLeft: number; - readonly topRight: number; - readonly bottomRight: number; - readonly bottomLeft: number; -} - -export interface Anchor { - readonly x: number; - readonly y: number; - readonly pivotX: number; - readonly pivotY: number; - readonly offsetX: number; - readonly offsetY: number; - readonly stretch: boolean; -} - -export interface ReadonlyColor { - readonly r: number; - readonly g: number; - readonly b: number; - readonly a: number; -} - -export interface ColorLike { - readonly r: number; - readonly g: number; - readonly b: number; - readonly a?: number; -} - -export type EdgeInput = - | number - | readonly [number, number] - | readonly [number, number, number, number] - | Readonly>>; - -export type CornerInput = number | readonly [number, number, number, number]; - -export type ColorInput = - | number - | ColorHexString - | readonly [number, number, number] - | readonly [number, number, number, number] - | ColorLike; - -export type AnchorPreset = - | 'top-left' - | 'top' - | 'top-right' - | 'left' - | 'center' - | 'right' - | 'bottom-left' - | 'bottom' - | 'bottom-right' - | 'stretch'; - -export type AnchorInput = - | AnchorPreset - | Readonly>>; - -export interface ResolvedLength { - readonly kind: LengthKind; - readonly value: number; -} - -export interface ResolvedLayout { - readonly display: DisplayMode; - readonly direction: Axis; - readonly gap: number; - readonly padding: EdgeInsets; - readonly margin: EdgeInsets; - readonly width: ResolvedLength; - readonly height: ResolvedLength; - readonly minWidth: number; - readonly minHeight: number; - readonly maxWidth: number; - readonly maxHeight: number; - readonly grow: number; - readonly shrink: number; - readonly basis: ResolvedLength; - readonly alignItems: AlignMode; - readonly alignSelf: AlignSelfMode; - readonly justifyContent: JustifyMode; - readonly position: PositionMode; - readonly insetTop?: ResolvedLength; - readonly insetRight?: ResolvedLength; - readonly insetBottom?: ResolvedLength; - readonly insetLeft?: ResolvedLength; - readonly anchor: Anchor; - readonly aspectRatio: number; - readonly zIndex: number; -} - -export interface LayoutBox extends RectLike { - readonly contentX: number; - readonly contentY: number; - readonly contentWidth: number; - readonly contentHeight: number; -} - -export interface WidgetLayoutInput { - readonly display?: DisplayMode; - readonly direction?: Axis; - readonly gap?: number; - readonly padding?: EdgeInput; - readonly margin?: EdgeInput; - readonly width?: UILengthInput; - readonly height?: UILengthInput; - readonly minWidth?: number; - readonly minHeight?: number; - readonly maxWidth?: number; - readonly maxHeight?: number; - readonly grow?: number; - readonly shrink?: number; - readonly basis?: UILengthInput; - readonly alignItems?: AlignMode; - readonly alignSelf?: AlignSelfMode; - readonly justifyContent?: JustifyMode; - readonly position?: PositionMode; - readonly inset?: Readonly>>; - readonly anchor?: AnchorInput; - readonly aspectRatio?: number; - readonly zIndex?: number; -} - -export interface WidgetStyleInput { - readonly visible?: boolean; - readonly opacity?: number; - readonly clip?: boolean; - readonly background?: ColorInput; - readonly borderColor?: ColorInput; - readonly borderWidth?: number; - readonly radius?: CornerInput; - readonly color?: ColorInput; -} - -export interface ResolvedWidgetStyle { - readonly visible: boolean; - readonly opacity: number; - readonly clip: boolean; - readonly background: ReadonlyColor; - readonly borderColor: ReadonlyColor; - readonly borderWidth: number; - readonly radius: CornerRadii; - readonly color: ReadonlyColor; -} - -export interface TextBlockInput { - readonly value: string; - readonly family?: string; - readonly size?: number; - readonly weight?: FontWeight; - readonly style?: FontStyle; - readonly locale?: string; - readonly direction?: TextDirectionMode; - readonly lineHeight?: number; - readonly letterSpacing?: number; - readonly wrap?: TextWrapMode; - readonly overflow?: TextOverflowMode; - readonly maxLines?: number; - readonly align?: TextAlignMode; - readonly color?: ColorInput; - readonly outlineColor?: ColorInput; - readonly outlineWidth?: number; - readonly edgeSoftness?: number; - readonly shadowColor?: ColorInput; - readonly shadowOffsetX?: number; - readonly shadowOffsetY?: number; - readonly underline?: boolean; - readonly underlineColor?: ColorInput; - readonly underlineThickness?: number; - readonly underlineOffset?: number; - readonly strikeThrough?: boolean; - readonly strikeThroughColor?: ColorInput; - readonly strikeThroughThickness?: number; - readonly selectionStart?: number; - readonly selectionEnd?: number; - readonly selectionColor?: ColorInput; - readonly caretIndex?: number; - readonly caretColor?: ColorInput; - readonly caretWidth?: number; - readonly caretInset?: number; -} - -export interface UIImageTextureSource { - readonly kind: 'texture'; - readonly resourceId: string; - readonly width: number; - readonly height: number; -} - -export interface UIImageMaterialSource { - readonly kind: 'material'; - readonly materialId: string; - readonly textureBinding?: string; - readonly width: number; - readonly height: number; -} - -export type UIImageSource = UIImageTextureSource | UIImageMaterialSource; - -export interface WidgetImageInput { - readonly source: UIImageSource; - readonly fit?: UIImageFitMode; - readonly alignX?: number; - readonly alignY?: number; - readonly sampling?: UIImageSamplingMode; - readonly tint?: ColorInput; - readonly uvRect?: Readonly>; -} - -export interface ResolvedTextBlock { - readonly value: string; - readonly family: string; - readonly size: number; - readonly weight: number; - readonly style: FontStyle; - readonly locale: string; - readonly direction: TextDirectionMode; - readonly lineHeight: number; - readonly letterSpacing: number; - readonly wrap: TextWrapMode; - readonly overflow: TextOverflowMode; - readonly maxLines: number; - readonly align: TextAlignMode; - readonly color: ReadonlyColor; - readonly outlineColor: ReadonlyColor; - readonly outlineWidth: number; - readonly edgeSoftness: number; - readonly shadowColor: ReadonlyColor; - readonly shadowOffsetX: number; - readonly shadowOffsetY: number; - readonly underline: boolean; - readonly underlineColor: ReadonlyColor; - readonly underlineThickness: number; - readonly underlineOffset: number; - readonly strikeThrough: boolean; - readonly strikeThroughColor: ReadonlyColor; - readonly strikeThroughThickness: number; - readonly selectionStart: number | null; - readonly selectionEnd: number | null; - readonly selectionColor: ReadonlyColor; - readonly caretIndex: number | null; - readonly caretColor: ReadonlyColor; - readonly caretWidth: number; - readonly caretInset: number; -} - -export interface ResolvedWidgetImage { - readonly source: UIImageSource; - readonly fit: UIImageFitMode; - readonly alignX: number; - readonly alignY: number; - readonly sampling: UIImageSamplingMode; - readonly tint: ReadonlyColor; - readonly uvRect: UVRect; -} - -export interface WidgetFocusPolicyInput { - readonly focusable?: boolean; - readonly tabIndex?: number; - readonly scope?: boolean; - readonly cycle?: boolean; - readonly order?: number; -} - -export interface ResolvedFocusPolicy { - readonly focusable: boolean; - readonly tabIndex: number; - readonly scope: boolean; - readonly cycle: boolean; - readonly order: number; -} - -export interface UIPointerEvent { - readonly type: 'pointer'; - readonly phase: 'move' | 'down' | 'up' | 'enter' | 'leave' | 'wheel'; - readonly x: number; - readonly y: number; - readonly pointerId?: number; - readonly button?: number; - readonly buttons?: number; - readonly deltaX?: number; - readonly deltaY?: number; - readonly altKey?: boolean; - readonly ctrlKey?: boolean; - readonly shiftKey?: boolean; - readonly metaKey?: boolean; -} - -export interface UIKeyEvent { - readonly type: 'key'; - readonly phase: 'down' | 'up'; - readonly key: string; - readonly code?: string; - readonly repeat?: boolean; - readonly altKey?: boolean; - readonly ctrlKey?: boolean; - readonly shiftKey?: boolean; - readonly metaKey?: boolean; -} - -export interface UITextInputEvent { - readonly type: 'text'; - readonly text: string; - readonly composing?: boolean; - readonly locale?: string; -} - -export interface UIWindowFocusEvent { - readonly type: 'focus'; - readonly focused: boolean; -} - -export interface WidgetFocusChangeEvent { - readonly type: 'widget-focus'; - readonly focused: boolean; - readonly reason: 'api' | 'pointer' | 'navigation' | 'window'; -} - -export type UIInputEvent = UIPointerEvent | UIKeyEvent | UITextInputEvent | UIWindowFocusEvent; - -export interface WidgetEventContext< - TProps extends Record = Record, - TRuntime = unknown, -> { - readonly runtime: TRuntime; - readonly widget: WidgetId; - readonly props: Readonly; -} - -export type WidgetEventHandler< - TEvent, - TProps extends Record = Record, - TRuntime = unknown, -> = (event: Readonly, context: WidgetEventContext) => boolean | void; - -export interface WidgetEventHandlers< - TProps extends Record = Record, - TRuntime = unknown, -> { - readonly pointerMove?: WidgetEventHandler; - readonly pointerDown?: WidgetEventHandler; - readonly pointerUp?: WidgetEventHandler; - readonly pointerEnter?: WidgetEventHandler; - readonly pointerLeave?: WidgetEventHandler; - readonly wheel?: WidgetEventHandler; - readonly keyDown?: WidgetEventHandler; - readonly keyUp?: WidgetEventHandler; - readonly textInput?: WidgetEventHandler; - readonly focus?: WidgetEventHandler; - readonly blur?: WidgetEventHandler; -} - -export interface WidgetConfig< - TProps extends Record = Record, - TRuntime = unknown, -> { - readonly role?: WidgetRole; - readonly controller?: string; - readonly key?: WidgetKey; - readonly props?: Readonly; - readonly enabled?: boolean; - readonly interactive?: boolean; - readonly layout?: WidgetLayoutInput; - readonly style?: WidgetStyleInput; - readonly text?: TextBlockInput | null; - readonly image?: WidgetImageInput | null; - readonly focus?: WidgetFocusPolicyInput; - readonly handlers?: WidgetEventHandlers; -} - -export type DeepReadonlyPartial = TValue extends readonly (infer TElement)[] - ? readonly DeepReadonlyPartial[] - : TValue extends (...args: never[]) => unknown - ? TValue - : TValue extends object - ? { readonly [TKey in keyof TValue]?: DeepReadonlyPartial } - : TValue; - -export type WidgetPatch< - TProps extends Record = Record, - TRuntime = unknown, -> = Omit>, 'props'> & { - readonly props?: Readonly | TProps>; -}; - -export interface FontAtlasOptions { - readonly width?: number; - readonly height?: number; - readonly padding?: number; -} - -export interface FontGlyphMetric { - readonly codePoint: number; - readonly advance: number; - readonly bearingX?: number; - readonly bearingY?: number; - readonly width?: number; - readonly height?: number; - readonly data?: ArrayBuffer | ArrayBufferView | null; - readonly format?: FontGlyphBitmapFormat; - readonly rowStride?: number; - readonly distanceRange?: number; -} - -export interface FontFaceAsset { - readonly family: string; - readonly face?: string; - readonly style?: FontStyle; - readonly weight?: FontWeight; - readonly locale?: string; - readonly ascent: number; - readonly descent: number; - readonly lineGap?: number; - readonly unitsPerEm?: number; - readonly defaultAdvance?: number; - readonly glyphs: - | ReadonlyArray - | ReadonlyMap - | Readonly>; - readonly kernings?: Readonly> | ReadonlyMap; - readonly fallbackCodePoint?: number; - readonly atlas?: FontAtlasOptions; -} - -export interface FontFamilyDefinition { - readonly name: string; - readonly fallbacks?: readonly string[]; -} - -export interface FontQuery { - readonly family?: string; - readonly weight?: FontWeight; - readonly style?: FontStyle; - readonly locale?: string; -} - -export interface FontAssetSourceDescriptor { - readonly kind: 'descriptor'; - readonly asset: FontFaceAsset; -} - -export interface FontAssetSourceBuffer { - readonly kind: 'buffer'; - readonly data: ArrayBuffer | ArrayBufferView; - readonly contentType?: string; - readonly cacheKey?: string; -} - -export interface FontAssetSourceUrl { - readonly kind: 'url'; - readonly url: string; - readonly headers?: Readonly>; - readonly cacheKey?: string; -} - -export type FontAssetSource = FontAssetSourceDescriptor | FontAssetSourceBuffer | FontAssetSourceUrl; - -export interface RetryPolicy { - readonly attempts?: number; - readonly baseDelayMs?: number; - readonly maxDelayMs?: number; - readonly jitter?: number; -} - -export interface FontLoadOptions { - readonly signal?: AbortSignal; - readonly retry?: RetryPolicy; -} - -export interface FontLoader { - readonly id: string; - canLoad(source: FontAssetSource): boolean; - load(source: FontAssetSource, signal?: AbortSignal): Promise; -} - -export interface FontRegistryOptions { - readonly atlasWidth?: number; - readonly atlasHeight?: number; - readonly atlasPadding?: number; - readonly defaultFamily?: string; - readonly retry?: RetryPolicy; - readonly fetch?: typeof globalThis.fetch; -} - -export interface GlyphAtlasEntry { - readonly faceId: FontFaceId; - readonly page: GlyphAtlasPageId; - readonly pageWidth: number; - readonly pageHeight: number; - readonly codePoint: number; - readonly x: number; - readonly y: number; - readonly width: number; - readonly height: number; - readonly format: FontGlyphBitmapFormat; - readonly rowStride: number; - readonly distanceRange: number; - readonly u0: number; - readonly v0: number; - readonly u1: number; - readonly v1: number; - readonly data?: ArrayBuffer | ArrayBufferView | null; -} - -export interface GlyphAtlasPageSnapshot { - readonly id: number; - readonly width: number; - readonly height: number; - readonly entries: readonly GlyphAtlasEntry[]; -} - -export interface FontFaceInfo { - readonly id: FontFaceId; - readonly family: string; - readonly face: string; - readonly style: FontStyle; - readonly weight: number; - readonly locale: string; - readonly ascent: number; - readonly descent: number; - readonly lineGap: number; - readonly unitsPerEm: number; - readonly defaultAdvance: number; - readonly fallbackCodePoint: number; -} - -export interface FontGlyphMeasurement { - readonly faceId: FontFaceId | null; - readonly codePoint: number; - readonly advance: number; - readonly width: number; - readonly height: number; - readonly metric: FontGlyphMetric | null; - readonly atlasEntry: GlyphAtlasEntry | null; -} - -export interface FontFaceSnapshot { - readonly id: number; - readonly family: string; - readonly face: string; - readonly style: FontStyle; - readonly weight: number; - readonly locale: string; - readonly ascent: number; - readonly descent: number; - readonly lineGap: number; - readonly unitsPerEm: number; - readonly defaultAdvance: number; - readonly fallbackCodePoint: number; - readonly glyphs: readonly FontGlyphMetric[]; - readonly kernings: readonly [KerningPairKey, number][]; - readonly atlas: readonly GlyphAtlasPageSnapshot[]; -} - -export interface FontRegistrySnapshot { - readonly defaultFamily: string | null; - readonly families: readonly FontFamilyDefinition[]; - readonly faces: readonly FontFaceSnapshot[]; -} - -export interface TextLayoutConstraint { - readonly width?: number; - readonly height?: number; -} - -export interface TextGlyphPlacement { - readonly codePoint: number; - readonly clusterIndex: number; - readonly x: number; - readonly y: number; - readonly advance: number; - readonly width: number; - readonly height: number; - readonly line: number; - readonly text: string; - readonly atlasEntry: GlyphAtlasEntry | null; -} - -export interface TextLineLayout { - readonly index: number; - readonly start: number; - readonly end: number; - readonly x: number; - readonly y: number; - readonly width: number; - readonly height: number; - readonly ascent: number; - readonly descent: number; - readonly gapCount: number; -} - -export interface TextClusterLayout { - readonly index: number; - readonly line: number; - readonly x: number; - readonly y: number; - readonly width: number; - readonly height: number; - readonly text: string; - readonly whitespace: boolean; - readonly newline: boolean; -} - -export interface TextCaretPlacement { - readonly index: number; - readonly line: number; - readonly x: number; - readonly y: number; - readonly height: number; -} - -export interface TextLayoutResult { - readonly faceId: FontFaceId | null; - readonly width: number; - readonly height: number; - readonly lineHeight: number; - readonly baseline: number; - readonly lines: readonly TextLineLayout[]; - readonly clusters: readonly TextClusterLayout[]; - readonly carets: readonly TextCaretPlacement[]; - readonly glyphs: readonly TextGlyphPlacement[]; - readonly truncated: boolean; - readonly direction: ResolvedTextDirection; - readonly text: string; -} - -export interface QuadRenderCommand { - readonly kind: 'quad'; - readonly widget: WidgetId; - readonly x: number; - readonly y: number; - readonly width: number; - readonly height: number; - readonly zIndex: number; - readonly color: ReadonlyColor; - readonly borderColor: ReadonlyColor; - readonly borderWidth: number; - readonly radius: CornerRadii; - readonly opacity: number; - readonly clip: RectLike | null; -} - -export interface TextRenderCommand { - readonly kind: 'text'; - readonly widget: WidgetId; - readonly x: number; - readonly y: number; - readonly zIndex: number; - readonly color: ReadonlyColor; - readonly outlineColor: ReadonlyColor; - readonly outlineWidth: number; - readonly edgeSoftness: number; - readonly opacity: number; - readonly clip: RectLike | null; - readonly layout: TextLayoutResult; -} - -export interface ImageRenderCommand { - readonly kind: 'image'; - readonly widget: WidgetId; - readonly source: UIImageSource; - readonly x: number; - readonly y: number; - readonly width: number; - readonly height: number; - readonly zIndex: number; - readonly tint: ReadonlyColor; - readonly opacity: number; - readonly sampling: UIImageSamplingMode; - readonly radius: CornerRadii; - readonly clip: RectLike | null; - readonly uvRect: UVRect; -} - -export interface CustomRenderCommand { - readonly kind: 'custom'; - readonly widget: WidgetId; - readonly zIndex: number; - readonly clip: RectLike | null; - readonly payload: TPayload; -} - -export type RenderCommand = - | QuadRenderCommand - | ImageRenderCommand - | TextRenderCommand - | CustomRenderCommand; - -export interface UIFrameMetrics { - readonly widgetCount: number; - readonly visibleWidgetCount: number; - readonly renderCount: number; - readonly customCommandCount: number; - readonly imageCommandCount: number; - readonly textCommandCount: number; - readonly glyphCount: number; - readonly layoutPasses: number; -} - -export interface UIFrame { - readonly viewportWidth: number; - readonly viewportHeight: number; - readonly commands: readonly RenderCommand[]; - readonly metrics: UIFrameMetrics; -} - -export interface WidgetSnapshot { - readonly role: WidgetRole; - readonly controller?: string; - readonly key?: WidgetSerializableKey; - readonly props?: Readonly> | null; - readonly enabled?: boolean; - readonly interactive?: boolean; - readonly layout?: WidgetLayoutInput; - readonly style?: WidgetStyleInput; - readonly text?: TextBlockInput | null; - readonly image?: WidgetImageInput | null; - readonly focus?: WidgetFocusPolicyInput; - readonly children: readonly WidgetSnapshot[]; -} - -export interface UIRuntimeSnapshot { - readonly viewportWidth: number; - readonly viewportHeight: number; - readonly locale: string; - readonly root: WidgetSnapshot; -} \ No newline at end of file +export * from './types/foundation'; +export * from './types/layout'; +export * from './types/widget'; +export * from './types/font'; +export * from './types/render-frame'; \ No newline at end of file diff --git a/web/packages/ui/src/types/font.ts b/web/packages/ui/src/types/font.ts new file mode 100644 index 00000000..96fda919 --- /dev/null +++ b/web/packages/ui/src/types/font.ts @@ -0,0 +1,243 @@ +import type { + FontBinaryFormat, + FontFaceId, + FontFamilyId, + FontGlyphBitmapFormat, + FontStyle, + FontWeight, + GlyphAtlasPageId, + KerningPairKey, +} from './foundation'; + +export interface FontAtlasOptions { + readonly width?: number; + readonly height?: number; + readonly padding?: number; +} + +export interface FontGlyphMetric { + readonly codePoint: number; + readonly advance: number; + readonly bearingX?: number; + readonly bearingY?: number; + readonly width?: number; + readonly height?: number; + readonly data?: ArrayBuffer | ArrayBufferView | null; + readonly format?: FontGlyphBitmapFormat; + readonly rowStride?: number; + readonly distanceRange?: number; +} + +export interface DynamicFontRuntimeInfo { + readonly family: string; + readonly face?: string; + readonly style?: FontStyle; + readonly weight?: FontWeight; + readonly locale?: string; + readonly ascent: number; + readonly descent: number; + readonly lineGap?: number; + readonly unitsPerEm?: number; + readonly defaultAdvance?: number; + readonly fallbackCodePoint?: number; + readonly atlas?: FontAtlasOptions; +} + +export interface DynamicFontGlyphRaster { + readonly codePoint: number; + readonly rasterSize: number; + readonly width: number; + readonly height: number; + readonly data?: ArrayBuffer | ArrayBufferView | null; + readonly format?: FontGlyphBitmapFormat; + readonly rowStride?: number; + readonly distanceRange?: number; +} + +export interface DynamicFontFaceRuntime { + readonly info: DynamicFontRuntimeInfo; + measureGlyph(codePoint: number): FontGlyphMetric | null; + rasterizeGlyph(codePoint: number, pixelSize: number): DynamicFontGlyphRaster | null; + getKerning?(leftCodePoint: number, rightCodePoint: number): number; + dispose?(): void; +} + +export interface StaticFontFaceAsset extends DynamicFontRuntimeInfo { + readonly kind?: 'static'; + readonly glyphs: + | ReadonlyArray + | ReadonlyMap + | Readonly>; + readonly kernings?: Readonly> | ReadonlyMap; +} + +export interface DynamicFontFaceAsset { + readonly kind: 'dynamic'; + readonly runtime: DynamicFontFaceRuntime; + readonly glyphs?: + | ReadonlyArray + | ReadonlyMap + | Readonly>; + readonly kernings?: Readonly> | ReadonlyMap; +} + +export type FontFaceAsset = StaticFontFaceAsset | DynamicFontFaceAsset; + +export interface FontFamilyDefinition { + readonly name: string; + readonly fallbacks?: readonly string[]; +} + +export interface FontQuery { + readonly family?: string; + readonly weight?: FontWeight; + readonly style?: FontStyle; + readonly locale?: string; +} + +export interface FontAssetSourceDescriptor { + readonly kind: 'descriptor'; + readonly asset: FontFaceAsset; +} + +export interface FontAssetSourceMetadata { + readonly family?: string; + readonly face?: string; + readonly style?: FontStyle; + readonly weight?: FontWeight; + readonly locale?: string; + readonly fallbackCodePoint?: number; + readonly atlas?: FontAtlasOptions; + readonly contentType?: string; +} + +export interface FontAssetSourceBuffer extends FontAssetSourceMetadata { + readonly kind: 'buffer'; + readonly data: ArrayBuffer | ArrayBufferView; + readonly cacheKey?: string; +} + +export interface FontAssetSourceUrl extends FontAssetSourceMetadata { + readonly kind: 'url'; + readonly url: string; + readonly headers?: Readonly>; + readonly cacheKey?: string; +} + +export type FontAssetSource = FontAssetSourceDescriptor | FontAssetSourceBuffer | FontAssetSourceUrl; + +export interface RetryPolicy { + readonly attempts?: number; + readonly baseDelayMs?: number; + readonly maxDelayMs?: number; + readonly jitter?: number; +} + +export interface FontLoadOptions { + readonly signal?: AbortSignal; + readonly retry?: RetryPolicy; +} + +export interface FontLoader { + readonly id: string; + canLoad(source: FontAssetSource): boolean; + load(source: FontAssetSource, signal?: AbortSignal): Promise; +} + +export interface FontRegistryOptions { + readonly atlasWidth?: number; + readonly atlasHeight?: number; + readonly atlasPadding?: number; + readonly defaultFamily?: string; + readonly retry?: RetryPolicy; + readonly fetch?: typeof globalThis.fetch; + readonly dynamicRuntimeFactory?: DynamicFontRuntimeFactory; +} + +export interface DynamicFontRuntimeSource { + readonly source: FontAssetSourceBuffer | FontAssetSourceUrl; + readonly bytes: ArrayBuffer; + readonly format: FontBinaryFormat; + readonly cacheKey: string; +} + +export interface DynamicFontRuntimeFactory { + create(source: DynamicFontRuntimeSource): Promise; +} + +export interface GlyphAtlasEntry { + readonly faceId: FontFaceId; + readonly page: GlyphAtlasPageId; + readonly pageWidth: number; + readonly pageHeight: number; + readonly codePoint: number; + readonly rasterSize?: number; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly format: FontGlyphBitmapFormat; + readonly rowStride: number; + readonly distanceRange: number; + readonly u0: number; + readonly v0: number; + readonly u1: number; + readonly v1: number; + readonly data?: ArrayBuffer | ArrayBufferView | null; +} + +export interface GlyphAtlasPageSnapshot { + readonly id: number; + readonly width: number; + readonly height: number; + readonly entries: readonly GlyphAtlasEntry[]; +} + +export interface FontFaceInfo { + readonly id: FontFaceId; + readonly family: string; + readonly face: string; + readonly style: FontStyle; + readonly weight: number; + readonly locale: string; + readonly ascent: number; + readonly descent: number; + readonly lineGap: number; + readonly unitsPerEm: number; + readonly defaultAdvance: number; + readonly fallbackCodePoint: number; +} + +export interface FontGlyphMeasurement { + readonly faceId: FontFaceId | null; + readonly codePoint: number; + readonly advance: number; + readonly width: number; + readonly height: number; + readonly metric: FontGlyphMetric | null; + readonly atlasEntry: GlyphAtlasEntry | null; +} + +export interface FontFaceSnapshot { + readonly id: number; + readonly family: string; + readonly face: string; + readonly style: FontStyle; + readonly weight: number; + readonly locale: string; + readonly ascent: number; + readonly descent: number; + readonly lineGap: number; + readonly unitsPerEm: number; + readonly defaultAdvance: number; + readonly fallbackCodePoint: number; + readonly glyphs: readonly FontGlyphMetric[]; + readonly kernings: readonly [KerningPairKey, number][]; + readonly atlas: readonly GlyphAtlasPageSnapshot[]; +} + +export interface FontRegistrySnapshot { + readonly defaultFamily: string | null; + readonly families: readonly FontFamilyDefinition[]; + readonly faces: readonly FontFaceSnapshot[]; +} \ No newline at end of file diff --git a/web/packages/ui/src/types/foundation.ts b/web/packages/ui/src/types/foundation.ts new file mode 100644 index 00000000..3d53b879 --- /dev/null +++ b/web/packages/ui/src/types/foundation.ts @@ -0,0 +1,85 @@ +import type { Brand } from '@axrone/utility'; + +export type WidgetId = Brand; +export type FontFaceId = Brand; +export type FontFamilyId = Brand; +export type GlyphAtlasPageId = Brand; + +export type WidgetKey = string | number | symbol; +export type WidgetSerializableKey = string | number | null; +export type Axis = 'row' | 'column'; +export type DisplayMode = 'stack' | 'overlay'; +export type PositionMode = 'flow' | 'absolute'; +export type AlignMode = 'start' | 'center' | 'end' | 'stretch'; +export type AlignSelfMode = AlignMode | 'auto'; +export type JustifyMode = + | 'start' + | 'center' + | 'end' + | 'space-between' + | 'space-around' + | 'space-evenly'; +export type LengthKind = 'auto' | 'px' | 'percent' | 'content' | 'stretch' | 'viewport'; +export type TextWrapMode = 'none' | 'word' | 'grapheme'; +export type TextOverflowMode = 'clip' | 'ellipsis'; +export type TextAlignMode = 'start' | 'center' | 'end' | 'justify'; +export type TextDirectionMode = 'auto' | 'ltr' | 'rtl'; +export type ResolvedTextDirection = 'ltr' | 'rtl'; +export type UIImageFitMode = 'fill' | 'contain' | 'cover' | 'none' | 'scale-down'; +export type UIImageSamplingMode = 'linear' | 'nearest'; +export type FontStyle = 'normal' | 'italic' | 'oblique'; +export type FontWeight = + | 100 + | 200 + | 300 + | 400 + | 500 + | 600 + | 700 + | 800 + | 900 + | 'thin' + | 'extralight' + | 'light' + | 'normal' + | 'medium' + | 'semibold' + | 'bold' + | 'extrabold' + | 'black'; +export type FocusMoveDirection = 'forward' | 'backward' | 'left' | 'right' | 'up' | 'down'; +export type WidgetRoleBase = 'root' | 'container' | 'text' | 'button' | 'input' | 'custom'; +export type WidgetRole = WidgetRoleBase | `${WidgetRoleBase}:${string}`; +export type PercentageString = `${number}%`; +export type StretchString = `stretch:${number}`; +export type ViewportString = `viewport:${number}`; +export type ColorHexString = `#${string}`; +export type KerningPairKey = `${number}:${number}`; +export type UILengthInput = + | number + | 'auto' + | 'content' + | PercentageString + | StretchString + | ViewportString; +export type FontGlyphBitmapFormat = 'alpha8' | 'rgba8' | 'sdf8'; +export type FontBinaryFormat = 'ttf' | 'otf' | 'woff' | 'woff2'; + +export interface Vec2Like { + readonly x: number; + readonly y: number; +} + +export interface SizeLike { + readonly width: number; + readonly height: number; +} + +export interface RectLike extends Vec2Like, SizeLike {} + +export interface UVRect { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} diff --git a/web/packages/ui/src/types/layout.ts b/web/packages/ui/src/types/layout.ts new file mode 100644 index 00000000..31222678 --- /dev/null +++ b/web/packages/ui/src/types/layout.ts @@ -0,0 +1,152 @@ +import type { + AlignMode, + AlignSelfMode, + Axis, + ColorHexString, + DisplayMode, + JustifyMode, + LengthKind, + PositionMode, + RectLike, + UILengthInput, +} from './foundation'; + +export interface EdgeInsets { + readonly top: number; + readonly right: number; + readonly bottom: number; + readonly left: number; +} + +export interface CornerRadii { + readonly topLeft: number; + readonly topRight: number; + readonly bottomRight: number; + readonly bottomLeft: number; +} + +export interface Anchor { + readonly x: number; + readonly y: number; + readonly pivotX: number; + readonly pivotY: number; + readonly offsetX: number; + readonly offsetY: number; + readonly stretch: boolean; +} + +export interface ReadonlyColor { + readonly r: number; + readonly g: number; + readonly b: number; + readonly a: number; +} + +export interface ColorLike { + readonly r: number; + readonly g: number; + readonly b: number; + readonly a?: number; +} + +export type EdgeInput = + | number + | readonly [number, number] + | readonly [number, number, number, number] + | Readonly>>; + +export type CornerInput = number | readonly [number, number, number, number]; + +export type ColorInput = + | number + | ColorHexString + | readonly [number, number, number] + | readonly [number, number, number, number] + | ColorLike; + +export type AnchorPreset = + | 'top-left' + | 'top' + | 'top-right' + | 'left' + | 'center' + | 'right' + | 'bottom-left' + | 'bottom' + | 'bottom-right' + | 'stretch'; + +export type AnchorInput = + | AnchorPreset + | Readonly>>; + +export interface ResolvedLength { + readonly kind: LengthKind; + readonly value: number; +} + +export interface ResolvedLayout { + readonly display: DisplayMode; + readonly direction: Axis; + readonly gap: number; + readonly padding: EdgeInsets; + readonly contentOffsetX: number; + readonly contentOffsetY: number; + readonly margin: EdgeInsets; + readonly width: ResolvedLength; + readonly height: ResolvedLength; + readonly minWidth: number; + readonly minHeight: number; + readonly maxWidth: number; + readonly maxHeight: number; + readonly grow: number; + readonly shrink: number; + readonly basis: ResolvedLength; + readonly alignItems: AlignMode; + readonly alignSelf: AlignSelfMode; + readonly justifyContent: JustifyMode; + readonly position: PositionMode; + readonly insetTop?: ResolvedLength; + readonly insetRight?: ResolvedLength; + readonly insetBottom?: ResolvedLength; + readonly insetLeft?: ResolvedLength; + readonly anchor: Anchor; + readonly aspectRatio: number; + readonly zIndex: number; +} + +export interface LayoutBox extends RectLike { + readonly contentX: number; + readonly contentY: number; + readonly contentWidth: number; + readonly contentHeight: number; +} + +export type AffineTransform2D = readonly [number, number, number, number, number, number]; + +export interface WidgetLayoutInput { + readonly display?: DisplayMode; + readonly direction?: Axis; + readonly gap?: number; + readonly padding?: EdgeInput; + readonly contentOffsetX?: number; + readonly contentOffsetY?: number; + readonly margin?: EdgeInput; + readonly width?: UILengthInput; + readonly height?: UILengthInput; + readonly minWidth?: number; + readonly minHeight?: number; + readonly maxWidth?: number; + readonly maxHeight?: number; + readonly grow?: number; + readonly shrink?: number; + readonly basis?: UILengthInput; + readonly alignItems?: AlignMode; + readonly alignSelf?: AlignSelfMode; + readonly justifyContent?: JustifyMode; + readonly position?: PositionMode; + readonly inset?: Readonly>>; + readonly anchor?: AnchorInput; + readonly aspectRatio?: number; + readonly zIndex?: number; +} diff --git a/web/packages/ui/src/types/render-frame.ts b/web/packages/ui/src/types/render-frame.ts new file mode 100644 index 00000000..36d6e6cf --- /dev/null +++ b/web/packages/ui/src/types/render-frame.ts @@ -0,0 +1,197 @@ +import type { + AffineTransform2D, + CornerRadii, + LayoutBox, + ReadonlyColor, + WidgetLayoutInput, +} from './layout'; +import type { + FocusMoveDirection, + FontFaceId, + ResolvedTextDirection, + RectLike, + UIImageSamplingMode, + UVRect, + WidgetId, + WidgetRole, + WidgetSerializableKey, +} from './foundation'; +import type { GlyphAtlasEntry } from './font'; +import type { + TextBlockInput, + UIImageSource, + WidgetFocusPolicyInput, + WidgetImageInput, + WidgetStyleInput, +} from './widget'; + +export interface TextLayoutConstraint { + readonly width?: number; + readonly height?: number; +} + +export interface TextGlyphPlacement { + readonly codePoint: number; + readonly clusterIndex: number; + readonly x: number; + readonly y: number; + readonly advance: number; + readonly width: number; + readonly height: number; + readonly line: number; + readonly text: string; + readonly atlasEntry: GlyphAtlasEntry | null; +} + +export interface TextLineLayout { + readonly index: number; + readonly start: number; + readonly end: number; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly ascent: number; + readonly descent: number; + readonly gapCount: number; +} + +export interface TextClusterLayout { + readonly index: number; + readonly line: number; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly text: string; + readonly whitespace: boolean; + readonly newline: boolean; +} + +export interface TextCaretPlacement { + readonly index: number; + readonly line: number; + readonly x: number; + readonly y: number; + readonly height: number; +} + +export interface TextLayoutResult { + readonly faceId: FontFaceId | null; + readonly width: number; + readonly height: number; + readonly lineHeight: number; + readonly baseline: number; + readonly lines: readonly TextLineLayout[]; + readonly clusters: readonly TextClusterLayout[]; + readonly carets: readonly TextCaretPlacement[]; + readonly glyphs: readonly TextGlyphPlacement[]; + readonly truncated: boolean; + readonly direction: ResolvedTextDirection; + readonly text: string; +} + +export interface QuadRenderCommand { + readonly kind: 'quad'; + readonly widget: WidgetId; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly zIndex: number; + readonly color: ReadonlyColor; + readonly borderColor: ReadonlyColor; + readonly borderWidth: number; + readonly radius: CornerRadii; + readonly opacity: number; + readonly clip: RectLike | null; + readonly transform?: AffineTransform2D; +} + +export interface TextRenderCommand { + readonly kind: 'text'; + readonly widget: WidgetId; + readonly x: number; + readonly y: number; + readonly zIndex: number; + readonly color: ReadonlyColor; + readonly outlineColor: ReadonlyColor; + readonly outlineWidth: number; + readonly edgeSoftness: number; + readonly opacity: number; + readonly clip: RectLike | null; + readonly layout: TextLayoutResult; + readonly transform?: AffineTransform2D; +} + +export interface ImageRenderCommand { + readonly kind: 'image'; + readonly widget: WidgetId; + readonly source: UIImageSource; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly zIndex: number; + readonly tint: ReadonlyColor; + readonly opacity: number; + readonly sampling: UIImageSamplingMode; + readonly radius: CornerRadii; + readonly clip: RectLike | null; + readonly uvRect: UVRect; + readonly transform?: AffineTransform2D; +} + +export interface CustomRenderCommand { + readonly kind: 'custom'; + readonly widget: WidgetId; + readonly zIndex: number; + readonly clip: RectLike | null; + readonly payload: TPayload; +} + +export type RenderCommand = + | QuadRenderCommand + | ImageRenderCommand + | TextRenderCommand + | CustomRenderCommand; + +export interface UIFrameMetrics { + readonly widgetCount: number; + readonly visibleWidgetCount: number; + readonly renderCount: number; + readonly customCommandCount: number; + readonly imageCommandCount: number; + readonly textCommandCount: number; + readonly glyphCount: number; + readonly layoutPasses: number; +} + +export interface UIFrame { + readonly viewportWidth: number; + readonly viewportHeight: number; + readonly commands: readonly RenderCommand[]; + readonly metrics: UIFrameMetrics; +} + +export interface WidgetSnapshot { + readonly role: WidgetRole; + readonly controller?: string; + readonly key?: WidgetSerializableKey; + readonly props?: Readonly> | null; + readonly enabled?: boolean; + readonly interactive?: boolean; + readonly layout?: WidgetLayoutInput; + readonly style?: WidgetStyleInput; + readonly text?: TextBlockInput | null; + readonly image?: WidgetImageInput | null; + readonly focus?: WidgetFocusPolicyInput; + readonly children: readonly WidgetSnapshot[]; +} + +export interface UIRuntimeSnapshot { + readonly viewportWidth: number; + readonly viewportHeight: number; + readonly locale: string; + readonly root: WidgetSnapshot; +} diff --git a/web/packages/ui/src/types/widget.ts b/web/packages/ui/src/types/widget.ts new file mode 100644 index 00000000..34e2f9ec --- /dev/null +++ b/web/packages/ui/src/types/widget.ts @@ -0,0 +1,270 @@ +import type { + FontStyle, + FontWeight, + UIImageFitMode, + UIImageSamplingMode, + TextAlignMode, + TextDirectionMode, + TextOverflowMode, + TextWrapMode, + UVRect, + WidgetId, + WidgetKey, + WidgetRole, +} from './foundation'; +import type { DeepReadonlyPartial } from '@axrone/utility'; +import type { ColorInput, CornerInput, CornerRadii, ReadonlyColor, WidgetLayoutInput } from './layout'; + +export interface WidgetStyleInput { + readonly visible?: boolean; + readonly opacity?: number; + readonly clip?: boolean; + readonly background?: ColorInput; + readonly borderColor?: ColorInput; + readonly borderWidth?: number; + readonly radius?: CornerInput; + readonly color?: ColorInput; +} + +export interface ResolvedWidgetStyle { + readonly visible: boolean; + readonly opacity: number; + readonly clip: boolean; + readonly background: ReadonlyColor; + readonly borderColor: ReadonlyColor; + readonly borderWidth: number; + readonly radius: CornerRadii; + readonly color: ReadonlyColor; +} + +export interface TextBlockInput { + readonly value: string; + readonly family?: string; + readonly size?: number; + readonly weight?: FontWeight; + readonly style?: FontStyle; + readonly locale?: string; + readonly direction?: TextDirectionMode; + readonly lineHeight?: number; + readonly letterSpacing?: number; + readonly wrap?: TextWrapMode; + readonly overflow?: TextOverflowMode; + readonly maxLines?: number; + readonly align?: TextAlignMode; + readonly color?: ColorInput; + readonly outlineColor?: ColorInput; + readonly outlineWidth?: number; + readonly edgeSoftness?: number; + readonly shadowColor?: ColorInput; + readonly shadowOffsetX?: number; + readonly shadowOffsetY?: number; + readonly underline?: boolean; + readonly underlineColor?: ColorInput; + readonly underlineThickness?: number; + readonly underlineOffset?: number; + readonly strikeThrough?: boolean; + readonly strikeThroughColor?: ColorInput; + readonly strikeThroughThickness?: number; + readonly selectionStart?: number; + readonly selectionEnd?: number; + readonly selectionColor?: ColorInput; + readonly caretIndex?: number; + readonly caretColor?: ColorInput; + readonly caretWidth?: number; + readonly caretInset?: number; +} + +export interface UIImageTextureSource { + readonly kind: 'texture'; + readonly resourceId: string; + readonly width: number; + readonly height: number; +} + +export interface UIImageMaterialSource { + readonly kind: 'material'; + readonly materialId: string; + readonly textureBinding?: string; + readonly width: number; + readonly height: number; +} + +export type UIImageSource = UIImageTextureSource | UIImageMaterialSource; + +export interface WidgetImageInput { + readonly source: UIImageSource; + readonly fit?: UIImageFitMode; + readonly alignX?: number; + readonly alignY?: number; + readonly sampling?: UIImageSamplingMode; + readonly tint?: ColorInput; + readonly uvRect?: Readonly>; +} + +export interface ResolvedTextBlock { + readonly value: string; + readonly family: string; + readonly size: number; + readonly weight: number; + readonly style: FontStyle; + readonly locale: string; + readonly direction: TextDirectionMode; + readonly lineHeight: number; + readonly letterSpacing: number; + readonly wrap: TextWrapMode; + readonly overflow: TextOverflowMode; + readonly maxLines: number; + readonly align: TextAlignMode; + readonly color: ReadonlyColor; + readonly outlineColor: ReadonlyColor; + readonly outlineWidth: number; + readonly edgeSoftness: number; + readonly shadowColor: ReadonlyColor; + readonly shadowOffsetX: number; + readonly shadowOffsetY: number; + readonly underline: boolean; + readonly underlineColor: ReadonlyColor; + readonly underlineThickness: number; + readonly underlineOffset: number; + readonly strikeThrough: boolean; + readonly strikeThroughColor: ReadonlyColor; + readonly strikeThroughThickness: number; + readonly selectionStart: number | null; + readonly selectionEnd: number | null; + readonly selectionColor: ReadonlyColor; + readonly caretIndex: number | null; + readonly caretColor: ReadonlyColor; + readonly caretWidth: number; + readonly caretInset: number; +} + +export interface ResolvedWidgetImage { + readonly source: UIImageSource; + readonly fit: UIImageFitMode; + readonly alignX: number; + readonly alignY: number; + readonly sampling: UIImageSamplingMode; + readonly tint: ReadonlyColor; + readonly uvRect: UVRect; +} + +export interface WidgetFocusPolicyInput { + readonly focusable?: boolean; + readonly tabIndex?: number; + readonly scope?: boolean; + readonly cycle?: boolean; + readonly order?: number; +} + +export interface ResolvedFocusPolicy { + readonly focusable: boolean; + readonly tabIndex: number; + readonly scope: boolean; + readonly cycle: boolean; + readonly order: number; +} + +export interface UIPointerEvent { + readonly type: 'pointer'; + readonly phase: 'move' | 'down' | 'up' | 'enter' | 'leave' | 'wheel'; + readonly x: number; + readonly y: number; + readonly pointerId?: number; + readonly button?: number; + readonly buttons?: number; + readonly deltaX?: number; + readonly deltaY?: number; + readonly altKey?: boolean; + readonly ctrlKey?: boolean; + readonly shiftKey?: boolean; + readonly metaKey?: boolean; +} + +export interface UIKeyEvent { + readonly type: 'key'; + readonly phase: 'down' | 'up'; + readonly key: string; + readonly code?: string; + readonly repeat?: boolean; + readonly altKey?: boolean; + readonly ctrlKey?: boolean; + readonly shiftKey?: boolean; + readonly metaKey?: boolean; +} + +export interface UITextInputEvent { + readonly type: 'text'; + readonly text: string; + readonly composing?: boolean; + readonly locale?: string; +} + +export interface UIWindowFocusEvent { + readonly type: 'focus'; + readonly focused: boolean; +} + +export interface WidgetFocusChangeEvent { + readonly type: 'widget-focus'; + readonly focused: boolean; + readonly reason: 'api' | 'pointer' | 'navigation' | 'window'; +} + +export type UIInputEvent = UIPointerEvent | UIKeyEvent | UITextInputEvent | UIWindowFocusEvent; + +export interface WidgetEventContext< + TProps extends Record = Record, + TRuntime = unknown, +> { + readonly runtime: TRuntime; + readonly widget: WidgetId; + readonly props: Readonly; +} + +export type WidgetEventHandler< + TEvent, + TProps extends Record = Record, + TRuntime = unknown, +> = (event: Readonly, context: WidgetEventContext) => boolean | void; + +export interface WidgetEventHandlers< + TProps extends Record = Record, + TRuntime = unknown, +> { + readonly pointerMove?: WidgetEventHandler; + readonly pointerDown?: WidgetEventHandler; + readonly pointerUp?: WidgetEventHandler; + readonly pointerEnter?: WidgetEventHandler; + readonly pointerLeave?: WidgetEventHandler; + readonly wheel?: WidgetEventHandler; + readonly keyDown?: WidgetEventHandler; + readonly keyUp?: WidgetEventHandler; + readonly textInput?: WidgetEventHandler; + readonly focus?: WidgetEventHandler; + readonly blur?: WidgetEventHandler; +} + +export interface WidgetConfig< + TProps extends Record = Record, + TRuntime = unknown, +> { + readonly role?: WidgetRole; + readonly controller?: string; + readonly key?: WidgetKey; + readonly props?: Readonly; + readonly enabled?: boolean; + readonly interactive?: boolean; + readonly layout?: WidgetLayoutInput; + readonly style?: WidgetStyleInput; + readonly text?: TextBlockInput | null; + readonly image?: WidgetImageInput | null; + readonly focus?: WidgetFocusPolicyInput; + readonly handlers?: WidgetEventHandlers; +} + +export type WidgetPatch< + TProps extends Record = Record, + TRuntime = unknown, +> = Omit>, 'props'> & { + readonly props?: Readonly | TProps>; +}; diff --git a/web/packages/ui/vitest.config.ts b/web/packages/ui/vitest.config.ts new file mode 100644 index 00000000..992270c1 --- /dev/null +++ b/web/packages/ui/vitest.config.ts @@ -0,0 +1,23 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; +import { createWorkspacePackageAliasEntries } from '../../build/workspace-package-aliases.mjs'; + +const packageDir = path.dirname(fileURLToPath(import.meta.url)); +const workspaceRoot = path.resolve(packageDir, '../..'); + +export default defineConfig({ + test: { + environment: 'happy-dom', + globals: true, + setupFiles: [path.join(workspaceRoot, 'vitest.setup.ts')], + include: ['src/**/*.{test,spec}.ts', 'src/**/__tests__/**/*.{test,spec}.ts'], + exclude: ['src/**/*.browser.{test,spec}.ts'], + }, + resolve: { + alias: createWorkspacePackageAliasEntries(workspaceRoot), + }, + esbuild: { + target: 'es2022', + }, +}); diff --git a/web/packages/utility/src/__tests__/freeze.test.ts b/web/packages/utility/src/__tests__/freeze.test.ts new file mode 100644 index 00000000..81ed0ceb --- /dev/null +++ b/web/packages/utility/src/__tests__/freeze.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { deepFreeze } from '../freeze'; + +describe('deepFreeze', () => { + it('freezes nested objects without throwing on binary buffer views', () => { + const buffer = new ArrayBuffer(8); + const bytes = new Uint8Array(buffer); + const view = new DataView(buffer); + const value = { + nested: { + list: [{ enabled: true }], + }, + buffer, + bytes, + view, + }; + + expect(() => deepFreeze(value)).not.toThrow(); + + expect(Object.isFrozen(value)).toBe(true); + expect(Object.isFrozen(value.nested)).toBe(true); + expect(Object.isFrozen(value.nested.list)).toBe(true); + expect(Object.isFrozen(value.nested.list[0])).toBe(true); + expect(value.buffer).toBe(buffer); + expect(value.bytes).toBe(bytes); + expect(value.view).toBe(view); + }); + + it('preserves cyclic references', () => { + const value: { self?: unknown; nested: { ready: boolean } } = { + nested: { ready: true }, + }; + value.self = value; + + expect(() => deepFreeze(value)).not.toThrow(); + expect(Object.isFrozen(value)).toBe(true); + expect(Object.isFrozen(value.nested)).toBe(true); + expect(value.self).toBe(value); + }); +}); \ No newline at end of file diff --git a/web/packages/utility/src/__tests__/memory/containers/dynamic-array.test.ts b/web/packages/utility/src/__tests__/memory/containers/dynamic-array.test.ts deleted file mode 100644 index a5b77e20..00000000 --- a/web/packages/utility/src/__tests__/memory/containers/dynamic-array.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { DynamicArray } from '../../../memory/containers/queue/dynamic-array'; -import { - createCapacity, - createQueueSize, - createHeapIndex, -} from '../../../memory/containers/queue/utils'; -import { EmptyQueueError, InvalidCapacityError } from '../../../memory/containers/queue/errors'; -import { beforeEach, describe, expect, it } from 'vitest'; - -describe('DynamicArray', () => { - describe('Constructor', () => { - it('should create array with default capacity', () => { - const array = new DynamicArray(); - - expect(array.length).toBe(createQueueSize(0)); - expect(array.capacity).toBe(createCapacity(16)); - }); - - it('should create array with custom capacity', () => { - const customCapacity = createCapacity(32); - const array = new DynamicArray(customCapacity); - - expect(array.length).toBe(createQueueSize(0)); - expect(array.capacity).toBe(customCapacity); - }); - - it('should throw error for negative capacity', () => { - expect(() => new DynamicArray(createCapacity(-1))).toThrow( - InvalidCapacityError - ); - }); - }); - - describe('Basic Operations', () => { - let array: DynamicArray; - - beforeEach(() => { - array = new DynamicArray(createCapacity(4)); - }); - - it('should get and set elements correctly', () => { - array.push(42); - array.push(24); - - expect(array.get(createHeapIndex(0))).toBe(42); - expect(array.get(createHeapIndex(1))).toBe(24); - - array.set(createHeapIndex(0), 100); - expect(array.get(createHeapIndex(0))).toBe(100); - }); - - it('should push elements and grow capacity', () => { - const values = [1, 2, 3, 4, 5]; - - for (const value of values) { - array.push(value); - } - - expect(array.length).toBe(createQueueSize(5)); - expect(array.capacity).toBeGreaterThan(createCapacity(4)); - - for (let i = 0; i < values.length; i++) { - expect(array.get(createHeapIndex(i))).toBe(values[i]); - } - }); - - it('should pop elements correctly', () => { - array.push(1); - array.push(2); - array.push(3); - - expect(array.pop()).toBe(3); - expect(array.length).toBe(createQueueSize(2)); - - expect(array.pop()).toBe(2); - expect(array.pop()).toBe(1); - expect(array.length).toBe(createQueueSize(0)); - }); - - it('should throw error when popping from empty array', () => { - expect(() => array.pop()).toThrow(EmptyQueueError); - }); - - it('should swap elements correctly', () => { - array.push(10); - array.push(20); - array.push(30); - - array.swap(createHeapIndex(0), createHeapIndex(2)); - - expect(array.get(createHeapIndex(0))).toBe(30); - expect(array.get(createHeapIndex(2))).toBe(10); - expect(array.get(createHeapIndex(1))).toBe(20); - }); - }); - - describe('Capacity Management', () => { - let array: DynamicArray; - - beforeEach(() => { - array = new DynamicArray(createCapacity(2)); - }); - - it('should resize correctly', () => { - array.push(1); - array.push(2); - - array.resize(createCapacity(10)); - - expect(array.capacity).toBe(createCapacity(10)); - expect(array.length).toBe(createQueueSize(2)); - expect(array.get(createHeapIndex(0))).toBe(1); - expect(array.get(createHeapIndex(1))).toBe(2); - }); - - it('should throw error when resizing below current length', () => { - array.push(1); - array.push(2); - array.push(3); - - expect(() => array.resize(createCapacity(2))).toThrow(InvalidCapacityError); - }); - - it('should ensure capacity grows geometrically', () => { - const initialCapacity = array.capacity; - - for (let i = 0; i < initialCapacity; i++) { - array.push(i); - } - - array.push(999); - - expect(array.capacity).toBeGreaterThanOrEqual(initialCapacity * 2); - }); - - it('should trim to size correctly', () => { - for (let i = 0; i < 10; i++) { - array.push(i); - } - - const largeCapacity = array.capacity; - - array.pop(); - array.pop(); - array.pop(); - - array.trimToSize(); - - expect(array.capacity).toBeLessThan(largeCapacity); - expect(array.capacity).toBeGreaterThanOrEqual(array.length); - }); - - it('should maintain minimum capacity of 1 when trimming empty array', () => { - array.trimToSize(); - - expect(array.capacity).toBeGreaterThanOrEqual(createCapacity(1)); - }); - }); - - describe('Utility Operations', () => { - let array: DynamicArray; - - beforeEach(() => { - array = new DynamicArray(); - array.push('first'); - array.push('second'); - array.push('third'); - }); - - it('should clear array correctly', () => { - array.clear(); - - expect(array.length).toBe(createQueueSize(0)); - expect(() => array.pop()).toThrow(EmptyQueueError); - }); - - it('should create correct slice', () => { - const slice = array.slice(); - - expect(slice).toEqual(['first', 'second', 'third']); - expect(slice.length).toBe(3); - - slice[0] = 'modified'; - expect(array.get(createHeapIndex(0))).toBe('first'); - }); - - it('should handle empty array slice', () => { - array.clear(); - const slice = array.slice(); - - expect(slice).toEqual([]); - expect(slice.length).toBe(0); - }); - }); - - describe('Performance Characteristics', () => { - it('should handle large number of elements efficiently', () => { - const array = new DynamicArray(); - const elementCount = 10000; - - const startTime = performance.now(); - - for (let i = 0; i < elementCount; i++) { - array.push(i); - } - - expect(array.length).toBe(createQueueSize(elementCount)); - - for (let i = 0; i < elementCount; i++) { - expect(array.get(createHeapIndex(i))).toBe(i); - } - - for (let i = elementCount - 1; i >= 0; i--) { - expect(array.pop()).toBe(i); - } - - const endTime = performance.now(); - const duration = endTime - startTime; - - expect(duration).toBeLessThan(1000); - }); - - it('should have efficient memory growth pattern', () => { - const array = new DynamicArray(createCapacity(1)); - const growthHistory: number[] = []; - - let previousCapacity = array.capacity as number; - growthHistory.push(previousCapacity); - - for (let i = 0; i < 100; i++) { - array.push(i); - - const currentCapacity = array.capacity as number; - if (currentCapacity !== previousCapacity) { - growthHistory.push(currentCapacity); - previousCapacity = currentCapacity; - } - } - - for (let i = 1; i < growthHistory.length; i++) { - const ratio = growthHistory[i] / growthHistory[i - 1]; - expect(ratio).toBeGreaterThanOrEqual(1.5); - expect(ratio).toBeLessThanOrEqual(2.5); - } - }); - }); - - describe('Edge Cases', () => { - it('should handle zero capacity correctly', () => { - const array = new DynamicArray(createCapacity(0)); - - expect(array.length).toBe(createQueueSize(0)); - expect(array.capacity).toBe(createCapacity(0)); - - array.push(42); - expect(array.capacity).toBeGreaterThan(createCapacity(0)); - expect(array.get(createHeapIndex(0))).toBe(42); - }); - - it('should handle single element operations', () => { - const array = new DynamicArray(createCapacity(1)); - - array.push('only'); - expect(array.length).toBe(createQueueSize(1)); - expect(array.get(createHeapIndex(0))).toBe('only'); - - const popped = array.pop(); - expect(popped).toBe('only'); - expect(array.length).toBe(createQueueSize(0)); - }); - - it('should handle object references correctly', () => { - const array = new DynamicArray(); - const obj1 = { id: 1 }; - const obj2 = { id: 2 }; - - array.push(obj1); - array.push(obj2); - - expect(array.get(createHeapIndex(0))).toBe(obj1); - expect(array.get(createHeapIndex(1))).toBe(obj2); - - obj1.id = 999; - expect((array.get(createHeapIndex(0)) as any).id).toBe(999); - }); - }); -}); diff --git a/web/packages/utility/src/__tests__/memory/pq.test.ts b/web/packages/utility/src/__tests__/memory/pq.test.ts deleted file mode 100644 index 4cf46822..00000000 --- a/web/packages/utility/src/__tests__/memory/pq.test.ts +++ /dev/null @@ -1,745 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { - PriorityQueue, - EmptyQueueError, - InvalidCapacityError, - QueueError, - PriorityQueueNode, - createCapacity, - createQueueSize, - createHeapIndex, - defaultComparator, - type Comparator, - type ReadonlyQueueNode, - type PriorityQueueOptions, -} from '../../memory/containers/queue/priority-queue'; - -describe('PriorityQueue', () => { - describe('constructor', () => { - it('should create empty queue with default options', () => { - const queue = new PriorityQueue(); - - expect(queue.size).toBe(0); - expect(queue.isEmpty).toBe(true); - expect(queue.capacity).toBeGreaterThan(0); - }); - - it('should create queue with custom comparator', () => { - const reverseComparator: Comparator = (a, b) => b - a; - const queue = new PriorityQueue({ comparator: reverseComparator }); - - queue.enqueue('low', 1); - queue.enqueue('high', 10); - - expect(queue.dequeue()).toBe('high'); - }); - - it('should create queue with initial capacity', () => { - const capacity = createCapacity(100); - const queue = new PriorityQueue({ initialCapacity: capacity }); - - expect(queue.capacity).toBe(capacity); - }); - - it('should create queue with auto trim enabled', () => { - const queue = new PriorityQueue({ autoTrim: true }); - - expect(queue).toBeDefined(); - }); - }); - - describe('enqueue', () => { - let queue: PriorityQueue; - - beforeEach(() => { - queue = new PriorityQueue(); - }); - - it('should add single element', () => { - queue.enqueue('test', 1); - - expect(queue.size).toBe(1); - expect(queue.isEmpty).toBe(false); - }); - - it('should maintain heap property with multiple elements', () => { - queue.enqueue('high', 10); - queue.enqueue('low', 1); - queue.enqueue('medium', 5); - - expect(queue.peek()).toBe('low'); - }); - - it('should handle duplicate priorities', () => { - queue.enqueue('first', 5); - queue.enqueue('second', 5); - - expect(queue.size).toBe(2); - }); - - it('should grow capacity automatically', () => { - const initialCapacity = queue.capacity; - - for (let i = 0; i < initialCapacity + 10; i++) { - queue.enqueue(`item-${i}`, i); - } - - expect(queue.capacity).toBeGreaterThan(initialCapacity); - expect(queue.size).toBe(initialCapacity + 10); - }); - }); - - describe('dequeue', () => { - let queue: PriorityQueue; - - beforeEach(() => { - queue = new PriorityQueue(); - }); - - it('should throw EmptyQueueError when queue is empty', () => { - expect(() => queue.dequeue()).toThrow(EmptyQueueError); - }); - - it('should return element with highest priority', () => { - queue.enqueue('medium', 5); - queue.enqueue('high', 10); - queue.enqueue('low', 1); - - expect(queue.dequeue()).toBe('low'); - }); - - it('should maintain heap property after removal', () => { - const items = [ - { element: 'a', priority: 3 }, - { element: 'b', priority: 1 }, - { element: 'c', priority: 4 }, - { element: 'd', priority: 2 }, - { element: 'e', priority: 5 }, - ]; - - items.forEach((item) => queue.enqueue(item.element, item.priority)); - - const results = []; - while (!queue.isEmpty) { - results.push(queue.dequeue()); - } - - expect(results).toEqual(['b', 'd', 'a', 'c', 'e']); - }); - - it('should update size correctly', () => { - queue.enqueue('test', 1); - expect(queue.size).toBe(1); - - queue.dequeue(); - expect(queue.size).toBe(0); - expect(queue.isEmpty).toBe(true); - }); - - it('should handle auto trim when enabled', () => { - const autoTrimQueue = new PriorityQueue({ autoTrim: true }); - - for (let i = 0; i < 100; i++) { - autoTrimQueue.enqueue(`item-${i}`, i); - } - - const capacityBeforeTrim = autoTrimQueue.capacity; - - for (let i = 0; i < 90; i++) { - autoTrimQueue.dequeue(); - } - - expect(autoTrimQueue.capacity).toBeLessThanOrEqual(capacityBeforeTrim); - }); - }); - - describe('peek', () => { - let queue: PriorityQueue; - - beforeEach(() => { - queue = new PriorityQueue(); - }); - - it('should throw EmptyQueueError when queue is empty', () => { - expect(() => queue.peek()).toThrow(EmptyQueueError); - }); - - it('should return highest priority element without removing it', () => { - queue.enqueue('low', 1); - queue.enqueue('high', 10); - - expect(queue.peek()).toBe('low'); - expect(queue.size).toBe(2); - }); - - it('should return same element on multiple calls', () => { - queue.enqueue('test', 1); - - expect(queue.peek()).toBe('test'); - expect(queue.peek()).toBe('test'); - }); - }); - - describe('tryDequeue', () => { - let queue: PriorityQueue; - - beforeEach(() => { - queue = new PriorityQueue(); - }); - - it('should return undefined when queue is empty', () => { - expect(queue.tryDequeue()).toBeUndefined(); - }); - - it('should return element when queue is not empty', () => { - queue.enqueue('test', 1); - - expect(queue.tryDequeue()).toBe('test'); - expect(queue.isEmpty).toBe(true); - }); - }); - - describe('tryPeek', () => { - let queue: PriorityQueue; - - beforeEach(() => { - queue = new PriorityQueue(); - }); - - it('should return undefined when queue is empty', () => { - expect(queue.tryPeek()).toBeUndefined(); - }); - - it('should return element when queue is not empty', () => { - queue.enqueue('test', 1); - - expect(queue.tryPeek()).toBe('test'); - expect(queue.size).toBe(1); - }); - }); - - describe('dequeueAll', () => { - let queue: PriorityQueue; - - beforeEach(() => { - queue = new PriorityQueue(); - }); - - it('should return empty array when queue is empty', () => { - expect(queue.dequeueAll()).toEqual([]); - }); - - it('should return all elements in priority order', () => { - queue.enqueue('c', 3); - queue.enqueue('a', 1); - queue.enqueue('b', 2); - - const result = queue.dequeueAll(); - - expect(result).toEqual(['a', 'b', 'c']); - expect(queue.isEmpty).toBe(true); - }); - }); - - describe('enqueueRange', () => { - let queue: PriorityQueue; - - beforeEach(() => { - queue = new PriorityQueue(); - }); - - it('should handle empty array', () => { - queue.enqueueRange([]); - - expect(queue.isEmpty).toBe(true); - }); - - it('should add multiple items maintaining priority order', () => { - const items: ReadonlyQueueNode[] = [ - { element: 'c', priority: 3 }, - { element: 'a', priority: 1 }, - { element: 'b', priority: 2 }, - ]; - - queue.enqueueRange(items); - - expect(queue.size).toBe(3); - expect(queue.dequeue()).toBe('a'); - expect(queue.dequeue()).toBe('b'); - expect(queue.dequeue()).toBe('c'); - }); - - it('should ensure capacity for large ranges', () => { - const items: ReadonlyQueueNode[] = []; - for (let i = 0; i < 1000; i++) { - items.push({ element: `item-${i}`, priority: i }); - } - - queue.enqueueRange(items); - - expect(queue.size).toBe(1000); - }); - }); - - describe('contains', () => { - let queue: PriorityQueue; - - beforeEach(() => { - queue = new PriorityQueue(); - }); - - it('should return false for empty queue', () => { - expect(queue.contains('test')).toBe(false); - }); - - it('should return true for existing element', () => { - queue.enqueue('test', 1); - - expect(queue.contains('test')).toBe(true); - }); - - it('should return false for non-existing element', () => { - queue.enqueue('test', 1); - - expect(queue.contains('other')).toBe(false); - }); - - it('should work with object elements', () => { - const objectQueue = new PriorityQueue<{ id: number }, number>(); - const obj1 = { id: 1 }; - const obj2 = { id: 2 }; - - objectQueue.enqueue(obj1, 1); - - expect(objectQueue.contains(obj1)).toBe(true); - expect(objectQueue.contains(obj2)).toBe(false); - }); - }); - - describe('clear', () => { - let queue: PriorityQueue; - - beforeEach(() => { - queue = new PriorityQueue(); - }); - - it('should clear empty queue', () => { - queue.clear(); - - expect(queue.isEmpty).toBe(true); - expect(queue.size).toBe(0); - }); - - it('should clear non-empty queue', () => { - queue.enqueue('a', 1); - queue.enqueue('b', 2); - - queue.clear(); - - expect(queue.isEmpty).toBe(true); - expect(queue.size).toBe(0); - }); - }); - - describe('ensureCapacity', () => { - let queue: PriorityQueue; - - beforeEach(() => { - queue = new PriorityQueue(); - }); - - it('should increase capacity when needed', () => { - const newCapacity = createCapacity(100); - const oldCapacity = queue.capacity; - - queue.ensureCapacity(newCapacity); - - if (newCapacity > oldCapacity) { - expect(queue.capacity).toBeGreaterThanOrEqual(newCapacity); - } - }); - - it('should not decrease capacity', () => { - const oldCapacity = queue.capacity; - const smallerCapacity = createCapacity(1); - - queue.ensureCapacity(smallerCapacity); - - expect(queue.capacity).toBe(oldCapacity); - }); - }); - - describe('trimExcess', () => { - let queue: PriorityQueue; - - beforeEach(() => { - queue = new PriorityQueue(); - }); - - it('should trim excess capacity', () => { - for (let i = 0; i < 50; i++) { - queue.enqueue(`item-${i}`, i); - } - - for (let i = 0; i < 40; i++) { - queue.dequeue(); - } - - const capacityBeforeTrim = queue.capacity; - queue.trimExcess(); - - expect(queue.capacity).toBeLessThanOrEqual(capacityBeforeTrim); - }); - }); - - describe('toArray', () => { - let queue: PriorityQueue; - - beforeEach(() => { - queue = new PriorityQueue(); - }); - - it('should return empty array for empty queue', () => { - const array = queue.toArray(); - - expect(array).toEqual([]); - expect(Object.isFrozen(array)).toBe(true); - }); - - it('should return frozen array of elements', () => { - queue.enqueue('a', 3); - queue.enqueue('b', 1); - queue.enqueue('c', 2); - - const array = queue.toArray(); - - expect(array).toHaveLength(3); - expect(Object.isFrozen(array)).toBe(true); - expect(array).toContain('a'); - expect(array).toContain('b'); - expect(array).toContain('c'); - }); - }); - - describe('clone', () => { - let queue: PriorityQueue; - - beforeEach(() => { - queue = new PriorityQueue(); - }); - - it('should create independent copy', () => { - queue.enqueue('a', 1); - queue.enqueue('b', 2); - - const clone = queue.clone(); - - expect(clone.size).toBe(queue.size); - expect(clone.dequeue()).toBe('a'); - expect(queue.peek()).toBe('a'); - }); - - it('should preserve comparator', () => { - const reverseQueue = new PriorityQueue({ - comparator: (a, b) => b - a, - }); - - reverseQueue.enqueue('low', 1); - reverseQueue.enqueue('high', 10); - - const clone = reverseQueue.clone(); - - expect(clone.dequeue()).toBe('high'); - }); - }); - - describe('iterator', () => { - let queue: PriorityQueue; - - beforeEach(() => { - queue = new PriorityQueue(); - }); - - it('should iterate over empty queue', () => { - const elements = Array.from(queue); - - expect(elements).toEqual([]); - }); - - it('should iterate in priority order', () => { - queue.enqueue('c', 3); - queue.enqueue('a', 1); - queue.enqueue('b', 2); - - const elements = Array.from(queue); - - expect(elements).toEqual(['a', 'b', 'c']); - expect(queue.size).toBe(3); - }); - - it('should work with for...of loop', () => { - queue.enqueue('second', 2); - queue.enqueue('first', 1); - - const elements: string[] = []; - for (const element of queue) { - elements.push(element); - } - - expect(elements).toEqual(['first', 'second']); - }); - }); - - describe('static factory methods', () => { - describe('from', () => { - it('should create queue from array', () => { - const items: ReadonlyQueueNode[] = [ - { element: 'c', priority: 3 }, - { element: 'a', priority: 1 }, - { element: 'b', priority: 2 }, - ]; - - const queue = PriorityQueue.from(items); - - expect(queue.size).toBe(3); - expect(queue.dequeue()).toBe('a'); - }); - - it('should create queue from iterable', () => { - const items = new Set([ - { element: 'b', priority: 2 }, - { element: 'a', priority: 1 }, - ]); - - const queue = PriorityQueue.from(items); - - expect(queue.size).toBe(2); - }); - - it('should accept options', () => { - const items: ReadonlyQueueNode[] = [ - { element: 'low', priority: 1 }, - { element: 'high', priority: 10 }, - ]; - - const queue = PriorityQueue.from(items, { - comparator: (a, b) => b - a, - }); - - expect(queue.dequeue()).toBe('high'); - }); - }); - describe('withComparator', () => { - it('should create queue with custom comparator', () => { - const queue = PriorityQueue.withComparator((a, b) => b - a); - - queue.enqueue('low', 1); - queue.enqueue('high', 10); - - expect(queue.dequeue()).toBe('high'); - }); - - it('should accept initial capacity', () => { - const capacity = createCapacity(50); - const queue = PriorityQueue.withComparator( - (a, b) => a - b, - capacity - ); - - expect(queue.capacity).toBe(capacity); - }); - }); - - describe('minQueue', () => { - it('should create min priority queue', () => { - const queue = PriorityQueue.minQueue(); - - queue.enqueue('high', 10); - queue.enqueue('low', 1); - - expect(queue.dequeue()).toBe('low'); - }); - - it('should accept initial capacity', () => { - const capacity = createCapacity(50); - const queue = PriorityQueue.minQueue(capacity); - - expect(queue.capacity).toBe(capacity); - }); - }); - - describe('maxQueue', () => { - it('should create max priority queue', () => { - const queue = PriorityQueue.maxQueue(); - - queue.enqueue('low', 1); - queue.enqueue('high', 10); - - expect(queue.dequeue()).toBe('high'); - }); - - it('should accept initial capacity', () => { - const capacity = createCapacity(50); - const queue = PriorityQueue.maxQueue(capacity); - - expect(queue.capacity).toBe(capacity); - }); - }); - }); - - describe('error handling', () => { - describe('EmptyQueueError', () => { - it('should have correct error code', () => { - const error = new EmptyQueueError(); - - expect(error.code).toBe('EMPTY_QUEUE'); - expect(error.message).toBe('Queue is empty'); - expect(error).toBeInstanceOf(QueueError); - expect(error).toBeInstanceOf(Error); - }); - }); - - describe('InvalidCapacityError', () => { - it('should have correct error code and message', () => { - const error = new InvalidCapacityError(-1); - - expect(error.code).toBe('INVALID_CAPACITY'); - expect(error.message).toBe('Invalid capacity: -1'); - expect(error).toBeInstanceOf(QueueError); - expect(error).toBeInstanceOf(Error); - }); - }); - }); - describe('utility functions', () => { - it('should create nominal types correctly', () => { - expect(createCapacity(10)).toBe(10); - expect(createQueueSize(5)).toBe(5); - expect(createHeapIndex(3)).toBe(3); - }); - - it('should use default comparator correctly', () => { - expect(defaultComparator(1, 2)).toBe(-1); - expect(defaultComparator(2, 1)).toBe(1); - expect(defaultComparator(1, 1)).toBe(0); - expect(defaultComparator('a', 'b')).toBe(-1); - expect(defaultComparator('b', 'a')).toBe(1); - expect(defaultComparator('a', 'a')).toBe(0); - }); - }); - - describe('PriorityQueueNode', () => { - it('should create node with element and priority', () => { - const node = new PriorityQueueNode('test', 5); - - expect(node.element).toBe('test'); - expect(node.priority).toBe(5); - }); - - it('should allow mutation of properties', () => { - const node = new PriorityQueueNode('test', 5); - - node.element = 'updated'; - node.priority = 10; - - expect(node.element).toBe('updated'); - expect(node.priority).toBe(10); - }); - }); - - describe('complex scenarios', () => { - it('should handle large number of operations', () => { - const queue = new PriorityQueue(); - const size = 10000; - - for (let i = 0; i < size; i++) { - queue.enqueue(i, Math.random() * 1000); - } - - expect(queue.size).toBe(size); - - let previousPriority = -Infinity; - while (!queue.isEmpty) { - const element = queue.dequeue(); - expect(typeof element).toBe('number'); - } - }); - - it('should handle mixed operations efficiently', () => { - const queue = new PriorityQueue(); - - for (let i = 0; i < 1000; i++) { - if (Math.random() > 0.3 || queue.isEmpty) { - queue.enqueue(`item-${i}`, Math.random() * 100); - } else { - queue.dequeue(); - } - } - - expect(queue.size).toBeGreaterThanOrEqual(0); - }); - - it('should maintain heap invariant under stress', () => { - const queue = new PriorityQueue(); - const operations = 1000; - - for (let i = 0; i < operations; i++) { - const action = Math.random(); - - if (action < 0.6 || queue.isEmpty) { - queue.enqueue(i, Math.random() * 1000); - } else if (action < 0.8) { - queue.dequeue(); - } else if (action < 0.9) { - if (!queue.isEmpty) { - queue.peek(); - } - } else { - queue.contains(Math.floor(Math.random() * operations)); - } - } - - const expectedSize = queue.size; - const results = queue.dequeueAll(); - - expect(results.length).toBe(expectedSize); - }); - }); - - describe('memory management', () => { - it('should not leak memory on repeated operations', () => { - const queue = new PriorityQueue(); - - for (let cycle = 0; cycle < 10; cycle++) { - for (let i = 0; i < 100; i++) { - queue.enqueue(`item-${i}`, i); - } - - while (!queue.isEmpty) { - queue.dequeue(); - } - - expect(queue.isEmpty).toBe(true); - } - }); - - it('should handle capacity management correctly', () => { - const queue = new PriorityQueue({ - initialCapacity: createCapacity(4), - }); - - expect(queue.capacity).toBe(4); - - for (let i = 0; i < 10; i++) { - queue.enqueue(i, i); - } - - expect(queue.capacity).toBeGreaterThan(4); - expect(queue.size).toBe(10); - - queue.clear(); - queue.trimExcess(); - - expect(queue.capacity).toBe(1); - }); - }); -}); diff --git a/web/packages/utility/src/__tests__/object.test.ts b/web/packages/utility/src/__tests__/object.test.ts new file mode 100644 index 00000000..27bc994e --- /dev/null +++ b/web/packages/utility/src/__tests__/object.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { isPlainObject, isRecord } from '../object'; + +describe('object predicates', () => { + it('treats non-array objects as records', () => { + expect(isRecord({})).toBe(true); + expect(isRecord(Object.create(null))).toBe(true); + expect(isRecord([])).toBe(false); + expect(isRecord(null)).toBe(false); + }); + + it('accepts only plain object prototypes', () => { + class Box { + constructor(readonly value: number) {} + } + + expect(isPlainObject({})).toBe(true); + expect(isPlainObject(Object.create(null))).toBe(true); + expect(isPlainObject(new Box(1))).toBe(false); + expect(isPlainObject(new Map())).toBe(false); + }); +}); \ No newline at end of file diff --git a/web/packages/utility/src/__tests__/serializable-clone.test.ts b/web/packages/utility/src/__tests__/serializable-clone.test.ts new file mode 100644 index 00000000..d77a5939 --- /dev/null +++ b/web/packages/utility/src/__tests__/serializable-clone.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { cloneSerializable } from '../clone/serializable-clone'; + +class CustomValue { + constructor(readonly value: number) {} +} + +describe('cloneSerializable', () => { + it('clones enumerable object graphs and typed arrays into serializable data', () => { + const source = { + nested: new CustomValue(3), + values: new Float32Array([1, 2, 3]), + items: [new CustomValue(7)], + }; + + const cloned = cloneSerializable(source); + + expect(cloned).toEqual({ + nested: { value: 3 }, + values: new Float32Array([1, 2, 3]), + items: [{ value: 7 }], + }); + expect(cloned).not.toBe(source); + expect(cloned.values).not.toBe(source.values); + expect(Object.getPrototypeOf(cloned.nested)).toBe(Object.prototype); + }); + + it('optionally freezes cloned arrays and objects', () => { + const source = { + nested: { enabled: true }, + items: [{ value: 1 }], + values: new Float32Array([4, 5]), + }; + + const cloned = cloneSerializable(source, { freeze: true }); + + expect(Object.isFrozen(cloned)).toBe(true); + expect(Object.isFrozen(cloned.nested)).toBe(true); + expect(Object.isFrozen(cloned.items)).toBe(true); + expect(Object.isFrozen(cloned.items[0])).toBe(true); + expect(cloned.values).not.toBe(source.values); + }); +}); \ No newline at end of file diff --git a/web/packages/utility/src/clone/deep-clone.ts b/web/packages/utility/src/clone/deep-clone.ts new file mode 100644 index 00000000..51d99739 --- /dev/null +++ b/web/packages/utility/src/clone/deep-clone.ts @@ -0,0 +1,74 @@ +import type { TypedArray } from '../types'; +import { isPlainObject } from '../object'; + +export const deepClone = (value: TValue, seen = new WeakMap()): TValue => { + if (value === null || typeof value !== 'object') { + return value; + } + + const source = value as object; + const existing = seen.get(source); + if (existing) { + return existing as TValue; + } + + if (Array.isArray(value)) { + const clone: unknown[] = []; + seen.set(source, clone); + for (const entry of value) { + clone.push(deepClone(entry, seen)); + } + return clone as TValue; + } + + if (value instanceof Date) { + return new Date(value.getTime()) as TValue; + } + + if (value instanceof Map) { + const clone = new Map(); + seen.set(source, clone); + for (const [key, entry] of value) { + clone.set(deepClone(key, seen), deepClone(entry, seen)); + } + return clone as TValue; + } + + if (value instanceof Set) { + const clone = new Set(); + seen.set(source, clone); + for (const entry of value) { + clone.add(deepClone(entry, seen)); + } + return clone as TValue; + } + + if (ArrayBuffer.isView(value)) { + if (value instanceof DataView) { + return new DataView( + value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) + ) as TValue; + } + const typedArray = value as unknown as TypedArray; + return typedArray.slice() as TValue; + } + + if (value instanceof ArrayBuffer) { + return value.slice(0) as TValue; + } + + if (!isPlainObject(value)) { + return value; + } + + const clone: Record = {}; + seen.set(source, clone); + + for (const key of Reflect.ownKeys(value)) { + clone[key] = deepClone((value as Record)[key], seen); + } + + return clone as TValue; +}; + +export const cloneData = deepClone; diff --git a/web/packages/utility/src/clone/serializable-clone.ts b/web/packages/utility/src/clone/serializable-clone.ts new file mode 100644 index 00000000..b4fd763b --- /dev/null +++ b/web/packages/utility/src/clone/serializable-clone.ts @@ -0,0 +1,50 @@ +import type { TypedArray } from '../types'; +import { isRecord } from '../object'; + +export interface CloneSerializableOptions { + readonly freeze?: boolean; +} + +const cloneArrayBufferView = (value: TValue): TValue => { + if (value instanceof DataView) { + const clonedBytes = new Uint8Array(value.byteLength); + clonedBytes.set(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)); + return new DataView(clonedBytes.buffer) as unknown as TValue; + } + + const typedArray = value as unknown as TypedArray; + return typedArray.slice() as unknown as TValue; +}; + +const maybeFreeze = (value: TValue, freeze: boolean): TValue => + (freeze ? Object.freeze(value) : value); + +const cloneSerializableInternal = (value: unknown, freeze: boolean): unknown => { + if (Array.isArray(value)) { + return maybeFreeze(value.map((entry) => cloneSerializableInternal(entry, freeze)), freeze); + } + + if (ArrayBuffer.isView(value)) { + return cloneArrayBufferView(value); + } + + if (value instanceof ArrayBuffer) { + return value.slice(0); + } + + if (!isRecord(value)) { + return value; + } + + const cloned: Record = {}; + for (const [key, entry] of Object.entries(value)) { + cloned[key] = cloneSerializableInternal(entry, freeze); + } + + return maybeFreeze(cloned, freeze); +}; + +export const cloneSerializable = ( + value: TValue, + options: CloneSerializableOptions = {} +): TValue => cloneSerializableInternal(value, options.freeze ?? false) as TValue; \ No newline at end of file diff --git a/web/packages/utility/src/comparer/fp-compare.ts b/web/packages/utility/src/comparer/fp-compare.ts index b6f3338c..f3906652 100644 --- a/web/packages/utility/src/comparer/fp-compare.ts +++ b/web/packages/utility/src/comparer/fp-compare.ts @@ -1,4 +1,5 @@ -type Brand = K & { readonly __brand: T }; +import type { Brand } from '../types'; + type Epsilon = Brand; type ULPsTolerance = Brand; diff --git a/web/packages/utility/src/disposable.ts b/web/packages/utility/src/disposable.ts new file mode 100644 index 00000000..cb4aa9ea --- /dev/null +++ b/web/packages/utility/src/disposable.ts @@ -0,0 +1,7 @@ +export interface Disposable { + dispose(): void; +} + +export interface IDisposable extends Disposable { + readonly isDisposed: boolean; +} diff --git a/web/packages/utility/src/freeze.ts b/web/packages/utility/src/freeze.ts new file mode 100644 index 00000000..f7e8a624 --- /dev/null +++ b/web/packages/utility/src/freeze.ts @@ -0,0 +1,30 @@ +import type { DeepReadonly } from './types'; + +const isArrayBufferLike = (value: object): value is ArrayBuffer | SharedArrayBuffer => + value instanceof ArrayBuffer || + (typeof SharedArrayBuffer !== 'undefined' && value instanceof SharedArrayBuffer); + +export const deepFreeze = ( + value: TValue, + seen = new WeakSet() +): DeepReadonly => { + if (value === null || typeof value !== 'object') { + return value as DeepReadonly; + } + + const target = value as object; + if (isArrayBufferLike(target) || ArrayBuffer.isView(target)) { + return value as DeepReadonly; + } + + if (seen.has(target)) { + return value as DeepReadonly; + } + seen.add(target); + + for (const key of Reflect.ownKeys(target)) { + deepFreeze((target as Record)[key], seen); + } + + return Object.freeze(value) as DeepReadonly; +}; diff --git a/web/packages/utility/src/index.ts b/web/packages/utility/src/index.ts index 361de6c9..23fc5c36 100644 --- a/web/packages/utility/src/index.ts +++ b/web/packages/utility/src/index.ts @@ -1,4 +1,31 @@ -export type { Primitive, TypedArray, TypedArrayConstructor, Builtin, BuiltinObject } from './types'; +export type { + Primitive, + TypedArray, + TypedArrayConstructor, + NumericTypedArray, + NumericTypedArrayConstructor, + Builtin, + BuiltinObject, + DeepReadonly, + DeepReadonlyPartial, + DeepMutable, + JsonPrimitive, + JsonObject, + JsonArray, + JsonValue, + Brand, + Nominal, + Opaque, + MaybePromise, + Constructor, + AbstractConstructor, + ArrayElement, + NonEmptyReadonlyArray, + Mutable, + ReadonlyTuple2, + ReadonlyTuple3, + ReadonlyTuple4, +} from './types'; export type { CompareResult, @@ -40,37 +67,13 @@ export { isEqualityComparer, } from './comparer/comparer'; -export { PriorityQueue } from './memory/containers/queue/priority-queue'; -export type { - Comparator, - HeapIndex, - QueueSize, - Capacity, - ReadonlyQueueNode, - QueueNode, - PriorityQueueOptions, - PriorityQueueCore, - OptionalOperations, - QueryOperations, - CapacityOperations, -} from './memory/containers/queue/priority-queue'; - -export type { - PoolableObject, - PoolObjectStatus, - PoolExpansionStrategy, - PoolAllocationStrategy, - PoolEvictionPolicy, - MemoryPoolOptions, - PoolPerformanceMetrics, - MemoryPoolOperations, - AsyncMemoryPoolOperations, -} from './memory/pool/mempool'; - export type { ICloneable } from './clone/cloner'; - -export * from './memory/buffering'; -export * from './memory/pool/index'; +export type { CloneSerializableOptions } from './clone/serializable-clone'; +export { cloneData, deepClone } from './clone/deep-clone'; +export { cloneSerializable } from './clone/serializable-clone'; +export type { Disposable, IDisposable } from './disposable'; +export { deepFreeze } from './freeze'; +export { isPlainObject, isRecord } from './object'; export type { SingletonKey, @@ -89,8 +92,6 @@ export type { SingletonDisposer, ScopeDisposer, ExtractSingletonType, - Constructor, - AbstractConstructor, } from './singleton'; export { diff --git a/web/packages/utility/src/memory/containers/queue/binary-heap.ts b/web/packages/utility/src/memory/containers/queue/binary-heap.ts deleted file mode 100644 index 4eda843e..00000000 --- a/web/packages/utility/src/memory/containers/queue/binary-heap.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { BinaryHeapOperations, HeapIndex, QueueSize, Capacity, Comparator } from './types'; -import { DynamicArray } from './dynamic-array'; -import { EmptyQueueError } from './errors'; - -export class BinaryMinHeap implements BinaryHeapOperations { - private storage: DynamicArray; - private compare: Comparator; - private _size: number; - - constructor(comparator: Comparator, initialCapacity?: Capacity) { - this.compare = comparator; - this.storage = new DynamicArray(initialCapacity); - this._size = 0; - } - - get size(): QueueSize { - return this._size as QueueSize; - } - - get isEmpty(): boolean { - return this._size === 0; - } - - get capacity(): Capacity { - return this.storage.capacity; - } - - insert(item: T): void { - this.storage.push(item); - this._size++; - - let currentIndex = this._size - 1; - - if (currentIndex > 0) { - let parentIndex = (currentIndex - 1) >>> 1; - - while ( - currentIndex > 0 && - this.compare(item, this.storage.get(parentIndex as HeapIndex)) < 0 - ) { - this.storage.set( - currentIndex as HeapIndex, - this.storage.get(parentIndex as HeapIndex) - ); - currentIndex = parentIndex; - parentIndex = (currentIndex - 1) >>> 1; - } - - this.storage.set(currentIndex as HeapIndex, item); - } - } - - extract(): T { - if (this._size === 0) { - throw new EmptyQueueError(); - } - - const root = this.storage.get(0 as HeapIndex); - - if (this._size === 1) { - this._size = 0; - this.storage.clear(); - return root; - } - - const lastItem = this.storage.pop(); - this._size--; - this.storage.set(0 as HeapIndex, lastItem); - - this.siftDown(0, lastItem); - - return root; - } - - peek(): T { - if (this._size === 0) { - throw new EmptyQueueError(); - } - return this.storage.get(0 as HeapIndex); - } - - clear(): void { - this.storage.clear(); - this._size = 0; - } - - ensureCapacity(capacity: Capacity): void { - this.storage.ensureCapacity(capacity); - } - - trimExcess(): void { - this.storage.trimToSize(); - } - - contains(item: T): boolean { - for (let i = 0; i < this._size; i++) { - if (this.storage.get(i as HeapIndex) === item) { - return true; - } - } - return false; - } - - toArray(): T[] { - return this.storage.slice(); - } - - private siftDown(startIndex: number, item: T): void { - let holeIndex = startIndex; - const halfSize = this._size >>> 1; - - while (holeIndex < halfSize) { - let childIndex = (holeIndex << 1) + 1; - const rightChildIndex = childIndex + 1; - - if (rightChildIndex < this._size) { - const leftChild = this.storage.get(childIndex as HeapIndex); - const rightChild = this.storage.get(rightChildIndex as HeapIndex); - - if (this.compare(rightChild, leftChild) < 0) { - childIndex = rightChildIndex; - } - } - - const child = this.storage.get(childIndex as HeapIndex); - - if (this.compare(item, child) <= 0) { - break; - } - - this.storage.set(holeIndex as HeapIndex, child); - holeIndex = childIndex; - } - - this.storage.set(holeIndex as HeapIndex, item); - } -} diff --git a/web/packages/utility/src/memory/containers/queue/dynamic-array.ts b/web/packages/utility/src/memory/containers/queue/dynamic-array.ts deleted file mode 100644 index 8c28b3a7..00000000 --- a/web/packages/utility/src/memory/containers/queue/dynamic-array.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { HeapIndex, QueueSize, Capacity, HeapStorage } from './types'; -import { EmptyQueueError, InvalidCapacityError } from './errors'; - -export class DynamicArray implements HeapStorage { - private buffer: T[]; - private _length: number; - private _capacity: number; - - constructor(initialCapacity?: Capacity) { - const capacity = initialCapacity != null ? (initialCapacity as number) : 16; - - if (capacity < 0) { - throw new InvalidCapacityError(capacity); - } - - this._capacity = capacity; - this._length = 0; - - this.buffer = new Array(capacity); - } - - get length(): QueueSize { - return this._length as QueueSize; - } - - get capacity(): Capacity { - return this._capacity as Capacity; - } - - get(index: HeapIndex): T { - return this.buffer[index as number]; - } - - set(index: HeapIndex, value: T): void { - this.buffer[index as number] = value; - } - - push(value: T): void { - if (this._length >= this._capacity) { - this.grow(); - } - - this.buffer[this._length] = value; - this._length++; - } - - pop(): T { - if (this._length === 0) { - throw new EmptyQueueError(); - } - - this._length--; - return this.buffer[this._length]; - } - - swap(i: HeapIndex, j: HeapIndex): void { - const buffer = this.buffer; - const iIndex = i as number; - const jIndex = j as number; - - const temp = buffer[iIndex]; - buffer[iIndex] = buffer[jIndex]; - buffer[jIndex] = temp; - } - - resize(newCapacity: Capacity): void { - const newCap = newCapacity as number; - - if (newCap < this._length) { - throw new InvalidCapacityError(newCap); - } - - const newBuffer = new Array(newCap); - - for (let i = 0; i < this._length; i++) { - newBuffer[i] = this.buffer[i]; - } - - this.buffer = newBuffer; - this._capacity = newCap; - } - - ensureCapacity(minCapacity: Capacity): void { - const minCap = minCapacity as number; - - if (minCap > this._capacity) { - const newCapacity = Math.max(minCap, this._capacity << 1); - this.resize(newCapacity as Capacity); - } - } - - trimToSize(): void { - if (this._length < this._capacity) { - const newCapacity = Math.max(1, this._length); - this.resize(newCapacity as Capacity); - } - } - - clear(): void { - this._length = 0; - } - - slice(): T[] { - return this.buffer.slice(0, this._length); - } - - private grow(): void { - const newCapacity = Math.max(this._capacity << 1, this._capacity + 1); - this.resize(newCapacity as Capacity); - } -} diff --git a/web/packages/utility/src/memory/containers/queue/index.ts b/web/packages/utility/src/memory/containers/queue/index.ts deleted file mode 100644 index e50054b9..00000000 --- a/web/packages/utility/src/memory/containers/queue/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './priority-queue'; diff --git a/web/packages/utility/src/memory/containers/queue/node.ts b/web/packages/utility/src/memory/containers/queue/node.ts deleted file mode 100644 index 36f0246e..00000000 --- a/web/packages/utility/src/memory/containers/queue/node.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class PriorityQueueNode { - constructor( - public element: TElement, - public priority: TPriority - ) {} -} diff --git a/web/packages/utility/src/memory/containers/queue/priority-queue.ts b/web/packages/utility/src/memory/containers/queue/priority-queue.ts deleted file mode 100644 index 133d2cfc..00000000 --- a/web/packages/utility/src/memory/containers/queue/priority-queue.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { - Comparator, - HeapIndex, - QueueSize, - Capacity, - ReadonlyQueueNode, - QueueNode, - PriorityQueueOptions, - PriorityQueueCore, - OptionalOperations, - QueryOperations, - CapacityOperations, -} from './types'; - -import { EmptyQueueError, InvalidCapacityError, QueueError } from './errors'; - -import { - createHeapIndex, - createQueueSize, - createCapacity, - defaultComparator, - getParentIndex, - getLeftChildIndex, - getRightChildIndex, - hasParent, - hasLeftChild, - hasRightChild, -} from './utils'; - -import { DynamicArray } from './dynamic-array'; -import { BinaryMinHeap } from './binary-heap'; -import { PriorityQueueNode } from './node'; - -export class PriorityQueue - implements - PriorityQueueCore, - OptionalOperations, - QueryOperations, - CapacityOperations, - Iterable -{ - private heap: BinaryMinHeap>; - private comparator: Comparator; - private autoTrimEnabled: boolean; - - constructor(options?: PriorityQueueOptions) { - this.comparator = options?.comparator ?? (defaultComparator as Comparator); - this.autoTrimEnabled = options?.autoTrim ?? false; - - const nodeComparator: Comparator> = (a, b) => - this.comparator(a.priority, b.priority); - - this.heap = new BinaryMinHeap(nodeComparator, options?.initialCapacity); - } - - get size(): QueueSize { - return this.heap.size; - } - - get isEmpty(): boolean { - return this.heap.isEmpty; - } - - get capacity(): Capacity { - return this.heap.capacity; - } - - enqueue(element: TElement, priority: TPriority): void { - const node = new PriorityQueueNode(element, priority); - this.heap.insert(node); - } - - dequeue(): TElement { - const node = this.heap.extract(); - - if (this.autoTrimEnabled && this.shouldAutoTrim()) { - this.trimExcess(); - } - - return node.element; - } - - peek(): TElement { - const node = this.heap.peek(); - return node.element; - } - - tryDequeue(): TElement | undefined { - if (this.isEmpty) { - return undefined; - } - return this.dequeue(); - } - - tryPeek(): TElement | undefined { - if (this.isEmpty) { - return undefined; - } - return this.peek(); - } - - dequeueAll(): TElement[] { - const result: TElement[] = []; - while (!this.isEmpty) { - result.push(this.dequeue()); - } - return result; - } - - enqueueRange(items: ReadonlyArray>): void { - if (items.length === 0) return; - - this.ensureCapacity(createCapacity((this.size as unknown as number) + items.length)); - - for (const item of items) { - this.enqueue(item.element, item.priority); - } - } - - contains(element: TElement): boolean { - return this.heap.toArray().some((node) => node.element === element); - } - - clear(): void { - this.heap.clear(); - } - - ensureCapacity(capacity: Capacity): void { - this.heap.ensureCapacity(capacity); - } - - trimExcess(): void { - this.heap.trimExcess(); - } - - toArray(): ReadonlyArray { - return Object.freeze(this.heap.toArray().map((node) => node.element)); - } - - clone(): PriorityQueue { - const cloned = new PriorityQueue({ - comparator: this.comparator, - initialCapacity: this.capacity, - autoTrim: this.autoTrimEnabled, - }); - - const nodes = this.heap.toArray(); - const nodeItems = nodes.map((node) => ({ - element: node.element, - priority: node.priority, - })); - - cloned.enqueueRange(nodeItems); - return cloned; - } - - *[Symbol.iterator](): Iterator { - const clone = this.clone(); - - while (!clone.isEmpty) { - yield clone.dequeue(); - } - } - - static from( - items: Iterable>, - options?: PriorityQueueOptions

    - ): PriorityQueue { - const queue = new PriorityQueue(options); - const itemArray = Array.isArray(items) ? items : Array.from(items); - queue.enqueueRange(itemArray); - return queue; - } - - static withComparator( - comparator: Comparator

    , - initialCapacity?: Capacity - ): PriorityQueue { - return new PriorityQueue({ - comparator, - initialCapacity, - }); - } - - static minQueue(initialCapacity?: Capacity): PriorityQueue { - return new PriorityQueue({ - comparator: defaultComparator as Comparator

    , - initialCapacity, - }); - } - - static maxQueue(initialCapacity?: Capacity): PriorityQueue { - const maxComparator: Comparator

    = (a, b) => (defaultComparator as Comparator

    )(b, a); - - return new PriorityQueue({ - comparator: maxComparator, - initialCapacity, - }); - } - - private shouldAutoTrim(): boolean { - return ( - (this.capacity as unknown as number) > 32 && - (this.size as unknown as number) < (this.capacity as unknown as number) / 4 - ); - } -} - -export type { - Comparator, - HeapIndex, - QueueSize, - Capacity, - ReadonlyQueueNode, - QueueNode, - PriorityQueueOptions, - PriorityQueueCore, - OptionalOperations, - QueryOperations, - CapacityOperations, -}; - -export { - QueueError, - EmptyQueueError, - InvalidCapacityError, - PriorityQueueNode, - createHeapIndex, - createQueueSize, - createCapacity, - defaultComparator, -}; diff --git a/web/packages/utility/src/memory/containers/queue/types.ts b/web/packages/utility/src/memory/containers/queue/types.ts deleted file mode 100644 index 446f7a9a..00000000 --- a/web/packages/utility/src/memory/containers/queue/types.ts +++ /dev/null @@ -1,109 +0,0 @@ -declare const __nominal: unique symbol; - -export type Nominal = T & { readonly [__nominal]: K }; - -export type Comparator = (a: T, b: T) => number; - -export type HeapIndex = Nominal; -export type QueueSize = Nominal; -export type Capacity = Nominal; - -export type Priority = Nominal; - -export interface ReadonlyQueueNode { - readonly element: TElement; - readonly priority: TPriority; -} - -export interface QueueNode extends ReadonlyQueueNode { - element: TElement; - priority: TPriority; -} - -export interface HeapStorage { - readonly length: QueueSize; - readonly capacity: Capacity; - - get(index: HeapIndex): T; - set(index: HeapIndex, value: T): void; - - resize(newCapacity: Capacity): void; - clear(): void; - - swap?(i: HeapIndex, j: HeapIndex): void; - ensureCapacity?(minCapacity: Capacity): void; - trimToSize?(): void; -} - -export interface BinaryHeapOperations { - insert(item: T): void; - extract(): T; - - peek(): T; - readonly size: QueueSize; - readonly isEmpty: boolean; - - clear(): void; -} - -export interface PriorityQueueCore { - enqueue(element: TElement, priority: TPriority): void; - dequeue(): TElement; - peek(): TElement; - clear(): void; - - readonly size: QueueSize; - readonly isEmpty: boolean; -} - -export interface OptionalOperations { - tryDequeue(): TElement | undefined; - tryPeek(): TElement | undefined; - - dequeueAll(): TElement[]; - enqueueRange(items: ReadonlyArray>): void; - - dequeueWhere?(predicate: (element: TElement, priority: TPriority) => boolean): TElement[]; - removeWhere?(predicate: (element: TElement, priority: TPriority) => boolean): number; -} - -export interface QueryOperations { - contains(element: TElement): boolean; - toArray(): ReadonlyArray; - - find?(predicate: (element: TElement) => boolean): TElement | undefined; - filter?(predicate: (element: TElement) => boolean): ReadonlyArray; - count?(predicate?: (element: TElement) => boolean): number; -} - -export interface CapacityOperations { - ensureCapacity(capacity: Capacity): void; - trimExcess(): void; - readonly capacity: Capacity; - - reserveCapacity?(additionalCapacity: Capacity): void; - shrinkToFit?(): void; - getMemoryUsage?(): { used: number; allocated: number; overhead: number }; -} - -export type PriorityQueueOptions = { - readonly comparator?: Comparator; - readonly initialCapacity?: Capacity; - readonly autoTrim?: boolean; - readonly growthFactor?: number; - readonly shrinkThreshold?: number; -}; - -export type AdvancedQueueOptions = PriorityQueueOptions & { - readonly customAllocator?: HeapStorage>; - readonly maxSize?: QueueSize; - readonly strictCapacity?: boolean; -}; - -export interface QueueIterator { - [Symbol.iterator](): Iterator; - - entries(): Iterator<[TElement, TPriority]>; - priorities(): Iterator; - nodes(): Iterator>; -} diff --git a/web/packages/utility/src/memory/index.ts b/web/packages/utility/src/memory/index.ts deleted file mode 100644 index b33f7892..00000000 --- a/web/packages/utility/src/memory/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './pool'; -export * from './containers/queue/priority-queue'; diff --git a/web/packages/utility/src/object.ts b/web/packages/utility/src/object.ts new file mode 100644 index 00000000..c13cf7a6 --- /dev/null +++ b/web/packages/utility/src/object.ts @@ -0,0 +1,11 @@ +export const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && Array.isArray(value) === false; + +export const isPlainObject = (value: unknown): value is Record => { + if (!isRecord(value)) { + return false; + } + + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; \ No newline at end of file diff --git a/web/packages/utility/src/types.ts b/web/packages/utility/src/types.ts index 7d822819..71755bbd 100644 --- a/web/packages/utility/src/types.ts +++ b/web/packages/utility/src/types.ts @@ -1,5 +1,37 @@ export type Primitive = undefined | null | boolean | number | string | bigint | symbol; +export type JsonPrimitive = string | number | boolean | null; + +export interface JsonObject { + readonly [key: string]: JsonValue; +} + +export interface JsonArray extends ReadonlyArray {} + +export type JsonValue = JsonPrimitive | JsonObject | JsonArray; + +export type Brand = TValue & { readonly __brand: TBrand }; +export type Nominal = TValue & { + readonly __nominal: TName; +}; + +declare const __opaqueBrand: unique symbol; +export type Opaque = TValue & { + readonly [__opaqueBrand]: TBrand; +}; + +export type MaybePromise = TValue | Promise; +export type Constructor = new ( + ...args: TArgs +) => TValue; +export type AbstractConstructor = abstract new ( + ...args: TArgs +) => TValue; + +export type ArrayElement = TValue extends readonly (infer TElement)[] ? TElement : never; +export type NonEmptyReadonlyArray = readonly [TValue, ...TValue[]]; +export type Mutable = { -readonly [TKey in keyof TValue]: TValue[TKey] }; + export type TypedArray = | Int8Array | Uint8Array @@ -13,6 +45,8 @@ export type TypedArray = | BigInt64Array | BigUint64Array; +export type NumericTypedArray = Exclude; + export type TypedArrayConstructor = | Int8ArrayConstructor | Uint8ArrayConstructor @@ -26,6 +60,15 @@ export type TypedArrayConstructor = | BigInt64ArrayConstructor | BigUint64ArrayConstructor; +export type NumericTypedArrayConstructor = Exclude< + TypedArrayConstructor, + BigInt64ArrayConstructor | BigUint64ArrayConstructor +>; + +export type ReadonlyTuple2 = readonly [TValue, TValue]; +export type ReadonlyTuple3 = readonly [TValue, TValue, TValue]; +export type ReadonlyTuple4 = readonly [TValue, TValue, TValue, TValue]; + export type Builtin = Primitive | BuiltinObject; export type BuiltinObject = @@ -67,14 +110,38 @@ export type BuiltinObject = | WebAssembly.LinkError | WebAssembly.RuntimeError; -type DeepReadonly = T extends Primitive +export type DeepReadonly = T extends (...args: never[]) => unknown + ? T + : T extends Primitive + ? T + : T extends readonly (infer U)[] + ? readonly DeepReadonly[] + : T extends Map + ? ReadonlyMap, DeepReadonly> + : T extends Set + ? ReadonlySet> + : T extends Record + ? { readonly [K in keyof T]: DeepReadonly } + : T; + +export type DeepReadonlyPartial = TValue extends readonly (infer TElement)[] + ? readonly DeepReadonlyPartial[] + : TValue extends (...args: never[]) => unknown + ? TValue + : TValue extends object + ? { readonly [TKey in keyof TValue]?: DeepReadonlyPartial } + : TValue; + +export type DeepMutable = T extends (...args: never[]) => unknown ? T - : T extends (infer U)[] - ? readonly DeepReadonly[] - : T extends Map - ? ReadonlyMap, DeepReadonly> - : T extends Set - ? ReadonlySet> - : T extends Record - ? { readonly [K in keyof T]: DeepReadonly } - : T; + : T extends Primitive + ? T + : T extends readonly (infer U)[] + ? DeepMutable[] + : T extends ReadonlyMap + ? Map, DeepMutable> + : T extends ReadonlySet + ? Set> + : T extends Record + ? { -readonly [K in keyof T]: DeepMutable } + : T; diff --git a/web/scripts/duplicate-governance.mjs b/web/scripts/duplicate-governance.mjs new file mode 100644 index 00000000..19f69c0d --- /dev/null +++ b/web/scripts/duplicate-governance.mjs @@ -0,0 +1,249 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const workspaceDir = path.resolve(scriptDir, '..'); +const reportDir = path.resolve(workspaceDir, '.tmp', 'duplicate-governance'); +const reportFilePath = path.resolve(reportDir, 'jscpd-report.json'); +const jscpdEntryPath = path.resolve(workspaceDir, 'node_modules', 'jscpd', 'bin', 'jscpd'); +const jscpdIgnoreGlobs = [ + '**/dist/**', + '**/__tests__/**', + '**/*.d.ts', + '**/*.test.ts', + '**/*.spec.ts', +]; +const approvedCrossPackageDebt = [ + { + files: [ + 'packages/asset-gltf/src/value-serialization.ts', + 'packages/scene-runtime/src/serialization.ts', + ], + maxLines: 52, + reason: 'Pending extraction of shared scene/gltf serialized value encoding contracts.', + }, + { + files: [ + 'packages/asset-gltf/src/asset-ir.ts', + 'packages/scene-runtime/src/types.ts', + ], + maxLines: 40, + reason: 'Pending extraction of shared scene/gltf texture source and binding contracts.', + }, +]; + +const normalizePath = (filePath) => filePath.replace(/\\/g, '/'); + +const createFilePairKey = (firstFile, secondFile) => + [normalizePath(firstFile), normalizePath(secondFile)].sort((left, right) => left.localeCompare(right)).join(' :: '); + +const approvedCrossPackageDebtByPair = new Map( + approvedCrossPackageDebt.map((entry) => [createFilePairKey(entry.files[0], entry.files[1]), entry]) +); + +const getPackageName = (filePath) => normalizePath(filePath).split('/')[1] ?? 'unknown'; + +const groupBy = (items, createKey) => { + const groups = new Map(); + + for (const item of items) { + const key = createKey(item); + const existing = groups.get(key); + if (existing) { + existing.push(item); + continue; + } + + groups.set(key, [item]); + } + + return groups; +}; + +const formatPercent = (value) => `${Number(value).toFixed(2)}%`; + +const formatRange = (item, side) => + `${item[`${side}File`]}:${item[`${side}StartLine`]}-${item[`${side}EndLine`]}`; + +const toDuplicateRecord = (duplicate) => { + const firstFile = normalizePath(duplicate.firstFile.name); + const secondFile = normalizePath(duplicate.secondFile.name); + + return { + lines: Number(duplicate.lines), + firstFile, + secondFile, + firstStartLine: Number(duplicate.firstFile.startLoc.line), + firstEndLine: Number(duplicate.firstFile.endLoc.line), + secondStartLine: Number(duplicate.secondFile.startLoc.line), + secondEndLine: Number(duplicate.secondFile.endLoc.line), + firstPackage: getPackageName(firstFile), + secondPackage: getPackageName(secondFile), + filePairKey: createFilePairKey(firstFile, secondFile), + }; +}; + +const summarizeDuplicatePair = (items) => { + const lines = items.reduce((total, item) => total + item.lines, 0); + const exemplar = items[0]; + + return { + lines, + count: items.length, + firstFile: exemplar.firstFile, + secondFile: exemplar.secondFile, + firstPackage: exemplar.firstPackage, + secondPackage: exemplar.secondPackage, + filePairKey: exemplar.filePairKey, + ranges: items + .map((item) => ({ + first: formatRange(item, 'first'), + second: formatRange(item, 'second'), + lines: item.lines, + })) + .sort((left, right) => right.lines - left.lines), + }; +}; + +const printDuplicateGroup = (heading, groups, decorateSummary) => { + if (groups.length === 0) { + console.log(`\n${heading}`); + console.log('None'); + return; + } + + console.log(`\n${heading}`); + for (const group of groups) { + const summary = decorateSummary(group); + console.log(`- ${summary}`); + for (const range of group.ranges) { + console.log(` ${range.lines} lines | ${range.first} <-> ${range.second}`); + } + } +}; + +const runJscpd = () => { + if (!fs.existsSync(jscpdEntryPath)) { + throw new Error( + 'Missing local jscpd binary. Run "npm install" from Axrone/web to install duplicate-governance dependencies.' + ); + } + + fs.rmSync(reportDir, { recursive: true, force: true }); + fs.mkdirSync(reportDir, { recursive: true }); + + const result = spawnSync( + process.execPath, + [ + jscpdEntryPath, + '--silent', + '--min-lines', + '20', + '--min-tokens', + '120', + '--format', + 'typescript', + '--ignore', + jscpdIgnoreGlobs.join(','), + '--reporters', + 'json', + '--output', + reportDir, + 'packages', + ], + { + cwd: workspaceDir, + encoding: 'utf8', + } + ); + + if (result.error || result.status !== 0) { + const errorOutput = + result.error?.message || + result.stderr?.trim() || + result.stdout?.trim() || + 'Unknown jscpd failure'; + throw new Error(`jscpd scan failed: ${errorOutput}`); + } + + if (!fs.existsSync(reportFilePath)) { + throw new Error(`Expected jscpd report at ${reportFilePath}, but no report was generated.`); + } + + return JSON.parse(fs.readFileSync(reportFilePath, 'utf8')); +}; + +const report = runJscpd(); +const summary = report.statistics.formats.typescript.total; +const duplicates = report.duplicates.map(toDuplicateRecord).sort((left, right) => right.lines - left.lines); +const crossPackageDuplicates = duplicates.filter((duplicate) => duplicate.firstPackage !== duplicate.secondPackage); +const crossPackageGroups = [...groupBy(crossPackageDuplicates, (duplicate) => duplicate.filePairKey).values()] + .map(summarizeDuplicatePair) + .map((group) => { + const allowance = approvedCrossPackageDebtByPair.get(group.filePairKey); + + return { + ...group, + allowance, + isApproved: Boolean(allowance) && group.lines <= allowance.maxLines, + }; + }) + .sort((left, right) => right.lines - left.lines || left.filePairKey.localeCompare(right.filePairKey)); + +const samePackageCrossFileGroups = [ + ...groupBy( + duplicates.filter( + (duplicate) => + duplicate.firstPackage === duplicate.secondPackage && duplicate.firstFile !== duplicate.secondFile + ), + (duplicate) => `${duplicate.firstPackage} :: ${duplicate.filePairKey}` + ).values(), +] + .map(summarizeDuplicatePair) + .sort((left, right) => right.lines - left.lines || left.filePairKey.localeCompare(right.filePairKey)); + +const unexpectedCrossPackageGroups = crossPackageGroups.filter( + (group) => !group.allowance || group.lines > group.allowance.maxLines +); +const resolvedApprovedDebt = approvedCrossPackageDebt.filter( + (entry) => !crossPackageGroups.some((group) => group.filePairKey === createFilePairKey(entry.files[0], entry.files[1])) +); + +console.log('Duplicate governance report'); +console.log( + `Sources: ${summary.sources} Exact clones: ${summary.clones} Duplicated lines: ${summary.duplicatedLines} (${formatPercent(summary.percentage)})` +); + +printDuplicateGroup( + 'Approved cross-package duplicate debt', + crossPackageGroups.filter((group) => group.isApproved), + (group) => + `${group.firstPackage} <-> ${group.secondPackage} | ${group.lines} lines across ${group.count} clone blocks | ${group.allowance.reason}` +); + +if (resolvedApprovedDebt.length > 0) { + console.log('\nResolved approved duplicate debt'); + for (const entry of resolvedApprovedDebt) { + console.log(`- ${entry.files[0]} <-> ${entry.files[1]}`); + } +} + +printDuplicateGroup( + 'Highest same-package cross-file hotspots', + samePackageCrossFileGroups.slice(0, 8), + (group) => `${group.firstPackage} | ${group.lines} duplicated lines across ${group.count} clone blocks` +); + +if (unexpectedCrossPackageGroups.length > 0) { + printDuplicateGroup( + 'Unexpected cross-package duplicate violations', + unexpectedCrossPackageGroups, + (group) => `${group.firstPackage} <-> ${group.secondPackage} | ${group.lines} duplicated lines across ${group.count} clone blocks` + ); + process.exit(1); +} + +console.log('\nDuplicate governance checks satisfied.'); \ No newline at end of file diff --git a/web/scripts/engine-benchmark-governance.mjs b/web/scripts/engine-benchmark-governance.mjs new file mode 100644 index 00000000..a1d95596 --- /dev/null +++ b/web/scripts/engine-benchmark-governance.mjs @@ -0,0 +1,345 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { parseArgs } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const workspaceDir = path.resolve(scriptDir, '..'); +const benchmarkRunnerPath = path.resolve(scriptDir, 'engine-benchmark-runner.mjs'); +const defaultReportPath = path.resolve( + workspaceDir, + '.tmp', + 'benchmarks', + 'engine-benchmark-report.json', +); + +const defaultRunnerOptions = { + iterations: '3', + warmup: '1', + durationSec: '5', + objectCounts: '19600', + comparisonModes: 'no-culling', + workloads: 'draw-call,triangle,mixed', +}; + +const scenarioBudgets = [ + { + workload: 'draw-call', + comparisonMode: 'no-culling', + objectCount: 19600, + minRunCount: 3, + minAverageFpsMean: 3.5, + maxSetupBuildMedianMs: 190, + maxFirstRenderMedianMs: 120, + maxSetupMedianMs: 290, + maxComponentInstantiateMedianMs: 55, + maxActorCreateMedianMs: 95, + maxRenderableCreateMedianMs: 160, + maxSceneSetupMedianMs: 10, + requireFpsLeader: true, + requireFirstRenderLeader: true, + }, + { + workload: 'triangle', + comparisonMode: 'no-culling', + objectCount: 19600, + minRunCount: 3, + minAverageFpsMean: 3.5, + maxSetupBuildMedianMs: 190, + maxFirstRenderMedianMs: 110, + maxSetupMedianMs: 290, + maxComponentInstantiateMedianMs: 52, + maxActorCreateMedianMs: 95, + maxRenderableCreateMedianMs: 160, + maxSceneSetupMedianMs: 12, + requireFpsLeader: true, + requireFirstRenderLeader: true, + }, + { + workload: 'mixed', + comparisonMode: 'no-culling', + objectCount: 19600, + minRunCount: 3, + minAverageFpsMean: 3.5, + maxSetupBuildMedianMs: 210, + maxFirstRenderMedianMs: 100, + maxSetupMedianMs: 310, + maxComponentInstantiateMedianMs: 52, + maxActorCreateMedianMs: 90, + maxRenderableCreateMedianMs: 155, + maxSceneSetupMedianMs: 12, + requireFpsLeader: true, + requireFirstRenderLeader: true, + }, +]; + +const fail = (message) => { + throw new Error(message); +}; + +const scenarioKey = ({ workload, comparisonMode, objectCount }) => + `${workload}|${comparisonMode}|${objectCount}`; + +const scenarioLabel = ({ workload, comparisonMode, objectCount }) => + `${workload}/${comparisonMode}/${objectCount.toLocaleString('en-US')}`; + +const formatNumber = (value) => value.toFixed(2).padStart(8, ' '); + +const getMetricSummary = (source, metricPath) => { + const result = metricPath.split('.').reduce((current, segment) => current?.[segment], source); + + if (!result || typeof result !== 'object') { + fail(`Missing metric summary at "${metricPath}".`); + } + + return result; +}; + +const pushMedianBudgetFailure = (failures, label, metricLabel, metricSummary, budgetValue) => { + if (typeof budgetValue !== 'number' || !Number.isFinite(budgetValue)) { + return; + } + + if (metricSummary.median > budgetValue) { + failures.push( + `${label} ${metricLabel} median ${metricSummary.median.toFixed(2)} ms exceeds budget ${budgetValue.toFixed(2)} ms.`, + ); + } +}; + +const runBenchmarkRefresh = (reportPath, options) => { + const runnerArgs = [ + benchmarkRunnerPath, + `--iterations=${options.iterations ?? defaultRunnerOptions.iterations}`, + `--warmup=${options.warmup ?? defaultRunnerOptions.warmup}`, + `--durationSec=${options.durationSec ?? defaultRunnerOptions.durationSec}`, + `--objectCounts=${options.objectCounts ?? defaultRunnerOptions.objectCounts}`, + `--comparisonModes=${options.comparisonModes ?? defaultRunnerOptions.comparisonModes}`, + `--workloads=${options.workloads ?? defaultRunnerOptions.workloads}`, + '--isolateRuns', + `--output=${reportPath}`, + ]; + + if (options.headless) { + runnerArgs.push('--headless'); + } + + console.log('Refreshing engine benchmark report...'); + const result = spawnSync(process.execPath, runnerArgs, { + cwd: workspaceDir, + stdio: 'inherit', + }); + + if (result.status !== 0) { + fail(`Engine benchmark refresh failed with exit code ${result.status ?? 1}.`); + } +}; + +const { values: cli } = parseArgs({ + options: { + report: { type: 'string' }, + refresh: { type: 'boolean' }, + strictStability: { type: 'boolean' }, + headless: { type: 'boolean' }, + iterations: { type: 'string' }, + warmup: { type: 'string' }, + durationSec: { type: 'string' }, + objectCounts: { type: 'string' }, + workloads: { type: 'string' }, + comparisonModes: { type: 'string' }, + }, + strict: true, + allowPositionals: false, +}); + +const reportPath = path.resolve(workspaceDir, cli.report ?? defaultReportPath); + +if (cli.refresh) { + runBenchmarkRefresh(reportPath, cli); +} + +if (!fs.existsSync(reportPath)) { + fail( + `Benchmark report not found at ${reportPath}. Run this script with --refresh or generate a report with npm run bench:engine:startup.`, + ); +} + +const report = JSON.parse(fs.readFileSync(reportPath, 'utf8')); +const scenarioReports = new Map( + (report.scenarios ?? []).map((scenarioReport) => [scenarioKey(scenarioReport.scenario), scenarioReport]), +); + +const reportRows = []; +const failures = []; +const stabilityWarnings = []; + +for (const budget of scenarioBudgets) { + const label = scenarioLabel(budget); + const scenarioReport = scenarioReports.get(scenarioKey(budget)); + + if (!scenarioReport) { + failures.push( + `${label} is missing from ${reportPath}. Refresh the report with the startup benchmark scenario set before evaluating governance.`, + ); + continue; + } + + if ((scenarioReport.summary?.runCount ?? 0) < budget.minRunCount) { + failures.push( + `${label} only has ${scenarioReport.summary?.runCount ?? 0} measured run(s); expected at least ${budget.minRunCount}.`, + ); + } + + const axrone = scenarioReport.summary?.engines?.axrone; + const deltas = scenarioReport.summary?.deltas; + if (!axrone || !deltas) { + failures.push(`${label} is missing Axrone summary data.`); + continue; + } + + const buildMetric = getMetricSummary(axrone, 'setupBuildTimeMs'); + const firstRenderMetric = getMetricSummary(axrone, 'firstRenderTimeMs'); + const setupMetric = getMetricSummary(axrone, 'setupTimeMs'); + const fpsMetric = getMetricSummary(axrone, 'averageFps'); + const componentInstantiateMetric = getMetricSummary(axrone, 'buildPhases.componentInstantiateMs'); + const actorCreateMetric = getMetricSummary(axrone, 'buildPhases.actorCreateMs'); + const renderableCreateMetric = getMetricSummary(axrone, 'buildPhases.renderableCreateMs'); + const sceneSetupMetric = getMetricSummary(axrone, 'buildPhases.sceneSetupMs'); + + reportRows.push({ + label, + buildMedianMs: buildMetric.median, + firstRenderMedianMs: firstRenderMetric.median, + setupMedianMs: setupMetric.median, + componentInstantiateMedianMs: componentInstantiateMetric.median, + sceneSetupMedianMs: sceneSetupMetric.median, + warningCount: (scenarioReport.summary?.qualityFlags ?? []).filter( + (warning) => warning.engine === 'axrone', + ).length, + }); + + if (fpsMetric.mean < budget.minAverageFpsMean) { + failures.push( + `${label} average FPS mean ${fpsMetric.mean.toFixed(2)} is below budget ${budget.minAverageFpsMean.toFixed(2)}.`, + ); + } + + pushMedianBudgetFailure(failures, label, 'setupBuildTimeMs', buildMetric, budget.maxSetupBuildMedianMs); + pushMedianBudgetFailure(failures, label, 'firstRenderTimeMs', firstRenderMetric, budget.maxFirstRenderMedianMs); + pushMedianBudgetFailure(failures, label, 'setupTimeMs', setupMetric, budget.maxSetupMedianMs); + pushMedianBudgetFailure( + failures, + label, + 'buildPhases.componentInstantiateMs', + componentInstantiateMetric, + budget.maxComponentInstantiateMedianMs, + ); + pushMedianBudgetFailure( + failures, + label, + 'buildPhases.actorCreateMs', + actorCreateMetric, + budget.maxActorCreateMedianMs, + ); + pushMedianBudgetFailure( + failures, + label, + 'buildPhases.renderableCreateMs', + renderableCreateMetric, + budget.maxRenderableCreateMedianMs, + ); + pushMedianBudgetFailure( + failures, + label, + 'buildPhases.sceneSetupMs', + sceneSetupMetric, + budget.maxSceneSetupMedianMs, + ); + + if (budget.requireFpsLeader && deltas.averageFps?.leader !== 'axrone') { + failures.push(`${label} lost the FPS leadership check against Three.js.`); + } + + if (budget.requireFirstRenderLeader && deltas.firstRenderTimeMs?.leader !== 'axrone') { + failures.push(`${label} lost the first-render leadership check against Three.js.`); + } + + const axroneWarnings = (scenarioReport.summary?.qualityFlags ?? []).filter( + (warning) => warning.engine === 'axrone', + ); + if (axroneWarnings.length > 0) { + stabilityWarnings.push({ + label, + warnings: axroneWarnings, + }); + + if (cli.strictStability) { + failures.push( + `${label} produced ${axroneWarnings.length} Axrone stability warning(s) under --strictStability.`, + ); + } + } +} + +console.log('Engine benchmark governance report'); +console.log( + 'Scenario'.padEnd(30) + + 'Build'.padStart(8) + + ' ' + + 'First'.padStart(8) + + ' ' + + 'Setup'.padStart(8) + + ' ' + + 'Comp'.padStart(8) + + ' ' + + 'Scene'.padStart(8) + + ' ' + + 'Warn'.padStart(6), +); +for (const row of reportRows) { + console.log( + row.label.padEnd(30) + + formatNumber(row.buildMedianMs) + + ' ' + + formatNumber(row.firstRenderMedianMs) + + ' ' + + formatNumber(row.setupMedianMs) + + ' ' + + formatNumber(row.componentInstantiateMedianMs) + + ' ' + + formatNumber(row.sceneSetupMedianMs) + + ' ' + + String(row.warningCount).padStart(6), + ); +} + +if (stabilityWarnings.length > 0) { + console.warn('\nAxrone stability warnings'); + for (const warningGroup of stabilityWarnings) { + console.warn( + `- ${warningGroup.label}: ${warningGroup.warnings + .map( + (warning) => + `${warning.metric} cv=${warning.coefficientOfVariationPct.toFixed(2)}% ratio=${warning.maxOverMedianRatio.toFixed(2)}`, + ) + .join(' | ')}`, + ); + } + + if (!cli.strictStability) { + console.warn('Stability warnings were surfaced but did not fail governance because --strictStability was not set.'); + } +} + +if (failures.length > 0) { + console.error('\nEngine benchmark governance violations'); + for (const failure of failures) { + console.error(`- ${failure}`); + } + + process.exit(1); +} + +console.log('\nEngine benchmark budgets satisfied.'); \ No newline at end of file diff --git a/web/scripts/engine-benchmark-runner.mjs b/web/scripts/engine-benchmark-runner.mjs new file mode 100644 index 00000000..bd2da974 --- /dev/null +++ b/web/scripts/engine-benchmark-runner.mjs @@ -0,0 +1,693 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { parseArgs } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; + +const VALID_WORKLOADS = ['draw-call', 'triangle', 'mixed']; +const VALID_COMPARISON_MODES = ['no-culling', 'three-culling']; +const DEFAULT_OBJECT_COUNTS = [2600, 19600]; +const DEFAULT_VIEWPORT = { width: 1600, height: 900 }; +const DEFAULT_TIMEOUT_MS = 120000; +const MIN_UNSTABLE_METRIC_MEDIAN_MS = 5; +const MIN_UNSTABLE_METRIC_SPAN_MS = 10; + +const metricNames = [ + 'averageFps', + 'p95FrameTimeMs', + 'frameCount', + 'drawCalls', + 'triangles', + 'setupBuildTimeMs', + 'firstRenderTimeMs', + 'setupTimeMs', +]; + +const summarizePhaseMetrics = (runs, engineName) => { + const phaseNames = new Set( + runs.flatMap((run) => Object.keys(run.engines[engineName].buildPhases ?? {})) + ); + + return Object.fromEntries( + [...phaseNames] + .sort((left, right) => left.localeCompare(right)) + .map((phaseName) => [ + phaseName, + summarizeMetric( + runs.map((run) => run.engines[engineName].buildPhases?.[phaseName] ?? 0) + ), + ]) + ); +}; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const workspaceDir = path.resolve(scriptDir, '..'); +const viteBinPath = path.resolve(workspaceDir, 'node_modules', 'vite', 'bin', 'vite.js'); + +const fail = (message) => { + throw new Error(message); +}; + +const round = (value, digits = 2) => Number(value.toFixed(digits)); + +const mean = (values) => + values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length; + +const percentile = (values, ratio) => { + if (values.length === 0) { + return 0; + } + + const sorted = [...values].sort((left, right) => left - right); + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1)); + return sorted[index]; +}; + +const standardDeviation = (values) => { + if (values.length <= 1) { + return 0; + } + + const average = mean(values); + const variance = + values.reduce((sum, value) => sum + (value - average) * (value - average), 0) / + values.length; + return Math.sqrt(variance); +}; + +const parseInteger = (value, label) => { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + fail(`Invalid ${label}: ${value}`); + } + return parsed; +}; + +const parseNumberList = (value, label) => + value + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => parseInteger(item, label)); + +const parseEnumList = (value, label, allowedValues) => { + const values = value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + + for (const entry of values) { + if (!allowedValues.includes(entry)) { + fail(`Invalid ${label}: ${entry}. Allowed values: ${allowedValues.join(', ')}`); + } + } + + return values; +}; + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const formatMetric = (value, suffix) => `${value.toFixed(2)}${suffix}`; + +const printHelp = () => { + console.log(`Axrone engine benchmark runner + +Options: + --iterations=5 Measured repetitions per scenario + --warmup=1 Warmup runs discarded before measurement + --durationSec=15 Benchmark duration per run in seconds + --objectCounts=2600,19600 Object counts to measure + --workloads=draw-call,triangle,mixed + --comparisonModes=no-culling,three-culling + --host=127.0.0.1 Local examples server host + --port=4173 Local examples server port + --url=http://... Reuse an existing benchmark page instead of starting Vite + --headless Run Chromium headless + --reuseBrowser Reuse one browser across scenarios instead of isolating each scenario + --isolateRuns Launch a fresh browser per warmup/measured run + --keepServer Leave the spawned Vite server running after completion + --output=.tmp/benchmarks/engine-benchmark-report.json + --help Show this help +`); +}; + +const { values: cli } = parseArgs({ + options: { + iterations: { type: 'string' }, + warmup: { type: 'string' }, + durationSec: { type: 'string' }, + objectCounts: { type: 'string' }, + workloads: { type: 'string' }, + comparisonModes: { type: 'string' }, + host: { type: 'string' }, + port: { type: 'string' }, + url: { type: 'string' }, + output: { type: 'string' }, + headless: { type: 'boolean' }, + reuseBrowser: { type: 'boolean' }, + isolateRuns: { type: 'boolean' }, + keepServer: { type: 'boolean' }, + help: { type: 'boolean' }, + }, + strict: true, + allowPositionals: false, +}); + +if (cli.help) { + printHelp(); + process.exit(0); +} + +const options = { + iterations: parseInteger(cli.iterations ?? '5', 'iterations'), + warmup: parseInteger(cli.warmup ?? '1', 'warmup'), + durationSec: parseInteger(cli.durationSec ?? '15', 'durationSec'), + objectCounts: cli.objectCounts + ? parseNumberList(cli.objectCounts, 'objectCounts') + : DEFAULT_OBJECT_COUNTS, + workloads: cli.workloads + ? parseEnumList(cli.workloads, 'workloads', VALID_WORKLOADS) + : VALID_WORKLOADS, + comparisonModes: cli.comparisonModes + ? parseEnumList(cli.comparisonModes, 'comparisonModes', VALID_COMPARISON_MODES) + : VALID_COMPARISON_MODES, + host: cli.host ?? '127.0.0.1', + port: parseInteger(cli.port ?? '4173', 'port'), + url: cli.url ?? null, + output: path.resolve(workspaceDir, cli.output ?? '.tmp/benchmarks/engine-benchmark-report.json'), + headless: Boolean(cli.headless), + reuseBrowser: Boolean(cli.reuseBrowser), + isolateRuns: Boolean(cli.isolateRuns), + keepServer: Boolean(cli.keepServer), +}; + +if (options.reuseBrowser && options.isolateRuns) { + fail('reuseBrowser and isolateRuns cannot be used together.'); +} + +if (options.iterations <= 0) { + fail('iterations must be greater than zero.'); +} +if (options.warmup < 0) { + fail('warmup must be zero or greater.'); +} +if (options.durationSec <= 0) { + fail('durationSec must be greater than zero.'); +} +if (options.objectCounts.length === 0) { + fail('At least one object count is required.'); +} + +const scenarios = options.workloads.flatMap((workload) => + options.comparisonModes.flatMap((comparisonMode) => + options.objectCounts.map((objectCount) => ({ + workload, + comparisonMode, + objectCount, + durationSeconds: options.durationSec, + })) + ) +); + +const benchmarkPageUrl = (baseUrl) => `${baseUrl.replace(/\/$/, '')}/engine-benchmark.html`; + +const startExamplesServer = async () => { + if (!fs.existsSync(viteBinPath)) { + fail('Missing local Vite binary. Run yarn install in Axrone/web before starting benchmark automation.'); + } + + const server = spawn( + process.execPath, + [viteBinPath, '--config', 'vite.examples.config.ts', '--host', options.host, '--port', String(options.port), '--strictPort'], + { + cwd: workspaceDir, + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + + const output = []; + let ready = false; + const pushOutput = (chunk) => { + const text = chunk.toString(); + output.push(text); + if (output.length > 30) { + output.shift(); + } + + if (text.includes('ready in') || text.includes('Local:')) { + ready = true; + } + }; + + server.stdout.on('data', pushOutput); + server.stderr.on('data', pushOutput); + + const url = `http://${options.host}:${options.port}`; + const deadline = Date.now() + 30_000; + + while (Date.now() < deadline) { + const combinedOutput = output.join(''); + + if (server.exitCode !== null) { + fail(`Examples server exited early: ${combinedOutput.trim() || 'unknown error'}`); + } + + if ( + combinedOutput.includes('Port ') && + combinedOutput.includes(' is already in use') + ) { + server.kill('SIGTERM'); + fail(`Examples server could not claim ${url}: ${combinedOutput.trim()}`); + } + + if (ready) { + try { + const response = await fetch(benchmarkPageUrl(url), { cache: 'no-store' }); + if (response.ok) { + return { server, url }; + } + } catch (error) {} + } else { + try { + const response = await fetch(benchmarkPageUrl(url), { cache: 'no-store' }); + if (response.ok) { + // Another process is already serving this port. Wait for the spawned Vite + // process to prove ownership or fail explicitly instead of attaching to it. + } + } + catch (error) {} + } + + await delay(250); + } + + server.kill('SIGTERM'); + fail(`Timed out waiting for examples server at ${url}. Last output:\n${output.join('').trim()}`); +}; + +const closeServer = async (server) => { + if (!server || server.exitCode !== null) { + return; + } + + server.kill('SIGTERM'); + const deadline = Date.now() + 5_000; + while (server.exitCode === null && Date.now() < deadline) { + await delay(100); + } + + if (server.exitCode === null) { + server.kill('SIGKILL'); + } +}; + +const waitForBenchmarkApi = async (page, baseUrl) => { + const errors = []; + const recordError = (message) => { + if (typeof message !== 'string' || message.length === 0) { + return; + } + + errors.push(message); + if (errors.length > 10) { + errors.shift(); + } + }; + const handleConsole = (message) => { + if (message.type() === 'error') { + recordError(message.text()); + } + }; + const handlePageError = (error) => { + recordError(error instanceof Error ? error.stack ?? error.message : String(error)); + }; + + page.on('console', handleConsole); + page.on('pageerror', handlePageError); + + try { + for (let attempt = 0; attempt < 2; attempt += 1) { + await page.goto(benchmarkPageUrl(baseUrl), { + waitUntil: 'domcontentloaded', + timeout: DEFAULT_TIMEOUT_MS, + }); + + try { + await page.waitForFunction( + () => Boolean(window.__AXRONE_ENGINE_BENCHMARK__), + undefined, + { timeout: DEFAULT_TIMEOUT_MS } + ); + return; + } catch (error) { + if (attempt === 1) { + const detail = errors.length > 0 ? ` Recent page errors: ${errors.join(' | ')}` : ''; + throw new Error( + `Benchmark automation API did not become available at ${benchmarkPageUrl(baseUrl)} within ${DEFAULT_TIMEOUT_MS} ms.${detail}`, + ); + } + } + } + } finally { + page.off('console', handleConsole); + page.off('pageerror', handlePageError); + } +}; + +const runSingleBenchmark = async (browser, baseUrl, scenario) => { + const context = await browser.newContext({ + viewport: DEFAULT_VIEWPORT, + deviceScaleFactor: 1, + serviceWorkers: 'block', + }); + const page = await context.newPage(); + + try { + await waitForBenchmarkApi(page, baseUrl); + + return await page.evaluate(async (payload) => { + const api = window.__AXRONE_ENGINE_BENCHMARK__; + if (!api) { + throw new Error('Benchmark automation API is not available on the page.'); + } + + return api.runOnce({ + ...payload, + timeoutMs: payload.durationSeconds * 1000 + 20_000, + }); + }, scenario); + } finally { + await context.close(); + } +}; + +const summarizeMetric = (values) => { + const sorted = [...values].sort((left, right) => left - right); + const average = mean(sorted); + const median = percentile(sorted, 0.5); + const p95 = percentile(sorted, 0.95); + const min = sorted[0] ?? 0; + const max = sorted[sorted.length - 1] ?? 0; + const stdev = standardDeviation(sorted); + + return { + mean: round(average), + median: round(median), + p95: round(p95), + min: round(min), + max: round(max), + stdev: round(stdev), + coefficientOfVariationPct: average === 0 ? 0 : round((stdev / average) * 100), + maxOverMedianRatio: median === 0 ? 0 : round(max / median), + }; +}; + +const summarizeEngine = (runs, engineName) => ({ + ...Object.fromEntries( + metricNames.map((metricName) => [ + metricName, + summarizeMetric(runs.map((run) => run.engines[engineName][metricName])), + ]) + ), + buildPhases: summarizePhaseMetrics(runs, engineName), +}); + +const getTopBuildPhases = (engineSummary, count = 3) => + Object.entries(engineSummary.buildPhases ?? {}) + .sort(([, left], [, right]) => right.mean - left.mean) + .slice(0, count); + +const collectUnstableMetrics = (engineSummary, engineName) => { + const warnings = []; + const considerMetric = (metricPath, metricSummary) => { + if (!metricSummary || typeof metricSummary !== 'object' || !('coefficientOfVariationPct' in metricSummary)) { + return; + } + + if ( + metricSummary.coefficientOfVariationPct >= 25 && + metricSummary.maxOverMedianRatio >= 2.5 && + ( + metricSummary.median >= MIN_UNSTABLE_METRIC_MEDIAN_MS || + metricSummary.max - metricSummary.min >= MIN_UNSTABLE_METRIC_SPAN_MS + ) + ) { + warnings.push({ + engine: engineName, + metric: metricPath, + coefficientOfVariationPct: metricSummary.coefficientOfVariationPct, + maxOverMedianRatio: metricSummary.maxOverMedianRatio, + }); + } + }; + + for (const metricName of metricNames) { + considerMetric(metricName, engineSummary[metricName]); + } + + for (const [phaseName, phaseSummary] of Object.entries(engineSummary.buildPhases ?? {})) { + considerMetric(`buildPhases.${phaseName}`, phaseSummary); + } + + return warnings; +}; + +const compareMetricMeans = (axroneMetric, threeMetric, higherIsBetter) => { + const axroneMean = axroneMetric.mean; + const threeMean = threeMetric.mean; + const delta = round(axroneMean - threeMean); + const percentVsThree = threeMean === 0 ? 0 : round((delta / threeMean) * 100); + const leader = + Math.abs(delta) < 0.0001 + ? 'tie' + : higherIsBetter + ? axroneMean > threeMean + ? 'axrone' + : 'three' + : axroneMean < threeMean + ? 'axrone' + : 'three'; + + return { + axroneMean, + threeMean, + delta, + percentVsThree, + leader, + }; +}; + +const summarizeScenario = (runs) => { + const axrone = summarizeEngine(runs, 'axrone'); + const three = summarizeEngine(runs, 'three'); + const qualityFlags = [ + ...collectUnstableMetrics(axrone, 'axrone'), + ...collectUnstableMetrics(three, 'three'), + ]; + + return { + runCount: runs.length, + engines: { + axrone, + three, + }, + qualityFlags, + deltas: { + averageFps: compareMetricMeans(axrone.averageFps, three.averageFps, true), + p95FrameTimeMs: compareMetricMeans(axrone.p95FrameTimeMs, three.p95FrameTimeMs, false), + setupBuildTimeMs: compareMetricMeans(axrone.setupBuildTimeMs, three.setupBuildTimeMs, false), + firstRenderTimeMs: compareMetricMeans(axrone.firstRenderTimeMs, three.firstRenderTimeMs, false), + setupTimeMs: compareMetricMeans(axrone.setupTimeMs, three.setupTimeMs, false), + }, + }; +}; + +const printScenarioSummary = (scenarioReport) => { + const { scenario, summary } = scenarioReport; + const axroneTopPhases = getTopBuildPhases(summary.engines.axrone); + const threeTopPhases = getTopBuildPhases(summary.engines.three); + + console.log( + `\n${scenario.workload} | ${scenario.comparisonMode} | ${scenario.objectCount.toLocaleString('en-US')} objects | ${summary.runCount} measured run(s)` + ); + console.log( + ` Build mean Axrone ${formatMetric(summary.engines.axrone.setupBuildTimeMs.mean, ' ms')} | Three ${formatMetric(summary.engines.three.setupBuildTimeMs.mean, ' ms')} | leader ${summary.deltas.setupBuildTimeMs.leader}` + ); + console.log( + ` First render Axrone ${formatMetric(summary.engines.axrone.firstRenderTimeMs.mean, ' ms')} | Three ${formatMetric(summary.engines.three.firstRenderTimeMs.mean, ' ms')} | leader ${summary.deltas.firstRenderTimeMs.leader}` + ); + console.log( + ` FPS mean Axrone ${formatMetric(summary.engines.axrone.averageFps.mean, '')} | Three ${formatMetric(summary.engines.three.averageFps.mean, '')} | leader ${summary.deltas.averageFps.leader}` + ); + console.log( + ` P95 mean Axrone ${formatMetric(summary.engines.axrone.p95FrameTimeMs.mean, ' ms')} | Three ${formatMetric(summary.engines.three.p95FrameTimeMs.mean, ' ms')} | leader ${summary.deltas.p95FrameTimeMs.leader}` + ); + + if (axroneTopPhases.length > 0) { + console.log( + ` Axrone top build phases ${axroneTopPhases + .map(([phaseName, phase]) => `${phaseName}=${formatMetric(phase.mean, ' ms')}`) + .join(' | ')}` + ); + } + + if (threeTopPhases.length > 0) { + console.log( + ` Three top build phases ${threeTopPhases + .map(([phaseName, phase]) => `${phaseName}=${formatMetric(phase.mean, ' ms')}`) + .join(' | ')}` + ); + } + + if (summary.qualityFlags.length > 0) { + console.log( + ` Stability warnings ${summary.qualityFlags + .map( + (warning) => + `${warning.engine}.${warning.metric} cv=${warning.coefficientOfVariationPct.toFixed(2)}% ratio=${warning.maxOverMedianRatio.toFixed(2)}` + ) + .join(' | ')}` + ); + } +}; + +let browser; +let server; + +try { + const startedServer = options.url ? null : await startExamplesServer(); + const baseUrl = options.url ? options.url.replace(/\/$/, '') : startedServer.url; + server = startedServer?.server ?? null; + const launchBrowser = () => + chromium.launch({ + headless: options.headless, + args: [ + '--enable-webgl', + '--enable-accelerated-2d-canvas', + '--disable-web-security', + '--allow-running-insecure-content', + ], + }); + const getScenarioBrowser = async () => { + if (options.reuseBrowser) { + browser ??= await launchBrowser(); + return browser; + } + + return launchBrowser(); + }; + const runWithBrowser = async (callback) => { + if (options.isolateRuns) { + const isolatedBrowser = await launchBrowser(); + + try { + return await callback(isolatedBrowser); + } finally { + await isolatedBrowser.close(); + } + } + + const scenarioBrowser = await getScenarioBrowser(); + return callback(scenarioBrowser); + }; + + let browserVersion = ''; + let userAgent = ''; + + const scenarioReports = []; + + for (const scenario of scenarios) { + await runWithBrowser(async (probeBrowser) => { + if (browserVersion && userAgent) { + return; + } + + const context = await probeBrowser.newContext({ + viewport: DEFAULT_VIEWPORT, + deviceScaleFactor: 1, + }); + const page = await context.newPage(); + + try { + await waitForBenchmarkApi(page, baseUrl); + browserVersion = probeBrowser.version(); + userAgent = await page.evaluate(() => navigator.userAgent); + } finally { + await context.close(); + } + }); + + console.log( + `Running ${scenario.workload} / ${scenario.comparisonMode} / ${scenario.objectCount} objects (${options.warmup} warmup + ${options.iterations} measured)` + ); + + try { + for (let warmupIndex = 0; warmupIndex < options.warmup; warmupIndex += 1) { + await runWithBrowser((benchmarkBrowser) => + runSingleBenchmark(benchmarkBrowser, baseUrl, scenario) + ); + } + + const measuredRuns = []; + for (let runIndex = 0; runIndex < options.iterations; runIndex += 1) { + const snapshot = await runWithBrowser((benchmarkBrowser) => + runSingleBenchmark(benchmarkBrowser, baseUrl, scenario) + ); + measuredRuns.push(snapshot); + } + + const scenarioReport = { + scenario, + summary: summarizeScenario(measuredRuns), + rawRuns: measuredRuns, + }; + scenarioReports.push(scenarioReport); + printScenarioSummary(scenarioReport); + } finally { + if (!options.reuseBrowser && !options.isolateRuns) { + await scenarioBrowser.close(); + } + } + } + + const report = { + generatedAt: new Date().toISOString(), + environment: { + platform: process.platform, + arch: process.arch, + nodeVersion: process.version, + browserVersion, + userAgent, + headless: options.headless, + viewport: DEFAULT_VIEWPORT, + }, + config: { + iterations: options.iterations, + warmup: options.warmup, + durationSec: options.durationSec, + workloads: options.workloads, + comparisonModes: options.comparisonModes, + objectCounts: options.objectCounts, + isolateRuns: options.isolateRuns, + baseUrl, + }, + scenarios: scenarioReports, + }; + + fs.mkdirSync(path.dirname(options.output), { recursive: true }); + fs.writeFileSync(options.output, JSON.stringify(report, null, 2)); + console.log(`\nBenchmark report written to ${path.relative(workspaceDir, options.output)}`); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} finally { + if (browser) { + await browser.close(); + } + + if (server && !options.keepServer) { + await closeServer(server); + } +} \ No newline at end of file diff --git a/web/scripts/runtime-profile-governance.mjs b/web/scripts/runtime-profile-governance.mjs index 42b02b43..b3bfba68 100644 --- a/web/scripts/runtime-profile-governance.mjs +++ b/web/scripts/runtime-profile-governance.mjs @@ -7,6 +7,102 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const workspaceDir = path.resolve(scriptDir, '..'); const packagesDir = path.resolve(workspaceDir, 'packages'); +const runtimeProfileSampleCount = Math.max( + 1, + Number.parseInt(process.env.AXRONE_RUNTIME_PROFILE_SAMPLES ?? '3', 10) || 3, +); + +const readPackageJson = (packageDir) => { + const packageJsonPath = path.resolve(packagesDir, packageDir, 'package.json'); + return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); +}; + +const resolveImportTarget = (exportTarget) => { + if (typeof exportTarget === 'string') { + return exportTarget; + } + + if (!exportTarget || typeof exportTarget !== 'object' || Array.isArray(exportTarget)) { + return null; + } + + if (typeof exportTarget.import === 'string') { + return exportTarget.import; + } + + if (typeof exportTarget.default === 'string') { + return exportTarget.default; + } + + if (typeof exportTarget.require === 'string') { + return exportTarget.require; + } + + for (const nestedTarget of Object.values(exportTarget)) { + const resolvedTarget = resolveImportTarget(nestedTarget); + if (resolvedTarget) { + return resolvedTarget; + } + } + + return null; +}; + +const createWorkspaceImportMap = () => { + const importMap = {}; + + for (const entry of fs.readdirSync(packagesDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + const packageDir = entry.name; + const packageRoot = path.resolve(packagesDir, packageDir); + const packageJson = readPackageJson(packageDir); + const packageName = packageJson.name; + + if (typeof packageName !== 'string' || !packageName.startsWith('@axrone/')) { + continue; + } + + const addImportTarget = (specifier, relativeTarget) => { + if (typeof relativeTarget !== 'string') { + return; + } + + const absoluteTarget = path.resolve(packageRoot, relativeTarget); + if (!fs.existsSync(absoluteTarget)) { + return; + } + + importMap[specifier] = pathToFileURL(absoluteTarget).href; + }; + + const exportsField = packageJson.exports; + if (exportsField && typeof exportsField === 'object' && !Array.isArray(exportsField)) { + addImportTarget( + packageName, + resolveImportTarget(exportsField['.']) ?? packageJson.module ?? packageJson.main, + ); + + for (const [subpath, exportTarget] of Object.entries(exportsField)) { + if (!subpath.startsWith('./')) { + continue; + } + + addImportTarget(`${packageName}/${subpath.slice(2)}`, resolveImportTarget(exportTarget)); + } + + continue; + } + + addImportTarget(packageName, packageJson.module ?? packageJson.main ?? './dist/index.mjs'); + } + + return importMap; +}; + +const workspaceImportMap = createWorkspaceImportMap(); const runtimeProfileBudgets = [ { @@ -76,8 +172,7 @@ const runtimeProfileBudgets = [ ]; const readDependencyKeys = (packageDir) => { - const packageJsonPath = path.resolve(packagesDir, packageDir, 'package.json'); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const packageJson = readPackageJson(packageDir); return Object.keys(packageJson.dependencies ?? {}).sort((left, right) => left.localeCompare(right), ); @@ -87,7 +182,7 @@ const resolveEntryPath = (packageDir) => { const entryPath = path.resolve(packagesDir, packageDir, 'dist', 'index.mjs'); if (!fs.existsSync(entryPath)) { throw new Error( - `Missing built entry for ${packageDir}. Run \"npm run build\" before runtime-profile governance.`, + `Missing built entry for ${packageDir}. Run "yarn build" before runtime-profile governance.`, ); } @@ -97,8 +192,25 @@ const resolveEntryPath = (packageDir) => { const measureColdImport = (entryPath) => { const moduleUrl = pathToFileURL(entryPath).href; const childScript = ` + import { registerHooks } from 'node:module'; import { performance } from 'node:perf_hooks'; + const workspaceImportMap = ${JSON.stringify(workspaceImportMap)}; + + registerHooks({ + resolve(specifier, context, nextResolve) { + const resolvedUrl = workspaceImportMap[specifier]; + if (typeof resolvedUrl === 'string') { + return { + shortCircuit: true, + url: resolvedUrl, + }; + } + + return nextResolve(specifier, context); + }, + }); + let nextWebGl2Constant = 0x2000; if (!globalThis.WebGL2RenderingContext) { @@ -127,7 +239,8 @@ const measureColdImport = (entryPath) => { const startupMs = performance.now() - startedAt; const heapDeltaKb = (process.memoryUsage().heapUsed - beforeHeapUsed) / 1024; - console.log(JSON.stringify({ startupMs, heapDeltaKb })); + process.stdout.write(JSON.stringify({ startupMs, heapDeltaKb }) + '\\n'); + process.exit(0); `; const result = spawnSync(process.execPath, ['--input-type=module', '--eval', childScript], { @@ -143,6 +256,17 @@ const measureColdImport = (entryPath) => { return JSON.parse(result.stdout.trim()); }; +const calculateMedian = (values) => { + const sortedValues = [...values].sort((left, right) => left - right); + const middleIndex = Math.floor(sortedValues.length / 2); + + if (sortedValues.length % 2 === 0) { + return (sortedValues[middleIndex - 1] + sortedValues[middleIndex]) / 2; + } + + return sortedValues[middleIndex]; +}; + const formatNumber = (value) => value.toFixed(2).padStart(8, ' '); const reportRows = []; @@ -152,14 +276,19 @@ for (const profile of runtimeProfileBudgets) { const entryPath = resolveEntryPath(profile.packageDir); const entryBytes = fs.statSync(entryPath).size; const dependencyKeys = readDependencyKeys(profile.packageDir); - const coldImport = measureColdImport(entryPath); + const coldImportSamples = Array.from({ length: runtimeProfileSampleCount }, () => + measureColdImport(entryPath), + ); + const startupMs = calculateMedian(coldImportSamples.map((sample) => sample.startupMs)); + const heapDeltaKb = calculateMedian(coldImportSamples.map((sample) => sample.heapDeltaKb)); reportRows.push({ packageName: profile.packageName, entryBytes, - startupMs: coldImport.startupMs, - heapDeltaKb: coldImport.heapDeltaKb, + startupMs, + heapDeltaKb, dependencyCount: dependencyKeys.length, + sampleCount: runtimeProfileSampleCount, }); if (entryBytes > profile.maxEntryBytes) { @@ -168,15 +297,15 @@ for (const profile of runtimeProfileBudgets) { ); } - if (coldImport.startupMs > profile.maxStartupMs) { + if (startupMs > profile.maxStartupMs) { failures.push( - `${profile.packageName} cold startup ${coldImport.startupMs.toFixed(2)} ms exceeds budget ${profile.maxStartupMs} ms.`, + `${profile.packageName} median cold startup ${startupMs.toFixed(2)} ms exceeds budget ${profile.maxStartupMs} ms across ${runtimeProfileSampleCount} samples.`, ); } - if (coldImport.heapDeltaKb > profile.maxHeapDeltaKb) { + if (heapDeltaKb > profile.maxHeapDeltaKb) { failures.push( - `${profile.packageName} heap delta ${coldImport.heapDeltaKb.toFixed(2)} KB exceeds budget ${profile.maxHeapDeltaKb} KB.`, + `${profile.packageName} median heap delta ${heapDeltaKb.toFixed(2)} KB exceeds budget ${profile.maxHeapDeltaKb} KB across ${runtimeProfileSampleCount} samples.`, ); } @@ -193,8 +322,8 @@ for (const profile of runtimeProfileBudgets) { } } -console.log('Runtime profile governance report'); -console.log('Package'.padEnd(32) + 'Entry'.padStart(8) + ' ' + 'Startup'.padStart(10) + ' ' + 'Heap'.padStart(10) + ' ' + 'Deps'.padStart(6)); +console.log(`Runtime profile governance report (${runtimeProfileSampleCount} cold-import samples, medians shown)`); +console.log('Package'.padEnd(32) + 'Entry'.padStart(8) + ' ' + 'Startup'.padStart(10) + ' ' + 'Heap'.padStart(10) + ' ' + 'Deps'.padStart(6) + ' ' + 'Runs'.padStart(6)); for (const row of reportRows) { console.log( row.packageName.padEnd(32) + @@ -204,7 +333,9 @@ for (const row of reportRows) { ' ' + formatNumber(row.heapDeltaKb) + ' ' + - String(row.dependencyCount).padStart(6), + String(row.dependencyCount).padStart(6) + + ' ' + + String(row.sampleCount).padStart(6), ); } diff --git a/web/scripts/utility-governance.mjs b/web/scripts/utility-governance.mjs new file mode 100644 index 00000000..33f432d5 --- /dev/null +++ b/web/scripts/utility-governance.mjs @@ -0,0 +1,243 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +const ROOT = process.cwd(); +const SOURCE_ROOTS = ['packages']; +const SKIPPED_DIRECTORIES = new Set([ + 'dist', + 'node_modules', + 'coverage', + '.git', + '.turbo', + '.vite', +]); + +const TYPE_DECLARATION_RULES = [ + { + name: 'Brand', + pattern: /^\s*(?:export\s+)?type\s+Brand\b/m, + replacement: "import type { Brand } from '@axrone/utility'", + }, + { + name: 'Nominal', + pattern: /^\s*(?:export\s+)?type\s+Nominal\b/m, + replacement: "import type { Nominal } from '@axrone/utility'", + }, + { + name: 'Primitive', + pattern: /^\s*(?:export\s+)?type\s+Primitive\b/m, + replacement: "import type { Primitive } from '@axrone/utility'", + }, + { + name: 'JsonPrimitive', + pattern: /^\s*(?:export\s+)?type\s+JsonPrimitive\b/m, + replacement: "import type { JsonPrimitive } from '@axrone/utility'", + }, + { + name: 'JsonObject', + pattern: /^\s*(?:export\s+)?interface\s+JsonObject\b/m, + replacement: "import type { JsonObject } from '@axrone/utility'", + }, + { + name: 'JsonArray', + pattern: /^\s*(?:export\s+)?interface\s+JsonArray\b/m, + replacement: "import type { JsonArray } from '@axrone/utility'", + }, + { + name: 'JsonValue', + pattern: /^\s*(?:export\s+)?type\s+JsonValue\b/m, + replacement: "import type { JsonValue } from '@axrone/utility'", + }, + { + name: 'TypedArray', + pattern: /^\s*(?:export\s+)?type\s+TypedArray\b/m, + replacement: "import type { TypedArray } from '@axrone/utility'", + }, + { + name: 'TypedArrayConstructor', + pattern: /^\s*(?:export\s+)?type\s+TypedArrayConstructor\b/m, + replacement: "import type { TypedArrayConstructor } from '@axrone/utility'", + }, +]; + +const VECTOR_ARITY = new Map([ + ['Vec2', 2], + ['Vec3', 3], + ['Vec4', 4], + ['Quat', 4], + ['Color', 4], +]); + +const isSourceFile = (filePath) => + /\.(ts|tsx|mts|cts)$/.test(filePath) && !filePath.endsWith('.d.ts'); + +const toPosix = (filePath) => filePath.replaceAll('\\', '/'); + +const isSkippedPath = (absolutePath) => { + const rel = toPosix(relative(ROOT, absolutePath)); + return rel.includes('/__tests__/') || rel.includes('/test/') || rel.includes('/tests/'); +}; + +const allowsLocalCentralTypeDeclarations = (relativePath) => + relativePath.startsWith('packages/utility/src/') || + relativePath.startsWith('packages/memory/src/'); + +const collectSourceFiles = (directory) => { + const files = []; + + const visit = (current) => { + for (const entry of readdirSync(current)) { + if (SKIPPED_DIRECTORIES.has(entry)) { + continue; + } + + const absolutePath = join(current, entry); + const stat = statSync(absolutePath); + if (stat.isDirectory()) { + visit(absolutePath); + continue; + } + + if (stat.isFile() && isSourceFile(absolutePath) && !isSkippedPath(absolutePath)) { + files.push(absolutePath); + } + } + }; + + visit(directory); + return files; +}; + +const lineOf = (source, index) => source.slice(0, index).split(/\r?\n/u).length; + +const splitTopLevelArguments = (source) => { + const args = []; + let depth = 0; + let start = 0; + + for (let index = 0; index < source.length; index += 1) { + const char = source[index]; + if (char === '(' || char === '[' || char === '{') { + depth += 1; + continue; + } + + if (char === ')' || char === ']' || char === '}') { + depth -= 1; + continue; + } + + if (char === ',' && depth === 0) { + args.push(source.slice(start, index).trim()); + start = index + 1; + } + } + + args.push(source.slice(start).trim()); + return args; +}; + +const unwrapNumberCall = (arg) => { + const match = /^Number\s*\(([\s\S]*)\)$/u.exec(arg.trim()); + return match ? match[1].trim() : arg.trim(); +}; + +const simpleIndexedArgument = (arg, expectedIndex) => { + const normalized = unwrapNumberCall(arg); + const match = /^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\[\s*(\d+)\s*\]$/u.exec(normalized); + + if (!match) { + return null; + } + + return Number(match[2]) === expectedIndex ? match[1] : null; +}; + +const findSimpleVectorConstructors = (source) => { + const violations = []; + const constructorPattern = /\bnew\s+(Vec2|Vec3|Vec4|Quat|Color)\s*\(/gu; + let match; + + while ((match = constructorPattern.exec(source))) { + const constructorName = match[1]; + const arity = VECTOR_ARITY.get(constructorName); + const argsStart = constructorPattern.lastIndex; + let depth = 1; + let cursor = argsStart; + + for (; cursor < source.length; cursor += 1) { + const char = source[cursor]; + if (char === '(') { + depth += 1; + } else if (char === ')') { + depth -= 1; + if (depth === 0) { + break; + } + } + } + + if (depth !== 0) { + continue; + } + + const args = splitTopLevelArguments(source.slice(argsStart, cursor)); + if (args.length !== arity) { + continue; + } + + const sources = args.map((arg, index) => simpleIndexedArgument(arg, index)); + if (sources.every(Boolean) && new Set(sources).size === 1) { + violations.push({ + index: match.index, + constructorName, + sourceName: sources[0], + }); + } + + constructorPattern.lastIndex = cursor + 1; + } + + return violations; +}; + +const files = SOURCE_ROOTS.flatMap((sourceRoot) => collectSourceFiles(join(ROOT, sourceRoot))); +const violations = []; + +for (const file of files) { + const relativePath = toPosix(relative(ROOT, file)); + const source = readFileSync(file, 'utf8'); + + if (!allowsLocalCentralTypeDeclarations(relativePath)) { + for (const rule of TYPE_DECLARATION_RULES) { + const match = rule.pattern.exec(source); + if (match) { + violations.push({ + file: relativePath, + line: lineOf(source, match.index), + message: `Local ${rule.name} declaration should use ${rule.replacement}.`, + }); + } + } + } + + if (!relativePath.startsWith('packages/numeric/src/')) { + for (const violation of findSimpleVectorConstructors(source)) { + violations.push({ + file: relativePath, + line: lineOf(source, violation.index), + message: `Use ${violation.constructorName}.fromArray(${violation.sourceName}) instead of indexed constructor arguments.`, + }); + } + } +} + +if (violations.length > 0) { + console.error('Utility governance found reusable helpers that should be centralized:\n'); + for (const violation of violations) { + console.error(`- ${violation.file}:${violation.line} ${violation.message}`); + } + process.exit(1); +} + +console.log('Utility governance passed.'); diff --git a/web/tests/architecture/ecs-split-boundaries/ecs-events-entry.test.ts b/web/tests/architecture/ecs-split-boundaries/ecs-events-entry.test.ts deleted file mode 100644 index 3a19ac8d..00000000 --- a/web/tests/architecture/ecs-split-boundaries/ecs-events-entry.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import * as ecsEvents from '@axrone/ecs-events'; - -describe('ecs-events entry', () => { - it('surfaces ecs event primitives without leaking world storage ownership', () => { - expect(ecsEvents.createTypedEmitter).toBeDefined(); - expect(ecsEvents.createSubject).toBeDefined(); - expect(ecsEvents.createBehaviorSubject).toBeDefined(); - expect(ecsEvents.ECSObservables).toBeDefined(); - expect(ecsEvents.WorldEventRuntime).toBeDefined(); - expect('World' in ecsEvents).toBe(false); - expect('WorldStorageRuntime' in ecsEvents).toBe(false); - expect('WorldQueryRuntime' in ecsEvents).toBe(false); - expect('Actor' in ecsEvents).toBe(false); - }); -}); \ No newline at end of file diff --git a/web/tests/architecture/ecs-split-boundaries/ecs-events-ownership-boundary.test.ts b/web/tests/architecture/ecs-split-boundaries/ecs-events-ownership-boundary.test.ts deleted file mode 100644 index d272016a..00000000 --- a/web/tests/architecture/ecs-split-boundaries/ecs-events-ownership-boundary.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; - -const testDir = path.dirname(fileURLToPath(import.meta.url)); -const ecsEventsSrcDir = path.resolve(testDir, '../../../packages/ecs-events/src'); -const disallowedImportPattern = - /(?:from ['"]|import\(['"])(?:[^'"]*@axrone\/ecs(?!-events)|[^'"]*core\/src\/(?:component-system|event|observer)|[^'"]*ecs\/src\/(?:component-system|support))(?:\/[^'"]*)?['"]/g; - -const collectTypeScriptFiles = (dirPath: string): readonly string[] => { - const files: string[] = []; - - for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) { - const fullPath = path.resolve(dirPath, entry.name); - if (entry.isDirectory()) { - if (entry.name === '__tests__') { - continue; - } - - files.push(...collectTypeScriptFiles(fullPath)); - continue; - } - - if ( - entry.isFile() && - entry.name.endsWith('.ts') && - !entry.name.endsWith('.test.ts') && - !entry.name.endsWith('.spec.ts') - ) { - files.push(fullPath); - } - } - - return files; -}; - -describe('ecs-events ownership boundary', () => { - it('keeps ecs-events owned sources off ecs and core internals', () => { - const violatingFiles = collectTypeScriptFiles(ecsEventsSrcDir) - .filter((filePath) => { - const content = fs.readFileSync(filePath, 'utf8'); - const hasDisallowedImport = disallowedImportPattern.test(content); - disallowedImportPattern.lastIndex = 0; - return hasDisallowedImport; - }) - .map((filePath) => path.relative(ecsEventsSrcDir, filePath).replace(/\\/g, '/')) - .sort((left, right) => left.localeCompare(right)); - - expect(violatingFiles).toEqual([]); - }); -}); \ No newline at end of file diff --git a/web/tests/architecture/ecs-split-boundaries/ecs-events-package-removal.test.ts b/web/tests/architecture/ecs-split-boundaries/ecs-events-package-removal.test.ts new file mode 100644 index 00000000..9b049f99 --- /dev/null +++ b/web/tests/architecture/ecs-split-boundaries/ecs-events-package-removal.test.ts @@ -0,0 +1,13 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const ecsEventsPackageDir = path.resolve(testDir, '../../../packages/ecs-events'); + +describe('ecs-events package removal', () => { + it('removes the deprecated ecs-events workspace package', () => { + expect(fs.existsSync(ecsEventsPackageDir)).toBe(false); + }); +}); \ No newline at end of file diff --git a/web/tests/architecture/ecs-split-boundaries/ecs-runtime-ownership-boundary.test.ts b/web/tests/architecture/ecs-split-boundaries/ecs-runtime-ownership-boundary.test.ts index d4e4cffe..14ce91b9 100644 --- a/web/tests/architecture/ecs-split-boundaries/ecs-runtime-ownership-boundary.test.ts +++ b/web/tests/architecture/ecs-split-boundaries/ecs-runtime-ownership-boundary.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest'; const testDir = path.dirname(fileURLToPath(import.meta.url)); const ecsRuntimeSrcDir = path.resolve(testDir, '../../../packages/ecs-runtime/src'); const disallowedImportPattern = - /(?:from ['"]|import\(['"])(?:[^'"]*@axrone\/ecs(?!-(?:runtime|events|query|storage|world-support))|[^'"]*core\/src\/(?:component-system|event|observer)|[^'"]*ecs\/src(?:\/[^'"]*)?)['"]/g; + /(?:from ['"]|import\(['"])(?:[^'"]*@axrone\/ecs(?!-(?:runtime|query|storage|world-support))|[^'"]*core\/src\/(?:component-system|event|observer)|[^'"]*ecs\/src(?:\/[^'"]*)?)['"]/g; const collectTypeScriptFiles = (dirPath: string): readonly string[] => { const files: string[] = []; @@ -49,4 +49,20 @@ describe('ecs-runtime ownership boundary', () => { expect(violatingFiles).toEqual([]); }); + + it('composes shared event and observer packages directly', () => { + const worldEventRuntime = fs.readFileSync( + path.resolve(ecsRuntimeSrcDir, 'component-system/core/world-event-runtime.ts'), + 'utf8' + ); + const ecsObserver = fs.readFileSync( + path.resolve(ecsRuntimeSrcDir, 'component-system/observers/ecs-observer.ts'), + 'utf8' + ); + + expect(worldEventRuntime).toContain("from '@axrone/event'"); + expect(ecsObserver).toContain("from '@axrone/observer'"); + expect(worldEventRuntime).not.toContain("@axrone/ecs-events"); + expect(ecsObserver).not.toContain("@axrone/ecs-events"); + }); }); \ No newline at end of file diff --git a/web/tsconfig.json b/web/tsconfig.json index 6efdc2e6..cee521cb 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -17,12 +17,16 @@ "paths": { "@axrone/asset-core": ["packages/asset-core/src/index.ts"], "@axrone/asset-core/*": ["packages/asset-core/src/*"], + "@axrone/asset-shader": ["packages/asset-shader/src/index.ts"], + "@axrone/asset-shader/*": ["packages/asset-shader/src/*"], "@axrone/event": ["packages/event/src/index.ts"], "@axrone/event/*": ["packages/event/src/*"], "@axrone/geometry": ["packages/geometry/src/index.ts"], "@axrone/geometry/*": ["packages/geometry/src/*"], "@axrone/input": ["packages/input/src/index.ts"], "@axrone/input/*": ["packages/input/src/*"], + "@axrone/memory": ["packages/memory/src/index.ts"], + "@axrone/memory/*": ["packages/memory/src/*"], "@axrone/observer": ["packages/observer/src/index.ts"], "@axrone/observer/*": ["packages/observer/src/*"], "@axrone/physics": ["packages/physics/src/index.ts"], @@ -39,9 +43,10 @@ "@axrone/scene-runtime-gltf/*": ["packages/scene-runtime-gltf/src/*"], "@axrone/scene-runtime": ["packages/scene-runtime/src"], "@axrone/scene-runtime/*": ["packages/scene-runtime/src/*"], + "@axrone/shapes-2d": ["packages/shapes-2d/src/index.ts"], + "@axrone/shapes-2d/*": ["packages/shapes-2d/src/*"], "@axrone/ecs-runtime": ["packages/ecs-runtime/src/index.ts"], "@axrone/ecs-runtime/*": ["packages/ecs-runtime/src/*"], - "@axrone/ecs-events/*": ["packages/ecs-events/src/*"], "@axrone/ecs-query/*": ["packages/ecs-query/src/*"], "@axrone/ecs-storage/*": ["packages/ecs-storage/src/*"], "@axrone/ecs-world-support": ["packages/ecs-world-support/src/index.ts"], diff --git a/web/types/draco3dgltf.d.ts b/web/types/draco3dgltf.d.ts index 8268b09b..2ef88e61 100644 --- a/web/types/draco3dgltf.d.ts +++ b/web/types/draco3dgltf.d.ts @@ -5,4 +5,15 @@ declare module 'draco3dgltf' { export function createEncoderModule( options?: Readonly> ): Promise; +} + +declare module 'draco3dgltf/draco_decoder_gltf_nodejs.js' { + const DracoDecoderModule: ( + options?: Readonly<{ + locateFile?: (path: string, scriptDirectory: string) => string; + }> + ) => Promise; + + export { DracoDecoderModule }; + export default DracoDecoderModule; } \ No newline at end of file diff --git a/web/vite.examples.config.ts b/web/vite.examples.config.ts index fd9cc006..3117caf4 100644 --- a/web/vite.examples.config.ts +++ b/web/vite.examples.config.ts @@ -65,7 +65,7 @@ const resolveManualChunk = (id: string): string | undefined => { export default defineConfig({ root: path.resolve(workspaceDir, 'examples'), - publicDir: false, + publicDir: path.resolve(workspaceDir, 'examples/public'), resolve: { alias: workspaceAliases, }, diff --git a/web/yarn.lock b/web/yarn.lock index d5a877ad..d682e91f 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -15,6 +15,13 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" +"@axrone/animation@^0.1.0", "@axrone/animation@file:E:\\Workspace\\code_training\\gnew\\Arone-Project\\Axrone\\web\\packages\\animation": + version "0.1.0" + resolved "file:packages/animation" + dependencies: + "@axrone/memory" "^0.0.1" + "@axrone/numeric" "^0.0.1" + "@axrone/asset-2d@^0.1.0", "@axrone/asset-2d@file:E:\\Workspace\\code_training\\gnew\\Arone-Project\\Axrone\\web\\packages\\asset-2d": version "0.1.0" resolved "file:packages/asset-2d" @@ -26,21 +33,37 @@ resolved "file:packages/asset-core" dependencies: "@axrone/random" "^0.0.1" + "@axrone/utility" "^0.0.1" "@axrone/asset-gltf@^0.1.0", "@axrone/asset-gltf@file:E:\\Workspace\\code_training\\gnew\\Arone-Project\\Axrone\\web\\packages\\asset-gltf": version "0.1.0" resolved "file:packages/asset-gltf" dependencies: + "@axrone/animation" "^0.1.0" "@axrone/asset-core" "^0.1.0" "@axrone/numeric" "^0.0.1" + "@axrone/render-core" "^0.1.0" "@axrone/render-webgl2" "^0.1.0" "@loaders.gl/textures" "^4.4.1" draco3dgltf "^1.5.7" meshoptimizer "^1.0.1" -"@axrone/ecs-events@^0.1.0", "@axrone/ecs-events@file:E:\\Workspace\\code_training\\gnew\\Arone-Project\\Axrone\\web\\packages\\ecs-events": +"@axrone/asset-shader@file:E:\\Workspace\\code_training\\gnew\\Arone-Project\\Axrone\\web\\packages\\asset-shader": version "0.1.0" - resolved "file:packages/ecs-events" + resolved "file:packages/asset-shader" + dependencies: + "@axrone/asset-core" "^0.1.0" + "@axrone/render-core" "^0.1.0" + +"@axrone/audio@file:E:\\Workspace\\code_training\\gnew\\Arone-Project\\Axrone\\web\\packages\\audio": + version "0.1.0" + resolved "file:packages/audio" + dependencies: + "@axrone/asset-core" "^0.1.0" + "@axrone/ecs-runtime" "^0.1.0" + "@axrone/event" "^0.1.0" + "@axrone/numeric" "^0.0.1" + "@axrone/utility" "^0.0.1" "@axrone/ecs-query@^0.1.0", "@axrone/ecs-query@file:E:\\Workspace\\code_training\\gnew\\Arone-Project\\Axrone\\web\\packages\\ecs-query": version "0.1.0" @@ -50,18 +73,19 @@ version "0.1.0" resolved "file:packages/ecs-runtime" dependencies: - "@axrone/ecs-events" "^0.1.0" "@axrone/ecs-query" "^0.1.0" "@axrone/ecs-storage" "^0.1.0" "@axrone/ecs-world-support" "^0.1.0" + "@axrone/event" "^0.1.0" "@axrone/numeric" "^0.0.1" + "@axrone/observer" "^0.1.0" "@axrone/utility" "^0.0.1" "@axrone/ecs-storage@^0.1.0", "@axrone/ecs-storage@file:E:\\Workspace\\code_training\\gnew\\Arone-Project\\Axrone\\web\\packages\\ecs-storage": version "0.1.0" resolved "file:packages/ecs-storage" dependencies: - "@axrone/utility" "^0.0.1" + "@axrone/memory" "^0.0.1" "@axrone/ecs-world-support@^0.1.0", "@axrone/ecs-world-support@file:E:\\Workspace\\code_training\\gnew\\Arone-Project\\Axrone\\web\\packages\\ecs-world-support": version "0.1.0" @@ -71,16 +95,19 @@ version "0.1.0" resolved "file:packages/event" dependencies: - "@axrone/utility" "^0.0.1" + "@axrone/memory" "^0.0.1" "@axrone/game-loop@^0.1.0", "@axrone/game-loop@file:E:\\Workspace\\code_training\\gnew\\Arone-Project\\Axrone\\web\\packages\\game-loop": version "0.1.0" resolved "file:packages/game-loop" + dependencies: + "@axrone/utility" "^0.0.1" "@axrone/geometry@^0.1.0", "@axrone/geometry@file:E:\\Workspace\\code_training\\gnew\\Arone-Project\\Axrone\\web\\packages\\geometry": version "0.1.0" resolved "file:packages/geometry" dependencies: + "@axrone/memory" "^0.0.1" "@axrone/numeric" "^0.0.1" "@axrone/utility" "^0.0.1" @@ -95,6 +122,11 @@ resolved "file:packages/input" dependencies: "@axrone/event" "^0.1.0" + "@axrone/utility" "^0.0.1" + +"@axrone/memory@^0.0.1", "@axrone/memory@file:E:\\Workspace\\code_training\\gnew\\Arone-Project\\Axrone\\web\\packages\\memory": + version "0.0.1" + resolved "file:packages/memory" "@axrone/numeric@^0.0.1", "@axrone/numeric@file:E:\\Workspace\\code_training\\gnew\\Arone-Project\\Axrone\\web\\packages\\numeric": version "0.0.1" @@ -103,7 +135,7 @@ "@axrone/random" "^0.0.1" "@axrone/utility" "^0.0.1" -"@axrone/observer@file:E:\\Workspace\\code_training\\gnew\\Arone-Project\\Axrone\\web\\packages\\observer": +"@axrone/observer@^0.1.0", "@axrone/observer@file:E:\\Workspace\\code_training\\gnew\\Arone-Project\\Axrone\\web\\packages\\observer": version "0.1.0" resolved "file:packages/observer" dependencies: @@ -116,6 +148,7 @@ dependencies: "@axrone/event" "^0.1.0" "@axrone/geometry" "^0.1.0" + "@axrone/memory" "^0.0.1" "@axrone/numeric" "^0.0.1" "@axrone/random" "^0.0.1" "@axrone/utility" "^0.0.1" @@ -161,6 +194,7 @@ version "0.1.0" resolved "file:packages/render-2d" dependencies: + "@axrone/numeric" "^0.0.1" "@axrone/render-core" "^0.1.0" "@axrone/render-3d@^0.1.0", "@axrone/render-3d@file:E:\\Workspace\\code_training\\gnew\\Arone-Project\\Axrone\\web\\packages\\render-3d": @@ -181,6 +215,7 @@ dependencies: "@axrone/ecs-runtime" "^0.1.0" "@axrone/geometry" "^0.1.0" + "@axrone/memory" "^0.0.1" "@axrone/numeric" "^0.0.1" "@axrone/utility" "^0.0.1" @@ -188,6 +223,11 @@ version "0.1.0" resolved "file:packages/runtime-profile-2d" dependencies: + "@axrone/asset-2d" "^0.1.0" + "@axrone/input-core" "^0.1.0" + "@axrone/physics-2d" "^0.1.0" + "@axrone/physics-core" "^0.1.0" + "@axrone/render-2d" "^0.1.0" "@axrone/scene-2d" "^0.1.0" "@axrone/scene-runtime" "^0.1.0" @@ -195,6 +235,13 @@ version "0.1.0" resolved "file:packages/runtime-profile-3d" dependencies: + "@axrone/asset-core" "^0.1.0" + "@axrone/asset-gltf" "^0.1.0" + "@axrone/input-core" "^0.1.0" + "@axrone/physics-3d" "^0.1.0" + "@axrone/physics-core" "^0.1.0" + "@axrone/render-3d" "^0.1.0" + "@axrone/render-webgl2" "^0.1.0" "@axrone/scene-3d" "^0.1.0" "@axrone/scene-runtime" "^0.1.0" @@ -202,12 +249,23 @@ version "0.1.0" resolved "file:packages/runtime-profile-core" dependencies: + "@axrone/input-core" "^0.1.0" "@axrone/scene-runtime" "^0.1.0" "@axrone/runtime-profile-full@file:E:\\Workspace\\code_training\\gnew\\Arone-Project\\Axrone\\web\\packages\\runtime-profile-full": version "0.1.0" resolved "file:packages/runtime-profile-full" dependencies: + "@axrone/asset-2d" "^0.1.0" + "@axrone/asset-core" "^0.1.0" + "@axrone/asset-gltf" "^0.1.0" + "@axrone/input-core" "^0.1.0" + "@axrone/physics-2d" "^0.1.0" + "@axrone/physics-3d" "^0.1.0" + "@axrone/physics-core" "^0.1.0" + "@axrone/render-2d" "^0.1.0" + "@axrone/render-3d" "^0.1.0" + "@axrone/render-webgl2" "^0.1.0" "@axrone/scene-2d" "^0.1.0" "@axrone/scene-3d" "^0.1.0" "@axrone/scene-runtime" "^0.1.0" @@ -248,14 +306,24 @@ version "0.1.0" resolved "file:packages/scene-runtime" dependencies: + "@axrone/animation" "^0.1.0" + "@axrone/asset-2d" "^0.1.0" "@axrone/ecs-runtime" "^0.1.0" "@axrone/game-loop" "^0.1.0" "@axrone/geometry" "^0.1.0" "@axrone/numeric" "^0.0.1" "@axrone/random" "^0.0.1" + "@axrone/render-2d" "^0.1.0" + "@axrone/render-core" "^0.1.0" "@axrone/render-webgl2" "^0.1.0" "@axrone/utility" "^0.0.1" +"@axrone/shapes-2d@file:E:\\Workspace\\code_training\\gnew\\Arone-Project\\Axrone\\web\\packages\\shapes-2d": + version "0.1.0" + resolved "file:packages/shapes-2d" + dependencies: + "@axrone/numeric" "^0.0.1" + "@axrone/tween@file:E:\\Workspace\\code_training\\gnew\\Arone-Project\\Axrone\\web\\packages\\tween": version "0.1.0" resolved "file:packages/tween" @@ -300,7 +368,7 @@ resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz" integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== -"@babel/parser@^7.25.4": +"@babel/parser@^7.25.4", "@babel/parser@^7.6.0", "@babel/parser@^7.9.6": version "7.27.0" resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz" integrity sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg== @@ -312,7 +380,7 @@ resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz" integrity sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA== -"@babel/types@^7.25.4", "@babel/types@^7.27.0": +"@babel/types@^7.25.4", "@babel/types@^7.27.0", "@babel/types@^7.6.1", "@babel/types@^7.9.6": version "7.27.0" resolved "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz" integrity sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg== @@ -347,6 +415,11 @@ "@types/tough-cookie" "^4.0.5" tough-cookie "^4.1.4" +"@colors/colors@1.5.0": + version "1.5.0" + resolved "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz" + integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== + "@commitlint/cli@^19.8.0": version "19.8.0" resolved "https://registry.npmjs.org/@commitlint/cli/-/cli-19.8.0.tgz" @@ -1016,6 +1089,56 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@jscpd/badge-reporter@4.0.5": + version "4.0.5" + resolved "https://registry.npmjs.org/@jscpd/badge-reporter/-/badge-reporter-4.0.5.tgz" + integrity sha512-SLVhP00R9lkQ//Ivaanfm7k0L9sewpBven670kk1uGec2SWUOa7MVQcuad/TV59KEZ73UIC1lXvi6O9hAnbpUw== + dependencies: + badgen "^3.2.3" + colors "^1.4.0" + fs-extra "^11.2.0" + +"@jscpd/core@4.0.5": + version "4.0.5" + resolved "https://registry.npmjs.org/@jscpd/core/-/core-4.0.5.tgz" + integrity sha512-Udvym21nWzxjYRVXwwpYNBqZ6b50QV2zHN3fFNzOPPg4cfQVYOZerILB7xNDUsXHC1PCr/N52Tq3q7AElvjWWA== + dependencies: + eventemitter3 "^5.0.1" + +"@jscpd/finder@4.0.5": + version "4.0.5" + resolved "https://registry.npmjs.org/@jscpd/finder/-/finder-4.0.5.tgz" + integrity sha512-/2VkRoVrrfya+51sitZo5I9MdwsRaPKB8X3L3khAYoHFXk4L/mUuG81RmGazDHjUIGg22ItlkQtwzorNZ2+aPw== + dependencies: + "@jscpd/core" "4.0.5" + "@jscpd/tokenizer" "4.0.5" + blamer "^1.0.6" + bytes "^3.1.2" + cli-table3 "^0.6.5" + colors "^1.4.0" + fast-glob "^3.3.2" + fs-extra "^11.2.0" + markdown-table "^2.0.0" + pug "^3.0.3" + +"@jscpd/html-reporter@4.0.5": + version "4.0.5" + resolved "https://registry.npmjs.org/@jscpd/html-reporter/-/html-reporter-4.0.5.tgz" + integrity sha512-drK2J8KyPIW9wvaElSIobZFp4dBO9GA++JW4gx3oihvLdDSp8qSo/CNqH47Dw0XkjQTxND3j/+Wz5JWvYRBgFQ== + dependencies: + colors "1.4.0" + fs-extra "^11.2.0" + pug "^3.0.3" + +"@jscpd/tokenizer@4.0.5": + version "4.0.5" + resolved "https://registry.npmjs.org/@jscpd/tokenizer/-/tokenizer-4.0.5.tgz" + integrity sha512-WzRujQtN5WedxZVDKuoanxmKAFrxcLrHpcA6kaM4z8AhGtWXZ325yseqgL5TZ8OK7Auwu7kQLlqhfk05fGYG7A== + dependencies: + "@jscpd/core" "4.0.5" + reprism "^0.0.11" + spark-md5 "^3.0.2" + "@lerna/create@8.2.2": version "8.2.2" resolved "https://registry.npmjs.org/@lerna/create/-/create-8.2.2.tgz" @@ -1856,6 +1979,8 @@ "@tybys/wasm-util@^0.10.1": version "0.10.1" + resolved "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz" + integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== dependencies: tslib "^2.4.0" @@ -1954,6 +2079,11 @@ resolved "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz" integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q== +"@types/sarif@^2.1.7": + version "2.1.7" + resolved "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz" + integrity sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ== + "@types/stats.js@*": version "0.17.4" resolved "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz" @@ -2243,6 +2373,11 @@ acorn-walk@^8.0.2: resolved "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz" integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== +acorn@^7.1.1: + version "7.4.1" + resolved "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz" + integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== + add-stream@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/add-stream/-/add-stream-1.0.0.tgz" @@ -2486,6 +2621,16 @@ arrify@^2.0.1: resolved "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz" integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== +asap@~2.0.3: + version "2.0.6" + resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz" + integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + +assert-never@^1.2.1: + version "1.4.0" + resolved "https://registry.npmjs.org/assert-never/-/assert-never-1.4.0.tgz" + integrity sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA== + assertion-error@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz" @@ -2533,6 +2678,18 @@ axios@^1.8.3: form-data "^4.0.0" proxy-from-env "^1.1.0" +babel-walk@3.0.0-canary-5: + version "3.0.0-canary-5" + resolved "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz" + integrity sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw== + dependencies: + "@babel/types" "^7.9.6" + +badgen@^3.2.3: + version "3.2.3" + resolved "https://registry.npmjs.org/badgen/-/badgen-3.2.3.tgz" + integrity sha512-svDuwkc63E/z0ky3drpUppB83s/nlgDciH9m+STwwQoWyq7yCgew1qEfJ+9axkKdNq7MskByptWUN9j1PGMwFA== + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" @@ -2572,6 +2729,14 @@ bl@^4.0.3, bl@^4.1.0: inherits "^2.0.4" readable-stream "^3.4.0" +blamer@^1.0.6: + version "1.0.7" + resolved "https://registry.npmjs.org/blamer/-/blamer-1.0.7.tgz" + integrity sha512-GbBStl/EVlSWkiJQBZps3H1iARBrC7vt++Jb/TTmCNu/jZ04VW7tSN1nScbFXBUy1AN+jzeL7Zep9sbQxLhXKA== + dependencies: + execa "^4.0.0" + which "^2.0.2" + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz" @@ -2623,6 +2788,11 @@ byte-size@8.1.1: resolved "https://registry.npmjs.org/byte-size/-/byte-size-8.1.1.tgz" integrity sha512-tUkzZWK0M/qdoLEqikxBWe4kumyuwjl3HO6zHTr4yEI23EojPtLYXdG1+AQY7MN0cGyNDvEaJ8wiYQm6P2bPxg== +bytes@^3.1.2: + version "3.1.2" + resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + cac@^6.7.14: version "6.7.14" resolved "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz" @@ -2740,6 +2910,13 @@ chalk@4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +character-parser@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/character-parser/-/character-parser-2.2.0.tgz" + integrity sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw== + dependencies: + is-regex "^1.0.3" + chardet@^0.7.0: version "0.7.0" resolved "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz" @@ -2794,6 +2971,15 @@ cli-spinners@2.6.1: resolved "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.6.1.tgz" integrity sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g== +cli-table3@^0.6.5: + version "0.6.5" + resolved "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz" + integrity sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ== + dependencies: + string-width "^4.2.0" + optionalDependencies: + "@colors/colors" "1.5.0" + cli-truncate@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz" @@ -2871,6 +3057,11 @@ colorette@^2.0.20: resolved "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz" integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== +colors@^1.4.0, colors@1.4.0: + version "1.4.0" + resolved "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz" + integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== + columnify@1.6.0: version "1.6.0" resolved "https://registry.npmjs.org/columnify/-/columnify-1.6.0.tgz" @@ -2916,6 +3107,11 @@ commander@^2.20.0: resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== +commander@^5.0.0: + version "5.1.0" + resolved "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz" + integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== + common-ancestor-path@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz" @@ -2954,6 +3150,14 @@ console-control-strings@^1.1.0: resolved "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz" integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== +constantinople@^4.0.1: + version "4.0.1" + resolved "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz" + integrity sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw== + dependencies: + "@babel/parser" "^7.6.0" + "@babel/types" "^7.6.1" + conventional-changelog-angular@^7.0.0, conventional-changelog-angular@7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz" @@ -3071,7 +3275,7 @@ cosmiconfig@^9.0.0, cosmiconfig@>=9, cosmiconfig@9.0.0: js-yaml "^4.1.0" parse-json "^5.2.0" -cross-spawn@^7.0.3, cross-spawn@^7.0.6: +cross-spawn@^7.0.0, cross-spawn@^7.0.3, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -3279,6 +3483,11 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" +doctypes@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz" + integrity sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ== + dom-accessibility-api@^0.5.9: version "0.5.16" resolved "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz" @@ -3380,7 +3589,7 @@ encoding@^0.1.0, encoding@^0.1.13: dependencies: iconv-lite "^0.6.2" -end-of-stream@^1.4.1: +end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.4" resolved "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz" integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== @@ -3787,9 +3996,24 @@ eventemitter3@^4.0.4: integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== eventemitter3@^5.0.1: - version "5.0.1" - resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz" - integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== + version "5.0.4" + resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz" + integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== + +execa@^4.0.0: + version "4.1.0" + resolved "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz" + integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA== + dependencies: + cross-spawn "^7.0.0" + get-stream "^5.0.0" + human-signals "^1.1.1" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.0" + onetime "^5.1.0" + signal-exit "^3.0.2" + strip-final-newline "^2.0.0" execa@^8.0.1: version "8.0.1" @@ -4046,7 +4270,7 @@ fs-extra@^10.0.0: jsonfile "^6.0.1" universalify "^2.0.0" -fs-extra@^11.2.0: +fs-extra@^11.1.1, fs-extra@^11.2.0: version "11.3.0" resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz" integrity sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew== @@ -4155,6 +4379,13 @@ get-proto@^1.0.0, get-proto@^1.0.1: dunder-proto "^1.0.1" es-object-atoms "^1.0.0" +get-stream@^5.0.0: + version "5.2.0" + resolved "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz" + integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== + dependencies: + pump "^3.0.0" + get-stream@^6.0.0: version "6.0.1" resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz" @@ -4497,6 +4728,11 @@ https-proxy-agent@^7.0.1: agent-base "^7.1.2" debug "4" +human-signals@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz" + integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== + human-signals@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz" @@ -4731,6 +4967,14 @@ is-docker@^2.0.0, is-docker@^2.1.1: resolved "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz" integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== +is-expression@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz" + integrity sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A== + dependencies: + acorn "^7.1.1" + object-assign "^4.1.1" + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz" @@ -4837,6 +5081,11 @@ is-potential-custom-element-name@^1.0.1: resolved "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz" integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== +is-promise@^2.0.0: + version "2.2.2" + resolved "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz" + integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== + is-reference@1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz" @@ -4844,7 +5093,7 @@ is-reference@1.2.1: dependencies: "@types/estree" "*" -is-regex@^1.2.1: +is-regex@^1.0.3, is-regex@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz" integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== @@ -4873,21 +5122,16 @@ is-ssh@^1.4.0: dependencies: protocols "^2.0.1" -is-stream@^2.0.0: - version "2.0.1" - resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz" - integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== +is-stream@^2.0.0, is-stream@2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz" + integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw== is-stream@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz" integrity sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA== -is-stream@2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz" - integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw== - is-string@^1.0.7, is-string@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz" @@ -5060,6 +5304,11 @@ jiti@*, jiti@^2.4.1, jiti@^2.6.1, jiti@>=1.21.0: resolved "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz" integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== +js-stringify@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz" + integrity sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g== + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" @@ -5093,6 +5342,30 @@ jsbn@1.1.0: resolved "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz" integrity sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A== +jscpd-sarif-reporter@4.0.7: + version "4.0.7" + resolved "https://registry.npmjs.org/jscpd-sarif-reporter/-/jscpd-sarif-reporter-4.0.7.tgz" + integrity sha512-Q/VlfTI/Nbjc8dZ/2pDVIf1aRi2bM2CTYujcAoeYr7brRnS4o5ZeW86W8q7MM7cQu40gezlNckl+E9wKFSMFiA== + dependencies: + colors "^1.4.0" + fs-extra "^11.2.0" + node-sarif-builder "^3.4.0" + +jscpd@^4.0.9: + version "4.0.9" + resolved "https://registry.npmjs.org/jscpd/-/jscpd-4.0.9.tgz" + integrity sha512-fp6Sh42W3mIPoQgZmgYmKDLQzEDnnX2vaGlTN4haILkB2vsi+ewcCHEtWR/2CR/QbsBvAvsNo8U5Sa+p9aHiGw== + dependencies: + "@jscpd/badge-reporter" "4.0.5" + "@jscpd/core" "4.0.5" + "@jscpd/finder" "4.0.5" + "@jscpd/html-reporter" "4.0.5" + "@jscpd/tokenizer" "4.0.5" + colors "^1.4.0" + commander "^5.0.0" + fs-extra "^11.2.0" + jscpd-sarif-reporter "4.0.7" + jsdom@*: version "20.0.3" resolved "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz" @@ -5214,6 +5487,14 @@ JSONStream@^1.3.5: jsonparse "^1.2.0" through ">=2.2.7 <3" +jstransformer@1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz" + integrity sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A== + dependencies: + is-promise "^2.0.0" + promise "^7.0.1" + just-diff-apply@^5.2.0: version "5.5.0" resolved "https://registry.npmjs.org/just-diff-apply/-/just-diff-apply-5.5.0.tgz" @@ -5714,6 +5995,13 @@ markdown-it@^14.1.0: punycode.js "^2.3.1" uc.micro "^2.1.0" +markdown-table@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz" + integrity sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A== + dependencies: + repeat-string "^1.0.0" + marked@14.0.0: version "14.0.0" resolved "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz" @@ -6083,6 +6371,14 @@ node-releases@^2.0.36: resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz" integrity sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg== +node-sarif-builder@^3.4.0: + version "3.4.0" + resolved "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.4.0.tgz" + integrity sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg== + dependencies: + "@types/sarif" "^2.1.7" + fs-extra "^11.1.1" + nopt@^7.0.0, nopt@^7.2.1: version "7.2.1" resolved "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz" @@ -6209,7 +6505,7 @@ npm-registry-fetch@^17.0.0, npm-registry-fetch@^17.0.1, npm-registry-fetch@^17.1 npm-package-arg "^11.0.0" proc-log "^4.0.0" -npm-run-path@^4.0.1: +npm-run-path@^4.0.0, npm-run-path@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz" integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== @@ -6279,6 +6575,11 @@ nwsapi@^2.2.2: "@nx/nx-win32-arm64-msvc" "20.8.0" "@nx/nx-win32-x64-msvc" "20.8.0" +object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + object-inspect@^1.13.3: version "1.13.4" resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz" @@ -6330,7 +6631,7 @@ object.values@^1.2.0: define-properties "^1.2.1" es-object-atoms "^1.0.0" -once@^1.4.0: +once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz" integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== @@ -6852,6 +7153,13 @@ promise-retry@^2.0.1: err-code "^2.0.2" retry "^0.12.0" +promise@^7.0.1: + version "7.3.1" + resolved "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz" + integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== + dependencies: + asap "~2.0.3" + promzard@^1.0.0: version "1.0.2" resolved "https://registry.npmjs.org/promzard/-/promzard-1.0.2.tgz" @@ -6876,6 +7184,117 @@ psl@^1.1.33: dependencies: punycode "^2.3.1" +pug-attrs@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/pug-attrs/-/pug-attrs-3.0.0.tgz" + integrity sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA== + dependencies: + constantinople "^4.0.1" + js-stringify "^1.0.2" + pug-runtime "^3.0.0" + +pug-code-gen@^3.0.4: + version "3.0.4" + resolved "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-3.0.4.tgz" + integrity sha512-6okWYIKdasTyXICyEtvobmTZAVX57JkzgzIi4iRJlin8kmhG+Xry2dsus+Mun/nGCn6F2U49haHI5mkELXB14g== + dependencies: + constantinople "^4.0.1" + doctypes "^1.1.0" + js-stringify "^1.0.2" + pug-attrs "^3.0.0" + pug-error "^2.1.0" + pug-runtime "^3.0.1" + void-elements "^3.1.0" + with "^7.0.0" + +pug-error@^2.0.0, pug-error@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/pug-error/-/pug-error-2.1.0.tgz" + integrity sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg== + +pug-filters@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/pug-filters/-/pug-filters-4.0.0.tgz" + integrity sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A== + dependencies: + constantinople "^4.0.1" + jstransformer "1.0.0" + pug-error "^2.0.0" + pug-walk "^2.0.0" + resolve "^1.15.1" + +pug-lexer@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/pug-lexer/-/pug-lexer-5.0.1.tgz" + integrity sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w== + dependencies: + character-parser "^2.2.0" + is-expression "^4.0.0" + pug-error "^2.0.0" + +pug-linker@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/pug-linker/-/pug-linker-4.0.0.tgz" + integrity sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw== + dependencies: + pug-error "^2.0.0" + pug-walk "^2.0.0" + +pug-load@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/pug-load/-/pug-load-3.0.0.tgz" + integrity sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ== + dependencies: + object-assign "^4.1.1" + pug-walk "^2.0.0" + +pug-parser@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/pug-parser/-/pug-parser-6.0.0.tgz" + integrity sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw== + dependencies: + pug-error "^2.0.0" + token-stream "1.0.0" + +pug-runtime@^3.0.0, pug-runtime@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/pug-runtime/-/pug-runtime-3.0.1.tgz" + integrity sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg== + +pug-strip-comments@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz" + integrity sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ== + dependencies: + pug-error "^2.0.0" + +pug-walk@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/pug-walk/-/pug-walk-2.0.0.tgz" + integrity sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ== + +pug@^3.0.3: + version "3.0.4" + resolved "https://registry.npmjs.org/pug/-/pug-3.0.4.tgz" + integrity sha512-kFfq5mMzrS7+wrl5pLJzZEzemx34OQ0w4SARfhy/3yxTlhbstsudDwJzhf1hP02yHzbjoVMSXUj/Sz6RNfMyXg== + dependencies: + pug-code-gen "^3.0.4" + pug-filters "^4.0.0" + pug-lexer "^5.0.1" + pug-linker "^4.0.0" + pug-load "^3.0.0" + pug-parser "^6.0.0" + pug-runtime "^3.0.1" + pug-strip-comments "^2.0.0" + +pump@^3.0.0: + version "3.0.4" + resolved "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz" + integrity sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + punycode.js@^2.3.1: version "2.3.1" resolved "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz" @@ -7035,6 +7454,16 @@ regexp.prototype.flags@^1.5.3: gopd "^1.2.0" set-function-name "^2.0.2" +repeat-string@^1.0.0: + version "1.6.1" + resolved "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz" + integrity sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w== + +reprism@^0.0.11: + version "0.0.11" + resolved "https://registry.npmjs.org/reprism/-/reprism-0.0.11.tgz" + integrity sha512-VsxDR5QxZo08M/3nRypNlScw5r3rKeSOPdU/QhDmu3Ai3BJxHn/qgfXGWQp/tAxUtzwYNo9W6997JZR0tPLZsA== + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz" @@ -7077,7 +7506,7 @@ resolve.exports@2.0.3: resolved "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz" integrity sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A== -resolve@^1.10.0, resolve@^1.22.1, resolve@^1.22.4: +resolve@^1.10.0, resolve@^1.15.1, resolve@^1.22.1, resolve@^1.22.4: version "1.22.10" resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz" integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== @@ -7501,6 +7930,11 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: resolved "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== +spark-md5@^3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz" + integrity sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw== + spdx-correct@^3.0.0: version "3.2.0" resolved "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz" @@ -7909,6 +8343,11 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +token-stream@1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz" + integrity sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg== + totalist@^3.0.0: version "3.0.1" resolved "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz" @@ -8284,6 +8723,11 @@ vitest@^2.1.8, vitest@2.1.9: vite-node "2.1.9" why-is-node-running "^2.3.0" +void-elements@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz" + integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w== + w3c-xmlserializer@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz" @@ -8401,6 +8845,13 @@ which@^2.0.1: dependencies: isexe "^2.0.0" +which@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + which@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/which/-/which-4.0.0.tgz" @@ -8423,6 +8874,16 @@ wide-align@1.1.5: dependencies: string-width "^1.0.2 || 2 || 3 || 4" +with@^7.0.0: + version "7.0.2" + resolved "https://registry.npmjs.org/with/-/with-7.0.2.tgz" + integrity sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w== + dependencies: + "@babel/parser" "^7.9.6" + "@babel/types" "^7.9.6" + assert-never "^1.2.1" + babel-walk "3.0.0-canary-5" + word-wrap@^1.2.5: version "1.2.5" resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz"