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:
root
2026-07-04 16:45:43 +02:00
parent a700a146a3
commit 3e1f10a01a
20 changed files with 3584 additions and 158 deletions

413
src/domain/demoDirector.ts Normal file
View File

@@ -0,0 +1,413 @@
/**
* Demo Director — автоматическая демонстрация работы ПАК.
* Управляет step-by-step показом: intro → spawn → detection → decision → routing → result.
*/
import type { Scenario, MachineState, Category } from './types';
export enum DemoStep {
intro = 'intro',
spawn_item = 'spawn_item',
move_to_detection = 'move_to_detection',
detection_scan = 'detection_scan',
dimensions_check = 'dimensions_check',
roundness_check = 'roundness_check',
decision = 'decision',
command_sent = 'command_sent',
actuator_move = 'actuator_move',
route_item = 'route_item',
result = 'result',
reset_or_next = 'reset_or_next',
}
export interface DemoStepConfig {
step: DemoStep;
durationMs: number;
label: string;
description: string;
cameraPreset: CameraPreset;
activeLabels: string[];
}
export interface CameraPreset {
position: [number, number, number];
target: [number, number, number];
fov: number;
}
export interface DemoDirectorState {
isAutoDemoRunning: boolean;
currentScenario: Scenario;
currentStep: DemoStep;
stepElapsedMs: number;
totalElapsedMs: number;
playbackSpeed: 1 | 2 | 0.5;
loopMode: boolean;
paused: boolean;
}
/**
* Camera presets for demo steps.
*/
export const CAMERA_PRESETS: Record<string, CameraPreset> = {
overview: {
position: [6.2, 4.8, 6.2],
target: [0, 0.3, 0],
fov: 42,
},
detectionCloseup: {
position: [-1.2, 2.5, 3.5],
target: [-1.2, 0.3, 0],
fov: 38,
},
gateCloseup: {
position: [1.6, 2.0, 3.0],
target: [1.6, 0.3, 0],
fov: 36,
},
routingB: {
position: [2.5, 3.5, 0],
target: [3.0, 0.5, 0],
fov: 40,
},
routingC: {
position: [2.5, 3.5, 2.4],
target: [2.0, 0.5, 2.4],
fov: 40,
},
routingD: {
position: [2.5, 3.5, -2.4],
target: [2.0, 0.5, -2.4],
fov: 40,
},
resultB: {
position: [3.8, 3.0, 0],
target: [3.8, 0.5, 0],
fov: 38,
},
resultC: {
position: [2.0, 2.5, 2.4],
target: [2.0, 0.5, 2.4],
fov: 38,
},
resultD: {
position: [2.0, 2.5, -2.4],
target: [2.0, 0.5, -2.4],
fov: 38,
},
};
/**
* Demo step configurations.
*/
export function getDemoStepConfig(step: DemoStep, category?: Category): DemoStepConfig {
const configs: Record<DemoStep, DemoStepConfig> = {
[DemoStep.intro]: {
step: DemoStep.intro,
durationMs: 1500,
label: 'Введение',
description: 'Обзор сортировочной ячейки A/B/C/D',
cameraPreset: CAMERA_PRESETS.overview,
activeLabels: ['A', 'B', 'C', 'D', 'Накопитель'],
},
[DemoStep.spawn_item]: {
step: DemoStep.spawn_item,
durationMs: 800,
label: 'Появление товара',
description: 'Товар входит в зону A',
cameraPreset: CAMERA_PRESETS.overview,
activeLabels: ['A'],
},
[DemoStep.move_to_detection]: {
step: DemoStep.move_to_detection,
durationMs: 1200,
label: 'Движение к камере',
description: 'Товар едет по конвейеру к зоне детекции',
cameraPreset: CAMERA_PRESETS.detectionCloseup,
activeLabels: ['A', 'Camera CV'],
},
[DemoStep.detection_scan]: {
step: DemoStep.detection_scan,
durationMs: 900,
label: 'CV детекция',
description: 'Камера сканирует товар, определяет bbox',
cameraPreset: CAMERA_PRESETS.detectionCloseup,
activeLabels: ['Camera CV', 'Laser'],
},
[DemoStep.dimensions_check]: {
step: DemoStep.dimensions_check,
durationMs: 700,
label: 'Проверка габаритов',
description: 'Dimensions: PASS or FAIL',
cameraPreset: CAMERA_PRESETS.gateCloseup,
activeLabels: ['Накопитель', 'Stop-gate'],
},
[DemoStep.roundness_check]: {
step: DemoStep.roundness_check,
durationMs: 700,
label: 'Проверка круглости',
description: 'Roundness K ≥ 0.7 or not',
cameraPreset: CAMERA_PRESETS.gateCloseup,
activeLabels: ['Накопитель', 'Stop-gate'],
},
[DemoStep.decision]: {
step: DemoStep.decision,
durationMs: 700,
label: 'Решение',
description: 'Category: B/C/D определён',
cameraPreset: CAMERA_PRESETS.gateCloseup,
activeLabels: ['Stop-gate', 'B', 'C', 'D'],
},
[DemoStep.command_sent]: {
step: DemoStep.command_sent,
durationMs: 600,
label: 'Команда отправлена',
description: 'ROUTE_TO_* команда передана исполнительной части',
cameraPreset: CAMERA_PRESETS.gateCloseup,
activeLabels: ['Stop-gate', category ?? 'B'],
},
[DemoStep.actuator_move]: {
step: DemoStep.actuator_move,
durationMs: 1000,
label: 'Исполнительная часть',
description: 'Gate открывается или Pusher выдвигается',
cameraPreset: CAMERA_PRESETS.gateCloseup,
activeLabels: ['Stop-gate', category === 'C' ? 'Pusher C' : category === 'D' ? 'Pusher D' : 'B'],
},
[DemoStep.route_item]: {
step: DemoStep.route_item,
durationMs: 1200,
label: 'Маршрутизация',
description: 'Товар физически уходит в целевую зону',
cameraPreset: category === 'C' ? CAMERA_PRESETS.routingC : category === 'D' ? CAMERA_PRESETS.routingD : CAMERA_PRESETS.routingB,
activeLabels: [category ?? 'B'],
},
[DemoStep.result]: {
step: DemoStep.result,
durationMs: 1500,
label: 'Результат',
description: 'Товар в целевой зоне, цикл завершён',
cameraPreset: category === 'C' ? CAMERA_PRESETS.resultC : category === 'D' ? CAMERA_PRESETS.resultD : CAMERA_PRESETS.resultB,
activeLabels: [category ?? 'B'],
},
[DemoStep.reset_or_next]: {
step: DemoStep.reset_or_next,
durationMs: 500,
label: 'Сброс',
description: 'Подготовка к следующему циклу',
cameraPreset: CAMERA_PRESETS.overview,
activeLabels: ['A', 'B', 'C', 'D'],
},
};
return configs[step];
}
/**
* Get demo step sequence for a scenario.
*/
export function getDemoStepSequence(): DemoStep[] {
return [
DemoStep.intro,
DemoStep.spawn_item,
DemoStep.move_to_detection,
DemoStep.detection_scan,
DemoStep.dimensions_check,
DemoStep.roundness_check,
DemoStep.decision,
DemoStep.command_sent,
DemoStep.actuator_move,
DemoStep.route_item,
DemoStep.result,
DemoStep.reset_or_next,
];
}
/**
* Map demo step to machine state (approximation).
*/
export function mapDemoStepToMachineState(step: DemoStep): MachineState | undefined {
const mapping: Partial<Record<DemoStep, MachineState>> = {
[DemoStep.move_to_detection]: 'MOVING_TO_CAMERA',
[DemoStep.detection_scan]: 'DETECTING',
[DemoStep.dimensions_check]: 'MOVING_TO_GATE',
[DemoStep.roundness_check]: 'WAITING_AT_GATE',
[DemoStep.decision]: 'CLASSIFYING',
[DemoStep.command_sent]: 'CLASSIFYING',
[DemoStep.actuator_move]: 'ROUTE_TO_B', // will be overridden by category
[DemoStep.route_item]: 'ROUTE_TO_B', // will be overridden by category
};
return mapping[step];
}
/**
* Create initial demo director state.
*/
export function createDemoDirectorState(scenario: Scenario): DemoDirectorState {
return {
isAutoDemoRunning: false,
currentScenario: scenario,
currentStep: DemoStep.intro,
stepElapsedMs: 0,
totalElapsedMs: 0,
playbackSpeed: 1,
loopMode: false,
paused: false,
};
}
/**
* Start auto demo.
*/
export function startAutoDemo(state: DemoDirectorState): DemoDirectorState {
return {
...state,
isAutoDemoRunning: true,
currentStep: DemoStep.intro,
stepElapsedMs: 0,
totalElapsedMs: 0,
paused: false,
};
}
/**
* Pause auto demo.
*/
export function pauseAutoDemo(state: DemoDirectorState): DemoDirectorState {
return {
...state,
paused: true,
};
}
/**
* Resume auto demo.
*/
export function resumeAutoDemo(state: DemoDirectorState): DemoDirectorState {
return {
...state,
paused: false,
};
}
/**
* Stop auto demo.
*/
export function stopAutoDemo(state: DemoDirectorState): DemoDirectorState {
return {
...state,
isAutoDemoRunning: false,
paused: false,
currentStep: DemoStep.intro,
stepElapsedMs: 0,
};
}
/**
* Update auto demo state (call on each animation frame).
*/
export function updateAutoDemo(
state: DemoDirectorState,
deltaMs: number,
category?: Category,
): DemoDirectorState {
if (!state.isAutoDemoRunning || state.paused) {
return state;
}
const adjustedDelta = deltaMs * state.playbackSpeed;
const newStepElapsed = state.stepElapsedMs + adjustedDelta;
const newTotalElapsed = state.totalElapsedMs + adjustedDelta;
const currentConfig = getDemoStepConfig(state.currentStep, category);
if (newStepElapsed >= currentConfig.durationMs) {
// Move to next step
const sequence = getDemoStepSequence();
const currentIndex = sequence.indexOf(state.currentStep);
const nextIndex = currentIndex + 1;
if (nextIndex >= sequence.length) {
// Demo completed
if (state.loopMode) {
// Restart from intro
return {
...state,
currentStep: DemoStep.intro,
stepElapsedMs: 0,
totalElapsedMs: 0,
};
} else {
// Stop demo
return stopAutoDemo(state);
}
}
// Move to next step
return {
...state,
currentStep: sequence[nextIndex],
stepElapsedMs: 0,
totalElapsedMs: newTotalElapsed,
};
}
// Continue current step
return {
...state,
stepElapsedMs: newStepElapsed,
totalElapsedMs: newTotalElapsed,
};
}
/**
* Get camera preset for current demo step.
*/
export function getCameraPresetForStep(step: DemoStep, category?: Category): CameraPreset {
const config = getDemoStepConfig(step, category);
return config.cameraPreset;
}
/**
* Get active labels for current demo step.
*/
export function getActiveLabelsForStep(step: DemoStep, category?: Category): string[] {
const config = getDemoStepConfig(step, category);
return config.activeLabels;
}
/**
* Set playback speed.
*/
export function setPlaybackSpeed(state: DemoDirectorState, speed: 1 | 2 | 0.5): DemoDirectorState {
return {
...state,
playbackSpeed: speed,
};
}
/**
* Toggle loop mode.
*/
export function toggleLoopMode(state: DemoDirectorState): DemoDirectorState {
return {
...state,
loopMode: !state.loopMode,
};
}
/**
* Select scenario (reset demo).
*/
export function selectScenario(state: DemoDirectorState, scenario: Scenario): DemoDirectorState {
return {
...state,
currentScenario: scenario,
currentStep: DemoStep.intro,
stepElapsedMs: 0,
totalElapsedMs: 0,
isAutoDemoRunning: false,
paused: false,
};
}