Skip to content
This repository was archived by the owner on May 19, 2026. It is now read-only.

Builder workbench + dock primitives + FPV refinements#65

Merged
apresmoi merged 77 commits into
mainfrom
refactor/gallery-workbench-and-fpv-controls
May 17, 2026
Merged

Builder workbench + dock primitives + FPV refinements#65
apresmoi merged 77 commits into
mainfrom
refactor/gallery-workbench-and-fpv-controls

Conversation

@apresmoi
Copy link
Copy Markdown
Collaborator

Summary

  • Dock refactor: extracted Dock primitives (useGui, useFolder, addToggle/Slider/Color/Option/Button) and lifted each domain (camera, lighting, rendering, model, animation) into its own folder hook. Per-page dock composition now picks the slots it needs — Gallery and Builder consume the same primitives.
  • Builder workbench (/builder): click-to-place from a kits sidebar (City Kit / Urban Pack / Medieval Village) with cursor-tracked dotted ghost wireframe, composable scene presets (City Block, City Roads), floating tool palette (Pointer / Raise / Lower / Smooth), Vertex/Face target toggle, and an Orbit/Pan camera-mode pill (Cmd-hold to temporarily pan on Mac).
  • Terrain editor: vertex-based sparse heightmap; the floor grid IS the terrain (gridlines bend at raised vertices, no separate fill mesh). Placements snap to the surface and tilt to the local slope. The slope rotation now uses a cell-average best-fit plane (stable across the cell's triangulation diagonal). Objects re-anchor to the floor on scale, gizmo translate, and terrain edits.
  • FPV refinements: distance-based render-distance culling (slider), spawn-on-engage, lazy load proximate scene items. Library now owns the .polycss-fpv-host perspective injection (React + Vue + vanilla mirrors).
  • Polish: header gains a Builder link; Astro dev toolbar disabled; misc fixes to capture-phase listeners, click-vs-drag discrimination, and the opacity-flattens-3D trap.

Coverage

  • Packages (core / polycss / react / vue) — full vitest suites pass; 7 of the 35 package files in this PR are tests (camera, cull, parser, dock helpers).
  • Website — no automated coverage (consistent with repo convention). The /builder work is exercised by hand. pnpm test && pnpm build are both green.

Test plan

  • /builder loads, kits sidebar populated; click-to-place lands a ghost-previewed mesh on the floor.
  • Scale slider keeps the mesh bottom anchored to the surface.
  • Gizmo translate keeps the mesh on the surface and re-tilts to the local slope; gizmo rotate is unaffected.
  • Raise/Lower/Smooth with both Vertex and Face targets warp the grid; placed items follow the surface on every edit.
  • Scene presets (City Block, City Roads, Medieval Village) drop without errors; FPV walks the result with render-distance culling.
  • Header shows Builder; active state highlights on /builder*.
  • Gallery dock still works (camera, lighting, rendering, model, animation folders).

apresmoi added 30 commits May 16, 2026 06:37
…FPV spike

- DebugWorkbench.tsx (5977 LOC) → GalleryWorkbench.tsx (702 LOC) plus
  scoped subfolders: types.ts, presets/, helpers/, hooks/.
- New components/ folders: AttributionCredit, Dock, DropOverlay, Inspector,
  ModelsSidebar, ReactScene, StatsOverlay, VanillaScene, GalleryWorkbench —
  each Folder/Folder.tsx + index.ts barrel.
- Drop deprecated src/debug/ tree and src/pages/debug/ subroutes; gallery
  is the only debug surface now.
- New createPolyFirstPersonControls in @layoutit/polycss: WASD/arrow walk,
  pointer-lock mouselook, Space jump, Ctrl crouch, per-axis enable flags.
  Wired into the gallery as a third dragMode option. Walking + mouselook
  still need calibration — that lands in a follow-up.
…text

The polycss scene element owns its own `perspective` CSS property, but
`.dn-vanilla-host` is `transform-style: flat` — so when target translates
in Z, the scene wrapper moves invisibly and polygons inside never get
re-foreshortened. Adding `perspective: 2000px; transform-style: preserve-3d`
to the host (scoped to FPV via `data-camera-mode="fpv"` on .dn-root)
unlocks real 3D walking: WASD now actually approaches/recedes from the
model, not just slides the scene in 2D.

Also: spawn FPV camera one mesh-span behind the model (instead of inside
it) along the orbit's previous look direction.
The FPV controls now maintain an internal `cameraOrigin` (camera world
position) and DERIVE the scene's `target` as
  target = cameraOrigin + lookDir * (perspective / tile)
so the polycss perspective viewer mathematically coincides with the camera
origin in world space.

Mouselook rotates `target` around the FIXED `cameraOrigin` (in-place head
rotation), instead of orbiting some point in front of the camera. WASD
moves `cameraOrigin` (target follows via the same offset). This matches
three.js PointerLockControls semantics exactly.

Also expose getOrigin/setOrigin on the FPV handle so callers (tests, spawn
hooks, teleporters) can read/write the camera position directly without
having to invert the derived-target math.

Bonus: expose more perspective values in the gallery dropdown (500 / 16k /
32k / 64k px) so it's easier to find a comfortable foreshortening.
FPV's lookOffset = perspective / tile assumes the scene-element perspective
and the host-element perspective are the same number. With the host CSS
hardcoded to 2000px, raising sceneOptions.perspective (e.g. to 64000)
pushed target onto a 1280-world-unit lever arm while the host viewer plane
stayed at 2000px. Result: the scene flew past the camera in Z, and pitching
on mouselook caused dramatic content drift instead of in-place rotation.

Fix: expose --fpv-perspective on .dn-root (driven by sceneOptions.perspective)
and have the FPV-scoped host rule read it. Now host and scene agree on
where the eye is, so mouselook stays in-place at any perspective value.
Native implementations of the FPV controls in @layoutit/polycss-react and
@layoutit/polycss-vue, matching the vanilla createPolyFirstPersonControls
behavior:
- cameraOrigin maintained internally; target derived as
  origin + lookDir * (perspective/tile) so the perspective viewer sits at
  origin in world space (in-place mouselook).
- WASD walks the origin; jump/crouch shift origin.z; mouselook on pointer-
  lock rotates target around the fixed origin.
- Same prop names, defaults, and handle shape (getOrigin/setOrigin/lock/
  unlock/update/etc.) across vanilla / react / vue.

React: 17 new tests, total 329 pass.
Vue: 17 new tests, total 329 pass.
Both packages depend only on @layoutit/polycss-core, matching the existing
controls pattern.
Add direct assertions for the core FPV invariant
  target = origin + lookDir(rotX, rotY) * (perspective / BASE_TILE)
plus the mouselook-keeps-origin-fixed property (in-place rotation, not
orbit). The behavior tests prior covered movement direction but didn't
encode the math identity as a unit-test failure — these do.

Coverage:
- polycss: +5 (numerical identity under attach, yaw sweep, pitch sweep,
  origin-fixed during a 30-step mouselook, lookOffset scales with
  sceneOptions.perspective).
- react: +2 (setOrigin matches getOrigin, target sits at perspective/tile).
- vue: +2 (same shape, plus checking |target - origin| across 500/2000/16k
  perspective values).

Totals: polycss 370 (+5), react 331 (+2), vue 331 (+2). Build green.
A new gallery-style workbench where users add presets from a sidebar,
drag them around with a transform gizmo, and remove them with Delete.
Reuses the gallery's components (ModelsSidebar, StatsOverlay) and
preset library verbatim.

State shape: placedItems[] = {id, preset, polygons, position, rotation,
scale}. Sidebar click = load preset + push. Click mesh or outliner row
to select. Selected item gets a PolyTransformControls in translate
mode; onObjectChange flows through into placedItems.

Known v1 limitations (follow-ups):
- Per-mesh scale isn't normalized; raw OBJ vertex extents differ wildly
  across presets so the user may need to scale them manually.
- 'Focus on selected' (re-frame the orbit target onto a clicked item)
  isn't wired up yet.
- Only translate mode; rotate/scale gizmo toggle TBD.
- Layout serialization (URL/localStorage) TBD.
Two bugs in v1:
1. Mesh sizing — parser `targetSize` isn't applied consistently across
   OBJ/GLB/VOX, so Cat dwarfed Apple. Now every loaded mesh is rebbox'd
   and scaled to NORMALIZED_MAX_DIM world units after parse, regardless
   of how the source was authored.
2. Placement coordinate system — PolyMesh.position is raw CSS-px
   (translate3d) while the orbit target was passed in world units, so
   the spawn was mixing scales. Now placements happen on a 3-wide world
   XY grid, with worldToCssPosition() applying polycss's world→CSS axis
   swap (world Y → CSS X, world X → CSS Y, *BASE_TILE) before handing
   off to PolyMesh.

Result: adding Apple/Bread/Cat produces a neat row of equally-sized
meshes instead of a pile of vastly different scales.
Layout:
- Scene outliner moved from bottom-left to top-right.
- Selection panel split into its own block below the outliner. Shows
  gizmo-mode toggle (Translate / Rotate), scale slider (0.1×–5×, written
  directly to PolyMesh.scale prop), and position/rotation readouts.
- Camera-mode pill at bottom-center: Orbit / Pan / FPV. Conditionally
  renders PolyOrbitControls / PolyMapControls / PolyFirstPersonControls.

Scale: PolyTransformControls only supports translate + rotate, so scale
isn't part of the gizmo — it's a slider that writes the mesh's scale
prop directly. Range 0.1–5×, step 0.05.

The selection panel only renders when an item is selected. Outliner
remains visible at all times (or shows an empty-state hint).
Pass selected.scale through to PolyTransformControls.size so the
translate-arrows / rotate-rings track the visible footprint of the mesh.
Without this a scaled-up mesh hides the gizmo inside it, and a
scaled-down mesh has a gizmo bigger than the mesh itself.

size is a raw multiplier on gizmo geometry — same axis-length unit the
mesh uses for its scale prop, so 1:1 keeps them in sync.
The user reported textured meshes 'not loading textures'. Debugging via
Playwright showed 30 <s> sprite tags with valid blob URLs in the DOM —
textures DO load. The issue was that NORMALIZED_MAX_DIM=4 world units
plus DEFAULT_ZOOM=0.18 gave roughly 36 CSS-px of mesh on screen, where
bitmap textures degrade to a muddy color blob.

- Normalize meshes to 8 world units (was 4) — 2× bigger render.
- DEFAULT_ZOOM 0.3 (was 0.18).
- Grid step bumped to 10 to keep the 25% gap around larger meshes.

Also: textures work fine when polygons have polygon.texture set (which
loadPresetModel does); no rendering pipeline change was needed.

Replace vertex-rewrite normalization with PolyMesh.scale composition
(fitScale × user scale) so texture triangles / atlas refs stay attached
to their original vertex positions — the earlier vertex-rewrite path
would have desynced atlas UVs from vertices.
DocsHeader hides the sidebar-search-dock on /gallery (workbench layout)
because Starlight's floating search icon collides with the workbench's
own search input. /builder uses the same layout pattern — extend the
guard to also match /builder, matching the gallery's behavior exactly.
Builder now uses the same Dock component as the gallery, with all its
Model / Rendering / Interaction / Camera / Lighting folders. State shape
is a complete SceneOptionsState wired through to the React renderer:

- Camera mode (orbit/pan/fpv) lives in the Dock's Camera folder; the
  custom builder pill is gone.
- Lighting (azimuth, elevation, color, intensity, ambient) goes through
  directionalFromOptions / ambientFromOptions like the gallery.
- Texture lighting, quality, edge-repair, strategies all controllable.
- Auto-center, show axes, show light helper all work.
- FPV with proper origin-tracking, perspective, and the rest still work
  via the Dock's FPV sub-folder.

The only builder-specific UI:
- Scene outliner at the very top-right (above the Dock) — list of placed
  items + delete-on-click.
- Selection panel inline inside the outliner — Translate/Rotate gizmo
  toggle + per-mesh scale slider (PolyTransformControls doesn't support
  scale gizmo, so it's a slider).

Stubs supplied to Dock for animation / loaded-model / metrics / preset
fields that don't apply to the multi-mesh builder workflow.
Dock now accepts an optional topSlot ReactNode that renders INSIDE the
.dn-floating-controls container, above the lil-gui folders. The builder
passes its Scene outliner (placed-items list + per-mesh gizmo toggle +
scale slider) via this slot.

Result: one unified right-column panel with Scene → Model → Rendering →
Interaction → Camera → Lighting, sharing the same chrome / scroll
context. No more visually-different secondary panel floating above the
Dock; the builder reads like the gallery with an extra section on top.

Gallery doesn't pass topSlot — its layout is unchanged.
…ng/Animation/Interaction/Camera/Lighting/Scene)
…bench-and-fpv-controls

# Conflicts:
#	website/src/components/Dock/Dock.tsx
#	website/src/components/Dock/index.ts
#	website/src/components/GalleryWorkbench/GalleryWorkbench.tsx
#	website/src/components/GalleryWorkbench/helpers/cssValues.ts
#	website/src/components/GalleryWorkbench/helpers/domMetrics.ts
#	website/src/components/GalleryWorkbench/helpers/interiorFill.ts
#	website/src/components/GalleryWorkbench/hooks/useScenePolygons.ts
#	website/src/components/ReactScene/ReactScene.tsx
#	website/src/components/VanillaScene/VanillaScene.tsx
#	website/src/components/types.ts
…fpv-controls

# Conflicts:
#	website/src/components/Dock/Dock.tsx
…ring/Animation/Interaction/Camera/Lighting/Scene children
apresmoi added 29 commits May 17, 2026 17:27
…-workbench-and-fpv-controls

# Conflicts:
#	website/src/components/BuilderWorkbench/BuilderWorkbench.tsx
#	website/src/components/BuilderWorkbench/builder-workbench.css
@apresmoi apresmoi merged commit 4f85c4d into main May 17, 2026
1 check passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant