fix: synchronize product visuals and apply Ozon light theme
Stop React RigidBody props from fighting Rapier mesh sync on playback ticks, and convert the shell/scene to the centralized Ozon light palette. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,12 +8,13 @@ import { getRenderedItemDimensions } from '../../domain/physicalLayout';
|
||||
import * as THREE from 'three';
|
||||
import RealItemModel, { isProductAssetReady } from './RealItemModel';
|
||||
import { ItemVerificationOverlay } from './RealModelVerification';
|
||||
import { CATEGORY_COLORS, INDUSTRIAL_PALETTE } from '../../domain/industrialTheme';
|
||||
|
||||
const COLORS = {
|
||||
B: '#16a34a',
|
||||
C: '#ea580c',
|
||||
D: '#7c3aed',
|
||||
sensorAccent: '#3b82f6',
|
||||
B: CATEGORY_COLORS.B,
|
||||
C: CATEGORY_COLORS.C,
|
||||
D: CATEGORY_COLORS.D,
|
||||
sensorAccent: INDUSTRIAL_PALETTE.sensorAccent,
|
||||
};
|
||||
|
||||
/** Base real-model materials per SKU (Stage 1 §20 — basic, form-revealing). */
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* C/D redirection is contact-only against kinematic CAD diverter colliders.
|
||||
* No junction setTranslation / route-specific lateral impulses.
|
||||
*/
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import {
|
||||
@@ -62,7 +62,7 @@ function colliderDensity(profile: ReturnType<typeof getProductPhysicsProfile>):
|
||||
return profile.massKg / Math.max(volume, 1e-6);
|
||||
}
|
||||
|
||||
export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhysics({
|
||||
function PhysicalPlaybackItemPhysicsInner({
|
||||
caseData,
|
||||
elapsedMs,
|
||||
slotIndex = 0,
|
||||
@@ -134,7 +134,7 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [caseData.id, profile.productId]);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
bodyIdentity.current = `${caseData.id}:${itemId}`;
|
||||
authority.current = isFault ? 'fault_kinematic' : 'preparing';
|
||||
phaseRef.current = 'preparing';
|
||||
@@ -148,6 +148,7 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
|
||||
setSpawned(ready);
|
||||
const body = bodyRef.current;
|
||||
if (body) {
|
||||
// Imperative spawn pose — never reapplied via React RigidBody props.
|
||||
body.setBodyType(RigidBodyType.KinematicPositionBased, false);
|
||||
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
||||
body.setAngvel({ x: 0, y: 0, z: 0 }, true);
|
||||
@@ -350,16 +351,17 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
|
||||
if (elapsedMs < 0) return null;
|
||||
|
||||
const density = colliderDensity(profile);
|
||||
const enabledRotations: [boolean, boolean, boolean] = [
|
||||
!profile.lockRotationX,
|
||||
!profile.lockRotationY,
|
||||
!profile.lockRotationZ,
|
||||
];
|
||||
const enabledRotations = useMemo<[boolean, boolean, boolean]>(
|
||||
() => [!profile.lockRotationX, !profile.lockRotationY, !profile.lockRotationZ],
|
||||
[profile.lockRotationX, profile.lockRotationY, profile.lockRotationZ],
|
||||
);
|
||||
|
||||
// Initial pose props are mount-only (component is memoized against elapsedMs
|
||||
// for non-fault cases). Never pass `type` — imperative setBodyType owns it.
|
||||
// World transform authority after spawn: Rapier body → R3F object3D sync.
|
||||
return (
|
||||
<RigidBody
|
||||
ref={bodyRef}
|
||||
type="kinematicPosition"
|
||||
colliders={false}
|
||||
friction={profile.beltFriction}
|
||||
restitution={profile.restitution}
|
||||
@@ -371,6 +373,8 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
|
||||
enabledRotations={enabledRotations}
|
||||
position={spawnPose.position}
|
||||
rotation={spawnPose.rotation}
|
||||
name={`product-body-${itemId}`}
|
||||
userData={{ productId: itemId, bodyIdentity: bodyIdentity.current }}
|
||||
>
|
||||
{spawned && profile.collider.type === 'cuboid' && (
|
||||
<CuboidCollider
|
||||
@@ -398,7 +402,7 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
|
||||
rotation={profile.collider.axis === 'x' ? [0, 0, Math.PI / 2] : undefined}
|
||||
/>
|
||||
)}
|
||||
<group visible={spawned}>
|
||||
<group name="ProductVisualGroup" visible={spawned}>
|
||||
<ItemVisualContent
|
||||
caseData={caseData}
|
||||
phase={pose.phase}
|
||||
@@ -411,4 +415,25 @@ export const PhysicalPlaybackItemPhysics = memo(function PhysicalPlaybackItemPhy
|
||||
</group>
|
||||
</RigidBody>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Block playback-tick re-renders for normal (non-fault) products so React
|
||||
* RigidBody props cannot fight Rapier's mesh sync every frame.
|
||||
*/
|
||||
export const PhysicalPlaybackItemPhysics = memo(
|
||||
PhysicalPlaybackItemPhysicsInner,
|
||||
(prev, next) => {
|
||||
if (prev.caseData.id !== next.caseData.id) return false;
|
||||
if (prev.slotIndex !== next.slotIndex) return false;
|
||||
if (prev.castShadow !== next.castShadow) return false;
|
||||
if (prev.verifySku !== next.verifySku) return false;
|
||||
if (prev.caseData.faultType !== next.caseData.faultType) return false;
|
||||
if (prev.jitter !== next.jitter) return false;
|
||||
// Fault kinematics still need elapsedMs; physical products do not.
|
||||
if (next.caseData.faultType) {
|
||||
return prev.elapsedMs === next.elapsedMs;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -114,12 +114,12 @@ const COLORS = {
|
||||
supports: INDUSTRIAL_PALETTE.plastic,
|
||||
motor: INDUSTRIAL_PALETTE.metalDark,
|
||||
sensorAccent: INDUSTRIAL_PALETTE.sensorAccent,
|
||||
sensorActive: '#60a5fa',
|
||||
sensorActive: INDUSTRIAL_PALETTE.sensorActive,
|
||||
gateFrame: INDUSTRIAL_PALETTE.metal,
|
||||
routeB: INDUSTRIAL_PALETTE.routeB,
|
||||
routeC: INDUSTRIAL_PALETTE.routeC,
|
||||
routeD: INDUSTRIAL_PALETTE.routeD,
|
||||
itemShadow: '#3a4a5a',
|
||||
itemShadow: INDUSTRIAL_PALETTE.metalDark,
|
||||
};
|
||||
|
||||
const ITEM_MATERIALS: Record<string, { color: string; roughness: number; metalness?: number }> = {
|
||||
@@ -505,10 +505,13 @@ function ZoneMarker({ position, label, color, active }: {
|
||||
</mesh>
|
||||
<Html position={[0, 0.15, 0]} center>
|
||||
<div style={{
|
||||
color: active ? color : '#64748b',
|
||||
color: active ? color : INDUSTRIAL_PALETTE.text,
|
||||
fontSize: '20px',
|
||||
fontWeight: 800,
|
||||
textShadow: active ? `0 0 8px ${color}` : 'none',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 8,
|
||||
background: 'rgba(255,255,255,0.88)',
|
||||
border: `1px solid ${active ? color : INDUSTRIAL_PALETTE.gridCell}`,
|
||||
userSelect: 'none',
|
||||
}}>
|
||||
{label}
|
||||
@@ -553,7 +556,16 @@ function BReceiverBin({ active }: { active: boolean }) {
|
||||
<meshStandardMaterial color={metal} roughness={0.65} metalness={0.35} />
|
||||
</mesh>
|
||||
<Html position={[centerX, floorY + wallHeight + 0.18, centerZ]} center>
|
||||
<div style={{ color: active ? COLORS.routeB : '#64748b', fontSize: '20px', fontWeight: 800, userSelect: 'none' }}>B</div>
|
||||
<div style={{
|
||||
color: active ? COLORS.routeB : INDUSTRIAL_PALETTE.text,
|
||||
fontSize: '20px',
|
||||
fontWeight: 800,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 8,
|
||||
background: 'rgba(255,255,255,0.88)',
|
||||
border: `1px solid ${active ? COLORS.routeB : INDUSTRIAL_PALETTE.gridCell}`,
|
||||
userSelect: 'none',
|
||||
}}>B</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
@@ -582,10 +594,13 @@ function RollCage({ position, label, color, active, shadows = false }: {
|
||||
{/* Label */}
|
||||
<Html position={[0, height + 0.15, 0]} center>
|
||||
<div style={{
|
||||
color: active ? color : '#64748b',
|
||||
color: active ? color : INDUSTRIAL_PALETTE.text,
|
||||
fontSize: '18px',
|
||||
fontWeight: 800,
|
||||
textShadow: active ? `0 0 8px ${color}` : 'none',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 8,
|
||||
background: 'rgba(255,255,255,0.88)',
|
||||
border: `1px solid ${active ? color : INDUSTRIAL_PALETTE.gridCell}`,
|
||||
userSelect: 'none',
|
||||
}}>
|
||||
{label}
|
||||
@@ -1025,8 +1040,9 @@ function ContinuousScene({
|
||||
const protoShadows = proto ? (proto && stage0!.shadows) : shadowsEnabled;
|
||||
const protoCamera = proto && stage0!.camera;
|
||||
const shotOverride = protoCamera && stage0!.shot ? shotToPhaseCategory(stage0!.shot) : null;
|
||||
// Stage 2: premium industrial dark environment is the default look.
|
||||
const darkBg = proto ? stage0!.darkBackground : true;
|
||||
// Ozon light presentation environment is the default product look.
|
||||
// Prototype stage0 may still request a dark background explicitly.
|
||||
const darkBg = proto ? stage0!.darkBackground : false;
|
||||
|
||||
const cameraHighlight = shouldHighlightCamera(phase);
|
||||
const showScan = shouldShowScanEffect(phase);
|
||||
@@ -1174,13 +1190,18 @@ function ContinuousScene({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Background: industrial dark product canvas (equipment must dominate) */}
|
||||
<color attach="background" args={[darkBg ? INDUSTRIAL_PALETTE.backgroundDark : '#070d16']} />
|
||||
<fog attach="fog" args={[darkBg ? INDUSTRIAL_PALETTE.backgroundDark : '#070d16', 13, 26]} />
|
||||
{/* Background: Ozon light presentation canvas (equipment stays readable) */}
|
||||
<color
|
||||
attach="background"
|
||||
args={[darkBg ? INDUSTRIAL_PALETTE.backgroundDark : INDUSTRIAL_PALETTE.background]}
|
||||
/>
|
||||
<fog
|
||||
attach="fog"
|
||||
args={[darkBg ? INDUSTRIAL_PALETTE.backgroundDark : INDUSTRIAL_PALETTE.background, 16, 32]}
|
||||
/>
|
||||
|
||||
{proto ? (
|
||||
<>
|
||||
{/* Cinematic rig: very low ambient, strong key, soft fill, cool rim */}
|
||||
<ambientLight intensity={stage0!.ambient} />
|
||||
<hemisphereLight args={['#223148', '#0b1220', 0.3]} />
|
||||
<directionalLight
|
||||
@@ -1197,21 +1218,18 @@ function ContinuousScene({
|
||||
shadow-camera-far={25}
|
||||
shadow-bias={-0.0004}
|
||||
/>
|
||||
{/* Fill — keeps shadowed side readable */}
|
||||
{stage0!.fill && <directionalLight position={[-5, 6, -3]} intensity={0.35} />}
|
||||
{/* Rim — cheap back light for edge separation */}
|
||||
{stage0!.rim && <directionalLight position={[2, 5, -8]} intensity={0.7} color="#bcd7ff" />}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Stage 2 default: premium industrial rig — readable ambient, key with
|
||||
PCF shadows, soft fill, cool rim (Stage 0 proven values, brightened
|
||||
in 2B so brackets/rollers read as volumes from every angle) */}
|
||||
<ambientLight intensity={0.46} />
|
||||
<hemisphereLight args={['#314860', '#0a1018', 0.9]} />
|
||||
{/* Light Ozon studio: cool ambient, neutral key, soft shadows */}
|
||||
<ambientLight intensity={0.72} />
|
||||
<hemisphereLight args={[INDUSTRIAL_PALETTE.lightFill, INDUSTRIAL_PALETTE.gridCell, 0.85]} />
|
||||
<directionalLight
|
||||
position={[6, 9, 4]}
|
||||
intensity={2.7}
|
||||
intensity={1.55}
|
||||
color={INDUSTRIAL_PALETTE.lightKey}
|
||||
castShadow={shadowsEnabled}
|
||||
shadow-mapSize-width={1024}
|
||||
shadow-mapSize-height={1024}
|
||||
@@ -1221,46 +1239,44 @@ function ContinuousScene({
|
||||
shadow-camera-bottom={-7}
|
||||
shadow-camera-near={1}
|
||||
shadow-camera-far={25}
|
||||
shadow-bias={-0.00035}
|
||||
shadow-normalBias={0.025}
|
||||
shadow-bias={-0.0003}
|
||||
shadow-normalBias={0.03}
|
||||
/>
|
||||
<directionalLight position={[-5, 6, -3]} intensity={0.7} />
|
||||
<directionalLight position={[2, 5, -8]} intensity={0.85} color="#bcd7ff" />
|
||||
{/* low front fill so the +Z face (camera side) never goes black */}
|
||||
<directionalLight position={[1, 3, 8]} intensity={0.55} color="#cfdcf2" />
|
||||
{/* Procedural studio environment (no external HDRI — offline-safe) */}
|
||||
<directionalLight position={[-5, 6, -3]} intensity={0.55} color={INDUSTRIAL_PALETTE.lightFill} />
|
||||
<directionalLight position={[2, 5, -8]} intensity={0.45} color={INDUSTRIAL_PALETTE.sensorActive} />
|
||||
<directionalLight position={[1, 3, 8]} intensity={0.4} color={INDUSTRIAL_PALETTE.lightKey} />
|
||||
{shadowsEnabled && (
|
||||
<Environment resolution={64} frames={1}>
|
||||
<Lightformer intensity={1.6} position={[0, 5, 0]} rotation-x={Math.PI / 2} scale={[8, 8, 1]} color="#dfe9ff" />
|
||||
<Lightformer intensity={0.7} position={[-5, 2, -4]} rotation-y={Math.PI / 3} scale={[4, 2, 1]} color="#b8c8e8" />
|
||||
<Lightformer intensity={0.5} position={[5, 1.5, 3]} rotation-y={-Math.PI / 4} scale={[3, 1.5, 1]} color="#ffe9c8" />
|
||||
<Lightformer intensity={1.8} position={[0, 5, 0]} rotation-x={Math.PI / 2} scale={[8, 8, 1]} color={INDUSTRIAL_PALETTE.lightKey} />
|
||||
<Lightformer intensity={0.6} position={[-5, 2, -4]} rotation-y={Math.PI / 3} scale={[4, 2, 1]} color={INDUSTRIAL_PALETTE.lightFill} />
|
||||
<Lightformer intensity={0.35} position={[5, 1.5, 3]} rotation-y={-Math.PI / 4} scale={[3, 1.5, 1]} color={INDUSTRIAL_PALETTE.lightKey} />
|
||||
</Environment>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Grid — subdued so equipment remains the visual subject */}
|
||||
{/* Grid — subtle Ozon blue-gray */}
|
||||
<Grid
|
||||
args={[16, 12]}
|
||||
cellSize={0.5}
|
||||
cellThickness={0.22}
|
||||
cellColor={darkBg ? '#1a2533' : '#1e2a38'}
|
||||
cellThickness={0.28}
|
||||
cellColor={darkBg ? INDUSTRIAL_PALETTE.metalDark : INDUSTRIAL_PALETTE.gridCell}
|
||||
sectionSize={2}
|
||||
sectionThickness={0.45}
|
||||
sectionColor={darkBg ? '#243344' : '#2a3a4c'}
|
||||
fadeDistance={10}
|
||||
sectionThickness={0.4}
|
||||
sectionColor={darkBg ? INDUSTRIAL_PALETTE.frame : INDUSTRIAL_PALETTE.sensorActive}
|
||||
fadeDistance={12}
|
||||
infiniteGrid={false}
|
||||
position={[0, 0.001, 0]}
|
||||
/>
|
||||
|
||||
{/* Floor — dark polished concrete with soft reflections */}
|
||||
{/* Floor — light cool gray */}
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]} receiveShadow={shadowsEnabled}>
|
||||
<planeGeometry args={[16, 12]} />
|
||||
<meshStandardMaterial
|
||||
color={darkBg ? '#121a26' : '#151d2a'}
|
||||
roughness={darkBg ? 0.64 : 0.8}
|
||||
metalness={darkBg ? 0.2 : 0.1}
|
||||
envMapIntensity={0.5}
|
||||
color={darkBg ? '#121a26' : INDUSTRIAL_PALETTE.floor}
|
||||
roughness={darkBg ? 0.64 : 0.88}
|
||||
metalness={darkBg ? 0.2 : 0.04}
|
||||
envMapIntensity={0.35}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
|
||||
@@ -1,30 +1,55 @@
|
||||
/**
|
||||
* Shared industrial visual tokens for the continuous digital twin.
|
||||
* Keeps both modes looking like projections of one system.
|
||||
* Shared visual tokens for the continuous digital twin.
|
||||
* Ozon light presentation environment (runtime materials / lights only).
|
||||
* CSS mirrors these values in `src/styles.css` (:root).
|
||||
*/
|
||||
|
||||
/** Canonical Ozon brand + semantic tokens (source of truth for tests / 3D). */
|
||||
export const OZON_TOKENS = {
|
||||
blue: '#005BFF',
|
||||
magenta: '#F1117E',
|
||||
darkSpace: '#001A34',
|
||||
morning: '#00A2FF',
|
||||
green: '#00BE6C',
|
||||
orange: '#FFA800',
|
||||
white: '#FFFFFF',
|
||||
page: '#F5F7FA',
|
||||
surface: '#FFFFFF',
|
||||
surfaceSoft: '#EEF4FF',
|
||||
border: '#DCE6F5',
|
||||
muted: '#5F6F82',
|
||||
} as const;
|
||||
|
||||
export const INDUSTRIAL_PALETTE = {
|
||||
background: '#e8eef4',
|
||||
backgroundDark: '#0b1220',
|
||||
floor: '#d5dde8',
|
||||
gridCell: '#c5d0de',
|
||||
gridSection: '#9aabbf',
|
||||
/** Scene clear / fog — soft Ozon morning surface */
|
||||
background: OZON_TOKENS.surfaceSoft,
|
||||
backgroundDark: OZON_TOKENS.darkSpace,
|
||||
floor: '#E2EAF5',
|
||||
gridCell: '#C5D4EA',
|
||||
gridSection: OZON_TOKENS.blue,
|
||||
belt: '#2f3a48',
|
||||
beltStripe: '#f1c40f',
|
||||
beltStripe: OZON_TOKENS.orange,
|
||||
metal: '#7b8796',
|
||||
metalDark: '#4a5563',
|
||||
frame: '#5b6b7c',
|
||||
plastic: '#94a3b8',
|
||||
rubber: '#1f2937',
|
||||
cardboard: '#b68b58',
|
||||
sensorAccent: '#3b82f6',
|
||||
routeB: '#16a34a',
|
||||
routeC: '#ea580c',
|
||||
routeD: '#7c3aed',
|
||||
fault: '#ef4444',
|
||||
warning: '#f59e0b',
|
||||
lightKey: '#f8fafc',
|
||||
lightFill: '#d0dae8',
|
||||
/** Ozon blue — primary technical markers */
|
||||
sensorAccent: OZON_TOKENS.blue,
|
||||
sensorActive: OZON_TOKENS.morning,
|
||||
/** Success / B */
|
||||
routeB: OZON_TOKENS.green,
|
||||
/** Attention / C */
|
||||
routeC: OZON_TOKENS.orange,
|
||||
/** Active selection / D accent (magenta used selectively) */
|
||||
routeD: OZON_TOKENS.magenta,
|
||||
fault: OZON_TOKENS.magenta,
|
||||
warning: OZON_TOKENS.orange,
|
||||
success: OZON_TOKENS.green,
|
||||
lightKey: OZON_TOKENS.white,
|
||||
lightFill: OZON_TOKENS.surfaceSoft,
|
||||
text: OZON_TOKENS.darkSpace,
|
||||
} as const;
|
||||
|
||||
export const INDUSTRIAL_MATERIALS = {
|
||||
|
||||
96
src/domain/productVisualOzon.test.ts
Normal file
96
src/domain/productVisualOzon.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
SETTLE_DURATION_SEC,
|
||||
SETTLE_LINEAR_SPEED_MPS,
|
||||
SETTLE_ANGULAR_SPEED_RAD_S,
|
||||
DOCUMENTED_CONTACT_PLANE_S,
|
||||
DOCUMENTED_CLEAR_PLANE_S,
|
||||
} from './junctionContactPhysics';
|
||||
import { BELT_SPEED_MPS } from './productPhysicsProfiles';
|
||||
import { PHYSICS_TIMESTEP_SEC } from './physicsTimestep';
|
||||
import {
|
||||
DIVERTER_LEFT_SIGNED_DEG,
|
||||
DIVERTER_RIGHT_SIGNED_DEG,
|
||||
categoryToPhysicalRoute,
|
||||
OPENING_SAFETY_MARGIN_SEC,
|
||||
rotationDurationSec,
|
||||
} from './pusherMotion';
|
||||
import { INDUSTRIAL_PALETTE, OZON_TOKENS } from './industrialTheme';
|
||||
import physicsSrc from '../components/ThreeD/PhysicalPlaybackItemPhysics.tsx?raw';
|
||||
import visualSrc from '../components/ThreeD/PhysicalPlaybackItem.tsx?raw';
|
||||
import sceneSrc from '../components/ThreeD/SorterDigitalTwinContinuous.tsx?raw';
|
||||
import mainSrc from '../main.tsx?raw';
|
||||
|
||||
describe('product visual / rigid-body sync contracts', () => {
|
||||
it('keeps a single RigidBody visual hierarchy with mount-only pose + memo', () => {
|
||||
expect(physicsSrc).toMatch(/<RigidBody[\s\S]*ProductVisualGroup[\s\S]*ItemVisualContent[\s\S]*<\/RigidBody>/);
|
||||
expect(physicsSrc).toMatch(/name="ProductVisualGroup"/);
|
||||
// Initial pose is allowed; continuous type control is not (fights Dynamic).
|
||||
expect(physicsSrc).toMatch(/position=\{spawnPose\.position\}/);
|
||||
expect(physicsSrc).not.toMatch(/type="kinematicPosition"/);
|
||||
expect(physicsSrc).toMatch(/setBodyType\(RigidBodyType\.Dynamic/);
|
||||
expect(physicsSrc).toMatch(/Block playback-tick re-renders/);
|
||||
expect(physicsSrc).toMatch(/castShadow=\{castShadow && spawned\}/);
|
||||
});
|
||||
|
||||
it('visual model uses only local pivot offset (no world position writer)', () => {
|
||||
expect(visualSrc).toMatch(/pivotOffsetY/);
|
||||
expect(visualSrc).toMatch(/<group position=\{\[0, pivotOffsetY, 0\]\}>/);
|
||||
expect(visualSrc).not.toMatch(/position=\{pose\.position\}/);
|
||||
expect(visualSrc).not.toMatch(/position=\{currentWorld/);
|
||||
expect(visualSrc).not.toMatch(/mesh\.position\.copy/);
|
||||
});
|
||||
|
||||
it('one physics instance path remains (no duplicate PhysicalPlaybackItem mount)', () => {
|
||||
const physicsMounts = sceneSrc.match(/PhysicalPlaybackItemPhysics/g) ?? [];
|
||||
const kinematicMounts = sceneSrc.match(/<PhysicalPlaybackItem[\s>]/g) ?? [];
|
||||
expect(physicsMounts.length).toBeGreaterThanOrEqual(1);
|
||||
expect(kinematicMounts.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('falling / physics freeze regression', () => {
|
||||
it('receiver settle thresholds unchanged', () => {
|
||||
expect(SETTLE_LINEAR_SPEED_MPS).toBe(0.20);
|
||||
expect(SETTLE_ANGULAR_SPEED_RAD_S).toBe(1.0);
|
||||
expect(SETTLE_DURATION_SEC).toBe(0.30);
|
||||
});
|
||||
|
||||
it('B/C/D routing, belt, timestep, diverter timing unchanged', () => {
|
||||
expect(categoryToPhysicalRoute('B')).toBe('STRAIGHT');
|
||||
expect(categoryToPhysicalRoute('C')).toBe('PHYSICAL_LEFT');
|
||||
expect(categoryToPhysicalRoute('D')).toBe('PHYSICAL_RIGHT');
|
||||
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);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ozon light theme tokens', () => {
|
||||
it('defines centralized Ozon tokens; CSS stylesheet is wired', () => {
|
||||
expect(OZON_TOKENS.blue).toBe('#005BFF');
|
||||
expect(OZON_TOKENS.magenta).toBe('#F1117E');
|
||||
expect(OZON_TOKENS.darkSpace).toBe('#001A34');
|
||||
expect(OZON_TOKENS.morning).toBe('#00A2FF');
|
||||
expect(OZON_TOKENS.green).toBe('#00BE6C');
|
||||
expect(OZON_TOKENS.orange).toBe('#FFA800');
|
||||
expect(OZON_TOKENS.page).toBe('#F5F7FA');
|
||||
expect(OZON_TOKENS.surfaceSoft).toBe('#EEF4FF');
|
||||
// Magenta is selective accent — never the page background token.
|
||||
expect(OZON_TOKENS.page).not.toBe(OZON_TOKENS.magenta);
|
||||
expect(mainSrc).toMatch(/styles\.css/);
|
||||
});
|
||||
|
||||
it('scene default is light Ozon environment', () => {
|
||||
expect(INDUSTRIAL_PALETTE.background.toUpperCase()).toBe('#EEF4FF');
|
||||
expect(INDUSTRIAL_PALETTE.sensorAccent.toUpperCase()).toBe('#005BFF');
|
||||
expect(INDUSTRIAL_PALETTE.routeB.toUpperCase()).toBe('#00BE6C');
|
||||
expect(sceneSrc).toMatch(/const darkBg = proto \? stage0!\.darkBackground : false/);
|
||||
expect(sceneSrc).toMatch(/INDUSTRIAL_PALETTE\.background/);
|
||||
});
|
||||
});
|
||||
207
src/styles.css
207
src/styles.css
@@ -1,25 +1,54 @@
|
||||
/* =========================================================
|
||||
OZON Tech Sorter — Product Demo UI
|
||||
OZON Tech Sorter — Product Demo UI (light Ozon palette)
|
||||
========================================================= */
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #050910;
|
||||
--panel: rgba(10, 19, 31, 0.92);
|
||||
--panel-strong: #0c1725;
|
||||
--line: #1e3a54;
|
||||
--muted: #91a4b8;
|
||||
--text: #e5f2ff;
|
||||
--cyan: #38bdf8;
|
||||
--cyan-hover: #7dd3fc;
|
||||
--green: #4ade80;
|
||||
--orange: #f59e0b;
|
||||
--purple: #c084fc;
|
||||
--red: #fb3d4e;
|
||||
color-scheme: light;
|
||||
|
||||
/* Brand tokens */
|
||||
--ozon-blue: #005BFF;
|
||||
--ozon-magenta: #F1117E;
|
||||
--ozon-dark-space: #001A34;
|
||||
--ozon-morning: #00A2FF;
|
||||
--ozon-green: #00BE6C;
|
||||
--ozon-orange: #FFA800;
|
||||
--ozon-white: #FFFFFF;
|
||||
--ozon-page: #F5F7FA;
|
||||
--ozon-surface: #FFFFFF;
|
||||
--ozon-surface-soft: #EEF4FF;
|
||||
--ozon-border: #DCE6F5;
|
||||
--ozon-text: #001A34;
|
||||
--ozon-muted: #5F6F82;
|
||||
|
||||
/* Semantic */
|
||||
--brand-primary: var(--ozon-blue);
|
||||
--brand-secondary: var(--ozon-magenta);
|
||||
--brand-light: var(--ozon-morning);
|
||||
--status-success: var(--ozon-green);
|
||||
--status-warning: var(--ozon-orange);
|
||||
--text-primary: var(--ozon-dark-space);
|
||||
--page-background: var(--ozon-page);
|
||||
--panel-background: var(--ozon-surface);
|
||||
--panel-border: var(--ozon-border);
|
||||
--focus-ring: var(--ozon-blue);
|
||||
|
||||
/* Compat aliases used across the stylesheet */
|
||||
--bg: var(--page-background);
|
||||
--panel: var(--panel-background);
|
||||
--panel-strong: var(--ozon-surface-soft);
|
||||
--line: var(--panel-border);
|
||||
--muted: var(--ozon-muted);
|
||||
--text: var(--text-primary);
|
||||
--cyan: var(--brand-primary);
|
||||
--cyan-hover: var(--brand-light);
|
||||
--green: var(--status-success);
|
||||
--orange: var(--status-warning);
|
||||
--purple: var(--brand-secondary);
|
||||
--red: var(--brand-secondary);
|
||||
--section-max: 1200px;
|
||||
--radius: 16px;
|
||||
--radius: 14px;
|
||||
--tap: 44px;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "Manrope", "Segoe UI", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -45,9 +74,9 @@ body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 18% 8%, rgba(56, 189, 248, 0.14), transparent 28%),
|
||||
radial-gradient(circle at 88% 18%, rgba(192, 132, 252, 0.1), transparent 24%),
|
||||
linear-gradient(160deg, #03060b 0%, #07111d 52%, #050910 100%);
|
||||
radial-gradient(circle at 12% 0%, rgba(0, 162, 255, 0.10), transparent 32%),
|
||||
radial-gradient(circle at 88% 8%, rgba(0, 91, 255, 0.06), transparent 28%),
|
||||
linear-gradient(180deg, var(--ozon-surface-soft) 0%, var(--page-background) 42%, var(--page-background) 100%);
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
@@ -77,25 +106,26 @@ a {
|
||||
|
||||
button {
|
||||
min-height: var(--tap);
|
||||
border: 1px solid #2a4a66;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 12px;
|
||||
background: #0e1b2b;
|
||||
color: var(--text);
|
||||
background: var(--panel-background);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 26, 52, 0.06);
|
||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: var(--cyan);
|
||||
background: #11273b;
|
||||
border-color: var(--brand-primary);
|
||||
background: var(--ozon-surface-soft);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
.nav-link:focus-visible,
|
||||
.criteria-link:focus-visible {
|
||||
outline: 2px solid var(--cyan);
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -121,19 +151,20 @@ button:disabled {
|
||||
|
||||
.btn-primary {
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #38bdf8, #0ea5e9);
|
||||
color: #041018;
|
||||
background: var(--brand-primary);
|
||||
color: var(--ozon-white);
|
||||
box-shadow: 0 2px 8px rgba(0, 91, 255, 0.28);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #7dd3fc, #38bdf8);
|
||||
color: #041018;
|
||||
background: var(--brand-light);
|
||||
color: var(--ozon-white);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #0e1b2b;
|
||||
color: var(--text);
|
||||
border: 1px solid #2a4a66;
|
||||
background: var(--panel-background);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--panel-border);
|
||||
}
|
||||
|
||||
.category-B { color: var(--green); }
|
||||
@@ -3686,7 +3717,7 @@ button:disabled {
|
||||
height: 100vh;
|
||||
max-width: 100vw;
|
||||
overflow: hidden;
|
||||
background: #070d16;
|
||||
background: var(--page-background);
|
||||
}
|
||||
|
||||
.main-page--product .product-topbar,
|
||||
@@ -3701,8 +3732,8 @@ button:disabled {
|
||||
height: 68px;
|
||||
max-height: 72px;
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid rgba(56, 189, 248, 0.18);
|
||||
background: rgba(6, 12, 20, 0.88);
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
background: var(--ozon-white);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
@@ -3720,9 +3751,9 @@ button:disabled {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid rgba(56, 189, 248, 0.4);
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
color: #7dd3fc;
|
||||
border: 1px solid var(--panel-border);
|
||||
background: var(--ozon-surface-soft);
|
||||
color: var(--brand-primary);
|
||||
font-weight: 800;
|
||||
font-size: 15px;
|
||||
flex: 0 0 auto;
|
||||
@@ -3732,7 +3763,7 @@ button:disabled {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
color: #e8f2ff;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.product-brand-sub {
|
||||
@@ -3829,7 +3860,7 @@ button:disabled {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
background: #070d16;
|
||||
background: var(--page-background);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@@ -3840,8 +3871,8 @@ button:disabled {
|
||||
height: 78px;
|
||||
max-height: 84px;
|
||||
padding: 0 24px 10px;
|
||||
border-top: 1px solid rgba(56, 189, 248, 0.18);
|
||||
background: rgba(6, 12, 20, 0.9);
|
||||
border-top: 1px solid var(--panel-border);
|
||||
background: var(--ozon-white);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
@@ -3895,7 +3926,9 @@ button:disabled {
|
||||
position: static;
|
||||
min-height: 40px;
|
||||
border-radius: 10px;
|
||||
background: rgba(12, 22, 34, 0.9);
|
||||
background: var(--ozon-surface-soft);
|
||||
border: 1px solid var(--panel-border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.product-sim-pill {
|
||||
@@ -3990,3 +4023,89 @@ button:disabled {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Ozon light chrome overrides (shell / HUD / nav / controls)
|
||||
========================================================= */
|
||||
|
||||
.main-hud,
|
||||
.app-nav-overlay,
|
||||
.app-nav-bar,
|
||||
.main-progress {
|
||||
background: rgba(255, 255, 255, 0.92) !important;
|
||||
border-color: var(--panel-border) !important;
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 4px 18px rgba(0, 26, 52, 0.08);
|
||||
}
|
||||
|
||||
.play-button,
|
||||
.main-page--product .play-button {
|
||||
background: var(--brand-primary) !important;
|
||||
color: var(--ozon-white) !important;
|
||||
box-shadow: 0 4px 16px rgba(0, 91, 255, 0.28);
|
||||
}
|
||||
|
||||
.play-button:hover {
|
||||
background: var(--brand-light) !important;
|
||||
}
|
||||
|
||||
.app-nav-link.active {
|
||||
color: var(--ozon-white) !important;
|
||||
background: var(--brand-primary) !important;
|
||||
border-color: var(--brand-primary) !important;
|
||||
}
|
||||
|
||||
.app-nav-link:hover {
|
||||
color: var(--brand-primary);
|
||||
border-color: var(--panel-border);
|
||||
background: var(--ozon-surface-soft);
|
||||
}
|
||||
|
||||
.product-topbar,
|
||||
.docs-product-topbar,
|
||||
.main-page--product .product-topbar,
|
||||
.docs-page .product-topbar {
|
||||
background: var(--ozon-white) !important;
|
||||
border-bottom: 1px solid var(--panel-border) !important;
|
||||
color: var(--text-primary) !important;
|
||||
box-shadow: 0 1px 0 rgba(0, 26, 52, 0.04);
|
||||
}
|
||||
|
||||
.product-brand-title,
|
||||
.product-tele-value {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.product-brand-sub,
|
||||
.product-tele-label {
|
||||
color: var(--ozon-muted) !important;
|
||||
}
|
||||
|
||||
.main-page--product .app-nav-solid,
|
||||
.docs-page .product-topbar .app-nav-solid {
|
||||
background: var(--ozon-surface-soft) !important;
|
||||
border-color: var(--panel-border) !important;
|
||||
}
|
||||
|
||||
.product-webgl-note,
|
||||
.docs-page {
|
||||
background: var(--page-background) !important;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.product-tele-value.category-B { color: var(--status-success) !important; }
|
||||
.product-tele-value.category-C { color: var(--status-warning) !important; }
|
||||
.product-tele-value.category-D { color: var(--brand-secondary) !important; }
|
||||
|
||||
.progress-dot {
|
||||
background: var(--ozon-border);
|
||||
}
|
||||
|
||||
.main-page--product .product-dock,
|
||||
.main-page--product .main-controls .auto-camera-toggle,
|
||||
.main-page--product .presentation-toggle,
|
||||
.stop-button {
|
||||
background: var(--ozon-white) !important;
|
||||
border-color: var(--panel-border) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user