chore: checkpoint premium 3d stages 0 and 1

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Даня Архипов
2026-07-29 19:05:27 +00:00
parent 5cc27c6fc2
commit 394c513037
79 changed files with 6221 additions and 612 deletions

View File

@@ -1,82 +1,123 @@
import { describe, it, expect } from 'vitest';
import {
MODEL_ASSETS,
ARCHIVE_ONLY_MODELS,
getModelAsset,
getSTLAssets,
getRealAssets,
getProceduralAssets,
getManifestStats,
} from './modelAssets';
describe('modelAssets', () => {
// Runtime files under public/ (project convention: import.meta.glob instead of node:fs)
const RUNTIME_MODEL_FILES = Object.keys(
import.meta.glob('../../public/models/*.stl', { eager: true, query: '?url', import: 'default' }),
).map((p) => p.replace(/^.*\/public/, ''));
describe('modelAssets (Stage 1 real-model manifest)', () => {
describe('MODEL_ASSETS', () => {
it('should contain assets for all scenario items', () => {
const expectedIds = [
'SKU-001',
'SKU-002',
'SKU-003',
'SKU-004',
'SKU-005',
'SKU-006',
'SKU-007',
'SKU-008',
'SKU-009',
'SKU-010',
'SKU-011',
'SKU-001', 'SKU-002', 'SKU-003', 'SKU-004', 'SKU-005', 'SKU-006',
'SKU-007', 'SKU-008', 'SKU-009', 'SKU-010', 'SKU-011',
];
const actualIds = MODEL_ASSETS.map((asset) => asset.itemId);
expectedIds.forEach((id) => {
expect(actualIds).toContain(id);
});
});
it('should have fallback primitive for every asset', () => {
it('should have a unique SKU per entry', () => {
const ids = MODEL_ASSETS.map((asset) => asset.itemId);
expect(new Set(ids).size).toBe(ids.length);
});
it('should define a fallback primitive for every asset', () => {
MODEL_ASSETS.forEach((asset) => {
expect(asset.fallbackPrimitive).toMatch(/^(box|cylinder|sphere)$/);
});
});
it('should have valid loader type for every asset', () => {
it('should have valid expected dimensions for every asset', () => {
MODEL_ASSETS.forEach((asset) => {
expect(asset.loaderType).toMatch(/^(stl|glb|procedural)$/);
expect(asset.dimensions.width).toBeGreaterThan(0);
expect(asset.dimensions.depth).toBeGreaterThan(0);
expect(asset.dimensions.height).toBeGreaterThan(0);
});
});
it('should have frontendAssetPath only for stl/glb loaders', () => {
MODEL_ASSETS.forEach((asset) => {
if (asset.loaderType === 'procedural') {
expect(asset.frontendAssetPath).toBeUndefined();
it('runtime paths must exist on disk for every real asset', () => {
for (const asset of MODEL_ASSETS) {
if (asset.defaultRealAsset) {
expect(asset.runtimePath, `${asset.itemId} runtimePath`).toBeTruthy();
expect(
RUNTIME_MODEL_FILES,
`${asset.itemId} → ${asset.runtimePath} must exist in public/models`,
).toContain(asset.runtimePath);
} else {
expect(asset.frontendAssetPath).toBeTruthy();
expect(asset.runtimePath).toBeNull();
}
});
});
});
describe('demo STL coverage', () => {
it('uses real STL for every available demo model', () => {
const stlMap: Record<string, string> = {
'SKU-001': '/models/box-300.stl',
'SKU-002': '/models/lunchbox.stl',
'SKU-004': '/models/box-400.stl',
'SKU-006': '/models/plate.stl',
'SKU-007': '/models/bottle.stl',
'SKU-008': '/models/cylinder.stl',
'SKU-011': '/models/cylinder.stl', // c_priority round item
};
for (const [id, path] of Object.entries(stlMap)) {
const asset = getModelAsset(id);
expect(asset?.loaderType).toBe('stl');
expect(asset?.frontendAssetPath).toBe(path);
}
});
it('keeps fallbacks explicit and only for heavy/missing STL', () => {
const fallbackIds = ['SKU-003', 'SKU-005', 'SKU-009', 'SKU-010'];
for (const id of fallbackIds) {
it('real assets must declare provenance (archive, file, sha256)', () => {
for (const asset of getRealAssets()) {
expect(asset.sourceArchive).toBe('input_info/doc-1782987733.zip');
expect(asset.sourceFile).toBeTruthy();
expect(asset.sourceSha256).toMatch(/^[0-9a-f]{64}$/);
expect(asset.runtimeSha256).toMatch(/^[0-9a-f]{64}$/);
expect(asset.sourceBoundingBoxMm).not.toBeNull();
expect(asset.worldExpectedMm).not.toBeNull();
expect(asset.triangleCount).toBeGreaterThan(0);
expect(asset.fileSizeBytes).toBeGreaterThan(0);
}
});
it('real assets use bottom-center pivot and uniform mm→m scale only', () => {
for (const asset of getRealAssets()) {
expect(asset.pivotMode).toBe('bottom-center');
expect(asset.scaleMode).toBe('uniform-mm-to-m');
}
});
it('must NOT silently substitute another model for a SKU', () => {
// SKU-011 previously reused cylinder.stl — forbidden now.
const sku011 = getModelAsset('SKU-011');
expect(sku011?.defaultRealAsset).toBe(false);
expect(sku011?.runtimePath).toBeNull();
expect(sku011?.notes).toContain('NO_EXACT_OFFICIAL_MODEL');
});
});
describe('official test-set coverage', () => {
it('integrates the 9 official models that have matching SKUs', () => {
const realIds = getRealAssets().map((a) => a.itemId).sort();
expect(realIds).toEqual([
'SKU-001', 'SKU-002', 'SKU-003', 'SKU-004', 'SKU-005',
'SKU-006', 'SKU-007', 'SKU-008', 'SKU-009',
]);
});
it('marks SKUs without an exact official model honestly', () => {
for (const id of ['SKU-010', 'SKU-011']) {
const asset = getModelAsset(id);
expect(asset?.loaderType).toBe('procedural');
expect(asset?.notes).toBeTruthy();
expect(asset?.defaultRealAsset).toBe(false);
expect(asset?.notes).toContain('NO_EXACT_OFFICIAL_MODEL');
}
});
it('documents archive-only official models (bag, helmet)', () => {
const names = ARCHIVE_ONLY_MODELS.map((m) => m.displayName);
expect(names).toContain('Мешок');
expect(names).toContain('Шлем');
ARCHIVE_ONLY_MODELS.forEach((m) => {
expect(m.sourceSha256).toMatch(/^[0-9a-f]{64}$/);
});
});
it('respects runtime file budgets (<= 1.5MB hard, <= 100k tris acceptable)', () => {
for (const asset of getRealAssets()) {
expect(asset.fileSizeBytes!, `${asset.itemId} file size`).toBeLessThanOrEqual(1.5 * 1024 * 1024);
expect(asset.triangleCount!, `${asset.itemId} triangles`).toBeLessThanOrEqual(100_000);
}
});
});
@@ -95,43 +136,14 @@ describe('modelAssets', () => {
});
});
describe('getSTLAssets', () => {
it('should return only assets with loaderType stl', () => {
const stlAssets = getSTLAssets();
stlAssets.forEach((asset) => {
expect(asset.loaderType).toBe('stl');
});
});
it('should contain at least some STL assets', () => {
const stlAssets = getSTLAssets();
expect(stlAssets.length).toBeGreaterThan(0);
});
});
describe('getProceduralAssets', () => {
it('should return only assets with loaderType procedural', () => {
const proceduralAssets = getProceduralAssets();
proceduralAssets.forEach((asset) => {
expect(asset.loaderType).toBe('procedural');
});
});
});
describe('getManifestStats', () => {
it('should return correct totals', () => {
const stats = getManifestStats();
expect(stats.total).toBe(MODEL_ASSETS.length);
expect(stats.stl).toBe(getSTLAssets().length);
expect(stats.real).toBe(getRealAssets().length);
expect(stats.procedural).toBe(getProceduralAssets().length);
expect(stats.stl + stats.procedural).toBe(stats.total);
});
it('should calculate correct percentage', () => {
const stats = getManifestStats();
expect(stats.stlPercentage).toBeGreaterThanOrEqual(0);
expect(stats.stlPercentage).toBeLessThanOrEqual(100);
expect(stats.stlPercentage).toBe(Math.round((stats.stl / stats.total) * 100));
expect(stats.real + stats.procedural).toBe(stats.total);
expect(stats.realPercentage).toBe(Math.round((stats.real / stats.total) * 100));
});
});
});

View File

@@ -1,46 +1,95 @@
/**
* Model Assets Manifest — real 3D models for product items.
* Maps simulation items to real STL/GLB models from input_info.
* Model Assets Manifest — единый источник сведений о 3D-ассетах товаров.
*
* Stage 1 (real models): каждая запись фиксирует происхождение (архив OZON,
* файл, SHA-256), измеренный исходный bounding box, transform нормализации
* (rotation → uniform mm→m scale → bottom-center pivot) и бюджеты.
*
* Source measurements: scripts/stage1-analyze-stl.mjs (см. docs/stage1_real_models/).
* Decimation: scripts/stage1-decimate-stl.mjs (vertex clustering, воспроизводимо).
* Validation: scripts/validate-real-models.mjs (НЕ редактировать статусы вручную).
*/
import type { Category, DimensionsMm } from '../domain/types';
export interface SourceBoundingBoxMm {
min: [number, number, number];
max: [number, number, number];
size: [number, number, number];
}
export interface ModelAsset {
/** Item ID from items.ts (e.g. 'SKU-006') */
itemId: string;
/** Display name (Russian) */
/** Display name (Russian, official test-set name) */
displayName: string;
/** Expected category scenario */
categoryScenario: Category;
/** Dimensions in mm */
/** Expected physical dimensions in mm (domain truth, items.ts) */
dimensions: DimensionsMm;
/** Source file from input_info (for reference) */
sourceFile: string;
/** Frontend asset path (public/models or undefined if not used) */
frontendAssetPath?: string;
/** Loader type: 'stl' = use STLLoader, 'glb' = use GLTFLoader, 'procedural' = fallback primitive */
loaderType: 'stl' | 'glb' | 'procedural';
/** Fallback primitive shape if model fails to load */
// ---------------- Provenance ----------------
/** 'official-stl' = byte-identical archive file; 'official-stl-decimated' = derived from archive STL by reproducible decimation; 'none' = no official model */
sourceType: 'official-stl' | 'official-stl-decimated' | 'none';
/** Archive the source file came from (repo-relative) */
sourceArchive: string | null;
/** File name inside the archive */
sourceFile: string | null;
/** SHA-256 of the source file inside the archive */
sourceSha256: string | null;
// ---------------- Runtime asset ----------------
/** Frontend asset path under public/ (null if no real asset) */
runtimePath: string | null;
/** SHA-256 of the runtime file */
runtimeSha256: string | null;
runtimeFormat: 'binary-stl' | null;
/** Measured triangle count of the runtime file */
triangleCount: number | null;
/** Runtime file size */
fileSizeBytes: number | null;
// ---------------- Normalization ----------------
/** Measured source STL bounding box (mm, source axes) */
sourceBoundingBoxMm: SourceBoundingBoxMm | null;
/** Expected size along world X/Y/Z AFTER rotation (orientation-aware, mm) */
worldExpectedMm: { x: number; y: number; z: number } | null;
/** Euler rotation (radians) applied in source mm space before scaling */
rotation: [number, number, number];
/** Human-readable axis/orientation decision */
axisMapping: string;
/** Pivot convention after normalization */
pivotMode: 'bottom-center';
/** Scale convention: source mm → scene meters, uniform */
scaleMode: 'uniform-mm-to-m';
// ---------------- Policy ----------------
/** true = real model is the default; false = fallback is the default (honest marking) */
defaultRealAsset: boolean;
/** Fallback primitive for load failure / low mode */
fallbackPrimitive: 'box' | 'cylinder' | 'sphere';
/** Notes about why model is used/not used */
/** Provenance of the runtime geometry */
conversionStatus: 'original' | 'decimated-cell-1mm' | 'decimated-cell-2mm' | 'not-applicable';
/** Filled by validate-real-models.mjs — never hand-edited */
validationStatus: 'pending' | 'pass' | 'fail';
/** Preload with the default playlist scene (budget-controlled) */
preload: boolean;
notes?: string;
}
const ARCHIVE_STL = 'input_info/doc-1782987733.zip';
/**
* Model assets manifest.
*
* Strategy:
* - Lightweight STL (< 700 KB): use directly with STLLoader.
* - Heavy STL (> 1 MB): use procedural fallback, mark as "too heavy".
* - Missing models: use procedural fallback.
*
* All runtime STLs are official OZON test-set models (or reproducible
* decimations of them), units = mm, rendered with uniform 0.001 scale.
* No silent substitutions: SKUs without an exact official model are marked
* NO_EXACT_OFFICIAL_MODEL and use an honestly labelled procedural fallback.
*/
export const MODEL_ASSETS: ModelAsset[] = [
{
@@ -48,155 +97,353 @@ export const MODEL_ASSETS: ModelAsset[] = [
displayName: 'Короб 300×200×200',
categoryScenario: 'B',
dimensions: { width: 300, depth: 200, height: 200 },
sourceFile: 'input_info/extracted/Stl/Короб 300х200х200.stl',
frontendAssetPath: '/models/box-300.stl',
loaderType: 'stl',
sourceType: 'official-stl',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/Короб 300х200х200.stl',
sourceSha256: '4ac9046bdef5bad2e7e50062fea30ead9132ebb6bdf23edeceb1f61c276f6e38',
runtimePath: '/models/box-300.stl',
runtimeSha256: '4ac9046bdef5bad2e7e50062fea30ead9132ebb6bdf23edeceb1f61c276f6e38',
runtimeFormat: 'binary-stl',
triangleCount: 592,
fileSizeBytes: 29684,
sourceBoundingBoxMm: { min: [-150.5, 0, -100], max: [150.5, 200.5, 100], size: [301, 200.5, 200] },
worldExpectedMm: { x: 300, y: 200, z: 200 },
rotation: [0, 0, 0],
axisMapping: 'x→width, y→height, z→depth; Y-up, bottom at y=0 in source',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: true,
fallbackPrimitive: 'box',
notes: 'Lightweight STL (29 KB), used directly.',
conversionStatus: 'original',
validationStatus: 'pending',
preload: true,
notes: 'Official STL, byte-identical to archive (checksum match).',
},
{
itemId: 'SKU-002',
displayName: 'ЛанчБокс',
categoryScenario: 'B',
dimensions: { width: 201, depth: 152, height: 62 },
sourceFile: 'input_info/extracted/Stl/ЛанчБокс.stl',
frontendAssetPath: '/models/lunchbox.stl',
loaderType: 'stl',
sourceType: 'official-stl',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/ЛанчБокс.stl',
sourceSha256: '3ad0f231777e7fe1ac55ac55e40074baffc3561c1fe082bb50dd47161f5c5c99',
runtimePath: '/models/lunchbox.stl',
runtimeSha256: '3ad0f231777e7fe1ac55ac55e40074baffc3561c1fe082bb50dd47161f5c5c99',
runtimeFormat: 'binary-stl',
triangleCount: 11574,
fileSizeBytes: 578784,
sourceBoundingBoxMm: { min: [-100.499, -55.8, -76.2], max: [100.496, 6.5, 76.2], size: [200.995, 62.3, 152.4] },
worldExpectedMm: { x: 201, y: 62, z: 152 },
rotation: [0, 0, 0],
axisMapping: 'x→width, y→height, z→depth; source pivot below center — normalized to bottom-center',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: true,
fallbackPrimitive: 'box',
notes: 'Medium STL (566 KB), acceptable for browser loading.',
conversionStatus: 'original',
validationStatus: 'pending',
preload: true,
notes: 'Official STL. Lid-down container; bottom-center pivot baked at load.',
},
{
itemId: 'SKU-003',
displayName: 'Моющее средство',
categoryScenario: 'B',
dimensions: { width: 259, depth: 179, height: 278 },
sourceFile: 'input_info/extracted/Stl/Моющее средство.STL',
frontendAssetPath: undefined,
loaderType: 'procedural',
sourceType: 'official-stl-decimated',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/Моющее средство.STL',
sourceSha256: '9a8239c0d079084ff45337f88a7169df75ef010c445a87ad3a5746d11ab154dd',
runtimePath: '/models/detergent.stl',
runtimeSha256: '1b93c69affe0a7b5fe67973d3c9ca2d7ca7f3daace9b26e386ce7cd89b78da92',
runtimeFormat: 'binary-stl',
triangleCount: 29458,
fileSizeBytes: 1472984,
sourceBoundingBoxMm: { min: [28.316, 1.054, 0.019], max: [287.345, 279.218, 179.252], size: [259.029, 278.164, 179.232] },
worldExpectedMm: { x: 259, y: 278, z: 179 },
rotation: [0, 0, 0],
axisMapping: 'x→width, y→height, z→depth; source offset from origin — normalized to bottom-center',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: true,
fallbackPrimitive: 'box',
notes: 'Heavy STL (3.5 MB), using fallback box to avoid performance impact.',
conversionStatus: 'decimated-cell-2mm',
validationStatus: 'pending',
preload: false,
notes: 'Official STL decimated 72,752→29,458 tris (vertex clustering, cell 2mm) to fit the 1.5MB budget; bbox preserved within 0.24mm.',
},
{
itemId: 'SKU-004',
displayName: 'Короб 400×400×300 (негабарит)',
categoryScenario: 'C',
dimensions: { width: 401, depth: 300, height: 400 },
sourceFile: 'input_info/extracted/Stl/Короб 400х400х300.stl',
frontendAssetPath: '/models/box-400.stl',
loaderType: 'stl',
sourceType: 'official-stl',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/Короб 400х400х300.stl',
sourceSha256: '05c4ec56883f085e1dafc9bd43fb87ce9993071122e19983192b72120daa9f68',
runtimePath: '/models/box-400.stl',
runtimeSha256: '05c4ec56883f085e1dafc9bd43fb87ce9993071122e19983192b72120daa9f68',
runtimeFormat: 'binary-stl',
triangleCount: 536,
fileSizeBytes: 26884,
sourceBoundingBoxMm: { min: [-200.5, 0, -200], max: [200.5, 300.5, 200], size: [401, 300.5, 400] },
worldExpectedMm: { x: 401, y: 300, z: 400 },
rotation: [0, 0, 0],
axisMapping: 'x→width, y→height, z→depth; Y-up, bottom at y=0 in source',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: true,
fallbackPrimitive: 'box',
notes: 'Lightweight STL (27 KB), used directly.',
conversionStatus: 'original',
validationStatus: 'pending',
preload: true,
notes: 'Official STL, byte-identical to archive.',
},
{
itemId: 'SKU-005',
displayName: 'Пуфик',
categoryScenario: 'C',
dimensions: { width: 489, depth: 264, height: 489 },
sourceFile: 'input_info/extracted/Stl/Пуфик.stl',
frontendAssetPath: undefined,
loaderType: 'procedural',
sourceType: 'official-stl',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/Пуфик.stl',
sourceSha256: '1761f3b2d9e5781a11f09e868d2c14e59d3c03bd5f0c19a006f28648de9f66d1',
runtimePath: '/models/pouf.stl',
runtimeSha256: '1761f3b2d9e5781a11f09e868d2c14e59d3c03bd5f0c19a006f28648de9f66d1',
runtimeFormat: 'binary-stl',
triangleCount: 12880,
fileSizeBytes: 644084,
sourceBoundingBoxMm: { min: [-244.452, -126, -124.452], max: [244.452, 138, 364.452], size: [488.905, 264, 488.905] },
worldExpectedMm: { x: 489, y: 264, z: 489 },
rotation: [0, 0, 0],
axisMapping: 'x→width, y→height, z→depth; source center-offset pivot — normalized to bottom-center',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: true,
fallbackPrimitive: 'cylinder',
notes: 'Medium-heavy STL (629 KB), using fallback cylinder for soft bulky item representation.',
conversionStatus: 'original',
validationStatus: 'pending',
preload: false,
notes: 'Official STL, round soft pouf. 489mm width exceeds the 450mm gate limit on purpose (C scenario).',
},
{
itemId: 'SKU-006',
displayName: 'Тарелка',
categoryScenario: 'D',
dimensions: { width: 210, depth: 209, height: 27 },
sourceFile: 'input_info/extracted/Stl/Тарелка.stl',
frontendAssetPath: '/models/plate.stl',
loaderType: 'stl',
sourceType: 'official-stl',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/Тарелка.stl',
sourceSha256: '9bd0fece5fca87951d9c05576f054d0f61da76b56f0ca1387ddcfce816b23f47',
runtimePath: '/models/plate.stl',
runtimeSha256: '9bd0fece5fca87951d9c05576f054d0f61da76b56f0ca1387ddcfce816b23f47',
runtimeFormat: 'binary-stl',
triangleCount: 2504,
fileSizeBytes: 125284,
sourceBoundingBoxMm: { min: [-104.755, -4.56, -104.793], max: [104.755, 21.967, 104.644], size: [209.511, 26.527, 209.437] },
worldExpectedMm: { x: 210, y: 27, z: 209 },
rotation: [0, 0, 0],
axisMapping: 'x→width, y→height, z→depth; Y-up',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: true,
fallbackPrimitive: 'cylinder',
notes: 'Lightweight STL (123 KB), used directly. Round plate is visually distinctive.',
conversionStatus: 'original',
validationStatus: 'pending',
preload: true,
notes: 'Official STL, round plate (D scenario).',
},
{
itemId: 'SKU-007',
displayName: 'Бутылка',
categoryScenario: 'D',
dimensions: { width: 91, depth: 91, height: 305 },
sourceFile: 'input_info/extracted/Stl/Бутылка.stl',
frontendAssetPath: '/models/bottle.stl',
loaderType: 'stl',
sourceType: 'official-stl',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/Бутылка.stl',
sourceSha256: '9a8c64f1c26a2f2e2539b5e283b36ca78158cb8d2f0f66d3201dbcc784417375',
runtimePath: '/models/bottle.stl',
runtimeSha256: '9a8c64f1c26a2f2e2539b5e283b36ca78158cb8d2f0f66d3201dbcc784417375',
runtimeFormat: 'binary-stl',
triangleCount: 6522,
fileSizeBytes: 326184,
sourceBoundingBoxMm: { min: [-45.7, 0, -45.659], max: [45.535, 305, 45.659], size: [91.235, 305, 91.318] },
worldExpectedMm: { x: 91, y: 305, z: 91 },
rotation: [0, 0, 0],
axisMapping: 'x→width, y→height, z→depth; Y-up, bottom at y=0 in source',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: true,
fallbackPrimitive: 'cylinder',
notes: 'Medium STL (319 KB), acceptable. Bottle is visually recognizable.',
conversionStatus: 'original',
validationStatus: 'pending',
preload: true,
notes: 'Official STL, standing bottle (D scenario).',
},
{
itemId: 'SKU-008',
displayName: 'Цилиндр',
categoryScenario: 'D',
dimensions: { width: 435, depth: 50, height: 43 },
sourceFile: 'input_info/extracted/Stl/Цилиндр.stl',
frontendAssetPath: '/models/cylinder.stl',
loaderType: 'stl',
sourceType: 'official-stl',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/Цилиндр.stl',
sourceSha256: '7aab451e1fd2154e4a2301e12642d17ccee62fc950dddf7de5e54c89453254e5',
runtimePath: '/models/cylinder.stl',
runtimeSha256: '7aab451e1fd2154e4a2301e12642d17ccee62fc950dddf7de5e54c89453254e5',
runtimeFormat: 'binary-stl',
triangleCount: 2152,
fileSizeBytes: 107684,
sourceBoundingBoxMm: { min: [-83, -19, -25], max: [352, 24, 25], size: [435, 43, 50] },
worldExpectedMm: { x: 435, y: 43, z: 50 },
rotation: [0, 0, 0],
axisMapping: 'x→width (long axis, along travel), y→height, z→depth; source offset — normalized to bottom-center',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: true,
fallbackPrimitive: 'cylinder',
notes: 'Lightweight STL (106 KB), used directly.',
conversionStatus: 'original',
validationStatus: 'pending',
preload: false,
notes: 'Official STL, long cylinder lying along the belt.',
},
{
itemId: 'SKU-009',
displayName: 'Ручка',
categoryScenario: 'C',
dimensions: { width: 9, depth: 13, height: 148 },
sourceFile: 'input_info/extracted/Stl/Ручка.stl',
frontendAssetPath: undefined,
loaderType: 'procedural',
sourceType: 'official-stl-decimated',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/Ручка.stl',
sourceSha256: '9c1d3b27b9a5e4f1e65a43ac953f74695bfa0d9550dd97963bfcb7bb39f38720',
runtimePath: '/models/pen.stl',
runtimeSha256: 'eb4baf50e4910c9be3eaa092da3720fe2d5dcfd0d9a056e4480af6b878561df5',
runtimeFormat: 'binary-stl',
triangleCount: 4800,
fileSizeBytes: 240084,
sourceBoundingBoxMm: { min: [36.143, 91.878, 53.556], max: [45.142, 105.031, 202.016], size: [8.999, 13.153, 148.46] },
worldExpectedMm: { x: 148, y: 13, z: 9 },
rotation: [0, Math.PI / 2, 0],
axisMapping: 'DEMO ORIENTATION: lying. Source long axis Z(148) rotated to world X (along travel); world Y=13 (depth), world Z=9 (width). Domain height 148 is the pen LENGTH (standing interpretation in items.ts).',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: true,
fallbackPrimitive: 'box',
notes: 'Heavy STL (2.0 MB), using fallback thin box for pen representation.',
conversionStatus: 'decimated-cell-1mm',
validationStatus: 'pending',
preload: true,
notes: 'Official STL decimated 40,926→4,800 tris (cell 1mm). Lying demo orientation: a 148mm pen cannot stand stably on a moving belt; documented per Stage 1 §12.',
},
{
itemId: 'SKU-010',
displayName: 'Boundary box 450×320×320',
categoryScenario: 'B',
dimensions: { width: 450, depth: 320, height: 320 },
sourceFile: 'N/A',
frontendAssetPath: undefined,
loaderType: 'procedural',
sourceType: 'none',
sourceArchive: null,
sourceFile: null,
sourceSha256: null,
runtimePath: null,
runtimeSha256: null,
runtimeFormat: null,
triangleCount: null,
fileSizeBytes: null,
sourceBoundingBoxMm: null,
worldExpectedMm: null,
rotation: [0, 0, 0],
axisMapping: 'n/a',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: false,
fallbackPrimitive: 'box',
notes: 'No STL available for boundary box, using procedural box.',
conversionStatus: 'not-applicable',
validationStatus: 'pending',
preload: false,
notes: 'NO_EXACT_OFFICIAL_MODEL — synthetic boundary-limit test case (exactly at 450×320×320 gate limit); no official counterpart exists in the OZON archives. Honest procedural box, dimension-accurate.',
},
{
itemId: 'SKU-011',
displayName: 'Oversized round',
displayName: 'Oversized round 500×300×300',
categoryScenario: 'C',
dimensions: { width: 500, depth: 300, height: 300 },
sourceFile: 'input_info/extracted/Stl/Цилиндр.stl',
frontendAssetPath: '/models/cylinder.stl',
loaderType: 'stl',
sourceType: 'none',
sourceArchive: null,
sourceFile: null,
sourceSha256: null,
runtimePath: null,
runtimeSha256: null,
runtimeFormat: null,
triangleCount: null,
fileSizeBytes: null,
sourceBoundingBoxMm: null,
worldExpectedMm: null,
rotation: [0, 0, 0],
axisMapping: 'n/a',
pivotMode: 'bottom-center',
scaleMode: 'uniform-mm-to-m',
defaultRealAsset: false,
fallbackPrimitive: 'cylinder',
notes: 'Uses available round STL (cylinder.stl, 106 KB) for the oversized-round C-priority case.',
conversionStatus: 'not-applicable',
validationStatus: 'pending',
preload: false,
notes: 'NO_EXACT_OFFICIAL_MODEL — no 500×300×300 round item in the official set. Previously reused cylinder.stl (silent substitution, removed in Stage 1). Honest procedural cylinder, dimension-accurate.',
},
];
/**
* Get model asset for item ID.
* Archive-only official models with no matching SKU in the app scenario set.
* Listed for inventory completeness (not loaded at runtime).
*/
export const ARCHIVE_ONLY_MODELS = [
{
displayName: 'Мешок',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/Мешок.stl',
sourceSha256: '74b3118643c0cd06ed639da1513b6db1b8d6d0a38e708cf2f8393d69263cd97b',
triangleCount: 21228,
fileSizeBytes: 5675429,
notes: 'ASCII STL, ~183×175×199mm. No SKU in the app scenario set.',
},
{
displayName: 'Шлем',
sourceArchive: ARCHIVE_STL,
sourceFile: 'Stl/Шлем.stl',
sourceSha256: '660429b26d576771cd02ff275b489dcb134b17bf83b0cab969f589b4d6f6192d',
triangleCount: 55159,
fileSizeBytes: 2758034,
notes: '~280×297×356mm. No SKU in the app scenario set.',
},
] as const;
/** Get model asset for item ID. */
export function getModelAsset(itemId: string): ModelAsset | undefined {
return MODEL_ASSETS.find((asset) => asset.itemId === itemId);
}
/**
* Get all assets that use real STL models.
*/
export function getSTLAssets(): ModelAsset[] {
return MODEL_ASSETS.filter((asset) => asset.loaderType === 'stl');
/** Assets whose default is a real official model. */
export function getRealAssets(): ModelAsset[] {
return MODEL_ASSETS.filter((asset) => asset.defaultRealAsset);
}
/**
* Get all assets that use procedural fallbacks.
*/
/** Assets whose default is the honest procedural fallback. */
export function getProceduralAssets(): ModelAsset[] {
return MODEL_ASSETS.filter((asset) => asset.loaderType === 'procedural');
return MODEL_ASSETS.filter((asset) => !asset.defaultRealAsset);
}
/**
* Summary stats for manifest.
*/
/** Assets to preload with the default playlist scene. */
export function getPreloadAssets(): ModelAsset[] {
return MODEL_ASSETS.filter((asset) => asset.preload && asset.runtimePath);
}
/** Summary stats for manifest. */
export function getManifestStats() {
const stlCount = getSTLAssets().length;
const proceduralCount = getProceduralAssets().length;
const real = getRealAssets().length;
const procedural = getProceduralAssets().length;
return {
total: MODEL_ASSETS.length,
stl: stlCount,
procedural: proceduralCount,
stlPercentage: Math.round((stlCount / MODEL_ASSETS.length) * 100),
real,
procedural,
realPercentage: Math.round((real / MODEL_ASSETS.length) * 100),
};
}