Repository navigation
feat(web): render mindwalk's Tree and Terrain scenes against a T3 citymap - #16
Conversation
…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>
|
|
||
| const camera = new THREE.PerspectiveCamera( | ||
| 38, | ||
| host.clientWidth / host.clientHeight || 1, |
There was a problem hiding this comment.
🟡 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.
| 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.
| const pxPerWorld = viewH / (2 * dist * tanV); | ||
| const subtreePx = label.entry.radius * pxPerWorld; | ||
| this.point.project(camera); |
There was a problem hiding this comment.
🟡 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.
| const stream = canvas.captureStream(fps); | ||
| const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); | ||
| const chunks: BlobPart[] = []; |
There was a problem hiding this comment.
🟡 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>
|
One taken, three declined — and the split is the whole point of this PR. Taken — Declined — all three scene findings. Each exists verbatim in the pinned reference at
They look like real defects to me too — the 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 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. |
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/, andtypes.tsare copied near-verbatim from.repos/mindwalk(MIT, © 2026 Ricko Yu, pinned atv0.3.0) underapps/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. Addsthree@0.182and a throwaway/mindwalk-3d?threadId=<id>route that lazy-loads the scenes into their own chunk and feeds them fromGET /api/citymap/threads/:threadIdin 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
requestAnimationFrameloops. 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:328renders every frame for as long as the component is mounted, with aMath.sinfirefly pulse, and only cancels on unmount.AGENTS.mdsays "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-3dis 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/mindwalk-3droute that lazy-loadsMindwalkHarness, which fetches a thread-specific citymap and lets users toggle between a radialTreeSceneand a top-downCityScene(terrain mode).CityScenerenders 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.TreeScenerenders a radial file tree with instanced leaves, halos, branch edges, a firefly sprite, and motion trails; supports the same interaction model asCityScene.sceneUtils.ts,dirLabels.ts,trail.ts, andtextures.tshandle camera fitting, screen-space label LOD/collision, additive trails, and reusable canvas textures.PlaybackEngineinplayback/reducer.tsdrives per-file touch state and recent-target history;playback/recorder.tsenables optional canvas-to-video recording.threeat^0.182.0as a new runtime dependency.Macroscope summarized b26f71b.