chore: checkpoint premium 3d stages 0 and 1
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user