Skip to content

Desktop ergonomics: zoom, panel resize, motion bar, exporter tagging - #245

Open
OmarB97 wants to merge 9 commits into
lightningpixel:mainfrom
OmarB97:feat/desktop-ergonomics
Open

Desktop ergonomics: zoom, panel resize, motion bar, exporter tagging#245
OmarB97 wants to merge 9 commits into
lightningpixel:mainfrom
OmarB97:feat/desktop-ergonomics

Conversation

@OmarB97

@OmarB97 OmarB97 commented Jul 28, 2026

Copy link
Copy Markdown

Summary

Ports a set of desktop ergonomics fixes into real source. These were previously applied to the built app as post-hoc patches against the extracted AppImage; every one of them is now a normal source change, so a future app update no longer silently erases them. Builds on top of #242 (animated model playback) and #243 (Library thumbnails and resize), both merged in unchanged.

What changed

  • Window zoom: Ctrl/Cmd with + / - / 0 scales the whole window like a browser, clamped to [-2, 4] and persisted to <userData>/ui-zoom.json. Implemented in the main process against webContents.setZoomLevel, not CSS zoom, because a CSS transform does not tell the 3D viewport it happened, and pointer math there (raycasting, gizmo dragging) then reads wrong coordinates.
  • Generate panel resize: the handle was a 1px, resting-invisible strip capped at 520px. It is now 2px with a visible resting tint plus a stronger hover/active highlight, marked up as a real separator (role, aria-orientation, title), capped at 900px, and its width persists to localStorage the same way the Library panel already does.
  • Parameter labels: the label column truncated anything longer than a couple of words. Widened and now wraps instead of clipping.
  • Prompt textarea: 14 rows by default (was 3) and user-resizable (resize-y, 14rem min-height); was resize-none.
  • Section headings: the image input reads "Your sketch or photo — required" and the text input reads "Extra details — optional", instead of the unlabeled "Image" / "Text".
  • Motion bar: the clip picker was a raw DOM <select> injected at the model origin, hard-cutting between clips by tearing down and recreating the whole AnimationMixer. Rebuilt as a real React overlay (bottom-left, matching the existing toolbar/stats-overlay pattern): the mixer now lives for as long as the model does, clip switches cross-fade over 0.25s, and the clip list is reported up to the viewer the same way mesh stats already are. Zero clips renders nothing, one clip shows its name read-only, more than one gets a dropdown plus a "Next" button. All playback loops.
  • Exporter naming, grouping, and tagging: the built-in mesh-exporter gains model_name, project, and tags params. Files land at <Exports>/<project-slug>/<name-slug>-<4-char-unique>.<ext>, falling back to a dated name when unnamed. Tags are what the operator typed plus what the file itself can tell (rigged, animated + clip-<name> per clip, textured, a poly-count tier, the project slug), written both as a <file>.tags.json sidecar and embedded under extras.modly in the glTF itself. Ported from the already-working implementation running on the operator's machine, not reinvented.
  • New: tag suggestion pills: the tags field in the Generate form now shows clickable suggestion pills underneath it — slugified words from that node's typed name/project fields, plus a small fixed game-asset vocabulary (prop, character, creature, environment, weapon, furniture, hero-asset, background, low-poly, stylized, realistic), deduped and capped at 10. Clicking toggles the tag in the comma-separated value. Fully client-side, no network calls.
  • Added a text param type (distinct from string, which renders a folder picker) so free-text fields like the exporter's new ones render as plain inputs instead of falling through to a numeric one.

Test plan

  • npm install && npm run build — clean, no errors.
  • npm run lint — 0 problems.
  • npm test — 23/23 Python tests, 40/40 Node tests, all passing.
  • Packaged with npm run package (electron-builder, AppImage target) and installed as the actual app the operator launches daily, replacing the previous extracted build (backed up first).
  • Verified on the real desktop, not just from logs:
    • Zoomed in/out with Ctrl/Cmd +/-/0; UI visibly scaled and ui-zoom.json updated on disk each time.
    • Dragged the generate panel wider; restarted the app; width was still there.
    • Dragged the Library panel wider (pre-existing Add Library thumbnail previews and a resizable panel #243 behavior); still works after the merge.
    • Library rows show real .thumb.png thumbnail images, not placeholders.
    • Opened a two-clip rigged model (squid-seamless-loop.glb); motion bar showed a dropdown with both clip names (idle_breathe, startle_flick) and a Next button; clicking Next cycled and cross-faded between them, then wrapped back around.
    • Export section shows the three new fields as working text inputs, plus the tag suggestion pills underneath the tags field, capped at 10 as designed.

Risk / rollback

All changes are additive to existing components; nothing upstream of these files changes behavior for models/flows that do not touch them. The riskiest piece is the motion bar rewrite (GltfMeshModel in Viewer3D.tsx) since it replaces the mixer lifecycle from #242 rather than only extending it — verified above with a real two-clip model, including the cross-fade and wraparound. If something regresses, reverting this branch on top of #242+#243 restores the prior (single mixer teardown per clip, DOM <select>) behavior.

Notes for reviewers

OmarB97 and others added 9 commits July 27, 2026 15:52
Rigged models (from the rigging extensions, or any GLB with clips) render
frozen in bind pose today: the viewer loads the glTF but never creates an
AnimationMixer, so the clips it just parsed never run. That reads as a
failed rig rather than a missing feature.

Create a mixer for models that carry clips, tick it from useFrame, and
dispose it with the model. When a file carries more than one clip, offer a
small picker so each motion can be reviewed without leaving the app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Asset rows now render their pre-rendered <name>.thumb.png beside the
filename instead of showing bare text, falling back to the existing
text-only row when no thumbnail exists. The Library panel can also be
resized by dragging its edge, with the chosen width persisted across
restarts.
npm ci fails on main because the committed lockfile is stale against
package.json; npm install (what upstream CI itself falls back to) brings
it back in sync. Committing the result so ci is usable again on this
branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ctrl/Cmd with +, -, or 0 now scales the whole window like a browser,
clamped to [-2, 4] and persisted to <userData>/ui-zoom.json so it survives
restarts. This extends the existing before-input-event handler (where the
macOS quit shortcut already lives) and calls webContents.setZoomLevel
directly, deliberately not CSS zoom: a CSS transform scales the rendered
pixels without telling the 3D viewport, so pointer math there (raycasting,
gizmo dragging) goes wrong as soon as the page is scaled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The panel's resize handle was a 1px strip with no resting affordance —
easy to miss entirely. Widen it to 2px with a visible resting tint plus a
stronger hover/active highlight, mark it up as a proper separator
(role, aria-orientation, title) matching the Library panel's handle, raise
the width cap from 520 to 900 so wide parameter forms have room, and
persist the chosen width to localStorage (modly-panel-width) the same way
the Library panel already remembers its own width.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Parameter labels used a truncating 5rem column that clipped anything
  longer than a couple of words; widen it to 8rem and let it wrap.
- The prompt textarea was a fixed 3-row, non-resizable box; make it 14
  rows by default and user-resizable (resize-y, 14rem min-height).
- Say plainly which Generate inputs are required vs optional: "Your
  sketch or photo -- required" and "Extra details -- optional".
- Add a text param type (a plain input, unlike the existing string type
  folder picker) so free-text fields like the exporter new name, project,
  and tags params render as usable text boxes instead of falling through
  to a numeric input.
- Render clickable tag-suggestion pills beneath any tags param: slugified
  words from that node typed name/project fields, plus a small fixed
  game-asset vocabulary, deduped and capped at 10. Clicking a pill toggles
  it in the comma-separated value; no network calls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The clip picker was a raw select mounted via drei Html at the model
origin, recreating the whole AnimationMixer every time the clip changed
(a hard cut, not a blend) and easy to lose behind the scene. Replace it
with a proper bottom-left overlay component (MotionBar), matching the
existing ViewerToolbar/stats-overlay idiom:

- The mixer and active AnimationAction now live for as long as the model
  does; switching clips fades the old action out and the new one in over
  0.25s instead of recreating anything.
- GltfMeshModel reports its clip list up to Viewer3D (the same
  onStats/onObject callback pattern already used here), which owns the
  active index and renders MotionBar as a plain sibling of the Canvas.
- Zero clips renders nothing, exactly one clip shows its name read-only,
  more than one gets a labelled dropdown plus a Next button that cycles
  with a cross-fade. All playback loops.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The built-in exporter named every file export-<epoch-ms>.glb, so a folder
of work told you nothing. Add three optional params: model_name (used to
slug the file name, falling back to a dated model-YYYY-MM-DD-HHMM name),
project (files land under Exports/<project-slug>/ so a game groups
together instead of one long list), and tags. Output is always
<slug>-<4-char-unique>.<ext> so re-exports never collide.

Tags are the operator typed list plus what the file itself can tell:
rigged (has skins), animated plus clip-<name> per animation, textured,
and one of low-poly/mid-poly/high-poly by triangle count, plus the
project slug. Tags are written both as a <file>.tags.json sidecar and
embedded under the glTF asset extras (extras.modly), so they travel with
the file either way.

Ported from the already-working implementation at
/mnt/tank/modly/app/extracted/resources/builtin-extensions/mesh-exporter
on the operator machine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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