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:
173
README.md
173
README.md
@@ -2,55 +2,27 @@
|
||||
|
||||
Web digital twin of an Ozon Tech Track 3 conveyor sorting line: continuous product playback, camera/classification stage, B/C/D routing, and author-CAD diverter motion.
|
||||
|
||||
**Production domain:** https://arhipovdan.ru
|
||||
## Current product
|
||||
|
||||
## Current solution
|
||||
This repository contains:
|
||||
|
||||
This repository (`main`) contains the final web simulation:
|
||||
|
||||
- continuous 3D conveyor digital twin (`/`);
|
||||
- web-based conveyor sorting simulation (`/`);
|
||||
- engineering documentation page (`/documentation`);
|
||||
- author CAD conveyor source (`3d_models/conveer.FCStd`);
|
||||
- runtime conveyor GLB (`public/models/sorter/conveyor-clean.glb`);
|
||||
- product STL assets (`public/models/*.stl`);
|
||||
- camera-triggered measurement / rule-based classification (digital sensor simulation);
|
||||
- B / C / D route mapping with CAD left/right diverters (−45° / +45°);
|
||||
- Rapier physics: belt ≈ 1.0 m/s, fixed timestep 1/120 s, CCD on product profiles, discharge into receivers.
|
||||
- camera / classification stage;
|
||||
- B / C / D route mapping with CAD left/right diverters;
|
||||
- Rapier-backed product physics (contact routing **not** fully validated).
|
||||
|
||||
### Main functions
|
||||
|
||||
1. Continuous SKU playback on a three-module CAD conveyor (clean → camera → sorter).
|
||||
2. Camera-volume triggered measurement and B/C/D classification.
|
||||
3. Physical diverter routing and receiver settle detection.
|
||||
4. Desktop WebGL twin + mobile-capable 3D policy.
|
||||
5. Canonical engineering status on `/documentation`.
|
||||
|
||||
## Technology stack
|
||||
|
||||
| Layer | Packages |
|
||||
|---|---|
|
||||
| UI | React, react-router-dom |
|
||||
| 3D | three, @react-three/fiber, @react-three/drei, @react-three/postprocessing |
|
||||
| Physics | @react-three/rapier, @dimforge/rapier3d-compat |
|
||||
| Build | Vite, TypeScript, @vitejs/plugin-react |
|
||||
| Tests | Vitest, Playwright |
|
||||
|
||||
See `package.json` for exact versions. Do not treat transitive npm packages as first-class product dependencies.
|
||||
|
||||
## Dependencies / Node
|
||||
|
||||
- Node.js **20+**
|
||||
- npm (lockfile: `package-lock.json`)
|
||||
|
||||
### Exact commands
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run dev # development: http://127.0.0.1:3100
|
||||
npm test -- --run # unit tests
|
||||
npm run build # production build → dist/
|
||||
npm run preview # serve build: http://127.0.0.1:3100
|
||||
npm run test:e2e # Playwright (needs preview or PLAYWRIGHT_BASE_URL)
|
||||
npm run dev # http://127.0.0.1:3100
|
||||
npm test -- --run
|
||||
npm run build
|
||||
npm run preview # http://127.0.0.1:3100
|
||||
```
|
||||
|
||||
## Active routes
|
||||
@@ -63,37 +35,20 @@ npm run test:e2e # Playwright (needs preview or PLAYWRIGHT_BASE_URL)
|
||||
|
||||
Removed routes (`/details`, `/device-test`) are not part of the product.
|
||||
|
||||
## Project structure
|
||||
## Runtime architecture
|
||||
|
||||
```
|
||||
src/ React app, domain logic, 3D twin, pages
|
||||
public/ Runtime static assets (models, draco)
|
||||
models/ Product STL + sorter/conveyor-clean.glb
|
||||
3d_models/ Author CAD (conveer.FCStd)
|
||||
docs/ Engineering notes (ENGINEERING.md)
|
||||
e2e/ Playwright smoke / routes
|
||||
input_info/ Official Ozon input packs (PDFs/ZIPs)
|
||||
official_sources/ Classifier bounds PDF cited by code
|
||||
Dockerfile Multi-stage Vite build + nginx
|
||||
docker-compose.server.yml
|
||||
nginx.conf
|
||||
.github/workflows/ci.yml
|
||||
index.html
|
||||
→ src/main.tsx
|
||||
→ src/App.tsx
|
||||
→ MainPage (/) | DocumentationPage (/documentation)
|
||||
→ SorterDigitalTwinContinuous
|
||||
→ ConveyorCadModel (conveyor-clean.glb)
|
||||
→ product visuals / physics (STL + Rapier)
|
||||
→ classifier (domain)
|
||||
→ diverter product state machine (READY→ARMED→OPENING→HOLDING→CLOSING)
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
No runtime secrets are required for local demo or production static hosting.
|
||||
|
||||
Optional **build-time** identity (Docker / CI only; never commit real secrets):
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `VITE_BUILD_COMMIT` | Short git commit in `/version.json` |
|
||||
| `VITE_BUILD_BRANCH` | Branch name in `/version.json` |
|
||||
| `VITE_BUILD_RELEASE` | Release label in `/version.json` |
|
||||
|
||||
Do not commit `.env` files. Local `npm run build` derives identity from git when available.
|
||||
|
||||
## Active assets
|
||||
|
||||
| Path | Role |
|
||||
@@ -114,24 +69,6 @@ public/models/sorter/conveyor-clean.glb
|
||||
1dc7a8d7891bfe756e277ad5368df74cb73410156b2fe0f92845afb8a56f285a
|
||||
```
|
||||
|
||||
## Classifier vs real CV
|
||||
|
||||
**Web twin (`main`):** classification is a **digital sensor simulation**. When a product enters the camera scan volume, dimensions / roundness are measured in the twin and routed with the same B/C/D rules as Track 3 (`src/domain/classifier.ts`, `src/domain/cameraClassification.ts`). This is **not** a neural network and is **not** wired to a live RealSense camera in production.
|
||||
|
||||
**Real CV (parallel prototype, not merged into the web app):** preserved on branch **`drho1y-mvp_1`** under `vision_classifier/` (OpenCV + RealSense D415 depth → L×W×H + circle_ratio → B/C/D → MQTT). No PyTorch/YOLO weights are stored in git.
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git switch drho1y-mvp_1
|
||||
cd vision_classifier
|
||||
# see vision_classifier/README.md and START.md
|
||||
./demo.sh # browser HUD demo
|
||||
.venv/bin/python test_classify.py
|
||||
./run.sh --preview # full pipeline on hardware (Orange PI + RealSense)
|
||||
```
|
||||
|
||||
Integration status: **WORKING_PROTOTYPE on `drho1y-mvp_1`**, **not integrated** into https://arhipovdan.ru.
|
||||
|
||||
## Sorting logic
|
||||
|
||||
**CURRENT_IMPLEMENTATION_VERIFIED_IN_CODE** (`src/domain/classifier.ts`, `src/domain/pusherMotion.ts`, unit tests).
|
||||
@@ -155,79 +92,29 @@ Classifier bounds (code + `official_sources/doc-1783095831.pdf` reference; PDF n
|
||||
- circular when **K > 0.8**
|
||||
- check order: dimensions → C, else circular → D, else B
|
||||
|
||||
## Physical simulation
|
||||
|
||||
- Fixed timestep **1/120 s**, max **4** substeps, gravity **[0, −9.81, 0]**
|
||||
- Belt target speed **1.0 m/s** (supported-body velocity coupling)
|
||||
- CCD enabled on active product physics profiles
|
||||
- Single dynamic product rigid body through spawn → junction → receiver settle
|
||||
- LEFT/RIGHT CAD diverters: `kinematicPositionBased` colliders synced to CAD yaw
|
||||
- Discharge edge ends belt support; gravity fall into receivers
|
||||
- Receiver volumes are sensors for completion
|
||||
|
||||
Details: `docs/ENGINEERING.md`, `/documentation`.
|
||||
Missing primary brief (do not cite as present): `input_info/extracted/Постановка_Задача_3_сжато_2.pdf`.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm test -- --run # currently 245 unit tests
|
||||
npm run build
|
||||
# focused E2E (with preview on :3101):
|
||||
npx vite preview --host 127.0.0.1 --port 3101 &
|
||||
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3101 npm run test:e2e -- e2e/routes.spec.ts e2e/smoke.spec.ts
|
||||
```
|
||||
|
||||
- Unit tests: **196/196** (`npm test -- --run`)
|
||||
- Production build: **PASS** (`npm run build`)
|
||||
- Two-page routing: `/` + `/documentation`
|
||||
- CAD / GLB checksums: verified against values above
|
||||
- Diverter frozen angles / duration: covered by unit tests
|
||||
- Junction / discharge / camera classification: covered by domain tests (engineering-derived, not production-calibrated)
|
||||
|
||||
## Production deploy (this host)
|
||||
|
||||
Nginx terminates TLS for `arhipovdan.ru` and proxies to Docker `owl-web-1` on `127.0.0.1:3100` (`docker-compose.server.yml` + `Dockerfile`).
|
||||
|
||||
```bash
|
||||
cd /opt/arhipovdan/app # or your checkout
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
RELEASE=$(date -u +%Y%m%d-%H%M%S)
|
||||
docker compose -p owl -f docker-compose.server.yml build \
|
||||
--build-arg "BUILD_COMMIT=${COMMIT}" \
|
||||
--build-arg "BUILD_BRANCH=${BRANCH}" \
|
||||
--build-arg "BUILD_RELEASE=${RELEASE}"
|
||||
docker compose -p owl -f docker-compose.server.yml up -d --force-recreate
|
||||
curl -s https://arhipovdan.ru/version.json
|
||||
```
|
||||
|
||||
## Repository vs cloud materials
|
||||
|
||||
| Material | In git | Cloud expected |
|
||||
|---|---|---|
|
||||
| Source, README, docs, configs, tests | yes | — |
|
||||
| Runtime GLB/STL used by the deployed app | yes (required for deploy) | recommended mirror |
|
||||
| Author CAD `conveer.FCStd` (~5 MB) | yes | recommended upload |
|
||||
| Official PDFs/ZIPs under `input_info/`, `official_sources/` | yes | optional mirror |
|
||||
| Presentation / video demonstration | **not in repo** | **required for platform field** |
|
||||
| ML weights | none (CV is classical OpenCV depth) | N/A |
|
||||
|
||||
Confirmed public presentation / video / cloud-folder URLs are **not stored in this repository**. Add them in the platform submission field when available.
|
||||
|
||||
Презентация / видеодемонстрация: links must be supplied by the owner (not invented here).
|
||||
|
||||
## Current limitations
|
||||
|
||||
- Web classifier is a digital sensor simulation, not live RealSense inference.
|
||||
- Product profiles are engineering-derived, not production-calibrated.
|
||||
- Full contact-only sorting through CAD diverters is **not fully validated**.
|
||||
- Belt surface-velocity physics (true 1 m/s tangential drive) is **planned**, not complete.
|
||||
- Per-SKU mass / COM / friction profiles still need calibration.
|
||||
- Author CAD horn / complete transmission is absent or incomplete in the active GLB (`AUTHOR_CAD_INCOMPLETE`).
|
||||
- Official compliance claims are limited by the missing extracted task PDF and by not re-parsing PDFs in every doc pass.
|
||||
- Generated screenshots, videos, Gate stage folders, and tool `out/` trees are not canonical.
|
||||
- Official compliance claims are limited by the **missing** extracted task PDF and by not re-parsing PDFs in every doc pass.
|
||||
- Generated screenshots, videos, Gate stage folders, and tool `out/` trees are **not** canonical.
|
||||
|
||||
## Repository policy
|
||||
|
||||
- Keep author CAD, active runtime assets, official sources, build configs, and tests that protect active behavior.
|
||||
- Generated outputs and historical media are not product truth.
|
||||
- Real CV remains on `drho1y-mvp_1`; do not force-merge it into `main` without a validated web integration.
|
||||
- Rollback point for the pre-cleanup web baseline: git `4413f01` on `dan_branch` history.
|
||||
- Rollback point: git baseline `4413f01` on `dan_branch`.
|
||||
|
||||
See also: `/documentation` in the running app, and `docs/ENGINEERING.md`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
13
src/App.tsx
13
src/App.tsx
@@ -13,11 +13,9 @@ import {
|
||||
seekNextCase,
|
||||
seekPrevCase,
|
||||
setPlaybackSpeed,
|
||||
applyCameraClassificationToPlayback,
|
||||
type ContinuousPlaybackState,
|
||||
type PlaybackSpeed,
|
||||
} from './domain/continuousPlayback';
|
||||
import type { ClassificationEvent } from './domain/cameraClassification';
|
||||
|
||||
function AppContent() {
|
||||
const [playback, setPlayback] = useState<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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
// =========================================================
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
302
src/styles.css
302
src/styles.css
@@ -1,54 +1,25 @@
|
||||
/* =========================================================
|
||||
OZON Tech Sorter — Product Demo UI (light Ozon palette)
|
||||
OZON Tech Sorter — Product Demo UI
|
||||
========================================================= */
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
/* Brand tokens */
|
||||
--ozon-blue: #005BFF;
|
||||
--ozon-magenta: #F1117E;
|
||||
--ozon-dark-space: #001A34;
|
||||
--ozon-morning: #00A2FF;
|
||||
--ozon-green: #00BE6C;
|
||||
--ozon-orange: #FFA800;
|
||||
--ozon-white: #FFFFFF;
|
||||
--ozon-page: #F5F7FA;
|
||||
--ozon-surface: #FFFFFF;
|
||||
--ozon-surface-soft: #EEF4FF;
|
||||
--ozon-border: #DCE6F5;
|
||||
--ozon-text: #001A34;
|
||||
--ozon-muted: #5F6F82;
|
||||
|
||||
/* Semantic */
|
||||
--brand-primary: var(--ozon-blue);
|
||||
--brand-secondary: var(--ozon-magenta);
|
||||
--brand-light: var(--ozon-morning);
|
||||
--status-success: var(--ozon-green);
|
||||
--status-warning: var(--ozon-orange);
|
||||
--text-primary: var(--ozon-dark-space);
|
||||
--page-background: var(--ozon-page);
|
||||
--panel-background: var(--ozon-surface);
|
||||
--panel-border: var(--ozon-border);
|
||||
--focus-ring: var(--ozon-blue);
|
||||
|
||||
/* Compat aliases used across the stylesheet */
|
||||
--bg: var(--page-background);
|
||||
--panel: var(--panel-background);
|
||||
--panel-strong: var(--ozon-surface-soft);
|
||||
--line: var(--panel-border);
|
||||
--muted: var(--ozon-muted);
|
||||
--text: var(--text-primary);
|
||||
--cyan: var(--brand-primary);
|
||||
--cyan-hover: var(--brand-light);
|
||||
--green: var(--status-success);
|
||||
--orange: var(--status-warning);
|
||||
--purple: var(--brand-secondary);
|
||||
--red: var(--brand-secondary);
|
||||
color-scheme: dark;
|
||||
--bg: #050910;
|
||||
--panel: rgba(10, 19, 31, 0.92);
|
||||
--panel-strong: #0c1725;
|
||||
--line: #1e3a54;
|
||||
--muted: #91a4b8;
|
||||
--text: #e5f2ff;
|
||||
--cyan: #38bdf8;
|
||||
--cyan-hover: #7dd3fc;
|
||||
--green: #4ade80;
|
||||
--orange: #f59e0b;
|
||||
--purple: #c084fc;
|
||||
--red: #fb3d4e;
|
||||
--section-max: 1200px;
|
||||
--radius: 14px;
|
||||
--radius: 16px;
|
||||
--tap: 44px;
|
||||
font-family: "Manrope", "Segoe UI", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -74,9 +45,9 @@ body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 12% 0%, rgba(0, 162, 255, 0.10), transparent 32%),
|
||||
radial-gradient(circle at 88% 8%, rgba(0, 91, 255, 0.06), transparent 28%),
|
||||
linear-gradient(180deg, var(--ozon-surface-soft) 0%, var(--page-background) 42%, var(--page-background) 100%);
|
||||
radial-gradient(circle at 18% 8%, rgba(56, 189, 248, 0.14), transparent 28%),
|
||||
radial-gradient(circle at 88% 18%, rgba(192, 132, 252, 0.1), transparent 24%),
|
||||
linear-gradient(160deg, #03060b 0%, #07111d 52%, #050910 100%);
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
@@ -106,26 +77,25 @@ a {
|
||||
|
||||
button {
|
||||
min-height: var(--tap);
|
||||
border: 1px solid var(--panel-border);
|
||||
border: 1px solid #2a4a66;
|
||||
border-radius: 12px;
|
||||
background: var(--panel-background);
|
||||
color: var(--text-primary);
|
||||
background: #0e1b2b;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 1px 2px rgba(0, 26, 52, 0.06);
|
||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, box-shadow 160ms ease;
|
||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: var(--brand-primary);
|
||||
background: var(--ozon-surface-soft);
|
||||
border-color: var(--cyan);
|
||||
background: #11273b;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
.nav-link:focus-visible,
|
||||
.criteria-link:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline: 2px solid var(--cyan);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -151,20 +121,19 @@ button:disabled {
|
||||
|
||||
.btn-primary {
|
||||
border: none;
|
||||
background: var(--brand-primary);
|
||||
color: var(--ozon-white);
|
||||
box-shadow: 0 2px 8px rgba(0, 91, 255, 0.28);
|
||||
background: linear-gradient(135deg, #38bdf8, #0ea5e9);
|
||||
color: #041018;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--brand-light);
|
||||
color: var(--ozon-white);
|
||||
background: linear-gradient(135deg, #7dd3fc, #38bdf8);
|
||||
color: #041018;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--panel-background);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--panel-border);
|
||||
background: #0e1b2b;
|
||||
color: var(--text);
|
||||
border: 1px solid #2a4a66;
|
||||
}
|
||||
|
||||
.category-B { color: var(--green); }
|
||||
@@ -3350,10 +3319,10 @@ button:disabled {
|
||||
min-height: 100dvh;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
background: #F5F7FA;
|
||||
color: #001A34;
|
||||
opacity: 1;
|
||||
background:
|
||||
radial-gradient(circle at 12% 0%, rgba(56, 189, 248, 0.12), transparent 32%),
|
||||
linear-gradient(180deg, #07111d 0%, #050910 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.docs-header {
|
||||
@@ -3367,10 +3336,9 @@ button:disabled {
|
||||
gap: 12px 20px;
|
||||
min-height: 56px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid #DCE6F5;
|
||||
background: #FFFFFF;
|
||||
color: #001A34;
|
||||
backdrop-filter: none;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(5, 9, 16, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.docs-brand {
|
||||
@@ -3396,13 +3364,10 @@ button:disabled {
|
||||
.docs-title {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.2;
|
||||
color: #001A34;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.docs-subtitle {
|
||||
color: #5F6F82;
|
||||
opacity: 1;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -3420,11 +3385,10 @@ button:disabled {
|
||||
top: 84px;
|
||||
align-self: start;
|
||||
padding: 12px;
|
||||
border: 1px solid #DCE6F5;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: #FFFFFF;
|
||||
background: rgba(10, 19, 31, 0.7);
|
||||
font-size: 0.85rem;
|
||||
color: #001A34;
|
||||
}
|
||||
|
||||
.docs-toc-mobile {
|
||||
@@ -3441,8 +3405,7 @@ button:disabled {
|
||||
|
||||
.docs-toc-desktop a,
|
||||
.docs-toc-mobile a {
|
||||
color: #005BFF;
|
||||
opacity: 1;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
min-height: 36px;
|
||||
@@ -3451,8 +3414,7 @@ button:disabled {
|
||||
|
||||
.docs-toc-desktop a:hover,
|
||||
.docs-toc-mobile a:hover {
|
||||
color: #001A34;
|
||||
background: #EEF4FF;
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.docs-main {
|
||||
@@ -3470,63 +3432,43 @@ button:disabled {
|
||||
}
|
||||
|
||||
.docs-status-banner {
|
||||
border: 1px solid #DCE6F5;
|
||||
background: #EEF4FF;
|
||||
color: #33465B;
|
||||
border: 1px solid rgba(74, 222, 128, 0.35);
|
||||
background: rgba(20, 48, 32, 0.45);
|
||||
}
|
||||
|
||||
.docs-status-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #5F6F82;
|
||||
opacity: 1;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.docs-status-value {
|
||||
margin-top: 6px;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: #005BFF;
|
||||
opacity: 1;
|
||||
color: var(--green);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.docs-status-note {
|
||||
margin-top: 10px;
|
||||
color: #33465B;
|
||||
opacity: 1;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.docs-section h2 {
|
||||
font-size: 1.15rem;
|
||||
margin-bottom: 10px;
|
||||
color: #001A34;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.docs-section p,
|
||||
.docs-section li {
|
||||
color: #33465B;
|
||||
opacity: 1;
|
||||
color: #c9d8e8;
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.docs-main,
|
||||
.docs-section,
|
||||
.docs-status-banner {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.docs-section ul {
|
||||
@@ -3538,22 +3480,19 @@ button:disabled {
|
||||
.docs-section code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.88em;
|
||||
color: #005BFF;
|
||||
opacity: 1;
|
||||
color: var(--cyan-hover);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.docs-conflict {
|
||||
margin-top: 12px;
|
||||
border: 1px solid #DCE6F5;
|
||||
background: #FFFFFF;
|
||||
color: #33465B;
|
||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||
background: rgba(48, 32, 10, 0.4);
|
||||
}
|
||||
|
||||
.docs-blocker-list li {
|
||||
color: #33465B;
|
||||
opacity: 1;
|
||||
color: #ffd0d5;
|
||||
}
|
||||
|
||||
.docs-table-scroll {
|
||||
@@ -3573,18 +3512,15 @@ button:disabled {
|
||||
.docs-table th,
|
||||
.docs-table td {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #DCE6F5;
|
||||
border: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
color: #33465B;
|
||||
opacity: 1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.docs-table th {
|
||||
background: #EEF4FF;
|
||||
color: #001A34;
|
||||
background: rgba(12, 23, 37, 0.9);
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -3657,10 +3593,9 @@ button:disabled {
|
||||
|
||||
.docs-toc-mobile {
|
||||
display: block;
|
||||
border: 1px solid #DCE6F5;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: #FFFFFF;
|
||||
color: #001A34;
|
||||
background: rgba(10, 19, 31, 0.7);
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
@@ -3672,8 +3607,6 @@ button:disabled {
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
color: #001A34;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.docs-toc-mobile > summary::-webkit-details-marker {
|
||||
@@ -3753,7 +3686,7 @@ button:disabled {
|
||||
height: 100vh;
|
||||
max-width: 100vw;
|
||||
overflow: hidden;
|
||||
background: var(--page-background);
|
||||
background: #070d16;
|
||||
}
|
||||
|
||||
.main-page--product .product-topbar,
|
||||
@@ -3768,8 +3701,8 @@ button:disabled {
|
||||
height: 68px;
|
||||
max-height: 72px;
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
background: var(--ozon-white);
|
||||
border-bottom: 1px solid rgba(56, 189, 248, 0.18);
|
||||
background: rgba(6, 12, 20, 0.88);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
@@ -3787,9 +3720,9 @@ button:disabled {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--panel-border);
|
||||
background: var(--ozon-surface-soft);
|
||||
color: var(--brand-primary);
|
||||
border: 1px solid rgba(56, 189, 248, 0.4);
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
color: #7dd3fc;
|
||||
font-weight: 800;
|
||||
font-size: 15px;
|
||||
flex: 0 0 auto;
|
||||
@@ -3799,7 +3732,7 @@ button:disabled {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
color: var(--text-primary);
|
||||
color: #e8f2ff;
|
||||
}
|
||||
|
||||
.product-brand-sub {
|
||||
@@ -3896,7 +3829,7 @@ button:disabled {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
background: var(--page-background);
|
||||
background: #070d16;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@@ -3907,8 +3840,8 @@ button:disabled {
|
||||
height: 78px;
|
||||
max-height: 84px;
|
||||
padding: 0 24px 10px;
|
||||
border-top: 1px solid var(--panel-border);
|
||||
background: var(--ozon-white);
|
||||
border-top: 1px solid rgba(56, 189, 248, 0.18);
|
||||
background: rgba(6, 12, 20, 0.9);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
@@ -3962,9 +3895,7 @@ button:disabled {
|
||||
position: static;
|
||||
min-height: 40px;
|
||||
border-radius: 10px;
|
||||
background: var(--ozon-surface-soft);
|
||||
border: 1px solid var(--panel-border);
|
||||
color: var(--text-primary);
|
||||
background: rgba(12, 22, 34, 0.9);
|
||||
}
|
||||
|
||||
.product-sim-pill {
|
||||
@@ -4011,8 +3942,9 @@ button:disabled {
|
||||
|
||||
/* Documentation desktop grid aligned to product chrome */
|
||||
.docs-page {
|
||||
background: #F5F7FA;
|
||||
color: #001A34;
|
||||
background:
|
||||
radial-gradient(circle at 10% 0%, rgba(56, 189, 248, 0.1), transparent 34%),
|
||||
linear-gradient(180deg, #07111d 0%, #050910 100%);
|
||||
}
|
||||
|
||||
.docs-layout {
|
||||
@@ -4058,89 +3990,3 @@ button:disabled {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Ozon light chrome overrides (shell / HUD / nav / controls)
|
||||
========================================================= */
|
||||
|
||||
.main-hud,
|
||||
.app-nav-overlay,
|
||||
.app-nav-bar,
|
||||
.main-progress {
|
||||
background: rgba(255, 255, 255, 0.92) !important;
|
||||
border-color: var(--panel-border) !important;
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 4px 18px rgba(0, 26, 52, 0.08);
|
||||
}
|
||||
|
||||
.play-button,
|
||||
.main-page--product .play-button {
|
||||
background: var(--brand-primary) !important;
|
||||
color: var(--ozon-white) !important;
|
||||
box-shadow: 0 4px 16px rgba(0, 91, 255, 0.28);
|
||||
}
|
||||
|
||||
.play-button:hover {
|
||||
background: var(--brand-light) !important;
|
||||
}
|
||||
|
||||
.app-nav-link.active {
|
||||
color: var(--ozon-white) !important;
|
||||
background: var(--brand-primary) !important;
|
||||
border-color: var(--brand-primary) !important;
|
||||
}
|
||||
|
||||
.app-nav-link:hover {
|
||||
color: var(--brand-primary);
|
||||
border-color: var(--panel-border);
|
||||
background: var(--ozon-surface-soft);
|
||||
}
|
||||
|
||||
.product-topbar,
|
||||
.docs-product-topbar,
|
||||
.main-page--product .product-topbar,
|
||||
.docs-page .product-topbar {
|
||||
background: var(--ozon-white) !important;
|
||||
border-bottom: 1px solid var(--panel-border) !important;
|
||||
color: var(--text-primary) !important;
|
||||
box-shadow: 0 1px 0 rgba(0, 26, 52, 0.04);
|
||||
}
|
||||
|
||||
.product-brand-title,
|
||||
.product-tele-value {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.product-brand-sub,
|
||||
.product-tele-label {
|
||||
color: var(--ozon-muted) !important;
|
||||
}
|
||||
|
||||
.main-page--product .app-nav-solid,
|
||||
.docs-page .product-topbar .app-nav-solid {
|
||||
background: var(--ozon-surface-soft) !important;
|
||||
border-color: var(--panel-border) !important;
|
||||
}
|
||||
|
||||
.product-webgl-note,
|
||||
.docs-page {
|
||||
background: var(--page-background) !important;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.product-tele-value.category-B { color: var(--status-success) !important; }
|
||||
.product-tele-value.category-C { color: var(--status-warning) !important; }
|
||||
.product-tele-value.category-D { color: var(--brand-secondary) !important; }
|
||||
|
||||
.progress-dot {
|
||||
background: var(--ozon-border);
|
||||
}
|
||||
|
||||
.main-page--product .product-dock,
|
||||
.main-page--product .main-controls .auto-camera-toggle,
|
||||
.main-page--product .presentation-toggle,
|
||||
.stop-button {
|
||||
background: var(--ozon-white) !important;
|
||||
border-color: var(--panel-border) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user