feat: add physical junction contact routing
Replace scripted junction handoff with kinematic CAD diverter colliders and contact-only B/C/D routing, validated by a 45-run engineering-derived matrix. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
14
README.md
14
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<typeof getProductPhysicsProfile>): 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<RapierRigidBody>(null);
|
||||
const authority = useRef<Authority>(isFault ? 'fault_kinematic' : 'preparing');
|
||||
const phaseRef = useRef<ProductPhysicsPhase>('preparing');
|
||||
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 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<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
|
||||
}, [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' && (
|
||||
<CuboidCollider args={profile.collider.halfExtents} density={density} friction={profile.beltFriction} />
|
||||
<CuboidCollider
|
||||
args={profile.collider.halfExtents}
|
||||
density={density}
|
||||
friction={profile.guideFriction}
|
||||
restitution={profile.restitution}
|
||||
/>
|
||||
)}
|
||||
{spawned && profile.collider.type === 'capsule' && (
|
||||
<CapsuleCollider
|
||||
args={[profile.collider.halfHeight, profile.collider.radius]}
|
||||
density={density}
|
||||
friction={profile.beltFriction}
|
||||
friction={profile.guideFriction}
|
||||
restitution={profile.restitution}
|
||||
rotation={profile.collider.axis === 'x' ? [0, 0, Math.PI / 2] : undefined}
|
||||
/>
|
||||
)}
|
||||
@@ -391,7 +393,8 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
|
||||
<CylinderCollider
|
||||
args={[profile.collider.halfHeight, profile.collider.radius]}
|
||||
density={density}
|
||||
friction={profile.beltFriction}
|
||||
friction={profile.guideFriction}
|
||||
restitution={profile.restitution}
|
||||
rotation={profile.collider.axis === 'x' ? [0, 0, Math.PI / 2] : undefined}
|
||||
/>
|
||||
)}
|
||||
@@ -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}
|
||||
|
||||
@@ -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<RapierRigidBody>(null);
|
||||
const rightRef = useRef<RapierRigidBody>(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 (
|
||||
<group name="cad-gate-colliders">
|
||||
<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
|
||||
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>
|
||||
<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
|
||||
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>
|
||||
</group>
|
||||
);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
134
src/domain/junctionContactPhysics.test.ts
Normal file
134
src/domain/junctionContactPhysics.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
424
src/domain/junctionContactPhysics.ts
Normal file
424
src/domain/junctionContactPhysics.ts
Normal file
@@ -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<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 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 };
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, ProductPhysicsProfile> = {
|
||||
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<string, ProductPhysicsProfile> = {
|
||||
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<string, ProductPhysicsProfile> = {
|
||||
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<string, ProductPhysicsProfile> = {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user