feat: three.js parity sweep — API, shadows, lighting, transforms across all renderers#60
Merged
Conversation
mesh.position is now world units (matching camera.target's existing frame instead of the old raw-CSS-pixel inconsistency). camera.zoom is now "px per world unit" (Three.js OrthographicCamera.zoom semantic) instead of a raw CSS scale factor. Both conversions live at the public-API boundary; the renderer's internal tile-pixel frame and load-bearing hacks (seam bleed, edge repair, canonical primitives, matrix decimals) are unchanged. three-parity.html confirms the same numeric inputs now produce the same on-screen geometry in PolyCSS and Three.js. PolyDirectionalLightHelper sheds its own ad-hoc CSS conversion now that the boundary handles it.
The polycss vanilla camera now interprets `zoom` as "px per world unit" (Three.js convention) rather than a raw CSS scale, but the website presets, demos, and PolyDemo controls still carry CSS-scale-tuned values that ALSO drive the React renderer (kept on the old semantic until the parity refactor is mirrored there). Multiply by 50 at the boundary in VanillaScene + PolyDemo + the landing apple so the site remains visually equivalent to polycss.com while the rest of the parity work lands. Remove once @layoutit/polycss-react mirrors the change and presets are rescaled.
directionalLight.direction is now in the standard world frame (+X right, +Y forward, +Z up) — Three.js convention. The renderer's row/col axis swap stays internal; createPolyScene applies the swap once at each pass into the lighting pipeline (renderEntry, applyBakedSolidColor, emitSceneShadows, applyLightingVars for dynamic mode, applyMeshLight- VarOverride for per-mesh inverse-rotation), so the same numeric direction now lights the same face in PolyCSS and Three.js. The per-mesh dynamic override test now expects the swapped components on the wrapper (-1 on --ply instead of --plx after rotateY(-90) of an upward light), reflecting that --plx/--ply/--plz drive a CSS-frame Lambert dot.
shadePolygon now performs the diffuse multiply in linear-light space (sRGB → linear → multiply → linear → sRGB-encode) and divides the direct contribution by π. This matches Three.js's MeshLambertMaterial output for the same `intensity` value — sampled lit pixels on the parity bench agree at rgb(128, 16, 16) versus the previous rgb(220, 38, 38). `intensity = 1` no longer saturates a white surface; physical Lambert caps direct contribution at `1 / π ≈ 0.318` in linear. The existing paintDefaults test that expected intensity=1 → full white is updated to the new value; a second case at `intensity = π` documents how callers restore the legacy saturated behavior.
The previous physical-Lambert change only divided the direct contribution by π and added ambient straight. Three.js's MeshLambertMaterial applies the diffuse BRDF (`albedo / π`) to BOTH direct and indirect (ambient) irradiance: lit = (albedo / π) × (directIrradiance + indirectIrradiance) Re-grouped the shadePolygon math so the `× (1/π)` factor multiplies the full `(lightColor × lambert × I + ambientColor × I_amb)` sum. Bench sampling now lands on identical rgb(129, 18, 18) for the same input parameters on the lit faces of both PolyCSS and Three.js. The legacy "ambient × 1 → full white" test moves to `intensity = π` for the saturated-output case, mirroring the same documented compensation used in the direct-light case.
Three.js renders shadows only on receiving surfaces; PolyCSS used to paint both the virtual ground SVG AND each receiver, so a floor at z=0 got double-darkened (~75% effective opacity instead of the configured 50%). Gated emitSceneGroundShadow on `!hasReceiver` so it only fires as a fallback when no `receiveShadow` mesh exists. Bench three-parity.html now includes a 20×20 floor + receive/cast config. Pixel sampling under the same input numbers: cube face rgb(129, 18, 18) on both engines (exact) floor lit rgb(119, 125, 132) on both engines (exact) shadow rgb(59,62,66) PolyCSS vs rgb(68,71,76) Three.js The remaining shadow gap is the per-pixel algorithm: PolyCSS uses shadow.opacity as a uniform black overlay; Three.js removes only the direct-light contribution. Documented as the next step.
Replaced the "uniform black-overlay at shadow.opacity" model with the per-receiver ambient-only lit color (what each receiver would look like with no direct contribution). Computed once per receiver inside emitSceneReceiverShadows via shadePolygon(directScale=0). At opacity=1.0 the shadow pixel exactly matches Three.js's "lit by ambient only". Bench parity: cube rgb(129, 18, 18) both engines floor rgb(119, 125, 132) both engines shadow rgb(68, 71, 76) both engines (byte-exact) shadow.color (the legacy "shadow tint" option) is now ignored — the surface-aware ambient color replaces it. Bench shadow.opacity default moves to 1.0 to match Three.js out of the box; values < 1 fade the shadow back toward the lit pixel. All 2350 tests (core + polycss + react + vue) still pass.
Two parity bench fixes: - parseObj defaults `gridShift` to 1, adding +1 to every vertex after scaling so the bbox MIN lands at (1, 1, 1) instead of the origin. The PolyCSS cottage was floating 1 unit above the floor while the Three.js cottage sat on it. Override to gridShift: 0 to match. - Camera drag/wheel was calling update() which unconditionally re-baked every PolyCSS atlas. For textured meshes (cottage) this re-rasterizes every slice on every pointer tick, producing visible texture flicker. Gate the re-bake on `lightsChanged`; camera-only and position-only changes skip the rebake since viewing angle doesn't affect Lambert.
gridShift was a leftover from PolyCSS's tile-grid origin: every parsed
vertex got `+ gridShift` added after the `(v - vMin) * scale` step so
the bbox MIN landed at (1,1,1) instead of the world origin (parseObj /
parseGltf defaulted to 1; parseVox already defaulted to 0). The option
served no rendering requirement — anything internal to the engine works
the same with or without the offset — and it diverged from Three.js's
convention where loaded geometry sits at its natural origin. Anyone
who wants the old behaviour can pass `position: [n, n, n]` on the mesh
handle.
Dropped:
- The `gridShift?: number` field from ObjParseOptions / GltfParseOptions /
VoxParseOptions.
- The `gridShift` field on `PolyVoxelSource` (no internal consumers).
- All `+ gridShift` adds in the three parser projection paths.
- Every workbench/bench/preset/test that passed it.
- The two parser tests that asserted the gridShift=1 behaviour and the
parseGltf describe("gridShift") block.
All 2347 tests pass (core 941 / polycss 581 / vue 412 / react 413);
website + packages build clean.
…ow clip DirectionalLight.position in Three.js doubles as the shadow camera's world position. Setting it to the raw direction vector (~1 unit from origin) put the shadow camera's near plane inside the cottage, clipping the projected shadow. PolyCSS only uses direction so it didn't notice. Scale the user direction by LIGHT_DIST=100 before assigning to the Three.js light position so the shadow camera sits far enough from the scene to capture the full silhouette — same direction, parallel light behaviour, no more cottage shadow truncation in the top-down view.
Hardcoded LIGHT_DIST / SHADOW_R worked for the default scene but failed
on larger meshes (cottage with mtl, anything bigger than the cube) and
on grazing light angles. PolyCSS has no equivalent limit because it
CPU-projects shadows onto receivers analytically.
Replaced with `fitDirLightToScene()` that:
- Walks every mesh in the scene to compute the world bounding sphere,
- Places dirLight.position at (sphere.center + direction × radius × 4)
so the shadow camera sits far from the geometry (near plane clear),
- Sets shadow.camera.{left,right,top,bottom} = ±radius × 6 to cover
the worst-case grazing-light shadow extent,
- Sets shadow.camera.far = lightDist × 3 to cover the depth of the
scene from the light's POV.
Called on every update so cottage/cube/floor swaps and slider tweaks
keep the shadow camera correctly framed. Bench now mirrors PolyCSS's
"shadows just work" feel regardless of scene size.
textureTintFactors now returns linear-light BRDF factors matching Three.js MeshLambertMaterial: `tint = (lightLinear × directScale + ambLinear × ambI) / π`. Light + ambient colors are decoded from sRGB to linear before forming the factors. applyTextureTint switched from canvas `globalCompositeOperation = multiply` (a sRGB×sRGB blend) to a per-pixel pass that decodes each texture sample from sRGB to linear, multiplies by the tint, and re-encodes — the gamma-correct path. Runs at atlas build time, not per-frame, so cost is bounded. Bench cottage textures now match Three.js brightness instead of appearing oversaturated. Tests: - textureTintFactors expectations updated to the new linear/π values - the low-alpha edge-repair test now grabs the LAST putImageData call (the tint pass introduces an earlier one). All 2347 tests pass (core 941 / polycss 581 / vue 412 / react 413).
…bert The bench was passing `textureLighting: "static"`, which is not a valid value in PolyTextureLightingMode (`"baked" | "dynamic"`). The string flowed through createPolyScene's `?? "baked"` nullish coalesces unmodified (?? only catches null/undefined), so the rasteriser's `=== "baked"` gate at rasterise.ts:587 skipped applyTextureTint entirely. Textured `<s>` leaves painted with raw atlas pixels — no per-face Lambert variation — so the cottage roof's two slopes appeared the same brightness despite having distinct normals. Solid `<b>`/`<i>`/`<u>` leaves bake into inline `color` and aren't gated by textureLighting, so the cube's parity held — the bug only surfaced on the textured cottage. One-line bench fix; the engine works correctly. Could harden createPoly- Scene to whitelist textureLighting values (default unknowns to "baked") as a follow-up so future typos don't silently swallow texture lighting.
Default 'auto' caps the atlas at ~2048px / 16MB which shrinks the cottage's wall and roof slices uniformly, leaving them visibly pixelated next to Three.js's full-res textures. The bench prioritises parity over memory; real apps stick with 'auto'.
Baked stores lit colors in atlas pixels — byte-exact parity with Three.js, but every light change re-rasterises every atlas, producing visible texture flicker on the cottage. Dynamic evaluates Lambert in CSS calc() per-frame from --plx/--ply/--plz vars — zero flicker, slider drags are smooth. Brightness is currently approximate (CSS calc skips the /π BRDF normalization and can't do per-pixel sRGB↔linear on a sampled texture), so this trades exact parity for interactivity. The textureLighting setOptions call already forwards the chosen mode, and the rebakeAtlas branch is now gated on `S.lighting === "baked"`.
emitSceneReceiverShadows used to `continue` when the caster was the same mesh as the receiver, so a mesh tagged both castShadow and receiveShadow never shadowed itself. Three.js does self-shadow when both flags are on (cottage roof over its own wall is the canonical case), so removed the skip — the per-triangle 3D-clip against the receiver-face plane already drops caster tris that are coplanar with or behind the receiver face, so a face never shadows itself, only OTHER parts of the same mesh. Bench cube + cottage now opt into receiveShadow: true so the parity demo shows correct self-shadow. All 581 polycss tests still pass.
The receiver-plane half-space test used `dp >= 0` to mark caster vertices as "above". For a convex mesh whose adjacent faces share an edge (cube), the shared-edge vertices have `planeDist == 0` on the neighbour's plane. With `>= 0` they got pushed into the above-set, producing degenerate triangles (3 vertices at the shared edge) that projected onto the receiver as visible artefact bands. Same root cause for the tiny gap between the cube's base and its shadow on the floor: the cube bottom sits at z=0, the lifted shadow ground at z=0.05, and the bottom vertices' near-zero planeDist generated edge-aligned degenerate projections. Switched to a strict `dp > SELF_SHADOW_EPS` (1e-3 in world units) for both the bbox prefilter and the per-tri half-space clip. Caster vertices ON the receiver plane or below it no longer contribute — only vertices genuinely above (by more than the epsilon) do, so: - convex meshes (cube) self-shadow correctly = no-op - non-convex meshes (cottage) still self-shadow where one part is genuinely above another along the light direction - shadows that hug the receiver edge don't smear into the gap All 581 polycss tests still pass.
…solid Receiver shadow fill now branches on whether the receiver has any textured polygons: - Solid receivers (floor, cube): keep the ambient-only lit color at full opacity → byte-exact Three.js "no direct light" parity. - Textured receivers (cottage walls, etc.): paint shadow.color (default black) at `shadow.opacity × directIntensity / (directIntensity + ambIntensity)`. The opacity factor approximates Three.js's per-pixel darkening ratio so the texture stays visible underneath while being multiplicatively darkened, instead of getting repainted with a solid gray patch. Restores task #88's "darken, not repaint" behaviour that was lost when the receiver fill switched to ambient-only color for solid-receiver parity. Cottage self-shadow on its walls no longer reads as solid gray patches over the brick texture. 581 polycss tests still pass.
The textured-receiver shadow opacity was using a single global value based on the LIGHT'S intensity vs ambient — same value for every face on the mesh, regardless of how oblique each wall is to the light. That over-darkened oblique walls (cottage gable on a side wall): Three.js darkens those by less since lambert is smaller, so ambient contributes relatively more to the final pixel. Moved the opacity calculation into the per-group loop so each receiver face uses its own `Ldotn` (the dot product already computed for back-face culling): direct = intensity × max(n·L, 0) // per group effOp = shadow.opacity × direct / (direct + ambient) A face at full lambert (= 1) still gets the strongest darkening; oblique faces (low lambert) get proportionally lighter shadow, matching Three.js's per-pixel lit × ambient/(direct+ambient) shadow formula.
The per-group opacity for textured self-shadow was using `direct / (direct + ambient)` directly as the SVG opacity, but CSS opacity blending happens in sRGB-encoded space, not linear. Three.js does the equivalent math in linear: shadow_linear = lit_linear × ambient / (direct + ambient) Applying that ratio directly as a sRGB opacity over-darkens because sRGB encoding is non-linear in the dark-tone region. Approximated the sRGB equivalent of the linear ratio with a γ=2.4 gamma correction: ratio_sRGB ≈ ratio_linear^(1/2.4) opacity = 1 - ratio_sRGB For the typical bench scene (direct=0.667, ambient=0.3): - old formula: opacity 0.69 → shadow pixel = lit × 0.31 = ~37 - new formula: opacity 0.40 → shadow pixel = lit × 0.60 = ~71 (matches Three.js's expected ~71) Mean cottage luminance moves PolyCSS 42.99 → 43.35; large darker-than-Three.js pixels drop 204 → 176. Visually the gable self-shadow on the cottage wall is no longer noticeably darker than the equivalent Three.js shadow.
…wMap - SELF_SHADOW_EPS dropped from 1e-3 to 1e-6 (now just above float precision). The larger epsilon was excluding caster vertices very close to the receiver plane (cottage roof barely above wall, etc.) and producing a visible gap between the casting feature and the self-shadow it should drop. - bench passes `shadow.lift: 0` so the floor-cast shadow sits exactly on the floor plane instead of the default 0.05-unit lifted plane that created a thin visible offset between the cube/cottage base and the shadow start. - bench switches Three.js shadow map from BasicShadowMap to PCFShadowMap (1-tap, no soft kernel) and bumps mapSize to 8192 so shadow edges stay crisp without the BasicShadowMap stairsteps. Less pixelation for cleaner cross-engine comparison.
Cube is convex (no self-shadow). Cottage is textured (shadow nuances hidden by brick pattern). Added a 3D capital E built from 4 boxes (spine + top/mid/bot arms) — strongly concave with the arms shadowing the spine and each other under standard lighting. Lets us judge self-shadow correctness without texture interference, and works in both engines as a single mesh (PolyCSS combines all box polygons into one parse result; Three.js puts the 4 BoxGeometries in a Group with castShadow + receiveShadow on each). Dropdown options are now `cube` / `e` / `cottage`; default stays `cube`.
The receiver-shadow SVG was positioned 5 CSS pixels off the receiver surface along the face normal to avoid z-fighting with the underlying polygons. At DEFAULT_TILE=50, that's 0.1 world units — visible as a "gap" between the casting feature and the shadow it produces, because the projected shadow content lands on a plane offset from the actual receiver surface. 0.5 CSS px still wins the depth test on every browser the renderer targets but is sub-pixel at typical zoom, so shadows now hug the casting feature without the visible detachment the user reported on the E-mesh floor cast and self-shadow.
… the lighting writes
…t floor is intrinsic
…/z; +38% mean fps
…ick)" This reverts commit fbdf029.
…hadow (H11b)" This reverts commit 2187a1e.
# Conflicts: # packages/polycss/src/api/createPolyOrbitControls.ts
…H9b)" This reverts commit e9cf56c.
…atlas - buildTransform: emit rotateY(-rx) rotateX(-ry) rotateZ(-rz) and pivot at wrapper local origin (drop bbox-center pre/post translates + transform-origin override). Matches vanilla buildMeshTransform (#157). - Pass mesh rotation to prepareReceiverFacePlanes / prepareCasterPolyItems so face planes + caster vertices follow rotated wrappers. - Disable per-light raytrace occlusion in the baked atlas plan (lightOccludedPolyIndices = undefined). Three.js doesn't bake shadow into the diffuse atlas; the real shadow map darkens occluded geometry at render time. Matches vanilla createPolyScene.ts:1162. - borderShape / cornerShapeSolid / projectiveSolid: always emit entry.shadedColor in baked mode instead of inheriting the wrapper --polycss-paint default when shadedColor matches it. Matches vanilla formatInitialSolidPaintStyle (commit 0423777). - buildAtlasPages.applyTextureTint: multiply texture pixels in linear light (decode sRGB -> multiply per channel -> encode) instead of the canvas globalCompositeOperation=multiply which is sRGB x sRGB and noticeably darkens mid-tone textures (rock1 was ~3x too dark).
The gridShift removal (commit 7880492 'refactor(parser): remove gridShift — bbox MIN at origin, like Three.js') shifted parsed vertex frames by a constant offset, which produced tiny but deterministic differences in optimizer output and changed which axis a synthetic MagicaVoxel row spans in PolyCSS frame. Update the four stale literal counts so CI is green; no behaviour or invariant changes: - shark.glb lossy: 634 → 635 - opportunity.glb lossless: 1895 → 1892, lossy: 1667 → 1670 - chest.obj lossless: 258 → 259 - 80×1×1 vox row: assert range on xs (was ys) — MV +X now maps to CSS +X Render-cost + seam invariants (unclosedPairs, maxResidualGapPx) remain enforced.
- vue PolyMesh.setPolygons: also update polygonOverride after the updateStableTriangleDom fast path. Without it, any later reactive re-render (cameraTick, scene-context change) re-emits the <u> VNode from stale polygons.value and Vue patches the leaf style back to the pre-setPolygons transform — undoing the imperative write. - polycss createPolyScene.test castShadow block: every test now mounts a floor receiver before the caster, matching commit 1bd0ede ('drop virtual ground fallback for three.js receive-shadow parity'). Casters use backTriangle() so their normal opposes the default light. Transform assertions switched from translate3d to matrix3d to match the per-receiver-face SVG placement. - polycss shadowSvg.test: ground SVG now sits inside the .polycss-shadows wrapper (commit a5b106a). Tests look up the wrapper and opt into debugAttrs=true so data-poly-shadow-* attributes are present.
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
End-to-end three.js parity for PolyCSS. Aligns the public API (position, rotation, scale, zoom, lights) with Three.js conventions, rewrites the shadow system to a receiver-only model with per-receiver-face SVG projection, replaces the lighting pipeline with physical Lambert + linear-light BRDF, and mirrors every change into the React + Vue bindings. Adds a comprehensive bench infrastructure for cross-renderer parity and a multi-iteration perf research loop that landed several real wins.
196 commits, 159 files, +14691/-4008. Squash-merge.
What changed, by theme
1. Public API: three.js shapes at the boundary
+X right, +Y forward, +Z up); the renderer applies the world→CSS axis swap (world.y → CSS.x,world.x → CSS.y) and× BASE_TILEscale internally. Meshposition,camera.target, light direction, light helper position — all consume the Three.js frame at the public surface.THREE.OrthographicCamera.zoom—zoom=1≡ 1 world unit = 1 CSS pixel; the scene-rootscale(zoom / DEFAULT_TILE)absorbs the internal tile scale.mesh.position/mesh.rotation/mesh.scale. Parsers gained{ center: true }opt-in for callers that want bbox-centered geometry.R(n, θ)becomesR(M·n, −θ)in CSS frame — axis swap and angle sign flip.world X → CSS Y,world Y → CSS X,world Z → CSS Z. Mirrored into React + VuebuildTransformin this PR.worldDirectionToCssbefore any Lambert dot.(1,1,1)offset.2. Shadow system: receiver-only, per-receiver-face SVG
receiveShadow: true, no shadow paints. Matches Three.js'smesh.receiveShadowcontract.<svg>per receiver face placed viamatrix3d(u, v, n, O)so SVG-local(u, v)projects back onto the receiver plane. Replaces the legacy per-caster compound SVG..polycss-shadowswrapper mounted under the scene root, instead of scattered across mesh wrappers.castShadowtoggle correctly tears down receiver SVGs when no casters remain or whenreceiveShadowflips off.shadow.maxExtendpropagated through the receiver-face path.shadow.liftwired through scene options + zoom-scaled bench.3. Lighting: physical Lambert + linear-light BRDF
BRDF_Lambert/ π inshadePolygon— direct + ambient both wrap by 1/π. MatchesMeshLambertMaterialexactly.srgbByteToLinear → multiply → linearToSrgbByteinstead of canvasglobalCompositeOperation = "multiply"(which is sRGB × sRGB and noticeably darkens mid-tones). React + Vue copies mirrored in this PR; rock1 went from ~3× too dark to byte-identical with vanilla.MeshLambertMaterialinstyles.tscascade.--plx/ly/lzon the wrapper inverse-rotated into the mesh local frame.entry.shadedColor— the=== solidPaintDefaults.paintColorshortcut produced silently-wrong inherited colors whenshadedColorwas undefined ("first render looks wrong, fixes after any light nudge"). React + Vue mirrored.castShadowflipping a voxel mesh swaps it back to the polygon renderer so shadows generate.4. Wrapper-rotation refactor
position/rotation/scaleall pivot at the wrapper's local origin (transform-origin: 0 0 0on.polycss-mesh).[rx, ry, rz]→ CSSrotateY(−rx) rotateX(−ry) rotateZ(−rz).parseGltf+parseObjaccept{ center }for callers that want centroid-pivot (bbox-min-at-origin by default).5. Cross-renderer parity (React + Vue mirror)
Per
AGENTS.md"Renderer-owned browser glue" — the canvas atlas pipeline, browser-feature detection, direct voxel renderer, and.polycss-scene/.polycss-camerabase styles exist as independent copies across the three renderers. This PR makes them consistent:lightOccludedPolyIndicesself-shadow path.buildBasisHints+data-poly-indexparity.worldDirectionalLightToCssapplied tobakedDirectionalbefore atlas plan.borderShapestrategy active in dynamic lighting.voxelSourceprop exposed on<PolyMesh>for the direct voxel fast path.cast-shadow+receive-shadowattributes on<poly-mesh>custom element.objOptionsattributes exposed on<poly-mesh>.zoom,rotX,rotY).optimizeMeshPolygonsin PolyMesh +mergeprop.worldDirectionToCssapplied to shadow light.filterAtlasPlanscornerShape support.prepareReceiverFacePlanes/prepareCasterPolyItems.=== solidPaintDefaults.paintColorshortcut on baked solid leaves (this PR).6. Performance research loop
Established
bench/notes/SHADOW_PERF_LOG.md+bench/scripts/shadow-regression.mjs(teapot-self, teapot-floor, castle-floor, crate-floor × 3 azimuths) and athree-parity-shots.mjsscript for visual regression vs three.js.Iterations explored (kept on branches for traceability):
d=strings — flat, discarded.d=memoization (re-tested post-H9b) — flat, discarded.--plx/ly/lz+--clx/cly/clzCSS vars to 0.01 → +38% mean fps on worst-case teapot-self drag. Mirrored to React + Vue.End result: 7-10× fps on worst-case teapot-self drag vs the branch baseline.
7. Code-organisation refactors
createPolyScene.tssplit into focused modules:types.ts+equality.tstransforms.ts(build mesh + scene transforms, axis swap, zoom)lightingVars.tsshadowSvg.ts(ShadowSvgState + ground SVG primitives)shadowGeometry.ts(groupReceiverFaceGroups, expandConvexHullOutward, worldCssForMesh)shadowCache.ts+emitGroundShadow.tssceneContext.ts+ MeshEntry/ReceiverFacePlane/CasterPolyItem typesreceiverShadow.ts(~625 LOC pulled out of createPolyScene)8. Bench infrastructure
parity-quad.html— 4-up live view (vanilla / react / vue / html-CE iframes) withpostMessageslider sync across panes.shadow-oracle.html— per-pixel shadow diff debugger with polygon attribution.three-paritybench — side-by-side vsthree.jsreference with cast-shadow, floor, autoCenter, light-color knobs.perf-*.htmlpages drive full state from URL params + postMessage; parity-quad iframes them.screenToWorldOnSphere.Test plan
pnpm test— 2607 / 2607 pass (core 1024, fonts 45, polycss 706, react 416, vue 416)pnpm build:packages— DTS + ESM + CJS green for all 4 published packagesparity-quad.html?mesh=rock1&mode=baked— vanilla, react, vue, html-CE all produce identical RGB at every atlas sample point