Skip to content

feat(controls): split camera input into a layered <PolyControls>#11

Merged
apresmoi merged 20 commits into
mainfrom
css-perf-wins
May 6, 2026
Merged

feat(controls): split camera input into a layered <PolyControls>#11
apresmoi merged 20 commits into
mainfrom
css-perf-wins

Conversation

@apresmoi
Copy link
Copy Markdown
Collaborator

@apresmoi apresmoi commented May 6, 2026

Summary

  • Introduces createPolyControls / <poly-controls> / <PolyControls> as a Three.js OrbitControls-style additive layer for pointer drag, wheel zoom, and dt-clamped autorotate. The renderer (createPolyScene / <poly-scene>) becomes pure — input lives in the controls layer.
  • Removes the interactive flag from PolySceneOptions (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.
  • Carries two CSS-only perf wins that survived the main merge: H5 (Lambert hoist into a per-poly registered property) and H5a (contain: strict on polygon leaves with transform-style: preserve-3d dropped from leaves — the 3D context lives on the wrappers).
  • Animate is dt-clamped at 50 ms / 60 Hz reference, so speed: 0.3 is ~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.
  • Drag tracks the pointer (right-drag pulls the front of the scene right); fixed during code review.
  • New docs page /components/poly-controls/ plus createPolyControls section in /api/headless/. poly-scene and poly-camera pages updated to remove dead props and point at PolyControls.

API surface

// vanilla
const scene = createPolyScene(host, { rotX, rotY, zoom, ...lighting });
const controls = createPolyControls(scene, {
  drag: true, wheel: true,
  invert: false, zoom: { min: 0.1, max: 10 },
  animate: { speed: 0.3, axis: 'y', pauseOnInteraction: true },
});
controls.update({ animate: false });
controls.start() / .stop() / .destroy();
<!-- custom element -->
<poly-scene rot-x='65' rot-y='45'>
  <poly-controls drag wheel animate-speed='0.3'></poly-controls>
  <poly-mesh src='/cottage.glb'></poly-mesh>
</poly-scene>
// React / Vue mirror exactly
<PolyCamera rotX={65} rotY={45}>
  <PolyScene>
    <PolyControls drag wheel animate={{ speed: 0.3 }} />
    <PolyMesh src='/cottage.glb' />
  </PolyScene>
</PolyCamera>

Breaking changes (pre-deploy, intentional)

  • PolySceneOptions.interactive removed. Migrate to createPolyControls / <poly-controls> / <PolyControls>.
  • <poly-scene interactive> attribute removed.
  • SceneHandle gains host: HTMLElement and getOptions(): Readonly<PolySceneOptions> (additive).

<PolyCamera>'s own interactive and animate props 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

  • Vitest passes across all packages: 820 tests total
    • core: 324
    • polycss: 181 (incl. 38 createPolyControls + 17 PolyControlsElement + 4 SceneHandle additions)
    • react: 162 (incl. 16 PolyControls)
    • vue: 153 (incl. 22 PolyControls)
  • Per-file coverage on the new layer: createPolyControls 98.46% stmts / 89.89% br · React PolyControls 98.24% / 65.45% · Vue PolyControls 96.55% / 71.62% · PolyControlsElement 95.83% / 77.04%
  • pnpm build clean across core, polycss, react, vue, and website.
  • Headless verification with Playwright on the live dev server:
    • / (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)

apresmoi added 20 commits May 6, 2026 12:50
…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).
@apresmoi apresmoi merged commit a328a71 into main May 6, 2026
@apresmoi apresmoi deleted the css-perf-wins branch May 7, 2026 22:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant