Skip to content

feat(web): render mindwalk's Tree and Terrain scenes against a T3 citymap - #16

Merged
Dillpickleschmidt merged 3 commits into
mainfrom
feat/3d-scenes-verbatim
Aug 1, 2026
Merged

Dillpickleschmidt merged 3 commits into
mainfrom
feat/3d-scenes-verbatim

Conversation

@Dillpickleschmidt

@Dillpickleschmidt Dillpickleschmidt commented Aug 1, 2026 •

Copy link
Copy Markdown
Owner

The baseline for the 3D surfaces: mindwalk's two scenes rendering this repo's citymap inside the T3 web app, verified screenshot-for-screenshot against a live mindwalk serve — same structure, palette, labels, and camera behaviour in both views.

scene/, playback/, and types.ts are copied near-verbatim from .repos/mindwalk (MIT, © 2026 Ricko Yu, pinned at v0.3.0) under apps/web/src/mindwalk/. Deviations from byte-identical are ~20 non-null assertions and 2 casts forced by this repo's stricter tsconfig, plus the pre-commit autoformat — all behaviour-preserving. Adds three@0.182 and a throwaway /mindwalk-3d?threadId=<id> route that lazy-loads the scenes into their own chunk and feeds them from GET /api/citymap/threads/:threadId in static-map mode.

Verbatim is deliberate. Translation bugs and intentional changes arriving in the same diff is exactly what makes 3D ports expensive to debug, so this lands the known-good copy first; adaptation is a separate ticket.

Two things a reviewer should know

mindwalk does not use react-three-fiber. The scenes are plain three.js driving their own requestAnimationFrame loops. Planning for this work assumed r3f, and that assumption is now corrected on the map.

The render loop is unconditional, and that is a known gap, not an oversight. CityScene.tsx:328 renders every frame for as long as the component is mounted, with a Math.sin firefly pulse, and only cancels on unmount. AGENTS.md says "No continuously repainting animations; they peg the GPU on high-refresh displays." Fixing it here would have meant deviating from verbatim in the very commit whose value is being verbatim.

Nothing ships to users from this PR: the surface picker is untouched and /mindwalk-3d is a development route. The gap is tracked and must close before the surface lands — the target is render-on-change, with a continuous loop only while playback is running, not merely pausing when the tab is hidden.

Not covered

No tests. The scenes are a verbatim copy verified visually against the reference; a meaningful unit test would be asserting mindwalk's own behaviour back at itself. The citymap data feeding them is already pinned by a golden fixture on the server side.

Refs #9.

Model: Claude Opus 5 via Claude Code.

🤖 Generated with Claude Code

Note

Add interactive 3D city map viewer with Tree and Terrain scenes at /mindwalk-3d

  • Adds a /mindwalk-3d route that lazy-loads MindwalkHarness, which fetches a thread-specific citymap and lets users toggle between a radial TreeScene and a top-down CityScene (terrain mode).
  • CityScene renders files as instanced tiles with animated columns whose heights and colors reflect playback attention or lines-of-code; supports hover tooltips, click selection, camera auto-rotate, and district labels.
  • TreeScene renders a radial file tree with instanced leaves, halos, branch edges, a firefly sprite, and motion trails; supports the same interaction model as CityScene.
  • Shared utilities in sceneUtils.ts, dirLabels.ts, trail.ts, and textures.ts handle camera fitting, screen-space label LOD/collision, additive trails, and reusable canvas textures.
  • PlaybackEngine in playback/reducer.ts drives per-file touch state and recent-target history; playback/recorder.ts enables optional canvas-to-video recording.
  • Adds three at ^0.182.0 as a new runtime dependency.

Macroscope summarized b26f71b.

Dillpickleschmidt and others added 2 commits August 1, 2026 00:13
…ymap

Copies mindwalk's scene/, playback/, and types.ts near-verbatim into
apps/web/src/mindwalk (MIT, © 2026 Ricko Yu) — the only edits are non-null
assertions and casts that this repo's noUncheckedIndexedAccess and
exactOptionalPropertyTypes flags force, all erased at runtime. A throwaway
/mindwalk-3d route lazy-loads the scenes and three.js in their own chunk and
renders a thread's citymap from GET /api/citymap/threads/:threadId in
static-map mode (no trace yet), mirroring mindwalk's ?map=1 baseline.

Both views verified against a live mindwalk serve on this repo:
screenshot-for-screenshot structural match on Tree and Terrain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/web/src/mindwalk/MindwalkHarness.tsx

const camera = new THREE.PerspectiveCamera(
38,
host.clientWidth / host.clientHeight || 1,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium scene/CityScene.tsx:159

When the scene mounts in a zero-sized host, the camera aspect is forced to 1 at line 159, so fitView() treats the aspect as usable and clears fitPendingRef. On the first real resize, the projection is updated but the camera is never refit, so the map stays cropped or misframed for the non-square viewport. The || 1 fallback short-circuits the deferred-fit mechanism that fitDistance's aspect <= 0 check was designed to protect.

Suggested change
host.clientWidth / host.clientHeight || 1,
host.clientWidth / host.clientHeight || 0,
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/mindwalk/scene/CityScene.tsx around line 159:

When the scene mounts in a zero-sized host, the camera aspect is forced to `1` at line 159, so `fitView()` treats the aspect as usable and clears `fitPendingRef`. On the first real resize, the projection is updated but the camera is never refit, so the map stays cropped or misframed for the non-square viewport. The `|| 1` fallback short-circuits the deferred-fit mechanism that `fitDistance`'s `aspect <= 0` check was designed to protect.

Comment on lines +101 to +103
const pxPerWorld = viewH / (2 * dist * tanV);
const subtreePx = label.entry.radius * pxPerWorld;
this.point.project(camera);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium scene/dirLabels.ts:101

pxPerWorld uses the Euclidean distance from the camera to the label (this.point.distanceTo(camera.position)), but perspective projection scales by camera-space depth, not straight-line distance. For labels near the edges of the view — common with the tilted ground-plane camera — dist is larger than the true view-space depth, so subtreePx is underestimated and worldH is overestimated. Edge labels pop at the wrong zoom level and render larger than the intended 13/15 px constant screen size. Transform the point into camera space and use -z for pxPerWorld instead of the Euclidean distance.

      this.point.set(label.entry.x, this.y, label.entry.z);
-      const dist = this.point.distanceTo(camera.position);
+      const camZ = this.point.clone().applyMatrix4(camera.matrixWorldInverse).z;
+      const dist = -camZ;
      const pxPerWorld = viewH / (2 * dist * tanV);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/mindwalk/scene/dirLabels.ts around lines 101-103:

`pxPerWorld` uses the Euclidean distance from the camera to the label (`this.point.distanceTo(camera.position)`), but perspective projection scales by camera-space depth, not straight-line distance. For labels near the edges of the view — common with the tilted ground-plane camera — `dist` is larger than the true view-space depth, so `subtreePx` is underestimated and `worldH` is overestimated. Edge labels pop at the wrong zoom level and render larger than the intended 13/15 px constant screen size. Transform the point into camera space and use `-z` for `pxPerWorld` instead of the Euclidean distance.

Comment on lines +75 to +77
const stream = canvas.captureStream(fps);
const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
const chunks: BlobPart[] = [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium playback/recorder.ts:75

canvas.captureStream(fps) is called before the returned Promise is constructed, so if it throws (e.g., the canvas is not origin-clean), the exception propagates synchronously instead of rejecting the promise returned by recordPlayback. Callers using recordPlayback(...).catch(...) cannot handle this failure. Move the stream and recorder setup inside the Promise executor so these errors reject as expected.

+  return new Promise<RecordResult>((resolve, reject) => {
+    let rafId = 0;
+    let startTs = 0;
+    let settled = false;
+
+    const fps = opts.fps ?? DEFAULT_FPS;
+    const stream = canvas.captureStream(fps);
+    const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
+    const chunks: BlobPart[] = [];
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/mindwalk/playback/recorder.ts around lines 75-77:

`canvas.captureStream(fps)` is called before the returned `Promise` is constructed, so if it throws (e.g., the canvas is not origin-clean), the exception propagates synchronously instead of rejecting the promise returned by `recordPlayback`. Callers using `recordPlayback(...).catch(...)` cannot handle this failure. Move the stream and recorder setup inside the `Promise` executor so these errors reject as expected.

The effect returned before resetting state, so dropping the threadId left
the previous citymap rendered behind the "pass a threadId" prompt.

Macroscope also flagged three issues in the verbatim scene copy; those are
upstream mindwalk behaviour and are deliberately preserved. Recorded as
post-port work instead.

Refs #9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Dillpickleschmidt

Dillpickleschmidt commented Aug 1, 2026 •

Copy link
Copy Markdown
Owner Author

One taken, three declined — and the split is the whole point of this PR.

Taken — MindwalkHarness.tsx. That file is ours, not mindwalk's, and the finding is correct: the effect returned before resetting state, so losing the threadId left the previous citymap rendered behind the "pass a threadId" prompt. Reset now happens before the early return.

Declined — all three scene findings. Each exists verbatim in the pinned reference at .repos/mindwalk (v0.3.0), so they are upstream behaviour rather than translation errors:

Finding Upstream
Euclidean distance instead of camera-space depth web/src/scene/dirLabels.ts:100 — this.point.distanceTo(camera.position)
|| 1 aspect fallback short-circuiting the deferred fit web/src/scene/CityScene.tsx:150
captureStream outside the Promise executor web/src/playback/recorder.ts:72

They look like real defects to me too — the dirLabels one especially, since perspective scales by camera-space depth and not straight-line distance, so edge labels genuinely pop at the wrong zoom on a tilted camera.

But this PR's entire value is being a byte-comparable baseline. Fixing upstream bugs inside the verbatim copy would mean the port no longer diffs cleanly against .repos/mindwalk, and every future comparison would have to remember which differences were intentional. It would also make any visual difference ambiguous: mistranslation, or deliberate improvement? That ambiguity is precisely what verbatim-first exists to avoid.

All three are recorded as post-port work, alongside the other deviations held back for the same reason (exclusion globs, float rounding, aggregation). They are good candidates to fix once the port is settled and a change can be verified against the reference in isolation. Nothing from this fork goes upstream.

Also worth flagging for anyone reading this later: the scenes' unconditional rAF loop is a known gap, not an oversight — see the PR description. It is now owned explicitly by #10 rather than floating as fog, with the target stated as render-on-change rather than merely pausing when hidden.

@Dillpickleschmidt
Dillpickleschmidt merged commit c3e30aa into main Aug 1, 2026
2 checks passed
@Dillpickleschmidt
Dillpickleschmidt deleted the feat/3d-scenes-verbatim branch August 1, 2026 12:46
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