Skip to content

feat: <poly-iframe> element + /tv showcase#62

Merged
apresmoi merged 14 commits into
mainfrom
feat/poly-iframe
Jun 4, 2026
Merged

feat: <poly-iframe> element + /tv showcase#62
apresmoi merged 14 commits into
mainfrom
feat/poly-iframe

Conversation

@apresmoi
Copy link
Copy Markdown
Collaborator

@apresmoi apresmoi commented Jun 4, 2026

Summary

New native custom element across all three renderers for placing live iframes in 3D space, plus a 5-TV showcase at /tv that drops a YouTube video on every CRT.

<poly-iframe> / <PolyIframe> (React + Vue)

A flat textured "quad" whose texture is a live document instead of an atlas slice. Mounts an <iframe> inside the scene's preserve-3d context so matrix3d transforms compose naturally with surrounding meshes.

  • Convention matches post-parity <poly-mesh>: position / rotation / scale in world units & world-axis order, width / height in world units, iframe centered at the wrapper's local origin so rotation/scale pivot at the visible center.
  • Iframe attrs (src, allow, sandbox, loading, referrerPolicy, title) forwarded straight to the underlying <iframe>.
  • 29 new tests pinned across the three packages (vanilla +14, React +8, Vue +7).
  • AGENTS.md naming section updated.

Element-runtime fixes shipped alongside

  • PolyPerspectiveCameraElement + PolyOrthographicCameraElement — runtime attribute changes (rot-x, rot-y, zoom, target, distance) now mutate the camera handle in place and re-apply the scene transform. Previously they either orphaned the scene's pointer to the camera or updated the handle but never called applyCamera(), so runtime camera tweaks were no-ops.
  • PolyShapeElement — adds an attributeChangedCallback so transform attrs (position / scale / rotation) propagate after mount, plus forwards cast-shadow / receive-shadow / exclude-from-auto-center to scene.add (mirrors <poly-mesh>). Previously primitive shapes ignored attribute changes entirely after their first mount.
  • PolyPlaneElement — new offset attribute defaulting to 0 so <poly-plane> is centered at its element-local origin. The underlying planePolygons helper defaulted to size * 2 because it was authored as a transform-control drag handle; that meant the plane was never usable as a ground without manually offsetting it back.

/tv demo

  • Five TVs in a vertical-rail picker (hand-drawn SVG tiles, active state, hover, keyboard label).
  • ?model=<id> query param with popstate sync — every TV is linkable.
  • Each preset declares one or more screens[].polyIndices. The page derives each iframe's world position, rotation, width, and height from those polygons' vertices at load time (area-weighted average normal, bbox in the in-plane right/up basis, small lift along the normal to clear z-fighting). Per-screen videoSrc and lift overrides — used by the Retro Stack to play a different YouTube embed on each CRT and recess them slightly into the bezels.
  • Per-TV rotX / rotY / zoom hand-tuned so every model shows up front-on at a sensible scale on first paint.
  • <poly-plane size=\"200\" receive-shadow> ground in gallery's #4a505a, positioned at the TV's bbox-min Z so the floor sits flush with the legs. Cast-shadow on the TV mesh lands real shadows on the floor; receive-shadow on the TV gives self-shadowing on the cabinet.
  • Hides the docs search dock to match /gallery, /builder, /wordart. Header nav gets a new "TV" link next to "WordArt".

Test plan

  • pnpm test — 1,612 tests across 4 packages, all green
  • pnpm build — packages + website both succeed
  • /tv — every TV shows a YouTube embed on its screen face, front-on, with floor + self-shadow
  • /tv?model=<id> — query param loads the right TV; back/forward keeps the active tile in sync
  • Retro Stack — all five CRTs play different videos

apresmoi added 14 commits June 4, 2026 09:58
Adds a new public element across all three renderers for placing live
iframes in 3D space, plus a five-TV demo page.

- packages/polycss: PolyIframeElement (vanilla custom element). Mounts an
  iframe wrapper inside the parent <poly-scene>'s .polycss-scene so the
  CSS camera transform composes naturally with surrounding meshes. Same
  position/rotation/scale conventions as <poly-mesh> post-parity (world
  units, world-axis order, rotation conjugation rotateY(-rx) rotateX(-ry)
  rotateZ(-rz)). Width/height in world units; iframe centred at the
  wrapper's local origin so rotation/scale pivot at the visible centre.
- packages/react: <PolyIframe> mirror with the same props.
- packages/vue:   <PolyIframe> mirror with the same props.
- 14/8/7 tests added per package; transform math + attribute forwarding
  pinned. AGENTS.md updated with the new element + naming entry.

Bug fixes shipped alongside:
- PolyPerspectiveCameraElement / PolyOrthographicCameraElement runtime
  attribute updates now mutate the camera handle in place and re-apply
  the scene transform. Before, the elements either recreated the handle
  (orphaning the scene's pointer) or updated but never called
  applyCamera, so rot-x/rot-y/zoom changes were ignored at runtime.
- DocsHeader hides the floating search dock on /television (matches
  /gallery, /builder, /wordart).

/television demo:
- Vertical-rail TV picker (5 sets, hand-drawn SVG art per tile). ?tv=id
  query param + popstate sync so individual TVs are linkable.
- Per-TV polygon indices identify which face(s) are the screen; the page
  derives the iframe's world-unit position, rotation, width, and height
  from those polygons' vertices at load time (area-weighted normal, bbox
  in the in-plane right/up basis, small lift along the normal so the
  iframe doesn't z-fight with the screen polygon itself). Per-TV rotY +
  zoom remain hand-tuned because GLB authoring orientations differ.
- Retro Stack uses multiple screen entries — one <poly-iframe> per CRT
  is mounted dynamically and each is placed independently.
- Rename /television route + public assets to /tv (shorter, link to it
  from the docs header next to WordArt).
- Query param renamed to ?model=<id> (was ?tv=<id> — reads better
  alongside /tv).
- Brighter scene lighting (directional 4.5→6.5, ambient 0.55→0.9) plus
  a ground <poly-plane> below each TV with cast-shadow on the mesh, so
  the bottom of every TV sits visibly on the floor.

Element changes to support the floor:
- PolyShapeElement now forwards `cast-shadow` / `receive-shadow` to
  scene.add (mirrors <poly-mesh>) and `exclude-from-auto-center` so
  ground planes don't skew the scene's auto-center calculation.
- PolyShapeElement gains an attributeChangedCallback so transform attrs
  (position/scale/rotation/shadow) propagate after mount — the floor is
  re-positioned per TV from the script side once the mesh has loaded.
- PolyPlaneElement: new `offset` attribute, defaulting to 0 so the
  plane is centered at the element's local origin. The underlying
  planePolygons helper defaulted to size*2 because it was authored as
  a transform-control drag handle.

Camera-element runtime fix:
- PolyPerspectiveCameraElement / PolyOrthographicCameraElement
  attributeChangedCallback now correctly locates the scene as a
  descendant (`this.querySelector('poly-scene')`) rather than an
  ancestor. The previous walk-up version always found null and
  applyCamera was never called, so runtime rot-x/rot-y/zoom changes
  were no-ops in vanilla.

Tuning:
- TV mesh gets `auto-center` so its bbox center IS mesh-local (0,0,0).
- Floor's per-preset Z is derived from the mesh's minZ on load.
- Per-TV cam.rotY/zoom dialed in by hand for each set (pixel TV at
  rotY 245 / zoom 10 for a 3/4 view that doesn't crop).
- Retro stack screen 1: append poly 216 to the polyIndices array (user
  identified it as part of that CRT's bezel strip; was missing).
- Floor shadow attempt: receiver-shadow on the floor + scene shadow
  config wired up cleanly, but the projected shadow SVG lands at wrong
  scene coords for our auto-centered TV + excludeFromAutoCenter floor
  combination. Vanilla dropped the legacy ground-shadow fallback for
  three.js parity, so a caster with no receiver draws nothing. Leaving
  the floor as a non-receiver for now with a TODO; lighting + floor
  geometry still ship.
The receiver-shadow SVG sits at the floor plane plus `shadow.lift`
(default 0.05 world units = 2.5 CSS px). On a big 400-world-unit floor
viewed at a normal-angle camera (rotX 70), that razor-thin gap z-fought
with the floor itself and the shadow only showed from straight overhead.
A 1 world unit (50 CSS px) lift puts the shadow clearly in front at
every angle the camera reaches.
The first CRT's actual screen polygons are 212–216 — 211 is an adjacent
bezel face the iframe-placement code was averaging into the plane,
shifting the iframe off the screen. Restricting to 212–216 lands the
iframe on the visible glass.
placementFromPolygons now takes an optional liftOverride; the TvScreen
config exposes it as `lift`. The retro stack's CRT screens already
model the front glass surface, so a small negative lift (−0.6 world
units) seats the iframe inside the bezel instead of in front of it.

The default lift formula (0.2 + 0.01 * max(w,h)) still applies for
every other TV — those polygons mark the screen plane itself, so the
iframe sitting slightly in front of them reads as the picture surface.
The 'floating quad' I attributed to receive-shadow earlier turned out to
be the monitor.glb's back panel authored at an offset position — present
even without receive-shadow.

Self-shadows on textured receivers are computed correctly but the
texturedReceiver code reduces opacity to mimic three.js's 'darken to
ambient-only' (`effOp = opacity * (1 - (ambient/total)^(1/2.4))`).
With our bright light (dir 6.5, ambient 0.9) that pushed shadow alpha
to ~0.32, which read as nearly-invisible 'white-ish' on textured TVs.
Bumping the input opacity to 0.95 lands the effective alpha at ~0.55
and the self-shadows now read as actually dark.
…ead dark

- Lift 1 → 0.1 world units. The bigger value was detaching shadows
  visibly from their receivers; 0.1 (5 CSS px) still clears z-fighting
  but reads as flush with the floor / bezel.
- Ambient intensity 0.9 → 0.4. The texture-receiver code caps shadow
  alpha at `opacity * (1 - (ambient/total)^(1/2.4))` to mimic three.js's
  'darken to ambient-only' lighting model. With ambient=0.9 the cap
  pushed effective shadow alpha to ~0.32 — visibly faint / 'white-ish'
  on the TV's own faces. Lower ambient = darker possible shadows; 0.4
  hits the polycss default and lets self-shadows read as properly dark.
@apresmoi apresmoi changed the title feat: <poly-iframe> element + /television demo feat: <poly-iframe> element + /tv showcase Jun 4, 2026
@apresmoi apresmoi merged commit 67d029c into main Jun 4, 2026
1 check passed
apresmoi added a commit that referenced this pull request Jun 5, 2026
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