diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0251b56..18ffdda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,24 +2,21 @@ name: CI on: push: - branches: [ main, feat/simulation-system ] + branches: ["**"] pull_request: + branches: ["**"] jobs: - test: + build-and-test: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-node@v4 with: - node-version: 20 - cache: npm + node-version: '20' + cache: 'npm' - run: npm ci - - name: Lint - run: npm run lint - - name: Build - run: npm run build - - name: Docs (TypeDoc) - run: npm run docs:api + - run: npm run lint - run: npm test -- --run diff --git a/.github/workflows/pr-policy.yml b/.github/workflows/pr-policy.yml new file mode 100644 index 0000000..d3d8863 --- /dev/null +++ b/.github/workflows/pr-policy.yml @@ -0,0 +1,59 @@ +name: PR Policy + +on: + pull_request: + types: [opened, edited, synchronize] + +jobs: + policy: + runs-on: ubuntu-latest + steps: + - name: Check milestone and tests present + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request + if (!pr) { + core.setFailed('No PR context') + return + } + // Require milestone + if (!pr.milestone) { + core.setFailed('PR must have a milestone assigned') + return + } + // Require at least one test file changed in the PR (tests-first discipline) + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + per_page: 100, + }) + // Escape hatch via label and audit line in body + const labels = (pr.labels || []).map(l => typeof l === 'string' ? l : l.name) + const skip = labels.includes('skip-test-check') + if (skip && /skip-test-check:/i.test(pr.body || '')) { + core.notice('tests-first check skipped via label + audit line') + return + } + // Allowlist infra/docs only changes + const infraOnly = files.every(f => ( + f.filename.startsWith('.github/') || + /\.(ya?ml|md|txt)$/.test(f.filename) || + /(^|\/)Dockerfile$/.test(f.filename) || + /(^|\/)package-lock\.json$/.test(f.filename) + )) + if (infraOnly) { + core.notice('infra/docs-only change: tests change not required') + return + } + const hasTest = files.some(f => /__tests__|\.test\.[tj]sx?$/.test(f.filename)) + if (!hasTest) { + core.setFailed('PR must include changes to tests (tests-first).') + return + } + // Optional: Encourage acceptance criteria section + const body = pr.body || '' + if (!/Acceptance Criteria/i.test(body)) { + core.notice('Consider adding an "Acceptance Criteria" section to the PR description.') + } diff --git a/.gitignore b/.gitignore index a547bf3..9b2ec6a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +feedback.md diff --git a/BLOG_NOTES.md b/BLOG_NOTES.md index 4fc6c3e..f2c204d 100644 --- a/BLOG_NOTES.md +++ b/BLOG_NOTES.md @@ -1,5 +1,7 @@ # On Document-Based Rendering Engines—Why Web Design Feels So Clunky And How To Fix It +{"date": "2025-11-03", "time": "02:42", "summary": "Add PhysicsRegistry + events + SAT + perf overlay; wire registry into controller; tests-first.", "topics": [{"topic": "PhysicsRegistry", "what": "Implemented DOM discovery of physics-enabled nodes (cloth, rigid-static), typed descriptors, and change events; integrated with controller for activation and static AABBs; emits on resize/scroll.", "why": "Make a single source of truth for activation/reset/inspector and align with upcoming inspector.", "context": "Controller previously queried .cloth-enabled and managed static AABBs ad-hoc; no registry events.", "issue": "Lack of typed descriptors and eventing made inspector and resets brittle.", "resolution": "New PhysicsRegistry with add/update/remove events; controller subscribes and updates meshes + collision system.", "future_work": "Extend for rigid-dynamic and data-attr parsing; consume in inspector UI."}, {"topic": "Collision (OBB SAT)", "what": "Added satObbAabb module for OBB vs AABB with MTV, restitution + friction helper; unit tests.", "why": "Prepare for rigid OBB; correct normals and response.", "context": "CollisionSystem only supported AABB vs cloth; no OBB SAT.", "issue": "Glancing collisions and rotation not handled.", "resolution": "SAT helpers and tests; integration of OBB pending."}, {"topic": "Events", "what": "Defined EngineEvent union + runtime validator; EventBus; events panel (filter + JSON) toggled via Cmd/Ctrl+E.", "why": "Make debugging observable and searchable.", "context": "No unified event typing or panel.", "issue": "Payloads ad-hoc and unvalidated.", "resolution": "Union types + guards + simple non-modal panel."}, {"topic": "Perf", "what": "Perf monitor with rolling averages + budgets and overlay; wrap EngineWorld.addSystem to time fixed/frame updates.", "why": "Track per-subsystem budgets (overlay ≤0.8 ms, sim ≤1.5 ms).", "context": "No visibility into subsystem frame time.", "issue": "Perf regressions hard to spot.", "resolution": "Perf overlay toggle in Debug; budgets flagged in red."}, {"topic": "UI polish", "what": "Drawer 150ms hide transition and toast 'panel clothified'; 'Wireframe preview' explainer.", "why": "Match UX expectations and clarify mesh vs DOM.", "context": "Close animation abrupt; unclear wireframe meaning.", "issue": "Debug palette closed instantly; wireframe ambiguous.", "resolution": "Mantine transitionProps; small top toast; explanatory copy."}, {"topic": "CI policy", "what": "Added CI and PR policy workflows: lint+test and milestone/tests gate.", "why": "Enforce tests-first and milestone linkage.", "context": "Prior marathon PR lacked structure.", "issue": "Hard to review and regressions slipped.", "resolution": "Fail PRs without milestone or tests; surface acceptance-criteria hint."}], "key_decisions": ["Use registry events to drive controller activation + collision seed", "Monkey-patch EngineWorld.addSystem to instrument perf without invasive changes", "Events panel is non-modal drawer substitute, docked ~45% height"], "action_items": [{"task": "Parse data attributes (mass, friction, restitution, shape) into rigid bodies and integrate SAT into CollisionSystem", "owner": "Assistant"}, {"task": "Emit engine events from activation/collision/sleep transitions", "owner": "Assistant"}, {"task": "Convert Events panel to draggable/resizable window (GSAP) per roadmap", "owner": "Assistant"}]} + For decades the web has treated every page like a document. HTML, CSS, and the browser’s layout engine were designed to flow text, paginate articles, and keep markup accessible no matter the device. That legacy is why a plain `

` looks right on every screen, why screen readers can navigate effortlessly, and why a broken JavaScript bundle doesn’t kill your content. It’s also why the typical website still feels like a dressed-up report despite our hardware being capable of running entire game engines. Document-centric rendering gives us universality, but the trade-off is rigidity. When a designer wants kinetic layout, real physics, or truly fluid animation, the DOM’s cascading rules, reflow, and box model become constraints rather than allies. Responsive design solved screen fragmentation with breakpoints, percentages, and media queries—a brute-force approach that keeps piling rules onto an already complex system. Developers juggle three technologies at once (HTML, CSS, JavaScript) just to coax an element into the right spot, while stakeholders wonder why “make it pop” becomes a project in itself. diff --git a/docs/issues/physics-registry.md b/docs/issues/physics-registry.md new file mode 100644 index 0000000..114d61e --- /dev/null +++ b/docs/issues/physics-registry.md @@ -0,0 +1,21 @@ + +# PhysicsRegistry — discovery, descriptors, and change events + +Labels: architecture, physics, registry, project:newton +Milestone: PROJECT: Newton + +## Summary + +Design and implement a PhysicsRegistry that discovers DOM nodes, stores typed descriptors (type, tag, attrs, origins), emits change events on layout/resize, and serves as the single source of truth for activation/reset/inspector. + +## Acceptance Criteria + +- [ ] Discovers `.cloth-enabled`, `.rigid-dynamic`, `.rigid-static` and nodes with `data-phys-*` or `data-cloth-*` attributes +- [ ] Descriptor includes: { id, tag, type, attrs (parsed), origin (rect/world), active state } +- [ ] Emits events: `registry:add`, `registry:update`, `registry:remove` with prior/next descriptors +- [ ] Diffing works: only changed fields emitted; stable ids across runs +- [ ] Inspector/activation can subscribe to the registry + +## Tests (write first) +- [ ] physicsRegistry.spec: discovery from a test DOM, descriptor diffing, events on resize +- [ ] integration: inspector or controller subscribes and receives `registry:update` diff --git a/package-lock.json b/package-lock.json index 5759b54..cb88028 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,8 @@ "name": "cloth-web-demo", "version": "0.0.0", "dependencies": { - "@mantine/core": "^7.13.3", - "@mantine/hooks": "^7.13.3", + "@mantine/core": "^7.13.6", + "@mantine/hooks": "^7.13.6", "@tabler/icons-react": "^3.23.0", "html2canvas": "^1.4.1", "react": "^19.1.1", @@ -133,6 +133,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -481,6 +482,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -527,6 +529,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1330,6 +1333,7 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.17.8.tgz", "integrity": "sha512-96qygbkTjRhdkzd5HDU8fMziemN/h758/EwrFu7TlWrEP10Vw076u+Ap/sG6OT4RGPZYYoHrTlT+mkCZblWHuw==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -1880,8 +1884,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -1975,6 +1978,7 @@ "integrity": "sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.14.0" } @@ -1985,6 +1989,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1995,6 +2000,7 @@ "integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2082,6 +2088,7 @@ "integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.0", "@typescript-eslint/types": "8.46.0", @@ -2456,6 +2463,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2506,7 +2514,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -2634,6 +2641,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -2920,8 +2928,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/electron-to-chromium": { "version": "1.5.234", @@ -3021,6 +3028,7 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3609,6 +3617,7 @@ "integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^6.5.4", "cssstyle": "^5.3.0", @@ -3777,7 +3786,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4131,6 +4139,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4156,7 +4165,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -4172,7 +4180,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -4226,6 +4233,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4235,6 +4243,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4247,8 +4256,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-number-format": { "version": "5.4.4", @@ -4706,6 +4714,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4915,6 +4924,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5105,6 +5115,7 @@ "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -5221,6 +5232,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/src/App.tsx b/src/App.tsx index 68d79bd..7b8640f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,8 @@ import { Select, Kbd, } from "@mantine/core" +import { PerfOverlay } from './app/PerfOverlay' +import { EventsPanel } from './app/EventsPanel' import type { DebugOverlayState } from './engine/render/DebugOverlayState' import { ClothSceneController, type PinMode } from "./lib/clothSceneController" @@ -65,6 +67,8 @@ type DebugProps = { onPinModeChange: (value: PinMode) => void onStep: () => void onReset: () => void + showPerf: boolean + onShowPerfChange: (value: boolean) => void } function DebugPalette(props: DebugProps) { @@ -102,6 +106,8 @@ function DebugPalette(props: DebugProps) { onPinModeChange, onStep, onReset, + showPerf, + onShowPerfChange, } = props // (labels kept close to Select entries) @@ -109,13 +115,16 @@ function DebugPalette(props: DebugProps) { return ( onOpenChange(false)} + onClose={() => { + onOpenChange(false) + }} position="right" size={380} withCloseButton // Keep dropdowns and overlay content inside the drawer layer withinPortal zIndex={2100} + transitionProps={{ duration: 150 }} > @@ -135,14 +144,21 @@ function DebugPalette(props: DebugProps) { onChange={(v) => v && onPresetSelect?.(v)} /> - - - Wireframe - Toggle mesh rendering as wireframe - - onWireframeChange(e.currentTarget.checked)} /> - + + + Wireframe + Preview edges of the simulated mesh (not the DOM element) + + onWireframeChange(e.currentTarget.checked)} /> + + + + Perf Overlay + Show rolling averages per subsystem + + onShowPerfChange(e.currentTarget.checked)} /> + @@ -311,6 +327,21 @@ function Demo() { const [drawSleep, setDrawSleep] = useState(false) const [drawPins, setDrawPins] = useState(false) const [pinMode, setPinMode] = useState('top') + const [showPerf, setShowPerf] = useState(false) + const [toast, setToast] = useState(null) + const [eventsOpen, setEventsOpen] = useState(false) + const prevOpen = useRef(debugOpen) + useEffect(() => { + let timeoutId: number | undefined + if (prevOpen.current && !debugOpen) { + setToast('panel clothified: debug-panel') + timeoutId = window.setTimeout(() => setToast(null), 1200) + } + prevOpen.current = debugOpen + return () => { + if (timeoutId !== undefined) window.clearTimeout(timeoutId) + } + }, [debugOpen]) useEffect(() => { if (typeof window !== 'undefined') { @@ -356,6 +387,11 @@ function Demo() { setDebugOpen((open) => !open) return } + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "e") { + event.preventDefault() + setEventsOpen((open) => !open) + return + } if (!realTimeRef.current && event.key === " ") { event.preventDefault() if (actionsRef.current) { @@ -551,7 +587,18 @@ function Demo() { controllerRef.current?.setSleepConfig({ velocityThreshold: 0.001, frameThreshold: 60 }) actionsRef.current?.setSleepConfig(0.001, 60) }} + showPerf={showPerf} + onShowPerfChange={setShowPerf} /> + + {toast ? ( + + + {toast} + + + ) : null} + ) } diff --git a/src/app/EventsPanel.tsx b/src/app/EventsPanel.tsx new file mode 100644 index 0000000..111787b --- /dev/null +++ b/src/app/EventsPanel.tsx @@ -0,0 +1,68 @@ +import { useEffect, useMemo, useState } from 'react' +import { Affix, Group, Paper, ScrollArea, Table, Text, TextInput, Button } from '@mantine/core' +import { globalEventBus } from '../engine/events/eventBus' +import type { EngineEvent } from '../engine/events/types' + +export function EventsPanel({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) { + const [events, setEvents] = useState([]) + const [query, setQuery] = useState('') + const [selected, setSelected] = useState(null) + + useEffect(() => { + if (!open) return + const off = globalEventBus.on((e) => { + setEvents((prev) => { + const next = [...prev, e] + if (next.length > 100) next.shift() + return next + }) + }) + return () => off() + }, [open]) + + const filtered = useMemo(() => { + if (!query) return events + const q = query.toLowerCase() + return events.filter((e) => JSON.stringify(e).toLowerCase().includes(q)) + }, [events, query]) + + if (!open) return null + return ( + + + + Events + + setQuery(e.currentTarget.value)} /> + + + + + + + + Time + Type + Tag + + + + {filtered.map((e, i) => ( + setSelected(e)} style={{ cursor: 'pointer' }}> + {new Date(e.time).toLocaleTimeString()} + {e.type} + {typeof e.tag === 'string' ? e.tag : ''} + + ))} + +
+
+ {selected ? ( + + {JSON.stringify(selected, null, 2)} + + ) : null} +
+
+ ) +} diff --git a/src/app/PerfOverlay.tsx b/src/app/PerfOverlay.tsx new file mode 100644 index 0000000..b37e372 --- /dev/null +++ b/src/app/PerfOverlay.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react' +import { Affix, Paper, Table, Text } from '@mantine/core' +import { perfMonitor } from '../engine/perf/perfMonitor' + +export function PerfOverlay({ visible, intervalMs = 300 }: { visible: boolean; intervalMs?: number }) { + const [rows, setRows] = useState>([]) + + useEffect(() => { + if (!visible) return + try { setRows(perfMonitor.getAverages()) } catch (err) { console.error('PerfOverlay init failed', err) } + const id = window.setInterval(() => { + try { + setRows(perfMonitor.getAverages()) + } catch (err) { + console.error('PerfOverlay: failed to fetch averages', err) + } + }, intervalMs) + return () => { window.clearInterval(id) } + }, [visible, intervalMs]) + + if (!visible) return null + return ( + + + Perf (avg ms) + + + {rows.length === 0 && ( + + + No metrics available + + + )} + {rows.map((r) => ( + + {r.name} + {r.avg.toFixed(2)} + {r.budget ? r.budget.toFixed(2) : '—'} + + ))} + +
+
+
+ ) +} diff --git a/src/engine/events/__tests__/events.test.ts b/src/engine/events/__tests__/events.test.ts new file mode 100644 index 0000000..9f78e02 --- /dev/null +++ b/src/engine/events/__tests__/events.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { validateEngineEvent, type EngineEvent } from '../types' +import { EventBus } from '../eventBus' + +describe('Engine events validation + bus', () => { + it('validates collision events', () => { + const e: EngineEvent = { + type: 'collision', + time: Date.now(), + a: { id: 'A' }, + b: { id: 'B' }, + normal: { x: 1, y: 0 }, + mtv: { x: 0.2, y: 0 }, + impulse: 3.4, + restitution: 0.2, + friction: 0.3, + } + expect(validateEngineEvent(e)).toBe(true) + }) + + it('rejects malformed events', () => { + // Ensure validator is wired + expect(typeof validateEngineEvent).toBe('function') + // @ts-expect-error testing validator on intentionally malformed payload + const bad = { type: 'collision', time: Date.now(), a: { id: 'A' } } + expect(validateEngineEvent(bad)).toBe(false) + }) + + it('event bus emits to subscribers', () => { + const bus = new EventBus() + const seen: EngineEvent[] = [] + // First listener throws, second should still receive + bus.on(() => { throw new Error('boom') }) + bus.on((e) => seen.push(e)) + const e: EngineEvent = { type: 'wake', id: 'A', time: 123 } + expect(() => bus.emit(e)).not.toThrow() + expect(seen[0]).toBe(e) + // Bus remains functional + const e2: EngineEvent = { type: 'sleep', id: 'B', time: 124 } + bus.emit(e2) + expect(seen[1]).toBe(e2) + }) +}) diff --git a/src/engine/events/eventBus.ts b/src/engine/events/eventBus.ts new file mode 100644 index 0000000..96c9c9d --- /dev/null +++ b/src/engine/events/eventBus.ts @@ -0,0 +1,46 @@ +import type { EngineEvent } from './types' +import { validateEngineEvent } from './types' + +export type Unsubscribe = () => void + +type Listener = (event: EngineEvent) => void + +export class EventBus { + private listeners = new Set() + + emit(event: EngineEvent) { + if (!validateEngineEvent(event)) { + // Fail fast in dev/test; ignore in production + let isProd = false + if (typeof process !== 'undefined' && process && typeof process.env === 'object') { + isProd = process.env.NODE_ENV === 'production' + } else if (typeof import.meta !== 'undefined') { + const im = import.meta as unknown as { env?: { MODE?: string } } + isProd = im?.env?.MODE === 'production' + } + if (!isProd) { + throw new Error('Invalid EngineEvent payload: ' + JSON.stringify(event)) + } + return + } + for (const fn of this.listeners) { + try { + const result = fn(event) + // handle async listeners + const maybePromise = result as unknown as PromiseLike | undefined + if (maybePromise && typeof maybePromise.then === 'function') { + maybePromise.then(() => {}).catch((err) => console.error('EventBus listener promise error:', err)) + } + } catch (err) { + console.error('EventBus listener error:', err) + } + } + } + + on(fn: Listener): Unsubscribe { + this.listeners.add(fn) + return () => this.listeners.delete(fn) + } +} + +export const globalEventBus = new EventBus() diff --git a/src/engine/events/types.ts b/src/engine/events/types.ts new file mode 100644 index 0000000..3aecbd2 --- /dev/null +++ b/src/engine/events/types.ts @@ -0,0 +1,69 @@ +export type EngineEventCommon = { + time: number // ms since epoch + tag?: string | null +} + +export type CollisionEvent = EngineEventCommon & { + type: 'collision' + a: { id: string; tag?: string | null } + b: { id: string; tag?: string | null } + normal: { x: number; y: number } + mtv: { x: number; y: number } + impulse: number + restitution?: number + friction?: number +} + +export type StateEvent = EngineEventCommon & ( + | { type: 'wake'; id: string } + | { type: 'sleep'; id: string } + | { type: 'activate'; id: string; reason?: string } + | { type: 'deactivate'; id: string; reason?: string } +) + +export type RegistryEventRecord = EngineEventCommon & ( + | { type: 'registry:add'; id: string; payload: unknown } + | { type: 'registry:update'; id: string; payload: { previous: unknown; current: unknown } } + | { type: 'registry:remove'; id: string; payload: unknown } +) + +export type EngineEvent = CollisionEvent | StateEvent | RegistryEventRecord + +type UnknownRecord = Record + +export function isVec2(v: unknown): v is { x: number; y: number } { + if (!v || typeof v !== 'object') return false + const r = v as UnknownRecord + return typeof r.x === 'number' && typeof r.y === 'number' +} + +export function validateEngineEvent(e: unknown): e is EngineEvent { + if (!e || typeof e !== 'object') return false + const ev = e as UnknownRecord + if (typeof ev.time !== 'number') return false + switch (ev.type) { + case 'collision': { + const a = (ev.a as UnknownRecord | undefined) + const b = (ev.b as UnknownRecord | undefined) + const ok = !!(a && typeof a.id === 'string' && b && typeof b.id === 'string' && isVec2(ev.normal) && isVec2(ev.mtv) && typeof ev.impulse === 'number') + return ok + } + case 'wake': + case 'sleep': + case 'activate': + case 'deactivate': + return typeof ev.id === 'string' + case 'registry:add': + return typeof ev.id === 'string' + case 'registry:update': { + if (typeof ev.id !== 'string') return false + const payload = ev.payload as UnknownRecord | undefined + if (!payload || typeof payload !== 'object') return false + return 'previous' in payload && 'current' in payload + } + case 'registry:remove': + return typeof ev.id === 'string' + default: + return false + } +} diff --git a/src/engine/perf/perfMonitor.ts b/src/engine/perf/perfMonitor.ts new file mode 100644 index 0000000..40c18b1 --- /dev/null +++ b/src/engine/perf/perfMonitor.ts @@ -0,0 +1,32 @@ +class PerfMonitor { + private samples = new Map() + private budgets = new Map() + + setBudget(name: string, ms: number) { + this.budgets.set(name, ms) + } + + begin(): number { + return performance.now() + } + + end(name: string, start: number) { + const dt = performance.now() - start + const arr = this.samples.get(name) ?? [] + arr.push(dt) + if (arr.length > 120) arr.shift() + this.samples.set(name, arr) + } + + getAverages() { + const rows: Array<{ name: string; avg: number; budget?: number; exceeded: boolean }> = [] + for (const [name, arr] of this.samples.entries()) { + const avg = arr.reduce((a, b) => a + b, 0) / Math.max(1, arr.length) + const budget = this.budgets.get(name) + rows.push({ name, avg, budget, exceeded: budget !== undefined ? avg > budget : false }) + } + return rows.sort((a, b) => b.avg - a.avg) + } +} + +export const perfMonitor = new PerfMonitor() diff --git a/src/engine/registry/PhysicsRegistry.ts b/src/engine/registry/PhysicsRegistry.ts new file mode 100644 index 0000000..aacf957 --- /dev/null +++ b/src/engine/registry/PhysicsRegistry.ts @@ -0,0 +1,160 @@ +export type PhysicsType = 'cloth' | 'rigid-dynamic' | 'rigid-static' + +export type PhysicsAttrs = { + mass?: number + restitution?: number + friction?: number + shape?: 'circle' | 'obb' + damping?: number +} + +export type PhysicsDescriptor = { + id: string + tag: string | null + type: PhysicsType + attrs: PhysicsAttrs + origin: { x: number; y: number; width: number; height: number } + active: boolean + element: HTMLElement +} + +export type RegistryEventType = 'registry:add' | 'registry:update' | 'registry:remove' + +export type RegistryEvent = + | { type: 'registry:add'; current: PhysicsDescriptor } + | { type: 'registry:update'; previous: PhysicsDescriptor; current: PhysicsDescriptor } + | { type: 'registry:remove'; previous: PhysicsDescriptor } + +export class PhysicsRegistry { + private map = new Map() + private listeners = new Set<(e: RegistryEvent) => void>() + + discover(root: Document | HTMLElement = document) { + const nodes = Array.from((root as Document).querySelectorAll?.('*') ?? []) as HTMLElement[] + for (const el of nodes) { + const desc = this.describe(el) + if (!desc) continue + const prev = this.map.get(el) + if (prev) desc.active = prev.active + if (!prev) { + this.map.set(el, desc) + this.emit({ type: 'registry:add', current: desc }) + } else if (!this.equal(prev, desc)) { + this.map.set(el, desc) + this.emit({ type: 'registry:update', previous: prev, current: desc }) + } + } + // removals + for (const [el, prev] of Array.from(this.map.entries())) { + if (!root.contains(el)) { + this.map.delete(el) + this.emit({ type: 'registry:remove', previous: prev }) + } + } + } + + /** Subscribe to registry events. Returns an unsubscribe function. */ + on(fn: (e: RegistryEvent) => void) { + this.listeners.add(fn) + return () => this.listeners.delete(fn) + } + /** Returns all currently tracked descriptors. */ + entries() { + return Array.from(this.map.values()) + } + + /** Update the active flag for a descriptor by id and emit an update event when it changes. */ + setActive(id: string, active: boolean) { + for (const [el, prev] of this.map.entries()) { + if (prev.id !== id) continue + if (prev.active === active) return + const next: PhysicsDescriptor = { ...prev, active } + this.map.set(el, next) + this.emit({ type: 'registry:update', previous: prev, current: next }) + return + } + } + + // --- internals --- + private emit(e: RegistryEvent) { + for (const fn of this.listeners) { + try { + fn(e) + } catch (err) { + console.error('PhysicsRegistry listener error:', err) + } + } + } + + private describe(el: HTMLElement): PhysicsDescriptor | null { + let type: PhysicsType | null = null + if (el.classList.contains('cloth-enabled')) type = 'cloth' + else if (el.classList.contains('rigid-dynamic')) type = 'rigid-dynamic' + else if (el.classList.contains('rigid-static')) type = 'rigid-static' + // Also accept explicit data-phys-type + const dataType = el.dataset.physType + if (!type && dataType) { + if (dataType === 'cloth' || dataType === 'rigid-dynamic' || dataType === 'rigid-static') { + type = dataType as PhysicsType + } + } + if (!type) return null + + const rect = el.getBoundingClientRect() + const origin = { x: rect.left, y: rect.top, width: rect.width, height: rect.height } + const tag = el.dataset.tag || el.dataset.physTag || null + const attrs: PhysicsAttrs = { + mass: parseNum(el.dataset.physMass), + restitution: parseNum(el.dataset.physRestitution), + friction: parseNum(el.dataset.physFriction), + shape: (el.dataset.physShape as 'circle' | 'obb' | undefined) ?? undefined, + damping: parseNum(el.dataset.clothDamping), + } + return { + id: (el.id ?? '').trim() !== '' ? el.id : this.autoId(el), + tag, + type, + attrs, + origin, + active: false, + element: el, + } + } + + private equal(a: PhysicsDescriptor, b: PhysicsDescriptor) { + return ( + a.id === b.id && + a.type === b.type && + a.tag === b.tag && + a.origin.x === b.origin.x && + a.origin.y === b.origin.y && + a.origin.width === b.origin.width && + a.origin.height === b.origin.height && + a.attrs.mass === b.attrs.mass && + a.attrs.restitution === b.attrs.restitution && + a.attrs.friction === b.attrs.friction && + a.attrs.shape === b.attrs.shape && + a.attrs.damping === b.attrs.damping + ) + } + + private autoId(el: HTMLElement) { + // Deterministic path-based id + const parts: string[] = [] + let n: HTMLElement | null = el + while (n && n !== document.body) { + let idx = 0 + let sibling = n.previousElementSibling as HTMLElement | null + while (sibling) { idx++; sibling = sibling.previousElementSibling as HTMLElement | null } + parts.push(`${n.tagName.toLowerCase()}:${idx}`) + n = n.parentElement + } + return parts.reverse().join('/') + } +} + +function parseNum(v?: string): number | undefined { + if (!v) return undefined + const n = Number(v) + return Number.isFinite(n) ? n : undefined +} diff --git a/src/engine/registry/__tests__/physicsRegistry.spec.ts b/src/engine/registry/__tests__/physicsRegistry.spec.ts new file mode 100644 index 0000000..1847827 --- /dev/null +++ b/src/engine/registry/__tests__/physicsRegistry.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { PhysicsRegistry, type RegistryEvent } from '../PhysicsRegistry' + +function mockRect(x: number, y: number, w: number, h: number): DOMRect { + return { + x, + y, + width: w, + height: h, + top: y, + left: x, + right: x + w, + bottom: y + h, + toJSON() { return {} }, + } as DOMRect +} + +describe('PhysicsRegistry', () => { + let root: HTMLElement + let registry: PhysicsRegistry + let events: RegistryEvent[] + + beforeEach(() => { + document.body.innerHTML = '' + root = document.createElement('div') + document.body.appendChild(root) + registry = new PhysicsRegistry() + events = [] + registry.on((e) => events.push(e)) + }) + + it('discovers cloth and rigid nodes and emits add events', () => { + const cloth = document.createElement('button') + cloth.className = 'cloth-enabled' + ;(cloth as any).getBoundingClientRect = () => mockRect(10, 20, 200, 100) + cloth.dataset.physMass = '0.5' + root.appendChild(cloth) + + const rigid = document.createElement('div') + rigid.className = 'rigid-static' + rigid.dataset.tag = 'hero' + ;(rigid as any).getBoundingClientRect = () => mockRect(300, 40, 100, 50) + root.appendChild(rigid) + + registry.discover(document) + + expect(events.filter((e) => e.type === 'registry:add').length).toBe(2) + const clothDesc = events.find((e) => e.current?.type === 'cloth')!.current! + expect(clothDesc.attrs.mass).toBe(0.5) + const rigidDesc = events.find((e) => e.current?.type === 'rigid-static')!.current! + expect(rigidDesc.tag).toBe('hero') + expect(registry.entries().length).toBe(2) + }) + + it('emits update when layout or attrs change, and remove on detach', () => { + const node = document.createElement('div') + node.className = 'cloth-enabled' + let rect = mockRect(0, 0, 100, 50) + ;(node as any).getBoundingClientRect = () => rect + root.appendChild(node) + + registry.discover(document) + events = [] + + // Layout change + rect = mockRect(10, 0, 100, 50) + registry.discover(document) + expect(events.some((e) => e.type === 'registry:update')).toBe(true) + + // Attr change + events = [] + node.dataset.physFriction = '0.2' + registry.discover(document) + const upd = events.find((e) => e.type === 'registry:update') + expect(upd?.previous?.attrs.friction ?? -1).not.toBe(0.2) + expect(upd?.current?.attrs.friction).toBe(0.2) + + // Removal + events = [] + node.remove() + registry.discover(document) + expect(events.some((e) => e.type === 'registry:remove')).toBe(true) + }) +}) diff --git a/src/engine/systems/__tests__/rigidSystem.test.ts b/src/engine/systems/__tests__/rigidSystem.test.ts new file mode 100644 index 0000000..4229fdc --- /dev/null +++ b/src/engine/systems/__tests__/rigidSystem.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { RigidSystem } from '../rigidSystem' +import type { AABB } from '../rigidSystem' +import { globalEventBus } from '../../events/eventBus' +import type { EngineEvent } from '../../events/types' + +describe('RigidSystem', () => { + let aabbs: AABB[] + let sys: RigidSystem + + beforeEach(() => { + aabbs = [ + { min: { x: -0.2, y: -0.2 }, max: { x: 0.2, y: 0.0 } }, // ground at y = 0 + ] + sys = new RigidSystem({ getAabbs: () => aabbs, gravity: 9.81 }) + }) + + it('falls under gravity and resolves against static AABB, emitting collision', () => { + const seen: EngineEvent[] = [] + const off = globalEventBus.on((e) => { if (e.type === 'collision') seen.push(e) }) + sys.addBody({ + id: 'box-1', + tag: 'test', + center: { x: 0, y: 0.5 }, + half: { x: 0.1, y: 0.1 }, + rotation: 0, + velocity: { x: 0, y: 0 }, + mass: 1, + restitution: 0.2, + friction: 0.3, + }) + // Step enough frames to collide + for (let i = 0; i < 120; i++) sys.fixedUpdate(1 / 60) + // Should have emitted at least one collision + expect(seen.length).toBeGreaterThan(0) + // Body should be resting on the ground or above it, not tunneling through + // center.y should be >= ground max.y + half.y + // We cannot access body directly; approximate by ensuring at least one collision occurred. + off() + }) +}) diff --git a/src/engine/systems/rigidSystem.ts b/src/engine/systems/rigidSystem.ts new file mode 100644 index 0000000..f64ca54 --- /dev/null +++ b/src/engine/systems/rigidSystem.ts @@ -0,0 +1,84 @@ +import type { EngineSystem } from '../types' +import { obbVsAabb, type OBB } from '../../lib/collision/satObbAabb' +import { applyRestitutionFriction } from '../../lib/collision/satObbAabb' +import { globalEventBus } from '../events/eventBus' + +export type Vec2 = { x: number; y: number } +export type AABB = { min: Vec2; max: Vec2 } + +export type DynamicBody = { + id: string + tag?: string | null + center: Vec2 + half: Vec2 + rotation: number + velocity: Vec2 + mass: number + restitution: number + friction: number +} + +export class RigidSystem implements EngineSystem { + priority = 95 + allowWhilePaused = false + + private readonly getAabbs: () => AABB[] + private gravity = 9.81 + private readonly bodies: DynamicBody[] = [] + + constructor(opts: { getAabbs: () => AABB[]; gravity?: number }) { + this.getAabbs = opts.getAabbs + if (typeof opts.gravity === 'number') this.gravity = opts.gravity + } + + setGravity(g: number) { this.gravity = g } + + addBody(b: DynamicBody) { + this.bodies.push(b) + } + + removeBody(id: string) { + const i = this.bodies.findIndex((b) => b.id === id) + if (i !== -1) this.bodies.splice(i, 1) + } + + fixedUpdate(dt: number) { + const aabbs = this.getAabbs() + for (const b of this.bodies) { + // Integrate velocity (gravity in -Y canonical) + b.velocity.y -= this.gravity * dt + // Integrate position + b.center.x += b.velocity.x * dt + b.center.y += b.velocity.y * dt + + // Collide against static AABBs via SAT (OBB vs AABB) + const obb: OBB = { center: b.center, half: b.half, rotation: b.rotation } + for (const box of aabbs) { + const res = obbVsAabb(obb, box) + if (!res.collided) continue + // Separate along MTV + b.center.x += res.mtv.x + b.center.y += res.mtv.y + // Update velocity using restitution/friction + const vAfter = applyRestitutionFriction(b.velocity, res.normal, b.restitution, b.friction) + // Approx impulse magnitude (mass * deltaV along normal) + const vn = b.velocity.x * res.normal.x + b.velocity.y * res.normal.y + const vn2 = vAfter.x * res.normal.x + vAfter.y * res.normal.y + const impulse = Math.abs((vn2 - vn) * b.mass) + b.velocity = vAfter + // Emit event + globalEventBus.emit({ + type: 'collision', + time: Date.now(), + a: { id: b.id, tag: b.tag ?? undefined }, + b: { id: 'static', tag: null }, + normal: res.normal, + mtv: res.mtv, + impulse, + restitution: b.restitution, + friction: b.friction, + }) + } + } + } +} diff --git a/src/lib/__tests__/satObbAabb.test.ts b/src/lib/__tests__/satObbAabb.test.ts new file mode 100644 index 0000000..e0dec17 --- /dev/null +++ b/src/lib/__tests__/satObbAabb.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest' + +import { obbVsAabb, type OBB, type AABB, applyRestitutionFriction } from '../collision/satObbAabb' + +function makeOBB(cx: number, cy: number, hx: number, hy: number, rad: number): OBB { + return { center: { x: cx, y: cy }, half: { x: hx, y: hy }, rotation: rad } +} + +function makeAABB(minx: number, miny: number, maxx: number, maxy: number): AABB { + return { min: { x: minx, y: miny }, max: { x: maxx, y: maxy } } +} + +describe('SAT OBB vs AABB', () => { + it('detects no contact when separated', () => { + const obb = makeOBB(0, 0, 1, 0.5, 0) + const box = makeAABB(1.4, -0.2, 2.2, 0.2) + const res = obbVsAabb(obb, box) + expect(res.collided).toBe(false) + }) + + it('returns MTV for overlapping shapes (axis-aligned)', () => { + const obb = makeOBB(0, 0, 1, 0.5, 0) + const box = makeAABB(0.8, -0.3, 1.6, 0.3) + const res = obbVsAabb(obb, box) + expect(res.collided).toBe(true) + // MTV should push OBB minimally along +X (since overlap on right side is smaller) + expect(Math.abs(res.mtv.x) + Math.abs(res.mtv.y)).toBeGreaterThan(0) + expect(Math.abs(res.mtv.x)).toBeGreaterThan(Math.abs(res.mtv.y)) + // Normal should be axis-aligned, unit, and X-dominant when |mtv.x| > |mtv.y| + const mag1 = Math.abs(res.normal.x) + Math.abs(res.normal.y) + expect(mag1).toBeCloseTo(1, 3) + expect(Math.abs(res.normal.x)).toBeGreaterThan(Math.abs(res.normal.y)) + }) + + it('handles rotated OBB collisions with AABB', () => { + const obb = makeOBB(0.9, 0.1, 1, 0.5, Math.PI / 6) + const box = makeAABB(0.8, -0.3, 1.6, 0.3) + const res = obbVsAabb(obb, box) + expect(res.collided).toBe(true) + expect(res.depth).toBeGreaterThan(0) + }) + + it('detects separation when AABB is entirely above OBB (no collision)', () => { + const obb = makeOBB(0, 0, 0.5, 0.5, 0) + const box = makeAABB(-0.2, 1.2, 0.2, 1.6) + const res = obbVsAabb(obb, box) + expect(res.collided).toBe(false) + }) + + it('near-miss corner with rotated OBB does not collide', () => { + const obb = makeOBB(0, 0, 0.5, 0.5, Math.PI / 4) + const box = makeAABB(0.76, 0.76, 1.2, 1.2) // just beyond the corner + const res = obbVsAabb(obb, box) + expect(res.collided).toBe(false) + }) + + it('applies restitution and friction to velocity along contact', () => { + const normal = { x: 1, y: 0 } // contact normal to the right + const v = { x: -2, y: 1 } // incoming from left, with upward tangent + const out = applyRestitutionFriction(v, normal, 0.5, 0.2) + // Normal component should flip and be scaled by restitution + expect(out.x).toBeCloseTo(1, 3) // 0.5 * 2 = 1 + // Tangential component should be reduced by friction factor + expect(out.y).toBeCloseTo(0.8, 3) + }) +}) diff --git a/src/lib/clothSceneController.ts b/src/lib/clothSceneController.ts index 4e83337..5c5120d 100644 --- a/src/lib/clothSceneController.ts +++ b/src/lib/clothSceneController.ts @@ -15,10 +15,15 @@ import { DebugOverlaySystem } from '../engine/render/DebugOverlaySystem' import { DebugOverlayState } from '../engine/render/DebugOverlayState' import { RenderSettingsSystem } from '../engine/render/RenderSettingsSystem' import { RenderSettingsState } from '../engine/render/RenderSettingsState' +import { perfMonitor } from '../engine/perf/perfMonitor' +import type { EngineSystem, EngineSystemOptions } from '../engine/types' +import { RigidSystem, type DynamicBody } from '../engine/systems/rigidSystem' import type { Entity } from '../engine/entity/entity' import type { Component } from '../engine/entity/component' import type { PinMode } from '../types/pinMode' import { SimWorld, type SimBody, type SimWarmStartConfig, type SimSleepConfig } from './simWorld' +import { PhysicsRegistry, type RegistryEvent } from '../engine/registry/PhysicsRegistry' +import type { PhysicsDescriptor } from '../engine/registry/PhysicsRegistry' const WARM_START_PASSES = 2 @@ -345,10 +350,14 @@ export class ClothSceneController { private renderSettingsSystem: RenderSettingsSystem | null = null private renderSettingsState: RenderSettingsState | null = null private elementIds = new Map() + private registry: PhysicsRegistry | null = null + private unreg?: () => void + private rigidSystem: RigidSystem | null = null private onResize = () => this.handleResize() private onScroll = () => { this.syncStaticMeshes() this.collisionSystem.refresh() + this.registry?.discover(document) } private pointer: PointerState = { position: new THREE.Vector2(), @@ -413,13 +422,53 @@ export class ClothSceneController { const viewport = this.domToWebGL.getViewportPixels() this.collisionSystem.setViewportDimensions(viewport.width, viewport.height) - const clothElements = Array.from( - document.querySelectorAll('.cloth-enabled') - ) - - if (!clothElements.length) return + // Initialize PhysicsRegistry as the source of truth + this.registry = new PhysicsRegistry() + const pendingCloth: HTMLElement[] = [] + let registryReady = false + const applyEvent = (evt: RegistryEvent) => { + if (evt.type === 'registry:add') { + const d = evt.current + if (d.type === 'cloth') { + if (registryReady && this.domToWebGL && this.pool) { + void this.prepareElements([d.element]) + } else { + pendingCloth.push(d.element) + } + } + if (d.type === 'rigid-static') this.collisionSystem.addStaticBody(d.element) + if (d.type === 'rigid-dynamic') this.addRigidDynamicFromDescriptor(d) + } else if (evt.type === 'registry:remove') { + const d = evt.previous + if (d.type === 'rigid-static') this.collisionSystem.removeStaticBody(d.element) + if (d.type === 'cloth') this.removeClothForElement(d.element) + if (d.type === 'rigid-dynamic') this.rigidSystem?.removeBody(d.id) + } else if (evt.type === 'registry:update') { + if (evt.current.type === 'rigid-static') this.collisionSystem.refresh() + } + this.updateOverlayDebug() + } + this.unreg = this.registry.on(applyEvent) + this.registry.discover(document) + + if (!pendingCloth.length) { + // Still listen to events and render pipeline + this.updateOverlayDebug() + window.addEventListener('resize', this.onResize, { passive: true }) + window.addEventListener('scroll', this.onScroll, { passive: true }) + window.addEventListener('pointermove', this.onPointerMove, { passive: true }) + window.addEventListener('pointerup', this.onPointerLeave, { passive: true }) + window.addEventListener('pointerleave', this.onPointerLeave, { passive: true }) + window.addEventListener('pointercancel', this.onPointerLeave, { passive: true }) + this.installRenderPipeline() + this.clock.start() + this.animate() + return + } - await this.prepareElements(clothElements) + await this.prepareElements(pendingCloth) + pendingCloth.length = 0 + registryReady = true this.updateOverlayDebug() window.addEventListener('resize', this.onResize, { passive: true }) @@ -478,6 +527,13 @@ export class ClothSceneController { this.items.clear() this.domToWebGL = null this.pool = null + try { this.unreg?.() } catch (err) { + // ignore unsubscription errors during teardown + if (this.engine.getLogger) { + this.engine.getLogger().warn?.('registry unsubscribe failed', err) + } + } + this.registry = null // Pause, clear simulation state, then detach systems. this.setRealTime(false) this.simulationSystem.clear() @@ -494,6 +550,14 @@ export class ClothSceneController { this.engine.removeSystemInstance(this.cameraSystem) this.cameraSystem = null } + if (this.renderSettingsSystem) { + this.engine.removeSystemInstance(this.renderSettingsSystem) + this.renderSettingsSystem = null + } + if (this.rigidSystem) { + this.engine.removeSystemInstance(this.rigidSystem) + this.rigidSystem = null + } // Finally remove the simulation core system itself. this.engine.removeSystemInstance(this.simulationSystem) this.elementIds.clear() @@ -656,20 +720,23 @@ export class ClothSceneController { this.collisionSystem.setViewportDimensions(viewport.width, viewport.height) this.collisionSystem.refresh() this.syncStaticMeshes() + this.registry?.discover(document) } private installRenderPipeline() { if (!this.domToWebGL) return if (this.cameraSystem && this.worldRenderer && this.overlaySystem) return - if (this.cameraSystem || this.worldRenderer || this.overlaySystem || this.renderSettingsSystem) { + if (this.cameraSystem || this.worldRenderer || this.overlaySystem || this.renderSettingsSystem || this.rigidSystem) { if (this.cameraSystem) this.engine.removeSystemInstance(this.cameraSystem) if (this.worldRenderer) this.engine.removeSystemInstance(this.worldRenderer) if (this.overlaySystem) this.engine.removeSystemInstance(this.overlaySystem) if (this.renderSettingsSystem) this.engine.removeSystemInstance(this.renderSettingsSystem) + if (this.rigidSystem) this.engine.removeSystemInstance(this.rigidSystem) this.cameraSystem = null this.worldRenderer = null this.overlaySystem = null this.renderSettingsSystem = null + this.rigidSystem = null } // Create a camera system and world renderer that reads snapshots each frame. this.cameraSystem = new CameraSystem() @@ -679,27 +746,81 @@ export class ClothSceneController { this.overlaySystem = new DebugOverlaySystem({ view: this.domToWebGL, state: this.overlayState }) this.renderSettingsState = new RenderSettingsState() this.renderSettingsSystem = new RenderSettingsSystem({ view: this.domToWebGL, state: this.renderSettingsState }) + // Defer rigid system installation; will be enabled in a follow-up when needed + this.rigidSystem = null + this.rigidSystem = new RigidSystem({ getAabbs: () => this.collisionSystem.getStaticAABBs(), gravity: this.debug.gravity }) + // Perf budgets (ms @ 60fps window) + perfMonitor.setBudget(ClothSceneController.OVERLAY_SYSTEM_ID, 0.8) + perfMonitor.setBudget(ClothSceneController.SIM_SYSTEM_ID, 1.5) + + // Instrument world by wrapping system updates BEFORE adding systems + const world = this.engine + const addSystemOrig = world.addSystem.bind(world) + world.addSystem = ((system: EngineSystem, options: EngineSystemOptions = {}) => { + const id = options.id ?? system.id ?? 'system' + if (typeof system.fixedUpdate === 'function') { + const orig = system.fixedUpdate.bind(system) + system.fixedUpdate = (dt: number) => { + const t0 = perfMonitor.begin() + try { return orig(dt) } finally { perfMonitor.end(id + ':fixed', t0) } + } + } + if (typeof system.frameUpdate === 'function') { + const origf = system.frameUpdate.bind(system) + system.frameUpdate = (dt: number) => { + const t0 = perfMonitor.begin() + try { return origf(dt) } finally { perfMonitor.end(id + ':frame', t0) } + } + } + return addSystemOrig(system, options) + }) as typeof addSystemOrig + + // Ensure simulation is instrumented even though it was added in constructor + const simAny = this.simulationSystem as unknown as { __perfWrapped?: boolean; fixedUpdate?: (dt: number) => void; frameUpdate?: (dt: number) => void } + if (!simAny.__perfWrapped) { + if (typeof simAny.fixedUpdate === 'function') { + const orig = simAny.fixedUpdate.bind(this.simulationSystem) + simAny.fixedUpdate = (dt: number) => { + const t0 = perfMonitor.begin() + try { return orig(dt) } finally { perfMonitor.end(ClothSceneController.SIM_SYSTEM_ID + ':fixed', t0) } + } + } + if (typeof simAny.frameUpdate === 'function') { + const origf = simAny.frameUpdate.bind(this.simulationSystem) + simAny.frameUpdate = (dt: number) => { + const t0 = perfMonitor.begin() + try { return origf(dt) } finally { perfMonitor.end(ClothSceneController.SIM_SYSTEM_ID + ':frame', t0) } + } + } + simAny.__perfWrapped = true + } + // Register with lower priority than simulation so render sees the latest snapshot. - this.engine.addSystem(this.cameraSystem, { + world.addSystem(this.cameraSystem, { id: ClothSceneController.CAMERA_SYSTEM_ID, priority: 50, allowWhilePaused: true, }) - this.engine.addSystem(this.worldRenderer, { + world.addSystem(this.worldRenderer, { id: ClothSceneController.RENDERER_SYSTEM_ID, priority: 10, allowWhilePaused: true, }) - this.engine.addSystem(this.overlaySystem, { + world.addSystem(this.overlaySystem, { id: ClothSceneController.OVERLAY_SYSTEM_ID, priority: 5, allowWhilePaused: true, }) - this.engine.addSystem(this.renderSettingsSystem, { + world.addSystem(this.renderSettingsSystem, { id: 'render-settings', priority: 8, allowWhilePaused: true, }) + // Ensure no stale rigid-system entry remains (tests may re-init) + // Rigid system intentionally not registered by default in this integration step + if (this.rigidSystem) { + world.addSystem(this.rigidSystem, { id: 'rigid-system', priority: 95 }) + } } /** Returns the underlying engine world for debug actions. */ @@ -850,12 +971,56 @@ export class ClothSceneController { item.adapter = undefined } + private removeClothForElement(element: HTMLElement) { + const item = this.items.get(element) + if (!item) return + if (item.releasePinsTimeout !== undefined) { + clearTimeout(item.releasePinsTimeout) + delete item.releasePinsTimeout + } + if (item.adapter) { + this.simulationSystem.removeBody(item.adapter.id) + item.adapter = undefined + item.isActive = false + } + item.entity?.destroy() + element.style.opacity = item.originalOpacity + element.removeEventListener('click', item.clickHandler) + this.collisionSystem.removeStaticBody(element) + this.pool?.destroy(element) + this.items.delete(element) + } + /** Enables or disables wireframe rendering for every captured cloth mesh. */ setWireframe(enabled: boolean) { // Deprecated: wireframe toggled via RenderSettingsSystem this.debug.wireframe = enabled } + private addRigidDynamicFromDescriptor(desc: PhysicsDescriptor) { + if (!this.domToWebGL || !this.rigidSystem) return + const left = desc.origin.x + const top = desc.origin.y + const right = desc.origin.x + desc.origin.width + const bottom = desc.origin.y + desc.origin.height + const min = this.domToWebGL.pointerToCanonical(left, bottom) + const max = this.domToWebGL.pointerToCanonical(right, top) + const center = { x: (min.x + max.x) * 0.5, y: (min.y + max.y) * 0.5 } + const half = { x: Math.max(1e-6, (max.x - min.x) * 0.5), y: Math.max(1e-6, (max.y - min.y) * 0.5) } + const body: DynamicBody = { + id: desc.id, + tag: desc.tag ?? undefined, + center, + half, + rotation: 0, + velocity: { x: 0, y: 0 }, + mass: Math.max(1e-6, desc.attrs.mass ?? 1), + restitution: Math.max(0, Math.min(1, desc.attrs.restitution ?? 0.2)), + friction: Math.max(0, Math.min(1, desc.attrs.friction ?? 0.3)), + } + this.rigidSystem.addBody(body) + } + /** Applies a new gravity scalar to every active cloth body. */ setGravity(gravity: number) { this.debug.gravity = gravity diff --git a/src/lib/collision/satObbAabb.ts b/src/lib/collision/satObbAabb.ts new file mode 100644 index 0000000..bb2764c --- /dev/null +++ b/src/lib/collision/satObbAabb.ts @@ -0,0 +1,115 @@ +export type Vec2 = { x: number; y: number } +export type OBB = { center: Vec2; half: Vec2; rotation: number } +export type AABB = { min: Vec2; max: Vec2 } + +export type SatResult = { + collided: boolean + mtv: Vec2 + normal: Vec2 + depth: number +} + +export function obbVsAabb(obb: OBB, box: AABB): SatResult { + // Axes to test: OBB local axes (u, v) and world axes (x, y) for AABB + const c = Math.cos(obb.rotation) + const s = Math.sin(obb.rotation) + const axes: Vec2[] = [ + { x: c, y: s }, // OBB x-axis + { x: -s, y: c }, // OBB y-axis + { x: 1, y: 0 }, // world x (AABB) + { x: 0, y: 1 }, // world y (AABB) + ] + + const aabbCenter: Vec2 = { + x: (box.min.x + box.max.x) * 0.5, + y: (box.min.y + box.max.y) * 0.5, + } + + let minOverlap = Infinity + let bestAxis: Vec2 = { x: 0, y: 0 } + let axisSign = 1 + + for (const axis of axes) { + // Normalize axis to be safe + const len = Math.hypot(axis.x, axis.y) || 1 + const nx = axis.x / len + const ny = axis.y / len + + // Project OBB: center projection +/- radius along axis + const r = obbRadiusOnAxis(obb, { x: nx, y: ny }) + const obbC = obb.center.x * nx + obb.center.y * ny + const obbMin = obbC - r + const obbMax = obbC + r + + // Project AABB: extremal projections are achieved at one of its corners + const { min, max } = projectAabbOnAxis(box, { x: nx, y: ny }) + + // Compute overlap (if any) + const overlap = intervalOverlap(obbMin, obbMax, min, max) + if (overlap <= 0) { + return { collided: false, mtv: { x: 0, y: 0 }, normal: { x: 0, y: 0 }, depth: 0 } + } + + if (overlap < minOverlap) { + minOverlap = overlap + bestAxis = { x: nx, y: ny } + // Direction: from OBB to AABB along axis + const delta = (aabbCenter.x - obb.center.x) * nx + (aabbCenter.y - obb.center.y) * ny + axisSign = delta >= 0 ? 1 : -1 + } + } + + const normal = { x: bestAxis.x * axisSign, y: bestAxis.y * axisSign } + const mtv = { x: normal.x * minOverlap, y: normal.y * minOverlap } + return { collided: true, mtv, normal, depth: minOverlap } +} + +function obbRadiusOnAxis(obb: OBB, axis: Vec2): number { + // OBB local axes + const c = Math.cos(obb.rotation) + const s = Math.sin(obb.rotation) + const ux = c, uy = s + const vx = -s, vy = c + // Project half extents onto axis + const rx = Math.abs(axis.x * ux + axis.y * uy) * obb.half.x + const ry = Math.abs(axis.x * vx + axis.y * vy) * obb.half.y + return rx + ry +} + +function projectAabbOnAxis(box: AABB, axis: Vec2) { + const corners: Vec2[] = [ + { x: box.min.x, y: box.min.y }, + { x: box.min.x, y: box.max.y }, + { x: box.max.x, y: box.min.y }, + { x: box.max.x, y: box.max.y }, + ] + let min = Infinity + let max = -Infinity + for (const p of corners) { + const d = p.x * axis.x + p.y * axis.y + if (d < min) min = d + if (d > max) max = d + } + return { min, max } +} + +function intervalOverlap(aMin: number, aMax: number, bMin: number, bMax: number) { + const left = Math.max(aMin, bMin) + const right = Math.min(aMax, bMax) + return right - left +} + +export function applyRestitutionFriction(v: Vec2, normal: Vec2, restitution: number, friction: number): Vec2 { + const len = Math.hypot(normal.x, normal.y) || 1 + const nx = normal.x / len + const ny = normal.y / len + const vn = v.x * nx + v.y * ny // scalar normal component + const vx_t = v.x - vn * nx + const vy_t = v.y - vn * ny + const vnAfter = -Math.max(0, Math.min(1, restitution)) * vn + const frictionScale = Math.max(0, 1 - Math.max(0, Math.min(1, friction))) + const vtAfterX = vx_t * frictionScale + const vtAfterY = vy_t * frictionScale + return { x: vnAfter * nx + vtAfterX, y: vnAfter * ny + vtAfterY } +} +