Skip to content

feat(studio): draggable 3D-transform cube in the design panel#1710

Open
miguel-heygen wants to merge 17 commits into
mainfrom
feat/3d-transform-widget
Open

feat(studio): draggable 3D-transform cube in the design panel#1710
miguel-heygen wants to merge 17 commits into
mainfrom
feat/3d-transform-widget

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

A draggable 3D-transform cube in the design panel's 3D Transform section — set an element's 3D orientation by dragging instead of typing degrees.

  • Drag the cube to tilt the element (rotationX / rotationY).
  • Shift-drag to roll it (rotationZ).
  • Recenter button resets the 3D transform to identity.
  • The cube previews the orientation live and commits on release through the existing keyframe-aware commitAnimatedProperty path — a drag at the playhead writes/updates keyframes exactly like the numeric fields. No new mutation/render infra.

Also surfaces the two 3D fields that were missing from the panel: RotZ and Perspective. Perspective drives the newly-editable transformPerspective (per-element depth — what makes the element's own X/Y rotation look 3D), not CSS perspective (which only affects children).

How it works

  • transform3dProjection.ts — pure unit-cube projection: rotate 8 corners by rotX/Y/Z, back-face cull, painter-sort the ≤3 visible faces. No three.js / 3D dependency. Unit-tested.
  • Transform3DCube.tsx — the SVG drag widget (pointer-capture, draft → onPoseCommit, mirrors the EaseCurveSection drag pattern).
  • propertyPanel3dTransform.tsx — mounts the cube, builds the pose from runtime values, commits only the changed rotation axes on release.

Test plan

  • Select a 3D-animatable element → drag the cube → the element tilts; release commits rotationX/rotationY.
  • Shift-drag → element rolls (rotationZ).
  • Recenter → 3D transform returns to identity.
  • Editing RotX/RotY/RotZ numerically re-poses the cube (two-way sync).
  • Perspective field gives the element real per-element 3D depth.
  • bun test packages/studio (adds 8 transform3dProjection tests) and packages/core pass.

Follow-ups (deferred)

Floating popover placement, on-canvas 3D gizmo, orientation presets, live element preview during drag (cube previews live today; element updates on release).

Add a Figma-style draggable cube to the 3D Transform section so users can set an
element's 3D orientation by dragging instead of typing degrees. Drag tilts the
element (rotationX/Y); Shift-drag rolls it (rotationZ); a recenter button resets
the 3D transform to identity. The cube previews the orientation live and commits
on release.

It's an input affordance over the existing keyframe-aware commit path
(commitAnimatedProperty) — a drag at the playhead writes/updates keyframes just
like the numeric fields, no new mutation infra.

- transform3dProjection.ts: pure unit-cube projection with back-face culling and
  painter ordering (no 3D dependency), unit-tested.
- Transform3DCube.tsx: the SVG drag widget (pointer-capture, draft→commit).
- Surface the two missing numeric fields (RotZ, Perspective). Perspective drives
  the new editable `transformPerspective` prop (per-element depth) rather than
  CSS `perspective` (which only affects children).
…, live drag preview

Address review of the first cut:
- 3D Transform section is now collapsible and collapsed by default (it was tall
  and ate panel space).
- Redesign the cube: compact and centered (was full-width), resting isometric
  camera so it reads as a 3D cube at identity instead of a flat square,
  directional per-face lighting, gradient backdrop + grounding shadow.
- Live element preview while dragging: onLivePreviewProps gsap.sets the live
  transform on the preview element so it moves WITH the cube; release still
  commits via the keyframe-aware path.
- Extract Cube3dControl to keep the panel component under the complexity gate.
The cube (and the RotX/RotY numeric fields) didn't stick on an element whose
only tween is a position 'set' — commitAnimatedProperty tried to convert the
zero-duration hold into keyframes, so the rotation was never written and the
cube snapped back. Handle the static-set case: merge the property into the set
(update-property) so a static 3D rotation/perspective persists, and the cube
reads it back from runtime.

Also refine the cube rendering: muted teal lit faces with edges that brighten
with how front-facing each face is (crisp bevels, not flat neon outlines), a
soft halo glow, and a stronger grounding shadow.
…n-cube perspective

- Keyframe diamonds: RotX/RotY/RotZ + Perspective (and Z/Scale) now each carry a
  KeyframeNavigation diamond, so 3D transforms can be keyframed like Layout X/Y.
  Refactored the six fields onto a shared Transform3dField.
- Flash-free: static-set 3D commits now use instantPatch (in-place runtime patch,
  no soft reload), and the set fast-path was widened to the 3D channels
  (rotationX/Y/Z, z, transformPerspective) — dragging the cube / scrubbing a 3D
  field no longer flashes.
- In-cube perspective: a Persp slider lives in the cube widget and the cube's
  foreshortening reflects transformPerspective live.
- Axis gizmo: render the rotated X (red) / Y (green) / Z (blue) vectors from the
  cube center — away-facing axes dimmed behind the cube, toward-facing on top
  with a tip dot + label — so orientation is readable at a glance.
- Flash diagnostics: add a gated, JSON-stringified [hf-3d:*] logger (on in dev or
  via window.__hfDebug). Instruments the commit path (which branch + picked
  tween), the cube pose/axis commits, and — the key signal — applyPreviewSync's
  instant-patch-vs-soft-reload decision (a soft reload IS the flash). Reproduce
  with the console open to pinpoint any remaining flash to a specific commit.
The resting isometric camera made the cube always look tilted, so at rotation
0/0/0 the cube showed a 3D pose while the element was flat — the cube didn't
represent the element. Drop the decorative camera (VIEW_RX/RY = 0): the cube now
faces front at identity, exactly matching the un-rotated element, and tilts to
match as the element rotates. The X/Y/Z axis gizmo keeps the flat-at-rest state
readable.

Flash status (from the gated [hf-3d:*] logs): every commit now reports
'instant (no flash)' via instantPatch — the soft-reload flashes are resolved.
Each 3D commit bumps the gsap cache; the panel then re-read runtime values, but
readGsapRuntimeValuesForPanel only included props already present in the parsed
gsapAnimations. A just-set rotationX isn't in the parse yet, so for that window
the cube + fields dropped it and flickered to 0. Always read the core transform
channels (x/y/rotation/rotationX/Y/Z/z/scale/transformPerspective/opacity)
directly via gsap.getProperty — which reflects the in-place instant patch — so
the panel shows the true current value with no flicker.
…frames

The cube/3D fields stored rotation as a static 'set', and convert-to-keyframes
flatly refused to convert a set (gsapParser.ts) — so two 3D 'keyframes' just
overwrote the same static value with no interpolation.

Now a set converts to an animatable to(): resolveConversionProps emits both
endpoints from the set's value (visual unchanged until edited), and both writers
flip set→to, drop the immediateRender hold, and add a duration. The element's
clip duration is threaded through the convert chain (3D field → handler →
convertToKeyframes → route → parser) so the keyframes span the whole clip and
land in range at any playhead. Click a 3D field's diamond to convert, then edit
at different playheads to animate. Acorn writer mirrored; recast round-trip test
added.
The cube had no keyframe affordance, so dragging it only ever wrote the static
set (logs showed every rotation commit as path:static-set) and nothing
interpolated — converting required clicking a numeric field's diamond, which
isn't discoverable while driving the cube.

Add a keyframe diamond button to the cube widget: it converts the 3D
('other'-group) static set to keyframes spanning the element's clip, and lights
up when the transform is already keyframed. Once keyframed, cube drags + numeric
edits add keyframes at the playhead and the 3D rotation interpolates.
… AssetsTab 404 loop

3D transforms now auto-keyframe like drag/resize/rotate: when the element is
already animated (its clip has keyframes), editing a 3D prop converts the static
set to keyframes so edits at other playheads interpolate — no manual keyframe
toggle needed. Purely static elements still write a static set (and the cube's
keyframe button remains a manual opt-in for them).

Also fix the AssetsTab media-manifest fetch: it was keyed on the assets array
reference (new each render) so it re-fetched the (usually missing) manifest on
every re-render — spamming 404s and churning the left sidebar during cube drags.
Key on a stable join and cache the 404 so a missing manifest is fetched once.
The cube committed rotationX/Y/Z as separate add-keyframe mutations; the first
axis's auto-keyframe convert shifted the tween so the second axis computed a
slightly different percentage → two adjacent keyframes instead of one.

Add a batched commitAnimatedProperties that writes all changed props into ONE
keyframe, and route the cube through it (commitAnimatedProperty is now a thin
single-prop wrapper). Threaded through the panel chain; numeric fields keep the
single-prop path. Set-path and keyframe-path extracted to helpers to stay under
the complexity gate.
…e check

The manifest-404 fix touched AssetsTab.tsx, which was already over the 600-line
cap (702). Move the self-contained AudioRow sub-component to its own file,
bringing AssetsTab to 493 lines.
@miguel-heygen miguel-heygen force-pushed the feat/3d-transform-widget branch from 8c3e934 to 5964df0 Compare June 25, 2026 15:53
A 3D property edit (cube drag / field) picks its target from the panel's
selectedGsapAnimations cache. When keyframes were just removed or the script
changed underneath, that id is gone server-side and the commit POST 404s
('animation not found'). The raw commitMutation already toasts but rethrows,
so the rejection escaped as an uncaught promise. Catch it in
commitAnimatedProperties and bump the cache so the panel re-syncs and the
next edit self-heals.
Reset 3D orientation looped six props (rotationX/Y/Z, z, scale,
transformPerspective) through the single-property commit, so one click
triggered six separate soft-reloads — six preview flashes. Batch them into
one onCommitAnimatedProperties call (one keyframe, one reload), matching the
cube-drag path.
Editing the 3D transform of an element with no keyframes created a keyframed
tween (Case 3 made a tl.to() + convert, a flat tween converted to keyframes).
A static element should stay static — same as manual drag / resize / rotate,
which tl.set() it. Route no-keyframe elements to a set: update an existing one
in place, or create a dedicated tl.set carrying all axes in ONE add mutation.
The single mutation also avoids the per-axis id race (a flat tween's
group-derived id shifts after the first prop, 404-ing the next and polluting
an unrelated tween).
@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown

Fallow audit report

Found 80 findings.

Dead code (3)
Severity Rule Location Description
major fallow/unused-export packages/studio/src/components/editor/propertyPanelHelpers.ts:222 Export 'EMPTY_FILTER_VALUE' is never imported by other modules
major fallow/unused-export packages/studio/src/components/editor/propertyPanelHelpers.ts:224 Export 'BOX_SHADOW_PRESETS' is never imported by other modules
major fallow/unused-export packages/studio/src/components/editor/propertyPanelHelpers.ts:285 Export 'clampPanelNumber' is never imported by other modules
Duplication (68, showing 50)
Severity Rule Location Description
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:313 Code clone group 1 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:345 Code clone group 2 (12 lines, 4 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:364 Code clone group 1 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:390 Code clone group 2 (12 lines, 4 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:404 Code clone group 2 (12 lines, 4 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:445 Code clone group 2 (12 lines, 4 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1640 Code clone group 3 (9 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1783 Code clone group 3 (9 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1965 Code clone group 4 (6 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1991 Code clone group 4 (6 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2191 Code clone group 5 (22 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2242 Code clone group 5 (22 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2370 Code clone group 6 (10 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2405 Code clone group 7 (6 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2419 Code clone group 8 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2435 Code clone group 6 (10 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2452 Code clone group 10 (12 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2452 Code clone group 9 (11 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2454 Code clone group 7 (6 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2465 Code clone group 9 (11 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2465 Code clone group 10 (12 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2470 Code clone group 7 (6 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2482 Code clone group 10 (12 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2542 Code clone group 11 (7 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2553 Code clone group 12 (11 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2568 Code clone group 8 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2570 Code clone group 11 (7 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2582 Code clone group 12 (11 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2613 Code clone group 13 (10 lines, 4 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2643 Code clone group 14 (15 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2643 Code clone group 13 (10 lines, 4 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2643 Code clone group 15 (9 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2671 Code clone group 13 (10 lines, 4 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2671 Code clone group 15 (9 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2687 Code clone group 13 (10 lines, 4 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2687 Code clone group 14 (15 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2687 Code clone group 15 (9 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:625 Code clone group 16 (7 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:740 Code clone group 16 (7 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:1357 Code clone group 17 (10 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:1385 Code clone group 17 (10 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:1480 Code clone group 18 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2135 Code clone group 19 (27 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2170 Code clone group 20 (9 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2204 Code clone group 19 (27 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2232 Code clone group 20 (9 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2436 Code clone group 21 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2598 Code clone group 22 (13 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2599 Code clone group 23 (10 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2641 Code clone group 22 (13 lines, 2 instances)

Showing 50 of 68 findings. Run fallow locally or inspect the CI output for the full report.

Health (9)
Severity Rule Location Description
major fallow/high-crap-score packages/studio/src/components/sidebar/AssetsTab.tsx:26 'ImageCard' has CRAP score 88.0 (threshold: 30.0, cyclomatic 18)
minor fallow/high-crap-score packages/studio/src/hooks/gsapRuntimeKeyframes.ts:205 'keyframeVarsCarryChannel' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
critical fallow/high-crap-score packages/studio/src/hooks/gsapRuntimeKeyframes.ts:235 'resolveRuntimeTween' has CRAP score 33.0 (threshold: 30.0, cyclomatic 30)
critical fallow/high-complexity packages/studio/src/hooks/gsapRuntimeKeyframes.ts:314 'readRuntimeKeyframes' has cyclomatic complexity 24 (threshold: 20) and cognitive complexity 45 (threshold: 15)
minor fallow/high-cognitive-complexity packages/studio/src/hooks/gsapRuntimeKeyframes.ts:381 'hasNonHoldTweenForElement' has cognitive complexity 17 (threshold: 15)
major fallow/high-crap-score packages/studio/src/hooks/gsapRuntimePatch.ts:154 'rebuildKeyframeTween' has CRAP score 71.3 (threshold: 30.0, cyclomatic 16)
minor fallow/high-crap-score packages/studio/src/hooks/useAnimatedPropertyCommit.ts:53 'scored' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
minor fallow/high-crap-score packages/studio/src/hooks/useAnimatedPropertyCommit.ts:178 'commitKeyframeProps' has CRAP score 49.5 (threshold: 30.0, cyclomatic 13)
minor fallow/high-crap-score packages/studio/src/hooks/useAnimatedPropertyCommit.ts:242 'commitAnimatedProperties' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)

Generated by fallow.

Dragging the cube on an animated element soft-reloaded the iframe on every
edit (a flash). GSAP compiles object-form keyframes ({ "0%": {...} }) into
sub-tweens at creation and ignores later vars.keyframes mutations, so the value
can't be patched the way a tl.set can. Instead REBUILD the tween in place: kill
it and recreate it on the same parent timeline at the same position with the
edited keyframe merged and all other vars preserved, then re-seek — no iframe
reload, no flash. Resolution is now channel-aware for keyframe tweens too, so a
rotation edit lands on the rotation tween, never a co-located position tween.
Declines (→ soft reload) for array-form, motionPath, or dynamic values.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant