diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5770da3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,101 @@ +AGENTS Guide for repo-wide agents + +Purpose +- This file instructs automated coding agents (and humans) how to build, lint, test and edit code in this repository. +- Follow the conventions below to keep changes consistent and to avoid accidental style/behavior regressions. + +1) Quick commands (run from repository root) +- Install dependencies: `npm install` +- Start dev server: `npm run dev` (Vite) +- Build production bundle: `npm run build` (runs `tsc -b && vite build`) +- Preview build: `npm run preview` +- Run all tests: `npm run test` (runs `vitest`) +- Lint & format (project): `npm run check` (runs `npx @biomejs/biome check --write`) + +Run a single test +- Run a single test file: `npx vitest src/path/to/file.test.ts` or `npx vitest src/path/to/file.spec.ts` +- Run a single test by name (pattern): `npx vitest -t "pattern"` + - Example: `npx vitest -t "renders planet mesh"` will run tests whose name matches that string/regex. +- Run a single test and watch: `npx vitest --watch -t "pattern"` + +Vitest notes +- The project uses `vitest` and `happy-dom` (see devDependencies). If tests touch DOM, ensure `happy-dom` is available in the test environment. +- Use `npx vitest run --coverage` to produce coverage reports. + +2) Formatting & linting +- The repository uses Biome (@biomejs/biome) as the formatter/linter. Run `npm run check` to apply fixes and check rules. +- Biome configuration (repo root): `biome.json` + - Formatter: tab indentation (`indentStyle: "tab"`) + - JS quote style: double quotes for JavaScript files + - Linter: `recommended` rules enabled + - Assist: `organizeImports` is enabled and will be applied by the Biome assist actions +- Pre-commit: `lefthook.yml` runs Biome against staged files. Agents must not bypass hooks; prefer to keep commits that pass `npm run check`. + +3) Import conventions +- Use absolute app aliases where present: `@/...` for project top-level imports (examples already in codebase: `@/types/planet`). +- Prefer grouped imports with external libs first, a blank line, then internal imports. + - Example order: + 1. Node / built-in imports (rare in browser app) + 2. External packages (react, three, etc.) + 3. Absolute/internal imports ("@/…" or "src/…") + 4. Relative imports ("./", "../") +- Keep import lists organized. Let Biome organize imports automatically if unsure. +- Prefer named exports for library code. Default exports are allowed for single-component files but prefer named for utilities. + +4) Files, components and naming +- React components: use PascalCase filenames and PascalCase export names (e.g., `PlanetMesh.tsx`, export `function PlanetMesh()` or `export const PlanetMesh = ...`). +- Hooks: use `useXxx` naming and camelCase filenames (e.g., `useLevaControls.ts`). +- Utility modules and plain functions: use camelCase filenames or kebab-case where appropriate; exports should be named and descriptive. +- Types and interfaces: prefer `type` aliases for object shape declarations in this codebase (project has converted many `interface` -> `type`). Use `type` for unions, tuples, mapped types; use `interface` only where declaration merging or extension is intentional. + +5) TypeScript usage +- Keep TypeScript types strict and explicit for public API surfaces (component props, exported functions, engine configs). +- Use `export type` for exported shapes and `type` for internal shapes unless you need `interface` semantics. +- Prefer `ReturnType` sparingly — prefer exported, named snapshot/types where possible for clarity. +- Avoid `any`. If you must use it, leave a short `// TODO` comment and create a ticket to improve the type. + +6) Formatting specifics +- Tabs for indentation (Biome enforces this). +- Double quotes for JavaScript; TypeScript files should follow the same style where applicable. +- Keep lines reasonably short; wrap long expressions over multiple lines with readable indentation. +- Use Biome auto-fix before committing: `npm run check` and stage the fixed files. + +7) Error handling & logging +- Fail fast in engine code, but avoid throwing unhandled exceptions to the top-level UI. + - Physics/engine code should validate inputs and use `console.warn` for recoverable or invalid data (this pattern is present in PhysicsEngine). + - Use `throw` only for unrecoverable errors during initialization or when invariants are violated and the caller must handle it. +- Avoid excessive console.log in production code. Use `console.debug`/`console.info` for verbose developer logs and remove or gate them behind a debug flag in production-critical code. +- When catching errors, prefer to: + - log a concise message and details (`console.error('Failed to load X', err)`), + - surface user-friendly messages to UI layers (not raw exceptions), + - preserve original error where useful (wrap only when adding context). + +8) Testing & test style +- Tests use `vitest`. Keep tests small and deterministic. Prefer unit tests for physics/utilities and lightweight integration tests for components. +- Use `happy-dom` for DOM-like tests. Mock three.js/WebGL-specific runtime where needed, or isolate logic from rendering. +- Name tests clearly: `describe('PlanetMesh')`, `it('renders texture and radius')`. +- Use `beforeEach` / `afterEach` to reset global state and registries to avoid test pollution. + +9) Git workflow & commits +- Commit messages: short present-tense summary. Agents should stage only the files they change and avoid amending unrelated user changes. +- Pre-commit runs Biome on staged files (via lefthook). Ensure `npm run check` passes locally before pushing. + +10) Code review expectations for agents +- Small, focused PRs: keep changes minimal and well-described. +- Include unit tests for new logic and update types as necessary. +- Run `npm run check` and `npm run test` locally before requesting review. + +11) Cursor / Copilot rules +- There are no repository-level Cursor rules (no `.cursor/rules/` or `.cursorrules` files found). +- There are no GitHub Copilot instructions present (`.github/copilot-instructions.md` not found). +- If you add such rules, include them here and ensure agents obey them. + +12) Miscellaneous guidance +- Use the existing code patterns as a guide (e.g., PhysicsEngine uses explicit vector reuse to reduce GC; follow similar performance-sensitive patterns there). +- Where changes touch rendering or physics timing, test in the running dev server to ensure behavior matches expectations. +- When adding new dependencies, prefer lightweight, well-maintained packages and add them to `package.json` devDependencies/devDependencies appropriately. + +Contact / follow-up +- If uncertain, open a small PR and request a human review rather than making wide-reaching changes. + +— End of AGENTS.md — diff --git a/package-lock.json b/package-lock.json index ecc311b..70110d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,10 +25,12 @@ "@types/three": "^0.182.0", "@vitejs/plugin-react": "^5.1.1", "globals": "^16.5.0", + "happy-dom": "^20.8.8", "lefthook": "^2.1.1", "tailwindcss": "^4.1.18", "typescript": "~5.9.3", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.1.1" } }, "node_modules/@babel/code-frame": { @@ -62,6 +64,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1751,6 +1754,7 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz", "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -2180,6 +2184,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@stitches/react": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@stitches/react/-/react-1.2.8.tgz", @@ -2512,6 +2523,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/draco3d": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", @@ -2531,6 +2560,7 @@ "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2546,6 +2576,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2556,6 +2587,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2580,6 +2612,7 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz", "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==", "license": "MIT", + "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -2596,6 +2629,23 @@ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@use-gesture/core": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", @@ -2635,12 +2685,135 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz", + "integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz", + "integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.1", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz", + "integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz", + "integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.1", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz", + "integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.1", + "@vitest/utils": "4.1.1", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz", + "integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz", + "integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.1", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webgpu/types": { "version": "0.1.68", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.68.tgz", "integrity": "sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA==", "license": "BSD-3-Clause" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/assign-symbols": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", @@ -2718,6 +2891,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2790,6 +2964,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/colord": { "version": "2.9.3", "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", @@ -2927,6 +3111,26 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -2979,6 +3183,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -3105,6 +3329,25 @@ "dev": true, "license": "ISC" }, + "node_modules/happy-dom": { + "version": "20.8.8", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.8.tgz", + "integrity": "sha512-5/F8wxkNxYtsN0bXfMwIyNLZ9WYsoOYPbmoluqVJqv8KBUbcyKZawJ7uYK4WTX8IHBLYv+VXIwfeNDPy1oKMwQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/hls.js": { "version": "1.6.15", "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", @@ -3819,6 +4062,17 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3828,6 +4082,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3841,6 +4102,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3928,6 +4190,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4142,6 +4405,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4177,6 +4447,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stats-gl": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", @@ -4203,6 +4480,13 @@ "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", "license": "MIT" }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/suspend-react": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", @@ -4237,7 +4521,8 @@ "version": "0.182.0", "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -4271,6 +4556,23 @@ "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4288,6 +4590,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/troika-three-text": { "version": "0.52.4", "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", @@ -4443,6 +4755,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4512,6 +4825,88 @@ } } }, + "node_modules/vitest": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz", + "integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.1", + "@vitest/mocker": "4.1.1", + "@vitest/pretty-format": "4.1.1", + "@vitest/runner": "4.1.1", + "@vitest/snapshot": "4.1.1", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.1", + "@vitest/browser-preview": "4.1.1", + "@vitest/browser-webdriverio": "4.1.1", + "@vitest/ui": "4.1.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/webgl-constants": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", @@ -4523,6 +4918,16 @@ "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4538,6 +4943,45 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index aa2addb..f17de36 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "check": "npx @biomejs/biome check --write", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest" }, "dependencies": { "@react-three/drei": "^10.7.7", @@ -27,9 +28,11 @@ "@types/three": "^0.182.0", "@vitejs/plugin-react": "^5.1.1", "globals": "^16.5.0", + "happy-dom": "^20.8.8", "lefthook": "^2.1.1", "tailwindcss": "^4.1.18", "typescript": "~5.9.3", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.1.1" } } diff --git a/src/pages/Home/Canvas.tsx b/src/pages/Home/Canvas.tsx index 59eb3e5..ae9d314 100644 --- a/src/pages/Home/Canvas.tsx +++ b/src/pages/Home/Canvas.tsx @@ -1,8 +1,8 @@ import { Canvas } from "@react-three/fiber"; -interface Props { +type Props = { children: React.ReactNode; -} +}; export default function ThreeCanvas(props: Props) { return ( diff --git a/src/pages/Simulation/__tests__/AddPlanet.test.ts b/src/pages/Simulation/__tests__/AddPlanet.test.ts new file mode 100644 index 0000000..dac34e8 --- /dev/null +++ b/src/pages/Simulation/__tests__/AddPlanet.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { earth } from "@/data/planets"; +import { PlanetRegistry } from "../core/PlanetRegistry"; +import { SimulationWorld } from "../core/SimulationWorld"; + +describe("Add Planet - Race Condition Fix", () => { + let registry: PlanetRegistry; + let world: SimulationWorld; + + beforeEach(() => { + registry = new PlanetRegistry(); + registry.register(earth.id, earth); + world = new SimulationWorld([earth]); + }); + + it("should not update snapshot until after planet is registered in registry", () => { + // This test verifies the fix for: "can't access property toFixed, radius is undefined" + // The bug occurred when addPlanetFromTemplate() updated the snapshot immediately, + // but the planet wasn't registered in PlanetRegistry yet, causing React to render + // with a planet ID that doesn't exist in the registry. + + const initialSnapshot = world.getSnapshot(); + expect(initialSnapshot.planetIds).toHaveLength(1); + expect(initialSnapshot.planetIds).toContain(earth.id); + + // Add a new planet + const newPlanet = world.addPlanetFromTemplate(earth, { + radius: 2.0, + position: [5, 0, 0], + rotationSpeedY: 0.5, + }); + + // After addPlanetFromTemplate(), the snapshot should NOT be updated yet + // This prevents React from rendering with planet ID before registry has it + const snapshotBeforeRegister = world.getSnapshot(); + + // The snapshot should still be the OLD one (not updated) + // This is the fix: snapshot is stale until we register and call syncWorld() + expect(snapshotBeforeRegister.planetIds).toHaveLength(1); + + // Now register the planet in the registry + registry.register(newPlanet.id, newPlanet); + + // Verify the planet exists in registry + const registryEntry = registry.get(newPlanet.id); + expect(registryEntry).toBeDefined(); + expect(registryEntry?.radius).toBe(2.0); + expect(registryEntry?.position.x).toBe(5); + + // Now update the snapshot (this is what happens after planetRegistry.register) + world.refreshSnapshot(); + + // Now the snapshot is updated (this is what syncWorld() returns) + const finalSnapshot = world.getSnapshot(); + expect(finalSnapshot.planetIds).toHaveLength(2); + expect(finalSnapshot.planetIds).toContain(newPlanet.id); + + // At this point, React can safely render because: + // 1. Planet ID is in snapshot + // 2. Planet is registered in registry + // 3. No race condition! + }); + + it("should have all required planet properties when registered", () => { + // Add planet + const newPlanet = world.addPlanetFromTemplate(earth, { + radius: 1.5, + position: [10, 5, -3], + rotationSpeedY: 0.8, + }); + + // Register in registry + registry.register(newPlanet.id, newPlanet); + + // Get from registry + const entry = registry.get(newPlanet.id); + + // Verify all properties exist (this is what the UI tries to access) + expect(entry).toBeDefined(); + if (!entry) throw new Error("Entry should exist"); + + expect(entry.radius).toBeDefined(); + expect(entry.radius).toBe(1.5); + expect(typeof entry.radius.toFixed).toBe("function"); // The line that was failing + + expect(entry.position).toBeDefined(); + expect(entry.position.x).toBeDefined(); + expect(typeof entry.position.x.toFixed).toBe("function"); + expect(entry.position.y).toBeDefined(); + expect(entry.position.z).toBeDefined(); + + expect(entry.name).toBeDefined(); + expect(entry.mass).toBeDefined(); + expect(entry.rotationSpeedY).toBeDefined(); + }); + + it("should handle multiple planets being added sequentially", () => { + // Add multiple planets + const planet1 = world.addPlanetFromTemplate(earth, { + radius: 1.0, + position: [0, 0, 0], + rotationSpeedY: 0.5, + }); + registry.register(planet1.id, planet1); + + const planet2 = world.addPlanetFromTemplate(earth, { + radius: 2.0, + position: [10, 0, 0], + rotationSpeedY: 0.3, + }); + registry.register(planet2.id, planet2); + + const planet3 = world.addPlanetFromTemplate(earth, { + radius: 1.5, + position: [5, 5, 0], + rotationSpeedY: 0.7, + }); + registry.register(planet3.id, planet3); + + // Refresh snapshot after all planets are registered + world.refreshSnapshot(); + + // Get final snapshot + const snapshot = world.getSnapshot(); + expect(snapshot.planetIds).toHaveLength(4); // earth + 3 new planets + + // Verify all planets are in registry with correct properties + for (const id of snapshot.planetIds) { + const entry = registry.get(id); + expect(entry).toBeDefined(); + expect(entry?.radius).toBeDefined(); + expect(entry?.position).toBeDefined(); + } + + // Verify specific planets + const entry1 = registry.get(planet1.id); + expect(entry1?.radius).toBe(1.0); + + const entry2 = registry.get(planet2.id); + expect(entry2?.radius).toBe(2.0); + + const entry3 = registry.get(planet3.id); + expect(entry3?.radius).toBe(1.5); + }); + + it("should handle planet removal correctly", () => { + // Add a planet + const newPlanet = world.addPlanetFromTemplate(earth, { + radius: 1.2, + position: [3, 0, 0], + rotationSpeedY: 0.4, + }); + registry.register(newPlanet.id, newPlanet); + world.refreshSnapshot(); + + // Verify it exists + expect(registry.get(newPlanet.id)).toBeDefined(); + const snapshotBefore = world.getSnapshot(); + expect(snapshotBefore.planetIds).toContain(newPlanet.id); + + // Remove planet + registry.unregister(newPlanet.id); + world.removePlanet(newPlanet.id); + + // Verify it's gone + expect(registry.get(newPlanet.id)).toBeUndefined(); + const snapshotAfter = world.getSnapshot(); + expect(snapshotAfter.planetIds).not.toContain(newPlanet.id); + }); +}); diff --git a/src/pages/Simulation/__tests__/NaNBug.test.ts b/src/pages/Simulation/__tests__/NaNBug.test.ts new file mode 100644 index 0000000..4f6be24 --- /dev/null +++ b/src/pages/Simulation/__tests__/NaNBug.test.ts @@ -0,0 +1,177 @@ +import * as THREE from "three"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { earth } from "@/data/planets"; +import { PhysicsEngine } from "../core/PhysicsEngine"; +import { PlanetRegistry } from "../core/PlanetRegistry"; + +/** + * Test to reproduce and verify fix for NaN position bug when adding planets + */ +describe("NaN Bug - Adding planets during simulation", () => { + let planetRegistry: PlanetRegistry; + let physicsEngine: PhysicsEngine; + + beforeEach(() => { + planetRegistry = new PlanetRegistry(); + // Start with Earth + planetRegistry.register(earth.id, earth); + + // Create physics engine that starts running + physicsEngine = new PhysicsEngine(planetRegistry, { + fixedTimestep: 1 / 60, + maxSubSteps: 5, + autoStart: true, + }); + }); + + afterEach(() => { + physicsEngine.destroy(); + }); + + it("should not produce NaN positions when adding a small planet near Earth", async () => { + // Wait for a few physics ticks + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Add a very small planet near Earth (this could trigger NaN with huge accelerations) + const smallPlanet = { + id: "small-planet", + name: "Small", + texturePath: earth.texturePath, + rotationSpeedY: 0.6, + radius: 0.2, // Very small radius + width: 64, + height: 64, + position: new THREE.Vector3(5, 0, 0), // Close to Earth + velocity: new THREE.Vector3(0, 0, 0), + mass: 0.001, // Very small mass from computeMass + }; + + planetRegistry.register(smallPlanet.id, smallPlanet); + + // Wait for physics to process + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Check that positions are still valid numbers + const earth_current = planetRegistry.get(earth.id); + const small_current = planetRegistry.get(smallPlanet.id); + + expect(earth_current).toBeDefined(); + expect(small_current).toBeDefined(); + + if (earth_current && small_current) { + // Check Earth's position + expect(Number.isFinite(earth_current.position.x)).toBe(true); + expect(Number.isFinite(earth_current.position.y)).toBe(true); + expect(Number.isFinite(earth_current.position.z)).toBe(true); + + // Check small planet's position + expect(Number.isFinite(small_current.position.x)).toBe(true); + expect(Number.isFinite(small_current.position.y)).toBe(true); + expect(Number.isFinite(small_current.position.z)).toBe(true); + + // Check velocities too + expect(Number.isFinite(earth_current.velocity.x)).toBe(true); + expect(Number.isFinite(earth_current.velocity.y)).toBe(true); + expect(Number.isFinite(earth_current.velocity.z)).toBe(true); + + expect(Number.isFinite(small_current.velocity.x)).toBe(true); + expect(Number.isFinite(small_current.velocity.y)).toBe(true); + expect(Number.isFinite(small_current.velocity.z)).toBe(true); + } + }); + + it("should handle planets with very small mass without producing NaN", async () => { + const tinyPlanet = { + id: "tiny-planet", + name: "Tiny", + texturePath: earth.texturePath, + rotationSpeedY: 0.6, + radius: 0.1, + width: 64, + height: 64, + position: new THREE.Vector3(3, 0, 0), + velocity: new THREE.Vector3(0, 0, 0), + mass: 0.00001, // Extremely small mass + }; + + planetRegistry.register(tinyPlanet.id, tinyPlanet); + + // Run physics for several frames + await new Promise((resolve) => setTimeout(resolve, 300)); + + const tiny_current = planetRegistry.get(tinyPlanet.id); + expect(tiny_current).toBeDefined(); + + if (tiny_current) { + expect(Number.isFinite(tiny_current.position.x)).toBe(true); + expect(Number.isFinite(tiny_current.position.y)).toBe(true); + expect(Number.isFinite(tiny_current.position.z)).toBe(true); + expect(Number.isFinite(tiny_current.velocity.x)).toBe(true); + expect(Number.isFinite(tiny_current.velocity.y)).toBe(true); + expect(Number.isFinite(tiny_current.velocity.z)).toBe(true); + } + }); + + it("should skip planets with invalid data (zero mass) and not crash", async () => { + const invalidPlanet = { + id: "invalid-planet", + name: "Invalid", + texturePath: earth.texturePath, + rotationSpeedY: 0.6, + radius: 1.0, + width: 64, + height: 64, + position: new THREE.Vector3(10, 0, 0), + velocity: new THREE.Vector3(0, 0, 0), + mass: 0, // Invalid: zero mass + }; + + planetRegistry.register(invalidPlanet.id, invalidPlanet); + + // Run physics - should skip this planet without crashing + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Earth should still be valid + const earth_current = planetRegistry.get(earth.id); + expect(earth_current).toBeDefined(); + + if (earth_current) { + expect(Number.isFinite(earth_current.position.x)).toBe(true); + expect(Number.isFinite(earth_current.position.y)).toBe(true); + expect(Number.isFinite(earth_current.position.z)).toBe(true); + } + }); + + it("should skip planets with NaN position and not propagate NaN", async () => { + const nanPlanet = { + id: "nan-planet", + name: "NaN", + texturePath: earth.texturePath, + rotationSpeedY: 0.6, + radius: 1.0, + width: 64, + height: 64, + position: new THREE.Vector3(Number.NaN, 0, 0), // Invalid: NaN position + velocity: new THREE.Vector3(0, 0, 0), + mass: 1, + }; + + planetRegistry.register(nanPlanet.id, nanPlanet); + + // Run physics - should skip this planet without propagating NaN + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Earth should still be valid and not affected by the NaN planet + const earth_current = planetRegistry.get(earth.id); + expect(earth_current).toBeDefined(); + + if (earth_current) { + expect(Number.isFinite(earth_current.position.x)).toBe(true); + expect(Number.isFinite(earth_current.position.y)).toBe(true); + expect(Number.isFinite(earth_current.position.z)).toBe(true); + expect(Number.isFinite(earth_current.velocity.x)).toBe(true); + expect(Number.isFinite(earth_current.velocity.y)).toBe(true); + expect(Number.isFinite(earth_current.velocity.z)).toBe(true); + } + }); +}); diff --git a/src/pages/Simulation/__tests__/PhysicsEngine.test.ts b/src/pages/Simulation/__tests__/PhysicsEngine.test.ts new file mode 100644 index 0000000..8c525c1 --- /dev/null +++ b/src/pages/Simulation/__tests__/PhysicsEngine.test.ts @@ -0,0 +1,186 @@ +import * as THREE from "three"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { Planet } from "@/types/planet"; +import { PhysicsEngine, type PhysicsEvent } from "../core/PhysicsEngine"; +import { PlanetRegistry } from "../core/PlanetRegistry"; + +describe("PhysicsEngine - Standalone Physics (No React)", () => { + let registry: PlanetRegistry; + let engine: PhysicsEngine; + let events: PhysicsEvent[]; + + beforeEach(() => { + registry = new PlanetRegistry(); + events = []; + }); + + afterEach(() => { + if (engine) { + engine.destroy(); + } + }); + + it("should run physics without React", () => { + // Create a simple planet + const planet: Planet = { + id: "test-planet-1", + name: "Test Planet", + mass: 1000, + radius: 1, + position: new THREE.Vector3(0, 0, 0), + velocity: new THREE.Vector3(1, 0, 0), + rotationSpeedY: 0.5, + texturePath: "test.jpg", + width: 32, + height: 32, + }; + + registry.register(planet.id, planet); + + // Create physics engine with manual start + engine = new PhysicsEngine(registry, { + fixedTimestep: 1 / 60, + autoStart: false, + }); + + expect(engine.isRunning()).toBe(false); + + // Subscribe to events + engine.on((event) => { + events.push(event); + }); + + // Manually step physics + const initialPosition = registry.get(planet.id)?.position.clone(); + expect(initialPosition).toBeDefined(); + if (!initialPosition) { + throw new Error("Initial position should be defined"); + } + + // Start the engine + engine.start(); + expect(engine.isRunning()).toBe(true); + + // Wait for some physics updates + return new Promise((resolve) => { + setTimeout(() => { + engine.stop(); + + // Physics should have updated the position + const updatedPosition = registry.get(planet.id)?.position; + expect(updatedPosition).toBeDefined(); + if (updatedPosition) { + expect(updatedPosition.x).toBeGreaterThan(initialPosition.x); + } + + // Should have received update events + const updateEvents = events.filter((e) => e.type === "update"); + expect(updateEvents.length).toBeGreaterThan(0); + + resolve(); + }, 100); + }); + }); + + it("should detect collisions and emit merge events", () => { + // Create two planets on collision course + const planet1: Planet = { + id: "planet-1", + name: "Planet 1", + mass: 1000, + radius: 1, + position: new THREE.Vector3(0, 0, 0), + velocity: new THREE.Vector3(1, 0, 0), + rotationSpeedY: 0.5, + texturePath: "test1.jpg", + width: 32, + height: 32, + }; + + const planet2: Planet = { + id: "planet-2", + name: "Planet 2", + mass: 1000, + radius: 1, + position: new THREE.Vector3(1.5, 0, 0), + velocity: new THREE.Vector3(-1, 0, 0), + rotationSpeedY: 0.5, + texturePath: "test2.jpg", + width: 32, + height: 32, + }; + + registry.register(planet1.id, planet1); + registry.register(planet2.id, planet2); + + // Create physics engine + engine = new PhysicsEngine(registry, { + fixedTimestep: 1 / 60, + autoStart: false, + }); + + // Subscribe to events + engine.on((event) => { + events.push(event); + }); + + engine.start(); + + // Wait for collision + return new Promise((resolve) => { + setTimeout(() => { + engine.stop(); + + // Should have collision events + const mergeEvents = events.filter((e) => e.type === "collision:merge"); + const explodeEvents = events.filter( + (e) => e.type === "collision:explode", + ); + + // Should have either merge or explode event + expect(mergeEvents.length + explodeEvents.length).toBeGreaterThan(0); + + resolve(); + }, 200); + }); + }); + + it("should use fixed timestep independent of frame rate", () => { + const planet: Planet = { + id: "test-planet", + name: "Test", + mass: 1000, + radius: 1, + position: new THREE.Vector3(0, 0, 0), + velocity: new THREE.Vector3(10, 0, 0), + rotationSpeedY: 0, + texturePath: "test.jpg", + width: 32, + height: 32, + }; + + registry.register(planet.id, planet); + + engine = new PhysicsEngine(registry, { + fixedTimestep: 1 / 60, + maxSubSteps: 5, + autoStart: true, + }); + + expect(engine.getFixedTimestep()).toBe(1 / 60); + + return new Promise((resolve) => { + setTimeout(() => { + engine.stop(); + + const position = registry.get(planet.id)?.position; + expect(position).toBeDefined(); + + // Position should have changed + expect(position?.x).toBeGreaterThan(0); + + resolve(); + }, 100); + }); + }); +}); diff --git a/src/pages/Simulation/__tests__/PlanetCreation.test.ts b/src/pages/Simulation/__tests__/PlanetCreation.test.ts new file mode 100644 index 0000000..f34859e --- /dev/null +++ b/src/pages/Simulation/__tests__/PlanetCreation.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { earth } from "@/data/planets"; +import type { Planet } from "@/types/planet"; +import { SimulationWorld } from "../core/SimulationWorld"; + +/** + * Test edge cases in planet creation that could produce NaN mass + */ +describe("SimulationWorld - Planet Creation Edge Cases", () => { + it("should handle valid planet creation", () => { + const world = new SimulationWorld([earth]); + + const newPlanet = world.addPlanetFromTemplate(earth, { + radius: 1.5, + position: [10, 0, 0], + rotationSpeedY: 0.5, + }); + + expect(newPlanet.mass).toBeGreaterThan(0); + expect(Number.isFinite(newPlanet.mass)).toBe(true); + expect(newPlanet.radius).toBe(1.5); + expect(newPlanet.position.x).toBe(10); + }); + + it("should handle very small radius without producing NaN mass", () => { + const world = new SimulationWorld([earth]); + + const newPlanet = world.addPlanetFromTemplate(earth, { + radius: 0.1, // Very small + position: [10, 0, 0], + rotationSpeedY: 0.5, + }); + + expect(newPlanet.mass).toBeGreaterThan(0); + expect(Number.isFinite(newPlanet.mass)).toBe(true); + expect(newPlanet.radius).toBe(0.1); + }); + + it("should handle template with zero radius by falling back to unit mass", () => { + const invalidTemplate: Planet = { + ...earth, + radius: 0, // Invalid + }; + + const world = new SimulationWorld([earth]); + + const newPlanet = world.addPlanetFromTemplate(invalidTemplate, { + radius: 1.0, + position: [10, 0, 0], + rotationSpeedY: 0.5, + }); + + // Should fallback to unit mass instead of NaN + expect(newPlanet.mass).toBe(1); + expect(Number.isFinite(newPlanet.mass)).toBe(true); + }); + + it("should handle template with NaN mass by falling back to unit mass", () => { + const invalidTemplate: Planet = { + ...earth, + mass: Number.NaN, // Invalid + }; + + const world = new SimulationWorld([earth]); + + const newPlanet = world.addPlanetFromTemplate(invalidTemplate, { + radius: 1.0, + position: [10, 0, 0], + rotationSpeedY: 0.5, + }); + + // Should fallback to unit mass instead of NaN + expect(newPlanet.mass).toBe(1); + expect(Number.isFinite(newPlanet.mass)).toBe(true); + }); + + it("should reject invalid position (NaN)", () => { + const world = new SimulationWorld([earth]); + + expect(() => { + world.addPlanetFromTemplate(earth, { + radius: 1.0, + position: [Number.NaN, 0, 0], // Invalid + rotationSpeedY: 0.5, + }); + }).toThrow("Invalid planet position"); + }); + + it("should reject invalid radius (zero)", () => { + const world = new SimulationWorld([earth]); + + expect(() => { + world.addPlanetFromTemplate(earth, { + radius: 0, // Invalid + position: [10, 0, 0], + rotationSpeedY: 0.5, + }); + }).toThrow("Invalid planet radius"); + }); + + it("should reject invalid radius (negative)", () => { + const world = new SimulationWorld([earth]); + + expect(() => { + world.addPlanetFromTemplate(earth, { + radius: -1, // Invalid + position: [10, 0, 0], + rotationSpeedY: 0.5, + }); + }).toThrow("Invalid planet radius"); + }); + + it("should reject invalid rotationSpeedY (NaN)", () => { + const world = new SimulationWorld([earth]); + + expect(() => { + world.addPlanetFromTemplate(earth, { + radius: 1.0, + position: [10, 0, 0], + rotationSpeedY: Number.NaN, // Invalid + }); + }).toThrow("Invalid planet rotationSpeedY"); + }); +}); diff --git a/src/pages/Simulation/components/CameraController.tsx b/src/pages/Simulation/components/CameraController.tsx index 22a621f..7b339c8 100644 --- a/src/pages/Simulation/components/CameraController.tsx +++ b/src/pages/Simulation/components/CameraController.tsx @@ -1,5 +1,5 @@ import { useFrame, useThree } from "@react-three/fiber"; -import { useEffect, useMemo } from "react"; +import { useEffect } from "react"; import type { OrbitControls } from "three-stdlib"; import { CameraFollowController } from "../core/CameraFollowController"; import type { PlanetRegistry } from "../core/PlanetRegistry"; @@ -7,20 +7,22 @@ import type { PlanetRegistry } from "../core/PlanetRegistry"; type CameraControllerProps = { followedPlanetId: string | null; planetRegistry: PlanetRegistry; - orbitControlsRef: React.MutableRefObject; + orbitControlsRef: React.RefObject; }; +// Reactのライフサイクル外でシングルトンとして管理する +const followController = new CameraFollowController(); + export function CameraController({ followedPlanetId, planetRegistry, orbitControlsRef, }: CameraControllerProps) { const { camera } = useThree(); - const followController = useMemo(() => new CameraFollowController(), []); useEffect(() => { return () => followController.reset(); - }, [followController]); + }, []); useFrame(() => { followController.update({ diff --git a/src/pages/Simulation/components/PlacementPanel.tsx b/src/pages/Simulation/components/PlacementPanel.tsx new file mode 100644 index 0000000..4bd73fb --- /dev/null +++ b/src/pages/Simulation/components/PlacementPanel.tsx @@ -0,0 +1,148 @@ +import { useState } from "react"; +import type { + PlanetRegistry, + PlanetRegistryEntry, +} from "../core/PlanetRegistry"; +import type { SimulationWorld } from "../core/SimulationWorld"; + +type PlacementPanelProps = { + worldState: ReturnType; + planetRegistry: PlanetRegistry; + simulationWorld: SimulationWorld; + syncWorld: () => void; + removePlanet: (planetId: string) => void; + placementMode: boolean; + setPlacementMode: (value: boolean) => void; +}; + +type PanelPlanet = { planetId: string; planet: PlanetRegistryEntry }; + +export function PlacementPanel({ + worldState, + planetRegistry, + simulationWorld, + syncWorld, + removePlanet, + placementMode, + setPlacementMode, +}: PlacementPanelProps) { + const [panelOpen, setPanelOpen] = useState(true); + + const panelPlanets = worldState.planetIds + .map((planetId) => { + const planet = planetRegistry.get(planetId); + if (!planet) return null; + return { planetId, planet }; + }) + .filter((item): item is PanelPlanet => item !== null); + + return ( +
+
+ クリック配置 +
+ + +
+
+ {panelOpen && ( + <> +

+ ONの間は水色の面をクリックすると、座標が自動入力されます。 +

+ + {worldState.followedPlanetId && ( +
+
+ + 追尾中: {(() => { + const planet = worldState.followedPlanetId + ? planetRegistry.get(worldState.followedPlanetId) + : undefined; + return planet ? ( + <> + {planet.name} +
+ (ID: {worldState.followedPlanetId}) + + ) : ( + "Unknown" + ); + })()} +
+ +
+
+ )} + + 追加済み惑星 ({worldState.planetIds.length}) +
    + {panelPlanets.map(({ planetId, planet }) => ( +
  • +
    +
    {planet.name}
    +
    + r={planet.radius.toFixed(1)} / ( + {planet.position.x.toFixed(1)}, + {planet.position.y.toFixed(1)},{" "} + {planet.position.z.toFixed(1)}) +
    +
    +
    + {worldState.followedPlanetId === planetId ? ( + + 追尾中 + + ) : ( + + )} + +
    +
  • + ))} +
+ + )} +
+ ); +} diff --git a/src/pages/Simulation/components/PlanetMesh.tsx b/src/pages/Simulation/components/PlanetMesh.tsx index 98af896..a640b2c 100644 --- a/src/pages/Simulation/components/PlanetMesh.tsx +++ b/src/pages/Simulation/components/PlanetMesh.tsx @@ -1,169 +1,74 @@ import { Trail, useTexture } from "@react-three/drei"; import { useFrame } from "@react-three/fiber"; -import { useEffect, useMemo, useRef } from "react"; -import * as THREE from "three"; -import type { Planet } from "@/types/planet"; -import { GravitySystem } from "../core/GravitySystem"; +import { useEffect, useRef } from "react"; +import type * as THREE from "three"; import type { PlanetRegistry } from "../core/PlanetRegistry"; -import { - CollisionType, - decideCollisionOutcome, -} from "../utils/decideCollisionOutcome"; -import { mergePlanets } from "../utils/mergePlanets"; + +const FALLBACK_TEXTURE = + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="; type PlanetMeshProps = { - planet: Planet; + planetId: string; planetRegistry: PlanetRegistry; - onExplosion: (position: THREE.Vector3, radius: number) => void; onSelect: (planetId: string) => void; - onMerge: (idA: string, idB: string, newData: Planet) => void; }; export function PlanetMesh({ - planet, + planetId, planetRegistry, - onExplosion, onSelect, - onMerge, }: PlanetMeshProps) { const meshRef = useRef(null); + const texturePath = + planetRegistry.get(planetId)?.texturePath ?? FALLBACK_TEXTURE; - // Load the texture (you can use any public Earth texture URL) - const [colorMap] = useTexture([planet.texturePath]); + const [colorMap] = useTexture([texturePath]); - // マウント時に自分のMeshをレジストリに登録し、他の惑星から参照できるようにする + // Initialize mesh position from registry on mount useEffect(() => { - if (meshRef.current) { - planetRegistry.register(planet.id, { - mass: planet.mass, - radius: planet.radius, - rotationSpeedY: planet.rotationSpeedY, - position: planet.position, - velocity: planet.velocity, - }); - // 初期位置の設定 - meshRef.current.position.copy( - new THREE.Vector3( - planet.position.x, - planet.position.y, - planet.position.z, - ), - ); + const entry = planetRegistry.get(planetId); + if (meshRef.current && entry) { + meshRef.current.position.copy(entry.position); } - return () => { - planetRegistry.unregister(planet.id); - }; - }, [ - planet.id, - planetRegistry, - planet.mass, - planet.radius, - planet.rotationSpeedY, - planet.position, - planet.velocity, - ]); - - // 計算用ベクトルをメモリに保持しておく(毎フレームnewしないため) - //const planetInfo = useMemo(() => planetRegistry.get(planet.id), []); - const gravitySystem = useMemo(() => new GravitySystem(), []); - const forceAccumulator = useMemo(() => new THREE.Vector3(), []); - const positionVec = useMemo(() => new THREE.Vector3(), []); - const velocityVec = useMemo(() => new THREE.Vector3(), []); + }, [planetId, planetRegistry]); - // This hook runs every frame (approx 60fps) + // Update mesh to match physics state every frame (rendering only) useFrame((_, delta) => { if (!meshRef.current) return; + const current = planetRegistry.get(planetId); + if (!current) return; + + // Validate position before copying to prevent NaN propagation + if ( + Number.isFinite(current.position.x) && + Number.isFinite(current.position.y) && + Number.isFinite(current.position.z) + ) { + // Sync mesh position with physics state + meshRef.current.position.copy(current.position); + } - // 力をリセット - forceAccumulator.set(0, 0, 0); - - // 重力の計算 - gravitySystem.accumulateForPlanet({ - planetId: planet.id, - targetMass: planet.mass, - targetRadius: planet.radius, - targetPosition: - planetRegistry.get(planet.id)?.position ?? planet.position, - planetRegistry, - outForce: forceAccumulator, - }); - - // 物理更新 - planetRegistry.update( - planet.id, - forceAccumulator.divideScalar(planet.mass), - delta, - ); - - positionVec.copy( - planetRegistry.get(planet.id)?.position ?? planet.position, - ); - velocityVec.copy( - planetRegistry.get(planet.id)?.velocity ?? planet.velocity, - ); - - // ===== 衝突判定ここから ===== - for (const [otherId, other] of planetRegistry) { - if (otherId === planet.id) continue; - - const otherPos = other.position; - - const dx = otherPos.x - positionVec.x; - const dy = otherPos.y - positionVec.y; - const dz = otherPos.z - positionVec.z; - const distSq = dx * dx + dy * dy + dz * dz; - - const otherRadius = other.radius; - const minDist = planet.radius + otherRadius; - - if (distSq <= minDist * minDist) { - // 衝突発生 - - if (planet.id < otherId) { - const result: string = decideCollisionOutcome( - planet.mass, - planet.radius, - positionVec.clone(), - velocityVec.clone(), - other.mass, - other.radius, - other.position.clone(), - other.velocity.clone(), - ); + // Update rotation (visual only, not physics) + meshRef.current.rotation.y += current.rotationSpeedY * delta; + }); - if (result === CollisionType.Merge) { - const newData = mergePlanets( - planet.mass, - planet.radius, - positionVec.clone(), - velocityVec.clone(), - planet.rotationSpeedY, - other.mass, - other.radius, - other.position.clone(), - other.velocity.clone(), - other.rotationSpeedY, - ); - onMerge(planet.id, otherId, newData); - } else { - const collisionPoint = positionVec.clone(); - onExplosion(collisionPoint, minDist); - } - } - } - } - // ===== 衝突判定ここまで ===== + const renderPlanet = planetRegistry.get(planetId); + if (!renderPlanet) return null; - // Meshへの反映 - meshRef.current.position.copy(positionVec); + // Ensure valid position values for rendering + const hasValidPosition = + Number.isFinite(renderPlanet.position.x) && + Number.isFinite(renderPlanet.position.y) && + Number.isFinite(renderPlanet.position.z); - // 自転 - meshRef.current.rotation.y += planet.rotationSpeedY * delta; - }, 0); + if (!hasValidPosition) { + console.warn(`Planet ${planetId} has invalid position, skipping render`); + return null; + } return ( t} @@ -171,15 +76,19 @@ export function PlanetMesh({ {/* biome-ignore lint: noStaticElementInteractions - Three.js mesh is not a DOM element*/} { e.stopPropagation(); - onSelect(planet.id); + onSelect(planetId); }} > - {/* args: [radius, widthSegments, heightSegments] - Higher segments = smoother sphere - */} - + diff --git a/src/pages/Simulation/components/SimulationCanvas.tsx b/src/pages/Simulation/components/SimulationCanvas.tsx new file mode 100644 index 0000000..111477a --- /dev/null +++ b/src/pages/Simulation/components/SimulationCanvas.tsx @@ -0,0 +1,110 @@ +import { OrbitControls, Stars } from "@react-three/drei"; +import { Canvas } from "@react-three/fiber"; +import { Suspense } from "react"; +import type { OrbitControls as Controls } from "three-stdlib"; +import type { PlanetRegistry } from "../core/PlanetRegistry"; +import type { SimulationWorld } from "../core/SimulationWorld"; +import { CameraController } from "./CameraController"; +import { Explosion } from "./Explosion"; +import { PlanetMesh } from "./PlanetMesh"; +import { PlacementSurface, PreviewPlanet } from "./PlanetPlacementView"; + +type SimulationCanvasProps = { + worldState: ReturnType; + planetRegistry: PlanetRegistry; + simulationWorld: SimulationWorld; + syncWorld: () => void; + orbitControlsRef: React.RefObject; + placementMode: boolean; + posY: number; + showPreview: boolean; + showGrid: boolean; + showAxes: boolean; + previewRadius: number; + previewPosition: [number, number, number]; + onPlace: (position: [number, number, number]) => void; +}; + +export function SimulationCanvas({ + worldState, + planetRegistry, + simulationWorld, + syncWorld, + orbitControlsRef, + placementMode, + posY, + showPreview, + showGrid, + showAxes, + previewRadius, + previewPosition, + onPlace, +}: SimulationCanvasProps) { + return ( + { + gl.setClearColor("#000000", 1); + }} + style={{ width: "100vw", height: "100vh" }} + > + {/* Adds ambient and directional light so we can see the 3D shape */} + + + + + + {worldState.planetIds.map((planetId) => ( + + { + simulationWorld.setFollowedPlanetId(id); + syncWorld(); + }} + /> + + ))} + + + + {showPreview && ( + + )} + {showGrid && } + {showAxes && } + + {worldState.explosions.map((exp) => ( + { + simulationWorld.completeExplosion(exp.id); + syncWorld(); + }} + /> + ))} + + {/* Optional background and controls */} + + + + ); +} diff --git a/src/pages/Simulation/core/GravitySystem.ts b/src/pages/Simulation/core/GravitySystem.ts index 4371ba7..fd81da6 100644 --- a/src/pages/Simulation/core/GravitySystem.ts +++ b/src/pages/Simulation/core/GravitySystem.ts @@ -35,9 +35,20 @@ export class GravitySystem { radius: otherRadius, position: otherPosition, } = other; + + // Validate other planet data + if (!otherMass || otherMass <= 0) continue; + if (!otherRadius || otherRadius <= 0) continue; + if ( + !Number.isFinite(otherPosition.x) || + !Number.isFinite(otherPosition.y) || + !Number.isFinite(otherPosition.z) + ) + continue; + this.sourcePosition.copy(otherPosition); - const sourceMass = otherMass || 1; - const sourceRadius = otherRadius || 0.1; + const sourceMass = otherMass; + const sourceRadius = otherRadius; this.addPairForce( outForce, diff --git a/src/pages/Simulation/core/PhysicsEngine.ts b/src/pages/Simulation/core/PhysicsEngine.ts new file mode 100644 index 0000000..4c379f9 --- /dev/null +++ b/src/pages/Simulation/core/PhysicsEngine.ts @@ -0,0 +1,362 @@ +import * as THREE from "three"; +import type { Planet } from "@/types/planet"; +import { + CollisionType, + decideCollisionOutcome, +} from "../utils/decideCollisionOutcome"; +import { mergePlanets } from "../utils/mergePlanets"; +import { GravitySystem } from "./GravitySystem"; +import type { PlanetRegistry } from "./PlanetRegistry"; + +/** + * Event types emitted by the PhysicsEngine + */ +export type PhysicsEvent = + | { + type: "collision:merge"; + idA: string; + idB: string; + newPlanet: Planet; + position: THREE.Vector3; + radius: number; + } + | { + type: "collision:explode"; + idA: string; + idB: string; + position: THREE.Vector3; + radius: number; + } + | { + type: "collision:repulse"; + idA: string; + idB: string; + position: THREE.Vector3; + radius: number; + } + | { type: "update"; timestamp: number }; + +export type PhysicsEventListener = (event: PhysicsEvent) => void; + +/** + * Configuration for the PhysicsEngine + */ +export type PhysicsEngineConfig = { + /** Fixed timestep for physics updates in seconds (default: 1/60 = ~16.67ms) */ + fixedTimestep?: number; + /** Maximum number of physics steps per frame to prevent spiral of death (default: 5) */ + maxSubSteps?: number; + /** Whether the engine should start running immediately (default: true) */ + autoStart?: boolean; +}; + +/** + * Standalone physics engine that runs independently of React. + * Handles all physics calculations with a fixed timestep for deterministic simulation. + * + * Key features: + * - Fixed timestep physics loop (default 60Hz) + * - Centralized gravity and collision calculations + * - Event-based communication with rendering layer + * - No React dependencies - fully testable + */ +export class PhysicsEngine { + private planetRegistry: PlanetRegistry; + private gravitySystem: GravitySystem; + private listeners: PhysicsEventListener[] = []; + private running = false; + private lastTime = 0; + private accumulator = 0; + + // Configuration + private readonly fixedTimestep: number; + private readonly maxSubSteps: number; + + // Temporary vectors for calculations (reused to avoid GC pressure) + private readonly forceAccumulator = new THREE.Vector3(); + private readonly positionVec = new THREE.Vector3(); + private readonly velocityVec = new THREE.Vector3(); + + // Animation frame handle for cleanup + private animationFrameId: number | null = null; + + constructor( + planetRegistry: PlanetRegistry, + config: PhysicsEngineConfig = {}, + ) { + this.planetRegistry = planetRegistry; + this.gravitySystem = new GravitySystem(); + + this.fixedTimestep = config.fixedTimestep ?? 1 / 60; + this.maxSubSteps = config.maxSubSteps ?? 5; + + if (config.autoStart !== false) { + this.start(); + } + } + + /** + * Start the physics loop + */ + public start(): void { + if (this.running) return; + this.running = true; + this.lastTime = performance.now() / 1000; + this.accumulator = 0; + this.tick(); + } + + /** + * Stop the physics loop + */ + public stop(): void { + this.running = false; + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + } + + /** + * Subscribe to physics events + */ + public on(listener: PhysicsEventListener): () => void { + this.listeners.push(listener); + // Return unsubscribe function + return () => { + const index = this.listeners.indexOf(listener); + if (index !== -1) { + this.listeners.splice(index, 1); + } + }; + } + + /** + * Emit an event to all listeners + */ + private emit(event: PhysicsEvent): void { + for (const listener of this.listeners) { + listener(event); + } + } + + /** + * Main physics loop with fixed timestep + * Uses the "semi-fixed timestep" pattern to prevent spiral of death + */ + private tick = (): void => { + if (!this.running) return; + + const currentTime = performance.now() / 1000; + let deltaTime = currentTime - this.lastTime; + this.lastTime = currentTime; + + // Prevent spiral of death: cap the maximum deltaTime + if (deltaTime > this.fixedTimestep * this.maxSubSteps) { + deltaTime = this.fixedTimestep * this.maxSubSteps; + } + + this.accumulator += deltaTime; + + // Process physics in fixed timesteps + let steps = 0; + while (this.accumulator >= this.fixedTimestep && steps < this.maxSubSteps) { + this.step(this.fixedTimestep); + this.accumulator -= this.fixedTimestep; + steps++; + } + + // Emit update event after all physics steps + this.emit({ type: "update", timestamp: currentTime }); + + // Schedule next tick + this.animationFrameId = requestAnimationFrame(this.tick); + }; + + /** + * Execute a single physics step + */ + private step(delta: number): void { + // 1. Calculate forces and update all planet positions/velocities + this.updatePlanets(delta); + + // 2. Detect and resolve collisions + this.detectCollisions(); + } + + /** + * Update all planets: calculate gravity forces and integrate motion + */ + private updatePlanets(delta: number): void { + for (const [planetId, planet] of this.planetRegistry) { + // Validate planet data to prevent NaN propagation + if (!planet.mass || planet.mass <= 0) { + console.warn(`Planet ${planetId} has invalid mass: ${planet.mass}`); + continue; + } + if (!planet.radius || planet.radius <= 0) { + console.warn(`Planet ${planetId} has invalid radius: ${planet.radius}`); + continue; + } + if ( + !Number.isFinite(planet.position.x) || + !Number.isFinite(planet.position.y) || + !Number.isFinite(planet.position.z) + ) { + console.warn(`Planet ${planetId} has NaN position`, planet.position); + continue; + } + + // Reset force accumulator + this.forceAccumulator.set(0, 0, 0); + + // Calculate gravitational forces from all other planets + this.gravitySystem.accumulateForPlanet({ + planetId, + targetMass: planet.mass, + targetRadius: planet.radius, + targetPosition: planet.position, + planetRegistry: this.planetRegistry, + outForce: this.forceAccumulator, + }); + + // Calculate acceleration (F = ma → a = F/m) + const acceleration = this.forceAccumulator + .clone() + .divideScalar(planet.mass); + + // Validate acceleration before updating + if ( + !Number.isFinite(acceleration.x) || + !Number.isFinite(acceleration.y) || + !Number.isFinite(acceleration.z) + ) { + console.warn( + `Planet ${planetId} calculated NaN acceleration`, + acceleration, + ); + continue; + } + + // Update velocity and position using Euler integration + this.planetRegistry.update(planetId, acceleration, delta); + } + } + + /** + * Detect collisions between all planets and emit events + */ + private detectCollisions(): void { + const planetIds: string[] = []; + + // Collect all planet IDs + for (const [id] of this.planetRegistry) { + planetIds.push(id); + } + + // Check all pairs of planets (i < j to avoid duplicates) + for (let i = 0; i < planetIds.length; i++) { + const idA = planetIds[i]; + const planetA = this.planetRegistry.get(idA); + if (!planetA) continue; + + // Get fresh position/velocity after physics update + this.positionVec.copy(planetA.position); + this.velocityVec.copy(planetA.velocity); + + for (let j = i + 1; j < planetIds.length; j++) { + const idB = planetIds[j]; + const planetB = this.planetRegistry.get(idB); + if (!planetB) continue; + + // Calculate distance between planets + const dx = planetB.position.x - this.positionVec.x; + const dy = planetB.position.y - this.positionVec.y; + const dz = planetB.position.z - this.positionVec.z; + const distSq = dx * dx + dy * dy + dz * dz; + + const minDist = planetA.radius + planetB.radius; + + // Check if planets are colliding + if (distSq <= minDist * minDist) { + // Decide collision outcome based on physics + const outcome = decideCollisionOutcome( + planetA.mass, + planetA.radius, + this.positionVec.clone(), + this.velocityVec.clone(), + planetB.mass, + planetB.radius, + planetB.position.clone(), + planetB.velocity.clone(), + ); + + if (outcome === CollisionType.Merge) { + // Calculate merged planet properties + const newPlanet = mergePlanets( + planetA.mass, + planetA.radius, + this.positionVec.clone(), + this.velocityVec.clone(), + planetA.rotationSpeedY, + planetB.mass, + planetB.radius, + planetB.position.clone(), + planetB.velocity.clone(), + planetB.rotationSpeedY, + ); + + const collisionPoint = this.positionVec.clone(); + + // Emit merge event + this.emit({ + type: "collision:merge", + idA, + idB, + newPlanet, + position: collisionPoint, + radius: minDist, + }); + } else { + // Calculate collision point for explosion + const collisionPoint = this.positionVec.clone(); + + // Emit explosion event + this.emit({ + type: "collision:explode", + idA, + idB, + position: collisionPoint, + radius: minDist, + }); + } + + // Skip checking this pair further (collision handled) + break; + } + } + } + } + + /** + * Check if the engine is currently running + */ + public isRunning(): boolean { + return this.running; + } + + /** + * Get the current fixed timestep + */ + public getFixedTimestep(): number { + return this.fixedTimestep; + } + + /** + * Destroy the engine and clean up resources + */ + public destroy(): void { + this.stop(); + this.listeners = []; + } +} diff --git a/src/pages/Simulation/core/PlanetRegistry.ts b/src/pages/Simulation/core/PlanetRegistry.ts index 0881122..6664dc9 100644 --- a/src/pages/Simulation/core/PlanetRegistry.ts +++ b/src/pages/Simulation/core/PlanetRegistry.ts @@ -1,16 +1,14 @@ import type * as THREE from "three"; -export type PositionRef = { - current: number[]; -}; - -export type VelocityRef = { - current: number[]; -}; +import type { Planet } from "@/types/planet"; export type PlanetRegistryEntry = { mass: number; radius: number; rotationSpeedY: number; + texturePath: string; + width: number; + height: number; + name: string; position: THREE.Vector3; velocity: THREE.Vector3; }; @@ -18,8 +16,18 @@ export type PlanetRegistryEntry = { export class PlanetRegistry implements Iterable<[string, PlanetRegistryEntry]> { private readonly entries = new Map(); - register(id: string, entry: PlanetRegistryEntry) { - this.entries.set(id, entry); + register(id: string, planet: Planet) { + this.entries.set(id, { + mass: planet.mass, + radius: planet.radius, + rotationSpeedY: planet.rotationSpeedY, + texturePath: planet.texturePath, + width: planet.width, + height: planet.height, + name: planet.name, + position: planet.position.clone(), + velocity: planet.velocity.clone(), + }); } unregister(id: string) { diff --git a/src/pages/Simulation/core/SimulationWorld.ts b/src/pages/Simulation/core/SimulationWorld.ts index 699a6d1..9b1893a 100644 --- a/src/pages/Simulation/core/SimulationWorld.ts +++ b/src/pages/Simulation/core/SimulationWorld.ts @@ -15,39 +15,58 @@ export type mergeQueueProps = { }; export type SimulationWorldSnapshot = { - planets: Planet[]; + planetIds: string[]; explosions: ExplosionData[]; mergeQueue: { id: string; data: mergeQueueProps }[]; followedPlanetId: string | null; }; function computeMass(radius: number, mass: number, newRadius: number) { - return mass * (newRadius / radius) ** 3; -} + // Validate inputs to prevent NaN + if (!Number.isFinite(radius) || radius <= 0) { + console.warn("computeMass: invalid radius", radius); + return 1; // Fallback to unit mass + } + if (!Number.isFinite(mass) || mass <= 0) { + console.warn("computeMass: invalid mass", mass); + return 1; // Fallback to unit mass + } + if (!Number.isFinite(newRadius) || newRadius <= 0) { + console.warn("computeMass: invalid newRadius", newRadius); + return 1; // Fallback to unit mass + } + + const computedMass = mass * (newRadius / radius) ** 3; + + // Validate output + if (!Number.isFinite(computedMass) || computedMass <= 0) { + console.warn( + "computeMass: computed invalid mass", + computedMass, + "from inputs:", + { radius, mass, newRadius }, + ); + return 1; // Fallback to unit mass + } -function clonePlanet(planet: Planet): Planet { - return { - ...planet, - position: planet.position.clone(), - velocity: planet.velocity.clone(), - }; + return computedMass; } export class SimulationWorld { - private planets: Planet[]; + private activePlanetIds: Set; private explosions: ExplosionData[] = []; private mergeQueue: { id: string; data: mergeQueueProps }[] = []; private followedPlanetId: string | null = null; private snapshot: SimulationWorldSnapshot; constructor(initialPlanets: Planet[]) { - this.planets = initialPlanets.map(clonePlanet); + this.activePlanetIds = new Set(initialPlanets.map((planet) => planet.id)); this.snapshot = this.buildSnapshot(); } private buildSnapshot(): SimulationWorldSnapshot { return { - planets: this.planets, + planetIds: [...this.activePlanetIds], explosions: this.explosions, mergeQueue: this.mergeQueue, followedPlanetId: this.followedPlanetId, @@ -58,40 +77,72 @@ export class SimulationWorld { this.snapshot = this.buildSnapshot(); } - addPlanetFromTemplate(template: Planet, settings: NewPlanetSettings) { + /** + * Manually update the snapshot after making changes. + * Call this after adding/removing planets to refresh the snapshot. + */ + public refreshSnapshot(): void { + this.updateSnapshot(); + } + + getSnapshot(): SimulationWorldSnapshot { + return this.snapshot; + } + + addPlanetFromTemplate(template: Planet, settings: NewPlanetSettings): Planet { const [posX, posY, posZ] = settings.position; + + // Validate inputs + if ( + !Number.isFinite(posX) || + !Number.isFinite(posY) || + !Number.isFinite(posZ) + ) { + console.error( + "addPlanetFromTemplate: invalid position", + settings.position, + ); + throw new Error("Invalid planet position"); + } + if (!Number.isFinite(settings.radius) || settings.radius <= 0) { + console.error("addPlanetFromTemplate: invalid radius", settings.radius); + throw new Error("Invalid planet radius"); + } + if (!Number.isFinite(settings.rotationSpeedY)) { + console.error( + "addPlanetFromTemplate: invalid rotationSpeedY", + settings.rotationSpeedY, + ); + throw new Error("Invalid planet rotationSpeedY"); + } + const mass = computeMass(template.radius, template.mass, settings.radius); - this.planets = [ - ...this.planets, - { - id: crypto.randomUUID(), - name: template.name, - texturePath: template.texturePath, - rotationSpeedY: settings.rotationSpeedY, - radius: settings.radius, - width: 64, - height: 64, - position: new THREE.Vector3(posX, posY, posZ), - velocity: new THREE.Vector3(0, 0, 0), - mass, - }, - ]; - this.updateSnapshot(); + const newPlanet: Planet = { + id: crypto.randomUUID(), + name: template.name, + texturePath: template.texturePath, + rotationSpeedY: settings.rotationSpeedY, + radius: settings.radius, + width: 64, + height: 64, + position: new THREE.Vector3(posX, posY, posZ), + velocity: new THREE.Vector3(0, 0, 0), + mass, + }; + this.activePlanetIds.add(newPlanet.id); + // Don't update snapshot here - let caller do it after registering in PlanetRegistry + // This prevents race condition where React renders with planet ID but no registry entry + return newPlanet; } addPlanet(data: Planet) { - if (this.planets.some((planet) => planet.id === data.id)) return; - this.planets = [ - ...this.planets, - { - ...data, - }, - ]; + if (this.activePlanetIds.has(data.id)) return; + this.activePlanetIds.add(data.id); this.updateSnapshot(); } removePlanet(planetId: string) { - this.planets = this.planets.filter((planet) => planet.id !== planetId); + this.activePlanetIds.delete(planetId); if (this.followedPlanetId === planetId) { this.followedPlanetId = null; } @@ -99,14 +150,29 @@ export class SimulationWorld { } setFollowedPlanetId(planetId: string | null) { - this.followedPlanetId = planetId; + if (planetId && !this.activePlanetIds.has(planetId)) { + this.followedPlanetId = null; + } else { + this.followedPlanetId = planetId; + } this.updateSnapshot(); } - registerExplosion(position: THREE.Vector3, radius: number) { + registerExplosion( + idA: string, + idB: string, + position: THREE.Vector3, + radius: number, + ) { if (this.explosions.some((e) => e.position.distanceTo(position) < 2)) { return; } + // Remove the two exploding planets + this.activePlanetIds.delete(idA); + this.activePlanetIds.delete(idB); + if (this.followedPlanetId === idA || this.followedPlanetId === idB) { + this.followedPlanetId = null; + } this.explosions = [ ...this.explosions, { @@ -119,6 +185,23 @@ export class SimulationWorld { this.updateSnapshot(); } + /** + * Register a small spark effect at a position without removing any planets. + * Used for repulse and merge collision visual feedback. + */ + registerSpark(position: THREE.Vector3, radius: number, fragmentCount = 10) { + this.explosions = [ + ...this.explosions, + { + id: crypto.randomUUID(), + radius: radius, + position: position.clone(), + fragmentCount, + }, + ]; + this.updateSnapshot(); + } + completeExplosion(explosionId: string) { this.explosions = this.explosions.filter( (explosion) => explosion.id !== explosionId, @@ -126,16 +209,14 @@ export class SimulationWorld { this.updateSnapshot(); } - registerMergeQueue( - obsoleteIdA: string, - obsoleteIdB: string, - newData: Planet, - ) { + registerMerge(obsoleteIdA: string, obsoleteIdB: string, newData: Planet) { if ( this.mergeQueue.some( (queue) => - queue.data.obsoleteIdA === obsoleteIdA && - queue.data.obsoleteIdB === obsoleteIdB, + (queue.data.obsoleteIdA === obsoleteIdA && + queue.data.obsoleteIdB === obsoleteIdB) || + (queue.data.obsoleteIdA === obsoleteIdB && + queue.data.obsoleteIdB === obsoleteIdA), ) ) return; @@ -155,18 +236,24 @@ export class SimulationWorld { this.updateSnapshot(); } + registerMergeQueue( + obsoleteIdA: string, + obsoleteIdB: string, + newData: Planet, + ) { + this.registerMerge(obsoleteIdA, obsoleteIdB, newData); + } + completeMergeQueue(obsoleteIdA: string, obsoleteIdB: string) { this.mergeQueue = this.mergeQueue.filter( (queue) => !( - queue.data.obsoleteIdA === obsoleteIdA && - queue.data.obsoleteIdB === obsoleteIdB + (queue.data.obsoleteIdA === obsoleteIdA && + queue.data.obsoleteIdB === obsoleteIdB) || + (queue.data.obsoleteIdA === obsoleteIdB && + queue.data.obsoleteIdB === obsoleteIdA) ), ); this.updateSnapshot(); } - - getSnapshot(): SimulationWorldSnapshot { - return this.snapshot; - } } diff --git a/src/pages/Simulation/hooks/useLevaControls.ts b/src/pages/Simulation/hooks/useLevaControls.ts new file mode 100644 index 0000000..8ed4650 --- /dev/null +++ b/src/pages/Simulation/hooks/useLevaControls.ts @@ -0,0 +1,130 @@ +import { button, useControls } from "leva"; +import { useEffect, useRef } from "react"; +import type { OrbitControls as Controls } from "three-stdlib"; +import { earth, jupiter, mars, sun, venus } from "@/data/planets"; +import type { PlanetRegistry } from "../core/PlanetRegistry"; +import type { SimulationWorld } from "../core/SimulationWorld"; + +const planetTemplates = { earth, sun, mars, jupiter, venus } as const; + +type UseLevaControlsOptions = { + simulationWorld: SimulationWorld; + planetRegistry: PlanetRegistry; + syncWorld: () => void; + orbitControlsRef: React.RefObject; +}; + +export function useLevaControls({ + simulationWorld, + planetRegistry, + syncWorld, + orbitControlsRef, +}: UseLevaControlsOptions) { + const selectedPlanetTypeRef = useRef("earth"); + const planetControlsRef = useRef<{ + radius: number; + posX: number; + posY: number; + posZ: number; + rotationSpeedY: number; + }>({ + radius: 1.2, + posX: 10, + posY: 0, + posZ: 0, + rotationSpeedY: 0.6, + }); + + const [planetControls, setPlanetControls] = useControls("New Planet", () => { + return { + planetType: { + value: "earth", + options: { + Earth: "earth", + Sun: "sun", + Mars: "mars", + Jupiter: "jupiter", + Venus: "venus", + }, + onChange: (value) => { + const selectedType = + (value as keyof typeof planetTemplates) ?? "earth"; + selectedPlanetTypeRef.current = selectedType; + const template = planetTemplates[selectedType] ?? earth; + setPlanetControls({ + radius: template.radius, + rotationSpeedY: template.rotationSpeedY, + }); + }, + }, + radius: { value: 1.2, min: 0.2, max: 6, step: 0.1 }, + posX: { value: 10, min: -200, max: 200, step: 0.2 }, + posY: { value: 0, min: -200, max: 200, step: 0.2 }, + posZ: { value: 0, min: -200, max: 200, step: 0.2 }, + rotationSpeedY: { value: 0.6, min: 0, max: 10, step: 0.1 }, + addPlanet: button(() => { + // Read current values from the ref to avoid stale closure + const current = planetControlsRef.current; + console.log("Button clicked! Current planetControls:", current); + + const selectedType = selectedPlanetTypeRef.current; + const template = planetTemplates[selectedType] ?? earth; + + console.log("Adding planet with settings:", { + radius: current.radius, + position: [current.posX, current.posY, current.posZ], + rotationSpeedY: current.rotationSpeedY, + }); + + const newPlanet = simulationWorld.addPlanetFromTemplate(template, { + radius: current.radius, + position: [current.posX, current.posY, current.posZ], + rotationSpeedY: current.rotationSpeedY, + }); + + console.log("Created planet:", newPlanet.id, newPlanet); + + // CRITICAL: Register in planetRegistry before calling syncWorld() + // to avoid race condition where React renders with planet ID but no registry entry + planetRegistry.register(newPlanet.id, newPlanet); + console.log("Registered planet in registry"); + + // Update the snapshot after both operations are complete + simulationWorld.refreshSnapshot(); + console.log("Refreshed snapshot"); + + syncWorld(); + console.log( + "Synced world, current snapshot:", + simulationWorld.getSnapshot(), + ); + }), + }; + }); + + // Keep the ref in sync with the latest Leva control values + useEffect(() => { + planetControlsRef.current = planetControls; + }, [planetControls]); + + const { showGrid, showAxes, showPreview } = useControls("Helpers", { + showGrid: true, + showAxes: true, + showPreview: true, + resetCameraPosition: button(() => { + if (orbitControlsRef.current) { + orbitControlsRef.current.reset(); + orbitControlsRef.current.target.set(0, 0, 0); + orbitControlsRef.current.update(); + } + }), + }); + + return { + planetControls, + setPlanetControls, + showGrid, + showAxes, + showPreview, + }; +} diff --git a/src/pages/Simulation/hooks/useSimulation.ts b/src/pages/Simulation/hooks/useSimulation.ts new file mode 100644 index 0000000..8957cdb --- /dev/null +++ b/src/pages/Simulation/hooks/useSimulation.ts @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useState } from "react"; +import { earth } from "@/data/planets"; +import { PhysicsEngine } from "../core/PhysicsEngine"; +import { PlanetRegistry } from "../core/PlanetRegistry"; +import { SimulationWorld } from "../core/SimulationWorld"; + +// Reactのライフサイクル外で、モジュールレベルのシングルトンとして管理する +const planetRegistry = new PlanetRegistry(); +planetRegistry.register(earth.id, earth); + +const simulationWorld = new SimulationWorld([earth]); + +const physicsEngine = new PhysicsEngine(planetRegistry, { + fixedTimestep: 1 / 60, + maxSubSteps: 5, + autoStart: true, +}); + +export function useSimulation() { + const [worldState, setWorldState] = useState(() => + simulationWorld.getSnapshot(), + ); + + const syncWorld = useCallback(() => { + setWorldState(simulationWorld.getSnapshot()); + }, []); + + useEffect(() => { + const unsubscribe = physicsEngine.on((event) => { + if (event.type === "collision:merge") { + // 古い惑星を即座に物理エンジンから削除 + planetRegistry.unregister(event.idA); + planetRegistry.unregister(event.idB); + // SimulationWorld からも削除 + simulationWorld.removePlanet(event.idA); + simulationWorld.removePlanet(event.idB); + // 新しい合体惑星を即座に追加 + planetRegistry.register(event.newPlanet.id, event.newPlanet); + simulationWorld.addPlanet(event.newPlanet); + // 合体時に小さなオレンジのスパークエフェクト + simulationWorld.registerSpark(event.position, event.radius * 0.8, 10); + syncWorld(); + } else if (event.type === "collision:repulse") { + // 反発時はオレンジのスパークのみ。惑星は削除しない + simulationWorld.registerSpark(event.position, event.radius, 8); + syncWorld(); + } else if (event.type === "collision:explode") { + planetRegistry.unregister(event.idA); + planetRegistry.unregister(event.idB); + simulationWorld.registerExplosion( + event.idA, + event.idB, + event.position, + event.radius, + ); + syncWorld(); + } + }); + + return () => { + unsubscribe(); + }; + }, [syncWorld]); + + // Hookのアンマウント時にエンジンを止めることはしない + // (もしページ遷移時などにエンジンを止めたい場合は別コンテキストでの制御が必要) + + const removePlanet = useCallback( + (planetId: string) => { + planetRegistry.unregister(planetId); + simulationWorld.removePlanet(planetId); + syncWorld(); + }, + [syncWorld], + ); + + return { + planetRegistry, + simulationWorld, + worldState, + syncWorld, + removePlanet, + }; +} diff --git a/src/pages/Simulation/index.tsx b/src/pages/Simulation/index.tsx index bb5888c..0a9c1c4 100644 --- a/src/pages/Simulation/index.tsx +++ b/src/pages/Simulation/index.tsx @@ -1,21 +1,11 @@ -import { OrbitControls, Stars, useTexture } from "@react-three/drei"; -import { Canvas } from "@react-three/fiber"; -import { button, useControls } from "leva"; -import { Suspense, useMemo, useRef, useState } from "react"; -import type { Vector3 } from "three"; +import { useTexture } from "@react-three/drei"; +import { useMemo, useRef, useState } from "react"; import type { OrbitControls as Controls } from "three-stdlib"; import { earth, jupiter, mars, sun, venus } from "@/data/planets"; -import type { Planet } from "@/types/planet"; -import { CameraController } from "./components/CameraController"; -import { Explosion } from "./components/Explosion"; -import { MergeController } from "./components/MergeController"; -import { PlanetMesh } from "./components/PlanetMesh"; -import { - PlacementSurface, - PreviewPlanet, -} from "./components/PlanetPlacementView"; -import { PlanetRegistry } from "./core/PlanetRegistry"; -import { SimulationWorld } from "./core/SimulationWorld"; +import { PlacementPanel } from "./components/PlacementPanel"; +import { SimulationCanvas } from "./components/SimulationCanvas"; +import { useLevaControls } from "./hooks/useLevaControls"; +import { useSimulation } from "./hooks/useSimulation"; const planetTexturePaths = [ earth.texturePath, @@ -26,89 +16,25 @@ const planetTexturePaths = [ ]; useTexture.preload(planetTexturePaths); -const planetTemplates = { earth, sun, mars, jupiter, venus } as const; - export default function Page() { const orbitControlsRef = useRef(null); - const planetRegistry = useMemo(() => new PlanetRegistry(), []); - const simulationWorld = useMemo(() => new SimulationWorld([earth]), []); - - const [worldState, setWorldState] = useState(() => - simulationWorld.getSnapshot(), - ); - const [placementMode, setPlacementMode] = useState(false); - const [placementPanelOpen, setPlacementPanelOpen] = useState(true); - const syncWorld = () => { - setWorldState(simulationWorld.getSnapshot()); - }; - - const [planetControls, setPlanetControls, getPlanetControl] = useControls( - "New Planet", - () => ({ - planetType: { - value: "earth", - options: { - Earth: "earth", - Sun: "sun", - Mars: "mars", - Jupiter: "jupiter", - Venus: "venus", - }, - onChange: (value) => { - const selectedType = - (value as keyof typeof planetTemplates) ?? "earth"; - const template = planetTemplates[selectedType] ?? earth; - setPlanetControls({ - radius: template.radius, - rotationSpeedY: template.rotationSpeedY, - }); - }, - }, - radius: { value: 1.2, min: 0.2, max: 6, step: 0.1 }, - posX: { value: 0, min: -200, max: 200, step: 0.2 }, - posY: { value: 0, min: -200, max: 200, step: 0.2 }, - posZ: { value: 0, min: -200, max: 200, step: 0.2 }, - rotationSpeedY: { value: 0.6, min: 0, max: 10, step: 0.1 }, - }), - ); - - useControls("New Planet", { - addPlanet: button(() => { - const selectedType = - (getPlanetControl("planetType") as keyof typeof planetTemplates) ?? - "earth"; - const template = planetTemplates[selectedType] ?? earth; - const settings = { - radius: getPlanetControl("radius"), - posX: getPlanetControl("posX"), - posY: getPlanetControl("posY"), - posZ: getPlanetControl("posZ"), - rotationSpeedY: getPlanetControl("rotationSpeedY"), - }; - - simulationWorld.addPlanetFromTemplate(template, { - radius: settings.radius, - position: [settings.posX, settings.posY, settings.posZ], - rotationSpeedY: settings.rotationSpeedY, - }); - syncWorld(); - }), - }); - - const { showGrid, showAxes, showPreview } = useControls("Helpers", { - showGrid: true, - showAxes: true, - showPreview: true, - resetCameraPosition: button(() => { - if (orbitControlsRef.current) { - orbitControlsRef.current.reset(); - orbitControlsRef.current.target.set(0, 0, 0); - orbitControlsRef.current.update(); - } - }), - }); + const { + planetRegistry, + simulationWorld, + worldState, + syncWorld, + removePlanet, + } = useSimulation(); + + const { planetControls, setPlanetControls, showGrid, showAxes, showPreview } = + useLevaControls({ + simulationWorld, + planetRegistry, + syncWorld, + orbitControlsRef, + }); const previewPosition = useMemo<[number, number, number]>( () => [planetControls.posX, planetControls.posY, planetControls.posZ], @@ -123,224 +49,32 @@ export default function Page() { }); }; - const removePlanet = (planetId: string) => { - simulationWorld.removePlanet(planetId); - syncWorld(); - }; - - const handleExplosion = (position: Vector3, radius: number) => { - simulationWorld.registerExplosion(position, radius); - syncWorld(); - }; - - const handleMerge = ( - obsoleteIdA: string, - obsoleteIdB: string, - newData: Planet, - ) => { - simulationWorld.registerMergeQueue(obsoleteIdA, obsoleteIdB, newData); - syncWorld(); - }; - return (
- { - gl.setClearColor("#000000", 1); - }} - style={{ width: "100vw", height: "100vh" }} - > - {/* Adds ambient and directional light so we can see the 3D shape */} - - - - - - {worldState.planets.map((planet) => ( - - { - simulationWorld.setFollowedPlanetId(id); - syncWorld(); - }} - onMerge={handleMerge} - /> - - ))} - - - - {showPreview && ( - - )} - {showGrid && } - {showAxes && } - - {worldState.mergeQueue.map((queue) => ( - - { - simulationWorld.addPlanet(newData); - syncWorld(); - }} - onDelete={(obsoleteId: string) => { - simulationWorld.removePlanet(obsoleteId); - syncWorld(); - }} - onComplete={(obsoleteIdA: string, obsoleteIdB: string) => { - simulationWorld.completeMergeQueue(obsoleteIdA, obsoleteIdB); - syncWorld(); - }} - /> - - ))} - - {worldState.explosions.map((exp) => ( - { - simulationWorld.completeExplosion(exp.id); - syncWorld(); - }} - /> - ))} - - {/* Optional background and controls */} - - - -
-
- クリック配置 -
- - -
-
- {placementPanelOpen && ( - <> -

- ONの間は水色の面をクリックすると、座標が自動入力されます。 -

- - {worldState.followedPlanetId && ( -
-
- - 追尾中: {(() => { - const planet = worldState.planets.find( - (p) => p.id === worldState.followedPlanetId, - ); - return planet ? ( - <> - {planet.name} -
- (ID: {planet.id}) - - ) : ( - "Unknown" - ); - })()} -
- -
-
- )} - - 追加済み惑星 ({worldState.planets.length}) -
    - {worldState.planets.map((planet) => ( -
  • -
    -
    {planet.name}
    -
    - r={planet.radius.toFixed(1)} / ( - {planet.position.x.toFixed(1)}, - {planet.position.y.toFixed(1)},{" "} - {planet.position.z.toFixed(1)}) -
    -
    -
    - {worldState.followedPlanetId === planet.id ? ( - - 追尾中 - - ) : ( - - )} - -
    -
  • - ))} -
- - )} -
+ +
); } diff --git a/vite.config.ts b/vite.config.ts index 8cdce9d..6def263 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,7 @@ import path from "node:path"; import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; +import { defineConfig } from "vitest/config"; // https://vite.dev/config/ export default defineConfig({ @@ -11,4 +11,8 @@ export default defineConfig({ "@": path.resolve(__dirname, "src"), }, }, + test: { + environment: "happy-dom", + globals: true, + }, });