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