@aura3d/scene 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +17 -0
  2. package/dist/Bounds.d.ts +18 -0
  3. package/dist/Bounds.d.ts.map +1 -0
  4. package/dist/Bounds.js +55 -0
  5. package/dist/Bounds.js.map +1 -0
  6. package/dist/Camera.d.ts +22 -0
  7. package/dist/Camera.d.ts.map +1 -0
  8. package/dist/Camera.js +44 -0
  9. package/dist/Camera.js.map +1 -0
  10. package/dist/DirectionalLight.d.ts +7 -0
  11. package/dist/DirectionalLight.d.ts.map +1 -0
  12. package/dist/DirectionalLight.js +12 -0
  13. package/dist/DirectionalLight.js.map +1 -0
  14. package/dist/Hierarchy.d.ts +6 -0
  15. package/dist/Hierarchy.d.ts.map +1 -0
  16. package/dist/Hierarchy.js +19 -0
  17. package/dist/Hierarchy.js.map +1 -0
  18. package/dist/Layers.d.ts +8 -0
  19. package/dist/Layers.d.ts.map +1 -0
  20. package/dist/Layers.js +21 -0
  21. package/dist/Layers.js.map +1 -0
  22. package/dist/Light.d.ts +14 -0
  23. package/dist/Light.d.ts.map +1 -0
  24. package/dist/Light.js +23 -0
  25. package/dist/Light.js.map +1 -0
  26. package/dist/Lights.d.ts +6 -0
  27. package/dist/Lights.d.ts.map +1 -0
  28. package/dist/Lights.js +5 -0
  29. package/dist/Lights.js.map +1 -0
  30. package/dist/MathTypes.d.ts +52 -0
  31. package/dist/MathTypes.d.ts.map +1 -0
  32. package/dist/MathTypes.js +119 -0
  33. package/dist/MathTypes.js.map +1 -0
  34. package/dist/Object3D.d.ts +48 -0
  35. package/dist/Object3D.d.ts.map +1 -0
  36. package/dist/Object3D.js +122 -0
  37. package/dist/Object3D.js.map +1 -0
  38. package/dist/OrthographicCamera.d.ts +29 -0
  39. package/dist/OrthographicCamera.d.ts.map +1 -0
  40. package/dist/OrthographicCamera.js +59 -0
  41. package/dist/OrthographicCamera.js.map +1 -0
  42. package/dist/PerspectiveCamera.d.ts +20 -0
  43. package/dist/PerspectiveCamera.d.ts.map +1 -0
  44. package/dist/PerspectiveCamera.js +42 -0
  45. package/dist/PerspectiveCamera.js.map +1 -0
  46. package/dist/PointLight.d.ts +10 -0
  47. package/dist/PointLight.d.ts.map +1 -0
  48. package/dist/PointLight.js +22 -0
  49. package/dist/PointLight.js.map +1 -0
  50. package/dist/Renderable.d.ts +30 -0
  51. package/dist/Renderable.d.ts.map +1 -0
  52. package/dist/Renderable.js +54 -0
  53. package/dist/Renderable.js.map +1 -0
  54. package/dist/Scene.d.ts +45 -0
  55. package/dist/Scene.d.ts.map +1 -0
  56. package/dist/Scene.js +117 -0
  57. package/dist/Scene.js.map +1 -0
  58. package/dist/SceneMetadata.d.ts +79 -0
  59. package/dist/SceneMetadata.d.ts.map +1 -0
  60. package/dist/SceneMetadata.js +130 -0
  61. package/dist/SceneMetadata.js.map +1 -0
  62. package/dist/SceneNode.d.ts +42 -0
  63. package/dist/SceneNode.d.ts.map +1 -0
  64. package/dist/SceneNode.js +126 -0
  65. package/dist/SceneNode.js.map +1 -0
  66. package/dist/SceneQuery.d.ts +16 -0
  67. package/dist/SceneQuery.d.ts.map +1 -0
  68. package/dist/SceneQuery.js +37 -0
  69. package/dist/SceneQuery.js.map +1 -0
  70. package/dist/SceneSerializer.d.ts +59 -0
  71. package/dist/SceneSerializer.d.ts.map +1 -0
  72. package/dist/SceneSerializer.js +127 -0
  73. package/dist/SceneSerializer.js.map +1 -0
  74. package/dist/SpotLight.d.ts +14 -0
  75. package/dist/SpotLight.d.ts.map +1 -0
  76. package/dist/SpotLight.js +35 -0
  77. package/dist/SpotLight.js.map +1 -0
  78. package/dist/TransformNode.d.ts +26 -0
  79. package/dist/TransformNode.d.ts.map +1 -0
  80. package/dist/TransformNode.js +98 -0
  81. package/dist/TransformNode.js.map +1 -0
  82. package/dist/index.d.ts +21 -0
  83. package/dist/index.d.ts.map +1 -0
  84. package/dist/index.js +21 -0
  85. package/dist/index.js.map +1 -0
  86. package/package.json +18 -0
  87. package/src/Bounds.ts +68 -0
  88. package/src/Camera.ts +56 -0
  89. package/src/DirectionalLight.ts +13 -0
  90. package/src/Hierarchy.ts +20 -0
  91. package/src/Layers.ts +24 -0
  92. package/src/Light.ts +27 -0
  93. package/src/Lights.ts +5 -0
  94. package/src/MathTypes.ts +157 -0
  95. package/src/Object3D.ts +149 -0
  96. package/src/OrthographicCamera.ts +73 -0
  97. package/src/PerspectiveCamera.ts +54 -0
  98. package/src/PointLight.ts +25 -0
  99. package/src/Renderable.ts +71 -0
  100. package/src/Scene.ts +134 -0
  101. package/src/SceneMetadata.ts +225 -0
  102. package/src/SceneNode.ts +139 -0
  103. package/src/SceneQuery.ts +39 -0
  104. package/src/SceneSerializer.ts +177 -0
  105. package/src/SpotLight.ts +39 -0
  106. package/src/TransformNode.ts +116 -0
  107. package/src/index.ts +20 -0
  108. package/tests/scene.test.ts +117 -0
  109. package/tsconfig.json +9 -0
@@ -0,0 +1,225 @@
1
+ import { ValidationError } from "@aura3d/core";
2
+
3
+ export type SceneMetadataSeed = string | number;
4
+ export type SceneMetadataPlanKind =
5
+ | "animation"
6
+ | "camera"
7
+ | "environment"
8
+ | "interaction"
9
+ | "lighting"
10
+ | "physics"
11
+ | "postprocess"
12
+ | "rendering";
13
+ export type SceneMetadataCapabilityStatus = "supported" | "unsupported" | "approximation" | "route-local";
14
+
15
+ export interface SceneAssetProvenance {
16
+ readonly id: string;
17
+ readonly uri?: string;
18
+ readonly source?: string;
19
+ readonly loader?: string;
20
+ readonly version?: string;
21
+ readonly digest?: string;
22
+ readonly license?: string;
23
+ readonly nodeIds?: readonly string[];
24
+ readonly materialIds?: readonly string[];
25
+ readonly notes?: string;
26
+ }
27
+
28
+ export interface SceneMaterialAssignment {
29
+ readonly nodeId: string;
30
+ readonly materialId: string;
31
+ readonly slot?: string | number;
32
+ readonly sourceMaterialId?: string;
33
+ readonly fallback?: boolean;
34
+ readonly unsupportedFeatures?: readonly string[];
35
+ }
36
+
37
+ export interface ScenePlanMetadata {
38
+ readonly id: string;
39
+ readonly kind: SceneMetadataPlanKind;
40
+ readonly description: string;
41
+ readonly owner?: string;
42
+ readonly nodeIds?: readonly string[];
43
+ readonly deterministicSeed?: SceneMetadataSeed;
44
+ readonly unsupportedFeatures?: readonly string[];
45
+ readonly evidence?: readonly string[];
46
+ }
47
+
48
+ export interface SceneCapabilityDisclosure {
49
+ readonly feature: string;
50
+ readonly status: SceneMetadataCapabilityStatus;
51
+ readonly reason?: string;
52
+ readonly owner?: string;
53
+ readonly evidence?: readonly string[];
54
+ }
55
+
56
+ export interface SceneRevisionMetadata {
57
+ readonly id?: string;
58
+ readonly parentRevisionId?: string;
59
+ readonly summary: string;
60
+ readonly author?: string;
61
+ readonly timestamp?: string;
62
+ readonly deterministicSeed?: SceneMetadataSeed;
63
+ }
64
+
65
+ export type SceneRevisionRecord = Required<Pick<SceneRevisionMetadata, "id" | "summary">> & SceneRevisionMetadata;
66
+
67
+ export interface SceneMetadataOptions {
68
+ readonly deterministicSeed?: SceneMetadataSeed;
69
+ readonly assets?: readonly SceneAssetProvenance[];
70
+ readonly materialAssignments?: readonly SceneMaterialAssignment[];
71
+ readonly plans?: readonly ScenePlanMetadata[];
72
+ readonly capabilities?: readonly SceneCapabilityDisclosure[];
73
+ readonly revisions?: readonly SceneRevisionMetadata[];
74
+ }
75
+
76
+ export type SerializedSceneMetadata = SceneMetadataOptions;
77
+
78
+ export class SceneMetadata {
79
+ deterministicSeed?: SceneMetadataSeed;
80
+ readonly assets: SceneAssetProvenance[] = [];
81
+ readonly materialAssignments: SceneMaterialAssignment[] = [];
82
+ readonly plans: ScenePlanMetadata[] = [];
83
+ readonly capabilities: SceneCapabilityDisclosure[] = [];
84
+ readonly revisions: SceneRevisionRecord[] = [];
85
+
86
+ constructor(options: SceneMetadataOptions = {}) {
87
+ this.replace(options);
88
+ }
89
+
90
+ replace(options: SceneMetadataOptions | SceneMetadata): this {
91
+ this.deterministicSeed = options.deterministicSeed;
92
+ this.assets.length = 0;
93
+ this.materialAssignments.length = 0;
94
+ this.plans.length = 0;
95
+ this.capabilities.length = 0;
96
+ this.revisions.length = 0;
97
+ for (const asset of options.assets ?? []) this.registerAsset(asset);
98
+ for (const assignment of options.materialAssignments ?? []) this.assignMaterial(assignment);
99
+ for (const plan of options.plans ?? []) this.addPlan(plan);
100
+ for (const capability of options.capabilities ?? []) this.discloseCapability(capability);
101
+ for (const revision of options.revisions ?? []) this.recordRevision(revision);
102
+ return this;
103
+ }
104
+
105
+ clone(): SceneMetadata {
106
+ return new SceneMetadata(this.toJSON());
107
+ }
108
+
109
+ get isEmpty(): boolean {
110
+ return this.deterministicSeed === undefined
111
+ && this.assets.length === 0
112
+ && this.materialAssignments.length === 0
113
+ && this.plans.length === 0
114
+ && this.capabilities.length === 0
115
+ && this.revisions.length === 0;
116
+ }
117
+
118
+ registerAsset(asset: SceneAssetProvenance): this {
119
+ requireNonEmpty(asset.id, "asset id");
120
+ upsertByKey(this.assets, cloneAsset(asset), (candidate) => candidate.id === asset.id);
121
+ return this;
122
+ }
123
+
124
+ assignMaterial(assignment: SceneMaterialAssignment): this {
125
+ requireNonEmpty(assignment.nodeId, "material assignment nodeId");
126
+ requireNonEmpty(assignment.materialId, "material assignment materialId");
127
+ const cloned = cloneMaterialAssignment(assignment);
128
+ upsertByKey(
129
+ this.materialAssignments,
130
+ cloned,
131
+ (candidate) => candidate.nodeId === cloned.nodeId && candidate.slot === cloned.slot
132
+ );
133
+ return this;
134
+ }
135
+
136
+ addPlan(plan: ScenePlanMetadata): this {
137
+ requireNonEmpty(plan.id, "plan id");
138
+ requireNonEmpty(plan.description, "plan description");
139
+ upsertByKey(this.plans, clonePlan(plan), (candidate) => candidate.id === plan.id);
140
+ return this;
141
+ }
142
+
143
+ discloseCapability(disclosure: SceneCapabilityDisclosure): this {
144
+ requireNonEmpty(disclosure.feature, "capability feature");
145
+ upsertByKey(this.capabilities, cloneCapability(disclosure), (candidate) => candidate.feature === disclosure.feature);
146
+ return this;
147
+ }
148
+
149
+ discloseUnsupportedFeature(
150
+ feature: string,
151
+ reason: string,
152
+ options: Omit<SceneCapabilityDisclosure, "feature" | "reason" | "status"> = {}
153
+ ): this {
154
+ return this.discloseCapability({ ...options, feature, reason, status: "unsupported" });
155
+ }
156
+
157
+ recordRevision(revision: SceneRevisionMetadata): this {
158
+ requireNonEmpty(revision.summary, "revision summary");
159
+ const id = revision.id ?? `revision-${this.revisions.length + 1}`;
160
+ upsertByKey(this.revisions, cloneRevision({ ...revision, id }), (candidate) => candidate.id === id);
161
+ return this;
162
+ }
163
+
164
+ unsupportedFeatures(): readonly SceneCapabilityDisclosure[] {
165
+ return this.capabilities.filter((capability) => capability.status === "unsupported");
166
+ }
167
+
168
+ toJSON(): SerializedSceneMetadata {
169
+ return {
170
+ ...(this.deterministicSeed !== undefined ? { deterministicSeed: this.deterministicSeed } : {}),
171
+ ...(this.assets.length > 0 ? { assets: this.assets.map(cloneAsset) } : {}),
172
+ ...(this.materialAssignments.length > 0 ? { materialAssignments: this.materialAssignments.map(cloneMaterialAssignment) } : {}),
173
+ ...(this.plans.length > 0 ? { plans: this.plans.map(clonePlan) } : {}),
174
+ ...(this.capabilities.length > 0 ? { capabilities: this.capabilities.map(cloneCapability) } : {}),
175
+ ...(this.revisions.length > 0 ? { revisions: this.revisions.map(cloneRevision) } : {})
176
+ };
177
+ }
178
+ }
179
+
180
+ function requireNonEmpty(value: string | undefined, label: string): void {
181
+ if (!value || value.trim().length === 0) {
182
+ throw new ValidationError("SCENE_METADATA", `Scene metadata ${label} must be a non-empty string.`);
183
+ }
184
+ }
185
+
186
+ function upsertByKey<T>(items: T[], value: T, matches: (candidate: T) => boolean): void {
187
+ const index = items.findIndex(matches);
188
+ if (index >= 0) items[index] = value;
189
+ else items.push(value);
190
+ }
191
+
192
+ function cloneAsset(asset: SceneAssetProvenance): SceneAssetProvenance {
193
+ return {
194
+ ...asset,
195
+ ...(asset.nodeIds ? { nodeIds: [...asset.nodeIds] } : {}),
196
+ ...(asset.materialIds ? { materialIds: [...asset.materialIds] } : {})
197
+ };
198
+ }
199
+
200
+ function cloneMaterialAssignment(assignment: SceneMaterialAssignment): SceneMaterialAssignment {
201
+ return {
202
+ ...assignment,
203
+ ...(assignment.unsupportedFeatures ? { unsupportedFeatures: [...assignment.unsupportedFeatures] } : {})
204
+ };
205
+ }
206
+
207
+ function clonePlan(plan: ScenePlanMetadata): ScenePlanMetadata {
208
+ return {
209
+ ...plan,
210
+ ...(plan.nodeIds ? { nodeIds: [...plan.nodeIds] } : {}),
211
+ ...(plan.unsupportedFeatures ? { unsupportedFeatures: [...plan.unsupportedFeatures] } : {}),
212
+ ...(plan.evidence ? { evidence: [...plan.evidence] } : {})
213
+ };
214
+ }
215
+
216
+ function cloneCapability(capability: SceneCapabilityDisclosure): SceneCapabilityDisclosure {
217
+ return {
218
+ ...capability,
219
+ ...(capability.evidence ? { evidence: [...capability.evidence] } : {})
220
+ };
221
+ }
222
+
223
+ function cloneRevision(revision: SceneRevisionMetadata): SceneRevisionRecord {
224
+ return { ...revision, id: revision.id ?? "revision-1" };
225
+ }
@@ -0,0 +1,139 @@
1
+ import { ValidationError } from "@aura3d/core";
2
+ import { Bounds3 } from "./Bounds.js";
3
+ import { cloneMat4, invertMat4, multiplyMat4 } from "./MathTypes.js";
4
+ import type { Renderable } from "./Renderable.js";
5
+ import { TransformNode } from "./TransformNode.js";
6
+
7
+ let nextSceneNodeId = 1;
8
+
9
+ export interface SceneNodeOptions {
10
+ id?: string;
11
+ name?: string;
12
+ }
13
+
14
+ export interface AddChildOptions {
15
+ preserveWorldTransform?: boolean;
16
+ }
17
+
18
+ export interface DisposeNodeOptions {
19
+ recursive?: boolean;
20
+ }
21
+
22
+ export class SceneNode {
23
+ readonly id: string;
24
+ name: string;
25
+ visible = true;
26
+ layerMask = 1;
27
+ renderOrder = 0;
28
+ disposed = false;
29
+ userData: Record<string, unknown> = {};
30
+ renderable?: Renderable;
31
+ readonly transform = new TransformNode();
32
+ localBounds: Bounds3 | null = null;
33
+ worldBounds = new Bounds3();
34
+ readonly children: SceneNode[] = [];
35
+ parent: SceneNode | null = null;
36
+
37
+ constructor(options: SceneNodeOptions = {}) {
38
+ this.id = options.id ?? `node-${nextSceneNodeId++}`;
39
+ this.name = options.name ?? this.id;
40
+ this.transform.onDirty(() => this.markWorldDirty());
41
+ }
42
+
43
+ addChild(child: SceneNode, options: AddChildOptions = {}): this {
44
+ this.assertAlive();
45
+ child.assertAlive();
46
+ assertCanParent(this, child);
47
+ if (child.parent === this) return this;
48
+ const preservedWorld = options.preserveWorldTransform ? cloneMat4(child.transform.worldMatrix) : undefined;
49
+ if (preservedWorld) this.updateWorldTransform();
50
+ child.parent?.removeChild(child);
51
+ this.children.push(child);
52
+ child.parent = this;
53
+ if (preservedWorld) child.transform.setFromLocalMatrix(multiplyMat4(invertMat4(this.transform.worldMatrix), preservedWorld));
54
+ child.markWorldDirty();
55
+ return this;
56
+ }
57
+
58
+ removeChild(child: SceneNode): boolean {
59
+ this.assertAlive();
60
+ const index = this.children.indexOf(child);
61
+ if (index < 0) return false;
62
+ this.children.splice(index, 1);
63
+ child.parent = null;
64
+ child.markWorldDirty();
65
+ return true;
66
+ }
67
+
68
+ removeFromParent(): boolean {
69
+ return this.parent?.removeChild(this) ?? false;
70
+ }
71
+
72
+ isAncestorOf(candidate: SceneNode): boolean {
73
+ let current = candidate.parent;
74
+ while (current) {
75
+ if (current === this) return true;
76
+ current = current.parent;
77
+ }
78
+ return false;
79
+ }
80
+
81
+ traverse(visitor: (node: SceneNode) => void): void {
82
+ visitor(this);
83
+ for (const child of [...this.children]) child.traverse(visitor);
84
+ }
85
+
86
+ updateWorldTransform(force = false): void {
87
+ const changed = this.transform.updateWorld(this.parent?.transform.worldMatrix, force);
88
+ for (const child of this.children) child.updateWorldTransform(force || changed);
89
+ }
90
+
91
+ updateWorldBounds(): Bounds3 {
92
+ let bounds = this.localBounds ? this.localBounds.transform(this.transform.worldMatrix) : new Bounds3();
93
+ for (const child of this.children) bounds = bounds.union(child.updateWorldBounds());
94
+ this.worldBounds = bounds;
95
+ return this.worldBounds;
96
+ }
97
+
98
+ setLocalBounds(bounds: Bounds3 | null): this {
99
+ this.assertAlive();
100
+ this.localBounds = bounds?.clone() ?? null;
101
+ this.worldBounds = this.localBounds ? this.localBounds.transform(this.transform.worldMatrix) : new Bounds3();
102
+ return this;
103
+ }
104
+
105
+ dispose(options: DisposeNodeOptions = {}): void {
106
+ if (this.disposed) return;
107
+ const recursive = options.recursive ?? true;
108
+ const children = [...this.children];
109
+ if (recursive) {
110
+ for (const child of children) child.dispose(options);
111
+ } else {
112
+ for (const child of children) child.parent = null;
113
+ }
114
+ this.children.length = 0;
115
+ this.parent?.removeChild(this);
116
+ this.parent = null;
117
+ this.renderable = undefined;
118
+ this.localBounds = null;
119
+ this.userData = {};
120
+ this.visible = false;
121
+ this.disposed = true;
122
+ }
123
+
124
+ markWorldDirty(): void {
125
+ for (const child of this.children) {
126
+ child.transform.markDirty();
127
+ child.markWorldDirty();
128
+ }
129
+ }
130
+
131
+ protected assertAlive(): void {
132
+ if (this.disposed) throw new ValidationError("DISPOSED_SCENE_NODE", `Scene node ${this.name} is disposed.`);
133
+ }
134
+ }
135
+
136
+ export function assertCanParent(parent: SceneNode, child: SceneNode): void {
137
+ if (parent === child) throw new ValidationError("SCENE_SELF_PARENT", "A scene node cannot be parented to itself.");
138
+ if (child.isAncestorOf(parent)) throw new ValidationError("SCENE_HIERARCHY_CYCLE", "Scene hierarchy cycle rejected.");
139
+ }
@@ -0,0 +1,39 @@
1
+ import { Bounds3 } from "./Bounds.js";
2
+ import { Camera } from "./Camera.js";
3
+ import { Light } from "./Light.js";
4
+ import { Renderable } from "./Renderable.js";
5
+ import { SceneNode } from "./SceneNode.js";
6
+
7
+ export interface SceneQueryOptions {
8
+ name?: string | RegExp;
9
+ visible?: boolean;
10
+ layerMask?: number;
11
+ type?: "camera" | "light" | "node";
12
+ bounds?: Bounds3;
13
+ }
14
+
15
+ export function queryScene(root: SceneNode, options: SceneQueryOptions): SceneNode[] {
16
+ const result: SceneNode[] = [];
17
+ root.traverse((node) => {
18
+ if (options.visible !== undefined && node.visible !== options.visible) return;
19
+ if (options.layerMask !== undefined && (node.layerMask & options.layerMask) === 0) return;
20
+ if (options.name !== undefined) {
21
+ const matches = typeof options.name === "string" ? node.name === options.name : options.name.test(node.name);
22
+ if (!matches) return;
23
+ }
24
+ if (options.type === "camera" && !(node instanceof Camera)) return;
25
+ if (options.type === "light" && !(node instanceof Light)) return;
26
+ if (options.bounds && !node.worldBounds.intersects(options.bounds)) return;
27
+ result.push(node);
28
+ });
29
+ return result;
30
+ }
31
+
32
+ export function collectRenderables(root: SceneNode): { node: SceneNode; renderable: Renderable }[] {
33
+ const result: { node: SceneNode; renderable: Renderable }[] = [];
34
+ root.traverse((node) => {
35
+ const maybe = node.renderable;
36
+ if (maybe && node.visible) result.push({ node, renderable: maybe });
37
+ });
38
+ return result;
39
+ }
@@ -0,0 +1,177 @@
1
+ import { ValidationError } from "@aura3d/core";
2
+ import { DirectionalLight } from "./DirectionalLight.js";
3
+ import { OrthographicCamera } from "./OrthographicCamera.js";
4
+ import { PerspectiveCamera } from "./PerspectiveCamera.js";
5
+ import { PointLight } from "./PointLight.js";
6
+ import { Renderable } from "./Renderable.js";
7
+ import { Scene } from "./Scene.js";
8
+ import type { SerializedSceneMetadata } from "./SceneMetadata.js";
9
+ import { SceneNode } from "./SceneNode.js";
10
+ import { SpotLight } from "./SpotLight.js";
11
+
12
+ export interface SerializedSceneNode {
13
+ id: string;
14
+ name: string;
15
+ visible: boolean;
16
+ layerMask: number;
17
+ kind: "node" | "perspectiveCamera" | "orthographicCamera" | "directionalLight" | "pointLight" | "spotLight";
18
+ transform: {
19
+ position: [number, number, number];
20
+ rotation: [number, number, number, number];
21
+ scale: [number, number, number];
22
+ };
23
+ renderable?: {
24
+ geometry: string;
25
+ material: string;
26
+ layerMask: number;
27
+ castShadow: boolean;
28
+ receiveShadow: boolean;
29
+ morphWeights?: readonly number[];
30
+ instanceTransforms?: readonly number[];
31
+ instanceColors?: readonly number[];
32
+ };
33
+ camera?: {
34
+ fovYRadians?: number;
35
+ aspect?: number;
36
+ left?: number;
37
+ right?: number;
38
+ bottom?: number;
39
+ top?: number;
40
+ near: number;
41
+ far: number;
42
+ zoom?: number;
43
+ resizeMode?: "fit-vertical" | "fit-horizontal" | "preserve-frustum";
44
+ viewport: { x: number; y: number; width: number; height: number };
45
+ };
46
+ light?: {
47
+ color: [number, number, number];
48
+ intensity: number;
49
+ castsShadow: boolean;
50
+ range?: number;
51
+ angle?: number;
52
+ penumbra?: number;
53
+ };
54
+ children: SerializedSceneNode[];
55
+ }
56
+
57
+ export interface SerializedScene {
58
+ version: 1;
59
+ metadata?: SerializedSceneMetadata;
60
+ root: SerializedSceneNode;
61
+ }
62
+
63
+ export function serializeScene(scene: Scene): SerializedScene {
64
+ return {
65
+ version: 1,
66
+ ...(scene.metadata.isEmpty ? {} : { metadata: scene.metadata.toJSON() }),
67
+ root: serializeNode(scene.root)
68
+ };
69
+ }
70
+
71
+ export function deserializeScene(serialized: SerializedScene): Scene {
72
+ if (serialized.version !== 1) throw new ValidationError("SCENE_VERSION", `Unsupported scene version: ${serialized.version}`);
73
+ const scene = new Scene();
74
+ if (serialized.metadata) scene.metadata.replace(serialized.metadata);
75
+ for (const child of serialized.root.children) {
76
+ const node = deserializeNode(child);
77
+ scene.root.addChild(node);
78
+ scene.registerSubtree(node);
79
+ }
80
+ return scene;
81
+ }
82
+
83
+ function serializeNode(node: SceneNode): SerializedSceneNode {
84
+ const renderable = node.renderable;
85
+ return {
86
+ id: node.id,
87
+ name: node.name,
88
+ visible: node.visible,
89
+ layerMask: node.layerMask,
90
+ kind: node instanceof PerspectiveCamera ? "perspectiveCamera" : node instanceof OrthographicCamera ? "orthographicCamera" : node instanceof DirectionalLight ? "directionalLight" : node instanceof PointLight ? "pointLight" : node instanceof SpotLight ? "spotLight" : "node",
91
+ transform: {
92
+ position: [...node.transform.position],
93
+ rotation: [...node.transform.rotation],
94
+ scale: [...node.transform.scale]
95
+ },
96
+ renderable: renderable ? {
97
+ geometry: renderable.geometry,
98
+ material: renderable.material,
99
+ layerMask: renderable.layerMask,
100
+ castShadow: renderable.castShadow,
101
+ receiveShadow: renderable.receiveShadow,
102
+ ...(renderable.morphWeights.length > 0 ? { morphWeights: [...renderable.morphWeights] } : {}),
103
+ ...(renderable.instanceTransforms ? { instanceTransforms: [...renderable.instanceTransforms] } : {}),
104
+ ...(renderable.instanceColors ? { instanceColors: [...renderable.instanceColors] } : {})
105
+ } : undefined,
106
+ camera: serializeCamera(node),
107
+ light: serializeLight(node),
108
+ children: node.children.map(serializeNode)
109
+ };
110
+ }
111
+
112
+ function deserializeNode(data: SerializedSceneNode): SceneNode {
113
+ const node =
114
+ data.kind === "perspectiveCamera" ? new PerspectiveCamera({ id: data.id, name: data.name, fovYRadians: data.camera?.fovYRadians, aspect: data.camera?.aspect, near: data.camera?.near, far: data.camera?.far }) :
115
+ data.kind === "orthographicCamera" ? new OrthographicCamera({ id: data.id, name: data.name, left: data.camera?.left, right: data.camera?.right, bottom: data.camera?.bottom, top: data.camera?.top, near: data.camera?.near, far: data.camera?.far, zoom: data.camera?.zoom, resizeMode: data.camera?.resizeMode }) :
116
+ data.kind === "directionalLight" ? new DirectionalLight(data.name, data.id) :
117
+ data.kind === "pointLight" ? new PointLight(data.name, data.id) :
118
+ data.kind === "spotLight" ? new SpotLight(data.name, data.id) :
119
+ new SceneNode({ id: data.id, name: data.name });
120
+ node.visible = data.visible;
121
+ node.layerMask = data.layerMask;
122
+ node.transform.setPosition(...data.transform.position);
123
+ node.transform.setRotation(...data.transform.rotation);
124
+ node.transform.setScale(...data.transform.scale);
125
+ if (data.camera && (node instanceof PerspectiveCamera || node instanceof OrthographicCamera)) node.setViewport(data.camera.viewport);
126
+ if (data.light && (node instanceof DirectionalLight || node instanceof PointLight || node instanceof SpotLight)) {
127
+ node.color = [...data.light!.color];
128
+ node.intensity = data.light!.intensity;
129
+ node.castsShadow = data.light!.castsShadow;
130
+ }
131
+ if (data.light?.range !== undefined && (node instanceof PointLight || node instanceof SpotLight)) node.range = data.light.range;
132
+ if (data.light?.angle !== undefined && node instanceof SpotLight) node.angle = data.light.angle;
133
+ if (data.light?.penumbra !== undefined && node instanceof SpotLight) node.penumbra = data.light.penumbra;
134
+ if (data.renderable) node.renderable = new Renderable(data.renderable);
135
+ for (const child of data.children) node.addChild(deserializeNode(child));
136
+ return node;
137
+ }
138
+
139
+ function serializeCamera(node: SceneNode): SerializedSceneNode["camera"] {
140
+ if (node instanceof PerspectiveCamera) {
141
+ return {
142
+ fovYRadians: node.fovYRadians,
143
+ aspect: node.aspect,
144
+ near: node.near,
145
+ far: node.far,
146
+ viewport: { ...node.viewport }
147
+ };
148
+ }
149
+ if (node instanceof OrthographicCamera) {
150
+ return {
151
+ left: node.left,
152
+ right: node.right,
153
+ bottom: node.bottom,
154
+ top: node.top,
155
+ near: node.near,
156
+ far: node.far,
157
+ zoom: node.zoom,
158
+ resizeMode: node.resizeMode,
159
+ viewport: { ...node.viewport }
160
+ };
161
+ }
162
+ return undefined;
163
+ }
164
+
165
+ function serializeLight(node: SceneNode): SerializedSceneNode["light"] {
166
+ if (node instanceof DirectionalLight || node instanceof PointLight || node instanceof SpotLight) {
167
+ const color = Array.isArray(node.color) ? [...node.color] as [number, number, number] : [node.color.x, node.color.y, node.color.z] as [number, number, number];
168
+ return {
169
+ color,
170
+ intensity: node.intensity,
171
+ castsShadow: node.castsShadow,
172
+ ...(node instanceof PointLight || node instanceof SpotLight ? { range: node.range } : {}),
173
+ ...(node instanceof SpotLight ? { angle: node.angle, penumbra: node.penumbra } : {})
174
+ };
175
+ }
176
+ return undefined;
177
+ }
@@ -0,0 +1,39 @@
1
+ import { ValidationError } from "@aura3d/core";
2
+ import { Light } from "./Light.js";
3
+
4
+ export class SpotLight extends Light {
5
+ private currentAngle = Math.PI / 4;
6
+ private currentPenumbra = 0;
7
+ private currentRange = 10;
8
+
9
+ constructor(name = "SpotLight", id?: string) {
10
+ super("spot", name, id);
11
+ }
12
+
13
+ get angle(): number {
14
+ return this.currentAngle;
15
+ }
16
+
17
+ set angle(value: number) {
18
+ if (!(value > 0 && value < Math.PI / 2)) throw new ValidationError("SPOT_LIGHT_ANGLE", "Spot light angle must be within (0, PI / 2).");
19
+ this.currentAngle = value;
20
+ }
21
+
22
+ get penumbra(): number {
23
+ return this.currentPenumbra;
24
+ }
25
+
26
+ set penumbra(value: number) {
27
+ if (!(value >= 0 && value <= 1)) throw new ValidationError("SPOT_LIGHT_PENUMBRA", "Spot light penumbra must be within [0, 1].");
28
+ this.currentPenumbra = value;
29
+ }
30
+
31
+ get range(): number {
32
+ return this.currentRange;
33
+ }
34
+
35
+ set range(value: number) {
36
+ if (!(value > 0) || !Number.isFinite(value)) throw new ValidationError("SPOT_LIGHT_RANGE", "Spot light range must be positive.");
37
+ this.currentRange = value;
38
+ }
39
+ }