fix: align discharge sorting and camera classification

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Даня Архипов
2026-08-02 07:56:35 +00:00
parent 30a9f7efb3
commit 6e3b6ae3f0
19 changed files with 881 additions and 142 deletions

View File

@@ -10,7 +10,7 @@ test.describe('routes', () => {
await page.getByTestId('nav-documentation').first().click();
await expect(page).toHaveURL(/\/documentation\/?$/);
await expect(page.getByTestId('documentation-page')).toBeVisible();
await expect(page.getByTestId('docs-production-status')).toContainText('DATA_ACQUISITION_PACK_READY');
await expect(page.getByTestId('docs-production-status')).toContainText('END_OF_LINE_CAMERA_CLASSIFICATION_PASS');
await expect(page.getByTestId('nav-documentation').first()).toHaveClass(/active/);
await page.reload();

View File

@@ -13,9 +13,11 @@ import {
seekNextCase,
seekPrevCase,
setPlaybackSpeed,
applyCameraClassificationToPlayback,
type ContinuousPlaybackState,
type PlaybackSpeed,
} from './domain/continuousPlayback';
import type { ClassificationEvent } from './domain/cameraClassification';
function AppContent() {
const [playback, setPlayback] = useState<ContinuousPlaybackState>(() => createPlaybackState());
@@ -34,6 +36,17 @@ function AppContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// UI/state category binds only from physical camera classification events.
useEffect(() => {
const onClass = (e: Event) => {
const detail = (e as CustomEvent<ClassificationEvent>).detail;
if (!detail?.result) return;
setPlayback((prev) => applyCameraClassificationToPlayback(prev, detail.result!));
};
window.addEventListener('camera-classification', onClass);
return () => window.removeEventListener('camera-classification', onClass);
}, []);
useEffect(() => {
if (playback.status !== 'running') {
if (playbackRafRef.current) {

View File

@@ -13,7 +13,7 @@ import { useFrame, useLoader, useThree } from '@react-three/fiber';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import type { Category } from '../../domain/types';
import { CONVEYOR_SPEED_MPS } from '../../domain/physicalLayout';
import { CONVEYOR_SPEED_MPS, SORTER_ASSEMBLY_ORIGIN_S } from '../../domain/physicalLayout';
import {
buildDiverterPlanes,
createDiverterProductMachine,
@@ -90,8 +90,9 @@ export const CAD_MODULE_Y = 0.594;
export const CAD_MODULE_ORIGINS = {
clean: -4.02,
camera: -2.01,
sorter: 0.0,
} as const;
/** Final discharge section — whole sorter assembly origin. */
sorter: SORTER_ASSEMBLY_ORIGIN_S,
};
export type CadModuleVariant = keyof typeof CAD_MODULE_ORIGINS;

View File

@@ -30,20 +30,25 @@ import {
type ProductPhysicsPhase,
} from '../../domain/productPhysicsProfiles';
import {
DOCUMENTED_CONTACT_PLANE_S,
DISCHARGE_EDGE_S,
detectReceiverZone,
runtimeWorldContactPlaneS,
SETTLE_LINEAR_SPEED_MPS,
SETTLE_ANGULAR_SPEED_RAD_S,
SETTLE_DURATION_SEC,
} from '../../domain/junctionContactPhysics';
import { resolveItem } from '../../data/resolveItem';
import { classifyItem } from '../../domain/classifier';
import { receiverContains } from '../../domain/receiverVolumes';
import type { PlaylistCase } from '../../domain/demoPlaylist';
import { ItemVisualContent } from './PhysicalPlaybackItem';
import { recordDropResult, PHYSICS_DT } from './SorterPhysics';
import { getModelAsset } from '../../data/modelAssets';
import { isProductAssetReady } from './RealItemModel';
import {
advanceCameraPhysicsStep,
getClassificationEvent,
tryClassifyAtCamera,
} from '../../domain/cameraClassification';
type Authority = 'preparing' | 'dynamic_active' | 'frozen' | 'fault_kinematic';
@@ -78,12 +83,12 @@ function PhysicalPlaybackItemPhysicsInner({
verifySku?: string | null;
}) {
const itemData = useMemo(() => resolveItem(caseData.itemId), [caseData.itemId]);
const classification = useMemo(() => classifyItem(itemData), [itemData]);
const category = classification.category as 'B' | 'C' | 'D';
const itemId = itemData.id.replace('-LC', '');
const profile = getProductPhysicsProfile(itemId);
const halfH = colliderHalfHeight(profile);
const isFault = Boolean(caseData.faultType);
/** Immutable after camera trigger — never from playlist. */
const classifiedCategory = useRef<'B' | 'C' | 'D' | null>(null);
const bodyRef = useRef<RapierRigidBody>(null);
const authority = useRef<Authority>(isFault ? 'fault_kinematic' : 'preparing');
@@ -111,7 +116,7 @@ function PhysicalPlaybackItemPhysicsInner({
caseId: caseData.id,
slotIndex,
dimensionsMm: itemData.dimensionsMm,
targetCategory: classification.category,
targetCategory: classifiedCategory.current,
elapsedMs,
faultType: caseData.faultType,
jitter,
@@ -122,7 +127,7 @@ function PhysicalPlaybackItemPhysicsInner({
caseId: caseData.id,
slotIndex,
dimensionsMm: itemData.dimensionsMm,
targetCategory: classification.category,
targetCategory: null,
elapsedMs: 0,
faultType: caseData.faultType,
jitter,
@@ -144,6 +149,7 @@ function PhysicalPlaybackItemPhysicsInner({
invalidLogged.current = false;
settleAccum.current = 0;
activeSinceSec.current = 0;
classifiedCategory.current = getClassificationEvent(itemId)?.category ?? null;
const ready = !needsRealAsset || isProductAssetReady(asset?.runtimePath);
setSpawned(ready);
const body = bodyRef.current;
@@ -222,10 +228,43 @@ function PhysicalPlaybackItemPhysicsInner({
return;
}
if (t.x >= DOCUMENTED_CONTACT_PLANE_S) {
const contactS = runtimeWorldContactPlaneS();
if (t.x >= contactS) {
phaseRef.current = 'junction';
}
// Camera-triggered classification (once per productId).
const step = advanceCameraPhysicsStep();
const classEv = tryClassifyAtCamera({
productId: itemId,
item: itemData,
position,
physicsStep: step,
});
if (classEv.lifecycle === 'CLASSIFIED' && classEv.category) {
classifiedCategory.current = classEv.category;
}
if (typeof window !== 'undefined') {
const w = window as unknown as {
__DIVERTER_PRODUCT_INPUT?: {
productId: string | null;
category: 'B' | 'C' | 'D' | null;
itemCenterS: number;
itemHalfLengthS: number;
};
};
// Route command only after a valid classification event.
w.__DIVERTER_PRODUCT_INPUT = {
productId: itemId,
category: classifiedCategory.current,
itemCenterS: t.x,
itemHalfLengthS: profile.collider.type === 'cuboid'
? profile.collider.halfExtents[0]
: 0.12,
};
}
const supported = isSupportedByBelt({
position,
halfHeight: halfH,
@@ -240,12 +279,13 @@ function PhysicalPlaybackItemPhysicsInner({
linearVelocity,
maxBeltAccelerationMps2: profile.maxBeltAccelerationMps2,
// Zero lateral correction inside physical junction / contact zone.
applyLateralCorrection: t.x < DOCUMENTED_CONTACT_PLANE_S,
applyLateralCorrection: t.x < contactS,
});
// Stationary belt collider cannot impart tangential speed — couple after
// measuring the drive sample. Upstream: hard 1.0 m/s. Junction: gentle
// pull so contact can redirect C/D without wiping lateral velocity.
const inJunction = t.x >= DOCUMENTED_CONTACT_PLANE_S;
// After DISCHARGE_EDGE_S support is false → belt force stays 0.
const inJunction = t.x >= contactS;
const latV = sample.lateralCorrection[2] * dt / Math.max(profile.massKg, 1e-6);
forceScratch.current.x = inJunction
? lv.x + Math.max(-8, Math.min(8, (BELT_SPEED_MPS - lv.x) * 0.35))
@@ -258,25 +298,54 @@ function PhysicalPlaybackItemPhysicsInner({
const now = performance.now();
if (now - lastTelemetryMs.current >= TELEMETRY_INTERVAL_MS) {
lastTelemetryMs.current = now;
const mesh = bodyRef.current as unknown as { translation?: () => { x: number; y: number; z: number } };
const bp = mesh?.translation?.() ?? t;
window.__CONVEYOR_PHYSICS_DEBUG__ = {
productId: itemId,
route: category,
physicsPhase: phaseRef.current,
physicsStep: step,
bodyPosition: position,
visibleWorldPosition: [bp.x, bp.y, bp.z],
supportedByBelt: supported,
dischargeEdgeS: DISCHARGE_EDGE_S,
cameraInside: classEv.lifecycle !== 'UNKNOWN' || t.x >= -1.82,
classificationState: classEv.lifecycle,
measuredDimensions: classEv.measurement,
category: classifiedCategory.current,
route: classEv.physicalRoute,
activeDiverter: classEv.activeDiverter,
receiver: detectReceiverZone(position),
bodyVisualErrorMm: 0,
physicsPhase: phaseRef.current,
currentDownstreamSpeed: sample.currentDownstreamSpeed,
targetSpeed: BELT_SPEED_MPS,
appliedAcceleration: sample.appliedAcceleration,
bodyPosition: position,
bodyIdentity: bodyIdentity.current,
invalidState: false,
};
}
}
} else if (import.meta.env.DEV && typeof window !== 'undefined') {
const now = performance.now();
if (now - lastTelemetryMs.current >= TELEMETRY_INTERVAL_MS) {
lastTelemetryMs.current = now;
window.__CONVEYOR_PHYSICS_DEBUG__ = {
productId: itemId,
physicsStep: step,
bodyPosition: position,
supportedByBelt: false,
dischargeEdgeS: DISCHARGE_EDGE_S,
classificationState: classEv.lifecycle,
category: classifiedCategory.current,
route: classEv.physicalRoute,
bodyVisualErrorMm: 0,
};
}
}
// Receiver sensor detection — never moves the body.
const zone = detectReceiverZone(position);
if (zone) {
const expected = classifiedCategory.current;
if (zone && expected) {
const speed = Math.hypot(lv.x, lv.y, lv.z);
const ang = Math.hypot(av.x, av.y, av.z);
if (speed <= SETTLE_LINEAR_SPEED_MPS && ang <= SETTLE_ANGULAR_SPEED_RAD_S) {
@@ -291,9 +360,9 @@ function PhysicalPlaybackItemPhysicsInner({
recordDropResult({
caseId: caseData.id,
itemId,
expectedZone: category,
expectedZone: expected,
finalPosition: position,
insideExpectedReceiver: receiverContains(category, position),
insideExpectedReceiver: receiverContains(expected, position),
settledByTimeout: settleAccum.current < SETTLE_DURATION_SEC,
timestampMs: Date.now(),
});

View File

@@ -15,6 +15,10 @@ import type { Mesh, Group } from 'three';
import * as THREE from 'three';
import type { ContinuousPlaybackState, CasePhase } from '../../domain/continuousPlayback';
import { isDetectionActive, isRoutingActive, getPhaseProgress } from '../../domain/continuousPlayback';
import {
getClassificationEvent,
peekClassificationUi,
} from '../../domain/cameraClassification';
import { getPhysicalItemPose, getRoutingStartMs } from '../../domain/physicalItemMotion';
import { shouldShowBoundingBox, shouldShowScanEffect, shouldShowShapeOutline, shouldHighlightCamera } from '../../domain/inspectionViewModel';
import { getMeasurementData, shouldShowLaserBeam, shouldShowStepperPulse, shouldShowPointCloud } from '../../domain/measurementSystem';
@@ -1030,8 +1034,26 @@ function ContinuousScene({
debugOverlays?: boolean;
physicsDebug?: boolean;
}) {
const category = playback.targetCategory;
const itemIdForClass = playback.currentCase.itemId.replace('-LC', '');
const [cameraCategory, setCameraCategory] = useState<'B' | 'C' | 'D' | null>(
() => getClassificationEvent(itemIdForClass)?.category ?? playback.targetCategory,
);
useEffect(() => {
const sync = () => {
const ev = getClassificationEvent(itemIdForClass);
setCameraCategory(ev?.category ?? null);
};
sync();
window.addEventListener('camera-classification', sync);
return () => window.removeEventListener('camera-classification', sync);
}, [itemIdForClass, playback.currentCaseIndex, playback.status]);
// Mechanical / visual category only from camera event — never playlist spawn.
const category = cameraCategory;
const classUi = peekClassificationUi(itemIdForClass);
const phase = playback.currentPhase;
if (import.meta.env.DEV && typeof window !== 'undefined') {
(window as unknown as { __CLASSIFICATION_UI__?: typeof classUi }).__CLASSIFICATION_UI__ = classUi;
}
const liteScene = simplified || !ENABLE_DEMO_EFFECTS;
const effectsEnabled = ENABLE_DEMO_EFFECTS && !simplified;

View File

@@ -190,7 +190,7 @@ export function SorterPhysicsWorld({
paused
timeStep={PHYSICS_DT}
gravity={PHYSICS_GRAVITY}
interpolate
interpolate={false}
>
<PhysicsSimClock />
<RapierStepper running={running} speed={speed} />

View File

@@ -6,11 +6,11 @@
/** Kept for e2e (`docs-production-status`) and acquisition-pack provenance. */
export const PRODUCTION_STATUS = {
acquisitionPackStatus: 'DATA_ACQUISITION_PACK_READY',
webTwinStatus: 'BASELINE_PRESERVED',
unitTests: '221/221',
acquisitionPackStatus: 'END_OF_LINE_CAMERA_CLASSIFICATION_PASS',
webTwinStatus: 'CAMERA_TRIGGERED_CLASSIFICATION_ACTIVE',
unitTests: 'PASS',
productionBuild: 'PASS',
contactPhysics: 'ENGINEERING_DERIVED_PHYSICAL_VALIDATION',
contactPhysics: 'DISCHARGE_EDGE_AND_CAMERA_CLASSIFICATION',
officialCompliance: 'PARTIAL_SOURCES_PRESENT',
} as const;
@@ -46,6 +46,7 @@ export const ROUTE_MAPPING = [
export const DIVERTER_KINEMATICS = {
rotationDurationSec: 0.5,
openingSafetyMarginSec: 0.15,
/** Documented LOCAL sorter offsets (world = assemblyOrigin + local). */
contactPlaneS: 1.0538,
clearPlaneS: 1.6,
phases: ['READY', 'ARMED', 'OPENING', 'HOLDING', 'CLOSING'] as const,
@@ -53,6 +54,7 @@ export const DIVERTER_KINEMATICS = {
oneActiveProduct: true,
closeAfterRearClear: true,
generatedMechanismActive: false,
cameraTriggeredClassification: true,
} as const;
export const CAD_PROVENANCE = {
@@ -70,11 +72,12 @@ export const PHYSICS_STATUS = {
'Single dynamic product rigid body from spawn through junction settle',
'KinematicPositionBased CAD diverter colliders synced to visual yaw',
'Contact-only C/D routing (no route-specific translation / lateral impulse)',
'Physical B straight corridor with neutral guides',
'Discharge edge ends belt support; gravity fall into receivers',
'Camera-volume triggered measurement + classifyItem (DIGITAL_SENSOR_SIMULATION)',
'Playlist selects SKU only — no preassigned sorter route',
'Receiver sensors detect only; settling thresholds applied',
'Belt drive toward 1.0 m/s while supported (velocity coupling)',
'CCD enabled on active product profiles',
'45-run deterministic junction matrix (ENGINEERING-DERIVED PHYSICAL VALIDATION)',
],
notFullyValidated: [
'Production-calibrated mass / COM / friction per SKU',
@@ -82,19 +85,20 @@ export const PHYSICS_STATUS = {
'All playlist SKUs under owner visual review',
],
planned: [
'Owner visual / physical-behavior review of contact routing',
'Owner visual review of end-of-line fall and camera classification',
'Production calibration of product profiles',
],
} as const;
export const VALIDATION_BOARD = [
{ item: 'Unit tests', status: '221/221 PASS' },
{ item: 'Unit tests', status: 'PASS' },
{ item: 'Production build', status: 'PASS' },
{ item: 'Active routes / + /documentation', status: 'PASS' },
{ item: 'conveyor-clean.glb checksum', status: 'PASS' },
{ item: 'Author FCStd checksum', status: 'PASS' },
{ item: 'Frozen diverter angles / 0.50 s', status: 'PASS' },
{ item: 'Physical junction contact matrix', status: '45/45 ENGINEERING-DERIVED PHYSICAL VALIDATION' },
{ item: 'Camera-triggered classification', status: 'PHYSICAL_CAMERA_CROSSING' },
{ item: 'End-of-line discharge fall', status: 'GRAVITY_AFTER_SUPPORT_LOSS' },
] as const;
export const OFFICIAL_SOURCE_MATRIX = [

View File

@@ -0,0 +1,229 @@
/**
* 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,
};
}

View File

@@ -58,11 +58,11 @@ describe('continuousPlayback', () => {
expect(state.currentCaseIndex).toBe(0);
});
it('startPlayback sets status to running and classifies via classifyItem', () => {
it('startPlayback sets status to running without preassigned route', () => {
const state = startPlayback(createPlaybackState());
expect(state.status).toBe('running');
expect(state.classification).not.toBeNull();
expect(state.targetCategory).toBe(state.classification!.category);
expect(state.classification).toBeNull();
expect(state.targetCategory).toBeNull();
});
it('pausePlayback sets status to paused', () => {

View File

@@ -1,6 +1,7 @@
/**
* Continuous Playback Engine — manages auto-demo on main page.
* Classification is always driven by classifyItem (live rule engine).
* Playlist selects SKU only. Sorter route / category is assigned by the
* physical camera classification pipeline (see cameraClassification.ts).
* Supports speed control, case seek, seeded variability, and event journal.
*/
@@ -9,6 +10,7 @@ import { DEMO_PLAYLIST, type PlaylistCase, PLAYLIST_LENGTH } from './demoPlaylis
import { classifyItem } from './classifier';
import { resolveItem } from '../data/resolveItem';
import { createSeededRng, DEFAULT_DEMO_SEED, seededOffset } from './seededRng';
import { resetCameraClassifications } from './cameraClassification';
/** Playback status for the continuous demo. */
export type PlaybackStatus = 'idle' | 'running' | 'paused' | 'finished';
@@ -41,9 +43,9 @@ export interface PhaseConfig {
*/
export const CASE_PHASES: PhaseConfig[] = [
{ phase: 'spawn', durationMs: 300, label: 'Spawn at A' },
// Stage 2B §10: 1900 ms so that feed(300)+1900+600+1000+1000+1000 = 5500 ms
// == A->GATE distance (5.5 m) at 1.0 m/s — continuous motion, no dwell.
{ phase: 'move_to_detection', durationMs: 1900, label: 'Moving to camera' },
// A→GATE = 5.95 m at 1.0 m/s. feed(300)+2350+600+1000+1000+1000 = 6250 ms
// → travel from feed start = 5950 ms = GATE.x − A.x.
{ phase: 'move_to_detection', durationMs: 2350, label: 'Moving to camera' },
{ phase: 'detection', durationMs: 600, label: 'CV Detection' },
{ phase: 'measurement', durationMs: 1000, label: 'Laser measurement' },
{ phase: 'classification', durationMs: 1000, label: 'Classification' },
@@ -127,11 +129,6 @@ function caseDurationMs(playlistCase: PlaylistCase): number {
return getPlaylistCaseDurationMs(playlistCase);
}
function classifyCase(playlistCase: PlaylistCase): ClassificationResult {
const item = resolveItem(playlistCase.itemId);
return classifyItem(item);
}
function buildJitter(seed: number, caseIndex: number): { x: number; z: number; yaw: number } {
const rng = createSeededRng(seed + caseIndex * 9973);
return {
@@ -158,11 +155,9 @@ function pushEvent(
}
function initCaseFields(playlistCase: PlaylistCase, seed: number, caseIndex: number, events: EventLogEntry[], simTime: number) {
const classification = classifyCase(playlistCase);
const warnings = [
...(playlistCase.warning ? [playlistCase.warning] : []),
...classification.warnings,
];
// SKU only — no preassigned category / route for motion.
void resolveItem(playlistCase.itemId);
const warnings = playlistCase.warning ? [playlistCase.warning] : [];
return {
currentCase: playlistCase,
currentCaseIndex: caseIndex,
@@ -170,8 +165,8 @@ function initCaseFields(playlistCase: PlaylistCase, seed: number, caseIndex: num
currentPhase: 'spawn' as CasePhase,
phaseElapsedMs: 0,
caseElapsedMs: 0,
targetCategory: classification.category,
classification,
targetCategory: null as Category | null,
classification: null as ClassificationResult | null,
command: 'IDLE',
warning: warnings[0] ?? null,
positionJitter: buildJitter(seed, caseIndex),
@@ -179,16 +174,29 @@ function initCaseFields(playlistCase: PlaylistCase, seed: number, caseIndex: num
itemId: playlistCase.itemId,
type: 'system',
message: `Case start: ${playlistCase.title}`,
category: classification.category,
category: undefined,
status: 'info',
}),
};
}
/** Bind camera classification result into playback UI state (never at spawn). */
export function applyCameraClassificationToPlayback(
state: ContinuousPlaybackState,
classification: ClassificationResult,
): ContinuousPlaybackState {
return {
...state,
targetCategory: classification.category,
classification,
warning: classification.warnings[0] ?? state.warning,
};
}
/** Create initial playback state. */
export function createPlaybackState(seed: number = DEFAULT_DEMO_SEED): ContinuousPlaybackState {
const firstCase = DEMO_PLAYLIST[0];
const classification = classifyCase(firstCase);
resetCameraClassifications();
return {
status: 'idle',
currentCaseIndex: 0,
@@ -213,6 +221,7 @@ export function createPlaybackState(seed: number = DEFAULT_DEMO_SEED): Continuou
/** Start playback from the beginning. */
export function startPlayback(state: ContinuousPlaybackState): ContinuousPlaybackState {
const firstCase = DEMO_PLAYLIST[0];
resetCameraClassifications();
const fields = initCaseFields(firstCase, state.seed, 0, [], 0);
return {
...state,
@@ -294,7 +303,7 @@ function advanceToNextCase(state: ContinuousPlaybackState): ContinuousPlaybackSt
return { ...state, ...fields };
}
function getCommandForPhase(phase: CasePhase, category: Category): string {
function getCommandForPhase(phase: CasePhase, category: Category | null): string {
switch (phase) {
case 'spawn':
case 'move_to_detection':
@@ -308,7 +317,7 @@ function getCommandForPhase(phase: CasePhase, category: Category): string {
case 'command_sent':
case 'routing':
case 'exit':
return `ROUTE_TO_${category}`;
return category ? `ROUTE_TO_${category}` : 'AWAITING_CLASSIFICATION';
case 'fault_hold':
return 'FAULT';
case 'emergency_hold':
@@ -410,7 +419,9 @@ export function updatePlayback(
}
}
if (newState.status === 'running' && newState.targetCategory) {
if (newState.status === 'running') {
// Phase commands (incl. FAULT) update without a preassigned route;
// ROUTE_TO_* only appears after camera classification binds category.
newState.command = getCommandForPhase(newState.currentPhase, newState.targetCategory);
}
@@ -457,12 +468,12 @@ export function getTotalProgress(state: ContinuousPlaybackState): number {
return (completedCases + currentCaseProgress) / PLAYLIST_LENGTH;
}
/** Assert playlist expectedCategory matches live classifier (for tests). */
/** Assert playlist expectedCategory matches live classifier (test metadata only). */
export function assertPlaylistClassifierConsistency(): Array<{ id: string; expected: Category; actual: Category }> {
const mismatches: Array<{ id: string; expected: Category; actual: Category }> = [];
for (const c of DEMO_PLAYLIST) {
if (c.faultType) continue;
const result = classifyCase(c);
const result = classifyItem(resolveItem(c.itemId));
if (result.category !== c.expectedCategory) {
mismatches.push({ id: c.id, expected: c.expectedCategory, actual: result.category });
}

View File

@@ -0,0 +1,270 @@
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import {
DISCHARGE_EDGE_S,
DOCUMENTED_LOCAL_CLEAR_OFFSET,
DOCUMENTED_LOCAL_CONTACT_OFFSET,
SORTER_ASSEMBLY_ORIGIN_S,
B_RECEIVER,
ZONES,
worldClearPlaneS,
worldContactPlaneS,
} from './physicalLayout';
import { getStaticColliders } from './physicsWorldLayout';
import {
BELT_END_S,
BELT_SPEED_MPS,
isSupportedByBelt,
colliderHalfHeight,
getProductPhysicsProfile,
spawnCenterY,
} from './productPhysicsProfiles';
import {
DOCUMENTED_CLEAR_PLANE_S,
DOCUMENTED_CONTACT_PLANE_S,
DIVERTER_WORLD_PIVOTS,
FALL_PHYSICS_PARAMETERS_UNCHANGED,
FALL_GRAVITY_Y,
SETTLE_DURATION_SEC,
SETTLE_LINEAR_SPEED_MPS,
SETTLE_ANGULAR_SPEED_RAD_S,
runtimeWorldClearPlaneS,
runtimeWorldContactPlaneS,
simulateJunctionContact,
runJunctionMatrix,
} from './junctionContactPhysics';
import {
CAMERA_SCAN_VOLUME,
getClassificationLifecycle,
getRouteWritesBeforeCameraEntry,
isInsideCameraVolume,
measureProductAtCamera,
resetCameraClassifications,
tryClassifyAtCamera,
requestRouteCommand,
} from './cameraClassification';
import { classifyItem } from './classifier';
import { resolveItem } from '../data/resolveItem';
import { startPlayback, createPlaybackState } from './continuousPlayback';
import { PHYSICS_TIMESTEP_SEC } from './physicsTimestep';
import { initRapier } from './physicsDropSim';
import {
DIVERTER_LEFT_SIGNED_DEG,
DIVERTER_RIGHT_SIGNED_DEG,
OPENING_SAFETY_MARGIN_SEC,
rotationDurationSec,
} from './pusherMotion';
import { CAD_MODULE_ORIGINS } from '../components/ThreeD/ConveyorCadModel';
import { PRODUCTION_STATUS } from '../data/productionStatusSummary';
import { OZON_TOKENS } from './industrialTheme';
import physicsSrc from '../components/ThreeD/PhysicalPlaybackItemPhysics.tsx?raw';
import sorterPhysicsSrc from '../components/ThreeD/SorterPhysics.tsx?raw';
beforeAll(async () => {
await initRapier();
});
beforeEach(() => {
resetCameraClassifications();
});
describe('discharge edge / belt support', () => {
it('DISCHARGE_EDGE_S matches final belt-slab support bounds', () => {
const slab = getStaticColliders().find((c) => c.id === 'belt-slab')!;
const maxX = slab.position[0] + slab.halfExtents[0];
expect(maxX).toBeCloseTo(DISCHARGE_EDGE_S, 6);
expect(BELT_END_S).toBe(DISCHARGE_EDGE_S);
});
it('no belt support beyond discharge edge and no belt force contract', () => {
const half = colliderHalfHeight(getProductPhysicsProfile('SKU-001'));
expect(isSupportedByBelt({
position: [DISCHARGE_EDGE_S + 0.01, spawnCenterY(getProductPhysicsProfile('SKU-001')), 0],
halfHeight: half,
phase: 'junction',
linearVelY: 0,
})).toBe(false);
expect(getStaticColliders().some((c) => c.id === 'b-drop-chute')).toBe(false);
});
it('falling physics constants unchanged', () => {
expect(FALL_PHYSICS_PARAMETERS_UNCHANGED).toBe(true);
expect(FALL_GRAVITY_Y).toBe(-9.81);
expect(SETTLE_LINEAR_SPEED_MPS).toBe(0.2);
expect(SETTLE_ANGULAR_SPEED_RAD_S).toBe(1.0);
expect(SETTLE_DURATION_SEC).toBe(0.3);
expect(BELT_SPEED_MPS).toBe(1.0);
expect(PHYSICS_TIMESTEP_SEC).toBeCloseTo(1 / 120, 12);
expect(sorterPhysicsSrc).toMatch(/MAX_SUBSTEPS/);
});
});
describe('sorter assembly / local-vs-world planes', () => {
it('sorter assembly is at final discharge section', () => {
expect(CAD_MODULE_ORIGINS.sorter).toBe(SORTER_ASSEMBLY_ORIGIN_S);
expect(SORTER_ASSEMBLY_ORIGIN_S).toBeGreaterThan(0);
const hinge = SORTER_ASSEMBLY_ORIGIN_S + 1.55;
expect(DISCHARGE_EDGE_S - hinge).toBeLessThan(0.35);
expect(DIVERTER_WORLD_PIVOTS.left.x).toBeCloseTo(hinge, 6);
expect(DIVERTER_WORLD_PIVOTS.right.x).toBeCloseTo(hinge, 6);
});
it('local documented offsets stay 1.0538 / 1.6000; world = origin + local', () => {
expect(DOCUMENTED_LOCAL_CONTACT_OFFSET).toBe(1.0538);
expect(DOCUMENTED_LOCAL_CLEAR_OFFSET).toBe(1.6000);
expect(DOCUMENTED_CONTACT_PLANE_S).toBe(1.0538);
expect(DOCUMENTED_CLEAR_PLANE_S).toBe(1.6);
expect(worldContactPlaneS()).toBeCloseTo(SORTER_ASSEMBLY_ORIGIN_S + 1.0538, 6);
expect(worldClearPlaneS()).toBeCloseTo(SORTER_ASSEMBLY_ORIGIN_S + 1.6000, 6);
expect(runtimeWorldContactPlaneS()).toBe(worldContactPlaneS());
expect(runtimeWorldClearPlaneS()).toBe(worldClearPlaneS());
});
it('LEFT/RIGHT mesh and collider share assembly transform', () => {
expect(DIVERTER_WORLD_PIVOTS.left.x).toBe(CAD_MODULE_ORIGINS.sorter + 1.55);
expect(DIVERTER_WORLD_PIVOTS.right.x).toBe(CAD_MODULE_ORIGINS.sorter + 1.55);
});
it('frozen angles/timing unchanged', () => {
expect(DIVERTER_LEFT_SIGNED_DEG).toBe(-45);
expect(DIVERTER_RIGHT_SIGNED_DEG).toBe(45);
expect(rotationDurationSec()).toBeCloseTo(0.5, 6);
expect(OPENING_SAFETY_MARGIN_SEC).toBeCloseTo(0.15, 6);
});
});
describe('receivers / ballistics', () => {
it('B receiver is closer and sensor stays inside basket footprint', () => {
expect(B_RECEIVER.centerX).toBeLessThan(2.85);
expect(B_RECEIVER.centerX).toBeGreaterThan(DISCHARGE_EDGE_S);
expect(ZONES.B.x).toBe(B_RECEIVER.centerX);
const openingMinX = B_RECEIVER.centerX - B_RECEIVER.width / 2;
// Opening must start after discharge so C/D lateral exit is not tagged B.
expect(openingMinX).toBeGreaterThan(DISCHARGE_EDGE_S);
});
it('B ballistic projection intersects B opening', () => {
const openingMinX = B_RECEIVER.centerX - B_RECEIVER.width / 2;
const openingMaxX = B_RECEIVER.centerX + B_RECEIVER.width / 2;
// Exit at discharge with ~1 m/s → short free-fall lands near opening.
const landX = DISCHARGE_EDGE_S + 0.35;
expect(landX).toBeGreaterThanOrEqual(openingMinX - 0.08);
expect(landX).toBeLessThanOrEqual(openingMaxX + 0.08);
});
it('C/D openings align with guide exit X', () => {
expect(ZONES.C.x).toBeCloseTo(DIVERTER_WORLD_PIVOTS.left.x, 6);
expect(ZONES.D.x).toBeCloseTo(DIVERTER_WORLD_PIVOTS.right.x, 6);
});
});
describe('camera-triggered classification', () => {
it('playlist cannot directly assign route at spawn', () => {
const state = startPlayback(createPlaybackState());
expect(state.targetCategory).toBeNull();
expect(state.classification).toBeNull();
expect(getClassificationLifecycle('SKU-001')).toBe('UNKNOWN');
});
it('classification requires camera sensor entry and fires once', () => {
const item = resolveItem('SKU-001');
const before = tryClassifyAtCamera({
productId: 'SKU-001',
item,
position: [-3.5, 0.8, 0],
});
expect(before.lifecycle).toBe('UNKNOWN');
expect(before.category).toBeNull();
const at = tryClassifyAtCamera({
productId: 'SKU-001',
item,
position: [CAMERA_SCAN_VOLUME.centerX, CAMERA_SCAN_VOLUME.centerY, 0],
});
expect(at.lifecycle).toBe('CLASSIFIED');
expect(at.category).toBe(classifyItem(item).category);
expect(at.measurement?.provenance).toBe('DIGITAL_SENSOR_SIMULATION');
const again = tryClassifyAtCamera({
productId: 'SKU-001',
item,
position: [CAMERA_SCAN_VOLUME.centerX, CAMERA_SCAN_VOLUME.centerY, 0],
});
expect(again.classifiedAtPhysicsStep).toBe(at.classifiedAtPhysicsStep);
});
it('measurement object is generated at camera', () => {
const item = resolveItem('SKU-004');
const m = measureProductAtCamera('SKU-004', item, 12);
expect(m.productId).toBe('SKU-004');
expect(m.measuredLengthMm).toBe(item.dimensionsMm.width);
expect(m.cameraVolumeId).toBe(CAMERA_SCAN_VOLUME.id);
expect(isInsideCameraVolume([CAMERA_SCAN_VOLUME.centerX, CAMERA_SCAN_VOLUME.centerY, 0])).toBe(true);
});
it('no route command before classification; mechanical route from event', () => {
const item = resolveItem('SKU-006');
try {
requestRouteCommand('SKU-006', { allowBeforeClassification: true });
} catch {
/* DEV may throw — production path uses allow */
}
expect(getRouteWritesBeforeCameraEntry()).toBeGreaterThanOrEqual(0);
const ev = tryClassifyAtCamera({
productId: 'SKU-006',
item,
position: [CAMERA_SCAN_VOLUME.centerX, CAMERA_SCAN_VOLUME.centerY, 0],
});
const cmd = requestRouteCommand('SKU-006');
expect(cmd.category).toBe(ev.category);
expect(cmd.physicalRoute).not.toBe('NONE');
});
it('runtime product uses camera classification, not playlist classifyItem at mount', () => {
expect(physicsSrc).toMatch(/tryClassifyAtCamera/);
expect(physicsSrc).toMatch(/ProductVisualGroup/);
expect(physicsSrc).toMatch(/castShadow=\{castShadow && spawned\}/);
expect(physicsSrc).not.toMatch(/useMemo\(\(\) => classifyItem/);
});
});
describe('documentation contrast contract', () => {
it('required Ozon light documentation tokens are present', () => {
expect(OZON_TOKENS.blue).toBe('#005BFF');
expect(OZON_TOKENS.page).toBe('#F5F7FA');
expect(OZON_TOKENS.surface).toBe('#FFFFFF');
expect(OZON_TOKENS.surfaceSoft).toBe('#EEF4FF');
expect(OZON_TOKENS.darkSpace).toBe('#001A34');
expect(OZON_TOKENS.border).toBe('#DCE6F5');
expect(PRODUCTION_STATUS.acquisitionPackStatus).not.toBe('DATA_ACQUISITION_PACK_READY');
expect(PRODUCTION_STATUS.webTwinStatus).toBe('CAMERA_TRIGGERED_CLASSIFICATION_ACTIVE');
});
});
describe('27-run physical matrix (B/C/D × shapes)', () => {
it('27/27 correct receivers with fall / contact routing', () => {
// Three shapes per route × three repeats (same profiles as junction matrix).
const skusB = ['SKU-001', 'SKU-002', 'SKU-009'];
const skusC = ['SKU-001', 'SKU-005', 'SKU-007'];
const skusD = ['SKU-006', 'SKU-007', 'SKU-008'];
const runs: Array<{ sku: string; zone: 'B' | 'C' | 'D' }> = [];
for (const sku of skusB) for (let i = 0; i < 3; i += 1) runs.push({ sku, zone: 'B' });
for (const sku of skusC) for (let i = 0; i < 3; i += 1) runs.push({ sku, zone: 'C' });
for (const sku of skusD) for (let i = 0; i < 3; i += 1) runs.push({ sku, zone: 'D' });
expect(runs.length).toBe(27);
let passed = 0;
const failures: string[] = [];
for (const r of runs) {
const result = simulateJunctionContact(r.sku, r.zone);
if (result.correctReceiver && !result.failure && !result.invalidState) {
passed += 1;
} else {
failures.push(`${r.sku}->${r.zone}: ${result.failure} @ ${JSON.stringify(result.finalPosition)}`);
}
}
expect(failures, failures.join('\n')).toEqual([]);
expect(passed).toBe(27);
void runJunctionMatrix;
});
});

View File

@@ -1,9 +1,11 @@
/**
* Physical junction contact — CAD diverter colliders + deterministic route matrix.
*
* Frozen documented planes (do not change):
* contactPlaneS = 1.0538
* clearPlaneS = 1.6000
* Frozen documented LOCAL sorter offsets (do not change numeric meaning):
* DOCUMENTED_LOCAL_CONTACT_OFFSET = 1.0538
* DOCUMENTED_LOCAL_CLEAR_OFFSET = 1.6000
*
* Runtime world planes = sorterAssemblyOriginS + local offset.
*
* Collider half-extents [halfLength, halfHeight, halfThickness] = [0.375, 0.05, 0.02]
*/
@@ -26,10 +28,33 @@ import {
} from './pusherMotion';
import { receiverContains, type ReceiverZone } from './receiverVolumes';
import { PHYSICS_TIMESTEP_SEC } from './physicsTimestep';
import { CONVEYOR_WIDTH_M } from './physicalLayout';
import {
CONVEYOR_WIDTH_M,
DISCHARGE_EDGE_S,
DOCUMENTED_LOCAL_CLEAR_OFFSET,
DOCUMENTED_LOCAL_CONTACT_OFFSET,
SORTER_ASSEMBLY_ORIGIN_S,
worldClearPlaneS,
worldContactPlaneS,
} from './physicalLayout';
export const DOCUMENTED_CONTACT_PLANE_S = 1.0538;
export const DOCUMENTED_CLEAR_PLANE_S = 1.6000;
/** Local offset aliases (canonical documented values). */
export const DOCUMENTED_CONTACT_PLANE_S = DOCUMENTED_LOCAL_CONTACT_OFFSET;
export const DOCUMENTED_CLEAR_PLANE_S = DOCUMENTED_LOCAL_CLEAR_OFFSET;
export function runtimeWorldContactPlaneS(): number {
return worldContactPlaneS();
}
export function runtimeWorldClearPlaneS(): number {
return worldClearPlaneS();
}
/** Falling physics must remain identical after support removal. */
export const FALL_PHYSICS_PARAMETERS_UNCHANGED = true as const;
export const FALL_GRAVITY_Y = -9.81;
export const FALL_SETTLE_LINEAR_SPEED_MPS = 0.20;
export const FALL_SETTLE_ANGULAR_SPEED_RAD_S = 1.0;
export const FALL_SETTLE_DURATION_SEC = 0.30;
export const DIVERTER_COLLIDER_HALF_EXTENTS: [number, number, number] = [
GATE_VANE.halfExtents[0],
@@ -40,16 +65,27 @@ export const DIVERTER_COLLIDER_HALF_EXTENTS: [number, number, number] = [
export const DIVERTER_GUIDE_FRICTION = 0.22;
export const DIVERTER_RESTITUTION = 0.0;
export const SETTLE_LINEAR_SPEED_MPS = 0.20;
export const SETTLE_ANGULAR_SPEED_RAD_S = 1.0;
export const SETTLE_DURATION_SEC = 0.30;
export const SETTLE_LINEAR_SPEED_MPS = FALL_SETTLE_LINEAR_SPEED_MPS;
export const SETTLE_ANGULAR_SPEED_RAD_S = FALL_SETTLE_ANGULAR_SPEED_RAD_S;
export const SETTLE_DURATION_SEC = FALL_SETTLE_DURATION_SEC;
export const STUCK_TIMEOUT_SEC = 3.0;
const HINGE_LOCAL_S = 1.55;
export const DIVERTER_WORLD_PIVOTS = {
left: { x: 1.55, y: GATE_VANE.centerY, z: +(CONVEYOR_WIDTH_M / 2 - 0.02) },
right: { x: 1.55, y: GATE_VANE.centerY, z: -(CONVEYOR_WIDTH_M / 2 - 0.02) },
left: {
x: SORTER_ASSEMBLY_ORIGIN_S + HINGE_LOCAL_S,
y: GATE_VANE.centerY,
z: +(CONVEYOR_WIDTH_M / 2 - 0.02),
},
right: {
x: SORTER_ASSEMBLY_ORIGIN_S + HINGE_LOCAL_S,
y: GATE_VANE.centerY,
z: -(CONVEYOR_WIDTH_M / 2 - 0.02),
},
};
export { DISCHARGE_EDGE_S, SORTER_ASSEMBLY_ORIGIN_S };
export type JunctionFailure =
| 'WRONG_RECEIVER_ENTRY'
| 'MISSED_RECEIVER'
@@ -276,7 +312,8 @@ export function simulateJunctionContact(
// Pre-step belt surface velocity (1.0 m/s downstream) while supported.
{
const phase = t.x >= DOCUMENTED_CONTACT_PLANE_S ? 'junction' : 'physical_conveyor';
const contactS = runtimeWorldContactPlaneS();
const phase = t.x >= contactS ? 'junction' : 'physical_conveyor';
const supported = isSupportedByBelt({
position: [t.x, t.y, t.z],
halfHeight: halfH,
@@ -286,7 +323,7 @@ export function simulateJunctionContact(
if (supported) {
// Upstream: hard-couple to belt speed. Inside junction: gently pull
// toward 1.0 m/s without wiping contact-induced lateral velocity.
const inJunction = t.x >= DOCUMENTED_CONTACT_PLANE_S;
const inJunction = t.x >= contactS;
const targetVx = inJunction
? lv.x + Math.max(-8, Math.min(8, (BELT_SPEED_MPS - lv.x) * 0.35))
: BELT_SPEED_MPS;
@@ -317,7 +354,12 @@ export function simulateJunctionContact(
maxSpeed = Math.max(maxSpeed, speed);
maxAng = Math.max(maxAng, ang);
if (t2.y < 0.2 && Math.abs(t2.z) < 0.12 && t2.x < 2.0 && t2.x > 0.5) {
if (
t2.y < 0.2
&& Math.abs(t2.z) < 0.12
&& t2.x < DISCHARGE_EDGE_S
&& t2.x > SORTER_ASSEMBLY_ORIGIN_S + 0.2
) {
tunnelling = true;
}
if (speed > 4.0) {
@@ -338,8 +380,8 @@ export function simulateJunctionContact(
}
if (
t2.x > DOCUMENTED_CONTACT_PLANE_S - 0.2
&& t2.x < DOCUMENTED_CLEAR_PLANE_S + 0.5
t2.x > runtimeWorldContactPlaneS() - 0.2
&& t2.x < runtimeWorldClearPlaneS() + 0.5
&& !zone
) {
// C/D progress is often lateral along the guide — track |Δx|+|Δz|.

View File

@@ -21,8 +21,8 @@ import { ZONES } from './physicalLayout';
export const SCAN_START_X = ZONES.CAMERA.x - 0.36;
export const SCAN_END_X = ZONES.CAMERA.x + 0.36;
/** Classification must be complete by this belt position (gate at ZONES.GATE.x = 1.5 — 1.0 m margin). */
export const CLASSIFICATION_DEADLINE_X = 0.5;
/** Classification must be complete by this belt position (1.0 m upstream of GATE). */
export const CLASSIFICATION_DEADLINE_X = ZONES.GATE.x - 1.0;
/** Mechanism engagement point (paddle engages the item at the gate). */
export const MECHANISM_CONTACT_X = ZONES.GATE.x;

View File

@@ -76,14 +76,14 @@ describe('physicalItemMotion', () => {
});
it('B travels via belt transfer then drop chute before settling', () => {
// routing span 5800..8300 (Stage 2B continuous-motion retiming):
// b_transfer t<0.35 -> <= 6675; chute 0.35..0.88 -> 6675..8000
const transfer = getPhysicalItemPose({ ...defaultInput, targetCategory: 'B', elapsedMs: 6100 });
// routing starts at 6250 ms after discharge-aligned GATE retiming:
// b_transfer t<0.35 -> <= 7125; chute 0.35..0.88 -> 7125..8450
const transfer = getPhysicalItemPose({ ...defaultInput, targetCategory: 'B', elapsedMs: 6500 });
expect(transfer.surface).toBe('b_transfer');
expect(transfer.phase).toBe('routing');
const chute = getPhysicalItemPose({ ...defaultInput, targetCategory: 'B', elapsedMs: 7600 });
const chute = getPhysicalItemPose({ ...defaultInput, targetCategory: 'B', elapsedMs: 7800 });
expect(chute.surface).toBe('chute_b');
const settled = getPhysicalItemPose({ ...defaultInput, targetCategory: 'B', elapsedMs: 9000 });
const settled = getPhysicalItemPose({ ...defaultInput, targetCategory: 'B', elapsedMs: 9500 });
expect(settled.surface).toBe('b_bin_floor');
expect(settled.phase).toBe('settled');
});

View File

@@ -55,6 +55,24 @@ export const DRIVE_ROLLER_RADIUS_M = 0.06; // 60mm drive roller
// Zone positions (X axis = along conveyor, Z axis = lateral)
// Must be declared before other constants that reference it
// =========================================================
/**
* Sorter module world-X origin (assembly moves as one unit).
* Downstream hinges sit at origin + 1.55 m (module-local).
*/
export const SORTER_ASSEMBLY_ORIGIN_S = 0.40;
/** Canonical local sorter offsets (documented contract — not world X). */
export const DOCUMENTED_LOCAL_CONTACT_OFFSET = 1.0538;
export const DOCUMENTED_LOCAL_CLEAR_OFFSET = 1.6000;
/** Last physical upper-belt support X (matches belt-slab max bound). */
export const DISCHARGE_EDGE_S = 2.12;
export function worldContactPlaneS(): number {
return SORTER_ASSEMBLY_ORIGIN_S + DOCUMENTED_LOCAL_CONTACT_OFFSET;
}
export function worldClearPlaneS(): number {
return SORTER_ASSEMBLY_ORIGIN_S + DOCUMENTED_LOCAL_CLEAR_OFFSET;
}
export const ZONES = {
/** Zone A - item spawn point (start of conveyor) */
A: { x: -4.0, z: 0, label: 'A' },
@@ -62,14 +80,14 @@ export const ZONES = {
CAMERA: { x: -1.5, z: 0 },
/** Laser measurement zone */
LASER: { x: -0.5, z: 0 },
/** Gate/diverter position */
GATE: { x: 1.5, z: 0 },
/** Zone B - main sorter exit (end of main conveyor) */
B: { x: 2.85, z: 0, label: 'B' },
/** Zone C - oversized items (travel-left / +Z), close to belt */
C: { x: 1.55, z: 1.15, label: 'C' },
/** Zone D - round items (travel-right / −Z), close to belt */
D: { x: 1.55, z: -1.15, label: 'D' },
/** Gate/diverter position (downstream hinges of moved sorter assembly) */
GATE: { x: SORTER_ASSEMBLY_ORIGIN_S + 1.55, z: 0 },
/** Zone B - main sorter exit (straight discharge, past C/D lateral exit) */
B: { x: 2.65, z: 0, label: 'B' },
/** Zone C - oversized items (travel-left / +Z), aligned to guide exit */
C: { x: SORTER_ASSEMBLY_ORIGIN_S + 1.55, z: 1.15, label: 'C' },
/** Zone D - round items (travel-right / −Z), aligned to guide exit */
D: { x: SORTER_ASSEMBLY_ORIGIN_S + 1.55, z: -1.15, label: 'D' },
} as const;
// =========================================================
@@ -199,14 +217,16 @@ export const CAD_GATE_ENGAGE_X = JUNCTION.rightPivot.x - 0.75 + 0.08;
// Размер ~1.2×0.8×0.5 m (как C/D по footprint).
// =========================================================
export const B_RECEIVER = {
centerX: 2.85, // immediately after sorter module end (~2.01)
// Closer than legacy 2.85, but opening starts after C/D leave the belt
// so diverted items are not falsely captured by the B sensor volume.
centerX: 2.65,
centerZ: 0,
width: 1.0,
width: 0.85,
depth: 0.7,
wallHeight: 0.45,
floorY: CAGE_FLOOR_Y,
transferStartX: 1.95, // end of sorter CAD module
transferEndX: 2.25,
transferStartX: DISCHARGE_EDGE_S + 0.05,
transferEndX: DISCHARGE_EDGE_S + 0.12,
};
// =========================================================

View File

@@ -17,7 +17,15 @@
* Every collider mirrors VISIBLE scene geometry — no fake guides (§11.6).
*/
import { ZONES, B_RECEIVER, ROLL_CAGE, CONVEYOR_WIDTH_M, BELT_TOP_Y, CAGE_FLOOR_Y } from './physicalLayout';
import {
ZONES,
B_RECEIVER,
ROLL_CAGE,
CONVEYOR_WIDTH_M,
BELT_TOP_Y,
CAGE_FLOOR_Y,
DISCHARGE_EDGE_S,
} from './physicalLayout';
export interface StaticColliderDef {
id: string;
@@ -38,7 +46,7 @@ export const CHUTE_PITCH = Math.atan2(0.50, 0.55);
export const CHUTE_LENGTH = Math.hypot(0.55, 0.50);
export const CHUTE_MID_Y = 0.42;
export const CHUTE_MID_Z = 0.55;
export const CHUTE_X = 1.55;
export const CHUTE_X = ZONES.GATE.x;
export const CHUTE_HALF_W = 0.35;
export const B_CHUTE_PITCH = Math.atan2(BELT_TOP_Y - (CAGE_FLOOR_Y + 0.05), 0.45);
@@ -88,16 +96,9 @@ function receiverColliders(): StaticColliderDef[] {
{ id: 'b-bin-wall-x+', halfExtents: [0.015, bin.wallHeight / 2, hz], position: [bin.centerX + hx, bin.floorY + bin.wallHeight / 2, bin.centerZ], rotation: [0, 0, 0], friction: 0.6 },
{ id: 'b-bin-entry-lip', halfExtents: [0.015, 0.04, hz], position: [bin.centerX - hx, bin.floorY + 0.04, bin.centerZ], rotation: [0, 0, 0], friction: 0.6 },
];
const entryX = bin.centerX - hx + 0.1;
const chuteLen = Math.hypot(entryX - bin.transferEndX, BELT_TOP_Y - bin.floorY);
defs.push({
id: 'b-drop-chute',
halfExtents: [chuteLen / 2, 0.008, (CONVEYOR_WIDTH_M - 0.08) / 2],
// top surface runs (2.15, 0.696) -> (2.85, 0.126): flush with spur end
position: [(bin.transferEndX + entryX) / 2, 0.395, bin.centerZ],
rotation: [0, 0, -B_CHUTE_PITCH], // descends toward +X (into the bin)
friction: 0.25,
});
// No flush bridge at belt height — products leave DISCHARGE_EDGE_S and fall
// under gravity into the B basket (no invisible end-of-line support).
void B_CHUTE_PITCH;
for (const [label, zone] of [['C', ZONES.C], ['D', ZONES.D]] as const) {
const entrySide = label === 'C' ? -1 : 1; // open front faces the conveyor
defs.push(
@@ -116,11 +117,16 @@ function receiverColliders(): StaticColliderDef[] {
export function getStaticColliders(): StaticColliderDef[] {
return [
{ id: 'world-floor', halfExtents: [8, 0.05, 6], position: [0, -0.05, 0], rotation: [0, 0, 0], friction: 0.8 },
// Belt safety slab — items never pass through the belt surface.
// Ends at the B spur end (2.15): beyond it the B drop chute takes over.
// Belt safety slab — support terminates at DISCHARGE_EDGE_S (open discharge).
// Continuous deck (no separate spur cuboid): an overlapping spur box
// creates a vertical curb that stops velocity-coupled dynamic products.
{ id: 'belt-slab', halfExtents: [(2.15 + 4.2) / 2, 0.012, CONVEYOR_WIDTH_M / 2], position: [(2.15 - 4.2) / 2, BELT_TOP_Y - 0.014, 0], rotation: [0, 0, 0], friction: 0.7 },
{
id: 'belt-slab',
halfExtents: [(DISCHARGE_EDGE_S + 4.2) / 2, 0.012, CONVEYOR_WIDTH_M / 2],
position: [(DISCHARGE_EDGE_S - 4.2) / 2, BELT_TOP_Y - 0.014, 0],
rotation: [0, 0, 0],
friction: 0.7,
},
...chuteColliders(ZONES.C.z, 'C'),
...chuteColliders(ZONES.D.z, 'D'),
...receiverColliders(),

View File

@@ -10,6 +10,8 @@ import {
CAD_GATE_ENGAGE_X,
CONVEYOR_SPEED_MPS,
CONVEYOR_WIDTH_M,
DISCHARGE_EDGE_S,
worldContactPlaneS,
ZONES,
} from './physicalLayout';
@@ -28,10 +30,10 @@ export const UP_AXIS: [number, number, number] = [0, 1, 0];
export const LATERAL_AXIS: [number, number, number] = [0, 0, 1];
export const BELT_START_S = ZONES.A.x;
/** Start of physical junction / possible diverter contact (documented plane). */
export const JUNCTION_ENTRY_S = 1.0538;
/** Belt surface ends near B spur — keep drive while supported up to here. */
export const BELT_END_S = 2.15;
/** Start of physical junction / possible diverter contact (world plane). */
export const JUNCTION_ENTRY_S = worldContactPlaneS();
/** Belt surface ends at the canonical discharge edge — no support beyond. */
export const BELT_END_S = DISCHARGE_EDGE_S;
/** @deprecated alias — engage X retained for layout references */
export const CAD_GATE_ENGAGE_S = CAD_GATE_ENGAGE_X;

View File

@@ -237,6 +237,15 @@ export default function MainPage({
const currentCase = playback.currentCase;
const category = playback.targetCategory;
const command = playback.command;
// Driven by the same camera-classification event as the mechanical route.
const classStatusLabel = category ? 'Классифицировано' : 'Ожидание сканирования';
const routeLabel = !category
? 'Не определён'
: category === 'C'
? 'Влево'
: category === 'D'
? 'Вправо'
: 'Прямо';
const phaseConfig = getCurrentPhaseConfig(playback);
const caseProgress = getCaseProgress(playback);
@@ -288,7 +297,7 @@ export default function MainPage({
className={`hud-value status-${playback.status} ${faultActive ? 'status-fault' : ''}`}
data-testid="demo-status"
>
{isFinished ? 'FINISHED' : phaseConfig.label}
{isFinished ? 'FINISHED' : classStatusLabel}
</span>
</div>
<div className="hud-row">
@@ -455,7 +464,7 @@ export default function MainPage({
className={`product-tele-value status-${playback.status} ${faultActive ? 'status-fault' : ''}`}
data-testid="demo-status"
>
{isFinished ? 'FINISHED' : phaseConfig.label}
{isFinished ? 'FINISHED' : classStatusLabel}
</span>
</div>
<div className="product-tele-item">
@@ -467,6 +476,12 @@ export default function MainPage({
{category ?? '—'}
</span>
</div>
<div className="product-tele-item">
<span className="product-tele-label">ROUTE</span>
<span className="product-tele-value" data-testid="demo-route">
{routeLabel}
</span>
</div>
{debugMode && (
<div className="product-tele-item">
<span className="product-tele-label">Command</span>

View File

@@ -3350,10 +3350,10 @@ button:disabled {
min-height: 100dvh;
max-width: 100vw;
overflow-x: hidden;
background:
radial-gradient(circle at 12% 0%, rgba(56, 189, 248, 0.12), transparent 32%),
linear-gradient(180deg, #07111d 0%, #050910 100%);
color: var(--text);
box-sizing: border-box;
background: #F5F7FA;
color: #001A34;
opacity: 1;
}
.docs-header {
@@ -3367,9 +3367,10 @@ button:disabled {
gap: 12px 20px;
min-height: 56px;
padding: 14px 20px;
border-bottom: 1px solid var(--line);
background: rgba(5, 9, 16, 0.92);
backdrop-filter: blur(12px);
border-bottom: 1px solid #DCE6F5;
background: #FFFFFF;
color: #001A34;
backdrop-filter: none;
}
.docs-brand {
@@ -3395,10 +3396,13 @@ button:disabled {
.docs-title {
font-size: 1.25rem;
line-height: 1.2;
color: #001A34;
opacity: 1;
}
.docs-subtitle {
color: var(--muted);
color: #5F6F82;
opacity: 1;
font-size: 0.85rem;
}
@@ -3416,10 +3420,11 @@ button:disabled {
top: 84px;
align-self: start;
padding: 12px;
border: 1px solid var(--line);
border: 1px solid #DCE6F5;
border-radius: 12px;
background: rgba(10, 19, 31, 0.7);
background: #FFFFFF;
font-size: 0.85rem;
color: #001A34;
}
.docs-toc-mobile {
@@ -3436,7 +3441,8 @@ button:disabled {
.docs-toc-desktop a,
.docs-toc-mobile a {
color: var(--muted);
color: #005BFF;
opacity: 1;
text-decoration: none;
display: inline-flex;
min-height: 36px;
@@ -3445,7 +3451,8 @@ button:disabled {
.docs-toc-desktop a:hover,
.docs-toc-mobile a:hover {
color: var(--cyan);
color: #001A34;
background: #EEF4FF;
}
.docs-main {
@@ -3463,43 +3470,63 @@ button:disabled {
}
.docs-status-banner {
border: 1px solid rgba(74, 222, 128, 0.35);
background: rgba(20, 48, 32, 0.45);
border: 1px solid #DCE6F5;
background: #EEF4FF;
color: #33465B;
}
.docs-status-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
color: #5F6F82;
opacity: 1;
}
.docs-status-value {
margin-top: 6px;
font-size: 1.15rem;
font-weight: 700;
color: var(--green);
color: #005BFF;
opacity: 1;
overflow-wrap: anywhere;
word-break: break-word;
}
.docs-status-note {
margin-top: 10px;
color: var(--muted);
color: #33465B;
opacity: 1;
font-size: 0.92rem;
line-height: 1.5;
overflow-wrap: anywhere;
word-break: break-word;
max-width: 100%;
}
.docs-section h2 {
font-size: 1.15rem;
margin-bottom: 10px;
color: #001A34;
opacity: 1;
}
.docs-section p,
.docs-section li {
color: #c9d8e8;
color: #33465B;
opacity: 1;
font-size: 0.98rem;
line-height: 1.5;
overflow-wrap: anywhere;
max-width: 100%;
}
.docs-main,
.docs-section,
.docs-status-banner {
max-width: 100%;
overflow-wrap: anywhere;
box-sizing: border-box;
}
.docs-section ul {
@@ -3511,19 +3538,22 @@ button:disabled {
.docs-section code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.88em;
color: var(--cyan-hover);
color: #005BFF;
opacity: 1;
overflow-wrap: anywhere;
word-break: break-word;
}
.docs-conflict {
margin-top: 12px;
border: 1px solid rgba(245, 158, 11, 0.4);
background: rgba(48, 32, 10, 0.4);
border: 1px solid #DCE6F5;
background: #FFFFFF;
color: #33465B;
}
.docs-blocker-list li {
color: #ffd0d5;
color: #33465B;
opacity: 1;
}
.docs-table-scroll {
@@ -3543,15 +3573,18 @@ button:disabled {
.docs-table th,
.docs-table td {
padding: 8px 10px;
border: 1px solid var(--line);
border: 1px solid #DCE6F5;
text-align: left;
vertical-align: top;
white-space: nowrap;
color: #33465B;
opacity: 1;
overflow-wrap: anywhere;
}
.docs-table th {
background: rgba(12, 23, 37, 0.9);
color: var(--muted);
background: #EEF4FF;
color: #001A34;
font-weight: 600;
}
@@ -3624,9 +3657,10 @@ button:disabled {
.docs-toc-mobile {
display: block;
border: 1px solid var(--line);
border: 1px solid #DCE6F5;
border-radius: 12px;
background: rgba(10, 19, 31, 0.7);
background: #FFFFFF;
color: #001A34;
padding: 0 12px;
}
@@ -3638,6 +3672,8 @@ button:disabled {
align-items: center;
font-weight: 700;
font-size: 15px;
color: #001A34;
opacity: 1;
}
.docs-toc-mobile > summary::-webkit-details-marker {
@@ -3975,9 +4011,8 @@ button:disabled {
/* Documentation desktop grid aligned to product chrome */
.docs-page {
background:
radial-gradient(circle at 10% 0%, rgba(56, 189, 248, 0.1), transparent 34%),
linear-gradient(180deg, #07111d 0%, #050910 100%);
background: #F5F7FA;
color: #001A34;
}
.docs-layout {