fix: align discharge sorting and camera classification
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
13
src/App.tsx
13
src/App.tsx
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ export function SorterPhysicsWorld({
|
||||
paused
|
||||
timeStep={PHYSICS_DT}
|
||||
gravity={PHYSICS_GRAVITY}
|
||||
interpolate
|
||||
interpolate={false}
|
||||
>
|
||||
<PhysicsSimClock />
|
||||
<RapierStepper running={running} speed={speed} />
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
229
src/domain/cameraClassification.ts
Normal file
229
src/domain/cameraClassification.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
270
src/domain/endOfLineCameraClassification.test.ts
Normal file
270
src/domain/endOfLineCameraClassification.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
@@ -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|.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
// =========================================================
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user