revert: restore last working simulation (13ce16b)

Roll back junction-contact / discharge / light-theme simulation changes
to the previously stable production tree while keeping linear history.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-08-02 22:11:16 +02:00
parent 496162a3db
commit bb76963902
28 changed files with 480 additions and 2878 deletions

173
README.md
View File

@@ -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`.

View File

@@ -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

View File

@@ -10,7 +10,7 @@ test.describe('routes', () => {
await page.getByTestId('nav-documentation').first().click();
await expect(page).toHaveURL(/\/documentation\/?$/);
await expect(page.getByTestId('documentation-page')).toBeVisible();
await expect(page.getByTestId('docs-production-status')).toContainText('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();

View File

@@ -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<ContinuousPlaybackState>(() => 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<ClassificationEvent>).detail;
if (!detail?.result) return;
setPlayback((prev) => applyCameraClassificationToPlayback(prev, detail.result!));
};
window.addEventListener('camera-classification', onClass);
return () => window.removeEventListener('camera-classification', onClass);
}, []);
useEffect(() => {
if (playback.status !== 'running') {
if (playbackRafRef.current) {

View File

@@ -13,7 +13,7 @@ import { useFrame, useLoader, useThree } from '@react-three/fiber';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import type { Category } from '../../domain/types';
import { CONVEYOR_SPEED_MPS, 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;

View File

@@ -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). */

View File

@@ -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<typeof getProductPhysicsProfile>): 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<typeof getVisualPhysicsProfile>): 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<RapierRigidBody>(null);
const authority = useRef<Authority>(isFault ? 'fault_kinematic' : 'preparing');
const phaseRef = useRef<ProductPhysicsPhase>('preparing');
const traceEnabled = useRef(
typeof window !== 'undefined'
&& new URLSearchParams(window.location.search).get('trace') === '1',
);
const authority = useRef<Authority>('kinematic');
const frozenPose = useRef<{ p: [number, number, number]; q: THREE.Quaternion } | null>(null);
const handedOffAtSimSec = useRef<number | null>(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<string> };
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<string> };
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 (
<RigidBody
ref={bodyRef}
type="kinematicPosition"
colliders={false}
friction={profile.beltFriction}
friction={profile.friction}
restitution={profile.restitution}
linearDamping={profile.linearDamping}
angularDamping={profile.angularDamping}
ccd={profile.ccd}
canSleep={false}
gravityScale={1}
enabledRotations={enabledRotations}
position={spawnPose.position}
rotation={spawnPose.rotation}
name={`product-body-${itemId}`}
userData={{ productId: itemId, bodyIdentity: bodyIdentity.current }}
ccd={ccd}
enabledRotations={[true, true, true]}
position={pose.position}
>
{spawned && profile.collider.type === 'cuboid' && (
<CuboidCollider
args={profile.collider.halfExtents}
density={density}
friction={profile.guideFriction}
restitution={profile.restitution}
/>
{/* Colliders only after visual ready — avoids stale/orphan contact. */}
{spawned && profile.collider === 'cuboid' && profile.cuboidHalfExtents && (
<CuboidCollider args={profile.cuboidHalfExtents} density={density} />
)}
{spawned && profile.collider.type === 'capsule' && (
{spawned && profile.collider === 'capsule' && profile.capsule && (
<CapsuleCollider
args={[profile.collider.halfHeight, profile.collider.radius]}
args={[profile.capsule[1], profile.capsule[0]]}
density={density}
friction={profile.guideFriction}
restitution={profile.restitution}
rotation={profile.collider.axis === 'x' ? [0, 0, Math.PI / 2] : undefined}
rotation={profile.colliderAxis === 'x' ? [0, 0, Math.PI / 2] : undefined}
/>
)}
{spawned && profile.collider.type === 'cylinder' && (
{spawned && profile.collider === 'cylinder' && profile.capsule && (
<CylinderCollider
args={[profile.collider.halfHeight, profile.collider.radius]}
args={[profile.capsule[1], profile.capsule[0]]}
density={density}
friction={profile.guideFriction}
restitution={profile.restitution}
rotation={profile.collider.axis === 'x' ? [0, 0, Math.PI / 2] : undefined}
rotation={profile.colliderAxis === 'x' ? [0, 0, Math.PI / 2] : undefined}
/>
)}
<group name="ProductVisualGroup" visible={spawned}>
<group visible={spawned}>
<ItemVisualContent
caseData={caseData}
phase={pose.phase}
surface={pose.surface}
isSettled={authority.current === 'frozen'}
isSettled={authority.current === 'frozen' ? true : pose.isSettled}
castShadow={castShadow && spawned}
verifySku={verifySku}
onVisualReady={onVisualReady}
@@ -484,25 +293,4 @@ function PhysicalPlaybackItemPhysicsInner({
</group>
</RigidBody>
);
}
/**
* 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;
},
);
});

View File

@@ -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<string, { color: string; roughness: number; metalness?: number }> = {
@@ -509,13 +494,10 @@ function ZoneMarker({ position, label, color, active }: {
</mesh>
<Html position={[0, 0.15, 0]} center>
<div style={{
color: active ? color : INDUSTRIAL_PALETTE.text,
color: active ? color : '#64748b',
fontSize: '20px',
fontWeight: 800,
padding: '2px 8px',
borderRadius: 8,
background: 'rgba(255,255,255,0.88)',
border: `1px solid ${active ? color : INDUSTRIAL_PALETTE.gridCell}`,
textShadow: active ? `0 0 8px ${color}` : 'none',
userSelect: 'none',
}}>
{label}
@@ -560,16 +542,7 @@ function BReceiverBin({ active }: { active: boolean }) {
<meshStandardMaterial color={metal} roughness={0.65} metalness={0.35} />
</mesh>
<Html position={[centerX, floorY + wallHeight + 0.18, centerZ]} center>
<div style={{
color: active ? COLORS.routeB : INDUSTRIAL_PALETTE.text,
fontSize: '20px',
fontWeight: 800,
padding: '2px 8px',
borderRadius: 8,
background: 'rgba(255,255,255,0.88)',
border: `1px solid ${active ? COLORS.routeB : INDUSTRIAL_PALETTE.gridCell}`,
userSelect: 'none',
}}>B</div>
<div style={{ color: active ? COLORS.routeB : '#64748b', fontSize: '20px', fontWeight: 800, userSelect: 'none' }}>B</div>
</Html>
</group>
);
@@ -598,13 +571,10 @@ function RollCage({ position, label, color, active, shadows = false }: {
{/* Label */}
<Html position={[0, height + 0.15, 0]} center>
<div style={{
color: active ? color : INDUSTRIAL_PALETTE.text,
color: active ? color : '#64748b',
fontSize: '18px',
fontWeight: 800,
padding: '2px 8px',
borderRadius: 8,
background: 'rgba(255,255,255,0.88)',
border: `1px solid ${active ? color : INDUSTRIAL_PALETTE.gridCell}`,
textShadow: active ? `0 0 8px ${color}` : 'none',
userSelect: 'none',
}}>
{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<RapierRigidBody>(null);
const rightRef = useRef<RapierRigidBody>(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 (
<group name="cad-gate-colliders">
<RigidBody
ref={leftRef}
name="LEFT_DIVERTER_BODY"
type="kinematicPosition"
colliders={false}
position={[left0.center.x, left0.center.y, left0.center.z]}
>
<CuboidCollider
args={[hx, hy, hz]}
friction={DIVERTER_GUIDE_FRICTION}
restitution={DIVERTER_RESTITUTION}
/>
{debug && (
<mesh>
<boxGeometry args={[hx * 2, hy * 2, hz * 2]} />
<meshBasicMaterial color="#22c55e" wireframe transparent opacity={0.85} />
</mesh>
)}
<RigidBody ref={leftRef} type="kinematicPosition" colliders={false} friction={0.55}
position={[lp.x - hx, GATE_VANE.centerY, lp.z]}>
<CuboidCollider args={[hx, hy, hz]} friction={0.55} restitution={0} />
</RigidBody>
<RigidBody
ref={rightRef}
name="RIGHT_DIVERTER_BODY"
type="kinematicPosition"
colliders={false}
position={[right0.center.x, right0.center.y, right0.center.z]}
>
<CuboidCollider
args={[hx, hy, hz]}
friction={DIVERTER_GUIDE_FRICTION}
restitution={DIVERTER_RESTITUTION}
/>
{debug && (
<mesh>
<boxGeometry args={[hx * 2, hy * 2, hz * 2]} />
<meshBasicMaterial color="#38bdf8" wireframe transparent opacity={0.85} />
</mesh>
)}
<RigidBody ref={rightRef} type="kinematicPosition" colliders={false} friction={0.55}
position={[rp.x - hx, GATE_VANE.centerY, rp.z]}>
<CuboidCollider args={[hx, hy, hz]} friction={0.55} restitution={0} />
</RigidBody>
</group>
);
@@ -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) */}
<color
attach="background"
args={[darkBg ? INDUSTRIAL_PALETTE.backgroundDark : INDUSTRIAL_PALETTE.background]}
/>
<fog
attach="fog"
args={[darkBg ? INDUSTRIAL_PALETTE.backgroundDark : INDUSTRIAL_PALETTE.background, 16, 32]}
/>
{/* Background: industrial dark product canvas (equipment must dominate) */}
<color attach="background" args={[darkBg ? INDUSTRIAL_PALETTE.backgroundDark : '#070d16']} />
<fog attach="fog" args={[darkBg ? INDUSTRIAL_PALETTE.backgroundDark : '#070d16', 13, 26]} />
{proto ? (
<>
{/* Cinematic rig: very low ambient, strong key, soft fill, cool rim */}
<ambientLight intensity={stage0!.ambient} />
<hemisphereLight args={['#223148', '#0b1220', 0.3]} />
<directionalLight
@@ -1240,18 +1145,21 @@ function ContinuousScene({
shadow-camera-far={25}
shadow-bias={-0.0004}
/>
{/* Fill — keeps shadowed side readable */}
{stage0!.fill && <directionalLight position={[-5, 6, -3]} intensity={0.35} />}
{/* Rim — cheap back light for edge separation */}
{stage0!.rim && <directionalLight position={[2, 5, -8]} intensity={0.7} color="#bcd7ff" />}
</>
) : (
<>
{/* Light Ozon studio: cool ambient, neutral key, soft shadows */}
<ambientLight intensity={0.72} />
<hemisphereLight args={[INDUSTRIAL_PALETTE.lightFill, INDUSTRIAL_PALETTE.gridCell, 0.85]} />
{/* 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) */}
<ambientLight intensity={0.46} />
<hemisphereLight args={['#314860', '#0a1018', 0.9]} />
<directionalLight
position={[6, 9, 4]}
intensity={1.55}
color={INDUSTRIAL_PALETTE.lightKey}
intensity={2.7}
castShadow={shadowsEnabled}
shadow-mapSize-width={1024}
shadow-mapSize-height={1024}
@@ -1261,44 +1169,46 @@ function ContinuousScene({
shadow-camera-bottom={-7}
shadow-camera-near={1}
shadow-camera-far={25}
shadow-bias={-0.0003}
shadow-normalBias={0.03}
shadow-bias={-0.00035}
shadow-normalBias={0.025}
/>
<directionalLight position={[-5, 6, -3]} intensity={0.55} color={INDUSTRIAL_PALETTE.lightFill} />
<directionalLight position={[2, 5, -8]} intensity={0.45} color={INDUSTRIAL_PALETTE.sensorActive} />
<directionalLight position={[1, 3, 8]} intensity={0.4} color={INDUSTRIAL_PALETTE.lightKey} />
<directionalLight position={[-5, 6, -3]} intensity={0.7} />
<directionalLight position={[2, 5, -8]} intensity={0.85} color="#bcd7ff" />
{/* low front fill so the +Z face (camera side) never goes black */}
<directionalLight position={[1, 3, 8]} intensity={0.55} color="#cfdcf2" />
{/* Procedural studio environment (no external HDRI — offline-safe) */}
{shadowsEnabled && (
<Environment resolution={64} frames={1}>
<Lightformer intensity={1.8} position={[0, 5, 0]} rotation-x={Math.PI / 2} scale={[8, 8, 1]} color={INDUSTRIAL_PALETTE.lightKey} />
<Lightformer intensity={0.6} position={[-5, 2, -4]} rotation-y={Math.PI / 3} scale={[4, 2, 1]} color={INDUSTRIAL_PALETTE.lightFill} />
<Lightformer intensity={0.35} position={[5, 1.5, 3]} rotation-y={-Math.PI / 4} scale={[3, 1.5, 1]} color={INDUSTRIAL_PALETTE.lightKey} />
<Lightformer intensity={1.6} position={[0, 5, 0]} rotation-x={Math.PI / 2} scale={[8, 8, 1]} color="#dfe9ff" />
<Lightformer intensity={0.7} position={[-5, 2, -4]} rotation-y={Math.PI / 3} scale={[4, 2, 1]} color="#b8c8e8" />
<Lightformer intensity={0.5} position={[5, 1.5, 3]} rotation-y={-Math.PI / 4} scale={[3, 1.5, 1]} color="#ffe9c8" />
</Environment>
)}
</>
)}
{/* Grid — subtle Ozon blue-gray */}
{/* Grid — subdued so equipment remains the visual subject */}
<Grid
args={[16, 12]}
cellSize={0.5}
cellThickness={0.28}
cellColor={darkBg ? INDUSTRIAL_PALETTE.metalDark : INDUSTRIAL_PALETTE.gridCell}
cellThickness={0.22}
cellColor={darkBg ? '#1a2533' : '#1e2a38'}
sectionSize={2}
sectionThickness={0.4}
sectionColor={darkBg ? INDUSTRIAL_PALETTE.frame : INDUSTRIAL_PALETTE.sensorActive}
fadeDistance={12}
sectionThickness={0.45}
sectionColor={darkBg ? '#243344' : '#2a3a4c'}
fadeDistance={10}
infiniteGrid={false}
position={[0, 0.001, 0]}
/>
{/* Floor — light cool gray */}
{/* Floor — dark polished concrete with soft reflections */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]} receiveShadow={shadowsEnabled}>
<planeGeometry args={[16, 12]} />
<meshStandardMaterial
color={darkBg ? '#121a26' : INDUSTRIAL_PALETTE.floor}
roughness={darkBg ? 0.64 : 0.88}
metalness={darkBg ? 0.2 : 0.04}
envMapIntensity={0.35}
color={darkBg ? '#121a26' : '#151d2a'}
roughness={darkBg ? 0.64 : 0.8}
metalness={darkBg ? 0.2 : 0.1}
envMapIntensity={0.5}
/>
</mesh>

View File

@@ -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<string, unknown>;
}
}
@@ -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 (
<RigidBody type="fixed" colliders={false}>
@@ -185,14 +172,7 @@ export function SorterPhysicsWorld({
children: ReactNode;
}) {
return (
<Physics
updateLoop="independent"
paused
timeStep={PHYSICS_DT}
gravity={PHYSICS_GRAVITY}
interpolate={false}
>
<PhysicsSimClock />
<Physics updateLoop="independent" paused timeStep={PHYSICS_DT} gravity={[0, -9.81, 0]}>
<RapierStepper running={running} speed={speed} />
<SorterStaticColliders />
{children}

View File

@@ -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 = [

View File

@@ -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<string, ClassificationEvent>();
let physicsStepCounter = 0;
let routeWritesBeforeCameraEntry = 0;
export function advanceCameraPhysicsStep(): number {
physicsStepCounter += 1;
return physicsStepCounter;
}
export function getCameraPhysicsStep(): number {
return physicsStepCounter;
}
export function resetCameraClassifications(): void {
classified.clear();
physicsStepCounter = 0;
routeWritesBeforeCameraEntry = 0;
}
export function getRouteWritesBeforeCameraEntry(): number {
return routeWritesBeforeCameraEntry;
}
export function isInsideCameraVolume(position: [number, number, number]): boolean {
const [x, y, z] = position;
const v = CAMERA_SCAN_VOLUME;
return (
Math.abs(x - v.centerX) <= v.halfX
&& Math.abs(y - v.centerY) <= v.halfY
&& Math.abs(z - v.centerZ) <= v.halfZ
);
}
export function measureProductAtCamera(
productId: string,
item: Item,
physicsStep: number,
): CameraMeasurement {
const d = item.dimensionsMm;
return {
productId,
measuredLengthMm: d.width,
measuredWidthMm: d.depth,
measuredHeightMm: d.height,
roundnessK: item.roundness,
capturedAtPhysicsStep: physicsStep,
cameraVolumeId: CAMERA_VOLUME_ID,
provenance: 'DIGITAL_SENSOR_SIMULATION',
};
}
export function getClassificationEvent(productId: string): ClassificationEvent | null {
return classified.get(productId) ?? null;
}
export function getClassificationLifecycle(productId: string): ClassificationLifecycle {
return classified.get(productId)?.lifecycle ?? 'UNKNOWN';
}
/**
* Request a mechanical route command. Throws in DEV if requested before a
* valid classification event; in production keeps diverters neutral.
*/
export function requestRouteCommand(
productId: string,
opts?: { allowBeforeClassification?: boolean },
): ClassificationEvent {
const ev = classified.get(productId);
if (ev?.lifecycle === 'CLASSIFIED' && ev.category) {
return ev;
}
routeWritesBeforeCameraEntry += 1;
if (import.meta.env.DEV && !opts?.allowBeforeClassification) {
throw new Error(
`[cameraClassification] route command before classification for ${productId}`,
);
}
return {
productId,
lifecycle: 'UNKNOWN',
measurement: null,
result: null,
category: null,
physicalRoute: 'NONE',
activeDiverter: 'NONE',
classifiedAtPhysicsStep: null,
};
}
/**
* Attempt classification when body is inside the camera volume.
* Fires once per productId. Returns the event (new or existing).
*/
export function tryClassifyAtCamera(input: {
productId: string;
item: Item;
position: [number, number, number];
physicsStep?: number;
}): ClassificationEvent {
const existing = classified.get(input.productId);
if (existing?.lifecycle === 'CLASSIFIED') {
return existing;
}
const inside = isInsideCameraVolume(input.position);
if (!inside) {
const waiting: ClassificationEvent = existing ?? {
productId: input.productId,
lifecycle: 'UNKNOWN',
measurement: null,
result: null,
category: null,
physicalRoute: 'NONE',
activeDiverter: 'NONE',
classifiedAtPhysicsStep: null,
};
classified.set(input.productId, waiting);
return waiting;
}
const step = input.physicsStep ?? advanceCameraPhysicsStep();
const measurement = measureProductAtCamera(input.productId, input.item, step);
const result = classifyItem(input.item);
const category = result.category as 'B' | 'C' | 'D';
const physicalRoute = categoryToPhysicalRoute(category);
const event: ClassificationEvent = {
productId: input.productId,
lifecycle: 'CLASSIFIED',
measurement,
result,
category,
physicalRoute,
activeDiverter: physicalRouteToActiveDiverter(physicalRoute),
classifiedAtPhysicsStep: step,
};
classified.set(input.productId, event);
if (typeof window !== 'undefined') {
const w = window as unknown as {
__CAMERA_CLASSIFICATION__?: ClassificationEvent;
__CLASSIFICATION_EVENTS__?: ClassificationEvent[];
};
w.__CAMERA_CLASSIFICATION__ = event;
w.__CLASSIFICATION_EVENTS__ = [...(w.__CLASSIFICATION_EVENTS__ ?? []), event].slice(-32);
window.dispatchEvent(new CustomEvent('camera-classification', { detail: event }));
}
return event;
}
/** Publish live UI snapshot without assigning a mechanical route prematurely. */
export function peekClassificationUi(productId: string | null): {
status: string;
category: string;
route: string;
} {
if (!productId) {
return { status: 'Ожидание сканирования', category: '—', route: 'Не определён' };
}
const ev = classified.get(productId);
if (!ev || ev.lifecycle === 'UNKNOWN') {
return { status: 'Ожидание сканирования', category: '—', route: 'Не определён' };
}
if (ev.lifecycle === 'SCANNING') {
return { status: 'Сканирование', category: '—', route: 'Не определён' };
}
const routeLabel =
ev.physicalRoute === 'PHYSICAL_LEFT' ? 'Влево'
: ev.physicalRoute === 'PHYSICAL_RIGHT' ? 'Вправо'
: ev.physicalRoute === 'STRAIGHT' ? 'Прямо'
: 'Не определён';
return {
status: 'Классифицировано',
category: ev.category ?? '—',
route: routeLabel,
};
}

View File

@@ -58,11 +58,11 @@ describe('continuousPlayback', () => {
expect(state.currentCaseIndex).toBe(0);
});
it('startPlayback sets status to running 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', () => {

View File

@@ -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 });
}

View File

@@ -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;
});
});

View File

@@ -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 = {

View File

@@ -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);
});
});

View File

@@ -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<typeof categoryToPhysicalRoute>;
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 };
}

View File

@@ -21,8 +21,8 @@ import { ZONES } from './physicalLayout';
export const SCAN_START_X = ZONES.CAMERA.x - 0.36;
export const SCAN_END_X = ZONES.CAMERA.x + 0.36;
/** Classification must be complete by this belt position (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;

View File

@@ -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');
});

View File

@@ -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,
};
// =========================================================

View File

@@ -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;

View File

@@ -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];

View File

@@ -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(),

View File

@@ -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);
});
});

View File

@@ -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<string, ProductPhysicsProfile> = {
'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;
}

View File

@@ -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(/<RigidBody[\s\S]*ProductVisualGroup[\s\S]*ItemVisualContent[\s\S]*<\/RigidBody>/);
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(/<group position=\{\[0, pivotOffsetY, 0\]\}>/);
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(/<PhysicalPlaybackItem[\s>]/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/);
});
});

View File

@@ -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}
</span>
</div>
<div className="hud-row">
@@ -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}
</span>
</div>
<div className="product-tele-item">
@@ -476,12 +467,6 @@ export default function MainPage({
{category ?? '—'}
</span>
</div>
<div className="product-tele-item">
<span className="product-tele-label">ROUTE</span>
<span className="product-tele-value" data-testid="demo-route">
{routeLabel}
</span>
</div>
{debugMode && (
<div className="product-tele-item">
<span className="product-tele-label">Command</span>

View File

@@ -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;
}