@aura3d/editor-runtime 1.4.0 → 1.4.1
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.
- package/package.json +36 -5
- package/src/AnimationSceneEditor.ts +0 -368
- package/src/AssetDropZone.ts +0 -144
- package/src/CameraPathEditor.ts +0 -223
- package/src/Command.ts +0 -11
- package/src/CommandHistory.ts +0 -136
- package/src/CurveEditor.ts +0 -152
- package/src/DiagnosticsOverlayModel.ts +0 -85
- package/src/EditorPluginHost.ts +0 -106
- package/src/EditorRuntime.ts +0 -349
- package/src/EditorStateModel.ts +0 -336
- package/src/EpisodeReviewPanel.ts +0 -236
- package/src/EventTrackEditor.ts +0 -169
- package/src/Gizmo.ts +0 -101
- package/src/HierarchyModel.ts +0 -57
- package/src/InspectorModel.ts +0 -71
- package/src/KeyframeEditor.ts +0 -214
- package/src/LocalizationAccessibilityFixtures.ts +0 -368
- package/src/MaterialVariantWorkflow.ts +0 -101
- package/src/MultiUserReviewWorkflow.ts +0 -229
- package/src/NonlinearAnimationEditor.ts +0 -262
- package/src/PickingService.ts +0 -174
- package/src/PlayModeBridge.ts +0 -27
- package/src/PrefabRegistry.ts +0 -164
- package/src/ProjectSerializer.ts +0 -200
- package/src/PropertyPanel.ts +0 -82
- package/src/RenderQueuePanel.ts +0 -106
- package/src/RotateGizmo.ts +0 -35
- package/src/ScaleGizmo.ts +0 -40
- package/src/SceneOutliner.ts +0 -78
- package/src/Selection.ts +0 -63
- package/src/ShaderGraphModel.ts +0 -254
- package/src/StaticExportRuntime.ts +0 -322
- package/src/TimelineEditorController.ts +0 -525
- package/src/TimelineModel.ts +0 -466
- package/src/TimelineRuntimeBridge.ts +0 -280
- package/src/TimelineTrackTypes.ts +0 -89
- package/src/TimelineUI.ts +0 -153
- package/src/TranslateGizmo.ts +0 -39
- package/src/VisualReviewDashboard.ts +0 -87
- package/src/commands/CreateNodeCommand.ts +0 -23
- package/src/commands/DeleteNodeCommand.ts +0 -74
- package/src/commands/ReparentNodeCommand.ts +0 -55
- package/src/commands/SetPropertyCommand.ts +0 -47
- package/src/commands/TransformCommand.ts +0 -107
- package/src/index.ts +0 -140
- package/tests/editor-runtime.test.ts +0 -1087
- package/tsconfig.json +0 -7
|
@@ -1,368 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
export type ReviewParticipantRole = "owner" | "reviewer" | "animator" | "audio" | "viewer";
|
|
2
|
-
export type ReviewDecisionStatus = "pending" | "approved" | "changes-requested";
|
|
3
|
-
export type ReviewWorkflowStatus = "draft" | "needs-review" | "changes-requested" | "approved";
|
|
4
|
-
|
|
5
|
-
export interface ReviewParticipant {
|
|
6
|
-
readonly id: string;
|
|
7
|
-
readonly name: string;
|
|
8
|
-
readonly role: ReviewParticipantRole;
|
|
9
|
-
readonly active?: boolean | undefined;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface ReviewThread {
|
|
13
|
-
readonly id: string;
|
|
14
|
-
readonly authorId: string;
|
|
15
|
-
readonly text: string;
|
|
16
|
-
readonly time?: number | undefined;
|
|
17
|
-
readonly frame?: number | undefined;
|
|
18
|
-
readonly assignedTo?: string | undefined;
|
|
19
|
-
readonly resolved: boolean;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface ReviewDecision {
|
|
23
|
-
readonly reviewerId: string;
|
|
24
|
-
readonly status: ReviewDecisionStatus;
|
|
25
|
-
readonly at: string;
|
|
26
|
-
readonly note?: string | undefined;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface MultiUserReviewWorkflowState {
|
|
30
|
-
readonly packageId: string;
|
|
31
|
-
readonly status?: ReviewWorkflowStatus | undefined;
|
|
32
|
-
readonly requiredReviewerCount?: number | undefined;
|
|
33
|
-
readonly participants: readonly ReviewParticipant[];
|
|
34
|
-
readonly threads: readonly ReviewThread[];
|
|
35
|
-
readonly decisions: readonly ReviewDecision[];
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface MultiUserReviewWorkflowSnapshot {
|
|
39
|
-
readonly kind: "multi-user-review-workflow";
|
|
40
|
-
readonly packageId: string;
|
|
41
|
-
readonly status: ReviewWorkflowStatus;
|
|
42
|
-
readonly participantCount: number;
|
|
43
|
-
readonly activeParticipantCount: number;
|
|
44
|
-
readonly reviewerCount: number;
|
|
45
|
-
readonly requiredReviewerCount: number;
|
|
46
|
-
readonly approvedReviewerCount: number;
|
|
47
|
-
readonly changeRequestCount: number;
|
|
48
|
-
readonly threadCount: number;
|
|
49
|
-
readonly unresolvedThreadCount: number;
|
|
50
|
-
readonly assignedThreadCount: number;
|
|
51
|
-
readonly canPublish: boolean;
|
|
52
|
-
readonly participants: readonly ReviewParticipant[];
|
|
53
|
-
readonly threads: readonly ReviewThread[];
|
|
54
|
-
readonly decisions: readonly ReviewDecision[];
|
|
55
|
-
readonly evidence: {
|
|
56
|
-
readonly multiUserReview: boolean;
|
|
57
|
-
readonly assignmentWorkflow: boolean;
|
|
58
|
-
readonly reviewerQuorum: boolean;
|
|
59
|
-
readonly publishBlockedByOpenThreads: boolean;
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export class MultiUserReviewWorkflow {
|
|
64
|
-
private packageId: string;
|
|
65
|
-
private status: ReviewWorkflowStatus;
|
|
66
|
-
private requiredReviewerCount: number;
|
|
67
|
-
private participants = new Map<string, ReviewParticipant>();
|
|
68
|
-
private threads = new Map<string, ReviewThread>();
|
|
69
|
-
private decisions = new Map<string, ReviewDecision>();
|
|
70
|
-
|
|
71
|
-
constructor(state: MultiUserReviewWorkflowState) {
|
|
72
|
-
this.packageId = nonEmpty(state.packageId, "Review package id");
|
|
73
|
-
this.status = state.status ?? "needs-review";
|
|
74
|
-
this.requiredReviewerCount = Math.max(1, Math.floor(state.requiredReviewerCount ?? 1));
|
|
75
|
-
for (const participant of state.participants) this.addParticipant(participant);
|
|
76
|
-
for (const thread of state.threads) this.addThread(thread);
|
|
77
|
-
for (const decision of state.decisions) this.recordDecision(decision);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
addParticipant(participant: ReviewParticipant): MultiUserReviewWorkflowSnapshot {
|
|
81
|
-
const clean = sanitizeParticipant(participant);
|
|
82
|
-
this.participants.set(clean.id, clean);
|
|
83
|
-
return this.snapshot();
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
addThread(thread: ReviewThread): MultiUserReviewWorkflowSnapshot {
|
|
87
|
-
const clean = sanitizeThread(thread);
|
|
88
|
-
this.requireParticipant(clean.authorId);
|
|
89
|
-
if (clean.assignedTo) this.requireParticipant(clean.assignedTo);
|
|
90
|
-
this.threads.set(clean.id, clean);
|
|
91
|
-
if (!clean.resolved && this.status === "approved") this.status = "changes-requested";
|
|
92
|
-
return this.snapshot();
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
assignThread(threadId: string, participantId: string): MultiUserReviewWorkflowSnapshot {
|
|
96
|
-
this.requireParticipant(participantId);
|
|
97
|
-
const thread = this.requireThread(threadId);
|
|
98
|
-
this.threads.set(threadId, { ...thread, assignedTo: participantId });
|
|
99
|
-
return this.snapshot();
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
resolveThread(threadId: string): MultiUserReviewWorkflowSnapshot {
|
|
103
|
-
const thread = this.requireThread(threadId);
|
|
104
|
-
this.threads.set(threadId, { ...thread, resolved: true });
|
|
105
|
-
return this.snapshot();
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
recordDecision(decision: ReviewDecision): MultiUserReviewWorkflowSnapshot {
|
|
109
|
-
const clean = sanitizeDecision(decision);
|
|
110
|
-
const participant = this.requireParticipant(clean.reviewerId);
|
|
111
|
-
if (participant.role !== "reviewer" && participant.role !== "owner") {
|
|
112
|
-
throw new Error(`Review participant "${clean.reviewerId}" is not allowed to approve or request changes.`);
|
|
113
|
-
}
|
|
114
|
-
this.decisions.set(clean.reviewerId, clean);
|
|
115
|
-
if (clean.status === "changes-requested") this.status = "changes-requested";
|
|
116
|
-
else if (this.snapshot().canPublish) this.status = "approved";
|
|
117
|
-
return this.snapshot();
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
markNeedsReview(): MultiUserReviewWorkflowSnapshot {
|
|
121
|
-
this.status = "needs-review";
|
|
122
|
-
return this.snapshot();
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
serialize(): MultiUserReviewWorkflowState {
|
|
126
|
-
return {
|
|
127
|
-
packageId: this.packageId,
|
|
128
|
-
status: this.status,
|
|
129
|
-
requiredReviewerCount: this.requiredReviewerCount,
|
|
130
|
-
participants: [...this.participants.values()],
|
|
131
|
-
threads: [...this.threads.values()],
|
|
132
|
-
decisions: [...this.decisions.values()]
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
snapshot(): MultiUserReviewWorkflowSnapshot {
|
|
137
|
-
const participants = [...this.participants.values()];
|
|
138
|
-
const threads = [...this.threads.values()];
|
|
139
|
-
const decisions = [...this.decisions.values()];
|
|
140
|
-
const reviewerCount = participants.filter((participant) => participant.role === "owner" || participant.role === "reviewer").length;
|
|
141
|
-
const approvedReviewerCount = decisions.filter((decision) => decision.status === "approved").length;
|
|
142
|
-
const changeRequestCount = decisions.filter((decision) => decision.status === "changes-requested").length;
|
|
143
|
-
const unresolvedThreadCount = threads.filter((thread) => !thread.resolved).length;
|
|
144
|
-
const canPublish = approvedReviewerCount >= this.requiredReviewerCount && changeRequestCount === 0 && unresolvedThreadCount === 0;
|
|
145
|
-
const status: ReviewWorkflowStatus = canPublish ? "approved" : changeRequestCount > 0 ? "changes-requested" : this.status === "draft" ? "draft" : "needs-review";
|
|
146
|
-
return {
|
|
147
|
-
kind: "multi-user-review-workflow",
|
|
148
|
-
packageId: this.packageId,
|
|
149
|
-
status,
|
|
150
|
-
participantCount: participants.length,
|
|
151
|
-
activeParticipantCount: participants.filter((participant) => participant.active !== false).length,
|
|
152
|
-
reviewerCount,
|
|
153
|
-
requiredReviewerCount: this.requiredReviewerCount,
|
|
154
|
-
approvedReviewerCount,
|
|
155
|
-
changeRequestCount,
|
|
156
|
-
threadCount: threads.length,
|
|
157
|
-
unresolvedThreadCount,
|
|
158
|
-
assignedThreadCount: threads.filter((thread) => Boolean(thread.assignedTo)).length,
|
|
159
|
-
canPublish,
|
|
160
|
-
participants,
|
|
161
|
-
threads,
|
|
162
|
-
decisions,
|
|
163
|
-
evidence: {
|
|
164
|
-
multiUserReview: participants.length > 1,
|
|
165
|
-
assignmentWorkflow: threads.some((thread) => Boolean(thread.assignedTo)),
|
|
166
|
-
reviewerQuorum: approvedReviewerCount >= this.requiredReviewerCount,
|
|
167
|
-
publishBlockedByOpenThreads: unresolvedThreadCount > 0
|
|
168
|
-
}
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
private requireParticipant(id: string): ReviewParticipant {
|
|
173
|
-
const participant = this.participants.get(id);
|
|
174
|
-
if (!participant) throw new Error(`Unknown review participant: ${id}`);
|
|
175
|
-
return participant;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
private requireThread(id: string): ReviewThread {
|
|
179
|
-
const thread = this.threads.get(id);
|
|
180
|
-
if (!thread) throw new Error(`Unknown review thread: ${id}`);
|
|
181
|
-
return thread;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export function createMultiUserReviewWorkflow(state: MultiUserReviewWorkflowState): MultiUserReviewWorkflow {
|
|
186
|
-
return new MultiUserReviewWorkflow(state);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function sanitizeParticipant(participant: ReviewParticipant): ReviewParticipant {
|
|
190
|
-
return {
|
|
191
|
-
id: nonEmpty(participant.id, "Review participant id"),
|
|
192
|
-
name: nonEmpty(participant.name, "Review participant name"),
|
|
193
|
-
role: participant.role,
|
|
194
|
-
active: participant.active
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function sanitizeThread(thread: ReviewThread): ReviewThread {
|
|
199
|
-
if (thread.time !== undefined && (!Number.isFinite(thread.time) || thread.time < 0)) {
|
|
200
|
-
throw new Error("Review thread time must be a non-negative finite number.");
|
|
201
|
-
}
|
|
202
|
-
if (thread.frame !== undefined && (!Number.isInteger(thread.frame) || thread.frame < 0)) {
|
|
203
|
-
throw new Error("Review thread frame must be a non-negative integer.");
|
|
204
|
-
}
|
|
205
|
-
return {
|
|
206
|
-
id: nonEmpty(thread.id, "Review thread id"),
|
|
207
|
-
authorId: nonEmpty(thread.authorId, "Review thread author"),
|
|
208
|
-
text: nonEmpty(thread.text, "Review thread text"),
|
|
209
|
-
time: thread.time,
|
|
210
|
-
frame: thread.frame,
|
|
211
|
-
assignedTo: thread.assignedTo,
|
|
212
|
-
resolved: thread.resolved
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function sanitizeDecision(decision: ReviewDecision): ReviewDecision {
|
|
217
|
-
return {
|
|
218
|
-
reviewerId: nonEmpty(decision.reviewerId, "Review decision reviewer"),
|
|
219
|
-
status: decision.status,
|
|
220
|
-
at: nonEmpty(decision.at, "Review decision timestamp"),
|
|
221
|
-
note: decision.note
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function nonEmpty(value: string, label: string): string {
|
|
226
|
-
const trimmed = value.trim();
|
|
227
|
-
if (!trimmed) throw new Error(`${label} must be a non-empty string.`);
|
|
228
|
-
return trimmed;
|
|
229
|
-
}
|