@aura3d/editor-runtime 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 (149) hide show
  1. package/README.md +20 -0
  2. package/dist/Command.d.ts +11 -0
  3. package/dist/Command.d.ts.map +1 -0
  4. package/dist/Command.js +2 -0
  5. package/dist/Command.js.map +1 -0
  6. package/dist/CommandHistory.d.ts +20 -0
  7. package/dist/CommandHistory.d.ts.map +1 -0
  8. package/dist/CommandHistory.js +124 -0
  9. package/dist/CommandHistory.js.map +1 -0
  10. package/dist/DiagnosticsOverlayModel.d.ts +30 -0
  11. package/dist/DiagnosticsOverlayModel.d.ts.map +1 -0
  12. package/dist/DiagnosticsOverlayModel.js +57 -0
  13. package/dist/DiagnosticsOverlayModel.js.map +1 -0
  14. package/dist/EditorPluginHost.d.ts +46 -0
  15. package/dist/EditorPluginHost.d.ts.map +1 -0
  16. package/dist/EditorPluginHost.js +57 -0
  17. package/dist/EditorPluginHost.js.map +1 -0
  18. package/dist/EditorRuntime.d.ts +95 -0
  19. package/dist/EditorRuntime.d.ts.map +1 -0
  20. package/dist/EditorRuntime.js +277 -0
  21. package/dist/EditorRuntime.js.map +1 -0
  22. package/dist/EditorStateModel.d.ts +93 -0
  23. package/dist/EditorStateModel.d.ts.map +1 -0
  24. package/dist/EditorStateModel.js +251 -0
  25. package/dist/EditorStateModel.js.map +1 -0
  26. package/dist/Gizmo.d.ts +43 -0
  27. package/dist/Gizmo.d.ts.map +1 -0
  28. package/dist/Gizmo.js +60 -0
  29. package/dist/Gizmo.js.map +1 -0
  30. package/dist/HierarchyModel.d.ts +21 -0
  31. package/dist/HierarchyModel.d.ts.map +1 -0
  32. package/dist/HierarchyModel.js +32 -0
  33. package/dist/HierarchyModel.js.map +1 -0
  34. package/dist/InspectorModel.d.ts +16 -0
  35. package/dist/InspectorModel.d.ts.map +1 -0
  36. package/dist/InspectorModel.js +53 -0
  37. package/dist/InspectorModel.js.map +1 -0
  38. package/dist/LocalizationAccessibilityFixtures.d.ts +70 -0
  39. package/dist/LocalizationAccessibilityFixtures.d.ts.map +1 -0
  40. package/dist/LocalizationAccessibilityFixtures.js +275 -0
  41. package/dist/LocalizationAccessibilityFixtures.js.map +1 -0
  42. package/dist/MaterialVariantWorkflow.d.ts +19 -0
  43. package/dist/MaterialVariantWorkflow.d.ts.map +1 -0
  44. package/dist/MaterialVariantWorkflow.js +82 -0
  45. package/dist/MaterialVariantWorkflow.js.map +1 -0
  46. package/dist/PickingService.d.ts +58 -0
  47. package/dist/PickingService.d.ts.map +1 -0
  48. package/dist/PickingService.js +119 -0
  49. package/dist/PickingService.js.map +1 -0
  50. package/dist/PlayModeBridge.d.ts +14 -0
  51. package/dist/PlayModeBridge.d.ts.map +1 -0
  52. package/dist/PlayModeBridge.js +23 -0
  53. package/dist/PlayModeBridge.js.map +1 -0
  54. package/dist/PrefabRegistry.d.ts +41 -0
  55. package/dist/PrefabRegistry.d.ts.map +1 -0
  56. package/dist/PrefabRegistry.js +119 -0
  57. package/dist/PrefabRegistry.js.map +1 -0
  58. package/dist/ProjectSerializer.d.ts +82 -0
  59. package/dist/ProjectSerializer.d.ts.map +1 -0
  60. package/dist/ProjectSerializer.js +131 -0
  61. package/dist/ProjectSerializer.js.map +1 -0
  62. package/dist/RotateGizmo.d.ts +5 -0
  63. package/dist/RotateGizmo.d.ts.map +1 -0
  64. package/dist/RotateGizmo.js +37 -0
  65. package/dist/RotateGizmo.js.map +1 -0
  66. package/dist/ScaleGizmo.d.ts +5 -0
  67. package/dist/ScaleGizmo.d.ts.map +1 -0
  68. package/dist/ScaleGizmo.js +42 -0
  69. package/dist/ScaleGizmo.js.map +1 -0
  70. package/dist/Selection.d.ts +20 -0
  71. package/dist/Selection.d.ts.map +1 -0
  72. package/dist/Selection.js +46 -0
  73. package/dist/Selection.js.map +1 -0
  74. package/dist/ShaderGraphModel.d.ts +69 -0
  75. package/dist/ShaderGraphModel.d.ts.map +1 -0
  76. package/dist/ShaderGraphModel.js +172 -0
  77. package/dist/ShaderGraphModel.js.map +1 -0
  78. package/dist/StaticExportRuntime.d.ts +13 -0
  79. package/dist/StaticExportRuntime.d.ts.map +1 -0
  80. package/dist/StaticExportRuntime.js +307 -0
  81. package/dist/StaticExportRuntime.js.map +1 -0
  82. package/dist/TimelineModel.d.ts +175 -0
  83. package/dist/TimelineModel.d.ts.map +1 -0
  84. package/dist/TimelineModel.js +335 -0
  85. package/dist/TimelineModel.js.map +1 -0
  86. package/dist/TimelineRuntimeBridge.d.ts +98 -0
  87. package/dist/TimelineRuntimeBridge.d.ts.map +1 -0
  88. package/dist/TimelineRuntimeBridge.js +186 -0
  89. package/dist/TimelineRuntimeBridge.js.map +1 -0
  90. package/dist/TranslateGizmo.d.ts +5 -0
  91. package/dist/TranslateGizmo.d.ts.map +1 -0
  92. package/dist/TranslateGizmo.js +41 -0
  93. package/dist/TranslateGizmo.js.map +1 -0
  94. package/dist/commands/CreateNodeCommand.d.ts +14 -0
  95. package/dist/commands/CreateNodeCommand.d.ts.map +1 -0
  96. package/dist/commands/CreateNodeCommand.js +16 -0
  97. package/dist/commands/CreateNodeCommand.js.map +1 -0
  98. package/dist/commands/DeleteNodeCommand.d.ts +13 -0
  99. package/dist/commands/DeleteNodeCommand.d.ts.map +1 -0
  100. package/dist/commands/DeleteNodeCommand.js +55 -0
  101. package/dist/commands/DeleteNodeCommand.js.map +1 -0
  102. package/dist/commands/ReparentNodeCommand.d.ts +24 -0
  103. package/dist/commands/ReparentNodeCommand.d.ts.map +1 -0
  104. package/dist/commands/ReparentNodeCommand.js +39 -0
  105. package/dist/commands/ReparentNodeCommand.js.map +1 -0
  106. package/dist/commands/SetPropertyCommand.d.ts +14 -0
  107. package/dist/commands/SetPropertyCommand.d.ts.map +1 -0
  108. package/dist/commands/SetPropertyCommand.js +43 -0
  109. package/dist/commands/SetPropertyCommand.js.map +1 -0
  110. package/dist/commands/TransformCommand.d.ts +42 -0
  111. package/dist/commands/TransformCommand.d.ts.map +1 -0
  112. package/dist/commands/TransformCommand.js +72 -0
  113. package/dist/commands/TransformCommand.js.map +1 -0
  114. package/dist/index.d.ts +49 -0
  115. package/dist/index.d.ts.map +1 -0
  116. package/dist/index.js +29 -0
  117. package/dist/index.js.map +1 -0
  118. package/package.json +17 -0
  119. package/src/Command.ts +11 -0
  120. package/src/CommandHistory.ts +136 -0
  121. package/src/DiagnosticsOverlayModel.ts +85 -0
  122. package/src/EditorPluginHost.ts +106 -0
  123. package/src/EditorRuntime.ts +349 -0
  124. package/src/EditorStateModel.ts +336 -0
  125. package/src/Gizmo.ts +101 -0
  126. package/src/HierarchyModel.ts +57 -0
  127. package/src/InspectorModel.ts +71 -0
  128. package/src/LocalizationAccessibilityFixtures.ts +368 -0
  129. package/src/MaterialVariantWorkflow.ts +101 -0
  130. package/src/PickingService.ts +174 -0
  131. package/src/PlayModeBridge.ts +27 -0
  132. package/src/PrefabRegistry.ts +164 -0
  133. package/src/ProjectSerializer.ts +200 -0
  134. package/src/RotateGizmo.ts +35 -0
  135. package/src/ScaleGizmo.ts +40 -0
  136. package/src/Selection.ts +63 -0
  137. package/src/ShaderGraphModel.ts +254 -0
  138. package/src/StaticExportRuntime.ts +322 -0
  139. package/src/TimelineModel.ts +466 -0
  140. package/src/TimelineRuntimeBridge.ts +280 -0
  141. package/src/TranslateGizmo.ts +39 -0
  142. package/src/commands/CreateNodeCommand.ts +23 -0
  143. package/src/commands/DeleteNodeCommand.ts +74 -0
  144. package/src/commands/ReparentNodeCommand.ts +55 -0
  145. package/src/commands/SetPropertyCommand.ts +47 -0
  146. package/src/commands/TransformCommand.ts +107 -0
  147. package/src/index.ts +85 -0
  148. package/tests/editor-runtime.test.ts +597 -0
  149. package/tsconfig.json +7 -0
@@ -0,0 +1,368 @@
1
+ export type EditorLocaleDirection = "ltr" | "rtl";
2
+ export type EditorPluralCategory = "zero" | "one" | "two" | "few" | "many" | "other";
3
+ export type EditorAccessibilityRole = "button" | "navigation" | "slider" | "textbox" | "status" | "dialog";
4
+
5
+ export interface EditorLocaleDescriptor {
6
+ readonly code: string;
7
+ readonly displayName: string;
8
+ readonly direction: EditorLocaleDirection;
9
+ readonly currencySymbol: string;
10
+ readonly decimalSeparator: string;
11
+ readonly thousandsSeparator: string;
12
+ readonly currencyPosition: "before" | "after";
13
+ }
14
+
15
+ export interface EditorLocalizedStringSample {
16
+ readonly locale: string;
17
+ readonly direction: EditorLocaleDirection;
18
+ readonly title: string;
19
+ readonly assetCount: string;
20
+ readonly fallbackTitle: string;
21
+ readonly missingKey: string;
22
+ readonly formattedNumber: string;
23
+ readonly formattedCurrency: string;
24
+ readonly pluralCategory: EditorPluralCategory;
25
+ }
26
+
27
+ export interface EditorAccessibilityElementSample {
28
+ readonly id: string;
29
+ readonly role: EditorAccessibilityRole;
30
+ readonly label: string;
31
+ readonly tabIndex: number;
32
+ readonly focusable: boolean;
33
+ readonly disabled?: boolean;
34
+ readonly ariaLive?: "polite" | "assertive";
35
+ readonly contrastRatio: number;
36
+ readonly contrastPassesAA: boolean;
37
+ }
38
+
39
+ export interface EditorLocalizationAccessibilityFixture {
40
+ readonly source: "origin-master-localization-ui-accessibility-adapted";
41
+ readonly sourceFiles: readonly string[];
42
+ readonly localeCount: number;
43
+ readonly rtlLocaleCount: number;
44
+ readonly tableKeyCount: number;
45
+ readonly jsonLoaderValidated: boolean;
46
+ readonly csvLoaderValidated: boolean;
47
+ readonly hotSwapLocale: {
48
+ readonly from: string;
49
+ readonly to: string;
50
+ readonly directionChanged: boolean;
51
+ readonly listenerNotifications: readonly string[];
52
+ };
53
+ readonly samples: readonly EditorLocalizedStringSample[];
54
+ readonly missingKeys: readonly string[];
55
+ readonly accessibility: {
56
+ readonly elementCount: number;
57
+ readonly labelledControls: number;
58
+ readonly focusOrder: readonly string[];
59
+ readonly focusWalk: readonly string[];
60
+ readonly liveRegionAnnouncements: readonly string[];
61
+ readonly highContrastMode: boolean;
62
+ readonly minContrastRatio: number;
63
+ readonly aaContrastPasses: boolean;
64
+ };
65
+ readonly blockedClaims: readonly string[];
66
+ readonly claimBoundary: string;
67
+ readonly hash: string;
68
+ }
69
+
70
+ type StringTable = Record<string, string>;
71
+
72
+ const sourceFiles = [
73
+ "origin/master:src/localization/LocalizationManager.ts",
74
+ "origin/master:src/localization/StringTable.ts",
75
+ "origin/master:src/localization/Locale.ts",
76
+ "origin/master:src/localization/Pluralization.ts",
77
+ "origin/master:src/localization/loaders/JSONLocaleLoader.ts",
78
+ "origin/master:src/localization/loaders/CSVLocaleLoader.ts",
79
+ "origin/master:src/ui/UIAccessibility.ts"
80
+ ] as const;
81
+
82
+ const blockedClaims = [
83
+ "full localization pipeline",
84
+ "ICU MessageFormat parity",
85
+ "bidirectional text shaping parity",
86
+ "screen-reader certification",
87
+ "WCAG conformance certification",
88
+ "Unity UI Toolkit parity",
89
+ "Unreal UMG parity"
90
+ ] as const;
91
+
92
+ const locales: readonly EditorLocaleDescriptor[] = [
93
+ {
94
+ code: "en-US",
95
+ displayName: "English (United States)",
96
+ direction: "ltr",
97
+ currencySymbol: "$",
98
+ decimalSeparator: ".",
99
+ thousandsSeparator: ",",
100
+ currencyPosition: "before"
101
+ },
102
+ {
103
+ code: "es-ES",
104
+ displayName: "Spanish (Spain)",
105
+ direction: "ltr",
106
+ currencySymbol: "EUR",
107
+ decimalSeparator: ",",
108
+ thousandsSeparator: ".",
109
+ currencyPosition: "after"
110
+ },
111
+ {
112
+ code: "ar-SA",
113
+ displayName: "Arabic (Saudi Arabia)",
114
+ direction: "rtl",
115
+ currencySymbol: "SAR",
116
+ decimalSeparator: ".",
117
+ thousandsSeparator: ",",
118
+ currencyPosition: "after"
119
+ }
120
+ ] as const;
121
+
122
+ const stringTables: Record<string, StringTable> = {
123
+ "en-US": {
124
+ "editor.title": "Aura3D Editor",
125
+ "assets.count.one": "{count} imported asset",
126
+ "assets.count.other": "{count} imported assets",
127
+ "status.saved": "Saved",
128
+ "panel.inspector": "Inspector"
129
+ },
130
+ "es-ES": {
131
+ "editor.title": "Editor Aura3D",
132
+ "assets.count.one": "{count} recurso importado",
133
+ "assets.count.other": "{count} recursos importados",
134
+ "status.saved": "Guardado"
135
+ },
136
+ "ar-SA": {
137
+ "editor.title": "Aura3D RTL Editor",
138
+ "assets.count.zero": "no imported assets",
139
+ "assets.count.one": "{count} imported asset",
140
+ "assets.count.two": "{count} imported assets",
141
+ "assets.count.few": "{count} imported assets",
142
+ "assets.count.many": "{count} imported assets",
143
+ "assets.count.other": "{count} imported assets",
144
+ "status.saved": "Saved RTL"
145
+ }
146
+ };
147
+
148
+ const accessibilityElements: readonly EditorAccessibilityElementSample[] = [
149
+ createAccessibilityElement("command-menu", "navigation", "Command menu", 1, "#f7fafc", "#101820"),
150
+ createAccessibilityElement("viewport", "status", "Editor WebGL viewport", 2, "#f5f7fb", "#07111c", "polite"),
151
+ createAccessibilityElement("inspector-name", "textbox", "Selected node name", 3, "#111827", "#f9fafb"),
152
+ createAccessibilityElement("timeline-scrub", "slider", "Timeline scrub time", 4, "#0f172a", "#e0f2fe"),
153
+ createAccessibilityElement("export-project", "button", "Export Project", 5, "#ffffff", "#1d4ed8")
154
+ ];
155
+
156
+ export function sampleLocalizationAccessibilityFixture(options: {
157
+ readonly assetCount?: number;
158
+ readonly hotSwapFrom?: string;
159
+ readonly hotSwapTo?: string;
160
+ } = {}): EditorLocalizationAccessibilityFixture {
161
+ const assetCount = Math.max(0, Math.floor(options.assetCount ?? 2));
162
+ const hotSwapFrom = options.hotSwapFrom ?? "en-US";
163
+ const hotSwapTo = options.hotSwapTo ?? "ar-SA";
164
+ const fromLocale = requireLocale(hotSwapFrom);
165
+ const toLocale = requireLocale(hotSwapTo);
166
+ const listenerNotifications = [fromLocale.code, toLocale.code];
167
+ const samples = locales.map((locale) => sampleLocale(locale, assetCount));
168
+ const missingKeys = findMissingKeys(stringTables["en-US"] ?? {}, stringTables["es-ES"] ?? {});
169
+ const focusOrder = accessibilityElements
170
+ .filter((element) => element.focusable && !element.disabled)
171
+ .sort((left, right) => left.tabIndex - right.tabIndex)
172
+ .map((element) => element.id);
173
+ const focusWalk = [focusOrder[0], focusOrder[1], focusOrder[2], focusOrder[1]].filter((id): id is string => Boolean(id));
174
+ const minContrastRatio = Math.min(...accessibilityElements.map((element) => element.contrastRatio));
175
+ const fixtureWithoutHash = {
176
+ source: "origin-master-localization-ui-accessibility-adapted" as const,
177
+ sourceFiles,
178
+ localeCount: locales.length,
179
+ rtlLocaleCount: locales.filter((locale) => locale.direction === "rtl").length,
180
+ tableKeyCount: Object.values(stringTables).reduce((sum, table) => sum + Object.keys(table).length, 0),
181
+ jsonLoaderValidated: validateJsonTableShape(stringTables),
182
+ csvLoaderValidated: validateCsvTableShape(toCsv(stringTables)),
183
+ hotSwapLocale: {
184
+ from: fromLocale.code,
185
+ to: toLocale.code,
186
+ directionChanged: fromLocale.direction !== toLocale.direction,
187
+ listenerNotifications
188
+ },
189
+ samples,
190
+ missingKeys,
191
+ accessibility: {
192
+ elementCount: accessibilityElements.length,
193
+ labelledControls: accessibilityElements.filter((element) => element.label.trim().length > 0).length,
194
+ focusOrder,
195
+ focusWalk,
196
+ liveRegionAnnouncements: ["Focused: Command menu", "Focused: Editor WebGL viewport", "Saved"],
197
+ highContrastMode: true,
198
+ minContrastRatio,
199
+ aaContrastPasses: accessibilityElements.every((element) => element.contrastPassesAA)
200
+ },
201
+ blockedClaims,
202
+ claimBoundary: "This fixture ports bounded old localization and UI accessibility concepts into deterministic editor-runtime evidence. It does not certify WCAG compliance, native assistive technology behavior, ICU parity, or Unity/Unreal UI parity."
203
+ };
204
+ return {
205
+ ...fixtureWithoutHash,
206
+ hash: stableHash(JSON.stringify(fixtureWithoutHash))
207
+ };
208
+ }
209
+
210
+ function sampleLocale(locale: EditorLocaleDescriptor, assetCount: number): EditorLocalizedStringSample {
211
+ const pluralCategory = cardinalPluralCategory(locale.code, assetCount);
212
+ const table = stringTables[locale.code] ?? {};
213
+ const fallback = stringTables["en-US"] ?? {};
214
+ return {
215
+ locale: locale.code,
216
+ direction: locale.direction,
217
+ title: lookup(table, fallback, "editor.title", {}),
218
+ assetCount: lookup(table, fallback, `assets.count.${pluralCategory}`, { count: assetCount }),
219
+ fallbackTitle: lookup(table, fallback, "panel.inspector", {}),
220
+ missingKey: lookup(table, fallback, "missing.key", {}),
221
+ formattedNumber: formatNumber(locale, 1234.5, 2),
222
+ formattedCurrency: formatCurrency(locale, 1234.5),
223
+ pluralCategory
224
+ };
225
+ }
226
+
227
+ function lookup(table: StringTable, fallback: StringTable, key: string, params: Record<string, number | string>): string {
228
+ const template = table[key] ?? fallback[key];
229
+ if (!template) return `[${key}]`;
230
+ return template.replace(/\{([^}]+)\}/g, (match, name) => String(params[name] ?? match));
231
+ }
232
+
233
+ function cardinalPluralCategory(localeCode: string, count: number): EditorPluralCategory {
234
+ const language = localeCode.split("-")[0] ?? localeCode;
235
+ if (language === "ar") {
236
+ if (count === 0) return "zero";
237
+ if (count === 1) return "one";
238
+ if (count === 2) return "two";
239
+ if (count % 100 >= 3 && count % 100 <= 10) return "few";
240
+ if (count % 100 >= 11 && count % 100 <= 99) return "many";
241
+ return "other";
242
+ }
243
+ return count === 1 ? "one" : "other";
244
+ }
245
+
246
+ function formatNumber(locale: EditorLocaleDescriptor, value: number, decimals: number): string {
247
+ const fixed = value.toFixed(decimals);
248
+ const [integer = "0", decimal = ""] = fixed.split(".");
249
+ const grouped = integer.replace(/\B(?=(\d{3})+(?!\d))/g, locale.thousandsSeparator);
250
+ return decimal.length > 0 ? `${grouped}${locale.decimalSeparator}${decimal}` : grouped;
251
+ }
252
+
253
+ function formatCurrency(locale: EditorLocaleDescriptor, value: number): string {
254
+ const formatted = formatNumber(locale, value, 2);
255
+ return locale.currencyPosition === "before" ? `${locale.currencySymbol}${formatted}` : `${formatted} ${locale.currencySymbol}`;
256
+ }
257
+
258
+ function findMissingKeys(reference: StringTable, target: StringTable): readonly string[] {
259
+ return Object.keys(reference).filter((key) => target[key] === undefined).sort();
260
+ }
261
+
262
+ function requireLocale(code: string): EditorLocaleDescriptor {
263
+ const locale = locales.find((candidate) => candidate.code === code);
264
+ if (!locale) throw new Error(`Unknown editor locale fixture: ${code}`);
265
+ return locale;
266
+ }
267
+
268
+ function validateJsonTableShape(tables: Record<string, StringTable>): boolean {
269
+ return Object.values(tables).every((table) => Object.values(table).every((value) => typeof value === "string" && value.length > 0));
270
+ }
271
+
272
+ function validateCsvTableShape(csv: string): boolean {
273
+ const rows = csv.split("\n").filter((line) => line.trim().length > 0);
274
+ if (rows.length < 2) return false;
275
+ const expectedColumns = parseCsvLine(rows[0] ?? "").length;
276
+ return expectedColumns >= 2 && rows.slice(1).every((line) => parseCsvLine(line).length === expectedColumns);
277
+ }
278
+
279
+ function toCsv(tables: Record<string, StringTable>): string {
280
+ const localeCodes = Object.keys(tables).sort();
281
+ const keys = [...new Set(localeCodes.flatMap((locale) => Object.keys(tables[locale] ?? {})))].sort();
282
+ return [
283
+ ["Key", ...localeCodes].join(","),
284
+ ...keys.map((key) => [key, ...localeCodes.map((locale) => escapeCsv(tables[locale]?.[key] ?? ""))].join(","))
285
+ ].join("\n");
286
+ }
287
+
288
+ function parseCsvLine(line: string): readonly string[] {
289
+ const columns: string[] = [];
290
+ let current = "";
291
+ let quoted = false;
292
+ for (let index = 0; index < line.length; index += 1) {
293
+ const char = line[index];
294
+ const next = line[index + 1];
295
+ if (char === "\"") {
296
+ if (quoted && next === "\"") {
297
+ current += "\"";
298
+ index += 1;
299
+ } else {
300
+ quoted = !quoted;
301
+ }
302
+ } else if (char === "," && !quoted) {
303
+ columns.push(current);
304
+ current = "";
305
+ } else {
306
+ current += char;
307
+ }
308
+ }
309
+ columns.push(current);
310
+ return columns;
311
+ }
312
+
313
+ function escapeCsv(value: string): string {
314
+ return value.includes(",") || value.includes("\"") || value.includes("\n") ? `"${value.replace(/"/g, "\"\"")}"` : value;
315
+ }
316
+
317
+ function createAccessibilityElement(
318
+ id: string,
319
+ role: EditorAccessibilityRole,
320
+ label: string,
321
+ tabIndex: number,
322
+ foreground: string,
323
+ background: string,
324
+ ariaLive?: "polite" | "assertive"
325
+ ): EditorAccessibilityElementSample {
326
+ const contrastRatio = Number(contrast(foreground, background).toFixed(2));
327
+ return {
328
+ id,
329
+ role,
330
+ label,
331
+ tabIndex,
332
+ focusable: true,
333
+ ...(ariaLive ? { ariaLive } : {}),
334
+ contrastRatio,
335
+ contrastPassesAA: contrastRatio >= 4.5
336
+ };
337
+ }
338
+
339
+ function contrast(foreground: string, background: string): number {
340
+ const foregroundLum = luminance(hexToRgb(foreground));
341
+ const backgroundLum = luminance(hexToRgb(background));
342
+ const light = Math.max(foregroundLum, backgroundLum);
343
+ const dark = Math.min(foregroundLum, backgroundLum);
344
+ return (light + 0.05) / (dark + 0.05);
345
+ }
346
+
347
+ function hexToRgb(hex: string): readonly [number, number, number] {
348
+ const normalized = hex.replace("#", "");
349
+ return [
350
+ Number.parseInt(normalized.slice(0, 2), 16) / 255,
351
+ Number.parseInt(normalized.slice(2, 4), 16) / 255,
352
+ Number.parseInt(normalized.slice(4, 6), 16) / 255
353
+ ];
354
+ }
355
+
356
+ function luminance(rgb: readonly [number, number, number]): number {
357
+ const [r, g, b] = rgb.map((channel) => channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
358
+ return 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (b ?? 0);
359
+ }
360
+
361
+ function stableHash(value: string): string {
362
+ let hash = 0x811c9dc5;
363
+ for (let index = 0; index < value.length; index += 1) {
364
+ hash ^= value.charCodeAt(index);
365
+ hash = Math.imul(hash, 0x01000193);
366
+ }
367
+ return (hash >>> 0).toString(16).padStart(8, "0");
368
+ }
@@ -0,0 +1,101 @@
1
+ export interface MaterialVariantState {
2
+ readonly assetId: string;
3
+ readonly variants: readonly string[];
4
+ readonly selected: string | null;
5
+ }
6
+
7
+ export interface MaterialVariantRenderOptions {
8
+ readonly materialVariant?: string;
9
+ }
10
+
11
+ export class MaterialVariantWorkflow {
12
+ private readonly states = new Map<string, MaterialVariantState>();
13
+
14
+ register(assetId: string, variants: readonly string[], selected: string | null = null): MaterialVariantState {
15
+ const normalizedAssetId = normalizeAssetId(assetId);
16
+ const normalizedVariants = normalizeVariants(variants);
17
+ const normalizedSelected = selected === null ? null : normalizeVariantName(selected);
18
+ if (normalizedSelected !== null && !normalizedVariants.includes(normalizedSelected)) {
19
+ throw new Error(`Material variant ${normalizedSelected} is not available for asset ${normalizedAssetId}.`);
20
+ }
21
+ const state = {
22
+ assetId: normalizedAssetId,
23
+ variants: normalizedVariants,
24
+ selected: normalizedSelected
25
+ };
26
+ this.states.set(normalizedAssetId, state);
27
+ return state;
28
+ }
29
+
30
+ unregister(assetId: string): void {
31
+ this.states.delete(normalizeAssetId(assetId));
32
+ }
33
+
34
+ select(assetId: string, variant: string | null): MaterialVariantState {
35
+ const normalizedAssetId = normalizeAssetId(assetId);
36
+ const state = this.states.get(normalizedAssetId);
37
+ if (!state) {
38
+ throw new Error(`Material variant asset ${normalizedAssetId} is not registered.`);
39
+ }
40
+ const selected = variant === null ? null : normalizeVariantName(variant);
41
+ if (selected !== null && !state.variants.includes(selected)) {
42
+ throw new Error(`Material variant ${selected} is not available for asset ${normalizedAssetId}.`);
43
+ }
44
+ const next = { ...state, selected };
45
+ this.states.set(normalizedAssetId, next);
46
+ return next;
47
+ }
48
+
49
+ state(assetId: string): MaterialVariantState {
50
+ const normalizedAssetId = normalizeAssetId(assetId);
51
+ const state = this.states.get(normalizedAssetId);
52
+ if (!state) {
53
+ throw new Error(`Material variant asset ${normalizedAssetId} is not registered.`);
54
+ }
55
+ return state;
56
+ }
57
+
58
+ snapshot(): readonly MaterialVariantState[] {
59
+ return [...this.states.values()].map((state) => ({
60
+ assetId: state.assetId,
61
+ variants: [...state.variants],
62
+ selected: state.selected
63
+ }));
64
+ }
65
+
66
+ renderOptions(assetId: string): MaterialVariantRenderOptions {
67
+ const selected = this.state(assetId).selected;
68
+ return selected === null ? {} : { materialVariant: selected };
69
+ }
70
+
71
+ clear(): void {
72
+ this.states.clear();
73
+ }
74
+ }
75
+
76
+ function normalizeAssetId(assetId: string): string {
77
+ const normalized = assetId.trim();
78
+ if (normalized.length === 0) {
79
+ throw new Error("Material variant asset id cannot be empty.");
80
+ }
81
+ return normalized;
82
+ }
83
+
84
+ function normalizeVariantName(variant: string): string {
85
+ const normalized = variant.trim();
86
+ if (normalized.length === 0) {
87
+ throw new Error("Material variant name cannot be empty.");
88
+ }
89
+ return normalized;
90
+ }
91
+
92
+ function normalizeVariants(variants: readonly string[]): readonly string[] {
93
+ if (variants.length === 0) {
94
+ throw new Error("Material variant list cannot be empty.");
95
+ }
96
+ const normalized = variants.map(normalizeVariantName);
97
+ if (new Set(normalized).size !== normalized.length) {
98
+ throw new Error("Material variant list cannot contain duplicate names.");
99
+ }
100
+ return normalized;
101
+ }
@@ -0,0 +1,174 @@
1
+ import type { Ray } from "@aura3d/math";
2
+ import type { SceneNode } from "@aura3d/scene";
3
+
4
+ type Tuple3 = readonly [number, number, number];
5
+
6
+ export interface EditorPickTarget {
7
+ readonly id: string;
8
+ readonly node: SceneNode;
9
+ readonly bounds: {
10
+ readonly min: Tuple3;
11
+ readonly max: Tuple3;
12
+ };
13
+ }
14
+
15
+ export interface EditorPickHit {
16
+ readonly target: EditorPickTarget;
17
+ readonly distance: number;
18
+ }
19
+
20
+ export interface EditorPickingColorId {
21
+ readonly targetId: string;
22
+ readonly encodedId: number;
23
+ readonly color: readonly [number, number, number, number];
24
+ }
25
+
26
+ export interface EditorPickingEvidenceSnapshot {
27
+ readonly source: "origin-master-gpu-picking-adapted";
28
+ readonly registeredTargetCount: number;
29
+ readonly width: number;
30
+ readonly height: number;
31
+ readonly needsUpdate: boolean;
32
+ readonly idCapacity: number;
33
+ readonly sampleColorId: EditorPickingColorId | null;
34
+ readonly decodedSampleTargetId: string | null;
35
+ readonly colorIds: readonly EditorPickingColorId[];
36
+ readonly evidence: {
37
+ readonly colorIdEncoding: boolean;
38
+ readonly colorIdDecoding: boolean;
39
+ readonly framebufferResizeBoundary: boolean;
40
+ readonly raycastFallback: boolean;
41
+ };
42
+ readonly blockedClaims: readonly string[];
43
+ }
44
+
45
+ export class PickingService {
46
+ private readonly targets = new Map<string, EditorPickTarget>();
47
+ private width = 1024;
48
+ private height = 768;
49
+ private needsUpdate = true;
50
+
51
+ setTargets(targets: readonly EditorPickTarget[]): void {
52
+ this.targets.clear();
53
+ for (const target of targets) {
54
+ this.targets.set(target.id, target);
55
+ }
56
+ this.needsUpdate = true;
57
+ }
58
+
59
+ addTarget(target: EditorPickTarget): void {
60
+ this.targets.set(target.id, target);
61
+ this.needsUpdate = true;
62
+ }
63
+
64
+ removeTarget(id: string): void {
65
+ this.targets.delete(id);
66
+ this.needsUpdate = true;
67
+ }
68
+
69
+ pick(ray: Ray): EditorPickHit | undefined {
70
+ const hit = [...this.targets.values()]
71
+ .map((target) => ({ target, distance: intersectBounds(ray, target.bounds) }))
72
+ .filter((hit): hit is EditorPickHit => hit.distance !== undefined)
73
+ .sort((a, b) => a.distance - b.distance)[0];
74
+ this.needsUpdate = false;
75
+ return hit;
76
+ }
77
+
78
+ resizePickingBuffer(width: number, height: number): void {
79
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
80
+ throw new RangeError("Picking buffer dimensions must be positive integers.");
81
+ }
82
+ this.width = width;
83
+ this.height = height;
84
+ this.needsUpdate = true;
85
+ }
86
+
87
+ invalidatePickingBuffer(): void {
88
+ this.needsUpdate = true;
89
+ }
90
+
91
+ colorIdForTarget(targetId: string): EditorPickingColorId | undefined {
92
+ const ids = this.colorIds();
93
+ return ids.find((entry) => entry.targetId === targetId);
94
+ }
95
+
96
+ targetIdFromColor(color: readonly [number, number, number] | readonly [number, number, number, number]): string | undefined {
97
+ const encodedId = PickingService.colorToId(color[0] ?? 0, color[1] ?? 0, color[2] ?? 0);
98
+ const entry = this.colorIds().find((candidate) => candidate.encodedId === encodedId);
99
+ return entry?.targetId;
100
+ }
101
+
102
+ snapshot(): EditorPickingEvidenceSnapshot {
103
+ const colorIds = this.colorIds();
104
+ const sampleColorId = colorIds[0] ?? null;
105
+ const decodedSampleTargetId = sampleColorId ? this.targetIdFromColor(sampleColorId.color) ?? null : null;
106
+ return {
107
+ source: "origin-master-gpu-picking-adapted",
108
+ registeredTargetCount: this.targets.size,
109
+ width: this.width,
110
+ height: this.height,
111
+ needsUpdate: this.needsUpdate,
112
+ idCapacity: 0xffffff,
113
+ sampleColorId,
114
+ decodedSampleTargetId,
115
+ colorIds,
116
+ evidence: {
117
+ colorIdEncoding: colorIds.every((entry) => entry.color[3] === 255 && entry.encodedId >= 1),
118
+ colorIdDecoding: sampleColorId !== null && decodedSampleTargetId === sampleColorId.targetId,
119
+ framebufferResizeBoundary: this.width > 0 && this.height > 0,
120
+ raycastFallback: true
121
+ },
122
+ blockedClaims: [
123
+ "production GPU framebuffer picking pass",
124
+ "depth-buffer world-position reconstruction",
125
+ "GPU picking performance parity with Unity Scene View",
126
+ "GPU picking performance parity with Unreal Editor viewport"
127
+ ]
128
+ };
129
+ }
130
+
131
+ static idToColor(id: number): readonly [number, number, number, number] {
132
+ if (!Number.isInteger(id) || id < 1 || id > 0xffffff) {
133
+ throw new RangeError("Picking color id must be an integer in the 1..16777215 range.");
134
+ }
135
+ return [id & 0xff, (id >> 8) & 0xff, (id >> 16) & 0xff, 255];
136
+ }
137
+
138
+ static colorToId(red: number, green: number, blue: number): number {
139
+ return (red & 0xff) | ((green & 0xff) << 8) | ((blue & 0xff) << 16);
140
+ }
141
+
142
+ private colorIds(): readonly EditorPickingColorId[] {
143
+ return [...this.targets.keys()].map((targetId, index) => {
144
+ const encodedId = index + 1;
145
+ return { targetId, encodedId, color: PickingService.idToColor(encodedId) };
146
+ });
147
+ }
148
+ }
149
+
150
+ function intersectBounds(ray: Ray, bounds: EditorPickTarget["bounds"]): number | undefined {
151
+ let tmin = Number.NEGATIVE_INFINITY;
152
+ let tmax = Number.POSITIVE_INFINITY;
153
+
154
+ for (const axis of [0, 1, 2] as const) {
155
+ const origin = axis === 0 ? ray.origin.x : axis === 1 ? ray.origin.y : ray.origin.z;
156
+ const direction = axis === 0 ? ray.direction.x : axis === 1 ? ray.direction.y : ray.direction.z;
157
+ const min = bounds.min[axis];
158
+ const max = bounds.max[axis];
159
+ if (Math.abs(direction) < 1e-8) {
160
+ if (origin < min || origin > max) return undefined;
161
+ continue;
162
+ }
163
+ const invD = 1 / direction;
164
+ let near = (min - origin) * invD;
165
+ let far = (max - origin) * invD;
166
+ if (near > far) [near, far] = [far, near];
167
+ tmin = Math.max(tmin, near);
168
+ tmax = Math.min(tmax, far);
169
+ if (tmin > tmax) return undefined;
170
+ }
171
+
172
+ const distance = tmin >= 0 ? tmin : tmax;
173
+ return distance >= 0 ? distance : undefined;
174
+ }
@@ -0,0 +1,27 @@
1
+ export interface SnapshotAdapter<T> {
2
+ capture(): T;
3
+ restore(snapshot: T): void;
4
+ }
5
+
6
+ export class PlayModeBridge<TSnapshot> {
7
+ private snapshot?: TSnapshot;
8
+
9
+ constructor(private readonly adapter: SnapshotAdapter<TSnapshot>) {}
10
+
11
+ enter(): void {
12
+ if (this.snapshot !== undefined) {
13
+ throw new Error("Play mode is already active");
14
+ }
15
+ this.snapshot = this.adapter.capture();
16
+ }
17
+
18
+ exit({ restore = true } = {}): void {
19
+ if (this.snapshot === undefined) {
20
+ return;
21
+ }
22
+ if (restore) {
23
+ this.adapter.restore(this.snapshot);
24
+ }
25
+ this.snapshot = undefined;
26
+ }
27
+ }