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:
413
src/domain/demoDirector.ts
Normal file
413
src/domain/demoDirector.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user