Skip to content

Commit e253797

Browse files
authored
fix(parity): builder, ground shadow, gizmo wrapper, wordart, doc demos (#61)
* fix(parity): builder placement, ground shadow Z, and gizmo wrapper position 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. * fix(website): wordart zoom scale + doc PolyDemo lighting 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 `<poly-scene>`, 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. * test(gizmo): update assertions to post-parity world-unit semantics 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.
1 parent 8fc77fd commit e253797

13 files changed

Lines changed: 288 additions & 146 deletions

File tree

packages/polycss/src/api/createTransformControls.test.ts

Lines changed: 35 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -243,19 +243,18 @@ describe("createTransformControls", () => {
243243
// then dispatch pointerdown on the host at (0, 0).
244244
triggerPointerDownOnGizmoEl(host, ".polycss-transform-arrow--x", 0, 0);
245245

246-
// cameraScale=2, X axis is cssAxis=0 (maps to translate3d[0]=x).
247-
// probeEl will get translate3d(shaftLength, 0, 0) → screen offset (shaftLength*2, 0).
248-
// screenAxisX = (shaftLength*2) / shaftLength = 2, screenAxisY = 0.
249-
// screenAxisLenSq = 4.
250-
// Pointer move to (10, 0): t = (10*2 + 0*0) / 4 = 5.
251-
// newPos = [100+5*1, 200+5*0, 0+5*0] = [105, 200, 0].
246+
// cameraScale=2, X arrow is cssAxis=0 → probe at translate3d(shaft,0,0).
247+
// Screen probe ratio 2, dot product with pointer (10,0) gives t=5 CSS px.
248+
// Post-parity drag math: t/SCENE_TILE_SIZE = 0.1 world units, applied
249+
// to world axis WORLD_AXIS_FOR_CSS[0] = 1 (world Y). So position[1]
250+
// moves 0.1 from 200 → 200.1; world X and Z unchanged.
252251
window.dispatchEvent(
253252
new PointerEvent("pointermove", { clientX: 10, clientY: 0, pointerId: 1 }),
254253
);
255254

256255
expect(onObjectChange).toHaveBeenCalled();
257256
const evt = onObjectChange.mock.calls[0][0];
258-
expect(evt.position).toEqual([105, 200, 0]);
257+
expect(evt.position).toEqual([100, 200.1, 0]);
259258
expect(evt.object).toBe(mesh);
260259

261260
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 10, clientY: 0, pointerId: 1 }));
@@ -272,22 +271,22 @@ describe("createTransformControls", () => {
272271

273272
triggerPointerDownOnGizmoEl(host, ".polycss-transform-arrow--y", 0, 0);
274273

275-
// Y axis is cssAxis=1 → translate3d(0, shaftLength, 0) → screen (0, shaftLength*2).
276-
// screenAxisX=0, screenAxisY=2. screenAxisLenSq=4.
277-
// Move (0, 6): t = (0*0 + 6*2) / 4 = 3. newPos = [10, 20+3, 30] = [10, 23, 30].
274+
// Y arrow is cssAxis=1 → screen probe (0, 2). Pointer (0, 6) → t=3 CSS px.
275+
// Post-parity: t/SCENE_TILE_SIZE = 0.06 world units, applied to world
276+
// axis WORLD_AXIS_FOR_CSS[1] = 0 (world X). position[0] moves 0.06 from
277+
// 10 → 10.06; Y and Z unchanged.
278278
window.dispatchEvent(
279279
new PointerEvent("pointermove", { clientX: 0, clientY: 6, pointerId: 1 }),
280280
);
281-
expect(onObjectChange.mock.calls[0][0].position).toEqual([10, 23, 30]);
281+
expect(onObjectChange.mock.calls[0][0].position).toEqual([10.06, 20, 30]);
282282

283-
// Perpendicular (X) pointer motion: dot([100, 0], [0, 2]) = 0 → t = 0.
284-
// newPos = [10, 20+0, 30] = [10, 20, 30] — same as start.
283+
// Perpendicular X-only pointer motion: dot([100, 0], [0, 2]) = 0 → t=0.
284+
// Cumulative pointer (100, 6) re-projects to t=3 (same as before), so
285+
// position[0] stays at 10.06 and Y/Z stay unchanged.
285286
window.dispatchEvent(
286287
new PointerEvent("pointermove", { clientX: 100, clientY: 6, pointerId: 1 }),
287288
);
288-
// Position should not have changed from the anchored start + t=3 (prev move reused anchor)
289-
// Actually cumulative t from (100, 6): dot([100, 6], [0, 2]) / 4 = 12/4 = 3 → same
290-
expect(onObjectChange.mock.calls[1][0].position[0]).toBe(10); // x unchanged
289+
expect(onObjectChange.mock.calls[1][0].position[1]).toBe(20); // y unchanged
291290
expect(onObjectChange.mock.calls[1][0].position[2]).toBe(30); // z unchanged
292291

293292
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 100, clientY: 6, pointerId: 1 }));
@@ -304,14 +303,17 @@ describe("createTransformControls", () => {
304303

305304
triggerPointerDownOnGizmoEl(host, ".polycss-transform-arrow--x", 0, 0);
306305

307-
// Pointer (14, 0): raw t = (14*2)/4 = 7. snap(7, 5) = 5.
308-
// newPos = [0+5, 0, 0] = [5, 0, 0].
306+
// Pointer (14, 0): raw t = 7 CSS px, snap(7, 5) = 5 CSS px. The snap
307+
// value is in CSS pixels (callers like the builder pre-multiply by
308+
// BASE_TILE so a "10 world unit" snap reads as 500 CSS px). After
309+
// post-parity drag math: 5 CSS px / SCENE_TILE_SIZE = 0.1 world units
310+
// along world axis WORLD_AXIS_FOR_CSS[0] = 1 (world Y).
309311
window.dispatchEvent(
310312
new PointerEvent("pointermove", { clientX: 14, clientY: 0, pointerId: 1 }),
311313
);
312314

313315
expect(onObjectChange).toHaveBeenCalled();
314-
expect(onObjectChange.mock.calls[0][0].position).toEqual([5, 0, 0]);
316+
expect(onObjectChange.mock.calls[0][0].position).toEqual([0, 0.1, 0]);
315317

316318
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 14, clientY: 0, pointerId: 1 }));
317319
});
@@ -342,36 +344,36 @@ describe("createTransformControls", () => {
342344
});
343345

344346
// ── Test 11: rotate ring drag → onObjectChange fires with new rotation ───────
345-
it("dragging X ring fires onObjectChange with updated rotation (X-axis inverted)", () => {
347+
it("dragging X ring fires onObjectChange with updated rotation around world Y", () => {
346348
withFakeLayout(2, () => {
347349
const onObjectChange = vi.fn();
348350
const mesh = scene.add(parseResult(), { id: "target" });
349351
mesh.setTransform({ rotation: [0, 0, 0] });
350352
tc = createTransformControls(scene, { mode: "rotate", onObjectChange });
351353
tc.attach(mesh);
352354

353-
// Dispatch pointerdown at (100, 100) so lastAngle = atan2(100-0, 100-0)
354-
// The gizmo wrapper has top=0, left=0 (no translate3d in the mesh), so
355-
// wRect.left=0, wRect.top=0. centerX=0, centerY=0.
355+
// Pointerdown at (100, 100); gizmo wrapper at (0, 0) so the start
356+
// angle is atan2(100, 100) = 45° in screen space.
356357
triggerPointerDownOnGizmoEl(host, ".polycss-transform-ring--x", 100, 100);
357358

358-
// Move pointer to (200, 100): angle changes from atan2(100,100)=45° to atan2(100,200)=~26.6°
359-
// d = new - old = atan2(100,200) - atan2(100,100) ≈ -0.3217 rad ≈ -18.43°
360-
// cumulative ≈ -18.43°. X-axis sign = -1 (inverted), so next[0] = 0 + (-18.43 * -1) ≈ 18.43°
359+
// Move pointer to (200, 100): atan2(100, 200) ≈ 26.57°. Screen delta is
360+
// CCW-negative ≈ -18.43°. Post-parity: the "X" ring is built around
361+
// WORLD_AXIS_FOR_CSS[0] = world Y, so the rotation goes into
362+
// `rotation[1]`, not `rotation[0]`. A single global sign flip
363+
// (-degrees) maps CCW-in-screen to CW-in-world, matching what the
364+
// user sees when they drag the visible ring.
361365
window.dispatchEvent(
362366
new PointerEvent("pointermove", { clientX: 200, clientY: 100, pointerId: 1 }),
363367
);
364368

365369
expect(onObjectChange).toHaveBeenCalled();
366370
const evt = onObjectChange.mock.calls[0][0];
367371
expect(evt.rotation).toBeDefined();
368-
// X-axis rotation is inverted; moving CW should produce positive rotation
369-
expect(evt.rotation![0]).toBeTypeOf("number");
370-
// y and z should remain ~0 (X ring drag only affects cssAxis=0). With
371-
// quaternion compose the round-trip through Euler can yield -0 for
372-
// nominally-zero components, so check the magnitude instead of strict
373-
// +0 equality.
374-
expect(Math.abs(evt.rotation![1])).toBeLessThan(1e-6);
372+
expect(evt.rotation![1]).toBeTypeOf("number");
373+
expect(Math.abs(evt.rotation![1])).toBeGreaterThan(0);
374+
// X and Z should stay ~0. Quaternion → Euler round-trip can yield -0
375+
// for nominally-zero components, so compare magnitudes.
376+
expect(Math.abs(evt.rotation![0])).toBeLessThan(1e-6);
375377
expect(Math.abs(evt.rotation![2])).toBeLessThan(1e-6);
376378
expect(evt.object).toBe(mesh);
377379

packages/polycss/src/api/createTransformControls.ts

Lines changed: 92 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
quatFromEulerXYZ,
2626
quatMultiply,
2727
ringQuadPolygons,
28+
rotateVec3,
2829
} from "@layoutit/polycss-core";
2930
import type { Polygon, Vec3 } from "@layoutit/polycss-core";
3031
import type { PolyMeshHandle, PolySceneHandle } from "./createPolyScene";
@@ -145,14 +146,14 @@ function snap(value: number, step: number | null | undefined): number {
145146
return Math.round(value / step) * step;
146147
}
147148

148-
/** Compute the bbox center of a mesh's polygons in scene-CSS pixels.
149-
* PolyCSS world→CSS axis remap: world-Y → CSS-x, world-X → CSS-y,
150-
* world-Z → CSS-z. The result is the offset we add to the gizmo
151-
* position so the gizmo overlays the visible center of the mesh. The
152-
* mesh wrapper sets `transform-origin: var(--origin)` to the same bbox
153-
* center, so its visible center is `position + bboxCenter` regardless
154-
* of scale or rotation — no per-axis scale multiplication needed. */
155-
function bboxCenterCss(polygons: Polygon[]): Vec3 {
149+
/** Compute the bbox center of a mesh's polygons in WORLD units, world-axis
150+
* order (`+X right, +Y forward, +Z up`). Added to `target.transform.position`
151+
* (also world units, world-axis) to place the gizmo at the mesh's visible
152+
* centroid. PolyMesh's buildMeshTransform applies the world→CSS axis swap +
153+
* ×BASE_TILE on the resulting position, so consumers stay in world units.
154+
* Collapses to (0,0,0) when the mesh is already centered (e.g. when
155+
* PolyMesh autoCenter or `loadMesh(..., { center: true })` was used). */
156+
function bboxCenterWorld(polygons: Polygon[]): Vec3 {
156157
if (polygons.length === 0) return [0, 0, 0];
157158
let minX = Infinity, minY = Infinity, minZ = Infinity;
158159
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
@@ -168,9 +169,9 @@ function bboxCenterCss(polygons: Polygon[]): Vec3 {
168169
}
169170
if (!Number.isFinite(minX)) return [0, 0, 0];
170171
return [
171-
((minY + maxY) / 2) * SCENE_TILE_SIZE,
172-
((minX + maxX) / 2) * SCENE_TILE_SIZE,
173-
((minZ + maxZ) / 2) * SCENE_TILE_SIZE,
172+
(minX + maxX) / 2,
173+
(minY + maxY) / 2,
174+
(minZ + maxZ) / 2,
174175
];
175176
}
176177

@@ -527,7 +528,19 @@ export function createTransformControls(
527528
function gizmoPosition(): Vec3 {
528529
if (!target) return [0, 0, 0];
529530
const t = target.transform.position ?? ([0, 0, 0] as Vec3);
530-
return [t[0] + centerOffset[0], t[1] + centerOffset[1], t[2] + centerOffset[2]];
531+
const r = target.transform.rotation ?? ([0, 0, 0] as Vec3);
532+
const s = typeof target.transform.scale === "number"
533+
? target.transform.scale
534+
: (target.transform.scale?.[0] ?? 1);
535+
// Visible mesh center under the post-parity wrapper transform
536+
// `T · R · S · p`: at p = bboxCenter (mesh-local), visible center
537+
// = T + scale * R(rotation) * bboxCenter. Apply current rotation so
538+
// the gizmo follows the mesh while it spins — without this, the
539+
// rotation handler's pivot compensation slides position around to
540+
// keep the visible center fixed and the gizmo (placed at
541+
// position + centerOffset) drifts off-axis.
542+
const rc = rotateVec3(centerOffset, r[0], r[1], r[2]);
543+
return [t[0] + s * rc[0], t[1] + s * rc[1], t[2] + s * rc[2]];
531544
}
532545

533546
function alphaFor(key: string): number {
@@ -729,7 +742,7 @@ export function createTransformControls(
729742
teardownGizmos();
730743
return;
731744
}
732-
centerOffset = bboxCenterCss(t.polygons);
745+
centerOffset = bboxCenterWorld(t.polygons);
733746
teardownGizmos();
734747
buildGizmos();
735748
}
@@ -756,16 +769,21 @@ export function createTransformControls(
756769
// The dragStartPosition snapshot is captured in the pointerdown
757770
// handler below.
758771
if (!dragStartPosition) return;
759-
const next: Vec3 = [
760-
dragStartPosition[0] + t * axisVec[0],
761-
dragStartPosition[1] + t * axisVec[1],
762-
dragStartPosition[2] + t * axisVec[2],
763-
];
772+
// Translate the drag from CSS-pixel CSS-axis space (where the screen
773+
// probe was measured) to world-unit world-axis space (where
774+
// `transform.position` lives post-parity). t is CSS px along the
775+
// visible arrow direction; divide by SCENE_TILE_SIZE for world units.
776+
// axisVec encodes the ±sign at index cssAxis; the corresponding
777+
// world axis is WORLD_AXIS_FOR_CSS[cssAxis].
778+
const sign = axisVec[spec.cssAxis];
779+
const worldAxis = WORLD_AXIS_FOR_CSS[spec.cssAxis];
780+
const worldStep = (t * sign) / SCENE_TILE_SIZE;
781+
const next = dragStartPosition.slice() as Vec3;
782+
next[worldAxis] = dragStartPosition[worldAxis] + worldStep;
764783
target.setTransform({ position: next });
765784
syncGizmoPositions();
766785
opts.onObjectChange?.({ object: target, position: next });
767786
opts.onChange?.();
768-
void spec;
769787
}
770788
let dragStartPosition: Vec3 | null = null;
771789

@@ -810,11 +828,16 @@ export function createTransformControls(
810828
translationSnap: opts.translationSnap ?? null,
811829
onPlaneDelta: (tA, tB, aVec, bVec) => {
812830
if (!target || !dragStartPosition) return;
813-
const next: Vec3 = [
814-
dragStartPosition[0] + tA * aVec[0] + tB * bVec[0],
815-
dragStartPosition[1] + tA * aVec[1] + tB * bVec[1],
816-
dragStartPosition[2] + tA * aVec[2] + tB * bVec[2],
817-
];
831+
// Same CSS→world translation as applyAxisDelta. aVec/bVec
832+
// encode the ±sign at index axisA/axisB (CSS-axis order);
833+
// the world axes to translate along are WORLD_AXIS_FOR_CSS.
834+
const signA = aVec[spec.axisA];
835+
const signB = bVec[spec.axisB];
836+
const worldAxisA = WORLD_AXIS_FOR_CSS[spec.axisA];
837+
const worldAxisB = WORLD_AXIS_FOR_CSS[spec.axisB];
838+
const next = dragStartPosition.slice() as Vec3;
839+
next[worldAxisA] += (tA * signA) / SCENE_TILE_SIZE;
840+
next[worldAxisB] += (tB * signB) / SCENE_TILE_SIZE;
818841
target.setTransform({ position: next });
819842
syncGizmoPositions();
820843
opts.onObjectChange?.({ object: target, position: next });
@@ -888,6 +911,10 @@ export function createTransformControls(
888911
draggingKey = spec.key;
889912
rebuildGizmoColors();
890913
dragStartRotation = (target.transform.rotation ?? [0, 0, 0]).slice() as Vec3;
914+
// Snapshot the position too so the per-tick pivot compensation
915+
// anchors to the drag-start state (otherwise compounding rounding
916+
// drift across moves slowly slides the mesh off-pivot).
917+
dragStartPosition = (target.transform.position ?? [0, 0, 0]).slice() as Vec3;
891918
startRingDrag({
892919
cssAxis: spec.cssAxis,
893920
wrapper: gm.handle.element,
@@ -896,22 +923,49 @@ export function createTransformControls(
896923
startClientY: event.clientY,
897924
rotationSnap: opts.rotationSnap ?? null,
898925
onAngleDelta: (degrees) => {
899-
if (!target || !dragStartRotation) return;
900-
// World-frame quaternion compose. Rings stay at world axes
901-
// visually (the gizmo isn't rotated with the mesh), so each
902-
// ring drag rotates the mesh around the WORLD axis the ring
903-
// points to — pre-multiply Qdelta · Qstart. Cumulative across
904-
// repeated drags. X-axis sign stays empirically inverted to
905-
// match user expectation for CW drag on the red ring.
906-
const sign = spec.cssAxis === 0 ? -1 : 1;
926+
if (!target || !dragStartRotation || !dragStartPosition) return;
927+
// Each ring rotates the mesh around the WORLD axis the ring
928+
// visually wraps. Ring quads are built with axis =
929+
// WORLD_AXIS_FOR_CSS[cssAxis] (see `buildPolygonsFor`), so the
930+
// rotation axis here must match — otherwise the mesh spins
931+
// around a different axis than the ring the user grabbed.
932+
const worldAxis = WORLD_AXIS_FOR_CSS[spec.cssAxis];
907933
const axisVec: Vec3 = [0, 0, 0];
908-
axisVec[spec.cssAxis] = 1;
909-
const deltaRad = (degrees * sign * Math.PI) / 180;
934+
axisVec[worldAxis] = 1;
935+
// Negate the screen-derived angle: `startRingDrag` returns a
936+
// CCW-in-screen-space delta, but the world↔CSS axis swap is a
937+
// reflection (det -1) so the same screen direction maps to a
938+
// CW world rotation around the ring's axis. Flip once globally
939+
// rather than per-axis empirically.
940+
const deltaRad = (-degrees * Math.PI) / 180;
910941
const qStart = quatFromEulerXYZ(dragStartRotation);
911942
const qDelta = quatFromAxisAngle(axisVec, deltaRad);
912-
const next = eulerXYZFromQuat(quatMultiply(qDelta, qStart));
913-
target.setTransform({ rotation: next });
914-
opts.onObjectChange?.({ object: target, rotation: next });
943+
const nextRot = eulerXYZFromQuat(quatMultiply(qDelta, qStart));
944+
// Pivot the mesh around its visible bbox center, not its
945+
// local origin. Post-parity `<PolyMesh rotation>` pivots at
946+
// (0,0,0) by design — for callers that haven't pre-centered
947+
// their geometry (most loaders default to `{ center: "min" }`),
948+
// raw rotation would swing the mesh around its bbox-min corner.
949+
// We compensate by re-translating so the world-space point
950+
// `position + scale * R * bboxCenter` stays put across the drag.
951+
const scaleVal = typeof target.transform.scale === "number"
952+
? target.transform.scale
953+
: (target.transform.scale?.[0] ?? 1);
954+
const startC = rotateVec3(
955+
centerOffset,
956+
dragStartRotation[0],
957+
dragStartRotation[1],
958+
dragStartRotation[2],
959+
);
960+
const nextC = rotateVec3(centerOffset, nextRot[0], nextRot[1], nextRot[2]);
961+
const nextPos: Vec3 = [
962+
dragStartPosition[0] + scaleVal * (startC[0] - nextC[0]),
963+
dragStartPosition[1] + scaleVal * (startC[1] - nextC[1]),
964+
dragStartPosition[2] + scaleVal * (startC[2] - nextC[2]),
965+
];
966+
target.setTransform({ rotation: nextRot, position: nextPos });
967+
syncGizmoPositions();
968+
opts.onObjectChange?.({ object: target, rotation: nextRot, position: nextPos });
915969
opts.onChange?.();
916970
},
917971
onMouseDown: opts.onMouseDown,
@@ -920,6 +974,7 @@ export function createTransformControls(
920974
if (!d) {
921975
draggingKey = null;
922976
dragStartRotation = null;
977+
dragStartPosition = null;
923978
rebuildGizmoColors();
924979
// Rebake the atlas now that the rotation is committed. The
925980
// mesh wrapper's CSS rotation has already been applied via

0 commit comments

Comments
 (0)