230 lines
6.8 KiB
TypeScript
230 lines
6.8 KiB
TypeScript
/**
|
||
* Camera-triggered classification — DIGITAL_SENSOR_SIMULATION.
|
||
*
|
||
* Classification fires only when a product body enters the physical camera
|
||
* scan volume. Playlist / spawn must not assign a sorter route.
|
||
*/
|
||
|
||
import { classifyItem } from './classifier';
|
||
import type { ClassificationResult, Item } from './types';
|
||
import { BELT_TOP_Y, CONVEYOR_WIDTH_M, ZONES } from './physicalLayout';
|
||
import {
|
||
categoryToPhysicalRoute,
|
||
physicalRouteToActiveDiverter,
|
||
type PhysicalRoute,
|
||
} from './pusherMotion';
|
||
|
||
export const CAMERA_VOLUME_ID = 'camera-scan-volume';
|
||
|
||
/** Physical sensor volume under the visible camera assembly (sensor, no response). */
|
||
export const CAMERA_SCAN_VOLUME = {
|
||
id: CAMERA_VOLUME_ID,
|
||
centerX: ZONES.CAMERA.x,
|
||
centerY: BELT_TOP_Y + 0.16,
|
||
centerZ: 0,
|
||
halfX: 0.32,
|
||
halfY: 0.22,
|
||
halfZ: CONVEYOR_WIDTH_M / 2 + 0.04,
|
||
} as const;
|
||
|
||
export type ClassificationLifecycle = 'UNKNOWN' | 'SCANNING' | 'CLASSIFIED';
|
||
|
||
export interface CameraMeasurement {
|
||
productId: string;
|
||
measuredLengthMm: number;
|
||
measuredWidthMm: number;
|
||
measuredHeightMm: number;
|
||
roundnessK: number;
|
||
capturedAtPhysicsStep: number;
|
||
cameraVolumeId: string;
|
||
provenance: 'DIGITAL_SENSOR_SIMULATION';
|
||
}
|
||
|
||
export interface ClassificationEvent {
|
||
productId: string;
|
||
lifecycle: ClassificationLifecycle;
|
||
measurement: CameraMeasurement | null;
|
||
result: ClassificationResult | null;
|
||
category: 'B' | 'C' | 'D' | null;
|
||
physicalRoute: PhysicalRoute | 'NONE';
|
||
activeDiverter: 'LEFT' | 'RIGHT' | 'NONE';
|
||
classifiedAtPhysicsStep: number | null;
|
||
}
|
||
|
||
const classified = new Map<string, ClassificationEvent>();
|
||
let physicsStepCounter = 0;
|
||
let routeWritesBeforeCameraEntry = 0;
|
||
|
||
export function advanceCameraPhysicsStep(): number {
|
||
physicsStepCounter += 1;
|
||
return physicsStepCounter;
|
||
}
|
||
|
||
export function getCameraPhysicsStep(): number {
|
||
return physicsStepCounter;
|
||
}
|
||
|
||
export function resetCameraClassifications(): void {
|
||
classified.clear();
|
||
physicsStepCounter = 0;
|
||
routeWritesBeforeCameraEntry = 0;
|
||
}
|
||
|
||
export function getRouteWritesBeforeCameraEntry(): number {
|
||
return routeWritesBeforeCameraEntry;
|
||
}
|
||
|
||
export function isInsideCameraVolume(position: [number, number, number]): boolean {
|
||
const [x, y, z] = position;
|
||
const v = CAMERA_SCAN_VOLUME;
|
||
return (
|
||
Math.abs(x - v.centerX) <= v.halfX
|
||
&& Math.abs(y - v.centerY) <= v.halfY
|
||
&& Math.abs(z - v.centerZ) <= v.halfZ
|
||
);
|
||
}
|
||
|
||
export function measureProductAtCamera(
|
||
productId: string,
|
||
item: Item,
|
||
physicsStep: number,
|
||
): CameraMeasurement {
|
||
const d = item.dimensionsMm;
|
||
return {
|
||
productId,
|
||
measuredLengthMm: d.width,
|
||
measuredWidthMm: d.depth,
|
||
measuredHeightMm: d.height,
|
||
roundnessK: item.roundness,
|
||
capturedAtPhysicsStep: physicsStep,
|
||
cameraVolumeId: CAMERA_VOLUME_ID,
|
||
provenance: 'DIGITAL_SENSOR_SIMULATION',
|
||
};
|
||
}
|
||
|
||
export function getClassificationEvent(productId: string): ClassificationEvent | null {
|
||
return classified.get(productId) ?? null;
|
||
}
|
||
|
||
export function getClassificationLifecycle(productId: string): ClassificationLifecycle {
|
||
return classified.get(productId)?.lifecycle ?? 'UNKNOWN';
|
||
}
|
||
|
||
/**
|
||
* Request a mechanical route command. Throws in DEV if requested before a
|
||
* valid classification event; in production keeps diverters neutral.
|
||
*/
|
||
export function requestRouteCommand(
|
||
productId: string,
|
||
opts?: { allowBeforeClassification?: boolean },
|
||
): ClassificationEvent {
|
||
const ev = classified.get(productId);
|
||
if (ev?.lifecycle === 'CLASSIFIED' && ev.category) {
|
||
return ev;
|
||
}
|
||
routeWritesBeforeCameraEntry += 1;
|
||
if (import.meta.env.DEV && !opts?.allowBeforeClassification) {
|
||
throw new Error(
|
||
`[cameraClassification] route command before classification for ${productId}`,
|
||
);
|
||
}
|
||
return {
|
||
productId,
|
||
lifecycle: 'UNKNOWN',
|
||
measurement: null,
|
||
result: null,
|
||
category: null,
|
||
physicalRoute: 'NONE',
|
||
activeDiverter: 'NONE',
|
||
classifiedAtPhysicsStep: null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Attempt classification when body is inside the camera volume.
|
||
* Fires once per productId. Returns the event (new or existing).
|
||
*/
|
||
export function tryClassifyAtCamera(input: {
|
||
productId: string;
|
||
item: Item;
|
||
position: [number, number, number];
|
||
physicsStep?: number;
|
||
}): ClassificationEvent {
|
||
const existing = classified.get(input.productId);
|
||
if (existing?.lifecycle === 'CLASSIFIED') {
|
||
return existing;
|
||
}
|
||
|
||
const inside = isInsideCameraVolume(input.position);
|
||
if (!inside) {
|
||
const waiting: ClassificationEvent = existing ?? {
|
||
productId: input.productId,
|
||
lifecycle: 'UNKNOWN',
|
||
measurement: null,
|
||
result: null,
|
||
category: null,
|
||
physicalRoute: 'NONE',
|
||
activeDiverter: 'NONE',
|
||
classifiedAtPhysicsStep: null,
|
||
};
|
||
classified.set(input.productId, waiting);
|
||
return waiting;
|
||
}
|
||
|
||
const step = input.physicsStep ?? advanceCameraPhysicsStep();
|
||
const measurement = measureProductAtCamera(input.productId, input.item, step);
|
||
const result = classifyItem(input.item);
|
||
const category = result.category as 'B' | 'C' | 'D';
|
||
const physicalRoute = categoryToPhysicalRoute(category);
|
||
const event: ClassificationEvent = {
|
||
productId: input.productId,
|
||
lifecycle: 'CLASSIFIED',
|
||
measurement,
|
||
result,
|
||
category,
|
||
physicalRoute,
|
||
activeDiverter: physicalRouteToActiveDiverter(physicalRoute),
|
||
classifiedAtPhysicsStep: step,
|
||
};
|
||
classified.set(input.productId, event);
|
||
|
||
if (typeof window !== 'undefined') {
|
||
const w = window as unknown as {
|
||
__CAMERA_CLASSIFICATION__?: ClassificationEvent;
|
||
__CLASSIFICATION_EVENTS__?: ClassificationEvent[];
|
||
};
|
||
w.__CAMERA_CLASSIFICATION__ = event;
|
||
w.__CLASSIFICATION_EVENTS__ = [...(w.__CLASSIFICATION_EVENTS__ ?? []), event].slice(-32);
|
||
window.dispatchEvent(new CustomEvent('camera-classification', { detail: event }));
|
||
}
|
||
return event;
|
||
}
|
||
|
||
/** Publish live UI snapshot without assigning a mechanical route prematurely. */
|
||
export function peekClassificationUi(productId: string | null): {
|
||
status: string;
|
||
category: string;
|
||
route: string;
|
||
} {
|
||
if (!productId) {
|
||
return { status: 'Ожидание сканирования', category: '—', route: 'Не определён' };
|
||
}
|
||
const ev = classified.get(productId);
|
||
if (!ev || ev.lifecycle === 'UNKNOWN') {
|
||
return { status: 'Ожидание сканирования', category: '—', route: 'Не определён' };
|
||
}
|
||
if (ev.lifecycle === 'SCANNING') {
|
||
return { status: 'Сканирование', category: '—', route: 'Не определён' };
|
||
}
|
||
const routeLabel =
|
||
ev.physicalRoute === 'PHYSICAL_LEFT' ? 'Влево'
|
||
: ev.physicalRoute === 'PHYSICAL_RIGHT' ? 'Вправо'
|
||
: ev.physicalRoute === 'STRAIGHT' ? 'Прямо'
|
||
: 'Не определён';
|
||
return {
|
||
status: 'Классифицировано',
|
||
category: ev.category ?? '—',
|
||
route: routeLabel,
|
||
};
|
||
}
|