diff --git a/e2e/routes.spec.ts b/e2e/routes.spec.ts index 23d4c43..907cb21 100644 --- a/e2e/routes.spec.ts +++ b/e2e/routes.spec.ts @@ -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(); diff --git a/src/App.tsx b/src/App.tsx index 29ae6f9..e9f4852 100644 --- a/src/App.tsx +++ b/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(() => 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).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) { diff --git a/src/components/ThreeD/ConveyorCadModel.tsx b/src/components/ThreeD/ConveyorCadModel.tsx index e54c208..3cfa80d 100644 --- a/src/components/ThreeD/ConveyorCadModel.tsx +++ b/src/components/ThreeD/ConveyorCadModel.tsx @@ -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; diff --git a/src/components/ThreeD/PhysicalPlaybackItemPhysics.tsx b/src/components/ThreeD/PhysicalPlaybackItemPhysics.tsx index a104da5..253de3c 100644 --- a/src/components/ThreeD/PhysicalPlaybackItemPhysics.tsx +++ b/src/components/ThreeD/PhysicalPlaybackItemPhysics.tsx @@ -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(null); const authority = useRef(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(), }); diff --git a/src/components/ThreeD/SorterDigitalTwinContinuous.tsx b/src/components/ThreeD/SorterDigitalTwinContinuous.tsx index 89f73ae..6da25a1 100644 --- a/src/components/ThreeD/SorterDigitalTwinContinuous.tsx +++ b/src/components/ThreeD/SorterDigitalTwinContinuous.tsx @@ -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; diff --git a/src/components/ThreeD/SorterPhysics.tsx b/src/components/ThreeD/SorterPhysics.tsx index db0dd11..b69c2a2 100644 --- a/src/components/ThreeD/SorterPhysics.tsx +++ b/src/components/ThreeD/SorterPhysics.tsx @@ -190,7 +190,7 @@ export function SorterPhysicsWorld({ paused timeStep={PHYSICS_DT} gravity={PHYSICS_GRAVITY} - interpolate + interpolate={false} > diff --git a/src/data/productionStatusSummary.ts b/src/data/productionStatusSummary.ts index 371f2c6..e9abff5 100644 --- a/src/data/productionStatusSummary.ts +++ b/src/data/productionStatusSummary.ts @@ -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 = [ diff --git a/src/domain/cameraClassification.ts b/src/domain/cameraClassification.ts new file mode 100644 index 0000000..aed3a7c --- /dev/null +++ b/src/domain/cameraClassification.ts @@ -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(); +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, + }; +} diff --git a/src/domain/continuousPlayback.test.ts b/src/domain/continuousPlayback.test.ts index 66180be..f7e1455 100644 --- a/src/domain/continuousPlayback.test.ts +++ b/src/domain/continuousPlayback.test.ts @@ -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', () => { diff --git a/src/domain/continuousPlayback.ts b/src/domain/continuousPlayback.ts index e6ebefd..bbe661c 100644 --- a/src/domain/continuousPlayback.ts +++ b/src/domain/continuousPlayback.ts @@ -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 }); } diff --git a/src/domain/endOfLineCameraClassification.test.ts b/src/domain/endOfLineCameraClassification.test.ts new file mode 100644 index 0000000..78ecc32 --- /dev/null +++ b/src/domain/endOfLineCameraClassification.test.ts @@ -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; + }); +}); diff --git a/src/domain/junctionContactPhysics.ts b/src/domain/junctionContactPhysics.ts index c87bcee..2adaad2 100644 --- a/src/domain/junctionContactPhysics.ts +++ b/src/domain/junctionContactPhysics.ts @@ -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|. diff --git a/src/domain/measurementZone.ts b/src/domain/measurementZone.ts index 866ce9a..53cc087 100644 --- a/src/domain/measurementZone.ts +++ b/src/domain/measurementZone.ts @@ -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; diff --git a/src/domain/physicalItemMotion.test.ts b/src/domain/physicalItemMotion.test.ts index 193b01a..b20980b 100644 --- a/src/domain/physicalItemMotion.test.ts +++ b/src/domain/physicalItemMotion.test.ts @@ -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'); }); diff --git a/src/domain/physicalLayout.ts b/src/domain/physicalLayout.ts index c7a7c0a..eaaeb08 100644 --- a/src/domain/physicalLayout.ts +++ b/src/domain/physicalLayout.ts @@ -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, }; // ========================================================= diff --git a/src/domain/physicsWorldLayout.ts b/src/domain/physicsWorldLayout.ts index 6a4e528..08760c0 100644 --- a/src/domain/physicsWorldLayout.ts +++ b/src/domain/physicsWorldLayout.ts @@ -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(), diff --git a/src/domain/productPhysicsProfiles.ts b/src/domain/productPhysicsProfiles.ts index 7982775..a436ae8 100644 --- a/src/domain/productPhysicsProfiles.ts +++ b/src/domain/productPhysicsProfiles.ts @@ -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; diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index 3607c58..9cbec33 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -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}
@@ -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}
@@ -467,6 +476,12 @@ export default function MainPage({ {category ?? '—'}
+
+ ROUTE + + {routeLabel} + +
{debugMode && (
Command diff --git a/src/styles.css b/src/styles.css index 66a52b8..de5c220 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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 {