feat: <poly-iframe> element + /tv showcase#62
Merged
Conversation
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.
7 tasks
apresmoi
added a commit
that referenced
this pull request
Jun 5, 2026
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
New native custom element across all three renderers for placing live iframes in 3D space, plus a 5-TV showcase at
/tvthat 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 somatrix3dtransforms compose naturally with surrounding meshes.<poly-mesh>:position/rotation/scalein world units & world-axis order,width/heightin world units, iframe centered at the wrapper's local origin so rotation/scale pivot at the visible center.src,allow,sandbox,loading,referrerPolicy,title) forwarded straight to the underlying<iframe>.AGENTS.mdnaming 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 calledapplyCamera(), so runtime camera tweaks were no-ops.PolyShapeElement— adds anattributeChangedCallbackso transform attrs (position / scale / rotation) propagate after mount, plus forwardscast-shadow/receive-shadow/exclude-from-auto-centertoscene.add(mirrors<poly-mesh>). Previously primitive shapes ignored attribute changes entirely after their first mount.PolyPlaneElement— newoffsetattribute defaulting to0so<poly-plane>is centered at its element-local origin. The underlyingplanePolygonshelper defaulted tosize * 2because it was authored as a transform-control drag handle; that meant the plane was never usable as a ground without manually offsetting it back./tvdemo?model=<id>query param withpopstatesync — every TV is linkable.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-screenvideoSrcandliftoverrides — used by the Retro Stack to play a different YouTube embed on each CRT and recess them slightly into the bezels.rotX/rotY/zoomhand-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./gallery,/builder,/wordart. Header nav gets a new "TV" link next to "WordArt".Test plan
pnpm test— 1,612 tests across 4 packages, all greenpnpm 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