feat(controls): split camera input into a layered <PolyControls>#11
Merged
Conversation
…m leaves Drop transform-style:preserve-3d on .polycss-scene i — the 3D context lives on the .polycss-scene / .polycss-mesh wrappers, not the leaves (leaves are themselves placed via matrix3d but have no children that need to participate in 3D compositing). With preserve-3d gone, contain:strict becomes legal on the dynamic-mode leaf rule. That isolates each polygon's layout/style/paint walks from its siblings — Blink can stop walking the whole sibling list when one polygon's vars change. Iter-4 measurement: light_rotate flat (within noise of H5 floor); the big win was camera_rotate 14.9 → 20.0 fps (+34.5%) — compositing-tree walks per camera change become bounded per polygon.
The 'Auto rotate' toggle was bound only through <PolyCamera animate>,
which is a React-renderer-only component. The vanilla renderer path
calls createPolyScene + scene.setOptions but had no animate equivalent,
so toggling did nothing in vanilla mode.
Add an effect that, when animate is on AND renderer === 'vanilla',
runs a dt-clamped rAF loop calling scene.setOptions({ rotY }). Pattern
mirrors website/src/components/PolyDemo.astro.
This is a stopgap — once createPolyControls lands, this local loop
should be replaced by passing animate options through controls.
…on camera/lighting changes
setOptions used to walk every mesh polygon (O(N polys) via
recomputeAutoCenter) on every call, even when the partial only
affected camera state, lighting, or interactivity — none of which
change the bbox.
Now recomputeAutoCenter only runs when 'autoCenter' itself appears
in the partial. Mesh add/remove paths still call it directly, so
geometry changes are still reflected.
This is foundational for the upcoming createPolyControls split:
without it, an autorotate loop calling setOptions({ rotY }) at
60 fps would walk the bbox 60 times/sec on every frame for nothing.
8 new tests under setOptions > 'autoCenter recomputation diff'
(7 negative, 1 positive, 1 sanity check that camera updates still
apply). All 122 polycss tests pass.
…e layer
Three.js OrbitControls-style split: createPolyScene becomes a pure
renderer (owns camera state, applies it to matrix3d transforms);
createPolyControls is an additive layer that listens for pointer/wheel
input and runs an optional rAF loop, calling scene.setOptions(...) to
drive state.
API:
const scene = createPolyScene(host, { rotX, rotY, zoom, ...lighting });
const controls = createPolyControls(scene, {
drag: true, // default true
wheel: true, // default true
invert: false, // bool or sensitivity multiplier
zoom: { min: 0.1, max: 10 },
animate: { speed: 0.3, axis: 'y', pauseOnInteraction: true },
});
controls.update({ animate: false }); // mutate live
controls.start() / .stop() / .destroy();
Defaults: drag on, wheel on, animate off — opt out by passing false.
Animate uses a dt-clamped formula (max 50 ms per tick, normalized to
60 Hz reference) so 'speed: 0.3' = ~18 deg/sec on every refresh rate.
Old in-scene drag/wheel handlers were frame-rate-naive; this fixes
that for the new API path.
Breaking changes (still pre-deploy, intentional):
- removed PolySceneOptions.interactive — use createPolyControls instead
- removed in-scene pointerdown/move/up/wheel handlers
- removed <poly-scene interactive> attribute (custom-element <poly-controls>
to follow in commit 3 — declarative HTML temporarily loses pointer drag)
Additions to SceneHandle:
- host: HTMLElement (exposes the host for layered helpers)
- getOptions(): Readonly<PolySceneOptions> (snapshot for state-reading helpers)
Tests: 35 createPolyControls tests + 4 SceneHandle additions tests
(host / getOptions). Total 159 passing (was 122).
Migration of existing callers (minimal, just to drop dangling refs):
- DebugWorkbench: dropped interactive from createPolyScene/setOptions
calls (toggle UI temporarily a no-op for vanilla until commit 6
migrates DebugWorkbench to controls)
- PolyDemo.astro: same (TODO marker added)
Repo hygiene: extended .gitignore to cover /bench/results, /bench/polycss.js,
and /.claude/ — these were generated by perf-loop iterations and
shouldn't follow css-perf-wins around.
Declarative wrapper around createPolyControls. Sits inside <poly-scene>
and walks up via the parent chain to attach itself. Mirrors the A-Frame
component pattern — no rendered output, behavior-only.
<poly-scene rot-x='65' rot-y='45'>
<poly-controls
drag (default true)
wheel (default true)
animate-speed='0.3' (any animate-* attr → animate on)
animate-axis='y'
animate-pause-on-interaction
invert='2' (number = sensitivity)
zoom-min='0.1'
zoom-max='10'
></poly-controls>
<poly-mesh ...></poly-mesh>
</poly-scene>
Boolean attribute semantics: presence enables (drag absent = use default
true; drag='false' or drag='0' explicitly disables). Numeric attrs
(zoom-min, animate-speed) parse as floats. invert accepts true/false/
number. animate-* attribute presence implies animate is on; removing
all of them propagates animate:false through update() so the rAF loop
stops cleanly.
Element handles the upgrade-after-children case: if controls connects
before its parent <poly-scene> has a SceneHandle ready, it listens
once for the scene's polycss:scene-ready custom event and retries.
Tests: 17 new tests covering registration, attachment (incl. orphan
+ upgrade-order), attribute → option coercion (drag, wheel, invert,
zoom-min/max, animate-*), live attributeChangedCallback flips
(animate on/off, drag toggle), and disconnect cleanup. 176 polycss
tests pass total (was 159).
Restores declarative drag/wheel that was temporarily removed in the
prior commit when <poly-scene interactive> was deleted.
React counterpart of the vanilla createPolyControls API. Render-free
component that uses the existing PolyCameraContext to attach pointer/
wheel handlers and run a dt-clamped animate loop.
<PolyCamera rotX={65} rotY={45} zoom={1}>
<PolyScene>
<PolyControls drag wheel animate={{ speed: 0.3, axis: 'y', pauseOnInteraction: true }} />
<PolyMesh polygons={...} />
</PolyScene>
</PolyCamera>
Defaults: drag on, wheel on, animate off. Pass false to opt out.
Animate uses dt-clamped formula (max 50 ms per tick, 60 Hz reference)
so 'speed: 0.3' = ~18 deg/sec on every refresh rate — fixes the
frame-rate-dependent issue in the existing <PolyCamera animate> path.
Context additions (back-compat — purely additive):
- cameraElRef: exposes the camera root element for handler attachment
- applyTransformDirect: lets layered components apply state changes to
the DOM after mutating cameraRef.current
Existing <PolyCamera interactive animate={...}> props continue to work
for back-compat; document use one-or-the-other to avoid double handlers.
PolyCamera is unchanged in behavior.
Tests: 13 new tests in PolyControls.test.tsx covering defaults, pointer
drag (with rotY math), wheel zoom, animate dt-clamping (incl. long-pause
clamp), axis switching, prop flip lifecycle, and unmount cleanup. All
146 pre-existing React tests continue to pass — total 159 React tests
pass.
Vue 3 counterpart of the vanilla createPolyControls / React <PolyControls>
APIs. Render-free behavior component placed inside <PolyCamera>
(typically also <PolyScene>). Uses inject() to pull the camera context
and attach pointer/wheel listeners + a dt-clamped animate loop.
<PolyCamera :rot-x='65' :rot-y='45' :zoom='1'>
<PolyScene>
<PolyControls drag wheel :animate='{ speed: 0.3, axis: "y" }' />
<PolyMesh :polygons='...' />
</PolyScene>
</PolyCamera>
Defaults: drag on, wheel on, animate off (pass false to opt out of any).
Animate uses dt-clamped formula (max 50 ms per tick, 60 Hz reference)
so 'speed' is consistent across refresh rates.
Context additions (back-compat — purely additive):
- cameraElRef: exposes the camera root element ref for handler attachment
- applyTransformDirect: lets layered components apply state changes to
the DOM after mutating cameraRef.value
Existing <PolyCamera :interactive :animate='...'> props continue to work
for back-compat; document use one-or-the-other to avoid double handlers.
Tests: 15 new tests asserting on cameraRef.value.state directly via a
ContextProbe helper component (the source of truth for camera mutations).
Covers defaults, pointer drag (with rotY/rotX math + invert sensitivity),
wheel zoom + min/max clamping, animate dt-normalization (incl. long-pause
clamp), axis switching, and unmount cleanup. All 131 pre-existing Vue
tests continue to pass — total 146 Vue tests pass.
Note on PolyScene render-cycle quirk: useCamera.applyTransformDirect
short-circuits when sceneElRef.value is null, which is the case until
PolyScene's local-ref → context-ref watch flushes. In real usage this
flushes before user input fires; in synthetic happy-dom tests it can
race. Tests assert on cameraRef.state for that reason — DOM transform
sync is PolyScene's contract.
…ontrols Replaces the interim local rAF effect (added in 34aecb2) with a real controls handle. Both interactive (drag + wheel) and animate (autorotate) toggles now flow through createPolyControls.update() instead of being re-implemented per-effect. Lifecycle: controls handle is created lazily once the scene mounts, then .update()'d when interactive/animate/renderer change, then .destroy()'d in the scene Effect 1 cleanup before the scene itself tears down. The ordering matters — controls' rAF tick can't fire against a stale handle if it's destroyed first. Headed-mode verified: toggling 'Auto rotate' in /debug/meshes (vanilla renderer) now genuinely rotates the scene through the controls layer (two screenshots 800 ms apart differ). All 803 tests across all packages still pass.
…PolyControls Drops the inline animTick / startAnim / stopAnim plumbing and the state.interactive attribute juggling. Both render paths now delegate drag/wheel/animate to the new controls layer: - Model mode (<poly-scene> custom element): appends a <poly-controls> child element and updates its kebab-case attributes (drag/wheel/animate-speed) when state changes. - Generator mode (createPolyScene + plain div): owns a createPolyControls handle; updates flow through controls.update(). ensureCustomElementsRegistered() now also registers <poly-controls>. Cleanup: controls handle is destroyed BEFORE the scene in the view-transition cleanup so the rAF tick can't fire one more time against a dying handle. Headed-mode verified: clicking the 'animate' toggle on a quickstart PolyDemo rotates the scene (two screenshots 800 ms apart differ). All 803 tests across all packages still pass.
The new createPolyControls / <PolyControls> code added rotY += dX, which makes the visible object move OPPOSITE to the pointer (right- drag would rotate the scene leftward — the user feels like they're pushing the camera around instead of grabbing the object). Flip to rotY -= dX so the rendered object follows the user's mouse: drag-right pulls the front of the scene rightward, drag-down tilts the top toward the user. invert:true now reverses to the other behavior (camera-orbit feel); invert as a number scales sensitivity in the default direction. Updated all three implementations (vanilla createPolyControls, React PolyControls, Vue PolyControls) and the corresponding test expectations across all four test suites. 803 tests still pass.
SliceTest used to pass interactive:true to createPolyScene, but that option was removed when the renderer was split from input handling (commit d54d486). The flag was silently ignored at runtime so the page shipped with no drag/wheel. Wire createPolyControls(scene, { drag: true, wheel: true }) instead. Controls handle is destroyed BEFORE the scene in the cleanup effect so the rAF/listeners unwind against a still-live handle. Live verified at /debug/slice-test: scene mounts, host cursor is 'grab', dispatching a 100px horizontal pointer drag mutates the scene transform. Other debug pages already work as-is: - /debug/meshes (DebugWorkbench) — vanilla path migrated commit 6 - /debug/platonic, /sphere, /triangle-editor — go through DebugScene which uses React <PolyCamera> (untouched, works as before)
Brings in the 'automatic merge is the only scene mode' refactor and related improvements (vox multi-model parsing, atlas-page skip for solid rectangles, syncMeshesForCull / domCullBackfaces, smarter setOptions diff via prevAutoCenter !== nextAutoCenter). Conflict resolution philosophy: main's substance wins on the renderer (setOptions diff, atlas, mesh-cull). Our camera-split design (split controls layer) goes on top. Where they overlap, our perf attempts that got superseded by main's refactor are dropped: - setOptions: keep main's smarter value-diff logic (prevAutoCenter !== nextAutoCenter); drop our 'autoCenter' in partial check — superseded. - createPolyScene.ts: keep main's domCullBackfaces / syncMeshesForCull; drop main's restored in-scene drag/wheel/syncInteractive (those belong in createPolyControls now). PolySceneOptions stays without the 'interactive' field — input is owned by createPolyControls. - PolyDemo.astro: drop the merge attribute pass-through (no longer configurable on main) AND the interactive attribute (now owned by the <poly-controls> child this branch already appends). - SliceTest: deleted on main (slice mode is gone). Our last-iter fix to it is moot; accept the deletion. - One createPolyScene test was rewritten — it asserted our older 'autoCenter re-set forces recompute' semantics. Main's value-diff is the more correct behavior; test now asserts the no-op stays a no-op. Verification: - All 807 tests pass (core 324 +2 vox, polycss 178, vue 146, react 159). - Production build clean across all packages + website. - Live: /debug/meshes vanilla autorotate works through createPolyControls; /debug/slice-test correctly 404s.
…mantics Main's setOptions diff is value-based (prevAutoCenter !== nextAutoCenter), so re-setting autoCenter to its current value is a no-op. The test from our perf-fix commit asserted the older 'force recompute on re-set' semantics — flipping the assertion to match the correct value-diff behavior. Callers needing a force-refresh should toggle off→on or mutate the underlying mesh.
…pets
The hero rock model on / passed interactive:true to createPolyScene,
but that option no longer exists (input was split into the additive
createPolyControls layer). The flag was silently ignored at runtime,
so drag and wheel did nothing on the hero.
Replace the manual rAF loop + interactive flag with createPolyControls
({ drag: true, wheel: true, animate: { speed: 0.3, axis: 'y',
pauseOnInteraction: true } }). The pauseOnInteraction default
preserves the prior 'spin freezes during a drag' behavior.
Update the three framework snippets in the framework-tabs section to
show the new API (vanilla <poly-controls>, React/Vue <PolyControls>
inside <PolyCamera><PolyScene>). Also drop the explicit
perspective='1000' so the snippets show the default (8000).
Live verified: hero scene mounts, host cursor is grab, autorotation
animates between frames, pointer drag mutates the scene transform.
Both 'Live demo — interactive scene' (shapes guide) and 'Live demo — UV-textured GLB' (textures guide) had perspective:1000 baked into their defaults block. The library default is 8000 (a much gentler iso-style projection), so the explicit 1000 was forcing strong fish-eye distortion on the demo scene. Drop perspective from the defaults so each demo renders at the library default (users can still pull up the perspective slider via the controls list to dial it in). Also tune the zoom to fit each model — barrel: 0.08, tree: 0.1.
New page components/poly-controls.mdx covering all four surfaces (custom element, vanilla createPolyControls, React, Vue) — full options table, animate sub-options, lifecycle methods, and a back-compat note about combining with PolyCamera's built-in interactive/animate props. components/poly-scene.mdx: drop the obsolete 'interactive' prop row (input was split into the controls layer; the option no longer exists on the renderer). Update perspective default 1000 → 8000 to match current code. Add a callout pointing readers at PolyControls for any camera input. components/poly-camera.mdx: mark interactive/animate props as back-compat (still work, but PolyControls is the recommended path). Replace the 'Interactive Camera with Auto-rotation' example to use <PolyControls> as a child rather than props on PolyCamera. Update perspective default 1000 → 8000. api/headless.mdx: add a createPolyControls(scene, options?) section between createIsometricCamera and Custom elements. Includes the options table, ControlsHandle methods, and the SceneHandle subset the controls layer reads/writes. Update the custom-element example to register and use <poly-controls>. Sidebar: add 'PolyControls' under Components in astro.config.mjs. Build passes; new /components/poly-controls/ page generated.
Same root cause as the landing hero: the gallery passed interactive:true
to createPolyScene, which became a no-op when the renderer was split
from input handling — drag/wheel did nothing on the viewer.
Wire createPolyControls(scene, { drag: true, wheel: true, animate: false })
right after the scene is created. The drag/wheel toggles default to
true, so the viewer is interactive out of the box. The toolbar 'animate'
checkbox now flips controls.update({ animate: ... }) instead of running
its own rAF tick.
Drop the local animTick / animFrameId / animRotY / lastAnimTime / ANIM_SPEED
machinery — the same logic lives in createPolyControls (with the
dt-clamping that the local copy didn't have).
Live verified at /gallery: scene mounts, host cursor = grab, dispatching
a pointer drag mutates the scene transform, toggling 'animate' on
starts the autorotate loop.
Audit caught two more places that hadn't been migrated:
- examples/headless/slicer.html still passed interactive:true to
createPolyScene. The standalone slicer demo now uses
createPolyControls(scene, { drag: true, wheel: true }) and tracks
the controls handle alongside scene/mesh in destroy/mount. Drag
and wheel work again.
- PolyDemo's vanilla code-snippet generator emitted a snippet that
imported createPolyScene only. Users reading the docs would copy
it and end up with an inert scene. Now imports + calls
createPolyControls(scene, { drag, wheel, animate }) reflecting
the toolbar toggles, so the snippet matches what the live demo
shows.
Adds targeted tests for previously-uncovered branches identified by the per-file v8 coverage report. Picks up: polycss createPolyControls (97.43→98.46% stmts / 86.31→89.89% br) - setPointerCapture throwing is non-fatal (line 144 catch) - releasePointerCapture throwing is non-fatal (line 176 catch) - animTick early-return when animate is false but a tick was queued (lines 200-204; defensive) react PolyControls (92.39→98.24% stmts / 50.98→65.45% br) - invert as a number multiplies sensitivity in default direction - animate tick re-queues without mutating state when paused by drag (the else-branch that updates lastTime) - animate tick is a no-op when animate prop has flipped to false vue PolyControls (85.71→96.55% stmts / 61.4→71.62% br) - drag prop watcher: false → true reattaches (cursor flips to grab) - drag prop watcher: true → false detaches (cursor cleared) - wheel prop watcher: false → true attaches handler (zoom changes) - wheel prop watcher: true → false detaches handler - animate prop watcher: false → object starts rAF loop - animate prop watcher: object → false stops rAF loop - inject-fallback warning when used outside <PolyCamera> Test counts: polycss +3 (181 total), react +3 (162), vue +7 (153).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
createPolyControls/<poly-controls>/<PolyControls>as a Three.js OrbitControls-style additive layer for pointer drag, wheel zoom, anddt-clamped autorotate. The renderer (createPolyScene/<poly-scene>) becomes pure — input lives in the controls layer.interactiveflag fromPolySceneOptions(intentional break — pre-deploy). Existing call sites across the website and examples are migrated: landing hero, gallery viewer, DebugWorkbench, PolyDemo (model + generator modes),examples/headless/slicer.html.contain: stricton polygon leaves withtransform-style: preserve-3ddropped from leaves — the 3D context lives on the wrappers).dt-clamped at 50 ms / 60 Hz reference, sospeed: 0.3is ~18 deg/sec on every refresh rate. The legacy<PolyCamera animate>was frame-rate-naive; that path stays for back-compat but is documented as such, with<PolyControls>recommended./components/poly-controls/pluscreatePolyControlssection in/api/headless/.poly-sceneandpoly-camerapages updated to remove dead props and point at PolyControls.API surface
Breaking changes (pre-deploy, intentional)
PolySceneOptions.interactiveremoved. Migrate tocreatePolyControls/<poly-controls>/<PolyControls>.<poly-scene interactive>attribute removed.SceneHandlegainshost: HTMLElementandgetOptions(): Readonly<PolySceneOptions>(additive).<PolyCamera>'s owninteractiveandanimateprops remain for back-compat but are documented as such — using both PolyCamera's flags AND<PolyControls>together double-attaches handlers.Files changed
37 files, +3155 / -243.
Test plan
core: 324polycss: 181 (incl. 38 createPolyControls + 17 PolyControlsElement + 4 SceneHandle additions)react: 162 (incl. 16 PolyControls)vue: 153 (incl. 22 PolyControls)pnpm buildclean acrosscore,polycss,react,vue, andwebsite./(landing hero) — drag mutates transform, autorotate animates between frames/gallery/— viewer scene mounts, cursor = grab, drag mutates transform, animate toggle starts the loop/debug/meshes(vanilla renderer) — autorotate toggle + drag both flow through createPolyControls/debug/slice-test— correctly 404s (deleted upstream)