-
- 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;
},
},
},