From f14d113b8c38d0f0dd24dfa42f758c32bf15627b Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Thu, 4 Jun 2026 02:15:00 +0200 Subject: [PATCH 1/3] fix(parity): builder placement, ground shadow Z, and gizmo wrapper position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-up bugs from the three.js parity sweep: 1. Builder zoom + placement. The builder still spoke pre-parity semantics: passed the unitless slider zoom directly to the camera (rendered the scene at 0.006× CSS scale), `placeMeshOnFloor` returned CSS-pixel positions in CSS-axis order, and `projectScreenToWorldGround` inverted with `1/zoom` instead of `BASE_TILE/zoom`. Mirrored gallery's `LEGACY_ZOOM_COMPAT` pattern, flipped placement to world units in world-axis order, and fixed the inverse scale. 2. React + Vue ground shadow lifted by the mesh. The per-mesh ground shadow SVG is rendered inside the `.polycss-mesh` wrapper, which translates by `position * BASE_TILE`. Pre-parity that was a no-op (position was near zero); post-parity the wrapper now lifts the shadow by the mesh's world Z, so it landed at the cube's vertical midpoint instead of the floor. Subtract `meshPosZ * BASE_TILE` from the projection target's Z so the wrapper translate cancels. 3. TransformControls wrapper placement + drag math. The gizmo wrapper was emitting `translate3d(position[0]px, position[1]px, position[2]px)` for a position that's now world units in world-axis order — so the gizmo sat near scene origin instead of on the mesh. Apply the world→CSS axis swap + ×BASE_TILE in all three renderers. Vanilla's axis-drag math also added a CSS-px `t` directly to a world-unit position with the wrong axis index; divide by `SCENE_TILE_SIZE` and write to `WORLD_AXIS_FOR_CSS[cssAxis]`. Vanilla rotation gizmo now uses the world axis the ring visually wraps, snapshots the start position, and re-translates each tick so the visible bbox center stays put (matches three.js's pivot-around- center feel without requiring callers to pre-center via `loadMesh({ center: true })`). `gizmoPosition` now applies the current rotation to `centerOffset` so the gizmo follows the spinning mesh. --- .../src/api/createTransformControls.ts | 129 +++++++++++++----- .../react/src/controls/TransformControls.tsx | 19 ++- packages/react/src/scene/PolyMesh.tsx | 16 ++- .../controls/PolyTransformControls.test.ts | 11 +- .../vue/src/controls/PolyTransformControls.ts | 16 ++- packages/vue/src/scene/PolyMesh.ts | 14 +- .../components/BuilderScene.tsx | 35 +++-- .../BuilderWorkbench/geometry/placement.ts | 32 ++--- .../geometry/screenToWorld.ts | 20 +-- 9 files changed, 196 insertions(+), 96 deletions(-) diff --git a/packages/polycss/src/api/createTransformControls.ts b/packages/polycss/src/api/createTransformControls.ts index 2ad302a2..0764230b 100644 --- a/packages/polycss/src/api/createTransformControls.ts +++ b/packages/polycss/src/api/createTransformControls.ts @@ -25,6 +25,7 @@ import { quatFromEulerXYZ, quatMultiply, ringQuadPolygons, + rotateVec3, } from "@layoutit/polycss-core"; import type { Polygon, Vec3 } from "@layoutit/polycss-core"; import type { PolyMeshHandle, PolySceneHandle } from "./createPolyScene"; @@ -145,14 +146,14 @@ function snap(value: number, step: number | null | undefined): number { return Math.round(value / step) * step; } -/** Compute the bbox center of a mesh's polygons in scene-CSS pixels. - * PolyCSS world→CSS axis remap: world-Y → CSS-x, world-X → CSS-y, - * world-Z → CSS-z. The result is the offset we add to the gizmo - * position so the gizmo overlays the visible center of the mesh. The - * mesh wrapper sets `transform-origin: var(--origin)` to the same bbox - * center, so its visible center is `position + bboxCenter` regardless - * of scale or rotation — no per-axis scale multiplication needed. */ -function bboxCenterCss(polygons: Polygon[]): Vec3 { +/** Compute the bbox center of a mesh's polygons in WORLD units, world-axis + * order (`+X right, +Y forward, +Z up`). Added to `target.transform.position` + * (also world units, world-axis) to place the gizmo at the mesh's visible + * centroid. PolyMesh's buildMeshTransform applies the world→CSS axis swap + + * ×BASE_TILE on the resulting position, so consumers stay in world units. + * Collapses to (0,0,0) when the mesh is already centered (e.g. when + * PolyMesh autoCenter or `loadMesh(..., { center: true })` was used). */ +function bboxCenterWorld(polygons: Polygon[]): Vec3 { if (polygons.length === 0) return [0, 0, 0]; let minX = Infinity, minY = Infinity, minZ = Infinity; let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; @@ -168,9 +169,9 @@ function bboxCenterCss(polygons: Polygon[]): Vec3 { } if (!Number.isFinite(minX)) return [0, 0, 0]; return [ - ((minY + maxY) / 2) * SCENE_TILE_SIZE, - ((minX + maxX) / 2) * SCENE_TILE_SIZE, - ((minZ + maxZ) / 2) * SCENE_TILE_SIZE, + (minX + maxX) / 2, + (minY + maxY) / 2, + (minZ + maxZ) / 2, ]; } @@ -527,7 +528,19 @@ export function createTransformControls( function gizmoPosition(): Vec3 { if (!target) return [0, 0, 0]; const t = target.transform.position ?? ([0, 0, 0] as Vec3); - return [t[0] + centerOffset[0], t[1] + centerOffset[1], t[2] + centerOffset[2]]; + const r = target.transform.rotation ?? ([0, 0, 0] as Vec3); + const s = typeof target.transform.scale === "number" + ? target.transform.scale + : (target.transform.scale?.[0] ?? 1); + // Visible mesh center under the post-parity wrapper transform + // `T · R · S · p`: at p = bboxCenter (mesh-local), visible center + // = T + scale * R(rotation) * bboxCenter. Apply current rotation so + // the gizmo follows the mesh while it spins — without this, the + // rotation handler's pivot compensation slides position around to + // keep the visible center fixed and the gizmo (placed at + // position + centerOffset) drifts off-axis. + const rc = rotateVec3(centerOffset, r[0], r[1], r[2]); + return [t[0] + s * rc[0], t[1] + s * rc[1], t[2] + s * rc[2]]; } function alphaFor(key: string): number { @@ -729,7 +742,7 @@ export function createTransformControls( teardownGizmos(); return; } - centerOffset = bboxCenterCss(t.polygons); + centerOffset = bboxCenterWorld(t.polygons); teardownGizmos(); buildGizmos(); } @@ -756,16 +769,21 @@ export function createTransformControls( // The dragStartPosition snapshot is captured in the pointerdown // handler below. if (!dragStartPosition) return; - const next: Vec3 = [ - dragStartPosition[0] + t * axisVec[0], - dragStartPosition[1] + t * axisVec[1], - dragStartPosition[2] + t * axisVec[2], - ]; + // Translate the drag from CSS-pixel CSS-axis space (where the screen + // probe was measured) to world-unit world-axis space (where + // `transform.position` lives post-parity). t is CSS px along the + // visible arrow direction; divide by SCENE_TILE_SIZE for world units. + // axisVec encodes the ±sign at index cssAxis; the corresponding + // world axis is WORLD_AXIS_FOR_CSS[cssAxis]. + const sign = axisVec[spec.cssAxis]; + const worldAxis = WORLD_AXIS_FOR_CSS[spec.cssAxis]; + const worldStep = (t * sign) / SCENE_TILE_SIZE; + const next = dragStartPosition.slice() as Vec3; + next[worldAxis] = dragStartPosition[worldAxis] + worldStep; target.setTransform({ position: next }); syncGizmoPositions(); opts.onObjectChange?.({ object: target, position: next }); opts.onChange?.(); - void spec; } let dragStartPosition: Vec3 | null = null; @@ -810,11 +828,16 @@ export function createTransformControls( translationSnap: opts.translationSnap ?? null, onPlaneDelta: (tA, tB, aVec, bVec) => { if (!target || !dragStartPosition) return; - const next: Vec3 = [ - dragStartPosition[0] + tA * aVec[0] + tB * bVec[0], - dragStartPosition[1] + tA * aVec[1] + tB * bVec[1], - dragStartPosition[2] + tA * aVec[2] + tB * bVec[2], - ]; + // Same CSS→world translation as applyAxisDelta. aVec/bVec + // encode the ±sign at index axisA/axisB (CSS-axis order); + // the world axes to translate along are WORLD_AXIS_FOR_CSS. + const signA = aVec[spec.axisA]; + const signB = bVec[spec.axisB]; + const worldAxisA = WORLD_AXIS_FOR_CSS[spec.axisA]; + const worldAxisB = WORLD_AXIS_FOR_CSS[spec.axisB]; + const next = dragStartPosition.slice() as Vec3; + next[worldAxisA] += (tA * signA) / SCENE_TILE_SIZE; + next[worldAxisB] += (tB * signB) / SCENE_TILE_SIZE; target.setTransform({ position: next }); syncGizmoPositions(); opts.onObjectChange?.({ object: target, position: next }); @@ -888,6 +911,10 @@ export function createTransformControls( draggingKey = spec.key; rebuildGizmoColors(); dragStartRotation = (target.transform.rotation ?? [0, 0, 0]).slice() as Vec3; + // Snapshot the position too so the per-tick pivot compensation + // anchors to the drag-start state (otherwise compounding rounding + // drift across moves slowly slides the mesh off-pivot). + dragStartPosition = (target.transform.position ?? [0, 0, 0]).slice() as Vec3; startRingDrag({ cssAxis: spec.cssAxis, wrapper: gm.handle.element, @@ -896,22 +923,49 @@ export function createTransformControls( startClientY: event.clientY, rotationSnap: opts.rotationSnap ?? null, onAngleDelta: (degrees) => { - if (!target || !dragStartRotation) return; - // World-frame quaternion compose. Rings stay at world axes - // visually (the gizmo isn't rotated with the mesh), so each - // ring drag rotates the mesh around the WORLD axis the ring - // points to — pre-multiply Qdelta · Qstart. Cumulative across - // repeated drags. X-axis sign stays empirically inverted to - // match user expectation for CW drag on the red ring. - const sign = spec.cssAxis === 0 ? -1 : 1; + if (!target || !dragStartRotation || !dragStartPosition) return; + // Each ring rotates the mesh around the WORLD axis the ring + // visually wraps. Ring quads are built with axis = + // WORLD_AXIS_FOR_CSS[cssAxis] (see `buildPolygonsFor`), so the + // rotation axis here must match — otherwise the mesh spins + // around a different axis than the ring the user grabbed. + const worldAxis = WORLD_AXIS_FOR_CSS[spec.cssAxis]; const axisVec: Vec3 = [0, 0, 0]; - axisVec[spec.cssAxis] = 1; - const deltaRad = (degrees * sign * Math.PI) / 180; + axisVec[worldAxis] = 1; + // Negate the screen-derived angle: `startRingDrag` returns a + // CCW-in-screen-space delta, but the world↔CSS axis swap is a + // reflection (det -1) so the same screen direction maps to a + // CW world rotation around the ring's axis. Flip once globally + // rather than per-axis empirically. + const deltaRad = (-degrees * Math.PI) / 180; const qStart = quatFromEulerXYZ(dragStartRotation); const qDelta = quatFromAxisAngle(axisVec, deltaRad); - const next = eulerXYZFromQuat(quatMultiply(qDelta, qStart)); - target.setTransform({ rotation: next }); - opts.onObjectChange?.({ object: target, rotation: next }); + const nextRot = eulerXYZFromQuat(quatMultiply(qDelta, qStart)); + // Pivot the mesh around its visible bbox center, not its + // local origin. Post-parity `` pivots at + // (0,0,0) by design — for callers that haven't pre-centered + // their geometry (most loaders default to `{ center: "min" }`), + // raw rotation would swing the mesh around its bbox-min corner. + // We compensate by re-translating so the world-space point + // `position + scale * R * bboxCenter` stays put across the drag. + const scaleVal = typeof target.transform.scale === "number" + ? target.transform.scale + : (target.transform.scale?.[0] ?? 1); + const startC = rotateVec3( + centerOffset, + dragStartRotation[0], + dragStartRotation[1], + dragStartRotation[2], + ); + const nextC = rotateVec3(centerOffset, nextRot[0], nextRot[1], nextRot[2]); + const nextPos: Vec3 = [ + dragStartPosition[0] + scaleVal * (startC[0] - nextC[0]), + dragStartPosition[1] + scaleVal * (startC[1] - nextC[1]), + dragStartPosition[2] + scaleVal * (startC[2] - nextC[2]), + ]; + target.setTransform({ rotation: nextRot, position: nextPos }); + syncGizmoPositions(); + opts.onObjectChange?.({ object: target, rotation: nextRot, position: nextPos }); opts.onChange?.(); }, onMouseDown: opts.onMouseDown, @@ -920,6 +974,7 @@ export function createTransformControls( if (!d) { draggingKey = null; dragStartRotation = null; + dragStartPosition = null; rebuildGizmoColors(); // Rebake the atlas now that the rotation is committed. The // mesh wrapper's CSS rotation has already been applied via diff --git a/packages/react/src/controls/TransformControls.tsx b/packages/react/src/controls/TransformControls.tsx index b1aa19e5..55630b3f 100644 --- a/packages/react/src/controls/TransformControls.tsx +++ b/packages/react/src/controls/TransformControls.tsx @@ -871,14 +871,19 @@ export function PolyTransformControls({ const position = target.getPosition() ?? ([0, 0, 0] as Vec3); const polygons = target.getPolygons(); const bboxCenter = gizmoCenterForMesh(polygons); - // Mesh wrapper pivots around `bboxCenter` via `transform-origin`, so the - // visible center stays at `position + bboxCenter` regardless of scale or - // rotation. The gizmo wrapper sits on the same point. When `autoCenter` is - // set on PolyMesh, bboxCenter collapses to (0,0,0) and this is a no-op. + // Post-parity: `position` is world units in world-axis order. The gizmo + // wrapper lives in scene-CSS pixel space, so apply the world→CSS axis + // swap + ×BASE_TILE before adding the (already CSS-pixel) bbox center. + // bboxCenter accounts for meshes whose vertices aren't centered at 0; + // when PolyMesh autoCenter is set, bboxCenter is (0,0,0) and this is a + // pure axis-swap. + const cssPosX = position[1] * SCENE_TILE_SIZE; + const cssPosY = position[0] * SCENE_TILE_SIZE; + const cssPosZ = position[2] * SCENE_TILE_SIZE; const wrapperPos: Vec3 = [ - position[0] + bboxCenter[0], - position[1] + bboxCenter[1], - position[2] + bboxCenter[2], + cssPosX + bboxCenter[0], + cssPosY + bboxCenter[1], + cssPosZ + bboxCenter[2], ]; const baseLength = gizmoLengthForMesh(polygons); const shaftLengthCss = baseLength * size; diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index e6c7bfb9..e76f0ef6 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -814,6 +814,18 @@ export const PolyMesh = forwardRef(function PolyM const userGroundLightDir = sceneDirectionalLight?.direction ?? ([0.4, -0.7, 0.59] as Vec3); const lightDir = worldDirectionToCss(userGroundLightDir); + + // Project shadows into the MESH WRAPPER's local frame so that the + // SVG, which is rendered as a child of `.polycss-mesh` and inherits + // its `translate3d(position * BASE_TILE)`, lands on the absolute + // scene ground (cssZ = bakedShadowGroundCssZ) — not lifted by the + // mesh's own world position. Vanilla's groundShadow.ts handles this + // by adding `worldPositionToCss(position)` to every vertex and + // mounting the SVG directly on the scene root; we keep the SVG in + // the wrapper and compensate by subtracting the wrapper's Z + // translation from the projection plane instead. + const meshPosZ = position?.[2] ?? 0; + const localGroundCssZ = bakedShadowGroundCssZ - meshPosZ * BASE_TILE; const shadowDedupDrop = findOverlappingPolygonDuplicates(polygons, { normalTolerance: 0.1, distanceTolerance: 0.5, @@ -847,7 +859,7 @@ export const PolyMesh = forwardRef(function PolyM if (cssVertex[1] < fpMinY) fpMinY = cssVertex[1]; if (cssVertex[0] > fpMaxX) fpMaxX = cssVertex[0]; if (cssVertex[1] > fpMaxY) fpMaxY = cssVertex[1]; - const p = projectCssVertexToGround(cssVertex, lightDir, bakedShadowGroundCssZ); + const p = projectCssVertexToGround(cssVertex, lightDir, localGroundCssZ); projected.push(p); if (p[0] < minX) minX = p[0]; if (p[1] < minY) minY = p[1]; @@ -912,7 +924,7 @@ export const PolyMesh = forwardRef(function PolyM transformOrigin: "0 0", pointerEvents: "none", willChange: "transform", - transform: `translate3d(${bx0.toFixed(3)}px,${by0.toFixed(3)}px,${bakedShadowGroundCssZ.toFixed(3)}px)`, + transform: `translate3d(${bx0.toFixed(3)}px,${by0.toFixed(3)}px,${localGroundCssZ.toFixed(3)}px)`, }} > { const wrapper = container.querySelector("[data-poly-transform-controls]") as HTMLElement; expect(wrapper).not.toBeNull(); expect(wrapper.getAttribute("data-poly-mode")).toBe("translate"); - // Wrapper plants itself on the mesh's visible center: - // position + bboxCenter * scale. TRIANGLE bbox center is (0.5, 0.5, 0) - // in world space → (25, 25, 0) CSS px at the standard tile (50). Scale 1 - // means it adds straight to [50, 60, 70]. - expect(wrapper.style.transform).toContain("translate3d(75px, 85px, 70px)"); + // Wrapper plants itself on the mesh's visible center in scene-CSS + // pixel space: worldPositionToCss(position) + bboxCenter. With + // position=[50,60,70] (world units), the CSS-pixel translation is + // [position[1]*50, position[0]*50, position[2]*50] = [3000,2500,3500]. + // TRIANGLE bbox center adds (25, 25, 0) in CSS px → [3025,2525,3500]. + expect(wrapper.style.transform).toContain("translate3d(3025px, 2525px, 3500px)"); const arrows = wrapper.querySelectorAll(".polycss-transform-arrow"); expect(arrows.length).toBe(6); expect(Array.from(arrows).map(axisKeyOf)).toEqual(["x", "-x", "y", "-y", "z", "-z"]); diff --git a/packages/vue/src/controls/PolyTransformControls.ts b/packages/vue/src/controls/PolyTransformControls.ts index b9e0c462..def38d3d 100644 --- a/packages/vue/src/controls/PolyTransformControls.ts +++ b/packages/vue/src/controls/PolyTransformControls.ts @@ -821,14 +821,16 @@ export const PolyTransformControls = defineComponent({ const t = target.value; if (!t) return null; const position = t.getPosition() ?? ([0, 0, 0] as Vec3); - // Mesh wrapper pivots around `bboxCenter` via `transform-origin`, so - // its visible bbox center stays at `position + bboxCenter` regardless - // of scale or rotation. The gizmo wrapper sits on the same point. When - // PolyMesh autoCenters its vertices, bboxCenter collapses to (0,0,0). + // Post-parity: `position` is world units in world-axis order. The gizmo + // wrapper lives in scene-CSS pixel space, so apply the world→CSS axis + // swap + ×SCENE_TILE_SIZE before adding the (already CSS-pixel) bbox + // center. bboxCenter accounts for meshes whose vertices aren't centered + // at 0; when PolyMesh autoCenter is set, bboxCenter is (0,0,0) and this + // is a pure axis-swap. const bboxCenter = gizmoCenterForMesh(t.getPolygons()); - const wx = position[0] + bboxCenter[0]; - const wy = position[1] + bboxCenter[1]; - const wz = position[2] + bboxCenter[2]; + const wx = position[1] * SCENE_TILE_SIZE + bboxCenter[0]; + const wy = position[0] * SCENE_TILE_SIZE + bboxCenter[1]; + const wz = position[2] * SCENE_TILE_SIZE + bboxCenter[2]; const wrapperStyle: Record = { transform: `translate3d(${wx}px, ${wy}px, ${wz}px)`, position: "absolute", diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index 4c80ccec..9822e18e 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -480,6 +480,16 @@ export const PolyMesh = defineComponent({ const userGroundLightDir = ctx?.directionalLight?.direction ?? ([0.4, -0.7, 0.59] as Vec3); const lightDir = worldDirectionToCss(userGroundLightDir); + + // Project shadows into the MESH WRAPPER's local frame so that the + // SVG, which is rendered as a child of `.polycss-mesh` and inherits + // its `translate3d(position * BASE_TILE)`, lands on the absolute + // scene ground (cssZ = groundCssZ) — not lifted by the mesh's own + // world position. Mirrors the React path; vanilla handles this by + // adding `worldPositionToCss(position)` to every vertex and mounting + // the SVG on the scene root. + const meshPosZ = props.position?.[2] ?? 0; + const localGroundCssZ = groundCssZ - meshPosZ * BASE_TILE; const dedupDrop = findOverlappingPolygonDuplicates(polygons.value, { normalTolerance: 0.1, distanceTolerance: 0.5, @@ -512,7 +522,7 @@ export const PolyMesh = defineComponent({ if (cssVertex[1] < fpMinY) fpMinY = cssVertex[1]; if (cssVertex[0] > fpMaxX) fpMaxX = cssVertex[0]; if (cssVertex[1] > fpMaxY) fpMaxY = cssVertex[1]; - const p = projectCssVertexToGround(cssVertex, lightDir, groundCssZ); + const p = projectCssVertexToGround(cssVertex, lightDir, localGroundCssZ); projected.push(p); if (p[0] < minX) minX = p[0]; if (p[1] < minY) minY = p[1]; @@ -575,7 +585,7 @@ export const PolyMesh = defineComponent({ transformOrigin: "0 0", pointerEvents: "none", willChange: "transform", - transform: `translate3d(${bx0.toFixed(3)}px,${by0.toFixed(3)}px,${groundCssZ.toFixed(3)}px)`, + transform: `translate3d(${bx0.toFixed(3)}px,${by0.toFixed(3)}px,${localGroundCssZ.toFixed(3)}px)`, } as CSSProperties, }, [ diff --git a/website/src/components/BuilderWorkbench/components/BuilderScene.tsx b/website/src/components/BuilderWorkbench/components/BuilderScene.tsx index e482cfb9..3aaf42f9 100644 --- a/website/src/components/BuilderWorkbench/components/BuilderScene.tsx +++ b/website/src/components/BuilderWorkbench/components/BuilderScene.tsx @@ -38,6 +38,13 @@ const GROUND_FILL_COLORS = { dark: "#05070b", } as const; +// The Dock's camera slider feeds a unitless 0.05–2.5 zoom value (legacy +// CSS-scale semantics shared with the gallery). Post-parity the camera +// expects px-per-world-unit, so we multiply the slider value by BASE_TILE +// (50) before handing it to the camera, and divide it back before pushing +// updates into sceneOptions. Same shape as gallery's LEGACY_ZOOM_COMPAT. +const LEGACY_ZOOM_COMPAT = 50; + export interface BuilderSceneProps { sceneOptions: SceneOptionsState; updateScene: (partial: Partial) => void; @@ -66,23 +73,27 @@ export interface BuilderSceneProps { selected: PlacedItem | null; } +// Post-parity `` is `T·R·S` pivoting at the local origin, +// so visible(v) = T + S·v (no rotation here — XY surfaces are axis-aligned). +// `position[2]` is the world-Z translate, so the visible bottom of a vertex +// at v.z = minZ sits at `position[2] + scale * minZ`. function selectedSurfaceWorldZ(item: PlacedItem): number { if (!item.rawPolygons) return item.elevation ?? 0; const bbox = meshBbox(item.rawPolygons); const scale = Math.max(item.fitScale * item.scale, 0.0001); - return item.position[2] / BASE_TILE + bbox.midZ * (1 - scale) + scale * bbox.minZ; + return item.position[2] + scale * bbox.minZ; } function itemTopSurfaceWorldZ(item: PlacedItem & { rawPolygons: Polygon[] }, polygons: Polygon[]): number { const bbox = meshBbox(polygons); const scale = Math.max(item.fitScale * item.scale, 0.0001); - return item.position[2] / BASE_TILE + bbox.midZ * (1 - scale) + scale * bbox.maxZ; + return item.position[2] + scale * bbox.maxZ; } function itemBaseSurfaceWorldZ(item: PlacedItem & { rawPolygons: Polygon[] }, polygons: Polygon[]): number { const bbox = meshBbox(polygons); const scale = Math.max(item.fitScale * item.scale, 0.0001); - return item.position[2] / BASE_TILE + bbox.midZ * (1 - scale) + scale * bbox.minZ; + return item.position[2] + scale * bbox.minZ; } interface PlacementSurface { @@ -246,15 +257,17 @@ function BuilderSelectedMeshInteractionControls({ setTimeout(() => window.removeEventListener("click", swallow, true), 0); }; - const projectAt = (clientX: number, clientY: number, planeWorldZ: number): [number, number] | null => - projectScreenToWorldGround({ + const projectAt = (clientX: number, clientY: number, planeWorldZ: number): [number, number] | null => { + const opts = stateRef.current.sceneOptions; + return projectScreenToWorldGround({ clientX, clientY, cameraEl, - sceneOptions: stateRef.current.sceneOptions, + sceneOptions: { ...opts, zoom: opts.zoom * LEGACY_ZOOM_COMPAT }, autoCenterOffset: store.getState().autoCenterOffset, planeWorldZ, }); + }; const armZClickSwallow = (pointerId: number): void => { const onUp = (event: PointerEvent): void => { @@ -405,11 +418,12 @@ function BuilderViewportToolControls({ const projectAt = (clientX: number, clientY: number, planeWorldZ = 0): [number, number] | null => { const state = stateRef.current; + const opts = state.sceneOptions; const hit = projectScreenToWorldGround({ clientX, clientY, cameraEl, - sceneOptions: state.sceneOptions, + sceneOptions: { ...opts, zoom: opts.zoom * LEGACY_ZOOM_COMPAT }, autoCenterOffset: store.getState().autoCenterOffset, planeWorldZ, }); @@ -605,10 +619,11 @@ export function BuilderScene({ const perspective = sceneOptions.dragMode === "fpv" ? FPV_PERSPECTIVE : sceneOptions.perspective; const Cam = perspective === false ? PolyOrthographicCamera : PolyPerspectiveCamera; const sceneKey = sceneOptions.meshResolution; + const cameraZoom = sceneOptions.zoom * LEGACY_ZOOM_COMPAT; const camProps = perspective === false - ? { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target } + ? { zoom: cameraZoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target } : { - zoom: sceneOptions.zoom, + zoom: cameraZoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target, @@ -617,7 +632,7 @@ export function BuilderScene({ const handleCameraChange = (cam: { rotX: number; rotY: number; zoom: number; target?: Vec3 }) => updateScene({ rotX: cam.rotX, rotY: cam.rotY, - zoom: cam.zoom, + zoom: cam.zoom / LEGACY_ZOOM_COMPAT, ...(cam.target ? { target: cam.target } : {}), }); const selectedWireframePolygons = useMemo(() => { diff --git a/website/src/components/BuilderWorkbench/geometry/placement.ts b/website/src/components/BuilderWorkbench/geometry/placement.ts index fb6ea5f2..c67f781f 100644 --- a/website/src/components/BuilderWorkbench/geometry/placement.ts +++ b/website/src/components/BuilderWorkbench/geometry/placement.ts @@ -1,16 +1,18 @@ import type { Vec3 } from "@layoutit/polycss-react"; -const BASE_TILE = 50; - /** - * Wrapper translate (CSS px) that lands the mesh's visible bbox center at - * `desiredWorld` (XY) and its lowest visible vertex at Z=0. + * Wrapper translate (world units, world-axis order) that lands the mesh's + * visible bbox center at `desiredWorld` (XY) and its lowest visible vertex + * at world z = surfaceZ. + * + * Post-parity, `` is `T·R·S` pivoting at the wrapper's + * local origin (0,0,0), so for any vertex `v`: + * visible(v) = T + S*v (rotation skipped here — caller applies it later) + * For v = bbox center: visible_center = T + S*(midX, midY, midZ) + * For v.z = minZ: visible_bottom_z = T.z + S*minZ * - * PolyMesh sets transform-origin to the bbox center, so for any vertex `v`: - * visible(v) = T + O + S*(v - O) = T + O*(1-S) + S*v - * At v = bbox center, the (1-S)*O term collapses to leaving the center at - * `T + O`. So to land the center at `desired*tile`, set `T = desired*tile - O`. - * For Z we want the BOTTOM (v = minZ) at 0, which gives the closed form below. + * Solve for T so the visible center lands at (desiredWorldX, desiredWorldY) + * and the visible bottom lands at surfaceZ. */ export function placeMeshOnFloor( desiredWorldX: number, @@ -23,14 +25,8 @@ export function placeMeshOnFloor( surfaceZ: number = 0, ): Vec3 { return [ - // CSS X = worldY · tile; origin X = midY · tile - (desiredWorldY - bbox.midY) * BASE_TILE, - // CSS Y = worldX · tile; origin Y = midX · tile - (desiredWorldX - bbox.midX) * BASE_TILE, - // CSS Z in scene-local coords maps directly to world Z (the cssPoints - // axis swap is identity for Z). To lift the mesh so its lowest vertex - // sits at world z = surfaceZ, ADD surfaceZ * tile to the CSS Z that - // would land the bottom at world z = 0. - -BASE_TILE * (bbox.midZ * (1 - scale) + scale * bbox.minZ) + BASE_TILE * surfaceZ, + desiredWorldX - scale * bbox.midX, + desiredWorldY - scale * bbox.midY, + surfaceZ - scale * bbox.minZ, ]; } diff --git a/website/src/components/BuilderWorkbench/geometry/screenToWorld.ts b/website/src/components/BuilderWorkbench/geometry/screenToWorld.ts index 520dec80..2ee9b6ec 100644 --- a/website/src/components/BuilderWorkbench/geometry/screenToWorld.ts +++ b/website/src/components/BuilderWorkbench/geometry/screenToWorld.ts @@ -34,9 +34,8 @@ * worldY = cssX / BASE_TILE */ -import type { SceneOptionsState } from "../types"; - -const BASE_TILE = 50; +import { BASE_TILE } from "@layoutit/polycss-react"; +import type { SceneOptionsState } from "../../types"; /** 3D vector [x, y, z]. */ type V3 = [number, number, number]; @@ -47,10 +46,15 @@ function deg2rad(d: number): number { /** * Apply a single row of the inverse transform: - * translate(+cssTarget) ∘ rotateZ(-rotY) ∘ rotateX(-rotX) ∘ scale(1/zoom) + * translate(+cssTarget) ∘ rotateZ(-rotY) ∘ rotateX(-rotX) ∘ scale(BASE_TILE/zoom) + * + * Post-parity, `zoom` is "px per world unit" (Three.js OrthographicCamera + * shape). The scene-root CSS scale is `zoom / BASE_TILE` (renderer geometry + * already lives at × BASE_TILE CSS px), so the inverse scale is + * `BASE_TILE / zoom`. * - * We apply the steps in order (innermost first in M^-1 = T * RZ * RX * S): - * 1. scale(1/zoom) + * Steps in order (innermost first in M^-1 = T * RZ * RX * S): + * 1. scale(BASE_TILE/zoom) * 2. rotateX(-rotX) — tilt back * 3. rotateZ(-rotY) — rotate back (CSS rotate() is actually rotateZ) * 4. translate(+cssX, +cssY, +cssZ) @@ -66,8 +70,8 @@ function applyInverseTransform( ): V3 { let [x, y, z] = p; - // 1. scale(1/zoom) - const inv = 1 / zoom; + // 1. inverse of scale(zoom / BASE_TILE) + const inv = BASE_TILE / zoom; x *= inv; y *= inv; z *= inv; From f54d76b7269a6b7b139acd1d8e371d43d1a57a27 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Thu, 4 Jun 2026 02:20:53 +0200 Subject: [PATCH 2/3] fix(website): wordart zoom scale + doc PolyDemo lighting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WordArt passed the legacy unitless zoom slider value (range ~0.001–1.2) directly to the camera, which post-parity is px-per-world-unit — scenes rendered tiny. Multiply by BASE_TILE at the camera boundary so the existing slider range produces the same on-screen scale as before. PolyDemo was writing `light-direction` / `light-ambient` / `light-color` attributes on ``, but the element reads `directional-direction` / `ambient-intensity` / `directional-color` (etc.) — the demo's lighting config was silently ignored on every docs page, falling back to the renderer default intensity 1, which is very dim under the post-parity physical-Lambert (/π) shading. Rename the attributes and bump the default when callers don't override (4.5 directional, 0.55 ambient — same shape as Gallery DEFAULT_SCENE). Generator-mode path also splits `state.light` into `directionalLight` + `ambientLight` for the imperative API. --- website/src/components/PolyDemo.astro | 49 +++++++++++++++---- .../WordArtWorkbench/WordArtWorkbench.tsx | 8 ++- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/website/src/components/PolyDemo.astro b/website/src/components/PolyDemo.astro index 95bdeb0b..dd21c568 100644 --- a/website/src/components/PolyDemo.astro +++ b/website/src/components/PolyDemo.astro @@ -341,12 +341,23 @@ const { id, model, mtl, generator, generatorParams, controls, defaults, showStat } catch {} // State — defaults merged with user overrides. - // `light` is a partial DirectionalLight ({ direction?, color?, ambient?, - // ambientColor? }) — `undefined` falls through to the renderer's - // default (ambient 0.35, key direction [0.4,-0.7,0.59], white). Demos - // bump ambient to brighten dim assets like trees / dark GLBs. + // `light` is a partial config ({ direction?, color?, intensity?, + // ambient?, ambientColor? }) — `ambient` is shorthand for ambient + // intensity, `intensity` is the directional intensity. Post-parity + // the renderer uses physical Lambert (/π), which makes intensity:1 + // look very dim — match the gallery's defaults (4.5 / 0.55) so demos + // look bright by default. Per-demo overrides still win. const rendererDefaultPerspective = 32000; const hasPerspectiveControl = controlList.includes("perspective"); + const POST_PARITY_LIGHT_DEFAULT = { + intensity: 4.5, + ambient: 0.55, + }; + const lightDefault = Object.assign( + { direction: [0.4, -0.7, 0.59], color: "#ffffff", ambientColor: "#ffffff" }, + POST_PARITY_LIGHT_DEFAULT, + defaults.light ?? {}, + ); const state: Record = { zoom: defaults.zoom ?? 1, rotX: defaults.rotX ?? 65, @@ -354,7 +365,7 @@ const { id, model, mtl, generator, generatorParams, controls, defaults, showStat perspective: defaults.perspective ?? (hasPerspectiveControl ? undefined : false), interactive: defaults.interactive ?? false, animate: defaults.animate ?? false, - light: defaults.light, + light: lightDefault, // Generator-specific state (sphere) subdivisions: generatorParams.subdivisions ?? defaults.subdivisions ?? 3, radius: generatorParams.radius ?? defaults.radius ?? 10, @@ -403,10 +414,15 @@ const { id, model, mtl, generator, generatorParams, controls, defaults, showStat // merge is automatic-only on main → no attribute to forward. // interactive is owned by (appended below in init). if (state.light) { - if (state.light.direction) attrs["light-direction"] = state.light.direction.join(","); - if (state.light.color) attrs["light-color"] = state.light.color; - if (state.light.ambient !== undefined) attrs["light-ambient"] = String(state.light.ambient); - if (state.light.ambientColor) attrs["light-ambient-color"] = state.light.ambientColor; + // `` reads directional-* / ambient-* (see + // PolySceneElement._readDirectionalLight) — the old `light-*` + // names were silently ignored, which is why every demo rendered + // at the renderer default intensity of 1 (very dim post-parity). + if (state.light.direction) attrs["directional-direction"] = state.light.direction.join(","); + if (state.light.color) attrs["directional-color"] = state.light.color; + if (state.light.intensity !== undefined) attrs["directional-intensity"] = String(state.light.intensity); + if (state.light.ambient !== undefined) attrs["ambient-intensity"] = String(state.light.ambient); + if (state.light.ambientColor) attrs["ambient-color"] = state.light.ambientColor; } return attrs; } @@ -558,7 +574,20 @@ const { id, model, mtl, generator, generatorParams, controls, defaults, showStat ); const sceneOptions: Record = { camera, autoCenter: true }; - if (state.light) sceneOptions.directionalLight = state.light; + if (state.light) { + // Split the demo's `light` config back into the two scene fields + // the imperative API takes. `state.light.ambient` is shorthand + // for ambient *intensity*. + const dir: Record = {}; + if (state.light.direction) dir.direction = state.light.direction; + if (state.light.color) dir.color = state.light.color; + if (state.light.intensity !== undefined) dir.intensity = state.light.intensity; + if (Object.keys(dir).length) sceneOptions.directionalLight = dir; + const amb: Record = {}; + if (state.light.ambient !== undefined) amb.intensity = state.light.ambient; + if (state.light.ambientColor) amb.color = state.light.ambientColor; + if (Object.keys(amb).length) sceneOptions.ambientLight = amb; + } sceneHandle = createPolyScene(sceneEl as HTMLElement, sceneOptions); // Vanilla controls layer — drag + wheel + animate. Updated by diff --git a/website/src/components/WordArtWorkbench/WordArtWorkbench.tsx b/website/src/components/WordArtWorkbench/WordArtWorkbench.tsx index 11727fd6..e8c2913b 100644 --- a/website/src/components/WordArtWorkbench/WordArtWorkbench.tsx +++ b/website/src/components/WordArtWorkbench/WordArtWorkbench.tsx @@ -738,7 +738,13 @@ function Stage({ polygons, preview, onFrameReady, scaleXFrac, scaleYFrac, zoomSc setZoomScale((z) => Math.max(0.1, Math.min(6, z * factor))); }; - const zoom = fitZoom(polygons, stage.w, stage.h, scaleXFrac, scaleYFrac) * zoomScale; + // Post-parity camera `zoom` is px-per-world-unit; the renderer divides + // by BASE_TILE for the scene-root CSS scale. `fitZoom` + `zoomScale` + // both produce the legacy unitless CSS-scale value (range 0.01–0.2 * + // 0.1–6 = roughly 0.001–1.2), so multiply by BASE_TILE to land back at + // the same on-screen scale. Same shape as gallery's LEGACY_ZOOM_COMPAT + // and the builder's recently-fixed zoom path. + const zoom = fitZoom(polygons, stage.w, stage.h, scaleXFrac, scaleYFrac) * zoomScale * BASE_TILE; const Cam = perspective ? PolyPerspectiveCamera : PolyOrthographicCamera; return ( From 80949447aaa7efc4972af1bb4e847a609b61eed1 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Thu, 4 Jun 2026 02:25:39 +0200 Subject: [PATCH 3/3] test(gizmo): update assertions to post-parity world-unit semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six test cases were pinned to the pre-parity drag math and bbox-center formula: - Vanilla `applyAxisDelta` now divides the CSS-px `t` by SCENE_TILE_SIZE and writes to WORLD_AXIS_FOR_CSS[cssAxis] (world axis), so dragging the X arrow moves `position[1]` (world Y) by t/50 — not `position[0]` by t CSS px. Three position-assertion tests updated. - Vanilla rotation: the X ring is built around WORLD_AXIS_FOR_CSS[0] = world Y, so the test now expects `rotation[1]` (not [0]) to be the changed component, with X and Z magnitudes near zero. Renamed from '(X-axis inverted)' to '(around world Y)' to reflect the new behavior. - React TransformControls wrapper now applies worldPositionToCss before adding the CSS-pixel bbox center. Two wrapper-transform assertions updated for the new pixel-space output. --- .../src/api/createTransformControls.test.ts | 68 ++++++++++--------- .../src/controls/TransformControls.test.tsx | 17 +++-- 2 files changed, 46 insertions(+), 39 deletions(-) diff --git a/packages/polycss/src/api/createTransformControls.test.ts b/packages/polycss/src/api/createTransformControls.test.ts index 889ea312..cdff1d3d 100644 --- a/packages/polycss/src/api/createTransformControls.test.ts +++ b/packages/polycss/src/api/createTransformControls.test.ts @@ -243,19 +243,18 @@ describe("createTransformControls", () => { // then dispatch pointerdown on the host at (0, 0). triggerPointerDownOnGizmoEl(host, ".polycss-transform-arrow--x", 0, 0); - // cameraScale=2, X axis is cssAxis=0 (maps to translate3d[0]=x). - // probeEl will get translate3d(shaftLength, 0, 0) → screen offset (shaftLength*2, 0). - // screenAxisX = (shaftLength*2) / shaftLength = 2, screenAxisY = 0. - // screenAxisLenSq = 4. - // Pointer move to (10, 0): t = (10*2 + 0*0) / 4 = 5. - // newPos = [100+5*1, 200+5*0, 0+5*0] = [105, 200, 0]. + // cameraScale=2, X arrow is cssAxis=0 → probe at translate3d(shaft,0,0). + // Screen probe ratio 2, dot product with pointer (10,0) gives t=5 CSS px. + // Post-parity drag math: t/SCENE_TILE_SIZE = 0.1 world units, applied + // to world axis WORLD_AXIS_FOR_CSS[0] = 1 (world Y). So position[1] + // moves 0.1 from 200 → 200.1; world X and Z unchanged. window.dispatchEvent( new PointerEvent("pointermove", { clientX: 10, clientY: 0, pointerId: 1 }), ); expect(onObjectChange).toHaveBeenCalled(); const evt = onObjectChange.mock.calls[0][0]; - expect(evt.position).toEqual([105, 200, 0]); + expect(evt.position).toEqual([100, 200.1, 0]); expect(evt.object).toBe(mesh); window.dispatchEvent(new PointerEvent("pointerup", { clientX: 10, clientY: 0, pointerId: 1 })); @@ -272,22 +271,22 @@ describe("createTransformControls", () => { triggerPointerDownOnGizmoEl(host, ".polycss-transform-arrow--y", 0, 0); - // Y axis is cssAxis=1 → translate3d(0, shaftLength, 0) → screen (0, shaftLength*2). - // screenAxisX=0, screenAxisY=2. screenAxisLenSq=4. - // Move (0, 6): t = (0*0 + 6*2) / 4 = 3. newPos = [10, 20+3, 30] = [10, 23, 30]. + // Y arrow is cssAxis=1 → screen probe (0, 2). Pointer (0, 6) → t=3 CSS px. + // Post-parity: t/SCENE_TILE_SIZE = 0.06 world units, applied to world + // axis WORLD_AXIS_FOR_CSS[1] = 0 (world X). position[0] moves 0.06 from + // 10 → 10.06; Y and Z unchanged. window.dispatchEvent( new PointerEvent("pointermove", { clientX: 0, clientY: 6, pointerId: 1 }), ); - expect(onObjectChange.mock.calls[0][0].position).toEqual([10, 23, 30]); + expect(onObjectChange.mock.calls[0][0].position).toEqual([10.06, 20, 30]); - // Perpendicular (X) pointer motion: dot([100, 0], [0, 2]) = 0 → t = 0. - // newPos = [10, 20+0, 30] = [10, 20, 30] — same as start. + // Perpendicular X-only pointer motion: dot([100, 0], [0, 2]) = 0 → t=0. + // Cumulative pointer (100, 6) re-projects to t=3 (same as before), so + // position[0] stays at 10.06 and Y/Z stay unchanged. window.dispatchEvent( new PointerEvent("pointermove", { clientX: 100, clientY: 6, pointerId: 1 }), ); - // Position should not have changed from the anchored start + t=3 (prev move reused anchor) - // Actually cumulative t from (100, 6): dot([100, 6], [0, 2]) / 4 = 12/4 = 3 → same - expect(onObjectChange.mock.calls[1][0].position[0]).toBe(10); // x unchanged + expect(onObjectChange.mock.calls[1][0].position[1]).toBe(20); // y unchanged expect(onObjectChange.mock.calls[1][0].position[2]).toBe(30); // z unchanged window.dispatchEvent(new PointerEvent("pointerup", { clientX: 100, clientY: 6, pointerId: 1 })); @@ -304,14 +303,17 @@ describe("createTransformControls", () => { triggerPointerDownOnGizmoEl(host, ".polycss-transform-arrow--x", 0, 0); - // Pointer (14, 0): raw t = (14*2)/4 = 7. snap(7, 5) = 5. - // newPos = [0+5, 0, 0] = [5, 0, 0]. + // Pointer (14, 0): raw t = 7 CSS px, snap(7, 5) = 5 CSS px. The snap + // value is in CSS pixels (callers like the builder pre-multiply by + // BASE_TILE so a "10 world unit" snap reads as 500 CSS px). After + // post-parity drag math: 5 CSS px / SCENE_TILE_SIZE = 0.1 world units + // along world axis WORLD_AXIS_FOR_CSS[0] = 1 (world Y). window.dispatchEvent( new PointerEvent("pointermove", { clientX: 14, clientY: 0, pointerId: 1 }), ); expect(onObjectChange).toHaveBeenCalled(); - expect(onObjectChange.mock.calls[0][0].position).toEqual([5, 0, 0]); + expect(onObjectChange.mock.calls[0][0].position).toEqual([0, 0.1, 0]); window.dispatchEvent(new PointerEvent("pointerup", { clientX: 14, clientY: 0, pointerId: 1 })); }); @@ -342,7 +344,7 @@ describe("createTransformControls", () => { }); // ── Test 11: rotate ring drag → onObjectChange fires with new rotation ─────── - it("dragging X ring fires onObjectChange with updated rotation (X-axis inverted)", () => { + it("dragging X ring fires onObjectChange with updated rotation around world Y", () => { withFakeLayout(2, () => { const onObjectChange = vi.fn(); const mesh = scene.add(parseResult(), { id: "target" }); @@ -350,14 +352,16 @@ describe("createTransformControls", () => { tc = createTransformControls(scene, { mode: "rotate", onObjectChange }); tc.attach(mesh); - // Dispatch pointerdown at (100, 100) so lastAngle = atan2(100-0, 100-0) - // The gizmo wrapper has top=0, left=0 (no translate3d in the mesh), so - // wRect.left=0, wRect.top=0. centerX=0, centerY=0. + // Pointerdown at (100, 100); gizmo wrapper at (0, 0) so the start + // angle is atan2(100, 100) = 45° in screen space. triggerPointerDownOnGizmoEl(host, ".polycss-transform-ring--x", 100, 100); - // Move pointer to (200, 100): angle changes from atan2(100,100)=45° to atan2(100,200)=~26.6° - // d = new - old = atan2(100,200) - atan2(100,100) ≈ -0.3217 rad ≈ -18.43° - // cumulative ≈ -18.43°. X-axis sign = -1 (inverted), so next[0] = 0 + (-18.43 * -1) ≈ 18.43° + // Move pointer to (200, 100): atan2(100, 200) ≈ 26.57°. Screen delta is + // CCW-negative ≈ -18.43°. Post-parity: the "X" ring is built around + // WORLD_AXIS_FOR_CSS[0] = world Y, so the rotation goes into + // `rotation[1]`, not `rotation[0]`. A single global sign flip + // (-degrees) maps CCW-in-screen to CW-in-world, matching what the + // user sees when they drag the visible ring. window.dispatchEvent( new PointerEvent("pointermove", { clientX: 200, clientY: 100, pointerId: 1 }), ); @@ -365,13 +369,11 @@ describe("createTransformControls", () => { expect(onObjectChange).toHaveBeenCalled(); const evt = onObjectChange.mock.calls[0][0]; expect(evt.rotation).toBeDefined(); - // X-axis rotation is inverted; moving CW should produce positive rotation - expect(evt.rotation![0]).toBeTypeOf("number"); - // y and z should remain ~0 (X ring drag only affects cssAxis=0). With - // quaternion compose the round-trip through Euler can yield -0 for - // nominally-zero components, so check the magnitude instead of strict - // +0 equality. - expect(Math.abs(evt.rotation![1])).toBeLessThan(1e-6); + expect(evt.rotation![1]).toBeTypeOf("number"); + expect(Math.abs(evt.rotation![1])).toBeGreaterThan(0); + // X and Z should stay ~0. Quaternion → Euler round-trip can yield -0 + // for nominally-zero components, so compare magnitudes. + expect(Math.abs(evt.rotation![0])).toBeLessThan(1e-6); expect(Math.abs(evt.rotation![2])).toBeLessThan(1e-6); expect(evt.object).toBe(mesh); diff --git a/packages/react/src/controls/TransformControls.test.tsx b/packages/react/src/controls/TransformControls.test.tsx index 526b5918..38330f98 100644 --- a/packages/react/src/controls/TransformControls.test.tsx +++ b/packages/react/src/controls/TransformControls.test.tsx @@ -112,9 +112,12 @@ describe("", () => { ); const wrapper = container.querySelector("[data-poly-transform-controls]") as HTMLElement; expect(wrapper).not.toBeNull(); - // Wrapper sits at position + bboxCenter(polygons) so the gizmo lands on - // the mesh's visual center. TRIANGLE's bbox center contributes (25, 25, 0). - expect(wrapper.style.transform).toContain("translate3d(75px, 85px, 70px)"); + // Wrapper sits at the mesh's visible center in scene-CSS pixel space. + // Post-parity `position` is world units / world-axis order, so the + // gizmo applies `worldPositionToCss(position)` = [pos[1]*50, pos[0]*50, + // pos[2]*50] = [3000, 2500, 3500]. TRIANGLE's bbox center adds + // (25, 25, 0) in CSS px (already swapped via SCENE_TILE_SIZE). + expect(wrapper.style.transform).toContain("translate3d(3025px, 2525px, 3500px)"); const arrows = wrapper.querySelectorAll(".polycss-transform-arrow"); expect(arrows.length).toBe(6); expect(Array.from(arrows).map(axisKeyOf)).toEqual([ @@ -287,7 +290,8 @@ describe("", () => { ), ); let wrapper = container.querySelector("[data-poly-transform-controls]") as HTMLElement; - // Wrapper = position + bboxCenter(TRIANGLE) where bbox center is (25, 25, 0). + // Wrapper = worldPositionToCss(position) + bboxCenter(TRIANGLE). + // position=[0,0,0] → CSS [0,0,0]; TRIANGLE bbox center = (25, 25, 0). expect(wrapper.style.transform).toContain("translate3d(25px, 25px, 0px)"); act(() => root.render( @@ -300,8 +304,9 @@ describe("", () => { ), ); wrapper = container.querySelector("[data-poly-transform-controls]") as HTMLElement; - // (42, 7, 0) + (25, 25, 0) bboxCenter - expect(wrapper.style.transform).toContain("translate3d(67px, 32px, 0px)"); + // position=[42,7,0] → CSS [pos[1]*50, pos[0]*50, pos[2]*50] = [350, 2100, 0] + // + bboxCenter (25, 25, 0) = [375, 2125, 0]. + expect(wrapper.style.transform).toContain("translate3d(375px, 2125px, 0px)"); }); it("dragging -X arrow decreases position[0]", () => {