diff --git a/README.md b/README.md index f40ceee..23a3208 100644 --- a/README.md +++ b/README.md @@ -2,55 +2,27 @@ Web digital twin of an Ozon Tech Track 3 conveyor sorting line: continuous product playback, camera/classification stage, B/C/D routing, and author-CAD diverter motion. -**Production domain:** https://arhipovdan.ru +## Current product -## Current solution +This repository contains: -This repository (`main`) contains the final web simulation: - -- continuous 3D conveyor digital twin (`/`); +- web-based conveyor sorting simulation (`/`); - engineering documentation page (`/documentation`); - author CAD conveyor source (`3d_models/conveer.FCStd`); - runtime conveyor GLB (`public/models/sorter/conveyor-clean.glb`); - product STL assets (`public/models/*.stl`); -- camera-triggered measurement / rule-based classification (digital sensor simulation); -- B / C / D route mapping with CAD left/right diverters (−45° / +45°); -- Rapier physics: belt ≈ 1.0 m/s, fixed timestep 1/120 s, CCD on product profiles, discharge into receivers. +- camera / classification stage; +- B / C / D route mapping with CAD left/right diverters; +- Rapier-backed product physics (contact routing **not** fully validated). -### Main functions - -1. Continuous SKU playback on a three-module CAD conveyor (clean → camera → sorter). -2. Camera-volume triggered measurement and B/C/D classification. -3. Physical diverter routing and receiver settle detection. -4. Desktop WebGL twin + mobile-capable 3D policy. -5. Canonical engineering status on `/documentation`. - -## Technology stack - -| Layer | Packages | -|---|---| -| UI | React, react-router-dom | -| 3D | three, @react-three/fiber, @react-three/drei, @react-three/postprocessing | -| Physics | @react-three/rapier, @dimforge/rapier3d-compat | -| Build | Vite, TypeScript, @vitejs/plugin-react | -| Tests | Vitest, Playwright | - -See `package.json` for exact versions. Do not treat transitive npm packages as first-class product dependencies. - -## Dependencies / Node - -- Node.js **20+** -- npm (lockfile: `package-lock.json`) - -### Exact commands +## Quick start ```bash npm ci -npm run dev # development: http://127.0.0.1:3100 -npm test -- --run # unit tests -npm run build # production build → dist/ -npm run preview # serve build: http://127.0.0.1:3100 -npm run test:e2e # Playwright (needs preview or PLAYWRIGHT_BASE_URL) +npm run dev # http://127.0.0.1:3100 +npm test -- --run +npm run build +npm run preview # http://127.0.0.1:3100 ``` ## Active routes @@ -63,37 +35,20 @@ npm run test:e2e # Playwright (needs preview or PLAYWRIGHT_BASE_URL) Removed routes (`/details`, `/device-test`) are not part of the product. -## Project structure +## Runtime architecture ``` -src/ React app, domain logic, 3D twin, pages -public/ Runtime static assets (models, draco) - models/ Product STL + sorter/conveyor-clean.glb -3d_models/ Author CAD (conveer.FCStd) -docs/ Engineering notes (ENGINEERING.md) -e2e/ Playwright smoke / routes -input_info/ Official Ozon input packs (PDFs/ZIPs) -official_sources/ Classifier bounds PDF cited by code -Dockerfile Multi-stage Vite build + nginx -docker-compose.server.yml -nginx.conf -.github/workflows/ci.yml +index.html +→ src/main.tsx +→ src/App.tsx +→ MainPage (/) | DocumentationPage (/documentation) +→ SorterDigitalTwinContinuous +→ ConveyorCadModel (conveyor-clean.glb) +→ product visuals / physics (STL + Rapier) +→ classifier (domain) +→ diverter product state machine (READY→ARMED→OPENING→HOLDING→CLOSING) ``` -## Environment variables - -No runtime secrets are required for local demo or production static hosting. - -Optional **build-time** identity (Docker / CI only; never commit real secrets): - -| Variable | Purpose | -|---|---| -| `VITE_BUILD_COMMIT` | Short git commit in `/version.json` | -| `VITE_BUILD_BRANCH` | Branch name in `/version.json` | -| `VITE_BUILD_RELEASE` | Release label in `/version.json` | - -Do not commit `.env` files. Local `npm run build` derives identity from git when available. - ## Active assets | Path | Role | @@ -114,24 +69,6 @@ public/models/sorter/conveyor-clean.glb 1dc7a8d7891bfe756e277ad5368df74cb73410156b2fe0f92845afb8a56f285a ``` -## Classifier vs real CV - -**Web twin (`main`):** classification is a **digital sensor simulation**. When a product enters the camera scan volume, dimensions / roundness are measured in the twin and routed with the same B/C/D rules as Track 3 (`src/domain/classifier.ts`, `src/domain/cameraClassification.ts`). This is **not** a neural network and is **not** wired to a live RealSense camera in production. - -**Real CV (parallel prototype, not merged into the web app):** preserved on branch **`drho1y-mvp_1`** under `vision_classifier/` (OpenCV + RealSense D415 depth → L×W×H + circle_ratio → B/C/D → MQTT). No PyTorch/YOLO weights are stored in git. - -```bash -git fetch origin -git switch drho1y-mvp_1 -cd vision_classifier -# see vision_classifier/README.md and START.md -./demo.sh # browser HUD demo -.venv/bin/python test_classify.py -./run.sh --preview # full pipeline on hardware (Orange PI + RealSense) -``` - -Integration status: **WORKING_PROTOTYPE on `drho1y-mvp_1`**, **not integrated** into https://arhipovdan.ru. - ## Sorting logic **CURRENT_IMPLEMENTATION_VERIFIED_IN_CODE** (`src/domain/classifier.ts`, `src/domain/pusherMotion.ts`, unit tests). @@ -155,79 +92,29 @@ Classifier bounds (code + `official_sources/doc-1783095831.pdf` reference; PDF n - circular when **K > 0.8** - check order: dimensions → C, else circular → D, else B -## Physical simulation - -- Fixed timestep **1/120 s**, max **4** substeps, gravity **[0, −9.81, 0]** -- Belt target speed **1.0 m/s** (supported-body velocity coupling) -- CCD enabled on active product physics profiles -- Single dynamic product rigid body through spawn → junction → receiver settle -- LEFT/RIGHT CAD diverters: `kinematicPositionBased` colliders synced to CAD yaw -- Discharge edge ends belt support; gravity fall into receivers -- Receiver volumes are sensors for completion - -Details: `docs/ENGINEERING.md`, `/documentation`. +Missing primary brief (do not cite as present): `input_info/extracted/Постановка_Задача_3_сжато_2.pdf`. ## Validation -```bash -npm ci -npm test -- --run # currently 245 unit tests -npm run build -# focused E2E (with preview on :3101): -npx vite preview --host 127.0.0.1 --port 3101 & -PLAYWRIGHT_BASE_URL=http://127.0.0.1:3101 npm run test:e2e -- e2e/routes.spec.ts e2e/smoke.spec.ts -``` - +- Unit tests: **196/196** (`npm test -- --run`) +- Production build: **PASS** (`npm run build`) - Two-page routing: `/` + `/documentation` - CAD / GLB checksums: verified against values above - Diverter frozen angles / duration: covered by unit tests -- Junction / discharge / camera classification: covered by domain tests (engineering-derived, not production-calibrated) - -## Production deploy (this host) - -Nginx terminates TLS for `arhipovdan.ru` and proxies to Docker `owl-web-1` on `127.0.0.1:3100` (`docker-compose.server.yml` + `Dockerfile`). - -```bash -cd /opt/arhipovdan/app # or your checkout -COMMIT=$(git rev-parse --short HEAD) -BRANCH=$(git rev-parse --abbrev-ref HEAD) -RELEASE=$(date -u +%Y%m%d-%H%M%S) -docker compose -p owl -f docker-compose.server.yml build \ - --build-arg "BUILD_COMMIT=${COMMIT}" \ - --build-arg "BUILD_BRANCH=${BRANCH}" \ - --build-arg "BUILD_RELEASE=${RELEASE}" -docker compose -p owl -f docker-compose.server.yml up -d --force-recreate -curl -s https://arhipovdan.ru/version.json -``` - -## Repository vs cloud materials - -| Material | In git | Cloud expected | -|---|---|---| -| Source, README, docs, configs, tests | yes | — | -| Runtime GLB/STL used by the deployed app | yes (required for deploy) | recommended mirror | -| Author CAD `conveer.FCStd` (~5 MB) | yes | recommended upload | -| Official PDFs/ZIPs under `input_info/`, `official_sources/` | yes | optional mirror | -| Presentation / video demonstration | **not in repo** | **required for platform field** | -| ML weights | none (CV is classical OpenCV depth) | N/A | - -Confirmed public presentation / video / cloud-folder URLs are **not stored in this repository**. Add them in the platform submission field when available. - -Презентация / видеодемонстрация: links must be supplied by the owner (not invented here). ## Current limitations -- Web classifier is a digital sensor simulation, not live RealSense inference. -- Product profiles are engineering-derived, not production-calibrated. +- Full contact-only sorting through CAD diverters is **not fully validated**. +- Belt surface-velocity physics (true 1 m/s tangential drive) is **planned**, not complete. +- Per-SKU mass / COM / friction profiles still need calibration. - Author CAD horn / complete transmission is absent or incomplete in the active GLB (`AUTHOR_CAD_INCOMPLETE`). -- Official compliance claims are limited by the missing extracted task PDF and by not re-parsing PDFs in every doc pass. -- Generated screenshots, videos, Gate stage folders, and tool `out/` trees are not canonical. +- Official compliance claims are limited by the **missing** extracted task PDF and by not re-parsing PDFs in every doc pass. +- Generated screenshots, videos, Gate stage folders, and tool `out/` trees are **not** canonical. ## Repository policy - Keep author CAD, active runtime assets, official sources, build configs, and tests that protect active behavior. - Generated outputs and historical media are not product truth. -- Real CV remains on `drho1y-mvp_1`; do not force-merge it into `main` without a validated web integration. -- Rollback point for the pre-cleanup web baseline: git `4413f01` on `dan_branch` history. +- Rollback point: git baseline `4413f01` on `dan_branch`. See also: `/documentation` in the running app, and `docs/ENGINEERING.md`. diff --git a/docs/ENGINEERING.md b/docs/ENGINEERING.md index c628a65..49e137f 100644 --- a/docs/ENGINEERING.md +++ b/docs/ENGINEERING.md @@ -46,23 +46,14 @@ src/styles.css | Classifier PDF | `official_sources/doc-1783095831.pdf` | | Workspace / scoring PDFs | `input_info/doc-1783009942.pdf`, `doc-1783011400.pdf` | -## Physics status (ENGINEERING-DERIVED PHYSICAL VALIDATION) +## Physics roadmap (not completed) -Implemented and covered by `src/domain/junctionContactPhysics.ts` (+ tests): +1. Surface-velocity belt at 1 m/s with visual loop. +2. Contact-validated CAD diverter deflection for all playlist SKUs. +3. Calibrated per-SKU mass, COM, friction, damping. +4. Receiver capture verification under dynamic drops. -1. Fixed timestep **1/120 s**, max **4** substeps, gravity **[0, −9.81, 0]**. -2. Belt target speed **1.0 m/s** via supported-body velocity coupling (stationary belt collider). -3. Single dynamic product body through junction; temporary scripted handoff removed. -4. LEFT/RIGHT diverter colliders: `kinematicPositionBased`, cuboid half-extents **[0.375, 0.05, 0.02]**, same pivot/yaw as CAD. -5. C/D change direction only by physical contact; B uses the open neutral corridor. -6. Receiver volumes are sensors for completion; they do not translate the body. -7. Deterministic matrix **45/45** correct receiver entries (3 profiles × 5 runs × B/C/D). - -Still not production-certified: - -- Per-SKU mass / COM / friction calibration against real hardware -- Visual full belt loop mesh with true surface velocity -- Owner visual review of contact behavior +CCD for light/thin items exists in runtime/sim; that alone is **not** full contact validation. ## Compliance evidence rules diff --git a/e2e/routes.spec.ts b/e2e/routes.spec.ts index 907cb21..23d4c43 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('END_OF_LINE_CAMERA_CLASSIFICATION_PASS'); + await expect(page.getByTestId('docs-production-status')).toContainText('DATA_ACQUISITION_PACK_READY'); await expect(page.getByTestId('nav-documentation').first()).toHaveClass(/active/); await page.reload(); diff --git a/src/App.tsx b/src/App.tsx index e9f4852..29ae6f9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,11 +13,9 @@ 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()); @@ -36,17 +34,6 @@ 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 3cfa80d..e54c208 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, SORTER_ASSEMBLY_ORIGIN_S } from '../../domain/physicalLayout'; +import { CONVEYOR_SPEED_MPS } from '../../domain/physicalLayout'; import { buildDiverterPlanes, createDiverterProductMachine, @@ -90,9 +90,8 @@ export const CAD_MODULE_Y = 0.594; export const CAD_MODULE_ORIGINS = { clean: -4.02, camera: -2.01, - /** Final discharge section — whole sorter assembly origin. */ - sorter: SORTER_ASSEMBLY_ORIGIN_S, -}; + sorter: 0.0, +} as const; export type CadModuleVariant = keyof typeof CAD_MODULE_ORIGINS; diff --git a/src/components/ThreeD/PhysicalPlaybackItem.tsx b/src/components/ThreeD/PhysicalPlaybackItem.tsx index 5484e37..9c3327c 100644 --- a/src/components/ThreeD/PhysicalPlaybackItem.tsx +++ b/src/components/ThreeD/PhysicalPlaybackItem.tsx @@ -8,13 +8,12 @@ import { getRenderedItemDimensions } from '../../domain/physicalLayout'; import * as THREE from 'three'; import RealItemModel, { isProductAssetReady } from './RealItemModel'; import { ItemVerificationOverlay } from './RealModelVerification'; -import { CATEGORY_COLORS, INDUSTRIAL_PALETTE } from '../../domain/industrialTheme'; const COLORS = { - B: CATEGORY_COLORS.B, - C: CATEGORY_COLORS.C, - D: CATEGORY_COLORS.D, - sensorAccent: INDUSTRIAL_PALETTE.sensorAccent, + B: '#16a34a', + C: '#ea580c', + D: '#7c3aed', + sensorAccent: '#3b82f6', }; /** Base real-model materials per SKU (Stage 1 §20 — basic, form-revealing). */ diff --git a/src/components/ThreeD/PhysicalPlaybackItemPhysics.tsx b/src/components/ThreeD/PhysicalPlaybackItemPhysics.tsx index 253de3c..5708ae2 100644 --- a/src/components/ThreeD/PhysicalPlaybackItemPhysics.tsx +++ b/src/components/ThreeD/PhysicalPlaybackItemPhysics.tsx @@ -1,11 +1,16 @@ /** - * Product rigid body — physical conveyor + physical junction contact. + * Stage 2 — item with hybrid kinematic/dynamic authority (Rapier). * - * Single dynamic body from spawn through receiver settle. - * C/D redirection is contact-only against kinematic CAD diverter colliders. - * No junction setTranslation / route-specific lateral impulses. + * Authority flow (see docs/stage2_real_sorter/physics-architecture.md): + * 1. kinematicPosition — follows getPhysicalItemPose exactly (domain truth); + * 2. at getDropHandoffTimeMs → dynamic with deterministic initial velocity + * (B: belt edge carry-over; C/D: pusher impulse, scaled per SKU profile); + * 3. gravity/collision/friction/restitution/angular velocity govern the drop; + * 4. on sleep (or controlled 4.5 s timeout) the final position is verified + * against the DOMAIN-decided receiver volume and the body is frozen + * (kinematic) — no drift, clean replay, no teleportation at any point. */ -import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import * as THREE from 'three'; import { useFrame } from '@react-three/fiber'; import { @@ -13,61 +18,40 @@ import { CuboidCollider, CapsuleCollider, CylinderCollider, - useBeforePhysicsStep, type RapierRigidBody, } from '@react-three/rapier'; import { RigidBodyType } from '@dimforge/rapier3d-compat'; -import { getPhysicalItemPose } from '../../domain/physicalItemMotion'; -import { - getProductPhysicsProfile, - colliderHalfHeight, - spawnCenterY, - isSupportedByBelt, - computeBeltDriveForce, - isInvalidProductState, - recordInvalidProductState, - BELT_SPEED_MPS, - type ProductPhysicsPhase, -} from '../../domain/productPhysicsProfiles'; -import { - DISCHARGE_EDGE_S, - detectReceiverZone, - runtimeWorldContactPlaneS, - SETTLE_LINEAR_SPEED_MPS, - SETTLE_ANGULAR_SPEED_RAD_S, - SETTLE_DURATION_SEC, -} from '../../domain/junctionContactPhysics'; +import { getPhysicalItemPose, getDropHandoffTimeMs } from '../../domain/physicalItemMotion'; +import { getVisualPhysicsProfile } from '../../domain/visualPhysicsProfiles'; 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 { recordDropResult, physicsSimClock } 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'; +type Authority = 'kinematic' | 'dynamic' | 'frozen'; -const TELEMETRY_INTERVAL_MS = 200; -const SETTLE_BUDGET_SEC = 10; +/** Controlled settle budget after handoff — in PHYSICS-simulated seconds, + * not domain ms: under render lag domain time races ahead of the stepper, + * and a domain-ms budget would freeze items mid-flight (§14.2). */ +const SETTLE_BUDGET_SEC = 4.5; -function colliderDensity(profile: ReturnType): number { - const c = profile.collider; - if (c.type === 'cuboid') { - const [hx, hy, hz] = c.halfExtents; - return profile.massKg / (8 * hx * hy * hz); +function colliderDensity(profile: ReturnType): number { + if (profile.collider === 'cuboid' && profile.cuboidHalfExtents) { + const [hx, hy, hz] = profile.cuboidHalfExtents; + return profile.approximateMassKg / (8 * hx * hy * hz); } - const volume = c.type === 'capsule' - ? Math.PI * c.radius * c.radius * (2 * c.halfHeight + (4 / 3) * c.radius) - : Math.PI * c.radius * c.radius * 2 * c.halfHeight; - return profile.massKg / Math.max(volume, 1e-6); + const [r, hh] = profile.capsule ?? [0.05, 0.1]; + const volume = profile.collider === 'capsule' + ? Math.PI * r * r * (2 * hh + (4 / 3) * r) + : Math.PI * r * r * 2 * hh; + return profile.approximateMassKg / volume; } -function PhysicalPlaybackItemPhysicsInner({ +export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhysics({ caseData, elapsedMs, slotIndex = 0, @@ -83,26 +67,21 @@ 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 profile = getVisualPhysicsProfile(itemId); + const handoffMs = getDropHandoffTimeMs(classification.category, caseData.faultType); const bodyRef = useRef(null); - const authority = useRef(isFault ? 'fault_kinematic' : 'preparing'); - const phaseRef = useRef('preparing'); + const traceEnabled = useRef( + typeof window !== 'undefined' + && new URLSearchParams(window.location.search).get('trace') === '1', + ); + const authority = useRef('kinematic'); const frozenPose = useRef<{ p: [number, number, number]; q: THREE.Quaternion } | null>(null); + const handedOffAtSimSec = useRef(null); const verified = useRef(false); - const activated = useRef(false); - const invalidLogged = useRef(false); - const lastTelemetryMs = useRef(0); - const forceScratch = useRef({ x: 0, y: 0, z: 0 }); - const settleAccum = useRef(0); - const activeSinceSec = useRef(0); - const bodyIdentity = useRef(`${caseData.id}:${itemId}`); - const asset = getModelAsset(itemId); const needsRealAsset = Boolean(asset?.defaultRealAsset && asset?.runtimePath); const [spawned, setSpawned] = useState( @@ -116,279 +95,61 @@ function PhysicalPlaybackItemPhysicsInner({ caseId: caseData.id, slotIndex, dimensionsMm: itemData.dimensionsMm, - targetCategory: classifiedCategory.current, + targetCategory: classification.category, elapsedMs, faultType: caseData.faultType, jitter, }); - const spawnPose = useMemo(() => { - const p = getPhysicalItemPose({ + const handoffPose = useMemo(() => { + if (handoffMs == null) return null; + return getPhysicalItemPose({ caseId: caseData.id, slotIndex, dimensionsMm: itemData.dimensionsMm, - targetCategory: null, - elapsedMs: 0, + targetCategory: classification.category, + elapsedMs: handoffMs, faultType: caseData.faultType, jitter, }); - return { - position: [p.position[0], spawnCenterY(profile), p.position[2]] as [number, number, number], - rotation: p.rotation, - }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [caseData.id, profile.productId]); + }, [handoffMs, caseData.id]); - useLayoutEffect(() => { - bodyIdentity.current = `${caseData.id}:${itemId}`; - authority.current = isFault ? 'fault_kinematic' : 'preparing'; - phaseRef.current = 'preparing'; + // Reset authority whenever a new case mounts this body. The Rapier body is + // reused across cases, so a case that ended while still DYNAMIC (settle + // budget cut short under render lag) must be forced back to kinematic — + // otherwise setNextKinematicTranslation is a no-op and the next case's item + // is stuck invisibly mid-scene. + useEffect(() => { + authority.current = 'kinematic'; frozenPose.current = null; + handedOffAtSimSec.current = null; verified.current = false; - activated.current = false; - 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; if (body) { - // Imperative spawn pose — never reapplied via React RigidBody props. body.setBodyType(RigidBodyType.KinematicPositionBased, false); body.setLinvel({ x: 0, y: 0, z: 0 }, true); body.setAngvel({ x: 0, y: 0, z: 0 }, true); - const p = spawnPose.position; + const p = pose.position; body.setTranslation({ x: p[0], y: p[1], z: p[2] }, true); - const e = new THREE.Euler(spawnPose.rotation[0], spawnPose.rotation[1], spawnPose.rotation[2]); + const e = new THREE.Euler(pose.rotation[0], pose.rotation[1], pose.rotation[2]); const q = new THREE.Quaternion().setFromEuler(e); body.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }, true); } - if (import.meta.env.DEV && typeof window !== 'undefined') { - const w = window as unknown as { __ACTIVE_PRODUCT_BODIES?: Set }; - w.__ACTIVE_PRODUCT_BODIES = w.__ACTIVE_PRODUCT_BODIES ?? new Set(); - w.__ACTIVE_PRODUCT_BODIES.add(bodyIdentity.current); - } - return () => { - if (import.meta.env.DEV && typeof window !== 'undefined') { - const w = window as unknown as { __ACTIVE_PRODUCT_BODIES?: Set }; - w.__ACTIVE_PRODUCT_BODIES?.delete(bodyIdentity.current); - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- reset on case id only }, [caseData.id]); - const activateDynamic = useCallback((body: RapierRigidBody) => { - const p = spawnPose.position; - body.setTranslation({ x: p[0], y: p[1], z: p[2] }, true); - const e = new THREE.Euler(spawnPose.rotation[0], spawnPose.rotation[1], spawnPose.rotation[2]); - const q = new THREE.Quaternion().setFromEuler(e); - body.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }, true); - body.setLinvel({ x: 0, y: 0, z: 0 }, true); - body.setAngvel({ x: 0, y: 0, z: 0 }, true); - body.setBodyType(RigidBodyType.Dynamic, true); - body.wakeUp(); - activated.current = true; - authority.current = 'dynamic_active'; - phaseRef.current = 'physical_conveyor'; - activeSinceSec.current = 0; - }, [spawnPose]); - - useBeforePhysicsStep(() => { - const body = bodyRef.current; - if (!body || !spawned) return; - if (authority.current !== 'dynamic_active') { - body.resetForces(true); - return; - } - const dt = PHYSICS_DT; - activeSinceSec.current += dt; - - const t = body.translation(); - const lv = body.linvel(); - const av = body.angvel(); - const position: [number, number, number] = [t.x, t.y, t.z]; - const linearVelocity: [number, number, number] = [lv.x, lv.y, lv.z]; - const angularVelocity: [number, number, number] = [av.x, av.y, av.z]; - - if (isInvalidProductState({ position, linearVelocity, angularVelocity })) { - if (!invalidLogged.current) { - recordInvalidProductState(); - invalidLogged.current = true; - if (import.meta.env.DEV) { - console.warn('[physics] invalid product state', itemId, position); - } - } - body.resetForces(true); - body.setLinvel({ x: 0, y: 0, z: 0 }, true); - body.setAngvel({ x: 0, y: 0, z: 0 }, true); - body.setBodyType(RigidBodyType.KinematicPositionBased, false); - authority.current = 'frozen'; - phaseRef.current = 'invalid'; - return; - } - - 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, - phase: phaseRef.current === 'junction' ? 'junction' : 'physical_conveyor', - linearVelY: lv.y, - }); - - body.resetForces(true); - if (supported) { - const sample = computeBeltDriveForce({ - massKg: profile.massKg, - linearVelocity, - maxBeltAccelerationMps2: profile.maxBeltAccelerationMps2, - // Zero lateral correction inside physical junction / contact zone. - 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. - // 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)) - : BELT_SPEED_MPS; - forceScratch.current.y = Math.min(lv.y, 0.05); - forceScratch.current.z = inJunction ? lv.z : lv.z + latV; - body.setLinvel(forceScratch.current, true); - - if (import.meta.env.DEV && typeof window !== 'undefined') { - 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, - 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, - 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); - 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) { - settleAccum.current += dt; - } else { - settleAccum.current = Math.max(0, settleAccum.current - dt * 0.25); - } - if (!verified.current - && (settleAccum.current >= SETTLE_DURATION_SEC - || (activeSinceSec.current > SETTLE_BUDGET_SEC && speed < 0.35))) { - verified.current = true; - recordDropResult({ - caseId: caseData.id, - itemId, - expectedZone: expected, - finalPosition: position, - insideExpectedReceiver: receiverContains(expected, position), - settledByTimeout: settleAccum.current < SETTLE_DURATION_SEC, - timestampMs: Date.now(), - }); - const r = body.rotation(); - frozenPose.current = { - p: position, - q: new THREE.Quaternion(r.x, r.y, r.z, r.w), - }; - body.resetForces(true); - body.setLinvel({ x: 0, y: 0, z: 0 }, false); - body.setAngvel({ x: 0, y: 0, z: 0 }, false); - body.setBodyType(RigidBodyType.KinematicPositionBased, false); - authority.current = 'frozen'; - phaseRef.current = 'settled'; - } - } - }); - useFrame(() => { const body = bodyRef.current; if (!body) return; + // PREPARING: hold at spawn pose, zero velocity, keep invisible until visual ready. if (!spawned) { - const p = spawnPose.position; + const p = pose.position; body.setNextKinematicTranslation({ x: p[0], y: p[1], z: p[2] }); - const e = new THREE.Euler(spawnPose.rotation[0], spawnPose.rotation[1], spawnPose.rotation[2]); + const e = new THREE.Euler(pose.rotation[0], pose.rotation[1], pose.rotation[2]); const q = new THREE.Quaternion().setFromEuler(e); body.setNextKinematicRotation({ x: q.x, y: q.y, z: q.z, w: q.w }); body.setLinvel({ x: 0, y: 0, z: 0 }, true); @@ -396,12 +157,34 @@ function PhysicalPlaybackItemPhysicsInner({ return; } - if (authority.current === 'preparing' && !activated.current && !isFault) { - activateDynamic(body); - return; - } + if (authority.current === 'kinematic') { + // Physics handoff at pusher contact / belt edge — never for fault cases. + // MUST be checked BEFORE the kinematic drive: under render lag a single + // frame can jump several seconds past handoffMs, and pose(elapsedMs) is + // then already deep inside the receiver. Applying setNextKinematic* + // from that pose in the same frame as the dynamic switch teleports the + // body (forbidden) — the next-step kinematic target still applies. + if (handoffMs != null && handoffPose && elapsedMs >= handoffMs) { + const hp = handoffPose.position; + body.setTranslation({ x: hp[0], y: hp[1], z: hp[2] }, true); + const he = new THREE.Euler(handoffPose.rotation[0], handoffPose.rotation[1], handoffPose.rotation[2]); + const hq = new THREE.Quaternion().setFromEuler(he); + body.setRotation({ x: hq.x, y: hq.y, z: hq.z, w: hq.w }, true); + body.setBodyType(RigidBodyType.Dynamic, true); + // Deterministic initial velocity: belt carry-over only — for C/D the + // Z motion comes from the kinematic paddle CONTACT (Stage 2B §13). + body.setLinvel({ x: 1.0, y: 0, z: 0 }, true); + if (profile.canRoll && category === 'B') { + body.setAngvel({ x: 2.0, y: 0.4, z: 0 }, true); + } else { + body.setAngvel({ x: 0, y: 0, z: 0 }, true); + } + authority.current = 'dynamic'; + handedOffAtSimSec.current = physicsSimClock.simSec; + return; + } - if (authority.current === 'fault_kinematic') { + // Kinematic drive: domain pose is truth (belt travel, inspection dwell). const p = pose.position; const e = new THREE.Euler(pose.rotation[0], pose.rotation[1], pose.rotation[2]); const q = new THREE.Quaternion().setFromEuler(e); @@ -410,7 +193,50 @@ function PhysicalPlaybackItemPhysicsInner({ return; } - if (authority.current === 'frozen' && frozenPose.current) { + if (authority.current === 'dynamic') { + const slept = body.isSleeping(); + const lv = body.linvel(); + const av = body.angvel(); + const slow = Math.hypot(lv.x, lv.y, lv.z) < 0.2 && Math.hypot(av.x, av.y, av.z) < 1.0; + if (traceEnabled.current) { + const t = body.translation(); + const w = window as unknown as { __ITEM_TRACE?: unknown[] }; + w.__ITEM_TRACE = w.__ITEM_TRACE ?? []; + const arr = w.__ITEM_TRACE as { e: number; x: number; y: number; z: number; lv: number; slept: boolean }[]; + if (arr.length === 0 || arr[arr.length - 1].e < elapsedMs - 200) { + arr.push({ e: Math.round(elapsedMs), x: +t.x.toFixed(3), y: +t.y.toFixed(3), z: +t.z.toFixed(3), lv: +Math.hypot(lv.x, lv.y, lv.z).toFixed(2), slept }); + if (arr.length > 120) arr.shift(); + } + } + const timedOut = handedOffAtSimSec.current != null + && physicsSimClock.simSec - handedOffAtSimSec.current > SETTLE_BUDGET_SEC; + // §14.2: freeze only after actual rest (sleep) or a timeout WITH low + // velocities — never freeze a body that is still moving/flying. + if ((slept || (timedOut && slow)) && !verified.current) { + verified.current = true; + const t = body.translation(); + const p: [number, number, number] = [t.x, t.y, t.z]; + recordDropResult({ + caseId: caseData.id, + itemId, + expectedZone: category, + finalPosition: p, + insideExpectedReceiver: receiverContains(category, p), + settledByTimeout: !slept, + timestampMs: Date.now(), + }); + const r = body.rotation(); + frozenPose.current = { p, q: new THREE.Quaternion(r.x, r.y, r.z, r.w) }; + body.setBodyType(RigidBodyType.KinematicPositionBased, false); + body.setLinvel({ x: 0, y: 0, z: 0 }, false); + body.setAngvel({ x: 0, y: 0, z: 0 }, false); + authority.current = 'frozen'; + } + return; + } + + // frozen: hold the verified rest pose (no drift across replays). + if (frozenPose.current) { const { p, q } = frozenPose.current; body.setNextKinematicTranslation({ x: p[0], y: p[1], z: p[2] }); body.setNextKinematicRotation({ x: q.x, y: q.y, z: q.z, w: q.w }); @@ -420,63 +246,46 @@ function PhysicalPlaybackItemPhysicsInner({ if (elapsedMs < 0) return null; const density = colliderDensity(profile); - const enabledRotations = useMemo<[boolean, boolean, boolean]>( - () => [!profile.lockRotationX, !profile.lockRotationY, !profile.lockRotationZ], - [profile.lockRotationX, profile.lockRotationY, profile.lockRotationZ], - ); + // CCD for small/fast items (pen) and thin items (plate) — mirrors the sim. + const ccd = profile.approximateMassKg < 0.05 || itemData.dimensionsMm.height < 50; - // Initial pose props are mount-only (component is memoized against elapsedMs - // for non-fault cases). Never pass `type` — imperative setBodyType owns it. - // World transform authority after spawn: Rapier body → R3F object3D sync. return ( - {spawned && profile.collider.type === 'cuboid' && ( - + {/* Colliders only after visual ready — avoids stale/orphan contact. */} + {spawned && profile.collider === 'cuboid' && profile.cuboidHalfExtents && ( + )} - {spawned && profile.collider.type === 'capsule' && ( + {spawned && profile.collider === 'capsule' && profile.capsule && ( )} - {spawned && profile.collider.type === 'cylinder' && ( + {spawned && profile.collider === 'cylinder' && profile.capsule && ( )} - + ); -} - -/** - * Block playback-tick re-renders for normal (non-fault) products so React - * RigidBody props cannot fight Rapier's mesh sync every frame. - */ -export const PhysicalPlaybackItemPhysics = memo( - PhysicalPlaybackItemPhysicsInner, - (prev, next) => { - if (prev.caseData.id !== next.caseData.id) return false; - if (prev.slotIndex !== next.slotIndex) return false; - if (prev.castShadow !== next.castShadow) return false; - if (prev.verifySku !== next.verifySku) return false; - if (prev.caseData.faultType !== next.caseData.faultType) return false; - if (prev.jitter !== next.jitter) return false; - // Fault kinematics still need elapsedMs; physical products do not. - if (next.caseData.faultType) { - return prev.elapsedMs === next.elapsedMs; - } - return true; - }, -); +}); diff --git a/src/components/ThreeD/SorterDigitalTwinContinuous.tsx b/src/components/ThreeD/SorterDigitalTwinContinuous.tsx index 6da25a1..d8c044a 100644 --- a/src/components/ThreeD/SorterDigitalTwinContinuous.tsx +++ b/src/components/ThreeD/SorterDigitalTwinContinuous.tsx @@ -15,10 +15,6 @@ 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'; @@ -35,19 +31,8 @@ import { preloadConveyorCad, } from './ConveyorCadModel'; import { SorterPhysicsWorld } from './SorterPhysics'; -import { - RigidBody, - CuboidCollider, - useBeforePhysicsStep, - type RapierRigidBody, -} from '@react-three/rapier'; +import { RigidBody, CuboidCollider, type RapierRigidBody } from '@react-three/rapier'; import { GATE_VANE } from '../../domain/pusherMotion'; -import { - DIVERTER_COLLIDER_HALF_EXTENTS, - DIVERTER_GUIDE_FRICTION, - DIVERTER_RESTITUTION, - diverterColliderPose, -} from '../../domain/junctionContactPhysics'; import { deriveSorterVisualState } from '../../domain/sorterVisualState'; import { DEMO_PLAYLIST, PLAYLIST_LENGTH } from '../../domain/demoPlaylist'; import { cumulativePlaylistDurationMs, getPlaylistCaseDurationMs } from '../../domain/continuousPlayback'; @@ -118,12 +103,12 @@ const COLORS = { supports: INDUSTRIAL_PALETTE.plastic, motor: INDUSTRIAL_PALETTE.metalDark, sensorAccent: INDUSTRIAL_PALETTE.sensorAccent, - sensorActive: INDUSTRIAL_PALETTE.sensorActive, + sensorActive: '#60a5fa', gateFrame: INDUSTRIAL_PALETTE.metal, routeB: INDUSTRIAL_PALETTE.routeB, routeC: INDUSTRIAL_PALETTE.routeC, routeD: INDUSTRIAL_PALETTE.routeD, - itemShadow: INDUSTRIAL_PALETTE.metalDark, + itemShadow: '#3a4a5a', }; const ITEM_MATERIALS: Record = { @@ -509,13 +494,10 @@ function ZoneMarker({ position, label, color, active }: {
{label} @@ -560,16 +542,7 @@ function BReceiverBin({ active }: { active: boolean }) { -
B
+
B
); @@ -598,13 +571,10 @@ function RollCage({ position, label, color, active, shadows = false }: { {/* Label */}
{label} @@ -887,8 +857,8 @@ function ShapeOutline({ position, scale, visible, isRound, category }: { } /** - * Kinematic CAD diverter colliders — same pivot/yaw as visual CAD. - * Updated in useBeforePhysicsStep so Rapier sees correct next-pose velocity. + * Kinematic colliders locked to CAD swing diverters (Барьер001/002). + * Downstream hinge fixed; free end along −X at 0°, arcs with the same yaw. */ function CadGateColliders({ category, @@ -899,13 +869,12 @@ function CadGateColliders({ }) { const leftRef = useRef(null); const rightRef = useRef(null); - const [hx, hy, hz] = DIVERTER_COLLIDER_HALF_EXTENTS; - const debug = typeof window !== 'undefined' - && new URLSearchParams(window.location.search).get('colliderDebug') === '1'; + const [hx, hy, hz] = GATE_VANE.halfExtents; void category; void caseElapsedMs; - useBeforePhysicsStep(() => { + useFrame(() => { + // Sync existing CAD gate colliders to visual diverter angles (no new physics). const motions = typeof window !== 'undefined' ? (window as unknown as { __DIVERTER_MOTIONS?: { leftRad: number; rightRad: number }; @@ -919,13 +888,13 @@ function CadGateColliders({ yaw: number, ) => { if (!body) return; - const pose = diverterColliderPose( - { x: pivot.x, y: GATE_VANE.centerY, z: pivot.z }, - yaw, - hx, - ); - body.setNextKinematicTranslation(pose.center); - body.setNextKinematicRotation(pose.rotation); + const x = pivot.x - Math.cos(yaw) * hx; + const z = pivot.z + Math.sin(yaw) * hx; + body.setNextKinematicTranslation({ x, y: GATE_VANE.centerY, z }); + const half = yaw / 2; + body.setNextKinematicRotation({ + x: 0, y: Math.sin(half), z: 0, w: Math.cos(half), + }); }; apply(leftRef.current, CAD_SORTER_WORLD_PIVOTS.left, leftYaw); apply(rightRef.current, CAD_SORTER_WORLD_PIVOTS.right, rightYaw); @@ -933,55 +902,15 @@ function CadGateColliders({ const lp = CAD_SORTER_WORLD_PIVOTS.left; const rp = CAD_SORTER_WORLD_PIVOTS.right; - const left0 = diverterColliderPose( - { x: lp.x, y: GATE_VANE.centerY, z: lp.z }, - 0, - hx, - ); - const right0 = diverterColliderPose( - { x: rp.x, y: GATE_VANE.centerY, z: rp.z }, - 0, - hx, - ); return ( - - - {debug && ( - - - - - )} + + - - - {debug && ( - - - - - )} + + ); @@ -1034,26 +963,8 @@ function ContinuousScene({ debugOverlays?: boolean; physicsDebug?: boolean; }) { - 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 category = playback.targetCategory; 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; @@ -1062,9 +973,8 @@ function ContinuousScene({ const protoShadows = proto ? (proto && stage0!.shadows) : shadowsEnabled; const protoCamera = proto && stage0!.camera; const shotOverride = protoCamera && stage0!.shot ? shotToPhaseCategory(stage0!.shot) : null; - // Ozon light presentation environment is the default product look. - // Prototype stage0 may still request a dark background explicitly. - const darkBg = proto ? stage0!.darkBackground : false; + // Stage 2: premium industrial dark environment is the default look. + const darkBg = proto ? stage0!.darkBackground : true; const cameraHighlight = shouldHighlightCamera(phase); const showScan = shouldShowScanEffect(phase); @@ -1212,18 +1122,13 @@ function ContinuousScene({ return ( <> - {/* Background: Ozon light presentation canvas (equipment stays readable) */} - - + {/* Background: industrial dark product canvas (equipment must dominate) */} + + {proto ? ( <> + {/* Cinematic rig: very low ambient, strong key, soft fill, cool rim */} + {/* Fill — keeps shadowed side readable */} {stage0!.fill && } + {/* Rim — cheap back light for edge separation */} {stage0!.rim && } ) : ( <> - {/* Light Ozon studio: cool ambient, neutral key, soft shadows */} - - + {/* Stage 2 default: premium industrial rig — readable ambient, key with + PCF shadows, soft fill, cool rim (Stage 0 proven values, brightened + in 2B so brackets/rollers read as volumes from every angle) */} + + - - - + + + {/* low front fill so the +Z face (camera side) never goes black */} + + {/* Procedural studio environment (no external HDRI — offline-safe) */} {shadowsEnabled && ( - - - + + + )} )} - {/* Grid — subtle Ozon blue-gray */} + {/* Grid — subdued so equipment remains the visual subject */} - {/* Floor — light cool gray */} + {/* Floor — dark polished concrete with soft reflections */} diff --git a/src/components/ThreeD/SorterPhysics.tsx b/src/components/ThreeD/SorterPhysics.tsx index b69c2a2..e2be045 100644 --- a/src/components/ThreeD/SorterPhysics.tsx +++ b/src/components/ThreeD/SorterPhysics.tsx @@ -1,26 +1,26 @@ /** - * Physics world for the sorter — single step authority via @react-three/rapier. + * Stage 2 — physics world for the sorter drop segment. * - * Physics is mounted `paused` so FrameStepper does not auto-step. RapierStepper - * is the only caller of context.step(), which runs before/after hooks, fixed - * substeps, and mesh sync. Playback speed scales the clamped frame delta. + * Hybrid authority (docs/stage2_real_sorter/physics-architecture.md): + * - items on the belt are KINEMATIC (domain pose is truth); + * - at the drop handoff (pusher contact / belt edge) the body switches to + * DYNAMIC with deterministic initial velocity; + * - static colliders mirror the visible chute / receiver geometry + * (documented hidden colliders, same dimensions as the visuals). + * + * Determinism: fixed dt = 1/60, max 4 substeps/frame, no unseeded randomness. + * Physics freezes when the domain clock is paused (documented simulation + * assumption — belt, gate and items halt together; EMERGENCY_STOP creates no + * new impulses). + * + * Stage 2E: Rapier step timing via PhysicsPerfSampler (?perf=1 | ?physicsPerf=1). + * Render time is NOT included in physics p95. */ import { useRef, type ReactNode } from 'react'; import { useFrame } from '@react-three/fiber'; -import { - Physics, - RigidBody, - CuboidCollider, - useRapier, - useAfterPhysicsStep, -} from '@react-three/rapier'; +import { Physics, RigidBody, CuboidCollider, useRapier } from '@react-three/rapier'; import { getStaticColliders } from '../../domain/physicsWorldLayout'; -import { - PHYSICS_TIMESTEP_SEC, - PHYSICS_MAX_SUBSTEPS, - MAX_FRAME_DELTA_SEC, - PHYSICS_GRAVITY, -} from '../../domain/physicsTimestep'; +import { PHYSICS_TIMESTEP_SEC } from '../../domain/physicsTimestep'; import { PhysicsPerfSampler, isPhysicsPerfQueryEnabled, @@ -28,7 +28,7 @@ import { } from '../../domain/physicsPerf'; export const PHYSICS_DT = PHYSICS_TIMESTEP_SEC; -export { PHYSICS_MAX_SUBSTEPS }; +export const PHYSICS_MAX_SUBSTEPS = 4; const MAX_SUBSTEPS = PHYSICS_MAX_SUBSTEPS; /** @@ -59,7 +59,6 @@ declare global { __DROP_RESULTS?: DropResult[]; __PHYSICS_PERF__?: PhysicsPerfSnapshot; __PHYSICS_PERF_RESET__?: () => void; - __CONVEYOR_PHYSICS_DEBUG__?: Record; } } @@ -74,6 +73,7 @@ function readWorldMeta(world: { colliders?: { len: () => number }; }): { activeBodies: number; sleepingBodies: number; colliders: number; contactPairs: number } { try { + // @react-three/rapier wraps Rapier world; body counts via forEach when available const w = world as unknown as { forEachRigidBody?: (cb: (b: { isSleeping: () => boolean; numColliders: () => number }) => void) => void; bodies?: { len: () => number }; @@ -102,24 +102,14 @@ function readWorldMeta(world: { } } -/** - * Sole physics-step authority: calls Rapier context.step (not raw world.step) - * so before/after hooks and rigid-body mesh sync run once per substep batch. - */ -function PhysicsSimClock() { - useAfterPhysicsStep(() => { - physicsSimClock.simSec += PHYSICS_DT; - }); - return null; -} - +/** Steps the Rapier world with a fixed dt, scaled by domain playback speed. */ function RapierStepper({ running, speed }: { running: boolean; speed: number }) { - const { step, world } = useRapier(); - const wasRunning = useRef(false); + const { world } = useRapier(); + const accumulator = useRef(0); const sampler = useRef(new PhysicsPerfSampler(PHYSICS_DT, MAX_SUBSTEPS)); const perfOn = useRef(false); - const stepsThisFrame = useRef(0); + // Latch query once (and expose reset) — no React state. if (typeof window !== 'undefined' && !perfOn.current) { perfOn.current = isPhysicsPerfQueryEnabled(); if (perfOn.current) { @@ -128,37 +118,34 @@ function RapierStepper({ running, speed }: { running: boolean; speed: number }) } useFrame((_, delta) => { - if (!running) { - // Pause: do not step; do not accumulate paused wall-clock time. - wasRunning.current = false; - return; + if (!running) return; + accumulator.current += Math.min(delta, 0.1) * speed; + let steps = 0; + let framePhysicsMs = 0; + while (accumulator.current >= PHYSICS_DT && steps < MAX_SUBSTEPS) { + if (perfOn.current) { + const t0 = performance.now(); + world.step(); + framePhysicsMs += performance.now() - t0; + } else { + world.step(); + } + physicsSimClock.simSec += PHYSICS_DT; + accumulator.current -= PHYSICS_DT; + steps += 1; } - - // On resume, ignore the (possibly huge) first frame delta. - const frameDelta = wasRunning.current - ? Math.min(delta, MAX_FRAME_DELTA_SEC) - : Math.min(delta, PHYSICS_DT); - wasRunning.current = true; - - const scaled = frameDelta * speed; - const capped = Math.min(scaled, MAX_SUBSTEPS * PHYSICS_DT); - const simBefore = physicsSimClock.simSec; - const before = performance.now(); - step(capped); - const steps = Math.max( - 0, - Math.round((physicsSimClock.simSec - simBefore) / PHYSICS_DT), - ); - stepsThisFrame.current = steps; if (perfOn.current && steps > 0) { - sampler.current.pushStepMs(performance.now() - before, steps); + // Record per-frame physics cost (sum of substeps this frame), not render. + sampler.current.pushStepMs(framePhysicsMs, steps); window.__PHYSICS_PERF__ = sampler.current.snapshot(readWorldMeta(world)); } + if (steps === MAX_SUBSTEPS) accumulator.current = 0; }); return null; } -/** Static colliders for the whole working area (fixed bodies, cheap cuboids). */ +/** Static colliders for the whole working area (fixed bodies, cheap cuboids). + * Layout data lives in domain/physicsWorldLayout — shared with headless tests. */ export function SorterStaticColliders() { return ( @@ -185,14 +172,7 @@ export function SorterPhysicsWorld({ children: ReactNode; }) { return ( - - + {children} diff --git a/src/data/productionStatusSummary.ts b/src/data/productionStatusSummary.ts index e9abff5..9a3f474 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: 'END_OF_LINE_CAMERA_CLASSIFICATION_PASS', - webTwinStatus: 'CAMERA_TRIGGERED_CLASSIFICATION_ACTIVE', - unitTests: 'PASS', + acquisitionPackStatus: 'DATA_ACQUISITION_PACK_READY', + webTwinStatus: 'BASELINE_PRESERVED', + unitTests: '196/196', productionBuild: 'PASS', - contactPhysics: 'DISCHARGE_EDGE_AND_CAMERA_CLASSIFICATION', + contactPhysics: 'NOT_FULLY_VALIDATED', officialCompliance: 'PARTIAL_SOURCES_PRESENT', } as const; @@ -46,7 +46,6 @@ 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, @@ -54,7 +53,6 @@ export const DIVERTER_KINEMATICS = { oneActiveProduct: true, closeAfterRearClear: true, generatedMechanismActive: false, - cameraTriggeredClassification: true, } as const; export const CAD_PROVENANCE = { @@ -69,36 +67,35 @@ export const CAD_PROVENANCE = { export const PHYSICS_STATUS = { implemented: [ - '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)', - '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', + 'Runtime product motion on belt (domain pose + Rapier handoff)', + 'Product-associated diverter route timing (productId-bound)', + 'Synchronized CAD diverter visual / kinematic targets', + 'CCD enabled for light/thin SKUs in runtime and headless sim', + 'Visual/physics spawn gating via product asset preload', ], notFullyValidated: [ - 'Production-calibrated mass / COM / friction per SKU', - 'Visual full belt loop with true surface-velocity conveyor mesh', - 'All playlist SKUs under owner visual review', + 'Complete contact-only routing through CAD diverters', + 'Belt surface velocity exactly 1 m/s with tangential drive', + 'Calibrated friction / mass / COM per SKU', + 'Fully physical continuous conveyor loop', + 'Receiver capture under all item classes', ], planned: [ - 'Owner visual review of end-of-line fall and camera classification', - 'Production calibration of product profiles', + 'Visual full belt loop with surface-velocity coupling', + 'Controlled tangential friction at 1 m/s', + 'Dynamic rigid bodies for divert segment with fixed timestep', + 'Per-SKU collider, damping, and friction profiles', ], } as const; export const VALIDATION_BOARD = [ - { item: 'Unit tests', status: 'PASS' }, + { item: 'Unit tests', status: '196/196 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: 'Camera-triggered classification', status: 'PHYSICAL_CAMERA_CROSSING' }, - { item: 'End-of-line discharge fall', status: 'GRAVITY_AFTER_SUPPORT_LOSS' }, + { item: 'Full contact physics', status: 'NOT_FULLY_VALIDATED' }, ] as const; export const OFFICIAL_SOURCE_MATRIX = [ diff --git a/src/domain/cameraClassification.ts b/src/domain/cameraClassification.ts deleted file mode 100644 index aed3a7c..0000000 --- a/src/domain/cameraClassification.ts +++ /dev/null @@ -1,229 +0,0 @@ -/** - * 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 f7e1455..66180be 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 without preassigned route', () => { + it('startPlayback sets status to running and classifies via classifyItem', () => { const state = startPlayback(createPlaybackState()); expect(state.status).toBe('running'); - expect(state.classification).toBeNull(); - expect(state.targetCategory).toBeNull(); + expect(state.classification).not.toBeNull(); + expect(state.targetCategory).toBe(state.classification!.category); }); it('pausePlayback sets status to paused', () => { diff --git a/src/domain/continuousPlayback.ts b/src/domain/continuousPlayback.ts index bbe661c..e6ebefd 100644 --- a/src/domain/continuousPlayback.ts +++ b/src/domain/continuousPlayback.ts @@ -1,7 +1,6 @@ /** * Continuous Playback Engine — manages auto-demo on main page. - * Playlist selects SKU only. Sorter route / category is assigned by the - * physical camera classification pipeline (see cameraClassification.ts). + * Classification is always driven by classifyItem (live rule engine). * Supports speed control, case seek, seeded variability, and event journal. */ @@ -10,7 +9,6 @@ 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'; @@ -43,9 +41,9 @@ export interface PhaseConfig { */ export const CASE_PHASES: PhaseConfig[] = [ { phase: 'spawn', durationMs: 300, label: 'Spawn at A' }, - // 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' }, + // 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' }, { phase: 'detection', durationMs: 600, label: 'CV Detection' }, { phase: 'measurement', durationMs: 1000, label: 'Laser measurement' }, { phase: 'classification', durationMs: 1000, label: 'Classification' }, @@ -129,6 +127,11 @@ 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 { @@ -155,9 +158,11 @@ function pushEvent( } function initCaseFields(playlistCase: PlaylistCase, seed: number, caseIndex: number, events: EventLogEntry[], simTime: number) { - // SKU only — no preassigned category / route for motion. - void resolveItem(playlistCase.itemId); - const warnings = playlistCase.warning ? [playlistCase.warning] : []; + const classification = classifyCase(playlistCase); + const warnings = [ + ...(playlistCase.warning ? [playlistCase.warning] : []), + ...classification.warnings, + ]; return { currentCase: playlistCase, currentCaseIndex: caseIndex, @@ -165,8 +170,8 @@ function initCaseFields(playlistCase: PlaylistCase, seed: number, caseIndex: num currentPhase: 'spawn' as CasePhase, phaseElapsedMs: 0, caseElapsedMs: 0, - targetCategory: null as Category | null, - classification: null as ClassificationResult | null, + targetCategory: classification.category, + classification, command: 'IDLE', warning: warnings[0] ?? null, positionJitter: buildJitter(seed, caseIndex), @@ -174,29 +179,16 @@ function initCaseFields(playlistCase: PlaylistCase, seed: number, caseIndex: num itemId: playlistCase.itemId, type: 'system', message: `Case start: ${playlistCase.title}`, - category: undefined, + category: classification.category, 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]; - resetCameraClassifications(); + const classification = classifyCase(firstCase); return { status: 'idle', currentCaseIndex: 0, @@ -221,7 +213,6 @@ 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, @@ -303,7 +294,7 @@ function advanceToNextCase(state: ContinuousPlaybackState): ContinuousPlaybackSt return { ...state, ...fields }; } -function getCommandForPhase(phase: CasePhase, category: Category | null): string { +function getCommandForPhase(phase: CasePhase, category: Category): string { switch (phase) { case 'spawn': case 'move_to_detection': @@ -317,7 +308,7 @@ function getCommandForPhase(phase: CasePhase, category: Category | null): string case 'command_sent': case 'routing': case 'exit': - return category ? `ROUTE_TO_${category}` : 'AWAITING_CLASSIFICATION'; + return `ROUTE_TO_${category}`; case 'fault_hold': return 'FAULT'; case 'emergency_hold': @@ -419,9 +410,7 @@ export function updatePlayback( } } - if (newState.status === 'running') { - // Phase commands (incl. FAULT) update without a preassigned route; - // ROUTE_TO_* only appears after camera classification binds category. + if (newState.status === 'running' && newState.targetCategory) { newState.command = getCommandForPhase(newState.currentPhase, newState.targetCategory); } @@ -468,12 +457,12 @@ export function getTotalProgress(state: ContinuousPlaybackState): number { return (completedCases + currentCaseProgress) / PLAYLIST_LENGTH; } -/** Assert playlist expectedCategory matches live classifier (test metadata only). */ +/** Assert playlist expectedCategory matches live classifier (for tests). */ 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 = classifyItem(resolveItem(c.itemId)); + const result = classifyCase(c); 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 deleted file mode 100644 index 78ecc32..0000000 --- a/src/domain/endOfLineCameraClassification.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -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/industrialTheme.ts b/src/domain/industrialTheme.ts index d4d1ad3..e1802f3 100644 --- a/src/domain/industrialTheme.ts +++ b/src/domain/industrialTheme.ts @@ -1,55 +1,30 @@ /** - * Shared visual tokens for the continuous digital twin. - * Ozon light presentation environment (runtime materials / lights only). - * CSS mirrors these values in `src/styles.css` (:root). + * Shared industrial visual tokens for the continuous digital twin. + * Keeps both modes looking like projections of one system. */ -/** Canonical Ozon brand + semantic tokens (source of truth for tests / 3D). */ -export const OZON_TOKENS = { - blue: '#005BFF', - magenta: '#F1117E', - darkSpace: '#001A34', - morning: '#00A2FF', - green: '#00BE6C', - orange: '#FFA800', - white: '#FFFFFF', - page: '#F5F7FA', - surface: '#FFFFFF', - surfaceSoft: '#EEF4FF', - border: '#DCE6F5', - muted: '#5F6F82', -} as const; - export const INDUSTRIAL_PALETTE = { - /** Scene clear / fog — soft Ozon morning surface */ - background: OZON_TOKENS.surfaceSoft, - backgroundDark: OZON_TOKENS.darkSpace, - floor: '#E2EAF5', - gridCell: '#C5D4EA', - gridSection: OZON_TOKENS.blue, + background: '#e8eef4', + backgroundDark: '#0b1220', + floor: '#d5dde8', + gridCell: '#c5d0de', + gridSection: '#9aabbf', belt: '#2f3a48', - beltStripe: OZON_TOKENS.orange, + beltStripe: '#f1c40f', metal: '#7b8796', metalDark: '#4a5563', frame: '#5b6b7c', plastic: '#94a3b8', rubber: '#1f2937', cardboard: '#b68b58', - /** Ozon blue — primary technical markers */ - sensorAccent: OZON_TOKENS.blue, - sensorActive: OZON_TOKENS.morning, - /** Success / B */ - routeB: OZON_TOKENS.green, - /** Attention / C */ - routeC: OZON_TOKENS.orange, - /** Active selection / D accent (magenta used selectively) */ - routeD: OZON_TOKENS.magenta, - fault: OZON_TOKENS.magenta, - warning: OZON_TOKENS.orange, - success: OZON_TOKENS.green, - lightKey: OZON_TOKENS.white, - lightFill: OZON_TOKENS.surfaceSoft, - text: OZON_TOKENS.darkSpace, + sensorAccent: '#3b82f6', + routeB: '#16a34a', + routeC: '#ea580c', + routeD: '#7c3aed', + fault: '#ef4444', + warning: '#f59e0b', + lightKey: '#f8fafc', + lightFill: '#d0dae8', } as const; export const INDUSTRIAL_MATERIALS = { diff --git a/src/domain/junctionContactPhysics.test.ts b/src/domain/junctionContactPhysics.test.ts deleted file mode 100644 index 1e60980..0000000 --- a/src/domain/junctionContactPhysics.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { beforeAll, describe, expect, it } from 'vitest'; -import { - DOCUMENTED_CONTACT_PLANE_S, - DOCUMENTED_CLEAR_PLANE_S, - DIVERTER_COLLIDER_HALF_EXTENTS, - DIVERTER_WORLD_PIVOTS, - diverterColliderPose, - hingeDriftMm, - angleDifferenceDeg, - neutralCorridorWidthM, - simulateJunctionContact, - runJunctionMatrix, -} from './junctionContactPhysics'; -import { initRapier } from './physicsDropSim'; -import { - DIVERTER_LEFT_SIGNED_DEG, - DIVERTER_RIGHT_SIGNED_DEG, - categoryToPhysicalRoute, - OPENING_SAFETY_MARGIN_SEC, - rotationDurationSec, - GATE_VANE, -} from './pusherMotion'; -import { BELT_SPEED_MPS, getProductPhysicsProfile } from './productPhysicsProfiles'; -import { PHYSICS_TIMESTEP_SEC } from './physicsTimestep'; -import PhysicalPlaybackItemPhysicsSrc from '../components/ThreeD/PhysicalPlaybackItemPhysics.tsx?raw'; - -beforeAll(async () => { - await initRapier(); -}); - -describe('diverter collider / CAD geometry contracts', () => { - it('uses canonical half-extents [0.375, 0.05, 0.02]', () => { - expect(DIVERTER_COLLIDER_HALF_EXTENTS).toEqual([0.375, 0.05, 0.02]); - expect(GATE_VANE.halfExtents).toEqual([0.375, 0.05, 0.02]); - }); - - it('LEFT/RIGHT collider angles match CAD pivot yaw at neutral and active', () => { - const left0 = diverterColliderPose(DIVERTER_WORLD_PIVOTS.left, 0); - const right0 = diverterColliderPose(DIVERTER_WORLD_PIVOTS.right, 0); - expect(angleDifferenceDeg(left0.yawRad, 0)).toBeLessThanOrEqual(0.5); - expect(angleDifferenceDeg(right0.yawRad, 0)).toBeLessThanOrEqual(0.5); - - const leftOpen = (DIVERTER_LEFT_SIGNED_DEG * Math.PI) / 180; - const rightOpen = (DIVERTER_RIGHT_SIGNED_DEG * Math.PI) / 180; - const leftA = diverterColliderPose(DIVERTER_WORLD_PIVOTS.left, leftOpen); - const rightA = diverterColliderPose(DIVERTER_WORLD_PIVOTS.right, rightOpen); - expect(angleDifferenceDeg(leftA.yawRad, leftOpen)).toBeLessThanOrEqual(0.5); - expect(angleDifferenceDeg(rightA.yawRad, rightOpen)).toBeLessThanOrEqual(0.5); - }); - - it('hinge drift and center offset stay within calibration budgets', () => { - const pivot = DIVERTER_WORLD_PIVOTS.left; - const pose = diverterColliderPose(pivot, 0); - expect(hingeDriftMm(pose.pivot, pivot)).toBeLessThanOrEqual(0.5); - // Center is half-length upstream of hinge along −X. - const expectedCenter = { - x: pivot.x - DIVERTER_COLLIDER_HALF_EXTENTS[0], - y: pivot.y, - z: pivot.z, - }; - const offsetMm = Math.hypot( - pose.center.x - expectedCenter.x, - pose.center.y - expectedCenter.y, - pose.center.z - expectedCenter.z, - ) * 1000; - expect(offsetMm).toBeLessThanOrEqual(2); - }); - - it('neutral corridor remains open for widest B product', () => { - const width = neutralCorridorWidthM(); - const widestB = getProductPhysicsProfile('SKU-001'); - const halfW = widestB.collider.type === 'cuboid' - ? Math.max(widestB.collider.halfExtents[0], widestB.collider.halfExtents[2]) - : 0.15; - expect(width).toBeGreaterThan(halfW * 2 + 0.04); - }); - - it('B/C/D diverter selection mapping', () => { - expect(categoryToPhysicalRoute('B')).toBe('STRAIGHT'); - expect(categoryToPhysicalRoute('C')).toBe('PHYSICAL_LEFT'); - expect(categoryToPhysicalRoute('D')).toBe('PHYSICAL_RIGHT'); - }); - - it('frozen timing and planes unchanged', () => { - expect(rotationDurationSec()).toBeCloseTo(0.5, 6); - expect(OPENING_SAFETY_MARGIN_SEC).toBeCloseTo(0.15, 6); - expect(DOCUMENTED_CONTACT_PLANE_S).toBe(1.0538); - expect(DOCUMENTED_CLEAR_PLANE_S).toBe(1.6); - expect(BELT_SPEED_MPS).toBe(1.0); - expect(PHYSICS_TIMESTEP_SEC).toBeCloseTo(1 / 120, 12); - }); - - it('runtime product code has no route-specific translation / handoff', () => { - expect(PhysicalPlaybackItemPhysicsSrc).not.toMatch(/getDropHandoffTimeMs/); - expect(PhysicalPlaybackItemPhysicsSrc).not.toMatch(/handoffPose/); - expect(PhysicalPlaybackItemPhysicsSrc).toMatch(/dynamic_active/); - expect(PhysicalPlaybackItemPhysicsSrc).toMatch(/detectReceiverZone/); - expect(PhysicalPlaybackItemPhysicsSrc).toMatch(/ccd=\{profile\.ccd\}/); - }); - - it('CCD remains enabled on profiles', () => { - for (const id of ['SKU-001', 'SKU-004', 'SKU-007', 'SKU-009']) { - expect(getProductPhysicsProfile(id).ccd).toBe(true); - } - }); -}); - -describe('deterministic physical junction matrix', () => { - it('single B/C/D smoke runs enter correct receivers', () => { - const b = simulateJunctionContact('SKU-001', 'B'); - const c = simulateJunctionContact('SKU-005', 'C'); - const d = simulateJunctionContact('SKU-006', 'D'); - expect(b.failure, JSON.stringify(b)).toBeNull(); - expect(c.failure, JSON.stringify(c)).toBeNull(); - expect(d.failure, JSON.stringify(d)).toBeNull(); - expect(b.correctReceiver).toBe(true); - expect(c.correctReceiver).toBe(true); - expect(d.correctReceiver).toBe(true); - expect(c.contactCount).toBeGreaterThan(0); - expect(d.contactCount).toBeGreaterThan(0); - expect(b.contactWhileOpening + c.contactWhileOpening + d.contactWhileOpening).toBe(0); - }); - - it('45/45 matrix: correct receivers, no tunnelling/invalid/duplicate failures', () => { - const matrix = runJunctionMatrix(5); - expect(matrix.total).toBe(45); - const failures = matrix.results.filter((r) => !r.correctReceiver || r.failure); - expect(failures, JSON.stringify(failures.slice(0, 5), null, 2)).toHaveLength(0); - expect(matrix.passed).toBe(45); - expect(matrix.results.every((r) => !r.tunnelling)).toBe(true); - expect(matrix.results.every((r) => !r.invalidState)).toBe(true); - expect(matrix.results.every((r) => r.contactWhileOpening === 0)).toBe(true); - }); -}); diff --git a/src/domain/junctionContactPhysics.ts b/src/domain/junctionContactPhysics.ts deleted file mode 100644 index 2adaad2..0000000 --- a/src/domain/junctionContactPhysics.ts +++ /dev/null @@ -1,466 +0,0 @@ -/** - * Physical junction contact — CAD diverter colliders + deterministic route matrix. - * - * 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] - */ - -import RAPIER from '@dimforge/rapier3d-compat'; -import { getStaticColliders } from './physicsWorldLayout'; -import { - getProductPhysicsProfile, - colliderHalfHeight, - spawnCenterY, - isSupportedByBelt, - BELT_SPEED_MPS, - type ProductPhysicsProfile, -} from './productPhysicsProfiles'; -import { - GATE_VANE, - DIVERTER_LEFT_SIGNED_DEG, - DIVERTER_RIGHT_SIGNED_DEG, - categoryToPhysicalRoute, -} from './pusherMotion'; -import { receiverContains, type ReceiverZone } from './receiverVolumes'; -import { PHYSICS_TIMESTEP_SEC } from './physicsTimestep'; -import { - CONVEYOR_WIDTH_M, - DISCHARGE_EDGE_S, - DOCUMENTED_LOCAL_CLEAR_OFFSET, - DOCUMENTED_LOCAL_CONTACT_OFFSET, - SORTER_ASSEMBLY_ORIGIN_S, - worldClearPlaneS, - worldContactPlaneS, -} from './physicalLayout'; - -/** 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], - GATE_VANE.halfExtents[1], - GATE_VANE.halfExtents[2], -]; - -export const DIVERTER_GUIDE_FRICTION = 0.22; -export const DIVERTER_RESTITUTION = 0.0; - -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: 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' - | 'PRODUCT_STUCK_IN_JUNCTION' - | 'PRODUCT_TUNNELLED_THROUGH_GUIDE' - | 'PRODUCT_LEFT_CONVEYOR' - | 'PRODUCT_OVER_SPEED' - | 'PRODUCT_UNDER_BELT' - | 'DIVERTER_CONTACT_WHILE_OPENING' - | 'INVALID_TRANSFORM'; - -export interface JunctionRunResult { - skuId: string; - expectedZone: ReceiverZone; - physicalRoute: ReturnType; - finalPosition: [number, number, number]; - receiverEntered: ReceiverZone | null; - correctReceiver: boolean; - contactCount: number; - contactWhileOpening: number; - maxSpeedMps: number; - maxAngularSpeed: number; - stuck: boolean; - tunnelling: boolean; - invalidState: boolean; - failure: JunctionFailure | null; - stepsSimulated: number; - lateralDisplacement: number; -} - -export interface DiverterColliderPose { - pivot: { x: number; y: number; z: number }; - yawRad: number; - center: { x: number; y: number; z: number }; - rotation: { x: number; y: number; z: number; w: number }; -} - -export function diverterColliderPose( - pivot: { x: number; y: number; z: number }, - yawRad: number, - halfLength = DIVERTER_COLLIDER_HALF_EXTENTS[0], -): DiverterColliderPose { - const x = pivot.x - Math.cos(yawRad) * halfLength; - const z = pivot.z + Math.sin(yawRad) * halfLength; - const half = yawRad / 2; - return { - pivot: { ...pivot }, - yawRad, - center: { x, y: pivot.y, z }, - rotation: { x: 0, y: Math.sin(half), z: 0, w: Math.cos(half) }, - }; -} - -export function hingeDriftMm( - a: { x: number; y: number; z: number }, - b: { x: number; y: number; z: number }, -): number { - return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z) * 1000; -} - -export function angleDifferenceDeg(a: number, b: number): number { - return (Math.abs(a - b) * 180) / Math.PI; -} - -export function neutralCorridorWidthM( - leftPivotZ = DIVERTER_WORLD_PIVOTS.left.z, - rightPivotZ = DIVERTER_WORLD_PIVOTS.right.z, - halfThickness = DIVERTER_COLLIDER_HALF_EXTENTS[2], -): number { - const leftInner = leftPivotZ - halfThickness; - const rightInner = rightPivotZ + halfThickness; - return leftInner - rightInner; -} - -export function detectReceiverZone(p: [number, number, number]): ReceiverZone | null { - if (receiverContains('B', p)) return 'B'; - if (receiverContains('C', p)) return 'C'; - if (receiverContains('D', p)) return 'D'; - return null; -} - -function quatFromEuler(x: number, y: number, z: number) { - const c1 = Math.cos(x / 2); const c2 = Math.cos(y / 2); const c3 = Math.cos(z / 2); - const s1 = Math.sin(x / 2); const s2 = Math.sin(y / 2); const s3 = Math.sin(z / 2); - return { - x: s1 * c2 * c3 + c1 * s2 * s3, - y: c1 * s2 * c3 - s1 * c2 * s3, - z: c1 * c2 * s3 + s1 * s2 * c3, - w: c1 * c2 * c3 - s1 * s2 * s3, - }; -} - -function productColliderDesc(profile: ProductPhysicsProfile): RAPIER.ColliderDesc { - const c = profile.collider; - let desc: RAPIER.ColliderDesc; - if (c.type === 'cuboid') { - const [hx, hy, hz] = c.halfExtents; - desc = RAPIER.ColliderDesc.cuboid(hx, hy, hz); - desc.setDensity(profile.massKg / (8 * hx * hy * hz)); - } else if (c.type === 'capsule') { - desc = RAPIER.ColliderDesc.capsule(c.halfHeight, c.radius); - desc.setDensity( - profile.massKg - / (Math.PI * c.radius * c.radius * (2 * c.halfHeight + (4 / 3) * c.radius)), - ); - if (c.axis === 'x') desc.setRotation(quatFromEuler(0, 0, Math.PI / 2)); - } else { - desc = RAPIER.ColliderDesc.cylinder(c.halfHeight, c.radius); - desc.setDensity(profile.massKg / (Math.PI * c.radius * c.radius * 2 * c.halfHeight)); - if (c.axis === 'x') desc.setRotation(quatFromEuler(0, 0, Math.PI / 2)); - } - desc.setFriction(profile.guideFriction); - desc.setRestitution(Math.min(profile.restitution, 0.03)); - return desc; -} - -function applyDiverterKinematic( - body: RAPIER.RigidBody, - side: 'left' | 'right', - yawRad: number, -) { - const pose = diverterColliderPose(DIVERTER_WORLD_PIVOTS[side], yawRad); - body.setNextKinematicTranslation(pose.center); - body.setNextKinematicRotation(pose.rotation); -} - -export function simulateJunctionContact( - skuId: string, - category: ReceiverZone, -): JunctionRunResult { - const profile = getProductPhysicsProfile(skuId); - const route = categoryToPhysicalRoute(category); - const halfH = colliderHalfHeight(profile); - const spawnX = -3.2; - const spawnY = spawnCenterY(profile); - const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 }); - world.timestep = PHYSICS_TIMESTEP_SEC; - - const leftYaw = category === 'C' ? (DIVERTER_LEFT_SIGNED_DEG * Math.PI) / 180 : 0; - const rightYaw = category === 'D' ? (DIVERTER_RIGHT_SIGNED_DEG * Math.PI) / 180 : 0; - - try { - for (const c of getStaticColliders()) { - const q = quatFromEuler(c.rotation[0], c.rotation[1], c.rotation[2]); - // Junction sim uses velocity-coupled belt drive against a stationary - // deck — keep belt tangential friction low so contact can redirect C/D. - const friction = c.id === 'belt-slab' ? 0.15 : c.friction; - world.createCollider( - RAPIER.ColliderDesc.cuboid(c.halfExtents[0], c.halfExtents[1], c.halfExtents[2]) - .setTranslation(c.position[0], c.position[1], c.position[2]) - .setRotation(q) - .setFriction(friction), - ); - } - - const [hx, hy, hz] = DIVERTER_COLLIDER_HALF_EXTENTS; - const leftPose0 = diverterColliderPose(DIVERTER_WORLD_PIVOTS.left, leftYaw); - const rightPose0 = diverterColliderPose(DIVERTER_WORLD_PIVOTS.right, rightYaw); - - // Do not setRotation on create — apply via setNextKinematic* each step - // (create-time quat objects NaN the island when paired with applyImpulse). - const leftBody = world.createRigidBody( - RAPIER.RigidBodyDesc.kinematicPositionBased() - .setTranslation(leftPose0.center.x, leftPose0.center.y, leftPose0.center.z), - ); - const leftCol = world.createCollider( - RAPIER.ColliderDesc.cuboid(hx, hy, hz) - .setFriction(DIVERTER_GUIDE_FRICTION) - .setRestitution(DIVERTER_RESTITUTION), - leftBody, - ); - - const rightBody = world.createRigidBody( - RAPIER.RigidBodyDesc.kinematicPositionBased() - .setTranslation(rightPose0.center.x, rightPose0.center.y, rightPose0.center.z), - ); - const rightCol = world.createCollider( - RAPIER.ColliderDesc.cuboid(hx, hy, hz) - .setFriction(DIVERTER_GUIDE_FRICTION) - .setRestitution(DIVERTER_RESTITUTION), - rightBody, - ); - - const body = world.createRigidBody( - RAPIER.RigidBodyDesc.dynamic() - .setTranslation(spawnX, spawnY, 0) - .setLinvel(BELT_SPEED_MPS * 0.5, 0, 0) - .setCcdEnabled(true) - .setLinearDamping(profile.linearDamping) - .setAngularDamping(profile.angularDamping) - .enabledRotations( - !profile.lockRotationX, - !profile.lockRotationY, - !profile.lockRotationZ, - ), - ); - const itemCol = world.createCollider(productColliderDesc(profile), body); - - let contactCount = 0; - const contactWhileOpening = 0; - let maxSpeed = 0; - let maxAng = 0; - let tunnelling = false; - let invalidState = false; - let stuck = false; - let receiverEntered: ReceiverZone | null = null; - let settleAccum = 0; - let lastProgressX = spawnX; - let lastProgressZ = 0; - let stuckClock = 0; - let steps = 0; - const maxSteps = Math.round(14 / PHYSICS_TIMESTEP_SEC); - - for (let i = 0; i < maxSteps; i += 1) { - applyDiverterKinematic(leftBody, 'left', leftYaw); - applyDiverterKinematic(rightBody, 'right', rightYaw); - - const t = body.translation(); - const lv = body.linvel(); - if (!Number.isFinite(t.x) || !Number.isFinite(t.y) || !Number.isFinite(t.z)) { - invalidState = true; - break; - } - - // Pre-step belt surface velocity (1.0 m/s downstream) while supported. - { - const contactS = runtimeWorldContactPlaneS(); - const phase = t.x >= contactS ? 'junction' : 'physical_conveyor'; - const supported = isSupportedByBelt({ - position: [t.x, t.y, t.z], - halfHeight: halfH, - phase, - linearVelY: lv.y, - }); - 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 >= contactS; - const targetVx = inJunction - ? lv.x + Math.max(-8, Math.min(8, (BELT_SPEED_MPS - lv.x) * 0.35)) - : BELT_SPEED_MPS; - body.setLinvel({ - x: targetVx, - y: Math.min(lv.y, 0.05), - z: inJunction ? lv.z : lv.z * 0.85, - }, true); - } - } - - world.step(); - steps += 1; - - let touching = false; - world.contactPairsWith(itemCol, (other) => { - if (other.handle === leftCol.handle || other.handle === rightCol.handle) { - touching = true; - } - }); - if (touching) contactCount += 1; - - const t2 = body.translation(); - const lv2 = body.linvel(); - const av2 = body.angvel(); - const speed = Math.hypot(lv2.x, lv2.y, lv2.z); - const ang = Math.hypot(av2.x, av2.y, av2.z); - maxSpeed = Math.max(maxSpeed, speed); - maxAng = Math.max(maxAng, ang); - - 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) { - invalidState = true; - break; - } - - const zone = detectReceiverZone([t2.x, t2.y, t2.z]); - if (zone) { - if (receiverEntered == null) receiverEntered = zone; - if (speed <= SETTLE_LINEAR_SPEED_MPS && ang <= SETTLE_ANGULAR_SPEED_RAD_S) { - settleAccum += PHYSICS_TIMESTEP_SEC; - } else { - settleAccum = Math.max(0, settleAccum - PHYSICS_TIMESTEP_SEC * 0.25); - } - if (settleAccum >= SETTLE_DURATION_SEC) break; - if (i * PHYSICS_TIMESTEP_SEC > 8 && speed < 0.4) break; - } - - if ( - t2.x > runtimeWorldContactPlaneS() - 0.2 - && t2.x < runtimeWorldClearPlaneS() + 0.5 - && !zone - ) { - // C/D progress is often lateral along the guide — track |Δx|+|Δz|. - const progress = Math.abs(t2.x - lastProgressX) + Math.abs(t2.z - lastProgressZ); - if (progress < 0.0015) stuckClock += PHYSICS_TIMESTEP_SEC; - else { - stuckClock = 0; - lastProgressX = t2.x; - lastProgressZ = t2.z; - } - if (stuckClock >= STUCK_TIMEOUT_SEC) { - stuck = true; - break; - } - } else { - stuckClock = 0; - lastProgressX = t2.x; - lastProgressZ = t2.z; - } - } - - const t = body.translation(); - const finalPosition: [number, number, number] = [t.x, t.y, t.z]; - if (!receiverEntered) receiverEntered = detectReceiverZone(finalPosition); - const correctReceiver = receiverEntered === category; - - let failure: JunctionFailure | null = null; - if (invalidState) failure = 'INVALID_TRANSFORM'; - else if (contactWhileOpening > 0) failure = 'DIVERTER_CONTACT_WHILE_OPENING'; - else if (tunnelling) failure = 'PRODUCT_TUNNELLED_THROUGH_GUIDE'; - else if (stuck) failure = 'PRODUCT_STUCK_IN_JUNCTION'; - else if (receiverEntered && receiverEntered !== category) failure = 'WRONG_RECEIVER_ENTRY'; - else if (!correctReceiver) failure = 'MISSED_RECEIVER'; - - return { - skuId, - expectedZone: category, - physicalRoute: route, - finalPosition, - receiverEntered, - correctReceiver, - contactCount, - contactWhileOpening, - maxSpeedMps: maxSpeed, - maxAngularSpeed: maxAng, - stuck, - tunnelling, - invalidState, - failure, - stepsSimulated: steps, - lateralDisplacement: finalPosition[2], - }; - } finally { - world.free(); - } -} - -export const JUNCTION_MATRIX_PROFILES = { - // Stable rect / light rect / thin difficult (pen). - B: ['SKU-001', 'SKU-002', 'SKU-009'] as const, - // Stable rect / corridor cylinder / tall capsule. - C: ['SKU-001', 'SKU-005', 'SKU-007'] as const, - // Flat pack / tall capsule / standing cylinder. - D: ['SKU-006', 'SKU-007', 'SKU-008'] as const, -}; - -export function runJunctionMatrix(runsPerProfile = 5): { - total: number; - passed: number; - results: JunctionRunResult[]; -} { - const results: JunctionRunResult[] = []; - for (const route of ['B', 'C', 'D'] as const) { - for (const sku of JUNCTION_MATRIX_PROFILES[route]) { - for (let i = 0; i < runsPerProfile; i += 1) { - results.push(simulateJunctionContact(sku, route)); - } - } - } - const passed = results.filter((r) => r.correctReceiver && r.failure == null).length; - return { total: results.length, passed, results }; -} diff --git a/src/domain/measurementZone.ts b/src/domain/measurementZone.ts index 53cc087..866ce9a 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 (1.0 m upstream of GATE). */ -export const CLASSIFICATION_DEADLINE_X = ZONES.GATE.x - 1.0; +/** 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; /** 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 b20980b..193b01a 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 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 }); + // 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 }); expect(transfer.surface).toBe('b_transfer'); expect(transfer.phase).toBe('routing'); - const chute = getPhysicalItemPose({ ...defaultInput, targetCategory: 'B', elapsedMs: 7800 }); + const chute = getPhysicalItemPose({ ...defaultInput, targetCategory: 'B', elapsedMs: 7600 }); expect(chute.surface).toBe('chute_b'); - const settled = getPhysicalItemPose({ ...defaultInput, targetCategory: 'B', elapsedMs: 9500 }); + const settled = getPhysicalItemPose({ ...defaultInput, targetCategory: 'B', elapsedMs: 9000 }); 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 eaaeb08..c7a7c0a 100644 --- a/src/domain/physicalLayout.ts +++ b/src/domain/physicalLayout.ts @@ -55,24 +55,6 @@ 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' }, @@ -80,14 +62,14 @@ export const ZONES = { CAMERA: { x: -1.5, z: 0 }, /** Laser measurement zone */ LASER: { x: -0.5, z: 0 }, - /** 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' }, + /** 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' }, } as const; // ========================================================= @@ -217,16 +199,14 @@ 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 = { - // 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, + centerX: 2.85, // immediately after sorter module end (~2.01) centerZ: 0, - width: 0.85, + width: 1.0, depth: 0.7, wallHeight: 0.45, floorY: CAGE_FLOOR_Y, - transferStartX: DISCHARGE_EDGE_S + 0.05, - transferEndX: DISCHARGE_EDGE_S + 0.12, + transferStartX: 1.95, // end of sorter CAD module + transferEndX: 2.25, }; // ========================================================= diff --git a/src/domain/physicsConfigHash.ts b/src/domain/physicsConfigHash.ts index 97a56d0..0de5c36 100644 --- a/src/domain/physicsConfigHash.ts +++ b/src/domain/physicsConfigHash.ts @@ -5,20 +5,15 @@ */ import { getStaticColliders } from './physicsWorldLayout'; import { PUSHER } from './pusherMotion'; -import { - PHYSICS_TIMESTEP_SEC, - PHYSICS_GRAVITY, - PHYSICS_MAX_SUBSTEPS, -} from './physicsTimestep'; +import { PHYSICS_TIMESTEP_SEC, PHYSICS_GRAVITY } from './physicsTimestep'; import { allVisualPhysicsProfiles } from './visualPhysicsProfiles'; import { SIM_SETTLE_SECONDS } from './physicsDropSim'; import { B_RECEIVER, ROLL_CAGE, ZONES } from './physicalLayout'; -export { - PHYSICS_TIMESTEP_SEC, - PHYSICS_GRAVITY, - PHYSICS_MAX_SUBSTEPS, -} from './physicsTimestep'; +export { PHYSICS_TIMESTEP_SEC, PHYSICS_GRAVITY } from './physicsTimestep'; + +/** Must match SorterPhysics PHYSICS_MAX_SUBSTEPS. */ +export const PHYSICS_MAX_SUBSTEPS = 4; export interface PhysicsConfigSnapshot { timestep: number; diff --git a/src/domain/physicsTimestep.ts b/src/domain/physicsTimestep.ts index 432d3db..9a27bdd 100644 --- a/src/domain/physicsTimestep.ts +++ b/src/domain/physicsTimestep.ts @@ -1,6 +1,3 @@ /** Shared fixed physics timestep — runtime Rapier and headless must match. */ -export const PHYSICS_TIMESTEP_SEC = 1 / 120; -export const PHYSICS_MAX_SUBSTEPS = 4; -/** Clamp tab-return / stall spikes before they enter the fixed-step accumulator. */ -export const MAX_FRAME_DELTA_SEC = 1 / 15; +export const PHYSICS_TIMESTEP_SEC = 1 / 60; export const PHYSICS_GRAVITY: [number, number, number] = [0, -9.81, 0]; diff --git a/src/domain/physicsWorldLayout.ts b/src/domain/physicsWorldLayout.ts index 08760c0..d75098a 100644 --- a/src/domain/physicsWorldLayout.ts +++ b/src/domain/physicsWorldLayout.ts @@ -17,15 +17,7 @@ * 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, - DISCHARGE_EDGE_S, -} from './physicalLayout'; +import { ZONES, B_RECEIVER, ROLL_CAGE, CONVEYOR_WIDTH_M, BELT_TOP_Y, CAGE_FLOOR_Y } from './physicalLayout'; export interface StaticColliderDef { id: string; @@ -46,7 +38,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 = ZONES.GATE.x; +export const CHUTE_X = 1.55; export const CHUTE_HALF_W = 0.35; export const B_CHUTE_PITCH = Math.atan2(BELT_TOP_Y - (CAGE_FLOOR_Y + 0.05), 0.45); @@ -96,9 +88,16 @@ 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 }, ]; - // 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; + 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, + }); 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( @@ -117,16 +116,11 @@ 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 — 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: [(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, - }, + // 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. + { 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 }, + // B transfer spur — top FLUSH with the belt slab (no 2mm trip step) + { id: 'b-spur', halfExtents: [0.325, 0.02, (CONVEYOR_WIDTH_M - 0.06) / 2], position: [1.825, BELT_TOP_Y - 0.022, 0], rotation: [0, 0, 0], friction: 0.4 }, ...chuteColliders(ZONES.C.z, 'C'), ...chuteColliders(ZONES.D.z, 'D'), ...receiverColliders(), diff --git a/src/domain/productPhysicsProfiles.test.ts b/src/domain/productPhysicsProfiles.test.ts deleted file mode 100644 index 332b596..0000000 --- a/src/domain/productPhysicsProfiles.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { describe, expect, it, beforeEach } from 'vitest'; -import { - BELT_SPEED_MPS, - BELT_RESPONSE_TIME_SEC, - BELT_SPEED_EPSILON, - DOWNSTREAM_AXIS, - LATERAL_AXIS, - UP_AXIS, - JUNCTION_ENTRY_S, - getProductPhysicsProfile, - computeBeltDriveForce, - isSupportedByBelt, - spawnCenterY, - colliderHalfHeight, - resetInvalidProductStateCount, - INVALID_PRODUCT_STATE_COUNT, - isInvalidProductState, -} from './productPhysicsProfiles'; -import { - PHYSICS_TIMESTEP_SEC, - PHYSICS_MAX_SUBSTEPS, - MAX_FRAME_DELTA_SEC, -} from './physicsTimestep'; -import { BELT_TOP_Y, CONVEYOR_SPEED_MPS } from './physicalLayout'; -import { - categoryToPhysicalRoute, - DIVERTER_LEFT_SIGNED_DEG, - DIVERTER_RIGHT_SIGNED_DEG, - OPENING_SAFETY_MARGIN_SEC, - rotationDurationSec, -} from './pusherMotion'; - -describe('physical conveyor foundation contracts', () => { - beforeEach(() => { - resetInvalidProductStateCount(); - }); - - it('BELT_SPEED_MPS equals exactly 1.0', () => { - expect(BELT_SPEED_MPS).toBe(1.0); - expect(CONVEYOR_SPEED_MPS).toBe(1.0); - }); - - it('fixed timestep equals 1/120 and max substeps equals 4', () => { - expect(PHYSICS_TIMESTEP_SEC).toBeCloseTo(1 / 120, 12); - expect(PHYSICS_MAX_SUBSTEPS).toBe(4); - expect(MAX_FRAME_DELTA_SEC).toBeCloseTo(1 / 15, 12); - }); - - it('axes are orthonormal with +X downstream', () => { - expect(DOWNSTREAM_AXIS).toEqual([1, 0, 0]); - expect(UP_AXIS).toEqual([0, 1, 0]); - expect(LATERAL_AXIS).toEqual([0, 0, 1]); - const dotDL = DOWNSTREAM_AXIS[0] * LATERAL_AXIS[0] - + DOWNSTREAM_AXIS[1] * LATERAL_AXIS[1] - + DOWNSTREAM_AXIS[2] * LATERAL_AXIS[2]; - expect(dotDL).toBe(0); - }); - - it('mass-scaled force converges toward 1.0 without large overshoot drive', () => { - let v = 0; - const dt = PHYSICS_TIMESTEP_SEC; - const profile = getProductPhysicsProfile('SKU-001'); - for (let i = 0; i < 240; i += 1) { - const sample = computeBeltDriveForce({ - massKg: profile.massKg, - linearVelocity: [v, 0, 0], - maxBeltAccelerationMps2: profile.maxBeltAccelerationMps2, - applyLateralCorrection: false, - }); - v += (sample.force[0] / profile.massKg) * dt; - } - expect(v).toBeGreaterThan(0.98); - expect(v).toBeLessThan(1.02 + BELT_SPEED_EPSILON); - }); - - it('heavier and lighter products both converge with mass-scaled force', () => { - const run = (sku: string) => { - let v = 0; - const p = getProductPhysicsProfile(sku); - for (let i = 0; i < 300; i += 1) { - const s = computeBeltDriveForce({ - massKg: p.massKg, - linearVelocity: [v, 0, 0], - maxBeltAccelerationMps2: p.maxBeltAccelerationMps2, - applyLateralCorrection: false, - }); - v += (s.force[0] / p.massKg) * PHYSICS_TIMESTEP_SEC; - } - return v; - }; - const light = run('SKU-009'); - const heavy = run('SKU-004'); - expect(light).toBeGreaterThan(0.95); - expect(heavy).toBeGreaterThan(0.95); - expect(light).toBeLessThan(1.05); - expect(heavy).toBeLessThan(1.05); - }); - - it('deadband applies no forward correction near target', () => { - const sample = computeBeltDriveForce({ - massKg: 1, - linearVelocity: [1.0, 0, 0], - maxBeltAccelerationMps2: 5, - applyLateralCorrection: false, - }); - expect(sample.withinDeadband).toBe(true); - expect(sample.appliedAcceleration).toBe(0); - expect(sample.force[0]).toBe(0); - }); - - it('no belt support when airborne or past belt end', () => { - const half = colliderHalfHeight(getProductPhysicsProfile('SKU-001')); - expect(isSupportedByBelt({ - position: [-3, BELT_TOP_Y + half + 0.1, 0], - halfHeight: half, - phase: 'physical_conveyor', - linearVelY: 0, - })).toBe(false); - - expect(isSupportedByBelt({ - position: [2.3, BELT_TOP_Y + half, 0], - halfHeight: half, - phase: 'physical_conveyor', - linearVelY: 0, - })).toBe(false); - - expect(isSupportedByBelt({ - position: [JUNCTION_ENTRY_S + 0.01, spawnCenterY(getProductPhysicsProfile('SKU-001')), 0], - halfHeight: half, - phase: 'junction', - linearVelY: 0, - })).toBe(true); - }); - - it('supported on belt top within clearance', () => { - const half = colliderHalfHeight(getProductPhysicsProfile('SKU-001')); - expect(isSupportedByBelt({ - position: [-3, spawnCenterY(getProductPhysicsProfile('SKU-001')), 0], - halfHeight: half, - phase: 'physical_conveyor', - linearVelY: 0, - })).toBe(true); - }); - - it('CCD enabled and spawn Y clears belt by 2mm', () => { - const p = getProductPhysicsProfile('SKU-001'); - expect(p.ccd).toBe(true); - expect(spawnCenterY(p)).toBeCloseTo(BELT_TOP_Y + colliderHalfHeight(p) + 0.002, 6); - }); - - it('profiles are ENGINEERING_DERIVED with primitive colliders', () => { - for (const id of ['SKU-001', 'SKU-004', 'SKU-007', 'SKU-009']) { - const p = getProductPhysicsProfile(id); - expect(p.provenance).toBe('ENGINEERING_DERIVED'); - expect(['cuboid', 'cylinder', 'capsule']).toContain(p.collider.type); - expect(p.restitution).toBeLessThanOrEqual(0.05); - } - }); - - it('invalid state detection catches NaN and overspeed', () => { - expect(isInvalidProductState({ - position: [0, NaN, 0], - linearVelocity: [0, 0, 0], - angularVelocity: [0, 0, 0], - })).toBe(true); - expect(isInvalidProductState({ - position: [0, 0.8, 0], - linearVelocity: [5, 0, 0], - angularVelocity: [0, 0, 0], - })).toBe(true); - expect(INVALID_PRODUCT_STATE_COUNT).toBe(0); - }); - - it('response time constant is 0.35 s', () => { - expect(BELT_RESPONSE_TIME_SEC).toBe(0.35); - }); - - it('physical conveyor has no per-frame setTranslation and no handoff switch', async () => { - const src = await import('../components/ThreeD/PhysicalPlaybackItemPhysics.tsx?raw'); - const text = (src as { default: string }).default; - expect(text).toMatch(/dynamic_active/); - expect(text).toMatch(/useBeforePhysicsStep/); - expect(text).toMatch(/setLinvel/); - expect(text).toMatch(/BELT_SPEED_MPS/); - expect(text).not.toMatch(/getDropHandoffTimeMs/); - expect(text).not.toMatch(/setLinvel\(\{ x: Math\.max/); - }); -}); - -describe('frozen routing contracts unchanged', () => { - it('B/C/D mapping unchanged', () => { - expect(categoryToPhysicalRoute('B')).toBe('STRAIGHT'); - expect(categoryToPhysicalRoute('C')).toBe('PHYSICAL_LEFT'); - expect(categoryToPhysicalRoute('D')).toBe('PHYSICAL_RIGHT'); - }); - - it('LEFT/RIGHT angles and 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); - }); -}); diff --git a/src/domain/productPhysicsProfiles.ts b/src/domain/productPhysicsProfiles.ts deleted file mode 100644 index a436ae8..0000000 --- a/src/domain/productPhysicsProfiles.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * Canonical product physics profiles + belt-drive helpers. - * - * All mass/friction/damping values are ENGINEERING_DERIVED for stable digital-twin - * behavior. They are not certified manufacturer masses. - */ - -import { - BELT_TOP_Y, - CAD_GATE_ENGAGE_X, - CONVEYOR_SPEED_MPS, - CONVEYOR_WIDTH_M, - DISCHARGE_EDGE_S, - worldContactPlaneS, - ZONES, -} from './physicalLayout'; - -export const BELT_SPEED_MPS = CONVEYOR_SPEED_MPS; // exactly 1.0 -export const BELT_RESPONSE_TIME_SEC = 0.35; -export const BELT_SPEED_EPSILON = 0.02; -export const LATERAL_DAMPING_GAIN = 2.5; -export const MAX_LATERAL_CORRECTION_MPS2 = 1.5; -export const SPAWN_CLEARANCE_M = 0.002; -export const MAX_PRODUCT_SPEED_MPS = 4.0; -export const MAX_ANGULAR_SPEED_RAD_S = 12.0; - -/** World axes confirmed by physicalLayout (+X downstream, +Y up, +Z left). */ -export const DOWNSTREAM_AXIS: [number, number, number] = [1, 0, 0]; -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 (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; - -export type ProductPhysicsPhase = - | 'preparing' - | 'physical_conveyor' - | 'junction' - | 'settled' - | 'invalid'; - -export type ProductCollider = - | { type: 'cuboid'; halfExtents: [number, number, number] } - | { type: 'cylinder'; radius: number; halfHeight: number; axis: 'x' | 'y' } - | { type: 'capsule'; radius: number; halfHeight: number; axis: 'x' | 'y' }; - -export type ProductPhysicsProfile = { - productId: string; - /** ENGINEERING_DERIVED */ - massKg: number; - collider: ProductCollider; - centerOfMassOffset: [number, number, number]; - beltFriction: number; - guideFriction: number; - restitution: number; - linearDamping: number; - angularDamping: number; - lockRotationX: boolean; - lockRotationY: boolean; - lockRotationZ: boolean; - maxBeltAccelerationMps2: number; - ccd: true; - provenance: 'ENGINEERING_DERIVED'; -}; - -const PROFILES: Record = { - 'SKU-001': { - productId: 'SKU-001', massKg: 0.8, - collider: { type: 'cuboid', halfExtents: [0.15, 0.1, 0.1] }, - centerOfMassOffset: [0, 0, 0], - beltFriction: 0.75, guideFriction: 0.35, restitution: 0.02, - linearDamping: 0.25, angularDamping: 3.5, - lockRotationX: true, lockRotationY: false, lockRotationZ: true, - // Accel budget must overcome stationary-belt friction (μN/m ≈ 5–6 m/s²). - maxBeltAccelerationMps2: 12, ccd: true, provenance: 'ENGINEERING_DERIVED', - }, - 'SKU-002': { - productId: 'SKU-002', massKg: 0.55, - collider: { type: 'cuboid', halfExtents: [0.1005, 0.031, 0.076] }, - centerOfMassOffset: [0, 0, 0], - beltFriction: 0.7, guideFriction: 0.3, restitution: 0.03, - linearDamping: 0.25, angularDamping: 3.2, - lockRotationX: true, lockRotationY: false, lockRotationZ: true, - maxBeltAccelerationMps2: 12, ccd: true, provenance: 'ENGINEERING_DERIVED', - }, - 'SKU-004': { - productId: 'SKU-004', massKg: 1.4, - // Sized to clear the neutral corridor and slide on the 45° guide face. - collider: { type: 'cuboid', halfExtents: [0.16, 0.12, 0.12] }, - centerOfMassOffset: [0, -0.02, 0], - beltFriction: 0.65, guideFriction: 0.25, restitution: 0.02, - linearDamping: 0.28, angularDamping: 4.0, - lockRotationX: true, lockRotationY: false, lockRotationZ: true, - maxBeltAccelerationMps2: 12, ccd: true, provenance: 'ENGINEERING_DERIVED', - }, - 'SKU-005': { - productId: 'SKU-005', massKg: 1.6, - // Corridor-compatible cylinder (visual drum scaled for junction clearance). - collider: { type: 'cylinder', radius: 0.11, halfHeight: 0.12, axis: 'y' }, - centerOfMassOffset: [0, -0.01, 0], - beltFriction: 0.7, guideFriction: 0.28, restitution: 0.01, - linearDamping: 0.3, angularDamping: 4.5, - lockRotationX: true, lockRotationY: false, lockRotationZ: true, - maxBeltAccelerationMps2: 12, ccd: true, provenance: 'ENGINEERING_DERIVED', - }, - 'SKU-006': { - productId: 'SKU-006', massKg: 0.45, - collider: { type: 'cuboid', halfExtents: [0.105, 0.0135, 0.1045] }, - centerOfMassOffset: [0, 0, 0], - beltFriction: 0.8, guideFriction: 0.35, restitution: 0.04, - linearDamping: 0.2, angularDamping: 3.0, - lockRotationX: true, lockRotationY: false, lockRotationZ: true, - maxBeltAccelerationMps2: 12, ccd: true, provenance: 'ENGINEERING_DERIVED', - }, - 'SKU-007': { - productId: 'SKU-007', massKg: 0.4, - collider: { type: 'capsule', radius: 0.0455, halfHeight: 0.107, axis: 'y' }, - centerOfMassOffset: [0, -0.02, 0], - beltFriction: 0.65, guideFriction: 0.35, restitution: 0.02, - linearDamping: 0.25, angularDamping: 5.0, - // Tall bottle: lock tip-over axes for stable belt/junction contact. - lockRotationX: true, lockRotationY: false, lockRotationZ: true, - maxBeltAccelerationMps2: 12, ccd: true, provenance: 'ENGINEERING_DERIVED', - }, - 'SKU-008': { - productId: 'SKU-008', massKg: 0.55, - // Standing cylinder (visual bottle) — Y axis; X-lying rolls off the belt. - collider: { type: 'cylinder', radius: 0.045, halfHeight: 0.11, axis: 'y' }, - centerOfMassOffset: [0, -0.01, 0], - beltFriction: 0.7, guideFriction: 0.35, restitution: 0.02, - linearDamping: 0.25, angularDamping: 4.5, - lockRotationX: true, lockRotationY: false, lockRotationZ: true, - maxBeltAccelerationMps2: 12, ccd: true, provenance: 'ENGINEERING_DERIVED', - }, - 'SKU-009': { - productId: 'SKU-009', massKg: 0.05, - // Thin pen: slightly larger contact radius so it does not tunnel the belt deck. - collider: { type: 'capsule', radius: 0.012, halfHeight: 0.06, axis: 'y' }, - centerOfMassOffset: [0, 0, 0], - beltFriction: 0.75, guideFriction: 0.35, restitution: 0.01, - linearDamping: 0.35, angularDamping: 6.0, - lockRotationX: true, lockRotationY: false, lockRotationZ: true, - maxBeltAccelerationMps2: 14, ccd: true, provenance: 'ENGINEERING_DERIVED', - }, - 'SKU-011': { - productId: 'SKU-011', massKg: 2.8, - collider: { type: 'cylinder', radius: 0.225, halfHeight: 0.15, axis: 'y' }, - centerOfMassOffset: [0, 0, 0], - beltFriction: 0.75, guideFriction: 0.4, restitution: 0.01, - linearDamping: 0.35, angularDamping: 4.5, - lockRotationX: true, lockRotationY: false, lockRotationZ: true, - maxBeltAccelerationMps2: 10, ccd: true, provenance: 'ENGINEERING_DERIVED', - }, -}; - -export const DEFAULT_PRODUCT_PHYSICS_PROFILE: ProductPhysicsProfile = { - productId: 'default', massKg: 1.0, - collider: { type: 'cuboid', halfExtents: [0.1, 0.08, 0.08] }, - centerOfMassOffset: [0, 0, 0], - beltFriction: 0.7, guideFriction: 0.3, restitution: 0.02, - linearDamping: 0.25, angularDamping: 3.5, - lockRotationX: true, lockRotationY: false, lockRotationZ: true, - maxBeltAccelerationMps2: 12, ccd: true, provenance: 'ENGINEERING_DERIVED', -}; - -export function getProductPhysicsProfile(productId: string): ProductPhysicsProfile { - const id = productId.replace(/-LC$/, ''); - return PROFILES[id] ?? DEFAULT_PRODUCT_PHYSICS_PROFILE; -} - -export function allProductPhysicsProfiles(): ProductPhysicsProfile[] { - return Object.values(PROFILES); -} - -export function colliderHalfHeight(profile: ProductPhysicsProfile): number { - const c = profile.collider; - if (c.type === 'cuboid') return c.halfExtents[1]; - // Capsule tips extend by radius beyond halfHeight; cylinder radius is lateral on Y. - if (c.type === 'capsule') { - return c.axis === 'y' ? c.halfHeight + c.radius : c.radius; - } - return c.axis === 'y' ? c.halfHeight : c.radius; -} - -export function spawnCenterY(profile: ProductPhysicsProfile): number { - return BELT_TOP_Y + colliderHalfHeight(profile) + SPAWN_CLEARANCE_M; -} - -export function isSupportedByBelt(input: { - position: [number, number, number]; - halfHeight: number; - phase: ProductPhysicsPhase; - linearVelY: number; -}): boolean { - if (input.phase !== 'physical_conveyor' && input.phase !== 'junction') return false; - const [x, y, z] = input.position; - if (x < BELT_START_S - 0.05 || x > BELT_END_S) return false; - // Laterally off the belt deck (entering C/D chutes) — no belt drive. - if (Math.abs(z) > CONVEYOR_WIDTH_M / 2 + 0.08) return false; - const bottomY = y - input.halfHeight; - if (bottomY > BELT_TOP_Y + 0.025) return false; // airborne - if (bottomY < BELT_TOP_Y - 0.04) return false; // sunk / off belt - if (input.linearVelY < -1.5) return false; // free-falling - return true; -} - -export type BeltForceSample = { - currentDownstreamSpeed: number; - speedError: number; - appliedAcceleration: number; - force: [number, number, number]; - lateralCorrection: [number, number, number]; - withinDeadband: boolean; -}; - -export function computeBeltDriveForce(input: { - massKg: number; - linearVelocity: [number, number, number]; - maxBeltAccelerationMps2: number; - applyLateralCorrection: boolean; - responseTimeSec?: number; - targetSpeedMps?: number; -}): BeltForceSample { - const target = input.targetSpeedMps ?? BELT_SPEED_MPS; - const response = input.responseTimeSec ?? BELT_RESPONSE_TIME_SEC; - const [vx, , vz] = input.linearVelocity; - // Downstream is +X; lateral is +Z (layout contract). - const downstreamSpeed = vx * DOWNSTREAM_AXIS[0] + vz * DOWNSTREAM_AXIS[2]; - const lateralVelocity = vx * LATERAL_AXIS[0] + vz * LATERAL_AXIS[2]; - const speedError = target - downstreamSpeed; - const withinDeadband = Math.abs(speedError) <= BELT_SPEED_EPSILON; - - let appliedAcceleration = 0; - if (!withinDeadband) { - const desired = speedError / response; - appliedAcceleration = Math.max( - -input.maxBeltAccelerationMps2, - Math.min(input.maxBeltAccelerationMps2, desired), - ); - } - - const force: [number, number, number] = [ - input.massKg * appliedAcceleration * DOWNSTREAM_AXIS[0], - input.massKg * appliedAcceleration * DOWNSTREAM_AXIS[1], - input.massKg * appliedAcceleration * DOWNSTREAM_AXIS[2], - ]; - - let lateralCorrection: [number, number, number] = [0, 0, 0]; - if (input.applyLateralCorrection) { - let aLat = -lateralVelocity * LATERAL_DAMPING_GAIN; - aLat = Math.max(-MAX_LATERAL_CORRECTION_MPS2, Math.min(MAX_LATERAL_CORRECTION_MPS2, aLat)); - lateralCorrection = [ - input.massKg * aLat * LATERAL_AXIS[0], - input.massKg * aLat * LATERAL_AXIS[1], - input.massKg * aLat * LATERAL_AXIS[2], - ]; - } - - return { - currentDownstreamSpeed: downstreamSpeed, - speedError, - appliedAcceleration, - force, - lateralCorrection, - withinDeadband, - }; -} - -export function isInvalidProductState(input: { - position: [number, number, number]; - linearVelocity: [number, number, number]; - angularVelocity: [number, number, number]; -}): boolean { - const vals = [...input.position, ...input.linearVelocity, ...input.angularVelocity]; - if (vals.some((v) => !Number.isFinite(v))) return true; - const [x, y, z] = input.position; - if (y < -0.5 || y > 5 || Math.abs(x) > 12 || Math.abs(z) > 6) return true; - const speed = Math.hypot(...input.linearVelocity); - if (speed > MAX_PRODUCT_SPEED_MPS) return true; - const ang = Math.hypot(...input.angularVelocity); - if (ang > MAX_ANGULAR_SPEED_RAD_S) return true; - return false; -} - -/** Dev/test counter — must stay 0 in normal validation runs. */ -export let INVALID_PRODUCT_STATE_COUNT = 0; - -export function resetInvalidProductStateCount() { - INVALID_PRODUCT_STATE_COUNT = 0; -} - -export function recordInvalidProductState() { - INVALID_PRODUCT_STATE_COUNT += 1; -} diff --git a/src/domain/productVisualOzon.test.ts b/src/domain/productVisualOzon.test.ts deleted file mode 100644 index 23e876d..0000000 --- a/src/domain/productVisualOzon.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - SETTLE_DURATION_SEC, - SETTLE_LINEAR_SPEED_MPS, - SETTLE_ANGULAR_SPEED_RAD_S, - DOCUMENTED_CONTACT_PLANE_S, - DOCUMENTED_CLEAR_PLANE_S, -} from './junctionContactPhysics'; -import { BELT_SPEED_MPS } from './productPhysicsProfiles'; -import { PHYSICS_TIMESTEP_SEC } from './physicsTimestep'; -import { - DIVERTER_LEFT_SIGNED_DEG, - DIVERTER_RIGHT_SIGNED_DEG, - categoryToPhysicalRoute, - OPENING_SAFETY_MARGIN_SEC, - rotationDurationSec, -} from './pusherMotion'; -import { INDUSTRIAL_PALETTE, OZON_TOKENS } from './industrialTheme'; -import physicsSrc from '../components/ThreeD/PhysicalPlaybackItemPhysics.tsx?raw'; -import visualSrc from '../components/ThreeD/PhysicalPlaybackItem.tsx?raw'; -import sceneSrc from '../components/ThreeD/SorterDigitalTwinContinuous.tsx?raw'; -import mainSrc from '../main.tsx?raw'; - -describe('product visual / rigid-body sync contracts', () => { - it('keeps a single RigidBody visual hierarchy with mount-only pose + memo', () => { - expect(physicsSrc).toMatch(//); - expect(physicsSrc).toMatch(/name="ProductVisualGroup"/); - // Initial pose is allowed; continuous type control is not (fights Dynamic). - expect(physicsSrc).toMatch(/position=\{spawnPose\.position\}/); - expect(physicsSrc).not.toMatch(/type="kinematicPosition"/); - expect(physicsSrc).toMatch(/setBodyType\(RigidBodyType\.Dynamic/); - expect(physicsSrc).toMatch(/Block playback-tick re-renders/); - expect(physicsSrc).toMatch(/castShadow=\{castShadow && spawned\}/); - }); - - it('visual model uses only local pivot offset (no world position writer)', () => { - expect(visualSrc).toMatch(/pivotOffsetY/); - expect(visualSrc).toMatch(//); - expect(visualSrc).not.toMatch(/position=\{pose\.position\}/); - expect(visualSrc).not.toMatch(/position=\{currentWorld/); - expect(visualSrc).not.toMatch(/mesh\.position\.copy/); - }); - - it('one physics instance path remains (no duplicate PhysicalPlaybackItem mount)', () => { - const physicsMounts = sceneSrc.match(/PhysicalPlaybackItemPhysics/g) ?? []; - const kinematicMounts = sceneSrc.match(/]/g) ?? []; - expect(physicsMounts.length).toBeGreaterThanOrEqual(1); - expect(kinematicMounts.length).toBe(0); - }); -}); - -describe('falling / physics freeze regression', () => { - it('receiver settle thresholds unchanged', () => { - expect(SETTLE_LINEAR_SPEED_MPS).toBe(0.20); - expect(SETTLE_ANGULAR_SPEED_RAD_S).toBe(1.0); - expect(SETTLE_DURATION_SEC).toBe(0.30); - }); - - it('B/C/D routing, belt, timestep, diverter timing unchanged', () => { - expect(categoryToPhysicalRoute('B')).toBe('STRAIGHT'); - expect(categoryToPhysicalRoute('C')).toBe('PHYSICAL_LEFT'); - expect(categoryToPhysicalRoute('D')).toBe('PHYSICAL_RIGHT'); - 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); - expect(DOCUMENTED_CONTACT_PLANE_S).toBe(1.0538); - expect(DOCUMENTED_CLEAR_PLANE_S).toBe(1.6); - expect(BELT_SPEED_MPS).toBe(1.0); - expect(PHYSICS_TIMESTEP_SEC).toBeCloseTo(1 / 120, 12); - }); -}); - -describe('Ozon light theme tokens', () => { - it('defines centralized Ozon tokens; CSS stylesheet is wired', () => { - expect(OZON_TOKENS.blue).toBe('#005BFF'); - expect(OZON_TOKENS.magenta).toBe('#F1117E'); - expect(OZON_TOKENS.darkSpace).toBe('#001A34'); - expect(OZON_TOKENS.morning).toBe('#00A2FF'); - expect(OZON_TOKENS.green).toBe('#00BE6C'); - expect(OZON_TOKENS.orange).toBe('#FFA800'); - expect(OZON_TOKENS.page).toBe('#F5F7FA'); - expect(OZON_TOKENS.surfaceSoft).toBe('#EEF4FF'); - // Magenta is selective accent — never the page background token. - expect(OZON_TOKENS.page).not.toBe(OZON_TOKENS.magenta); - expect(mainSrc).toMatch(/styles\.css/); - }); - - it('scene default is light Ozon environment', () => { - expect(INDUSTRIAL_PALETTE.background.toUpperCase()).toBe('#EEF4FF'); - expect(INDUSTRIAL_PALETTE.sensorAccent.toUpperCase()).toBe('#005BFF'); - expect(INDUSTRIAL_PALETTE.routeB.toUpperCase()).toBe('#00BE6C'); - expect(sceneSrc).toMatch(/const darkBg = proto \? stage0!\.darkBackground : false/); - expect(sceneSrc).toMatch(/INDUSTRIAL_PALETTE\.background/); - }); -}); diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index 9cbec33..3607c58 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -237,15 +237,6 @@ 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); @@ -297,7 +288,7 @@ export default function MainPage({ className={`hud-value status-${playback.status} ${faultActive ? 'status-fault' : ''}`} data-testid="demo-status" > - {isFinished ? 'FINISHED' : classStatusLabel} + {isFinished ? 'FINISHED' : phaseConfig.label}
@@ -464,7 +455,7 @@ export default function MainPage({ className={`product-tele-value status-${playback.status} ${faultActive ? 'status-fault' : ''}`} data-testid="demo-status" > - {isFinished ? 'FINISHED' : classStatusLabel} + {isFinished ? 'FINISHED' : phaseConfig.label}
@@ -476,12 +467,6 @@ export default function MainPage({ {category ?? '—'}
-
- ROUTE - - {routeLabel} - -
{debugMode && (
Command diff --git a/src/styles.css b/src/styles.css index de5c220..7968c46 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,54 +1,25 @@ /* ========================================================= - OZON Tech Sorter — Product Demo UI (light Ozon palette) + OZON Tech Sorter — Product Demo UI ========================================================= */ :root { - color-scheme: light; - - /* Brand tokens */ - --ozon-blue: #005BFF; - --ozon-magenta: #F1117E; - --ozon-dark-space: #001A34; - --ozon-morning: #00A2FF; - --ozon-green: #00BE6C; - --ozon-orange: #FFA800; - --ozon-white: #FFFFFF; - --ozon-page: #F5F7FA; - --ozon-surface: #FFFFFF; - --ozon-surface-soft: #EEF4FF; - --ozon-border: #DCE6F5; - --ozon-text: #001A34; - --ozon-muted: #5F6F82; - - /* Semantic */ - --brand-primary: var(--ozon-blue); - --brand-secondary: var(--ozon-magenta); - --brand-light: var(--ozon-morning); - --status-success: var(--ozon-green); - --status-warning: var(--ozon-orange); - --text-primary: var(--ozon-dark-space); - --page-background: var(--ozon-page); - --panel-background: var(--ozon-surface); - --panel-border: var(--ozon-border); - --focus-ring: var(--ozon-blue); - - /* Compat aliases used across the stylesheet */ - --bg: var(--page-background); - --panel: var(--panel-background); - --panel-strong: var(--ozon-surface-soft); - --line: var(--panel-border); - --muted: var(--ozon-muted); - --text: var(--text-primary); - --cyan: var(--brand-primary); - --cyan-hover: var(--brand-light); - --green: var(--status-success); - --orange: var(--status-warning); - --purple: var(--brand-secondary); - --red: var(--brand-secondary); + color-scheme: dark; + --bg: #050910; + --panel: rgba(10, 19, 31, 0.92); + --panel-strong: #0c1725; + --line: #1e3a54; + --muted: #91a4b8; + --text: #e5f2ff; + --cyan: #38bdf8; + --cyan-hover: #7dd3fc; + --green: #4ade80; + --orange: #f59e0b; + --purple: #c084fc; + --red: #fb3d4e; --section-max: 1200px; - --radius: 14px; + --radius: 16px; --tap: 44px; - font-family: "Manrope", "Segoe UI", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } *, @@ -74,9 +45,9 @@ body { margin: 0; min-height: 100vh; background: - radial-gradient(circle at 12% 0%, rgba(0, 162, 255, 0.10), transparent 32%), - radial-gradient(circle at 88% 8%, rgba(0, 91, 255, 0.06), transparent 28%), - linear-gradient(180deg, var(--ozon-surface-soft) 0%, var(--page-background) 42%, var(--page-background) 100%); + radial-gradient(circle at 18% 8%, rgba(56, 189, 248, 0.14), transparent 28%), + radial-gradient(circle at 88% 18%, rgba(192, 132, 252, 0.1), transparent 24%), + linear-gradient(160deg, #03060b 0%, #07111d 52%, #050910 100%); color: var(--text); font-size: 16px; line-height: 1.5; @@ -106,26 +77,25 @@ a { button { min-height: var(--tap); - border: 1px solid var(--panel-border); + border: 1px solid #2a4a66; border-radius: 12px; - background: var(--panel-background); - color: var(--text-primary); + background: #0e1b2b; + color: var(--text); cursor: pointer; font-weight: 700; - box-shadow: 0 1px 2px rgba(0, 26, 52, 0.06); - transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, box-shadow 160ms ease; + transition: transform 160ms ease, border-color 160ms ease, background 160ms ease; } button:hover { - border-color: var(--brand-primary); - background: var(--ozon-surface-soft); + border-color: var(--cyan); + background: #11273b; transform: translateY(-1px); } button:focus-visible, .nav-link:focus-visible, .criteria-link:focus-visible { - outline: 2px solid var(--focus-ring); + outline: 2px solid var(--cyan); outline-offset: 2px; } @@ -151,20 +121,19 @@ button:disabled { .btn-primary { border: none; - background: var(--brand-primary); - color: var(--ozon-white); - box-shadow: 0 2px 8px rgba(0, 91, 255, 0.28); + background: linear-gradient(135deg, #38bdf8, #0ea5e9); + color: #041018; } .btn-primary:hover { - background: var(--brand-light); - color: var(--ozon-white); + background: linear-gradient(135deg, #7dd3fc, #38bdf8); + color: #041018; } .btn-secondary { - background: var(--panel-background); - color: var(--text-primary); - border: 1px solid var(--panel-border); + background: #0e1b2b; + color: var(--text); + border: 1px solid #2a4a66; } .category-B { color: var(--green); } @@ -3350,10 +3319,10 @@ button:disabled { min-height: 100dvh; max-width: 100vw; overflow-x: hidden; - box-sizing: border-box; - background: #F5F7FA; - color: #001A34; - opacity: 1; + 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); } .docs-header { @@ -3367,10 +3336,9 @@ button:disabled { gap: 12px 20px; min-height: 56px; padding: 14px 20px; - border-bottom: 1px solid #DCE6F5; - background: #FFFFFF; - color: #001A34; - backdrop-filter: none; + border-bottom: 1px solid var(--line); + background: rgba(5, 9, 16, 0.92); + backdrop-filter: blur(12px); } .docs-brand { @@ -3396,13 +3364,10 @@ button:disabled { .docs-title { font-size: 1.25rem; line-height: 1.2; - color: #001A34; - opacity: 1; } .docs-subtitle { - color: #5F6F82; - opacity: 1; + color: var(--muted); font-size: 0.85rem; } @@ -3420,11 +3385,10 @@ button:disabled { top: 84px; align-self: start; padding: 12px; - border: 1px solid #DCE6F5; + border: 1px solid var(--line); border-radius: 12px; - background: #FFFFFF; + background: rgba(10, 19, 31, 0.7); font-size: 0.85rem; - color: #001A34; } .docs-toc-mobile { @@ -3441,8 +3405,7 @@ button:disabled { .docs-toc-desktop a, .docs-toc-mobile a { - color: #005BFF; - opacity: 1; + color: var(--muted); text-decoration: none; display: inline-flex; min-height: 36px; @@ -3451,8 +3414,7 @@ button:disabled { .docs-toc-desktop a:hover, .docs-toc-mobile a:hover { - color: #001A34; - background: #EEF4FF; + color: var(--cyan); } .docs-main { @@ -3470,63 +3432,43 @@ button:disabled { } .docs-status-banner { - border: 1px solid #DCE6F5; - background: #EEF4FF; - color: #33465B; + border: 1px solid rgba(74, 222, 128, 0.35); + background: rgba(20, 48, 32, 0.45); } .docs-status-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; - color: #5F6F82; - opacity: 1; + color: var(--muted); } .docs-status-value { margin-top: 6px; font-size: 1.15rem; font-weight: 700; - color: #005BFF; - opacity: 1; + color: var(--green); overflow-wrap: anywhere; word-break: break-word; } .docs-status-note { margin-top: 10px; - color: #33465B; - opacity: 1; + color: var(--muted); 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: #33465B; - opacity: 1; + color: #c9d8e8; 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 { @@ -3538,22 +3480,19 @@ button:disabled { .docs-section code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.88em; - color: #005BFF; - opacity: 1; + color: var(--cyan-hover); overflow-wrap: anywhere; word-break: break-word; } .docs-conflict { margin-top: 12px; - border: 1px solid #DCE6F5; - background: #FFFFFF; - color: #33465B; + border: 1px solid rgba(245, 158, 11, 0.4); + background: rgba(48, 32, 10, 0.4); } .docs-blocker-list li { - color: #33465B; - opacity: 1; + color: #ffd0d5; } .docs-table-scroll { @@ -3573,18 +3512,15 @@ button:disabled { .docs-table th, .docs-table td { padding: 8px 10px; - border: 1px solid #DCE6F5; + border: 1px solid var(--line); text-align: left; vertical-align: top; white-space: nowrap; - color: #33465B; - opacity: 1; - overflow-wrap: anywhere; } .docs-table th { - background: #EEF4FF; - color: #001A34; + background: rgba(12, 23, 37, 0.9); + color: var(--muted); font-weight: 600; } @@ -3657,10 +3593,9 @@ button:disabled { .docs-toc-mobile { display: block; - border: 1px solid #DCE6F5; + border: 1px solid var(--line); border-radius: 12px; - background: #FFFFFF; - color: #001A34; + background: rgba(10, 19, 31, 0.7); padding: 0 12px; } @@ -3672,8 +3607,6 @@ button:disabled { align-items: center; font-weight: 700; font-size: 15px; - color: #001A34; - opacity: 1; } .docs-toc-mobile > summary::-webkit-details-marker { @@ -3753,7 +3686,7 @@ button:disabled { height: 100vh; max-width: 100vw; overflow: hidden; - background: var(--page-background); + background: #070d16; } .main-page--product .product-topbar, @@ -3768,8 +3701,8 @@ button:disabled { height: 68px; max-height: 72px; padding: 0 24px; - border-bottom: 1px solid var(--panel-border); - background: var(--ozon-white); + border-bottom: 1px solid rgba(56, 189, 248, 0.18); + background: rgba(6, 12, 20, 0.88); backdrop-filter: blur(14px); } @@ -3787,9 +3720,9 @@ button:disabled { width: 34px; height: 34px; border-radius: 9px; - border: 1px solid var(--panel-border); - background: var(--ozon-surface-soft); - color: var(--brand-primary); + border: 1px solid rgba(56, 189, 248, 0.4); + background: rgba(56, 189, 248, 0.12); + color: #7dd3fc; font-weight: 800; font-size: 15px; flex: 0 0 auto; @@ -3799,7 +3732,7 @@ button:disabled { font-size: 15px; font-weight: 700; line-height: 1.15; - color: var(--text-primary); + color: #e8f2ff; } .product-brand-sub { @@ -3896,7 +3829,7 @@ button:disabled { display: grid; place-items: center; color: var(--muted); - background: var(--page-background); + background: #070d16; z-index: 2; } @@ -3907,8 +3840,8 @@ button:disabled { height: 78px; max-height: 84px; padding: 0 24px 10px; - border-top: 1px solid var(--panel-border); - background: var(--ozon-white); + border-top: 1px solid rgba(56, 189, 248, 0.18); + background: rgba(6, 12, 20, 0.9); backdrop-filter: blur(14px); } @@ -3962,9 +3895,7 @@ button:disabled { position: static; min-height: 40px; border-radius: 10px; - background: var(--ozon-surface-soft); - border: 1px solid var(--panel-border); - color: var(--text-primary); + background: rgba(12, 22, 34, 0.9); } .product-sim-pill { @@ -4011,8 +3942,9 @@ button:disabled { /* Documentation desktop grid aligned to product chrome */ .docs-page { - background: #F5F7FA; - color: #001A34; + background: + radial-gradient(circle at 10% 0%, rgba(56, 189, 248, 0.1), transparent 34%), + linear-gradient(180deg, #07111d 0%, #050910 100%); } .docs-layout { @@ -4058,89 +3990,3 @@ button:disabled { font-size: 1.05rem; } } - -/* ========================================================= - Ozon light chrome overrides (shell / HUD / nav / controls) - ========================================================= */ - -.main-hud, -.app-nav-overlay, -.app-nav-bar, -.main-progress { - background: rgba(255, 255, 255, 0.92) !important; - border-color: var(--panel-border) !important; - color: var(--text-primary); - box-shadow: 0 4px 18px rgba(0, 26, 52, 0.08); -} - -.play-button, -.main-page--product .play-button { - background: var(--brand-primary) !important; - color: var(--ozon-white) !important; - box-shadow: 0 4px 16px rgba(0, 91, 255, 0.28); -} - -.play-button:hover { - background: var(--brand-light) !important; -} - -.app-nav-link.active { - color: var(--ozon-white) !important; - background: var(--brand-primary) !important; - border-color: var(--brand-primary) !important; -} - -.app-nav-link:hover { - color: var(--brand-primary); - border-color: var(--panel-border); - background: var(--ozon-surface-soft); -} - -.product-topbar, -.docs-product-topbar, -.main-page--product .product-topbar, -.docs-page .product-topbar { - background: var(--ozon-white) !important; - border-bottom: 1px solid var(--panel-border) !important; - color: var(--text-primary) !important; - box-shadow: 0 1px 0 rgba(0, 26, 52, 0.04); -} - -.product-brand-title, -.product-tele-value { - color: var(--text-primary) !important; -} - -.product-brand-sub, -.product-tele-label { - color: var(--ozon-muted) !important; -} - -.main-page--product .app-nav-solid, -.docs-page .product-topbar .app-nav-solid { - background: var(--ozon-surface-soft) !important; - border-color: var(--panel-border) !important; -} - -.product-webgl-note, -.docs-page { - background: var(--page-background) !important; - color: var(--text-primary); -} - -.product-tele-value.category-B { color: var(--status-success) !important; } -.product-tele-value.category-C { color: var(--status-warning) !important; } -.product-tele-value.category-D { color: var(--brand-secondary) !important; } - -.progress-dot { - background: var(--ozon-border); -} - -.main-page--product .product-dock, -.main-page--product .main-controls .auto-camera-toggle, -.main-page--product .presentation-toggle, -.stop-button { - background: var(--ozon-white) !important; - border-color: var(--panel-border) !important; - color: var(--text-primary) !important; -}