- {/* CAD-derived conveyor module (real machine: frame, belt, rollers,
- drive, metering gates, servo diverters, camera arch) */}
+
- {/* Entry extension: zone A feed into the CAD module */}
- {/* Exit extension: CAD module discharge to the B spur */}
- {/* Belt stripes — deterministic movement synced to item (offset = time * 1 m/s) */}
{stripeOffsets.map((offset, i) => (
))}
- {/* Drive roller at end (larger) */}
-
+
-
+
- {/* Tension roller at start (larger) */}
-
+
-
+
- {/* Stepper motor at drive end */}
-
-
- {/* End caps / guards */}
-
+
-
+
);
diff --git a/src/components/ThreeD/SorterPhysics.tsx b/src/components/ThreeD/SorterPhysics.tsx
index 9d05758..86acce9 100644
--- a/src/components/ThreeD/SorterPhysics.tsx
+++ b/src/components/ThreeD/SorterPhysics.tsx
@@ -17,8 +17,9 @@ import { useRef, type ReactNode } from 'react';
import { useFrame } from '@react-three/fiber';
import { Physics, RigidBody, CuboidCollider, useRapier } from '@react-three/rapier';
import { getStaticColliders } from '../../domain/physicsWorldLayout';
+import { PHYSICS_TIMESTEP_SEC } from '../../domain/physicsTimestep';
-export const PHYSICS_DT = 1 / 60;
+export const PHYSICS_DT = PHYSICS_TIMESTEP_SEC;
const MAX_SUBSTEPS = 4;
/**
diff --git a/src/domain/cadAssemblyParams.ts b/src/domain/cadAssemblyParams.ts
new file mode 100644
index 0000000..822b8b1
--- /dev/null
+++ b/src/domain/cadAssemblyParams.ts
@@ -0,0 +1,45 @@
+/**
+ * Stage 2C — modular CAD conveyor assembly parameters.
+ *
+ * Derived from 3d_models/conveer.FCStd (manifest Body and Link bbox mm) and
+ * OZON Track 3 layout (500 mm belt / 700 mm height). Extensions flanking the
+ * ~2.01 m CAD module use the same pitch/width/height — never non-uniform scale.
+ */
+
+/** Author CAD module length after bake+placement (m). */
+export const CAD_MODULE_LENGTH_M = 2.01;
+/** CAD roller assembly pitch along the line (profiles / Link005 spacing ~500 mm). */
+export const CAD_ROLLER_PITCH_M = 0.5;
+/** CAD roller outer radius — Body002 roller diameter 50 mm. */
+export const CAD_ROLLER_RADIUS_M = 0.025;
+/** Spec / CAD belt width. */
+export const CAD_CONVEYOR_WIDTH_M = 0.5;
+/** Belt top height from floor. */
+export const CAD_BELT_HEIGHT_M = 0.7;
+/** Support leg spacing along extensions. */
+export const CAD_SUPPORT_SPACING_M = 2.0;
+/** Full domain line length (entry A to B spur tip), meters. */
+export const CAD_TOTAL_LINE_LENGTH_M = 8.5;
+
+export const CAD_ASSEMBLY_PARAMS = {
+ segmentLength: CAD_MODULE_LENGTH_M,
+ rollerPitch: CAD_ROLLER_PITCH_M,
+ rollerRadius: CAD_ROLLER_RADIUS_M,
+ conveyorWidth: CAD_CONVEYOR_WIDTH_M,
+ beltHeight: CAD_BELT_HEIGHT_M,
+ supportSpacing: CAD_SUPPORT_SPACING_M,
+ totalLineLength: CAD_TOTAL_LINE_LENGTH_M,
+} as const;
+
+/** Structured correction transforms (bake is authoritative; JSX must not invent offsets). */
+export const CAD_TRANSFORM_MANIFEST = {
+ bakeFormula: '(x,y,z)_mm_Zup -> (-x, z, y+250)/1000 Y-up meters',
+ worldPlacement: [-2.02, 0.594, 0] as [number, number, number],
+ moduleSpanX: [-2.086, -0.076] as [number, number],
+ motorNode: 'motor-and-drive/NEMA17',
+ motorWorldAabbApprox: {
+ min: [-2.062, 0.564, 0.212],
+ max: [-2.02, 0.606, 0.284],
+ note: 'On frame at belt height — not under floor. Detached motor was procedural StepperMotor.',
+ },
+} as const;
diff --git a/src/domain/physicsConfigHash.ts b/src/domain/physicsConfigHash.ts
new file mode 100644
index 0000000..21b3cbd
--- /dev/null
+++ b/src/domain/physicsConfigHash.ts
@@ -0,0 +1,89 @@
+/**
+ * Stage 2C §11.1 — machine-readable runtime ↔ headless physics config hash.
+ * Both paths must consume the same static colliders, pusher geometry, dt, gravity.
+ * Hash is pure-JS (no Node crypto) so the module stays isomorphic.
+ */
+import { getStaticColliders } from './physicsWorldLayout';
+import { PUSHER } from './pusherMotion';
+import { PHYSICS_TIMESTEP_SEC, PHYSICS_GRAVITY } from './physicsTimestep';
+
+export { PHYSICS_TIMESTEP_SEC, PHYSICS_GRAVITY } from './physicsTimestep';
+
+export interface PhysicsConfigSnapshot {
+ timestep: number;
+ gravity: [number, number, number];
+ pusherHalfExtents: [number, number, number];
+ pusherCenterY: number;
+ pusherEngage: { x: number; z: number };
+ staticColliderCount: number;
+ staticColliderIds: string[];
+ staticColliderFingerprint: string;
+}
+
+function fingerprintColliders(): string {
+ const defs = getStaticColliders();
+ return defs
+ .map((d) =>
+ [
+ d.id,
+ d.halfExtents.map((n) => n.toFixed(5)).join(','),
+ d.position.map((n) => n.toFixed(5)).join(','),
+ d.rotation.map((n) => n.toFixed(5)).join(','),
+ d.friction.toFixed(4),
+ ].join('|'),
+ )
+ .join(';');
+}
+
+/** FNV-1a 64-bit (as hex) — deterministic, isomorphic. */
+function fnv1aHex(input: string): string {
+ let h = 0xcbf29ce484222325n;
+ const prime = 0x100000001b3n;
+ for (let i = 0; i < input.length; i++) {
+ h ^= BigInt(input.charCodeAt(i));
+ h = (h * prime) & 0xffffffffffffffffn;
+ }
+ return h.toString(16).padStart(16, '0');
+}
+
+export function buildPhysicsConfigSnapshot(timestep: number): PhysicsConfigSnapshot {
+ const defs = getStaticColliders();
+ return {
+ timestep,
+ gravity: PHYSICS_GRAVITY,
+ pusherHalfExtents: [...PUSHER.halfExtents] as [number, number, number],
+ pusherCenterY: PUSHER.centerY,
+ pusherEngage: { x: PUSHER.engageX, z: PUSHER.engageZ },
+ staticColliderCount: defs.length,
+ staticColliderIds: defs.map((d) => d.id),
+ staticColliderFingerprint: fingerprintColliders(),
+ };
+}
+
+export function hashPhysicsConfig(snapshot: PhysicsConfigSnapshot): string {
+ return fnv1aHex(JSON.stringify(snapshot));
+}
+
+export function getRuntimePhysicsConfigHash(): string {
+ return hashPhysicsConfig(buildPhysicsConfigSnapshot(PHYSICS_TIMESTEP_SEC));
+}
+
+export function getHeadlessPhysicsConfigHash(headlessTimestep = PHYSICS_TIMESTEP_SEC): string {
+ return hashPhysicsConfig(buildPhysicsConfigSnapshot(headlessTimestep));
+}
+
+export function assertRuntimeHeadlessParity(headlessTimestep = PHYSICS_TIMESTEP_SEC): {
+ runtime: string;
+ headless: string;
+ equal: boolean;
+ timestepEqual: boolean;
+} {
+ const runtime = getRuntimePhysicsConfigHash();
+ const headless = getHeadlessPhysicsConfigHash(headlessTimestep);
+ return {
+ runtime,
+ headless,
+ equal: runtime === headless,
+ timestepEqual: headlessTimestep === PHYSICS_TIMESTEP_SEC,
+ };
+}
diff --git a/src/domain/physicsDropSim.ts b/src/domain/physicsDropSim.ts
index 87d8468..765c4a7 100644
--- a/src/domain/physicsDropSim.ts
+++ b/src/domain/physicsDropSim.ts
@@ -21,8 +21,9 @@ import { getPusherState, PUSHER } from './pusherMotion';
import { receiverContains, type ReceiverZone } from './receiverVolumes';
import { resolveItem } from '../data/resolveItem';
import { BELT_TOP_Y } from './physicalLayout';
+import { PHYSICS_TIMESTEP_SEC } from './physicsTimestep';
-export const SIM_DT = 1 / 60;
+export const SIM_DT = PHYSICS_TIMESTEP_SEC;
export const SIM_SETTLE_SECONDS = 6.0;
let rapierReady = false;
diff --git a/src/domain/physicsTimestep.ts b/src/domain/physicsTimestep.ts
new file mode 100644
index 0000000..9a27bdd
--- /dev/null
+++ b/src/domain/physicsTimestep.ts
@@ -0,0 +1,3 @@
+/** Shared fixed physics timestep — runtime Rapier and headless must match. */
+export const PHYSICS_TIMESTEP_SEC = 1 / 60;
+export const PHYSICS_GRAVITY: [number, number, number] = [0, -9.81, 0];
diff --git a/src/domain/stage2cCadVisual.test.ts b/src/domain/stage2cCadVisual.test.ts
new file mode 100644
index 0000000..b3b0e62
--- /dev/null
+++ b/src/domain/stage2cCadVisual.test.ts
@@ -0,0 +1,52 @@
+/**
+ * Stage 2C — unit tests for CAD assembly params + runtime/headless physics hash.
+ */
+import { describe, expect, it } from 'vitest';
+import {
+ CAD_ASSEMBLY_PARAMS,
+ CAD_TRANSFORM_MANIFEST,
+ CAD_ROLLER_PITCH_M,
+ CAD_CONVEYOR_WIDTH_M,
+ CAD_BELT_HEIGHT_M,
+} from './cadAssemblyParams';
+import {
+ assertRuntimeHeadlessParity,
+ PHYSICS_TIMESTEP_SEC,
+ buildPhysicsConfigSnapshot,
+} from './physicsConfigHash';
+import { SIM_DT } from './physicsDropSim';
+
+describe('Stage 2C CAD assembly params', () => {
+ it('preserves official belt width and height', () => {
+ expect(CAD_CONVEYOR_WIDTH_M).toBe(0.5);
+ expect(CAD_BELT_HEIGHT_M).toBe(0.7);
+ expect(CAD_ASSEMBLY_PARAMS.rollerPitch).toBe(CAD_ROLLER_PITCH_M);
+ });
+
+ it('documents motor on-frame AABB (not under floor)', () => {
+ const aabb = CAD_TRANSFORM_MANIFEST.motorWorldAabbApprox;
+ expect(aabb.min[1]).toBeGreaterThan(0.4);
+ expect(aabb.max[1]).toBeLessThan(0.8);
+ });
+});
+
+describe('Stage 2C runtime/headless physics parity', () => {
+ it('headless SIM_DT matches canonical timestep', () => {
+ expect(SIM_DT).toBe(PHYSICS_TIMESTEP_SEC);
+ });
+
+ it('hashes match for runtime and headless snapshots', () => {
+ const parity = assertRuntimeHeadlessParity(SIM_DT);
+ expect(parity.timestepEqual).toBe(true);
+ expect(parity.equal).toBe(true);
+ expect(parity.runtime).toBe(parity.headless);
+ expect(parity.runtime).toMatch(/^[a-f0-9]{16}$/);
+ });
+
+ it('snapshot includes static colliders and pusher geometry', () => {
+ const snap = buildPhysicsConfigSnapshot(PHYSICS_TIMESTEP_SEC);
+ expect(snap.staticColliderCount).toBeGreaterThan(5);
+ expect(snap.pusherHalfExtents[0]).toBe(0.5);
+ expect(snap.gravity).toEqual([0, -9.81, 0]);
+ });
+});
diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx
index c253ea8..9629955 100644
--- a/src/pages/MainPage.tsx
+++ b/src/pages/MainPage.tsx
@@ -241,7 +241,16 @@ export default function MainPage({
const caseProgress = getCaseProgress(playback);
const measurementData = useMemo(() => getMeasurementData(playback), [playback]);
+ // Stage 2C §6: large white MEASUREMENT panel is debug-only.
+ // Default / — never mount. ?debug=1 — compact. ?debug=1&measurement=full — full panel.
+ const measurementMode = useMemo(() => {
+ if (typeof window === 'undefined') return 'off' as const;
+ const params = new URLSearchParams(window.location.search);
+ if (params.get('debug') !== '1') return 'off' as const;
+ return params.get('measurement') === 'full' ? 'full' as const : 'compact' as const;
+ }, []);
const showMeasurement =
+ measurementMode !== 'off' &&
!presentationMode &&
shouldShowMeasurement(playback.currentPhase) &&
(isRunning || isPaused) &&
@@ -306,9 +315,29 @@ export default function MainPage({
)}
- {width >= 768 && (
+ {width >= 768 && measurementMode === 'full' && (
)}
+ {width >= 768 && measurementMode === 'compact' && showMeasurement && (
+
+
+ MEAS
+ {Math.round((measurementData.confidence ?? 0) * 100)}%
+
+
+
+ L×W×H
+
+ {measurementData.measuredLengthMm}×{measurementData.measuredWidthMm}×{measurementData.measuredHeightMm}
+
+
+
+ K
+ {measurementData.roundnessK.toFixed(2)}
+
+
+
+ )}
{!presentationMode && (!stage0.enabled || stage0.hud) && (
diff --git a/src/styles.css b/src/styles.css
index b75681a..2680b45 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -2046,9 +2046,12 @@ button:disabled {
.main-page {
position: relative;
width: 100vw;
- height: 100vh;
+ height: 100dvh;
+ height: 100vh; /* fallback */
+ min-height: 100dvh;
overflow: hidden;
background: var(--bg);
+ padding-bottom: env(safe-area-inset-bottom, 0);
}
.main-demo-viewport {
@@ -2057,11 +2060,26 @@ button:disabled {
z-index: 1;
}
-.main-demo-viewport .digital-twin-wrap {
+/* Stage 2C §7 — canvas-holder must fill viewport; % height of auto parent was collapsing Canvas. */
+.main-demo-viewport .canvas-holder {
+ position: absolute;
+ inset: 0;
+ width: 100%;
height: 100%;
+ min-height: 100%;
+}
+
+.main-demo-viewport .digital-twin-wrap {
+ position: absolute;
+ inset: 0;
+ height: 100%;
+ width: 100%;
}
.main-demo-viewport .digital-twin-canvas {
+ position: absolute;
+ inset: 0;
+ width: 100% !important;
height: 100% !important;
min-height: 100% !important;
border: none;
@@ -2544,7 +2562,7 @@ button:disabled {
left: 16px;
z-index: 15;
width: 220px;
- max-height: calc(100vh - 220px);
+ max-height: calc(100dvh - 220px);
overflow-y: auto;
border: 1px solid rgba(37, 99, 235, 0.2);
border-radius: 10px;
@@ -2553,6 +2571,16 @@ button:disabled {
box-shadow: 0 2px 16px rgba(37, 99, 235, 0.12);
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 10px;
+ pointer-events: auto;
+}
+
+.cv-overlay-compact {
+ width: 160px;
+ top: 88px;
+ left: auto;
+ right: 16px;
+ max-height: 120px;
+ pointer-events: none;
}
.cv-header {