Files
ozone-tech_owl_prime/src/domain/productPhysicsProfiles.test.ts
Даня Архипов dce7faee24 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>
2026-08-02 06:46:10 +00:00

204 lines
6.6 KiB
TypeScript

import { describe, expect, it, beforeEach } from 'vitest';
import {
BELT_SPEED_MPS,
BELT_RESPONSE_TIME_SEC,
BELT_SPEED_EPSILON,
DOWNSTREAM_AXIS,
LATERAL_AXIS,
UP_AXIS,
JUNCTION_ENTRY_S,
getProductPhysicsProfile,
computeBeltDriveForce,
isSupportedByBelt,
spawnCenterY,
colliderHalfHeight,
resetInvalidProductStateCount,
INVALID_PRODUCT_STATE_COUNT,
isInvalidProductState,
} from './productPhysicsProfiles';
import {
PHYSICS_TIMESTEP_SEC,
PHYSICS_MAX_SUBSTEPS,
MAX_FRAME_DELTA_SEC,
} from './physicsTimestep';
import { BELT_TOP_Y, CONVEYOR_SPEED_MPS } from './physicalLayout';
import {
categoryToPhysicalRoute,
DIVERTER_LEFT_SIGNED_DEG,
DIVERTER_RIGHT_SIGNED_DEG,
OPENING_SAFETY_MARGIN_SEC,
rotationDurationSec,
} from './pusherMotion';
describe('physical conveyor foundation contracts', () => {
beforeEach(() => {
resetInvalidProductStateCount();
});
it('BELT_SPEED_MPS equals exactly 1.0', () => {
expect(BELT_SPEED_MPS).toBe(1.0);
expect(CONVEYOR_SPEED_MPS).toBe(1.0);
});
it('fixed timestep equals 1/120 and max substeps equals 4', () => {
expect(PHYSICS_TIMESTEP_SEC).toBeCloseTo(1 / 120, 12);
expect(PHYSICS_MAX_SUBSTEPS).toBe(4);
expect(MAX_FRAME_DELTA_SEC).toBeCloseTo(1 / 15, 12);
});
it('axes are orthonormal with +X downstream', () => {
expect(DOWNSTREAM_AXIS).toEqual([1, 0, 0]);
expect(UP_AXIS).toEqual([0, 1, 0]);
expect(LATERAL_AXIS).toEqual([0, 0, 1]);
const dotDL = DOWNSTREAM_AXIS[0] * LATERAL_AXIS[0]
+ DOWNSTREAM_AXIS[1] * LATERAL_AXIS[1]
+ DOWNSTREAM_AXIS[2] * LATERAL_AXIS[2];
expect(dotDL).toBe(0);
});
it('mass-scaled force converges toward 1.0 without large overshoot drive', () => {
let v = 0;
const dt = PHYSICS_TIMESTEP_SEC;
const profile = getProductPhysicsProfile('SKU-001');
for (let i = 0; i < 240; i += 1) {
const sample = computeBeltDriveForce({
massKg: profile.massKg,
linearVelocity: [v, 0, 0],
maxBeltAccelerationMps2: profile.maxBeltAccelerationMps2,
applyLateralCorrection: false,
});
v += (sample.force[0] / profile.massKg) * dt;
}
expect(v).toBeGreaterThan(0.98);
expect(v).toBeLessThan(1.02 + BELT_SPEED_EPSILON);
});
it('heavier and lighter products both converge with mass-scaled force', () => {
const run = (sku: string) => {
let v = 0;
const p = getProductPhysicsProfile(sku);
for (let i = 0; i < 300; i += 1) {
const s = computeBeltDriveForce({
massKg: p.massKg,
linearVelocity: [v, 0, 0],
maxBeltAccelerationMps2: p.maxBeltAccelerationMps2,
applyLateralCorrection: false,
});
v += (s.force[0] / p.massKg) * PHYSICS_TIMESTEP_SEC;
}
return v;
};
const light = run('SKU-009');
const heavy = run('SKU-004');
expect(light).toBeGreaterThan(0.95);
expect(heavy).toBeGreaterThan(0.95);
expect(light).toBeLessThan(1.05);
expect(heavy).toBeLessThan(1.05);
});
it('deadband applies no forward correction near target', () => {
const sample = computeBeltDriveForce({
massKg: 1,
linearVelocity: [1.0, 0, 0],
maxBeltAccelerationMps2: 5,
applyLateralCorrection: false,
});
expect(sample.withinDeadband).toBe(true);
expect(sample.appliedAcceleration).toBe(0);
expect(sample.force[0]).toBe(0);
});
it('no belt support when airborne or past belt end', () => {
const half = colliderHalfHeight(getProductPhysicsProfile('SKU-001'));
expect(isSupportedByBelt({
position: [-3, BELT_TOP_Y + half + 0.1, 0],
halfHeight: half,
phase: 'physical_conveyor',
linearVelY: 0,
})).toBe(false);
expect(isSupportedByBelt({
position: [2.3, BELT_TOP_Y + half, 0],
halfHeight: half,
phase: 'physical_conveyor',
linearVelY: 0,
})).toBe(false);
expect(isSupportedByBelt({
position: [JUNCTION_ENTRY_S + 0.01, spawnCenterY(getProductPhysicsProfile('SKU-001')), 0],
halfHeight: half,
phase: 'junction',
linearVelY: 0,
})).toBe(true);
});
it('supported on belt top within clearance', () => {
const half = colliderHalfHeight(getProductPhysicsProfile('SKU-001'));
expect(isSupportedByBelt({
position: [-3, spawnCenterY(getProductPhysicsProfile('SKU-001')), 0],
halfHeight: half,
phase: 'physical_conveyor',
linearVelY: 0,
})).toBe(true);
});
it('CCD enabled and spawn Y clears belt by 2mm', () => {
const p = getProductPhysicsProfile('SKU-001');
expect(p.ccd).toBe(true);
expect(spawnCenterY(p)).toBeCloseTo(BELT_TOP_Y + colliderHalfHeight(p) + 0.002, 6);
});
it('profiles are ENGINEERING_DERIVED with primitive colliders', () => {
for (const id of ['SKU-001', 'SKU-004', 'SKU-007', 'SKU-009']) {
const p = getProductPhysicsProfile(id);
expect(p.provenance).toBe('ENGINEERING_DERIVED');
expect(['cuboid', 'cylinder', 'capsule']).toContain(p.collider.type);
expect(p.restitution).toBeLessThanOrEqual(0.05);
}
});
it('invalid state detection catches NaN and overspeed', () => {
expect(isInvalidProductState({
position: [0, NaN, 0],
linearVelocity: [0, 0, 0],
angularVelocity: [0, 0, 0],
})).toBe(true);
expect(isInvalidProductState({
position: [0, 0.8, 0],
linearVelocity: [5, 0, 0],
angularVelocity: [0, 0, 0],
})).toBe(true);
expect(INVALID_PRODUCT_STATE_COUNT).toBe(0);
});
it('response time constant is 0.35 s', () => {
expect(BELT_RESPONSE_TIME_SEC).toBe(0.35);
});
it('physical conveyor has no per-frame setTranslation and no handoff switch', async () => {
const src = await import('../components/ThreeD/PhysicalPlaybackItemPhysics.tsx?raw');
const text = (src as { default: string }).default;
expect(text).toMatch(/dynamic_active/);
expect(text).toMatch(/useBeforePhysicsStep/);
expect(text).toMatch(/setLinvel/);
expect(text).toMatch(/BELT_SPEED_MPS/);
expect(text).not.toMatch(/getDropHandoffTimeMs/);
expect(text).not.toMatch(/setLinvel\(\{ x: Math\.max/);
});
});
describe('frozen routing contracts unchanged', () => {
it('B/C/D mapping unchanged', () => {
expect(categoryToPhysicalRoute('B')).toBe('STRAIGHT');
expect(categoryToPhysicalRoute('C')).toBe('PHYSICAL_LEFT');
expect(categoryToPhysicalRoute('D')).toBe('PHYSICAL_RIGHT');
});
it('LEFT/RIGHT angles and timing unchanged', () => {
expect(DIVERTER_LEFT_SIGNED_DEG).toBe(-45);
expect(DIVERTER_RIGHT_SIGNED_DEG).toBe(45);
expect(rotationDurationSec()).toBeCloseTo(0.5, 6);
expect(OPENING_SAFETY_MARGIN_SEC).toBeCloseTo(0.15, 6);
});
});