From b4decd577684d2c020c79a024407f9deb2aa4cb8 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Mon, 3 Nov 2025 02:42:43 -0800 Subject: [PATCH 01/13] feat(registry): add PhysicsRegistry with typed descriptors + events; wire into controller; emit on resize/scroll - Introduce PhysicsRegistry (cloth, rigid-static) with add/update/remove events - Controller subscribes; seeds static AABBs; removes on detach - Overlay AABBs refresh on changes - Tests: registry discovery/diffing/events refs #43 --- .github/workflows/ci.yml | 20 ++- .github/workflows/pr-policy.yml | 42 ++++++ BLOG_NOTES.md | 2 + docs/issues/physics-registry.md | 19 +++ src/App.tsx | 62 +++++++-- src/app/EventsPanel.tsx | 69 ++++++++++ src/app/PerfOverlay.tsx | 36 ++++++ src/engine/events/__tests__/events.test.ts | 37 ++++++ src/engine/events/eventBus.ts | 29 +++++ src/engine/events/types.ts | 63 +++++++++ src/engine/perf/perfMonitor.ts | 32 +++++ src/engine/registry/PhysicsRegistry.ts | 120 ++++++++++++++++++ .../__tests__/physicsRegistry.spec.ts | 84 ++++++++++++ src/lib/__tests__/satObbAabb.test.ts | 51 ++++++++ src/lib/clothSceneController.ts | 96 +++++++++++++- src/lib/collision/satObbAabb.ts | 115 +++++++++++++++++ 16 files changed, 851 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/pr-policy.yml create mode 100644 docs/issues/physics-registry.md create mode 100644 src/app/EventsPanel.tsx create mode 100644 src/app/PerfOverlay.tsx create mode 100644 src/engine/events/__tests__/events.test.ts create mode 100644 src/engine/events/eventBus.ts create mode 100644 src/engine/events/types.ts create mode 100644 src/engine/perf/perfMonitor.ts create mode 100644 src/engine/registry/PhysicsRegistry.ts create mode 100644 src/engine/registry/__tests__/physicsRegistry.spec.ts create mode 100644 src/lib/__tests__/satObbAabb.test.ts create mode 100644 src/lib/collision/satObbAabb.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0251b56..b88cf73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,24 +2,22 @@ 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..61dd9eb --- /dev/null +++ b/.github/workflows/pr-policy.yml @@ -0,0 +1,42 @@ +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, + }) + 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/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..761b807 --- /dev/null +++ b/docs/issues/physics-registry.md @@ -0,0 +1,19 @@ +# 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/src/App.tsx b/src/App.tsx index 68d79bd..4648752 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,18 @@ 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(() => { + if (prevOpen.current && !debugOpen) { + setToast('panel clothified: debug-panel') + const t = window.setTimeout(() => setToast(null), 1200) + return () => window.clearTimeout(t) + } + prevOpen.current = debugOpen + }, [debugOpen]) useEffect(() => { if (typeof window !== 'undefined') { @@ -356,6 +384,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 +584,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..7a93310 --- /dev/null +++ b/src/app/EventsPanel.tsx @@ -0,0 +1,69 @@ +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} + {(e as any).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..a03ee48 --- /dev/null +++ b/src/app/PerfOverlay.tsx @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react' +import { Affix, Paper, Table, Text } from '@mantine/core' +import { perfMonitor } from '../engine/perf/perfMonitor' + +export function PerfOverlay({ visible }: { visible: boolean }) { + const [rows, setRows] = useState(perfMonitor.getAverages()) + + useEffect(() => { + if (!visible) return + const id = window.setInterval(() => { + setRows(perfMonitor.getAverages()) + }, 250) + return () => { window.clearInterval(id) } + }, [visible]) + + if (!visible) return null + return ( + + + Perf (avg ms) + + + {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..cce061a --- /dev/null +++ b/src/engine/events/__tests__/events.test.ts @@ -0,0 +1,37 @@ +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[] = [] + bus.on((e) => seen.push(e)) + const e: EngineEvent = { type: 'wake', id: 'A', time: 123 } + bus.emit(e) + expect(seen[0]).toBe(e) + }) +}) diff --git a/src/engine/events/eventBus.ts b/src/engine/events/eventBus.ts new file mode 100644 index 0000000..18ae70e --- /dev/null +++ b/src/engine/events/eventBus.ts @@ -0,0 +1,29 @@ +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 + if (import.meta?.env?.MODE !== 'production') { + throw new Error('Invalid EngineEvent payload: ' + JSON.stringify(event)) + } + return + } + for (const fn of this.listeners) fn(event) + } + + 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..c74d7a6 --- /dev/null +++ b/src/engine/events/types.ts @@ -0,0 +1,63 @@ +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': + case 'registry:update': + 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..54c4c7d --- /dev/null +++ b/src/engine/registry/PhysicsRegistry.ts @@ -0,0 +1,120 @@ +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: RegistryEventType + current?: PhysicsDescriptor + 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) { + 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 }) + } + } + } + + on(fn: (e: RegistryEvent) => void) { this.listeners.add(fn); return () => this.listeners.delete(fn) } + entries() { return Array.from(this.map.values()) } + + // --- internals --- + private emit(e: RegistryEvent) { + for (const fn of this.listeners) fn(e) + } + + 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 as PhysicsType | undefined) ?? undefined + if (!type && dataType) type = dataType + 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 || 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 && + JSON.stringify(a.attrs) === JSON.stringify(b.attrs) + } + + private autoId(el: HTMLElement) { + // Deterministic path-based id + const parts: string[] = [] + let n: HTMLElement | null = el + while (n && n !== document.body) { + const idx = n.parentElement ? Array.from(n.parentElement.children).indexOf(n) : 0 + 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/lib/__tests__/satObbAabb.test.ts b/src/lib/__tests__/satObbAabb.test.ts new file mode 100644 index 0000000..577d135 --- /dev/null +++ b/src/lib/__tests__/satObbAabb.test.ts @@ -0,0 +1,51 @@ +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)) + // Projecting the OBB by MTV should separate (quick check on sign) + expect(res.normal.x === 1 || res.normal.x === -1 || res.normal.y === 1 || res.normal.y === -1).toBe(true) + }) + + 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('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).toBeLessThan(1) + }) +}) + diff --git a/src/lib/clothSceneController.ts b/src/lib/clothSceneController.ts index 4e83337..1ae83d1 100644 --- a/src/lib/clothSceneController.ts +++ b/src/lib/clothSceneController.ts @@ -15,10 +15,13 @@ 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 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' const WARM_START_PASSES = 2 @@ -345,10 +348,13 @@ 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 onResize = () => this.handleResize() private onScroll = () => { this.syncStaticMeshes() this.collisionSystem.refresh() + this.registry?.discover(document) } private pointer: PointerState = { position: new THREE.Vector2(), @@ -413,13 +419,42 @@ 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[] = [] + const applyEvent = (evt: RegistryEvent) => { + if (evt.type === 'registry:add') { + const d = evt.current! + if (d.type === 'cloth') pendingCloth.push(d.element) + if (d.type === 'rigid-static') this.collisionSystem.addStaticBody(d.element) + } 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) + } 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) this.updateOverlayDebug() window.addEventListener('resize', this.onResize, { passive: true }) @@ -478,6 +513,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() @@ -656,6 +698,7 @@ export class ClothSceneController { this.collisionSystem.setViewportDimensions(viewport.width, viewport.height) this.collisionSystem.refresh() this.syncStaticMeshes() + this.registry?.discover(document) } private installRenderPipeline() { @@ -700,6 +743,33 @@ export class ClothSceneController { priority: 8, allowWhilePaused: true, }) + + // 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 + const world = this.engine + const addSystemOrig = world.addSystem.bind(world) + // Wrap addSystem for perf instrumentation + 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 } /** Returns the underlying engine world for debug actions. */ @@ -850,6 +920,20 @@ 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 + } + item.entity?.destroy() + element.style.opacity = item.originalOpacity + element.removeEventListener('click', item.clickHandler) + 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 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 } +} + From ab70dbabc7eac9e828f464bf45b632519e263103 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Mon, 3 Nov 2025 02:42:43 -0800 Subject: [PATCH 02/13] test(collision): add failing specs for OBB SAT vs AABB with MTV, restitution + friction helpers From 2a5c3bdcb646ea843ec7884dac7a9bdc3d85e922 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Mon, 3 Nov 2025 15:20:28 -0800 Subject: [PATCH 03/13] fix: perf instrumentation before system registration; dynamic registry adds; stronger types/guards; UI polish - Move addSystem wrapper before adding camera/render/overlay systems - Process registry:add cloth after startup; clear pending list - Strengthen PhysicsRegistry event typing, data-phys-type validation, id handling, attrs equality; error isolation; docs minor - EventBus: listener isolation + portable env check - EventsPanel: remove any; use optional top-level tag - PerfOverlay: lazy init, error handling, theme color, empty state, interval prop - App toast effect: stable ref + cleanup - Remove stale collider on cloth removal - CI: allowlist + escape hatch for tests-first; trim trailing blank line - Tests: SAT stronger assertions; EventBus resilience --- .github/workflows/ci.yml | 1 - .github/workflows/pr-policy.yml | 19 ++++++- package-lock.json | 36 ++++++++---- src/App.tsx | 7 ++- src/app/EventsPanel.tsx | 3 +- src/app/PerfOverlay.tsx | 25 ++++++--- src/engine/events/__tests__/events.test.ts | 8 ++- src/engine/events/eventBus.ts | 23 +++++++- src/engine/events/types.ts | 8 ++- src/engine/registry/PhysicsRegistry.ts | 61 ++++++++++++++------ src/lib/__tests__/satObbAabb.test.ts | 9 +-- src/lib/clothSceneController.ts | 65 ++++++++++++---------- 12 files changed, 186 insertions(+), 79 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b88cf73..18ffdda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,4 +20,3 @@ jobs: - run: npm ci - run: npm run lint - run: npm test -- --run - diff --git a/.github/workflows/pr-policy.yml b/.github/workflows/pr-policy.yml index 61dd9eb..d3d8863 100644 --- a/.github/workflows/pr-policy.yml +++ b/.github/workflows/pr-policy.yml @@ -29,6 +29,24 @@ jobs: 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).') @@ -39,4 +57,3 @@ jobs: if (!/Acceptance Criteria/i.test(body)) { core.notice('Consider adding an "Acceptance Criteria" section to the PR description.') } - 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 4648752..7b8640f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -332,12 +332,15 @@ function Demo() { const [eventsOpen, setEventsOpen] = useState(false) const prevOpen = useRef(debugOpen) useEffect(() => { + let timeoutId: number | undefined if (prevOpen.current && !debugOpen) { setToast('panel clothified: debug-panel') - const t = window.setTimeout(() => setToast(null), 1200) - return () => window.clearTimeout(t) + timeoutId = window.setTimeout(() => setToast(null), 1200) } prevOpen.current = debugOpen + return () => { + if (timeoutId !== undefined) window.clearTimeout(timeoutId) + } }, [debugOpen]) useEffect(() => { diff --git a/src/app/EventsPanel.tsx b/src/app/EventsPanel.tsx index 7a93310..111787b 100644 --- a/src/app/EventsPanel.tsx +++ b/src/app/EventsPanel.tsx @@ -51,7 +51,7 @@ export function EventsPanel({ open, onOpenChange }: { open: boolean; onOpenChang setSelected(e)} style={{ cursor: 'pointer' }}> {new Date(e.time).toLocaleTimeString()} {e.type} - {(e as any).tag ?? ''} + {typeof e.tag === 'string' ? e.tag : ''} ))} @@ -66,4 +66,3 @@ export function EventsPanel({ open, onOpenChange }: { open: boolean; onOpenChang ) } - diff --git a/src/app/PerfOverlay.tsx b/src/app/PerfOverlay.tsx index a03ee48..b37e372 100644 --- a/src/app/PerfOverlay.tsx +++ b/src/app/PerfOverlay.tsx @@ -2,16 +2,21 @@ import { useEffect, useState } from 'react' import { Affix, Paper, Table, Text } from '@mantine/core' import { perfMonitor } from '../engine/perf/perfMonitor' -export function PerfOverlay({ visible }: { visible: boolean }) { - const [rows, setRows] = useState(perfMonitor.getAverages()) +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(() => { - setRows(perfMonitor.getAverages()) - }, 250) + try { + setRows(perfMonitor.getAverages()) + } catch (err) { + console.error('PerfOverlay: failed to fetch averages', err) + } + }, intervalMs) return () => { window.clearInterval(id) } - }, [visible]) + }, [visible, intervalMs]) if (!visible) return null return ( @@ -20,10 +25,17 @@ export function PerfOverlay({ visible }: { visible: boolean }) { Perf (avg ms) + {rows.length === 0 && ( + + + No metrics available + + + )} {rows.map((r) => ( {r.name} - {r.avg.toFixed(2)} + {r.avg.toFixed(2)} {r.budget ? r.budget.toFixed(2) : '—'} ))} @@ -33,4 +45,3 @@ export function PerfOverlay({ visible }: { visible: boolean }) { ) } - diff --git a/src/engine/events/__tests__/events.test.ts b/src/engine/events/__tests__/events.test.ts index cce061a..9f78e02 100644 --- a/src/engine/events/__tests__/events.test.ts +++ b/src/engine/events/__tests__/events.test.ts @@ -29,9 +29,15 @@ describe('Engine events validation + bus', () => { 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 } - bus.emit(e) + 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 index 18ae70e..96c9c9d 100644 --- a/src/engine/events/eventBus.ts +++ b/src/engine/events/eventBus.ts @@ -11,12 +11,30 @@ export class EventBus { emit(event: EngineEvent) { if (!validateEngineEvent(event)) { // Fail fast in dev/test; ignore in production - if (import.meta?.env?.MODE !== '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) fn(event) + 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 { @@ -26,4 +44,3 @@ export class EventBus { } export const globalEventBus = new EventBus() - diff --git a/src/engine/events/types.ts b/src/engine/events/types.ts index c74d7a6..3aecbd2 100644 --- a/src/engine/events/types.ts +++ b/src/engine/events/types.ts @@ -54,7 +54,13 @@ export function validateEngineEvent(e: unknown): e is EngineEvent { case 'deactivate': return typeof ev.id === 'string' case 'registry:add': - case 'registry:update': + 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: diff --git a/src/engine/registry/PhysicsRegistry.ts b/src/engine/registry/PhysicsRegistry.ts index 54c4c7d..b359861 100644 --- a/src/engine/registry/PhysicsRegistry.ts +++ b/src/engine/registry/PhysicsRegistry.ts @@ -20,11 +20,10 @@ export type PhysicsDescriptor = { export type RegistryEventType = 'registry:add' | 'registry:update' | 'registry:remove' -export type RegistryEvent = { - type: RegistryEventType - current?: PhysicsDescriptor - previous?: PhysicsDescriptor -} +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() @@ -53,12 +52,25 @@ export class PhysicsRegistry { } } - on(fn: (e: RegistryEvent) => void) { this.listeners.add(fn); return () => this.listeners.delete(fn) } - entries() { return Array.from(this.map.values()) } + /** 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()) + } // --- internals --- private emit(e: RegistryEvent) { - for (const fn of this.listeners) fn(e) + for (const fn of this.listeners) { + try { + fn(e) + } catch (err) { + console.error('PhysicsRegistry listener error:', err) + } + } } private describe(el: HTMLElement): PhysicsDescriptor | null { @@ -67,8 +79,12 @@ export class PhysicsRegistry { 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 as PhysicsType | undefined) ?? undefined - if (!type && dataType) type = dataType + 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() @@ -82,7 +98,7 @@ export class PhysicsRegistry { damping: parseNum(el.dataset.clothDamping), } return { - id: el.id || this.autoId(el), + id: (el.id ?? '').trim() !== '' ? el.id : this.autoId(el), tag, type, attrs, @@ -93,10 +109,20 @@ export class PhysicsRegistry { } 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 && - JSON.stringify(a.attrs) === JSON.stringify(b.attrs) + 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) { @@ -104,7 +130,9 @@ export class PhysicsRegistry { const parts: string[] = [] let n: HTMLElement | null = el while (n && n !== document.body) { - const idx = n.parentElement ? Array.from(n.parentElement.children).indexOf(n) : 0 + 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 } @@ -117,4 +145,3 @@ function parseNum(v?: string): number | undefined { const n = Number(v) return Number.isFinite(n) ? n : undefined } - diff --git a/src/lib/__tests__/satObbAabb.test.ts b/src/lib/__tests__/satObbAabb.test.ts index 577d135..bf4c87c 100644 --- a/src/lib/__tests__/satObbAabb.test.ts +++ b/src/lib/__tests__/satObbAabb.test.ts @@ -26,8 +26,10 @@ describe('SAT OBB vs AABB', () => { // 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)) - // Projecting the OBB by MTV should separate (quick check on sign) - expect(res.normal.x === 1 || res.normal.x === -1 || res.normal.y === 1 || res.normal.y === -1).toBe(true) + // 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', () => { @@ -45,7 +47,6 @@ describe('SAT OBB vs AABB', () => { // 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).toBeLessThan(1) + expect(out.y).toBeCloseTo(0.8, 3) }) }) - diff --git a/src/lib/clothSceneController.ts b/src/lib/clothSceneController.ts index 1ae83d1..04ee53f 100644 --- a/src/lib/clothSceneController.ts +++ b/src/lib/clothSceneController.ts @@ -422,17 +422,24 @@ export class ClothSceneController { // 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') pendingCloth.push(d.element) + 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) } else if (evt.type === 'registry:remove') { - const d = evt.previous! + const d = evt.previous if (d.type === 'rigid-static') this.collisionSystem.removeStaticBody(d.element) if (d.type === 'cloth') this.removeClothForElement(d.element) } else if (evt.type === 'registry:update') { - if (evt.current?.type === 'rigid-static') this.collisionSystem.refresh() + if (evt.current.type === 'rigid-static') this.collisionSystem.refresh() } this.updateOverlayDebug() } @@ -455,6 +462,8 @@ export class ClothSceneController { } await this.prepareElements(pendingCloth) + pendingCloth.length = 0 + registryReady = true this.updateOverlayDebug() window.addEventListener('resize', this.onResize, { passive: true }) @@ -722,36 +731,13 @@ 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 }) - // Register with lower priority than simulation so render sees the latest snapshot. - this.engine.addSystem(this.cameraSystem, { - id: ClothSceneController.CAMERA_SYSTEM_ID, - priority: 50, - allowWhilePaused: true, - }) - this.engine.addSystem(this.worldRenderer, { - id: ClothSceneController.RENDERER_SYSTEM_ID, - priority: 10, - allowWhilePaused: true, - }) - this.engine.addSystem(this.overlaySystem, { - id: ClothSceneController.OVERLAY_SYSTEM_ID, - priority: 5, - allowWhilePaused: true, - }) - this.engine.addSystem(this.renderSettingsSystem, { - id: 'render-settings', - priority: 8, - allowWhilePaused: true, - }) - // 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 + // Instrument world by wrapping system updates BEFORE adding systems const world = this.engine const addSystemOrig = world.addSystem.bind(world) - // Wrap addSystem for perf instrumentation world.addSystem = ((system: EngineSystem, options: EngineSystemOptions = {}) => { const id = options.id ?? system.id ?? 'system' if (typeof system.fixedUpdate === 'function') { @@ -770,6 +756,28 @@ export class ClothSceneController { } return addSystemOrig(system, options) }) as typeof addSystemOrig + + // Register with lower priority than simulation so render sees the latest snapshot. + world.addSystem(this.cameraSystem, { + id: ClothSceneController.CAMERA_SYSTEM_ID, + priority: 50, + allowWhilePaused: true, + }) + world.addSystem(this.worldRenderer, { + id: ClothSceneController.RENDERER_SYSTEM_ID, + priority: 10, + allowWhilePaused: true, + }) + world.addSystem(this.overlaySystem, { + id: ClothSceneController.OVERLAY_SYSTEM_ID, + priority: 5, + allowWhilePaused: true, + }) + world.addSystem(this.renderSettingsSystem, { + id: 'render-settings', + priority: 8, + allowWhilePaused: true, + }) } /** Returns the underlying engine world for debug actions. */ @@ -930,6 +938,7 @@ export class ClothSceneController { 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) } From f999f8e79106aa7f9e5a0701b64490d644993a93 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Mon, 3 Nov 2025 15:53:11 -0800 Subject: [PATCH 04/13] feat(rigid): integrate OBB SAT into RigidSystem and wire into controller via PhysicsRegistry - New RigidSystem (fixedUpdate): OBB dynamics vs static AABBs (SAT) - Controller installs rigid system and maps registry rigid-dynamic descriptors to bodies - Emits collision EngineEvents via global EventBus - Tests: rigidSystem integration + collision emit --- .../systems/__tests__/rigidSystem.test.ts | 41 +++++++++ src/engine/systems/rigidSystem.ts | 86 +++++++++++++++++++ src/lib/clothSceneController.ts | 45 +++++++++- 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 src/engine/systems/__tests__/rigidSystem.test.ts create mode 100644 src/engine/systems/rigidSystem.ts 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..ec533d4 --- /dev/null +++ b/src/engine/systems/rigidSystem.ts @@ -0,0 +1,86 @@ +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 { + id = 'rigid-system' + 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/clothSceneController.ts b/src/lib/clothSceneController.ts index 04ee53f..0ebea41 100644 --- a/src/lib/clothSceneController.ts +++ b/src/lib/clothSceneController.ts @@ -17,11 +17,13 @@ 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 @@ -350,6 +352,7 @@ export class ClothSceneController { 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() @@ -434,10 +437,12 @@ export class ClothSceneController { } } 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() } @@ -545,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() @@ -713,15 +726,17 @@ export class ClothSceneController { 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() @@ -731,6 +746,7 @@ 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 }) + 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) @@ -778,6 +794,9 @@ export class ClothSceneController { priority: 8, allowWhilePaused: true, }) + if (this.rigidSystem) { + world.addSystem(this.rigidSystem, { id: 'rigid-system', priority: 95 }) + } } /** Returns the underlying engine world for debug actions. */ @@ -949,6 +968,30 @@ export class ClothSceneController { 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 From a2b6dc7a6f339f549b4228efb3bb8a495d1bbac0 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Mon, 3 Nov 2025 18:38:54 -0800 Subject: [PATCH 05/13] Update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From 6d6987ada9ad8cb594c4637371ca25d0d0431e15 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Mon, 3 Nov 2025 18:49:10 -0800 Subject: [PATCH 06/13] ci(policy): allowlist infra/docs + skip-test-check hatch; yamllint trailing newline fixed From eb2d1eb3f7e269a17167e986817209ba935aa25d Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Mon, 3 Nov 2025 18:49:10 -0800 Subject: [PATCH 07/13] docs(issues): fix heading spacing + inline code backticks in physics-registry.md From 90647e9f88b42b959629a2494b78921166b83b1e Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Mon, 3 Nov 2025 18:49:10 -0800 Subject: [PATCH 08/13] events(types): strengthen collision validation and registry:update payload checks From 7309f9e586a9bdfa9f9bff922979acd987d587e7 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Mon, 3 Nov 2025 18:49:33 -0800 Subject: [PATCH 09/13] docs(issues): fix markdown spacing + inline code in physics-registry.md --- docs/issues/physics-registry.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/issues/physics-registry.md b/docs/issues/physics-registry.md index 761b807..114d61e 100644 --- a/docs/issues/physics-registry.md +++ b/docs/issues/physics-registry.md @@ -1,13 +1,16 @@ + # 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 + +- [ ] 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 @@ -16,4 +19,3 @@ Design and implement a PhysicsRegistry that discovers DOM nodes, stores typed de ## Tests (write first) - [ ] physicsRegistry.spec: discovery from a test DOM, descriptor diffing, events on resize - [ ] integration: inspector or controller subscribes and receives `registry:update` - From 83e2eeeb45f94d552a985b6a7d85893813d0ad48 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Mon, 3 Nov 2025 18:49:33 -0800 Subject: [PATCH 10/13] registry: add setActive(id, active); preserve active flag across rediscovery; emit update --- src/engine/registry/PhysicsRegistry.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/engine/registry/PhysicsRegistry.ts b/src/engine/registry/PhysicsRegistry.ts index b359861..aacf957 100644 --- a/src/engine/registry/PhysicsRegistry.ts +++ b/src/engine/registry/PhysicsRegistry.ts @@ -35,6 +35,7 @@ export class PhysicsRegistry { 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 }) @@ -62,6 +63,18 @@ export class PhysicsRegistry { 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) { From f8b45cdc249a66d317b7b23ce9aebb8e221ccd12 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Mon, 3 Nov 2025 18:49:34 -0800 Subject: [PATCH 11/13] tests(sat): add Y-separation and rotated corner near-miss non-collision cases; strengthen normal checks --- src/lib/__tests__/satObbAabb.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/lib/__tests__/satObbAabb.test.ts b/src/lib/__tests__/satObbAabb.test.ts index bf4c87c..e0dec17 100644 --- a/src/lib/__tests__/satObbAabb.test.ts +++ b/src/lib/__tests__/satObbAabb.test.ts @@ -40,6 +40,20 @@ describe('SAT OBB vs AABB', () => { 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 From 481eca2ef917f80c3ccfa7882d0b8198f4b6e490 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Mon, 3 Nov 2025 18:49:34 -0800 Subject: [PATCH 12/13] rigid: remove fixed id to avoid duplicate id conflicts; let EngineWorld assign id --- src/engine/systems/rigidSystem.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/engine/systems/rigidSystem.ts b/src/engine/systems/rigidSystem.ts index ec533d4..f64ca54 100644 --- a/src/engine/systems/rigidSystem.ts +++ b/src/engine/systems/rigidSystem.ts @@ -19,7 +19,6 @@ export type DynamicBody = { } export class RigidSystem implements EngineSystem { - id = 'rigid-system' priority = 95 allowWhilePaused = false @@ -83,4 +82,3 @@ export class RigidSystem implements EngineSystem { } } } - From d2345ab2c3e5fd33ade76f633f0f3cfb217808c5 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Mon, 3 Nov 2025 18:49:34 -0800 Subject: [PATCH 13/13] perf(controller): instrument SimulationSystem without re-register; defer RigidSystem install; robust teardown of active cloth bodies --- src/lib/clothSceneController.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/lib/clothSceneController.ts b/src/lib/clothSceneController.ts index 0ebea41..5c5120d 100644 --- a/src/lib/clothSceneController.ts +++ b/src/lib/clothSceneController.ts @@ -746,6 +746,8 @@ 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) @@ -773,6 +775,26 @@ export class ClothSceneController { 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. world.addSystem(this.cameraSystem, { id: ClothSceneController.CAMERA_SYSTEM_ID, @@ -794,6 +816,8 @@ export class ClothSceneController { 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 }) } @@ -954,6 +978,11 @@ export class ClothSceneController { 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)