Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 35 additions & 33 deletions packages/polycss/src/api/createTransformControls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand All @@ -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 }));
Expand All @@ -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 }));
});
Expand Down Expand Up @@ -342,36 +344,36 @@ 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" });
mesh.setTransform({ rotation: [0, 0, 0] });
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 }),
);

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);

Expand Down
129 changes: 92 additions & 37 deletions packages/polycss/src/api/createTransformControls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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,
];
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -729,7 +742,7 @@ export function createTransformControls(
teardownGizmos();
return;
}
centerOffset = bboxCenterCss(t.polygons);
centerOffset = bboxCenterWorld(t.polygons);
teardownGizmos();
buildGizmos();
}
Expand All @@ -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;

Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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,
Expand All @@ -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 `<PolyMesh rotation>` 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,
Expand All @@ -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
Expand Down
Loading
Loading