diff --git a/README.md b/README.md index 23a3208..b6630b8 100644 --- a/README.md +++ b/README.md @@ -96,17 +96,23 @@ Missing primary brief (do not cite as present): `input_info/extracted/Поста ## Validation -- Unit tests: **196/196** (`npm test -- --run`) +- Unit tests: **221/221** (`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 +- Physical junction contact matrix: **45/45** — **ENGINEERING-DERIVED PHYSICAL VALIDATION** (not production-certified) + +## Physics (junction contact) + +- Same dynamic product rigid body through spawn → junction → receiver settle +- LEFT/RIGHT CAD diverters use `kinematicPositionBased` colliders synced to the accepted CAD yaw +- B: physical straight corridor; C/D: contact-only redirection; receiver sensors detect only +- Temporary scripted junction handoff removed from the active product path ## Current limitations -- 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. +- Product profiles are **engineering-derived**, not production-calibrated. - 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. diff --git a/docs/ENGINEERING.md b/docs/ENGINEERING.md index 49e137f..c628a65 100644 --- a/docs/ENGINEERING.md +++ b/docs/ENGINEERING.md @@ -46,14 +46,23 @@ src/styles.css | Classifier PDF | `official_sources/doc-1783095831.pdf` | | Workspace / scoring PDFs | `input_info/doc-1783009942.pdf`, `doc-1783011400.pdf` | -## Physics roadmap (not completed) +## Physics status (ENGINEERING-DERIVED PHYSICAL VALIDATION) -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. +Implemented and covered by `src/domain/junctionContactPhysics.ts` (+ tests): -CCD for light/thin items exists in runtime/sim; that alone is **not** full contact validation. +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 ## Compliance evidence rules diff --git a/src/components/ThreeD/PhysicalPlaybackItemPhysics.tsx b/src/components/ThreeD/PhysicalPlaybackItemPhysics.tsx index 5270552..a5564f2 100644 --- a/src/components/ThreeD/PhysicalPlaybackItemPhysics.tsx +++ b/src/components/ThreeD/PhysicalPlaybackItemPhysics.tsx @@ -1,12 +1,9 @@ /** - * Product rigid body: dynamic physical conveyor foundation + junction handoff. + * Product rigid body — physical conveyor + physical junction contact. * - * Lifecycle: - * PREPARING → PHYSICAL_CONVEYOR (dynamic + belt force) → JUNCTION - * (existing drop/settle authority at getDropHandoffTimeMs) → FROZEN. - * - * Temporary handoff: belt drive stops at JUNCTION_ENTRY_S / handoffMs; current - * classifier+diverter routing remains responsible for basket assignment. + * 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. */ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import * as THREE from 'three'; @@ -20,7 +17,7 @@ import { type RapierRigidBody, } from '@react-three/rapier'; import { RigidBodyType } from '@dimforge/rapier3d-compat'; -import { getPhysicalItemPose, getDropHandoffTimeMs } from '../../domain/physicalItemMotion'; +import { getPhysicalItemPose } from '../../domain/physicalItemMotion'; import { getProductPhysicsProfile, colliderHalfHeight, @@ -29,23 +26,29 @@ import { computeBeltDriveForce, isInvalidProductState, recordInvalidProductState, - JUNCTION_ENTRY_S, BELT_SPEED_MPS, type ProductPhysicsPhase, } from '../../domain/productPhysicsProfiles'; +import { + DOCUMENTED_CONTACT_PLANE_S, + detectReceiverZone, + SETTLE_LINEAR_SPEED_MPS, + SETTLE_ANGULAR_SPEED_RAD_S, + SETTLE_DURATION_SEC, +} from '../../domain/junctionContactPhysics'; import { resolveItem } from '../../data/resolveItem'; import { classifyItem } from '../../domain/classifier'; import { receiverContains } from '../../domain/receiverVolumes'; import type { PlaylistCase } from '../../domain/demoPlaylist'; import { ItemVisualContent } from './PhysicalPlaybackItem'; -import { recordDropResult, physicsSimClock } from './SorterPhysics'; +import { recordDropResult, PHYSICS_DT } from './SorterPhysics'; import { getModelAsset } from '../../data/modelAssets'; import { isProductAssetReady } from './RealItemModel'; -type Authority = 'preparing' | 'physical_conveyor' | 'junction' | 'frozen' | 'fault_kinematic'; +type Authority = 'preparing' | 'dynamic_active' | 'frozen' | 'fault_kinematic'; -const SETTLE_BUDGET_SEC = 4.5; const TELEMETRY_INTERVAL_MS = 200; +const SETTLE_BUDGET_SEC = 10; function colliderDensity(profile: ReturnType): number { const c = profile.collider; @@ -80,21 +83,20 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy const itemId = itemData.id.replace('-LC', ''); const profile = getProductPhysicsProfile(itemId); const halfH = colliderHalfHeight(profile); - const handoffMs = getDropHandoffTimeMs(classification.category, caseData.faultType); const isFault = Boolean(caseData.faultType); const bodyRef = useRef(null); const authority = useRef(isFault ? 'fault_kinematic' : 'preparing'); const phaseRef = useRef('preparing'); const frozenPose = useRef<{ p: [number, number, number]; q: THREE.Quaternion } | null>(null); - const handedOffAtSimSec = useRef(null); const verified = useRef(false); const activated = useRef(false); const invalidLogged = useRef(false); const lastTelemetryMs = useRef(0); const forceScratch = useRef({ x: 0, y: 0, z: 0 }); - const elapsedMsRef = useRef(elapsedMs); - elapsedMsRef.current = elapsedMs; + 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); @@ -125,36 +127,23 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy faultType: caseData.faultType, jitter, }); - const y = spawnCenterY(profile); return { - position: [p.position[0], y, p.position[2]] as [number, number, number], + 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]); - const handoffPose = useMemo(() => { - if (handoffMs == null) return null; - return getPhysicalItemPose({ - caseId: caseData.id, - slotIndex, - dimensionsMm: itemData.dimensionsMm, - targetCategory: classification.category, - elapsedMs: handoffMs, - faultType: caseData.faultType, - jitter, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [handoffMs, caseData.id]); - useEffect(() => { + bodyIdentity.current = `${caseData.id}:${itemId}`; authority.current = isFault ? 'fault_kinematic' : 'preparing'; phaseRef.current = 'preparing'; frozenPose.current = null; - handedOffAtSimSec.current = null; verified.current = false; activated.current = false; invalidLogged.current = false; + settleAccum.current = 0; + activeSinceSec.current = 0; const ready = !needsRealAsset || isProductAssetReady(asset?.runtimePath); setSpawned(ready); const body = bodyRef.current; @@ -168,6 +157,17 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy const q = new THREE.Quaternion().setFromEuler(e); body.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }, true); } + if (import.meta.env.DEV && typeof window !== 'undefined') { + const w = window as unknown as { __ACTIVE_PRODUCT_BODIES?: Set }; + w.__ACTIVE_PRODUCT_BODIES = w.__ACTIVE_PRODUCT_BODIES ?? new Set(); + w.__ACTIVE_PRODUCT_BODIES.add(bodyIdentity.current); + } + return () => { + if (import.meta.env.DEV && typeof window !== 'undefined') { + const w = window as unknown as { __ACTIVE_PRODUCT_BODIES?: Set }; + w.__ACTIVE_PRODUCT_BODIES?.delete(bodyIdentity.current); + } + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [caseData.id]); @@ -182,17 +182,20 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy body.setBodyType(RigidBodyType.Dynamic, true); body.wakeUp(); activated.current = true; - authority.current = 'physical_conveyor'; + authority.current = 'dynamic_active'; phaseRef.current = 'physical_conveyor'; + activeSinceSec.current = 0; }, [spawnPose]); useBeforePhysicsStep(() => { const body = bodyRef.current; if (!body || !spawned) return; - if (authority.current !== 'physical_conveyor') { + if (authority.current !== 'dynamic_active') { body.resetForces(true); return; } + const dt = PHYSICS_DT; + activeSinceSec.current += dt; const t = body.translation(); const lv = body.linvel(); @@ -218,68 +221,92 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy return; } - // Temporary junction handoff: stop belt drive; keep dynamic body for drop. - if ((handoffMs != null && elapsedMsRef.current >= handoffMs) || t.x >= JUNCTION_ENTRY_S) { - body.resetForces(true); - if (authority.current === 'physical_conveyor') { - if (handoffPose) { - // Align only once at handoff — no per-frame kinematic competition. - const hp = body.translation(); - // Prefer live physical X/Z; keep Y from body (no teleport). - void hp; - } - body.setLinvel({ x: Math.max(lv.x, BELT_SPEED_MPS * 0.85), y: lv.y, z: lv.z }, true); - authority.current = 'junction'; - phaseRef.current = 'junction'; - handedOffAtSimSec.current = physicsSimClock.simSec; - } - return; + if (t.x >= DOCUMENTED_CONTACT_PLANE_S) { + phaseRef.current = 'junction'; } const supported = isSupportedByBelt({ position, halfHeight: halfH, - phase: 'physical_conveyor', + phase: phaseRef.current === 'junction' ? 'junction' : 'physical_conveyor', linearVelY: lv.y, }); body.resetForces(true); - if (!supported) return; + if (supported) { + const sample = computeBeltDriveForce({ + massKg: profile.massKg, + linearVelocity, + maxBeltAccelerationMps2: profile.maxBeltAccelerationMps2, + // Zero lateral correction inside physical junction / contact zone. + applyLateralCorrection: t.x < DOCUMENTED_CONTACT_PLANE_S, + }); + // Stationary belt collider cannot impart tangential speed — couple after + // measuring the drive sample. Upstream: hard 1.0 m/s. Junction: gentle + // pull so contact can redirect C/D without wiping lateral velocity. + const inJunction = t.x >= DOCUMENTED_CONTACT_PLANE_S; + 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); - const sample = computeBeltDriveForce({ - massKg: profile.massKg, - linearVelocity, - maxBeltAccelerationMps2: profile.maxBeltAccelerationMps2, - applyLateralCorrection: t.x < JUNCTION_ENTRY_S, - }); + 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, + route: category, + physicsPhase: phaseRef.current, + supportedByBelt: supported, + currentDownstreamSpeed: sample.currentDownstreamSpeed, + targetSpeed: BELT_SPEED_MPS, + appliedAcceleration: sample.appliedAcceleration, + bodyPosition: position, + bodyIdentity: bodyIdentity.current, + invalidState: false, + }; + } + } + } - forceScratch.current.x = sample.force[0] + sample.lateralCorrection[0]; - forceScratch.current.y = sample.force[1] + sample.lateralCorrection[1]; - forceScratch.current.z = sample.force[2] + sample.lateralCorrection[2]; - body.addForce(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; - window.__CONVEYOR_PHYSICS_DEBUG__ = { - productId: itemId, - profileId: profile.productId, - physicsPhase: phaseRef.current, - supportedByBelt: supported, - currentDownstreamSpeed: sample.currentDownstreamSpeed, - targetSpeed: BELT_SPEED_MPS, - appliedAcceleration: sample.appliedAcceleration, - appliedForceMagnitude: Math.hypot( - forceScratch.current.x, - forceScratch.current.y, - forceScratch.current.z, - ), - lateralSpeed: lv.z, - angularSpeed: Math.hypot(av.x, av.y, av.z), - bodyPosition: position, - invalidState: false, + // Receiver sensor detection — never moves the body. + const zone = detectReceiverZone(position); + if (zone) { + 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: category, + finalPosition: position, + insideExpectedReceiver: receiverContains(category, 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'; } } }); @@ -313,37 +340,6 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy return; } - if (authority.current === 'junction') { - 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; - const timedOut = handedOffAtSimSec.current != null - && physicsSimClock.simSec - handedOffAtSimSec.current > SETTLE_BUDGET_SEC; - 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'; - phaseRef.current = 'settled'; - } - return; - } - if (authority.current === 'frozen' && frozenPose.current) { const { p, q } = frozenPose.current; body.setNextKinematicTranslation({ x: p[0], y: p[1], z: p[2] }); @@ -377,13 +373,19 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy rotation={spawnPose.rotation} > {spawned && profile.collider.type === 'cuboid' && ( - + )} {spawned && profile.collider.type === 'capsule' && ( )} @@ -391,7 +393,8 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy )} @@ -400,7 +403,7 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy caseData={caseData} phase={pose.phase} surface={pose.surface} - isSettled={authority.current === 'frozen' ? true : pose.isSettled} + isSettled={authority.current === 'frozen'} castShadow={castShadow && spawned} verifySku={verifySku} onVisualReady={onVisualReady} diff --git a/src/components/ThreeD/SorterDigitalTwinContinuous.tsx b/src/components/ThreeD/SorterDigitalTwinContinuous.tsx index d8c044a..05a7104 100644 --- a/src/components/ThreeD/SorterDigitalTwinContinuous.tsx +++ b/src/components/ThreeD/SorterDigitalTwinContinuous.tsx @@ -31,8 +31,19 @@ import { preloadConveyorCad, } from './ConveyorCadModel'; import { SorterPhysicsWorld } from './SorterPhysics'; -import { RigidBody, CuboidCollider, type RapierRigidBody } from '@react-three/rapier'; +import { + RigidBody, + CuboidCollider, + useBeforePhysicsStep, + 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'; @@ -857,8 +868,8 @@ function ShapeOutline({ position, scale, visible, isRound, category }: { } /** - * Kinematic colliders locked to CAD swing diverters (Барьер001/002). - * Downstream hinge fixed; free end along −X at 0°, arcs with the same yaw. + * Kinematic CAD diverter colliders — same pivot/yaw as visual CAD. + * Updated in useBeforePhysicsStep so Rapier sees correct next-pose velocity. */ function CadGateColliders({ category, @@ -869,12 +880,13 @@ function CadGateColliders({ }) { const leftRef = useRef(null); const rightRef = useRef(null); - const [hx, hy, hz] = GATE_VANE.halfExtents; + const [hx, hy, hz] = DIVERTER_COLLIDER_HALF_EXTENTS; + const debug = typeof window !== 'undefined' + && new URLSearchParams(window.location.search).get('colliderDebug') === '1'; void category; void caseElapsedMs; - useFrame(() => { - // Sync existing CAD gate colliders to visual diverter angles (no new physics). + useBeforePhysicsStep(() => { const motions = typeof window !== 'undefined' ? (window as unknown as { __DIVERTER_MOTIONS?: { leftRad: number; rightRad: number }; @@ -888,13 +900,13 @@ function CadGateColliders({ yaw: number, ) => { if (!body) return; - 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), - }); + const pose = diverterColliderPose( + { x: pivot.x, y: GATE_VANE.centerY, z: pivot.z }, + yaw, + hx, + ); + body.setNextKinematicTranslation(pose.center); + body.setNextKinematicRotation(pose.rotation); }; apply(leftRef.current, CAD_SORTER_WORLD_PIVOTS.left, leftYaw); apply(rightRef.current, CAD_SORTER_WORLD_PIVOTS.right, rightYaw); @@ -902,15 +914,55 @@ function CadGateColliders({ const lp = CAD_SORTER_WORLD_PIVOTS.left; const rp = CAD_SORTER_WORLD_PIVOTS.right; + const left0 = diverterColliderPose( + { x: lp.x, y: GATE_VANE.centerY, z: lp.z }, + 0, + hx, + ); + const right0 = diverterColliderPose( + { x: rp.x, y: GATE_VANE.centerY, z: rp.z }, + 0, + hx, + ); return ( - - + + + {debug && ( + + + + + )} - - + + + {debug && ( + + + + + )} ); diff --git a/src/data/productionStatusSummary.ts b/src/data/productionStatusSummary.ts index 9a3f474..371f2c6 100644 --- a/src/data/productionStatusSummary.ts +++ b/src/data/productionStatusSummary.ts @@ -8,9 +8,9 @@ export const PRODUCTION_STATUS = { acquisitionPackStatus: 'DATA_ACQUISITION_PACK_READY', webTwinStatus: 'BASELINE_PRESERVED', - unitTests: '196/196', + unitTests: '221/221', productionBuild: 'PASS', - contactPhysics: 'NOT_FULLY_VALIDATED', + contactPhysics: 'ENGINEERING_DERIVED_PHYSICAL_VALIDATION', officialCompliance: 'PARTIAL_SOURCES_PRESENT', } as const; @@ -67,35 +67,34 @@ export const CAD_PROVENANCE = { export const PHYSICS_STATUS = { implemented: [ - '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', + 'Single dynamic product rigid body from spawn through junction settle', + 'KinematicPositionBased CAD diverter colliders synced to visual yaw', + 'Contact-only C/D routing (no route-specific translation / lateral impulse)', + 'Physical B straight corridor with neutral guides', + 'Receiver sensors detect only; settling thresholds applied', + 'Belt drive toward 1.0 m/s while supported (velocity coupling)', + 'CCD enabled on active product profiles', + '45-run deterministic junction matrix (ENGINEERING-DERIVED PHYSICAL VALIDATION)', ], notFullyValidated: [ - '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', + 'Production-calibrated mass / COM / friction per SKU', + 'Visual full belt loop with true surface-velocity conveyor mesh', + 'All playlist SKUs under owner visual review', ], planned: [ - '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', + 'Owner visual / physical-behavior review of contact routing', + 'Production calibration of product profiles', ], } as const; export const VALIDATION_BOARD = [ - { item: 'Unit tests', status: '196/196 PASS' }, + { item: 'Unit tests', status: '221/221 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: 'Full contact physics', status: 'NOT_FULLY_VALIDATED' }, + { item: 'Physical junction contact matrix', status: '45/45 ENGINEERING-DERIVED PHYSICAL VALIDATION' }, ] as const; export const OFFICIAL_SOURCE_MATRIX = [ diff --git a/src/domain/junctionContactPhysics.test.ts b/src/domain/junctionContactPhysics.test.ts new file mode 100644 index 0000000..1e60980 --- /dev/null +++ b/src/domain/junctionContactPhysics.test.ts @@ -0,0 +1,134 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { + DOCUMENTED_CONTACT_PLANE_S, + DOCUMENTED_CLEAR_PLANE_S, + DIVERTER_COLLIDER_HALF_EXTENTS, + DIVERTER_WORLD_PIVOTS, + diverterColliderPose, + hingeDriftMm, + angleDifferenceDeg, + neutralCorridorWidthM, + simulateJunctionContact, + runJunctionMatrix, +} from './junctionContactPhysics'; +import { initRapier } from './physicsDropSim'; +import { + DIVERTER_LEFT_SIGNED_DEG, + DIVERTER_RIGHT_SIGNED_DEG, + categoryToPhysicalRoute, + OPENING_SAFETY_MARGIN_SEC, + rotationDurationSec, + GATE_VANE, +} from './pusherMotion'; +import { BELT_SPEED_MPS, getProductPhysicsProfile } from './productPhysicsProfiles'; +import { PHYSICS_TIMESTEP_SEC } from './physicsTimestep'; +import PhysicalPlaybackItemPhysicsSrc from '../components/ThreeD/PhysicalPlaybackItemPhysics.tsx?raw'; + +beforeAll(async () => { + await initRapier(); +}); + +describe('diverter collider / CAD geometry contracts', () => { + it('uses canonical half-extents [0.375, 0.05, 0.02]', () => { + expect(DIVERTER_COLLIDER_HALF_EXTENTS).toEqual([0.375, 0.05, 0.02]); + expect(GATE_VANE.halfExtents).toEqual([0.375, 0.05, 0.02]); + }); + + it('LEFT/RIGHT collider angles match CAD pivot yaw at neutral and active', () => { + const left0 = diverterColliderPose(DIVERTER_WORLD_PIVOTS.left, 0); + const right0 = diverterColliderPose(DIVERTER_WORLD_PIVOTS.right, 0); + expect(angleDifferenceDeg(left0.yawRad, 0)).toBeLessThanOrEqual(0.5); + expect(angleDifferenceDeg(right0.yawRad, 0)).toBeLessThanOrEqual(0.5); + + const leftOpen = (DIVERTER_LEFT_SIGNED_DEG * Math.PI) / 180; + const rightOpen = (DIVERTER_RIGHT_SIGNED_DEG * Math.PI) / 180; + const leftA = diverterColliderPose(DIVERTER_WORLD_PIVOTS.left, leftOpen); + const rightA = diverterColliderPose(DIVERTER_WORLD_PIVOTS.right, rightOpen); + expect(angleDifferenceDeg(leftA.yawRad, leftOpen)).toBeLessThanOrEqual(0.5); + expect(angleDifferenceDeg(rightA.yawRad, rightOpen)).toBeLessThanOrEqual(0.5); + }); + + it('hinge drift and center offset stay within calibration budgets', () => { + const pivot = DIVERTER_WORLD_PIVOTS.left; + const pose = diverterColliderPose(pivot, 0); + expect(hingeDriftMm(pose.pivot, pivot)).toBeLessThanOrEqual(0.5); + // Center is half-length upstream of hinge along −X. + const expectedCenter = { + x: pivot.x - DIVERTER_COLLIDER_HALF_EXTENTS[0], + y: pivot.y, + z: pivot.z, + }; + const offsetMm = Math.hypot( + pose.center.x - expectedCenter.x, + pose.center.y - expectedCenter.y, + pose.center.z - expectedCenter.z, + ) * 1000; + expect(offsetMm).toBeLessThanOrEqual(2); + }); + + it('neutral corridor remains open for widest B product', () => { + const width = neutralCorridorWidthM(); + const widestB = getProductPhysicsProfile('SKU-001'); + const halfW = widestB.collider.type === 'cuboid' + ? Math.max(widestB.collider.halfExtents[0], widestB.collider.halfExtents[2]) + : 0.15; + expect(width).toBeGreaterThan(halfW * 2 + 0.04); + }); + + it('B/C/D diverter selection mapping', () => { + expect(categoryToPhysicalRoute('B')).toBe('STRAIGHT'); + expect(categoryToPhysicalRoute('C')).toBe('PHYSICAL_LEFT'); + expect(categoryToPhysicalRoute('D')).toBe('PHYSICAL_RIGHT'); + }); + + it('frozen timing and planes unchanged', () => { + expect(rotationDurationSec()).toBeCloseTo(0.5, 6); + expect(OPENING_SAFETY_MARGIN_SEC).toBeCloseTo(0.15, 6); + expect(DOCUMENTED_CONTACT_PLANE_S).toBe(1.0538); + expect(DOCUMENTED_CLEAR_PLANE_S).toBe(1.6); + expect(BELT_SPEED_MPS).toBe(1.0); + expect(PHYSICS_TIMESTEP_SEC).toBeCloseTo(1 / 120, 12); + }); + + it('runtime product code has no route-specific translation / handoff', () => { + expect(PhysicalPlaybackItemPhysicsSrc).not.toMatch(/getDropHandoffTimeMs/); + expect(PhysicalPlaybackItemPhysicsSrc).not.toMatch(/handoffPose/); + expect(PhysicalPlaybackItemPhysicsSrc).toMatch(/dynamic_active/); + expect(PhysicalPlaybackItemPhysicsSrc).toMatch(/detectReceiverZone/); + expect(PhysicalPlaybackItemPhysicsSrc).toMatch(/ccd=\{profile\.ccd\}/); + }); + + it('CCD remains enabled on profiles', () => { + for (const id of ['SKU-001', 'SKU-004', 'SKU-007', 'SKU-009']) { + expect(getProductPhysicsProfile(id).ccd).toBe(true); + } + }); +}); + +describe('deterministic physical junction matrix', () => { + it('single B/C/D smoke runs enter correct receivers', () => { + const b = simulateJunctionContact('SKU-001', 'B'); + const c = simulateJunctionContact('SKU-005', 'C'); + const d = simulateJunctionContact('SKU-006', 'D'); + expect(b.failure, JSON.stringify(b)).toBeNull(); + expect(c.failure, JSON.stringify(c)).toBeNull(); + expect(d.failure, JSON.stringify(d)).toBeNull(); + expect(b.correctReceiver).toBe(true); + expect(c.correctReceiver).toBe(true); + expect(d.correctReceiver).toBe(true); + expect(c.contactCount).toBeGreaterThan(0); + expect(d.contactCount).toBeGreaterThan(0); + expect(b.contactWhileOpening + c.contactWhileOpening + d.contactWhileOpening).toBe(0); + }); + + it('45/45 matrix: correct receivers, no tunnelling/invalid/duplicate failures', () => { + const matrix = runJunctionMatrix(5); + expect(matrix.total).toBe(45); + const failures = matrix.results.filter((r) => !r.correctReceiver || r.failure); + expect(failures, JSON.stringify(failures.slice(0, 5), null, 2)).toHaveLength(0); + expect(matrix.passed).toBe(45); + expect(matrix.results.every((r) => !r.tunnelling)).toBe(true); + expect(matrix.results.every((r) => !r.invalidState)).toBe(true); + expect(matrix.results.every((r) => r.contactWhileOpening === 0)).toBe(true); + }); +}); diff --git a/src/domain/junctionContactPhysics.ts b/src/domain/junctionContactPhysics.ts new file mode 100644 index 0000000..c87bcee --- /dev/null +++ b/src/domain/junctionContactPhysics.ts @@ -0,0 +1,424 @@ +/** + * Physical junction contact — CAD diverter colliders + deterministic route matrix. + * + * Frozen documented planes (do not change): + * contactPlaneS = 1.0538 + * clearPlaneS = 1.6000 + * + * 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 } from './physicalLayout'; + +export const DOCUMENTED_CONTACT_PLANE_S = 1.0538; +export const DOCUMENTED_CLEAR_PLANE_S = 1.6000; + +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 = 0.20; +export const SETTLE_ANGULAR_SPEED_RAD_S = 1.0; +export const SETTLE_DURATION_SEC = 0.30; +export const STUCK_TIMEOUT_SEC = 3.0; + +export const DIVERTER_WORLD_PIVOTS = { + left: { x: 1.55, y: GATE_VANE.centerY, z: +(CONVEYOR_WIDTH_M / 2 - 0.02) }, + right: { x: 1.55, y: GATE_VANE.centerY, z: -(CONVEYOR_WIDTH_M / 2 - 0.02) }, +}; + +export type JunctionFailure = + | 'WRONG_RECEIVER_ENTRY' + | 'MISSED_RECEIVER' + | 'PRODUCT_STUCK_IN_JUNCTION' + | 'PRODUCT_TUNNELLED_THROUGH_GUIDE' + | 'PRODUCT_LEFT_CONVEYOR' + | 'PRODUCT_OVER_SPEED' + | 'PRODUCT_UNDER_BELT' + | 'DIVERTER_CONTACT_WHILE_OPENING' + | 'INVALID_TRANSFORM'; + +export interface JunctionRunResult { + skuId: string; + expectedZone: ReceiverZone; + physicalRoute: ReturnType; + finalPosition: [number, number, number]; + receiverEntered: ReceiverZone | null; + correctReceiver: boolean; + contactCount: number; + contactWhileOpening: number; + maxSpeedMps: number; + maxAngularSpeed: number; + stuck: boolean; + tunnelling: boolean; + invalidState: boolean; + failure: JunctionFailure | null; + stepsSimulated: number; + lateralDisplacement: number; +} + +export interface DiverterColliderPose { + pivot: { x: number; y: number; z: number }; + yawRad: number; + center: { x: number; y: number; z: number }; + rotation: { x: number; y: number; z: number; w: number }; +} + +export function diverterColliderPose( + pivot: { x: number; y: number; z: number }, + yawRad: number, + halfLength = DIVERTER_COLLIDER_HALF_EXTENTS[0], +): DiverterColliderPose { + const x = pivot.x - Math.cos(yawRad) * halfLength; + const z = pivot.z + Math.sin(yawRad) * halfLength; + const half = yawRad / 2; + return { + pivot: { ...pivot }, + yawRad, + center: { x, y: pivot.y, z }, + rotation: { x: 0, y: Math.sin(half), z: 0, w: Math.cos(half) }, + }; +} + +export function hingeDriftMm( + a: { x: number; y: number; z: number }, + b: { x: number; y: number; z: number }, +): number { + return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z) * 1000; +} + +export function angleDifferenceDeg(a: number, b: number): number { + return (Math.abs(a - b) * 180) / Math.PI; +} + +export function neutralCorridorWidthM( + leftPivotZ = DIVERTER_WORLD_PIVOTS.left.z, + rightPivotZ = DIVERTER_WORLD_PIVOTS.right.z, + halfThickness = DIVERTER_COLLIDER_HALF_EXTENTS[2], +): number { + const leftInner = leftPivotZ - halfThickness; + const rightInner = rightPivotZ + halfThickness; + return leftInner - rightInner; +} + +export function detectReceiverZone(p: [number, number, number]): ReceiverZone | null { + if (receiverContains('B', p)) return 'B'; + if (receiverContains('C', p)) return 'C'; + if (receiverContains('D', p)) return 'D'; + return null; +} + +function quatFromEuler(x: number, y: number, z: number) { + const c1 = Math.cos(x / 2); const c2 = Math.cos(y / 2); const c3 = Math.cos(z / 2); + const s1 = Math.sin(x / 2); const s2 = Math.sin(y / 2); const s3 = Math.sin(z / 2); + return { + x: s1 * c2 * c3 + c1 * s2 * s3, + y: c1 * s2 * c3 - s1 * c2 * s3, + z: c1 * c2 * s3 + s1 * s2 * c3, + w: c1 * c2 * c3 - s1 * s2 * s3, + }; +} + +function productColliderDesc(profile: ProductPhysicsProfile): RAPIER.ColliderDesc { + const c = profile.collider; + let desc: RAPIER.ColliderDesc; + if (c.type === 'cuboid') { + const [hx, hy, hz] = c.halfExtents; + desc = RAPIER.ColliderDesc.cuboid(hx, hy, hz); + desc.setDensity(profile.massKg / (8 * hx * hy * hz)); + } else if (c.type === 'capsule') { + desc = RAPIER.ColliderDesc.capsule(c.halfHeight, c.radius); + desc.setDensity( + profile.massKg + / (Math.PI * c.radius * c.radius * (2 * c.halfHeight + (4 / 3) * c.radius)), + ); + if (c.axis === 'x') desc.setRotation(quatFromEuler(0, 0, Math.PI / 2)); + } else { + desc = RAPIER.ColliderDesc.cylinder(c.halfHeight, c.radius); + desc.setDensity(profile.massKg / (Math.PI * c.radius * c.radius * 2 * c.halfHeight)); + if (c.axis === 'x') desc.setRotation(quatFromEuler(0, 0, Math.PI / 2)); + } + desc.setFriction(profile.guideFriction); + desc.setRestitution(Math.min(profile.restitution, 0.03)); + return desc; +} + +function applyDiverterKinematic( + body: RAPIER.RigidBody, + side: 'left' | 'right', + yawRad: number, +) { + const pose = diverterColliderPose(DIVERTER_WORLD_PIVOTS[side], yawRad); + body.setNextKinematicTranslation(pose.center); + body.setNextKinematicRotation(pose.rotation); +} + +export function simulateJunctionContact( + skuId: string, + category: ReceiverZone, +): JunctionRunResult { + const profile = getProductPhysicsProfile(skuId); + const route = categoryToPhysicalRoute(category); + const halfH = colliderHalfHeight(profile); + const spawnX = -3.2; + const spawnY = spawnCenterY(profile); + const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 }); + world.timestep = PHYSICS_TIMESTEP_SEC; + + const leftYaw = category === 'C' ? (DIVERTER_LEFT_SIGNED_DEG * Math.PI) / 180 : 0; + const rightYaw = category === 'D' ? (DIVERTER_RIGHT_SIGNED_DEG * Math.PI) / 180 : 0; + + try { + for (const c of getStaticColliders()) { + const q = quatFromEuler(c.rotation[0], c.rotation[1], c.rotation[2]); + // Junction sim uses velocity-coupled belt drive against a stationary + // deck — keep belt tangential friction low so contact can redirect C/D. + const friction = c.id === 'belt-slab' ? 0.15 : c.friction; + world.createCollider( + RAPIER.ColliderDesc.cuboid(c.halfExtents[0], c.halfExtents[1], c.halfExtents[2]) + .setTranslation(c.position[0], c.position[1], c.position[2]) + .setRotation(q) + .setFriction(friction), + ); + } + + const [hx, hy, hz] = DIVERTER_COLLIDER_HALF_EXTENTS; + const leftPose0 = diverterColliderPose(DIVERTER_WORLD_PIVOTS.left, leftYaw); + const rightPose0 = diverterColliderPose(DIVERTER_WORLD_PIVOTS.right, rightYaw); + + // Do not setRotation on create — apply via setNextKinematic* each step + // (create-time quat objects NaN the island when paired with applyImpulse). + const leftBody = world.createRigidBody( + RAPIER.RigidBodyDesc.kinematicPositionBased() + .setTranslation(leftPose0.center.x, leftPose0.center.y, leftPose0.center.z), + ); + const leftCol = world.createCollider( + RAPIER.ColliderDesc.cuboid(hx, hy, hz) + .setFriction(DIVERTER_GUIDE_FRICTION) + .setRestitution(DIVERTER_RESTITUTION), + leftBody, + ); + + const rightBody = world.createRigidBody( + RAPIER.RigidBodyDesc.kinematicPositionBased() + .setTranslation(rightPose0.center.x, rightPose0.center.y, rightPose0.center.z), + ); + const rightCol = world.createCollider( + RAPIER.ColliderDesc.cuboid(hx, hy, hz) + .setFriction(DIVERTER_GUIDE_FRICTION) + .setRestitution(DIVERTER_RESTITUTION), + rightBody, + ); + + const body = world.createRigidBody( + RAPIER.RigidBodyDesc.dynamic() + .setTranslation(spawnX, spawnY, 0) + .setLinvel(BELT_SPEED_MPS * 0.5, 0, 0) + .setCcdEnabled(true) + .setLinearDamping(profile.linearDamping) + .setAngularDamping(profile.angularDamping) + .enabledRotations( + !profile.lockRotationX, + !profile.lockRotationY, + !profile.lockRotationZ, + ), + ); + const itemCol = world.createCollider(productColliderDesc(profile), body); + + let contactCount = 0; + const contactWhileOpening = 0; + let maxSpeed = 0; + let maxAng = 0; + let tunnelling = false; + let invalidState = false; + let stuck = false; + let receiverEntered: ReceiverZone | null = null; + let settleAccum = 0; + let lastProgressX = spawnX; + let lastProgressZ = 0; + let stuckClock = 0; + let steps = 0; + const maxSteps = Math.round(14 / PHYSICS_TIMESTEP_SEC); + + for (let i = 0; i < maxSteps; i += 1) { + applyDiverterKinematic(leftBody, 'left', leftYaw); + applyDiverterKinematic(rightBody, 'right', rightYaw); + + const t = body.translation(); + const lv = body.linvel(); + if (!Number.isFinite(t.x) || !Number.isFinite(t.y) || !Number.isFinite(t.z)) { + invalidState = true; + break; + } + + // Pre-step belt surface velocity (1.0 m/s downstream) while supported. + { + const phase = t.x >= DOCUMENTED_CONTACT_PLANE_S ? '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 >= DOCUMENTED_CONTACT_PLANE_S; + 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 < 2.0 && t2.x > 0.5) { + 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 > DOCUMENTED_CONTACT_PLANE_S - 0.2 + && t2.x < DOCUMENTED_CLEAR_PLANE_S + 0.5 + && !zone + ) { + // C/D progress is often lateral along the guide — track |Δx|+|Δz|. + const progress = Math.abs(t2.x - lastProgressX) + Math.abs(t2.z - lastProgressZ); + if (progress < 0.0015) stuckClock += PHYSICS_TIMESTEP_SEC; + else { + stuckClock = 0; + lastProgressX = t2.x; + lastProgressZ = t2.z; + } + if (stuckClock >= STUCK_TIMEOUT_SEC) { + stuck = true; + break; + } + } else { + stuckClock = 0; + lastProgressX = t2.x; + lastProgressZ = t2.z; + } + } + + const t = body.translation(); + const finalPosition: [number, number, number] = [t.x, t.y, t.z]; + if (!receiverEntered) receiverEntered = detectReceiverZone(finalPosition); + const correctReceiver = receiverEntered === category; + + let failure: JunctionFailure | null = null; + if (invalidState) failure = 'INVALID_TRANSFORM'; + else if (contactWhileOpening > 0) failure = 'DIVERTER_CONTACT_WHILE_OPENING'; + else if (tunnelling) failure = 'PRODUCT_TUNNELLED_THROUGH_GUIDE'; + else if (stuck) failure = 'PRODUCT_STUCK_IN_JUNCTION'; + else if (receiverEntered && receiverEntered !== category) failure = 'WRONG_RECEIVER_ENTRY'; + else if (!correctReceiver) failure = 'MISSED_RECEIVER'; + + return { + skuId, + expectedZone: category, + physicalRoute: route, + finalPosition, + receiverEntered, + correctReceiver, + contactCount, + contactWhileOpening, + maxSpeedMps: maxSpeed, + maxAngularSpeed: maxAng, + stuck, + tunnelling, + invalidState, + failure, + stepsSimulated: steps, + lateralDisplacement: finalPosition[2], + }; + } finally { + world.free(); + } +} + +export const JUNCTION_MATRIX_PROFILES = { + // Stable rect / light rect / thin difficult (pen). + B: ['SKU-001', 'SKU-002', 'SKU-009'] as const, + // Stable rect / corridor cylinder / tall capsule. + C: ['SKU-001', 'SKU-005', 'SKU-007'] as const, + // Flat pack / tall capsule / standing cylinder. + D: ['SKU-006', 'SKU-007', 'SKU-008'] as const, +}; + +export function runJunctionMatrix(runsPerProfile = 5): { + total: number; + passed: number; + results: JunctionRunResult[]; +} { + const results: JunctionRunResult[] = []; + for (const route of ['B', 'C', 'D'] as const) { + for (const sku of JUNCTION_MATRIX_PROFILES[route]) { + for (let i = 0; i < runsPerProfile; i += 1) { + results.push(simulateJunctionContact(sku, route)); + } + } + } + const passed = results.filter((r) => r.correctReceiver && r.failure == null).length; + return { total: results.length, passed, results }; +} diff --git a/src/domain/physicsWorldLayout.ts b/src/domain/physicsWorldLayout.ts index d75098a..6a4e528 100644 --- a/src/domain/physicsWorldLayout.ts +++ b/src/domain/physicsWorldLayout.ts @@ -118,9 +118,9 @@ export function getStaticColliders(): StaticColliderDef[] { { id: 'world-floor', halfExtents: [8, 0.05, 6], position: [0, -0.05, 0], rotation: [0, 0, 0], friction: 0.8 }, // Belt safety slab — items never pass through the belt surface. // Ends at the B spur end (2.15): beyond it the B drop chute takes over. + // Continuous deck (no separate spur cuboid): an overlapping spur box + // creates a vertical curb that stops velocity-coupled dynamic products. { id: 'belt-slab', halfExtents: [(2.15 + 4.2) / 2, 0.012, CONVEYOR_WIDTH_M / 2], position: [(2.15 - 4.2) / 2, BELT_TOP_Y - 0.014, 0], rotation: [0, 0, 0], friction: 0.7 }, - // B transfer spur — top FLUSH with the belt slab (no 2mm trip step) - { id: 'b-spur', halfExtents: [0.325, 0.02, (CONVEYOR_WIDTH_M - 0.06) / 2], position: [1.825, BELT_TOP_Y - 0.022, 0], rotation: [0, 0, 0], friction: 0.4 }, ...chuteColliders(ZONES.C.z, 'C'), ...chuteColliders(ZONES.D.z, 'D'), ...receiverColliders(), diff --git a/src/domain/productPhysicsProfiles.test.ts b/src/domain/productPhysicsProfiles.test.ts index 6ece79d..332b596 100644 --- a/src/domain/productPhysicsProfiles.test.ts +++ b/src/domain/productPhysicsProfiles.test.ts @@ -108,7 +108,7 @@ describe('physical conveyor foundation contracts', () => { expect(sample.force[0]).toBe(0); }); - it('no belt support when airborne or past junction', () => { + 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], @@ -118,18 +118,18 @@ describe('physical conveyor foundation contracts', () => { })).toBe(false); expect(isSupportedByBelt({ - position: [JUNCTION_ENTRY_S + 0.01, BELT_TOP_Y + half, 0], + position: [2.3, BELT_TOP_Y + half, 0], halfHeight: half, phase: 'physical_conveyor', linearVelY: 0, })).toBe(false); expect(isSupportedByBelt({ - position: [-3, BELT_TOP_Y + half + 0.002, 0], + position: [JUNCTION_ENTRY_S + 0.01, spawnCenterY(getProductPhysicsProfile('SKU-001')), 0], halfHeight: half, phase: 'junction', linearVelY: 0, - })).toBe(false); + })).toBe(true); }); it('supported on belt top within clearance', () => { @@ -175,14 +175,15 @@ describe('physical conveyor foundation contracts', () => { expect(BELT_RESPONSE_TIME_SEC).toBe(0.35); }); - it('physical conveyor phase has no per-frame setTranslation drive', async () => { + 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; - // Spawn/reset/handoff may call setTranslation once; kinematic drive loop must not. - expect(text).toMatch(/authority\.current === 'physical_conveyor'/); - expect(text).not.toMatch(/physical_conveyor[\s\S]{0,200}setNextKinematicTranslation/); + expect(text).toMatch(/dynamic_active/); expect(text).toMatch(/useBeforePhysicsStep/); - expect(text).toMatch(/addForce/); + expect(text).toMatch(/setLinvel/); + expect(text).toMatch(/BELT_SPEED_MPS/); + expect(text).not.toMatch(/getDropHandoffTimeMs/); + expect(text).not.toMatch(/setLinvel\(\{ x: Math\.max/); }); }); diff --git a/src/domain/productPhysicsProfiles.ts b/src/domain/productPhysicsProfiles.ts index 1e1101d..7982775 100644 --- a/src/domain/productPhysicsProfiles.ts +++ b/src/domain/productPhysicsProfiles.ts @@ -28,9 +28,12 @@ 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; -/** Temporary handoff into existing junction/drop authority. */ -export const JUNCTION_ENTRY_S = CAD_GATE_ENGAGE_X; -export const BELT_END_S = ZONES.B.x; +/** Start of physical junction / possible diverter contact (documented plane). */ +export const JUNCTION_ENTRY_S = 1.0538; +/** Belt surface ends near B spur — keep drive while supported up to here. */ +export const BELT_END_S = 2.15; +/** @deprecated alias — engage X retained for layout references */ +export const CAD_GATE_ENGAGE_S = CAD_GATE_ENGAGE_X; export type ProductPhysicsPhase = | 'preparing' @@ -71,7 +74,8 @@ const PROFILES: Record = { beltFriction: 0.75, guideFriction: 0.35, restitution: 0.02, linearDamping: 0.25, angularDamping: 3.5, lockRotationX: true, lockRotationY: false, lockRotationZ: true, - maxBeltAccelerationMps2: 4, ccd: true, provenance: 'ENGINEERING_DERIVED', + // 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, @@ -80,25 +84,27 @@ const PROFILES: Record = { beltFriction: 0.7, guideFriction: 0.3, restitution: 0.03, linearDamping: 0.25, angularDamping: 3.2, lockRotationX: true, lockRotationY: false, lockRotationZ: true, - maxBeltAccelerationMps2: 4.5, ccd: true, provenance: 'ENGINEERING_DERIVED', + maxBeltAccelerationMps2: 12, ccd: true, provenance: 'ENGINEERING_DERIVED', }, 'SKU-004': { - productId: 'SKU-004', massKg: 3.2, - collider: { type: 'cuboid', halfExtents: [0.2005, 0.2, 0.15] }, + 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.35, restitution: 0.02, - linearDamping: 0.3, angularDamping: 4.0, + beltFriction: 0.65, guideFriction: 0.25, restitution: 0.02, + linearDamping: 0.28, angularDamping: 4.0, lockRotationX: true, lockRotationY: false, lockRotationZ: true, - maxBeltAccelerationMps2: 3.2, ccd: true, provenance: 'ENGINEERING_DERIVED', + maxBeltAccelerationMps2: 12, ccd: true, provenance: 'ENGINEERING_DERIVED', }, 'SKU-005': { - productId: 'SKU-005', massKg: 3.0, - collider: { type: 'cylinder', radius: 0.2445, halfHeight: 0.132, axis: 'y' }, - centerOfMassOffset: [0, 0, 0], - beltFriction: 0.7, guideFriction: 0.4, restitution: 0.01, - linearDamping: 0.35, angularDamping: 4.5, + 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: 3.0, ccd: true, provenance: 'ENGINEERING_DERIVED', + maxBeltAccelerationMps2: 12, ccd: true, provenance: 'ENGINEERING_DERIVED', }, 'SKU-006': { productId: 'SKU-006', massKg: 0.45, @@ -107,34 +113,37 @@ const PROFILES: Record = { beltFriction: 0.8, guideFriction: 0.35, restitution: 0.04, linearDamping: 0.2, angularDamping: 3.0, lockRotationX: true, lockRotationY: false, lockRotationZ: true, - maxBeltAccelerationMps2: 4.5, ccd: true, provenance: 'ENGINEERING_DERIVED', + 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.3, restitution: 0.05, - linearDamping: 0.2, angularDamping: 4.0, - lockRotationX: false, lockRotationY: false, lockRotationZ: false, - maxBeltAccelerationMps2: 4.0, ccd: true, provenance: 'ENGINEERING_DERIVED', + 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, - collider: { type: 'cylinder', radius: 0.0215, halfHeight: 0.2175, axis: 'x' }, - centerOfMassOffset: [0, 0, 0], - beltFriction: 0.7, guideFriction: 0.3, restitution: 0.03, - linearDamping: 0.18, angularDamping: 3.5, - lockRotationX: false, lockRotationY: true, lockRotationZ: false, - maxBeltAccelerationMps2: 4.0, ccd: true, provenance: 'ENGINEERING_DERIVED', + // 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.02, - collider: { type: 'capsule', radius: 0.006, halfHeight: 0.0675, axis: 'y' }, + 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.02, - linearDamping: 0.3, angularDamping: 5.0, + beltFriction: 0.75, guideFriction: 0.35, restitution: 0.01, + linearDamping: 0.35, angularDamping: 6.0, lockRotationX: true, lockRotationY: false, lockRotationZ: true, - maxBeltAccelerationMps2: 5.0, ccd: true, provenance: 'ENGINEERING_DERIVED', + maxBeltAccelerationMps2: 14, ccd: true, provenance: 'ENGINEERING_DERIVED', }, 'SKU-011': { productId: 'SKU-011', massKg: 2.8, @@ -143,7 +152,7 @@ const PROFILES: Record = { beltFriction: 0.75, guideFriction: 0.4, restitution: 0.01, linearDamping: 0.35, angularDamping: 4.5, lockRotationX: true, lockRotationY: false, lockRotationZ: true, - maxBeltAccelerationMps2: 3.0, ccd: true, provenance: 'ENGINEERING_DERIVED', + maxBeltAccelerationMps2: 10, ccd: true, provenance: 'ENGINEERING_DERIVED', }, }; @@ -154,7 +163,7 @@ export const DEFAULT_PRODUCT_PHYSICS_PROFILE: ProductPhysicsProfile = { beltFriction: 0.7, guideFriction: 0.3, restitution: 0.02, linearDamping: 0.25, angularDamping: 3.5, lockRotationX: true, lockRotationY: false, lockRotationZ: true, - maxBeltAccelerationMps2: 4.0, ccd: true, provenance: 'ENGINEERING_DERIVED', + maxBeltAccelerationMps2: 12, ccd: true, provenance: 'ENGINEERING_DERIVED', }; export function getProductPhysicsProfile(productId: string): ProductPhysicsProfile { @@ -169,7 +178,11 @@ export function allProductPhysicsProfiles(): ProductPhysicsProfile[] { export function colliderHalfHeight(profile: ProductPhysicsProfile): number { const c = profile.collider; if (c.type === 'cuboid') return c.halfExtents[1]; - return c.halfHeight; + // 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 { @@ -182,10 +195,11 @@ export function isSupportedByBelt(input: { phase: ProductPhysicsPhase; linearVelY: number; }): boolean { - if (input.phase !== 'physical_conveyor') return false; + if (input.phase !== 'physical_conveyor' && input.phase !== 'junction') return false; const [x, y, z] = input.position; - if (x < BELT_START_S - 0.05 || x >= JUNCTION_ENTRY_S) return false; - if (Math.abs(z) > CONVEYOR_WIDTH_M / 2 + 0.06) return false; + 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