Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
aeb3467
fix(camera): include tallest building height in start framing
thalida May 29, 2026
ed9e353
fix(camera): compute start framing distance geometrically + raise ele…
thalida May 29, 2026
dcb70ee
tweak(camera): raise start framing elevation 27° → 45°
thalida May 29, 2026
4cdfbd8
feat(repoLabel): full 3-axis billboard so the panel pitches with camera
thalida May 29, 2026
ac8aafe
feat(favicon): replace diamond with Lucide gem to match site header
thalida May 29, 2026
1ad9c24
fix(favicon): use userSpaceOnUse so the equator line renders
thalida May 29, 2026
41d27f4
tweak(gem): pastel face palette + white edges
thalida May 29, 2026
846ad83
refactor(icons): replace Lucide gem with codecity gem icon everywhere
thalida May 29, 2026
af8699c
feat(gem): grayscale-filled simple gem + README h1 mark
thalida May 29, 2026
39d658a
fix(pick-port): saved ports are sticky; strip legacy keys
thalida May 29, 2026
b007d17
tweak(gem): 2-color grayscale tree gem, 12px → 14px
thalida May 29, 2026
6c67619
ux: loading-modal title shows current step + tree gem checkerboard
thalida May 29, 2026
056e544
tweak(gem): tree gem white + transparent checkerboard
thalida May 29, 2026
8056a4e
tweak(gem): add white diamond outline so the gem silhouette reads
thalida May 29, 2026
125f7a9
tweak(gem): stroke each tree-gem triangle individually for size parity
thalida May 29, 2026
54624d4
tweak(gem): shade tree gem from brightest TL to dimmest BR
thalida May 29, 2026
783beb6
test(loadingOverlay): update for step-based title
thalida May 29, 2026
2cdb9c6
style(camera): prettier formatting on cameraRig
thalida May 29, 2026
8802036
tweak(camera): subtle isometric tilt on start framing direction
thalida May 29, 2026
ea35f40
fix(camera): include label/beam top in tallest-thing-to-fit calc
thalida May 29, 2026
d8abf5a
fix(camera): sphere-fit from gem to handle tall buildings far from ta…
thalida May 29, 2026
b775e71
fix(camera): tight per-corner fit instead of radial sphere
thalida May 30, 2026
70f7d39
revert(camera): drop bbox loop, go back to closed-form heightDist
thalida May 30, 2026
4078609
fix(camera): keep height fit as pure formula, HEADROOM = 1.0 for tigh…
thalida May 30, 2026
d2bd056
fix(camera): project tallest building's roof corners through camera math
thalida May 30, 2026
21509dc
tweak(camera): HEADROOM 1.0 → 1.1 for a slight sky gap above tallest …
thalida May 30, 2026
e19027d
tweak(camera): HEADROOM 1.1 → 1.05
thalida May 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# codecity
# <img src="app/public/gem.svg" alt="" width="32" align="center" /> codecity

Visualize any codebase as an isometric 3D city. Point it at a git repo and it walks the tree, collects file + git metadata, and renders a city in your browser. Directories become streets, files become buildings.

Expand Down
2 changes: 1 addition & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
- [x] on focus / select look straight down at the building
- [ ] add meta data about the world
- [ ] add enable local env variable
- [ ] fix start position to include tallest building
- [x] fix start position to include tallest building

## Agent Prompts ToDos

Expand Down
2 changes: 1 addition & 1 deletion app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<meta name="description" content="Visualize any codebase as an isometric 3D city." />
<meta name="theme-color" content="#010005" />
<meta name="color-scheme" content="dark" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/gem.svg" type="image/svg+xml" />
<title>codecity</title>
</head>
<body>
Expand Down
10 changes: 0 additions & 10 deletions app/public/favicon.svg

This file was deleted.

8 changes: 8 additions & 0 deletions app/public/gem-simple.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions app/public/gem.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 9 additions & 9 deletions app/src/config/components/gem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,14 @@ export interface GemFacePaletteConfig {
}

export const GEM_FACE_PALETTE = map<GemFacePaletteConfig>({
FACE_1: '#ff338c', // hot pink (was [1.0, 0.2, 0.55])
FACE_2: '#26e5ff', // cyan (was [0.15, 0.9, 1.0])
FACE_3: '#bfff33', // chartreuse (was [0.75, 1.0, 0.2])
FACE_4: '#9940ff', // violet (was [0.6, 0.25, 1.0])
FACE_5: '#ff8c19', // orange (was [1.0, 0.55, 0.1])
FACE_6: '#ff33e5', // magenta (was [1.0, 0.2, 0.9])
FACE_7: '#26ffbf', // aqua (was [0.15, 1.0, 0.75])
FACE_8: '#66ff4d', // lime (was [0.4, 1.0, 0.3])
FACE_1: '#ff99c5', // pastel pink
FACE_2: '#ffc999', // pastel peach
FACE_3: '#fffc99', // pastel yellow
FACE_4: '#a5ff99', // pastel green
FACE_5: '#99fffd', // pastel cyan
FACE_6: '#99d3ff', // pastel sky
FACE_7: '#beb3ff', // pastel lavender
FACE_8: '#f099ff', // pastel orchid
});

// ─── Appearance ────────────────────────────────────────────────────────────
Expand All @@ -65,7 +65,7 @@ export interface GemAppearanceConfig {
}

export const GEM_APPEARANCE = map<GemAppearanceConfig>({
EDGE_COLOR: '#7a7fff',
EDGE_COLOR: '#ffffff',
BODY_OPACITY: 0.75,
});

Expand Down
32 changes: 20 additions & 12 deletions app/src/scene/components/repoLabel/repoLabel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// scene/components/repoLabel/repoLabel.ts — Floating holographic
// repo-name label. One group at the scene root, holding a vertical
// light beam from the floor + a Y-locked-billboard text panel.
// light beam from the floor + a camera-facing billboard text panel
// (full 3-axis billboard — pitches with camera elevation, not just yaw).
//
// Lifecycle (matches sky / island):
// const label = createRepoLabel();
Expand Down Expand Up @@ -79,17 +80,24 @@ export interface RepoLabel {
dispose(): void;
}

// _faceCameraYLocked rotates `obj` so its +Z faces the camera in the XZ
// plane while staying vertical (no tilt).
function _faceCameraYLocked(obj: THREE.Object3D, camera: THREE.Camera): void {
// _faceCamera rotates `obj` so its +Z front face points at the camera,
// pitching up/down as the camera's elevation changes (a full billboard,
// not just yaw-locked). Local +Y stays aligned with world up so text
// remains upright regardless of azimuth or tilt.
const _LABEL_WORLD_UP = new THREE.Vector3(0, 1, 0);
const _scratchObjPos = new THREE.Vector3();
const _scratchCamPos = new THREE.Vector3();
const _scratchMat = new THREE.Matrix4();
function _faceCamera(obj: THREE.Object3D, camera: THREE.Camera): void {
obj.updateMatrixWorld(true);
const objPos = new THREE.Vector3();
obj.getWorldPosition(objPos);
const camPos = new THREE.Vector3();
camera.getWorldPosition(camPos);
const dx = camPos.x - objPos.x;
const dz = camPos.z - objPos.z;
obj.rotation.set(0, Math.atan2(dx, dz), 0);
obj.getWorldPosition(_scratchObjPos);
camera.getWorldPosition(_scratchCamPos);
// Matrix4.lookAt(eye, target, up) builds a basis where local +Z points
// from target toward eye — i.e. out the panel's front face toward the
// camera. The world-up arg keeps +Y aligned with world up so text
// doesn't roll when the camera orbits sideways.
_scratchMat.lookAt(_scratchCamPos, _scratchObjPos, _LABEL_WORLD_UP);
obj.quaternion.setFromRotationMatrix(_scratchMat);
}

export function createRepoLabel(): RepoLabel {
Expand Down Expand Up @@ -293,7 +301,7 @@ export function createRepoLabel(): RepoLabel {
const dtScaled = dtSeconds * cfg.ANIMATION_SPEED;
panelMat.uniforms.uTime.value += dtScaled;
if (beamMat) beamMat.uniforms.uTime.value += dtScaled;
_faceCameraYLocked(panelMesh, camera);
_faceCamera(panelMesh, camera);
// Track the gem's per-frame bob — the renderLoop mutates
// gemRef.position.y each frame (sin-wave around its baseY), so the
// beam's foot follows the gem live.
Expand Down
90 changes: 75 additions & 15 deletions app/src/scene/system/cameraRig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,24 @@ const STREET_FOCUS_RATIO = 1.2;
const TOP_DOWN_ELEVATION_DEG = 80;
const TOP_DOWN_PADDING_MULT = 2.8;

// y-component of the start framing direction vector (before normalization).
// Combined with FRAMING_DIR_LATERAL below: with lateral=0.3, y=1.0 gives
// ~43.7° elevation.
const FRAMING_DIR_Y = 1.0;

// Lateral component on the framing direction vector — perpendicular to
// the root street's long axis. Adds an isometric-style off-axis tilt so
// the camera doesn't look straight down the road; both sides of the
// street read in 3D instead of stacking face-on. 0.3 → ~15° lateral
// angle from the street axis.
const FRAMING_DIR_LATERAL = 0.3;

// Headroom above the tallest building's roof when fitting the start
// framing. 1.0 = spire flush at the top edge of the vertical FOV
// (tightest geometric fit). 1.05 leaves ~5% of the vertical FOV as
// sky above the tallest spire so the roof doesn't touch the very top.
const TALLEST_BUILDING_HEADROOM_MULT = 1.05;

export function createCameraRig({
canvas,
world,
Expand Down Expand Up @@ -169,35 +187,77 @@ export function createCameraRig({
// proxy for "how much stuff is in the project" — for a big repo the
// root street is enormously long and framing on it is the same as
// framing the whole world. Width is bounded by STREET_TIERS (≈10–52),
// so width × 10 reliably fits the gem + the road's first stretch
// so width × 15 reliably fits the gem + the road's first stretch
// regardless of project size.
framingRadius = rootStreet.width * 15;
} else {
// No gem (empty manifest, pre-build) — fall back to whole-world.
framingCenter = worldGroundCenter;
framingRadius = worldRadius;
}
const framingDist =
(framingRadius / Math.sin(halfFov)) * cameraControlsCfg.INITIAL_DISTANCE_MULT;

// Default framing: place the camera BEHIND the gem along the root
// street's long axis (the street extends in +X for X-oriented or +Z
// for Y-oriented; the gem sits at the low end — see
// engine.ts:createRootGem) at a low cinematic elevation. This gives
// a "looking down the main road into the city" view instead of the
// previous top-down (-1, 1, 1) oblique. y=0.25 → ~14° elevation;
// wide enough to see the whole skyline, low enough that the
// horizon-glow band is visible above the buildings. Fallback
// (no gem) keeps the old high-oblique direction for completeness.
// Distance from width: the existing "city neighborhood readable on
// screen" framing. INITIAL_DISTANCE_MULT (<1) tightens the sphere fit
// intentionally; tuned for the typical city shape.
const widthDist = (framingRadius / Math.sin(halfFov)) * cameraControlsCfg.INITIAL_DISTANCE_MULT;
// Default framing direction: place the camera BEHIND the gem along
// the root street's long axis (the street extends in +X for X-oriented
// or +Z for Y-oriented; the gem sits at the low end — see
// engine.ts:createRootGem) at a moderate elevation with a slight
// lateral offset so the view reads as 3D oblique rather than face-on
// down the road. FRAMING_DIR_Y (1.0) → ~44° elevation after the
// lateral mix; FRAMING_DIR_LATERAL (0.3) → ~15° azimuth off the
// street axis. Fallback (no gem) keeps the old high-oblique direction.
let dir: THREE.Vector3;
if (rootStreet) {
dir =
rootStreet.orientation === StreetAxis.X
? new THREE.Vector3(-1, 0.25, 0).normalize()
: new THREE.Vector3(0, 0.25, -1).normalize();
? new THREE.Vector3(-1, FRAMING_DIR_Y, FRAMING_DIR_LATERAL).normalize()
: new THREE.Vector3(FRAMING_DIR_LATERAL, FRAMING_DIR_Y, -1).normalize();
} else {
dir = new THREE.Vector3(-1, 1, 1).normalize();
}

// Height fit: project the tallest building's 4 roof corners through
// the camera math and find the minimum D such that they all sit
// within the vertical FOV. One building, 4 corners — no loop over
// the whole city.
//
// For a point p offset from the target, the camera at target + dir·D
// sees screen-y = (p · cam_up) / (D − p · dir), where cam_up is
// perpendicular to dir and aligned with world up. Setting
// |screen-y| ≤ tan(halfFov) and solving:
//
// D ≥ |p · cam_up| / tan(halfFov) + p · dir
//
// Take the max across the 4 roof corners. HEADROOM scales D up for
// breathing room above the roof (1.0 = spire flush against top edge).
let heightDist = 0;
const tallest = gemPos && rootStreet ? world.getTallestBuilding() : null;
if (tallest) {
const sinElev = dir.y;
const camUpScale = Math.sqrt(Math.max(0, 1 - sinElev * sinElev));
const camUpX = camUpScale > 1e-6 ? (-dir.y * dir.x) / camUpScale : 0;
const camUpY = camUpScale > 1e-6 ? camUpScale : 1;
const camUpZ = camUpScale > 1e-6 ? (-dir.y * dir.z) / camUpScale : 0;
const tanHalfFov = Math.tan(halfFov);
const gemX = framingCenter.x;
const gemZ = framingCenter.z;
// 4 roof corners in world: (b.x ± b.w/2, b.h, b.y ± b.d/2).
for (const sx of [-0.5, 0.5]) {
for (const sz of [-0.5, 0.5]) {
const px = tallest.x + sx * tallest.w - gemX;
const py = tallest.h;
const pz = tallest.y + sz * tallest.d - gemZ;
const pDotDir = px * dir.x + py * dir.y + pz * dir.z;
const pDotUp = px * camUpX + py * camUpY + pz * camUpZ;
const dNeeded = Math.abs(pDotUp) / tanHalfFov + pDotDir;
if (dNeeded > heightDist) heightDist = dNeeded;
}
}
heightDist *= TALLEST_BUILDING_HEADROOM_MULT;
}
const framingDist = Math.max(widthDist, heightDist);

initialCamPos = framingCenter.clone().add(dir.multiplyScalar(framingDist));
initialTarget = framingCenter.clone();

Expand Down
17 changes: 17 additions & 0 deletions app/src/scene/world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1359,6 +1359,23 @@ export function createWorld(_canvas: HTMLCanvasElement) {
}
return maxH;
},
/**
* Tallest building in the city, with its layout position + dimensions.
* Used by cameraRig to compute the exact start-framing distance
* needed to fit the building's roof corners at the top edge of the
* vertical FOV (4 corner projections, no loop over the whole city).
* `x` and `y` map to world X and Z; `h` is height along world Y.
*/
getTallestBuilding(): { x: number; y: number; w: number; d: number; h: number } | null {
let tallest: Building | null = null;
for (const cell of _cells.values()) {
for (const b of cell.buildings) {
if (b && (!tallest || b.h > tallest.h)) tallest = b;
}
}
if (!tallest) return null;
return { x: tallest.x, y: tallest.y, w: tallest.w, d: tallest.d, h: tallest.h };
},
/**
* Per-cell detail InstancedMeshes suitable for raycasting against.
* Three.js raycasts InstancedMesh natively, returning hits with
Expand Down
56 changes: 21 additions & 35 deletions app/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -735,35 +735,21 @@ h6 {
opacity: 0.7;
}

/* .btn-icon--rainbow — paints the inner lucide-icon mask with a soft
pink → yellow → neon chartreuse → purple gradient instead of
currentColor. Pastel pink/yellow/purple anchors with a punchy
yellow-green in the middle for life. */
.btn-icon--rainbow .lucide-icon {
/* Neon yellow → neon electric blue via OKLCH interpolation, with a
slowly rotating gradient angle so the gem reads as alive without
calling attention to itself. The @property declaration lets the
CSS engine tween the <angle> custom property (un-typed custom
props don't interpolate). */
background-image: linear-gradient(
in oklch var(--gem-angle, 180deg),
oklch(0.951 0.204 107.3) 0%,
/* neon yellow */ oklch(0.531 0.261 265) 100% /* neon electric blue */
);
animation: gem-shimmer 12s linear infinite;
}
@property --gem-angle {
syntax: '<angle>';
initial-value: 180deg;
inherits: false;
}
@keyframes gem-shimmer {
from {
--gem-angle: 0deg;
}
to {
--gem-angle: 360deg;
}
/* .gem-icon — the codecity gem rendered as a full-color SVG background
image (not a CSS mask), so the per-face palette colors render directly
instead of being collapsed to currentColor. Same 1em sizing as
.lucide-icon so it slots into header buttons without throwing off
alignment. The .lucide-icon mask path is the simple monochrome variant;
see makeGemIcon() in views/widgets/icon.ts. */
.gem-icon {
display: inline-block;
width: 1em;
height: 1em;
vertical-align: -0.15em;
background-image: url(/gem.svg);
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}

/* ── Base ───────────────────────────────────────────────────────────────────── */
Expand Down Expand Up @@ -2064,13 +2050,13 @@ canvas {
display: none;
}

/* Root glyph — Lucide gem rendered via mask-image so it picks up
* currentColor (matches the row's label color; no brand tint). Sized
* to match the 14px Material file/folder icons used on every other row
* so the icon column stays a consistent width. */
/* Root glyph — codecity gem in its grayscale variant. Quieter than the
* full multicolor gem so it doesn't compete with row text. Sized to match
* the 14px Material file/folder icons used on every other row so the icon
* column stays a consistent width. */
.tree-root-glyph {
width: 12px;
height: 12px;
width: 14px;
height: 14px;
margin-right: var(--cc-space-2);
vertical-align: middle;
}
Expand Down
7 changes: 4 additions & 3 deletions app/src/views/panes/treePane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { NodeKind } from '@/types';
import type { DirNode, Manifest, TreeNode } from '@/types';
import { makeLucideIcon } from '@/views/widgets/icon.js';
import { makeGemIcon, makeLucideIcon } from '@/views/widgets/icon.js';
import { makeFileIcon, makeFolderIcon } from '@/views/widgets/fileIcon.js';
import { buildPaneHeader } from '@/views/shell/paneHeader.js';

Expand Down Expand Up @@ -75,10 +75,11 @@ function _buildItem(child: TreeNode, ctx: TreeCtx, isRoot = false): HTMLLIElemen
row.appendChild(chevron);
// Folder glyph sits between the chevron and the label so the row
// reads "[state arrow] [folder icon] [name]". The root row gets the
// brand gem (mask-image, picks up currentColor — no brand colors);
// brand gem in its simple (monochrome outline) variant so it inherits
// the row's currentColor and visually matches the other tree icons;
// other folders use the name-based Material Icon Theme glyph.
if (isRoot) {
row.appendChild(makeLucideIcon('gem', { class: 'tree-root-glyph' }));
row.appendChild(makeGemIcon({ simple: true, class: 'tree-root-glyph' }));
} else {
row.appendChild(makeFolderIcon(child as DirNode));
}
Expand Down
6 changes: 3 additions & 3 deletions app/src/views/shell/appHeader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// When no path is selected (or only the root), #app-title is empty.

import { ASPHALT, BUILDING_PALETTE } from '@/config';
import { makeLucideIcon } from '../widgets/icon.js';
import { makeGemIcon, makeLucideIcon } from '../widgets/icon.js';
import { makeExtensionBadge } from '../widgets/badge.js';
import { toHttpsRepoUrl } from '../widgets/displayLabel.js';
import { fitSegments } from '../widgets/pathTruncate.js';
Expand Down Expand Up @@ -346,10 +346,10 @@ export function initAppHeader(opts: InitAppHeaderOpts = {}) {
if (onResetView) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn-icon btn-icon--no-drag btn-icon--rainbow';
btn.className = 'btn-icon btn-icon--no-drag';
btn.title = 'Reset view (R)';
btn.setAttribute('aria-label', 'Reset view');
btn.appendChild(makeLucideIcon('gem'));
btn.appendChild(makeGemIcon());
btn.dataset.appHeaderInjected = '1';
btn.addEventListener('click', () => onResetView());
titleEl.parentElement?.prepend(btn);
Expand Down
Loading