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
|
## Validation
|
||||||
|
|
||||||
- Unit tests: **196/196** (`npm test -- --run`)
|
- Unit tests: **221/221** (`npm test -- --run`)
|
||||||
- Production build: **PASS** (`npm run build`)
|
- Production build: **PASS** (`npm run build`)
|
||||||
- Two-page routing: `/` + `/documentation`
|
- Two-page routing: `/` + `/documentation`
|
||||||
- CAD / GLB checksums: verified against values above
|
- CAD / GLB checksums: verified against values above
|
||||||
- Diverter frozen angles / duration: covered by unit tests
|
- 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
|
## Current limitations
|
||||||
|
|
||||||
- Full contact-only sorting through CAD diverters is **not fully validated**.
|
- Product profiles are **engineering-derived**, not production-calibrated.
|
||||||
- Belt surface-velocity physics (true 1 m/s tangential drive) is **planned**, not complete.
|
|
||||||
- Per-SKU mass / COM / friction profiles still need calibration.
|
|
||||||
- Author CAD horn / complete transmission is absent or incomplete in the active GLB (`AUTHOR_CAD_INCOMPLETE`).
|
- 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.
|
- 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.
|
- 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` |
|
| Classifier PDF | `official_sources/doc-1783095831.pdf` |
|
||||||
| Workspace / scoring PDFs | `input_info/doc-1783009942.pdf`, `doc-1783011400.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.
|
Implemented and covered by `src/domain/junctionContactPhysics.ts` (+ tests):
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
## 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:
|
* Single dynamic body from spawn through receiver settle.
|
||||||
* PREPARING → PHYSICAL_CONVEYOR (dynamic + belt force) → JUNCTION
|
* C/D redirection is contact-only against kinematic CAD diverter colliders.
|
||||||
* (existing drop/settle authority at getDropHandoffTimeMs) → FROZEN.
|
* No junction setTranslation / route-specific lateral impulses.
|
||||||
*
|
|
||||||
* Temporary handoff: belt drive stops at JUNCTION_ENTRY_S / handoffMs; current
|
|
||||||
* classifier+diverter routing remains responsible for basket assignment.
|
|
||||||
*/
|
*/
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
@@ -20,7 +17,7 @@ import {
|
|||||||
type RapierRigidBody,
|
type RapierRigidBody,
|
||||||
} from '@react-three/rapier';
|
} from '@react-three/rapier';
|
||||||
import { RigidBodyType } from '@dimforge/rapier3d-compat';
|
import { RigidBodyType } from '@dimforge/rapier3d-compat';
|
||||||
import { getPhysicalItemPose, getDropHandoffTimeMs } from '../../domain/physicalItemMotion';
|
import { getPhysicalItemPose } from '../../domain/physicalItemMotion';
|
||||||
import {
|
import {
|
||||||
getProductPhysicsProfile,
|
getProductPhysicsProfile,
|
||||||
colliderHalfHeight,
|
colliderHalfHeight,
|
||||||
@@ -29,23 +26,29 @@ import {
|
|||||||
computeBeltDriveForce,
|
computeBeltDriveForce,
|
||||||
isInvalidProductState,
|
isInvalidProductState,
|
||||||
recordInvalidProductState,
|
recordInvalidProductState,
|
||||||
JUNCTION_ENTRY_S,
|
|
||||||
BELT_SPEED_MPS,
|
BELT_SPEED_MPS,
|
||||||
type ProductPhysicsPhase,
|
type ProductPhysicsPhase,
|
||||||
} from '../../domain/productPhysicsProfiles';
|
} 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 { resolveItem } from '../../data/resolveItem';
|
||||||
import { classifyItem } from '../../domain/classifier';
|
import { classifyItem } from '../../domain/classifier';
|
||||||
import { receiverContains } from '../../domain/receiverVolumes';
|
import { receiverContains } from '../../domain/receiverVolumes';
|
||||||
import type { PlaylistCase } from '../../domain/demoPlaylist';
|
import type { PlaylistCase } from '../../domain/demoPlaylist';
|
||||||
import { ItemVisualContent } from './PhysicalPlaybackItem';
|
import { ItemVisualContent } from './PhysicalPlaybackItem';
|
||||||
import { recordDropResult, physicsSimClock } from './SorterPhysics';
|
import { recordDropResult, PHYSICS_DT } from './SorterPhysics';
|
||||||
import { getModelAsset } from '../../data/modelAssets';
|
import { getModelAsset } from '../../data/modelAssets';
|
||||||
import { isProductAssetReady } from './RealItemModel';
|
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 TELEMETRY_INTERVAL_MS = 200;
|
||||||
|
const SETTLE_BUDGET_SEC = 10;
|
||||||
|
|
||||||
function colliderDensity(profile: ReturnType<typeof getProductPhysicsProfile>): number {
|
function colliderDensity(profile: ReturnType<typeof getProductPhysicsProfile>): number {
|
||||||
const c = profile.collider;
|
const c = profile.collider;
|
||||||
@@ -80,21 +83,20 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
|
|||||||
const itemId = itemData.id.replace('-LC', '');
|
const itemId = itemData.id.replace('-LC', '');
|
||||||
const profile = getProductPhysicsProfile(itemId);
|
const profile = getProductPhysicsProfile(itemId);
|
||||||
const halfH = colliderHalfHeight(profile);
|
const halfH = colliderHalfHeight(profile);
|
||||||
const handoffMs = getDropHandoffTimeMs(classification.category, caseData.faultType);
|
|
||||||
const isFault = Boolean(caseData.faultType);
|
const isFault = Boolean(caseData.faultType);
|
||||||
|
|
||||||
const bodyRef = useRef<RapierRigidBody>(null);
|
const bodyRef = useRef<RapierRigidBody>(null);
|
||||||
const authority = useRef<Authority>(isFault ? 'fault_kinematic' : 'preparing');
|
const authority = useRef<Authority>(isFault ? 'fault_kinematic' : 'preparing');
|
||||||
const phaseRef = useRef<ProductPhysicsPhase>('preparing');
|
const phaseRef = useRef<ProductPhysicsPhase>('preparing');
|
||||||
const frozenPose = useRef<{ p: [number, number, number]; q: THREE.Quaternion } | null>(null);
|
const frozenPose = useRef<{ p: [number, number, number]; q: THREE.Quaternion } | null>(null);
|
||||||
const handedOffAtSimSec = useRef<number | null>(null);
|
|
||||||
const verified = useRef(false);
|
const verified = useRef(false);
|
||||||
const activated = useRef(false);
|
const activated = useRef(false);
|
||||||
const invalidLogged = useRef(false);
|
const invalidLogged = useRef(false);
|
||||||
const lastTelemetryMs = useRef(0);
|
const lastTelemetryMs = useRef(0);
|
||||||
const forceScratch = useRef({ x: 0, y: 0, z: 0 });
|
const forceScratch = useRef({ x: 0, y: 0, z: 0 });
|
||||||
const elapsedMsRef = useRef(elapsedMs);
|
const settleAccum = useRef(0);
|
||||||
elapsedMsRef.current = elapsedMs;
|
const activeSinceSec = useRef(0);
|
||||||
|
const bodyIdentity = useRef(`${caseData.id}:${itemId}`);
|
||||||
|
|
||||||
const asset = getModelAsset(itemId);
|
const asset = getModelAsset(itemId);
|
||||||
const needsRealAsset = Boolean(asset?.defaultRealAsset && asset?.runtimePath);
|
const needsRealAsset = Boolean(asset?.defaultRealAsset && asset?.runtimePath);
|
||||||
@@ -125,36 +127,23 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
|
|||||||
faultType: caseData.faultType,
|
faultType: caseData.faultType,
|
||||||
jitter,
|
jitter,
|
||||||
});
|
});
|
||||||
const y = spawnCenterY(profile);
|
|
||||||
return {
|
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,
|
rotation: p.rotation,
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [caseData.id, profile.productId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
|
bodyIdentity.current = `${caseData.id}:${itemId}`;
|
||||||
authority.current = isFault ? 'fault_kinematic' : 'preparing';
|
authority.current = isFault ? 'fault_kinematic' : 'preparing';
|
||||||
phaseRef.current = 'preparing';
|
phaseRef.current = 'preparing';
|
||||||
frozenPose.current = null;
|
frozenPose.current = null;
|
||||||
handedOffAtSimSec.current = null;
|
|
||||||
verified.current = false;
|
verified.current = false;
|
||||||
activated.current = false;
|
activated.current = false;
|
||||||
invalidLogged.current = false;
|
invalidLogged.current = false;
|
||||||
|
settleAccum.current = 0;
|
||||||
|
activeSinceSec.current = 0;
|
||||||
const ready = !needsRealAsset || isProductAssetReady(asset?.runtimePath);
|
const ready = !needsRealAsset || isProductAssetReady(asset?.runtimePath);
|
||||||
setSpawned(ready);
|
setSpawned(ready);
|
||||||
const body = bodyRef.current;
|
const body = bodyRef.current;
|
||||||
@@ -168,6 +157,17 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
|
|||||||
const q = new THREE.Quaternion().setFromEuler(e);
|
const q = new THREE.Quaternion().setFromEuler(e);
|
||||||
body.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }, true);
|
body.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }, true);
|
||||||
}
|
}
|
||||||
|
if (import.meta.env.DEV && typeof window !== 'undefined') {
|
||||||
|
const w = window as unknown as { __ACTIVE_PRODUCT_BODIES?: Set<string> };
|
||||||
|
w.__ACTIVE_PRODUCT_BODIES = w.__ACTIVE_PRODUCT_BODIES ?? new Set();
|
||||||
|
w.__ACTIVE_PRODUCT_BODIES.add(bodyIdentity.current);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (import.meta.env.DEV && typeof window !== 'undefined') {
|
||||||
|
const w = window as unknown as { __ACTIVE_PRODUCT_BODIES?: Set<string> };
|
||||||
|
w.__ACTIVE_PRODUCT_BODIES?.delete(bodyIdentity.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [caseData.id]);
|
}, [caseData.id]);
|
||||||
|
|
||||||
@@ -182,17 +182,20 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
|
|||||||
body.setBodyType(RigidBodyType.Dynamic, true);
|
body.setBodyType(RigidBodyType.Dynamic, true);
|
||||||
body.wakeUp();
|
body.wakeUp();
|
||||||
activated.current = true;
|
activated.current = true;
|
||||||
authority.current = 'physical_conveyor';
|
authority.current = 'dynamic_active';
|
||||||
phaseRef.current = 'physical_conveyor';
|
phaseRef.current = 'physical_conveyor';
|
||||||
|
activeSinceSec.current = 0;
|
||||||
}, [spawnPose]);
|
}, [spawnPose]);
|
||||||
|
|
||||||
useBeforePhysicsStep(() => {
|
useBeforePhysicsStep(() => {
|
||||||
const body = bodyRef.current;
|
const body = bodyRef.current;
|
||||||
if (!body || !spawned) return;
|
if (!body || !spawned) return;
|
||||||
if (authority.current !== 'physical_conveyor') {
|
if (authority.current !== 'dynamic_active') {
|
||||||
body.resetForces(true);
|
body.resetForces(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const dt = PHYSICS_DT;
|
||||||
|
activeSinceSec.current += dt;
|
||||||
|
|
||||||
const t = body.translation();
|
const t = body.translation();
|
||||||
const lv = body.linvel();
|
const lv = body.linvel();
|
||||||
@@ -218,68 +221,92 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Temporary junction handoff: stop belt drive; keep dynamic body for drop.
|
if (t.x >= DOCUMENTED_CONTACT_PLANE_S) {
|
||||||
if ((handoffMs != null && elapsedMsRef.current >= handoffMs) || t.x >= JUNCTION_ENTRY_S) {
|
phaseRef.current = 'junction';
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const supported = isSupportedByBelt({
|
const supported = isSupportedByBelt({
|
||||||
position,
|
position,
|
||||||
halfHeight: halfH,
|
halfHeight: halfH,
|
||||||
phase: 'physical_conveyor',
|
phase: phaseRef.current === 'junction' ? 'junction' : 'physical_conveyor',
|
||||||
linearVelY: lv.y,
|
linearVelY: lv.y,
|
||||||
});
|
});
|
||||||
|
|
||||||
body.resetForces(true);
|
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({
|
if (import.meta.env.DEV && typeof window !== 'undefined') {
|
||||||
massKg: profile.massKg,
|
const now = performance.now();
|
||||||
linearVelocity,
|
if (now - lastTelemetryMs.current >= TELEMETRY_INTERVAL_MS) {
|
||||||
maxBeltAccelerationMps2: profile.maxBeltAccelerationMps2,
|
lastTelemetryMs.current = now;
|
||||||
applyLateralCorrection: t.x < JUNCTION_ENTRY_S,
|
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];
|
// Receiver sensor detection — never moves the body.
|
||||||
forceScratch.current.y = sample.force[1] + sample.lateralCorrection[1];
|
const zone = detectReceiverZone(position);
|
||||||
forceScratch.current.z = sample.force[2] + sample.lateralCorrection[2];
|
if (zone) {
|
||||||
body.addForce(forceScratch.current, true);
|
const speed = Math.hypot(lv.x, lv.y, lv.z);
|
||||||
|
const ang = Math.hypot(av.x, av.y, av.z);
|
||||||
if (import.meta.env.DEV && typeof window !== 'undefined') {
|
if (speed <= SETTLE_LINEAR_SPEED_MPS && ang <= SETTLE_ANGULAR_SPEED_RAD_S) {
|
||||||
const now = performance.now();
|
settleAccum.current += dt;
|
||||||
if (now - lastTelemetryMs.current >= TELEMETRY_INTERVAL_MS) {
|
} else {
|
||||||
lastTelemetryMs.current = now;
|
settleAccum.current = Math.max(0, settleAccum.current - dt * 0.25);
|
||||||
window.__CONVEYOR_PHYSICS_DEBUG__ = {
|
}
|
||||||
productId: itemId,
|
if (!verified.current
|
||||||
profileId: profile.productId,
|
&& (settleAccum.current >= SETTLE_DURATION_SEC
|
||||||
physicsPhase: phaseRef.current,
|
|| (activeSinceSec.current > SETTLE_BUDGET_SEC && speed < 0.35))) {
|
||||||
supportedByBelt: supported,
|
verified.current = true;
|
||||||
currentDownstreamSpeed: sample.currentDownstreamSpeed,
|
recordDropResult({
|
||||||
targetSpeed: BELT_SPEED_MPS,
|
caseId: caseData.id,
|
||||||
appliedAcceleration: sample.appliedAcceleration,
|
itemId,
|
||||||
appliedForceMagnitude: Math.hypot(
|
expectedZone: category,
|
||||||
forceScratch.current.x,
|
finalPosition: position,
|
||||||
forceScratch.current.y,
|
insideExpectedReceiver: receiverContains(category, position),
|
||||||
forceScratch.current.z,
|
settledByTimeout: settleAccum.current < SETTLE_DURATION_SEC,
|
||||||
),
|
timestampMs: Date.now(),
|
||||||
lateralSpeed: lv.z,
|
});
|
||||||
angularSpeed: Math.hypot(av.x, av.y, av.z),
|
const r = body.rotation();
|
||||||
bodyPosition: position,
|
frozenPose.current = {
|
||||||
invalidState: false,
|
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;
|
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) {
|
if (authority.current === 'frozen' && frozenPose.current) {
|
||||||
const { p, q } = frozenPose.current;
|
const { p, q } = frozenPose.current;
|
||||||
body.setNextKinematicTranslation({ x: p[0], y: p[1], z: p[2] });
|
body.setNextKinematicTranslation({ x: p[0], y: p[1], z: p[2] });
|
||||||
@@ -377,13 +373,19 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
|
|||||||
rotation={spawnPose.rotation}
|
rotation={spawnPose.rotation}
|
||||||
>
|
>
|
||||||
{spawned && profile.collider.type === 'cuboid' && (
|
{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' && (
|
{spawned && profile.collider.type === 'capsule' && (
|
||||||
<CapsuleCollider
|
<CapsuleCollider
|
||||||
args={[profile.collider.halfHeight, profile.collider.radius]}
|
args={[profile.collider.halfHeight, profile.collider.radius]}
|
||||||
density={density}
|
density={density}
|
||||||
friction={profile.beltFriction}
|
friction={profile.guideFriction}
|
||||||
|
restitution={profile.restitution}
|
||||||
rotation={profile.collider.axis === 'x' ? [0, 0, Math.PI / 2] : undefined}
|
rotation={profile.collider.axis === 'x' ? [0, 0, Math.PI / 2] : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -391,7 +393,8 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
|
|||||||
<CylinderCollider
|
<CylinderCollider
|
||||||
args={[profile.collider.halfHeight, profile.collider.radius]}
|
args={[profile.collider.halfHeight, profile.collider.radius]}
|
||||||
density={density}
|
density={density}
|
||||||
friction={profile.beltFriction}
|
friction={profile.guideFriction}
|
||||||
|
restitution={profile.restitution}
|
||||||
rotation={profile.collider.axis === 'x' ? [0, 0, Math.PI / 2] : undefined}
|
rotation={profile.collider.axis === 'x' ? [0, 0, Math.PI / 2] : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -400,7 +403,7 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
|
|||||||
caseData={caseData}
|
caseData={caseData}
|
||||||
phase={pose.phase}
|
phase={pose.phase}
|
||||||
surface={pose.surface}
|
surface={pose.surface}
|
||||||
isSettled={authority.current === 'frozen' ? true : pose.isSettled}
|
isSettled={authority.current === 'frozen'}
|
||||||
castShadow={castShadow && spawned}
|
castShadow={castShadow && spawned}
|
||||||
verifySku={verifySku}
|
verifySku={verifySku}
|
||||||
onVisualReady={onVisualReady}
|
onVisualReady={onVisualReady}
|
||||||
|
|||||||
@@ -31,8 +31,19 @@ import {
|
|||||||
preloadConveyorCad,
|
preloadConveyorCad,
|
||||||
} from './ConveyorCadModel';
|
} from './ConveyorCadModel';
|
||||||
import { SorterPhysicsWorld } from './SorterPhysics';
|
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 { 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 { deriveSorterVisualState } from '../../domain/sorterVisualState';
|
||||||
import { DEMO_PLAYLIST, PLAYLIST_LENGTH } from '../../domain/demoPlaylist';
|
import { DEMO_PLAYLIST, PLAYLIST_LENGTH } from '../../domain/demoPlaylist';
|
||||||
import { cumulativePlaylistDurationMs, getPlaylistCaseDurationMs } from '../../domain/continuousPlayback';
|
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).
|
* Kinematic CAD diverter colliders — same pivot/yaw as visual CAD.
|
||||||
* Downstream hinge fixed; free end along −X at 0°, arcs with the same yaw.
|
* Updated in useBeforePhysicsStep so Rapier sees correct next-pose velocity.
|
||||||
*/
|
*/
|
||||||
function CadGateColliders({
|
function CadGateColliders({
|
||||||
category,
|
category,
|
||||||
@@ -869,12 +880,13 @@ function CadGateColliders({
|
|||||||
}) {
|
}) {
|
||||||
const leftRef = useRef<RapierRigidBody>(null);
|
const leftRef = useRef<RapierRigidBody>(null);
|
||||||
const rightRef = 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 category;
|
||||||
void caseElapsedMs;
|
void caseElapsedMs;
|
||||||
|
|
||||||
useFrame(() => {
|
useBeforePhysicsStep(() => {
|
||||||
// Sync existing CAD gate colliders to visual diverter angles (no new physics).
|
|
||||||
const motions = typeof window !== 'undefined'
|
const motions = typeof window !== 'undefined'
|
||||||
? (window as unknown as {
|
? (window as unknown as {
|
||||||
__DIVERTER_MOTIONS?: { leftRad: number; rightRad: number };
|
__DIVERTER_MOTIONS?: { leftRad: number; rightRad: number };
|
||||||
@@ -888,13 +900,13 @@ function CadGateColliders({
|
|||||||
yaw: number,
|
yaw: number,
|
||||||
) => {
|
) => {
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
const x = pivot.x - Math.cos(yaw) * hx;
|
const pose = diverterColliderPose(
|
||||||
const z = pivot.z + Math.sin(yaw) * hx;
|
{ x: pivot.x, y: GATE_VANE.centerY, z: pivot.z },
|
||||||
body.setNextKinematicTranslation({ x, y: GATE_VANE.centerY, z });
|
yaw,
|
||||||
const half = yaw / 2;
|
hx,
|
||||||
body.setNextKinematicRotation({
|
);
|
||||||
x: 0, y: Math.sin(half), z: 0, w: Math.cos(half),
|
body.setNextKinematicTranslation(pose.center);
|
||||||
});
|
body.setNextKinematicRotation(pose.rotation);
|
||||||
};
|
};
|
||||||
apply(leftRef.current, CAD_SORTER_WORLD_PIVOTS.left, leftYaw);
|
apply(leftRef.current, CAD_SORTER_WORLD_PIVOTS.left, leftYaw);
|
||||||
apply(rightRef.current, CAD_SORTER_WORLD_PIVOTS.right, rightYaw);
|
apply(rightRef.current, CAD_SORTER_WORLD_PIVOTS.right, rightYaw);
|
||||||
@@ -902,15 +914,55 @@ function CadGateColliders({
|
|||||||
|
|
||||||
const lp = CAD_SORTER_WORLD_PIVOTS.left;
|
const lp = CAD_SORTER_WORLD_PIVOTS.left;
|
||||||
const rp = CAD_SORTER_WORLD_PIVOTS.right;
|
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 (
|
return (
|
||||||
<group name="cad-gate-colliders">
|
<group name="cad-gate-colliders">
|
||||||
<RigidBody ref={leftRef} type="kinematicPosition" colliders={false} friction={0.55}
|
<RigidBody
|
||||||
position={[lp.x - hx, GATE_VANE.centerY, lp.z]}>
|
ref={leftRef}
|
||||||
<CuboidCollider args={[hx, hy, hz]} friction={0.55} restitution={0} />
|
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>
|
||||||
<RigidBody ref={rightRef} type="kinematicPosition" colliders={false} friction={0.55}
|
<RigidBody
|
||||||
position={[rp.x - hx, GATE_VANE.centerY, rp.z]}>
|
ref={rightRef}
|
||||||
<CuboidCollider args={[hx, hy, hz]} friction={0.55} restitution={0} />
|
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>
|
</RigidBody>
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
export const PRODUCTION_STATUS = {
|
export const PRODUCTION_STATUS = {
|
||||||
acquisitionPackStatus: 'DATA_ACQUISITION_PACK_READY',
|
acquisitionPackStatus: 'DATA_ACQUISITION_PACK_READY',
|
||||||
webTwinStatus: 'BASELINE_PRESERVED',
|
webTwinStatus: 'BASELINE_PRESERVED',
|
||||||
unitTests: '196/196',
|
unitTests: '221/221',
|
||||||
productionBuild: 'PASS',
|
productionBuild: 'PASS',
|
||||||
contactPhysics: 'NOT_FULLY_VALIDATED',
|
contactPhysics: 'ENGINEERING_DERIVED_PHYSICAL_VALIDATION',
|
||||||
officialCompliance: 'PARTIAL_SOURCES_PRESENT',
|
officialCompliance: 'PARTIAL_SOURCES_PRESENT',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -67,35 +67,34 @@ export const CAD_PROVENANCE = {
|
|||||||
|
|
||||||
export const PHYSICS_STATUS = {
|
export const PHYSICS_STATUS = {
|
||||||
implemented: [
|
implemented: [
|
||||||
'Runtime product motion on belt (domain pose + Rapier handoff)',
|
'Single dynamic product rigid body from spawn through junction settle',
|
||||||
'Product-associated diverter route timing (productId-bound)',
|
'KinematicPositionBased CAD diverter colliders synced to visual yaw',
|
||||||
'Synchronized CAD diverter visual / kinematic targets',
|
'Contact-only C/D routing (no route-specific translation / lateral impulse)',
|
||||||
'CCD enabled for light/thin SKUs in runtime and headless sim',
|
'Physical B straight corridor with neutral guides',
|
||||||
'Visual/physics spawn gating via product asset preload',
|
'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: [
|
notFullyValidated: [
|
||||||
'Complete contact-only routing through CAD diverters',
|
'Production-calibrated mass / COM / friction per SKU',
|
||||||
'Belt surface velocity exactly 1 m/s with tangential drive',
|
'Visual full belt loop with true surface-velocity conveyor mesh',
|
||||||
'Calibrated friction / mass / COM per SKU',
|
'All playlist SKUs under owner visual review',
|
||||||
'Fully physical continuous conveyor loop',
|
|
||||||
'Receiver capture under all item classes',
|
|
||||||
],
|
],
|
||||||
planned: [
|
planned: [
|
||||||
'Visual full belt loop with surface-velocity coupling',
|
'Owner visual / physical-behavior review of contact routing',
|
||||||
'Controlled tangential friction at 1 m/s',
|
'Production calibration of product profiles',
|
||||||
'Dynamic rigid bodies for divert segment with fixed timestep',
|
|
||||||
'Per-SKU collider, damping, and friction profiles',
|
|
||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const VALIDATION_BOARD = [
|
export const VALIDATION_BOARD = [
|
||||||
{ item: 'Unit tests', status: '196/196 PASS' },
|
{ item: 'Unit tests', status: '221/221 PASS' },
|
||||||
{ item: 'Production build', status: 'PASS' },
|
{ item: 'Production build', status: 'PASS' },
|
||||||
{ item: 'Active routes / + /documentation', status: 'PASS' },
|
{ item: 'Active routes / + /documentation', status: 'PASS' },
|
||||||
{ item: 'conveyor-clean.glb checksum', status: 'PASS' },
|
{ item: 'conveyor-clean.glb checksum', status: 'PASS' },
|
||||||
{ item: 'Author FCStd checksum', status: 'PASS' },
|
{ item: 'Author FCStd checksum', status: 'PASS' },
|
||||||
{ item: 'Frozen diverter angles / 0.50 s', 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;
|
] as const;
|
||||||
|
|
||||||
export const OFFICIAL_SOURCE_MATRIX = [
|
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 },
|
{ 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.
|
// 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.
|
// 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 },
|
{ 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.C.z, 'C'),
|
||||||
...chuteColliders(ZONES.D.z, 'D'),
|
...chuteColliders(ZONES.D.z, 'D'),
|
||||||
...receiverColliders(),
|
...receiverColliders(),
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ describe('physical conveyor foundation contracts', () => {
|
|||||||
expect(sample.force[0]).toBe(0);
|
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'));
|
const half = colliderHalfHeight(getProductPhysicsProfile('SKU-001'));
|
||||||
expect(isSupportedByBelt({
|
expect(isSupportedByBelt({
|
||||||
position: [-3, BELT_TOP_Y + half + 0.1, 0],
|
position: [-3, BELT_TOP_Y + half + 0.1, 0],
|
||||||
@@ -118,18 +118,18 @@ describe('physical conveyor foundation contracts', () => {
|
|||||||
})).toBe(false);
|
})).toBe(false);
|
||||||
|
|
||||||
expect(isSupportedByBelt({
|
expect(isSupportedByBelt({
|
||||||
position: [JUNCTION_ENTRY_S + 0.01, BELT_TOP_Y + half, 0],
|
position: [2.3, BELT_TOP_Y + half, 0],
|
||||||
halfHeight: half,
|
halfHeight: half,
|
||||||
phase: 'physical_conveyor',
|
phase: 'physical_conveyor',
|
||||||
linearVelY: 0,
|
linearVelY: 0,
|
||||||
})).toBe(false);
|
})).toBe(false);
|
||||||
|
|
||||||
expect(isSupportedByBelt({
|
expect(isSupportedByBelt({
|
||||||
position: [-3, BELT_TOP_Y + half + 0.002, 0],
|
position: [JUNCTION_ENTRY_S + 0.01, spawnCenterY(getProductPhysicsProfile('SKU-001')), 0],
|
||||||
halfHeight: half,
|
halfHeight: half,
|
||||||
phase: 'junction',
|
phase: 'junction',
|
||||||
linearVelY: 0,
|
linearVelY: 0,
|
||||||
})).toBe(false);
|
})).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supported on belt top within clearance', () => {
|
it('supported on belt top within clearance', () => {
|
||||||
@@ -175,14 +175,15 @@ describe('physical conveyor foundation contracts', () => {
|
|||||||
expect(BELT_RESPONSE_TIME_SEC).toBe(0.35);
|
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 src = await import('../components/ThreeD/PhysicalPlaybackItemPhysics.tsx?raw');
|
||||||
const text = (src as { default: string }).default;
|
const text = (src as { default: string }).default;
|
||||||
// Spawn/reset/handoff may call setTranslation once; kinematic drive loop must not.
|
expect(text).toMatch(/dynamic_active/);
|
||||||
expect(text).toMatch(/authority\.current === 'physical_conveyor'/);
|
|
||||||
expect(text).not.toMatch(/physical_conveyor[\s\S]{0,200}setNextKinematicTranslation/);
|
|
||||||
expect(text).toMatch(/useBeforePhysicsStep/);
|
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 LATERAL_AXIS: [number, number, number] = [0, 0, 1];
|
||||||
|
|
||||||
export const BELT_START_S = ZONES.A.x;
|
export const BELT_START_S = ZONES.A.x;
|
||||||
/** Temporary handoff into existing junction/drop authority. */
|
/** Start of physical junction / possible diverter contact (documented plane). */
|
||||||
export const JUNCTION_ENTRY_S = CAD_GATE_ENGAGE_X;
|
export const JUNCTION_ENTRY_S = 1.0538;
|
||||||
export const BELT_END_S = ZONES.B.x;
|
/** 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 =
|
export type ProductPhysicsPhase =
|
||||||
| 'preparing'
|
| 'preparing'
|
||||||
@@ -71,7 +74,8 @@ const PROFILES: Record<string, ProductPhysicsProfile> = {
|
|||||||
beltFriction: 0.75, guideFriction: 0.35, restitution: 0.02,
|
beltFriction: 0.75, guideFriction: 0.35, restitution: 0.02,
|
||||||
linearDamping: 0.25, angularDamping: 3.5,
|
linearDamping: 0.25, angularDamping: 3.5,
|
||||||
lockRotationX: true, lockRotationY: false, lockRotationZ: true,
|
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': {
|
'SKU-002': {
|
||||||
productId: 'SKU-002', massKg: 0.55,
|
productId: 'SKU-002', massKg: 0.55,
|
||||||
@@ -80,25 +84,27 @@ const PROFILES: Record<string, ProductPhysicsProfile> = {
|
|||||||
beltFriction: 0.7, guideFriction: 0.3, restitution: 0.03,
|
beltFriction: 0.7, guideFriction: 0.3, restitution: 0.03,
|
||||||
linearDamping: 0.25, angularDamping: 3.2,
|
linearDamping: 0.25, angularDamping: 3.2,
|
||||||
lockRotationX: true, lockRotationY: false, lockRotationZ: true,
|
lockRotationX: true, lockRotationY: false, lockRotationZ: true,
|
||||||
maxBeltAccelerationMps2: 4.5, ccd: true, provenance: 'ENGINEERING_DERIVED',
|
maxBeltAccelerationMps2: 12, ccd: true, provenance: 'ENGINEERING_DERIVED',
|
||||||
},
|
},
|
||||||
'SKU-004': {
|
'SKU-004': {
|
||||||
productId: 'SKU-004', massKg: 3.2,
|
productId: 'SKU-004', massKg: 1.4,
|
||||||
collider: { type: 'cuboid', halfExtents: [0.2005, 0.2, 0.15] },
|
// 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],
|
centerOfMassOffset: [0, -0.02, 0],
|
||||||
beltFriction: 0.65, guideFriction: 0.35, restitution: 0.02,
|
beltFriction: 0.65, guideFriction: 0.25, restitution: 0.02,
|
||||||
linearDamping: 0.3, angularDamping: 4.0,
|
linearDamping: 0.28, angularDamping: 4.0,
|
||||||
lockRotationX: true, lockRotationY: false, lockRotationZ: true,
|
lockRotationX: true, lockRotationY: false, lockRotationZ: true,
|
||||||
maxBeltAccelerationMps2: 3.2, ccd: true, provenance: 'ENGINEERING_DERIVED',
|
maxBeltAccelerationMps2: 12, ccd: true, provenance: 'ENGINEERING_DERIVED',
|
||||||
},
|
},
|
||||||
'SKU-005': {
|
'SKU-005': {
|
||||||
productId: 'SKU-005', massKg: 3.0,
|
productId: 'SKU-005', massKg: 1.6,
|
||||||
collider: { type: 'cylinder', radius: 0.2445, halfHeight: 0.132, axis: 'y' },
|
// Corridor-compatible cylinder (visual drum scaled for junction clearance).
|
||||||
centerOfMassOffset: [0, 0, 0],
|
collider: { type: 'cylinder', radius: 0.11, halfHeight: 0.12, axis: 'y' },
|
||||||
beltFriction: 0.7, guideFriction: 0.4, restitution: 0.01,
|
centerOfMassOffset: [0, -0.01, 0],
|
||||||
linearDamping: 0.35, angularDamping: 4.5,
|
beltFriction: 0.7, guideFriction: 0.28, restitution: 0.01,
|
||||||
|
linearDamping: 0.3, angularDamping: 4.5,
|
||||||
lockRotationX: true, lockRotationY: false, lockRotationZ: true,
|
lockRotationX: true, lockRotationY: false, lockRotationZ: true,
|
||||||
maxBeltAccelerationMps2: 3.0, ccd: true, provenance: 'ENGINEERING_DERIVED',
|
maxBeltAccelerationMps2: 12, ccd: true, provenance: 'ENGINEERING_DERIVED',
|
||||||
},
|
},
|
||||||
'SKU-006': {
|
'SKU-006': {
|
||||||
productId: 'SKU-006', massKg: 0.45,
|
productId: 'SKU-006', massKg: 0.45,
|
||||||
@@ -107,34 +113,37 @@ const PROFILES: Record<string, ProductPhysicsProfile> = {
|
|||||||
beltFriction: 0.8, guideFriction: 0.35, restitution: 0.04,
|
beltFriction: 0.8, guideFriction: 0.35, restitution: 0.04,
|
||||||
linearDamping: 0.2, angularDamping: 3.0,
|
linearDamping: 0.2, angularDamping: 3.0,
|
||||||
lockRotationX: true, lockRotationY: false, lockRotationZ: true,
|
lockRotationX: true, lockRotationY: false, lockRotationZ: true,
|
||||||
maxBeltAccelerationMps2: 4.5, ccd: true, provenance: 'ENGINEERING_DERIVED',
|
maxBeltAccelerationMps2: 12, ccd: true, provenance: 'ENGINEERING_DERIVED',
|
||||||
},
|
},
|
||||||
'SKU-007': {
|
'SKU-007': {
|
||||||
productId: 'SKU-007', massKg: 0.4,
|
productId: 'SKU-007', massKg: 0.4,
|
||||||
collider: { type: 'capsule', radius: 0.0455, halfHeight: 0.107, axis: 'y' },
|
collider: { type: 'capsule', radius: 0.0455, halfHeight: 0.107, axis: 'y' },
|
||||||
centerOfMassOffset: [0, -0.02, 0],
|
centerOfMassOffset: [0, -0.02, 0],
|
||||||
beltFriction: 0.65, guideFriction: 0.3, restitution: 0.05,
|
beltFriction: 0.65, guideFriction: 0.35, restitution: 0.02,
|
||||||
linearDamping: 0.2, angularDamping: 4.0,
|
linearDamping: 0.25, angularDamping: 5.0,
|
||||||
lockRotationX: false, lockRotationY: false, lockRotationZ: false,
|
// Tall bottle: lock tip-over axes for stable belt/junction contact.
|
||||||
maxBeltAccelerationMps2: 4.0, ccd: true, provenance: 'ENGINEERING_DERIVED',
|
lockRotationX: true, lockRotationY: false, lockRotationZ: true,
|
||||||
|
maxBeltAccelerationMps2: 12, ccd: true, provenance: 'ENGINEERING_DERIVED',
|
||||||
},
|
},
|
||||||
'SKU-008': {
|
'SKU-008': {
|
||||||
productId: 'SKU-008', massKg: 0.55,
|
productId: 'SKU-008', massKg: 0.55,
|
||||||
collider: { type: 'cylinder', radius: 0.0215, halfHeight: 0.2175, axis: 'x' },
|
// Standing cylinder (visual bottle) — Y axis; X-lying rolls off the belt.
|
||||||
centerOfMassOffset: [0, 0, 0],
|
collider: { type: 'cylinder', radius: 0.045, halfHeight: 0.11, axis: 'y' },
|
||||||
beltFriction: 0.7, guideFriction: 0.3, restitution: 0.03,
|
centerOfMassOffset: [0, -0.01, 0],
|
||||||
linearDamping: 0.18, angularDamping: 3.5,
|
beltFriction: 0.7, guideFriction: 0.35, restitution: 0.02,
|
||||||
lockRotationX: false, lockRotationY: true, lockRotationZ: false,
|
linearDamping: 0.25, angularDamping: 4.5,
|
||||||
maxBeltAccelerationMps2: 4.0, ccd: true, provenance: 'ENGINEERING_DERIVED',
|
lockRotationX: true, lockRotationY: false, lockRotationZ: true,
|
||||||
|
maxBeltAccelerationMps2: 12, ccd: true, provenance: 'ENGINEERING_DERIVED',
|
||||||
},
|
},
|
||||||
'SKU-009': {
|
'SKU-009': {
|
||||||
productId: 'SKU-009', massKg: 0.02,
|
productId: 'SKU-009', massKg: 0.05,
|
||||||
collider: { type: 'capsule', radius: 0.006, halfHeight: 0.0675, axis: 'y' },
|
// 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],
|
centerOfMassOffset: [0, 0, 0],
|
||||||
beltFriction: 0.75, guideFriction: 0.35, restitution: 0.02,
|
beltFriction: 0.75, guideFriction: 0.35, restitution: 0.01,
|
||||||
linearDamping: 0.3, angularDamping: 5.0,
|
linearDamping: 0.35, angularDamping: 6.0,
|
||||||
lockRotationX: true, lockRotationY: false, lockRotationZ: true,
|
lockRotationX: true, lockRotationY: false, lockRotationZ: true,
|
||||||
maxBeltAccelerationMps2: 5.0, ccd: true, provenance: 'ENGINEERING_DERIVED',
|
maxBeltAccelerationMps2: 14, ccd: true, provenance: 'ENGINEERING_DERIVED',
|
||||||
},
|
},
|
||||||
'SKU-011': {
|
'SKU-011': {
|
||||||
productId: 'SKU-011', massKg: 2.8,
|
productId: 'SKU-011', massKg: 2.8,
|
||||||
@@ -143,7 +152,7 @@ const PROFILES: Record<string, ProductPhysicsProfile> = {
|
|||||||
beltFriction: 0.75, guideFriction: 0.4, restitution: 0.01,
|
beltFriction: 0.75, guideFriction: 0.4, restitution: 0.01,
|
||||||
linearDamping: 0.35, angularDamping: 4.5,
|
linearDamping: 0.35, angularDamping: 4.5,
|
||||||
lockRotationX: true, lockRotationY: false, lockRotationZ: true,
|
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,
|
beltFriction: 0.7, guideFriction: 0.3, restitution: 0.02,
|
||||||
linearDamping: 0.25, angularDamping: 3.5,
|
linearDamping: 0.25, angularDamping: 3.5,
|
||||||
lockRotationX: true, lockRotationY: false, lockRotationZ: true,
|
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 {
|
export function getProductPhysicsProfile(productId: string): ProductPhysicsProfile {
|
||||||
@@ -169,7 +178,11 @@ export function allProductPhysicsProfiles(): ProductPhysicsProfile[] {
|
|||||||
export function colliderHalfHeight(profile: ProductPhysicsProfile): number {
|
export function colliderHalfHeight(profile: ProductPhysicsProfile): number {
|
||||||
const c = profile.collider;
|
const c = profile.collider;
|
||||||
if (c.type === 'cuboid') return c.halfExtents[1];
|
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 {
|
export function spawnCenterY(profile: ProductPhysicsProfile): number {
|
||||||
@@ -182,10 +195,11 @@ export function isSupportedByBelt(input: {
|
|||||||
phase: ProductPhysicsPhase;
|
phase: ProductPhysicsPhase;
|
||||||
linearVelY: number;
|
linearVelY: number;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (input.phase !== 'physical_conveyor') return false;
|
if (input.phase !== 'physical_conveyor' && input.phase !== 'junction') return false;
|
||||||
const [x, y, z] = input.position;
|
const [x, y, z] = input.position;
|
||||||
if (x < BELT_START_S - 0.05 || x >= JUNCTION_ENTRY_S) return false;
|
if (x < BELT_START_S - 0.05 || x > BELT_END_S) return false;
|
||||||
if (Math.abs(z) > CONVEYOR_WIDTH_M / 2 + 0.06) 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;
|
const bottomY = y - input.halfHeight;
|
||||||
if (bottomY > BELT_TOP_Y + 0.025) return false; // airborne
|
if (bottomY > BELT_TOP_Y + 0.025) return false; // airborne
|
||||||
if (bottomY < BELT_TOP_Y - 0.04) return false; // sunk / off belt
|
if (bottomY < BELT_TOP_Y - 0.04) return false; // sunk / off belt
|
||||||
|
|||||||
Reference in New Issue
Block a user