From 71268ef7475b2420aa6aa44ab72d0c0dc68cbc44 Mon Sep 17 00:00:00 2001 From: NellInc Date: Mon, 10 Aug 2026 12:23:39 +0100 Subject: [PATCH] Polish v0.40 operational fidelity and delivery --- e2e/master-refinement.spec.ts | 7 +- package-lock.json | 12 +- public/release-navigation.js | 2 +- scripts/run-performance-benchmark.mjs | 21 +- src/App.tsx | 6 +- .../ConveyorSystem.kinematics.test.ts | 16 + src/components/ConveyorSystem.tsx | 377 ++++++++---------- src/components/LoadingScreen.tsx | 79 +++- src/components/RuntimeController.tsx | 8 +- src/components/SceneOrbitControls.tsx | 6 + .../performance/StaticMeshBatch.test.ts | 18 + .../performance/StaticMeshBatch.tsx | 12 +- .../ui-new/sidebar/ContextSidebar.tsx | 11 +- src/config/releaseNavigationBridge.test.ts | 6 + src/config/releaseVersions.test.ts | 12 +- src/config/releaseVersions.ts | 4 + src/stores/aiConfigStore.ts | 3 +- src/utils/geminiClient.test.ts | 10 + src/utils/geminiClient.ts | 56 +-- vite.config.ts | 31 +- 20 files changed, 389 insertions(+), 308 deletions(-) create mode 100644 src/components/ConveyorSystem.kinematics.test.ts create mode 100644 src/components/SceneOrbitControls.tsx create mode 100644 src/utils/geminiClient.test.ts diff --git a/e2e/master-refinement.spec.ts b/e2e/master-refinement.spec.ts index a74a45b..a49dc67 100644 --- a/e2e/master-refinement.spec.ts +++ b/e2e/master-refinement.spec.ts @@ -263,7 +263,12 @@ test.describe('MillOS master refinement runtime', () => { name: 'Select MillOS version', }); await expect(versionSelector).toHaveValue('v0.40'); - await expect(versionSelector.locator('option')).toHaveText(['0.40', '0.30', '0.20', '0.10']); + await expect(versionSelector.locator('option')).toHaveText([ + '0.40 (current)', + '0.30 (historical)', + '0.20 (historical)', + '0.10 (historical)', + ]); await expect( overviewSidebar.getByRole('alert', { name: 'Mill Overview unavailable' }) ).toHaveCount(0); diff --git a/package-lock.json b/package-lock.json index f842969..0ab4885 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5735,9 +5735,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -6341,9 +6341,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", diff --git a/public/release-navigation.js b/public/release-navigation.js index d501d94..a470b29 100644 --- a/public/release-navigation.js +++ b/public/release-navigation.js @@ -18,7 +18,7 @@ for (const release of matrix.releases) { const option = document.createElement('option'); option.value = release.version; - option.textContent = release.label; + option.textContent = `${release.label} (${release.type === 'current' ? 'current' : 'historical'})`; selector.append(option); } selector.value = matrix.releases.some((release) => release.version === activeVersion) diff --git a/scripts/run-performance-benchmark.mjs b/scripts/run-performance-benchmark.mjs index f292a92..440129f 100644 --- a/scripts/run-performance-benchmark.mjs +++ b/scripts/run-performance-benchmark.mjs @@ -328,6 +328,7 @@ function summarizeMotion(samples) { distance: 0, phases: [], cargoStates: [], + stopReasons: [], telemetry: {}, lastPosition: null, }; @@ -344,6 +345,9 @@ function summarizeMotion(samples) { if (entity.cargo && current.cargoStates.at(-1) !== entity.cargo) { current.cargoStates.push(entity.cargo); } + if (entity.stopReason && current.stopReasons.at(-1) !== entity.stopReason) { + current.stopReasons.push(entity.stopReason); + } for (const key of numericTelemetryKeys) { const value = entity[key]; if (!Number.isFinite(value)) continue; @@ -401,6 +405,10 @@ function evaluateMotionAcceptance(samples, summary) { .every((entity) => Math.abs(entity.articulation ?? 0) <= 0.701) ); const movingEntities = summary.filter((entity) => entity.distance > 0.25); + const stationaryEntities = summary.filter((entity) => entity.distance <= 0.25); + const stationaryStatesExplained = stationaryEntities.every((entity) => + entity.stopReasons.some((reason) => reason !== 'none') + ); const wheelTravelFollowsMotion = movingEntities.every( (entity) => Math.abs(entity.telemetry.wheelTravel?.delta ?? 0) > 0.1 ); @@ -410,6 +418,14 @@ function evaluateMotionAcceptance(samples, summary) { { id: 'bounded-steering', passed: boundedSteering }, { id: 'bounded-articulation', passed: boundedArticulation }, { id: 'vehicle-motion-observed', passed: movingEntities.length > 0 }, + { + id: 'stationary-vehicles-have-interlock-reason', + passed: stationaryStatesExplained, + observed: stationaryEntities.map((entity) => ({ + id: entity.id, + stopReasons: entity.stopReasons, + })), + }, { id: 'wheel-travel-follows-motion', passed: wheelTravelFollowsMotion }, ]; return { passed: checks.every((check) => check.passed), checks }; @@ -789,7 +805,10 @@ async function main() { systemComparisons: { scada: scadaComparisons, }, - passed: results.every((result) => result.budget.passed && result.motionAcceptance.passed), + passed: results.every( + (result) => + result.budget.passed && (options.startupOnly || result.motionAcceptance?.passed === true) + ), results, }; const reportPath = path.join(options.output, 'benchmark.json'); diff --git a/src/App.tsx b/src/App.tsx index 88d080b..dc538fb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,6 @@ import React, { useState, Suspense, useEffect, useCallback, useRef } from 'react'; import { Canvas } from '@react-three/fiber'; -import { OrbitControls } from '@react-three/drei'; -import { OrbitControls as OrbitControlsImpl } from 'three-stdlib'; +import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib'; import * as THREE from 'three'; import { trackRender } from './utils/renderProfiler'; import './utils/perfMonitor'; @@ -58,6 +57,7 @@ import { installAtmosphericFogChunks } from './shaders/atmosphericFog'; installAtmosphericFogChunks(); const PhysicsScene = recoverableLazy(() => import('./components/PhysicsScene')); +const DeferredOrbitControls = recoverableLazy(() => import('./components/SceneOrbitControls')); const AuthoredMillScene = recoverableLazy(() => import('./components/MillScene').then((module) => ({ default: module.MillScene })) ); @@ -844,7 +844,7 @@ const App: React.FC = () => { ) ) : ( - { + it('moves at belt speed and caps a resumed-frame delta', () => { + expect(advanceBagPosition(0, 5, 1, 1)).toBeCloseTo(0.5); + }); + + it('preserves overflow when a bag wraps around the belt', () => { + expect(advanceBagPosition(27.9, 5, 1, 0.1)).toBeCloseTo(-27.6); + }); + + it('does not reverse while production is stopped', () => { + expect(advanceBagPosition(4, 5, -1, 0.1)).toBe(4); + }); +}); diff --git a/src/components/ConveyorSystem.tsx b/src/components/ConveyorSystem.tsx index 56d9684..0b2f838 100644 --- a/src/components/ConveyorSystem.tsx +++ b/src/components/ConveyorSystem.tsx @@ -1,7 +1,6 @@ -import React, { useRef, useMemo, useEffect, useState } from 'react'; +import React, { useRef, useMemo, useEffect, useLayoutEffect, useState } from 'react'; import { useFrame } from '@react-three/fiber'; import { Html } from '@react-three/drei'; -import { SceneText as Text } from './shared/SceneText'; import * as THREE from 'three'; import { useShallow } from 'zustand/react/shallow'; import { audioManager } from '../utils/audioManager'; @@ -63,31 +62,6 @@ export const unregisterConveyorAudio = (id: string) => { conveyorAudioRegistry.delete(id); }; -// Module-level registry for centralized bag animations (15-60 bags → 1 useFrame) -interface BagAnimationState { - ref: THREE.Group; - speed: number; - currentX: number; - crossedBoundary: boolean; -} -const bagAnimationRegistry = new Map(); - -export const registerBagAnimation = (id: string, state: BagAnimationState) => { - bagAnimationRegistry.set(id, state); -}; - -export const unregisterBagAnimation = (id: string) => { - bagAnimationRegistry.delete(id); -}; - -export const updateBagPosition = (id: string, x: number, crossedBoundary: boolean) => { - const state = bagAnimationRegistry.get(id); - if (state) { - state.currentX = x; - state.crossedBoundary = crossedBoundary; - } -}; - // Generate batch number in format: YYYYMMDD-XXX const generateBatchNumber = (index: number): string => { const date = new Date(); @@ -411,47 +385,6 @@ const ConveyorAudioManager: React.FC<{ productionSpeed: number }> = ({ productio return null; }; -// Centralized bag animation manager - updates all bags in ONE useFrame (15-60 bags → 1 call) -// NOTE: This is purely visual animation - production counting is handled by App.tsx -// interval-based system which scales with gameSpeed for proper game-time production -const BagAnimationManager: React.FC<{ - productionSpeed: number; -}> = ({ productionSpeed }) => { - const isTabVisible = useGameSimulationStore((state) => state.isTabVisible); - - useFrame((_, delta) => { - // PERFORMANCE: Skip when tab hidden or production stopped - if (!isTabVisible || productionSpeed === 0) return; - // Skip if no bags registered - if (bagAnimationRegistry.size === 0) return; - - // Deliberately NOT throttled. This loop is <= 60 iterations of two float - // ops; at a 3-frame throttle bags advanced 0.25 world units per step, which - // is visible stepping against a belt surface that now scrolls at 60 Hz. - // Cap delta to prevent huge jumps when tab regains focus (max 100ms). - const cappedDelta = Math.min(delta, 0.1); - - // Update all bags in a single pass (visual only - no production counting) - bagAnimationRegistry.forEach((state) => { - if (!state.ref) return; - - state.currentX += state.speed * productionSpeed * cappedDelta; - - // Wrap bag when it crosses the boundary (visual continuity) - if (state.currentX > BAG_BOUNDARY) { - // Preserve overflow to prevent stuttering/bunching - const overflow = state.currentX - BAG_BOUNDARY; - state.currentX = -BAG_BOUNDARY + overflow; - } - - // Apply position to mesh - state.ref.position.x = state.currentX; - }); - }); - - return null; -}; - interface ConveyorSystemProps { productionSpeed: number; } @@ -514,9 +447,6 @@ export const ConveyorSystem = React.memo(({ productionSpeed {/* Centralized audio manager - updates all conveyors in one pass */} - {/* Centralized bag animation manager - updates all bags in ONE useFrame */} - - {/* Ambient contact darkening on the floor beneath every belt run. One triangle pair + one texture fetch each; works at EVERY tier including `low`, where there is no shadow-casting light at all. @@ -542,10 +472,8 @@ export const ConveyorSystem = React.memo(({ productionSpeed ))} - {/* Flour bags */} - {bags.map((bag) => ( - - ))} + {/* Flour bags: two animated instanced draws, one body and one quality stripe. */} + {/* Roller conveyor to packing with enhanced details - moved to z=21 */} @@ -1326,19 +1254,13 @@ const getFlourSackGeometry = (): THREE.BufferGeometry => { }; /** - * Two shared sack materials (idle + hovered) instead of 60 inline ones. - * - * `color` is WHITE on purpose. The grain generator's bytes are now correctly - * tagged sRGB, so the albedo map already carries the cloth hue; the old - * `#fef3c7` was compensating for the map not being bound at all and would now - * multiply the same cream in twice. + * One shared sack material for every instance. `color` is white because the + * correctly tagged sRGB albedo map already carries the cloth hue. Per-instance + * colour supplies the restrained hover highlight without another draw call. */ -let flourSackMaterialCache: { - base: THREE.MeshStandardMaterial; - hovered: THREE.MeshStandardMaterial; -} | null = null; +let flourSackMaterialCache: THREE.MeshStandardMaterial | null = null; -const getFlourSackMaterials = () => { +const getFlourSackMaterial = (): THREE.MeshStandardMaterial => { if (flourSackMaterialCache) return flourSackMaterialCache; const source = getFlourSackMaps(); @@ -1351,7 +1273,7 @@ const getFlourSackMaterials = () => { return clone; }; - const base = new THREE.MeshStandardMaterial({ + flourSackMaterialCache = new THREE.MeshStandardMaterial({ color: '#ffffff', map: tile(source.map), normalMap: tile(source.normal), @@ -1361,148 +1283,175 @@ const getFlourSackMaterials = () => { metalness: 0, envMapIntensity: 0.7, }); - - const hovered = base.clone(); - hovered.emissive = new THREE.Color('#fbbf24'); - // Stays under 1.0 linear, so this is safe on `low` where there is no composer - // and `toneMapped` clamping would flatten a brighter value to white. - hovered.emissiveIntensity = 0.12; - - flourSackMaterialCache = { base, hovered }; return flourSackMaterialCache; }; -// FlourBagMesh - now uses centralized animation via BagAnimationManager (15-60 bags → 1 useFrame) -const FlourBagMesh: React.FC<{ data: FlourBag }> = React.memo(({ data }) => { - const ref = useRef(null); - const [hovered, setHovered] = useState(false); - const enableProceduralTextures = useGraphicsStore( - useShallow((state) => state.graphics.enableProceduralTextures) +const FLOUR_STRIPE_GEOMETRY = new THREE.PlaneGeometry(0.5, 0.3); +const FLOUR_STRIPE_MATERIAL = new THREE.MeshBasicMaterial({ + color: '#ffffff', + depthWrite: false, + polygonOffset: true, + polygonOffsetFactor: POLYGON_OFFSET.standard.factor, + polygonOffsetUnits: POLYGON_OFFSET.standard.units, +}); +const BAG_BODY_OFFSET = new THREE.Matrix4().makeTranslation(0, 0.25, 0); +const BAG_STRIPE_OFFSET = new THREE.Matrix4().makeTranslation(0, 0.25, 0.48); +const BAG_SCALE = new THREE.Vector3(1, 1, 1); +const BAG_UP = new THREE.Vector3(0, 1, 0); +const BAG_IDLE_COLOR = new THREE.Color('#ffffff'); +const BAG_HOVER_COLOR = new THREE.Color('#ffd99a'); + +export function advanceBagPosition( + currentX: number, + speed: number, + productionSpeed: number, + delta: number +): number { + const cappedDelta = Math.min(Math.max(delta, 0), 0.1); + const next = currentX + speed * Math.max(0, productionSpeed) * cappedDelta; + if (next <= BAG_BOUNDARY) return next; + return -BAG_BOUNDARY + ((next - BAG_BOUNDARY) % (BAG_BOUNDARY * 2)); +} + +/** + * Medium previously rendered 30 sacks as 60 individual draws. The body and + * quality stripe now remain independently shaded, animated and pickable in two + * instanced draws. One tooltip follows the selected instance. + */ +const InstancedFlourBags: React.FC<{ + bags: FlourBag[]; + productionSpeed: number; +}> = React.memo(({ bags, productionSpeed }) => { + const bodiesRef = useRef(null); + const stripesRef = useRef(null); + const tooltipRef = useRef(null); + const positionsRef = useRef(bags.map((bag) => bag.position[0])); + const [hoveredIndex, setHoveredIndex] = useState(null); + const isTabVisible = useGameSimulationStore((state) => state.isTabVisible); + const groupPosition = useMemo(() => new THREE.Vector3(), []); + const groupRotation = useMemo(() => new THREE.Quaternion(), []); + const groupMatrix = useMemo(() => new THREE.Matrix4(), []); + const instanceMatrix = useMemo(() => new THREE.Matrix4(), []); + const qualityColours = useMemo( + () => bags.map((bag) => new THREE.Color(QUALITY_COLORS[bag.quality])), + [bags] ); - // Register with centralized bag animation manager on mount - useEffect(() => { - if (!ref.current) return; - - // Register bag state with centralized manager - registerBagAnimation(data.id, { - ref: ref.current, - speed: data.speed, - currentX: data.position[0], - crossedBoundary: false, - }); + const writeMatrices = (): void => { + const bodies = bodiesRef.current; + const stripes = stripesRef.current; + if (!bodies || !stripes) return; + + for (let index = 0; index < bags.length; index += 1) { + const bag = bags[index]; + groupPosition.set(positionsRef.current[index], bag.position[1], bag.position[2]); + groupRotation.setFromAxisAngle(BAG_UP, bag.rotation); + groupMatrix.compose(groupPosition, groupRotation, BAG_SCALE); + + instanceMatrix.multiplyMatrices(groupMatrix, BAG_BODY_OFFSET); + bodies.setMatrixAt(index, instanceMatrix); + instanceMatrix.multiplyMatrices(groupMatrix, BAG_STRIPE_OFFSET); + stripes.setMatrixAt(index, instanceMatrix); + } - return () => { - unregisterBagAnimation(data.id); - }; - }, [data.id, data.speed, data.position]); + bodies.instanceMatrix.needsUpdate = true; + stripes.instanceMatrix.needsUpdate = true; + }; - // The troika labels below stay gated on `enableProceduralTextures` - // (false on every tier). Each label is a separate draw call with its own SDF - // atlas upload; 60 bags x 2 labels is 120 calls against a ~1200-call scene. - const showDetails = enableProceduralTextures; - const qualityColor = QUALITY_COLORS[data.quality]; - const sackMaterials = getFlourSackMaterials(); - - // Extract position values for stable initial position (animated via ref after mount) - const initPosX = data.position[0]; - const initPosY = data.position[1]; - const initPosZ = data.position[2]; - const initialPosition = useMemo<[number, number, number]>( - () => [initPosX, initPosY, initPosZ], - [initPosX, initPosY, initPosZ] - ); + useLayoutEffect(() => { + const bodies = bodiesRef.current; + const stripes = stripesRef.current; + if (!bodies || !stripes) return; + + positionsRef.current = bags.map((bag) => bag.position[0]); + bodies.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + stripes.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + for (let index = 0; index < bags.length; index += 1) { + bodies.setColorAt(index, BAG_IDLE_COLOR); + stripes.setColorAt(index, qualityColours[index]); + } + if (bodies.instanceColor) bodies.instanceColor.needsUpdate = true; + if (stripes.instanceColor) stripes.instanceColor.needsUpdate = true; + writeMatrices(); + bodies.computeBoundingSphere(); + stripes.computeBoundingSphere(); + setHoveredIndex(null); + }, [bags, qualityColours]); - return ( - setHovered(true)} - onPointerOut={() => setHovered(false)} - > - {/* Bag body - main object keeps shadow */} - + useEffect(() => { + const bodies = bodiesRef.current; + if (!bodies) return; + for (let index = 0; index < bags.length; index += 1) { + bodies.setColorAt(index, index === hoveredIndex ? BAG_HOVER_COLOR : BAG_IDLE_COLOR); + } + if (bodies.instanceColor) bodies.instanceColor.needsUpdate = true; + }, [bags.length, hoveredIndex]); - {/* Quality-colored label stripe - z offset increased to 0.48 to prevent z-fighting with bag front face at z=0.45 */} - - - - + useFrame((_, delta) => { + if (isTabVisible && productionSpeed > 0) { + for (let index = 0; index < bags.length; index += 1) { + positionsRef.current[index] = advanceBagPosition( + positionsRef.current[index], + bags[index].speed, + productionSpeed, + delta + ); + } + writeMatrices(); + } - {/* Batch number text on bag (3D text) */} - {showDetails && ( - - {data.batchNumber} - - )} + if (hoveredIndex !== null && tooltipRef.current) { + const bag = bags[hoveredIndex]; + tooltipRef.current.position.set( + positionsRef.current[hoveredIndex], + bag.position[1] + 0.8, + bag.position[2] + ); + } + }); - {/* Weight indicator */} - {showDetails && ( - - {data.weight}kg - - )} + const hoveredBag = hoveredIndex === null ? null : bags[hoveredIndex]; - {/* Bag stitching detail - NO SHADOWS for small details */} - {showDetails && ( - <> - - - - - {/* Top fold */} - - - - - - )} + return ( + + { + event.stopPropagation(); + if (event.instanceId !== undefined) setHoveredIndex(event.instanceId); + }} + onPointerOut={() => setHoveredIndex(null)} + /> + - {/* Hover tooltip with full batch info */} - {hovered && ( - -
-
{data.batchNumber}
-
-
- Quality: - - {data.quality} - -
-
- Weight: - {data.weight} kg + {hoveredBag && ( + + +
+
{hoveredBag.batchNumber}
+
+
+ Quality: + + {hoveredBag.quality} + +
+
+ Weight: + {hoveredBag.weight} kg +
-
- + + )} ); diff --git a/src/components/LoadingScreen.tsx b/src/components/LoadingScreen.tsx index 9f12455..1b85356 100644 --- a/src/components/LoadingScreen.tsx +++ b/src/components/LoadingScreen.tsx @@ -1,5 +1,5 @@ import React, { Suspense, useEffect, useMemo, useState } from 'react'; -import { useProgress } from '@react-three/drei'; +import * as THREE from 'three'; import { FEATURE_FLAGS } from '../config/featureFlags'; import { recoverableLazy } from '../utils/recoverableLazy'; @@ -12,11 +12,86 @@ interface LoadingScreenProps { maximumLoadTimeMs?: number; } +interface LoadingProgress { + progress: number; + active: boolean; + loaded: number; + total: number; + item: string; + errors: string[]; +} + +const EMPTY_PROGRESS: LoadingProgress = { + progress: 0, + active: false, + loaded: 0, + total: 0, + item: '', + errors: [], +}; + +/** + * Track Three's default asset queue without importing the complete Drei package + * into the critical startup path. The previous callbacks are preserved so a + * host integration can observe the same queue independently. + */ +function useLoadingProgress(): LoadingProgress { + const [state, setState] = useState(EMPTY_PROGRESS); + + useEffect(() => { + const manager = THREE.DefaultLoadingManager; + const previous = { + onStart: manager.onStart, + onLoad: manager.onLoad, + onProgress: manager.onProgress, + onError: manager.onError, + }; + + const update = (url: string, loaded: number, total: number, active: boolean): void => { + const progress = total > 0 ? (loaded / total) * 100 : 0; + setState((current) => ({ ...current, progress, active, loaded, total, item: url })); + }; + + const onStart: THREE.LoadingManager['onStart'] = (url, loaded, total) => { + previous.onStart?.(url, loaded, total); + update(url, loaded, total, true); + }; + const onLoad: THREE.LoadingManager['onLoad'] = () => { + previous.onLoad?.(); + setState((current) => ({ ...current, progress: 100, active: false })); + }; + const onProgress: THREE.LoadingManager['onProgress'] = (url, loaded, total) => { + previous.onProgress?.(url, loaded, total); + update(url, loaded, total, loaded < total); + }; + const onError: THREE.LoadingManager['onError'] = (url) => { + previous.onError?.(url); + setState((current) => + current.errors.includes(url) ? current : { ...current, errors: [...current.errors, url] } + ); + }; + + manager.onStart = onStart; + manager.onLoad = onLoad; + manager.onProgress = onProgress; + manager.onError = onError; + + return () => { + if (manager.onStart === onStart) manager.onStart = previous.onStart; + if (manager.onLoad === onLoad) manager.onLoad = previous.onLoad; + if (manager.onProgress === onProgress) manager.onProgress = previous.onProgress; + if (manager.onError === onError) manager.onError = previous.onError; + }; + }, []); + + return state; +} + export const LoadingScreen: React.FC = ({ minimumLoadTimeMs = 700, maximumLoadTimeMs = 8000, }) => { - const { progress, active, loaded, total, item, errors } = useProgress(); + const { progress, active, loaded, total, item, errors } = useLoadingProgress(); const [showLoading, setShowLoading] = useState(true); const [minimumTimePassed, setMinimumTimePassed] = useState(false); const [firstFrameRendered, setFirstFrameRendered] = useState( diff --git a/src/components/RuntimeController.tsx b/src/components/RuntimeController.tsx index 24fafa2..7248850 100644 --- a/src/components/RuntimeController.tsx +++ b/src/components/RuntimeController.tsx @@ -638,7 +638,13 @@ export const RuntimeController: React.FC = ({ adaptiveEn }); sceneGraph.uniqueGeometries = geometryIds.size; sceneGraph.uniqueMaterials = materialIds.size; - const branchRoot = scene.children.length === 1 ? scene.children[0] : scene; + // The Canvas owns a small unnamed helper sibling beside `world-root`, so + // choosing the Scene whenever there is more than one child collapsed all + // diagnostics into a single 1,400-mesh branch. Prefer the authored root + // explicitly and retain the old fallback for isolated test scenes. + const branchRoot = + scene.getObjectByName('world-root') ?? + (scene.children.length === 1 ? scene.children[0] : scene); sceneGraph.topBranches = branchRoot.children .map((branch, index) => { let objects = 0; diff --git a/src/components/SceneOrbitControls.tsx b/src/components/SceneOrbitControls.tsx new file mode 100644 index 0000000..d851fb6 --- /dev/null +++ b/src/components/SceneOrbitControls.tsx @@ -0,0 +1,6 @@ +/** + * Narrow lazy boundary for scene navigation. Importing this local module lets + * Rollup retain OrbitControls without turning the complete Drei public surface + * into one manual chunk on the critical startup path. + */ +export { OrbitControls as default } from '@react-three/drei'; diff --git a/src/components/performance/StaticMeshBatch.test.ts b/src/components/performance/StaticMeshBatch.test.ts index 79d25ac..a1c7aa7 100644 --- a/src/components/performance/StaticMeshBatch.test.ts +++ b/src/components/performance/StaticMeshBatch.test.ts @@ -203,4 +203,22 @@ describe('StaticMeshBatch', () => { mergedMeshes: 1, }); }); + + it('accumulates diagnostics when candidates are processed in startup slices', () => { + const root = new THREE.Group(); + const firstPair = [makeBox(-4), makeBox(-2)]; + const secondPair = [makeBox(2), makeBox(4)]; + root.add(...firstPair, ...secondPair); + + const candidates = collectStaticBatchCandidates(root); + createStaticMeshBatches(root, candidates.slice(0, 2), 'slice:0', 2); + createStaticMeshBatches(root, candidates.slice(2), 'slice:1', 2); + + expect(root.userData.staticBatchStats).toMatchObject({ + optimizedOriginals: 4, + batches: 2, + instancedOriginals: 4, + instancedBatches: 2, + }); + }); }); diff --git a/src/components/performance/StaticMeshBatch.tsx b/src/components/performance/StaticMeshBatch.tsx index 843cf73..c2ba45f 100644 --- a/src/components/performance/StaticMeshBatch.tsx +++ b/src/components/performance/StaticMeshBatch.tsx @@ -612,15 +612,15 @@ export const createStaticMeshBatches = ( const existingDiagnostics = root.userData.staticBatchStats as StaticBatchDiagnostics | undefined; if (existingDiagnostics) { - existingDiagnostics.optimizedOriginals = batches.reduce( + existingDiagnostics.optimizedOriginals += batches.reduce( (total, batch) => total + batch.originals.length, 0 ); - existingDiagnostics.batches = batches.length; - existingDiagnostics.instancedOriginals = instancedOriginalCount; - existingDiagnostics.instancedBatches = instancedBatchCount; - existingDiagnostics.mergedOriginals = mergedOriginalCount; - existingDiagnostics.mergedMeshes = mergedMeshCount; + existingDiagnostics.batches += batches.length; + existingDiagnostics.instancedOriginals += instancedOriginalCount; + existingDiagnostics.instancedBatches += instancedBatchCount; + existingDiagnostics.mergedOriginals += mergedOriginalCount; + existingDiagnostics.mergedMeshes += mergedMeshCount; } return batches; diff --git a/src/components/ui-new/sidebar/ContextSidebar.tsx b/src/components/ui-new/sidebar/ContextSidebar.tsx index 635babe..92d0997 100644 --- a/src/components/ui-new/sidebar/ContextSidebar.tsx +++ b/src/components/ui-new/sidebar/ContextSidebar.tsx @@ -17,10 +17,7 @@ import { MachineData } from '../../../types'; import { AboutModal } from '../../AboutModal'; import { RecoverableFeatureBoundary } from '../../ErrorBoundary'; import { recoverableLazy } from '../../../utils/recoverableLazy'; -import { - CURRENT_RELEASE_VERSION, - SELECTABLE_RELEASE_VERSIONS, -} from '../../../config/releaseVersions'; +import { CURRENT_RELEASE_VERSION, SELECTABLE_RELEASES } from '../../../config/releaseVersions'; // Lazy load the heavy panels const AICommandCenter = recoverableLazy(() => @@ -208,9 +205,9 @@ export const ContextSidebar: React.FC = ({ }} aria-label="Select MillOS version" > - {SELECTABLE_RELEASE_VERSIONS.map((version) => ( - ))} diff --git a/src/config/releaseNavigationBridge.test.ts b/src/config/releaseNavigationBridge.test.ts index 0c209ac..b02de74 100644 --- a/src/config/releaseNavigationBridge.test.ts +++ b/src/config/releaseNavigationBridge.test.ts @@ -31,6 +31,12 @@ describe('historical release navigation bridge', () => { 'v0.20', 'v0.10', ]); + expect(Array.from(selector?.options ?? []).map((option) => option.textContent)).toEqual([ + '0.40 (current)', + '0.30 (historical)', + '0.20 (historical)', + '0.10 (historical)', + ]); expect(selector?.value).toBe('v0.20'); expect(go?.disabled).toBe(true); diff --git a/src/config/releaseVersions.test.ts b/src/config/releaseVersions.test.ts index 627ff6e..531d8d7 100644 --- a/src/config/releaseVersions.test.ts +++ b/src/config/releaseVersions.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; import releaseMatrix from '../../release-matrix.json'; -import { CURRENT_RELEASE_VERSION, SELECTABLE_RELEASE_VERSIONS } from './releaseVersions'; +import { + CURRENT_RELEASE_VERSION, + SELECTABLE_RELEASES, + SELECTABLE_RELEASE_VERSIONS, +} from './releaseVersions'; describe('MillOS release versions', () => { it('defaults to v0.40 while preserving every published release', () => { @@ -10,6 +14,12 @@ describe('MillOS release versions', () => { releaseMatrix.releases.map((release) => release.version) ); expect(new Set(SELECTABLE_RELEASE_VERSIONS).size).toBe(SELECTABLE_RELEASE_VERSIONS.length); + expect(SELECTABLE_RELEASES.map((release) => release.displayLabel)).toEqual([ + '0.40 (current)', + '0.30 (historical)', + '0.20 (historical)', + '0.10 (historical)', + ]); }); it('records the historical v0.30 package metadata discrepancy explicitly', () => { diff --git a/src/config/releaseVersions.ts b/src/config/releaseVersions.ts index 306afe5..5ce4e03 100644 --- a/src/config/releaseVersions.ts +++ b/src/config/releaseVersions.ts @@ -10,4 +10,8 @@ if (releaseMatrix.currentVersion !== packageReleaseVersion) { } export const CURRENT_RELEASE_VERSION = releaseMatrix.currentVersion; +export const SELECTABLE_RELEASES = releaseMatrix.releases.map((release) => ({ + ...release, + displayLabel: `${release.label} (${release.type === 'current' ? 'current' : 'historical'})`, +})); export const SELECTABLE_RELEASE_VERSIONS = releaseMatrix.releases.map((release) => release.version); diff --git a/src/stores/aiConfigStore.ts b/src/stores/aiConfigStore.ts index aee7110..4d91060 100644 --- a/src/stores/aiConfigStore.ts +++ b/src/stores/aiConfigStore.ts @@ -35,8 +35,9 @@ export type WebGPUStatus = | 'error'; // Gemini pricing per 1M tokens (paid tier, text), per model in the fallback -// chain. Verified against https://ai.google.dev/gemini-api/docs/pricing (June 2026). +// chain. Verified against https://ai.google.dev/gemini-api/docs/pricing (August 2026). const GEMINI_COST_PER_1M: Record = { + 'gemini-3.6-flash': { input: 1.5, output: 7.5 }, 'gemini-3.5-flash': { input: 1.5, output: 9.0 }, 'gemini-3-flash-preview': { input: 0.5, output: 3.0 }, 'gemini-2.5-flash': { input: 0.3, output: 2.5 }, diff --git a/src/utils/geminiClient.test.ts b/src/utils/geminiClient.test.ts new file mode 100644 index 0000000..d4c12bb --- /dev/null +++ b/src/utils/geminiClient.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { GEMINI_MODEL_CANDIDATES } from './geminiClient'; + +describe('Gemini model fallback policy', () => { + it('prefers the current stable model and retains distinct fallbacks', () => { + expect(GEMINI_MODEL_CANDIDATES[0]).toBe('gemini-3.6-flash'); + expect(GEMINI_MODEL_CANDIDATES).toContain('gemini-3.5-flash'); + expect(new Set(GEMINI_MODEL_CANDIDATES).size).toBe(GEMINI_MODEL_CANDIDATES.length); + }); +}); diff --git a/src/utils/geminiClient.ts b/src/utils/geminiClient.ts index b4e8b69..8f725d2 100644 --- a/src/utils/geminiClient.ts +++ b/src/utils/geminiClient.ts @@ -17,12 +17,14 @@ import { logger } from './logger'; * the previous hardcoded single ID ('gemini-3-flash-preview') left live AI * silently dead once that preview model was retired. * - * Verified against https://ai.google.dev/gemini-api/docs/models (June 2026): - * - gemini-3.5-flash: stable GA (May 2026), no announced shutdown + * Verified against https://ai.google.dev/gemini-api/docs/latest-model (August 2026): + * - gemini-3.6-flash: stable GA and the recommended 3.5 Flash migration target + * - gemini-3.5-flash: stable GA fallback * - gemini-3-flash-preview: preview tier (restrictive rate limits) * - gemini-2.5-flash: legacy stable, shutdown announced for 2026-10-16 */ export const GEMINI_MODEL_CANDIDATES = [ + 'gemini-3.6-flash', 'gemini-3.5-flash', 'gemini-3-flash-preview', 'gemini-2.5-flash', @@ -82,46 +84,12 @@ class GeminiClient { private readonly CACHE_MAX_SIZE = 10; /** - * Robust hash function for cache keys - * - * Uses a full-prompt hash to prevent collisions entirely. - * No normalization is applied - exact prompt matching only. - * - * Previous approaches with number normalization caused collisions: - * - "Temperature: 95" vs "Temperature: 45" -> same hash (BAD) - * - "Machine RM-101" vs "Machine RM-999" -> same hash (BAD) - * - * Current approach: Hash the ENTIRE prompt without normalization. - * This ensures semantically different prompts never collide. - * Trade-off: Slightly lower cache hit rate for truly identical content - * with different timestamps, but zero false cache hits. - */ - private hashPrompt(prompt: string): string { - // Use djb2 hash algorithm on the full prompt for collision resistance - // This is a well-tested hash with good distribution properties - let hash1 = 5381; - let hash2 = 52711; - - for (let i = 0; i < prompt.length; i++) { - const char = prompt.charCodeAt(i); - hash1 = (hash1 * 33) ^ char; - hash2 = (hash2 * 33) ^ char; - } - - // Combine both hashes for better collision resistance - // Using unsigned right shift to ensure positive numbers - const combined = ((hash1 >>> 0) * 4096 + (hash2 >>> 0)) >>> 0; - - // Include prompt length as additional discriminator - return `cache-v2-${combined.toString(36)}-${prompt.length}`; - } - - /** - * Check cache for a similar prompt + * Check the bounded cache for an exact prompt. The full string is the key, + * avoiding the false hits that a lossy numeric normalizer or 32-bit hash can + * produce for different plant states. */ private getCachedResponse(prompt: string): string | null { - const cacheKey = this.hashPrompt(prompt); - const cached = this.responseCache.get(cacheKey); + const cached = this.responseCache.get(prompt); if (cached && Date.now() - cached.timestamp < this.CACHE_TTL_MS) { logger.info('[GeminiClient] Cache hit for strategic decision'); @@ -130,7 +98,7 @@ class GeminiClient { // Clean up expired entry if (cached) { - this.responseCache.delete(cacheKey); + this.responseCache.delete(prompt); } return null; @@ -140,15 +108,13 @@ class GeminiClient { * Store response in cache */ private setCachedResponse(prompt: string, response: string): void { - const cacheKey = this.hashPrompt(prompt); - // Evict oldest if at capacity if (this.responseCache.size >= this.CACHE_MAX_SIZE) { const oldestKey = this.responseCache.keys().next().value; if (oldestKey) this.responseCache.delete(oldestKey); } - this.responseCache.set(cacheKey, { response, timestamp: Date.now() }); + this.responseCache.set(prompt, { response, timestamp: Date.now() }); } /** @@ -179,8 +145,6 @@ class GeminiClient { this.model = this.genAI.getGenerativeModel({ model: GEMINI_MODEL_CANDIDATES[this.modelIndex], generationConfig: { - temperature: 0.7, - topP: 0.9, maxOutputTokens: 2048, }, }); diff --git a/vite.config.ts b/vite.config.ts index 43f9d3c..8ad4858 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -187,24 +187,19 @@ export default defineConfig((): UserConfig => { main: path.resolve(__dirname, 'index.html'), }, output: { - // Manual chunks for better caching and parallel loading - manualChunks: { - // Three.js ecosystem (largest dependencies) - 'three-core': ['three'], - // Fiber/Drei depend directly on React and Zustand. Keeping that - // tightly coupled runtime together prevents circular vendor chunks. - 'three-fiber': [ - 'react', - 'react-dom', - 'zustand', - '@react-three/fiber', - '@react-three/drei', - ], - // UI libraries - 'ui-vendor': ['framer-motion'], - // Utilities - icons: ['lucide-react'], - 'math-utils': ['maath'], + // Manual chunks for better caching and parallel loading. Match package + // paths rather than package entry points so React's JSX runtimes and + // React DOM's client entry do not fall back into the application chunk. + manualChunks(id) { + if (id.includes('/node_modules/three/build/')) return 'three-core'; + if (id.includes('/node_modules/@react-three/fiber/')) return 'three-fiber'; + if (/\/node_modules\/(?:react|react-dom|scheduler|zustand)(?:\/|$)/.test(id)) { + return 'react-core'; + } + if (id.includes('/node_modules/framer-motion/')) return 'ui-vendor'; + if (id.includes('/node_modules/lucide-react/')) return 'icons'; + if (id.includes('/node_modules/maath/')) return 'math-utils'; + return undefined; }, }, },