diff --git a/README.md b/README.md
index f35488b6..ca9370f7 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# codecity
+# 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.
diff --git a/TODO.md b/TODO.md
index 5f65cd74..8b2799a9 100644
--- a/TODO.md
+++ b/TODO.md
@@ -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
diff --git a/app/index.html b/app/index.html
index d3463096..00c79bdc 100644
--- a/app/index.html
+++ b/app/index.html
@@ -6,7 +6,7 @@
-
+
codecity
diff --git a/app/public/favicon.svg b/app/public/favicon.svg
deleted file mode 100644
index 68c71da0..00000000
--- a/app/public/favicon.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
diff --git a/app/public/gem-simple.svg b/app/public/gem-simple.svg
new file mode 100644
index 00000000..7854a8ea
--- /dev/null
+++ b/app/public/gem-simple.svg
@@ -0,0 +1,8 @@
+
diff --git a/app/public/gem.svg b/app/public/gem.svg
new file mode 100644
index 00000000..3e6934b2
--- /dev/null
+++ b/app/public/gem.svg
@@ -0,0 +1,6 @@
+
diff --git a/app/src/config/components/gem.ts b/app/src/config/components/gem.ts
index a1a4978f..a795650c 100644
--- a/app/src/config/components/gem.ts
+++ b/app/src/config/components/gem.ts
@@ -45,14 +45,14 @@ export interface GemFacePaletteConfig {
}
export const GEM_FACE_PALETTE = map({
- 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 ────────────────────────────────────────────────────────────
@@ -65,7 +65,7 @@ export interface GemAppearanceConfig {
}
export const GEM_APPEARANCE = map({
- EDGE_COLOR: '#7a7fff',
+ EDGE_COLOR: '#ffffff',
BODY_OPACITY: 0.75,
});
diff --git a/app/src/scene/components/repoLabel/repoLabel.ts b/app/src/scene/components/repoLabel/repoLabel.ts
index d5a9bd69..0b29e58d 100644
--- a/app/src/scene/components/repoLabel/repoLabel.ts
+++ b/app/src/scene/components/repoLabel/repoLabel.ts
@@ -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();
@@ -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 {
@@ -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.
diff --git a/app/src/scene/system/cameraRig.ts b/app/src/scene/system/cameraRig.ts
index 85ee1e58..0bc5f3c0 100644
--- a/app/src/scene/system/cameraRig.ts
+++ b/app/src/scene/system/cameraRig.ts
@@ -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,
@@ -169,7 +187,7 @@ 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 {
@@ -177,27 +195,69 @@ export function createCameraRig({
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();
diff --git a/app/src/scene/world.ts b/app/src/scene/world.ts
index c627830f..4f68b6fc 100644
--- a/app/src/scene/world.ts
+++ b/app/src/scene/world.ts
@@ -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
diff --git a/app/src/styles.css b/app/src/styles.css
index 69d5568c..7723ea96 100644
--- a/app/src/styles.css
+++ b/app/src/styles.css
@@ -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 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: '';
- 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 ───────────────────────────────────────────────────────────────────── */
@@ -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;
}
diff --git a/app/src/views/panes/treePane.ts b/app/src/views/panes/treePane.ts
index c68f2429..c17ba160 100644
--- a/app/src/views/panes/treePane.ts
+++ b/app/src/views/panes/treePane.ts
@@ -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';
@@ -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));
}
diff --git a/app/src/views/shell/appHeader.ts b/app/src/views/shell/appHeader.ts
index 3eef0f83..7750eed9 100644
--- a/app/src/views/shell/appHeader.ts
+++ b/app/src/views/shell/appHeader.ts
@@ -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';
@@ -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);
diff --git a/app/src/views/source/loadingOverlay.ts b/app/src/views/source/loadingOverlay.ts
index ae5948b3..4a953963 100644
--- a/app/src/views/source/loadingOverlay.ts
+++ b/app/src/views/source/loadingOverlay.ts
@@ -142,32 +142,41 @@ export function createLoadingOverlay(): LoadingOverlay {
show({ kind, label, branch }: LoadingOverlayShowOpts) {
_buildDOM();
- // Set title.
- if (_titleEl) {
- _titleEl.textContent = branch
- ? `Loading ${label} (branch ${branch})…`
- : `Loading ${label}…`;
- }
-
// Hide git-only steps for local sources.
+ const initialStep: LoadingStep = kind === 'local' ? 'scanning' : 'resolving';
if (kind === 'local') {
const resolvingEl = _stepEls['resolving'];
const cloningEl = _stepEls['cloning'];
if (resolvingEl) resolvingEl.style.display = 'none';
if (cloningEl) cloningEl.style.display = 'none';
- _applyStep('scanning');
- } else {
- // Git sources: start at 'resolving'. The server emits 'cloning'
- // immediately after receiving the request, so the next setStep
- // call from main.ts moves us forward within milliseconds.
- _applyStep('resolving');
}
+ _applyStep(initialStep);
+
+ // Title shows the current step in sentence form ("Resolving source…",
+ // "Cloning…"), updated by setStep below. Previously it repeated the
+ // project name ("Loading