Closes the last UI-layer direct ColorAction construction. Same shape
as the toggleObjectLock / toggleObjectVisibility / transformObject
helpers — the view passes editor + uuid + new value; the helper
reads the before-state from the live engine, applies the change, and
records the undo entry.
Bonus correctness: before-state now comes from obj.userData.color
instead of the panel's local React `color` state. The two diverge if
the user undoes a color change (engine reverts but the local React
ref doesn't), so the engine-read shape produces the right diff
entries even when the panel state is stale.
Outliner.tsx imported LockAction and VisibilityAction directly and
mixed engine + Zustand + history calls in its click handlers,
breaking the layering convention the rest of actions/ follows
(addObject, transformObject, etc. — UI calls one helper; the helper
owns engine + store + history orchestration).
actions/toggleObjectLock and actions/toggleObjectVisibility now wrap
the engine action class + store toggle + history record in one
function. Each reads the before-state from the live engine so the
view doesn't have to plumb it.
Outliner.tsx drops the engine-action imports; handlers shrink to a
single call.
Color picker recording in ControlPanelSceneObject still constructs
ColorAction directly. Same pattern applies (actions/setObjectColor)
— easy follow-up if full layering symmetry is wanted.
The .tsx file shouldn't construct UndoableAction instances or call
HistoryManager directly — those are engine-level concerns that other
action helpers (addObject, addCharacter, addShape) already keep behind
the actions/ layer.
actions/transformObject.ts exposes beginTransformSession(editor, uuid)
which returns a session handle with one method, commit(). Open the
session on first edit, call commit() on selection change / panel
unmount. The session:
- snapshots the before-state in the constructor (via TransformAction)
- on commit, captures after-state and pushes a single undo entry only
if the transform actually changed
- always re-syncs the Zustand objectPanel slice from the engine on
commit so currentSceneObject reflects what the user just committed
ControlPanelSceneObject swaps its TransformAction import + manual
record/commit logic for the session helper. The view only owns the
"when does a session begin/end" boundary now; the action layer owns
the recording + store sync.
Closes the last undo/redo feature-parity gap vs. main. Main captured
panel-input transforms via the mouseup-diff in check_scene_for_updates
(only worked when the user happened to fire a subsequent mouseup);
this branch had no equivalent until now.
Implementation:
- A useRef holds a pending TransformAction. beginPanelTransform()
constructs one on the first detected change for the currently-shown
object — the constructor captures the before-state of the live
scene object before the inputsUpdated useEffect dispatches the
engine write.
- All three change handlers (position / rotation / scale, including
the uniform-scale path) call beginPanelTransform() when an actual
diff is detected.
- The selection-change useEffect's cleanup function runs
commitPanelTransform() — captures the after-state and pushes the
entry only if the transform actually changed. This fires both on
selection switch (panel reloads for a new object) and on unmount.
The TransformAction stashes the uuid at construction, so commit
correctly targets the original object even if the panel has already
reloaded for a new selection by the time cleanup runs.
Resolves the longstanding `// TODO Refactor remove editor.` Drops
the Editor import; constructor takes a SaveManagerDeps surface
inline-built by Editor (Phase 2 idiom — same shape as
CameraControllerDeps, GizmoControllerDeps, etc.).
Deps surface (10 fields, single-purpose):
- getVersion / setVersion — engine version (load may upgrade)
- getActiveScene — proxy serialization target
- getRenderer — for save thumbnail
- removeTransformControls — pre-save cleanup
- getCamera / refreshCamObj / changeRenderCameraAspectRatio — camera
- setPositivePrompt — restore prompt on load
- saveSceneState / loadSceneState — backend API
current_scene_media_token moves into SaveManager as private state
(only the load path used it; nothing outside SaveManager read it).
The field on Editor is dropped along with the two `= null` writes
in the constructor and initialize.
CameraController gains refreshCamObj(scene) — re-anchors cam_obj to
the named entity in the scene + sets layers. Replaces the inline
post-load setup that SaveManager used to do directly. Editor.initialize
also routes through the same method now (was inline at line ~410).
Pure removals, no behavior change. Sets up the SaveManager Deps
refactor in the next commit.
SaveManager:
- Drop unused saveData (the live caller is getSceneJson + saveScene
which goes through saveSceneState directly).
- Drop unused computeSceneChecksum + checkSumData + the
computeHashForBrowser helper. Zero callers anywhere.
- Drop the editor.generating_preview = true/false bracket inside
saveScene. The field has no readers — all the noise about "stop
control panel from flipping out" is dead with VICCCCCCCCCCCCCCCTORRRRRRRR.
Editor:
- Drop generating_preview field (no readers anywhere).
- Drop current_scene_glb_media_token field + the two writes (no
readers anywhere; suspected leftover of an older save format).
Shapes redo'd to broken / invisible objects because the snapshot
captured obj.name = "Cube" (the display label addShape sets after
creation), but scene.instantiate's geometry switch only matches the
geometry key ("Box", "Sphere", "PointLight", ...). Calling
instantiate("Cube", pos) fell through with no matching geometry.
Fix: addShape stashes the geometry key in userData.shapeKey before
overriding obj.name. recreateFromSnapshot picks shapeKey when
present (falls back to snap.name for non-shape paths), then restores
obj.name = snap.name after the create returns so the display label
sticks. Non-shape recreations are unaffected — their userData has no
shapeKey, so the fallback to snap.name keeps the existing flow.
After the migration to LockAction, recording is the call site's
responsibility (Outliner.tsx pushes a LockAction directly). Drop
the recordLockChange dep callback and the bridge's auto-record
inside lockUnlockObject — the bridge stays history-agnostic.
Every recording site now constructs an action class and passes it to
record(). HistoryManager loses all per-kind methods, ctx/HistoryContext,
HistoryEntry, the wrap helper, the legacy entry factories, and the
pendingTransform field — down to a generic stack with one external
method (record) plus undo/redo.
editor.ts shrinks dramatically: the ~70-line inline HistoryContext
deps blob is gone, replaced by `new HistoryManager({ capacity: 64 })`
on its own. Gizmo onDraggingChanged now stores a TransformAction
between dragstart and dragend; commits and records via record(...) at
dragend. SelectionBridgeDeps drops recordLockChange — lock recording
moves to the Outliner call site via new LockAction(...).
Migration map:
- actions/addObject, addCharacter, addShape:
history.recordCreate(obj) → history.record(new CreateAction(editor, obj))
- editor.deleteObject:
history.recordDelete(obj) → history.record(new DeleteAction(editor, obj))
- editor.ts gizmo onDraggingChanged:
begin/endTransform → activeTransform: TransformAction with commit()
- keymap.ts paste:
history.recordCreate(obj) → history.record(new CreateAction(editor, obj))
- comps/Outliner/Outliner.tsx:
lock — new LockAction(selection, uuid, before, after)
visibility — new VisibilityAction(editor, uuid, before, after)
- ControlPanelSceneObject color picker:
history.recordSetColor(...) → history.record(new ColorAction(editor, uuid, before, after))
LockAction and VisibilityAction self-refresh the outliner after
apply/revert, matching the pattern CreateAction/DeleteAction already
use. HistoryManager has no global onReplay refresh hook — actions
that need UI sync handle it themselves.
The "adding a new action" test: a future "duplicate object" action is
just a new file under engine/editor/actions/DuplicateAction.ts
implementing UndoableAction. Zero edits to HistoryManager, zero edits
to editor.ts.
Six self-contained classes under engine/editor/actions/, one per
action kind. Each implements UndoableAction, captures its own state
in the constructor, and runs apply/revert against subsystem refs
held privately. Adding a new kind = a new file here, no edits
elsewhere.
- CreateAction: snapshots an obj, recreates on apply (redo),
removes via utils.deleteObject on revert (undo). Uses the
non-recording delete path so undo of create can't recurse.
- DeleteAction: mirror — removes on apply, recreates on revert.
- TransformAction: encapsulates begin/end transaction state. Caller
constructs at dragstart (captures before), calls commit() at
dragend to capture after + check for a no-op move. records only
if commit() returns true.
- ColorAction: simple before/after pair, routes through
activeScene.setColor.
- LockAction: routes through SelectionBridge.setLockState so the
gizmo attach/detach side effects fire on undo.
- VisibilityAction: writes obj.visible + userData.visible directly.
No call sites use these yet — that's the next commit. HistoryManager
is unchanged; it already accepts UndoableAction via record().
The user critique on the original HistoryManager design was sharp:
adding a new action kind required edits to four places (HistoryContext
interface, deps wiring in editor.ts, entry factory, recorder method).
Anything shared between kinds becomes a chokepoint that grows.
Introduce the proper Command pattern:
- UndoableAction interface — single shape every action class
implements. HistoryManager's external API becomes record(action) +
undo/redo. Future kinds add a new class file under
engine/editor/actions/; HistoryManager itself never grows.
- isReplaying flag — record() no-ops while undo/redo is mid-flight,
so any side-effect chain inside an action's apply/revert (e.g.
delete-undo reaching editor.deleteObject which calls
history.recordDelete) can't poison the stack. Belt-and-suspenders
with the per-kind silent-setter routes the action classes will use.
- engine/editor/actions/snapshots.ts — shared ObjectSnap /
TransformSnap types, snapshot helpers, transformsEqual,
writeTransform, and recreateFromSnapshot. Used by the action
classes added in the next commit.
Legacy recordCreate / recordDelete / begin+endTransform /
recordSetColor / recordSetLocked / recordSetVisible methods stay as
thin wrappers that bind the current HistoryContext into an
UndoableAction via wrap(). Call sites compile unchanged. They're
removed once all sites migrate to record(new XAction(...)).
SelectionBridge.lockUnlockObject toggled userData.locked AND
attached/detached the gizmo, but HistoryContext.setLocked only did
the userData write. After Ctrl+Z the lock flag reverted but the
gizmo state stayed wrong.
Extract the side-effect chain (setObjectLocked + gizmo attach/detach
+ updateSelectedUI) into a private SelectionBridge.applyLockState
helper. Both lockUnlockObject (toggle path) and the new
setLockState (history-replay path) call it. Add a SceneUtils
direct-set helper (setObjectLocked) so applyLockState can target a
specific state instead of toggling.
Wire HistoryContext.setLocked through selection.setLockState so undo
of a lock toggle re-runs the gizmo attach/detach. The
recordLockChange dep stays for now; it moves to the call site in a
later commit when the action layer migrates to LockAction.
Four loader paths in scene.ts passed `position` to the placeholder
manager but never copied it onto the actual loaded object — so a
GLB / MMD / Splat / image / video drop showed the placeholder at the
cursor and then jumped to (0, 0, 0) when the asset finished loading.
Shapes were unaffected because Scene.instantiate already does
`obj.position.copy(pos)`.
Mirror the existing loadObjectFromUrl template (line 553-555):
- loadObject image/video branch — copy position onto the result of
instantiate("Image::"+url)
- loadMMDWithPlaceholder — copy onto mmd before scene.add
- loadSplatWithPlaceholder — copy onto splat before scene.add
- loadGlbWithPlaceholder — copy onto child_result before scene.add
Now that every mutation site records explicitly through HistoryManager,
SceneManager no longer needs to own an undo stack or reverse-engineer
edits from per-mouseup scene-state diffs.
Removed from SceneManager:
- undoStack, undoIndex, lastSceneState, copiedObject is kept
- undo, redo, dropRedoHistory methods
- getSceneState, is_creation, is_deletion, is_userdata, is_transformation
- add_creation_undostack, check_scene_for_updates
- onMouseMove/onMouseClick/onKeyDown/onMouseDown/onMouseUp wrappers
(no callers — pointer events now go canvas → useViewportPointer →
mouse_controls directly)
- attachEventListeners/detachEventListeners (already empty stubs)
- Constructor params: devMode, updateOutliner, is_character (unused
after the gut)
- updateOutliner field — paste() refresh moved to keymap's paste handler
Also:
- engine/Commands.ts deleted — its CreationSceneItem / DeletionSceneItem
/ TransformSceneItem / UserDataSceneItem and supporting types are
obsolete (HistoryManager owns its own entry factories).
- useViewportPointer.onPointerUp reverts to mouse_controls.onMouseUp
directly (the sceneManager indirection only existed to call
check_scene_for_updates).
- Editor.initialize drops attachEventListeners/detachEventListeners
calls and the three trailing constructor args.
scene_manager_api.ts: 477 → 265 LOC. SceneManagerAPI interface trims
to what it actually does — object lifecycle (create/retrieve/delete/
update), selection, outliner rendering. Object Panel transform-input
recording is the one remaining gap from the old diff path; tracked
as a follow-up.
Color picker is the third interactive surface (after gizmo drag and
outliner toggles) that mutates an object. Wire it the same way:
record in the onChange handler (input type="color" fires once per
committed pick, so one undo entry per user choice).
Object Panel transform inputs (position/rotation/scale text fields)
are still on the legacy diff path. Wiring them is one more pass —
they need a coalescer because each keystroke flushes to the engine.
Tracking that as a follow-up.
The gizmo's onDraggingChanged fires once with dragging=true at
dragstart and once with dragging=false at dragend. Wire those
boundaries to history.beginTransform / endTransform so each drag
becomes one coalesced undo entry. No-op moves (mouse-down + drag-up
without movement) are dropped automatically by HistoryManager.
Object Panel inputs and color edits still ride the legacy
check_scene_for_updates diff path; those are migrated separately
before the diff machinery is removed.
Wires creation, deletion, lock, visibility, and paste through the new
HistoryManager. Mutator-recorded — every external call site keeps
the same signature it had before; recording happens inside the mutator
or its dep callback, so callers don't grow new responsibilities.
Recordings added:
- actions/addObject, addCharacter, addShape: editor.history.recordCreate(obj)
replaces the legacy add_creation_undostack call.
- editor.deleteObject: snapshots the target before delegating to
utils.deleteObject so undo can fully reconstruct it.
- SelectionBridge.lockUnlockObject: takes a recordLockChange dep
callback, records before/after locked state inline. No coupling
back to HistoryManager from the bridge.
- Outliner.handleToggleVisibility: records visible before/after at the
call site (hideObject lives on SceneManager which has no history dep).
- SceneManager.paste: now returns the new Object3D so keymap's paste
handler can record it via editor.history.recordCreate. The legacy
add_creation_undostack call inside paste is removed.
keymap.ts: undo/redo target editor.history; deleteSelected routes
through editor.deleteObject (so history records); paste captures the
returned object and records.
Transform recording (gizmo drag, panel inputs) and color stay on the
old check_scene_for_updates diff path until follow-up commits. Old
SceneManager undo machinery (undoStack, undo/redo methods,
add_creation_undostack, the diff helpers) is still in place but only
the diff path drives transforms now.
New peer subsystem under engine/editor/ that owns the undo/redo stack.
Lands the foundation only — old SceneManager-based undo machinery
still drives undo/redo for now. A follow-up commit migrates every
mutation site over and removes the legacy diffing.
Design:
- Pure-data HistoryEntry: { label, apply(ctx), revert(ctx) }. Entries
receive a HistoryContext when replayed; they don't hold subsystem
references. Easy to test in isolation, immune to subsystem
re-creation.
- The external API is the recordX methods (recordCreate, recordDelete,
begin/endTransform, recordSetColor/Locked/Visible). Mutation sites
call ONE method — entry construction stays in the manager. Adding
a future action is "add an entry factory + a recorder method"; no
churn at action sites.
- Async-serialized replay via a Promise chain: a Ctrl+Z mash during
an in-flight asset reload can't interleave entry replays.
- onChange callback hook for a future store-bridge to publish
canUndo/canRedo into PageSceneStore.
- Capacity 64 (vs. main's 16); transform/userdata no-ops are dropped.
Editor wires the HistoryContext inline in its constructor (Phase 2
deps idiom — same shape as CameraControllerDeps etc.). Closures over
`this` resolve sceneManager/activeScene lazily so construction order
inside the editor constructor doesn't matter.
Pre-refactor, the queue handler (timeline.addShape/addObject/addCharacter)
called sceneManager.add_creation_undostack(obj) after creating the
THREE.Object3D. The actions/ extraction collapsed the queue path but
forgot to carry the undo push along, so dragging a new asset onto the
3D canvas left the undo stack untouched.
Push a CreationSceneItem from each of addObject, addCharacter, and
addShape after the engine call succeeds. check_scene_for_updates only
detects transforms / deletions / userdata diffs on mouseup; it does
not record creations, which is why this has to be an explicit call.
useViewportPointer.onPointerUp called editor.mouse_controls.onMouseUp
directly. That ran the interaction state machine (gizmo end, FK toggle,
selection click) but skipped editor.sceneManager.onMouseUp, which is
the only thing that calls check_scene_for_updates. Result: transform,
deletion, and userdata edits (color, lock, visibility) never made it
into the undo stack, so Ctrl+Z found nothing to undo.
Pre-refactor, SceneManager.attachEventListeners attached a window-level
mouseup that went through SceneManager.onMouseUp; the canvas-scoped
hook lost the same pass-through. Calling sceneManager.onMouseUp here
preserves all the mouse_controls behavior (it forwards internally) and
restores the post-mouseup undo capture.
Phase 1 moved camera, freeCamState, and focalLengthToFov off Editor and
onto CameraController, but two camera-update blocks in PageEditor
(handleSelectCamera and handleAddCamera) still read editorEngine.camera,
editorEngine.focalLengthToFov, and editorEngine.freeCamState. Those
fields are undefined now, so the cam-update guards short-circuited and
the reset/random-camera buttons silently did nothing.
Sweep both blocks to read from editorEngine.cameraController.* and
collapse the chain into a local `cam` const.
DndAsset.onPointerUp captured event.pageX/Y but threw them away; both
add* actions then defaulted position to origin. PageEditor's gallery
onImageDrop tried an NDC unproject but gated on editorEngine.camera,
which Phase 1's CameraController extraction left undefined — the guard
short-circuited and worldPosition stayed undefined.
Add pickDropPosition: a deps-pattern helper that mirrors the
pre-refactor timeline.getPos() — raycasts from the camera through the
cursor against scene children and returns the first hit (or origin if
the ray misses or the camera/canvas isn't ready). Same idiom as
CameraControllerDeps / GizmoControllerDeps from Phase 2; callers build
the deps inline so the helper stays decoupled from Editor and any
subsystem's import surface.
Both drop sites — DndAsset.onPointerUp and PageEditor's 3D gallery
onImageDrop — now call the helper and forward worldPosition to
addCharacter / addObject / addShape.
create_parim was a one-line passthrough to activeScene.instantiate
that the pre-refactor queue path used. After actions/addShape was
rewired to call sceneManager.create with the "Parim" sentinel,
create_parim has zero callers anywhere in the artcraft frontend.
actions/addShape passed item.media_id ("Box", "Sphere", ...) as the
routing token to SceneManager.create. That hits the
loadObject(media_token, ...) branch and tries to fetch a media URL
for "Box", which fails — the shape never lands in the scene.
Pass "Parim" as the routing sentinel and item.media_id as the
geometry name so create() forwards to scene.instantiate(name, pos),
which has the geometry switch ("Box" -> BoxGeometry, etc.). Then
overwrite obj.name with the display label so the outliner reads
"Cube" instead of "Box".
Restores the pre-Phase-4 behavior of editorEngine.create_parim(media_id, pos)
that the queue path used.
- Move sound catalog registration off PageScene mount and into a single
SoundManager.install() call in GlobalSettingsManager so sounds exist at
app boot (previously silent until PageScene mounted).
- Honor the play_sounds master toggle in all five event playback sites
via SoundManager.playEvent helpers. Settings preview keeps firing
regardless (handled by SoundManager.playPreview).
- Replace per-callsite GetAppPreferences + SoundRegistry boilerplate in
the four generation event hooks and the file-deleted handler with
one-line SoundManager.* calls.
- Dedupe SOUND_OPTIONS: AudioSettingsPane now reads
SoundManager.OPTIONS, derived from a single declarative SOUNDS array
(label-bearing entries are user-selectable; others are still registered
for code-driven use).
- Narrow the soundboard public barrel to SoundManager + SoundOption
only; SoundRegistry and SoundEffect are now lib-internal.
The last batch of PageScene-owned signals — `is3DPageMounted`,
`is3DEditorInitialized`, `is3DSceneLoaded`, and the three canvas-ref
signals (sceneContainer, editorCanvas, camViewCanvas) — now live on
the Zustand store. EngineProvider, the canvas components, the
useEditorCanvas hook, and the cross-app TopBar consumer all read from
the store instead. The helper signals in editor.ts are gone; the
`set3DPageMounted` / `setIs3DEditorInitialized` / `setIs3DSceneLoaded`
exports stay as thin wrappers around the store actions for existing
callers.
PromptBox3D and CameraSettingsModal now accept plain values + setters
instead of `Signal<T>` props. The camera/focalLengthDragging/
isPromptBoxFocused signals are gone — store is the single source of
truth. Engine code (editor/save_manager/scene) reads/writes via
`usePageSceneStore.getState()` and store actions. Demo asset signals
are now plain constants (they were never reactive).
api_manager.ts goes from 460 lines to ~135. The video-render
pipeline (uploadMediaFrameGeneration, stylizeVideo,
StylizeVideoParams, getMediaBatch, getMediaFile, uploadMedia,
gltfExport) was unreachable after videoGeneration was deleted.
Surviving public methods:
- saveSceneState (called by save_manager)
- loadSceneState (called by save_manager)
- uploadMediaSceneThumbnail (used internally by saveSceneState)
Also drops the local ArtStyle / Visibility / MediaFile types —
they only fed the deleted methods. The ArtStyle enum has a
separate copy in ~/enums/ArtStyle.ts that other code uses.
Delete two files that became unreferenced:
- engine/APIManagerResponseError.ts (duplicate of the local class
in api_manager.ts; nothing imported it)
- models/generationOptions.ts (IGenerationOptions/GenerationOptions
fed editor.generation_options, deleted last commit)
Net: -1 typecheck error, -326 lines.
Files with zero importers across the artcraft app:
- constants/RandomTexts.ts — RandomTextsPositive/Negative seeded
prompt suggestions in the deleted StylizeTab.
- composable_engines/scene_format.tsx — empty (1 line).
- constants/page.ts and the matching currentPage signal in
signals/global.ts — Pages enum + signal, neither read.
- enums/StylizeTabPages.ts and enums/AudioTabPages.ts — re-exported
via enums/index.ts but no consumer.
- models/track.ts, models/message.ts, models/voice.ts, models/tts.ts
— timeline / TTS / audio-panel types from deleted features.
- styleList.ts — used to seed selectedArtStyle's default; that
store field is gone.
- models/types.tsx had a duplicate AudioTabPages enum (separate
from the deleted one in enums/) — drop the duplicate too.
models/index.ts and enums/index.ts trimmed to match. Error count
unchanged (151) — none of these had broken type errors anyway.
Strip the stylize fields that nothing reads anymore now that the
StyleMoreOptions / StyleSelection UI is gone. From PageSceneStore:
selectedArtStyle, upscale, faceDetail, styleStrength, lipSync,
cinematic, enginePreProcessing, globalIPAMediaToken, adapterImage,
positivePrompt, negativePrompt, isUserInputPositive,
isUserInputNegative, showNegativePrompt — and the matching setters
+ resetSceneGenerationMetadata. Drop the styleList / ArtStyle
imports; both go unused here.
From editor.ts: drop negative_prompt, art_style,
engine_preprocessing, generation_options, globalIpAdapterImage.
Keep positive_prompt — PromptBox3D writes it via setEnginePrompt
(PageEditor.tsx) and save_manager round-trips it.
Trim save_manager.loadFromJson to the prompt-only path (legacy
saves with stylize keys load fine; the now-removed if-guards just
silently ignored them anyway). Trim SceneMetadata to
{ positivePrompt, cameraAspectRatio } and the matching
SceneGenereationMetaData interface.
ControlsTopButtons.handleResetScene drops its now-removed store
call.
Both component trees survived earlier cleanup but never render
from PageEditor or PageScene. They were the last UI consumers
of the stylize store fields; deleting them sets up a follow-up
that drops those store fields entirely.
Net: -17 typecheck errors (the orphan files were the source of
many missing-import errors).
cardLayout.ts, MoodboardHtmlColorPicker.tsx, findDropTarget.ts, and
CardNodeHtml.tsx were swept into the PageEnigma -> PageScene rename
commit (76add858c9) on this 3D-refactor branch. They belong on
moodboard-page-html.
The file content is preserved on the local-only moodboard-rescue
branch (pointing at 01e0e27037). To carry them back over:
git checkout moodboard-page-html
git checkout moodboard-rescue -- frontend/apps/artcraft/app/src/pages/PageMoodboard/cardLayout.ts frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/MoodboardHtmlColorPicker.tsx frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/interactions/findDropTarget.ts frontend/apps/artcraft/app/src/pages/PageMoodboardHtml/nodes/CardNodeHtml.tsx
git commit
The Enter Pose Mode button reached through a window.__mouseControls
global handle that MouseControls' constructor set up. With
getActiveEditor() already exposing the live editor instance for
non-React callers (used in the camera-view toggle, the keymap),
the global is unnecessary.
Drop the (window as any).__mouseControls = this assignment in
MouseControls and the matching Window augmentation in
PoseModeSelector. Replace the click handler's reach with
getActiveEditor()?.mouse_controls?.toggleFKMode() — same shape
the keymap entry already uses.
The Tab interface in this file was the type for the deleted
SidePanel/tabList.tsx tab list. Grep across app/src/ confirms no
consumer remains; AssetModal uses its own local AssetTab type.
Drop the file and the matching re-export in models/index.ts.
EditorStates is no longer referenced from editor.ts now that
PREVIEW is gone from the enum and switchPreview/switchEdit are
deleted. EngineCanvases.tsx never used useContext/EngineContext;
the imports were leftover from earlier work.
Net: -3 typecheck errors.
The mouseOverEventHandler / enableFreeCamControls /
disableFreeCamControls trio existed to track when the cursor was on
the viewport canvas and toggle FreeCam.enabled. With useFreeCam
gating itself on editorState === CAMERA_VIEW, that gate is
redundant; the only remaining consumer of selectedCanvas was
MouseControls.onkeydown's space-bar branch, which is unreachable
after the previous keyboard rewrite.
Removes the last document.addEventListener call from PageScene.
Replace MouseControls.onkeydown's giant if-else chain with a
declarative keymap:
- engine/keymap.ts: KeyBinding type + buildKeymap() returning every
one-shot viewport shortcut as one entry. Each carries label /
group metadata so a future Ctrl-hold cheatsheet overlay can read
the same array. dispatchBinding() does the modifier match
(treating Ctrl and Meta as equivalent for macOS).
- hooks/useViewportKeyboard.ts: attaches a single keydown listener
that gates on hotkeyStatus + editable-element checks, then walks
the keymap. Held movement keys (W/A/S/D etc.) live in useFreeCam
since they're continuous; this hook is for one-shots only.
The listener is on document, not the canvas, deliberately: canvas
focus is finicky and would require the user to click the viewport
before T/R/G work. The editable-element guard prevents the keymap
from firing while inputs are focused.
MouseControls.onkeydown is now unreachable (SceneManager's window
listeners are no-op since the previous commit, and useViewportKeyboard
goes straight to keymap dispatch). The class itself stays for now —
it still implements selection / FK / pointer-lock state.
The old SceneManager.attachEventListeners() attached window-level
mousemove / click / mousedown / mouseup / keydown handlers in dev
mode and dispatched them into MouseControls. Replace with two pieces:
- hooks/useViewportPointer.ts: canvas-scoped pointer listeners
that call into editor.mouse_controls. NDC pointer coords are
computed via the new engine/pointer.ts helpers (ndcFromClient,
applyNdcToVector2). Selection clicks suppress while the
free-cam owns input (CAMERA_VIEW state).
- engine/pointer.ts: pure NDC coordinate translation, no DOM
side-effects.
SceneManager.attachEventListeners / detachEventListeners are now
no-ops; kept for back-compat with the editor lifecycle so future
follow-ups can carve them out cleanly.
MouseControls itself still owns selection / FK / drag state — its
dispatch methods (onMouseMove, onMouseClick, etc.) are now invoked
by the React hook instead of window listeners. A future PR can
dissolve MouseControls further; the goal here is "no window
listeners" + "React owns lifecycle".
The 446-line FreeCam class is gone. Its responsibilities split:
- Math (move/rotate vectors, pan/zoom from inputs, lookAt) lives in
cameraMath.ts as pure functions, plus a freeCamFrameTick() that
integrates the per-frame state.
- Listener attachment + drag/key state ownership moves into
hooks/useFreeCam.ts. Listeners are scoped to the canvas (no
window/document handlers); held-key state is reset on
CAMERA_VIEW exit so a stale W keypress can't drift the camera.
- The shared FreeCamControlState struct flows through Editor via
editor.setFreeCamState(state); the render loop reads it and
calls freeCamFrameTick() each frame.
Side effects: keybinds_controls.ts now imports
isPointerLockSupported from browserChecks; helper.ts and
PageEditor's camera-select handler stop poking
.cameraViewControls and reset velocity via freeCamState instead.
PreviewEngineCamera's camera-view toggle now just flips the store
state — useFreeCam picks it up on its own (no more
enableFreeCamControls/disableFreeCamControls call site).
Pure-function helpers carved out of FreeCam in preparation for the
hook rewrite:
- engine/cameraMath.ts: HeldMoveKeys / HeldRotateKeys types,
moveVectorFromKeys, rotationVectorFromKeys, panFromDrag,
zoomFromWheel, lookAtFromCamera, lerpVelocity. No DOM access, no
signals.
- engine/browserChecks.ts: isSafariOrWebKit, hasApplePay, isSafari,
isPointerLockSupported. Unrelated to camera math; were just
hitchhiking in free_cam.ts.
free_cam.ts re-exports the browser checks for back-compat with
keybinds_controls.ts; both consumers will switch to the direct
imports when free_cam.ts is deleted in the next commit.
The Phase 6 hooks (useFreeCam, useViewportPointer, useViewportKeyboard)
need a React-shaped accessor for the canvas DOM node so their
useEffects re-run when the canvas mounts/unmounts. Promote the
existing editorCanvasSignal / camViewCanvasSignal into useEditorCanvas
/ useCamViewCanvas hooks that bridge signal -> React state via a
subscribe.
Add tabIndex={0} and outline:none to the editor canvas so it can
receive keyboard focus without showing a focus ring.