feat: 3D digital twin major rework - real models, clean labels, improved geometry
- Real STL Models: 6 models loaded (55%), fallback for 5 (45%) - Clean Labels: 12+ → 5 default, step-based, technical toggle - Geometry: Gate lift, pusher plates, conveyor rollers, roll-cage wireframes - Demo Director: 12 steps, camera presets, auto-play logic (not integrated) - Tests: +16 new (43/43 green), Build ✅, Docker ✅, Domains ✅ Remaining: Auto demo UI integration, camera transitions, docs updates, Visual QA
This commit is contained in:
109
src/data/modelAssets.test.ts
Normal file
109
src/data/modelAssets.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
MODEL_ASSETS,
|
||||
getModelAsset,
|
||||
getSTLAssets,
|
||||
getProceduralAssets,
|
||||
getManifestStats,
|
||||
} from './modelAssets';
|
||||
|
||||
describe('modelAssets', () => {
|
||||
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',
|
||||
];
|
||||
|
||||
const actualIds = MODEL_ASSETS.map((asset) => asset.itemId);
|
||||
expectedIds.forEach((id) => {
|
||||
expect(actualIds).toContain(id);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have 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', () => {
|
||||
MODEL_ASSETS.forEach((asset) => {
|
||||
expect(asset.loaderType).toMatch(/^(stl|glb|procedural)$/);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have frontendAssetPath only for stl/glb loaders', () => {
|
||||
MODEL_ASSETS.forEach((asset) => {
|
||||
if (asset.loaderType === 'procedural') {
|
||||
expect(asset.frontendAssetPath).toBeUndefined();
|
||||
} else {
|
||||
expect(asset.frontendAssetPath).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModelAsset', () => {
|
||||
it('should return asset for valid item ID', () => {
|
||||
const asset = getModelAsset('SKU-006');
|
||||
expect(asset).toBeDefined();
|
||||
expect(asset?.displayName).toBe('Тарелка');
|
||||
expect(asset?.categoryScenario).toBe('D');
|
||||
});
|
||||
|
||||
it('should return undefined for invalid item ID', () => {
|
||||
const asset = getModelAsset('SKU-999');
|
||||
expect(asset).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
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.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));
|
||||
});
|
||||
});
|
||||
});
|
||||
202
src/data/modelAssets.ts
Normal file
202
src/data/modelAssets.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Model Assets Manifest — real 3D models for product items.
|
||||
* Maps simulation items to real STL/GLB models from input_info.
|
||||
*/
|
||||
|
||||
import type { Category, DimensionsMm } from '../domain/types';
|
||||
|
||||
export interface ModelAsset {
|
||||
/** Item ID from items.ts (e.g. 'SKU-006') */
|
||||
itemId: string;
|
||||
|
||||
/** Display name (Russian) */
|
||||
displayName: string;
|
||||
|
||||
/** Expected category scenario */
|
||||
categoryScenario: Category;
|
||||
|
||||
/** Dimensions in mm */
|
||||
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 */
|
||||
fallbackPrimitive: 'box' | 'cylinder' | 'sphere';
|
||||
|
||||
/** Notes about why model is used/not used */
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const MODEL_ASSETS: ModelAsset[] = [
|
||||
{
|
||||
itemId: 'SKU-001',
|
||||
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',
|
||||
fallbackPrimitive: 'box',
|
||||
notes: 'Lightweight STL (29 KB), used directly.',
|
||||
},
|
||||
{
|
||||
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',
|
||||
fallbackPrimitive: 'box',
|
||||
notes: 'Medium STL (566 KB), acceptable for browser loading.',
|
||||
},
|
||||
{
|
||||
itemId: 'SKU-003',
|
||||
displayName: 'Моющее средство',
|
||||
categoryScenario: 'B',
|
||||
dimensions: { width: 259, depth: 179, height: 278 },
|
||||
sourceFile: 'input_info/extracted/Stl/Моющее средство.STL',
|
||||
frontendAssetPath: undefined,
|
||||
loaderType: 'procedural',
|
||||
fallbackPrimitive: 'box',
|
||||
notes: 'Heavy STL (3.5 MB), using fallback box to avoid performance impact.',
|
||||
},
|
||||
{
|
||||
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',
|
||||
fallbackPrimitive: 'box',
|
||||
notes: 'Lightweight STL (27 KB), used directly.',
|
||||
},
|
||||
{
|
||||
itemId: 'SKU-005',
|
||||
displayName: 'Пуфик',
|
||||
categoryScenario: 'C',
|
||||
dimensions: { width: 489, depth: 264, height: 489 },
|
||||
sourceFile: 'input_info/extracted/Stl/Пуфик.stl',
|
||||
frontendAssetPath: undefined,
|
||||
loaderType: 'procedural',
|
||||
fallbackPrimitive: 'cylinder',
|
||||
notes: 'Medium-heavy STL (629 KB), using fallback cylinder for soft bulky item representation.',
|
||||
},
|
||||
{
|
||||
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',
|
||||
fallbackPrimitive: 'cylinder',
|
||||
notes: 'Lightweight STL (123 KB), used directly. Round plate is visually distinctive.',
|
||||
},
|
||||
{
|
||||
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',
|
||||
fallbackPrimitive: 'cylinder',
|
||||
notes: 'Medium STL (319 KB), acceptable. Bottle is visually recognizable.',
|
||||
},
|
||||
{
|
||||
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',
|
||||
fallbackPrimitive: 'cylinder',
|
||||
notes: 'Lightweight STL (106 KB), used directly.',
|
||||
},
|
||||
{
|
||||
itemId: 'SKU-009',
|
||||
displayName: 'Ручка',
|
||||
categoryScenario: 'C',
|
||||
dimensions: { width: 9, depth: 13, height: 148 },
|
||||
sourceFile: 'input_info/extracted/Stl/Ручка.stl',
|
||||
frontendAssetPath: undefined,
|
||||
loaderType: 'procedural',
|
||||
fallbackPrimitive: 'box',
|
||||
notes: 'Heavy STL (2.0 MB), using fallback thin box for pen representation.',
|
||||
},
|
||||
{
|
||||
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',
|
||||
fallbackPrimitive: 'box',
|
||||
notes: 'No STL available for boundary box, using procedural box.',
|
||||
},
|
||||
{
|
||||
itemId: 'SKU-011',
|
||||
displayName: 'Oversized round',
|
||||
categoryScenario: 'C',
|
||||
dimensions: { width: 500, depth: 300, height: 300 },
|
||||
sourceFile: 'N/A',
|
||||
frontendAssetPath: undefined,
|
||||
loaderType: 'procedural',
|
||||
fallbackPrimitive: 'cylinder',
|
||||
notes: 'No STL available for oversized round, using procedural cylinder.',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all assets that use procedural fallbacks.
|
||||
*/
|
||||
export function getProceduralAssets(): ModelAsset[] {
|
||||
return MODEL_ASSETS.filter((asset) => asset.loaderType === 'procedural');
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary stats for manifest.
|
||||
*/
|
||||
export function getManifestStats() {
|
||||
const stlCount = getSTLAssets().length;
|
||||
const proceduralCount = getProceduralAssets().length;
|
||||
return {
|
||||
total: MODEL_ASSETS.length,
|
||||
stl: stlCount,
|
||||
procedural: proceduralCount,
|
||||
stlPercentage: Math.round((stlCount / MODEL_ASSETS.length) * 100),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user